bugfixes and refactoring in game-server
This commit is contained in:
@@ -16,7 +16,37 @@ import (
|
||||
// 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")
|
||||
var (
|
||||
ErrMatchFinished = errors.New("match already finished")
|
||||
ErrInvalidSeq = errors.New("invalid seq")
|
||||
)
|
||||
|
||||
// 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.
|
||||
@@ -49,6 +79,7 @@ type PlayerSlot struct {
|
||||
// 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
|
||||
@@ -56,10 +87,24 @@ type Match struct {
|
||||
mu sync.Mutex
|
||||
started bool // both players joined at least once
|
||||
ended bool
|
||||
state int // current global game state
|
||||
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
|
||||
}
|
||||
|
||||
func (m *Match) slotIndex(userID int64) int {
|
||||
switch userID {
|
||||
case m.playerIDs[0]:
|
||||
@@ -77,6 +122,7 @@ func (m *Match) slotIndex(userID int64) int {
|
||||
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.
|
||||
@@ -96,6 +142,13 @@ func NewManager(joinTimeout time.Duration) *Manager {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
@@ -120,8 +173,13 @@ func (m *Manager) Join(ctx context.Context, conn Sender, gc *auth.GameClaims, us
|
||||
}
|
||||
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{
|
||||
@@ -133,7 +191,7 @@ func (m *Manager) Join(ctx context.Context, conn Sender, gc *auth.GameClaims, us
|
||||
// never later than the game token itself expires — after that a
|
||||
// join is impossible anyway.
|
||||
deadline := now.Add(m.joinTimeout)
|
||||
if match.tokenExp.Before(deadline) {
|
||||
if !match.tokenExp.IsZero() && match.tokenExp.Before(deadline) {
|
||||
deadline = match.tokenExp
|
||||
}
|
||||
match.joinTimer = time.AfterFunc(time.Until(deadline), func() {
|
||||
@@ -141,13 +199,29 @@ func (m *Manager) Join(ctx context.Context, conn Sender, gc *auth.GameClaims, us
|
||||
})
|
||||
m.matches[gc.MatchID] = match
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// 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 {
|
||||
// Lost race with the join timeout; the match is being torn down.
|
||||
// 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
|
||||
}
|
||||
@@ -174,8 +248,7 @@ func (m *Manager) Join(ctx context.Context, conn Sender, gc *auth.GameClaims, us
|
||||
if match.joinTimer != nil {
|
||||
match.joinTimer.Stop()
|
||||
}
|
||||
match.state = protocol.StatePlacingShips
|
||||
m.setStateLocked(ctx, match, match.state)
|
||||
m.setStateLocked(ctx, match, StatePlacingShips)
|
||||
case match.started:
|
||||
// Reconnect of a player while the game is running.
|
||||
m.replayLocked(ctx, slot)
|
||||
@@ -198,10 +271,16 @@ func (m *Manager) Leave(conn Sender, match *Match, idx int) {
|
||||
|
||||
// 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) {
|
||||
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 {
|
||||
@@ -209,13 +288,26 @@ func (m *Manager) Ack(match *Match, idx int, seq int64) {
|
||||
}
|
||||
}
|
||||
slot.unacked = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
// setStateLocked moves the match into a new state and notifies both players.
|
||||
// match.mu must be held.
|
||||
// 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) {
|
||||
for _, slot := range match.slots {
|
||||
m.queueStateLocked(ctx, slot, state)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +340,23 @@ func (m *Manager) replayLocked(ctx context.Context, slot *PlayerSlot) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -262,8 +371,18 @@ func (m *Manager) onJoinTimeout(matchID string) {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.finishMatchLocked(match, protocol.EndCodeJoinTimeout, "join timeout exceeded")
|
||||
}
|
||||
|
||||
// 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
|
||||
// upon entry; both are unlocked by this function.
|
||||
func (m *Manager) finishMatchLocked(match *Match, code int, reason string) {
|
||||
match.ended = true
|
||||
delete(m.matches, matchID)
|
||||
if match.joinTimer != nil {
|
||||
match.joinTimer.Stop()
|
||||
}
|
||||
delete(m.matches, match.ID)
|
||||
|
||||
ends := make(map[int64][]byte, 2)
|
||||
type notification struct {
|
||||
@@ -273,7 +392,7 @@ func (m *Manager) onJoinTimeout(matchID string) {
|
||||
var notify []notification
|
||||
for _, slot := range match.slots {
|
||||
slot.seq++
|
||||
data := protocol.BuildEnd(slot.seq, protocol.EndCodeJoinTimeout, "join timeout exceeded")
|
||||
data := protocol.BuildEnd(slot.seq, code, reason)
|
||||
slot.unacked = append(slot.unacked, queuedMessage{seq: slot.seq, data: data})
|
||||
ends[slot.UserID] = data
|
||||
if slot.conn != nil {
|
||||
@@ -283,11 +402,19 @@ func (m *Manager) onJoinTimeout(matchID string) {
|
||||
}
|
||||
match.mu.Unlock()
|
||||
|
||||
// Remember the outcome until the game token expires so that late join
|
||||
// attempts are answered with the same END message instead of creating a
|
||||
// phantom match.
|
||||
m.finished[matchID] = &finishedMatch{ends: ends, expiresAt: match.tokenExp}
|
||||
m.purgeFinishedLocked(time.Now())
|
||||
// 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.
|
||||
@@ -297,6 +424,37 @@ func (m *Manager) onJoinTimeout(matchID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user