Compare commits
2
Commits
31ef12b5f2
...
27adf47465
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27adf47465 | ||
|
|
6392c2ef03 |
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "sea-battle-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@rails/actioncable": "^8.1.400",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -807,6 +808,12 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@rails/actioncable": {
|
||||
"version": "8.1.400",
|
||||
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.1.400.tgz",
|
||||
"integrity": "sha512-Qffc3/I9xcpeaal12hmi9EPRV3hz9FvakUHBR7kL0jpZviWwO9dyaG8F1ltcjkHIdFxQ5QqzRyequAWHMq17xQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rails/actioncable": "^8.1.400",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
const API_URL = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_URL) || 'http://localhost:3000'
|
||||
|
||||
async function request(path, { method = 'GET', body, token, formData } = {}) {
|
||||
const headers = {}
|
||||
|
||||
+35
-3
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { api } from './api'
|
||||
import AuthModal from './components/AuthModal'
|
||||
import { connectCable, disconnectCable, CableContext } from './cable.js'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
const AuthModalContext = createContext(null)
|
||||
@@ -27,20 +28,32 @@ export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
|
||||
const [loading, setLoading] = useState(!!localStorage.getItem(TOKEN_KEY))
|
||||
const [consumer, setConsumer] = useState(() => {
|
||||
const savedToken = localStorage.getItem(TOKEN_KEY)
|
||||
return savedToken ? connectCable(savedToken) : null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
disconnectCable()
|
||||
setConsumer(null)
|
||||
return
|
||||
}
|
||||
api
|
||||
.me(token)
|
||||
.then((data) => setUser(data.user))
|
||||
.then((data) => {
|
||||
setUser(data.user)
|
||||
const c = connectCable(token)
|
||||
setConsumer(c)
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
disconnectCable()
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setConsumer(null)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [token])
|
||||
@@ -50,6 +63,8 @@ export function AuthProvider({ children }) {
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
const c = connectCable(data.token)
|
||||
setConsumer(c)
|
||||
}, [])
|
||||
|
||||
const register = useCallback(async (nickname, password, avatarFile) => {
|
||||
@@ -57,18 +72,35 @@ export function AuthProvider({ children }) {
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
const c = connectCable(data.token)
|
||||
setConsumer(c)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
if (token) api.logout(token).catch(() => {})
|
||||
disconnectCable()
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setConsumer(null)
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
|
||||
{children}
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
consumer,
|
||||
cable: consumer,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
<CableContext.Provider value={{ consumer, cable: consumer }}>
|
||||
{children}
|
||||
</CableContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createContext, useContext, useEffect, useState, createElement } from 'react'
|
||||
import { createConsumer } from '@rails/actioncable'
|
||||
import { wsUrl } from './api.js'
|
||||
|
||||
let consumerInstance = null
|
||||
let activeToken = null
|
||||
|
||||
/**
|
||||
* Возвращает активный экземпляр ActionCable consumer, если он создан.
|
||||
*
|
||||
* @returns {import('@rails/actioncable').Consumer | null}
|
||||
*/
|
||||
export function getConsumer() {
|
||||
return consumerInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает (или возвращает существующий) ActionCable consumer для переданного JWT токена.
|
||||
* ActionCable автоматически обрабатывает переподключения при разрывах связи.
|
||||
*
|
||||
* @param {string} token JWT токен пользователя
|
||||
* @returns {import('@rails/actioncable').Consumer | null}
|
||||
*/
|
||||
export function connectCable(token) {
|
||||
if (!token) return null
|
||||
|
||||
if (consumerInstance && activeToken === token) {
|
||||
consumerInstance.connect()
|
||||
return consumerInstance
|
||||
}
|
||||
|
||||
if (consumerInstance) {
|
||||
disconnectCable()
|
||||
}
|
||||
|
||||
activeToken = token
|
||||
const url = wsUrl(`/cable?token=${encodeURIComponent(token)}`)
|
||||
consumerInstance = createConsumer(url)
|
||||
consumerInstance.connect()
|
||||
return consumerInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключает ActionCable consumer и закрывает соединение.
|
||||
* Вызывается при выходе из профиля (logout) или истечении сессии.
|
||||
*/
|
||||
export function disconnectCable() {
|
||||
if (consumerInstance) {
|
||||
try {
|
||||
consumerInstance.disconnect()
|
||||
} catch {
|
||||
// Игнорируем возможные ошибки при закрытии
|
||||
}
|
||||
consumerInstance = null
|
||||
}
|
||||
activeToken = null
|
||||
}
|
||||
|
||||
export const CableContext = createContext(null)
|
||||
|
||||
/**
|
||||
* Провайдер ActionCable для React приложения.
|
||||
* Может принимать token явно или использоваться в составе дерева компонентов.
|
||||
*/
|
||||
export function CableProvider({ children, token }) {
|
||||
const [consumer, setConsumer] = useState(() => (token ? connectCable(token) : getConsumer()))
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
const c = connectCable(token)
|
||||
setConsumer(c)
|
||||
} else if (token === null) {
|
||||
disconnectCable()
|
||||
setConsumer(null)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
return createElement(
|
||||
CableContext.Provider,
|
||||
{ value: { consumer, cable: consumer } },
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для доступа к ActionCable consumer из любого компонента.
|
||||
* Поддерживает деструктуризацию: `const { consumer } = useCable()`, `const { cable } = useCable()`.
|
||||
*/
|
||||
export function useCable() {
|
||||
const context = useContext(CableContext)
|
||||
if (context && context.consumer !== undefined) return context
|
||||
const consumer = getConsumer()
|
||||
return { consumer, cable: consumer }
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения экземпляра consumer напрямую: `const consumer = useConsumer()`.
|
||||
*/
|
||||
export function useConsumer() {
|
||||
const { consumer } = useCable()
|
||||
return consumer
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createMatchmaking, MatchmakingError } from '../matchmaking'
|
||||
import { useCable } from '../cable'
|
||||
|
||||
const STAGE_LABELS = {
|
||||
connecting: 'Подключение...',
|
||||
@@ -13,6 +14,7 @@ const ERROR_LABELS = {
|
||||
}
|
||||
|
||||
export default function SearchScreen({ token, onMatched, onCancel }) {
|
||||
const { consumer } = useCable()
|
||||
const [stage, setStage] = useState('connecting') // 'connecting' | 'searching'
|
||||
const [error, setError] = useState(null)
|
||||
const [retryToken, setRetryToken] = useState(0)
|
||||
@@ -26,7 +28,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
|
||||
setStage('connecting')
|
||||
setError(null)
|
||||
|
||||
matchmaking = createMatchmaking(token, (status) => {
|
||||
matchmaking = createMatchmaking(consumer || token, (status) => {
|
||||
if (!cancelled && attempt === attemptRef.current) setStage(status)
|
||||
})
|
||||
|
||||
@@ -43,7 +45,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
|
||||
cancelled = true
|
||||
matchmaking.cancel()
|
||||
}
|
||||
}, [token, onMatched, retryToken])
|
||||
}, [consumer, token, onMatched, retryToken])
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel()
|
||||
|
||||
+226
-106
@@ -1,6 +1,4 @@
|
||||
import { wsUrl } from './api'
|
||||
|
||||
const CHANNEL_IDENTIFIER = JSON.stringify({ channel: 'MatchmakingChannel' })
|
||||
import { getConsumer, connectCable } from './cable.js'
|
||||
|
||||
export class MatchmakingError extends Error {
|
||||
constructor(code, message) {
|
||||
@@ -9,128 +7,250 @@ export class MatchmakingError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключается к main-server по WebSocket (Action Cable) и инициирует
|
||||
* поиск соперника. Возвращает объект с promise (резолвится при событии
|
||||
* `matched`) и функцией отмены `cancel()`.
|
||||
*
|
||||
* @param {string} token JWT пользователя
|
||||
* @param {(status: 'connecting' | 'searching') => void} onStatus
|
||||
*/
|
||||
export function createMatchmaking(token, onStatus) {
|
||||
let ws = null
|
||||
let settled = false
|
||||
// Текущая активная сессия матчмейкинга на уровне модуля
|
||||
let activeSession = null
|
||||
|
||||
const send = (payload) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload))
|
||||
/**
|
||||
* Инициирует поиск соперника через ActionCable MatchmakingChannel.
|
||||
* Возвращает объект с promise (резолвится при событии `matched`)
|
||||
* и функцией отмены `cancel()`.
|
||||
*
|
||||
* Соединение WebSocket НЕ закрывается: при завершении или отмене
|
||||
* отменяется только подписка на канал (MatchmakingChannel),
|
||||
* а сокет остается открытым и доступным для всего приложения.
|
||||
*
|
||||
* Если отмена происходит до подтверждения подписки сервером (например,
|
||||
* при двойном монтировании в React StrictMode или быстром уходе со страницы),
|
||||
* отписка откладывается до прихода `confirm_subscription`, чтобы не создавать
|
||||
* гонку непарных subscribe/unsubscribe на сервере. При немедленном повторном
|
||||
* монтировании уже начатая подписка переиспользуется.
|
||||
*
|
||||
* @param {import('@rails/actioncable').Consumer | string} [consumerOrToken]
|
||||
* Экземпляр ActionCable consumer или JWT токен пользователя
|
||||
* @param {(status: 'connecting' | 'searching') => void} [onStatus]
|
||||
* @returns {{ promise: Promise<{ matchId: string, gameToken: string, gameServerUrl: string, players: [number, number] }>, cancel: () => void }}
|
||||
*/
|
||||
export function createMatchmaking(consumerOrToken, onStatus) {
|
||||
let consumer = null
|
||||
let statusCallback = onStatus
|
||||
|
||||
if (typeof consumerOrToken === 'function') {
|
||||
statusCallback = consumerOrToken
|
||||
consumer = getConsumer()
|
||||
} else if (consumerOrToken && typeof consumerOrToken.subscriptions === 'object') {
|
||||
consumer = consumerOrToken
|
||||
} else if (typeof consumerOrToken === 'string') {
|
||||
consumer = getConsumer() || connectCable(consumerOrToken)
|
||||
} else {
|
||||
consumer = getConsumer()
|
||||
}
|
||||
|
||||
if (!consumer) {
|
||||
return {
|
||||
promise: Promise.reject(new MatchmakingError('connection', 'Нет подключения к серверу')),
|
||||
cancel: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(err)
|
||||
// Если уже существует активная сессия подписки (например, от предыдущего быстрого mount в StrictMode)
|
||||
if (activeSession && activeSession.consumer === consumer && !activeSession.settled) {
|
||||
activeSession.cancelled = false
|
||||
activeSession.statusCallback = statusCallback
|
||||
|
||||
// Если подписка уже успела подтвердиться ранее
|
||||
if (activeSession.isSubscribed) {
|
||||
statusCallback?.('searching')
|
||||
if (!activeSession.started) {
|
||||
activeSession.subscription.perform('start')
|
||||
activeSession.started = true
|
||||
}
|
||||
} else {
|
||||
statusCallback?.('connecting')
|
||||
}
|
||||
|
||||
const succeed = (match) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
return {
|
||||
promise: activeSession.promise,
|
||||
cancel: activeSession.cancel,
|
||||
}
|
||||
}
|
||||
|
||||
const session = {
|
||||
consumer,
|
||||
statusCallback,
|
||||
isSubscribed: false,
|
||||
cancelled: false,
|
||||
settled: false,
|
||||
started: false,
|
||||
subscription: null,
|
||||
connectTimeoutId: null,
|
||||
disconnectTimeoutId: null,
|
||||
promise: null,
|
||||
cancel: null,
|
||||
}
|
||||
activeSession = session
|
||||
|
||||
session.promise = new Promise((resolve, reject) => {
|
||||
function cleanup() {
|
||||
if (session.connectTimeoutId) {
|
||||
clearTimeout(session.connectTimeoutId)
|
||||
session.connectTimeoutId = null
|
||||
}
|
||||
if (session.disconnectTimeoutId) {
|
||||
clearTimeout(session.disconnectTimeoutId)
|
||||
session.disconnectTimeoutId = null
|
||||
}
|
||||
if (activeSession === session) {
|
||||
activeSession = null
|
||||
}
|
||||
if (session.subscription) {
|
||||
try {
|
||||
session.subscription.unsubscribe()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
session.subscription = null
|
||||
}
|
||||
}
|
||||
|
||||
function succeed(match) {
|
||||
if (session.settled) return
|
||||
session.settled = true
|
||||
cleanup()
|
||||
resolve(match)
|
||||
}
|
||||
|
||||
onStatus('connecting')
|
||||
|
||||
ws = new WebSocket(wsUrl(`/cable?token=${encodeURIComponent(token)}`))
|
||||
let subscribed = false
|
||||
|
||||
ws.onopen = () => {
|
||||
send({ command: 'subscribe', identifier: CHANNEL_IDENTIFIER })
|
||||
function fail(err) {
|
||||
if (session.settled) return
|
||||
session.settled = true
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(event.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
session.statusCallback?.('connecting')
|
||||
|
||||
if (msg.type === 'confirm_subscription') {
|
||||
subscribed = true
|
||||
onStatus('searching')
|
||||
send({
|
||||
command: 'message',
|
||||
identifier: CHANNEL_IDENTIFIER,
|
||||
data: JSON.stringify({ action: 'start' }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'reject_subscription') {
|
||||
fail(new MatchmakingError('rejected', 'Не удалось подписаться на поиск соперника'))
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'ping' || msg.type === 'welcome') return
|
||||
|
||||
const message = msg.message
|
||||
if (!message || typeof message !== 'object') return
|
||||
|
||||
if (message.type === 'matched') {
|
||||
succeed({
|
||||
matchId: message.match_id,
|
||||
gameToken: message.game_token,
|
||||
gameServerUrl: message.game_server,
|
||||
players: message.players,
|
||||
})
|
||||
} else if (message.type === 'timeout') {
|
||||
fail(new MatchmakingError('timeout', 'Соперник не найден'))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!settled) fail(new MatchmakingError('connection', 'Сетевая ошибка'))
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!settled) {
|
||||
const err = new MatchmakingError('connection', 'Соединение прервано')
|
||||
err.closeCode = event.code
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Подписка не подтверждена — защита от «зависшего» соединения
|
||||
setTimeout(() => {
|
||||
if (!settled && !subscribed) {
|
||||
// Защита от «зависшего» подключения / подписки
|
||||
session.connectTimeoutId = setTimeout(() => {
|
||||
if (!session.settled && !session.started) {
|
||||
fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу'))
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
try {
|
||||
session.subscription = consumer.subscriptions.create(
|
||||
{ channel: 'MatchmakingChannel' },
|
||||
{
|
||||
connected() {
|
||||
session.isSubscribed = true
|
||||
|
||||
// Если поиск был отменен ДО подтверждения подписки (отложенная отписка)
|
||||
if (session.cancelled) {
|
||||
try {
|
||||
this.perform('stop')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
if (session.settled) return
|
||||
|
||||
if (session.connectTimeoutId) {
|
||||
clearTimeout(session.connectTimeoutId)
|
||||
session.connectTimeoutId = null
|
||||
}
|
||||
if (session.disconnectTimeoutId) {
|
||||
clearTimeout(session.disconnectTimeoutId)
|
||||
session.disconnectTimeoutId = null
|
||||
}
|
||||
|
||||
session.statusCallback?.('searching')
|
||||
this.perform('start')
|
||||
session.started = true
|
||||
},
|
||||
|
||||
rejected() {
|
||||
fail(new MatchmakingError('rejected', 'Не удалось подписаться на поиск соперника'))
|
||||
},
|
||||
|
||||
disconnected(data) {
|
||||
session.isSubscribed = false
|
||||
if (session.settled) return
|
||||
|
||||
// Если ActionCable пытается восстановить связь
|
||||
if (data?.willAttemptReconnect) {
|
||||
session.statusCallback?.('connecting')
|
||||
if (!session.disconnectTimeoutId) {
|
||||
session.disconnectTimeoutId = setTimeout(() => {
|
||||
if (!session.settled) {
|
||||
fail(new MatchmakingError('connection', 'Соединение прервано'))
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
} else {
|
||||
fail(new MatchmakingError('connection', 'Соединение прервано'))
|
||||
}
|
||||
},
|
||||
|
||||
received(message) {
|
||||
if (session.settled || session.cancelled) return
|
||||
if (!message || typeof message !== 'object') return
|
||||
|
||||
if (message.type === 'matched') {
|
||||
succeed({
|
||||
matchId: message.match_id,
|
||||
gameToken: message.game_token,
|
||||
gameServerUrl: message.game_server,
|
||||
players: message.players,
|
||||
})
|
||||
} else if (message.type === 'timeout') {
|
||||
fail(new MatchmakingError('timeout', 'Соперник не найден'))
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
fail(new MatchmakingError('connection', err.message || 'Ошибка подключения'))
|
||||
}
|
||||
})
|
||||
|
||||
function cleanup() {
|
||||
if (ws) {
|
||||
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null
|
||||
ws.close()
|
||||
ws = null
|
||||
session.cancel = function cancel() {
|
||||
if (session.settled) return
|
||||
session.cancelled = true
|
||||
|
||||
if (session.connectTimeoutId) {
|
||||
clearTimeout(session.connectTimeoutId)
|
||||
session.connectTimeoutId = null
|
||||
}
|
||||
if (session.disconnectTimeoutId) {
|
||||
clearTimeout(session.disconnectTimeoutId)
|
||||
session.disconnectTimeoutId = null
|
||||
}
|
||||
|
||||
// Если сервер уже подтвердил подписку — сразу отправляем stop и отписываемся
|
||||
if (session.isSubscribed && session.subscription) {
|
||||
try {
|
||||
if (session.started) {
|
||||
session.subscription.perform('stop')
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
session.subscription.unsubscribe()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
session.subscription = null
|
||||
if (activeSession === session) {
|
||||
activeSession = null
|
||||
}
|
||||
}
|
||||
// Если подписка ещё не подтверждена (isSubscribed === false),
|
||||
// мы НЕ шлем немедленный unsubscribe, предотвращая гонку на сервере.
|
||||
// Как только сервер пришлет подтверждение, сработает connected()
|
||||
// и выполнит отложенный unsubscribe.
|
||||
}
|
||||
|
||||
/** Явная отмена поиска: команда `stop` + закрытие сокета. */
|
||||
function cancel() {
|
||||
if (settled) return
|
||||
settled = true
|
||||
send({
|
||||
command: 'message',
|
||||
identifier: CHANNEL_IDENTIFIER,
|
||||
data: JSON.stringify({ action: 'stop' }),
|
||||
})
|
||||
cleanup()
|
||||
}
|
||||
|
||||
return { promise, cancel }
|
||||
return { promise: session.promise, cancel: session.cancel }
|
||||
}
|
||||
|
||||
+62
-12
@@ -27,12 +27,13 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
||||
|
||||
Все сообщения — JSON-массивы, первый элемент — числовой опкод.
|
||||
|
||||
| Опкод | Направление | Название | Формат |
|
||||
|-------|-------------|----------|--------|
|
||||
| 1 | C -> S | JOIN | `[1, "<game_token>", "<user_token>"]` |
|
||||
| 2 | S -> C | END | `[2, <seq>, <code>, "<reason>"]` |
|
||||
| 3 | S -> C | STATE | `[3, <seq>, <state_code>]` |
|
||||
| 4 | C <-> S | ACK | `[4, <seq>]` |
|
||||
| Опкод | Направление | Название | Формат |
|
||||
|-------|-------------|-------------|--------|
|
||||
| 1 | C -> S | JOIN | `[1, "<game_token>", "<user_token>"]` |
|
||||
| 2 | S -> C | END | `[2, <seq>, <code>, "<reason>"]` |
|
||||
| 3 | S -> C | STATE | `[3, <seq>, <state_code>]` |
|
||||
| 4 | C <-> S | ACK | `[4, <seq>]` |
|
||||
| 5 | C -> S | PLACE_SHIPS | `[5, ship0_orientation, ship0_x, ship0_y, ...]` |
|
||||
|
||||
### JOIN (1)
|
||||
|
||||
@@ -85,8 +86,12 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
||||
| 4 | ваш ход |
|
||||
| 5 | ожидайте ход соперника |
|
||||
|
||||
Сейчас рассылается только состояние `1` — при старте партии (когда оба
|
||||
игрока впервые присоединились).
|
||||
Смена состояний:
|
||||
- Состояние `1` рассылается обоим игрокам при старте партии (когда оба игрока впервые присоединились).
|
||||
- При получении расстановки кораблей от одного из игроков партия переходит в состояние ожидания соперника:
|
||||
игрок, сдавший корабли, получает состояние `3` («ожидайте готовности соперника»), а его противник — состояние `2` («соперник готов»).
|
||||
- Когда расстановку сдаёт второй игрок, игра переходит в фазу ходов: игрок, завершивший расстановку первым,
|
||||
получает состояние `4` («ваш ход»), а второй игрок — состояние `5` («ожидайте ход соперника»).
|
||||
|
||||
### ACK (4)
|
||||
|
||||
@@ -103,6 +108,51 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
||||
Направление S->C зарезервировано для подтверждения будущих сообщений
|
||||
клиента (у которых пока нет номеров).
|
||||
|
||||
### PLACE_SHIPS (5)
|
||||
|
||||
Сообщение расстановки кораблей от клиента серверу. Принимается только от игрока,
|
||||
от которого ожидается расстановка в текущем состоянии партии (`StatePlacingShips` для
|
||||
обоих игроков; `StatePlayer1Ready` — только для игрока 2; `StatePlayer2Ready` — только
|
||||
для игрока 1). Повторные сообщения от уже расставившегося игрока или сообщения в фазе
|
||||
ходов отклоняются.
|
||||
|
||||
Формат сообщения:
|
||||
```json
|
||||
[5, ship0_orientation, ship0_x, ship0_y, ship1_orientation, ship1_x, ship1_y, ..., ship9_orientation, ship9_x, ship9_y]
|
||||
```
|
||||
|
||||
Сообщение содержит ровно 30 аргументов — по 3 аргумента на каждый из 10 кораблей
|
||||
в порядке возрастания размера:
|
||||
- 4 однопалубных корабля (размер 1, индексы 0..3)
|
||||
- 3 двухпалубных корабля (размер 2, индексы 4..6)
|
||||
- 2 трёхпалубных корабля (размер 3, индексы 7..8)
|
||||
- 1 четырёхпалубный корабль (размер 4, индекс 9)
|
||||
|
||||
Каждый корабль описывается 3-кортежем `(orientation, x, y)`:
|
||||
- `orientation` (boolean): `false` — горизонтальная ориентация (корабль продолжается
|
||||
вправо по оси X от начальной клетки `(x, y)`: клетки `(x + i, y)` для `i = 0..size-1`),
|
||||
`true` — вертикальная ориентация (корабль продолжается вниз по оси Y от начальной
|
||||
клетки `(x, y)`: клетки `(x, y + i)` для `i = 0..size-1`). Для однопалубных кораблей
|
||||
допустимо любое булево значение.
|
||||
- `x` (integer): координата X начала корабля (`0 <= x <= 9`).
|
||||
- `y` (integer): координата Y начала корабля (`0 <= y <= 9`).
|
||||
|
||||
Правила валидации расстановки:
|
||||
1. Количество кораблей строго 10, размеры строго соответствуют схеме 4x1, 3x2, 2x3, 1x4.
|
||||
2. Все клетки каждого корабля должны лежать строго в пределах игрового поля 10x10
|
||||
(`0 <= x < 10` и `0 <= y < 10`).
|
||||
3. Корабли не должны перекрываться и не должны соприкасаться сторонами или углами:
|
||||
между любыми двумя различными кораблями во всех направлениях (включая диагонали)
|
||||
всегда должен оставаться зазор минимум в 1 клетку.
|
||||
|
||||
При получении валидного сообщения сервер сохраняет расстановку в структуре матча
|
||||
(в виде сетки 10x10 с идентификаторами кораблей и списка кораблей) и переводит партию
|
||||
в следующее состояние:
|
||||
- Если первым расстановку завершил игрок 1:
|
||||
`StatePlacingShips` (1) -> `StatePlayer1Ready` (2) -> `StatePlayer1Turn` (4).
|
||||
- Если первым расстановку завершил игрок 2:
|
||||
`StatePlacingShips` (1) -> `StatePlayer2Ready` (3) -> `StatePlayer2Turn` (5).
|
||||
|
||||
## Коды закрытия соединения
|
||||
|
||||
| Код | Значение |
|
||||
@@ -166,13 +216,13 @@ internal/ws/ — websocket-эндпоинт: апгрейд, JOIN,
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
Покрыты: валидация токенов, разбор/сборка сообщений, сценарии менеджера
|
||||
(ожидание, старт, таймаут, повторный вход, замена соединения, ACK) и
|
||||
сквозные интеграционные тесты через настоящий websocket.
|
||||
Покрыты: валидация токенов, разбор/сборка сообщений (включая расстановку кораблей),
|
||||
сценарии менеджера (ожидание, старт, таймаут, повторный вход, замена соединения, ACK,
|
||||
расстановка кораблей с переходами состояний) и сквозные интеграционные тесты через настоящий websocket.
|
||||
|
||||
## TODO (следующие этапы)
|
||||
|
||||
- игровая логика (расстановка кораблей, ходы) и остальные состояния;
|
||||
- игровая логика стрельбы/ходов (расстановка кораблей и переходы к фазе ходов уже реализованы);
|
||||
- завершение партии по итогам игры с персонифицированными кодами
|
||||
победы/поражения;
|
||||
- репорт итогов партии на main-server (реестр завершённых партий уже
|
||||
|
||||
@@ -17,10 +17,34 @@ import (
|
||||
// that has already ended. The stored END message (if any) is replayed to the
|
||||
// connection before Join returns.
|
||||
var (
|
||||
ErrMatchFinished = errors.New("match already finished")
|
||||
ErrInvalidSeq = errors.New("invalid seq")
|
||||
ErrMatchFinished = errors.New("match already finished")
|
||||
ErrInvalidSeq = errors.New("invalid seq")
|
||||
ErrMatchNotStarted = errors.New("match not started")
|
||||
ErrUnexpectedState = errors.New("unexpected state for placing ships")
|
||||
ErrShipsAlreadyPlaced = errors.New("ships already placed")
|
||||
ErrInvalidSlot = errors.New("invalid slot index")
|
||||
)
|
||||
|
||||
// BoardSize is the width and height of the Sea Battle board.
|
||||
const BoardSize = protocol.BoardSize
|
||||
|
||||
// Board represents a 10x10 Sea Battle grid.
|
||||
// 0 indicates water/empty cell.
|
||||
// 1..10 indicates a ship (ship index 0..9 + 1).
|
||||
type Board [BoardSize][BoardSize]int8
|
||||
|
||||
// NewBoard constructs a Board from validated ships.
|
||||
func NewBoard(ships []protocol.Ship) Board {
|
||||
var b Board
|
||||
for i, ship := range ships {
|
||||
id := int8(i + 1)
|
||||
for _, cell := range ship.Cells() {
|
||||
b[cell.Y][cell.X] = id
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Server-side match states.
|
||||
const (
|
||||
StatePlacingShips = 1
|
||||
@@ -74,6 +98,10 @@ type PlayerSlot struct {
|
||||
unacked []queuedMessage
|
||||
lastState int // last state sent to this player, 0 if none
|
||||
everConnected bool // true after the first successful join
|
||||
|
||||
ships []protocol.Ship
|
||||
board Board
|
||||
shipsPlaced bool
|
||||
}
|
||||
|
||||
// Match is the state of a single game of sea battle.
|
||||
@@ -105,6 +133,29 @@ func (m *Match) State() int {
|
||||
return m.state
|
||||
}
|
||||
|
||||
// Ships returns a copy of the ships placed by the player in slotIdx,
|
||||
// or nil if ships have not been placed yet.
|
||||
func (m *Match) Ships(slotIdx int) []protocol.Ship {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if slotIdx < 0 || slotIdx >= len(m.slots) || !m.slots[slotIdx].shipsPlaced {
|
||||
return nil
|
||||
}
|
||||
res := make([]protocol.Ship, len(m.slots[slotIdx].ships))
|
||||
copy(res, m.slots[slotIdx].ships)
|
||||
return res
|
||||
}
|
||||
|
||||
// Board returns the board of the player in slotIdx and whether ships have been placed.
|
||||
func (m *Match) Board(slotIdx int) (Board, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if slotIdx < 0 || slotIdx >= len(m.slots) {
|
||||
return Board{}, false
|
||||
}
|
||||
return m.slots[slotIdx].board, m.slots[slotIdx].shipsPlaced
|
||||
}
|
||||
|
||||
func (m *Match) slotIndex(userID int64) int {
|
||||
switch userID {
|
||||
case m.playerIDs[0]:
|
||||
@@ -291,6 +342,64 @@ func (m *Manager) Ack(match *Match, idx int, seq int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlaceShips validates and stores the player's ship placement, advancing to the next match state.
|
||||
// The state order depends on which player completed the placement first:
|
||||
// - If Player 1 is first: StatePlacingShips -> StatePlayer1Ready -> StatePlayer1Turn
|
||||
// - If Player 2 is first: StatePlacingShips -> StatePlayer2Ready -> StatePlayer2Turn
|
||||
func (m *Manager) PlaceShips(ctx context.Context, match *Match, slotIdx int, ships []protocol.Ship) error {
|
||||
if err := protocol.ValidateShips(ships); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
match.mu.Lock()
|
||||
defer match.mu.Unlock()
|
||||
|
||||
if match.ended {
|
||||
return ErrMatchFinished
|
||||
}
|
||||
if !match.started {
|
||||
return ErrMatchNotStarted
|
||||
}
|
||||
if slotIdx < 0 || slotIdx >= len(match.slots) {
|
||||
return ErrInvalidSlot
|
||||
}
|
||||
|
||||
slot := match.slots[slotIdx]
|
||||
if slot.shipsPlaced {
|
||||
return ErrShipsAlreadyPlaced
|
||||
}
|
||||
|
||||
var nextState int
|
||||
switch match.state {
|
||||
case StatePlacingShips:
|
||||
if slotIdx == 0 {
|
||||
nextState = StatePlayer1Ready
|
||||
} else {
|
||||
nextState = StatePlayer2Ready
|
||||
}
|
||||
case StatePlayer1Ready:
|
||||
if slotIdx != 1 {
|
||||
return ErrUnexpectedState
|
||||
}
|
||||
nextState = StatePlayer1Turn
|
||||
case StatePlayer2Ready:
|
||||
if slotIdx != 0 {
|
||||
return ErrUnexpectedState
|
||||
}
|
||||
nextState = StatePlayer2Turn
|
||||
default:
|
||||
return ErrUnexpectedState
|
||||
}
|
||||
|
||||
slot.ships = make([]protocol.Ship, len(ships))
|
||||
copy(slot.ships, ships)
|
||||
slot.board = NewBoard(ships)
|
||||
slot.shipsPlaced = true
|
||||
|
||||
m.setStateLocked(ctx, match, nextState)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetState moves the match into a new server-side state and notifies both players.
|
||||
func (m *Manager) SetState(ctx context.Context, match *Match, state int) {
|
||||
match.mu.Lock()
|
||||
|
||||
@@ -454,3 +454,193 @@ func mustMatch(t *testing.T, m *Manager, id string) *Match {
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func validTestShips() []protocol.Ship {
|
||||
return []protocol.Ship{
|
||||
// 4 size 1
|
||||
{Orientation: protocol.OrientationHorizontal, X: 9, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationVertical, X: 7, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 5, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 3, Y: 4, Size: 1},
|
||||
// 3 size 2
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 4, Size: 2},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 7, Y: 2, Size: 2},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 4, Y: 2, Size: 2},
|
||||
// 2 size 3
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 2, Size: 3},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 5, Y: 0, Size: 3},
|
||||
// 1 size 4
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 0, Size: 4},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsP1First(t *testing.T) {
|
||||
m := NewManager(time.Minute)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
c1, c2 := &fakeConn{}, &fakeConn{}
|
||||
|
||||
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("join p1: %v", err)
|
||||
}
|
||||
if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil {
|
||||
t.Fatalf("join p2: %v", err)
|
||||
}
|
||||
if match.State() != StatePlacingShips {
|
||||
t.Fatalf("state = %d, want StatePlacingShips", match.State())
|
||||
}
|
||||
|
||||
ships := validTestShips()
|
||||
|
||||
// Player 1 places ships first
|
||||
if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil {
|
||||
t.Fatalf("p1 place ships: %v", err)
|
||||
}
|
||||
if match.State() != StatePlayer1Ready {
|
||||
t.Fatalf("state = %d, want StatePlayer1Ready (2)", match.State())
|
||||
}
|
||||
|
||||
// Verify stored ships and board for p1
|
||||
p1Ships := match.Ships(0)
|
||||
if len(p1Ships) != len(ships) {
|
||||
t.Fatalf("got %d ships, want %d", len(p1Ships), len(ships))
|
||||
}
|
||||
p1Board, placed := match.Board(0)
|
||||
if !placed {
|
||||
t.Fatal("expected p1 board placed to be true")
|
||||
}
|
||||
// Check that ship 9 (size 4 at 0,0 horizontal) is in p1 board with id 10
|
||||
for x := 0; x < 4; x++ {
|
||||
if p1Board[0][x] != 10 {
|
||||
t.Fatalf("p1Board[0][%d] = %d, want 10", x, p1Board[0][x])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify states delivered to p1 and p2
|
||||
p1Msgs, p2Msgs := c1.messages(), c2.messages()
|
||||
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) {
|
||||
t.Fatalf("p1 got state %v, want StateWaitOpponentReady (3)", v)
|
||||
}
|
||||
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) {
|
||||
t.Fatalf("p2 got state %v, want StateOpponentReady (2)", v)
|
||||
}
|
||||
|
||||
// Player 1 cannot place ships again
|
||||
if err := m.PlaceShips(context.Background(), match, 0, ships); err == nil {
|
||||
t.Fatal("expected error when player 1 places ships again")
|
||||
}
|
||||
|
||||
// Player 2 places ships
|
||||
if err := m.PlaceShips(context.Background(), match, 1, ships); err != nil {
|
||||
t.Fatalf("p2 place ships: %v", err)
|
||||
}
|
||||
if match.State() != StatePlayer1Turn {
|
||||
t.Fatalf("state = %d, want StatePlayer1Turn (4)", match.State())
|
||||
}
|
||||
|
||||
// Verify states delivered to p1 and p2 after game starts
|
||||
p1Msgs, p2Msgs = c1.messages(), c2.messages()
|
||||
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateYourTurn) {
|
||||
t.Fatalf("p1 got state %v, want StateYourTurn (4)", v)
|
||||
}
|
||||
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||
t.Fatalf("p2 got state %v, want StateWaitOpponentTurn (5)", v)
|
||||
}
|
||||
|
||||
// Subsequent PlaceShips calls are rejected
|
||||
if err := m.PlaceShips(context.Background(), match, 1, ships); err == nil {
|
||||
t.Fatal("expected error when match is in turn phase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsP2First(t *testing.T) {
|
||||
m := NewManager(time.Minute)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
c1, c2 := &fakeConn{}, &fakeConn{}
|
||||
|
||||
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("join p1: %v", err)
|
||||
}
|
||||
if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil {
|
||||
t.Fatalf("join p2: %v", err)
|
||||
}
|
||||
|
||||
ships := validTestShips()
|
||||
|
||||
// Player 2 places ships first
|
||||
if err := m.PlaceShips(context.Background(), match, 1, ships); err != nil {
|
||||
t.Fatalf("p2 place ships: %v", err)
|
||||
}
|
||||
if match.State() != StatePlayer2Ready {
|
||||
t.Fatalf("state = %d, want StatePlayer2Ready (3)", match.State())
|
||||
}
|
||||
|
||||
// Verify states delivered to p1 and p2
|
||||
p1Msgs, p2Msgs := c1.messages(), c2.messages()
|
||||
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) {
|
||||
t.Fatalf("p1 got state %v, want StateOpponentReady (2)", v)
|
||||
}
|
||||
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) {
|
||||
t.Fatalf("p2 got state %v, want StateWaitOpponentReady (3)", v)
|
||||
}
|
||||
|
||||
// Player 1 places ships
|
||||
if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil {
|
||||
t.Fatalf("p1 place ships: %v", err)
|
||||
}
|
||||
if match.State() != StatePlayer2Turn {
|
||||
t.Fatalf("state = %d, want StatePlayer2Turn (5)", match.State())
|
||||
}
|
||||
|
||||
// Player 2 finished first, so Player 2 gets first turn!
|
||||
p1Msgs, p2Msgs = c1.messages(), c2.messages()
|
||||
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||
t.Fatalf("p1 got state %v, want StateWaitOpponentTurn (5)", v)
|
||||
}
|
||||
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateYourTurn) {
|
||||
t.Fatalf("p2 got state %v, want StateYourTurn (4)", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsValidationFailure(t *testing.T) {
|
||||
m := NewManager(time.Minute)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
c1, c2 := &fakeConn{}, &fakeConn{}
|
||||
|
||||
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("join p1: %v", err)
|
||||
}
|
||||
if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil {
|
||||
t.Fatalf("join p2: %v", err)
|
||||
}
|
||||
|
||||
invalidShips := validTestShips()
|
||||
invalidShips[0].X = invalidShips[1].X // overlapping ships
|
||||
|
||||
if err := m.PlaceShips(context.Background(), match, 0, invalidShips); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if match.State() != StatePlacingShips {
|
||||
t.Fatalf("state changed to %d on error, want StatePlacingShips", match.State())
|
||||
}
|
||||
if match.Ships(0) != nil {
|
||||
t.Fatal("expected nil ships for p1 after failed placement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsBeforeMatchStarts(t *testing.T) {
|
||||
m := NewManager(time.Minute)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
c1 := &fakeConn{}
|
||||
|
||||
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("join p1: %v", err)
|
||||
}
|
||||
|
||||
if err := m.PlaceShips(context.Background(), match, 0, validTestShips()); err != ErrMatchNotStarted {
|
||||
t.Fatalf("got err = %v, want ErrMatchNotStarted", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
//
|
||||
// Opcodes:
|
||||
//
|
||||
// 1 C->S JOIN [1, "<game_token>", "<user_token>"]
|
||||
// 2 S->C END [2, <seq>, <code>, "<reason>"]
|
||||
// 3 S->C STATE [3, <seq>, <state_code>]
|
||||
// 4 C<->S ACK [4, <seq>]
|
||||
// 1 C->S JOIN [1, "<game_token>", "<user_token>"]
|
||||
// 2 S->C END [2, <seq>, <code>, "<reason>"]
|
||||
// 3 S->C STATE [3, <seq>, <state_code>]
|
||||
// 4 C<->S ACK [4, <seq>]
|
||||
// 5 C->S PLACE_SHIPS [5, ship0_orientation, ship0_x, ship0_y, ...]
|
||||
//
|
||||
// Every state-changing server->client message (END, STATE) carries a
|
||||
// per-player monotonically increasing sequence number. The client confirms
|
||||
@@ -30,12 +31,54 @@ import (
|
||||
|
||||
// Opcodes.
|
||||
const (
|
||||
OpJoin = 1 // C->S: join a match
|
||||
OpEnd = 2 // S->C: match ended
|
||||
OpState = 3 // S->C: game state transition
|
||||
OpAck = 4 // C<->S: delivery acknowledgement
|
||||
OpJoin = 1 // C->S: join a match
|
||||
OpEnd = 2 // S->C: match ended
|
||||
OpState = 3 // S->C: game state transition
|
||||
OpAck = 4 // C<->S: delivery acknowledgement
|
||||
OpPlaceShips = 5 // C->S: ship placement
|
||||
)
|
||||
|
||||
// Board and ship configuration constants.
|
||||
const (
|
||||
BoardSize = 10
|
||||
ShipCount = 10
|
||||
ShipArgs = 30 // 10 ships * 3 args each
|
||||
|
||||
OrientationHorizontal = false
|
||||
OrientationVertical = true
|
||||
)
|
||||
|
||||
// ShipSizes defines the expected sizes of the 10 ships in order:
|
||||
// 4 ships of size 1, 3 ships of size 2, 2 ships of size 3, 1 ship of size 4.
|
||||
var ShipSizes = [ShipCount]int{1, 1, 1, 1, 2, 2, 2, 3, 3, 4}
|
||||
|
||||
// Cell represents coordinates on the 10x10 board.
|
||||
type Cell struct {
|
||||
X int
|
||||
Y int
|
||||
}
|
||||
|
||||
// Ship represents a single ship placement on the board.
|
||||
type Ship struct {
|
||||
Orientation bool // false: horizontal, true: vertical
|
||||
X int // column (0-9)
|
||||
Y int // row (0-9)
|
||||
Size int // length (1-4)
|
||||
}
|
||||
|
||||
// Cells returns all board cells occupied by the ship.
|
||||
func (s Ship) Cells() []Cell {
|
||||
cells := make([]Cell, s.Size)
|
||||
for i := 0; i < s.Size; i++ {
|
||||
if s.Orientation {
|
||||
cells[i] = Cell{X: s.X, Y: s.Y + i}
|
||||
} else {
|
||||
cells[i] = Cell{X: s.X + i, Y: s.Y}
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
// Game state codes. States are always sent from the receiver's point of
|
||||
// view: a player never learns whether they are "player 1" or "player 2".
|
||||
const (
|
||||
@@ -103,6 +146,98 @@ func ParseAck(args []json.RawMessage) (seq int64, err error) {
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
// ParsePlaceShips extracts the 10 ships from PLACE_SHIPS arguments.
|
||||
// args must contain 30 elements: (orientation, x, y) for each of the 10 ships.
|
||||
// The ships correspond to increasing sizes: 4 size 1, 3 size 2, 2 size 3, 1 size 4.
|
||||
func ParsePlaceShips(args []json.RawMessage) ([]Ship, error) {
|
||||
if len(args) != ShipArgs {
|
||||
return nil, fmt.Errorf("PLACE_SHIPS expects %d arguments, got %d", ShipArgs, len(args))
|
||||
}
|
||||
|
||||
ships := make([]Ship, ShipCount)
|
||||
for i := 0; i < ShipCount; i++ {
|
||||
base := i * 3
|
||||
|
||||
var orientation bool
|
||||
if err := json.Unmarshal(args[base], &orientation); err != nil {
|
||||
return nil, fmt.Errorf("ship %d orientation is not a boolean: %w", i, err)
|
||||
}
|
||||
|
||||
var x int
|
||||
if err := json.Unmarshal(args[base+1], &x); err != nil {
|
||||
return nil, fmt.Errorf("ship %d x is not an integer: %w", i, err)
|
||||
}
|
||||
|
||||
var y int
|
||||
if err := json.Unmarshal(args[base+2], &y); err != nil {
|
||||
return nil, fmt.Errorf("ship %d y is not an integer: %w", i, err)
|
||||
}
|
||||
|
||||
ships[i] = Ship{
|
||||
Orientation: orientation,
|
||||
X: x,
|
||||
Y: y,
|
||||
Size: ShipSizes[i],
|
||||
}
|
||||
}
|
||||
|
||||
return ships, nil
|
||||
}
|
||||
|
||||
// ValidateShips validates the ship placement on the 10x10 board:
|
||||
// - exactly 10 ships with sizes [1, 1, 1, 1, 2, 2, 2, 3, 3, 4]
|
||||
// - all ship cells are within bounds (0 <= x < 10, 0 <= y < 10)
|
||||
// - no ships overlap or touch each other in any direction (including diagonals),
|
||||
// ensuring a gap of at least 1 cell between any two ships.
|
||||
func ValidateShips(ships []Ship) error {
|
||||
if len(ships) != ShipCount {
|
||||
return fmt.Errorf("expected %d ships, got %d", ShipCount, len(ships))
|
||||
}
|
||||
|
||||
var grid [BoardSize][BoardSize]int
|
||||
for y := 0; y < BoardSize; y++ {
|
||||
for x := 0; x < BoardSize; x++ {
|
||||
grid[y][x] = -1
|
||||
}
|
||||
}
|
||||
|
||||
for i, ship := range ships {
|
||||
expectedSize := ShipSizes[i]
|
||||
if ship.Size != expectedSize {
|
||||
return fmt.Errorf("ship %d: expected size %d, got %d", i, expectedSize, ship.Size)
|
||||
}
|
||||
|
||||
cells := ship.Cells()
|
||||
// 1. Bounds check
|
||||
for _, c := range cells {
|
||||
if c.X < 0 || c.X >= BoardSize || c.Y < 0 || c.Y >= BoardSize {
|
||||
return fmt.Errorf("ship %d out of bounds at (%d, %d)", i, c.X, c.Y)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collision / gap check against already placed ships
|
||||
for _, c := range cells {
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
nx, ny := c.X+dx, c.Y+dy
|
||||
if nx >= 0 && nx < BoardSize && ny >= 0 && ny < BoardSize {
|
||||
if other := grid[ny][nx]; other != -1 {
|
||||
return fmt.Errorf("ship %d at (%d, %d) touches or overlaps with ship %d at (%d, %d)", i, c.X, c.Y, other, nx, ny)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Place ship on the grid
|
||||
for _, c := range cells {
|
||||
grid[c.Y][c.X] = i
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func build(v []any) []byte {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
@@ -127,3 +262,16 @@ func BuildState(seq int64, state int) []byte {
|
||||
func BuildAck(seq int64) []byte {
|
||||
return build([]any{OpAck, seq})
|
||||
}
|
||||
|
||||
// BuildPlaceShips builds a PLACE_SHIPS message from a slice of ships:
|
||||
// [5, ship0_orientation, ship0_x, ship0_y, ship1_orientation, ship1_x, ship1_y, ...]
|
||||
func BuildPlaceShips(ships []Ship) []byte {
|
||||
v := make([]any, 1+len(ships)*3)
|
||||
v[0] = OpPlaceShips
|
||||
for i, s := range ships {
|
||||
v[1+i*3] = s.Orientation
|
||||
v[1+i*3+1] = s.X
|
||||
v[1+i*3+2] = s.Y
|
||||
}
|
||||
return build(v)
|
||||
}
|
||||
|
||||
@@ -69,3 +69,196 @@ func TestParseAck(t *testing.T) {
|
||||
t.Fatalf("seq = %d, want 12", seq)
|
||||
}
|
||||
}
|
||||
|
||||
func validTestShips() []Ship {
|
||||
return []Ship{
|
||||
// 4 size 1
|
||||
{Orientation: OrientationHorizontal, X: 9, Y: 4, Size: 1},
|
||||
{Orientation: OrientationVertical, X: 7, Y: 4, Size: 1},
|
||||
{Orientation: OrientationHorizontal, X: 5, Y: 4, Size: 1},
|
||||
{Orientation: OrientationHorizontal, X: 3, Y: 4, Size: 1},
|
||||
// 3 size 2
|
||||
{Orientation: OrientationHorizontal, X: 0, Y: 4, Size: 2},
|
||||
{Orientation: OrientationHorizontal, X: 7, Y: 2, Size: 2},
|
||||
{Orientation: OrientationHorizontal, X: 4, Y: 2, Size: 2},
|
||||
// 2 size 3
|
||||
{Orientation: OrientationHorizontal, X: 0, Y: 2, Size: 3},
|
||||
{Orientation: OrientationHorizontal, X: 5, Y: 0, Size: 3},
|
||||
// 1 size 4
|
||||
{Orientation: OrientationHorizontal, X: 0, Y: 0, Size: 4},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsRoundtrip(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
data := BuildPlaceShips(ships)
|
||||
|
||||
opcode, args, err := ParseMessage(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMessage: %v", err)
|
||||
}
|
||||
if opcode != OpPlaceShips {
|
||||
t.Fatalf("opcode = %d, want %d", opcode, OpPlaceShips)
|
||||
}
|
||||
|
||||
parsed, err := ParsePlaceShips(args)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePlaceShips: %v", err)
|
||||
}
|
||||
if len(parsed) != len(ships) {
|
||||
t.Fatalf("got %d ships, want %d", len(parsed), len(ships))
|
||||
}
|
||||
for i := range ships {
|
||||
if parsed[i] != ships[i] {
|
||||
t.Fatalf("ship %d = %+v, want %+v", i, parsed[i], ships[i])
|
||||
}
|
||||
}
|
||||
|
||||
if err := ValidateShips(parsed); err != nil {
|
||||
t.Fatalf("ValidateShips: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlaceShipsErrors(t *testing.T) {
|
||||
t.Run("wrong argument count", func(t *testing.T) {
|
||||
args := make([]json.RawMessage, 29)
|
||||
for i := range args {
|
||||
args[i] = json.RawMessage(`0`)
|
||||
}
|
||||
if _, err := ParsePlaceShips(args); err == nil {
|
||||
t.Fatal("expected error for 29 arguments")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("orientation not boolean", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
raw := make([]any, 31)
|
||||
raw[0] = OpPlaceShips
|
||||
for i, s := range ships {
|
||||
raw[1+i*3] = s.Orientation
|
||||
raw[1+i*3+1] = s.X
|
||||
raw[1+i*3+2] = s.Y
|
||||
}
|
||||
raw[1] = "invalid_orientation" // string instead of bool
|
||||
data, _ := json.Marshal(raw)
|
||||
_, args, _ := ParseMessage(data)
|
||||
if _, err := ParsePlaceShips(args); err == nil {
|
||||
t.Fatal("expected error for non-boolean orientation")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("x not integer", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
raw := make([]any, 31)
|
||||
raw[0] = OpPlaceShips
|
||||
for i, s := range ships {
|
||||
raw[1+i*3] = s.Orientation
|
||||
raw[1+i*3+1] = s.X
|
||||
raw[1+i*3+2] = s.Y
|
||||
}
|
||||
raw[2] = 1.5 // float instead of integer
|
||||
data, _ := json.Marshal(raw)
|
||||
_, args, _ := ParseMessage(data)
|
||||
if _, err := ParsePlaceShips(args); err == nil {
|
||||
t.Fatal("expected error for float x")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("y not integer", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
raw := make([]any, 31)
|
||||
raw[0] = OpPlaceShips
|
||||
for i, s := range ships {
|
||||
raw[1+i*3] = s.Orientation
|
||||
raw[1+i*3+1] = s.X
|
||||
raw[1+i*3+2] = s.Y
|
||||
}
|
||||
raw[3] = "zero" // string instead of integer
|
||||
data, _ := json.Marshal(raw)
|
||||
_, args, _ := ParseMessage(data)
|
||||
if _, err := ParsePlaceShips(args); err == nil {
|
||||
t.Fatal("expected error for string y")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateShips(t *testing.T) {
|
||||
t.Run("valid placement passes", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
if err := ValidateShips(ships); err != nil {
|
||||
t.Fatalf("unexpected validation error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong ship count", func(t *testing.T) {
|
||||
ships := validTestShips()[:9]
|
||||
if err := ValidateShips(ships); err == nil {
|
||||
t.Fatal("expected error for 9 ships")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong ship size", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
ships[0].Size = 2 // ship 0 must have size 1
|
||||
if err := ValidateShips(ships); err == nil {
|
||||
t.Fatal("expected error for wrong ship size")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative coordinate", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
ships[0].X = -1
|
||||
if err := ValidateShips(ships); err == nil {
|
||||
t.Fatal("expected error for negative X")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out of bounds horizontal", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
// Ship 9 has size 4; at X=7, cells are 7, 8, 9, 10
|
||||
ships[9].X = 7
|
||||
if err := ValidateShips(ships); err == nil {
|
||||
t.Fatal("expected error for horizontal ship sticking out of bounds")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out of bounds vertical", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
// Ship 9 has size 4, vertical at Y=7: cells are 7, 8, 9, 10
|
||||
ships[9].Orientation = OrientationVertical
|
||||
ships[9].Y = 7
|
||||
if err := ValidateShips(ships); err == nil {
|
||||
t.Fatal("expected error for vertical ship sticking out of bounds")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overlapping ships", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
// Place ship 0 at exact same cell as ship 1
|
||||
ships[0].X = ships[1].X
|
||||
ships[0].Y = ships[1].Y
|
||||
if err := ValidateShips(ships); err == nil {
|
||||
t.Fatal("expected error for overlapping ships")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("orthogonal touching", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
// Ship 0 is at (9, 4), place ship 1 at (9, 5) -> touches orthogonally
|
||||
ships[1].X = 9
|
||||
ships[1].Y = 5
|
||||
if err := ValidateShips(ships); err == nil {
|
||||
t.Fatal("expected error for orthogonally touching ships")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("diagonal touching", func(t *testing.T) {
|
||||
ships := validTestShips()
|
||||
// Ship 0 is at (9, 4), place ship 1 at (8, 5) -> touches diagonally
|
||||
ships[1].X = 8
|
||||
ships[1].Y = 5
|
||||
if err := ValidateShips(ships); err == nil {
|
||||
t.Fatal("expected error for diagonally touching ships")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -277,6 +277,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.log.Debug("invalid ACK seq", "match", match.ID, "user", uc.UserID, "seq", seq, "err", err)
|
||||
continue
|
||||
}
|
||||
case protocol.OpPlaceShips:
|
||||
ships, err := protocol.ParsePlaceShips(args)
|
||||
if err != nil {
|
||||
h.log.Debug("malformed PlaceShips", "match", match.ID, "user", uc.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
if err := protocol.ValidateShips(ships); err != nil {
|
||||
h.log.Debug("invalid ship placement", "match", match.ID, "user", uc.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
if err := h.Manager.PlaceShips(ctx, match, slotIdx, ships); err != nil {
|
||||
h.log.Debug("place ships rejected", "match", match.ID, "user", uc.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
default:
|
||||
// Unknown opcodes are ignored for forward compatibility.
|
||||
h.log.Debug("unknown opcode", "match", match.ID, "user", uc.UserID, "opcode", opcode)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/ruby-sea-battle/game-server/internal/auth"
|
||||
"github.com/ruby-sea-battle/game-server/internal/game"
|
||||
"github.com/ruby-sea-battle/game-server/internal/protocol"
|
||||
"github.com/ruby-sea-battle/game-server/internal/ws"
|
||||
)
|
||||
|
||||
@@ -55,10 +57,16 @@ func userToken(t *testing.T, userID int64) string {
|
||||
func startServerWithManager(t *testing.T, joinTimeout time.Duration) (string, *game.Manager) {
|
||||
t.Helper()
|
||||
manager := game.NewManager(joinTimeout)
|
||||
log := slog.New(slog.NewTextHandler(testWriter{t}, nil))
|
||||
tw := &testWriter{t: t}
|
||||
log := slog.New(slog.NewTextHandler(tw, nil))
|
||||
handler := ws.NewHandler(manager, secret, publicAddr, []string{"*"}, log)
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
t.Cleanup(func() {
|
||||
srv.Close()
|
||||
tw.mu.Lock()
|
||||
tw.done = true
|
||||
tw.mu.Unlock()
|
||||
})
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http"), manager
|
||||
}
|
||||
|
||||
@@ -67,10 +75,18 @@ func startServer(t *testing.T, joinTimeout time.Duration) (url string) {
|
||||
return u
|
||||
}
|
||||
|
||||
type testWriter struct{ t *testing.T }
|
||||
type testWriter struct {
|
||||
mu sync.Mutex
|
||||
t *testing.T
|
||||
done bool
|
||||
}
|
||||
|
||||
func (w testWriter) Write(p []byte) (int, error) {
|
||||
w.t.Log(strings.TrimSpace(string(p)))
|
||||
func (w *testWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if !w.done {
|
||||
w.t.Log(strings.TrimSpace(string(p)))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
@@ -317,3 +333,99 @@ func TestManagerShutdownClosesConnectionsGoingAway(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validTestShips() []protocol.Ship {
|
||||
return []protocol.Ship{
|
||||
// 4 size 1
|
||||
{Orientation: protocol.OrientationHorizontal, X: 9, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationVertical, X: 7, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 5, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 3, Y: 4, Size: 1},
|
||||
// 3 size 2
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 4, Size: 2},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 7, Y: 2, Size: 2},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 4, Y: 2, Size: 2},
|
||||
// 2 size 3
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 2, Size: 3},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 5, Y: 0, Size: 3},
|
||||
// 1 size 4
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 0, Size: 4},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsOverWebSocket(t *testing.T) {
|
||||
url, mgr := startServerWithManager(t, time.Minute)
|
||||
exp := time.Now().Add(time.Hour)
|
||||
gt := gameToken(t, "m-ws-placeships", 1, 2, publicAddr, exp)
|
||||
|
||||
c1, c2 := dial(t, url), dial(t, url)
|
||||
join(t, c1, gt, userToken(t, 1))
|
||||
join(t, c2, gt, userToken(t, 2))
|
||||
|
||||
// Both receive StatePlacingShips (seq 1)
|
||||
v1 := readMsg(t, c1)
|
||||
if v1[0] != float64(protocol.OpState) || v1[1] != float64(1) || v1[2] != float64(protocol.StatePlacingShips) {
|
||||
t.Fatalf("c1 unexpected initial state: %v", v1)
|
||||
}
|
||||
v2 := readMsg(t, c2)
|
||||
if v2[0] != float64(protocol.OpState) || v2[1] != float64(1) || v2[2] != float64(protocol.StatePlacingShips) {
|
||||
t.Fatalf("c2 unexpected initial state: %v", v2)
|
||||
}
|
||||
|
||||
// ACK seq 1
|
||||
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1))
|
||||
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1))
|
||||
|
||||
// Player 1 sends PlaceShips
|
||||
ships := validTestShips()
|
||||
placeMsg := protocol.BuildPlaceShips(ships)
|
||||
if err := c1.Write(context.Background(), websocket.MessageText, placeMsg); err != nil {
|
||||
t.Fatalf("write PlaceShips c1: %v", err)
|
||||
}
|
||||
|
||||
// c1 gets StateWaitOpponentReady (3), c2 gets StateOpponentReady (2)
|
||||
v1 = readMsg(t, c1)
|
||||
if v1[0] != float64(protocol.OpState) || v1[1] != float64(2) || v1[2] != float64(protocol.StateWaitOpponentReady) {
|
||||
t.Fatalf("c1 got %v, want StateWaitOpponentReady (3)", v1)
|
||||
}
|
||||
v2 = readMsg(t, c2)
|
||||
if v2[0] != float64(protocol.OpState) || v2[1] != float64(2) || v2[2] != float64(protocol.StateOpponentReady) {
|
||||
t.Fatalf("c2 got %v, want StateOpponentReady (2)", v2)
|
||||
}
|
||||
|
||||
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2))
|
||||
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2))
|
||||
|
||||
// Player 2 sends PlaceShips
|
||||
if err := c2.Write(context.Background(), websocket.MessageText, placeMsg); err != nil {
|
||||
t.Fatalf("write PlaceShips c2: %v", err)
|
||||
}
|
||||
|
||||
// Game starts: Player 1 was first, so c1 gets StateYourTurn (4), c2 gets StateWaitOpponentTurn (5)
|
||||
v1 = readMsg(t, c1)
|
||||
if v1[0] != float64(protocol.OpState) || v1[1] != float64(3) || v1[2] != float64(protocol.StateYourTurn) {
|
||||
t.Fatalf("c1 got %v, want StateYourTurn (4)", v1)
|
||||
}
|
||||
v2 = readMsg(t, c2)
|
||||
if v2[0] != float64(protocol.OpState) || v2[1] != float64(3) || v2[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||
t.Fatalf("c2 got %v, want StateWaitOpponentTurn (5)", v2)
|
||||
}
|
||||
|
||||
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3))
|
||||
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3))
|
||||
|
||||
// Check that the match on the server has the placements stored
|
||||
match := mgr.GetMatch("m-ws-placeships")
|
||||
if match == nil {
|
||||
t.Fatal("match not found on manager")
|
||||
}
|
||||
if len(match.Ships(0)) != 10 || len(match.Ships(1)) != 10 {
|
||||
t.Fatalf("ships not stored on match: p1=%d, p2=%d", len(match.Ships(0)), len(match.Ships(1)))
|
||||
}
|
||||
if _, ok := match.Board(0); !ok {
|
||||
t.Fatal("p1 board not placed")
|
||||
}
|
||||
if _, ok := match.Board(1); !ok {
|
||||
t.Fatal("p2 board not placed")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user