bugfixes and refactoring in game-server

This commit is contained in:
2026-10-27 15:55:48 +03:00
parent 3629411030
commit 31ef12b5f2
9 changed files with 732 additions and 66 deletions
+177 -19
View File
@@ -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) {