770 lines
22 KiB
Go
770 lines
22 KiB
Go
// Package game keeps the in-memory state of all matches running on this
|
|
// game server: player connections, per-player unacknowledged message queues
|
|
// and a registry of recently finished matches.
|
|
package game
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ruby-sea-battle/game-server/internal/auth"
|
|
"github.com/ruby-sea-battle/game-server/internal/protocol"
|
|
)
|
|
|
|
// ErrMatchFinished is returned by Join when the player tries to join a match
|
|
// 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")
|
|
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")
|
|
ErrNotYourTurn = errors.New("not your turn")
|
|
)
|
|
|
|
// 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
|
|
StatePlayer1Ready = 2
|
|
StatePlayer2Ready = 3
|
|
StatePlayer1Turn = 4
|
|
StatePlayer2Turn = 5
|
|
)
|
|
|
|
// playerStatesFor maps a server-side state to the respective states seen by player 1 and player 2.
|
|
func playerStatesFor(serverState int) (p1State, p2State int) {
|
|
switch serverState {
|
|
case StatePlacingShips:
|
|
return protocol.StatePlacingShips, protocol.StatePlacingShips
|
|
case StatePlayer1Ready:
|
|
return protocol.StateWaitOpponentReady, protocol.StateOpponentReady
|
|
case StatePlayer2Ready:
|
|
return protocol.StateOpponentReady, protocol.StateWaitOpponentReady
|
|
case StatePlayer1Turn:
|
|
return protocol.StateYourTurn, protocol.StateWaitOpponentTurn
|
|
case StatePlayer2Turn:
|
|
return protocol.StateWaitOpponentTurn, protocol.StateYourTurn
|
|
default:
|
|
return 0, 0
|
|
}
|
|
}
|
|
|
|
// Sender abstracts a player connection so the game logic stays testable and
|
|
// independent of the websocket implementation.
|
|
type Sender interface {
|
|
// Send writes one message to the connection. Implementations must be
|
|
// safe for concurrent use and must bound the write with a timeout.
|
|
Send(ctx context.Context, data []byte) error
|
|
// Close closes the connection with the given websocket close code.
|
|
Close(code int, reason string) error
|
|
}
|
|
|
|
// queuedMessage is a state-changing message that has been sent to a player
|
|
// but not yet acknowledged with an ACK.
|
|
type queuedMessage struct {
|
|
seq int64
|
|
data []byte
|
|
}
|
|
|
|
// PlayerSlot is the state of one of the two players of a match.
|
|
type PlayerSlot struct {
|
|
UserID int64
|
|
|
|
conn Sender // nil while the player is disconnected
|
|
seq int64 // last issued sequence number
|
|
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
|
|
struck [BoardSize][BoardSize]bool
|
|
shipsRemaining int
|
|
}
|
|
|
|
// Match is the state of a single game of sea battle.
|
|
type Match struct {
|
|
ID string
|
|
gameToken string // game token used when match was created
|
|
tokenExp time.Time // game token expiration; bounds the join deadline
|
|
playerIDs [2]int64 // slot 0 = player1, slot 1 = player2 (internal only)
|
|
slots [2]*PlayerSlot
|
|
|
|
mu sync.Mutex
|
|
started bool // both players joined at least once
|
|
ended bool
|
|
state int // current global server-side game state
|
|
joinTimer *time.Timer
|
|
}
|
|
|
|
func (m *Match) GameToken() string {
|
|
return m.gameToken
|
|
}
|
|
|
|
func (m *Match) PlayerIDs() [2]int64 {
|
|
return m.playerIDs
|
|
}
|
|
|
|
func (m *Match) State() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
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
|
|
}
|
|
|
|
// 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]:
|
|
return 0
|
|
case m.playerIDs[1]:
|
|
return 1
|
|
default:
|
|
return -1
|
|
}
|
|
}
|
|
|
|
// finishedMatch remembers how a match ended so that late join attempts with
|
|
// a still-valid game token get the same END message instead of creating a
|
|
// phantom match (and, later, a duplicate result report to the main server).
|
|
type finishedMatch struct {
|
|
ends map[int64][]byte // userID -> END message sent to that player
|
|
expiresAt time.Time // game token expiration time
|
|
gameToken string
|
|
}
|
|
|
|
// Manager owns all matches of this server instance.
|
|
type Manager struct {
|
|
joinTimeout time.Duration
|
|
|
|
mu sync.Mutex
|
|
matches map[string]*Match
|
|
finished map[string]*finishedMatch
|
|
}
|
|
|
|
func NewManager(joinTimeout time.Duration) *Manager {
|
|
return &Manager{
|
|
joinTimeout: joinTimeout,
|
|
matches: make(map[string]*Match),
|
|
finished: make(map[string]*finishedMatch),
|
|
}
|
|
}
|
|
|
|
// GetMatch returns the match with the given ID, or nil if not found.
|
|
func (m *Manager) GetMatch(matchID string) *Match {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.matches[matchID]
|
|
}
|
|
|
|
// Join attaches conn to the match identified by the game token claims.
|
|
// userID must already be validated against the claims by the caller.
|
|
//
|
|
// If the player was already connected, the old connection is closed and
|
|
// replaced. Unacknowledged messages are replayed to the new connection; if
|
|
// there is nothing to replay but the game is already running, the current
|
|
// state is re-sent so the client can restore its UI.
|
|
//
|
|
// Returns the match and the player's slot index (0 or 1).
|
|
func (m *Manager) Join(ctx context.Context, conn Sender, gc *auth.GameClaims, userID int64) (*Match, int, error) {
|
|
now := time.Now()
|
|
|
|
m.mu.Lock()
|
|
m.purgeFinishedLocked(now)
|
|
if fin, ok := m.finished[gc.MatchID]; ok {
|
|
m.mu.Unlock()
|
|
if data, ok := fin.ends[userID]; ok {
|
|
_ = conn.Send(ctx, data)
|
|
}
|
|
_ = conn.Close(protocol.CloseCodeNormal, "match already finished")
|
|
return nil, -1, ErrMatchFinished
|
|
}
|
|
match, ok := m.matches[gc.MatchID]
|
|
if !ok {
|
|
token := gc.RawToken
|
|
if token == "" {
|
|
token = gc.MatchID
|
|
}
|
|
match = &Match{
|
|
ID: gc.MatchID,
|
|
gameToken: token,
|
|
tokenExp: gc.ExpiresAt.Time,
|
|
playerIDs: [2]int64{gc.Player1ID, gc.Player2ID},
|
|
slots: [2]*PlayerSlot{
|
|
{UserID: gc.Player1ID},
|
|
{UserID: gc.Player2ID},
|
|
},
|
|
}
|
|
// The second player must join before the join timeout expires, but
|
|
// never later than the game token itself expires — after that a
|
|
// join is impossible anyway.
|
|
deadline := now.Add(m.joinTimeout)
|
|
if !match.tokenExp.IsZero() && match.tokenExp.Before(deadline) {
|
|
deadline = match.tokenExp
|
|
}
|
|
match.joinTimer = time.AfterFunc(time.Until(deadline), func() {
|
|
m.onJoinTimeout(match.ID)
|
|
})
|
|
m.matches[gc.MatchID] = match
|
|
}
|
|
|
|
// Acquire match.mu before releasing m.mu to eliminate the race with onJoinTimeout / EndMatch
|
|
match.mu.Lock()
|
|
m.mu.Unlock()
|
|
defer match.mu.Unlock()
|
|
|
|
if match.ended {
|
|
// The match has ended; make sure the END message is sent before closing.
|
|
var endData []byte
|
|
idx := match.slotIndex(userID)
|
|
if idx >= 0 && len(match.slots[idx].unacked) > 0 {
|
|
endData = match.slots[idx].unacked[len(match.slots[idx].unacked)-1].data
|
|
}
|
|
if endData == nil {
|
|
m.mu.Lock()
|
|
if fin := m.finished[gc.MatchID]; fin != nil {
|
|
endData = fin.ends[userID]
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
if endData != nil {
|
|
_ = conn.Send(ctx, endData)
|
|
}
|
|
_ = conn.Close(protocol.CloseCodeNormal, "match already finished")
|
|
return nil, -1, ErrMatchFinished
|
|
}
|
|
|
|
idx := match.slotIndex(userID)
|
|
if idx < 0 {
|
|
// Should be impossible: the caller validated membership.
|
|
_ = conn.Close(protocol.CloseCodeBadUserToken, "not a participant of this match")
|
|
return nil, -1, errors.New("user is not a participant")
|
|
}
|
|
slot := match.slots[idx]
|
|
|
|
if slot.conn != nil && slot.conn != conn {
|
|
// Only one live connection per player: replace the old one.
|
|
_ = slot.conn.Close(protocol.CloseCodeReplaced, "connection replaced by a new one")
|
|
}
|
|
slot.conn = conn
|
|
slot.everConnected = true
|
|
|
|
switch {
|
|
case !match.started && match.slots[0].everConnected && match.slots[1].everConnected:
|
|
// Both players joined for the first time: the game begins.
|
|
match.started = true
|
|
if match.joinTimer != nil {
|
|
match.joinTimer.Stop()
|
|
}
|
|
m.setStateLocked(ctx, match, StatePlacingShips)
|
|
case match.started:
|
|
// Reconnect of a player while the game is running.
|
|
m.replayLocked(ctx, slot)
|
|
}
|
|
// Otherwise: first player waiting for the opponent, nothing to send.
|
|
|
|
return match, idx, nil
|
|
}
|
|
|
|
// Leave detaches conn from the match if it is still the current connection
|
|
// of the player's slot. Messages queued for the player survive the
|
|
// disconnect and will be replayed on rejoin.
|
|
func (m *Manager) Leave(conn Sender, match *Match, idx int) {
|
|
match.mu.Lock()
|
|
defer match.mu.Unlock()
|
|
if slot := match.slots[idx]; slot.conn == conn {
|
|
slot.conn = nil
|
|
}
|
|
}
|
|
|
|
// Ack confirms delivery of all messages with seq <= the given sequence
|
|
// number for the given player.
|
|
func (m *Manager) Ack(match *Match, idx int, seq int64) error {
|
|
match.mu.Lock()
|
|
defer match.mu.Unlock()
|
|
if idx < 0 || idx >= len(match.slots) {
|
|
return errors.New("invalid slot index")
|
|
}
|
|
slot := match.slots[idx]
|
|
if seq <= 0 || seq > slot.seq {
|
|
return ErrInvalidSeq
|
|
}
|
|
kept := slot.unacked[:0]
|
|
for _, qm := range slot.unacked {
|
|
if qm.seq > seq {
|
|
kept = append(kept, qm)
|
|
}
|
|
}
|
|
slot.unacked = kept
|
|
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
|
|
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()
|
|
defer match.mu.Unlock()
|
|
m.setStateLocked(ctx, match, state)
|
|
}
|
|
|
|
// setStateLocked moves the match into a new server-side state and notifies both players
|
|
// with appropriate player-side states. match.mu must be held.
|
|
func (m *Manager) setStateLocked(ctx context.Context, match *Match, state int) {
|
|
match.state = state
|
|
p1State, p2State := playerStatesFor(state)
|
|
if p1State != 0 {
|
|
m.queueStateLocked(ctx, match.slots[0], p1State)
|
|
}
|
|
if p2State != 0 {
|
|
m.queueStateLocked(ctx, match.slots[1], p2State)
|
|
}
|
|
}
|
|
|
|
// queueStateLocked appends a STATE 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) queueStateLocked(ctx context.Context, slot *PlayerSlot, state int) {
|
|
slot.seq++
|
|
data := protocol.BuildState(slot.seq, state)
|
|
slot.unacked = append(slot.unacked, queuedMessage{seq: slot.seq, data: data})
|
|
slot.lastState = state
|
|
if slot.conn != nil {
|
|
_ = slot.conn.Send(ctx, data)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// match.mu must be held.
|
|
func (m *Manager) replayLocked(ctx context.Context, slot *PlayerSlot) {
|
|
if len(slot.unacked) == 0 {
|
|
if slot.lastState != 0 {
|
|
m.queueStateLocked(ctx, slot, slot.lastState)
|
|
}
|
|
return
|
|
}
|
|
for _, qm := range slot.unacked {
|
|
_ = slot.conn.Send(ctx, qm.data)
|
|
}
|
|
}
|
|
|
|
// EndMatch finishes a match with the given code and reason for both players.
|
|
func (m *Manager) EndMatch(matchID string, code int, reason string) {
|
|
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
|
|
}
|
|
m.finishMatchLocked(match, code, reason)
|
|
}
|
|
|
|
// onJoinTimeout finishes a match whose second player never joined.
|
|
func (m *Manager) onJoinTimeout(matchID string) {
|
|
m.mu.Lock()
|
|
match, ok := m.matches[matchID]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
match.mu.Lock()
|
|
if match.started || match.ended {
|
|
match.mu.Unlock()
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
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 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()
|
|
}
|
|
delete(m.matches, match.ID)
|
|
|
|
ends := make(map[int64][]byte, 2)
|
|
type notification struct {
|
|
conn Sender
|
|
data []byte
|
|
}
|
|
var notify []notification
|
|
for i, slot := range match.slots {
|
|
slot.seq++
|
|
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 {
|
|
notify = append(notify, notification{conn: slot.conn, data: data})
|
|
slot.conn = nil
|
|
}
|
|
}
|
|
match.mu.Unlock()
|
|
|
|
// Remember outcome in finished registry. Bound expiresAt to at least now + 5m if tokenExp is in the past.
|
|
now := time.Now()
|
|
expiresAt := match.tokenExp
|
|
if expiresAt.Before(now.Add(5 * time.Minute)) {
|
|
expiresAt = now.Add(5 * time.Minute)
|
|
}
|
|
|
|
m.finished[match.ID] = &finishedMatch{
|
|
ends: ends,
|
|
expiresAt: expiresAt,
|
|
gameToken: match.gameToken,
|
|
}
|
|
m.purgeFinishedLocked(now)
|
|
m.mu.Unlock()
|
|
|
|
// Network I/O happens outside of any lock.
|
|
for _, n := range notify {
|
|
_ = n.conn.Send(context.Background(), n.data)
|
|
}
|
|
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)
|
|
// and stops all pending join timers.
|
|
func (m *Manager) Shutdown() {
|
|
m.mu.Lock()
|
|
var conns []Sender
|
|
for _, match := range m.matches {
|
|
match.mu.Lock()
|
|
if match.joinTimer != nil {
|
|
match.joinTimer.Stop()
|
|
}
|
|
for _, slot := range match.slots {
|
|
if slot.conn != nil {
|
|
conns = append(conns, slot.conn)
|
|
slot.conn = nil
|
|
}
|
|
}
|
|
match.mu.Unlock()
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
var wg sync.WaitGroup
|
|
for _, conn := range conns {
|
|
wg.Add(1)
|
|
go func(c Sender) {
|
|
defer wg.Done()
|
|
_ = c.Close(protocol.CloseCodeGoingAway, "going away")
|
|
}(conn)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// purgeFinishedLocked drops registry entries whose game token has expired.
|
|
// Called lazily whenever the registry is touched. m.mu must be held.
|
|
func (m *Manager) purgeFinishedLocked(now time.Time) {
|
|
for id, fin := range m.finished {
|
|
if !now.Before(fin.expiresAt) {
|
|
delete(m.finished, id)
|
|
}
|
|
}
|
|
}
|