implement game turn logic
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -644,3 +644,343 @@ func TestPlaceShipsBeforeMatchStarts(t *testing.T) {
|
||||
t.Fatalf("got err = %v, want ErrMatchNotStarted", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupStartedGame(t *testing.T) (*Manager, *Match, *fakeConn, *fakeConn) {
|
||||
t.Helper()
|
||||
m := NewManager(time.Minute)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
c1, c2 := &fakeConn{}, &fakeConn{}
|
||||
|
||||
match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("join p1: %v", err)
|
||||
}
|
||||
if _, _, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil {
|
||||
t.Fatalf("join p2: %v", err)
|
||||
}
|
||||
|
||||
ships := validTestShips()
|
||||
if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil {
|
||||
t.Fatalf("p1 place ships: %v", err)
|
||||
}
|
||||
if err := m.PlaceShips(context.Background(), match, 1, ships); err != nil {
|
||||
t.Fatalf("p2 place ships: %v", err)
|
||||
}
|
||||
if match.State() != StatePlayer1Turn {
|
||||
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
|
||||
}
|
||||
return m, match, c1, c2
|
||||
}
|
||||
|
||||
func TestFireMissTransitionsTurn(t *testing.T) {
|
||||
m, match, c1, c2 := setupStartedGame(t)
|
||||
|
||||
// In validTestShips, cell (9, 9) has no ships.
|
||||
res, err := m.Fire(context.Background(), match, 0, 9, 9)
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if res != protocol.FireResultMiss {
|
||||
t.Fatalf("res = %d, want FireResultMiss", res)
|
||||
}
|
||||
|
||||
// Shooter receives FIRE_RESULT with miss (1)
|
||||
c1Msgs := c1.messages()
|
||||
// c1 got: [STATE 1], [STATE 3], [STATE 4], [FIRE_RESULT 1], [STATE 5]
|
||||
fireMsg := parseMsg(t, c1Msgs[len(c1Msgs)-2])
|
||||
if fireMsg[0] != float64(protocol.OpFireResult) || fireMsg[2] != float64(protocol.FireResultMiss) {
|
||||
t.Fatalf("unexpected fireMsg: %v", fireMsg)
|
||||
}
|
||||
|
||||
// Turn transitions to Player 2
|
||||
if match.State() != StatePlayer2Turn {
|
||||
t.Fatalf("state = %d, want StatePlayer2Turn", match.State())
|
||||
}
|
||||
|
||||
c1Last := parseMsg(t, c1Msgs[len(c1Msgs)-1])
|
||||
if c1Last[0] != float64(protocol.OpState) || c1Last[2] != float64(protocol.StateWaitOpponentTurn) {
|
||||
t.Fatalf("c1 last state = %v, want StateWaitOpponentTurn", c1Last)
|
||||
}
|
||||
|
||||
c2Msgs := c2.messages()
|
||||
c2Last := parseMsg(t, c2Msgs[len(c2Msgs)-1])
|
||||
if c2Last[0] != float64(protocol.OpState) || c2Last[2] != float64(protocol.StateYourTurn) {
|
||||
t.Fatalf("c2 last state = %v, want StateYourTurn", c2Last)
|
||||
}
|
||||
|
||||
// Target board cell (9, 9) marked struck
|
||||
struck, ok := match.Struck(1)
|
||||
if !ok || !struck[9][9] {
|
||||
t.Fatalf("expected p2 cell (9, 9) to be struck")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireHitKeepsTurn(t *testing.T) {
|
||||
m, match, c1, c2 := setupStartedGame(t)
|
||||
|
||||
// Ship 9 is 4 cells horizontally at (0, 0), (1, 0), (2, 0), (3, 0)
|
||||
res, err := m.Fire(context.Background(), match, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if res != protocol.FireResultHit {
|
||||
t.Fatalf("res = %d, want FireResultHit", res)
|
||||
}
|
||||
|
||||
c1Msgs := c1.messages()
|
||||
fireMsg := parseMsg(t, c1Msgs[len(c1Msgs)-1])
|
||||
if fireMsg[0] != float64(protocol.OpFireResult) || fireMsg[2] != float64(protocol.FireResultHit) {
|
||||
t.Fatalf("unexpected fireMsg: %v", fireMsg)
|
||||
}
|
||||
|
||||
// Turn stays with Player 1
|
||||
if match.State() != StatePlayer1Turn {
|
||||
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
|
||||
}
|
||||
|
||||
// Player 2 should not have received any new messages
|
||||
c2Msgs := c2.messages()
|
||||
// c2 messages before: [STATE 1], [STATE 2], [STATE 5]
|
||||
if len(c2Msgs) != 3 {
|
||||
t.Fatalf("p2 received unexpected message: %d messages", len(c2Msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireSunkMarksSurroundings(t *testing.T) {
|
||||
m, match, c1, _ := setupStartedGame(t)
|
||||
|
||||
// Ship 0 is 1 cell at (9, 4)
|
||||
res, err := m.Fire(context.Background(), match, 0, 9, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if res != protocol.FireResultSunk {
|
||||
t.Fatalf("res = %d, want FireResultSunk", res)
|
||||
}
|
||||
|
||||
c1Msgs := c1.messages()
|
||||
fireMsg := parseMsg(t, c1Msgs[len(c1Msgs)-1])
|
||||
if fireMsg[0] != float64(protocol.OpFireResult) || fireMsg[2] != float64(protocol.FireResultSunk) {
|
||||
t.Fatalf("unexpected fireMsg: %v", fireMsg)
|
||||
}
|
||||
|
||||
if match.ShipsRemaining(1) != 9 {
|
||||
t.Fatalf("shipsRemaining = %d, want 9", match.ShipsRemaining(1))
|
||||
}
|
||||
|
||||
// Check that (9, 4) and surrounding cells (8,3), (8,4), (8,5), (9,3), (9,5) are struck
|
||||
struck, _ := match.Struck(1)
|
||||
expectedCells := [][2]int{
|
||||
{9, 4},
|
||||
{8, 3}, {8, 4}, {8, 5},
|
||||
{9, 3}, {9, 5},
|
||||
}
|
||||
for _, pt := range expectedCells {
|
||||
if !struck[pt[1]][pt[0]] {
|
||||
t.Fatalf("expected cell (%d, %d) to be struck", pt[0], pt[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Turn stays with Player 1 because hit/sunk does not pass turn unless game over
|
||||
if match.State() != StatePlayer1Turn {
|
||||
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireInvalidPosition(t *testing.T) {
|
||||
m, match, c1, _ := setupStartedGame(t)
|
||||
|
||||
t.Run("out of bounds negative", func(t *testing.T) {
|
||||
res, err := m.Fire(context.Background(), match, 0, -1, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if res != protocol.FireResultInvalidPosition {
|
||||
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
|
||||
}
|
||||
msgs := c1.messages()
|
||||
msg := parseMsg(t, msgs[len(msgs)-1])
|
||||
if msg[0] != float64(protocol.OpFireResult) || msg[2] != float64(protocol.FireResultInvalidPosition) {
|
||||
t.Fatalf("unexpected msg: %v", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out of bounds over 9", func(t *testing.T) {
|
||||
res, err := m.Fire(context.Background(), match, 0, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if res != protocol.FireResultInvalidPosition {
|
||||
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already struck cell", func(t *testing.T) {
|
||||
// First shoot (9, 4) - sinks single ship
|
||||
res, err := m.Fire(context.Background(), match, 0, 9, 4)
|
||||
if err != nil || res != protocol.FireResultSunk {
|
||||
t.Fatalf("initial fire: res=%d, err=%v", res, err)
|
||||
}
|
||||
|
||||
// Shooting (9, 4) again returns InvalidPosition
|
||||
res, err = m.Fire(context.Background(), match, 0, 9, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("repeat fire: %v", err)
|
||||
}
|
||||
if res != protocol.FireResultInvalidPosition {
|
||||
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
|
||||
}
|
||||
|
||||
// Shooting diagonal neighbor (8, 5) which was marked struck due to sunk ship also returns InvalidPosition
|
||||
res, err = m.Fire(context.Background(), match, 0, 8, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("halo fire: %v", err)
|
||||
}
|
||||
if res != protocol.FireResultInvalidPosition {
|
||||
t.Fatalf("res = %d, want FireResultInvalidPosition", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFireIgnoredWhenNotYourTurn(t *testing.T) {
|
||||
m, match, c1, c2 := setupStartedGame(t)
|
||||
|
||||
// It's player 1's turn, player 2 tries to shoot
|
||||
c2CountBefore := len(c2.messages())
|
||||
res, err := m.Fire(context.Background(), match, 1, 0, 0)
|
||||
if err != ErrNotYourTurn {
|
||||
t.Fatalf("p2 fire: got err = %v, want ErrNotYourTurn", err)
|
||||
}
|
||||
if res != 0 {
|
||||
t.Fatalf("res = %d, want 0", res)
|
||||
}
|
||||
if len(c2.messages()) != c2CountBefore {
|
||||
t.Fatal("expected no messages sent to p2 on ignored fire")
|
||||
}
|
||||
|
||||
// Match state unchanged
|
||||
if match.State() != StatePlayer1Turn {
|
||||
t.Fatalf("state = %d, want StatePlayer1Turn", match.State())
|
||||
}
|
||||
_ = c1
|
||||
}
|
||||
|
||||
func TestFireWinCondition(t *testing.T) {
|
||||
m, match, c1, c2 := setupStartedGame(t)
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
|
||||
// Player 1 destroys all 10 ships of Player 2
|
||||
ships := validTestShips()
|
||||
for i, ship := range ships {
|
||||
cells := ship.Cells()
|
||||
for j, c := range cells {
|
||||
res, err := m.Fire(context.Background(), match, 0, c.X, c.Y)
|
||||
if err != nil {
|
||||
t.Fatalf("ship %d cell %d fire: %v", i, j, err)
|
||||
}
|
||||
if j < len(cells)-1 {
|
||||
if res != protocol.FireResultHit {
|
||||
t.Fatalf("ship %d cell %d: res = %d, want Hit", i, j, res)
|
||||
}
|
||||
} else {
|
||||
if res != protocol.FireResultSunk {
|
||||
t.Fatalf("ship %d cell %d: res = %d, want Sunk", i, j, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match should be removed from m.matches (preventing memory leak)
|
||||
if m.GetMatch(match.ID) != nil {
|
||||
t.Fatal("expected match to be removed from m.matches")
|
||||
}
|
||||
|
||||
// Check END messages sent to both players
|
||||
c1Msgs := c1.messages()
|
||||
// c1 receives FIRE_RESULT (sunk) followed by END (victory)
|
||||
c1End := parseMsg(t, c1Msgs[len(c1Msgs)-1])
|
||||
if c1End[0] != float64(protocol.OpEnd) || c1End[2] != float64(protocol.EndCodeVictory) || c1End[3] != "victory" {
|
||||
t.Fatalf("p1 got %v, want END with EndCodeVictory (1) 'victory'", c1End)
|
||||
}
|
||||
|
||||
c2Msgs := c2.messages()
|
||||
c2End := parseMsg(t, c2Msgs[len(c2Msgs)-1])
|
||||
if c2End[0] != float64(protocol.OpEnd) || c2End[2] != float64(protocol.EndCodeDefeat) || c2End[3] != "defeat" {
|
||||
t.Fatalf("p2 got %v, want END with EndCodeDefeat (2) 'defeat'", c2End)
|
||||
}
|
||||
|
||||
// Connections must be closed immediately with CloseCodeNormal
|
||||
if closed, code := c1.isClosed(); !closed || code != protocol.CloseCodeNormal {
|
||||
t.Fatalf("c1 closed=%v code=%d, want closed with %d", closed, code, protocol.CloseCodeNormal)
|
||||
}
|
||||
if closed, code := c2.isClosed(); !closed || code != protocol.CloseCodeNormal {
|
||||
t.Fatalf("c2 closed=%v code=%d, want closed with %d", closed, code, protocol.CloseCodeNormal)
|
||||
}
|
||||
|
||||
// Late reconnects get the respective END messages replayed
|
||||
c1Rejoin := &fakeConn{}
|
||||
_, _, err := m.Join(context.Background(), c1Rejoin, gc, gc.Player1ID)
|
||||
if err != ErrMatchFinished {
|
||||
t.Fatalf("rejoin p1: got err = %v, want ErrMatchFinished", err)
|
||||
}
|
||||
msgs := c1Rejoin.messages()
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("p1 got %d replay messages, want 1", len(msgs))
|
||||
}
|
||||
if v := parseMsg(t, msgs[0]); v[0] != float64(protocol.OpEnd) || v[2] != float64(protocol.EndCodeVictory) {
|
||||
t.Fatalf("p1 replayed msg = %v, want END victory", v)
|
||||
}
|
||||
|
||||
c2Rejoin := &fakeConn{}
|
||||
_, _, err = m.Join(context.Background(), c2Rejoin, gc, gc.Player2ID)
|
||||
if err != ErrMatchFinished {
|
||||
t.Fatalf("rejoin p2: got err = %v, want ErrMatchFinished", err)
|
||||
}
|
||||
msgs = c2Rejoin.messages()
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("p2 got %d replay messages, want 1", len(msgs))
|
||||
}
|
||||
if v := parseMsg(t, msgs[0]); v[0] != float64(protocol.OpEnd) || v[2] != float64(protocol.EndCodeDefeat) {
|
||||
t.Fatalf("p2 replayed msg = %v, want END defeat", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireAckAndReplay(t *testing.T) {
|
||||
m, match, _, _ := setupStartedGame(t)
|
||||
|
||||
// In setupStartedGame, c1 received 3 STATE messages (seq 1, 2, 3)
|
||||
// Player 1 fires: generates FIRE_RESULT with seq 4
|
||||
res, err := m.Fire(context.Background(), match, 0, 0, 0)
|
||||
if err != nil || res != protocol.FireResultHit {
|
||||
t.Fatalf("fire: res=%d, err=%v", res, err)
|
||||
}
|
||||
|
||||
// Ack seq 4
|
||||
if err := m.Ack(match, 0, 4); err != nil {
|
||||
t.Fatalf("ack: %v", err)
|
||||
}
|
||||
|
||||
// Player 1 fires again at (1, 0): generates FIRE_RESULT with seq 5
|
||||
res, err = m.Fire(context.Background(), match, 0, 1, 0)
|
||||
if err != nil || res != protocol.FireResultHit {
|
||||
t.Fatalf("fire 2: res=%d, err=%v", res, err)
|
||||
}
|
||||
|
||||
// Reconnect player 1 without ACKing seq 5
|
||||
fresh := &fakeConn{}
|
||||
gc := claims(time.Now().Add(time.Hour))
|
||||
if _, _, err := m.Join(context.Background(), fresh, gc, gc.Player1ID); err != nil {
|
||||
t.Fatalf("rejoin: %v", err)
|
||||
}
|
||||
|
||||
freshMsgs := fresh.messages()
|
||||
if len(freshMsgs) != 1 {
|
||||
t.Fatalf("rejoined player got %d messages, want 1", len(freshMsgs))
|
||||
}
|
||||
v := parseMsg(t, freshMsgs[0])
|
||||
// Should be replayed FIRE_RESULT [7, 5, 2]
|
||||
if v[0] != float64(protocol.OpFireResult) || v[1] != float64(5) || v[2] != float64(protocol.FireResultHit) {
|
||||
t.Fatalf("replayed msg = %v, want [7, 5, 2]", v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user