bfa8797f8c
Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged. New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer). Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
152 lines
4.2 KiB
Go
152 lines
4.2 KiB
Go
package lobby
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
)
|
|
|
|
// fakeCreator records the games a matchmaker asks it to start.
|
|
type fakeCreator struct {
|
|
created []game.CreateParams
|
|
err error
|
|
}
|
|
|
|
func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, error) {
|
|
if f.err != nil {
|
|
return game.Game{}, f.err
|
|
}
|
|
f.created = append(f.created, p)
|
|
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
|
}
|
|
|
|
func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
|
|
for _, w := range want {
|
|
found := false
|
|
for _, s := range seats {
|
|
if s == w {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func TestMatchmakerPairsTwoHumans(t *testing.T) {
|
|
creator := &fakeCreator{}
|
|
mm := NewMatchmaker(creator)
|
|
ctx := context.Background()
|
|
a, b := uuid.New(), uuid.New()
|
|
|
|
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
|
|
if err != nil {
|
|
t.Fatalf("enqueue a: %v", err)
|
|
}
|
|
if r1.Matched {
|
|
t.Fatal("first enqueue must wait, not match")
|
|
}
|
|
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
|
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish))
|
|
}
|
|
|
|
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
|
|
if err != nil {
|
|
t.Fatalf("enqueue b: %v", err)
|
|
}
|
|
if !r2.Matched {
|
|
t.Fatal("second enqueue must match")
|
|
}
|
|
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
|
t.Fatalf("queue len = %d, want 0 after match", mm.QueueLen(engine.VariantEnglish))
|
|
}
|
|
if len(creator.created) != 1 {
|
|
t.Fatalf("created %d games, want 1", len(creator.created))
|
|
}
|
|
p := creator.created[0]
|
|
if len(p.Seats) != 2 || !seatsContain(p.Seats, a, b) {
|
|
t.Errorf("seats = %v, want both %s and %s", p.Seats, a, b)
|
|
}
|
|
if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer {
|
|
t.Errorf("auto-match defaults not applied: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestMatchmakerAlreadyQueued(t *testing.T) {
|
|
mm := NewMatchmaker(&fakeCreator{})
|
|
ctx := context.Background()
|
|
a := uuid.New()
|
|
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); !errors.Is(err, ErrAlreadyQueued) {
|
|
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
|
|
}
|
|
}
|
|
|
|
func TestMatchmakerCancel(t *testing.T) {
|
|
mm := NewMatchmaker(&fakeCreator{})
|
|
ctx := context.Background()
|
|
a := uuid.New()
|
|
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
if !mm.Cancel(ctx, a) {
|
|
t.Fatal("cancel of a queued account must report true")
|
|
}
|
|
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
|
t.Fatalf("queue len = %d, want 0 after cancel", mm.QueueLen(engine.VariantEnglish))
|
|
}
|
|
if mm.Cancel(ctx, a) {
|
|
t.Fatal("cancel of an unqueued account must report false")
|
|
}
|
|
}
|
|
|
|
func TestMatchmakerVariantsAreSeparate(t *testing.T) {
|
|
creator := &fakeCreator{}
|
|
mm := NewMatchmaker(creator)
|
|
ctx := context.Background()
|
|
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish); err != nil {
|
|
t.Fatalf("enqueue en: %v", err)
|
|
}
|
|
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble); err != nil {
|
|
t.Fatalf("enqueue ru: %v", err)
|
|
}
|
|
if len(creator.created) != 0 {
|
|
t.Fatalf("different variants must not match; created %d", len(creator.created))
|
|
}
|
|
if mm.QueueLen(engine.VariantEnglish) != 1 || mm.QueueLen(engine.VariantRussianScrabble) != 1 {
|
|
t.Errorf("each variant pool should hold one waiter")
|
|
}
|
|
}
|
|
|
|
func TestMatchmakerFIFO(t *testing.T) {
|
|
creator := &fakeCreator{}
|
|
mm := NewMatchmaker(creator)
|
|
ctx := context.Background()
|
|
a, b, c := uuid.New(), uuid.New(), uuid.New()
|
|
for _, id := range []uuid.UUID{a, b, c} {
|
|
if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish); err != nil {
|
|
t.Fatalf("enqueue %s: %v", id, err)
|
|
}
|
|
}
|
|
// a waited, b matched a (oldest), c waits.
|
|
if len(creator.created) != 1 {
|
|
t.Fatalf("created %d games, want 1", len(creator.created))
|
|
}
|
|
if !seatsContain(creator.created[0].Seats, a, b) {
|
|
t.Errorf("FIFO match should pair a and b, got %v", creator.created[0].Seats)
|
|
}
|
|
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
|
t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish))
|
|
}
|
|
}
|