Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48094cd55 | ||
|
|
1cadf00280 | ||
|
|
c198815a18 | ||
|
|
db89677790 | ||
|
|
189b027a11 | ||
|
|
153dc74ff6 | ||
|
|
802968df3d | ||
|
|
27adf47465 | ||
|
|
6392c2ef03 | ||
|
|
31ef12b5f2 | ||
|
|
3629411030 | ||
|
|
50e8aacfa9 | ||
|
|
b122cbb652 | ||
|
|
785cb6b7fc | ||
|
|
3368686201 |
@@ -0,0 +1,28 @@
|
|||||||
|
# Ruby Sea Battle
|
||||||
|
## Introduction
|
||||||
|
"Ruby Sea Battle" is a battleship-like **multiplayer browser game** with a simple React front-end and a back-end composed of two servers:
|
||||||
|
- **the main server** for handling users (sign-up/sign-in, profile customization, etc.), direct user-to-user interactions, match-making and player rating.
|
||||||
|
- **the game server** for managing matches, with real-time client-server communication and score calculation. The players are invited to join the game on the game server by the main server, and once the game finishes the game server reports back the results.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
- **Ruby on Rails** is used as a basis for the main server, implementing only an HTTP API without hosting the front-end.
|
||||||
|
- The game server is written in Go with no framework, implementing a WebSockets server
|
||||||
|
|
||||||
|
## Directory structure
|
||||||
|
The repository's directory structure is simple: there are 3 directories for each part of the project: `frontend`, `game-server` and `main-server`. The documentation for `main-server` currently resides fully in `main-server/API.md`, but more files can be added in `main-server/docs`, if needed. `game-server` also has its own `README.md`, where all relevant information is contained at the moment. The docs for `main-server` and `game-server` are in Russian.
|
||||||
|
|
||||||
|
## Project roadmap
|
||||||
|
- [x] Basic sign-up/sign-in function
|
||||||
|
- [x] Matchmaking with delegation to the game server (using a shared JWT secret)
|
||||||
|
- [x] Actual game logic & game UI
|
||||||
|
- [x] Ship placement UI and logic
|
||||||
|
- [x] Battle UI and logic
|
||||||
|
- [x] End of battle UI
|
||||||
|
- [ ] Saving results on the main server, match history, rating calculation
|
||||||
|
- [ ] Additional in-game features: chat, reactions, live connection status
|
||||||
|
- [ ] Profile customization
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
- The mentioned documentation files should be kept up-to-date and updated in the same language. The formatting can be changed, though, if it could improve readability.
|
||||||
|
- The Ruby backend should be written with Rails principles in mind. The tasks performed by this backend are going to be relatively simple (especially at earlier stages of this project), so the focus should be on clean code and best practices, not unnecessary complexity.
|
||||||
|
- The methods in the Ruby backend comprising the business-logic should be covered with unit-tests after being implemented.
|
||||||
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "sea-battle-frontend",
|
"name": "sea-battle-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rails/actioncable": "^8.1.400",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
@@ -807,6 +808,12 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rails/actioncable": "^8.1.400",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,22 +3,26 @@ import CodeInput from './components/CodeInput'
|
|||||||
import ProfileBar from './components/ProfileBar'
|
import ProfileBar from './components/ProfileBar'
|
||||||
import SearchScreen from './components/SearchScreen'
|
import SearchScreen from './components/SearchScreen'
|
||||||
import GameScreen from './components/GameScreen'
|
import GameScreen from './components/GameScreen'
|
||||||
import { useAuth } from './auth'
|
import { useAuth, useAuthModal } from './auth'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
|
const { openAuthModal } = useAuthModal()
|
||||||
const [screen, setScreen] = useState('menu') // 'menu' | 'search' | 'game'
|
const [screen, setScreen] = useState('menu') // 'menu' | 'search' | 'game'
|
||||||
const [gameSession, setGameSession] = useState(null)
|
const [gameSession, setGameSession] = useState(null)
|
||||||
|
|
||||||
const handleRandomBattle = () => {
|
const handleRandomBattle = () => {
|
||||||
|
if (!token) return openAuthModal()
|
||||||
setScreen('search')
|
setScreen('search')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInvite = () => {
|
const handleInvite = () => {
|
||||||
|
if (!token) return openAuthModal()
|
||||||
// TODO: создание приватной комнаты
|
// TODO: создание приватной комнаты
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleJoinByCode = (code) => {
|
const handleJoinByCode = (code) => {
|
||||||
|
if (!token) return openAuthModal()
|
||||||
// TODO: присоединение к комнате по коду
|
// TODO: присоединение к комнате по коду
|
||||||
console.log('join code:', code)
|
console.log('join code:', code)
|
||||||
}
|
}
|
||||||
|
|||||||
+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 } = {}) {
|
async function request(path, { method = 'GET', body, token, formData } = {}) {
|
||||||
const headers = {}
|
const headers = {}
|
||||||
|
|||||||
+53
-2
@@ -1,27 +1,59 @@
|
|||||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
|
import AuthModal from './components/AuthModal'
|
||||||
|
import { connectCable, disconnectCable, CableContext } from './cable.js'
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
const AuthContext = createContext(null)
|
||||||
|
const AuthModalContext = createContext(null)
|
||||||
const TOKEN_KEY = 'sea_battle_token'
|
const TOKEN_KEY = 'sea_battle_token'
|
||||||
|
|
||||||
|
export function AuthModalProvider({ children }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const openAuthModal = useCallback(() => setOpen(true), [])
|
||||||
|
const closeAuthModal = useCallback(() => setOpen(false), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthModalContext.Provider value={{ openAuthModal }}>
|
||||||
|
{children}
|
||||||
|
{open && <AuthModal onClose={closeAuthModal} />}
|
||||||
|
</AuthModalContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthModal() {
|
||||||
|
return useContext(AuthModalContext)
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
|
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
|
||||||
const [loading, setLoading] = 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(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
disconnectCable()
|
||||||
|
setConsumer(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
api
|
api
|
||||||
.me(token)
|
.me(token)
|
||||||
.then((data) => setUser(data.user))
|
.then((data) => {
|
||||||
|
setUser(data.user)
|
||||||
|
const c = connectCable(token)
|
||||||
|
setConsumer(c)
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
disconnectCable()
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
setConsumer(null)
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [token])
|
}, [token])
|
||||||
@@ -31,6 +63,8 @@ export function AuthProvider({ children }) {
|
|||||||
localStorage.setItem(TOKEN_KEY, data.token)
|
localStorage.setItem(TOKEN_KEY, data.token)
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
|
const c = connectCable(data.token)
|
||||||
|
setConsumer(c)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const register = useCallback(async (nickname, password, avatarFile) => {
|
const register = useCallback(async (nickname, password, avatarFile) => {
|
||||||
@@ -38,18 +72,35 @@ export function AuthProvider({ children }) {
|
|||||||
localStorage.setItem(TOKEN_KEY, data.token)
|
localStorage.setItem(TOKEN_KEY, data.token)
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
|
const c = connectCable(data.token)
|
||||||
|
setConsumer(c)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
if (token) api.logout(token).catch(() => {})
|
if (token) api.logout(token).catch(() => {})
|
||||||
|
disconnectCable()
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
setConsumer(null)
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
loading,
|
||||||
|
consumer,
|
||||||
|
cable: consumer,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CableContext.Provider value={{ consumer, cable: consumer }}>
|
||||||
{children}
|
{children}
|
||||||
|
</CableContext.Provider>
|
||||||
</AuthContext.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
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import SeaBoard from './SeaBoard'
|
||||||
|
import {
|
||||||
|
STATE_YOUR_TURN,
|
||||||
|
STATE_WAIT_OPPONENT_TURN,
|
||||||
|
FIRE_RESULT_MISS,
|
||||||
|
FIRE_RESULT_HIT,
|
||||||
|
FIRE_RESULT_SUNK,
|
||||||
|
} from '../game'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обрабатывает потопление корабля на поле соперника:
|
||||||
|
* находит все соединенные клетки 'damaged', помечает их как 'destroyed',
|
||||||
|
* а все окружающие клетки (включая диагональные) — как 'miss'.
|
||||||
|
*/
|
||||||
|
export function applySunkShipToBoard(prevBoard, hitX, hitY) {
|
||||||
|
const board = prevBoard.map((row) => [...row])
|
||||||
|
board[hitY][hitX] = 'damaged'
|
||||||
|
|
||||||
|
const shipCells = [{ x: hitX, y: hitY }]
|
||||||
|
|
||||||
|
// Поиск поврежденных клеток корабля по горизонтали
|
||||||
|
let cx = hitX - 1
|
||||||
|
while (cx >= 0 && board[hitY][cx] === 'damaged') {
|
||||||
|
shipCells.push({ x: cx, y: hitY })
|
||||||
|
cx--
|
||||||
|
}
|
||||||
|
cx = hitX + 1
|
||||||
|
while (cx < 10 && board[hitY][cx] === 'damaged') {
|
||||||
|
shipCells.push({ x: cx, y: hitY })
|
||||||
|
cx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск поврежденных клеток корабля по вертикали
|
||||||
|
let cy = hitY - 1
|
||||||
|
while (cy >= 0 && board[cy][hitX] === 'damaged') {
|
||||||
|
shipCells.push({ x: hitX, y: cy })
|
||||||
|
cy--
|
||||||
|
}
|
||||||
|
cy = hitY + 1
|
||||||
|
while (cy < 10 && board[cy][hitX] === 'damaged') {
|
||||||
|
shipCells.push({ x: hitX, y: cy })
|
||||||
|
cy++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Помечаем все палубы потопленного корабля как 'destroyed'
|
||||||
|
for (const c of shipCells) {
|
||||||
|
board[c.y][c.x] = 'destroyed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Окружающие клетки (включая диагональные) помечаем как 'miss'
|
||||||
|
for (const c of shipCells) {
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const nx = c.x + dx
|
||||||
|
const ny = c.y + dy
|
||||||
|
if (nx >= 0 && nx < 10 && ny >= 0 && ny < 10) {
|
||||||
|
if (board[ny][nx] !== 'destroyed') {
|
||||||
|
board[ny][nx] = 'miss'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return board
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BattleScreen({
|
||||||
|
myShips = [],
|
||||||
|
gameState,
|
||||||
|
connection,
|
||||||
|
onLeave,
|
||||||
|
}) {
|
||||||
|
// Поле игрока (собственные корабли без скруглений)
|
||||||
|
const [myBoard] = useState(() => {
|
||||||
|
const b = Array.from({ length: 10 }, () => Array(10).fill('empty'))
|
||||||
|
for (const ship of myShips) {
|
||||||
|
for (let i = 0; i < ship.size; i++) {
|
||||||
|
const cx = ship.orientation ? ship.x : ship.x + i
|
||||||
|
const cy = ship.orientation ? ship.y + i : ship.y
|
||||||
|
if (cx >= 0 && cx < 10 && cy >= 0 && cy < 10) {
|
||||||
|
b[cy][cx] = 'ship'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
})
|
||||||
|
|
||||||
|
// Поле соперника (туман войны: изначально все клетки пустые)
|
||||||
|
const [opponentBoard, setOpponentBoard] = useState(() =>
|
||||||
|
Array.from({ length: 10 }, () => Array(10).fill('empty'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const [aiming, setAiming] = useState(null)
|
||||||
|
const [isFiring, setIsFiring] = useState(false)
|
||||||
|
const [showSurrenderModal, setShowSurrenderModal] = useState(false)
|
||||||
|
|
||||||
|
const pendingShotRef = useRef(null)
|
||||||
|
|
||||||
|
const isMyTurn = gameState === STATE_YOUR_TURN
|
||||||
|
|
||||||
|
// Обработка результатов выстрела от сервера
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connection) return
|
||||||
|
|
||||||
|
connection.onFireResult = (resultCode) => {
|
||||||
|
const shot = pendingShotRef.current
|
||||||
|
pendingShotRef.current = null
|
||||||
|
setIsFiring(false)
|
||||||
|
setAiming(null)
|
||||||
|
|
||||||
|
if (!shot) return
|
||||||
|
|
||||||
|
const { x, y } = shot
|
||||||
|
|
||||||
|
if (resultCode === FIRE_RESULT_MISS) {
|
||||||
|
setOpponentBoard((prev) => {
|
||||||
|
const next = prev.map((row) => [...row])
|
||||||
|
next[y][x] = 'miss'
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else if (resultCode === FIRE_RESULT_HIT) {
|
||||||
|
setOpponentBoard((prev) => {
|
||||||
|
const next = prev.map((row) => [...row])
|
||||||
|
next[y][x] = 'damaged'
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else if (resultCode === FIRE_RESULT_SUNK) {
|
||||||
|
setOpponentBoard((prev) => applySunkShipToBoard(prev, x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
connection.onFireResult = null
|
||||||
|
}
|
||||||
|
}, [connection])
|
||||||
|
|
||||||
|
// Наведение курсора на клетку поля соперника (прицеливание)
|
||||||
|
const handleCellHover = useCallback(
|
||||||
|
(x, y) => {
|
||||||
|
if (!isMyTurn || isFiring) return
|
||||||
|
// Не подсвечивать прицел на уже пораженных клетках
|
||||||
|
if (opponentBoard[y][x] !== 'empty') {
|
||||||
|
setAiming(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAiming({ x, y })
|
||||||
|
},
|
||||||
|
[isMyTurn, isFiring, opponentBoard]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCellLeave = useCallback(() => {
|
||||||
|
setAiming(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Клик по клетке поля соперника (выстрел)
|
||||||
|
const handleCellClick = useCallback(
|
||||||
|
(x, y) => {
|
||||||
|
if (!isMyTurn || isFiring) return
|
||||||
|
if (opponentBoard[y][x] !== 'empty') return
|
||||||
|
|
||||||
|
setIsFiring(true)
|
||||||
|
pendingShotRef.current = { x, y }
|
||||||
|
connection.fire(x, y)
|
||||||
|
},
|
||||||
|
[isMyTurn, isFiring, opponentBoard, connection]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="battle-screen-container">
|
||||||
|
{/* Верхняя панель со статусом и кнопкой сдаться в углу */}
|
||||||
|
<div className="battle-header">
|
||||||
|
<div className="battle-header-left">
|
||||||
|
<div className="battle-status-badge">
|
||||||
|
{isMyTurn ? 'Ваш ход — выберите цель' : 'Ожидайте ход соперника'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="battle-header-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary surrender-btn"
|
||||||
|
onClick={() => setShowSurrenderModal(true)}
|
||||||
|
>
|
||||||
|
Сдаться
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основная боевая область с двумя полями и стрелкой посередине */}
|
||||||
|
<div className="battle-boards-layout">
|
||||||
|
{/* Левая половина: собственное поле игрока */}
|
||||||
|
<div className="battle-field-panel">
|
||||||
|
<div className="battle-field-title">Ваш флот</div>
|
||||||
|
<SeaBoard cells={myBoard} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Между половинами: голубая стрелка, показывающая направление атаки */}
|
||||||
|
<div className="battle-turn-indicator-col">
|
||||||
|
<div
|
||||||
|
className={`battle-arrow ${isMyTurn ? 'arrow-right' : 'arrow-left'}`}
|
||||||
|
title={isMyTurn ? 'Вы атакуете поле соперника' : 'Соперник атакует ваше поле'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="44"
|
||||||
|
height="44"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polygon points="6,4 18,12 6,20" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="battle-arrow-text">
|
||||||
|
{isMyTurn ? 'Атака' : 'Оборона'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая половина: поле соперника */}
|
||||||
|
<div className={`battle-field-panel ${isMyTurn ? 'active-target-panel' : ''}`}>
|
||||||
|
<div className="battle-field-title">Флот соперника</div>
|
||||||
|
<SeaBoard
|
||||||
|
cells={opponentBoard}
|
||||||
|
interactive={isMyTurn && !isFiring}
|
||||||
|
aiming={isMyTurn && !isFiring ? aiming : null}
|
||||||
|
onCellHover={handleCellHover}
|
||||||
|
onCellLeave={handleCellLeave}
|
||||||
|
onCellClick={handleCellClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Модальное окно заглушки "Сдаться" */}
|
||||||
|
{showSurrenderModal && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal surrender-modal">
|
||||||
|
<h2 className="modal-title">Сдаться</h2>
|
||||||
|
<p className="surrender-desc">
|
||||||
|
Функция «Сдаться» в данный момент находится в разработке на сервере.
|
||||||
|
</p>
|
||||||
|
<div className="surrender-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowSurrenderModal(false)}
|
||||||
|
>
|
||||||
|
Вернуться в бой
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onLeave}
|
||||||
|
>
|
||||||
|
Выйти в меню
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,89 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { createGameConnection } from '../game'
|
import {
|
||||||
|
createGameConnection,
|
||||||
|
STATE_PLACING_SHIPS,
|
||||||
|
STATE_OPPONENT_READY,
|
||||||
|
STATE_WAIT_OPPONENT_READY,
|
||||||
|
STATE_YOUR_TURN,
|
||||||
|
STATE_WAIT_OPPONENT_TURN,
|
||||||
|
END_CODE_VICTORY,
|
||||||
|
END_CODE_DEFEAT,
|
||||||
|
} from '../game'
|
||||||
|
import ShipPlacementScreen from './ShipPlacementScreen'
|
||||||
|
import BattleScreen from './BattleScreen'
|
||||||
|
|
||||||
export default function GameScreen({ session, onLeave }) {
|
export default function GameScreen({ session, onLeave }) {
|
||||||
const [status, setStatus] = useState('connecting') // 'connecting' | 'ready' | 'error'
|
// 'connecting' | 'placing' | 'waiting_opponent' | 'battle' | 'victory' | 'defeat' | 'error'
|
||||||
|
const [status, setStatus] = useState('connecting')
|
||||||
|
const [gameState, setGameState] = useState(STATE_PLACING_SHIPS)
|
||||||
|
const [myShips, setMyShips] = useState([])
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [opponentReady, setOpponentReady] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const connectionRef = useRef(null)
|
||||||
const attemptRef = useRef(0)
|
const attemptRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const attempt = ++attemptRef.current
|
const attempt = ++attemptRef.current
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const connection = createGameConnection(session)
|
const connInstance = createGameConnection(session)
|
||||||
|
|
||||||
connection.promise
|
connInstance.promise
|
||||||
.then(() => {
|
.then((conn) => {
|
||||||
if (!cancelled && attempt === attemptRef.current) setStatus('ready')
|
if (cancelled || attempt !== attemptRef.current) return
|
||||||
|
connectionRef.current = conn
|
||||||
|
|
||||||
|
conn.onState = (newState) => {
|
||||||
|
if (cancelled || attempt !== attemptRef.current) return
|
||||||
|
|
||||||
|
if (newState === STATE_OPPONENT_READY) {
|
||||||
|
setOpponentReady(true)
|
||||||
|
} else if (newState === STATE_WAIT_OPPONENT_READY) {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
setGameState(newState)
|
||||||
|
setStatus('waiting_opponent')
|
||||||
|
} else if (
|
||||||
|
newState === STATE_YOUR_TURN ||
|
||||||
|
newState === STATE_WAIT_OPPONENT_TURN
|
||||||
|
) {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
setGameState(newState)
|
||||||
|
setStatus('battle')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.onEnd = (code, reason) => {
|
||||||
|
if (cancelled || attempt !== attemptRef.current) return
|
||||||
|
setIsSubmitting(false)
|
||||||
|
|
||||||
|
if (code === END_CODE_VICTORY || reason === 'victory') {
|
||||||
|
setStatus('victory')
|
||||||
|
} else if (code === END_CODE_DEFEAT || reason === 'defeat') {
|
||||||
|
setStatus('defeat')
|
||||||
|
} else {
|
||||||
|
setError(reason || 'Партия завершена')
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.onClose = () => {
|
||||||
|
if (cancelled || attempt !== attemptRef.current) return
|
||||||
|
setIsSubmitting(false)
|
||||||
|
setStatus((prev) => {
|
||||||
|
// Если игра уже закончилась победой или поражением, не перетираем статус
|
||||||
|
if (prev === 'victory' || prev === 'defeat') {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
if (prev !== 'error') {
|
||||||
|
setError('Соединение с сервером прервано')
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('placing')
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (cancelled || attempt !== attemptRef.current) return
|
if (cancelled || attempt !== attemptRef.current) return
|
||||||
@@ -23,33 +93,104 @@ export default function GameScreen({ session, onLeave }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
connection.cancel()
|
connInstance.cancel()
|
||||||
}
|
}
|
||||||
}, [session])
|
}, [session])
|
||||||
|
|
||||||
|
const handleReady = useCallback((ships) => {
|
||||||
|
if (!connectionRef.current) return
|
||||||
|
setMyShips(ships)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
connectionRef.current.placeShips(ships)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page game-page">
|
||||||
<main className="search-screen">
|
|
||||||
{status === 'connecting' && (
|
{status === 'connecting' && (
|
||||||
<>
|
<main className="search-screen">
|
||||||
<div className="search-label">Подключение к игре...</div>
|
<div className="search-label">Подключение к игре...</div>
|
||||||
<div className="bouncing-dots" aria-hidden="true">
|
<div className="bouncing-dots" aria-hidden="true">
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</main>
|
||||||
)}
|
)}
|
||||||
{status === 'ready' && <div className="search-label">Расстановка кораблей</div>}
|
|
||||||
|
{status === 'placing' && (
|
||||||
|
<main className="game-screen-main">
|
||||||
|
<ShipPlacementScreen
|
||||||
|
onReady={handleReady}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
opponentReady={opponentReady}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'waiting_opponent' && (
|
||||||
|
<main className="search-screen">
|
||||||
|
<div className="search-label">Соперник расставляет корабли...</div>
|
||||||
|
<div className="bouncing-dots" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'battle' && (
|
||||||
|
<main className="game-screen-main">
|
||||||
|
<BattleScreen
|
||||||
|
myShips={myShips}
|
||||||
|
gameState={gameState}
|
||||||
|
connection={connectionRef.current}
|
||||||
|
onLeave={onLeave}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'victory' && (
|
||||||
|
<main className="search-screen">
|
||||||
|
<div className="end-game-title victory-title">Победа!</div>
|
||||||
|
<div className="end-game-subtitle">Все корабли соперника уничтожены</div>
|
||||||
|
<button className="btn btn-big btn-primary" onClick={onLeave}>
|
||||||
|
В главное меню
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'defeat' && (
|
||||||
|
<main className="search-screen">
|
||||||
|
<div className="end-game-title defeat-title">Поражение</div>
|
||||||
|
<div className="end-game-subtitle">Все ваши корабли были уничтожены</div>
|
||||||
|
<button className="btn btn-big btn-secondary" onClick={onLeave}>
|
||||||
|
В главное меню
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<>
|
<main className="search-screen">
|
||||||
<div className="search-error">{error}</div>
|
<div className="search-error">{error}</div>
|
||||||
<button className="btn btn-big btn-secondary" onClick={onLeave}>
|
<button className="btn btn-big btn-secondary" onClick={onLeave}>
|
||||||
Назад в меню
|
Назад в меню
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальный pop-up с надписью "Загрузка...", пока сервер не ответит сменой состояния */}
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal loading-modal">
|
||||||
|
<h2 className="modal-title">Загрузка...</h2>
|
||||||
|
<div className="bouncing-dots" style={{ justifyContent: 'center', marginTop: 16 }}>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useAuth, useAuthModal } from '../auth'
|
||||||
import { useAuth } from '../auth'
|
|
||||||
import AuthModal from './AuthModal'
|
|
||||||
|
|
||||||
export default function ProfileBar() {
|
export default function ProfileBar() {
|
||||||
const { user, loading, logout } = useAuth()
|
const { user, loading, logout } = useAuth()
|
||||||
const [showAuth, setShowAuth] = useState(false)
|
const { openAuthModal } = useAuthModal()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -26,12 +24,11 @@ export default function ProfileBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-primary" onClick={() => setShowAuth(true)}>
|
<button className="btn btn-primary" onClick={openAuthModal}>
|
||||||
Войти
|
Войти
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showAuth && <AuthModal onClose={() => setShowAuth(false)} />}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { ROW_LETTERS, COL_NUMBERS } from '../shipLogic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент игрового поля 10x10 с вертикальной (А-К) и горизонтальной (1-10) линейками.
|
||||||
|
* Линейки идут слева и сверху параллельно полю и отделены небольшим отступом.
|
||||||
|
*
|
||||||
|
* Поддерживает два режима:
|
||||||
|
* 1. Режим расстановки (передаются ships, preview, onShipPointerDown)
|
||||||
|
* 2. Режим боя (передается 10x10 матрица cells, aiming, onCellHover, onCellClick)
|
||||||
|
*/
|
||||||
|
export default function SeaBoard({
|
||||||
|
ships = [],
|
||||||
|
preview = null,
|
||||||
|
onShipPointerDown,
|
||||||
|
gridRef,
|
||||||
|
className = '',
|
||||||
|
// Свойства для режима боя
|
||||||
|
cells = null,
|
||||||
|
aiming = null,
|
||||||
|
onCellHover = null,
|
||||||
|
onCellLeave = null,
|
||||||
|
onCellClick = null,
|
||||||
|
interactive = false,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`sea-board-wrapper ${className}`}>
|
||||||
|
{/* Пустой угол (пересечение линеек) */}
|
||||||
|
<div className="sea-board-corner" aria-hidden="true" />
|
||||||
|
|
||||||
|
{/* Горизонтальная линейка (цифры 1..10) */}
|
||||||
|
<div className="sea-board-col-ruler" aria-hidden="true">
|
||||||
|
{COL_NUMBERS.map((num) => (
|
||||||
|
<div key={num} className="sea-ruler-cell">
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Вертикальная линейка (буквы А..К) */}
|
||||||
|
<div className="sea-board-row-ruler" aria-hidden="true">
|
||||||
|
{ROW_LETTERS.map((letter) => (
|
||||||
|
<div key={letter} className="sea-ruler-cell">
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Само игровое поле 10x10 */}
|
||||||
|
<div className="sea-grid-container" ref={gridRef}>
|
||||||
|
{/* Базовая сетка 10x10 клеток */}
|
||||||
|
<div
|
||||||
|
className={`sea-grid ${interactive ? 'interactive-grid' : ''}`}
|
||||||
|
onMouseLeave={onCellLeave}
|
||||||
|
>
|
||||||
|
{cells
|
||||||
|
? Array.from({ length: 10 }).map((_, y) =>
|
||||||
|
Array.from({ length: 10 }).map((_, x) => {
|
||||||
|
const status = cells[y]?.[x] || 'empty'
|
||||||
|
const isTarget = aiming && aiming.x === x && aiming.y === y
|
||||||
|
const isAimRow = aiming && aiming.y === y
|
||||||
|
const isAimCol = aiming && aiming.x === x
|
||||||
|
|
||||||
|
const classNames = ['sea-cell']
|
||||||
|
if (status === 'ship') classNames.push('ship-cell')
|
||||||
|
else if (status === 'miss') classNames.push('miss-cell')
|
||||||
|
else if (status === 'damaged') classNames.push('damaged-ship-cell')
|
||||||
|
else if (status === 'destroyed') classNames.push('destroyed-ship-cell')
|
||||||
|
|
||||||
|
if (interactive && aiming) {
|
||||||
|
if (isTarget) classNames.push('aim-target')
|
||||||
|
else if (isAimRow) classNames.push('aim-row')
|
||||||
|
else if (isAimCol) classNames.push('aim-col')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${x}-${y}`}
|
||||||
|
className={classNames.join(' ')}
|
||||||
|
onMouseEnter={() => onCellHover && onCellHover(x, y)}
|
||||||
|
onClick={() => onCellClick && onCellClick(x, y)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: Array.from({ length: 100 }).map((_, idx) => (
|
||||||
|
<div key={idx} className="sea-cell" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Слой расставленных кораблей (в режиме расстановки) */}
|
||||||
|
{!cells && (
|
||||||
|
<div className="sea-ships-layer">
|
||||||
|
{ships.map((ship) => {
|
||||||
|
if (!ship.placed) return null
|
||||||
|
const isHorizontal = !ship.orientation
|
||||||
|
const left = 3 + ship.x * 33
|
||||||
|
const top = 3 + ship.y * 33
|
||||||
|
const width = isHorizontal ? ship.size * 30 + (ship.size - 1) * 3 : 30
|
||||||
|
const height = isHorizontal ? 30 : ship.size * 30 + (ship.size - 1) * 3
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ship.id}
|
||||||
|
className={`board-ship ${ship.orientation ? 'vertical' : 'horizontal'}`}
|
||||||
|
style={{
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (onShipPointerDown) {
|
||||||
|
onShipPointerDown(e, ship)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: ship.size }).map((_, segIdx) => (
|
||||||
|
<div
|
||||||
|
key={segIdx}
|
||||||
|
className="ship-segment"
|
||||||
|
data-segment={segIdx}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Слой призрака при перетаскивании (ghost preview) */}
|
||||||
|
{!cells && preview && (
|
||||||
|
<div
|
||||||
|
className={`board-ship ghost-preview ${
|
||||||
|
preview.orientation ? 'vertical' : 'horizontal'
|
||||||
|
} ${preview.isValid ? 'ghost-valid' : 'ghost-invalid'}`}
|
||||||
|
style={{
|
||||||
|
left: `${3 + preview.x * 33}px`,
|
||||||
|
top: `${3 + preview.y * 33}px`,
|
||||||
|
width: `${
|
||||||
|
!preview.orientation ? preview.size * 30 + (preview.size - 1) * 3 : 30
|
||||||
|
}px`,
|
||||||
|
height: `${
|
||||||
|
!preview.orientation ? 30 : preview.size * 30 + (preview.size - 1) * 3
|
||||||
|
}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: preview.size }).map((_, segIdx) => (
|
||||||
|
<div key={segIdx} className="ship-segment" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { createMatchmaking, MatchmakingError } from '../matchmaking'
|
import { createMatchmaking, MatchmakingError } from '../matchmaking'
|
||||||
|
import { useCable } from '../cable'
|
||||||
|
|
||||||
const STAGE_LABELS = {
|
const STAGE_LABELS = {
|
||||||
connecting: 'Подключение...',
|
connecting: 'Подключение...',
|
||||||
@@ -13,6 +14,7 @@ const ERROR_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchScreen({ token, onMatched, onCancel }) {
|
export default function SearchScreen({ token, onMatched, onCancel }) {
|
||||||
|
const { consumer } = useCable()
|
||||||
const [stage, setStage] = useState('connecting') // 'connecting' | 'searching'
|
const [stage, setStage] = useState('connecting') // 'connecting' | 'searching'
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [retryToken, setRetryToken] = useState(0)
|
const [retryToken, setRetryToken] = useState(0)
|
||||||
@@ -26,7 +28,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
|
|||||||
setStage('connecting')
|
setStage('connecting')
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
matchmaking = createMatchmaking(token, (status) => {
|
matchmaking = createMatchmaking(consumer || token, (status) => {
|
||||||
if (!cancelled && attempt === attemptRef.current) setStage(status)
|
if (!cancelled && attempt === attemptRef.current) setStage(status)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
matchmaking.cancel()
|
matchmaking.cancel()
|
||||||
}
|
}
|
||||||
}, [token, onMatched, retryToken])
|
}, [consumer, token, onMatched, retryToken])
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
onCancel()
|
onCancel()
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import SeaBoard from './SeaBoard'
|
||||||
|
import {
|
||||||
|
createInitialShips,
|
||||||
|
canPlaceShip,
|
||||||
|
findValidRotation,
|
||||||
|
generateRandomPlacement,
|
||||||
|
} from '../shipLogic'
|
||||||
|
|
||||||
|
function ShuffleIcon({ size = 20 }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 12.7 12.7"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: 'middle', flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.2,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
}}
|
||||||
|
d="m 9.4816001,7.6107086 c 0,2.5748604 0,2.5748604 0,2.5748604 L 10.700616,8.9665533 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.17,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
}}
|
||||||
|
d="m 1.9172753,3.9133276 h 1.3938432 c 0,0 0.418658,0.043751 0.7439689,0.3929024 l 3.2927061,4.3012881 c 0,0 0.3355245,0.3407588 0.6968466,0.321765 l 1.9478831,-0.00957"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.2,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
}}
|
||||||
|
d="m 9.4810176,5.2347575 c 0,-2.5749117 0,-2.5749117 0,-2.5749117 L 10.699942,3.878886 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style={{
|
||||||
|
fill: 'currentColor',
|
||||||
|
strokeWidth: 1.77,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
}}
|
||||||
|
d="M 7.9405757,3.3290259 C 7.2803298,3.3554207 6.9286279,3.8253665 6.9286279,3.8253665 L 6.9047744,3.8538074 6.3276981,4.6070341 7.0497314,5.5923757 7.7745171,4.6437321 c 0.01942,-0.01041 0.2210083,-0.1184824 0.240372,-0.1394525 h 9.175e-4 v -9.175e-4 -9.173e-4 h -9.175e-4 v -9.175e-4 h 0.012844 l 1.9633434,0.011009 A 0.5871519,0.5871519 0 0 0 10.580079,3.9299557 0.5871519,0.5871519 0 0 0 9.9938292,3.3400353 L 8.0763583,3.3299434 c -0.04675,-0.00246 -0.091766,-0.00268 -0.1357826,-9.175e-4 z M 4.3432533,7.2006658 3.62122,8.1428873 C 3.4547894,8.317437 3.2641018,8.3428563 3.2569921,8.3438089 H 1.9184323 A 0.5871519,0.5871519 0 0 0 1.3285117,8.9337295 0.5871519,0.5871519 0 0 0 1.9184323,9.5190627 h 1.4211304 l 0.031193,-0.00367 c 0,0 0.6339103,-0.06015 1.1137846,-0.5752412 l 0.020184,-0.021101 0.5605621,-0.7330428 z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShipPlacementScreen({
|
||||||
|
onReady,
|
||||||
|
isSubmitting = false,
|
||||||
|
opponentReady = false,
|
||||||
|
}) {
|
||||||
|
const [ships, setShips] = useState(createInitialShips)
|
||||||
|
const [activeDrag, setActiveDrag] = useState(null)
|
||||||
|
const shipsRef = useRef(ships)
|
||||||
|
shipsRef.current = ships
|
||||||
|
|
||||||
|
const gridRef = useRef(null)
|
||||||
|
const dragRef = useRef(null)
|
||||||
|
|
||||||
|
const allPlaced = ships.every((s) => s.placed)
|
||||||
|
const placedCount = ships.filter((s) => s.placed).length
|
||||||
|
|
||||||
|
// Очистка класса is-dragging при размонтировании
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove('is-dragging')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Расчет сетки при перетаскивании
|
||||||
|
const computeGridCandidate = useCallback((ship, pointerPos, orientation, segIndex) => {
|
||||||
|
if (!gridRef.current) return null
|
||||||
|
const rect = gridRef.current.getBoundingClientRect()
|
||||||
|
const cellPitch = 33 // 30px ячейка + 3px gap
|
||||||
|
const pad = 3
|
||||||
|
|
||||||
|
// Вычисляем положение левого верхнего угла корабля (shipLeft, shipTop) на экране:
|
||||||
|
const shipLeft = pointerPos.x - (!orientation ? segIndex * cellPitch + 15 : 15)
|
||||||
|
const shipTop = pointerPos.y - (orientation ? segIndex * cellPitch + 15 : 15)
|
||||||
|
|
||||||
|
// Положение левого верхнего угла корабля относительно сетки:
|
||||||
|
const relX = shipLeft - (rect.left + pad)
|
||||||
|
const relY = shipTop - (rect.top + pad)
|
||||||
|
|
||||||
|
// Симметричное математическое округление к ближайшей колонке и строке сетки:
|
||||||
|
const targetX = Math.round(relX / cellPitch)
|
||||||
|
const targetY = Math.round(relY / cellPitch)
|
||||||
|
|
||||||
|
const maxX = orientation ? targetX : targetX + ship.size - 1
|
||||||
|
const maxY = orientation ? targetY + ship.size - 1 : targetY
|
||||||
|
|
||||||
|
// Проекция отображается ТОЛЬКО если корабль полностью внутри поля 10x10:
|
||||||
|
if (targetX < 0 || targetY < 0 || maxX >= 10 || maxY >= 10) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем возможность размещения (нет пересечений и зазор минимум 1 клетка)
|
||||||
|
const otherPlaced = shipsRef.current.filter((s) => s.id !== ship.id && s.placed)
|
||||||
|
const isValid = canPlaceShip(ship, targetX, targetY, orientation, otherPlaced)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: targetX,
|
||||||
|
y: targetY,
|
||||||
|
orientation,
|
||||||
|
size: ship.size,
|
||||||
|
isValid,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Поворот корабля на поле при одиночном клике
|
||||||
|
const handleRotateShip = useCallback((ship) => {
|
||||||
|
const rot = findValidRotation(ship, shipsRef.current)
|
||||||
|
if (rot) {
|
||||||
|
setShips((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === ship.id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
x: rot.x,
|
||||||
|
y: rot.y,
|
||||||
|
orientation: rot.orientation,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Начало перетаскивания или клика
|
||||||
|
const handleStartDrag = useCallback(
|
||||||
|
(e, ship, fromBoard) => {
|
||||||
|
// Только левая кнопка мыши или тач
|
||||||
|
if (e.button !== undefined && e.button !== 0) return
|
||||||
|
|
||||||
|
// Предотвращаем выделение текста на странице
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const segEl = e.target.closest('[data-segment]')
|
||||||
|
let segIndex = 0
|
||||||
|
if (segEl) {
|
||||||
|
segIndex = parseInt(segEl.getAttribute('data-segment'), 10) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const startX = e.clientX
|
||||||
|
const startY = e.clientY
|
||||||
|
let isDragging = false
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
ship,
|
||||||
|
fromBoard,
|
||||||
|
segIndex,
|
||||||
|
orientation: fromBoard ? ship.orientation : false,
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
currentPos: { x: startX, y: startY },
|
||||||
|
candidate: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = (moveEvent) => {
|
||||||
|
const drag = dragRef.current
|
||||||
|
if (!drag) return
|
||||||
|
|
||||||
|
moveEvent.preventDefault()
|
||||||
|
|
||||||
|
const dist = Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY)
|
||||||
|
if (!isDragging && dist > 5) {
|
||||||
|
isDragging = true
|
||||||
|
document.body.classList.add('is-dragging')
|
||||||
|
// Снимаем корабль с поля на время переноса
|
||||||
|
if (drag.fromBoard) {
|
||||||
|
setShips((prev) =>
|
||||||
|
prev.map((s) => (s.id === drag.ship.id ? { ...s, placed: false } : s))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
const currentPos = { x: moveEvent.clientX, y: moveEvent.clientY }
|
||||||
|
const candidate = computeGridCandidate(
|
||||||
|
drag.ship,
|
||||||
|
currentPos,
|
||||||
|
drag.orientation,
|
||||||
|
drag.segIndex
|
||||||
|
)
|
||||||
|
drag.currentPos = currentPos
|
||||||
|
drag.candidate = candidate
|
||||||
|
|
||||||
|
setActiveDrag({
|
||||||
|
ship: drag.ship,
|
||||||
|
orientation: drag.orientation,
|
||||||
|
currentPos,
|
||||||
|
segIndex: drag.segIndex,
|
||||||
|
candidate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
window.removeEventListener('pointermove', onPointerMove)
|
||||||
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.body.classList.remove('is-dragging')
|
||||||
|
|
||||||
|
const drag = dragRef.current
|
||||||
|
dragRef.current = null
|
||||||
|
setActiveDrag(null)
|
||||||
|
|
||||||
|
if (!drag) return
|
||||||
|
|
||||||
|
if (!isDragging) {
|
||||||
|
// Одиночный клик
|
||||||
|
if (drag.fromBoard) {
|
||||||
|
handleRotateShip(drag.ship)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завершение перетаскивания
|
||||||
|
if (drag.candidate && drag.candidate.isValid) {
|
||||||
|
// Успешная установка на поле
|
||||||
|
setShips((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === drag.ship.id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
placed: true,
|
||||||
|
x: drag.candidate.x,
|
||||||
|
y: drag.candidate.y,
|
||||||
|
orientation: drag.candidate.orientation,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Некорректная позиция или за пределами поля -> возвращается в инвентарь
|
||||||
|
setShips((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === drag.ship.id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
placed: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
orientation: false,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (keyEvent) => {
|
||||||
|
if (keyEvent.code === 'Space' || keyEvent.key === 'r' || keyEvent.key === 'R') {
|
||||||
|
keyEvent.preventDefault()
|
||||||
|
const drag = dragRef.current
|
||||||
|
if (!drag) return
|
||||||
|
drag.orientation = !drag.orientation
|
||||||
|
const candidate = computeGridCandidate(
|
||||||
|
drag.ship,
|
||||||
|
drag.currentPos,
|
||||||
|
drag.orientation,
|
||||||
|
drag.segIndex
|
||||||
|
)
|
||||||
|
drag.candidate = candidate
|
||||||
|
setActiveDrag((prev) =>
|
||||||
|
prev ? { ...prev, orientation: drag.orientation, candidate } : null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onPointerMove)
|
||||||
|
window.addEventListener('pointerup', onPointerUp)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
},
|
||||||
|
[computeGridCandidate, handleRotateShip]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Очистить сетку
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setShips(createInitialShips())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Случайная расстановка с backtracking
|
||||||
|
const handleRandom = useCallback(() => {
|
||||||
|
const placed = generateRandomPlacement()
|
||||||
|
setShips(placed)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 4 строчки инвентаря по возрастанию размера:
|
||||||
|
// Строка 1: 4 однопалубных (id 0..3)
|
||||||
|
// Строка 2: 3 двухпалубных (id 4..6)
|
||||||
|
// Строка 3: 2 трехпалубных (id 7..8)
|
||||||
|
// Строка 4: 1 четырехпалубный (id 9)
|
||||||
|
const inventoryRows = [
|
||||||
|
ships.filter((s) => s.size === 1),
|
||||||
|
ships.filter((s) => s.size === 2),
|
||||||
|
ships.filter((s) => s.size === 3),
|
||||||
|
ships.filter((s) => s.size === 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="placement-screen-layout">
|
||||||
|
<div className="placement-opponent-badge">
|
||||||
|
{opponentReady ? "Соперник готов к бою!" : "Соперник расставляет корабли..."}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="placement-container">
|
||||||
|
{/* Левая половина: игровое поле 10x10 с линейками и кнопками под ним */}
|
||||||
|
<div className="placement-left">
|
||||||
|
<SeaBoard
|
||||||
|
ships={ships}
|
||||||
|
preview={activeDrag?.candidate}
|
||||||
|
onShipPointerDown={(e, ship) => handleStartDrag(e, ship, true)}
|
||||||
|
gridRef={gridRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="placement-board-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary ship-action-btn"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={placedCount === 0 || isSubmitting}
|
||||||
|
>
|
||||||
|
Очистить поле
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary ship-action-btn ship-auto-btn"
|
||||||
|
title="Случайная расстановка (Авто)"
|
||||||
|
onClick={handleRandom}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<ShuffleIcon size={20} />
|
||||||
|
<span>Авто</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая половина: инвентарь кораблей в 4 строки и кнопка Готово */}
|
||||||
|
<div className="placement-right">
|
||||||
|
<div className="ship-inventory">
|
||||||
|
<div className="inventory-header">
|
||||||
|
<span className="inventory-title">Корабли</span>
|
||||||
|
<span className="inventory-hint">
|
||||||
|
{allPlaced ? 'Все корабли на поле' : `Расставлено: ${placedCount} из 10`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inventory-rows">
|
||||||
|
{inventoryRows.map((row, rowIndex) => (
|
||||||
|
<div key={rowIndex} className="inventory-row">
|
||||||
|
{row.map((ship) => {
|
||||||
|
const slotWidth = ship.size * 30 + (ship.size - 1) * 3
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ship.id}
|
||||||
|
className="inventory-slot"
|
||||||
|
style={{
|
||||||
|
width: `${slotWidth}px`,
|
||||||
|
height: '30px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!ship.placed && (
|
||||||
|
<div
|
||||||
|
className="board-ship horizontal in-inventory"
|
||||||
|
onPointerDown={(e) => handleStartDrag(e, ship, false)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: ship.size }).map((_, segIdx) => (
|
||||||
|
<div
|
||||||
|
key={segIdx}
|
||||||
|
className="ship-segment"
|
||||||
|
data-segment={segIdx}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Подсказка по повороту кораблей */}
|
||||||
|
<div className="placement-hint">
|
||||||
|
<span className="placement-hint-icon" aria-hidden="true">💡</span>
|
||||||
|
<span className="placement-hint-text">
|
||||||
|
Корабли можно поворачивать во время перетаскивания (клавиши <strong>Пробел</strong> или <strong>R</strong>), а также одиночным нажатием после перетаскивания.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="placement-ready-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-big ready-btn"
|
||||||
|
disabled={!allPlaced || isSubmitting}
|
||||||
|
onClick={() => onReady(ships)}
|
||||||
|
>
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Парящий под курсором корабль при перетаскивании */}
|
||||||
|
{activeDrag && (
|
||||||
|
<div
|
||||||
|
className={`floating-drag-ship ${activeDrag.orientation ? 'vertical' : 'horizontal'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
left: `${activeDrag.currentPos.x -
|
||||||
|
(!activeDrag.orientation ? activeDrag.segIndex * 33 + 15 : 15)
|
||||||
|
}px`,
|
||||||
|
top: `${activeDrag.currentPos.y -
|
||||||
|
(activeDrag.orientation ? activeDrag.segIndex * 33 + 15 : 15)
|
||||||
|
}px`,
|
||||||
|
width: `${!activeDrag.orientation
|
||||||
|
? activeDrag.ship.size * 30 + (activeDrag.ship.size - 1) * 3
|
||||||
|
: 30
|
||||||
|
}px`,
|
||||||
|
height: `${activeDrag.orientation
|
||||||
|
? activeDrag.ship.size * 30 + (activeDrag.ship.size - 1) * 3
|
||||||
|
: 30
|
||||||
|
}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: activeDrag.ship.size }).map((_, segIdx) => (
|
||||||
|
<div key={segIdx} className="ship-segment" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+73
-15
@@ -6,10 +6,31 @@ export class GameConnectionError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Опкоды протокола game-server
|
// Опкоды протокола game-server
|
||||||
const OP_JOIN = 1
|
export const OP_JOIN = 1
|
||||||
const OP_END = 2
|
export const OP_END = 2
|
||||||
const OP_STATE = 3
|
export const OP_STATE = 3
|
||||||
const OP_ACK = 4
|
export const OP_ACK = 4
|
||||||
|
export const OP_PLACE_SHIPS = 5
|
||||||
|
export const OP_FIRE = 6
|
||||||
|
export const OP_FIRE_RESULT = 7
|
||||||
|
|
||||||
|
// Коды состояний протокола game-server
|
||||||
|
export const STATE_PLACING_SHIPS = 1 // оба игрока расставляют корабли
|
||||||
|
export const STATE_OPPONENT_READY = 2 // соперник готов
|
||||||
|
export const STATE_WAIT_OPPONENT_READY = 3 // ожидайте готовности соперника
|
||||||
|
export const STATE_YOUR_TURN = 4 // ваш ход
|
||||||
|
export const STATE_WAIT_OPPONENT_TURN = 5 // ожидайте ход соперника
|
||||||
|
|
||||||
|
// Коды результатов выстрела (FIRE_RESULT)
|
||||||
|
export const FIRE_RESULT_MISS = 1 // промах
|
||||||
|
export const FIRE_RESULT_HIT = 2 // попадание
|
||||||
|
export const FIRE_RESULT_SUNK = 3 // корабль потоплен
|
||||||
|
export const FIRE_RESULT_INVALID_POSITION = 4 // неверная позиция
|
||||||
|
|
||||||
|
// Коды завершения партии (END)
|
||||||
|
export const END_CODE_TIMEOUT = -1 // join timeout
|
||||||
|
export const END_CODE_VICTORY = 1 // победа
|
||||||
|
export const END_CODE_DEFEAT = 2 // поражение
|
||||||
|
|
||||||
const CLOSE_REASONS = {
|
const CLOSE_REASONS = {
|
||||||
4000: 'Соединение заменено новым',
|
4000: 'Соединение заменено новым',
|
||||||
@@ -31,6 +52,8 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
|||||||
let settled = false
|
let settled = false
|
||||||
let lastSeq = 0
|
let lastSeq = 0
|
||||||
|
|
||||||
|
let connection = null
|
||||||
|
|
||||||
const promise = new Promise((resolve, reject) => {
|
const promise = new Promise((resolve, reject) => {
|
||||||
const fail = (err) => {
|
const fail = (err) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
@@ -39,19 +62,36 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
|||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const succeed = (connection) => {
|
const succeed = (conn) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
resolve(connection)
|
resolve(conn)
|
||||||
// Сокет НЕ закрываем — соединение переиспользуется для игры
|
|
||||||
ws.onerror = null
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
if (connection.onClose) connection.onClose(event.code)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ws = new WebSocket(gameServerUrl)
|
ws = new WebSocket(gameServerUrl)
|
||||||
|
|
||||||
|
connection = {
|
||||||
|
socket: ws,
|
||||||
|
send: sendMessage,
|
||||||
|
placeShips(ships) {
|
||||||
|
// Сортируем строго по возрастанию размера: 4x1, 3x2, 2x3, 1x4
|
||||||
|
const sorted = [...ships].sort((a, b) => a.size - b.size)
|
||||||
|
const payload = [OP_PLACE_SHIPS]
|
||||||
|
for (const s of sorted) {
|
||||||
|
payload.push(Boolean(s.orientation), Number(s.x), Number(s.y))
|
||||||
|
}
|
||||||
|
sendMessage(payload)
|
||||||
|
},
|
||||||
|
fire(x, y) {
|
||||||
|
sendMessage([OP_FIRE, Number(x), Number(y)])
|
||||||
|
},
|
||||||
|
onState: null,
|
||||||
|
onFireResult: null,
|
||||||
|
onEnd: null,
|
||||||
|
onClose: null,
|
||||||
|
onError: null,
|
||||||
|
}
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
ws.send(JSON.stringify([OP_JOIN, gameToken, userToken]))
|
ws.send(JSON.stringify([OP_JOIN, gameToken, userToken]))
|
||||||
}
|
}
|
||||||
@@ -73,18 +113,34 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
|||||||
|
|
||||||
if (opcode === OP_STATE) {
|
if (opcode === OP_STATE) {
|
||||||
const state = msg[2]
|
const state = msg[2]
|
||||||
if (state === 1) {
|
if (!settled && state === STATE_PLACING_SHIPS) {
|
||||||
succeed({ socket: ws, send: sendMessage })
|
succeed(connection)
|
||||||
|
}
|
||||||
|
if (connection && typeof connection.onState === 'function') {
|
||||||
|
connection.onState(state)
|
||||||
|
}
|
||||||
|
} else if (opcode === OP_FIRE_RESULT) {
|
||||||
|
const resultCode = msg[2]
|
||||||
|
if (connection && typeof connection.onFireResult === 'function') {
|
||||||
|
connection.onFireResult(resultCode)
|
||||||
}
|
}
|
||||||
} else if (opcode === OP_END) {
|
} else if (opcode === OP_END) {
|
||||||
const code = msg[2]
|
const code = msg[2]
|
||||||
const reason = msg[3] || 'Партия завершена'
|
const reason = msg[3] || 'Партия завершена'
|
||||||
|
if (!settled) {
|
||||||
fail(new GameConnectionError(code === -1 ? 'join_timeout' : 'closed', reason))
|
fail(new GameConnectionError(code === -1 ? 'join_timeout' : 'closed', reason))
|
||||||
|
} else if (connection && typeof connection.onEnd === 'function') {
|
||||||
|
connection.onEnd(code, reason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = (err) => {
|
||||||
if (!settled) fail(new GameConnectionError('connection', 'Сетевая ошибка'))
|
if (!settled) {
|
||||||
|
fail(new GameConnectionError('connection', 'Сетевая ошибка'))
|
||||||
|
} else if (connection && typeof connection.onError === 'function') {
|
||||||
|
connection.onError(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
@@ -93,6 +149,8 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
|||||||
CLOSE_REASONS[event.code] ||
|
CLOSE_REASONS[event.code] ||
|
||||||
(event.code === 1000 ? 'Соединение закрыто' : 'Соединение прервано')
|
(event.code === 1000 ? 'Соединение закрыто' : 'Соединение прервано')
|
||||||
fail(new GameConnectionError('closed', message))
|
fail(new GameConnectionError('closed', message))
|
||||||
|
} else if (connection && typeof connection.onClose === 'function') {
|
||||||
|
connection.onClose(event.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { AuthProvider } from './auth'
|
import { AuthProvider, AuthModalProvider } from './auth'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<AuthModalProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthModalProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
+201
-81
@@ -1,6 +1,4 @@
|
|||||||
import { wsUrl } from './api'
|
import { getConsumer, connectCable } from './cable.js'
|
||||||
|
|
||||||
const CHANNEL_IDENTIFIER = JSON.stringify({ channel: 'MatchmakingChannel' })
|
|
||||||
|
|
||||||
export class MatchmakingError extends Error {
|
export class MatchmakingError extends Error {
|
||||||
constructor(code, message) {
|
constructor(code, message) {
|
||||||
@@ -9,75 +7,193 @@ export class MatchmakingError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Текущая активная сессия матчмейкинга на уровне модуля
|
||||||
|
let activeSession = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подключается к main-server по WebSocket (Action Cable) и инициирует
|
* Инициирует поиск соперника через ActionCable MatchmakingChannel.
|
||||||
* поиск соперника. Возвращает объект с promise (резолвится при событии
|
* Возвращает объект с promise (резолвится при событии `matched`)
|
||||||
* `matched`) и функцией отмены `cancel()`.
|
* и функцией отмены `cancel()`.
|
||||||
*
|
*
|
||||||
* @param {string} token JWT пользователя
|
* Соединение WebSocket НЕ закрывается: при завершении или отмене
|
||||||
* @param {(status: 'connecting' | 'searching') => void} onStatus
|
* отменяется только подписка на канал (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(token, onStatus) {
|
export function createMatchmaking(consumerOrToken, onStatus) {
|
||||||
let ws = null
|
let consumer = null
|
||||||
let settled = false
|
let statusCallback = onStatus
|
||||||
|
|
||||||
const send = (payload) => {
|
if (typeof consumerOrToken === 'function') {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
statusCallback = consumerOrToken
|
||||||
ws.send(JSON.stringify(payload))
|
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) => {
|
// Если уже существует активная сессия подписки (например, от предыдущего быстрого mount в StrictMode)
|
||||||
const fail = (err) => {
|
if (activeSession && activeSession.consumer === consumer && !activeSession.settled) {
|
||||||
if (settled) return
|
activeSession.cancelled = false
|
||||||
settled = true
|
activeSession.statusCallback = statusCallback
|
||||||
cleanup()
|
|
||||||
reject(err)
|
// Если подписка уже успела подтвердиться ранее
|
||||||
|
if (activeSession.isSubscribed) {
|
||||||
|
statusCallback?.('searching')
|
||||||
|
if (!activeSession.started) {
|
||||||
|
activeSession.subscription.perform('start')
|
||||||
|
activeSession.started = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusCallback?.('connecting')
|
||||||
}
|
}
|
||||||
|
|
||||||
const succeed = (match) => {
|
return {
|
||||||
if (settled) return
|
promise: activeSession.promise,
|
||||||
settled = true
|
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()
|
cleanup()
|
||||||
resolve(match)
|
resolve(match)
|
||||||
}
|
}
|
||||||
|
|
||||||
onStatus('connecting')
|
function fail(err) {
|
||||||
|
if (session.settled) return
|
||||||
ws = new WebSocket(wsUrl(`/cable?token=${encodeURIComponent(token)}`))
|
session.settled = true
|
||||||
let subscribed = false
|
cleanup()
|
||||||
|
reject(err)
|
||||||
ws.onopen = () => {
|
|
||||||
send({ command: 'subscribe', identifier: CHANNEL_IDENTIFIER })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
session.statusCallback?.('connecting')
|
||||||
let msg
|
|
||||||
|
// Защита от «зависшего» подключения / подписки
|
||||||
|
session.connectTimeoutId = setTimeout(() => {
|
||||||
|
if (!session.settled && !session.started) {
|
||||||
|
fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу'))
|
||||||
|
}
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(event.data)
|
session.subscription = consumer.subscriptions.create(
|
||||||
|
{ channel: 'MatchmakingChannel' },
|
||||||
|
{
|
||||||
|
connected() {
|
||||||
|
session.isSubscribed = true
|
||||||
|
|
||||||
|
// Если поиск был отменен ДО подтверждения подписки (отложенная отписка)
|
||||||
|
if (session.cancelled) {
|
||||||
|
try {
|
||||||
|
this.perform('stop')
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'confirm_subscription') {
|
if (session.settled) return
|
||||||
subscribed = true
|
|
||||||
onStatus('searching')
|
if (session.connectTimeoutId) {
|
||||||
send({
|
clearTimeout(session.connectTimeoutId)
|
||||||
command: 'message',
|
session.connectTimeoutId = null
|
||||||
identifier: CHANNEL_IDENTIFIER,
|
}
|
||||||
data: JSON.stringify({ action: 'start' }),
|
if (session.disconnectTimeoutId) {
|
||||||
})
|
clearTimeout(session.disconnectTimeoutId)
|
||||||
return
|
session.disconnectTimeoutId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'reject_subscription') {
|
session.statusCallback?.('searching')
|
||||||
|
this.perform('start')
|
||||||
|
session.started = true
|
||||||
|
},
|
||||||
|
|
||||||
|
rejected() {
|
||||||
fail(new MatchmakingError('rejected', 'Не удалось подписаться на поиск соперника'))
|
fail(new MatchmakingError('rejected', 'Не удалось подписаться на поиск соперника'))
|
||||||
return
|
},
|
||||||
|
|
||||||
|
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', 'Соединение прервано'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
if (msg.type === 'ping' || msg.type === 'welcome') return
|
received(message) {
|
||||||
|
if (session.settled || session.cancelled) return
|
||||||
const message = msg.message
|
|
||||||
if (!message || typeof message !== 'object') return
|
if (!message || typeof message !== 'object') return
|
||||||
|
|
||||||
if (message.type === 'matched') {
|
if (message.type === 'matched') {
|
||||||
@@ -90,47 +206,51 @@ export function createMatchmaking(token, onStatus) {
|
|||||||
} else if (message.type === 'timeout') {
|
} else if (message.type === 'timeout') {
|
||||||
fail(new MatchmakingError('timeout', 'Соперник не найден'))
|
fail(new MatchmakingError('timeout', 'Соперник не найден'))
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
)
|
||||||
ws.onerror = () => {
|
} catch (err) {
|
||||||
if (!settled) fail(new MatchmakingError('connection', 'Сетевая ошибка'))
|
fail(new MatchmakingError('connection', err.message || 'Ошибка подключения'))
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
if (!settled) {
|
|
||||||
const err = new MatchmakingError('connection', 'Соединение прервано')
|
|
||||||
err.closeCode = event.code
|
|
||||||
fail(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подписка не подтверждена — защита от «зависшего» соединения
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!settled && !subscribed) {
|
|
||||||
fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу'))
|
|
||||||
}
|
|
||||||
}, 10000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function cleanup() {
|
session.cancel = function cancel() {
|
||||||
if (ws) {
|
if (session.settled) return
|
||||||
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null
|
session.cancelled = true
|
||||||
ws.close()
|
|
||||||
ws = null
|
if (session.connectTimeoutId) {
|
||||||
|
clearTimeout(session.connectTimeoutId)
|
||||||
|
session.connectTimeoutId = null
|
||||||
}
|
}
|
||||||
|
if (session.disconnectTimeoutId) {
|
||||||
|
clearTimeout(session.disconnectTimeoutId)
|
||||||
|
session.disconnectTimeoutId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Явная отмена поиска: команда `stop` + закрытие сокета. */
|
// Если сервер уже подтвердил подписку — сразу отправляем stop и отписываемся
|
||||||
function cancel() {
|
if (session.isSubscribed && session.subscription) {
|
||||||
if (settled) return
|
try {
|
||||||
settled = true
|
if (session.started) {
|
||||||
send({
|
session.subscription.perform('stop')
|
||||||
command: 'message',
|
}
|
||||||
identifier: CHANNEL_IDENTIFIER,
|
} catch {
|
||||||
data: JSON.stringify({ action: 'stop' }),
|
// ignore
|
||||||
})
|
}
|
||||||
cleanup()
|
try {
|
||||||
|
session.subscription.unsubscribe()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
session.subscription = null
|
||||||
|
if (activeSession === session) {
|
||||||
|
activeSession = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Если подписка ещё не подтверждена (isSubscribed === false),
|
||||||
|
// мы НЕ шлем немедленный unsubscribe, предотвращая гонку на сервере.
|
||||||
|
// Как только сервер пришлет подтверждение, сработает connected()
|
||||||
|
// и выполнит отложенный unsubscribe.
|
||||||
}
|
}
|
||||||
|
|
||||||
return { promise, cancel }
|
return { promise: session.promise, cancel: session.cancel }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// Размеры 10 кораблей по возрастанию размера:
|
||||||
|
// 4 однопалубных, 3 двухпалубных, 2 трехпалубных, 1 четырехпалубный
|
||||||
|
export const SHIP_CONFIGS = [
|
||||||
|
{ id: 0, size: 1 },
|
||||||
|
{ id: 1, size: 1 },
|
||||||
|
{ id: 2, size: 1 },
|
||||||
|
{ id: 3, size: 1 },
|
||||||
|
{ id: 4, size: 2 },
|
||||||
|
{ id: 5, size: 2 },
|
||||||
|
{ id: 6, size: 2 },
|
||||||
|
{ id: 7, size: 3 },
|
||||||
|
{ id: 8, size: 3 },
|
||||||
|
{ id: 9, size: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ROW_LETTERS = ['А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ж', 'З', 'И', 'К']
|
||||||
|
export const COL_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает начальное состояние кораблей (все в инвентаре, горизонтальные).
|
||||||
|
*/
|
||||||
|
export function createInitialShips() {
|
||||||
|
return SHIP_CONFIGS.map((cfg) => ({
|
||||||
|
id: cfg.id,
|
||||||
|
size: cfg.size,
|
||||||
|
placed: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
orientation: false, // false = горизонтально, true = вертикально
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает массив клеток { x, y }, занимаемых кораблем.
|
||||||
|
*/
|
||||||
|
export function getShipCells(ship) {
|
||||||
|
const cells = []
|
||||||
|
for (let i = 0; i < ship.size; i++) {
|
||||||
|
cells.push({
|
||||||
|
x: ship.orientation ? ship.x : ship.x + i,
|
||||||
|
y: ship.orientation ? ship.y + i : ship.y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, соприкасаются ли или перекрываются ли два корабля.
|
||||||
|
* Корабли не должны соприкасаться сторонами или углами (зазор минимум 1 клетка).
|
||||||
|
*/
|
||||||
|
export function shipsCollideOrTouch(s1, s2) {
|
||||||
|
const cells1 = getShipCells(s1)
|
||||||
|
const cells2 = getShipCells(s2)
|
||||||
|
for (const c1 of cells1) {
|
||||||
|
for (const c2 of cells2) {
|
||||||
|
if (Math.abs(c1.x - c2.x) <= 1 && Math.abs(c1.y - c2.y) <= 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, можно ли поставить корабль в позицию (x, y) с заданной ориентацией
|
||||||
|
* среди массива уже расставленных кораблей placedShips.
|
||||||
|
*/
|
||||||
|
export function canPlaceShip(ship, x, y, orientation, placedShips) {
|
||||||
|
const maxX = orientation ? x : x + ship.size - 1
|
||||||
|
const maxY = orientation ? y + ship.size - 1 : y
|
||||||
|
|
||||||
|
if (x < 0 || maxX >= 10 || y < 0 || maxY >= 10) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const testShip = { ...ship, x, y, orientation }
|
||||||
|
|
||||||
|
for (const other of placedShips) {
|
||||||
|
if (other.id === ship.id || !other.placed) continue
|
||||||
|
if (shipsCollideOrTouch(testShip, other)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычисляет список позиций-кандидатов для поворота корабля:
|
||||||
|
* При повороте корабль размера s, стоящий горизонтально в позиции (x, y),
|
||||||
|
* должен попытаться встать во все позиции, имеющие общую точку с позицией до поворота:
|
||||||
|
* (x + dx, y + dy), dx = 0...s-1, dy = -s+1...0.
|
||||||
|
* И аналогично для вертикального корабля:
|
||||||
|
* (x + dx, y + dy), dy = 0...s-1, dx = -s+1...0.
|
||||||
|
*
|
||||||
|
* Кандидаты сортируются: сначала без смещения (dx=0, dy=0),
|
||||||
|
* затем по возрастанию расстояния/сдвига от исходной клетки.
|
||||||
|
*/
|
||||||
|
export function getRotationCandidates(ship) {
|
||||||
|
const { size, orientation, x, y } = ship
|
||||||
|
const candidates = []
|
||||||
|
|
||||||
|
if (!orientation) {
|
||||||
|
// Текущий: горизонтальный -> поворачивается в вертикальный (orientation: true)
|
||||||
|
for (let dx = 0; dx < size; dx++) {
|
||||||
|
for (let dy = -size + 1; dy <= 0; dy++) {
|
||||||
|
candidates.push({
|
||||||
|
x: x + dx,
|
||||||
|
y: y + dy,
|
||||||
|
orientation: true,
|
||||||
|
dist: dx * dx + dy * dy,
|
||||||
|
absDist: Math.abs(dx) + Math.abs(dy),
|
||||||
|
dx,
|
||||||
|
dy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Текущий: вертикальный -> поворачивается в горизонтальный (orientation: false)
|
||||||
|
for (let dy = 0; dy < size; dy++) {
|
||||||
|
for (let dx = -size + 1; dx <= 0; dx++) {
|
||||||
|
candidates.push({
|
||||||
|
x: x + dx,
|
||||||
|
y: y + dy,
|
||||||
|
orientation: false,
|
||||||
|
dist: dx * dx + dy * dy,
|
||||||
|
absDist: Math.abs(dx) + Math.abs(dy),
|
||||||
|
dx,
|
||||||
|
dy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приоритет: сначала dx=0, dy=0, затем по минимальному расстоянию
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
if (a.dist !== b.dist) return a.dist - b.dist
|
||||||
|
if (a.absDist !== b.absDist) return a.absDist - b.absDist
|
||||||
|
if (a.dx !== b.dx) return a.dx - b.dx
|
||||||
|
return a.dy - b.dy
|
||||||
|
})
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Находит первую подходящую позицию для поворота корабля.
|
||||||
|
* Если ни одна позиция не подходит — возвращает null (корабль не поворачивается).
|
||||||
|
*/
|
||||||
|
export function findValidRotation(ship, allShips) {
|
||||||
|
const candidates = getRotationCandidates(ship)
|
||||||
|
const otherPlacedShips = allShips.filter((s) => s.id !== ship.id && s.placed)
|
||||||
|
|
||||||
|
for (const cand of candidates) {
|
||||||
|
if (canPlaceShip(ship, cand.x, cand.y, cand.orientation, otherPlacedShips)) {
|
||||||
|
return {
|
||||||
|
x: cand.x,
|
||||||
|
y: cand.y,
|
||||||
|
orientation: cand.orientation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует случайную валидную расстановку всех 10 кораблей с помощью алгоритма с backtracking.
|
||||||
|
*/
|
||||||
|
export function generateRandomPlacement() {
|
||||||
|
// Начинаем с самых больших кораблей (размер 4, 3, 3, 2, 2, 2, 1, 1, 1, 1)
|
||||||
|
const sortedConfig = [...SHIP_CONFIGS].sort((a, b) => b.size - a.size)
|
||||||
|
const placed = []
|
||||||
|
|
||||||
|
function backtrack(index) {
|
||||||
|
if (index === sortedConfig.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = sortedConfig[index]
|
||||||
|
const candidates = []
|
||||||
|
|
||||||
|
for (let x = 0; x < 10; x++) {
|
||||||
|
for (let y = 0; y < 10; y++) {
|
||||||
|
for (const orientation of [false, true]) {
|
||||||
|
const testShip = { id: cfg.id, size: cfg.size, x, y, orientation, placed: true }
|
||||||
|
if (canPlaceShip(testShip, x, y, orientation, placed)) {
|
||||||
|
candidates.push({ x, y, orientation })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Случайное перемешивание кандидатов (Fisher-Yates)
|
||||||
|
for (let i = candidates.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[candidates[i], candidates[j]] = [candidates[j], candidates[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cand of candidates) {
|
||||||
|
const newShip = {
|
||||||
|
id: cfg.id,
|
||||||
|
size: cfg.size,
|
||||||
|
placed: true,
|
||||||
|
x: cand.x,
|
||||||
|
y: cand.y,
|
||||||
|
orientation: cand.orientation,
|
||||||
|
}
|
||||||
|
placed.push(newShip)
|
||||||
|
|
||||||
|
if (backtrack(index + 1)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
placed.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
backtrack(0)
|
||||||
|
|
||||||
|
// Возвращаем в исходном порядке по id 0..9
|
||||||
|
placed.sort((a, b) => a.id - b.id)
|
||||||
|
return placed
|
||||||
|
}
|
||||||
+618
-1
@@ -149,7 +149,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input:not([type]), input[type="text"] {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -510,3 +510,620 @@ input {
|
|||||||
height: 58px;
|
height: 58px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Экран расстановки кораблей */
|
||||||
|
.game-screen-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px 20px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.is-dragging,
|
||||||
|
body.is-dragging * {
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-screen-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-opponent-badge {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary-darker);
|
||||||
|
font-family: 'Comfortaa';
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(47, 128, 237, 0.15);
|
||||||
|
animation: badge-pulse 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes badge-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 50px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 400px;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Игровое поле с линейками */
|
||||||
|
.sea-board-wrapper {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sea-board-corner {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sea-board-col-ruler {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 3px;
|
||||||
|
margin-bottom: 8px; /* небольшой отступ от самого поля */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sea-board-row-ruler {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 3px 0;
|
||||||
|
margin-right: 8px; /* небольшой отступ от самого поля */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sea-ruler-cell {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: 'Comfortaa', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sea-grid-container {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
position: relative;
|
||||||
|
width: 333px;
|
||||||
|
height: 333px;
|
||||||
|
box-shadow: 0 8px 24px rgba(30, 58, 95, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sea-ships-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Корабли */
|
||||||
|
.board-ship {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
z-index: 2;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
transition: transform 0.08s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-ship:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-ship.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-ship.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-segment {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
background-color: var(--ship);
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Закругление концов корабля */
|
||||||
|
.board-ship.horizontal .ship-segment:first-child {
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-ship.horizontal .ship-segment:last-child {
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-ship.vertical .ship-segment:first-child {
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-ship.vertical .ship-segment:last-child {
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Корабль в инвентаре */
|
||||||
|
.board-ship.in-inventory {
|
||||||
|
position: relative;
|
||||||
|
left: 0 !important;
|
||||||
|
top: 0 !important;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-ship.in-inventory:hover .ship-segment {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Призрак при переносе на сетке */
|
||||||
|
.ghost-preview {
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-valid .ship-segment {
|
||||||
|
background-color: rgba(47, 128, 237, 0.6);
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-invalid .ship-segment {
|
||||||
|
background-color: rgba(224, 72, 62, 0.65);
|
||||||
|
outline: 2px solid var(--danger);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Парящий под курсором корабль */
|
||||||
|
.floating-drag-ship {
|
||||||
|
position: fixed;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0.92;
|
||||||
|
filter: drop-shadow(0 8px 18px rgba(29, 58, 95, 0.35));
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-drag-ship.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-drag-ship.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-drag-ship .ship-segment {
|
||||||
|
background-color: var(--ship);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-drag-ship.horizontal .ship-segment:first-child {
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-drag-ship.horizontal .ship-segment:last-child {
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-drag-ship.vertical .ship-segment:first-child {
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-drag-ship.vertical .ship-segment:last-child {
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки под полем */
|
||||||
|
.placement-board-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-action-btn {
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 15px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-auto-btn {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Инвентарь */
|
||||||
|
.ship-inventory {
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(47, 128, 237, 0.15);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
box-shadow: 0 8px 24px rgba(30, 58, 95, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-title {
|
||||||
|
font-family: 'Comfortaa', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 30px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Текстовая подсказка */
|
||||||
|
.placement-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
border: 1px solid rgba(47, 128, 237, 0.2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
box-shadow: 0 4px 14px rgba(30, 58, 95, 0.04);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-hint-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-hint-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-hint-text strong {
|
||||||
|
color: var(--primary-darker);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка Готово */
|
||||||
|
.placement-ready-wrapper {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready-btn {
|
||||||
|
min-width: 170px;
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-modal {
|
||||||
|
text-align: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.placement-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-right {
|
||||||
|
min-width: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-ready-wrapper {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Боевой экран (Battle Screen)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.battle-screen-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 920px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-status-badge {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary-darker);
|
||||||
|
font-family: 'Comfortaa', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(47, 128, 237, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surrender-btn {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-boards-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-field-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-field-title {
|
||||||
|
font-family: 'Comfortaa', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стрелка между полями */
|
||||||
|
.battle-turn-indicator-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-arrow {
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(47, 128, 237, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-arrow.arrow-right {
|
||||||
|
transform: scaleX(1);
|
||||||
|
animation: arrow-nudge-right 1.6s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-arrow.arrow-left {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
animation: arrow-nudge-left 1.6s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes arrow-nudge-right {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scaleX(1) translateX(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleX(1) translateX(6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes arrow-nudge-left {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scaleX(-1) translateX(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleX(-1) translateX(6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-arrow-text {
|
||||||
|
font-family: 'Comfortaa', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Прицеливание на интерактивном поле соперника */
|
||||||
|
.interactive-grid .sea-cell:not(.miss-cell):not(.damaged-ship-cell):not(.destroyed-ship-cell) {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Подсветка строки и столбца более тёмным цветом */
|
||||||
|
.sea-cell.aim-row,
|
||||||
|
.sea-cell.aim-col {
|
||||||
|
background-color: #d7e8fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Медленно мигающая клетка на пересечении прицела */
|
||||||
|
.sea-cell.aim-target {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
cursor: crosshair;
|
||||||
|
animation: target-slow-blink 1.4s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes target-slow-blink {
|
||||||
|
0%, 100% {
|
||||||
|
background-color: #82b3f4;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--primary);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-color: #2f80ed;
|
||||||
|
box-shadow: inset 0 0 0 3px #134c9c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Модальное окно "Сдаться" */
|
||||||
|
.surrender-modal {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surrender-desc {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin: 12px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surrender-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Экраны Победы / Поражения */
|
||||||
|
.end-game-title {
|
||||||
|
font-family: 'Comfortaa', sans-serif;
|
||||||
|
font-size: clamp(40px, 8vw, 68px);
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-title {
|
||||||
|
color: var(--primary);
|
||||||
|
text-shadow: 0 4px 12px rgba(47, 128, 237, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defeat-title {
|
||||||
|
color: var(--danger);
|
||||||
|
text-shadow: 0 4px 12px rgba(224, 72, 62, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-game-subtitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.battle-boards-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-turn-indicator-col {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-arrow.arrow-left {
|
||||||
|
transform: rotate(90deg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-arrow.arrow-right {
|
||||||
|
transform: rotate(-90deg) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+126
-25
@@ -28,11 +28,14 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
|||||||
Все сообщения — JSON-массивы, первый элемент — числовой опкод.
|
Все сообщения — JSON-массивы, первый элемент — числовой опкод.
|
||||||
|
|
||||||
| Опкод | Направление | Название | Формат |
|
| Опкод | Направление | Название | Формат |
|
||||||
|-------|-------------|----------|--------|
|
|-------|-------------|-------------|--------|
|
||||||
| 1 | C -> S | JOIN | `[1, "<game_token>", "<user_token>"]` |
|
| 1 | C -> S | JOIN | `[1, "<game_token>", "<user_token>"]` |
|
||||||
| 2 | S -> C | END | `[2, <seq>, <code>, "<reason>"]` |
|
| 2 | S -> C | END | `[2, <seq>, <code>, "<reason>"]` |
|
||||||
| 3 | S -> C | STATE | `[3, <seq>, <state_code>]` |
|
| 3 | S -> C | STATE | `[3, <seq>, <state_code>]` |
|
||||||
| 4 | C <-> S | ACK | `[4, <seq>]` |
|
| 4 | C <-> S | ACK | `[4, <seq>]` |
|
||||||
|
| 5 | C -> S | PLACE_SHIPS | `[5, ship0_orientation, ship0_x, ship0_y, ...]` |
|
||||||
|
| 6 | C -> S | FIRE | `[6, <x>, <y>]` |
|
||||||
|
| 7 | S -> C | FIRE_RESULT | `[7, <seq>, <result_code>]` |
|
||||||
|
|
||||||
### JOIN (1)
|
### JOIN (1)
|
||||||
|
|
||||||
@@ -53,10 +56,10 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
|||||||
|
|
||||||
Проверки при JOIN:
|
Проверки при JOIN:
|
||||||
|
|
||||||
- подпись (HS256) и срок действия обоих токенов;
|
- подпись (HS256) и срок действия обоих токенов (при первом подключении к партии);
|
||||||
- `game_server` строго равен `PUBLIC_ADDR` этого сервера;
|
- `game_server` строго равен `PUBLIC_ADDR` этого сервера;
|
||||||
- `player1_id != player2_id`;
|
- `player1_id > 0` и `player2_id > 0`, а также `player1_id != player2_id`;
|
||||||
- `user_id` из пользовательского токена совпадает с `player1_id` или
|
- `user_id > 0` из пользовательского токена совпадает с `player1_id` или
|
||||||
`player2_id` игрового токена;
|
`player2_id` игрового токена;
|
||||||
- партия не находится в реестре завершённых (см. ниже).
|
- партия не находится в реестре завершённых (см. ниже).
|
||||||
|
|
||||||
@@ -66,11 +69,15 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
|||||||
|
|
||||||
Партия завершена. Формат: `[2, <seq>, <code>, "<reason>"]`.
|
Партия завершена. Формат: `[2, <seq>, <code>, "<reason>"]`.
|
||||||
|
|
||||||
Коды завершения:
|
Коды завершения (отсылаются с точки зрения получателя):
|
||||||
|
|
||||||
| Код | Причина |
|
| Код | Причина | Описание |
|
||||||
|-----|---------|
|
|-----|---------|----------|
|
||||||
| -1 | `join timeout exceeded` — второй игрок не присоединился вовремя |
|
| -1 | `join timeout exceeded` | второй игрок не присоединился вовремя |
|
||||||
|
| 1 | `victory` | победа (все корабли соперника уничтожены) |
|
||||||
|
| 2 | `defeat` | поражение (все корабли игрока уничтожены) |
|
||||||
|
|
||||||
|
После отправки сообщения END сервер сразу закрывает WebSocket-соединение с кодом 1000 (нормальное завершение). Сохранённое сообщение END хранится в реестре завершённых партий и автоматически воспроизводится игроку при повторном подключении перед закрытием соединения.
|
||||||
|
|
||||||
### STATE (3)
|
### STATE (3)
|
||||||
|
|
||||||
@@ -85,12 +92,19 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
|||||||
| 4 | ваш ход |
|
| 4 | ваш ход |
|
||||||
| 5 | ожидайте ход соперника |
|
| 5 | ожидайте ход соперника |
|
||||||
|
|
||||||
Сейчас рассылается только состояние `1` — при старте партии (когда оба
|
Смена состояний:
|
||||||
игрока впервые присоединились).
|
- Состояние `1` рассылается обоим игрокам при старте партии (когда оба игрока впервые присоединились).
|
||||||
|
- При получении расстановки кораблей от одного из игроков партия переходит в состояние ожидания соперника:
|
||||||
|
игрок, сдавший корабли, получает состояние `3` («ожидайте готовности соперника»), а его противник — состояние `2` («соперник готов»).
|
||||||
|
- Когда расстановку сдаёт второй игрок, игра переходит в фазу ходов: игрок, завершивший расстановку первым,
|
||||||
|
получает состояние `4` («ваш ход»), а второй игрок — состояние `5` («ожидайте ход соперника»).
|
||||||
|
- При промахе ход переходит к противнику: стрелявший игрок получает состояние `5` («ожидайте ход соперника»), а противник — состояние `4` («ваш ход»).
|
||||||
|
- При попадании или потоплении корабля ход остаётся у текущего стрелка (состояние матча не изменяется).
|
||||||
|
- При уничтожении всех кораблей одного из игроков игра завершается победой: партия не переходит в новые состояния STATE — вместо этого обоим игрокам рассылается сообщение END (победителю — с кодом 1 victory, проигравшему — с кодом 2 defeat), после чего соединение сразу закрывается.
|
||||||
|
|
||||||
### ACK (4)
|
### ACK (4)
|
||||||
|
|
||||||
Каждое изменяющее состояние сообщение S->C (END, STATE) несёт `seq` —
|
Каждое изменяющее состояние сообщение S->C (END, STATE, FIRE_RESULT) несёт `seq` —
|
||||||
монотонно возрастающий номер в рамках пары (партия, игрок). Клиент
|
монотонно возрастающий номер в рамках пары (партия, игрок). Клиент
|
||||||
подтверждает доставку сообщением `[4, <seq>]`; ACK кумулятивен
|
подтверждает доставку сообщением `[4, <seq>]`; ACK кумулятивен
|
||||||
(подтверждает все сообщения с `seq` <= указанного).
|
(подтверждает все сообщения с `seq` <= указанного).
|
||||||
@@ -103,24 +117,107 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
|||||||
Направление S->C зарезервировано для подтверждения будущих сообщений
|
Направление 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).
|
||||||
|
|
||||||
|
### FIRE (6)
|
||||||
|
|
||||||
|
Сообщение выстрела от клиента к серверу. Содержит координаты клетки на поле соперника:
|
||||||
|
```json
|
||||||
|
[6, x, y]
|
||||||
|
```
|
||||||
|
- `x` (integer): координата X (колонка, `0 <= x <= 9`).
|
||||||
|
- `y` (integer): координата Y (строка, `0 <= y <= 9`).
|
||||||
|
|
||||||
|
Обработка сообщения сервером:
|
||||||
|
- Сообщение принимается только от игрока, чей сейчас ход (`StateYourTurn` / серверное состояние `StatePlayer1Turn` или `StatePlayer2Turn`). Сообщения от игрока не в свой ход, во время фазы расстановки или после завершения партии игнорируются без ответа.
|
||||||
|
- Если координаты выходят за границы игрового поля (`x < 0` или `x >= 10`, `y < 0` или `y >= 10`) либо клетка уже была поражена ранее (выстрелом, попаданием или окаймлением потопленного корабля), сервер возвращает стрелявшему игроку результат `4` (`неверная позиция`). Право хода остаётся у текущего игрока.
|
||||||
|
- Если выстрел произведён в валидную ещё не поражённую клетку:
|
||||||
|
- При промахе (клетка с водой) клетка помечается поражённой, игроку отправляется результат `1` (`промах`), а ход переходит противнику (рассылается `STATE` 5 и 4).
|
||||||
|
- При попадании в корабль, если корабль ещё не уничтожен полностью, клетка помечается поражённой, игроку отправляется результат `2` (`простое попадание`). Стрелявший игрок продолжает свой ход.
|
||||||
|
- Если корабль уничтожен полностью, все клетки корабля и все соседние с ним клетки (включая диагональные) помечаются поражёнными, а игроку отправляется результат `3` (`корабль потоплен`). Если после этого у противника не осталось непотопленных кораблей, партия завершается: сервер отправляет обоим игрокам сообщение END (`1` victory победителю и `2` defeat проигравшему) и сразу закрывает соединения. Если у противника ещё остались корабли, стрелявший игрок продолжает ход.
|
||||||
|
|
||||||
|
### FIRE_RESULT (7)
|
||||||
|
|
||||||
|
Результат выстрела, отправляемый сервером стрелявшему игроку в ответ на выстрел. Содержит порядковый номер сообщения `seq` для подтверждения через ACK и числовой код результата:
|
||||||
|
```json
|
||||||
|
[7, <seq>, <result_code>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Коды результатов выстрела:
|
||||||
|
|
||||||
|
| Код | Результат | Описание |
|
||||||
|
|-----|-----------|----------|
|
||||||
|
| 1 | `miss` (промах) | Выстрел в пустую клетку поля соперника. Клетка помечается как поражённая, ход переходит сопернику. |
|
||||||
|
| 2 | `hit` (простое попадание) | Попадание в палубу корабля соперника, но корабль ещё имеет уцелевшие палубы. Клетка помечается как поражённая, право хода остаётся у стрелявшего игрока. |
|
||||||
|
| 3 | `sunk` (корабль потоплен) | Корабль полностью уничтожен последним выстрелом. Все палубы корабля и все окружающие его клетки (включая диагональные) помечаются как поражённые. Если все корабли соперника уничтожены, партия завершается победой (рассылается сообщение END и соединение сразу закрывается). В противном случае право хода остаётся у стрелявшего игрока. |
|
||||||
|
| 4 | `invalid_position` (неверная позиция) | Координаты выходят за пределы поля `10x10` либо указанная клетка уже была поражена ранее. Право хода остаётся у игрока. |
|
||||||
|
|
||||||
## Коды закрытия соединения
|
## Коды закрытия соединения
|
||||||
|
|
||||||
| Код | Значение |
|
| Код | Значение |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 1000 | нормальное завершение (партия окончена, END уже отправлен) |
|
| 1000 | нормальное завершение (партия окончена, END уже отправлен) |
|
||||||
|
| 1001 | сервер завершает работу (graceful shutdown) |
|
||||||
| 4000 | соединение заменено новым соединением того же игрока |
|
| 4000 | соединение заменено новым соединением того же игрока |
|
||||||
| 4001 | первое сообщение не JOIN |
|
| 4001 | первое сообщение не JOIN |
|
||||||
| 4002 | сообщение не соответствует формату |
|
| 4002 | сообщение не соответствует формату |
|
||||||
| 4003 | недействительный игровой токен / чужой `game_server` |
|
| 4003 | недействительный игровой токен / чужой `game_server` / неположительные id |
|
||||||
| 4004 | недействительный пользовательский токен / игрок не участник партии |
|
| 4004 | недействительный пользовательский токен / игрок не участник партии / неположительный id |
|
||||||
|
|
||||||
## Обработка краевых случаев
|
## Обработка краевых случаев
|
||||||
|
|
||||||
- **Переподключение.** Игрок идентифицируется токенами, а не соединением.
|
- **Переподключение.** Игрок идентифицируется токенами, а не соединением.
|
||||||
Новое JOIN от того же игрока закрывает старое соединение (код 4000) и
|
Игровой токен запоминается в структуре матча при первом подключении:
|
||||||
занимает его слот; очередь неподтверждённых сообщений переживает
|
повторное подключение с тем же игровым токеном полностью пропускает
|
||||||
разрыв и переотправляется. Одновременно существует не более одного
|
его валидацию (включая проверку срока действия). Новое JOIN от того же
|
||||||
соединения на игрока.
|
игрока закрывает старое соединение (код 4000) и занимает его слот;
|
||||||
|
очередь неподтверждённых сообщений переживает разрыв и переотправляется.
|
||||||
|
Одновременно существует не более одного соединения на игрока.
|
||||||
|
- **Повторный JOIN.** Если клиент присылает повторный JOIN на уже
|
||||||
|
установленном соединении, такое сообщение просто игнорируется без ошибки.
|
||||||
- **Повторный JOIN после завершения партии.** Завершённые партии хранятся
|
- **Повторный JOIN после завершения партии.** Завершённые партии хранятся
|
||||||
в реестре (`match_id` -> END-сообщения обоих игроков) до истечения срока
|
в реестре (`match_id` -> END-сообщения обоих игроков) до истечения срока
|
||||||
действия игрового токена. Поздний JOIN получает в ответ сохранённое END
|
действия игрового токена. Поздний JOIN получает в ответ сохранённое END
|
||||||
@@ -131,8 +228,13 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
|||||||
токена: после истечения токена второй игрок всё равно не сможет войти.
|
токена: после истечения токена второй игрок всё равно не сможет войти.
|
||||||
- **JOIN с токеном, указывающим на другой сервер** (неверный
|
- **JOIN с токеном, указывающим на другой сервер** (неверный
|
||||||
`game_server`) отклоняется с кодом 4003.
|
`game_server`) отклоняется с кодом 4003.
|
||||||
- **`player1_id == player2_id`** отклоняется (код 4003) — иначе один игрок
|
- **`player1_id == player2_id`** или неположительные ID отклоняются (код 4003).
|
||||||
мог бы занять оба слота.
|
- **Валидация ACK.** Поле `seq` от клиента проверяется на положительность и
|
||||||
|
ненахождение за пределами последнего отправленного `seq`. Некорректный `seq`
|
||||||
|
отклоняется с ошибкой.
|
||||||
|
- **Асинхронная отправка.** Запись сообщений в WebSocket делегирована
|
||||||
|
отдельной per-connection горутине, что предотвращает блокировку мьютексов
|
||||||
|
менеджера во время write timeout.
|
||||||
- Неизвестные опкоды после JOIN игнорируются (совместимость с будущими
|
- Неизвестные опкоды после JOIN игнорируются (совместимость с будущими
|
||||||
версиями протокола); мусорные/не-JSON сообщения логируются и пропускаются.
|
версиями протокола); мусорные/не-JSON сообщения логируются и пропускаются.
|
||||||
- Keepalive: сервер пингует соединение каждые 25 с; размер входящего
|
- Keepalive: сервер пингует соединение каждые 25 с; размер входящего
|
||||||
@@ -156,15 +258,14 @@ internal/ws/ — websocket-эндпоинт: апгрейд, JOIN,
|
|||||||
go test -race ./...
|
go test -race ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
Покрыты: валидация токенов, разбор/сборка сообщений, сценарии менеджера
|
Покрыты: валидация токенов, разбор/сборка сообщений (включая расстановку кораблей),
|
||||||
(ожидание, старт, таймаут, повторный вход, замена соединения, ACK) и
|
сценарии менеджера (ожидание, старт, таймаут, повторный вход, замена соединения, ACK,
|
||||||
сквозные интеграционные тесты через настоящий websocket.
|
расстановка кораблей с переходами состояний) и сквозные интеграционные тесты через настоящий websocket.
|
||||||
|
|
||||||
## TODO (следующие этапы)
|
## TODO (следующие этапы)
|
||||||
|
|
||||||
- игровая логика (расстановка кораблей, ходы) и остальные состояния;
|
- игровая логика стрельбы/ходов (расстановка кораблей и переходы к фазе ходов уже реализованы);
|
||||||
- завершение партии по итогам игры с персонифицированными кодами
|
- завершение партии по итогам игры с персонифицированными кодами
|
||||||
победы/поражения;
|
победы/поражения;
|
||||||
- репорт итогов партии на main-server (реестр завершённых партий уже
|
- репорт итогов партии на main-server (реестр завершённых партий уже
|
||||||
гарантирует, что партия завершается ровно один раз);
|
гарантирует, что партия завершается ровно один раз).
|
||||||
- очистка состояния начавшихся партий после их завершения.
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"github.com/ruby-sea-battle/game-server/internal/config"
|
"github.com/ruby-sea-battle/game-server/internal/config"
|
||||||
"github.com/ruby-sea-battle/game-server/internal/game"
|
"github.com/ruby-sea-battle/game-server/internal/game"
|
||||||
"github.com/ruby-sea-battle/game-server/internal/ws"
|
"github.com/ruby-sea-battle/game-server/internal/ws"
|
||||||
|
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -57,6 +59,7 @@ func main() {
|
|||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
log.Info("shutting down")
|
log.Info("shutting down")
|
||||||
|
manager.Shutdown()
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = srv.Shutdown(shutdownCtx)
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ require (
|
|||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU
|
|||||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type GameClaims struct {
|
|||||||
Player1ID int64 `json:"player1_id"`
|
Player1ID int64 `json:"player1_id"`
|
||||||
Player2ID int64 `json:"player2_id"`
|
Player2ID int64 `json:"player2_id"`
|
||||||
GameServer string `json:"game_server"`
|
GameServer string `json:"game_server"`
|
||||||
|
RawToken string `json:"-"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +49,28 @@ func ParseGameToken(secret []byte, tokenString string) (*GameClaims, error) {
|
|||||||
if claims.MatchID == "" {
|
if claims.MatchID == "" {
|
||||||
return nil, fmt.Errorf("invalid game token: empty match_id")
|
return nil, fmt.Errorf("invalid game token: empty match_id")
|
||||||
}
|
}
|
||||||
|
if claims.Player1ID <= 0 || claims.Player2ID <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid game token: player IDs must be positive")
|
||||||
|
}
|
||||||
|
claims.RawToken = tokenString
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseGameTokenUnverified extracts claims from a game token without verifying
|
||||||
|
// its signature or expiration.
|
||||||
|
func ParseGameTokenUnverified(tokenString string) (*GameClaims, error) {
|
||||||
|
claims := &GameClaims{}
|
||||||
|
_, _, err := jwt.NewParser().ParseUnverified(tokenString, claims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid game token: %w", err)
|
||||||
|
}
|
||||||
|
if claims.MatchID == "" {
|
||||||
|
return nil, fmt.Errorf("invalid game token: empty match_id")
|
||||||
|
}
|
||||||
|
if claims.Player1ID <= 0 || claims.Player2ID <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid game token: player IDs must be positive")
|
||||||
|
}
|
||||||
|
claims.RawToken = tokenString
|
||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,5 +85,8 @@ func ParseUserToken(secret []byte, tokenString string) (*UserClaims, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid user token: %w", err)
|
return nil, fmt.Errorf("invalid user token: %w", err)
|
||||||
}
|
}
|
||||||
|
if claims.UserID <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid user token: user_id must be positive")
|
||||||
|
}
|
||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,37 @@ func TestParseGameTokenMissingExp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseGameTokenNonPositiveIDs(t *testing.T) {
|
||||||
|
for _, tc := range [][2]int64{{0, 2}, {-1, 2}, {1, 0}, {1, -5}} {
|
||||||
|
token := sign(t, &GameClaims{
|
||||||
|
MatchID: "m1",
|
||||||
|
Player1ID: tc[0],
|
||||||
|
Player2ID: tc[1],
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}, secret)
|
||||||
|
if _, err := ParseGameToken(secret, token); err == nil {
|
||||||
|
t.Fatalf("expected error for non-positive IDs: %+v", tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGameTokenUnverified(t *testing.T) {
|
||||||
|
// Works even if expired
|
||||||
|
token := gameToken(t, secret, time.Now().Add(-time.Hour))
|
||||||
|
claims, err := ParseGameTokenUnverified(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse unverified: %v", err)
|
||||||
|
}
|
||||||
|
if claims.MatchID != "m1" || claims.Player1ID != 1 || claims.Player2ID != 2 {
|
||||||
|
t.Fatalf("unexpected claims: %+v", claims)
|
||||||
|
}
|
||||||
|
if claims.RawToken != token {
|
||||||
|
t.Fatalf("raw token mismatch: got %q, want %q", claims.RawToken, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseUserToken(t *testing.T) {
|
func TestParseUserToken(t *testing.T) {
|
||||||
token := sign(t, &UserClaims{
|
token := sign(t, &UserClaims{
|
||||||
UserID: 42,
|
UserID: 42,
|
||||||
@@ -76,3 +107,15 @@ func TestParseUserToken(t *testing.T) {
|
|||||||
t.Fatalf("user_id = %d, want 42", claims.UserID)
|
t.Fatalf("user_id = %d, want 42", claims.UserID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseUserTokenNonPositiveID(t *testing.T) {
|
||||||
|
for _, id := range []int64{0, -1, -99} {
|
||||||
|
token := sign(t, &UserClaims{
|
||||||
|
UserID: id,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
|
||||||
|
}, secret)
|
||||||
|
if _, err := ParseUserToken(secret, token); err == nil {
|
||||||
|
t.Fatalf("expected error for user_id = %d", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,62 @@ import (
|
|||||||
// ErrMatchFinished is returned by Join when the player tries to join a match
|
// ErrMatchFinished is returned by Join when the player tries to join a match
|
||||||
// that has already ended. The stored END message (if any) is replayed to the
|
// that has already ended. The stored END message (if any) is replayed to the
|
||||||
// connection before Join returns.
|
// connection before Join returns.
|
||||||
var ErrMatchFinished = errors.New("match already finished")
|
var (
|
||||||
|
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")
|
||||||
|
ErrNotYourTurn = errors.New("not your turn")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
StatePlayer1Ready = 2
|
||||||
|
StatePlayer2Ready = 3
|
||||||
|
StatePlayer1Turn = 4
|
||||||
|
StatePlayer2Turn = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// playerStatesFor maps a server-side state to the respective states seen by player 1 and player 2.
|
||||||
|
func playerStatesFor(serverState int) (p1State, p2State int) {
|
||||||
|
switch serverState {
|
||||||
|
case StatePlacingShips:
|
||||||
|
return protocol.StatePlacingShips, protocol.StatePlacingShips
|
||||||
|
case StatePlayer1Ready:
|
||||||
|
return protocol.StateWaitOpponentReady, protocol.StateOpponentReady
|
||||||
|
case StatePlayer2Ready:
|
||||||
|
return protocol.StateOpponentReady, protocol.StateWaitOpponentReady
|
||||||
|
case StatePlayer1Turn:
|
||||||
|
return protocol.StateYourTurn, protocol.StateWaitOpponentTurn
|
||||||
|
case StatePlayer2Turn:
|
||||||
|
return protocol.StateWaitOpponentTurn, protocol.StateYourTurn
|
||||||
|
default:
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sender abstracts a player connection so the game logic stays testable and
|
// Sender abstracts a player connection so the game logic stays testable and
|
||||||
// independent of the websocket implementation.
|
// independent of the websocket implementation.
|
||||||
@@ -44,11 +99,18 @@ type PlayerSlot struct {
|
|||||||
unacked []queuedMessage
|
unacked []queuedMessage
|
||||||
lastState int // last state sent to this player, 0 if none
|
lastState int // last state sent to this player, 0 if none
|
||||||
everConnected bool // true after the first successful join
|
everConnected bool // true after the first successful join
|
||||||
|
|
||||||
|
ships []protocol.Ship
|
||||||
|
board Board
|
||||||
|
shipsPlaced bool
|
||||||
|
struck [BoardSize][BoardSize]bool
|
||||||
|
shipsRemaining int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match is the state of a single game of sea battle.
|
// Match is the state of a single game of sea battle.
|
||||||
type Match struct {
|
type Match struct {
|
||||||
ID string
|
ID string
|
||||||
|
gameToken string // game token used when match was created
|
||||||
tokenExp time.Time // game token expiration; bounds the join deadline
|
tokenExp time.Time // game token expiration; bounds the join deadline
|
||||||
playerIDs [2]int64 // slot 0 = player1, slot 1 = player2 (internal only)
|
playerIDs [2]int64 // slot 0 = player1, slot 1 = player2 (internal only)
|
||||||
slots [2]*PlayerSlot
|
slots [2]*PlayerSlot
|
||||||
@@ -56,10 +118,67 @@ type Match struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
started bool // both players joined at least once
|
started bool // both players joined at least once
|
||||||
ended bool
|
ended bool
|
||||||
state int // current global game state
|
state int // current global server-side game state
|
||||||
joinTimer *time.Timer
|
joinTimer *time.Timer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Match) GameToken() string {
|
||||||
|
return m.gameToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Match) PlayerIDs() [2]int64 {
|
||||||
|
return m.playerIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Match) State() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Struck returns a copy of the struck grid for the player in slotIdx.
|
||||||
|
func (m *Match) Struck(slotIdx int) ([BoardSize][BoardSize]bool, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if slotIdx < 0 || slotIdx >= len(m.slots) {
|
||||||
|
return [BoardSize][BoardSize]bool{}, false
|
||||||
|
}
|
||||||
|
return m.slots[slotIdx].struck, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShipsRemaining returns the number of unsunk ships for the player in slotIdx.
|
||||||
|
func (m *Match) ShipsRemaining(slotIdx int) int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if slotIdx < 0 || slotIdx >= len(m.slots) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return m.slots[slotIdx].shipsRemaining
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Match) slotIndex(userID int64) int {
|
func (m *Match) slotIndex(userID int64) int {
|
||||||
switch userID {
|
switch userID {
|
||||||
case m.playerIDs[0]:
|
case m.playerIDs[0]:
|
||||||
@@ -77,6 +196,7 @@ func (m *Match) slotIndex(userID int64) int {
|
|||||||
type finishedMatch struct {
|
type finishedMatch struct {
|
||||||
ends map[int64][]byte // userID -> END message sent to that player
|
ends map[int64][]byte // userID -> END message sent to that player
|
||||||
expiresAt time.Time // game token expiration time
|
expiresAt time.Time // game token expiration time
|
||||||
|
gameToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager owns all matches of this server instance.
|
// Manager owns all matches of this server instance.
|
||||||
@@ -96,6 +216,13 @@ func NewManager(joinTimeout time.Duration) *Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMatch returns the match with the given ID, or nil if not found.
|
||||||
|
func (m *Manager) GetMatch(matchID string) *Match {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.matches[matchID]
|
||||||
|
}
|
||||||
|
|
||||||
// Join attaches conn to the match identified by the game token claims.
|
// Join attaches conn to the match identified by the game token claims.
|
||||||
// userID must already be validated against the claims by the caller.
|
// userID must already be validated against the claims by the caller.
|
||||||
//
|
//
|
||||||
@@ -120,8 +247,13 @@ func (m *Manager) Join(ctx context.Context, conn Sender, gc *auth.GameClaims, us
|
|||||||
}
|
}
|
||||||
match, ok := m.matches[gc.MatchID]
|
match, ok := m.matches[gc.MatchID]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
token := gc.RawToken
|
||||||
|
if token == "" {
|
||||||
|
token = gc.MatchID
|
||||||
|
}
|
||||||
match = &Match{
|
match = &Match{
|
||||||
ID: gc.MatchID,
|
ID: gc.MatchID,
|
||||||
|
gameToken: token,
|
||||||
tokenExp: gc.ExpiresAt.Time,
|
tokenExp: gc.ExpiresAt.Time,
|
||||||
playerIDs: [2]int64{gc.Player1ID, gc.Player2ID},
|
playerIDs: [2]int64{gc.Player1ID, gc.Player2ID},
|
||||||
slots: [2]*PlayerSlot{
|
slots: [2]*PlayerSlot{
|
||||||
@@ -133,7 +265,7 @@ func (m *Manager) Join(ctx context.Context, conn Sender, gc *auth.GameClaims, us
|
|||||||
// never later than the game token itself expires — after that a
|
// never later than the game token itself expires — after that a
|
||||||
// join is impossible anyway.
|
// join is impossible anyway.
|
||||||
deadline := now.Add(m.joinTimeout)
|
deadline := now.Add(m.joinTimeout)
|
||||||
if match.tokenExp.Before(deadline) {
|
if !match.tokenExp.IsZero() && match.tokenExp.Before(deadline) {
|
||||||
deadline = match.tokenExp
|
deadline = match.tokenExp
|
||||||
}
|
}
|
||||||
match.joinTimer = time.AfterFunc(time.Until(deadline), func() {
|
match.joinTimer = time.AfterFunc(time.Until(deadline), func() {
|
||||||
@@ -141,13 +273,29 @@ func (m *Manager) Join(ctx context.Context, conn Sender, gc *auth.GameClaims, us
|
|||||||
})
|
})
|
||||||
m.matches[gc.MatchID] = match
|
m.matches[gc.MatchID] = match
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
|
// Acquire match.mu before releasing m.mu to eliminate the race with onJoinTimeout / EndMatch
|
||||||
match.mu.Lock()
|
match.mu.Lock()
|
||||||
|
m.mu.Unlock()
|
||||||
defer match.mu.Unlock()
|
defer match.mu.Unlock()
|
||||||
|
|
||||||
if match.ended {
|
if match.ended {
|
||||||
// Lost race with the join timeout; the match is being torn down.
|
// The match has ended; make sure the END message is sent before closing.
|
||||||
|
var endData []byte
|
||||||
|
idx := match.slotIndex(userID)
|
||||||
|
if idx >= 0 && len(match.slots[idx].unacked) > 0 {
|
||||||
|
endData = match.slots[idx].unacked[len(match.slots[idx].unacked)-1].data
|
||||||
|
}
|
||||||
|
if endData == nil {
|
||||||
|
m.mu.Lock()
|
||||||
|
if fin := m.finished[gc.MatchID]; fin != nil {
|
||||||
|
endData = fin.ends[userID]
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
if endData != nil {
|
||||||
|
_ = conn.Send(ctx, endData)
|
||||||
|
}
|
||||||
_ = conn.Close(protocol.CloseCodeNormal, "match already finished")
|
_ = conn.Close(protocol.CloseCodeNormal, "match already finished")
|
||||||
return nil, -1, ErrMatchFinished
|
return nil, -1, ErrMatchFinished
|
||||||
}
|
}
|
||||||
@@ -174,8 +322,7 @@ func (m *Manager) Join(ctx context.Context, conn Sender, gc *auth.GameClaims, us
|
|||||||
if match.joinTimer != nil {
|
if match.joinTimer != nil {
|
||||||
match.joinTimer.Stop()
|
match.joinTimer.Stop()
|
||||||
}
|
}
|
||||||
match.state = protocol.StatePlacingShips
|
m.setStateLocked(ctx, match, StatePlacingShips)
|
||||||
m.setStateLocked(ctx, match, match.state)
|
|
||||||
case match.started:
|
case match.started:
|
||||||
// Reconnect of a player while the game is running.
|
// Reconnect of a player while the game is running.
|
||||||
m.replayLocked(ctx, slot)
|
m.replayLocked(ctx, slot)
|
||||||
@@ -198,10 +345,16 @@ func (m *Manager) Leave(conn Sender, match *Match, idx int) {
|
|||||||
|
|
||||||
// Ack confirms delivery of all messages with seq <= the given sequence
|
// Ack confirms delivery of all messages with seq <= the given sequence
|
||||||
// number for the given player.
|
// number for the given player.
|
||||||
func (m *Manager) Ack(match *Match, idx int, seq int64) {
|
func (m *Manager) Ack(match *Match, idx int, seq int64) error {
|
||||||
match.mu.Lock()
|
match.mu.Lock()
|
||||||
defer match.mu.Unlock()
|
defer match.mu.Unlock()
|
||||||
|
if idx < 0 || idx >= len(match.slots) {
|
||||||
|
return errors.New("invalid slot index")
|
||||||
|
}
|
||||||
slot := match.slots[idx]
|
slot := match.slots[idx]
|
||||||
|
if seq <= 0 || seq > slot.seq {
|
||||||
|
return ErrInvalidSeq
|
||||||
|
}
|
||||||
kept := slot.unacked[:0]
|
kept := slot.unacked[:0]
|
||||||
for _, qm := range slot.unacked {
|
for _, qm := range slot.unacked {
|
||||||
if qm.seq > seq {
|
if qm.seq > seq {
|
||||||
@@ -209,13 +362,192 @@ func (m *Manager) Ack(match *Match, idx int, seq int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
slot.unacked = kept
|
slot.unacked = kept
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setStateLocked moves the match into a new state and notifies both players.
|
// PlaceShips validates and stores the player's ship placement, advancing to the next match state.
|
||||||
// match.mu must be held.
|
// 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
|
||||||
|
slot.shipsRemaining = len(ships)
|
||||||
|
slot.struck = [BoardSize][BoardSize]bool{}
|
||||||
|
|
||||||
|
m.setStateLocked(ctx, match, nextState)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire processes a shot from the player at slotIdx towards opponent's board (1 - slotIdx).
|
||||||
|
// If the player does not have the right of turn, the message is ignored and ErrNotYourTurn is returned.
|
||||||
|
// If the position is out of bounds or already struck, FireResultInvalidPosition is sent to the shooter.
|
||||||
|
// Otherwise:
|
||||||
|
// - Miss: marks cell as struck, sends FireResultMiss to shooter, turn passes to opponent.
|
||||||
|
// - Hit: marks cell as struck, sends FireResultHit to shooter, turn does not pass.
|
||||||
|
// - Sunk: marks ship and all surrounding cells (including diagonals) as struck, sends FireResultSunk to shooter.
|
||||||
|
// If all opponent ships are destroyed, match finishes: END messages (victory/defeat) are sent to both players,
|
||||||
|
// and connections are closed immediately.
|
||||||
|
// Otherwise, turn does not pass.
|
||||||
|
func (m *Manager) Fire(ctx context.Context, match *Match, slotIdx int, x, y int) (int, error) {
|
||||||
|
res, won, matchID, err := m.fireLocked(ctx, match, slotIdx, x, y)
|
||||||
|
if won {
|
||||||
|
// TODO: Report match result to main-server once endpoint is ready.
|
||||||
|
m.finishGame(matchID, slotIdx)
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fireLocked(ctx context.Context, match *Match, slotIdx int, x, y int) (res int, won bool, matchID string, err error) {
|
||||||
|
match.mu.Lock()
|
||||||
|
defer match.mu.Unlock()
|
||||||
|
|
||||||
|
if match.ended {
|
||||||
|
return 0, false, "", ErrMatchFinished
|
||||||
|
}
|
||||||
|
if !match.started {
|
||||||
|
return 0, false, "", ErrMatchNotStarted
|
||||||
|
}
|
||||||
|
if slotIdx < 0 || slotIdx >= len(match.slots) {
|
||||||
|
return 0, false, "", ErrInvalidSlot
|
||||||
|
}
|
||||||
|
|
||||||
|
var isTurn bool
|
||||||
|
switch match.state {
|
||||||
|
case StatePlayer1Turn:
|
||||||
|
isTurn = (slotIdx == 0)
|
||||||
|
case StatePlayer2Turn:
|
||||||
|
isTurn = (slotIdx == 1)
|
||||||
|
}
|
||||||
|
if !isTurn {
|
||||||
|
return 0, false, "", ErrNotYourTurn
|
||||||
|
}
|
||||||
|
|
||||||
|
shooter := match.slots[slotIdx]
|
||||||
|
target := match.slots[1-slotIdx]
|
||||||
|
|
||||||
|
if x < 0 || x >= BoardSize || y < 0 || y >= BoardSize || target.struck[y][x] {
|
||||||
|
m.queueFireResultLocked(ctx, shooter, protocol.FireResultInvalidPosition)
|
||||||
|
return protocol.FireResultInvalidPosition, false, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
shipID := target.board[y][x]
|
||||||
|
if shipID == 0 {
|
||||||
|
target.struck[y][x] = true
|
||||||
|
m.queueFireResultLocked(ctx, shooter, protocol.FireResultMiss)
|
||||||
|
|
||||||
|
var nextState int
|
||||||
|
if slotIdx == 0 {
|
||||||
|
nextState = StatePlayer2Turn
|
||||||
|
} else {
|
||||||
|
nextState = StatePlayer1Turn
|
||||||
|
}
|
||||||
|
m.setStateLocked(ctx, match, nextState)
|
||||||
|
return protocol.FireResultMiss, false, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target.struck[y][x] = true
|
||||||
|
shipIdx := int(shipID - 1)
|
||||||
|
ship := target.ships[shipIdx]
|
||||||
|
|
||||||
|
sunk := true
|
||||||
|
for _, c := range ship.Cells() {
|
||||||
|
if !target.struck[c.Y][c.X] {
|
||||||
|
sunk = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sunk {
|
||||||
|
m.queueFireResultLocked(ctx, shooter, protocol.FireResultHit)
|
||||||
|
return protocol.FireResultHit, false, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range ship.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 {
|
||||||
|
target.struck[ny][nx] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.shipsRemaining--
|
||||||
|
|
||||||
|
m.queueFireResultLocked(ctx, shooter, protocol.FireResultSunk)
|
||||||
|
|
||||||
|
if target.shipsRemaining == 0 {
|
||||||
|
match.state = 0
|
||||||
|
return protocol.FireResultSunk, true, match.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return protocol.FireResultSunk, false, "", 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()
|
||||||
|
defer match.mu.Unlock()
|
||||||
|
m.setStateLocked(ctx, match, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setStateLocked moves the match into a new server-side state and notifies both players
|
||||||
|
// with appropriate player-side states. match.mu must be held.
|
||||||
func (m *Manager) setStateLocked(ctx context.Context, match *Match, state int) {
|
func (m *Manager) setStateLocked(ctx context.Context, match *Match, state int) {
|
||||||
for _, slot := range match.slots {
|
match.state = state
|
||||||
m.queueStateLocked(ctx, slot, state)
|
p1State, p2State := playerStatesFor(state)
|
||||||
|
if p1State != 0 {
|
||||||
|
m.queueStateLocked(ctx, match.slots[0], p1State)
|
||||||
|
}
|
||||||
|
if p2State != 0 {
|
||||||
|
m.queueStateLocked(ctx, match.slots[1], p2State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +564,18 @@ func (m *Manager) queueStateLocked(ctx context.Context, slot *PlayerSlot, state
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queueFireResultLocked appends a FIRE_RESULT message to the player's unacknowledged
|
||||||
|
// queue and sends it right away if the player is connected.
|
||||||
|
// match.mu must be held.
|
||||||
|
func (m *Manager) queueFireResultLocked(ctx context.Context, slot *PlayerSlot, result int) {
|
||||||
|
slot.seq++
|
||||||
|
data := protocol.BuildFireResult(slot.seq, result)
|
||||||
|
slot.unacked = append(slot.unacked, queuedMessage{seq: slot.seq, data: data})
|
||||||
|
if slot.conn != nil {
|
||||||
|
_ = slot.conn.Send(ctx, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// replayLocked re-sends everything the connected player has not
|
// replayLocked re-sends everything the connected player has not
|
||||||
// acknowledged yet; if there is nothing pending, the current state is sent
|
// acknowledged yet; if there is nothing pending, the current state is sent
|
||||||
// anew so a reconnected client can catch up.
|
// anew so a reconnected client can catch up.
|
||||||
@@ -248,6 +592,23 @@ func (m *Manager) replayLocked(ctx context.Context, slot *PlayerSlot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EndMatch finishes a match with the given code and reason for both players.
|
||||||
|
func (m *Manager) EndMatch(matchID string, code int, reason string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
match, ok := m.matches[matchID]
|
||||||
|
if !ok {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
match.mu.Lock()
|
||||||
|
if match.ended {
|
||||||
|
match.mu.Unlock()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.finishMatchLocked(match, code, reason)
|
||||||
|
}
|
||||||
|
|
||||||
// onJoinTimeout finishes a match whose second player never joined.
|
// onJoinTimeout finishes a match whose second player never joined.
|
||||||
func (m *Manager) onJoinTimeout(matchID string) {
|
func (m *Manager) onJoinTimeout(matchID string) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -262,8 +623,33 @@ func (m *Manager) onJoinTimeout(matchID string) {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
m.finishMatchLocked(match, protocol.EndCodeJoinTimeout, "join timeout exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
type playerEnd struct {
|
||||||
|
code int
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishMatchLocked tears down a match, removes it from m.matches, records it in m.finished,
|
||||||
|
// and sends identical END notifications to all players. Both m.mu and match.mu must be held
|
||||||
|
// upon entry; both are unlocked by this function.
|
||||||
|
func (m *Manager) finishMatchLocked(match *Match, code int, reason string) {
|
||||||
|
m.finishMatchWithEndsLocked(match, [2]playerEnd{
|
||||||
|
{code: code, reason: reason},
|
||||||
|
{code: code, reason: reason},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishMatchWithEndsLocked tears down a match, removes it from m.matches, records it in m.finished,
|
||||||
|
// and sends per-player END notifications to connected players. Both m.mu and match.mu must be held
|
||||||
|
// upon entry; both are unlocked by this function.
|
||||||
|
func (m *Manager) finishMatchWithEndsLocked(match *Match, endsPerSlot [2]playerEnd) {
|
||||||
match.ended = true
|
match.ended = true
|
||||||
delete(m.matches, matchID)
|
if match.joinTimer != nil {
|
||||||
|
match.joinTimer.Stop()
|
||||||
|
}
|
||||||
|
delete(m.matches, match.ID)
|
||||||
|
|
||||||
ends := make(map[int64][]byte, 2)
|
ends := make(map[int64][]byte, 2)
|
||||||
type notification struct {
|
type notification struct {
|
||||||
@@ -271,9 +657,10 @@ func (m *Manager) onJoinTimeout(matchID string) {
|
|||||||
data []byte
|
data []byte
|
||||||
}
|
}
|
||||||
var notify []notification
|
var notify []notification
|
||||||
for _, slot := range match.slots {
|
for i, slot := range match.slots {
|
||||||
slot.seq++
|
slot.seq++
|
||||||
data := protocol.BuildEnd(slot.seq, protocol.EndCodeJoinTimeout, "join timeout exceeded")
|
end := endsPerSlot[i]
|
||||||
|
data := protocol.BuildEnd(slot.seq, end.code, end.reason)
|
||||||
slot.unacked = append(slot.unacked, queuedMessage{seq: slot.seq, data: data})
|
slot.unacked = append(slot.unacked, queuedMessage{seq: slot.seq, data: data})
|
||||||
ends[slot.UserID] = data
|
ends[slot.UserID] = data
|
||||||
if slot.conn != nil {
|
if slot.conn != nil {
|
||||||
@@ -283,18 +670,92 @@ func (m *Manager) onJoinTimeout(matchID string) {
|
|||||||
}
|
}
|
||||||
match.mu.Unlock()
|
match.mu.Unlock()
|
||||||
|
|
||||||
// Remember the outcome until the game token expires so that late join
|
// Remember outcome in finished registry. Bound expiresAt to at least now + 5m if tokenExp is in the past.
|
||||||
// attempts are answered with the same END message instead of creating a
|
now := time.Now()
|
||||||
// phantom match.
|
expiresAt := match.tokenExp
|
||||||
m.finished[matchID] = &finishedMatch{ends: ends, expiresAt: match.tokenExp}
|
if expiresAt.Before(now.Add(5 * time.Minute)) {
|
||||||
m.purgeFinishedLocked(time.Now())
|
expiresAt = now.Add(5 * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.finished[match.ID] = &finishedMatch{
|
||||||
|
ends: ends,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
gameToken: match.gameToken,
|
||||||
|
}
|
||||||
|
m.purgeFinishedLocked(now)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
// Network I/O happens outside of any lock.
|
// Network I/O happens outside of any lock.
|
||||||
for _, n := range notify {
|
for _, n := range notify {
|
||||||
_ = n.conn.Send(context.Background(), n.data)
|
_ = n.conn.Send(context.Background(), n.data)
|
||||||
_ = n.conn.Close(protocol.CloseCodeNormal, "match ended")
|
|
||||||
}
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, n := range notify {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(c Sender) {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = c.Close(protocol.CloseCodeNormal, "match ended")
|
||||||
|
}(n.conn)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishGame completes a match due to all opponent ships being sunk.
|
||||||
|
// winnerSlot is the slot index of the winning player (0 or 1).
|
||||||
|
func (m *Manager) finishGame(matchID string, winnerSlot int) {
|
||||||
|
m.mu.Lock()
|
||||||
|
match, ok := m.matches[matchID]
|
||||||
|
if !ok {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
match.mu.Lock()
|
||||||
|
if match.ended {
|
||||||
|
match.mu.Unlock()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
endsPerSlot := [2]playerEnd{
|
||||||
|
{code: protocol.EndCodeVictory, reason: "victory"},
|
||||||
|
{code: protocol.EndCodeDefeat, reason: "defeat"},
|
||||||
|
}
|
||||||
|
if winnerSlot == 1 {
|
||||||
|
endsPerSlot[0], endsPerSlot[1] = endsPerSlot[1], endsPerSlot[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
m.finishMatchWithEndsLocked(match, endsPerSlot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown closes all active player connections across all matches with code 1001 (going away)
|
||||||
|
// and stops all pending join timers.
|
||||||
|
func (m *Manager) Shutdown() {
|
||||||
|
m.mu.Lock()
|
||||||
|
var conns []Sender
|
||||||
|
for _, match := range m.matches {
|
||||||
|
match.mu.Lock()
|
||||||
|
if match.joinTimer != nil {
|
||||||
|
match.joinTimer.Stop()
|
||||||
|
}
|
||||||
|
for _, slot := range match.slots {
|
||||||
|
if slot.conn != nil {
|
||||||
|
conns = append(conns, slot.conn)
|
||||||
|
slot.conn = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match.mu.Unlock()
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, conn := range conns {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(c Sender) {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = c.Close(protocol.CloseCodeGoingAway, "going away")
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// purgeFinishedLocked drops registry entries whose game token has expired.
|
// purgeFinishedLocked drops registry entries whose game token has expired.
|
||||||
|
|||||||
@@ -237,7 +237,9 @@ func TestAckThenReconnectResendsCurrentState(t *testing.T) {
|
|||||||
|
|
||||||
// Player 1 acknowledges the STATE message, then reconnects. The queue
|
// Player 1 acknowledges the STATE message, then reconnects. The queue
|
||||||
// is empty, so the server re-sends the current state with a fresh seq.
|
// is empty, so the server re-sends the current state with a fresh seq.
|
||||||
m.Ack(match, 0, 1)
|
if err := m.Ack(match, 0, 1); err != nil {
|
||||||
|
t.Fatalf("ack: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
fresh := &fakeConn{}
|
fresh := &fakeConn{}
|
||||||
if _, _, err := m.Join(context.Background(), fresh, gc, gc.Player1ID); err != nil {
|
if _, _, err := m.Join(context.Background(), fresh, gc, gc.Player1ID); err != nil {
|
||||||
@@ -253,6 +255,195 @@ func TestAckThenReconnectResendsCurrentState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerStateTransitions(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)
|
||||||
|
}
|
||||||
|
// Initial state is StatePlacingShips
|
||||||
|
if match.State() != StatePlacingShips {
|
||||||
|
t.Fatalf("state = %d, want StatePlacingShips", match.State())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to StatePlayer1Ready
|
||||||
|
m.SetState(context.Background(), match, StatePlayer1Ready)
|
||||||
|
p1Msgs, p2Msgs := c1.messages(), c2.messages()
|
||||||
|
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) {
|
||||||
|
t.Fatalf("p1 got %v, want StateWaitOpponentReady (3)", v)
|
||||||
|
}
|
||||||
|
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) {
|
||||||
|
t.Fatalf("p2 got %v, want StateOpponentReady (2)", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to StatePlayer1Turn
|
||||||
|
m.SetState(context.Background(), match, StatePlayer1Turn)
|
||||||
|
p1Msgs, p2Msgs = c1.messages(), c2.messages()
|
||||||
|
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateYourTurn) {
|
||||||
|
t.Fatalf("p1 got %v, want StateYourTurn (4)", v)
|
||||||
|
}
|
||||||
|
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||||
|
t.Fatalf("p2 got %v, want StateWaitOpponentTurn (5)", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to StatePlayer2Turn
|
||||||
|
m.SetState(context.Background(), match, StatePlayer2Turn)
|
||||||
|
p1Msgs, p2Msgs = c1.messages(), c2.messages()
|
||||||
|
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||||
|
t.Fatalf("p1 got %v, want StateWaitOpponentTurn (5)", v)
|
||||||
|
}
|
||||||
|
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateYourTurn) {
|
||||||
|
t.Fatalf("p2 got %v, want StateYourTurn (4)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAckValidation(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seq <= 0 should fail
|
||||||
|
if err := m.Ack(match, 0, 0); err != ErrInvalidSeq {
|
||||||
|
t.Fatalf("ack 0: got err = %v, want ErrInvalidSeq", err)
|
||||||
|
}
|
||||||
|
if err := m.Ack(match, 0, -1); err != ErrInvalidSeq {
|
||||||
|
t.Fatalf("ack -1: got err = %v, want ErrInvalidSeq", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seq > slot.seq (e.g. 2 when only 1 sent) should fail
|
||||||
|
if err := m.Ack(match, 0, 2); err != ErrInvalidSeq {
|
||||||
|
t.Fatalf("ack 2: got err = %v, want ErrInvalidSeq", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seq == 1 should succeed
|
||||||
|
if err := m.Ack(match, 0, 1); err != nil {
|
||||||
|
t.Fatalf("ack 1: unexpected err = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent duplicate ack should succeed
|
||||||
|
if err := m.Ack(match, 0, 1); err != nil {
|
||||||
|
t.Fatalf("repeat ack 1: unexpected err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndMatchCleansUpMatch(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match started successfully and is in m.matches
|
||||||
|
if m.GetMatch(gc.MatchID) == nil {
|
||||||
|
t.Fatal("expected match in m.matches")
|
||||||
|
}
|
||||||
|
|
||||||
|
// End match
|
||||||
|
m.EndMatch(match.ID, 0, "game completed")
|
||||||
|
|
||||||
|
// Match should be deleted from m.matches (preventing memory leak)
|
||||||
|
if m.GetMatch(gc.MatchID) != nil {
|
||||||
|
t.Fatal("expected match to be deleted from m.matches")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both connections should be notified and closed with CloseCodeNormal
|
||||||
|
for i, c := range []*fakeConn{c1, c2} {
|
||||||
|
msgs := c.messages()
|
||||||
|
lastMsg := parseMsg(t, msgs[len(msgs)-1])
|
||||||
|
if lastMsg[0] != float64(protocol.OpEnd) || lastMsg[3] != "game completed" {
|
||||||
|
t.Fatalf("player %d unexpected END: %v", i, lastMsg)
|
||||||
|
}
|
||||||
|
if closed, code := c.isClosed(); !closed || code != protocol.CloseCodeNormal {
|
||||||
|
t.Fatalf("player %d closed=%v code=%d, want closed with %d", i, closed, code, protocol.CloseCodeNormal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Late join should replay END
|
||||||
|
c3 := &fakeConn{}
|
||||||
|
_, _, err = m.Join(context.Background(), c3, gc, gc.Player1ID)
|
||||||
|
if err != ErrMatchFinished {
|
||||||
|
t.Fatalf("err = %v, want ErrMatchFinished", err)
|
||||||
|
}
|
||||||
|
msgs := c3.messages()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("replayed %d messages, want 1", len(msgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerShutdown(t *testing.T) {
|
||||||
|
m := NewManager(time.Minute)
|
||||||
|
gc := claims(time.Now().Add(time.Hour))
|
||||||
|
c1, c2 := &fakeConn{}, &fakeConn{}
|
||||||
|
|
||||||
|
_, _, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Shutdown()
|
||||||
|
|
||||||
|
for i, c := range []*fakeConn{c1, c2} {
|
||||||
|
if closed, code := c.isClosed(); !closed || code != protocol.CloseCodeGoingAway {
|
||||||
|
t.Fatalf("player %d closed=%v code=%d, want closed with %d", i, closed, code, protocol.CloseCodeGoingAway)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinWhenMatchAlreadyEndedReplaysReason(t *testing.T) {
|
||||||
|
m := NewManager(time.Minute)
|
||||||
|
gc := claims(time.Now().Add(time.Hour))
|
||||||
|
c1 := &fakeConn{}
|
||||||
|
|
||||||
|
_, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger onJoinTimeout so match is ended
|
||||||
|
m.onJoinTimeout(gc.MatchID)
|
||||||
|
|
||||||
|
// Late join on ended match must send the reason before closing
|
||||||
|
c2 := &fakeConn{}
|
||||||
|
_, _, err = m.Join(context.Background(), c2, gc, gc.Player2ID)
|
||||||
|
if err != ErrMatchFinished {
|
||||||
|
t.Fatalf("got err = %v, want ErrMatchFinished", err)
|
||||||
|
}
|
||||||
|
msgs := c2.messages()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("got %d messages, want 1", len(msgs))
|
||||||
|
}
|
||||||
|
v := parseMsg(t, msgs[0])
|
||||||
|
if v[0] != float64(protocol.OpEnd) || v[2] != float64(protocol.EndCodeJoinTimeout) || v[3] != "join timeout exceeded" {
|
||||||
|
t.Fatalf("unexpected message: %v", v)
|
||||||
|
}
|
||||||
|
if closed, code := c2.isClosed(); !closed || code != protocol.CloseCodeNormal {
|
||||||
|
t.Fatalf("c2 closed=%v code=%d, want %d", closed, code, protocol.CloseCodeNormal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustMatch(t *testing.T, m *Manager, id string) *Match {
|
func mustMatch(t *testing.T, m *Manager, id string) *Match {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -263,3 +454,533 @@ func mustMatch(t *testing.T, m *Manager, id string) *Match {
|
|||||||
}
|
}
|
||||||
return 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupStartedGame(t *testing.T) (*Manager, *Match, *fakeConn, *fakeConn) {
|
||||||
|
t.Helper()
|
||||||
|
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()
|
||||||
|
if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil {
|
||||||
|
t.Fatalf("p1 place ships: %v", err)
|
||||||
|
}
|
||||||
|
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", match.State())
|
||||||
|
}
|
||||||
|
return m, match, c1, c2
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireMissTransitionsTurn(t *testing.T) {
|
||||||
|
m, match, c1, c2 := setupStartedGame(t)
|
||||||
|
|
||||||
|
// In validTestShips, cell (9, 9) has no ships.
|
||||||
|
res, err := m.Fire(context.Background(), match, 0, 9, 9)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fire: %v", err)
|
||||||
|
}
|
||||||
|
if res != protocol.FireResultMiss {
|
||||||
|
t.Fatalf("res = %d, want FireResultMiss", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shooter receives FIRE_RESULT with miss (1)
|
||||||
|
c1Msgs := c1.messages()
|
||||||
|
// c1 got: [STATE 1], [STATE 3], [STATE 4], [FIRE_RESULT 1], [STATE 5]
|
||||||
|
fireMsg := parseMsg(t, c1Msgs[len(c1Msgs)-2])
|
||||||
|
if fireMsg[0] != float64(protocol.OpFireResult) || fireMsg[2] != float64(protocol.FireResultMiss) {
|
||||||
|
t.Fatalf("unexpected fireMsg: %v", fireMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn transitions to Player 2
|
||||||
|
if match.State() != StatePlayer2Turn {
|
||||||
|
t.Fatalf("state = %d, want StatePlayer2Turn", match.State())
|
||||||
|
}
|
||||||
|
|
||||||
|
c1Last := parseMsg(t, c1Msgs[len(c1Msgs)-1])
|
||||||
|
if c1Last[0] != float64(protocol.OpState) || c1Last[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||||
|
t.Fatalf("c1 last state = %v, want StateWaitOpponentTurn", c1Last)
|
||||||
|
}
|
||||||
|
|
||||||
|
c2Msgs := c2.messages()
|
||||||
|
c2Last := parseMsg(t, c2Msgs[len(c2Msgs)-1])
|
||||||
|
if c2Last[0] != float64(protocol.OpState) || c2Last[2] != float64(protocol.StateYourTurn) {
|
||||||
|
t.Fatalf("c2 last state = %v, want StateYourTurn", c2Last)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target board cell (9, 9) marked struck
|
||||||
|
struck, ok := match.Struck(1)
|
||||||
|
if !ok || !struck[9][9] {
|
||||||
|
t.Fatalf("expected p2 cell (9, 9) to be struck")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireHitKeepsTurn(t *testing.T) {
|
||||||
|
m, match, c1, c2 := setupStartedGame(t)
|
||||||
|
|
||||||
|
// Ship 9 is 4 cells horizontally at (0, 0), (1, 0), (2, 0), (3, 0)
|
||||||
|
res, err := m.Fire(context.Background(), match, 0, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fire: %v", err)
|
||||||
|
}
|
||||||
|
if res != protocol.FireResultHit {
|
||||||
|
t.Fatalf("res = %d, want FireResultHit", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
c1Msgs := c1.messages()
|
||||||
|
fireMsg := parseMsg(t, c1Msgs[len(c1Msgs)-1])
|
||||||
|
if fireMsg[0] != float64(protocol.OpFireResult) || fireMsg[2] != float64(protocol.FireResultHit) {
|
||||||
|
t.Fatalf("unexpected fireMsg: %v", fireMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn stays with Player 1
|
||||||
|
if match.State() != StatePlayer1Turn {
|
||||||
|
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player 2 should not have received any new messages
|
||||||
|
c2Msgs := c2.messages()
|
||||||
|
// c2 messages before: [STATE 1], [STATE 2], [STATE 5]
|
||||||
|
if len(c2Msgs) != 3 {
|
||||||
|
t.Fatalf("p2 received unexpected message: %d messages", len(c2Msgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireSunkMarksSurroundings(t *testing.T) {
|
||||||
|
m, match, c1, _ := setupStartedGame(t)
|
||||||
|
|
||||||
|
// Ship 0 is 1 cell at (9, 4)
|
||||||
|
res, err := m.Fire(context.Background(), match, 0, 9, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fire: %v", err)
|
||||||
|
}
|
||||||
|
if res != protocol.FireResultSunk {
|
||||||
|
t.Fatalf("res = %d, want FireResultSunk", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
c1Msgs := c1.messages()
|
||||||
|
fireMsg := parseMsg(t, c1Msgs[len(c1Msgs)-1])
|
||||||
|
if fireMsg[0] != float64(protocol.OpFireResult) || fireMsg[2] != float64(protocol.FireResultSunk) {
|
||||||
|
t.Fatalf("unexpected fireMsg: %v", fireMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if match.ShipsRemaining(1) != 9 {
|
||||||
|
t.Fatalf("shipsRemaining = %d, want 9", match.ShipsRemaining(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that (9, 4) and surrounding cells (8,3), (8,4), (8,5), (9,3), (9,5) are struck
|
||||||
|
struck, _ := match.Struck(1)
|
||||||
|
expectedCells := [][2]int{
|
||||||
|
{9, 4},
|
||||||
|
{8, 3}, {8, 4}, {8, 5},
|
||||||
|
{9, 3}, {9, 5},
|
||||||
|
}
|
||||||
|
for _, pt := range expectedCells {
|
||||||
|
if !struck[pt[1]][pt[0]] {
|
||||||
|
t.Fatalf("expected cell (%d, %d) to be struck", pt[0], pt[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn stays with Player 1 because hit/sunk does not pass turn unless game over
|
||||||
|
if match.State() != StatePlayer1Turn {
|
||||||
|
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireInvalidPosition(t *testing.T) {
|
||||||
|
m, match, c1, _ := setupStartedGame(t)
|
||||||
|
|
||||||
|
t.Run("out of bounds negative", func(t *testing.T) {
|
||||||
|
res, err := m.Fire(context.Background(), match, 0, -1, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fire: %v", err)
|
||||||
|
}
|
||||||
|
if res != protocol.FireResultInvalidPosition {
|
||||||
|
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
|
||||||
|
}
|
||||||
|
msgs := c1.messages()
|
||||||
|
msg := parseMsg(t, msgs[len(msgs)-1])
|
||||||
|
if msg[0] != float64(protocol.OpFireResult) || msg[2] != float64(protocol.FireResultInvalidPosition) {
|
||||||
|
t.Fatalf("unexpected msg: %v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("out of bounds over 9", func(t *testing.T) {
|
||||||
|
res, err := m.Fire(context.Background(), match, 0, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fire: %v", err)
|
||||||
|
}
|
||||||
|
if res != protocol.FireResultInvalidPosition {
|
||||||
|
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("already struck cell", func(t *testing.T) {
|
||||||
|
// First shoot (9, 4) - sinks single ship
|
||||||
|
res, err := m.Fire(context.Background(), match, 0, 9, 4)
|
||||||
|
if err != nil || res != protocol.FireResultSunk {
|
||||||
|
t.Fatalf("initial fire: res=%d, err=%v", res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shooting (9, 4) again returns InvalidPosition
|
||||||
|
res, err = m.Fire(context.Background(), match, 0, 9, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repeat fire: %v", err)
|
||||||
|
}
|
||||||
|
if res != protocol.FireResultInvalidPosition {
|
||||||
|
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shooting diagonal neighbor (8, 5) which was marked struck due to sunk ship also returns InvalidPosition
|
||||||
|
res, err = m.Fire(context.Background(), match, 0, 8, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("halo fire: %v", err)
|
||||||
|
}
|
||||||
|
if res != protocol.FireResultInvalidPosition {
|
||||||
|
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireIgnoredWhenNotYourTurn(t *testing.T) {
|
||||||
|
m, match, c1, c2 := setupStartedGame(t)
|
||||||
|
|
||||||
|
// It's player 1's turn, player 2 tries to shoot
|
||||||
|
c2CountBefore := len(c2.messages())
|
||||||
|
res, err := m.Fire(context.Background(), match, 1, 0, 0)
|
||||||
|
if err != ErrNotYourTurn {
|
||||||
|
t.Fatalf("p2 fire: got err = %v, want ErrNotYourTurn", err)
|
||||||
|
}
|
||||||
|
if res != 0 {
|
||||||
|
t.Fatalf("res = %d, want 0", res)
|
||||||
|
}
|
||||||
|
if len(c2.messages()) != c2CountBefore {
|
||||||
|
t.Fatal("expected no messages sent to p2 on ignored fire")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match state unchanged
|
||||||
|
if match.State() != StatePlayer1Turn {
|
||||||
|
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
|
||||||
|
}
|
||||||
|
_ = c1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireWinCondition(t *testing.T) {
|
||||||
|
m, match, c1, c2 := setupStartedGame(t)
|
||||||
|
gc := claims(time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
// Player 1 destroys all 10 ships of Player 2
|
||||||
|
ships := validTestShips()
|
||||||
|
for i, ship := range ships {
|
||||||
|
cells := ship.Cells()
|
||||||
|
for j, c := range cells {
|
||||||
|
res, err := m.Fire(context.Background(), match, 0, c.X, c.Y)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ship %d cell %d fire: %v", i, j, err)
|
||||||
|
}
|
||||||
|
if j < len(cells)-1 {
|
||||||
|
if res != protocol.FireResultHit {
|
||||||
|
t.Fatalf("ship %d cell %d: res = %d, want Hit", i, j, res)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if res != protocol.FireResultSunk {
|
||||||
|
t.Fatalf("ship %d cell %d: res = %d, want Sunk", i, j, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match should be removed from m.matches (preventing memory leak)
|
||||||
|
if m.GetMatch(match.ID) != nil {
|
||||||
|
t.Fatal("expected match to be removed from m.matches")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check END messages sent to both players
|
||||||
|
c1Msgs := c1.messages()
|
||||||
|
// c1 receives FIRE_RESULT (sunk) followed by END (victory)
|
||||||
|
c1End := parseMsg(t, c1Msgs[len(c1Msgs)-1])
|
||||||
|
if c1End[0] != float64(protocol.OpEnd) || c1End[2] != float64(protocol.EndCodeVictory) || c1End[3] != "victory" {
|
||||||
|
t.Fatalf("p1 got %v, want END with EndCodeVictory (1) 'victory'", c1End)
|
||||||
|
}
|
||||||
|
|
||||||
|
c2Msgs := c2.messages()
|
||||||
|
c2End := parseMsg(t, c2Msgs[len(c2Msgs)-1])
|
||||||
|
if c2End[0] != float64(protocol.OpEnd) || c2End[2] != float64(protocol.EndCodeDefeat) || c2End[3] != "defeat" {
|
||||||
|
t.Fatalf("p2 got %v, want END with EndCodeDefeat (2) 'defeat'", c2End)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connections must be closed immediately with CloseCodeNormal
|
||||||
|
if closed, code := c1.isClosed(); !closed || code != protocol.CloseCodeNormal {
|
||||||
|
t.Fatalf("c1 closed=%v code=%d, want closed with %d", closed, code, protocol.CloseCodeNormal)
|
||||||
|
}
|
||||||
|
if closed, code := c2.isClosed(); !closed || code != protocol.CloseCodeNormal {
|
||||||
|
t.Fatalf("c2 closed=%v code=%d, want closed with %d", closed, code, protocol.CloseCodeNormal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Late reconnects get the respective END messages replayed
|
||||||
|
c1Rejoin := &fakeConn{}
|
||||||
|
_, _, err := m.Join(context.Background(), c1Rejoin, gc, gc.Player1ID)
|
||||||
|
if err != ErrMatchFinished {
|
||||||
|
t.Fatalf("rejoin p1: got err = %v, want ErrMatchFinished", err)
|
||||||
|
}
|
||||||
|
msgs := c1Rejoin.messages()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("p1 got %d replay messages, want 1", len(msgs))
|
||||||
|
}
|
||||||
|
if v := parseMsg(t, msgs[0]); v[0] != float64(protocol.OpEnd) || v[2] != float64(protocol.EndCodeVictory) {
|
||||||
|
t.Fatalf("p1 replayed msg = %v, want END victory", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
c2Rejoin := &fakeConn{}
|
||||||
|
_, _, err = m.Join(context.Background(), c2Rejoin, gc, gc.Player2ID)
|
||||||
|
if err != ErrMatchFinished {
|
||||||
|
t.Fatalf("rejoin p2: got err = %v, want ErrMatchFinished", err)
|
||||||
|
}
|
||||||
|
msgs = c2Rejoin.messages()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("p2 got %d replay messages, want 1", len(msgs))
|
||||||
|
}
|
||||||
|
if v := parseMsg(t, msgs[0]); v[0] != float64(protocol.OpEnd) || v[2] != float64(protocol.EndCodeDefeat) {
|
||||||
|
t.Fatalf("p2 replayed msg = %v, want END defeat", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireAckAndReplay(t *testing.T) {
|
||||||
|
m, match, _, _ := setupStartedGame(t)
|
||||||
|
|
||||||
|
// In setupStartedGame, c1 received 3 STATE messages (seq 1, 2, 3)
|
||||||
|
// Player 1 fires: generates FIRE_RESULT with seq 4
|
||||||
|
res, err := m.Fire(context.Background(), match, 0, 0, 0)
|
||||||
|
if err != nil || res != protocol.FireResultHit {
|
||||||
|
t.Fatalf("fire: res=%d, err=%v", res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ack seq 4
|
||||||
|
if err := m.Ack(match, 0, 4); err != nil {
|
||||||
|
t.Fatalf("ack: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player 1 fires again at (1, 0): generates FIRE_RESULT with seq 5
|
||||||
|
res, err = m.Fire(context.Background(), match, 0, 1, 0)
|
||||||
|
if err != nil || res != protocol.FireResultHit {
|
||||||
|
t.Fatalf("fire 2: res=%d, err=%v", res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect player 1 without ACKing seq 5
|
||||||
|
fresh := &fakeConn{}
|
||||||
|
gc := claims(time.Now().Add(time.Hour))
|
||||||
|
if _, _, err := m.Join(context.Background(), fresh, gc, gc.Player1ID); err != nil {
|
||||||
|
t.Fatalf("rejoin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
freshMsgs := fresh.messages()
|
||||||
|
if len(freshMsgs) != 1 {
|
||||||
|
t.Fatalf("rejoined player got %d messages, want 1", len(freshMsgs))
|
||||||
|
}
|
||||||
|
v := parseMsg(t, freshMsgs[0])
|
||||||
|
// Should be replayed FIRE_RESULT [7, 5, 2]
|
||||||
|
if v[0] != float64(protocol.OpFireResult) || v[1] != float64(5) || v[2] != float64(protocol.FireResultHit) {
|
||||||
|
t.Fatalf("replayed msg = %v, want [7, 5, 2]", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
// 2 S->C END [2, <seq>, <code>, "<reason>"]
|
// 2 S->C END [2, <seq>, <code>, "<reason>"]
|
||||||
// 3 S->C STATE [3, <seq>, <state_code>]
|
// 3 S->C STATE [3, <seq>, <state_code>]
|
||||||
// 4 C<->S ACK [4, <seq>]
|
// 4 C<->S ACK [4, <seq>]
|
||||||
|
// 5 C->S PLACE_SHIPS [5, ship0_orientation, ship0_x, ship0_y, ...]
|
||||||
|
// 6 C->S FIRE [6, <x>, <y>]
|
||||||
|
// 7 S->C FIRE_RESULT [7, <seq>, <result_code>]
|
||||||
//
|
//
|
||||||
// Every state-changing server->client message (END, STATE) carries a
|
// Every state-changing server->client message (END, STATE, FIRE_RESULT) carries a
|
||||||
// per-player monotonically increasing sequence number. The client confirms
|
// per-player monotonically increasing sequence number. The client confirms
|
||||||
// delivery with an ACK message containing the sequence number of the last
|
// delivery with an ACK message containing the sequence number of the last
|
||||||
// received message (ACK is cumulative). Unacknowledged messages are kept in
|
// received message (ACK is cumulative). Unacknowledged messages are kept in
|
||||||
@@ -34,8 +37,52 @@ const (
|
|||||||
OpEnd = 2 // S->C: match ended
|
OpEnd = 2 // S->C: match ended
|
||||||
OpState = 3 // S->C: game state transition
|
OpState = 3 // S->C: game state transition
|
||||||
OpAck = 4 // C<->S: delivery acknowledgement
|
OpAck = 4 // C<->S: delivery acknowledgement
|
||||||
|
OpPlaceShips = 5 // C->S: ship placement
|
||||||
|
OpFire = 6 // C->S: fire shot
|
||||||
|
OpFireResult = 7 // S->C: shot result
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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
|
// 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".
|
// view: a player never learns whether they are "player 1" or "player 2".
|
||||||
const (
|
const (
|
||||||
@@ -46,14 +93,25 @@ const (
|
|||||||
StateWaitOpponentTurn = 5 // wait for the opponent's move
|
StateWaitOpponentTurn = 5 // wait for the opponent's move
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shot result codes for FIRE_RESULT (OpFireResult).
|
||||||
|
const (
|
||||||
|
FireResultMiss = 1 // miss (промах)
|
||||||
|
FireResultHit = 2 // hit (простое попадание)
|
||||||
|
FireResultSunk = 3 // ship sunk (корабль потоплен)
|
||||||
|
FireResultInvalidPosition = 4 // invalid position (неверная позиция)
|
||||||
|
)
|
||||||
|
|
||||||
// Match end codes.
|
// Match end codes.
|
||||||
const (
|
const (
|
||||||
EndCodeJoinTimeout = -1 // the second player never joined
|
EndCodeJoinTimeout = -1 // the second player never joined
|
||||||
|
EndCodeVictory = 1 // you won (all opponent ships sunk)
|
||||||
|
EndCodeDefeat = 2 // you lost (all your ships sunk)
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSocket close codes used by the server (application range 4000+).
|
// WebSocket close codes used by the server (application range 4000+ and RFC 6455 codes).
|
||||||
const (
|
const (
|
||||||
CloseCodeNormal = 1000 // normal closure
|
CloseCodeNormal = 1000 // normal closure
|
||||||
|
CloseCodeGoingAway = 1001 // server shutting down
|
||||||
CloseCodeReplaced = 4000 // connection replaced by a newer one of the same player
|
CloseCodeReplaced = 4000 // connection replaced by a newer one of the same player
|
||||||
CloseCodeExpectedJoin = 4001 // first message was not a JOIN
|
CloseCodeExpectedJoin = 4001 // first message was not a JOIN
|
||||||
CloseCodeBadMessage = 4002 // malformed message
|
CloseCodeBadMessage = 4002 // malformed message
|
||||||
@@ -102,6 +160,98 @@ func ParseAck(args []json.RawMessage) (seq int64, err error) {
|
|||||||
return seq, nil
|
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 {
|
func build(v []any) []byte {
|
||||||
data, err := json.Marshal(v)
|
data, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -126,3 +276,55 @@ func BuildState(seq int64, state int) []byte {
|
|||||||
func BuildAck(seq int64) []byte {
|
func BuildAck(seq int64) []byte {
|
||||||
return build([]any{OpAck, seq})
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFire extracts the x and y coordinates from FIRE arguments.
|
||||||
|
func ParseFire(args []json.RawMessage) (x, y int, err error) {
|
||||||
|
if len(args) != 2 {
|
||||||
|
return 0, 0, fmt.Errorf("FIRE expects 2 arguments, got %d", len(args))
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args[0], &x); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("x coordinate is not an integer: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args[1], &y); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("y coordinate is not an integer: %w", err)
|
||||||
|
}
|
||||||
|
return x, y, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFire builds a FIRE message: [6, x, y].
|
||||||
|
func BuildFire(x, y int) []byte {
|
||||||
|
return build([]any{OpFire, x, y})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFireResult extracts seq and result code from FIRE_RESULT arguments.
|
||||||
|
func ParseFireResult(args []json.RawMessage) (seq int64, result int, err error) {
|
||||||
|
if len(args) != 2 {
|
||||||
|
return 0, 0, fmt.Errorf("FIRE_RESULT expects 2 arguments, got %d", len(args))
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args[0], &seq); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("seq is not a number: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args[1], &result); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("result is not a number: %w", err)
|
||||||
|
}
|
||||||
|
return seq, result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFireResult builds a FIRE_RESULT message: [7, seq, result].
|
||||||
|
func BuildFireResult(seq int64, result int) []byte {
|
||||||
|
return build([]any{OpFireResult, seq, result})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,3 +69,283 @@ func TestParseAck(t *testing.T) {
|
|||||||
t.Fatalf("seq = %d, want 12", seq)
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireRoundtrip(t *testing.T) {
|
||||||
|
data := BuildFire(3, 7)
|
||||||
|
opcode, args, err := ParseMessage(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMessage: %v", err)
|
||||||
|
}
|
||||||
|
if opcode != OpFire {
|
||||||
|
t.Fatalf("opcode = %d, want %d", opcode, OpFire)
|
||||||
|
}
|
||||||
|
x, y, err := ParseFire(args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFire: %v", err)
|
||||||
|
}
|
||||||
|
if x != 3 || y != 7 {
|
||||||
|
t.Fatalf("got (%d, %d), want (3, 7)", x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFireErrors(t *testing.T) {
|
||||||
|
t.Run("wrong argument count", func(t *testing.T) {
|
||||||
|
_, args, _ := ParseMessage([]byte(`[6, 1]`))
|
||||||
|
if _, _, err := ParseFire(args); err == nil {
|
||||||
|
t.Fatal("expected error for 1 argument")
|
||||||
|
}
|
||||||
|
_, args3, _ := ParseMessage([]byte(`[6, 1, 2, 3]`))
|
||||||
|
if _, _, err := ParseFire(args3); err == nil {
|
||||||
|
t.Fatal("expected error for 3 arguments")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("x not integer", func(t *testing.T) {
|
||||||
|
_, args, _ := ParseMessage([]byte(`[6, "a", 2]`))
|
||||||
|
if _, _, err := ParseFire(args); err == nil {
|
||||||
|
t.Fatal("expected error for string x")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("y not integer", func(t *testing.T) {
|
||||||
|
_, args, _ := ParseMessage([]byte(`[6, 1, true]`))
|
||||||
|
if _, _, err := ParseFire(args); err == nil {
|
||||||
|
t.Fatal("expected error for bool y")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireResultRoundtrip(t *testing.T) {
|
||||||
|
data := BuildFireResult(5, FireResultSunk)
|
||||||
|
opcode, args, err := ParseMessage(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMessage: %v", err)
|
||||||
|
}
|
||||||
|
if opcode != OpFireResult {
|
||||||
|
t.Fatalf("opcode = %d, want %d", opcode, OpFireResult)
|
||||||
|
}
|
||||||
|
seq, result, err := ParseFireResult(args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFireResult: %v", err)
|
||||||
|
}
|
||||||
|
if seq != 5 || result != FireResultSunk {
|
||||||
|
t.Fatalf("got seq=%d, result=%d, want seq=5, result=%d", seq, result, FireResultSunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFireResultErrors(t *testing.T) {
|
||||||
|
t.Run("wrong argument count", func(t *testing.T) {
|
||||||
|
_, args, _ := ParseMessage([]byte(`[7, 1]`))
|
||||||
|
if _, _, err := ParseFireResult(args); err == nil {
|
||||||
|
t.Fatal("expected error for 1 argument")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("seq not int64", func(t *testing.T) {
|
||||||
|
_, args, _ := ParseMessage([]byte(`[7, "str", 1]`))
|
||||||
|
if _, _, err := ParseFireResult(args); err == nil {
|
||||||
|
t.Fatal("expected error for string seq")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("result not int", func(t *testing.T) {
|
||||||
|
_, args, _ := ParseMessage([]byte(`[7, 1, "hit"]`))
|
||||||
|
if _, _, err := ParseFireResult(args); err == nil {
|
||||||
|
t.Fatal("expected error for string result")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ package ws
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
@@ -24,22 +24,109 @@ const (
|
|||||||
pingTimeout = 10 * time.Second
|
pingTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Conn adapts a websocket connection to the game.Sender interface.
|
type writeOp struct {
|
||||||
type Conn struct {
|
data []byte
|
||||||
ws *websocket.Conn
|
isClose bool
|
||||||
wmu sync.Mutex // serializes writes
|
code websocket.StatusCode
|
||||||
|
reason string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn) Send(_ context.Context, data []byte) error {
|
// Conn adapts a websocket connection to the game.Sender interface,
|
||||||
|
// delegating all writes to a dedicated goroutine to prevent locking
|
||||||
|
// game mutexes during write timeouts.
|
||||||
|
type Conn struct {
|
||||||
|
ws *websocket.Conn
|
||||||
|
sendCh chan writeOp
|
||||||
|
done chan struct{} // closed to abort writeLoop
|
||||||
|
closed chan struct{} // closed when writeLoop exits
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConn(ws *websocket.Conn) *Conn {
|
||||||
|
c := &Conn{
|
||||||
|
ws: ws,
|
||||||
|
sendCh: make(chan writeOp, 64),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
closed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go c.writeLoop()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) writeLoop() {
|
||||||
|
defer close(c.closed)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return
|
||||||
|
case op, ok := <-c.sendCh:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if op.isClose {
|
||||||
|
_ = c.ws.Close(op.code, op.reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
||||||
defer cancel()
|
err := c.ws.Write(ctx, websocket.MessageText, op.data)
|
||||||
c.wmu.Lock()
|
cancel()
|
||||||
defer c.wmu.Unlock()
|
if err != nil {
|
||||||
return c.ws.Write(ctx, websocket.MessageText, data)
|
_ = c.ws.Close(websocket.StatusGoingAway, "write error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) Send(ctx context.Context, data []byte) error {
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return errors.New("connection closed")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := make([]byte, len(data))
|
||||||
|
copy(msg, data)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return errors.New("connection closed")
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case c.sendCh <- writeOp{data: msg}:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn) Close(code int, reason string) error {
|
func (c *Conn) Close(code int, reason string) error {
|
||||||
return c.ws.Close(websocket.StatusCode(code), reason)
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
op := writeOp{
|
||||||
|
isClose: true,
|
||||||
|
code: websocket.StatusCode(code),
|
||||||
|
reason: reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.sendCh <- op:
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
case <-time.After(writeTimeout):
|
||||||
|
_ = c.ws.Close(websocket.StatusCode(code), reason)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
default:
|
||||||
|
close(c.done)
|
||||||
|
}
|
||||||
|
_ = c.ws.Close(websocket.StatusCode(code), reason)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler is the http.Handler of the websocket endpoint.
|
// Handler is the http.Handler of the websocket endpoint.
|
||||||
@@ -104,9 +191,26 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If player is reconnecting with the same game token used when creating the match,
|
||||||
|
// skip token validation completely.
|
||||||
|
var gc *auth.GameClaims
|
||||||
|
unverified, _ := auth.ParseGameTokenUnverified(gameToken)
|
||||||
|
if unverified != nil {
|
||||||
|
if existing := h.Manager.GetMatch(unverified.MatchID); existing != nil && existing.GameToken() == gameToken {
|
||||||
|
gc = &auth.GameClaims{
|
||||||
|
MatchID: existing.ID,
|
||||||
|
Player1ID: existing.PlayerIDs()[0],
|
||||||
|
Player2ID: existing.PlayerIDs()[1],
|
||||||
|
RawToken: gameToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gc == nil {
|
||||||
// Validate the game token and make sure the token was issued for this
|
// Validate the game token and make sure the token was issued for this
|
||||||
// particular server instance (strict comparison with the public address).
|
// particular server instance (strict comparison with the public address).
|
||||||
gc, err := auth.ParseGameToken(h.JWTSecret, gameToken)
|
var err error
|
||||||
|
gc, err = auth.ParseGameToken(h.JWTSecret, gameToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Debug("bad game token", "err", err)
|
h.log.Debug("bad game token", "err", err)
|
||||||
wsc.Close(protocol.CloseCodeBadGameToken, "invalid game token")
|
wsc.Close(protocol.CloseCodeBadGameToken, "invalid game token")
|
||||||
@@ -117,10 +221,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
wsc.Close(protocol.CloseCodeBadGameToken, "game server mismatch")
|
wsc.Close(protocol.CloseCodeBadGameToken, "game server mismatch")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if gc.Player1ID == gc.Player2ID {
|
if gc.Player1ID <= 0 || gc.Player2ID <= 0 || gc.Player1ID == gc.Player2ID {
|
||||||
wsc.Close(protocol.CloseCodeBadGameToken, "invalid match")
|
wsc.Close(protocol.CloseCodeBadGameToken, "invalid match")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate the user token and make sure the user is a participant.
|
// Validate the user token and make sure the user is a participant.
|
||||||
uc, err := auth.ParseUserToken(h.JWTSecret, userToken)
|
uc, err := auth.ParseUserToken(h.JWTSecret, userToken)
|
||||||
@@ -129,12 +234,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
wsc.Close(protocol.CloseCodeBadUserToken, "invalid user token")
|
wsc.Close(protocol.CloseCodeBadUserToken, "invalid user token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if uc.UserID != gc.Player1ID && uc.UserID != gc.Player2ID {
|
if uc.UserID <= 0 || (uc.UserID != gc.Player1ID && uc.UserID != gc.Player2ID) {
|
||||||
wsc.Close(protocol.CloseCodeBadUserToken, "not a participant of this match")
|
wsc.Close(protocol.CloseCodeBadUserToken, "not a participant of this match")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
conn := &Conn{ws: wsc}
|
conn := newConn(wsc)
|
||||||
match, slotIdx, err := h.Manager.Join(ctx, conn, gc, uc.UserID)
|
match, slotIdx, err := h.Manager.Join(ctx, conn, gc, uc.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// ErrMatchFinished: the END message has already been replayed and
|
// ErrMatchFinished: the END message has already been replayed and
|
||||||
@@ -160,13 +265,42 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch opcode {
|
switch opcode {
|
||||||
|
case protocol.OpJoin:
|
||||||
|
// Duplicate JOIN on an established connection is ignored.
|
||||||
case protocol.OpAck:
|
case protocol.OpAck:
|
||||||
seq, err := protocol.ParseAck(args)
|
seq, err := protocol.ParseAck(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Debug("malformed ACK", "match", match.ID, "user", uc.UserID, "err", err)
|
h.log.Debug("malformed ACK", "match", match.ID, "user", uc.UserID, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
h.Manager.Ack(match, slotIdx, seq)
|
if err := h.Manager.Ack(match, slotIdx, seq); err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
case protocol.OpFire:
|
||||||
|
x, y, err := protocol.ParseFire(args)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Debug("malformed Fire", "match", match.ID, "user", uc.UserID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := h.Manager.Fire(ctx, match, slotIdx, x, y); err != nil {
|
||||||
|
h.log.Debug("fire rejected or ignored", "match", match.ID, "user", uc.UserID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// Unknown opcodes are ignored for forward compatibility.
|
// Unknown opcodes are ignored for forward compatibility.
|
||||||
h.log.Debug("unknown opcode", "match", match.ID, "user", uc.UserID, "opcode", opcode)
|
h.log.Debug("unknown opcode", "match", match.ID, "user", uc.UserID, "opcode", opcode)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/ruby-sea-battle/game-server/internal/auth"
|
"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/game"
|
||||||
|
"github.com/ruby-sea-battle/game-server/internal/protocol"
|
||||||
"github.com/ruby-sea-battle/game-server/internal/ws"
|
"github.com/ruby-sea-battle/game-server/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,20 +54,39 @@ func userToken(t *testing.T, userID int64) string {
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServer(t *testing.T, joinTimeout time.Duration) (url string) {
|
func startServerWithManager(t *testing.T, joinTimeout time.Duration) (string, *game.Manager) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
manager := game.NewManager(joinTimeout)
|
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)
|
handler := ws.NewHandler(manager, secret, publicAddr, []string{"*"}, log)
|
||||||
srv := httptest.NewServer(handler)
|
srv := httptest.NewServer(handler)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(func() {
|
||||||
return "ws" + strings.TrimPrefix(srv.URL, "http")
|
srv.Close()
|
||||||
|
tw.mu.Lock()
|
||||||
|
tw.done = true
|
||||||
|
tw.mu.Unlock()
|
||||||
|
})
|
||||||
|
return "ws" + strings.TrimPrefix(srv.URL, "http"), manager
|
||||||
}
|
}
|
||||||
|
|
||||||
type testWriter struct{ t *testing.T }
|
func startServer(t *testing.T, joinTimeout time.Duration) (url string) {
|
||||||
|
u, _ := startServerWithManager(t, joinTimeout)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
func (w testWriter) Write(p []byte) (int, error) {
|
type testWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
t *testing.T
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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)))
|
w.t.Log(strings.TrimSpace(string(p)))
|
||||||
|
}
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,3 +211,378 @@ func TestReconnectDuringGame(t *testing.T) {
|
|||||||
t.Fatalf("unexpected replayed message: %v", v)
|
t.Fatalf("unexpected replayed message: %v", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReconnectWithExpiredTokenAllowedIfSameToken(t *testing.T) {
|
||||||
|
url := startServer(t, time.Minute)
|
||||||
|
// Short expiration: 1200ms
|
||||||
|
exp := time.Now().Add(1200 * time.Millisecond)
|
||||||
|
gt := gameToken(t, "m-reconnect-exp", 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))
|
||||||
|
readMsg(t, c1) // STATE
|
||||||
|
readMsg(t, c2) // STATE
|
||||||
|
|
||||||
|
// Wait for the game token to expire
|
||||||
|
time.Sleep(1300 * time.Millisecond)
|
||||||
|
|
||||||
|
// Simulate disconnect
|
||||||
|
c1.Close(websocket.StatusAbnormalClosure, "network gone")
|
||||||
|
|
||||||
|
// Reconnect with the SAME token (which is now expired)
|
||||||
|
c1b := dial(t, url)
|
||||||
|
join(t, c1b, gt, userToken(t, 1))
|
||||||
|
v := readMsg(t, c1b)
|
||||||
|
if v[0] != float64(3) || v[1] != float64(1) || v[2] != float64(1) {
|
||||||
|
t.Fatalf("unexpected replayed message on reconnect: %v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// But an expired token for a non-existent match is rejected with 4003
|
||||||
|
gtExpired := gameToken(t, "m-other", 1, 2, publicAddr, time.Now().Add(-time.Hour))
|
||||||
|
cOther := dial(t, url)
|
||||||
|
join(t, cOther, gtExpired, userToken(t, 1))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _, err := cOther.Read(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected connection to be rejected")
|
||||||
|
}
|
||||||
|
if code := websocket.CloseStatus(err); code != 4003 {
|
||||||
|
t.Fatalf("close code = %d, want 4003", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateJoinIgnored(t *testing.T) {
|
||||||
|
url := startServer(t, time.Minute)
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
gt := gameToken(t, "m-dup-join", 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))
|
||||||
|
readMsg(t, c1) // STATE
|
||||||
|
|
||||||
|
// Send duplicate JOIN on c1
|
||||||
|
join(t, c1, gt, userToken(t, 1))
|
||||||
|
|
||||||
|
// Send ACK after duplicate JOIN; connection must still be open and functioning
|
||||||
|
ackMsg, _ := json.Marshal([]any{4, 1})
|
||||||
|
if err := c1.Write(context.Background(), websocket.MessageText, ackMsg); err != nil {
|
||||||
|
t.Fatalf("write ACK: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonPositiveIDsRejected(t *testing.T) {
|
||||||
|
url := startServer(t, time.Minute)
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
|
// Non-positive Player1ID
|
||||||
|
gtBadP1 := gameToken(t, "m-bad-p1", 0, 2, publicAddr, exp)
|
||||||
|
c := dial(t, url)
|
||||||
|
join(t, c, gtBadP1, userToken(t, 2))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _, err := c.Read(ctx)
|
||||||
|
if err == nil || websocket.CloseStatus(err) != 4003 {
|
||||||
|
t.Fatalf("expected 4003 for player1_id = 0, got err = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-positive UserID
|
||||||
|
gtValid := gameToken(t, "m-valid", 1, 2, publicAddr, exp)
|
||||||
|
c2 := dial(t, url)
|
||||||
|
join(t, c2, gtValid, userToken(t, -1))
|
||||||
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel2()
|
||||||
|
_, _, err = c2.Read(ctx2)
|
||||||
|
if err == nil || websocket.CloseStatus(err) != 4004 {
|
||||||
|
t.Fatalf("expected 4004 for user_id = -1, got err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerShutdownClosesConnectionsGoingAway(t *testing.T) {
|
||||||
|
url, mgr := startServerWithManager(t, time.Minute)
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
gt := gameToken(t, "m-shutdown", 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))
|
||||||
|
readMsg(t, c1)
|
||||||
|
readMsg(t, c2)
|
||||||
|
|
||||||
|
errCh := make(chan error, 2)
|
||||||
|
for _, c := range []*websocket.Conn{c1, c2} {
|
||||||
|
go func(conn *websocket.Conn) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _, err := conn.Read(ctx)
|
||||||
|
errCh <- err
|
||||||
|
}(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr.Shutdown()
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
err := <-errCh
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("client %d: expected connection to be closed", i)
|
||||||
|
}
|
||||||
|
if code := websocket.CloseStatus(err); code != 1001 {
|
||||||
|
t.Fatalf("client %d: close status = %d, want 1001", i, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireOverWebSocket(t *testing.T) {
|
||||||
|
url, _ := startServerWithManager(t, time.Minute)
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
gt := gameToken(t, "m-ws-fire", 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))
|
||||||
|
|
||||||
|
// Initial StatePlacingShips (seq 1)
|
||||||
|
readMsg(t, c1)
|
||||||
|
readMsg(t, c2)
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1))
|
||||||
|
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1))
|
||||||
|
|
||||||
|
// Both place ships
|
||||||
|
ships := validTestShips()
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildPlaceShips(ships))
|
||||||
|
readMsg(t, c1) // seq 2: StateWaitOpponentReady
|
||||||
|
readMsg(t, c2) // seq 2: StateOpponentReady
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2))
|
||||||
|
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2))
|
||||||
|
|
||||||
|
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildPlaceShips(ships))
|
||||||
|
readMsg(t, c1) // seq 3: StateYourTurn (p1 starts)
|
||||||
|
readMsg(t, c2) // seq 3: StateWaitOpponentTurn
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3))
|
||||||
|
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3))
|
||||||
|
|
||||||
|
// 1. Player 1 sends invalid position (-1, 0)
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(-1, 0))
|
||||||
|
v1 := readMsg(t, c1)
|
||||||
|
// [7, 4, 4]
|
||||||
|
if v1[0] != float64(protocol.OpFireResult) || v1[1] != float64(4) || v1[2] != float64(protocol.FireResultInvalidPosition) {
|
||||||
|
t.Fatalf("c1 invalid position result = %v, want [7, 4, 4]", v1)
|
||||||
|
}
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(4))
|
||||||
|
|
||||||
|
// 2. Player 1 hits ship at (0, 0)
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(0, 0))
|
||||||
|
v1 = readMsg(t, c1)
|
||||||
|
// [7, 5, 2]
|
||||||
|
if v1[0] != float64(protocol.OpFireResult) || v1[1] != float64(5) || v1[2] != float64(protocol.FireResultHit) {
|
||||||
|
t.Fatalf("c1 hit result = %v, want [7, 5, 2]", v1)
|
||||||
|
}
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(5))
|
||||||
|
|
||||||
|
// 3. Player 1 misses at (9, 9)
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(9, 9))
|
||||||
|
v1Fire := readMsg(t, c1)
|
||||||
|
// [7, 6, 1]
|
||||||
|
if v1Fire[0] != float64(protocol.OpFireResult) || v1Fire[1] != float64(6) || v1Fire[2] != float64(protocol.FireResultMiss) {
|
||||||
|
t.Fatalf("c1 miss result = %v, want [7, 6, 1]", v1Fire)
|
||||||
|
}
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(6))
|
||||||
|
|
||||||
|
// Turn transitions: c1 gets StateWaitOpponentTurn (5) with seq 7
|
||||||
|
v1State := readMsg(t, c1)
|
||||||
|
if v1State[0] != float64(protocol.OpState) || v1State[1] != float64(7) || v1State[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||||
|
t.Fatalf("c1 state after miss = %v, want [3, 7, 5]", v1State)
|
||||||
|
}
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(7))
|
||||||
|
|
||||||
|
// c2 gets StateYourTurn (4) with seq 4
|
||||||
|
v2State := readMsg(t, c2)
|
||||||
|
if v2State[0] != float64(protocol.OpState) || v2State[1] != float64(4) || v2State[2] != float64(protocol.StateYourTurn) {
|
||||||
|
t.Fatalf("c2 state after miss = %v, want [3, 4, 4]", v2State)
|
||||||
|
}
|
||||||
|
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(4))
|
||||||
|
|
||||||
|
// 4. Player 1 tries to fire while it's Player 2's turn -> ignored
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(5, 5))
|
||||||
|
|
||||||
|
// 5. Player 2 fires at (9, 4) -> single ship sunk
|
||||||
|
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildFire(9, 4))
|
||||||
|
v2Fire := readMsg(t, c2)
|
||||||
|
if v2Fire[0] != float64(protocol.OpFireResult) || v2Fire[1] != float64(5) || v2Fire[2] != float64(protocol.FireResultSunk) {
|
||||||
|
t.Fatalf("c2 sunk result = %v, want [7, 5, 3]", v2Fire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFireWinOverWebSocket(t *testing.T) {
|
||||||
|
url, _ := startServerWithManager(t, time.Minute)
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
gt := gameToken(t, "m-ws-fire-win", 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))
|
||||||
|
|
||||||
|
// Initial StatePlacingShips
|
||||||
|
readMsg(t, c1)
|
||||||
|
readMsg(t, c2)
|
||||||
|
|
||||||
|
// Both place ships
|
||||||
|
ships := validTestShips()
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildPlaceShips(ships))
|
||||||
|
readMsg(t, c1)
|
||||||
|
readMsg(t, c2)
|
||||||
|
|
||||||
|
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildPlaceShips(ships))
|
||||||
|
readMsg(t, c1) // StateYourTurn
|
||||||
|
readMsg(t, c2) // StateWaitOpponentTurn
|
||||||
|
|
||||||
|
// Player 1 destroys all 10 ships of Player 2
|
||||||
|
for i, ship := range ships {
|
||||||
|
cells := ship.Cells()
|
||||||
|
for j, c := range cells {
|
||||||
|
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(c.X, c.Y))
|
||||||
|
v := readMsg(t, c1)
|
||||||
|
if j < len(cells)-1 {
|
||||||
|
if v[0] != float64(protocol.OpFireResult) || v[2] != float64(protocol.FireResultHit) {
|
||||||
|
t.Fatalf("ship %d cell %d: want Hit, got %v", i, j, v)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if v[0] != float64(protocol.OpFireResult) || v[2] != float64(protocol.FireResultSunk) {
|
||||||
|
t.Fatalf("ship %d cell %d: want Sunk, got %v", i, j, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player 1 receives END victory
|
||||||
|
v1End := readMsg(t, c1)
|
||||||
|
if v1End[0] != float64(protocol.OpEnd) || v1End[2] != float64(protocol.EndCodeVictory) || v1End[3] != "victory" {
|
||||||
|
t.Fatalf("c1 want END victory, got %v", v1End)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player 2 receives END defeat
|
||||||
|
v2End := readMsg(t, c2)
|
||||||
|
if v2End[0] != float64(protocol.OpEnd) || v2End[2] != float64(protocol.EndCodeDefeat) || v2End[3] != "defeat" {
|
||||||
|
t.Fatalf("c2 want END defeat, got %v", v2End)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connections should be closed with 1000
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _, err1 := c1.Read(ctx)
|
||||||
|
if err1 == nil {
|
||||||
|
t.Fatal("expected c1 connection to be closed")
|
||||||
|
}
|
||||||
|
_, _, err2 := c2.Read(ctx)
|
||||||
|
if err2 == nil {
|
||||||
|
t.Fatal("expected c2 connection to be closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Late reconnect gets END replayed and connection closed
|
||||||
|
c1Late := dial(t, url)
|
||||||
|
join(t, c1Late, gt, userToken(t, 1))
|
||||||
|
lateEnd := readMsg(t, c1Late)
|
||||||
|
if lateEnd[0] != float64(protocol.OpEnd) || lateEnd[2] != float64(protocol.EndCodeVictory) {
|
||||||
|
t.Fatalf("late join want END victory, got %v", lateEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+87
-33
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Центральный сервер для браузерной онлайн-игры «Морской бой». Предоставляет REST API для регистрации, аутентификации и матчмейкинга.
|
Центральный сервер для браузерной онлайн-игры «Морской бой». Предоставляет REST API для регистрации, аутентификации и матчмейкинга.
|
||||||
|
|
||||||
|
## Дополнительная документация
|
||||||
|
|
||||||
|
- [docs/matchmaking.md](docs/matchmaking.md) — детальное описание алгоритма матчмейкинга, структур данных Redis, формулы допуска рейтинга, Lua-скриптов и гарантий доставки.
|
||||||
|
- [docs/architecture.md](docs/architecture.md) — архитектура главного сервера, стек компонентов Rails, безопасность и взаимодействие с Game Server.
|
||||||
|
|
||||||
## Базовый URL
|
## Базовый URL
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -10,13 +15,17 @@ http://localhost:3000/api
|
|||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
|
|
||||||
Все endpoints (кроме `/register` и `/login`) требуют JWT-токен в заголовке:
|
Все endpoints (кроме `/register` и `/login`) требуют JWT-токен. Токен может передаваться двумя способами:
|
||||||
|
1. В HTTP-заголовке (рекомендуемый способ для REST API):
|
||||||
```
|
```
|
||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
2. В параметрах запроса query string (используется для WebSocket-подключений и поддерживается REST API):
|
||||||
|
```
|
||||||
|
?token=<token>
|
||||||
|
```
|
||||||
|
|
||||||
Токен выдаётся при регистрации или входе и действителен **7 дней**.
|
Токен выдаётся при успешной регистрации или входе и действителен **7 дней**. Токен содержит идентификатор пользователя (`user_id`) и время истечения (`exp`). При обращении к защищённым endpoints сервер проверяет подпись токена, срок действия и существование пользователя в базе данных.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -209,65 +218,110 @@ curl -X DELETE http://localhost:3000/api/logout \
|
|||||||
|
|
||||||
### Поиск соперника (Матчмейкинг)
|
### Поиск соперника (Матчмейкинг)
|
||||||
|
|
||||||
Поиск соперника происходит через **WebSocket** (`Action Cable`), а не через long-polling: HTTP-поток не блокируется, а результат доставляется сервером push-уведомлением, как только найден соперник.
|
Поиск соперника происходит через **WebSocket** на базе протокола `Action Cable`. Это устраняет необходимость long-polling: сокет не блокирует HTTP-потоки, а результат поиска доставляется сервером асинхронно push-уведомлением.
|
||||||
|
|
||||||
|
> Подробное описание структур данных в Redis, формулы допуска рейтинга, двухфазного поиска и Lua-скриптов вынесено в отдельный документ: [docs/matchmaking.md](docs/matchmaking.md).
|
||||||
|
|
||||||
**URL подключения:** `ws://localhost:3000/cable?token=<JWT>`
|
**URL подключения:** `ws://localhost:3000/cable?token=<JWT>`
|
||||||
|
|
||||||
**Auth:** токен передаётся параметром `token` в query string (тот же JWT, что и для HTTP API).
|
**Auth:** токен передаётся параметром `token` в query string (или в заголовке `Authorization: Bearer <JWT>` при поддержке клиентом). Если токен отсутствует, просрочен или пользователь не найден, сервер немедленно закрывает соединение (`reject_unauthorized_connection`).
|
||||||
|
|
||||||
**Канал:** `MatchmakingChannel`
|
**Канал:** `MatchmakingChannel`
|
||||||
|
|
||||||
#### Поток взаимодействия
|
#### Протокол взаимодействия Action Cable
|
||||||
|
|
||||||
1. Подключиться к `/cable?token=...` и подписаться на канал `MatchmakingChannel`.
|
Action Cable использует формат JSON-сообщений через WebSocket:
|
||||||
2. Отправить команду начала поиска:
|
|
||||||
|
|
||||||
|
1. **Подключение и рукопожатие:**
|
||||||
|
При открытии сокета сервер отправляет приветственное сообщение:
|
||||||
```json
|
```json
|
||||||
{ "action": "start" }
|
{ "type": "welcome" }
|
||||||
|
```
|
||||||
|
В процессе работы сервер регулярно отправляет heartbeat-сообщения (пинг):
|
||||||
|
```json
|
||||||
|
{ "type": "ping", "message": 1729864800 }
|
||||||
```
|
```
|
||||||
|
|
||||||
Рейтинг для подбора берётся из базы данных (`users.rating`). У новых пользователей он инициализируется нейтральным значением `100` (значение задано в БД); в будущем рейтинг будет пересчитываться по результатам матчей. Игроки подбираются по близости рейтинга; допуск расширяется с ростом времени ожидания.
|
2. **Подписка на канал:**
|
||||||
|
Клиент отправляет команду подписки на канал `MatchmakingChannel`:
|
||||||
3. Дождаться одного из сообщений:
|
|
||||||
|
|
||||||
**Матч найден:**
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"command": "subscribe",
|
||||||
|
"identifier": "{\"channel\":\"MatchmakingChannel\"}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
При успешной подписке сервер отвечает подтверждением:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "confirm_subscription",
|
||||||
|
"identifier": "{\"channel\":\"MatchmakingChannel\"}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*Примечание:* Если матч был сформирован в момент временного разрыва соединения (кратковременный реконнект), сервер в момент подписки выполняет операцию `claim` и немедленно передаёт клиенту сообщение `matched` (гарантия exactly-once доставки).
|
||||||
|
|
||||||
|
3. **Запуск поиска соперника:**
|
||||||
|
Для входа в очередь матчмейкинга клиент отправляет action `start`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "message",
|
||||||
|
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
|
||||||
|
"data": "{\"action\":\"start\"}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Рейтинг для подбора считывается из БД (`users.rating`, по умолчанию `100`). Игрок помещается в Redis (`mm:queue`). Сервер сразу выполняет попытку мгновенного подбора (Phase 1). Если подходящий соперник не найден сразу, фоновый воркер `MatchmakingSweepJob` каждые 2 секунды проверяет очередь (Phase 2), плавно расширяя допустимый диапазон рейтинга по мере ожидания.
|
||||||
|
|
||||||
|
4. **Получение результатов поиска:**
|
||||||
|
|
||||||
|
**Матч найден:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
|
||||||
|
"message": {
|
||||||
"type": "matched",
|
"type": "matched",
|
||||||
"match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
|
"match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
|
||||||
"game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...",
|
"game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...",
|
||||||
"game_server": "ws://localhost:8080",
|
"game_server": "ws://localhost:8080",
|
||||||
"players": [1, 2]
|
"players": [1, 2]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Поле | Описание |
|
| Поле | Описание |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `match_id` | Уникальный идентификатор матча (UUID) |
|
| `match_id` | Уникальный UUID матча, сгенерированный сервером |
|
||||||
| `game_token` | JWT-токен для аутентификации на игровом сервере |
|
| `game_token` | Подписанный JWT-токен для авторизации на `game-server` (действителен `MATCHMAKING_TOKEN_TTL` сек) |
|
||||||
| `game_server` | WebSocket-адрес игрового сервера |
|
| `game_server` | WebSocket URL игрового сервера Go, на который должны подключиться оба игрока |
|
||||||
| `players` | ID обоих игроков `[player1_id, player2_id]` |
|
| `players` | Массив идентификаторов игроков матча `[player1_id, player2_id]` |
|
||||||
|
|
||||||
**Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд):
|
|
||||||
|
|
||||||
|
**Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд, по умолчанию 60 сек):
|
||||||
```json
|
```json
|
||||||
{ "type": "timeout" }
|
{
|
||||||
|
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
|
||||||
|
"message": {
|
||||||
|
"type": "timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
После таймаута игрок удаляется из очереди поиска. При необходимости поиск можно начать заново командой `start`.
|
||||||
|
|
||||||
4. Поиск отменяется автоматически при закрытии WebSocket-подписки. Также можно явно отправить:
|
5. **Отмена поиска:**
|
||||||
|
Отменить поиск можно двумя способами:
|
||||||
|
- Отправить команду `stop`:
|
||||||
```json
|
```json
|
||||||
{ "action": "stop" }
|
{
|
||||||
|
"command": "message",
|
||||||
|
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
|
||||||
|
"data": "{\"action\":\"stop\"}"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
- Отписаться от канала или закрыть WebSocket-соединение:
|
||||||
**Примечание:** `game_token` содержит `match_id`, `player1_id`, `player2_id`, `game_server` и действителен `MATCHMAKING_TOKEN_TTL` секунд.
|
```json
|
||||||
|
{
|
||||||
---
|
"command": "unsubscribe",
|
||||||
|
"identifier": "{\"channel\":\"MatchmakingChannel\"}"
|
||||||
### Отмена поиска
|
}
|
||||||
|
```
|
||||||
Отмена поиска происходит через WebSocket: достаточно отключиться от `MatchmakingChannel` либо отправить `{ "action": "stop" }`.
|
Сервер атомарно удаляет игрока из всех очередей и хешей Redis.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -51,3 +51,5 @@ gem "redis", "~> 6.0"
|
|||||||
group :test do
|
group :test do
|
||||||
gem "simplecov", require: false
|
gem "simplecov", require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
gem "dotenv-rails", "~> 3.2", groups: [:development, :test]
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ GEM
|
|||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
diff-lcs (1.6.2)
|
diff-lcs (1.6.2)
|
||||||
dotenv (3.2.0)
|
dotenv (3.2.0)
|
||||||
|
dotenv-rails (3.2.0)
|
||||||
|
dotenv (= 3.2.0)
|
||||||
|
railties (>= 6.1)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (6.0.7)
|
erb (6.0.7)
|
||||||
@@ -378,6 +381,7 @@ DEPENDENCIES
|
|||||||
brakeman
|
brakeman
|
||||||
bundler-audit
|
bundler-audit
|
||||||
debug
|
debug
|
||||||
|
dotenv-rails (~> 3.2)
|
||||||
image_processing (~> 1.2)
|
image_processing (~> 1.2)
|
||||||
json (~> 2.19)
|
json (~> 2.19)
|
||||||
jwt (~> 2.10)
|
jwt (~> 2.10)
|
||||||
@@ -426,6 +430,7 @@ CHECKSUMS
|
|||||||
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
|
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
|
||||||
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
|
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
|
||||||
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
|
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
|
||||||
|
dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab
|
||||||
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
||||||
ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506
|
ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506
|
||||||
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
|
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
|
||||||
|
|||||||
+29
-13
@@ -1,24 +1,40 @@
|
|||||||
# README
|
# Ruby Sea Battle — Main Server
|
||||||
|
|
||||||
This README would normally document whatever steps are necessary to get the
|
Главный бэкенд многопользовательской онлайн-игры «Морской бой», написанный на Ruby on Rails 8.
|
||||||
application up and running.
|
|
||||||
|
|
||||||
Things you may want to cover:
|
## Документация
|
||||||
|
|
||||||
* Ruby version
|
- [API.md](API.md) — спецификация REST API и WebSocket Action Cable
|
||||||
|
- [docs/matchmaking.md](docs/matchmaking.md) — детальное описание алгоритма матчмейкинга, структур Redis и Lua-скриптов
|
||||||
|
- [docs/architecture.md](docs/architecture.md) — общая архитектура сервера, стек компонентов и модель безопасности
|
||||||
|
|
||||||
* System dependencies
|
## Требования
|
||||||
|
|
||||||
* Configuration
|
- Ruby 3.4+ / 4.0
|
||||||
|
- SQLite 3 (для разработки и тестов) / PostgreSQL (production)
|
||||||
|
- Redis 6+ (для очередей матчмейкинга и Action Cable)
|
||||||
|
|
||||||
* Database creation
|
## Установка и запуск
|
||||||
|
|
||||||
* Database initialization
|
```bash
|
||||||
|
# 1. Установка зависимостей
|
||||||
|
bundle install
|
||||||
|
|
||||||
* How to run the test suite
|
# 2. Подготовка базы данных
|
||||||
|
bin/rails db:prepare
|
||||||
|
|
||||||
* Services (job queues, cache servers, search engines, etc.)
|
# 3. Запуск сервера разработки (Puma на порту 3000)
|
||||||
|
bin/rails server
|
||||||
|
```
|
||||||
|
|
||||||
* Deployment instructions
|
## Тестирование
|
||||||
|
|
||||||
* ...
|
Проект полностью покрыт тестами на RSpec (модели, сервисы, контроллеры, Action Cable каналы и фоновые задачи).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск полного набора тестов
|
||||||
|
bundle exec rspec
|
||||||
|
|
||||||
|
# Запуск тестов со сбором отчета о покрытии кода (SimpleCov)
|
||||||
|
COVERAGE=true bundle exec rspec
|
||||||
|
```
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ class Api::SessionsController < ApplicationController
|
|||||||
skip_before_action :authenticate_request!, only: [:create]
|
skip_before_action :authenticate_request!, only: [:create]
|
||||||
|
|
||||||
def create
|
def create
|
||||||
user = User.authenticate_by(params.permit(:nickname, :password))
|
user = if params[:nickname].present? && params[:password].present?
|
||||||
|
User.authenticate_by(params.permit(:nickname, :password))
|
||||||
|
end
|
||||||
|
|
||||||
if user
|
if user
|
||||||
token = JwtService.encode(user_id: user.id)
|
token = JwtService.encode(user_id: user.id)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class ApplicationJob < ActiveJob::Base
|
||||||
|
# Automatically retry jobs that encountered a deadlock
|
||||||
|
# retry_on ActiveRecord::Deadlocked
|
||||||
|
|
||||||
|
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||||
|
# discard_on ActiveJob::DeserializationError
|
||||||
|
end
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class Match < ApplicationRecord
|
||||||
|
belongs_to :player1, class_name: "User"
|
||||||
|
belongs_to :player2, class_name: "User"
|
||||||
|
|
||||||
|
enum :status, {
|
||||||
|
ongoing: 0,
|
||||||
|
player1_won: 1,
|
||||||
|
player2_won: 2,
|
||||||
|
draw: 3,
|
||||||
|
abandoned: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
before_validation :assign_uuid, on: :create
|
||||||
|
|
||||||
|
validates :uuid, presence: true, uniqueness: true
|
||||||
|
validates :status, presence: true
|
||||||
|
validates :game_server, presence: true
|
||||||
|
validates :started_at, presence: true
|
||||||
|
validates :duration_seconds, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
|
||||||
|
validates :player1_rating_before, :player2_rating_before,
|
||||||
|
presence: true, numericality: { only_integer: true }
|
||||||
|
validates :player1_rating_change, :player2_rating_change,
|
||||||
|
presence: true, numericality: { only_integer: true }
|
||||||
|
validate :players_must_differ
|
||||||
|
|
||||||
|
scope :finished, -> { where.not(finished_at: nil) }
|
||||||
|
scope :for_user, ->(user_or_id) {
|
||||||
|
id = user_or_id.is_a?(User) ? user_or_id.id : user_or_id
|
||||||
|
where(player1_id: id).or(where(player2_id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def assign_uuid
|
||||||
|
self.uuid ||= SecureRandom.uuid
|
||||||
|
end
|
||||||
|
|
||||||
|
def players_must_differ
|
||||||
|
if player1_id.present? && player1_id == player2_id
|
||||||
|
errors.add(:player2, "must differ from player1")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,6 +2,8 @@ class User < ApplicationRecord
|
|||||||
has_secure_password validations: false
|
has_secure_password validations: false
|
||||||
|
|
||||||
has_many :sessions, dependent: :destroy
|
has_many :sessions, dependent: :destroy
|
||||||
|
has_many :matches_as_player1, class_name: "Match", foreign_key: :player1_id, dependent: :restrict_with_exception, inverse_of: :player1
|
||||||
|
has_many :matches_as_player2, class_name: "Match", foreign_key: :player2_id, dependent: :restrict_with_exception, inverse_of: :player2
|
||||||
has_one_attached :avatar
|
has_one_attached :avatar
|
||||||
|
|
||||||
normalizes :nickname, with: ->(n) { n.strip }
|
normalizes :nickname, with: ->(n) { n.strip }
|
||||||
@@ -11,6 +13,13 @@ class User < ApplicationRecord
|
|||||||
validates :password, presence: true, length: { minimum: 6 }, if: -> { new_record? || password_digest_changed? }
|
validates :password, presence: true, length: { minimum: 6 }, if: -> { new_record? || password_digest_changed? }
|
||||||
validate :avatar_content_type, if: -> { avatar.attached? }
|
validate :avatar_content_type, if: -> { avatar.attached? }
|
||||||
|
|
||||||
|
# All matches of this user that have finished, newest first.
|
||||||
|
def finished_matches
|
||||||
|
Match.where("player1_id = :id OR player2_id = :id", id: id)
|
||||||
|
.where.not(finished_at: nil)
|
||||||
|
.order(finished_at: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def avatar_content_type
|
def avatar_content_type
|
||||||
unless avatar.blob.content_type.in?(%w[image/png image/jpeg image/gif image/webp])
|
unless avatar.blob.content_type.in?(%w[image/png image/jpeg image/gif image/webp])
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Production: set REDIS_URL env variable, e.g. redis://redis.example.com:6379/0
|
# Production: set REDIS_URL env variable, e.g. redis://redis.example.com:6379/0
|
||||||
|
|
||||||
# Use an isolated Redis logical database in tests to avoid clobbering dev data.
|
# Use an isolated Redis logical database in tests to avoid clobbering dev data.
|
||||||
url = ENV.fetch("REDIS_URL", "redis://localhost:6380/0")
|
url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
|
||||||
url = url.sub(%r{/\d+$}, "/15") if Rails.env.test?
|
url = url.sub(%r{/\d+$}, "/15") if Rails.env.test?
|
||||||
|
|
||||||
RedisConnection = ConnectionPool.new(size: 5, timeout: 5) do
|
RedisConnection = ConnectionPool.new(size: 5, timeout: 5) do
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
class CreateMatches < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :matches do |t|
|
||||||
|
t.string :uuid, null: false
|
||||||
|
t.references :player1, null: false, foreign_key: { to_table: :users }
|
||||||
|
t.references :player2, null: false, foreign_key: { to_table: :users }
|
||||||
|
t.integer :status, null: false, default: 0
|
||||||
|
t.text :game_server, null: false
|
||||||
|
t.datetime :started_at, null: false
|
||||||
|
t.datetime :finished_at
|
||||||
|
t.integer :duration_seconds
|
||||||
|
t.integer :player1_rating_before, null: false
|
||||||
|
t.integer :player2_rating_before, null: false
|
||||||
|
t.integer :player1_rating_change, null: false, default: 0
|
||||||
|
t.integer :player2_rating_change, null: false, default: 0
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :matches, :uuid, unique: true
|
||||||
|
add_index :matches, [ :player1_id, :finished_at ]
|
||||||
|
add_index :matches, [ :player2_id, :finished_at ]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_10_05_000000) do
|
ActiveRecord::Schema[8.1].define(version: 2026_11_03_000000) do
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -39,6 +39,28 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_05_000000) do
|
|||||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "matches", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.integer "duration_seconds"
|
||||||
|
t.datetime "finished_at"
|
||||||
|
t.text "game_server", null: false
|
||||||
|
t.integer "player1_id", null: false
|
||||||
|
t.integer "player1_rating_before", null: false
|
||||||
|
t.integer "player1_rating_change", default: 0, null: false
|
||||||
|
t.integer "player2_id", null: false
|
||||||
|
t.integer "player2_rating_before", null: false
|
||||||
|
t.integer "player2_rating_change", default: 0, null: false
|
||||||
|
t.datetime "started_at", null: false
|
||||||
|
t.integer "status", default: 0, null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "uuid", null: false
|
||||||
|
t.index ["player1_id", "finished_at"], name: "index_matches_on_player1_id_and_finished_at"
|
||||||
|
t.index ["player1_id"], name: "index_matches_on_player1_id"
|
||||||
|
t.index ["player2_id", "finished_at"], name: "index_matches_on_player2_id_and_finished_at"
|
||||||
|
t.index ["player2_id"], name: "index_matches_on_player2_id"
|
||||||
|
t.index ["uuid"], name: "index_matches_on_uuid", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "sessions", force: :cascade do |t|
|
create_table "sessions", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "ip_address"
|
t.string "ip_address"
|
||||||
@@ -59,5 +81,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_05_000000) do
|
|||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "matches", "users", column: "player1_id"
|
||||||
|
add_foreign_key "matches", "users", column: "player2_id"
|
||||||
add_foreign_key "sessions", "users"
|
add_foreign_key "sessions", "users"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Архитектура Main Server (Ruby on Rails)
|
||||||
|
|
||||||
|
Документ описывает архитектуру главного сервера проекта «Ruby Sea Battle», структуру компонентов Rails-приложения, модель безопасности и схему взаимодействия с клиентом и игровым сервером.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Роль Main Server в общей системе
|
||||||
|
|
||||||
|
Система «Ruby Sea Battle» состоит из трёх ключевых узлов:
|
||||||
|
1. **Frontend (React):** SPA-интерфейс в браузере.
|
||||||
|
2. **Main Server (Ruby on Rails):** центральный сервер бизнес-логики, аккаунтов и матчмейкинга.
|
||||||
|
3. **Game Server (Go):** сервер игровых комнат в реальном времени по WebSocket.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Client["React Frontend"]
|
||||||
|
MainServer["Main Server (Rails 8)"]
|
||||||
|
RedisStore[("Redis (Очереди и Pub/Sub)")]
|
||||||
|
DB[(База данных SQLite/PostgreSQL)]
|
||||||
|
GameServer["Game Server (Go)"]
|
||||||
|
|
||||||
|
Client -- "REST API (Auth, Profile)" --> MainServer
|
||||||
|
Client -- "WebSocket /cable (Matchmaking)" --> MainServer
|
||||||
|
MainServer -- "State & Lua Scripts" --> RedisStore
|
||||||
|
MainServer -- "Пользователи и рейтинг" --> DB
|
||||||
|
MainServer -. "JWT с общим секретом" .-> GameServer
|
||||||
|
Client -- "WebSocket (Игровой процесс)" --> GameServer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Основные обязанности Main Server:
|
||||||
|
- Регистрация и аутентификация пользователей с выдачей JWT.
|
||||||
|
- Хранение профилей и аватарок игроков (через Active Storage).
|
||||||
|
- Ведение базового рейтинга пользователей (`users.rating`).
|
||||||
|
- Очередь матчмейкинга и подбор соперников в реальном времени через Action Cable и Redis.
|
||||||
|
- Выпуск одноразовых билетов матча (`game_token`) для подключения к игровому серверу Go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Структура слоёв приложения
|
||||||
|
|
||||||
|
Главный сервер построен на принципах Rails API-only приложения:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── channels/
|
||||||
|
│ ├── application_cable/
|
||||||
|
│ │ ├── channel.rb # Базовый класс каналов
|
||||||
|
│ │ └── connection.rb # Аутентификация WebSocket по JWT
|
||||||
|
│ └── matchmaking_channel.rb # Канал поиска соперника (start/stop/claim)
|
||||||
|
├── controllers/
|
||||||
|
│ ├── application_controller.rb # Базовый контроллер: проверка JWT и current_user
|
||||||
|
│ └── api/
|
||||||
|
│ ├── registrations_controller.rb # Регистрация и загрузка аватара
|
||||||
|
│ ├── sessions_controller.rb # Вход, выход, профиль (/api/me)
|
||||||
|
│ └── matchmaking_controller.rb # HTTP-отмена поиска
|
||||||
|
├── jobs/
|
||||||
|
│ ├── application_job.rb # Базовый класс фоновых задач
|
||||||
|
│ └── matchmaking_sweep_job.rb # Периодическая проверка очереди (Solid Queue)
|
||||||
|
├── models/
|
||||||
|
│ ├── application_record.rb # Базовый класс моделей
|
||||||
|
│ └── user.rb # Модель игрока (никнейм, пароль, рейтинг, аватар)
|
||||||
|
└── services/
|
||||||
|
├── jwt_service.rb # Кодирование и валидация JWT
|
||||||
|
└── matchmaking_service.rb # Логика очереди и вызовы Lua в Redis
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Компоненты приложения
|
||||||
|
|
||||||
|
### 3.1. Контроллеры и фильтры аутентификации
|
||||||
|
|
||||||
|
Все защищённые маршруты наследуются от `ApplicationController`.
|
||||||
|
- **`authenticate_request!`:** выполняет проверку токена перед каждым действием.
|
||||||
|
- **Извлечение токена (`extract_token`):** поддерживает как заголовок `Authorization: Bearer <token>`, так и параметр строки запроса `params[:token]`.
|
||||||
|
- При отсутствии или невалидности токена клиенту возвращается стандартизированный ответ:
|
||||||
|
```json
|
||||||
|
{ "error": "Unauthorized" }
|
||||||
|
```
|
||||||
|
со статусом `401 Unauthorized`.
|
||||||
|
|
||||||
|
### 3.2. Модель пользователя (`User`)
|
||||||
|
|
||||||
|
- Использует механизм `has_secure_password` (BCrypt).
|
||||||
|
- **Нормализация никнейма:** автоматическая очистка крайних пробелов через `normalizes :nickname, with: ->(n) { n.strip }`.
|
||||||
|
- **Валидации:**
|
||||||
|
- Никнейм: обязателен, длина от 3 до 24 символов, регистронезависимая уникальность.
|
||||||
|
- Пароль: минимум 6 символов.
|
||||||
|
- Рейтинг: обязателен, значение по умолчанию в БД — `100`.
|
||||||
|
- Аватар (`has_one_attached :avatar`): проверка допустимых типов файлов (`image/png`, `image/jpeg`, `image/gif`, `image/webp`).
|
||||||
|
|
||||||
|
### 3.3. Action Cable и WebSocket
|
||||||
|
|
||||||
|
- **Подключение (`ApplicationCable::Connection`):** при коннекте к `/cable?token=...` сокет проверяет подпись токена. Если токен валиден и пользователь найден в базе, сокет идентифицируется как `current_user`. В противном случае вызывается `reject_unauthorized_connection`.
|
||||||
|
- **Канал `MatchmakingChannel`:**
|
||||||
|
- При подписке клиент слушает индивидуальный стрим `matchmaking:<user_id>`.
|
||||||
|
- Метод `claim` обеспечивает атомарную выдачу уже найденного матча, если клиент переподключился.
|
||||||
|
- Действие `start` запускает подбор.
|
||||||
|
- Действие `stop` или закрытие соединения (`unsubscribed`) отменяет поиск.
|
||||||
|
|
||||||
|
### 3.4. Сервисный слой
|
||||||
|
|
||||||
|
- **`JwtService`:**
|
||||||
|
- Алгоритм: HMAC-SHA256 (`HS256`).
|
||||||
|
- Секретный ключ: `Rails.application.secret_key_base`.
|
||||||
|
- Стандартный срок действия токена авторизации: 7 дней.
|
||||||
|
- Обработка исключений `JWT::DecodeError` и `JWT::ExpiredSignature` с возвратом `nil`.
|
||||||
|
- **`MatchmakingService`:**
|
||||||
|
- Инкапсулирует вызовы Redis Lua-скриптов (`PAIR_SCRIPT`, `FINALIZE_SCRIPT`, `CANCEL_SCRIPT`).
|
||||||
|
- Выполняет создание матчей, подписание игровых токенов и бродкаст уведомлений через `MatchmakingChannel`.
|
||||||
|
|
||||||
|
### 3.5. Фоновые задачи (Background Processing)
|
||||||
|
|
||||||
|
- В качестве движка фоновых задач используется `Solid Queue` (на базе БД) с периодическим расписанием `config/recurring.yml`.
|
||||||
|
- `MatchmakingSweepJob` запускается каждые 2 секунды (`MATCHMAKING_SWEEP_INTERVAL`) и вызывает `MatchmakingService.sweep` для постепенного расширения диапазона поиска и очистки таймаутов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Модель токенов и безопасность
|
||||||
|
|
||||||
|
В системе используются два вида JWT:
|
||||||
|
|
||||||
|
| Параметр | User Auth Token | Game Match Token (`game_token`) |
|
||||||
|
|----------|-----------------|---------------------------------|
|
||||||
|
| **Назначение** | Авторизация запросов к REST API и Action Cable | Доступ к комнате матча на `game-server` |
|
||||||
|
| **Payload** | `{ "user_id": 1, "exp": ... }` | `{ "match_id": "...", "player1_id": 1, "player2_id": 2, "game_server": "...", "exp": ... }` |
|
||||||
|
| **Срок жизни** | 7 дней | `MATCHMAKING_TOKEN_TTL` (по умолчанию 120 сек) |
|
||||||
|
| **Секрет подписи** | `SECRET_KEY_BASE` | `SECRET_KEY_BASE` (общий для Rails и Go) |
|
||||||
|
| **Алгоритм** | HS256 | HS256 |
|
||||||
|
|
||||||
|
### Безопасность делегирования на Game Server:
|
||||||
|
1. Игровой сервер Go не имеет прямого доступа к базе данных пользователей Main Server.
|
||||||
|
2. При подключении к Game Server игрок предъявляет `game_token`.
|
||||||
|
3. Game Server валидирует подпись токена тем же общим секретом `SECRET_KEY_BASE`, сверяет `match_id` и идентификатор подключившегося игрока (`player1_id` или `player2_id`).
|
||||||
|
4. Токен действует всего 120 секунд, что исключает возможность повторного использования после начала игры.
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# Алгоритм матчмейкинга (Matchmaking Algorithm)
|
||||||
|
|
||||||
|
Документ содержит подробное описание алгоритма подбора соперников в `Ruby Sea Battle`, структур данных Redis, математической модели допуска рейтинга, механизмов атомарности и отказоустойчивости.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Цели и требования к системе
|
||||||
|
|
||||||
|
Матчмейкинг отвечает за сведение двух игроков в онлайн-матч и решает следующие задачи:
|
||||||
|
1. **Баланс по навыку (рейтингу):** игроки со схожим рейтингом должны играть друг с другом.
|
||||||
|
2. **Минимизация времени ожидания:** если идеального соперника нет, допустимая разница в рейтинге со временем плавно увеличивается.
|
||||||
|
3. **Строгая атомарность и исключение race conditions:** ни один игрок не может быть одновременно взят в два разных матча при конкурентных запросах.
|
||||||
|
4. **Отказоустойчивость (Fault Tolerance):** сбой процесса сервера между нахождением пары и генерацией JWT-токенов не должен приводить к потере игроков из очереди.
|
||||||
|
5. **Надёжная доставка результата:** матч должен быть доставлен игроку даже при кратковременном разрыве WebSocket-соединения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Структуры данных в Redis
|
||||||
|
|
||||||
|
Все данные матчмейкинга хранятся в Redis и имеют ограниченное время жизни (TTL):
|
||||||
|
|
||||||
|
| Ключ | Тип данных Redis | Значение / Элемент | Назначение |
|
||||||
|
|------|-------------------|-------------------|------------|
|
||||||
|
| `mm:queue` | Sorted Set | `score` = `rating`, `member` = `user_id` | Основная очередь игроков, ожидающих матч. Отсортирована по рейтингу для быстрого диапазонного поиска (`ZRANGEBYSCORE`). |
|
||||||
|
| `mm:since` | Hash | `field` = `user_id`, `value` = `timestamp` (мс) | Время входа игрока в очередь в миллисекундах. Используется для расчёта времени ожидания. |
|
||||||
|
| `mm:rating` | Hash | `field` = `user_id`, `value` = `rating` | Сохранённый рейтинг игрока. Необходим для возврата игрока в очередь при сбое на этапе финализации (`requeue`). |
|
||||||
|
| `mm:pending` | Sorted Set | `score` = `timestamp` спаривания (мс), `member` = `user_id` | Игроки, для которых пара найдена, но генерация матча и JWT ещё выполняется в Ruby. |
|
||||||
|
| `mm:match:<match_id>` | String (JSON) | Полный payload матча | Данные матча (TTL = `MATCHMAKING_MATCH_TTL`, по умолчанию 120 сек). |
|
||||||
|
| `mm:user:<user_id>` | String (JSON) | Копия payload матча | Индивидуальный указатель для игрока для механизма `claim` при реконнекте (TTL = `MATCHMAKING_MATCH_TTL`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Модель динамического допуска рейтинга (Rating Tolerance)
|
||||||
|
|
||||||
|
Чтобы игроки быстро находили матч даже при небольшом онлайне, допуск по рейтингу плавно растёт с течением времени ожидания.
|
||||||
|
|
||||||
|
### Формула допуска
|
||||||
|
|
||||||
|
Для игрока с временем постановки в очередь $t_{\text{start}}$ в текущий момент времени $t_{\text{now}}$:
|
||||||
|
|
||||||
|
$$\Delta t = \frac{t_{\text{now}} - t_{\text{start}}}{1000} \quad \text{(секунды ожидания)}$$
|
||||||
|
|
||||||
|
$$\text{tolerance}(\Delta t) = \min\Big(\text{base\_range} + \Delta t \times \text{range\_per\_second}, \; \text{max\_range}\Big)$$
|
||||||
|
|
||||||
|
### Конфигурационные параметры (из `Rails.application.config.x.matchmaking`)
|
||||||
|
|
||||||
|
- `base_range` = `100` — начальный допуск рейтинга в момент входа в очередь.
|
||||||
|
- `range_per_second` = `50` — прирост допуска за каждую секунду ожидания.
|
||||||
|
- `max_range` = `1000` — верхний предел допуска рейтинга.
|
||||||
|
- `queue_ttl` = `60` сек — максимальное время нахождения в очереди до таймаута.
|
||||||
|
|
||||||
|
### Динамика расширения диапазона
|
||||||
|
|
||||||
|
| Время ожидания ($\Delta t$) | Допуск ($\pm$ очков рейтинга) | Диапазон при рейтинге 1000 |
|
||||||
|
|-----------------------------|-------------------------------|---------------------------|
|
||||||
|
| 0 секунд | $\pm 100$ | [900 ... 1100] |
|
||||||
|
| 2 секунды | $\pm 200$ | [800 ... 1200] |
|
||||||
|
| 6 секунд | $\pm 400$ | [600 ... 1400] |
|
||||||
|
| 10 секунд | $\pm 600$ | [400 ... 1600] |
|
||||||
|
| 18+ секунд | $\pm 1000$ (максимум) | [0 ... 2000] |
|
||||||
|
|
||||||
|
### Правило симметрии для пары
|
||||||
|
|
||||||
|
При периодической сверке очереди (`sweep`) пара $(A, B)$ считается совместимой, если:
|
||||||
|
|
||||||
|
$$| \text{rating}_A - \text{rating}_B | \le \text{tolerance}\big(\min(t_{\text{since}}(A), t_{\text{since}}(B))\big)$$
|
||||||
|
|
||||||
|
То есть допуск рассчитывается по **дольше ожидающему** игроку из пары. Это предотвращает дискриминацию игроков, которые долго ждут в очереди.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Двухфазный алгоритм подбора (Two-Phase Matchmaking)
|
||||||
|
|
||||||
|
Подбор разделен на две взаимодополняющие фазы:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Игрок отправляет start]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Фаза 1: Немедленный поиск (enqueue в Lua)
|
||||||
|
├─► Подходящий соперник найден ──► [Перевод в pending] ──► [Генерация матча]
|
||||||
|
└─► Соперник не найден ──────────► [Добавление в mm:queue]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Фаза 2: Фоновая сверка (sweep)
|
||||||
|
(каждые 2 сек)
|
||||||
|
├─► Расширение допуска и спаривание
|
||||||
|
├─► Таймаут (waited > queue_ttl)
|
||||||
|
└─► Requeue зависших игроков
|
||||||
|
```
|
||||||
|
|
||||||
|
### Фаза 1: Синхронная проверка при входе (`enqueue`)
|
||||||
|
|
||||||
|
Выполняется мгновенно в контексте команды WebSocket `start`:
|
||||||
|
1. Вызывается Redis Lua-скрипт `PAIR_SCRIPT` с аргументом `mode = "enqueue"`.
|
||||||
|
2. Из очереди принудительно удаляются предыдущие записи этого пользователя (сброс сессии ожидания).
|
||||||
|
3. Через `ZRANGEBYSCORE mm:queue (rating - max_range) (rating + max_range)` запрашиваются потенциальные кандидаты.
|
||||||
|
4. Среди кандидатов выбирается игрок с **минимальной разницей в рейтинге**, у которого:
|
||||||
|
$$| \text{rating} - \text{other\_rating} | \le \text{tolerance}(\text{other\_since})$$
|
||||||
|
5. **Если кандидат найден:**
|
||||||
|
- Оба игрока атомарно удаляются из `mm:queue`, `mm:since`, `mm:rating`.
|
||||||
|
- Оба игрока добавляются в `mm:pending` с текущим временем.
|
||||||
|
- Скрипт возвращает строку вида `"P:<id1>:<id2>"`.
|
||||||
|
6. **Если кандидат не найден:**
|
||||||
|
- Игрок сохраняется в `mm:queue` с весом `rating`.
|
||||||
|
- В `mm:since` записывается время входа.
|
||||||
|
- В `mm:rating` сохраняется рейтинг игрока.
|
||||||
|
|
||||||
|
### Фаза 2: Периодическая фоновая сверка (`sweep`)
|
||||||
|
|
||||||
|
Каждые `MATCHMAKING_SWEEP_INTERVAL` секунд (по умолчанию каждые 2 секунды) запускается фоновая задача `MatchmakingSweepJob`, вызывающая `MatchmakingService.sweep`:
|
||||||
|
|
||||||
|
1. Вызывается Redis Lua-скрипт `PAIR_SCRIPT` с аргументом `mode = "sweep"`.
|
||||||
|
2. Из `mm:queue` считываются все ожидающие игроки со своими рейтингами и временем ожидания.
|
||||||
|
3. Массив сортируется по возрастанию рейтинга: $O(N \log N)$.
|
||||||
|
4. Выполняется жадный проход:
|
||||||
|
- Если текущий игрок ждёт дольше `queue_ttl` (60 сек): он помечается как экспирированный (`"E:<id>"`).
|
||||||
|
- Иначе ищется ближайший по рейтингу свободный соперник в списке, для которого разница в рейтинге не превышает допуск по времени ожидания более старого игрока.
|
||||||
|
- При нахождении оба игрока помечаются как использованные и добавляются в результат (`"P:<id1>:<id2>"`).
|
||||||
|
5. Все найденные пары атомарно переносятся из `mm:queue` в `mm:pending`, а экспирированные игроки удаляются из очереди.
|
||||||
|
6. **Requeue (защита от падений):**
|
||||||
|
- Запрашиваются все игроки из `mm:pending`, находящиеся там дольше `pending_ttl` (15 сек).
|
||||||
|
- Если для игрока ключ матча `mm:user:<uid>` так и не был создан (например, Ruby-процесс упал при генерации токена), игрок автоматически возвращается назад в `mm:queue` со своим исходным рейтингом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Создание матча и атомарная финализация
|
||||||
|
|
||||||
|
Когда Lua-скрипт возвращает сформированную пару `"P:<id1>:<id2>"`:
|
||||||
|
|
||||||
|
1. **Генерация идентификаторов и токенов в Ruby:**
|
||||||
|
- Генерируется уникальный UUID матча: `match_id = SecureRandom.uuid`.
|
||||||
|
- Генерируется JWT-токен для `game-server`:
|
||||||
|
```ruby
|
||||||
|
JwtService.encode(
|
||||||
|
match_id: match_id,
|
||||||
|
player1_id: player1_id,
|
||||||
|
player2_id: player2_id,
|
||||||
|
game_server: config.game_server_url,
|
||||||
|
exp: config.token_ttl.seconds.from_now.to_i
|
||||||
|
)
|
||||||
|
```
|
||||||
|
2. **Атомарная финализация в Redis (`FINALIZE_SCRIPT`):**
|
||||||
|
- Сохраняет `mm:match:<match_id>` с TTL.
|
||||||
|
- Сохраняет указатели `mm:user:<player1_id>` и `mm:user:<player2_id>` с тем же TTL.
|
||||||
|
- Удаляет обоих игроков из `mm:pending`, `mm:rating`, `mm:since`.
|
||||||
|
3. **Push-уведомление через Action Cable:**
|
||||||
|
- Для каждого игрока выполняется атомарное чтение и удаление указателя: `redis.getdel("mm:user:<id>")`.
|
||||||
|
- Если указатель успешно прочитан, сообщение с типом `matched` отправляется в персональный поток пользователя:
|
||||||
|
`MatchmakingChannel.broadcast_to(user_id, payload)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Гарантия доставки (At-most-once & Reconnection)
|
||||||
|
|
||||||
|
Если в момент формирования матча у пользователя произошёл кратковременный разрыв соединения или переподключение:
|
||||||
|
- Сообщение broadcast могло быть отправлено в момент отсутствия активного WebSocket-клиента.
|
||||||
|
- Персональный ключ `mm:user:<user_id>` сохраняется в Redis до момента подтверждённой доставки.
|
||||||
|
- При повторном подключении к WebSocket метод `MatchmakingChannel#subscribed` вызывает:
|
||||||
|
```ruby
|
||||||
|
if match = MatchmakingService.claim(current_user.id)
|
||||||
|
transmit(match)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
- Метод `claim` выполняет Redis-команду `GETDEL mm:user:<user_id>`, что гарантирует:
|
||||||
|
1. Матч будет немедленно доставлен клиенту при переподключении.
|
||||||
|
2. Никакой матч не может быть получен дважды (атомарное удаление при первой же выдаче).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Диаграммы взаимодействия
|
||||||
|
|
||||||
|
### Sequence Diagram: Успешный подбор матча
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor PlayerA as Игрок A
|
||||||
|
actor PlayerB as Игрок B
|
||||||
|
participant AC as ActionCable (MatchmakingChannel)
|
||||||
|
participant MS as MatchmakingService
|
||||||
|
participant Redis as Redis (Lua Scripts)
|
||||||
|
participant Game as Game Server (Go)
|
||||||
|
|
||||||
|
PlayerA->>AC: subscribe
|
||||||
|
PlayerA->>AC: action: "start"
|
||||||
|
AC->>MS: enqueue(PlayerA)
|
||||||
|
MS->>Redis: eval PAIR_SCRIPT (enqueue)
|
||||||
|
Redis-->>MS: соперник не найден -> добавлен в mm:queue
|
||||||
|
|
||||||
|
PlayerB->>AC: subscribe
|
||||||
|
PlayerB->>AC: action: "start"
|
||||||
|
AC->>MS: enqueue(PlayerB)
|
||||||
|
MS->>Redis: eval PAIR_SCRIPT (enqueue)
|
||||||
|
Redis-->>MS: пара сформирована! (P:PlayerB:PlayerA)
|
||||||
|
|
||||||
|
MS->>MS: генерация match_id (UUID) и game_token (JWT)
|
||||||
|
MS->>Redis: eval FINALIZE_SCRIPT (сохранить матч, перевести из pending)
|
||||||
|
MS->>Redis: getdel mm:user:PlayerA & mm:user:PlayerB
|
||||||
|
MS->>AC: broadcast_to(PlayerA, matched)
|
||||||
|
MS->>AC: broadcast_to(PlayerB, matched)
|
||||||
|
AC-->>PlayerA: message { type: "matched", match_id, game_token, game_server }
|
||||||
|
AC-->>PlayerB: message { type: "matched", match_id, game_token, game_server }
|
||||||
|
|
||||||
|
PlayerA->>Game: WebSocket connect(game_token)
|
||||||
|
PlayerB->>Game: WebSocket connect(game_token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Diagram: Жизненный цикл игрока в матчмейкинге
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Idle: Пользователь авторизован
|
||||||
|
|
||||||
|
Idle --> Queued: Отправка action "start" (enqueue)
|
||||||
|
Queued --> Pending: Найден соперник в Phase 1 или Phase 2
|
||||||
|
Queued --> Idle: Отправка action "stop" / разрыв сокета (cancel)
|
||||||
|
Queued --> TimedOut: Ожидание > MATCHMAKING_QUEUE_TTL
|
||||||
|
|
||||||
|
TimedOut --> Idle: Уведомление { type: "timeout" }
|
||||||
|
|
||||||
|
Pending --> Matched: Успешная финализация (генерация токена)
|
||||||
|
Pending --> Queued: Сбой финализации (requeue через 15 сек)
|
||||||
|
|
||||||
|
Matched --> InGame: Подключение к Game Server по game_token
|
||||||
|
InGame --> Idle: Завершение игры
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe ApplicationCable::Connection, type: :channel do
|
||||||
|
include ActionCable::Connection::TestCase::Behavior
|
||||||
|
|
||||||
|
let(:user) { User.create!(nickname: "CaptainCook", password: "password123") }
|
||||||
|
let(:token) { JwtService.encode(user_id: user.id) }
|
||||||
|
|
||||||
|
describe "#connect" do
|
||||||
|
it "connects successfully with token in query params" do
|
||||||
|
connect "/cable", params: { token: token }
|
||||||
|
expect(connection.current_user).to eq(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "connects successfully with Bearer token in Authorization header" do
|
||||||
|
connect "/cable", headers: { "Authorization" => "Bearer #{token}" }
|
||||||
|
expect(connection.current_user).to eq(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects connection when no token is provided" do
|
||||||
|
expect { connect "/cable" }.to have_rejected_connection
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects connection when token is invalid" do
|
||||||
|
expect {
|
||||||
|
connect "/cable", params: { token: "invalid.jwt.token" }
|
||||||
|
}.to have_rejected_connection
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects connection when token is expired" do
|
||||||
|
expired_token = JwtService.encode(user_id: user.id, exp: 1.hour.ago.to_i)
|
||||||
|
expect {
|
||||||
|
connect "/cable", params: { token: expired_token }
|
||||||
|
}.to have_rejected_connection
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects connection when user does not exist in the database" do
|
||||||
|
orphan_token = JwtService.encode(user_id: 999_999)
|
||||||
|
expect {
|
||||||
|
connect "/cable", params: { token: orphan_token }
|
||||||
|
}.to have_rejected_connection
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe MatchmakingChannel, type: :channel do
|
||||||
|
let(:user) { User.create!(nickname: "Blackbeard", password: "password123", rating: 1250) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_connection current_user: user
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#subscribed" do
|
||||||
|
it "subscribes successfully and streams from the user's private channel" do
|
||||||
|
subscribe
|
||||||
|
expect(subscription).to be_confirmed
|
||||||
|
expect(subscription).to have_stream_from("matchmaking:#{user.id}")
|
||||||
|
expect(transmissions).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it "transmits a claimed match if one was ready before or during connection" do
|
||||||
|
match_payload = {
|
||||||
|
type: "matched",
|
||||||
|
match_id: "test-match-uuid",
|
||||||
|
game_token: "jwt-game-token",
|
||||||
|
game_server: "ws://localhost:8080",
|
||||||
|
players: [ user.id, 999 ]
|
||||||
|
}
|
||||||
|
allow(MatchmakingService).to receive(:claim).with(user.id).and_return(match_payload)
|
||||||
|
|
||||||
|
subscribe
|
||||||
|
|
||||||
|
expect(subscription).to be_confirmed
|
||||||
|
expect(transmissions.last).to eq(match_payload.as_json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#start" do
|
||||||
|
it "calls MatchmakingService.enqueue with the user's id and current rating" do
|
||||||
|
subscribe
|
||||||
|
|
||||||
|
expect(MatchmakingService).to receive(:enqueue).with(user.id, rating: 1250)
|
||||||
|
perform :start
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accepts extra parameters in data hash without error" do
|
||||||
|
subscribe
|
||||||
|
|
||||||
|
expect(MatchmakingService).to receive(:enqueue).with(user.id, rating: 1250)
|
||||||
|
perform :start, { "custom" => "data" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#stop" do
|
||||||
|
it "calls MatchmakingService.cancel for the current user" do
|
||||||
|
subscribe
|
||||||
|
|
||||||
|
expect(MatchmakingService).to receive(:cancel).with(user.id)
|
||||||
|
perform :stop
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#unsubscribed" do
|
||||||
|
it "cancels matchmaking search when unsubscribing" do
|
||||||
|
subscribe
|
||||||
|
|
||||||
|
expect(MatchmakingService).to receive(:cancel).with(user.id)
|
||||||
|
unsubscribe
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Api::MatchmakingController, type: :controller do
|
||||||
|
routes do
|
||||||
|
ActionDispatch::Routing::RouteSet.new.tap do |r|
|
||||||
|
r.draw do
|
||||||
|
delete "destroy", to: "api/matchmaking#destroy"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:user) { User.create!(nickname: "CaptainHook", password: "password123") }
|
||||||
|
let(:token) { JwtService.encode(user_id: user.id) }
|
||||||
|
|
||||||
|
describe "DELETE #destroy" do
|
||||||
|
context "when authenticated" do
|
||||||
|
it "cancels matchmaking for current user and returns success message" do
|
||||||
|
request.headers["Authorization"] = "Bearer #{token}"
|
||||||
|
expect(MatchmakingService).to receive(:cancel).with(user.id)
|
||||||
|
|
||||||
|
delete :destroy
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["message"]).to eq("Matchmaking cancelled")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when unauthenticated" do
|
||||||
|
it "returns 401 unauthorized and does not call cancel" do
|
||||||
|
expect(MatchmakingService).not_to receive(:cancel)
|
||||||
|
|
||||||
|
delete :destroy
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Unauthorized")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Api::RegistrationsController, type: :controller do
|
||||||
|
describe "POST #create" do
|
||||||
|
context "with valid parameters" do
|
||||||
|
let(:valid_params) do
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
nickname: "CaptainNemo",
|
||||||
|
password: "secretpassword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it "creates a new user and returns 201 with user data and JWT token" do
|
||||||
|
expect {
|
||||||
|
post :create, params: valid_params
|
||||||
|
}.to change(User, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["token"]).to be_present
|
||||||
|
expect(json["user"]).to include(
|
||||||
|
"nickname" => "CaptainNemo",
|
||||||
|
"avatar_url" => nil
|
||||||
|
)
|
||||||
|
expect(json["user"]["id"]).to be_present
|
||||||
|
expect(json["user"]["created_at"]).to be_present
|
||||||
|
|
||||||
|
# Verify token is valid and decodes to created user
|
||||||
|
decoded = JwtService.decode(json["token"])
|
||||||
|
expect(decoded[:user_id]).to eq(json["user"]["id"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "attaches avatar and returns avatar_url when avatar is provided" do
|
||||||
|
avatar = fixture_file_upload(
|
||||||
|
Rails.root.join("spec", "fixtures", "files", "avatar.png"),
|
||||||
|
"image/png"
|
||||||
|
) rescue Rack::Test::UploadedFile.new(
|
||||||
|
StringIO.new("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"),
|
||||||
|
"image/png",
|
||||||
|
original_filename: "avatar.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
params_with_avatar = valid_params.deep_merge(user: { avatar: avatar })
|
||||||
|
|
||||||
|
post :create, params: params_with_avatar
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["user"]["avatar_url"]).to be_present
|
||||||
|
|
||||||
|
user = User.find(json["user"]["id"])
|
||||||
|
expect(user.avatar).to be_attached
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid parameters" do
|
||||||
|
it "returns 422 unprocessable entity when nickname is missing" do
|
||||||
|
post :create, params: { user: { nickname: "", password: "password123" } }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["errors"]).to be_an(Array)
|
||||||
|
expect(json["errors"]).to include("Nickname can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 422 unprocessable entity when password is too short" do
|
||||||
|
post :create, params: { user: { nickname: "CaptainNemo", password: "123" } }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["errors"]).to include("Password is too short (minimum is 6 characters)")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 422 unprocessable entity when nickname is already taken" do
|
||||||
|
User.create!(nickname: "CaptainNemo", password: "password123")
|
||||||
|
|
||||||
|
post :create, params: { user: { nickname: "captainnemo", password: "password123" } }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["errors"]).to include("Nickname has already been taken")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 422 when avatar has an unsupported content type" do
|
||||||
|
invalid_avatar = Rack::Test::UploadedFile.new(
|
||||||
|
StringIO.new("plain text content"),
|
||||||
|
"text/plain",
|
||||||
|
original_filename: "test.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
post :create, params: {
|
||||||
|
user: {
|
||||||
|
nickname: "CaptainNemo",
|
||||||
|
password: "password123",
|
||||||
|
avatar: invalid_avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["errors"]).to include("Avatar must be a PNG, JPEG, GIF or WebP image")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises ParameterMissing when user param is omitted" do
|
||||||
|
expect {
|
||||||
|
post :create, params: {}
|
||||||
|
}.to raise_error(ActionController::ParameterMissing)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Api::SessionsController, type: :controller do
|
||||||
|
let!(:user) { User.create!(nickname: "AdmiralNelson", password: "securepassword") }
|
||||||
|
let(:token) { JwtService.encode(user_id: user.id) }
|
||||||
|
|
||||||
|
describe "POST #create" do
|
||||||
|
context "with valid credentials" do
|
||||||
|
it "returns 200 OK with user info and JWT token" do
|
||||||
|
post :create, params: { nickname: "AdmiralNelson", password: "securepassword" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["token"]).to be_present
|
||||||
|
expect(json["user"]).to include(
|
||||||
|
"id" => user.id,
|
||||||
|
"nickname" => "AdmiralNelson",
|
||||||
|
"avatar_url" => nil
|
||||||
|
)
|
||||||
|
|
||||||
|
decoded = JwtService.decode(json["token"])
|
||||||
|
expect(decoded[:user_id]).to eq(user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid credentials" do
|
||||||
|
it "returns 401 when password does not match" do
|
||||||
|
post :create, params: { nickname: "AdmiralNelson", password: "wrongpassword" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Invalid nickname or password")
|
||||||
|
expect(json["token"]).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 401 when user does not exist" do
|
||||||
|
post :create, params: { nickname: "GhostCaptain", password: "securepassword" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Invalid nickname or password")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 401 when nickname is omitted" do
|
||||||
|
post :create, params: { password: "securepassword" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Invalid nickname or password")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 401 when password is omitted" do
|
||||||
|
post :create, params: { nickname: "AdmiralNelson" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Invalid nickname or password")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 401 when credentials are blank" do
|
||||||
|
post :create, params: { nickname: "", password: "" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Invalid nickname or password")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "DELETE #destroy" do
|
||||||
|
context "when authenticated" do
|
||||||
|
it "returns 200 with success message" do
|
||||||
|
request.headers["Authorization"] = "Bearer #{token}"
|
||||||
|
delete :destroy
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["message"]).to eq("Signed out successfully")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when unauthenticated" do
|
||||||
|
it "returns 401 unauthorized" do
|
||||||
|
delete :destroy
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Unauthorized")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #me" do
|
||||||
|
context "when authenticated with Authorization header" do
|
||||||
|
it "returns 200 with user data" do
|
||||||
|
request.headers["Authorization"] = "Bearer #{token}"
|
||||||
|
get :me
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["user"]).to include(
|
||||||
|
"id" => user.id,
|
||||||
|
"nickname" => "AdmiralNelson",
|
||||||
|
"avatar_url" => nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "includes avatar_url when user has an attached avatar" do
|
||||||
|
user.avatar.attach(
|
||||||
|
io: StringIO.new("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"),
|
||||||
|
filename: "avatar.png",
|
||||||
|
content_type: "image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
request.headers["Authorization"] = "Bearer #{token}"
|
||||||
|
get :me
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["user"]["avatar_url"]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when authenticated with token in query params" do
|
||||||
|
it "returns 200 with user data" do
|
||||||
|
get :me, params: { token: token }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["user"]["id"]).to eq(user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when unauthenticated" do
|
||||||
|
it "returns 401 unauthorized" do
|
||||||
|
get :me
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Unauthorized")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when token is expired" do
|
||||||
|
it "returns 401 unauthorized" do
|
||||||
|
expired_token = JwtService.encode(user_id: user.id, exp: 1.hour.ago.to_i)
|
||||||
|
request.headers["Authorization"] = "Bearer #{expired_token}"
|
||||||
|
get :me
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe ApplicationController, type: :controller do
|
||||||
|
controller do
|
||||||
|
def index
|
||||||
|
render json: { user_id: current_user.id }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:user) { User.create!(nickname: "alice", password: "password123") }
|
||||||
|
let(:token) { JwtService.encode(user_id: user.id) }
|
||||||
|
|
||||||
|
describe "#authenticate_request!" do
|
||||||
|
context "with valid Bearer token in Authorization header" do
|
||||||
|
it "sets current_user and succeeds" do
|
||||||
|
request.headers["Authorization"] = "Bearer #{token}"
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(JSON.parse(response.body)).to eq({ "user_id" => user.id })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with valid token in params" do
|
||||||
|
it "sets current_user and succeeds" do
|
||||||
|
get :index, params: { token: token }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(JSON.parse(response.body)).to eq({ "user_id" => user.id })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "without token" do
|
||||||
|
it "returns 401 unauthorized" do
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
expect(JSON.parse(response.body)).to eq({ "error" => "Unauthorized" })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid token" do
|
||||||
|
it "returns 401 unauthorized" do
|
||||||
|
request.headers["Authorization"] = "Bearer invalid_token"
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
expect(JSON.parse(response.body)).to eq({ "error" => "Unauthorized" })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with token for nonexistent user" do
|
||||||
|
it "returns 401 unauthorized" do
|
||||||
|
orphan_token = JwtService.encode(user_id: 999_999)
|
||||||
|
request.headers["Authorization"] = "Bearer #{orphan_token}"
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
expect(JSON.parse(response.body)).to eq({ "error" => "Unauthorized" })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe MatchmakingSweepJob, type: :job do
|
||||||
|
describe "#perform" do
|
||||||
|
it "calls MatchmakingService.sweep" do
|
||||||
|
expect(MatchmakingService).to receive(:sweep)
|
||||||
|
described_class.new.perform
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Match, type: :model do
|
||||||
|
let(:player1) { User.create!(nickname: "player_one", password: "secret123") }
|
||||||
|
let(:player2) { User.create!(nickname: "player_two", password: "secret123") }
|
||||||
|
|
||||||
|
let(:valid_attributes) do
|
||||||
|
{
|
||||||
|
player1: player1,
|
||||||
|
player2: player2,
|
||||||
|
status: :ongoing,
|
||||||
|
game_server: "ws://game-server:8080/ws",
|
||||||
|
started_at: Time.current,
|
||||||
|
player1_rating_before: 100,
|
||||||
|
player2_rating_before: 100,
|
||||||
|
player1_rating_change: 0,
|
||||||
|
player2_rating_change: 0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is valid with valid attributes" do
|
||||||
|
expect(Match.new(valid_attributes)).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "generates a uuid automatically" do
|
||||||
|
match = Match.create!(valid_attributes)
|
||||||
|
expect(match.uuid).to match(/\A[0-9a-f-]{36}\z/)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "belongs to two players" do
|
||||||
|
match = Match.create!(valid_attributes)
|
||||||
|
expect(match.player1).to eq(player1)
|
||||||
|
expect(match.player2).to eq(player2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not allow the same player on both sides" do
|
||||||
|
match = Match.new(valid_attributes.merge(player2: player1))
|
||||||
|
expect(match).not_to be_valid
|
||||||
|
expect(match.errors[:player2]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires a game_server" do
|
||||||
|
expect(Match.new(valid_attributes.merge(game_server: nil))).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires started_at" do
|
||||||
|
expect(Match.new(valid_attributes.merge(started_at: nil))).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allows finished_at and duration_seconds to be nil" do
|
||||||
|
expect(Match.new(valid_attributes)).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects negative duration_seconds" do
|
||||||
|
match = Match.new(valid_attributes.merge(duration_seconds: -1))
|
||||||
|
expect(match).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects nil rating changes" do
|
||||||
|
match = Match.new(valid_attributes.merge(player1_rating_change: nil))
|
||||||
|
expect(match).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allows zero rating changes" do
|
||||||
|
match = Match.new(valid_attributes.merge(player1_rating_change: 0, player2_rating_change: 0))
|
||||||
|
expect(match).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "statuses" do
|
||||||
|
it "defines all statuses" do
|
||||||
|
expect(Match.statuses.keys).to contain_exactly(
|
||||||
|
"ongoing", "player1_won", "player2_won", "draw", "abandoned"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "defaults to ongoing" do
|
||||||
|
match = Match.create!(valid_attributes)
|
||||||
|
expect(match.status).to eq("ongoing")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "supports transitions to final statuses" do
|
||||||
|
match = Match.create!(valid_attributes)
|
||||||
|
match.player1_won!
|
||||||
|
expect(match.reload.status).to eq("player1_won")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".finished" do
|
||||||
|
it "returns only matches with finished_at set" do
|
||||||
|
finished = Match.create!(valid_attributes.merge(finished_at: Time.current))
|
||||||
|
Match.create!(valid_attributes)
|
||||||
|
expect(Match.finished).to contain_exactly(finished)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".for_user" do
|
||||||
|
it "returns matches where the user is either player" do
|
||||||
|
other = User.create!(nickname: "player_three", password: "secret123")
|
||||||
|
m1 = Match.create!(valid_attributes)
|
||||||
|
m2 = Match.create!(valid_attributes.merge(player1: player2, player2: player1))
|
||||||
|
Match.create!(valid_attributes.merge(player1: player2, player2: other))
|
||||||
|
|
||||||
|
expect(Match.for_user(player1)).to contain_exactly(m1, m2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -38,6 +38,27 @@ RSpec.describe User, type: :model do
|
|||||||
expect(duplicate).not_to be_valid
|
expect(duplicate).not_to be_valid
|
||||||
expect(duplicate.errors[:nickname]).to be_present
|
expect(duplicate.errors[:nickname]).to be_present
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "accepts valid image formats for avatar" do
|
||||||
|
user = User.new(valid_attributes)
|
||||||
|
user.avatar.attach(
|
||||||
|
io: StringIO.new("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"),
|
||||||
|
filename: "avatar.png",
|
||||||
|
content_type: "image/png"
|
||||||
|
)
|
||||||
|
expect(user).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects unsupported mime types for avatar" do
|
||||||
|
user = User.new(valid_attributes)
|
||||||
|
user.avatar.attach(
|
||||||
|
io: StringIO.new("plain text"),
|
||||||
|
filename: "test.txt",
|
||||||
|
content_type: "text/plain"
|
||||||
|
)
|
||||||
|
expect(user).not_to be_valid
|
||||||
|
expect(user.errors[:avatar]).to include("must be a PNG, JPEG, GIF or WebP image")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "rating" do
|
describe "rating" do
|
||||||
@@ -76,4 +97,33 @@ RSpec.describe User, type: :model do
|
|||||||
expect(user.update(nickname: "new_nickname")).to be true
|
expect(user.update(nickname: "new_nickname")).to be true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#finished_matches" do
|
||||||
|
let(:user) { User.create!(valid_attributes) }
|
||||||
|
let(:opponent) { User.create!(nickname: "opponent", password: "secret123") }
|
||||||
|
let(:outsider) { User.create!(nickname: "outsider", password: "secret123") }
|
||||||
|
|
||||||
|
def build_match(attrs)
|
||||||
|
Match.create!({
|
||||||
|
player1: user,
|
||||||
|
player2: opponent,
|
||||||
|
status: :ongoing,
|
||||||
|
game_server: "ws://game-server:8080/ws",
|
||||||
|
started_at: Time.current,
|
||||||
|
player1_rating_before: 100,
|
||||||
|
player2_rating_before: 100,
|
||||||
|
player1_rating_change: 0,
|
||||||
|
player2_rating_change: 0
|
||||||
|
}.merge(attrs))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns only finished matches of the user ordered by finished_at desc" do
|
||||||
|
older = build_match(status: :player1_won, finished_at: 2.days.ago)
|
||||||
|
newer = build_match(player1: opponent, player2: user, status: :player2_won, finished_at: 1.day.ago)
|
||||||
|
build_match(status: :ongoing, finished_at: nil)
|
||||||
|
build_match(player1: opponent, player2: outsider, status: :player1_won, finished_at: Time.current)
|
||||||
|
|
||||||
|
expect(user.finished_matches.to_a).to eq([ newer, older ])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
if ENV["COVERAGE"]
|
if ENV["COVERAGE"]
|
||||||
require "simplecov"
|
require "simplecov"
|
||||||
SimpleCov.start "rails" do
|
SimpleCov.start "rails" do
|
||||||
add_filter "/spec/"
|
skip "/spec/"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
require 'spec_helper'
|
require 'spec_helper'
|
||||||
@@ -69,7 +69,7 @@ RSpec.configure do |config|
|
|||||||
# behaviour is considered legacy and will be removed in a future version.
|
# behaviour is considered legacy and will be removed in a future version.
|
||||||
#
|
#
|
||||||
# To enable this behaviour uncomment the line below.
|
# To enable this behaviour uncomment the line below.
|
||||||
# config.infer_spec_type_from_file_location!
|
config.infer_spec_type_from_file_location!
|
||||||
|
|
||||||
# Filter lines from Rails gems in backtraces.
|
# Filter lines from Rails gems in backtraces.
|
||||||
config.filter_rails_from_backtrace!
|
config.filter_rails_from_backtrace!
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Registrations API", type: :request do
|
||||||
|
describe "POST /api/register" do
|
||||||
|
let(:valid_attributes) do
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
nickname: "Sinbad",
|
||||||
|
password: "password123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with valid parameters" do
|
||||||
|
it "registers a new user and returns 201 Created with token" do
|
||||||
|
expect {
|
||||||
|
post "/api/register", params: valid_attributes, as: :json
|
||||||
|
}.to change(User, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
|
||||||
|
expect(json["token"]).to be_present
|
||||||
|
expect(json["user"]["nickname"]).to eq("Sinbad")
|
||||||
|
expect(json["user"]["id"]).to eq(User.last.id)
|
||||||
|
expect(json["user"]["avatar_url"]).to be_nil
|
||||||
|
expect(json["user"]["created_at"]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid parameters" do
|
||||||
|
it "returns 422 Unprocessable Content when nickname is taken" do
|
||||||
|
User.create!(nickname: "Sinbad", password: "password123")
|
||||||
|
|
||||||
|
post "/api/register", params: valid_attributes, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(422)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["errors"]).to include("Nickname has already been taken")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 422 Unprocessable Content when password is too short" do
|
||||||
|
invalid_attrs = { user: { nickname: "Sinbad", password: "123" } }
|
||||||
|
post "/api/register", params: invalid_attrs, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(422)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["errors"]).to include("Password is too short (minimum is 6 characters)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Sessions API", type: :request do
|
||||||
|
let!(:user) { User.create!(nickname: "Columbus", password: "securepassword") }
|
||||||
|
let(:token) { JwtService.encode(user_id: user.id) }
|
||||||
|
|
||||||
|
describe "POST /api/login" do
|
||||||
|
it "logs in with valid credentials and returns 200 OK with token" do
|
||||||
|
post "/api/login", params: { nickname: "Columbus", password: "securepassword" }, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["token"]).to be_present
|
||||||
|
expect(json["user"]["id"]).to eq(user.id)
|
||||||
|
expect(json["user"]["nickname"]).to eq("Columbus")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 401 Unauthorized with invalid credentials" do
|
||||||
|
post "/api/login", params: { nickname: "Columbus", password: "wrongpassword" }, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Invalid nickname or password")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "DELETE /api/logout" do
|
||||||
|
it "signs out successfully when bearer token is supplied" do
|
||||||
|
delete "/api/logout", headers: { "Authorization" => "Bearer #{token}" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["message"]).to eq("Signed out successfully")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 401 Unauthorized when unauthenticated" do
|
||||||
|
delete "/api/logout"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/me" do
|
||||||
|
it "returns user profile with Bearer authorization header" do
|
||||||
|
get "/api/me", headers: { "Authorization" => "Bearer #{token}" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["user"]["id"]).to eq(user.id)
|
||||||
|
expect(json["user"]["nickname"]).to eq("Columbus")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 401 Unauthorized when Authorization header is missing" do
|
||||||
|
get "/api/me"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["error"]).to eq("Unauthorized")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user