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. m.Ack(match, 0, 1) 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 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 }