Compare commits
37 Commits
c1fb19af53
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f39f60553f | |||
| 64f1cb35cc | |||
| 50ed341474 | |||
| fd978f323d | |||
| bad0803a6a | |||
| 4ea5280f61 | |||
| c6544716e7 | |||
| 17ccc0cb0f | |||
| d30eb59225 | |||
| 3fcab465f8 | |||
| 93af738e36 | |||
| d5eb26d330 | |||
| a68023e684 | |||
| c0c3c73c7a | |||
| 5cb4d8e9f6 | |||
| 81d70a7862 | |||
| dfc6b023f7 | |||
| 4d9d0db02d | |||
| a12eac6b5b | |||
| 5e1502a18d | |||
| e5f9ad7b28 | |||
| 3793b1d7a3 | |||
| d67fd327c9 | |||
| c8e0c9168b | |||
| 650ade2726 | |||
| e77d9d62c7 | |||
| 03b283f892 | |||
| 95b3450317 | |||
| 97bebfd8fb | |||
| a691783f58 | |||
| ec1ff20ddd | |||
| 65eca822c0 | |||
| a957d0defc | |||
| 4ff1718307 | |||
| 63e33510b3 | |||
| ef141358e7 | |||
| 0619c6be9c |
@@ -2,3 +2,5 @@
|
|||||||
sdkconfig.esp32-c6-devkitm-1
|
sdkconfig.esp32-c6-devkitm-1
|
||||||
.pio
|
.pio
|
||||||
.vscode
|
.vscode
|
||||||
|
include/wifi_config.h
|
||||||
|
data/*.gz
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
cmake_minimum_required(VERSION 3.16.0)
|
cmake_minimum_required(VERSION 3.16.0)
|
||||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
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)
|
project(battleship)
|
||||||
|
|||||||
+773
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status, #network-status { color: #c1ddea; font-size: .9rem; } .secondary { margin-top: 1rem; background: #315a75; color: #effaff; } a { color: #9ce8ff; }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#08233d">
|
||||||
|
<title>Настройка сети — Морской бой</title>
|
||||||
|
<link rel="stylesheet" href="/setup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<p class="eyebrow">BATTLESHIP-OPEN · ЛОКАЛЬНАЯ СЕТЬ</p>
|
||||||
|
<h1>Настройка Wi‑Fi</h1>
|
||||||
|
<p>Игра доступна в этой открытой сети. Выберите домашнюю сеть или укажите скрытую вручную.</p>
|
||||||
|
<section aria-labelledby="networks-title">
|
||||||
|
<div class="section-heading"><h2 id="networks-title">Доступные сети</h2><button id="refresh" type="button">Обновить</button></div>
|
||||||
|
<p id="scan-status" role="status">Нажмите «Обновить», чтобы найти сети.</p>
|
||||||
|
<div id="networks" class="networks" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
<form id="network-form">
|
||||||
|
<h2>Скрытая или другая сеть</h2>
|
||||||
|
<label for="ssid">Название сети (SSID)</label>
|
||||||
|
<input id="ssid" name="ssid" maxlength="32" autocomplete="off" required>
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password">
|
||||||
|
<p class="hint">Пароль не отображается после отправки. Пока идёт проверка, оставайтесь в Battleship-open.</p>
|
||||||
|
<button type="submit">Проверить и сохранить</button>
|
||||||
|
</form>
|
||||||
|
<button id="delete-network" type="button" class="secondary">Удалить сохранённую сеть</button>
|
||||||
|
<p id="network-status" role="status"></p>
|
||||||
|
<p><a href="/">Открыть игру</a></p>
|
||||||
|
</main>
|
||||||
|
<script src="/setup.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
(() => {
|
||||||
|
const networks = document.querySelector('#networks');
|
||||||
|
const status = document.querySelector('#scan-status');
|
||||||
|
const ssid = document.querySelector('#ssid');
|
||||||
|
const refresh = document.querySelector('#refresh');
|
||||||
|
const form = document.querySelector('#network-form');
|
||||||
|
const password = document.querySelector('#password');
|
||||||
|
const networkStatus = document.querySelector('#network-status');
|
||||||
|
const remove = document.querySelector('#delete-network');
|
||||||
|
let timer = 0;
|
||||||
|
const scan = async () => {
|
||||||
|
clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/network/scan', { cache: 'no-store' });
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error('scan unavailable');
|
||||||
|
if (result.state === 'scanning') { timer = setTimeout(scan, 700); return; }
|
||||||
|
const names = [...new Set(result.networks || [])];
|
||||||
|
networks.replaceChildren(...names.map(name => { const button = document.createElement('button'); button.className = 'network'; button.type = 'button'; button.textContent = name; button.addEventListener('click', () => { ssid.value = name; ssid.focus(); }); return button; }));
|
||||||
|
status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.';
|
||||||
|
} catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; }
|
||||||
|
finally { if (!timer) refresh.disabled = false; }
|
||||||
|
};
|
||||||
|
const updateStatus = async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetch('/api/network/status', { cache: 'no-store' }).then(response => response.json());
|
||||||
|
networkStatus.textContent = result.message || '';
|
||||||
|
if (result.state === 'validating') setTimeout(updateStatus, 1000);
|
||||||
|
} catch (_) { networkStatus.textContent = 'Не удалось получить состояние сети.'; }
|
||||||
|
};
|
||||||
|
refresh.addEventListener('click', scan);
|
||||||
|
form.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/network/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid: ssid.value, password: password.value }) });
|
||||||
|
const result = await response.json(); password.value = ''; networkStatus.textContent = result.message || 'Проверяем подключение…';
|
||||||
|
if (response.ok) updateStatus();
|
||||||
|
} catch (_) { password.value = ''; networkStatus.textContent = 'Не удалось начать проверку. Повторите попытку.'; }
|
||||||
|
});
|
||||||
|
remove.addEventListener('click', async () => {
|
||||||
|
try { const result = await fetch('/api/network/delete', { method: 'POST' }).then(response => response.json()); networkStatus.textContent = result.message || 'Сеть удалена.'; }
|
||||||
|
catch (_) { networkStatus.textContent = 'Не удалось удалить сеть.'; }
|
||||||
|
});
|
||||||
|
updateStatus();
|
||||||
|
})();
|
||||||
@@ -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 |
@@ -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; } }
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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
|
||||||
@@ -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 0–9 |
|
||||||
|
| `name` | 1–20 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.
|
||||||
@@ -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 и громкость; не провоцируют ли звуки случайные нажатия. При замечании
|
||||||
|
укажите тип звука, а не сведения о человеке.
|
||||||
@@ -34,7 +34,7 @@ carrier PCB model or its LED and pin routing.
|
|||||||
## Current repository baseline (not hardware confirmation)
|
## Current repository baseline (not hardware confirmation)
|
||||||
|
|
||||||
| Item | Observed value | Status |
|
| Item | Observed value | Status |
|
||||||
|---|---|---|
|
| ---------------------------- | ------------------------------- | --------------------------------------------------------------------- |
|
||||||
| PlatformIO Core | 6.1.19 | Installed locally |
|
| PlatformIO Core | 6.1.19 | Installed locally |
|
||||||
| PlatformIO platform | `platformio/espressif32` 7.0.1 | Installed locally; `platformio.ini` is not version-pinned |
|
| 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 |
|
| Framework | ESP-IDF 6.0.1 | Current project setting conflicts with the Arduino-first MVP baseline |
|
||||||
|
|||||||
@@ -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 500–900 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 1–20 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.
|
||||||
@@ -274,10 +274,13 @@ struct Board {
|
|||||||
| `GET` | `/api/info` | Состояние устройства и доступность мест без скрытых данных |
|
| `GET` | `/api/info` | Состояние устройства и доступность мест без скрытых данных |
|
||||||
| `POST` | `/api/session/join` | Вход по имени и желаемой роли |
|
| `POST` | `/api/session/join` | Вход по имени и желаемой роли |
|
||||||
| `POST` | `/api/session/resume` | Восстановление роли по токену |
|
| `POST` | `/api/session/resume` | Восстановление роли по токену |
|
||||||
|
| `POST` | `/api/session/leave` | Освобождение только текущей сессии |
|
||||||
|
| `POST` | `/api/session/profile-reset` | Освобождение сессии перед локальной очисткой профиля |
|
||||||
| `POST` | `/api/game/config` | Выбор режима игроком 1 |
|
| `POST` | `/api/game/config` | Выбор режима игроком 1 |
|
||||||
| `POST` | `/api/game/start` | Запуск готовой партии |
|
| `POST` | `/api/game/start` | Запуск готовой партии |
|
||||||
| `POST` | `/api/game/shot` | Выстрел по координатам |
|
| `POST` | `/api/game/shot` | Выстрел по координатам |
|
||||||
| `POST` | `/api/game/rematch` | Подтверждение повторной игры |
|
| `POST` | `/api/game/rematch` | Подтверждение повторной игры |
|
||||||
|
| `POST` | `/api/game/reset` | Аварийный полный сброс игровой памяти игроком |
|
||||||
| `GET` | `/api/state?version=N` | Снимок разрешённого состояния и резервный опрос |
|
| `GET` | `/api/state?version=N` | Снимок разрешённого состояния и резервный опрос |
|
||||||
| `GET` | `/api/health` | Проверка доступности сервера |
|
| `GET` | `/api/health` | Проверка доступности сервера |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Если игра застряла
|
||||||
|
|
||||||
|
Кнопка с спасательным кругом «Помощь» доступна после подключения к игре. Она
|
||||||
|
не стреляет и не меняет поле сама по себе: сначала выберите действие, затем
|
||||||
|
подтвердите его.
|
||||||
|
|
||||||
|
## Выйти из игры
|
||||||
|
|
||||||
|
Выберите «Выйти из игры», чтобы освободить своё место. Имя на этом устройстве
|
||||||
|
останется, поэтому можно быстро подключиться снова. Если игрок выходит во
|
||||||
|
время партии, партия безопасно прекращается и оставшийся игрок возвращается в
|
||||||
|
лобби.
|
||||||
|
|
||||||
|
## Сбросить мой профиль
|
||||||
|
|
||||||
|
Этот пункт удаляет только данные текущего браузера: имя, выбранного героя и
|
||||||
|
токен. Данные других игроков не меняются. Без связи профиль всё равно можно
|
||||||
|
очистить локально; место на ESP32 освободится после восстановления связи или
|
||||||
|
обычного таймаута.
|
||||||
|
|
||||||
|
## Сбросить всю игру
|
||||||
|
|
||||||
|
Этот пункт виден только игроку. Он возвращает всех пользователей к первому
|
||||||
|
экрану и удаляет текущую партию и статистику текущего запуска. Для защиты от
|
||||||
|
случайного нажатия подтверждение нужно удерживать около двух секунд.
|
||||||
|
|
||||||
|
Старые вкладки и токены после этого не могут изменить новую партию.
|
||||||
|
|
||||||
|
## Что не удаляется
|
||||||
|
|
||||||
|
Эти действия не стирают Wi‑Fi, пароль сети, прошивку, файлы игры, flash или
|
||||||
|
настройки платы. Перезагрузка ESP32 не нужна.
|
||||||
|
|
||||||
|
## Проверка на устройстве
|
||||||
|
|
||||||
|
Проверьте с двумя игроками и зрителем выход, локальный профиль, полный сброс
|
||||||
|
из лобби/игры/результата, WebSocket и HTTP fallback. После 50 смешанных
|
||||||
|
циклов сравните `/api/health`: свободная куча, largest free block, число
|
||||||
|
клиентов и причина перезагрузки не должны показывать утечку, зависание или
|
||||||
|
перезагрузку.
|
||||||
@@ -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. |
|
||||||
|
| 4–6 | 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. |
|
||||||
|
| 10–11 | 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 1–13: PASS / FAIL with evidence:
|
||||||
|
Release decision: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Post-MVP work begins only after this record is complete and Milestone 017 is
|
||||||
|
marked `DONE`.
|
||||||
@@ -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.
|
||||||
@@ -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: 1–20 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' }
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#ifndef CAPTIVE_PORTAL_H
|
||||||
|
#define CAPTIVE_PORTAL_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
bool captive_portal_is_probe_path(const char *path);
|
||||||
|
bool captive_portal_dns_response(const uint8_t *query, size_t query_length, const uint8_t address[4], uint8_t *response, size_t response_capacity, size_t *response_length);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,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");
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#ifndef NETWORK_CONFIGURATION_H
|
||||||
|
#define NETWORK_CONFIGURATION_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "network_state.h"
|
||||||
|
|
||||||
|
enum { kNetworkValidationWindowMs = 30000U, kNetworkSuccessNoticeMs = 15000U };
|
||||||
|
typedef enum { NETWORK_CONFIGURATION_IDLE, NETWORK_CONFIGURATION_VALIDATING, NETWORK_CONFIGURATION_SUCCESS, NETWORK_CONFIGURATION_FAILED } network_configuration_state_t;
|
||||||
|
typedef struct {
|
||||||
|
network_configuration_state_t state;
|
||||||
|
network_profile_t previous_profile;
|
||||||
|
bool had_previous_profile;
|
||||||
|
uint32_t validation_deadline_ms;
|
||||||
|
uint32_t success_deadline_ms;
|
||||||
|
} network_configuration_t;
|
||||||
|
|
||||||
|
void network_configuration_init(network_configuration_t *configuration);
|
||||||
|
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms);
|
||||||
|
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms);
|
||||||
|
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms);
|
||||||
|
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms);
|
||||||
|
bool network_configuration_busy(const network_configuration_t *configuration);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#ifndef NETWORK_CREDENTIAL_STORE_H
|
||||||
|
#define NETWORK_CREDENTIAL_STORE_H
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include "network_state.h"
|
||||||
|
typedef enum { NETWORK_CREDENTIAL_LOAD_VALID, NETWORK_CREDENTIAL_LOAD_ABSENT, NETWORK_CREDENTIAL_LOAD_INVALID } network_credential_load_result_t;
|
||||||
|
network_credential_load_result_t network_credential_store_load(network_profile_t *profile);
|
||||||
|
bool network_credential_store_replace(const network_profile_t *profile);
|
||||||
|
bool network_credential_store_delete(void);
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#ifndef NETWORK_CREDENTIALS_H
|
||||||
|
#define NETWORK_CREDENTIALS_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include "network_state.h"
|
||||||
|
|
||||||
|
typedef struct { uint32_t magic; uint16_t version; uint16_t reserved; network_profile_t profile; uint32_t checksum; } network_credential_record_t;
|
||||||
|
typedef bool (*network_credential_read_fn)(void *context, network_credential_record_t *record);
|
||||||
|
typedef bool (*network_credential_write_fn)(void *context, const network_credential_record_t *record);
|
||||||
|
typedef bool (*network_credential_delete_fn)(void *context);
|
||||||
|
typedef struct {
|
||||||
|
void *context;
|
||||||
|
network_credential_read_fn read;
|
||||||
|
network_credential_write_fn write;
|
||||||
|
network_credential_delete_fn remove;
|
||||||
|
} network_credential_backend_t;
|
||||||
|
|
||||||
|
bool network_credential_encode(const network_profile_t *profile, network_credential_record_t *record);
|
||||||
|
bool network_credential_decode(const network_credential_record_t *record, network_profile_t *profile);
|
||||||
|
bool network_credential_load(const network_credential_backend_t *backend, network_profile_t *profile);
|
||||||
|
bool network_credential_replace(const network_credential_backend_t *backend, const network_profile_t *profile);
|
||||||
|
bool network_credential_delete(const network_credential_backend_t *backend);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#ifndef NETWORK_STATE_H
|
||||||
|
#define NETWORK_STATE_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
enum { kNetworkSsidBytes = 32, kNetworkPasswordBytes = 63, kNetworkConnectWindowMs = 30000 };
|
||||||
|
typedef struct { char ssid[kNetworkSsidBytes + 1]; char password[kNetworkPasswordBytes + 1]; } network_profile_t;
|
||||||
|
typedef enum { NETWORK_STATE_FALLBACK, NETWORK_STATE_CONNECTING, NETWORK_STATE_EXTERNAL, NETWORK_STATE_VALIDATING } network_state_t;
|
||||||
|
typedef enum { NETWORK_ACTION_NONE, NETWORK_ACTION_CONNECT, NETWORK_ACTION_FALLBACK } network_action_t;
|
||||||
|
typedef struct { network_state_t state; network_state_t state_before_validation; network_profile_t profile; network_profile_t candidate; uint32_t started_ms; bool has_profile; } network_manager_t;
|
||||||
|
|
||||||
|
bool network_profile_valid(const network_profile_t *profile);
|
||||||
|
network_action_t network_manager_init(network_manager_t *manager, const network_profile_t *profile, uint32_t now_ms);
|
||||||
|
network_action_t network_manager_tick(network_manager_t *manager, uint32_t now_ms);
|
||||||
|
network_action_t network_manager_connected(network_manager_t *manager);
|
||||||
|
network_action_t network_manager_disconnected(network_manager_t *manager, uint32_t now_ms);
|
||||||
|
bool network_manager_begin_validation(network_manager_t *manager, const network_profile_t *candidate);
|
||||||
|
network_action_t network_manager_finish_validation(network_manager_t *manager, bool success, uint32_t now_ms);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -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");
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_interfaces.h"
|
||||||
|
|
||||||
|
typedef struct { transport_t transport; } transport_service_t;
|
||||||
@@ -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"
|
||||||
@@ -13,5 +13,9 @@ platform = espressif32 @ 7.0.1
|
|||||||
board = esp32-c6-devkitm-1
|
board = esp32-c6-devkitm-1
|
||||||
framework = espidf
|
framework = espidf
|
||||||
board_build.partitions = partitions.csv
|
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_port = /dev/ttyACM0
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
|
|||||||
Binary file not shown.
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
CONFIG_LWIP_MAX_SOCKETS=16
|
||||||
+5
-3
@@ -1,6 +1,8 @@
|
|||||||
# This file was automatically generated for projects
|
# This file was automatically generated for projects
|
||||||
# without default 'CMakeLists.txt' file.
|
# without default 'CMakeLists.txt' file.
|
||||||
|
|
||||||
FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*)
|
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"
|
||||||
idf_component_register(SRCS ${app_sources})
|
INCLUDE_DIRS "../include"
|
||||||
|
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
|
||||||
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
+706
-116
@@ -1,141 +1,731 @@
|
|||||||
#include <inttypes.h>
|
#include <stdbool.h>
|
||||||
#include <stddef.h>
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <errno.h>
|
||||||
|
|
||||||
#include "driver/gpio.h"
|
#include "app_config.h"
|
||||||
#include "esp_chip_info.h"
|
#include "application.h"
|
||||||
#include "esp_err.h"
|
#include "captive_portal.h"
|
||||||
#include "esp_flash.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_log.h"
|
||||||
#include "esp_system.h"
|
#include "esp_netif.h"
|
||||||
|
#include "esp_random.h"
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
|
#include "esp_wifi.h"
|
||||||
#include "freertos/FreeRTOS.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 char *const kLogTag = "battleship";
|
||||||
static const gpio_num_t kCandidateLedGpio = GPIO_NUM_8;
|
static const char *const kLittlefsBasePath = "/littlefs";
|
||||||
static const uint32_t kLedProbeCount = 10;
|
static const char *const kLittlefsPartitionLabel = "littlefs";
|
||||||
static const uint32_t kLedProbeIntervalMs = 500;
|
static const size_t kFileChunkBytes = 1024U;
|
||||||
static const uint32_t kHealthIntervalMs = 30000;
|
enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U, kScanResultLimit = 12U };
|
||||||
static const uint32_t kStartupAttachmentDelayMs = 10000;
|
|
||||||
|
|
||||||
static const char *reset_reason_name(esp_reset_reason_t reason) {
|
typedef struct { bool configured; bool connected; bool fallback_active; } wifi_state_t;
|
||||||
switch (reason) {
|
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
case ESP_RST_UNKNOWN:
|
static portMUX_TYPE s_network_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
return "unknown";
|
static portMUX_TYPE s_configuration_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
case ESP_RST_POWERON:
|
static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
return "power_on";
|
static wifi_state_t s_wifi_state = {0};
|
||||||
case ESP_RST_EXT:
|
static bool s_littlefs_mounted;
|
||||||
return "external";
|
static httpd_handle_t s_server;
|
||||||
case ESP_RST_SW:
|
static esp_netif_t *s_station_netif;
|
||||||
return "software";
|
static esp_netif_t *s_access_point_netif;
|
||||||
case ESP_RST_PANIC:
|
static application_t s_application;
|
||||||
return "panic";
|
static http_api_t s_api;
|
||||||
case ESP_RST_INT_WDT:
|
static sync_service_t s_sync;
|
||||||
return "interrupt_watchdog";
|
static uint16_t s_rejected_input;
|
||||||
case ESP_RST_TASK_WDT:
|
static esp_timer_handle_t s_sync_timer;
|
||||||
return "task_watchdog";
|
static esp_timer_handle_t s_bot_timer;
|
||||||
case ESP_RST_WDT:
|
static esp_timer_handle_t s_network_timer;
|
||||||
return "other_watchdog";
|
static network_manager_t s_network_manager;
|
||||||
case ESP_RST_DEEPSLEEP:
|
static network_configuration_t s_configuration;
|
||||||
return "deep_sleep";
|
static bool s_scan_pending;
|
||||||
case ESP_RST_BROWNOUT:
|
static bool s_scan_ready;
|
||||||
return "brownout";
|
static bool s_dns_running;
|
||||||
case ESP_RST_SDIO:
|
static bool s_validation_waiting_for_disconnect;
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
static uint16_t rejected_input_snapshot(void) {
|
||||||
const size_t free_heap_bytes = esp_get_free_heap_size();
|
uint16_t rejected = 0;
|
||||||
const size_t minimum_free_heap_bytes = esp_get_minimum_free_heap_size();
|
portENTER_CRITICAL(&s_diagnostics_lock);
|
||||||
uint32_t flash_size_bytes = 0;
|
rejected = s_rejected_input;
|
||||||
const esp_err_t flash_result = esp_flash_get_size(NULL, &flash_size_bytes);
|
portEXIT_CRITICAL(&s_diagnostics_lock);
|
||||||
|
return rejected;
|
||||||
|
}
|
||||||
|
|
||||||
if (flash_result == ESP_OK) {
|
static wifi_state_t wifi_state_snapshot(void) {
|
||||||
ESP_LOGI(kLogTag,
|
wifi_state_t snapshot;
|
||||||
"health uptime_ms=%" PRIu64 " reset_reason=%s flash_bytes=%" PRIu32
|
portENTER_CRITICAL(&s_wifi_lock);
|
||||||
" free_heap_bytes=%u min_free_heap_bytes=%u",
|
snapshot = s_wifi_state;
|
||||||
uptime_ms,
|
portEXIT_CRITICAL(&s_wifi_lock);
|
||||||
reset_reason_name(esp_reset_reason()),
|
return snapshot;
|
||||||
flash_size_bytes,
|
}
|
||||||
(unsigned int)free_heap_bytes,
|
|
||||||
(unsigned int)minimum_free_heap_bytes);
|
static const char *wifi_state_name(const wifi_state_t *state) {
|
||||||
} else {
|
if (state->fallback_active) return "fallback";
|
||||||
ESP_LOGE(kLogTag, "health flash_size_failed=%s", esp_err_to_name(flash_result));
|
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) {
|
static esp_err_t api_handler(httpd_req_t *request) {
|
||||||
ESP_LOGI(kLogTag,
|
const http_api_route_t route = (http_api_route_t)(uintptr_t)request->user_ctx;
|
||||||
"led_probe gpio=%d transitions=%" PRIu32 " interval_ms=%" PRIu32 "; visually confirm the LED",
|
char body[kRequestBodyCapacity + 1U] = {0};
|
||||||
(int)kCandidateLedGpio,
|
char token[kSessionTokenBytes * 2U + 1U] = {0};
|
||||||
kLedProbeCount,
|
const size_t maximum = route_body_limit(route);
|
||||||
kLedProbeIntervalMs);
|
size_t body_length = 0U;
|
||||||
|
if (request->method == HTTP_POST) {
|
||||||
esp_err_t result = gpio_reset_pin(kCandidateLedGpio);
|
if ((size_t)request->content_len > maximum) { body_length = maximum + 1U; record_rejected_input(); }
|
||||||
if (result != ESP_OK) {
|
else {
|
||||||
ESP_LOGW(kLogTag, "led_probe gpio_reset_pin failed: %s", esp_err_to_name(result));
|
while (body_length < (size_t)request->content_len) {
|
||||||
return;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (uint32_t transition = 0; transition < kLedProbeCount; ++transition) {
|
|
||||||
gpio_set_level(kCandidateLedGpio, transition % 2U);
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(kLedProbeIntervalMs));
|
|
||||||
}
|
}
|
||||||
|
const bool target_too_large = request->method == HTTP_GET && httpd_req_get_url_query_len(request) > 128U;
|
||||||
gpio_set_level(kCandidateLedGpio, 0);
|
if (target_too_large) record_rejected_input();
|
||||||
ESP_LOGI(kLogTag, "led_probe complete; USB serial remained available during probe");
|
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");
|
||||||
|
}
|
||||||
|
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) {
|
static esp_err_t websocket_handler(httpd_req_t *request) {
|
||||||
esp_chip_info_t chip_info = {0};
|
const int client_id = httpd_req_to_sockfd(request);
|
||||||
uint32_t flash_size_bytes = 0;
|
if (request->method == HTTP_GET) {
|
||||||
const esp_err_t flash_result = esp_flash_get_size(NULL, &flash_size_bytes);
|
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);
|
||||||
esp_chip_info(&chip_info);
|
return ESP_FAIL;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
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) {
|
void app_main(void) {
|
||||||
vTaskDelay(pdMS_TO_TICKS(kStartupAttachmentDelayMs));
|
esp_err_t result = nvs_flash_init();
|
||||||
log_startup();
|
if (result != ESP_OK) ESP_LOGE(kLogTag, "NVS unavailable (%s); preserving storage and starting fallback", esp_err_to_name(result));
|
||||||
probe_candidate_led();
|
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});
|
||||||
while (true) {
|
const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"};
|
||||||
log_heap((uint64_t)(esp_timer_get_time() / 1000));
|
ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer));
|
||||||
vTaskDelay(pdMS_TO_TICKS(kHealthIntervalMs));
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#include "network_configuration.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
void network_configuration_init(network_configuration_t *configuration) { memset(configuration, 0, sizeof(*configuration)); }
|
||||||
|
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms) {
|
||||||
|
if (configuration == NULL || manager == NULL || configuration->state == NETWORK_CONFIGURATION_VALIDATING || !network_manager_begin_validation(manager, candidate)) return false;
|
||||||
|
configuration->previous_profile = manager->profile; configuration->had_previous_profile = manager->has_profile;
|
||||||
|
configuration->state = NETWORK_CONFIGURATION_VALIDATING; configuration->validation_deadline_ms = now_ms + kNetworkValidationWindowMs;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms) {
|
||||||
|
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING && (int32_t)(now_ms - configuration->validation_deadline_ms) >= 0;
|
||||||
|
}
|
||||||
|
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms) {
|
||||||
|
if (configuration == NULL || manager == NULL || configuration->state != NETWORK_CONFIGURATION_VALIDATING) return NETWORK_ACTION_NONE;
|
||||||
|
const network_action_t action = network_manager_finish_validation(manager, success, now_ms);
|
||||||
|
configuration->state = success ? NETWORK_CONFIGURATION_SUCCESS : NETWORK_CONFIGURATION_FAILED;
|
||||||
|
if (success) configuration->success_deadline_ms = now_ms + kNetworkSuccessNoticeMs;
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms) {
|
||||||
|
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_SUCCESS && (int32_t)(now_ms - configuration->success_deadline_ms) >= 0;
|
||||||
|
}
|
||||||
|
bool network_configuration_busy(const network_configuration_t *configuration) { return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#include "network_credential_store.h"
|
||||||
|
#include "network_credentials.h"
|
||||||
|
#include "nvs.h"
|
||||||
|
|
||||||
|
static const char *const kNamespace = "network";
|
||||||
|
static const char *const kProfileKey = "profile";
|
||||||
|
network_credential_load_result_t network_credential_store_load(network_profile_t *profile) { nvs_handle_t handle; network_credential_record_t record; size_t size = sizeof(record); const esp_err_t open_result = nvs_open(kNamespace, NVS_READONLY, &handle); if (open_result == ESP_ERR_NVS_NOT_FOUND) return NETWORK_CREDENTIAL_LOAD_ABSENT; if (open_result != ESP_OK) return NETWORK_CREDENTIAL_LOAD_INVALID; const esp_err_t result = nvs_get_blob(handle, kProfileKey, &record, &size); nvs_close(handle); if (result == ESP_ERR_NVS_NOT_FOUND) return NETWORK_CREDENTIAL_LOAD_ABSENT; return result == ESP_OK && size == sizeof(record) && network_credential_decode(&record, profile) ? NETWORK_CREDENTIAL_LOAD_VALID : NETWORK_CREDENTIAL_LOAD_INVALID; }
|
||||||
|
bool network_credential_store_replace(const network_profile_t *profile) { network_credential_record_t record; nvs_handle_t handle; if (!network_credential_encode(profile, &record) || nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) return false; const esp_err_t result = nvs_set_blob(handle, kProfileKey, &record, sizeof(record)); const bool ok = result == ESP_OK && nvs_commit(handle) == ESP_OK; nvs_close(handle); return ok; }
|
||||||
|
bool network_credential_store_delete(void) { nvs_handle_t handle; if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) return false; const esp_err_t result = nvs_erase_key(handle, kProfileKey); const bool ok = (result == ESP_OK || result == ESP_ERR_NVS_NOT_FOUND) && nvs_commit(handle) == ESP_OK; nvs_close(handle); return ok; }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#include "network_credentials.h"
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <string.h>
|
||||||
|
enum { kCredentialMagic = 0x42534E57U, kCredentialVersion = 1U };
|
||||||
|
static uint32_t checksum(const uint8_t *bytes, size_t length) { uint32_t value = 2166136261U; for (size_t i = 0; i < length; ++i) value = (value ^ bytes[i]) * 16777619U; return value; }
|
||||||
|
bool network_credential_encode(const network_profile_t *profile, network_credential_record_t *record) { if (!network_profile_valid(profile) || record == NULL) return false; memset(record, 0, sizeof(*record)); record->magic = kCredentialMagic; record->version = kCredentialVersion; record->profile = *profile; record->checksum = checksum((const uint8_t *)record, offsetof(network_credential_record_t, checksum)); return true; }
|
||||||
|
bool network_credential_decode(const network_credential_record_t *record, network_profile_t *profile) { if (record == NULL || profile == NULL || record->magic != kCredentialMagic || record->version != kCredentialVersion || record->checksum != checksum((const uint8_t *)record, offsetof(network_credential_record_t, checksum)) || !network_profile_valid(&record->profile)) return false; *profile = record->profile; return true; }
|
||||||
|
bool network_credential_load(const network_credential_backend_t *backend, network_profile_t *profile) { network_credential_record_t record; return backend != NULL && backend->read != NULL && backend->read(backend->context, &record) && network_credential_decode(&record, profile); }
|
||||||
|
bool network_credential_replace(const network_credential_backend_t *backend, const network_profile_t *profile) { network_credential_record_t record; return backend != NULL && backend->write != NULL && network_credential_encode(profile, &record) && backend->write(backend->context, &record); }
|
||||||
|
bool network_credential_delete(const network_credential_backend_t *backend) { return backend != NULL && backend->remove != NULL && backend->remove(backend->context); }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#include "network_state.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
bool network_profile_valid(const network_profile_t *profile) {
|
||||||
|
return profile != NULL && profile->ssid[0] != '\0' && memchr(profile->ssid, '\0', sizeof(profile->ssid)) != NULL &&
|
||||||
|
memchr(profile->password, '\0', sizeof(profile->password)) != NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
network_action_t network_manager_init(network_manager_t *manager, const network_profile_t *profile, uint32_t now_ms) {
|
||||||
|
memset(manager, 0, sizeof(*manager));
|
||||||
|
if (!network_profile_valid(profile)) { manager->state = NETWORK_STATE_FALLBACK; return NETWORK_ACTION_FALLBACK; }
|
||||||
|
manager->profile = *profile; manager->has_profile = true; manager->state = NETWORK_STATE_CONNECTING; manager->started_ms = now_ms;
|
||||||
|
return NETWORK_ACTION_CONNECT;
|
||||||
|
}
|
||||||
|
network_action_t network_manager_tick(network_manager_t *manager, uint32_t now_ms) {
|
||||||
|
if (manager->state == NETWORK_STATE_CONNECTING && (uint32_t)(now_ms - manager->started_ms) >= kNetworkConnectWindowMs) { manager->state = NETWORK_STATE_FALLBACK; return NETWORK_ACTION_FALLBACK; }
|
||||||
|
return NETWORK_ACTION_NONE;
|
||||||
|
}
|
||||||
|
network_action_t network_manager_connected(network_manager_t *manager) { manager->state = NETWORK_STATE_EXTERNAL; return NETWORK_ACTION_NONE; }
|
||||||
|
network_action_t network_manager_disconnected(network_manager_t *manager, uint32_t now_ms) {
|
||||||
|
if (!manager->has_profile) { manager->state = NETWORK_STATE_FALLBACK; return NETWORK_ACTION_FALLBACK; }
|
||||||
|
if (manager->state == NETWORK_STATE_CONNECTING) return NETWORK_ACTION_CONNECT;
|
||||||
|
manager->state = NETWORK_STATE_CONNECTING; manager->started_ms = now_ms; return NETWORK_ACTION_CONNECT;
|
||||||
|
}
|
||||||
|
bool network_manager_begin_validation(network_manager_t *manager, const network_profile_t *candidate) {
|
||||||
|
if (!network_profile_valid(candidate) || manager->state == NETWORK_STATE_VALIDATING) return false;
|
||||||
|
manager->candidate = *candidate; manager->state_before_validation = manager->state; manager->state = NETWORK_STATE_VALIDATING; return true;
|
||||||
|
}
|
||||||
|
network_action_t network_manager_finish_validation(network_manager_t *manager, bool success, uint32_t now_ms) {
|
||||||
|
if (manager->state != NETWORK_STATE_VALIDATING) return NETWORK_ACTION_NONE;
|
||||||
|
if (!success) { manager->state = manager->state_before_validation; return NETWORK_ACTION_NONE; }
|
||||||
|
manager->profile = manager->candidate; manager->has_profile = true; manager->state = NETWORK_STATE_CONNECTING; manager->started_ms = now_ms; return NETWORK_ACTION_CONNECT;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "network_configuration.h"
|
||||||
|
|
||||||
|
static network_profile_t profile(const char *ssid, const char *password) { network_profile_t value = {0}; snprintf(value.ssid, sizeof(value.ssid), "%s", ssid); snprintf(value.password, sizeof(value.password), "%s", password); return value; }
|
||||||
|
int main(void) {
|
||||||
|
network_manager_t manager; network_configuration_t configuration; network_profile_t old = profile("Old", "old-pass"); network_profile_t next = profile("New", "new-pass");
|
||||||
|
network_manager_init(&manager, &old, 0U); network_configuration_init(&configuration);
|
||||||
|
assert(network_configuration_begin(&configuration, &manager, &next, 10U)); assert(network_configuration_busy(&configuration));
|
||||||
|
assert(!network_configuration_begin(&configuration, &manager, &next, 11U)); assert(!network_configuration_validation_timed_out(&configuration, 30009U)); assert(network_configuration_validation_timed_out(&configuration, 30010U));
|
||||||
|
assert(network_configuration_finish(&configuration, &manager, false, 30010U) == NETWORK_ACTION_NONE); assert(strcmp(manager.profile.ssid, "Old") == 0);
|
||||||
|
assert(network_configuration_begin(&configuration, &manager, &next, 40000U)); assert(network_configuration_finish(&configuration, &manager, true, 40001U) == NETWORK_ACTION_CONNECT); assert(strcmp(manager.profile.ssid, "New") == 0);
|
||||||
|
assert(!network_configuration_success_notice_expired(&configuration, 55000U)); assert(network_configuration_success_notice_expired(&configuration, 55001U));
|
||||||
|
puts("network configuration tests passed"); return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "network_credentials.h"
|
||||||
|
#include "network_state.h"
|
||||||
|
|
||||||
|
static network_profile_t profile(const char *ssid, const char *password) { network_profile_t value = {0}; snprintf(value.ssid, sizeof(value.ssid), "%s", ssid); snprintf(value.password, sizeof(value.password), "%s", password); return value; }
|
||||||
|
typedef struct { bool present; network_credential_record_t record; } memory_store_t;
|
||||||
|
static bool read_record(void *context, network_credential_record_t *record) { memory_store_t *store = context; if (!store->present) return false; *record = store->record; return true; }
|
||||||
|
static bool write_record(void *context, const network_credential_record_t *record) { memory_store_t *store = context; store->record = *record; store->present = true; return true; }
|
||||||
|
static bool delete_record(void *context) { memory_store_t *store = context; store->present = false; memset(&store->record, 0, sizeof(store->record)); return true; }
|
||||||
|
int main(void) {
|
||||||
|
network_manager_t manager; network_profile_t home = profile("Home", "secret"); network_profile_t next = profile("Next", "newsecret");
|
||||||
|
assert(network_manager_init(&manager, NULL, 10U) == NETWORK_ACTION_FALLBACK && manager.state == NETWORK_STATE_FALLBACK);
|
||||||
|
assert(network_manager_init(&manager, &home, 10U) == NETWORK_ACTION_CONNECT);
|
||||||
|
assert(network_manager_tick(&manager, 30009U) == NETWORK_ACTION_NONE);
|
||||||
|
assert(network_manager_tick(&manager, 30010U) == NETWORK_ACTION_FALLBACK);
|
||||||
|
assert(network_manager_disconnected(&manager, 400U) == NETWORK_ACTION_CONNECT);
|
||||||
|
assert(network_manager_disconnected(&manager, 1000U) == NETWORK_ACTION_CONNECT);
|
||||||
|
assert(network_manager_tick(&manager, 30400U) == NETWORK_ACTION_FALLBACK);
|
||||||
|
assert(network_manager_connected(&manager) == NETWORK_ACTION_NONE && manager.state == NETWORK_STATE_EXTERNAL);
|
||||||
|
assert(network_manager_begin_validation(&manager, &next));
|
||||||
|
assert(network_manager_finish_validation(&manager, false, 500U) == NETWORK_ACTION_NONE && strcmp(manager.profile.ssid, "Home") == 0);
|
||||||
|
assert(network_manager_begin_validation(&manager, &next));
|
||||||
|
assert(network_manager_finish_validation(&manager, true, 600U) == NETWORK_ACTION_CONNECT && strcmp(manager.profile.ssid, "Next") == 0);
|
||||||
|
network_credential_record_t record; network_profile_t restored = {0};
|
||||||
|
assert(network_credential_encode(&next, &record)); assert(network_credential_decode(&record, &restored)); assert(strcmp(restored.password, "newsecret") == 0);
|
||||||
|
record.profile.ssid[0] = 'X'; assert(!network_credential_decode(&record, &restored));
|
||||||
|
assert(network_credential_encode(&next, &record)); ++record.version; assert(!network_credential_decode(&record, &restored));
|
||||||
|
assert(network_credential_encode(&next, &record)); record.profile.password[0] = '\0'; assert(!network_credential_decode(&record, &restored));
|
||||||
|
memory_store_t store = {0}; network_credential_backend_t backend = {&store, read_record, write_record, delete_record};
|
||||||
|
assert(network_credential_replace(&backend, &home)); assert(network_credential_load(&backend, &restored)); assert(strcmp(restored.ssid, "Home") == 0);
|
||||||
|
store.record.profile.ssid[0] = 'X'; assert(!network_credential_load(&backend, &restored));
|
||||||
|
assert(network_credential_replace(&backend, &next)); assert(network_credential_load(&backend, &restored)); assert(strcmp(restored.ssid, "Next") == 0);
|
||||||
|
assert(network_credential_delete(&backend)); assert(!network_credential_load(&backend, &restored));
|
||||||
|
network_profile_t invalid = profile("", "x"); assert(!network_profile_valid(&invalid));
|
||||||
|
puts("network foundation tests passed"); return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { createSoundDirector, COUNTS, CATALOG } = require('../../data/game_sounds.js');
|
||||||
|
|
||||||
|
function recorder() { const played = []; return { played, playPreset: preset => { played.push(preset); return true; } }; }
|
||||||
|
|
||||||
|
test('every required event family has its planned minimum of original synthesis presets', () => {
|
||||||
|
Object.entries(COUNTS).forEach(([family, count]) => {
|
||||||
|
assert.equal(CATALOG[family].length, count);
|
||||||
|
assert.equal(new Set(CATALOG[family].map(preset => preset.id)).size, count);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection is randomized without immediate repeats and keeps bounded history', () => {
|
||||||
|
const output = recorder(); let sample = 0;
|
||||||
|
const director = createSoundDirector({ engine: output, random: () => (sample++ % 11) / 11, historyLimit: 3 });
|
||||||
|
const selected = [];
|
||||||
|
for (let index = 0; index < 30; index += 1) selected.push(director.play('shot').preset.id);
|
||||||
|
selected.slice(1).forEach((id, index) => assert.notEqual(id, selected[index]));
|
||||||
|
assert.ok(director.getHistory('shot').length <= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preset parameter ranges remain safe through many randomized selections', () => {
|
||||||
|
const output = recorder(); const director = createSoundDirector({ engine: output, random: Math.random });
|
||||||
|
for (let index = 0; index < 3000; index += 1) director.play(Object.keys(COUNTS)[index % Object.keys(COUNTS).length]);
|
||||||
|
output.played.forEach(preset => {
|
||||||
|
assert.ok(preset.frequency >= 100 && preset.frequency <= 1000);
|
||||||
|
assert.ok(preset.endFrequency >= 100 && preset.endFrequency <= 1000);
|
||||||
|
assert.ok(preset.durationMs >= 100 && preset.durationMs <= 900);
|
||||||
|
assert.ok(preset.filterHz >= 900 && preset.filterHz <= 3500);
|
||||||
|
assert.ok(Math.abs(preset.pan) <= 0.4 && preset.gain <= 0.75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state versions, perspectives, priority, waiting rate limit, and reconnect summaries are safe', () => {
|
||||||
|
const output = recorder(); let clock = 0;
|
||||||
|
const director = createSoundDirector({ engine: output, random: () => 0, now: () => clock, waitingIntervalMs: 12000 });
|
||||||
|
assert.ok(director.getPriority('victory') > director.getPriority('sunk'));
|
||||||
|
assert.ok(director.getPriority('sunk') > director.getPriority('hit'));
|
||||||
|
assert.ok(director.getPriority('hit') > director.getPriority('miss'));
|
||||||
|
assert.equal(director.play('watch-hit', { version: 4 }).preset.family, 'hit');
|
||||||
|
assert.equal(director.play('damage', { version: 4 }), false);
|
||||||
|
assert.equal(director.play('watch-sunk', { version: 5 }).preset.family, 'sunk');
|
||||||
|
assert.equal(director.play('waiting').preset.family, 'waiting');
|
||||||
|
assert.equal(director.play('waiting'), false);
|
||||||
|
clock = 12001;
|
||||||
|
assert.equal(director.play('waiting').preset.family, 'waiting');
|
||||||
|
assert.equal(director.play('summary', { version: 6, summary: true }).preset.family, 'victory');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('combos escalate, reduced intensity suppresses extras, and recovery remains calm', () => {
|
||||||
|
const output = recorder(); const director = createSoundDirector({ engine: output, random: () => 0.3 });
|
||||||
|
assert.equal(director.play('hit', { combo: 2 }).combo, 'combo');
|
||||||
|
assert.equal(director.play('hit', { combo: 3 }).combo, 'mega');
|
||||||
|
assert.equal(director.play('sunk', { combo: 4 }).combo, 'ultra');
|
||||||
|
const count = output.played.length;
|
||||||
|
assert.equal(director.play('hit', { combo: 4, reduced: true }).combo, 'ultra');
|
||||||
|
assert.equal(output.played.length, count + 1);
|
||||||
|
assert.equal(director.play('waiting', { reduced: true }), false);
|
||||||
|
const recovery = ['recovery-open', 'recovery-cancel', 'recovery-leave', 'recovery-profile', 'recovery-game'].map(event => director.play(event).preset);
|
||||||
|
assert.equal(new Set(recovery.map(preset => preset.id)).size, 5);
|
||||||
|
recovery.forEach(preset => assert.ok(preset.family === 'recovery' && preset.gain <= 0.16 && preset.durationMs <= 300));
|
||||||
|
});
|
||||||
@@ -0,0 +1,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\)/);
|
||||||
|
});
|
||||||
@@ -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'/);
|
||||||
|
});
|
||||||
@@ -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'/);
|
||||||
|
});
|
||||||
@@ -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'/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { createAudioEngine, MAX_VOICES, MAX_GAIN } = require('../../data/web_audio.js');
|
||||||
|
|
||||||
|
function memoryStorage() {
|
||||||
|
const values = new Map();
|
||||||
|
return { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, String(value)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeTimer() {
|
||||||
|
let next = 1;
|
||||||
|
const tasks = new Map();
|
||||||
|
return { setTimeout: callback => { const id = next++; tasks.set(id, callback); return id; }, clearTimeout: id => tasks.delete(id), runAll: () => [...tasks.values()].forEach(callback => callback()), size: () => tasks.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeAudioContext() {
|
||||||
|
const contexts = [];
|
||||||
|
const parameter = () => ({ setValueAtTime() {}, exponentialRampToValueAtTime() {} });
|
||||||
|
const node = () => ({ connected: 0, disconnected: 0, connect() { this.connected += 1; }, disconnect() { this.disconnected += 1; } });
|
||||||
|
class FakeAudioContext {
|
||||||
|
constructor() { this.currentTime = 0; this.sampleRate = 1000; this.destination = node(); this.state = 'suspended'; contexts.push(this); }
|
||||||
|
createGain() { return { ...node(), gain: parameter() }; }
|
||||||
|
createDynamicsCompressor() { return { ...node(), threshold: parameter(), knee: parameter(), ratio: parameter() }; }
|
||||||
|
createBiquadFilter() { return { ...node(), frequency: parameter(), type: '' }; }
|
||||||
|
createOscillator() { return { ...node(), frequency: parameter(), start() {}, stop() { this.stopped = true; } }; }
|
||||||
|
createBufferSource() { return { ...node(), start() {}, stop() { this.stopped = true; } }; }
|
||||||
|
createBuffer(_channels, length) { const samples = new Float32Array(length); return { getChannelData: () => samples }; }
|
||||||
|
async resume() { this.state = 'running'; }
|
||||||
|
async suspend() { this.state = 'suspended'; }
|
||||||
|
close() { this.state = 'closed'; }
|
||||||
|
}
|
||||||
|
return { FakeAudioContext, contexts };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('does not create an audio context before explicit enablement', () => {
|
||||||
|
const { FakeAudioContext, contexts } = fakeAudioContext();
|
||||||
|
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage() });
|
||||||
|
assert.equal(contexts.length, 0);
|
||||||
|
assert.equal(engine.play('test'), false);
|
||||||
|
assert.equal(contexts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enables through a user gesture, persists safe preferences, and caps gain', async () => {
|
||||||
|
const { FakeAudioContext, contexts } = fakeAudioContext();
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage });
|
||||||
|
assert.equal(await engine.enable(), true);
|
||||||
|
engine.setVolume('loud'); engine.setReduced(true);
|
||||||
|
assert.equal(contexts.length, 1);
|
||||||
|
assert.equal(engine.getPreferences().enabled, true);
|
||||||
|
assert.equal(engine.getPreferences().volume, 'loud');
|
||||||
|
assert.equal(engine.getPreferences().reduced, true);
|
||||||
|
assert.equal(engine.getMaxGain(), MAX_GAIN);
|
||||||
|
assert.ok(MAX_GAIN <= 0.3);
|
||||||
|
assert.equal(storage.getItem('battleship.soundVolume'), 'loud');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses a bounded voice pool, priority replacement, one noise buffer, and cleanup', async () => {
|
||||||
|
const { FakeAudioContext } = fakeAudioContext();
|
||||||
|
const timer = fakeTimer();
|
||||||
|
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer });
|
||||||
|
await engine.enable();
|
||||||
|
for (let index = 0; index < MAX_VOICES; index += 1) assert.equal(engine.play('test'), true);
|
||||||
|
assert.equal(engine.getVoiceCount(), MAX_VOICES);
|
||||||
|
assert.equal(engine.play('test'), true);
|
||||||
|
assert.equal(engine.getVoiceCount(), MAX_VOICES);
|
||||||
|
assert.equal(engine.play('noise'), true);
|
||||||
|
const noise = engine.getNoiseBuffer();
|
||||||
|
assert.ok(noise);
|
||||||
|
engine.play('noise');
|
||||||
|
assert.equal(engine.getNoiseBuffer(), noise);
|
||||||
|
timer.runAll();
|
||||||
|
assert.equal(engine.getVoiceCount(), 0);
|
||||||
|
assert.equal(timer.size(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('suppresses duplicate versions and cancels audio for mute, page hide, and reset cleanup', async () => {
|
||||||
|
const { FakeAudioContext, contexts } = fakeAudioContext();
|
||||||
|
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer: fakeTimer() });
|
||||||
|
await engine.enable();
|
||||||
|
assert.equal(engine.play('test', 4), true);
|
||||||
|
assert.equal(engine.play('test', 4), false);
|
||||||
|
assert.equal(engine.getVoiceCount(), 1);
|
||||||
|
await engine.setPageHidden(true);
|
||||||
|
assert.equal(engine.getVoiceCount(), 0);
|
||||||
|
await engine.setPageHidden(false);
|
||||||
|
await engine.setEnabled(false);
|
||||||
|
assert.equal(engine.play('test'), false);
|
||||||
|
engine.cleanup();
|
||||||
|
assert.equal(contexts[0].state, 'closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back silently when Web Audio is unsupported or initialization fails', async () => {
|
||||||
|
const silent = createAudioEngine({ AudioContext: undefined, storage: memoryStorage() });
|
||||||
|
assert.equal(await silent.enable(), false);
|
||||||
|
assert.equal(silent.play('test'), false);
|
||||||
|
class FailingContext { constructor() { throw new Error('no audio'); } }
|
||||||
|
const failing = createAudioEngine({ AudioContext: FailingContext, storage: memoryStorage() });
|
||||||
|
assert.equal(await failing.enable(), false);
|
||||||
|
assert.equal(failing.play('test'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the application exposes an explicit Russian sound control and cleans audio without changing transport', () => {
|
||||||
|
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
|
||||||
|
const page = fs.readFileSync(path.join(__dirname, '../../data/index.html'), 'utf8');
|
||||||
|
const firmware = fs.readFileSync(path.join(__dirname, '../../src/main.c'), 'utf8');
|
||||||
|
assert.match(page, /id="sound-toggle"[^>]*aria-pressed="false"/);
|
||||||
|
assert.match(page, /id="sound-volume"/);
|
||||||
|
assert.match(page, /id="sound-reduced"/);
|
||||||
|
assert.match(page, /id="recovery-sound-mute"/);
|
||||||
|
assert.match(page, /src="\/web_audio\.js"/);
|
||||||
|
assert.match(page, /src="\/game_sounds\.js"/);
|
||||||
|
assert.match(firmware, /strcmp\(request->uri, "\/web_audio\.js"\)/);
|
||||||
|
assert.match(firmware, /strcmp\(request->uri, "\/game_sounds\.js"\)/);
|
||||||
|
assert.match(app, /audio\.cleanup\(\)/);
|
||||||
|
assert.match(app, /globalThis\.BattleshipAudio\?\.createAudioEngine\?\.\(\) \|\| silentAudio/);
|
||||||
|
assert.match(app, /visibilitychange/);
|
||||||
|
assert.match(app, /soundKeys = new Set/);
|
||||||
|
assert.match(app, /ui\.recoverySoundMute\.hidden = !preferences\.enabled/);
|
||||||
|
assert.match(app, /\/api\/game\/shot/);
|
||||||
|
assert.match(app, /sounds\.play\('shot'/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user