implement game turn logic

This commit is contained in:
2026-11-02 21:24:32 +03:00
parent 153dc74ff6
commit 189b027a11
7 changed files with 896 additions and 13 deletions
+47 -5
View File
@@ -34,6 +34,8 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
| 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)
@@ -67,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)
@@ -92,10 +98,13 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
игрок, сдавший корабли, получает состояние `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` <= указанного).
@@ -153,6 +162,39 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
- Если первым расстановку завершил игрок 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` либо указанная клетка уже была поражена ранее. Право хода остаётся у игрока. |
## Коды закрытия соединения
| Код | Значение |
+201 -7
View File
@@ -23,6 +23,7 @@ var (
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.
@@ -99,9 +100,11 @@ type PlayerSlot struct {
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
ships []protocol.Ship
board Board
shipsPlaced bool
struck [BoardSize][BoardSize]bool
shipsRemaining int
}
// Match is the state of a single game of sea battle.
@@ -156,6 +159,26 @@ func (m *Match) Board(slotIdx int) (Board, bool) {
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]:
@@ -395,11 +418,119 @@ func (m *Manager) PlaceShips(ctx context.Context, match *Match, slotIdx int, shi
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()
@@ -433,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.
@@ -483,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()
@@ -499,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 {
@@ -529,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)
+340
View File
@@ -644,3 +644,343 @@ func TestPlaceShipsBeforeMatchStarts(t *testing.T) {
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)
}
}
+54 -1
View File
@@ -12,8 +12,10 @@
// 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
@@ -36,6 +38,8 @@ const (
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.
@@ -89,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).
@@ -275,3 +289,42 @@ func BuildPlaceShips(ships []Ship) []byte {
}
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})
}
@@ -262,3 +262,90 @@ func TestValidateShips(t *testing.T) {
}
})
}
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")
}
})
}
+10
View File
@@ -291,6 +291,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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)
+157
View File
@@ -429,3 +429,160 @@ func TestPlaceShipsOverWebSocket(t *testing.T) {
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)
}
}