Compare commits
9
Commits
31ef12b5f2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48094cd55 | ||
|
|
1cadf00280 | ||
|
|
c198815a18 | ||
|
|
db89677790 | ||
|
|
189b027a11 | ||
|
|
153dc74ff6 | ||
|
|
802968df3d | ||
|
|
27adf47465 | ||
|
|
6392c2ef03 |
@@ -12,11 +12,14 @@
|
||||
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] Very simple sign-up/sign-in function
|
||||
- [x] Basic sign-up/sign-in function
|
||||
- [x] Matchmaking with delegation to the game server (using a shared JWT secret)
|
||||
- [ ] Actual game logic
|
||||
- [ ] Reporting game results back to the main server
|
||||
- [ ] Game UI
|
||||
- [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
|
||||
|
||||
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "sea-battle-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@rails/actioncable": "^8.1.400",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -807,6 +808,12 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@rails/actioncable": {
|
||||
"version": "8.1.400",
|
||||
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.1.400.tgz",
|
||||
"integrity": "sha512-Qffc3/I9xcpeaal12hmi9EPRV3hz9FvakUHBR7kL0jpZviWwO9dyaG8F1ltcjkHIdFxQ5QqzRyequAWHMq17xQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rails/actioncable": "^8.1.400",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
const API_URL = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_URL) || 'http://localhost:3000'
|
||||
|
||||
async function request(path, { method = 'GET', body, token, formData } = {}) {
|
||||
const headers = {}
|
||||
|
||||
+34
-2
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { api } from './api'
|
||||
import AuthModal from './components/AuthModal'
|
||||
import { connectCable, disconnectCable, CableContext } from './cable.js'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
const AuthModalContext = createContext(null)
|
||||
@@ -27,20 +28,32 @@ export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
|
||||
const [loading, setLoading] = useState(!!localStorage.getItem(TOKEN_KEY))
|
||||
const [consumer, setConsumer] = useState(() => {
|
||||
const savedToken = localStorage.getItem(TOKEN_KEY)
|
||||
return savedToken ? connectCable(savedToken) : null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
disconnectCable()
|
||||
setConsumer(null)
|
||||
return
|
||||
}
|
||||
api
|
||||
.me(token)
|
||||
.then((data) => setUser(data.user))
|
||||
.then((data) => {
|
||||
setUser(data.user)
|
||||
const c = connectCable(token)
|
||||
setConsumer(c)
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
disconnectCable()
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setConsumer(null)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [token])
|
||||
@@ -50,6 +63,8 @@ export function AuthProvider({ children }) {
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
const c = connectCable(data.token)
|
||||
setConsumer(c)
|
||||
}, [])
|
||||
|
||||
const register = useCallback(async (nickname, password, avatarFile) => {
|
||||
@@ -57,18 +72,35 @@ export function AuthProvider({ children }) {
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
const c = connectCable(data.token)
|
||||
setConsumer(c)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
if (token) api.logout(token).catch(() => {})
|
||||
disconnectCable()
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setConsumer(null)
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
consumer,
|
||||
cable: consumer,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
<CableContext.Provider value={{ consumer, cable: consumer }}>
|
||||
{children}
|
||||
</CableContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createContext, useContext, useEffect, useState, createElement } from 'react'
|
||||
import { createConsumer } from '@rails/actioncable'
|
||||
import { wsUrl } from './api.js'
|
||||
|
||||
let consumerInstance = null
|
||||
let activeToken = null
|
||||
|
||||
/**
|
||||
* Возвращает активный экземпляр ActionCable consumer, если он создан.
|
||||
*
|
||||
* @returns {import('@rails/actioncable').Consumer | null}
|
||||
*/
|
||||
export function getConsumer() {
|
||||
return consumerInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает (или возвращает существующий) ActionCable consumer для переданного JWT токена.
|
||||
* ActionCable автоматически обрабатывает переподключения при разрывах связи.
|
||||
*
|
||||
* @param {string} token JWT токен пользователя
|
||||
* @returns {import('@rails/actioncable').Consumer | null}
|
||||
*/
|
||||
export function connectCable(token) {
|
||||
if (!token) return null
|
||||
|
||||
if (consumerInstance && activeToken === token) {
|
||||
consumerInstance.connect()
|
||||
return consumerInstance
|
||||
}
|
||||
|
||||
if (consumerInstance) {
|
||||
disconnectCable()
|
||||
}
|
||||
|
||||
activeToken = token
|
||||
const url = wsUrl(`/cable?token=${encodeURIComponent(token)}`)
|
||||
consumerInstance = createConsumer(url)
|
||||
consumerInstance.connect()
|
||||
return consumerInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключает ActionCable consumer и закрывает соединение.
|
||||
* Вызывается при выходе из профиля (logout) или истечении сессии.
|
||||
*/
|
||||
export function disconnectCable() {
|
||||
if (consumerInstance) {
|
||||
try {
|
||||
consumerInstance.disconnect()
|
||||
} catch {
|
||||
// Игнорируем возможные ошибки при закрытии
|
||||
}
|
||||
consumerInstance = null
|
||||
}
|
||||
activeToken = null
|
||||
}
|
||||
|
||||
export const CableContext = createContext(null)
|
||||
|
||||
/**
|
||||
* Провайдер ActionCable для React приложения.
|
||||
* Может принимать token явно или использоваться в составе дерева компонентов.
|
||||
*/
|
||||
export function CableProvider({ children, token }) {
|
||||
const [consumer, setConsumer] = useState(() => (token ? connectCable(token) : getConsumer()))
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
const c = connectCable(token)
|
||||
setConsumer(c)
|
||||
} else if (token === null) {
|
||||
disconnectCable()
|
||||
setConsumer(null)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
return createElement(
|
||||
CableContext.Provider,
|
||||
{ value: { consumer, cable: consumer } },
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для доступа к ActionCable consumer из любого компонента.
|
||||
* Поддерживает деструктуризацию: `const { consumer } = useCable()`, `const { cable } = useCable()`.
|
||||
*/
|
||||
export function useCable() {
|
||||
const context = useContext(CableContext)
|
||||
if (context && context.consumer !== undefined) return context
|
||||
const consumer = getConsumer()
|
||||
return { consumer, cable: consumer }
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения экземпляра consumer напрямую: `const consumer = useConsumer()`.
|
||||
*/
|
||||
export function useConsumer() {
|
||||
const { consumer } = useCable()
|
||||
return consumer
|
||||
}
|
||||
@@ -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 { createGameConnection } from '../game'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
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 }) {
|
||||
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 connectionRef = useRef(null)
|
||||
const attemptRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const attempt = ++attemptRef.current
|
||||
let cancelled = false
|
||||
const connection = createGameConnection(session)
|
||||
const connInstance = createGameConnection(session)
|
||||
|
||||
connection.promise
|
||||
.then(() => {
|
||||
if (!cancelled && attempt === attemptRef.current) setStatus('ready')
|
||||
connInstance.promise
|
||||
.then((conn) => {
|
||||
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) => {
|
||||
if (cancelled || attempt !== attemptRef.current) return
|
||||
@@ -23,33 +93,104 @@ export default function GameScreen({ session, onLeave }) {
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
connection.cancel()
|
||||
connInstance.cancel()
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const handleReady = useCallback((ships) => {
|
||||
if (!connectionRef.current) return
|
||||
setMyShips(ships)
|
||||
setIsSubmitting(true)
|
||||
connectionRef.current.placeShips(ships)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<main className="search-screen">
|
||||
<div className="page game-page">
|
||||
{status === 'connecting' && (
|
||||
<>
|
||||
<main className="search-screen">
|
||||
<div className="search-label">Подключение к игре...</div>
|
||||
<div className="bouncing-dots" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</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' && (
|
||||
<>
|
||||
<main className="search-screen">
|
||||
<div className="search-error">{error}</div>
|
||||
<button className="btn btn-big btn-secondary" onClick={onLeave}>
|
||||
Назад в меню
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { createMatchmaking, MatchmakingError } from '../matchmaking'
|
||||
import { useCable } from '../cable'
|
||||
|
||||
const STAGE_LABELS = {
|
||||
connecting: 'Подключение...',
|
||||
@@ -13,6 +14,7 @@ const ERROR_LABELS = {
|
||||
}
|
||||
|
||||
export default function SearchScreen({ token, onMatched, onCancel }) {
|
||||
const { consumer } = useCable()
|
||||
const [stage, setStage] = useState('connecting') // 'connecting' | 'searching'
|
||||
const [error, setError] = useState(null)
|
||||
const [retryToken, setRetryToken] = useState(0)
|
||||
@@ -26,7 +28,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
|
||||
setStage('connecting')
|
||||
setError(null)
|
||||
|
||||
matchmaking = createMatchmaking(token, (status) => {
|
||||
matchmaking = createMatchmaking(consumer || token, (status) => {
|
||||
if (!cancelled && attempt === attemptRef.current) setStage(status)
|
||||
})
|
||||
|
||||
@@ -43,7 +45,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
|
||||
cancelled = true
|
||||
matchmaking.cancel()
|
||||
}
|
||||
}, [token, onMatched, retryToken])
|
||||
}, [consumer, token, onMatched, retryToken])
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel()
|
||||
|
||||
@@ -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
|
||||
const OP_JOIN = 1
|
||||
const OP_END = 2
|
||||
const OP_STATE = 3
|
||||
const OP_ACK = 4
|
||||
export const OP_JOIN = 1
|
||||
export const OP_END = 2
|
||||
export const OP_STATE = 3
|
||||
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 = {
|
||||
4000: 'Соединение заменено новым',
|
||||
@@ -31,6 +52,8 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
||||
let settled = false
|
||||
let lastSeq = 0
|
||||
|
||||
let connection = null
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
if (settled) return
|
||||
@@ -39,19 +62,36 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
||||
reject(err)
|
||||
}
|
||||
|
||||
const succeed = (connection) => {
|
||||
const succeed = (conn) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(connection)
|
||||
// Сокет НЕ закрываем — соединение переиспользуется для игры
|
||||
ws.onerror = null
|
||||
ws.onclose = (event) => {
|
||||
if (connection.onClose) connection.onClose(event.code)
|
||||
}
|
||||
resolve(conn)
|
||||
}
|
||||
|
||||
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.send(JSON.stringify([OP_JOIN, gameToken, userToken]))
|
||||
}
|
||||
@@ -73,18 +113,34 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
||||
|
||||
if (opcode === OP_STATE) {
|
||||
const state = msg[2]
|
||||
if (state === 1) {
|
||||
succeed({ socket: ws, send: sendMessage })
|
||||
if (!settled && state === STATE_PLACING_SHIPS) {
|
||||
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) {
|
||||
const code = msg[2]
|
||||
const reason = msg[3] || 'Партия завершена'
|
||||
if (!settled) {
|
||||
fail(new GameConnectionError(code === -1 ? 'join_timeout' : 'closed', reason))
|
||||
} else if (connection && typeof connection.onEnd === 'function') {
|
||||
connection.onEnd(code, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!settled) fail(new GameConnectionError('connection', 'Сетевая ошибка'))
|
||||
ws.onerror = (err) => {
|
||||
if (!settled) {
|
||||
fail(new GameConnectionError('connection', 'Сетевая ошибка'))
|
||||
} else if (connection && typeof connection.onError === 'function') {
|
||||
connection.onError(err)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
@@ -93,6 +149,8 @@ export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
|
||||
CLOSE_REASONS[event.code] ||
|
||||
(event.code === 1000 ? 'Соединение закрыто' : 'Соединение прервано')
|
||||
fail(new GameConnectionError('closed', message))
|
||||
} else if (connection && typeof connection.onClose === 'function') {
|
||||
connection.onClose(event.code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+201
-81
@@ -1,6 +1,4 @@
|
||||
import { wsUrl } from './api'
|
||||
|
||||
const CHANNEL_IDENTIFIER = JSON.stringify({ channel: 'MatchmakingChannel' })
|
||||
import { getConsumer, connectCable } from './cable.js'
|
||||
|
||||
export class MatchmakingError extends Error {
|
||||
constructor(code, message) {
|
||||
@@ -9,75 +7,193 @@ export class MatchmakingError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Текущая активная сессия матчмейкинга на уровне модуля
|
||||
let activeSession = null
|
||||
|
||||
/**
|
||||
* Подключается к main-server по WebSocket (Action Cable) и инициирует
|
||||
* поиск соперника. Возвращает объект с promise (резолвится при событии
|
||||
* `matched`) и функцией отмены `cancel()`.
|
||||
* Инициирует поиск соперника через ActionCable MatchmakingChannel.
|
||||
* Возвращает объект с promise (резолвится при событии `matched`)
|
||||
* и функцией отмены `cancel()`.
|
||||
*
|
||||
* @param {string} token JWT пользователя
|
||||
* @param {(status: 'connecting' | 'searching') => void} onStatus
|
||||
* Соединение WebSocket НЕ закрывается: при завершении или отмене
|
||||
* отменяется только подписка на канал (MatchmakingChannel),
|
||||
* а сокет остается открытым и доступным для всего приложения.
|
||||
*
|
||||
* Если отмена происходит до подтверждения подписки сервером (например,
|
||||
* при двойном монтировании в React StrictMode или быстром уходе со страницы),
|
||||
* отписка откладывается до прихода `confirm_subscription`, чтобы не создавать
|
||||
* гонку непарных subscribe/unsubscribe на сервере. При немедленном повторном
|
||||
* монтировании уже начатая подписка переиспользуется.
|
||||
*
|
||||
* @param {import('@rails/actioncable').Consumer | string} [consumerOrToken]
|
||||
* Экземпляр ActionCable consumer или JWT токен пользователя
|
||||
* @param {(status: 'connecting' | 'searching') => void} [onStatus]
|
||||
* @returns {{ promise: Promise<{ matchId: string, gameToken: string, gameServerUrl: string, players: [number, number] }>, cancel: () => void }}
|
||||
*/
|
||||
export function createMatchmaking(token, onStatus) {
|
||||
let ws = null
|
||||
let settled = false
|
||||
export function createMatchmaking(consumerOrToken, onStatus) {
|
||||
let consumer = null
|
||||
let statusCallback = onStatus
|
||||
|
||||
const send = (payload) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload))
|
||||
if (typeof consumerOrToken === 'function') {
|
||||
statusCallback = consumerOrToken
|
||||
consumer = getConsumer()
|
||||
} else if (consumerOrToken && typeof consumerOrToken.subscriptions === 'object') {
|
||||
consumer = consumerOrToken
|
||||
} else if (typeof consumerOrToken === 'string') {
|
||||
consumer = getConsumer() || connectCable(consumerOrToken)
|
||||
} else {
|
||||
consumer = getConsumer()
|
||||
}
|
||||
|
||||
if (!consumer) {
|
||||
return {
|
||||
promise: Promise.reject(new MatchmakingError('connection', 'Нет подключения к серверу')),
|
||||
cancel: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(err)
|
||||
// Если уже существует активная сессия подписки (например, от предыдущего быстрого mount в StrictMode)
|
||||
if (activeSession && activeSession.consumer === consumer && !activeSession.settled) {
|
||||
activeSession.cancelled = false
|
||||
activeSession.statusCallback = statusCallback
|
||||
|
||||
// Если подписка уже успела подтвердиться ранее
|
||||
if (activeSession.isSubscribed) {
|
||||
statusCallback?.('searching')
|
||||
if (!activeSession.started) {
|
||||
activeSession.subscription.perform('start')
|
||||
activeSession.started = true
|
||||
}
|
||||
} else {
|
||||
statusCallback?.('connecting')
|
||||
}
|
||||
|
||||
const succeed = (match) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
return {
|
||||
promise: activeSession.promise,
|
||||
cancel: activeSession.cancel,
|
||||
}
|
||||
}
|
||||
|
||||
const session = {
|
||||
consumer,
|
||||
statusCallback,
|
||||
isSubscribed: false,
|
||||
cancelled: false,
|
||||
settled: false,
|
||||
started: false,
|
||||
subscription: null,
|
||||
connectTimeoutId: null,
|
||||
disconnectTimeoutId: null,
|
||||
promise: null,
|
||||
cancel: null,
|
||||
}
|
||||
activeSession = session
|
||||
|
||||
session.promise = new Promise((resolve, reject) => {
|
||||
function cleanup() {
|
||||
if (session.connectTimeoutId) {
|
||||
clearTimeout(session.connectTimeoutId)
|
||||
session.connectTimeoutId = null
|
||||
}
|
||||
if (session.disconnectTimeoutId) {
|
||||
clearTimeout(session.disconnectTimeoutId)
|
||||
session.disconnectTimeoutId = null
|
||||
}
|
||||
if (activeSession === session) {
|
||||
activeSession = null
|
||||
}
|
||||
if (session.subscription) {
|
||||
try {
|
||||
session.subscription.unsubscribe()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
session.subscription = null
|
||||
}
|
||||
}
|
||||
|
||||
function succeed(match) {
|
||||
if (session.settled) return
|
||||
session.settled = true
|
||||
cleanup()
|
||||
resolve(match)
|
||||
}
|
||||
|
||||
onStatus('connecting')
|
||||
|
||||
ws = new WebSocket(wsUrl(`/cable?token=${encodeURIComponent(token)}`))
|
||||
let subscribed = false
|
||||
|
||||
ws.onopen = () => {
|
||||
send({ command: 'subscribe', identifier: CHANNEL_IDENTIFIER })
|
||||
function fail(err) {
|
||||
if (session.settled) return
|
||||
session.settled = true
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg
|
||||
session.statusCallback?.('connecting')
|
||||
|
||||
// Защита от «зависшего» подключения / подписки
|
||||
session.connectTimeoutId = setTimeout(() => {
|
||||
if (!session.settled && !session.started) {
|
||||
fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу'))
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
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 {
|
||||
// ignore
|
||||
}
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'confirm_subscription') {
|
||||
subscribed = true
|
||||
onStatus('searching')
|
||||
send({
|
||||
command: 'message',
|
||||
identifier: CHANNEL_IDENTIFIER,
|
||||
data: JSON.stringify({ action: 'start' }),
|
||||
})
|
||||
return
|
||||
if (session.settled) return
|
||||
|
||||
if (session.connectTimeoutId) {
|
||||
clearTimeout(session.connectTimeoutId)
|
||||
session.connectTimeoutId = null
|
||||
}
|
||||
if (session.disconnectTimeoutId) {
|
||||
clearTimeout(session.disconnectTimeoutId)
|
||||
session.disconnectTimeoutId = null
|
||||
}
|
||||
|
||||
if (msg.type === 'reject_subscription') {
|
||||
session.statusCallback?.('searching')
|
||||
this.perform('start')
|
||||
session.started = true
|
||||
},
|
||||
|
||||
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
|
||||
|
||||
const message = msg.message
|
||||
received(message) {
|
||||
if (session.settled || session.cancelled) return
|
||||
if (!message || typeof message !== 'object') return
|
||||
|
||||
if (message.type === 'matched') {
|
||||
@@ -90,47 +206,51 @@ export function createMatchmaking(token, onStatus) {
|
||||
} else if (message.type === 'timeout') {
|
||||
fail(new MatchmakingError('timeout', 'Соперник не найден'))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!settled) fail(new MatchmakingError('connection', 'Сетевая ошибка'))
|
||||
)
|
||||
} catch (err) {
|
||||
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() {
|
||||
if (ws) {
|
||||
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null
|
||||
ws.close()
|
||||
ws = null
|
||||
session.cancel = function cancel() {
|
||||
if (session.settled) return
|
||||
session.cancelled = true
|
||||
|
||||
if (session.connectTimeoutId) {
|
||||
clearTimeout(session.connectTimeoutId)
|
||||
session.connectTimeoutId = null
|
||||
}
|
||||
if (session.disconnectTimeoutId) {
|
||||
clearTimeout(session.disconnectTimeoutId)
|
||||
session.disconnectTimeoutId = null
|
||||
}
|
||||
|
||||
/** Явная отмена поиска: команда `stop` + закрытие сокета. */
|
||||
function cancel() {
|
||||
if (settled) return
|
||||
settled = true
|
||||
send({
|
||||
command: 'message',
|
||||
identifier: CHANNEL_IDENTIFIER,
|
||||
data: JSON.stringify({ action: 'stop' }),
|
||||
})
|
||||
cleanup()
|
||||
// Если сервер уже подтвердил подписку — сразу отправляем stop и отписываемся
|
||||
if (session.isSubscribed && session.subscription) {
|
||||
try {
|
||||
if (session.started) {
|
||||
session.subscription.perform('stop')
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
session.subscription.unsubscribe()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
session.subscription = null
|
||||
if (activeSession === session) {
|
||||
activeSession = null
|
||||
}
|
||||
}
|
||||
// Если подписка ещё не подтверждена (isSubscribed === false),
|
||||
// мы НЕ шлем немедленный unsubscribe, предотвращая гонку на сервере.
|
||||
// Как только сервер пришлет подтверждение, сработает connected()
|
||||
// и выполнит отложенный unsubscribe.
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -510,3 +510,620 @@ input:not([type]), input[type="text"] {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+104
-12
@@ -28,11 +28,14 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
||||
Все сообщения — JSON-массивы, первый элемент — числовой опкод.
|
||||
|
||||
| Опкод | Направление | Название | Формат |
|
||||
|-------|-------------|----------|--------|
|
||||
|-------|-------------|-------------|--------|
|
||||
| 1 | C -> S | JOIN | `[1, "<game_token>", "<user_token>"]` |
|
||||
| 2 | S -> C | END | `[2, <seq>, <code>, "<reason>"]` |
|
||||
| 3 | S -> C | STATE | `[3, <seq>, <state_code>]` |
|
||||
| 4 | C <-> S | ACK | `[4, <seq>]` |
|
||||
| 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)
|
||||
|
||||
@@ -66,11 +69,15 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
||||
|
||||
Партия завершена. Формат: `[2, <seq>, <code>, "<reason>"]`.
|
||||
|
||||
Коды завершения:
|
||||
Коды завершения (отсылаются с точки зрения получателя):
|
||||
|
||||
| Код | Причина |
|
||||
|-----|---------|
|
||||
| -1 | `join timeout exceeded` — второй игрок не присоединился вовремя |
|
||||
| Код | Причина | Описание |
|
||||
|-----|---------|----------|
|
||||
| -1 | `join timeout exceeded` | второй игрок не присоединился вовремя |
|
||||
| 1 | `victory` | победа (все корабли соперника уничтожены) |
|
||||
| 2 | `defeat` | поражение (все корабли игрока уничтожены) |
|
||||
|
||||
После отправки сообщения END сервер сразу закрывает WebSocket-соединение с кодом 1000 (нормальное завершение). Сохранённое сообщение END хранится в реестре завершённых партий и автоматически воспроизводится игроку при повторном подключении перед закрытием соединения.
|
||||
|
||||
### STATE (3)
|
||||
|
||||
@@ -85,12 +92,19 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
||||
| 4 | ваш ход |
|
||||
| 5 | ожидайте ход соперника |
|
||||
|
||||
Сейчас рассылается только состояние `1` — при старте партии (когда оба
|
||||
игрока впервые присоединились).
|
||||
Смена состояний:
|
||||
- Состояние `1` рассылается обоим игрокам при старте партии (когда оба игрока впервые присоединились).
|
||||
- При получении расстановки кораблей от одного из игроков партия переходит в состояние ожидания соперника:
|
||||
игрок, сдавший корабли, получает состояние `3` («ожидайте готовности соперника»), а его противник — состояние `2` («соперник готов»).
|
||||
- Когда расстановку сдаёт второй игрок, игра переходит в фазу ходов: игрок, завершивший расстановку первым,
|
||||
получает состояние `4` («ваш ход»), а второй игрок — состояние `5` («ожидайте ход соперника»).
|
||||
- При промахе ход переходит к противнику: стрелявший игрок получает состояние `5` («ожидайте ход соперника»), а противник — состояние `4` («ваш ход»).
|
||||
- При попадании или потоплении корабля ход остаётся у текущего стрелка (состояние матча не изменяется).
|
||||
- При уничтожении всех кораблей одного из игроков игра завершается победой: партия не переходит в новые состояния STATE — вместо этого обоим игрокам рассылается сообщение END (победителю — с кодом 1 victory, проигравшему — с кодом 2 defeat), после чего соединение сразу закрывается.
|
||||
|
||||
### ACK (4)
|
||||
|
||||
Каждое изменяющее состояние сообщение S->C (END, STATE) несёт `seq` —
|
||||
Каждое изменяющее состояние сообщение S->C (END, STATE, FIRE_RESULT) несёт `seq` —
|
||||
монотонно возрастающий номер в рамках пары (партия, игрок). Клиент
|
||||
подтверждает доставку сообщением `[4, <seq>]`; ACK кумулятивен
|
||||
(подтверждает все сообщения с `seq` <= указанного).
|
||||
@@ -103,6 +117,84 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
|
||||
Направление S->C зарезервировано для подтверждения будущих сообщений
|
||||
клиента (у которых пока нет номеров).
|
||||
|
||||
### PLACE_SHIPS (5)
|
||||
|
||||
Сообщение расстановки кораблей от клиента серверу. Принимается только от игрока,
|
||||
от которого ожидается расстановка в текущем состоянии партии (`StatePlacingShips` для
|
||||
обоих игроков; `StatePlayer1Ready` — только для игрока 2; `StatePlayer2Ready` — только
|
||||
для игрока 1). Повторные сообщения от уже расставившегося игрока или сообщения в фазе
|
||||
ходов отклоняются.
|
||||
|
||||
Формат сообщения:
|
||||
```json
|
||||
[5, ship0_orientation, ship0_x, ship0_y, ship1_orientation, ship1_x, ship1_y, ..., ship9_orientation, ship9_x, ship9_y]
|
||||
```
|
||||
|
||||
Сообщение содержит ровно 30 аргументов — по 3 аргумента на каждый из 10 кораблей
|
||||
в порядке возрастания размера:
|
||||
- 4 однопалубных корабля (размер 1, индексы 0..3)
|
||||
- 3 двухпалубных корабля (размер 2, индексы 4..6)
|
||||
- 2 трёхпалубных корабля (размер 3, индексы 7..8)
|
||||
- 1 четырёхпалубный корабль (размер 4, индекс 9)
|
||||
|
||||
Каждый корабль описывается 3-кортежем `(orientation, x, y)`:
|
||||
- `orientation` (boolean): `false` — горизонтальная ориентация (корабль продолжается
|
||||
вправо по оси X от начальной клетки `(x, y)`: клетки `(x + i, y)` для `i = 0..size-1`),
|
||||
`true` — вертикальная ориентация (корабль продолжается вниз по оси Y от начальной
|
||||
клетки `(x, y)`: клетки `(x, y + i)` для `i = 0..size-1`). Для однопалубных кораблей
|
||||
допустимо любое булево значение.
|
||||
- `x` (integer): координата X начала корабля (`0 <= x <= 9`).
|
||||
- `y` (integer): координата Y начала корабля (`0 <= y <= 9`).
|
||||
|
||||
Правила валидации расстановки:
|
||||
1. Количество кораблей строго 10, размеры строго соответствуют схеме 4x1, 3x2, 2x3, 1x4.
|
||||
2. Все клетки каждого корабля должны лежать строго в пределах игрового поля 10x10
|
||||
(`0 <= x < 10` и `0 <= y < 10`).
|
||||
3. Корабли не должны перекрываться и не должны соприкасаться сторонами или углами:
|
||||
между любыми двумя различными кораблями во всех направлениях (включая диагонали)
|
||||
всегда должен оставаться зазор минимум в 1 клетку.
|
||||
|
||||
При получении валидного сообщения сервер сохраняет расстановку в структуре матча
|
||||
(в виде сетки 10x10 с идентификаторами кораблей и списка кораблей) и переводит партию
|
||||
в следующее состояние:
|
||||
- Если первым расстановку завершил игрок 1:
|
||||
`StatePlacingShips` (1) -> `StatePlayer1Ready` (2) -> `StatePlayer1Turn` (4).
|
||||
- Если первым расстановку завершил игрок 2:
|
||||
`StatePlacingShips` (1) -> `StatePlayer2Ready` (3) -> `StatePlayer2Turn` (5).
|
||||
|
||||
### 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` либо указанная клетка уже была поражена ранее. Право хода остаётся у игрока. |
|
||||
|
||||
## Коды закрытия соединения
|
||||
|
||||
| Код | Значение |
|
||||
@@ -166,13 +258,13 @@ internal/ws/ — websocket-эндпоинт: апгрейд, JOIN,
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
Покрыты: валидация токенов, разбор/сборка сообщений, сценарии менеджера
|
||||
(ожидание, старт, таймаут, повторный вход, замена соединения, ACK) и
|
||||
сквозные интеграционные тесты через настоящий websocket.
|
||||
Покрыты: валидация токенов, разбор/сборка сообщений (включая расстановку кораблей),
|
||||
сценарии менеджера (ожидание, старт, таймаут, повторный вход, замена соединения, ACK,
|
||||
расстановка кораблей с переходами состояний) и сквозные интеграционные тесты через настоящий websocket.
|
||||
|
||||
## TODO (следующие этапы)
|
||||
|
||||
- игровая логика (расстановка кораблей, ходы) и остальные состояния;
|
||||
- игровая логика стрельбы/ходов (расстановка кораблей и переходы к фазе ходов уже реализованы);
|
||||
- завершение партии по итогам игры с персонифицированными кодами
|
||||
победы/поражения;
|
||||
- репорт итогов партии на main-server (реестр завершённых партий уже
|
||||
|
||||
@@ -19,8 +19,33 @@ import (
|
||||
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
|
||||
@@ -74,6 +99,12 @@ type PlayerSlot struct {
|
||||
unacked []queuedMessage
|
||||
lastState int // last state sent to this player, 0 if none
|
||||
everConnected bool // true after the first successful join
|
||||
|
||||
ships []protocol.Ship
|
||||
board Board
|
||||
shipsPlaced bool
|
||||
struck [BoardSize][BoardSize]bool
|
||||
shipsRemaining int
|
||||
}
|
||||
|
||||
// Match is the state of a single game of sea battle.
|
||||
@@ -105,6 +136,49 @@ func (m *Match) State() int {
|
||||
return m.state
|
||||
}
|
||||
|
||||
// Ships returns a copy of the ships placed by the player in slotIdx,
|
||||
// or nil if ships have not been placed yet.
|
||||
func (m *Match) Ships(slotIdx int) []protocol.Ship {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if slotIdx < 0 || slotIdx >= len(m.slots) || !m.slots[slotIdx].shipsPlaced {
|
||||
return nil
|
||||
}
|
||||
res := make([]protocol.Ship, len(m.slots[slotIdx].ships))
|
||||
copy(res, m.slots[slotIdx].ships)
|
||||
return res
|
||||
}
|
||||
|
||||
// Board returns the board of the player in slotIdx and whether ships have been placed.
|
||||
func (m *Match) Board(slotIdx int) (Board, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if slotIdx < 0 || slotIdx >= len(m.slots) {
|
||||
return Board{}, false
|
||||
}
|
||||
return m.slots[slotIdx].board, m.slots[slotIdx].shipsPlaced
|
||||
}
|
||||
|
||||
// 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 {
|
||||
switch userID {
|
||||
case m.playerIDs[0]:
|
||||
@@ -291,6 +365,172 @@ func (m *Manager) Ack(match *Match, idx int, seq int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlaceShips validates and stores the player's ship placement, advancing to the next match state.
|
||||
// The state order depends on which player completed the placement first:
|
||||
// - If Player 1 is first: StatePlacingShips -> StatePlayer1Ready -> StatePlayer1Turn
|
||||
// - If Player 2 is first: StatePlacingShips -> StatePlayer2Ready -> StatePlayer2Turn
|
||||
func (m *Manager) PlaceShips(ctx context.Context, match *Match, slotIdx int, ships []protocol.Ship) error {
|
||||
if err := protocol.ValidateShips(ships); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
match.mu.Lock()
|
||||
defer match.mu.Unlock()
|
||||
|
||||
if match.ended {
|
||||
return ErrMatchFinished
|
||||
}
|
||||
if !match.started {
|
||||
return ErrMatchNotStarted
|
||||
}
|
||||
if slotIdx < 0 || slotIdx >= len(match.slots) {
|
||||
return ErrInvalidSlot
|
||||
}
|
||||
|
||||
slot := match.slots[slotIdx]
|
||||
if slot.shipsPlaced {
|
||||
return ErrShipsAlreadyPlaced
|
||||
}
|
||||
|
||||
var nextState int
|
||||
switch match.state {
|
||||
case StatePlacingShips:
|
||||
if slotIdx == 0 {
|
||||
nextState = StatePlayer1Ready
|
||||
} else {
|
||||
nextState = StatePlayer2Ready
|
||||
}
|
||||
case StatePlayer1Ready:
|
||||
if slotIdx != 1 {
|
||||
return ErrUnexpectedState
|
||||
}
|
||||
nextState = StatePlayer1Turn
|
||||
case StatePlayer2Ready:
|
||||
if slotIdx != 0 {
|
||||
return ErrUnexpectedState
|
||||
}
|
||||
nextState = StatePlayer2Turn
|
||||
default:
|
||||
return ErrUnexpectedState
|
||||
}
|
||||
|
||||
slot.ships = make([]protocol.Ship, len(ships))
|
||||
copy(slot.ships, ships)
|
||||
slot.board = NewBoard(ships)
|
||||
slot.shipsPlaced = true
|
||||
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()
|
||||
@@ -324,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
|
||||
// acknowledged yet; if there is nothing pending, the current state is sent
|
||||
// anew so a reconnected client can catch up.
|
||||
@@ -374,10 +626,25 @@ func (m *Manager) onJoinTimeout(matchID string) {
|
||||
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 END notifications to connected players. Both m.mu and match.mu must be held
|
||||
// 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
|
||||
if match.joinTimer != nil {
|
||||
match.joinTimer.Stop()
|
||||
@@ -390,9 +657,10 @@ func (m *Manager) finishMatchLocked(match *Match, code int, reason string) {
|
||||
data []byte
|
||||
}
|
||||
var notify []notification
|
||||
for _, slot := range match.slots {
|
||||
for i, slot := range match.slots {
|
||||
slot.seq++
|
||||
data := protocol.BuildEnd(slot.seq, code, reason)
|
||||
end := endsPerSlot[i]
|
||||
data := protocol.BuildEnd(slot.seq, end.code, end.reason)
|
||||
slot.unacked = append(slot.unacked, queuedMessage{seq: slot.seq, data: data})
|
||||
ends[slot.UserID] = data
|
||||
if slot.conn != nil {
|
||||
@@ -420,8 +688,43 @@ func (m *Manager) finishMatchLocked(match *Match, code int, reason string) {
|
||||
// Network I/O happens outside of any lock.
|
||||
for _, n := range notify {
|
||||
_ = 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)
|
||||
|
||||
@@ -454,3 +454,533 @@ func mustMatch(t *testing.T, m *Manager, id string) *Match {
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func validTestShips() []protocol.Ship {
|
||||
return []protocol.Ship{
|
||||
// 4 size 1
|
||||
{Orientation: protocol.OrientationHorizontal, X: 9, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationVertical, X: 7, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 5, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 3, Y: 4, Size: 1},
|
||||
// 3 size 2
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 4, Size: 2},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 7, Y: 2, Size: 2},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 4, Y: 2, Size: 2},
|
||||
// 2 size 3
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 2, Size: 3},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 5, Y: 0, Size: 3},
|
||||
// 1 size 4
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 0, Size: 4},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsP1First(t *testing.T) {
|
||||
m := NewManager(time.Minute)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
c1, c2 := &fakeConn{}, &fakeConn{}
|
||||
|
||||
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("join p1: %v", err)
|
||||
}
|
||||
if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil {
|
||||
t.Fatalf("join p2: %v", err)
|
||||
}
|
||||
if match.State() != StatePlacingShips {
|
||||
t.Fatalf("state = %d, want StatePlacingShips", match.State())
|
||||
}
|
||||
|
||||
ships := validTestShips()
|
||||
|
||||
// Player 1 places ships first
|
||||
if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil {
|
||||
t.Fatalf("p1 place ships: %v", err)
|
||||
}
|
||||
if match.State() != StatePlayer1Ready {
|
||||
t.Fatalf("state = %d, want StatePlayer1Ready (2)", match.State())
|
||||
}
|
||||
|
||||
// Verify stored ships and board for p1
|
||||
p1Ships := match.Ships(0)
|
||||
if len(p1Ships) != len(ships) {
|
||||
t.Fatalf("got %d ships, want %d", len(p1Ships), len(ships))
|
||||
}
|
||||
p1Board, placed := match.Board(0)
|
||||
if !placed {
|
||||
t.Fatal("expected p1 board placed to be true")
|
||||
}
|
||||
// Check that ship 9 (size 4 at 0,0 horizontal) is in p1 board with id 10
|
||||
for x := 0; x < 4; x++ {
|
||||
if p1Board[0][x] != 10 {
|
||||
t.Fatalf("p1Board[0][%d] = %d, want 10", x, p1Board[0][x])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify states delivered to p1 and p2
|
||||
p1Msgs, p2Msgs := c1.messages(), c2.messages()
|
||||
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) {
|
||||
t.Fatalf("p1 got state %v, want StateWaitOpponentReady (3)", v)
|
||||
}
|
||||
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) {
|
||||
t.Fatalf("p2 got state %v, want StateOpponentReady (2)", v)
|
||||
}
|
||||
|
||||
// Player 1 cannot place ships again
|
||||
if err := m.PlaceShips(context.Background(), match, 0, ships); err == nil {
|
||||
t.Fatal("expected error when player 1 places ships again")
|
||||
}
|
||||
|
||||
// Player 2 places ships
|
||||
if err := m.PlaceShips(context.Background(), match, 1, ships); err != nil {
|
||||
t.Fatalf("p2 place ships: %v", err)
|
||||
}
|
||||
if match.State() != StatePlayer1Turn {
|
||||
t.Fatalf("state = %d, want StatePlayer1Turn (4)", match.State())
|
||||
}
|
||||
|
||||
// Verify states delivered to p1 and p2 after game starts
|
||||
p1Msgs, p2Msgs = c1.messages(), c2.messages()
|
||||
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateYourTurn) {
|
||||
t.Fatalf("p1 got state %v, want StateYourTurn (4)", v)
|
||||
}
|
||||
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||
t.Fatalf("p2 got state %v, want StateWaitOpponentTurn (5)", v)
|
||||
}
|
||||
|
||||
// Subsequent PlaceShips calls are rejected
|
||||
if err := m.PlaceShips(context.Background(), match, 1, ships); err == nil {
|
||||
t.Fatal("expected error when match is in turn phase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsP2First(t *testing.T) {
|
||||
m := NewManager(time.Minute)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
c1, c2 := &fakeConn{}, &fakeConn{}
|
||||
|
||||
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("join p1: %v", err)
|
||||
}
|
||||
if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil {
|
||||
t.Fatalf("join p2: %v", err)
|
||||
}
|
||||
|
||||
ships := validTestShips()
|
||||
|
||||
// Player 2 places ships first
|
||||
if err := m.PlaceShips(context.Background(), match, 1, ships); err != nil {
|
||||
t.Fatalf("p2 place ships: %v", err)
|
||||
}
|
||||
if match.State() != StatePlayer2Ready {
|
||||
t.Fatalf("state = %d, want StatePlayer2Ready (3)", match.State())
|
||||
}
|
||||
|
||||
// Verify states delivered to p1 and p2
|
||||
p1Msgs, p2Msgs := c1.messages(), c2.messages()
|
||||
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) {
|
||||
t.Fatalf("p1 got state %v, want StateOpponentReady (2)", v)
|
||||
}
|
||||
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) {
|
||||
t.Fatalf("p2 got state %v, want StateWaitOpponentReady (3)", v)
|
||||
}
|
||||
|
||||
// Player 1 places ships
|
||||
if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil {
|
||||
t.Fatalf("p1 place ships: %v", err)
|
||||
}
|
||||
if match.State() != StatePlayer2Turn {
|
||||
t.Fatalf("state = %d, want StatePlayer2Turn (5)", match.State())
|
||||
}
|
||||
|
||||
// Player 2 finished first, so Player 2 gets first turn!
|
||||
p1Msgs, p2Msgs = c1.messages(), c2.messages()
|
||||
if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||
t.Fatalf("p1 got state %v, want StateWaitOpponentTurn (5)", v)
|
||||
}
|
||||
if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateYourTurn) {
|
||||
t.Fatalf("p2 got state %v, want StateYourTurn (4)", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsValidationFailure(t *testing.T) {
|
||||
m := NewManager(time.Minute)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
c1, c2 := &fakeConn{}, &fakeConn{}
|
||||
|
||||
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("join p1: %v", err)
|
||||
}
|
||||
if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil {
|
||||
t.Fatalf("join p2: %v", err)
|
||||
}
|
||||
|
||||
invalidShips := validTestShips()
|
||||
invalidShips[0].X = invalidShips[1].X // overlapping ships
|
||||
|
||||
if err := m.PlaceShips(context.Background(), match, 0, invalidShips); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if match.State() != StatePlacingShips {
|
||||
t.Fatalf("state changed to %d on error, want StatePlacingShips", match.State())
|
||||
}
|
||||
if match.Ships(0) != nil {
|
||||
t.Fatal("expected nil ships for p1 after failed placement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsBeforeMatchStarts(t *testing.T) {
|
||||
m := NewManager(time.Minute)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
c1 := &fakeConn{}
|
||||
|
||||
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("join p1: %v", err)
|
||||
}
|
||||
|
||||
if err := m.PlaceShips(context.Background(), match, 0, validTestShips()); err != ErrMatchNotStarted {
|
||||
t.Fatalf("got err = %v, want ErrMatchNotStarted", err)
|
||||
}
|
||||
}
|
||||
|
||||
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>"]
|
||||
// 3 S->C STATE [3, <seq>, <state_code>]
|
||||
// 4 C<->S ACK [4, <seq>]
|
||||
// 5 C->S PLACE_SHIPS [5, ship0_orientation, ship0_x, ship0_y, ...]
|
||||
// 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
|
||||
// delivery with an ACK message containing the sequence number of the last
|
||||
// received message (ACK is cumulative). Unacknowledged messages are kept in
|
||||
@@ -34,8 +37,52 @@ const (
|
||||
OpEnd = 2 // S->C: match ended
|
||||
OpState = 3 // S->C: game state transition
|
||||
OpAck = 4 // C<->S: delivery acknowledgement
|
||||
OpPlaceShips = 5 // C->S: ship placement
|
||||
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
|
||||
// view: a player never learns whether they are "player 1" or "player 2".
|
||||
const (
|
||||
@@ -46,9 +93,19 @@ const (
|
||||
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.
|
||||
const (
|
||||
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+ and RFC 6455 codes).
|
||||
@@ -103,6 +160,98 @@ func ParseAck(args []json.RawMessage) (seq int64, err error) {
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
// ParsePlaceShips extracts the 10 ships from PLACE_SHIPS arguments.
|
||||
// args must contain 30 elements: (orientation, x, y) for each of the 10 ships.
|
||||
// The ships correspond to increasing sizes: 4 size 1, 3 size 2, 2 size 3, 1 size 4.
|
||||
func ParsePlaceShips(args []json.RawMessage) ([]Ship, error) {
|
||||
if len(args) != ShipArgs {
|
||||
return nil, fmt.Errorf("PLACE_SHIPS expects %d arguments, got %d", ShipArgs, len(args))
|
||||
}
|
||||
|
||||
ships := make([]Ship, ShipCount)
|
||||
for i := 0; i < ShipCount; i++ {
|
||||
base := i * 3
|
||||
|
||||
var orientation bool
|
||||
if err := json.Unmarshal(args[base], &orientation); err != nil {
|
||||
return nil, fmt.Errorf("ship %d orientation is not a boolean: %w", i, err)
|
||||
}
|
||||
|
||||
var x int
|
||||
if err := json.Unmarshal(args[base+1], &x); err != nil {
|
||||
return nil, fmt.Errorf("ship %d x is not an integer: %w", i, err)
|
||||
}
|
||||
|
||||
var y int
|
||||
if err := json.Unmarshal(args[base+2], &y); err != nil {
|
||||
return nil, fmt.Errorf("ship %d y is not an integer: %w", i, err)
|
||||
}
|
||||
|
||||
ships[i] = Ship{
|
||||
Orientation: orientation,
|
||||
X: x,
|
||||
Y: y,
|
||||
Size: ShipSizes[i],
|
||||
}
|
||||
}
|
||||
|
||||
return ships, nil
|
||||
}
|
||||
|
||||
// ValidateShips validates the ship placement on the 10x10 board:
|
||||
// - exactly 10 ships with sizes [1, 1, 1, 1, 2, 2, 2, 3, 3, 4]
|
||||
// - all ship cells are within bounds (0 <= x < 10, 0 <= y < 10)
|
||||
// - no ships overlap or touch each other in any direction (including diagonals),
|
||||
// ensuring a gap of at least 1 cell between any two ships.
|
||||
func ValidateShips(ships []Ship) error {
|
||||
if len(ships) != ShipCount {
|
||||
return fmt.Errorf("expected %d ships, got %d", ShipCount, len(ships))
|
||||
}
|
||||
|
||||
var grid [BoardSize][BoardSize]int
|
||||
for y := 0; y < BoardSize; y++ {
|
||||
for x := 0; x < BoardSize; x++ {
|
||||
grid[y][x] = -1
|
||||
}
|
||||
}
|
||||
|
||||
for i, ship := range ships {
|
||||
expectedSize := ShipSizes[i]
|
||||
if ship.Size != expectedSize {
|
||||
return fmt.Errorf("ship %d: expected size %d, got %d", i, expectedSize, ship.Size)
|
||||
}
|
||||
|
||||
cells := ship.Cells()
|
||||
// 1. Bounds check
|
||||
for _, c := range cells {
|
||||
if c.X < 0 || c.X >= BoardSize || c.Y < 0 || c.Y >= BoardSize {
|
||||
return fmt.Errorf("ship %d out of bounds at (%d, %d)", i, c.X, c.Y)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collision / gap check against already placed ships
|
||||
for _, c := range cells {
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
nx, ny := c.X+dx, c.Y+dy
|
||||
if nx >= 0 && nx < BoardSize && ny >= 0 && ny < BoardSize {
|
||||
if other := grid[ny][nx]; other != -1 {
|
||||
return fmt.Errorf("ship %d at (%d, %d) touches or overlaps with ship %d at (%d, %d)", i, c.X, c.Y, other, nx, ny)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Place ship on the grid
|
||||
for _, c := range cells {
|
||||
grid[c.Y][c.X] = i
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func build(v []any) []byte {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
@@ -127,3 +276,55 @@ func BuildState(seq int64, state int) []byte {
|
||||
func BuildAck(seq int64) []byte {
|
||||
return build([]any{OpAck, seq})
|
||||
}
|
||||
|
||||
// BuildPlaceShips builds a PLACE_SHIPS message from a slice of ships:
|
||||
// [5, ship0_orientation, ship0_x, ship0_y, ship1_orientation, ship1_x, ship1_y, ...]
|
||||
func BuildPlaceShips(ships []Ship) []byte {
|
||||
v := make([]any, 1+len(ships)*3)
|
||||
v[0] = OpPlaceShips
|
||||
for i, s := range ships {
|
||||
v[1+i*3] = s.Orientation
|
||||
v[1+i*3+1] = s.X
|
||||
v[1+i*3+2] = s.Y
|
||||
}
|
||||
return build(v)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +277,30 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.log.Debug("invalid ACK seq", "match", match.ID, "user", uc.UserID, "seq", seq, "err", err)
|
||||
continue
|
||||
}
|
||||
case protocol.OpPlaceShips:
|
||||
ships, err := protocol.ParsePlaceShips(args)
|
||||
if err != nil {
|
||||
h.log.Debug("malformed PlaceShips", "match", match.ID, "user", uc.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
if err := protocol.ValidateShips(ships); err != nil {
|
||||
h.log.Debug("invalid ship placement", "match", match.ID, "user", uc.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
if err := h.Manager.PlaceShips(ctx, match, slotIdx, ships); err != nil {
|
||||
h.log.Debug("place ships rejected", "match", match.ID, "user", uc.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
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:
|
||||
// Unknown opcodes are ignored for forward compatibility.
|
||||
h.log.Debug("unknown opcode", "match", match.ID, "user", uc.UserID, "opcode", opcode)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/ruby-sea-battle/game-server/internal/auth"
|
||||
"github.com/ruby-sea-battle/game-server/internal/game"
|
||||
"github.com/ruby-sea-battle/game-server/internal/protocol"
|
||||
"github.com/ruby-sea-battle/game-server/internal/ws"
|
||||
)
|
||||
|
||||
@@ -55,10 +57,16 @@ func userToken(t *testing.T, userID int64) string {
|
||||
func startServerWithManager(t *testing.T, joinTimeout time.Duration) (string, *game.Manager) {
|
||||
t.Helper()
|
||||
manager := game.NewManager(joinTimeout)
|
||||
log := slog.New(slog.NewTextHandler(testWriter{t}, nil))
|
||||
tw := &testWriter{t: t}
|
||||
log := slog.New(slog.NewTextHandler(tw, nil))
|
||||
handler := ws.NewHandler(manager, secret, publicAddr, []string{"*"}, log)
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
t.Cleanup(func() {
|
||||
srv.Close()
|
||||
tw.mu.Lock()
|
||||
tw.done = true
|
||||
tw.mu.Unlock()
|
||||
})
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http"), manager
|
||||
}
|
||||
|
||||
@@ -67,10 +75,18 @@ func startServer(t *testing.T, joinTimeout time.Duration) (url string) {
|
||||
return u
|
||||
}
|
||||
|
||||
type testWriter struct{ t *testing.T }
|
||||
type testWriter struct {
|
||||
mu sync.Mutex
|
||||
t *testing.T
|
||||
done bool
|
||||
}
|
||||
|
||||
func (w testWriter) Write(p []byte) (int, error) {
|
||||
func (w *testWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if !w.done {
|
||||
w.t.Log(strings.TrimSpace(string(p)))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
@@ -317,3 +333,256 @@ func TestManagerShutdownClosesConnectionsGoingAway(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validTestShips() []protocol.Ship {
|
||||
return []protocol.Ship{
|
||||
// 4 size 1
|
||||
{Orientation: protocol.OrientationHorizontal, X: 9, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationVertical, X: 7, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 5, Y: 4, Size: 1},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 3, Y: 4, Size: 1},
|
||||
// 3 size 2
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 4, Size: 2},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 7, Y: 2, Size: 2},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 4, Y: 2, Size: 2},
|
||||
// 2 size 3
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 2, Size: 3},
|
||||
{Orientation: protocol.OrientationHorizontal, X: 5, Y: 0, Size: 3},
|
||||
// 1 size 4
|
||||
{Orientation: protocol.OrientationHorizontal, X: 0, Y: 0, Size: 4},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceShipsOverWebSocket(t *testing.T) {
|
||||
url, mgr := startServerWithManager(t, time.Minute)
|
||||
exp := time.Now().Add(time.Hour)
|
||||
gt := gameToken(t, "m-ws-placeships", 1, 2, publicAddr, exp)
|
||||
|
||||
c1, c2 := dial(t, url), dial(t, url)
|
||||
join(t, c1, gt, userToken(t, 1))
|
||||
join(t, c2, gt, userToken(t, 2))
|
||||
|
||||
// Both receive StatePlacingShips (seq 1)
|
||||
v1 := readMsg(t, c1)
|
||||
if v1[0] != float64(protocol.OpState) || v1[1] != float64(1) || v1[2] != float64(protocol.StatePlacingShips) {
|
||||
t.Fatalf("c1 unexpected initial state: %v", v1)
|
||||
}
|
||||
v2 := readMsg(t, c2)
|
||||
if v2[0] != float64(protocol.OpState) || v2[1] != float64(1) || v2[2] != float64(protocol.StatePlacingShips) {
|
||||
t.Fatalf("c2 unexpected initial state: %v", v2)
|
||||
}
|
||||
|
||||
// ACK seq 1
|
||||
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1))
|
||||
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1))
|
||||
|
||||
// Player 1 sends PlaceShips
|
||||
ships := validTestShips()
|
||||
placeMsg := protocol.BuildPlaceShips(ships)
|
||||
if err := c1.Write(context.Background(), websocket.MessageText, placeMsg); err != nil {
|
||||
t.Fatalf("write PlaceShips c1: %v", err)
|
||||
}
|
||||
|
||||
// c1 gets StateWaitOpponentReady (3), c2 gets StateOpponentReady (2)
|
||||
v1 = readMsg(t, c1)
|
||||
if v1[0] != float64(protocol.OpState) || v1[1] != float64(2) || v1[2] != float64(protocol.StateWaitOpponentReady) {
|
||||
t.Fatalf("c1 got %v, want StateWaitOpponentReady (3)", v1)
|
||||
}
|
||||
v2 = readMsg(t, c2)
|
||||
if v2[0] != float64(protocol.OpState) || v2[1] != float64(2) || v2[2] != float64(protocol.StateOpponentReady) {
|
||||
t.Fatalf("c2 got %v, want StateOpponentReady (2)", v2)
|
||||
}
|
||||
|
||||
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2))
|
||||
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2))
|
||||
|
||||
// Player 2 sends PlaceShips
|
||||
if err := c2.Write(context.Background(), websocket.MessageText, placeMsg); err != nil {
|
||||
t.Fatalf("write PlaceShips c2: %v", err)
|
||||
}
|
||||
|
||||
// Game starts: Player 1 was first, so c1 gets StateYourTurn (4), c2 gets StateWaitOpponentTurn (5)
|
||||
v1 = readMsg(t, c1)
|
||||
if v1[0] != float64(protocol.OpState) || v1[1] != float64(3) || v1[2] != float64(protocol.StateYourTurn) {
|
||||
t.Fatalf("c1 got %v, want StateYourTurn (4)", v1)
|
||||
}
|
||||
v2 = readMsg(t, c2)
|
||||
if v2[0] != float64(protocol.OpState) || v2[1] != float64(3) || v2[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||
t.Fatalf("c2 got %v, want StateWaitOpponentTurn (5)", v2)
|
||||
}
|
||||
|
||||
_ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3))
|
||||
_ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3))
|
||||
|
||||
// Check that the match on the server has the placements stored
|
||||
match := mgr.GetMatch("m-ws-placeships")
|
||||
if match == nil {
|
||||
t.Fatal("match not found on manager")
|
||||
}
|
||||
if len(match.Ships(0)) != 10 || len(match.Ships(1)) != 10 {
|
||||
t.Fatalf("ships not stored on match: p1=%d, p2=%d", len(match.Ships(0)), len(match.Ships(1)))
|
||||
}
|
||||
if _, ok := match.Board(0); !ok {
|
||||
t.Fatal("p1 board not placed")
|
||||
}
|
||||
if _, ok := match.Board(1); !ok {
|
||||
t.Fatal("p2 board not placed")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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_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
|
||||
|
||||
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? }
|
||||
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
|
||||
def avatar_content_type
|
||||
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
|
||||
|
||||
# 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?
|
||||
|
||||
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.
|
||||
|
||||
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|
|
||||
t.bigint "blob_id", 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
|
||||
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|
|
||||
t.datetime "created_at", null: false
|
||||
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_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"
|
||||
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
|
||||
@@ -97,4 +97,33 @@ RSpec.describe User, type: :model do
|
||||
expect(user.update(nickname: "new_nickname")).to be true
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user