// Package protocol defines the wire format of messages exchanged between // game clients and the game server. // // All messages are JSON arrays whose first element is a numeric opcode: // // [opcode, param1, param2, ...] // // Opcodes: // // 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, ...] // 6 C->S FIRE [6, , ] // 7 S->C FIRE_RESULT [7, , ] // // 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 // a per-player queue and re-sent when the player reconnects. // // ACK in the S->C direction is reserved for confirming future client->server // messages once the protocol grows (client messages do not carry sequence // numbers yet). package protocol import ( "encoding/json" "fmt" ) // 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 OpPlaceShips = 5 // C->S: ship placement OpFire = 6 // C->S: fire shot OpFireResult = 7 // S->C: shot result ) // Board and ship configuration constants. const ( BoardSize = 10 ShipCount = 10 ShipArgs = 30 // 10 ships * 3 args each OrientationHorizontal = false OrientationVertical = true ) // ShipSizes defines the expected sizes of the 10 ships in order: // 4 ships of size 1, 3 ships of size 2, 2 ships of size 3, 1 ship of size 4. var ShipSizes = [ShipCount]int{1, 1, 1, 1, 2, 2, 2, 3, 3, 4} // Cell represents coordinates on the 10x10 board. type Cell struct { X int Y int } // Ship represents a single ship placement on the board. type Ship struct { Orientation bool // false: horizontal, true: vertical X int // column (0-9) Y int // row (0-9) Size int // length (1-4) } // Cells returns all board cells occupied by the ship. func (s Ship) Cells() []Cell { cells := make([]Cell, s.Size) for i := 0; i < s.Size; i++ { if s.Orientation { cells[i] = Cell{X: s.X, Y: s.Y + i} } else { cells[i] = Cell{X: s.X + i, Y: s.Y} } } return cells } // Game state codes. States are always sent from the receiver's point of // view: a player never learns whether they are "player 1" or "player 2". const ( StatePlacingShips = 1 // both players are placing their ships StateOpponentReady = 2 // opponent finished placing ships StateWaitOpponentReady = 3 // you are ready, waiting for the opponent StateYourTurn = 4 // make your move StateWaitOpponentTurn = 5 // wait for the opponent's move ) // Shot result codes for FIRE_RESULT (OpFireResult). const ( FireResultMiss = 1 // miss (промах) FireResultHit = 2 // hit (простое попадание) FireResultSunk = 3 // ship sunk (корабль потоплен) FireResultInvalidPosition = 4 // invalid position (неверная позиция) ) // Match end codes. 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). const ( CloseCodeNormal = 1000 // normal closure CloseCodeGoingAway = 1001 // server shutting down CloseCodeReplaced = 4000 // connection replaced by a newer one of the same player CloseCodeExpectedJoin = 4001 // first message was not a JOIN CloseCodeBadMessage = 4002 // malformed message CloseCodeBadGameToken = 4003 // invalid/expired game token, server mismatch, invalid match CloseCodeBadUserToken = 4004 // invalid/expired user token or user is not a participant ) // ParseMessage decodes a raw message into its opcode and the remaining // arguments. func ParseMessage(data []byte) (opcode int, args []json.RawMessage, err error) { var raw []json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return 0, nil, fmt.Errorf("not a JSON array: %w", err) } if len(raw) == 0 { return 0, nil, fmt.Errorf("empty message") } if err := json.Unmarshal(raw[0], &opcode); err != nil { return 0, nil, fmt.Errorf("opcode is not a number: %w", err) } return opcode, raw[1:], nil } // ParseJoin extracts the game token and the user token from JOIN arguments. func ParseJoin(args []json.RawMessage) (gameToken, userToken string, err error) { if len(args) != 2 { return "", "", fmt.Errorf("JOIN expects 2 arguments, got %d", len(args)) } if err := json.Unmarshal(args[0], &gameToken); err != nil { return "", "", fmt.Errorf("game token is not a string: %w", err) } if err := json.Unmarshal(args[1], &userToken); err != nil { return "", "", fmt.Errorf("user token is not a string: %w", err) } return gameToken, userToken, nil } // ParseAck extracts the acknowledged sequence number from ACK arguments. func ParseAck(args []json.RawMessage) (seq int64, err error) { if len(args) != 1 { return 0, fmt.Errorf("ACK expects 1 argument, got %d", len(args)) } if err := json.Unmarshal(args[0], &seq); err != nil { return 0, fmt.Errorf("seq is not a number: %w", err) } 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 { // Only possible with non-serializable values, which never happens // with the fixed shapes below. panic(fmt.Sprintf("protocol: marshal message: %v", err)) } return data } // BuildEnd builds an END message: [2, seq, code, reason]. func BuildEnd(seq int64, code int, reason string) []byte { return build([]any{OpEnd, seq, code, reason}) } // BuildState builds a STATE message: [3, seq, state]. func BuildState(seq int64, state int) []byte { return build([]any{OpState, seq, state}) } // BuildAck builds an ACK message: [4, seq]. func BuildAck(seq int64) []byte { return build([]any{OpAck, seq}) } // BuildPlaceShips builds a PLACE_SHIPS message from a slice of ships: // [5, ship0_orientation, ship0_x, ship0_y, ship1_orientation, ship1_x, ship1_y, ...] func BuildPlaceShips(ships []Ship) []byte { v := make([]any, 1+len(ships)*3) v[0] = OpPlaceShips for i, s := range ships { v[1+i*3] = s.Orientation v[1+i*3+1] = s.X v[1+i*3+2] = s.Y } return build(v) } // ParseFire extracts the x and y coordinates from FIRE arguments. func ParseFire(args []json.RawMessage) (x, y int, err error) { if len(args) != 2 { return 0, 0, fmt.Errorf("FIRE expects 2 arguments, got %d", len(args)) } if err := json.Unmarshal(args[0], &x); err != nil { return 0, 0, fmt.Errorf("x coordinate is not an integer: %w", err) } if err := json.Unmarshal(args[1], &y); err != nil { return 0, 0, fmt.Errorf("y coordinate is not an integer: %w", err) } return x, y, nil } // BuildFire builds a FIRE message: [6, x, y]. func BuildFire(x, y int) []byte { return build([]any{OpFire, x, y}) } // ParseFireResult extracts seq and result code from FIRE_RESULT arguments. func ParseFireResult(args []json.RawMessage) (seq int64, result int, err error) { if len(args) != 2 { return 0, 0, fmt.Errorf("FIRE_RESULT expects 2 arguments, got %d", len(args)) } if err := json.Unmarshal(args[0], &seq); err != nil { return 0, 0, fmt.Errorf("seq is not a number: %w", err) } if err := json.Unmarshal(args[1], &result); err != nil { return 0, 0, fmt.Errorf("result is not a number: %w", err) } return seq, result, nil } // BuildFireResult builds a FIRE_RESULT message: [7, seq, result]. func BuildFireResult(seq int64, result int) []byte { return build([]any{OpFireResult, seq, result}) }