From 6392c2ef030078a2cda336390bdd77d406264b17 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Tue, 27 Oct 2026 17:31:41 +0300 Subject: [PATCH] add PlaceShips message --- game-server/README.md | 74 +++++-- game-server/internal/game/manager.go | 113 +++++++++- game-server/internal/game/manager_test.go | 190 +++++++++++++++++ game-server/internal/protocol/protocol.go | 164 ++++++++++++++- .../internal/protocol/protocol_test.go | 193 ++++++++++++++++++ game-server/internal/ws/handler.go | 14 ++ game-server/internal/ws/handler_test.go | 122 ++++++++++- 7 files changed, 843 insertions(+), 27 deletions(-) diff --git a/game-server/README.md b/game-server/README.md index 6c0abb7..5b9ae1e 100644 --- a/game-server/README.md +++ b/game-server/README.md @@ -27,12 +27,13 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server Все сообщения — JSON-массивы, первый элемент — числовой опкод. -| Опкод | Направление | Название | Формат | -|-------|-------------|----------|--------| -| 1 | C -> S | JOIN | `[1, "", ""]` | -| 2 | S -> C | END | `[2, , , ""]` | -| 3 | S -> C | STATE | `[3, , ]` | -| 4 | C <-> S | ACK | `[4, ]` | +| Опкод | Направление | Название | Формат | +|-------|-------------|-------------|--------| +| 1 | C -> S | JOIN | `[1, "", ""]` | +| 2 | S -> C | END | `[2, , , ""]` | +| 3 | S -> C | STATE | `[3, , ]` | +| 4 | C <-> S | ACK | `[4, ]` | +| 5 | C -> S | PLACE_SHIPS | `[5, ship0_orientation, ship0_x, ship0_y, ...]` | ### JOIN (1) @@ -85,8 +86,12 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server | 4 | ваш ход | | 5 | ожидайте ход соперника | -Сейчас рассылается только состояние `1` — при старте партии (когда оба -игрока впервые присоединились). +Смена состояний: +- Состояние `1` рассылается обоим игрокам при старте партии (когда оба игрока впервые присоединились). +- При получении расстановки кораблей от одного из игроков партия переходит в состояние ожидания соперника: + игрок, сдавший корабли, получает состояние `3` («ожидайте готовности соперника»), а его противник — состояние `2` («соперник готов»). +- Когда расстановку сдаёт второй игрок, игра переходит в фазу ходов: игрок, завершивший расстановку первым, + получает состояние `4` («ваш ход»), а второй игрок — состояние `5` («ожидайте ход соперника»). ### ACK (4) @@ -103,6 +108,51 @@ JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server Направление S->C зарезервировано для подтверждения будущих сообщений клиента (у которых пока нет номеров). +### PLACE_SHIPS (5) + +Сообщение расстановки кораблей от клиента серверу. Принимается только от игрока, +от которого ожидается расстановка в текущем состоянии партии (`StatePlacingShips` для +обоих игроков; `StatePlayer1Ready` — только для игрока 2; `StatePlayer2Ready` — только +для игрока 1). Повторные сообщения от уже расставившегося игрока или сообщения в фазе +ходов отклоняются. + +Формат сообщения: +```json +[5, ship0_orientation, ship0_x, ship0_y, ship1_orientation, ship1_x, ship1_y, ..., ship9_orientation, ship9_x, ship9_y] +``` + +Сообщение содержит ровно 30 аргументов — по 3 аргумента на каждый из 10 кораблей +в порядке возрастания размера: +- 4 однопалубных корабля (размер 1, индексы 0..3) +- 3 двухпалубных корабля (размер 2, индексы 4..6) +- 2 трёхпалубных корабля (размер 3, индексы 7..8) +- 1 четырёхпалубный корабль (размер 4, индекс 9) + +Каждый корабль описывается 3-кортежем `(orientation, x, y)`: +- `orientation` (boolean): `false` — горизонтальная ориентация (корабль продолжается + вправо по оси X от начальной клетки `(x, y)`: клетки `(x + i, y)` для `i = 0..size-1`), + `true` — вертикальная ориентация (корабль продолжается вниз по оси Y от начальной + клетки `(x, y)`: клетки `(x, y + i)` для `i = 0..size-1`). Для однопалубных кораблей + допустимо любое булево значение. +- `x` (integer): координата X начала корабля (`0 <= x <= 9`). +- `y` (integer): координата Y начала корабля (`0 <= y <= 9`). + +Правила валидации расстановки: +1. Количество кораблей строго 10, размеры строго соответствуют схеме 4x1, 3x2, 2x3, 1x4. +2. Все клетки каждого корабля должны лежать строго в пределах игрового поля 10x10 + (`0 <= x < 10` и `0 <= y < 10`). +3. Корабли не должны перекрываться и не должны соприкасаться сторонами или углами: + между любыми двумя различными кораблями во всех направлениях (включая диагонали) + всегда должен оставаться зазор минимум в 1 клетку. + +При получении валидного сообщения сервер сохраняет расстановку в структуре матча +(в виде сетки 10x10 с идентификаторами кораблей и списка кораблей) и переводит партию +в следующее состояние: +- Если первым расстановку завершил игрок 1: + `StatePlacingShips` (1) -> `StatePlayer1Ready` (2) -> `StatePlayer1Turn` (4). +- Если первым расстановку завершил игрок 2: + `StatePlacingShips` (1) -> `StatePlayer2Ready` (3) -> `StatePlayer2Turn` (5). + ## Коды закрытия соединения | Код | Значение | @@ -166,13 +216,13 @@ internal/ws/ — websocket-эндпоинт: апгрейд, JOIN, go test -race ./... ``` -Покрыты: валидация токенов, разбор/сборка сообщений, сценарии менеджера -(ожидание, старт, таймаут, повторный вход, замена соединения, ACK) и -сквозные интеграционные тесты через настоящий websocket. +Покрыты: валидация токенов, разбор/сборка сообщений (включая расстановку кораблей), +сценарии менеджера (ожидание, старт, таймаут, повторный вход, замена соединения, ACK, +расстановка кораблей с переходами состояний) и сквозные интеграционные тесты через настоящий websocket. ## TODO (следующие этапы) -- игровая логика (расстановка кораблей, ходы) и остальные состояния; +- игровая логика стрельбы/ходов (расстановка кораблей и переходы к фазе ходов уже реализованы); - завершение партии по итогам игры с персонифицированными кодами победы/поражения; - репорт итогов партии на main-server (реестр завершённых партий уже diff --git a/game-server/internal/game/manager.go b/game-server/internal/game/manager.go index a423476..c319412 100644 --- a/game-server/internal/game/manager.go +++ b/game-server/internal/game/manager.go @@ -17,10 +17,34 @@ import ( // that has already ended. The stored END message (if any) is replayed to the // connection before Join returns. var ( - ErrMatchFinished = errors.New("match already finished") - ErrInvalidSeq = errors.New("invalid seq") + ErrMatchFinished = errors.New("match already finished") + ErrInvalidSeq = errors.New("invalid seq") + ErrMatchNotStarted = errors.New("match not started") + ErrUnexpectedState = errors.New("unexpected state for placing ships") + ErrShipsAlreadyPlaced = errors.New("ships already placed") + ErrInvalidSlot = errors.New("invalid slot index") ) +// BoardSize is the width and height of the Sea Battle board. +const BoardSize = protocol.BoardSize + +// Board represents a 10x10 Sea Battle grid. +// 0 indicates water/empty cell. +// 1..10 indicates a ship (ship index 0..9 + 1). +type Board [BoardSize][BoardSize]int8 + +// NewBoard constructs a Board from validated ships. +func NewBoard(ships []protocol.Ship) Board { + var b Board + for i, ship := range ships { + id := int8(i + 1) + for _, cell := range ship.Cells() { + b[cell.Y][cell.X] = id + } + } + return b +} + // Server-side match states. const ( StatePlacingShips = 1 @@ -74,6 +98,10 @@ type PlayerSlot struct { unacked []queuedMessage lastState int // last state sent to this player, 0 if none everConnected bool // true after the first successful join + + ships []protocol.Ship + board Board + shipsPlaced bool } // Match is the state of a single game of sea battle. @@ -105,6 +133,29 @@ func (m *Match) State() int { return m.state } +// Ships returns a copy of the ships placed by the player in slotIdx, +// or nil if ships have not been placed yet. +func (m *Match) Ships(slotIdx int) []protocol.Ship { + m.mu.Lock() + defer m.mu.Unlock() + if slotIdx < 0 || slotIdx >= len(m.slots) || !m.slots[slotIdx].shipsPlaced { + return nil + } + res := make([]protocol.Ship, len(m.slots[slotIdx].ships)) + copy(res, m.slots[slotIdx].ships) + return res +} + +// Board returns the board of the player in slotIdx and whether ships have been placed. +func (m *Match) Board(slotIdx int) (Board, bool) { + m.mu.Lock() + defer m.mu.Unlock() + if slotIdx < 0 || slotIdx >= len(m.slots) { + return Board{}, false + } + return m.slots[slotIdx].board, m.slots[slotIdx].shipsPlaced +} + func (m *Match) slotIndex(userID int64) int { switch userID { case m.playerIDs[0]: @@ -291,6 +342,64 @@ func (m *Manager) Ack(match *Match, idx int, seq int64) error { return nil } +// PlaceShips validates and stores the player's ship placement, advancing to the next match state. +// The state order depends on which player completed the placement first: +// - If Player 1 is first: StatePlacingShips -> StatePlayer1Ready -> StatePlayer1Turn +// - If Player 2 is first: StatePlacingShips -> StatePlayer2Ready -> StatePlayer2Turn +func (m *Manager) PlaceShips(ctx context.Context, match *Match, slotIdx int, ships []protocol.Ship) error { + if err := protocol.ValidateShips(ships); err != nil { + return err + } + + match.mu.Lock() + defer match.mu.Unlock() + + if match.ended { + return ErrMatchFinished + } + if !match.started { + return ErrMatchNotStarted + } + if slotIdx < 0 || slotIdx >= len(match.slots) { + return ErrInvalidSlot + } + + slot := match.slots[slotIdx] + if slot.shipsPlaced { + return ErrShipsAlreadyPlaced + } + + var nextState int + switch match.state { + case StatePlacingShips: + if slotIdx == 0 { + nextState = StatePlayer1Ready + } else { + nextState = StatePlayer2Ready + } + case StatePlayer1Ready: + if slotIdx != 1 { + return ErrUnexpectedState + } + nextState = StatePlayer1Turn + case StatePlayer2Ready: + if slotIdx != 0 { + return ErrUnexpectedState + } + nextState = StatePlayer2Turn + default: + return ErrUnexpectedState + } + + slot.ships = make([]protocol.Ship, len(ships)) + copy(slot.ships, ships) + slot.board = NewBoard(ships) + slot.shipsPlaced = true + + m.setStateLocked(ctx, match, nextState) + return nil +} + // SetState moves the match into a new server-side state and notifies both players. func (m *Manager) SetState(ctx context.Context, match *Match, state int) { match.mu.Lock() diff --git a/game-server/internal/game/manager_test.go b/game-server/internal/game/manager_test.go index 694932e..7b3ae2c 100644 --- a/game-server/internal/game/manager_test.go +++ b/game-server/internal/game/manager_test.go @@ -454,3 +454,193 @@ func mustMatch(t *testing.T, m *Manager, id string) *Match { } return match } + +func validTestShips() []protocol.Ship { + return []protocol.Ship{ + // 4 size 1 + {Orientation: protocol.OrientationHorizontal, X: 9, Y: 4, Size: 1}, + {Orientation: protocol.OrientationVertical, X: 7, Y: 4, Size: 1}, + {Orientation: protocol.OrientationHorizontal, X: 5, Y: 4, Size: 1}, + {Orientation: protocol.OrientationHorizontal, X: 3, Y: 4, Size: 1}, + // 3 size 2 + {Orientation: protocol.OrientationHorizontal, X: 0, Y: 4, Size: 2}, + {Orientation: protocol.OrientationHorizontal, X: 7, Y: 2, Size: 2}, + {Orientation: protocol.OrientationHorizontal, X: 4, Y: 2, Size: 2}, + // 2 size 3 + {Orientation: protocol.OrientationHorizontal, X: 0, Y: 2, Size: 3}, + {Orientation: protocol.OrientationHorizontal, X: 5, Y: 0, Size: 3}, + // 1 size 4 + {Orientation: protocol.OrientationHorizontal, X: 0, Y: 0, Size: 4}, + } +} + +func TestPlaceShipsP1First(t *testing.T) { + m := NewManager(time.Minute) + gc := claims(time.Now().Add(time.Hour)) + c1, c2 := &fakeConn{}, &fakeConn{} + + match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID) + if err != nil { + t.Fatalf("join p1: %v", err) + } + if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil { + t.Fatalf("join p2: %v", err) + } + if match.State() != StatePlacingShips { + t.Fatalf("state = %d, want StatePlacingShips", match.State()) + } + + ships := validTestShips() + + // Player 1 places ships first + if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil { + t.Fatalf("p1 place ships: %v", err) + } + if match.State() != StatePlayer1Ready { + t.Fatalf("state = %d, want StatePlayer1Ready (2)", match.State()) + } + + // Verify stored ships and board for p1 + p1Ships := match.Ships(0) + if len(p1Ships) != len(ships) { + t.Fatalf("got %d ships, want %d", len(p1Ships), len(ships)) + } + p1Board, placed := match.Board(0) + if !placed { + t.Fatal("expected p1 board placed to be true") + } + // Check that ship 9 (size 4 at 0,0 horizontal) is in p1 board with id 10 + for x := 0; x < 4; x++ { + if p1Board[0][x] != 10 { + t.Fatalf("p1Board[0][%d] = %d, want 10", x, p1Board[0][x]) + } + } + + // Verify states delivered to p1 and p2 + p1Msgs, p2Msgs := c1.messages(), c2.messages() + if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) { + t.Fatalf("p1 got state %v, want StateWaitOpponentReady (3)", v) + } + if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) { + t.Fatalf("p2 got state %v, want StateOpponentReady (2)", v) + } + + // Player 1 cannot place ships again + if err := m.PlaceShips(context.Background(), match, 0, ships); err == nil { + t.Fatal("expected error when player 1 places ships again") + } + + // Player 2 places ships + if err := m.PlaceShips(context.Background(), match, 1, ships); err != nil { + t.Fatalf("p2 place ships: %v", err) + } + if match.State() != StatePlayer1Turn { + t.Fatalf("state = %d, want StatePlayer1Turn (4)", match.State()) + } + + // Verify states delivered to p1 and p2 after game starts + p1Msgs, p2Msgs = c1.messages(), c2.messages() + if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateYourTurn) { + t.Fatalf("p1 got state %v, want StateYourTurn (4)", v) + } + if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) { + t.Fatalf("p2 got state %v, want StateWaitOpponentTurn (5)", v) + } + + // Subsequent PlaceShips calls are rejected + if err := m.PlaceShips(context.Background(), match, 1, ships); err == nil { + t.Fatal("expected error when match is in turn phase") + } +} + +func TestPlaceShipsP2First(t *testing.T) { + m := NewManager(time.Minute) + gc := claims(time.Now().Add(time.Hour)) + c1, c2 := &fakeConn{}, &fakeConn{} + + match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID) + if err != nil { + t.Fatalf("join p1: %v", err) + } + if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil { + t.Fatalf("join p2: %v", err) + } + + ships := validTestShips() + + // Player 2 places ships first + if err := m.PlaceShips(context.Background(), match, 1, ships); err != nil { + t.Fatalf("p2 place ships: %v", err) + } + if match.State() != StatePlayer2Ready { + t.Fatalf("state = %d, want StatePlayer2Ready (3)", match.State()) + } + + // Verify states delivered to p1 and p2 + p1Msgs, p2Msgs := c1.messages(), c2.messages() + if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) { + t.Fatalf("p1 got state %v, want StateOpponentReady (2)", v) + } + if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) { + t.Fatalf("p2 got state %v, want StateWaitOpponentReady (3)", v) + } + + // Player 1 places ships + if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil { + t.Fatalf("p1 place ships: %v", err) + } + if match.State() != StatePlayer2Turn { + t.Fatalf("state = %d, want StatePlayer2Turn (5)", match.State()) + } + + // Player 2 finished first, so Player 2 gets first turn! + p1Msgs, p2Msgs = c1.messages(), c2.messages() + if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) { + t.Fatalf("p1 got state %v, want StateWaitOpponentTurn (5)", v) + } + if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateYourTurn) { + t.Fatalf("p2 got state %v, want StateYourTurn (4)", v) + } +} + +func TestPlaceShipsValidationFailure(t *testing.T) { + m := NewManager(time.Minute) + gc := claims(time.Now().Add(time.Hour)) + c1, c2 := &fakeConn{}, &fakeConn{} + + match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID) + if err != nil { + t.Fatalf("join p1: %v", err) + } + if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil { + t.Fatalf("join p2: %v", err) + } + + invalidShips := validTestShips() + invalidShips[0].X = invalidShips[1].X // overlapping ships + + if err := m.PlaceShips(context.Background(), match, 0, invalidShips); err == nil { + t.Fatal("expected validation error") + } + if match.State() != StatePlacingShips { + t.Fatalf("state changed to %d on error, want StatePlacingShips", match.State()) + } + if match.Ships(0) != nil { + t.Fatal("expected nil ships for p1 after failed placement") + } +} + +func TestPlaceShipsBeforeMatchStarts(t *testing.T) { + m := NewManager(time.Minute) + gc := claims(time.Now().Add(time.Hour)) + c1 := &fakeConn{} + + match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID) + if err != nil { + t.Fatalf("join p1: %v", err) + } + + if err := m.PlaceShips(context.Background(), match, 0, validTestShips()); err != ErrMatchNotStarted { + t.Fatalf("got err = %v, want ErrMatchNotStarted", err) + } +} diff --git a/game-server/internal/protocol/protocol.go b/game-server/internal/protocol/protocol.go index 942741b..b558f22 100644 --- a/game-server/internal/protocol/protocol.go +++ b/game-server/internal/protocol/protocol.go @@ -7,10 +7,11 @@ // // Opcodes: // -// 1 C->S JOIN [1, "", ""] -// 2 S->C END [2, , , ""] -// 3 S->C STATE [3, , ] -// 4 C<->S ACK [4, ] +// 1 C->S JOIN [1, "", ""] +// 2 S->C END [2, , , ""] +// 3 S->C STATE [3, , ] +// 4 C<->S ACK [4, ] +// 5 C->S PLACE_SHIPS [5, ship0_orientation, ship0_x, ship0_y, ...] // // Every state-changing server->client message (END, STATE) carries a // per-player monotonically increasing sequence number. The client confirms @@ -30,12 +31,54 @@ import ( // Opcodes. const ( - OpJoin = 1 // C->S: join a match - OpEnd = 2 // S->C: match ended - OpState = 3 // S->C: game state transition - OpAck = 4 // C<->S: delivery acknowledgement + OpJoin = 1 // C->S: join a match + OpEnd = 2 // S->C: match ended + OpState = 3 // S->C: game state transition + OpAck = 4 // C<->S: delivery acknowledgement + OpPlaceShips = 5 // C->S: ship placement ) +// Board and ship configuration constants. +const ( + BoardSize = 10 + ShipCount = 10 + ShipArgs = 30 // 10 ships * 3 args each + + OrientationHorizontal = false + OrientationVertical = true +) + +// ShipSizes defines the expected sizes of the 10 ships in order: +// 4 ships of size 1, 3 ships of size 2, 2 ships of size 3, 1 ship of size 4. +var ShipSizes = [ShipCount]int{1, 1, 1, 1, 2, 2, 2, 3, 3, 4} + +// Cell represents coordinates on the 10x10 board. +type Cell struct { + X int + Y int +} + +// Ship represents a single ship placement on the board. +type Ship struct { + Orientation bool // false: horizontal, true: vertical + X int // column (0-9) + Y int // row (0-9) + Size int // length (1-4) +} + +// Cells returns all board cells occupied by the ship. +func (s Ship) Cells() []Cell { + cells := make([]Cell, s.Size) + for i := 0; i < s.Size; i++ { + if s.Orientation { + cells[i] = Cell{X: s.X, Y: s.Y + i} + } else { + cells[i] = Cell{X: s.X + i, Y: s.Y} + } + } + return cells +} + // Game state codes. States are always sent from the receiver's point of // view: a player never learns whether they are "player 1" or "player 2". const ( @@ -103,6 +146,98 @@ func ParseAck(args []json.RawMessage) (seq int64, err error) { return seq, nil } +// ParsePlaceShips extracts the 10 ships from PLACE_SHIPS arguments. +// args must contain 30 elements: (orientation, x, y) for each of the 10 ships. +// The ships correspond to increasing sizes: 4 size 1, 3 size 2, 2 size 3, 1 size 4. +func ParsePlaceShips(args []json.RawMessage) ([]Ship, error) { + if len(args) != ShipArgs { + return nil, fmt.Errorf("PLACE_SHIPS expects %d arguments, got %d", ShipArgs, len(args)) + } + + ships := make([]Ship, ShipCount) + for i := 0; i < ShipCount; i++ { + base := i * 3 + + var orientation bool + if err := json.Unmarshal(args[base], &orientation); err != nil { + return nil, fmt.Errorf("ship %d orientation is not a boolean: %w", i, err) + } + + var x int + if err := json.Unmarshal(args[base+1], &x); err != nil { + return nil, fmt.Errorf("ship %d x is not an integer: %w", i, err) + } + + var y int + if err := json.Unmarshal(args[base+2], &y); err != nil { + return nil, fmt.Errorf("ship %d y is not an integer: %w", i, err) + } + + ships[i] = Ship{ + Orientation: orientation, + X: x, + Y: y, + Size: ShipSizes[i], + } + } + + return ships, nil +} + +// ValidateShips validates the ship placement on the 10x10 board: +// - exactly 10 ships with sizes [1, 1, 1, 1, 2, 2, 2, 3, 3, 4] +// - all ship cells are within bounds (0 <= x < 10, 0 <= y < 10) +// - no ships overlap or touch each other in any direction (including diagonals), +// ensuring a gap of at least 1 cell between any two ships. +func ValidateShips(ships []Ship) error { + if len(ships) != ShipCount { + return fmt.Errorf("expected %d ships, got %d", ShipCount, len(ships)) + } + + var grid [BoardSize][BoardSize]int + for y := 0; y < BoardSize; y++ { + for x := 0; x < BoardSize; x++ { + grid[y][x] = -1 + } + } + + for i, ship := range ships { + expectedSize := ShipSizes[i] + if ship.Size != expectedSize { + return fmt.Errorf("ship %d: expected size %d, got %d", i, expectedSize, ship.Size) + } + + cells := ship.Cells() + // 1. Bounds check + for _, c := range cells { + if c.X < 0 || c.X >= BoardSize || c.Y < 0 || c.Y >= BoardSize { + return fmt.Errorf("ship %d out of bounds at (%d, %d)", i, c.X, c.Y) + } + } + + // 2. Collision / gap check against already placed ships + for _, c := range cells { + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + nx, ny := c.X+dx, c.Y+dy + if nx >= 0 && nx < BoardSize && ny >= 0 && ny < BoardSize { + if other := grid[ny][nx]; other != -1 { + return fmt.Errorf("ship %d at (%d, %d) touches or overlaps with ship %d at (%d, %d)", i, c.X, c.Y, other, nx, ny) + } + } + } + } + } + + // 3. Place ship on the grid + for _, c := range cells { + grid[c.Y][c.X] = i + } + } + + return nil +} + func build(v []any) []byte { data, err := json.Marshal(v) if err != nil { @@ -127,3 +262,16 @@ func BuildState(seq int64, state int) []byte { func BuildAck(seq int64) []byte { return build([]any{OpAck, seq}) } + +// BuildPlaceShips builds a PLACE_SHIPS message from a slice of ships: +// [5, ship0_orientation, ship0_x, ship0_y, ship1_orientation, ship1_x, ship1_y, ...] +func BuildPlaceShips(ships []Ship) []byte { + v := make([]any, 1+len(ships)*3) + v[0] = OpPlaceShips + for i, s := range ships { + v[1+i*3] = s.Orientation + v[1+i*3+1] = s.X + v[1+i*3+2] = s.Y + } + return build(v) +} diff --git a/game-server/internal/protocol/protocol_test.go b/game-server/internal/protocol/protocol_test.go index c24131c..fbed680 100644 --- a/game-server/internal/protocol/protocol_test.go +++ b/game-server/internal/protocol/protocol_test.go @@ -69,3 +69,196 @@ func TestParseAck(t *testing.T) { t.Fatalf("seq = %d, want 12", seq) } } + +func validTestShips() []Ship { + return []Ship{ + // 4 size 1 + {Orientation: OrientationHorizontal, X: 9, Y: 4, Size: 1}, + {Orientation: OrientationVertical, X: 7, Y: 4, Size: 1}, + {Orientation: OrientationHorizontal, X: 5, Y: 4, Size: 1}, + {Orientation: OrientationHorizontal, X: 3, Y: 4, Size: 1}, + // 3 size 2 + {Orientation: OrientationHorizontal, X: 0, Y: 4, Size: 2}, + {Orientation: OrientationHorizontal, X: 7, Y: 2, Size: 2}, + {Orientation: OrientationHorizontal, X: 4, Y: 2, Size: 2}, + // 2 size 3 + {Orientation: OrientationHorizontal, X: 0, Y: 2, Size: 3}, + {Orientation: OrientationHorizontal, X: 5, Y: 0, Size: 3}, + // 1 size 4 + {Orientation: OrientationHorizontal, X: 0, Y: 0, Size: 4}, + } +} + +func TestPlaceShipsRoundtrip(t *testing.T) { + ships := validTestShips() + data := BuildPlaceShips(ships) + + opcode, args, err := ParseMessage(data) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + if opcode != OpPlaceShips { + t.Fatalf("opcode = %d, want %d", opcode, OpPlaceShips) + } + + parsed, err := ParsePlaceShips(args) + if err != nil { + t.Fatalf("ParsePlaceShips: %v", err) + } + if len(parsed) != len(ships) { + t.Fatalf("got %d ships, want %d", len(parsed), len(ships)) + } + for i := range ships { + if parsed[i] != ships[i] { + t.Fatalf("ship %d = %+v, want %+v", i, parsed[i], ships[i]) + } + } + + if err := ValidateShips(parsed); err != nil { + t.Fatalf("ValidateShips: %v", err) + } +} + +func TestParsePlaceShipsErrors(t *testing.T) { + t.Run("wrong argument count", func(t *testing.T) { + args := make([]json.RawMessage, 29) + for i := range args { + args[i] = json.RawMessage(`0`) + } + if _, err := ParsePlaceShips(args); err == nil { + t.Fatal("expected error for 29 arguments") + } + }) + + t.Run("orientation not boolean", func(t *testing.T) { + ships := validTestShips() + raw := make([]any, 31) + raw[0] = OpPlaceShips + for i, s := range ships { + raw[1+i*3] = s.Orientation + raw[1+i*3+1] = s.X + raw[1+i*3+2] = s.Y + } + raw[1] = "invalid_orientation" // string instead of bool + data, _ := json.Marshal(raw) + _, args, _ := ParseMessage(data) + if _, err := ParsePlaceShips(args); err == nil { + t.Fatal("expected error for non-boolean orientation") + } + }) + + t.Run("x not integer", func(t *testing.T) { + ships := validTestShips() + raw := make([]any, 31) + raw[0] = OpPlaceShips + for i, s := range ships { + raw[1+i*3] = s.Orientation + raw[1+i*3+1] = s.X + raw[1+i*3+2] = s.Y + } + raw[2] = 1.5 // float instead of integer + data, _ := json.Marshal(raw) + _, args, _ := ParseMessage(data) + if _, err := ParsePlaceShips(args); err == nil { + t.Fatal("expected error for float x") + } + }) + + t.Run("y not integer", func(t *testing.T) { + ships := validTestShips() + raw := make([]any, 31) + raw[0] = OpPlaceShips + for i, s := range ships { + raw[1+i*3] = s.Orientation + raw[1+i*3+1] = s.X + raw[1+i*3+2] = s.Y + } + raw[3] = "zero" // string instead of integer + data, _ := json.Marshal(raw) + _, args, _ := ParseMessage(data) + if _, err := ParsePlaceShips(args); err == nil { + t.Fatal("expected error for string y") + } + }) +} + +func TestValidateShips(t *testing.T) { + t.Run("valid placement passes", func(t *testing.T) { + ships := validTestShips() + if err := ValidateShips(ships); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } + }) + + t.Run("wrong ship count", func(t *testing.T) { + ships := validTestShips()[:9] + if err := ValidateShips(ships); err == nil { + t.Fatal("expected error for 9 ships") + } + }) + + t.Run("wrong ship size", func(t *testing.T) { + ships := validTestShips() + ships[0].Size = 2 // ship 0 must have size 1 + if err := ValidateShips(ships); err == nil { + t.Fatal("expected error for wrong ship size") + } + }) + + t.Run("negative coordinate", func(t *testing.T) { + ships := validTestShips() + ships[0].X = -1 + if err := ValidateShips(ships); err == nil { + t.Fatal("expected error for negative X") + } + }) + + t.Run("out of bounds horizontal", func(t *testing.T) { + ships := validTestShips() + // Ship 9 has size 4; at X=7, cells are 7, 8, 9, 10 + ships[9].X = 7 + if err := ValidateShips(ships); err == nil { + t.Fatal("expected error for horizontal ship sticking out of bounds") + } + }) + + t.Run("out of bounds vertical", func(t *testing.T) { + ships := validTestShips() + // Ship 9 has size 4, vertical at Y=7: cells are 7, 8, 9, 10 + ships[9].Orientation = OrientationVertical + ships[9].Y = 7 + if err := ValidateShips(ships); err == nil { + t.Fatal("expected error for vertical ship sticking out of bounds") + } + }) + + t.Run("overlapping ships", func(t *testing.T) { + ships := validTestShips() + // Place ship 0 at exact same cell as ship 1 + ships[0].X = ships[1].X + ships[0].Y = ships[1].Y + if err := ValidateShips(ships); err == nil { + t.Fatal("expected error for overlapping ships") + } + }) + + t.Run("orthogonal touching", func(t *testing.T) { + ships := validTestShips() + // Ship 0 is at (9, 4), place ship 1 at (9, 5) -> touches orthogonally + ships[1].X = 9 + ships[1].Y = 5 + if err := ValidateShips(ships); err == nil { + t.Fatal("expected error for orthogonally touching ships") + } + }) + + t.Run("diagonal touching", func(t *testing.T) { + ships := validTestShips() + // Ship 0 is at (9, 4), place ship 1 at (8, 5) -> touches diagonally + ships[1].X = 8 + ships[1].Y = 5 + if err := ValidateShips(ships); err == nil { + t.Fatal("expected error for diagonally touching ships") + } + }) +} diff --git a/game-server/internal/ws/handler.go b/game-server/internal/ws/handler.go index ce84181..2e0d9ef 100644 --- a/game-server/internal/ws/handler.go +++ b/game-server/internal/ws/handler.go @@ -277,6 +277,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.log.Debug("invalid ACK seq", "match", match.ID, "user", uc.UserID, "seq", seq, "err", err) continue } + case protocol.OpPlaceShips: + ships, err := protocol.ParsePlaceShips(args) + if err != nil { + h.log.Debug("malformed PlaceShips", "match", match.ID, "user", uc.UserID, "err", err) + continue + } + if err := protocol.ValidateShips(ships); err != nil { + h.log.Debug("invalid ship placement", "match", match.ID, "user", uc.UserID, "err", err) + continue + } + if err := h.Manager.PlaceShips(ctx, match, slotIdx, ships); err != nil { + h.log.Debug("place ships rejected", "match", match.ID, "user", uc.UserID, "err", err) + continue + } default: // Unknown opcodes are ignored for forward compatibility. h.log.Debug("unknown opcode", "match", match.ID, "user", uc.UserID, "opcode", opcode) diff --git a/game-server/internal/ws/handler_test.go b/game-server/internal/ws/handler_test.go index 3519c71..4194e30 100644 --- a/game-server/internal/ws/handler_test.go +++ b/game-server/internal/ws/handler_test.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http/httptest" "strings" + "sync" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/ruby-sea-battle/game-server/internal/auth" "github.com/ruby-sea-battle/game-server/internal/game" + "github.com/ruby-sea-battle/game-server/internal/protocol" "github.com/ruby-sea-battle/game-server/internal/ws" ) @@ -55,10 +57,16 @@ func userToken(t *testing.T, userID int64) string { func startServerWithManager(t *testing.T, joinTimeout time.Duration) (string, *game.Manager) { t.Helper() manager := game.NewManager(joinTimeout) - log := slog.New(slog.NewTextHandler(testWriter{t}, nil)) + tw := &testWriter{t: t} + log := slog.New(slog.NewTextHandler(tw, nil)) handler := ws.NewHandler(manager, secret, publicAddr, []string{"*"}, log) srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) + t.Cleanup(func() { + srv.Close() + tw.mu.Lock() + tw.done = true + tw.mu.Unlock() + }) return "ws" + strings.TrimPrefix(srv.URL, "http"), manager } @@ -67,10 +75,18 @@ func startServer(t *testing.T, joinTimeout time.Duration) (url string) { return u } -type testWriter struct{ t *testing.T } +type testWriter struct { + mu sync.Mutex + t *testing.T + done bool +} -func (w testWriter) Write(p []byte) (int, error) { - w.t.Log(strings.TrimSpace(string(p))) +func (w *testWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if !w.done { + w.t.Log(strings.TrimSpace(string(p))) + } return len(p), nil } @@ -317,3 +333,99 @@ func TestManagerShutdownClosesConnectionsGoingAway(t *testing.T) { } } } + +func validTestShips() []protocol.Ship { + return []protocol.Ship{ + // 4 size 1 + {Orientation: protocol.OrientationHorizontal, X: 9, Y: 4, Size: 1}, + {Orientation: protocol.OrientationVertical, X: 7, Y: 4, Size: 1}, + {Orientation: protocol.OrientationHorizontal, X: 5, Y: 4, Size: 1}, + {Orientation: protocol.OrientationHorizontal, X: 3, Y: 4, Size: 1}, + // 3 size 2 + {Orientation: protocol.OrientationHorizontal, X: 0, Y: 4, Size: 2}, + {Orientation: protocol.OrientationHorizontal, X: 7, Y: 2, Size: 2}, + {Orientation: protocol.OrientationHorizontal, X: 4, Y: 2, Size: 2}, + // 2 size 3 + {Orientation: protocol.OrientationHorizontal, X: 0, Y: 2, Size: 3}, + {Orientation: protocol.OrientationHorizontal, X: 5, Y: 0, Size: 3}, + // 1 size 4 + {Orientation: protocol.OrientationHorizontal, X: 0, Y: 0, Size: 4}, + } +} + +func TestPlaceShipsOverWebSocket(t *testing.T) { + url, mgr := startServerWithManager(t, time.Minute) + exp := time.Now().Add(time.Hour) + gt := gameToken(t, "m-ws-placeships", 1, 2, publicAddr, exp) + + c1, c2 := dial(t, url), dial(t, url) + join(t, c1, gt, userToken(t, 1)) + join(t, c2, gt, userToken(t, 2)) + + // Both receive StatePlacingShips (seq 1) + v1 := readMsg(t, c1) + if v1[0] != float64(protocol.OpState) || v1[1] != float64(1) || v1[2] != float64(protocol.StatePlacingShips) { + t.Fatalf("c1 unexpected initial state: %v", v1) + } + v2 := readMsg(t, c2) + if v2[0] != float64(protocol.OpState) || v2[1] != float64(1) || v2[2] != float64(protocol.StatePlacingShips) { + t.Fatalf("c2 unexpected initial state: %v", v2) + } + + // ACK seq 1 + _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1)) + _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1)) + + // Player 1 sends PlaceShips + ships := validTestShips() + placeMsg := protocol.BuildPlaceShips(ships) + if err := c1.Write(context.Background(), websocket.MessageText, placeMsg); err != nil { + t.Fatalf("write PlaceShips c1: %v", err) + } + + // c1 gets StateWaitOpponentReady (3), c2 gets StateOpponentReady (2) + v1 = readMsg(t, c1) + if v1[0] != float64(protocol.OpState) || v1[1] != float64(2) || v1[2] != float64(protocol.StateWaitOpponentReady) { + t.Fatalf("c1 got %v, want StateWaitOpponentReady (3)", v1) + } + v2 = readMsg(t, c2) + if v2[0] != float64(protocol.OpState) || v2[1] != float64(2) || v2[2] != float64(protocol.StateOpponentReady) { + t.Fatalf("c2 got %v, want StateOpponentReady (2)", v2) + } + + _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2)) + _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2)) + + // Player 2 sends PlaceShips + if err := c2.Write(context.Background(), websocket.MessageText, placeMsg); err != nil { + t.Fatalf("write PlaceShips c2: %v", err) + } + + // Game starts: Player 1 was first, so c1 gets StateYourTurn (4), c2 gets StateWaitOpponentTurn (5) + v1 = readMsg(t, c1) + if v1[0] != float64(protocol.OpState) || v1[1] != float64(3) || v1[2] != float64(protocol.StateYourTurn) { + t.Fatalf("c1 got %v, want StateYourTurn (4)", v1) + } + v2 = readMsg(t, c2) + if v2[0] != float64(protocol.OpState) || v2[1] != float64(3) || v2[2] != float64(protocol.StateWaitOpponentTurn) { + t.Fatalf("c2 got %v, want StateWaitOpponentTurn (5)", v2) + } + + _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3)) + _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3)) + + // Check that the match on the server has the placements stored + match := mgr.GetMatch("m-ws-placeships") + if match == nil { + t.Fatal("match not found on manager") + } + if len(match.Ships(0)) != 10 || len(match.Ships(1)) != 10 { + t.Fatalf("ships not stored on match: p1=%d, p2=%d", len(match.Ships(0)), len(match.Ships(1))) + } + if _, ok := match.Board(0); !ok { + t.Fatal("p1 board not placed") + } + if _, ok := match.Board(1); !ok { + t.Fatal("p2 board not placed") + } +}