implement game turn logic

This commit is contained in:
2026-11-02 21:24:32 +03:00
parent 153dc74ff6
commit 189b027a11
7 changed files with 896 additions and 13 deletions
+201 -7
View File
@@ -23,6 +23,7 @@ var (
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.
@@ -99,9 +100,11 @@ type PlayerSlot struct {
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
ships []protocol.Ship
board Board
shipsPlaced bool
struck [BoardSize][BoardSize]bool
shipsRemaining int
}
// Match is the state of a single game of sea battle.
@@ -156,6 +159,26 @@ func (m *Match) Board(slotIdx int) (Board, bool) {
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]:
@@ -395,11 +418,119 @@ func (m *Manager) PlaceShips(ctx context.Context, match *Match, slotIdx int, shi
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()
@@ -433,6 +564,18 @@ func (m *Manager) queueStateLocked(ctx context.Context, slot *PlayerSlot, state
}
}
// 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.
@@ -483,10 +626,25 @@ func (m *Manager) onJoinTimeout(matchID string) {
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 END notifications to connected players. Both m.mu and match.mu must be held
// 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()
@@ -499,9 +657,10 @@ func (m *Manager) finishMatchLocked(match *Match, code int, reason string) {
data []byte
}
var notify []notification
for _, slot := range match.slots {
for i, slot := range match.slots {
slot.seq++
data := protocol.BuildEnd(slot.seq, code, reason)
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 {
@@ -529,8 +688,43 @@ func (m *Manager) finishMatchLocked(match *Match, code int, reason string) {
// Network I/O happens outside of any lock.
for _, n := range notify {
_ = n.conn.Send(context.Background(), n.data)
_ = n.conn.Close(protocol.CloseCodeNormal, "match ended")
}
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)