package game import ( "context" "encoding/json" "sync" "testing" "time" "github.com/golang-jwt/jwt/v5" "github.com/ruby-sea-battle/game-server/internal/auth" "github.com/ruby-sea-battle/game-server/internal/protocol" ) type fakeConn struct { mu sync.Mutex sent [][]byte closed bool closeCode int } func (f *fakeConn) Send(_ context.Context, data []byte) error { f.mu.Lock() defer f.mu.Unlock() cp := make([]byte, len(data)) copy(cp, data) f.sent = append(f.sent, cp) return nil } func (f *fakeConn) Close(code int, _ string) error { f.mu.Lock() defer f.mu.Unlock() f.closed = true f.closeCode = code return nil } func (f *fakeConn) messages() [][]byte { f.mu.Lock() defer f.mu.Unlock() out := make([][]byte, len(f.sent)) copy(out, f.sent) return out } func (f *fakeConn) isClosed() (bool, int) { f.mu.Lock() defer f.mu.Unlock() return f.closed, f.closeCode } func claims(exp time.Time) *auth.GameClaims { return &auth.GameClaims{ MatchID: "match-1", Player1ID: 10, Player2ID: 20, GameServer: "ws://game.example.com/ws", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(exp), }, } } func parseMsg(t *testing.T, data []byte) []any { t.Helper() var v []any if err := json.Unmarshal(data, &v); err != nil { t.Fatalf("unmarshal %s: %v", data, err) } return v } func TestJoinFirstPlayerWaits(t *testing.T) { m := NewManager(time.Minute) c1 := &fakeConn{} gc := claims(time.Now().Add(time.Hour)) _, idx, err := m.Join(context.Background(), c1, gc, gc.Player1ID) if err != nil { t.Fatalf("join: %v", err) } if idx != 0 { t.Fatalf("slot = %d, want 0", idx) } if got := len(c1.messages()); got != 0 { t.Fatalf("first player got %d messages, want 0", got) } } func TestSecondJoinStartsMatch(t *testing.T) { m := NewManager(time.Minute) gc := claims(time.Now().Add(time.Hour)) c1, c2 := &fakeConn{}, &fakeConn{} if _, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID); err != nil { t.Fatalf("join p1: %v", err) } if _, idx, err := m.Join(context.Background(), c2, gc, gc.Player2ID); err != nil { t.Fatalf("join p2: %v", err) } else if idx != 1 { t.Fatalf("slot = %d, want 1", idx) } for i, c := range []*fakeConn{c1, c2} { msgs := c.messages() if len(msgs) != 1 { t.Fatalf("player %d got %d messages, want 1", i, len(msgs)) } v := parseMsg(t, msgs[0]) // [3, seq, StatePlacingShips] if v[0] != float64(protocol.OpState) || v[1] != float64(1) || v[2] != float64(protocol.StatePlacingShips) { t.Fatalf("player %d got unexpected message: %v", i, v) } } } func TestJoinTimeoutEndsMatch(t *testing.T) { m := NewManager(50 * time.Millisecond) gc := claims(time.Now().Add(time.Hour)) c1 := &fakeConn{} if _, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID); err != nil { t.Fatalf("join: %v", err) } deadline := time.Now().Add(2 * time.Second) for { if closed, _ := c1.isClosed(); closed { break } if time.Now().After(deadline) { t.Fatal("timed out waiting for the match to end") } time.Sleep(10 * time.Millisecond) } msgs := c1.messages() if len(msgs) != 1 { t.Fatalf("got %d messages, want 1", len(msgs)) } v := parseMsg(t, msgs[0]) // [2, seq, -1, "join timeout exceeded"] if v[0] != float64(protocol.OpEnd) || v[1] != float64(1) || v[2] != float64(protocol.EndCodeJoinTimeout) || v[3] != "join timeout exceeded" { t.Fatalf("unexpected END message: %v", v) } if closed, code := c1.isClosed(); !closed || code != protocol.CloseCodeNormal { t.Fatalf("conn closed=%v code=%d, want closed with %d", closed, code, protocol.CloseCodeNormal) } } func TestJoinAfterFinishReplaysEnd(t *testing.T) { m := NewManager(50 * time.Millisecond) gc := claims(time.Now().Add(time.Hour)) c1 := &fakeConn{} if _, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID); err != nil { t.Fatalf("join: %v", err) } time.Sleep(200 * time.Millisecond) // let the join timer fire // A late join with the same (still valid) token must not create a new // match; the stored END is replayed instead. This also works for the // player who never connected at all. for _, userID := range []int64{gc.Player1ID, gc.Player2ID} { c := &fakeConn{} _, _, err := m.Join(context.Background(), c, gc, userID) if err != ErrMatchFinished { t.Fatalf("join after finish: err = %v, want ErrMatchFinished", err) } msgs := c.messages() if len(msgs) != 1 { t.Fatalf("replayed %d messages, want 1", len(msgs)) } v := parseMsg(t, msgs[0]) if v[0] != float64(protocol.OpEnd) || v[2] != float64(protocol.EndCodeJoinTimeout) { t.Fatalf("unexpected replayed message: %v", v) } if closed, _ := c.isClosed(); !closed { t.Fatal("connection was not closed after replaying END") } } } func TestReconnectReplacesConnection(t *testing.T) { m := NewManager(time.Minute) gc := claims(time.Now().Add(time.Hour)) old, c2 := &fakeConn{}, &fakeConn{} if _, _, err := m.Join(context.Background(), old, gc, gc.Player1ID); 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) } // Game started: both got STATE, nobody acknowledged it yet. // Player 1 reconnects: old connection must be closed, the unacknowledged // STATE must be replayed to the new one. fresh := &fakeConn{} if _, idx, err := m.Join(context.Background(), fresh, gc, gc.Player1ID); err != nil { t.Fatalf("rejoin p1: %v", err) } else if idx != 0 { t.Fatalf("slot = %d, want 0", idx) } if closed, code := old.isClosed(); !closed || code != protocol.CloseCodeReplaced { t.Fatalf("old conn closed=%v code=%d, want closed with %d", closed, code, protocol.CloseCodeReplaced) } msgs := fresh.messages() if len(msgs) != 1 { t.Fatalf("replayed %d messages, want 1", len(msgs)) } v := parseMsg(t, msgs[0]) if v[0] != float64(protocol.OpState) || v[2] != float64(protocol.StatePlacingShips) { t.Fatalf("unexpected replayed message: %v", v) } // Leave with the old connection must not detach the new one. m.Leave(old, mustMatch(t, m, gc.MatchID), 0) m.Leave(fresh, mustMatch(t, m, gc.MatchID), 0) } func TestAckThenReconnectResendsCurrentState(t *testing.T) { 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) } // Player 1 acknowledges the STATE message, then reconnects. The queue // is empty, so the server re-sends the current state with a fresh seq. if err := m.Ack(match, 0, 1); err != nil { t.Fatalf("ack: %v", err) } fresh := &fakeConn{} if _, _, err := m.Join(context.Background(), fresh, gc, gc.Player1ID); err != nil { t.Fatalf("rejoin p1: %v", err) } msgs := fresh.messages() if len(msgs) != 1 { t.Fatalf("got %d messages, want 1", len(msgs)) } v := parseMsg(t, msgs[0]) if v[0] != float64(protocol.OpState) || v[1] != float64(2) || v[2] != float64(protocol.StatePlacingShips) { t.Fatalf("unexpected message: %v", v) } } func TestServerStateTransitions(t *testing.T) { 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) } // Initial state is StatePlacingShips if match.State() != StatePlacingShips { t.Fatalf("state = %d, want StatePlacingShips", match.State()) } // Move to StatePlayer1Ready m.SetState(context.Background(), match, StatePlayer1Ready) p1Msgs, p2Msgs := c1.messages(), c2.messages() if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) { t.Fatalf("p1 got %v, want StateWaitOpponentReady (3)", v) } if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) { t.Fatalf("p2 got %v, want StateOpponentReady (2)", v) } // Move to StatePlayer1Turn m.SetState(context.Background(), match, StatePlayer1Turn) p1Msgs, p2Msgs = c1.messages(), c2.messages() if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateYourTurn) { t.Fatalf("p1 got %v, want StateYourTurn (4)", v) } if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) { t.Fatalf("p2 got %v, want StateWaitOpponentTurn (5)", v) } // Move to StatePlayer2Turn m.SetState(context.Background(), match, StatePlayer2Turn) p1Msgs, p2Msgs = c1.messages(), c2.messages() if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) { t.Fatalf("p1 got %v, want StateWaitOpponentTurn (5)", v) } if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateYourTurn) { t.Fatalf("p2 got %v, want StateYourTurn (4)", v) } } func TestAckValidation(t *testing.T) { 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) } // seq <= 0 should fail if err := m.Ack(match, 0, 0); err != ErrInvalidSeq { t.Fatalf("ack 0: got err = %v, want ErrInvalidSeq", err) } if err := m.Ack(match, 0, -1); err != ErrInvalidSeq { t.Fatalf("ack -1: got err = %v, want ErrInvalidSeq", err) } // seq > slot.seq (e.g. 2 when only 1 sent) should fail if err := m.Ack(match, 0, 2); err != ErrInvalidSeq { t.Fatalf("ack 2: got err = %v, want ErrInvalidSeq", err) } // seq == 1 should succeed if err := m.Ack(match, 0, 1); err != nil { t.Fatalf("ack 1: unexpected err = %v", err) } // Idempotent duplicate ack should succeed if err := m.Ack(match, 0, 1); err != nil { t.Fatalf("repeat ack 1: unexpected err = %v", err) } } func TestEndMatchCleansUpMatch(t *testing.T) { 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) } // Match started successfully and is in m.matches if m.GetMatch(gc.MatchID) == nil { t.Fatal("expected match in m.matches") } // End match m.EndMatch(match.ID, 0, "game completed") // Match should be deleted from m.matches (preventing memory leak) if m.GetMatch(gc.MatchID) != nil { t.Fatal("expected match to be deleted from m.matches") } // Both connections should be notified and closed with CloseCodeNormal for i, c := range []*fakeConn{c1, c2} { msgs := c.messages() lastMsg := parseMsg(t, msgs[len(msgs)-1]) if lastMsg[0] != float64(protocol.OpEnd) || lastMsg[3] != "game completed" { t.Fatalf("player %d unexpected END: %v", i, lastMsg) } if closed, code := c.isClosed(); !closed || code != protocol.CloseCodeNormal { t.Fatalf("player %d closed=%v code=%d, want closed with %d", i, closed, code, protocol.CloseCodeNormal) } } // Late join should replay END c3 := &fakeConn{} _, _, err = m.Join(context.Background(), c3, gc, gc.Player1ID) if err != ErrMatchFinished { t.Fatalf("err = %v, want ErrMatchFinished", err) } msgs := c3.messages() if len(msgs) != 1 { t.Fatalf("replayed %d messages, want 1", len(msgs)) } } func TestManagerShutdown(t *testing.T) { m := NewManager(time.Minute) gc := claims(time.Now().Add(time.Hour)) c1, c2 := &fakeConn{}, &fakeConn{} _, _, 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) } m.Shutdown() for i, c := range []*fakeConn{c1, c2} { if closed, code := c.isClosed(); !closed || code != protocol.CloseCodeGoingAway { t.Fatalf("player %d closed=%v code=%d, want closed with %d", i, closed, code, protocol.CloseCodeGoingAway) } } } func TestJoinWhenMatchAlreadyEndedReplaysReason(t *testing.T) { m := NewManager(time.Minute) gc := claims(time.Now().Add(time.Hour)) c1 := &fakeConn{} _, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID) if err != nil { t.Fatalf("join: %v", err) } // Trigger onJoinTimeout so match is ended m.onJoinTimeout(gc.MatchID) // Late join on ended match must send the reason before closing c2 := &fakeConn{} _, _, err = m.Join(context.Background(), c2, gc, gc.Player2ID) if err != ErrMatchFinished { t.Fatalf("got err = %v, want ErrMatchFinished", err) } msgs := c2.messages() if len(msgs) != 1 { t.Fatalf("got %d messages, want 1", len(msgs)) } v := parseMsg(t, msgs[0]) if v[0] != float64(protocol.OpEnd) || v[2] != float64(protocol.EndCodeJoinTimeout) || v[3] != "join timeout exceeded" { t.Fatalf("unexpected message: %v", v) } if closed, code := c2.isClosed(); !closed || code != protocol.CloseCodeNormal { t.Fatalf("c2 closed=%v code=%d, want %d", closed, code, protocol.CloseCodeNormal) } } func mustMatch(t *testing.T, m *Manager, id string) *Match { t.Helper() m.mu.Lock() defer m.mu.Unlock() match, ok := m.matches[id] if !ok { t.Fatalf("match %q not found", id) } return match } func validTestShips() []protocol.Ship { return []protocol.Ship{ // 4 size 1 {Orientation: protocol.OrientationHorizontal, X: 9, Y: 4, Size: 1}, {Orientation: protocol.OrientationVertical, X: 7, Y: 4, Size: 1}, {Orientation: protocol.OrientationHorizontal, X: 5, Y: 4, Size: 1}, {Orientation: protocol.OrientationHorizontal, X: 3, Y: 4, Size: 1}, // 3 size 2 {Orientation: protocol.OrientationHorizontal, X: 0, Y: 4, Size: 2}, {Orientation: protocol.OrientationHorizontal, X: 7, Y: 2, Size: 2}, {Orientation: protocol.OrientationHorizontal, X: 4, Y: 2, Size: 2}, // 2 size 3 {Orientation: protocol.OrientationHorizontal, X: 0, Y: 2, Size: 3}, {Orientation: protocol.OrientationHorizontal, X: 5, Y: 0, Size: 3}, // 1 size 4 {Orientation: protocol.OrientationHorizontal, X: 0, Y: 0, Size: 4}, } } func TestPlaceShipsP1First(t *testing.T) { 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) } if match.State() != StatePlacingShips { t.Fatalf("state = %d, want StatePlacingShips", match.State()) } ships := validTestShips() // Player 1 places ships first if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil { t.Fatalf("p1 place ships: %v", err) } if match.State() != StatePlayer1Ready { t.Fatalf("state = %d, want StatePlayer1Ready (2)", match.State()) } // Verify stored ships and board for p1 p1Ships := match.Ships(0) if len(p1Ships) != len(ships) { t.Fatalf("got %d ships, want %d", len(p1Ships), len(ships)) } p1Board, placed := match.Board(0) if !placed { t.Fatal("expected p1 board placed to be true") } // Check that ship 9 (size 4 at 0,0 horizontal) is in p1 board with id 10 for x := 0; x < 4; x++ { if p1Board[0][x] != 10 { t.Fatalf("p1Board[0][%d] = %d, want 10", x, p1Board[0][x]) } } // Verify states delivered to p1 and p2 p1Msgs, p2Msgs := c1.messages(), c2.messages() if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) { t.Fatalf("p1 got state %v, want StateWaitOpponentReady (3)", v) } if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) { t.Fatalf("p2 got state %v, want StateOpponentReady (2)", v) } // Player 1 cannot place ships again if err := m.PlaceShips(context.Background(), match, 0, ships); err == nil { t.Fatal("expected error when player 1 places ships again") } // Player 2 places ships 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 (4)", match.State()) } // Verify states delivered to p1 and p2 after game starts p1Msgs, p2Msgs = c1.messages(), c2.messages() if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateYourTurn) { t.Fatalf("p1 got state %v, want StateYourTurn (4)", v) } if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) { t.Fatalf("p2 got state %v, want StateWaitOpponentTurn (5)", v) } // Subsequent PlaceShips calls are rejected if err := m.PlaceShips(context.Background(), match, 1, ships); err == nil { t.Fatal("expected error when match is in turn phase") } } func TestPlaceShipsP2First(t *testing.T) { 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() // Player 2 places ships first if err := m.PlaceShips(context.Background(), match, 1, ships); err != nil { t.Fatalf("p2 place ships: %v", err) } if match.State() != StatePlayer2Ready { t.Fatalf("state = %d, want StatePlayer2Ready (3)", match.State()) } // Verify states delivered to p1 and p2 p1Msgs, p2Msgs := c1.messages(), c2.messages() if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateOpponentReady) { t.Fatalf("p1 got state %v, want StateOpponentReady (2)", v) } if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentReady) { t.Fatalf("p2 got state %v, want StateWaitOpponentReady (3)", v) } // Player 1 places ships if err := m.PlaceShips(context.Background(), match, 0, ships); err != nil { t.Fatalf("p1 place ships: %v", err) } if match.State() != StatePlayer2Turn { t.Fatalf("state = %d, want StatePlayer2Turn (5)", match.State()) } // Player 2 finished first, so Player 2 gets first turn! p1Msgs, p2Msgs = c1.messages(), c2.messages() if v := parseMsg(t, p1Msgs[len(p1Msgs)-1]); v[2] != float64(protocol.StateWaitOpponentTurn) { t.Fatalf("p1 got state %v, want StateWaitOpponentTurn (5)", v) } if v := parseMsg(t, p2Msgs[len(p2Msgs)-1]); v[2] != float64(protocol.StateYourTurn) { t.Fatalf("p2 got state %v, want StateYourTurn (4)", v) } } func TestPlaceShipsValidationFailure(t *testing.T) { 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) } invalidShips := validTestShips() invalidShips[0].X = invalidShips[1].X // overlapping ships if err := m.PlaceShips(context.Background(), match, 0, invalidShips); err == nil { t.Fatal("expected validation error") } if match.State() != StatePlacingShips { t.Fatalf("state changed to %d on error, want StatePlacingShips", match.State()) } if match.Ships(0) != nil { t.Fatal("expected nil ships for p1 after failed placement") } } func TestPlaceShipsBeforeMatchStarts(t *testing.T) { m := NewManager(time.Minute) gc := claims(time.Now().Add(time.Hour)) c1 := &fakeConn{} match, _, err := m.Join(context.Background(), c1, gc, gc.Player1ID) if err != nil { t.Fatalf("join p1: %v", err) } if err := m.PlaceShips(context.Background(), match, 0, validTestShips()); err != ErrMatchNotStarted { 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) } }