create a websocket game server
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user