diff --git a/game-server/README.md b/game-server/README.md index 5b9ae1e..c5d9cd6 100644 --- a/game-server/README.md +++ b/game-server/README.md @@ -34,6 +34,8 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server | 3 | S -> C | STATE | `[3, , ]` | | 4 | C <-> S | ACK | `[4, ]` | | 5 | C -> S | PLACE_SHIPS | `[5, ship0_orientation, ship0_x, ship0_y, ...]` | +| 6 | C -> S | FIRE | `[6, , ]` | +| 7 | S -> C | FIRE_RESULT | `[7, , ]` | ### JOIN (1) @@ -67,11 +69,15 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server Партия завершена. Формат: `[2, , , ""]`. -Коды завершения: +Коды завершения (отсылаются с точки зрения получателя): -| Код | Причина | -|-----|---------| -| -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, ]`; 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, , ] +``` + +Коды результатов выстрела: + +| Код | Результат | Описание | +|-----|-----------|----------| +| 1 | `miss` (промах) | Выстрел в пустую клетку поля соперника. Клетка помечается как поражённая, ход переходит сопернику. | +| 2 | `hit` (простое попадание) | Попадание в палубу корабля соперника, но корабль ещё имеет уцелевшие палубы. Клетка помечается как поражённая, право хода остаётся у стрелявшего игрока. | +| 3 | `sunk` (корабль потоплен) | Корабль полностью уничтожен последним выстрелом. Все палубы корабля и все окружающие его клетки (включая диагональные) помечаются как поражённые. Если все корабли соперника уничтожены, партия завершается победой (рассылается сообщение END и соединение сразу закрывается). В противном случае право хода остаётся у стрелявшего игрока. | +| 4 | `invalid_position` (неверная позиция) | Координаты выходят за пределы поля `10x10` либо указанная клетка уже была поражена ранее. Право хода остаётся у игрока. | + ## Коды закрытия соединения | Код | Значение | diff --git a/game-server/internal/game/manager.go b/game-server/internal/game/manager.go index c319412..77928c1 100644 --- a/game-server/internal/game/manager.go +++ b/game-server/internal/game/manager.go @@ -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) diff --git a/game-server/internal/game/manager_test.go b/game-server/internal/game/manager_test.go index 7b3ae2c..ed716d1 100644 --- a/game-server/internal/game/manager_test.go +++ b/game-server/internal/game/manager_test.go @@ -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) + } +} + diff --git a/game-server/internal/protocol/protocol.go b/game-server/internal/protocol/protocol.go index b558f22..a5d5737 100644 --- a/game-server/internal/protocol/protocol.go +++ b/game-server/internal/protocol/protocol.go @@ -12,8 +12,10 @@ // 3 S->C STATE [3, , ] // 4 C<->S ACK [4, ] // 5 C->S PLACE_SHIPS [5, ship0_orientation, ship0_x, ship0_y, ...] +// 6 C->S FIRE [6, , ] +// 7 S->C FIRE_RESULT [7, , ] // -// 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}) +} + diff --git a/game-server/internal/protocol/protocol_test.go b/game-server/internal/protocol/protocol_test.go index fbed680..c987f78 100644 --- a/game-server/internal/protocol/protocol_test.go +++ b/game-server/internal/protocol/protocol_test.go @@ -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") + } + }) +} + diff --git a/game-server/internal/ws/handler.go b/game-server/internal/ws/handler.go index 2e0d9ef..cd7d2e9 100644 --- a/game-server/internal/ws/handler.go +++ b/game-server/internal/ws/handler.go @@ -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) diff --git a/game-server/internal/ws/handler_test.go b/game-server/internal/ws/handler_test.go index 4194e30..b58e7ee 100644 --- a/game-server/internal/ws/handler_test.go +++ b/game-server/internal/ws/handler_test.go @@ -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) + } +} + +