278 lines
8.5 KiB
Go
278 lines
8.5 KiB
Go
// 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, "<game_token>", "<user_token>"]
|
|
// 2 S->C END [2, <seq>, <code>, "<reason>"]
|
|
// 3 S->C STATE [3, <seq>, <state_code>]
|
|
// 4 C<->S ACK [4, <seq>]
|
|
// 5 C->S PLACE_SHIPS [5, ship0_orientation, ship0_x, ship0_y, ...]
|
|
//
|
|
// Every state-changing server->client message (END, STATE) 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
|
|
)
|
|
|
|
// 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
|
|
)
|
|
|
|
// Match end codes.
|
|
const (
|
|
EndCodeJoinTimeout = -1 // the second player never joined
|
|
)
|
|
|
|
// 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)
|
|
}
|