From d30eb5922579ac3bdf5b55d9d7d05c87d3e3573b Mon Sep 17 00:00:00 2001 From: sasa Date: Mon, 31 Aug 2026 00:14:27 +0300 Subject: [PATCH] fix: spectator board rendering --- data/app.js | 23 +++++++---- test/host/test_human_game_integration.c | 52 +++++++++++++++++++++++-- test/web/test_spectator_view.js | 31 +++++++++++++++ 3 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 test/web/test_spectator_view.js diff --git a/data/app.js b/data/app.js index 1560022..5f0b7c8 100644 --- a/data/app.js +++ b/data/app.js @@ -1,6 +1,7 @@ (() => { const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К']; const storage = { token: 'battleship.sessionToken', name: 'battleship.displayName' }; + const boardKeys = Object.freeze({ own: 'own', opponent: 'opponent', player1: 'player1', player2: 'player2' }); const screens = ['connect', 'lobby', 'game', 'result', 'reconnecting', 'error']; const el = id => document.getElementById(id); const ui = { @@ -336,6 +337,7 @@ return payload && payload.type === 'state' && Array.isArray(payload.boards) && payload.boards.length === 2 && payload.boards.every(board => typeof board === 'string' && /^[01234]{100}$/.test(board)) && typeof payload.version === 'number' && typeof payload.gameId === 'number' && + ['player1', 'player2', 'spectator'].includes(payload.viewer) && (payload.winner === null || payload.winner === 0 || payload.winner === 1) && Array.isArray(payload.statistics) && Array.isArray(payload.players) && payload.players.length === 2 && payload.players.every(name => typeof name === 'string' && name.length <= 80) && payload.statistics.length === 2 && payload.statistics.every(entry => Array.isArray(entry) && entry.length === 4 && @@ -348,6 +350,8 @@ 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}`; @@ -511,18 +515,19 @@ function boardDefinitions(result = false) { if (role === 'spectator' || result) return [ - { index: 0, title: `Поле: ${playerLabel(0)}`, key: 'player1', targetable: false }, - { index: 1, title: `Поле: ${playerLabel(1)}`, key: 'player2', targetable: false } + { index: 0, title: `Поле: ${playerLabel(0)}`, key: boardKeys.player1, targetable: false }, + { index: 1, title: `Поле: ${playerLabel(1)}`, key: boardKeys.player2, targetable: false } ]; return [ - { index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false }, - { index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, key: 'opponent', targetable: canShoot() } + { index: ownIndex(), title: 'Моё поле', key: boardKeys.own, targetable: false }, + { index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, key: boardKeys.opponent, targetable: canShoot() } ]; } function renderBoards(container, result = false) { container.replaceChildren(); const definitions = boardDefinitions(result); + if (!result && !definitions.some(definition => definition.key === activeBoard)) activeBoard = definitions[0].key; definitions.forEach(definition => { const board = boardElement(definition.index, definition.title, definition.targetable, result); board.hidden = !result && definition.key !== activeBoard; @@ -532,7 +537,7 @@ ui.tabs.replaceChildren(); definitions.forEach(definition => { const tab = document.createElement('button'); - tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.key === 'opponent' ? playerLabel(definition.index) : definition.title; + tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.key === boardKeys.opponent ? playerLabel(definition.index) : definition.title; tab.title = definition.title; tab.setAttribute('aria-label', definition.title); tab.setAttribute('aria-selected', String(definition.key === activeBoard)); tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); }); @@ -566,15 +571,17 @@ if (!state) return; stopLobbyInfoPolling(); showScreen('game'); - if (canShoot() && !selectedTarget) activeBoard = 'opponent'; - ui.turn.textContent = canShoot() ? 'Ваш ход' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}`; + if (canShoot() && !selectedTarget) activeBoard = boardKeys.opponent; + const turnPlayer = playerLabel(state.turn === 'player2' ? 1 : 0); + const watching = role === 'spectator'; + ui.turn.textContent = canShoot() ? 'Ваш ход' : watching ? `Наблюдаем за боем · Ходит ${turnPlayer}` : `Ходит ${turnPlayer}`; ui.wins.textContent = `${playerLabel(0)} ${state.wins[0]} : ${state.wins[1]} ${playerLabel(1)}`; renderBoards(ui.boards); const available = canShoot(); ui.cue.classList.toggle('is-ready', available && !selectedTarget); ui.cue.classList.toggle('has-target', Boolean(available && selectedTarget)); setSpriteIcon(ui.cueIcon, available ? (selectedTarget ? 'icon-blast' : 'icon-target') : 'icon-hourglass'); - ui.cueText.textContent = available ? (selectedTarget ? 'Жми «Огонь»!' : 'Твой ход! Выбери клетку') : 'Ждём соперника…'; + 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); diff --git a/test/host/test_human_game_integration.c b/test/host/test_human_game_integration.c index 60281ff..8944c67 100644 --- a/test/host/test_human_game_integration.c +++ b/test/host/test_human_game_integration.c @@ -96,6 +96,9 @@ static void test_human_game_journey(void) { resume(&api, player_tokens[0], "player1"); resume(&api, player_tokens[1], "player2"); resume(&api, spectator_token, "spectator"); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); + assert(response.status == 200U && strstr(response.body, "\"phase\":\"lobby\"") != NULL && + strstr(response.body, "\"viewer\":\"spectator\"") != NULL); const uint32_t game_id = application.lifecycle.game.state.game_id; char config[96]; snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}", @@ -105,14 +108,21 @@ static void test_human_game_journey(void) { command(&api, HTTP_API_ROUTE_START, player_tokens[0], game_id); assert(application.lifecycle.game.state.phase == PHASE_IN_PROGRESS); + response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"Новый зритель\",\"requestedRole\":\"spectator\"}", NULL); + assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL); + char in_progress_spectator_token[33]; + token_from_response(&response, in_progress_spectator_token); + resume(&api, in_progress_spectator_token, "spectator"); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[0]); assert(response.status == 200U && board_start(response.body, 0)[0] <= '3'); assert_hidden(response.body, 1U); response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[1]); assert(response.status == 200U); assert_hidden(response.body, 0U); - response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); - assert(response.status == 200U); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, in_progress_spectator_token); + assert(response.status == 200U && strstr(response.body, "\"viewer\":\"spectator\"") != NULL); assert_hidden(response.body, 0U); assert_hidden(response.body, 1U); @@ -124,7 +134,7 @@ static void test_human_game_journey(void) { while (application.lifecycle.game.state.phase == PHASE_IN_PROGRESS) { const coordinate_t target = first_cell(&application.lifecycle.game.state.boards[winner ^ 1U], CELL_SHIP); shot(&api, player_tokens[winner], game_id, target); - response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, in_progress_spectator_token); saw_sunk = saw_sunk || strchr(board_start(response.body, winner ^ 1U), '4') != NULL; } assert(saw_sunk); @@ -133,11 +143,21 @@ static void test_human_game_journey(void) { assert(application.lifecycle.game.state.statistics[winner].hits == 20U); assert(application.lifecycle.game.state.statistics[winner].ships_sunk == 10U); assert(application.lifecycle.game.state.statistics[first_player].misses == 1U); - response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, in_progress_spectator_token); assert(response.status == 200U && strstr(response.body, "\"winner\":") != NULL); assert(strstr(response.body, "\"statistics\":[") != NULL); assert(strchr(board_start(response.body, 0U), '1') != NULL || strchr(board_start(response.body, 1U), '1') != NULL); + response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"Финишный зритель\",\"requestedRole\":\"spectator\"}", NULL); + assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL); + char finished_spectator_token[33]; + token_from_response(&response, finished_spectator_token); + resume(&api, finished_spectator_token, "spectator"); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, finished_spectator_token); + assert(response.status == 200U && strstr(response.body, "\"phase\":\"finished\"") != NULL && + strstr(response.body, "\"viewer\":\"spectator\"") != NULL); + resume(&api, player_tokens[0], "player1"); resume(&api, player_tokens[1], "player2"); command(&api, HTTP_API_ROUTE_REMATCH, player_tokens[0], game_id); @@ -149,6 +169,30 @@ static void test_human_game_journey(void) { assert(game_lifecycle_disconnect(&application.lifecycle, 1U) == LIFECYCLE_RESULT_OK); command(&api, HTTP_API_ROUTE_ABORT, player_tokens[0], application.lifecycle.game.state.game_id); assert(application.lifecycle.game.state.phase == PHASE_LOBBY); + + char recovery[80]; + snprintf(recovery, sizeof(recovery), "{\"token\":\"%s\",\"gameId\":%u}", in_progress_spectator_token, + (unsigned int)application.lifecycle.game.state.game_id); + response = call(&api, HTTP_API_ROUTE_PROFILE_RESET, HTTP_API_POST, recovery, NULL); + assert(response.status == 200U); + response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"После профиля\",\"requestedRole\":\"spectator\"}", NULL); + assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL); + char recovery_spectator_token[33]; + token_from_response(&response, recovery_spectator_token); + + snprintf(recovery, sizeof(recovery), "{\"token\":\"%s\",\"gameId\":%u}", recovery_spectator_token, + (unsigned int)application.lifecycle.game.state.game_id); + response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, recovery, NULL); + assert(response.status == 200U); + response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"После выхода\",\"requestedRole\":\"spectator\"}", NULL); + assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL); + + command(&api, HTTP_API_ROUTE_RESET, player_tokens[0], application.lifecycle.game.state.game_id); + response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"После сброса\",\"requestedRole\":\"spectator\"}", NULL); + assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL); } static void test_atomic_two_player_lobby(void) { diff --git a/test/web/test_spectator_view.js b/test/web/test_spectator_view.js new file mode 100644 index 0000000..2f3b298 --- /dev/null +++ b/test/web/test_spectator_view.js @@ -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'/); +});