feat: "multiple words per turn" rule for Russian games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s

Add a per-game rule chosen on New Game for Russian variants (default off = the
single-word rule; on = standard Scrabble). Off, only the main word along the play
direction is validated and scored; perpendicular cross-words are ignored,
including in robot move generation. The rule rides every create and enqueue
request and joins the matchmaking key, so games and auto-match stay one uniform
path; "Russian-only" is a UI affordance (English always sends standard and shows
no toggle).

- Engine: consume scrabble-solver v1.1.0's PlayOptions{IgnoreCrossWords}, threaded
  through engine.Options.MultipleWordsPerTurn -> playOpts() into validate, score
  and generate.
- Backend: thread the flag through game CreateParams/Game + store (games column),
  lobby InvitationSettings + invitation row, and the matchmaker queue key (variant
  + rule); persisted, so a rebuilt-from-journal game keeps it. Baseline migration
  gains multiple_words_per_turn (DB not versioned); jet regenerated.
- Edge: multiple_words_per_turn added to the EnqueueRequest / CreateInvitationRequest
  FlatBuffers tables (Go + TS regenerated) and threaded through the gateway.
- UI: a "Multiple words per turn" toggle on New Game, shown for Russian variants
  only (auto-match and friend invite), default off; English silently sends standard.
- Tests: backend engine/matchmaker; UI unit (gating) + Playwright e2e (solver
  corner-case + GCG fixtures ship in v1.1.0). Docs + PRERELEASE tracker updated.
This commit is contained in:
Ilia Denisov
2026-06-12 02:17:30 +02:00
parent d4a1616d03
commit 74455c7b12
46 changed files with 643 additions and 296 deletions
+2 -1
View File
@@ -29,7 +29,8 @@ that auto-resigns overdue turns (honouring each player's daily away window). Lik
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
The lobby and social fabric. `internal/lobby` holds an in-memory
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
matchmaking pool (FIFO per variant and per-turn word rule, pairs two humans into an
auto-match) and
friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
+1 -1
View File
@@ -3,7 +3,7 @@ module scrabble/backend
go 1.26.3
require (
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0
github.com/XSAM/otelsql v0.42.0
github.com/gin-gonic/gin v1.12.0
github.com/go-jet/jet/v2 v2.14.1
+38 -23
View File
@@ -92,6 +92,12 @@ type Options struct {
// DropoutTiles is the disposition of a dropped-out player's tiles in a game
// with three or more seats; the zero value removes them from play.
DropoutTiles DropoutTiles
// MultipleWordsPerTurn selects standard Scrabble when true: every cross-word a
// play forms must be a valid word and is scored. When false the game uses the
// "single word per turn" rule — only the main word is validated and scored and
// perpendicular cross-words are ignored. Callers always set this explicitly; the
// zero value (false) is the single-word rule.
MultipleWordsPerTurn bool
}
// Game is the in-memory state of a single match and the pure rules engine over
@@ -104,17 +110,18 @@ type Game struct {
variant Variant
version string
board *board.Board
bag *Bag
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
scores []int
toMove int
scorelessRun int
over bool
reason EndReason
resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
log []MoveRecord
board *board.Board
bag *Bag
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
scores []int
toMove int
scorelessRun int
over bool
reason EndReason
resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
log []MoveRecord
}
// New starts a game described by opts over a dictionary from reg. It resolves
@@ -140,16 +147,17 @@ func New(reg *Registry, opts Options) (*Game, error) {
rs := solver.Rules()
g := &Game{
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles,
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles,
multipleWords: opts.MultipleWordsPerTurn,
}
for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -157,6 +165,13 @@ func New(reg *Registry, opts Options) (*Game, error) {
return g, nil
}
// playOpts returns the solver play options for this game's rules. Under the single-word
// rule (multipleWords false) the solver ignores perpendicular cross-words: only the main
// word is validated and scored, and move generation is not constrained by cross-words.
func (g *Game) playOpts() scrabble.PlayOptions {
return scrabble.PlayOptions{IgnoreCrossWords: !g.multipleWords}
}
// Play validates and applies the current player's placement of tiles forming a
// word in direction dir. It scores the play, refills the rack from the bag,
// advances the turn and may end the game. It returns ErrTilesNotOnRack when the
@@ -170,7 +185,7 @@ func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRec
if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
return MoveRecord{}, err
}
move, err := g.solver.ValidatePlay(g.board, dir, tiles)
move, err := g.solver.ValidatePlayOpts(g.board, dir, tiles, g.playOpts())
if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
@@ -279,7 +294,7 @@ func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
// GenerateMoves returns every legal play for the current player's rack, ranked
// by descending score. It is empty when the player has no legal play.
func (g *Game) GenerateMoves() []scrabble.Move {
return g.solver.GenerateMoves(g.board, g.rackOf(g.toMove), scrabble.Both)
return g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
}
// Hint returns the highest-scoring legal play for the current player and true,
@@ -0,0 +1,48 @@
package engine
import "testing"
// TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The
// single-word game ignores perpendicular cross-words, so move generation from a shared
// position is a superset of the standard game's; the standard game does not relax them.
func TestSingleWordRuleWiring(t *testing.T) {
const seed = 7
mk := func(multipleWords bool) *Game {
g, err := New(testReg, Options{
Variant: VariantEnglish,
Version: testVersion,
Players: 2,
Seed: seed,
MultipleWordsPerTurn: multipleWords,
})
if err != nil {
t.Fatalf("new game: %v", err)
}
return g
}
std, single := mk(true), mk(false)
if std.playOpts().IgnoreCrossWords {
t.Error("standard game must not ignore cross-words")
}
if !single.playOpts().IgnoreCrossWords {
t.Error("single-word game must ignore cross-words")
}
// Play the same opening (the standard game's top move) in both games, then compare
// the next player's candidate moves. Both games share the seed, so the next rack is
// identical; relaxed (single-word) generation never drops a legal standard move.
hint, ok := std.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := std.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("standard opening: %v", err)
}
if _, err := single.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("single-word opening: %v", err)
}
stdMoves, singleMoves := len(std.GenerateMoves()), len(single.GenerateMoves())
if singleMoves < stdMoves {
t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves)
}
}
+22 -19
View File
@@ -107,11 +107,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
seed = svc.rng()
}
g, err := engine.New(svc.registry, engine.Options{
Variant: params.Variant,
Version: svc.version,
Players: len(params.Seats),
Seed: seed,
DropoutTiles: params.DropoutTiles,
Variant: params.Variant,
Version: svc.version,
Players: len(params.Seats),
Seed: seed,
DropoutTiles: params.DropoutTiles,
MultipleWordsPerTurn: params.MultipleWordsPerTurn,
})
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
@@ -125,15 +126,16 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
return Game{}, fmt.Errorf("game: new id: %w", err)
}
ins := gameInsert{
id: id,
variant: params.Variant.String(),
dictVersion: svc.version,
seed: seed,
players: len(params.Seats),
turnTimeoutSecs: int(timeout / time.Second),
hintsAllowed: params.HintsAllowed,
hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(),
id: id,
variant: params.Variant.String(),
dictVersion: svc.version,
seed: seed,
players: len(params.Seats),
turnTimeoutSecs: int(timeout / time.Second),
hintsAllowed: params.HintsAllowed,
hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn,
}
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
return Game{}, err
@@ -934,11 +936,12 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
return nil, err
}
g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
DropoutTiles: pre.DropoutTiles,
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
})
if err != nil {
return nil, err
+5 -2
View File
@@ -38,6 +38,8 @@ type gameInsert struct {
hintsAllowed bool
hintsPerPlayer int
dropoutTiles string
// multipleWordsPerTurn false selects the single-word rule for the game.
multipleWordsPerTurn bool
}
// statDelta is one account's contribution to its statistics on a game finish.
@@ -92,8 +94,8 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUI
gi := table.Games.INSERT(
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
table.Games.DropoutTiles,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles)
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.multipleWordsPerTurn)
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
}
@@ -761,6 +763,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
}
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
if g.EndReason != nil {
out.EndReason = *g.EndReason
}
+5
View File
@@ -80,6 +80,9 @@ type CreateParams struct {
HintsPerPlayer int // starting per-seat hint allowance
DropoutTiles engine.DropoutTiles // disposition of a dropped-out seat's tiles (3+ players); zero → remove
Seed int64 // zero → a random seed is chosen
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule —
// only the main word is validated and scored. Russian games default to false.
MultipleWordsPerTurn bool
}
// Game is the persisted state of a match: the games row joined with its seats.
@@ -101,6 +104,8 @@ type Game struct {
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
MultipleWordsPerTurn bool
}
// Seat is one player's standing in a game.
+2 -2
View File
@@ -35,14 +35,14 @@ func TestMatchmakingPairsAndStartsGame(t *testing.T) {
mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second)
a, b := provisionAccount(t), provisionAccount(t)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must wait")
}
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
+1 -1
View File
@@ -150,7 +150,7 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
human := provisionAccount(t)
before := time.Now()
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish)
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
+28 -21
View File
@@ -49,6 +49,8 @@ type InvitationSettings struct {
HintsAllowed bool
HintsPerPlayer int
DropoutTiles engine.DropoutTiles
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
MultipleWordsPerTurn bool
}
// Invitee is one invited player's seat and response.
@@ -214,14 +216,15 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
return Invitation{}, fmt.Errorf("lobby: new invitation id: %w", err)
}
ins := invitationInsert{
id: id,
inviterID: inviterID,
variant: settings.Variant.String(),
turnTimeoutSecs: int(settings.TurnTimeout / time.Second),
hintsAllowed: settings.HintsAllowed,
hintsPerPlayer: settings.HintsPerPlayer,
dropoutTiles: settings.DropoutTiles.String(),
expiresAt: svc.now().Add(invitationTTL),
id: id,
inviterID: inviterID,
variant: settings.Variant.String(),
turnTimeoutSecs: int(settings.TurnTimeout / time.Second),
hintsAllowed: settings.HintsAllowed,
hintsPerPlayer: settings.HintsPerPlayer,
dropoutTiles: settings.DropoutTiles.String(),
multipleWordsPerTurn: settings.MultipleWordsPerTurn,
expiresAt: svc.now().Add(invitationTTL),
}
if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
return Invitation{}, err
@@ -265,12 +268,13 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
seats[iv.Seat] = iv.AccountID
}
g, err := svc.games.Create(ctx, game.CreateParams{
Variant: inv.Settings.Variant,
Seats: seats,
TurnTimeout: inv.Settings.TurnTimeout,
HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles,
Variant: inv.Settings.Variant,
Seats: seats,
TurnTimeout: inv.Settings.TurnTimeout,
HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles,
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
})
if err != nil {
return err
@@ -322,6 +326,8 @@ type invitationInsert struct {
hintsPerPlayer int
dropoutTiles string
expiresAt time.Time
// multipleWordsPerTurn false selects the single-word rule.
multipleWordsPerTurn bool
}
// respondResult reports the state after an invitee response.
@@ -335,8 +341,8 @@ func (s *Store) insertInvitation(ctx context.Context, ins invitationInsert, invi
ii := table.GameInvitations.INSERT(
table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant,
table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer,
table.GameInvitations.DropoutTiles, table.GameInvitations.ExpiresAt,
).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.expiresAt)
table.GameInvitations.DropoutTiles, table.GameInvitations.MultipleWordsPerTurn, table.GameInvitations.ExpiresAt,
).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.expiresAt)
if _, err := ii.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert invitation: %w", err)
}
@@ -377,11 +383,12 @@ func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, e
ID: row.InvitationID,
InviterID: row.InviterID,
Settings: InvitationSettings{
Variant: variant,
TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second,
HintsAllowed: row.HintsAllowed,
HintsPerPlayer: int(row.HintsPerPlayer),
DropoutTiles: dropout,
Variant: variant,
TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second,
HintsAllowed: row.HintsAllowed,
HintsPerPlayer: int(row.HintsPerPlayer),
DropoutTiles: dropout,
MultipleWordsPerTurn: row.MultipleWordsPerTurn,
},
Status: row.Status,
GameID: row.GameID,
+49 -36
View File
@@ -14,6 +14,14 @@ import (
"scrabble/backend/internal/notify"
)
// matchKey buckets the auto-match pool: two players are paired only when they chose
// the same variant and the same per-turn word rule (multipleWords), so a game always
// starts under a rule both players asked for.
type matchKey struct {
variant engine.Variant
multipleWords bool
}
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
// the next two humans into a two-player game, or — when no human arrives within
// the wait window — substitutes a robot. It holds no database state and is lost on
@@ -35,8 +43,8 @@ type Matchmaker struct {
log *zap.Logger
mu sync.Mutex
queues map[engine.Variant][]uuid.UUID
queued map[uuid.UUID]engine.Variant
queues map[matchKey][]uuid.UUID
queued map[uuid.UUID]matchKey
waitingSince map[uuid.UUID]time.Time
results map[uuid.UUID]game.Game
rng *rand.Rand
@@ -55,8 +63,8 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat
clock: func() time.Time { return time.Now().UTC() },
pub: notify.Nop{},
log: log,
queues: make(map[engine.Variant][]uuid.UUID),
queued: make(map[uuid.UUID]engine.Variant),
queues: make(map[matchKey][]uuid.UUID),
queued: make(map[uuid.UUID]matchKey),
waitingSince: make(map[uuid.UUID]time.Time),
results: make(map[uuid.UUID]game.Game),
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
@@ -101,34 +109,36 @@ type EnqueueResult struct {
Game game.Game
}
// Enqueue joins accountID to the variant pool. If an opponent already waits, the
// two are paired (seat order randomised for first-move fairness) and a game starts
// immediately; otherwise the account waits, and a later pairing or robot
// substitution is delivered through Poll. An account already waiting in any pool
// gets ErrAlreadyQueued.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) {
// Enqueue joins accountID to the auto-match pool for variant under the chosen
// per-turn word rule (multipleWords). If an opponent already waits for the same
// variant and rule, the two are paired (seat order randomised for first-move
// fairness) and a game starts immediately; otherwise the account waits, and a later
// pairing or robot substitution is delivered through Poll. An account already waiting
// in any pool gets ErrAlreadyQueued.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
key := matchKey{variant: variant, multipleWords: multipleWords}
m.mu.Lock()
if _, ok := m.queued[accountID]; ok {
m.mu.Unlock()
return EnqueueResult{}, ErrAlreadyQueued
}
q := m.queues[variant]
q := m.queues[key]
if len(q) == 0 {
m.queues[variant] = append(q, accountID)
m.queued[accountID] = variant
m.queues[key] = append(q, accountID)
m.queued[accountID] = key
m.waitingSince[accountID] = m.clock()
m.mu.Unlock()
return EnqueueResult{}, nil
}
opponent := q[0]
m.removeLocked(opponent, variant)
m.removeLocked(opponent, key)
seats := []uuid.UUID{opponent, accountID}
if m.rng.Intn(2) == 0 {
seats[0], seats[1] = seats[1], seats[0]
}
m.mu.Unlock()
g, err := m.games.Create(ctx, autoMatchParams(variant, seats))
g, err := m.games.Create(ctx, autoMatchParams(key, seats))
if err != nil {
return EnqueueResult{}, err
}
@@ -161,19 +171,21 @@ func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.results, accountID)
variant, ok := m.queued[accountID]
key, ok := m.queued[accountID]
if !ok {
return false
}
m.removeLocked(accountID, variant)
m.removeLocked(accountID, key)
return true
}
// QueueLen returns the number of accounts waiting in the variant pool.
// QueueLen returns the number of accounts waiting in the variant pool, summed across
// both per-turn word rules.
func (m *Matchmaker) QueueLen(variant engine.Variant) int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.queues[variant])
return len(m.queues[matchKey{variant: variant, multipleWords: false}]) +
len(m.queues[matchKey{variant: variant, multipleWords: true}])
}
// RunReaper substitutes a robot for any player that has waited past waitDelay,
@@ -198,9 +210,9 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
// momentarily empty pool just defers substitution to a later tick.
func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
type sub struct {
human uuid.UUID
variant engine.Variant
seats []uuid.UUID
human uuid.UUID
key matchKey
seats []uuid.UUID
}
m.mu.Lock()
var due []uuid.UUID
@@ -211,23 +223,23 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
}
var subs []sub
for _, acc := range due {
variant := m.queued[acc]
robotID, err := m.robots.Pick(variant)
key := m.queued[acc]
robotID, err := m.robots.Pick(key.variant)
if err != nil {
m.log.Warn("robot substitution deferred", zap.Error(err))
continue
}
m.removeLocked(acc, variant)
m.removeLocked(acc, key)
seats := []uuid.UUID{acc, robotID}
if m.rng.Intn(2) == 0 {
seats[0], seats[1] = seats[1], seats[0]
}
subs = append(subs, sub{human: acc, variant: variant, seats: seats})
subs = append(subs, sub{human: acc, key: key, seats: seats})
}
m.mu.Unlock()
for _, s := range subs {
g, err := m.games.Create(ctx, autoMatchParams(s.variant, s.seats))
g, err := m.games.Create(ctx, autoMatchParams(s.key, s.seats))
if err != nil {
m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err))
continue
@@ -241,13 +253,13 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
// removeLocked drops accountID from the queue, the queued index and the waiting
// clock. The caller holds m.mu.
func (m *Matchmaker) removeLocked(accountID uuid.UUID, variant engine.Variant) {
func (m *Matchmaker) removeLocked(accountID uuid.UUID, key matchKey) {
delete(m.queued, accountID)
delete(m.waitingSince, accountID)
q := m.queues[variant]
q := m.queues[key]
for i, id := range q {
if id == accountID {
m.queues[variant] = append(q[:i], q[i+1:]...)
m.queues[key] = append(q[:i], q[i+1:]...)
break
}
}
@@ -255,12 +267,13 @@ func (m *Matchmaker) removeLocked(accountID uuid.UUID, variant engine.Variant) {
// autoMatchParams builds the create parameters for a two-player auto-match with
// the casual defaults.
func autoMatchParams(variant engine.Variant, seats []uuid.UUID) game.CreateParams {
func autoMatchParams(key matchKey, seats []uuid.UUID) game.CreateParams {
return game.CreateParams{
Variant: variant,
Seats: seats,
TurnTimeout: game.DefaultTurnTimeout,
HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer,
Variant: key.variant,
Seats: seats,
TurnTimeout: game.DefaultTurnTimeout,
HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer,
MultipleWordsPerTurn: key.multipleWords,
}
}
+66 -12
View File
@@ -80,7 +80,7 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) {
ctx := context.Background()
a, b := uuid.New(), uuid.New()
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
@@ -91,7 +91,7 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) {
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish))
}
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
@@ -129,10 +129,10 @@ func TestMatchmakerAlreadyQueued(t *testing.T) {
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); !errors.Is(err, ErrAlreadyQueued) {
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); !errors.Is(err, ErrAlreadyQueued) {
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
}
}
@@ -141,7 +141,7 @@ func TestMatchmakerCancel(t *testing.T) {
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
if !mm.Cancel(ctx, a) {
@@ -159,10 +159,10 @@ func TestMatchmakerVariantsAreSeparate(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish); err != nil {
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue en: %v", err)
}
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble); err != nil {
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil {
t.Fatalf("enqueue ru: %v", err)
}
if len(creator.created) != 0 {
@@ -179,7 +179,7 @@ func TestMatchmakerFIFO(t *testing.T) {
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 {
if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue %s: %v", id, err)
}
}
@@ -204,7 +204,7 @@ func TestMatchmakerReaperSubstitutesRobot(t *testing.T) {
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
@@ -237,7 +237,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Cancel(ctx, a)
@@ -258,7 +258,7 @@ func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
@@ -276,7 +276,7 @@ func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
@@ -287,3 +287,57 @@ func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
t.Errorf("waiter must stay queued when substitution is deferred; len %d", mm.QueueLen(engine.VariantEnglish))
}
}
// TestMatchmakerRulesAreSeparate confirms two players who chose the same variant but a
// different per-turn word rule are not paired, and that the rule reaches the started game.
func TestMatchmakerRulesAreSeparate(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background()
// Same variant, opposite rules: they must not match.
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil {
t.Fatalf("enqueue single-word: %v", err)
}
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil {
t.Fatalf("enqueue standard: %v", err)
}
if len(creator.created) != 0 {
t.Fatalf("different rules must not match; created %d", len(creator.created))
}
// A second single-word player pairs with the first; the game carries the rule.
r, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false)
if err != nil {
t.Fatalf("enqueue single-word opponent: %v", err)
}
if !r.Matched {
t.Fatal("same variant and rule must match")
}
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1", len(creator.created))
}
if creator.created[0].MultipleWordsPerTurn {
t.Error("single-word match must create a game with MultipleWordsPerTurn=false")
}
}
// TestMatchmakerReaperKeepsRule confirms a robot substitution carries the waiter's rule.
func TestMatchmakerReaperKeepsRule(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1", len(creator.created))
}
if creator.created[0].MultipleWordsPerTurn {
t.Error("robot substitution must keep the waiter's single-word rule")
}
}
@@ -13,16 +13,17 @@ import (
)
type GameInvitations struct {
InvitationID uuid.UUID `sql:"primary_key"`
InviterID uuid.UUID
Variant string
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
DropoutTiles string
Status string
GameID *uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
InvitationID uuid.UUID `sql:"primary_key"`
InviterID uuid.UUID
Variant string
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
DropoutTiles string
MultipleWordsPerTurn bool
Status string
GameID *uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -13,21 +13,22 @@ import (
)
type Games struct {
GameID uuid.UUID `sql:"primary_key"`
Variant string
DictVersion string
Seed int64
Status string
Players int16
ToMove int16
TurnStartedAt time.Time
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
MoveCount int32
EndReason *string
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
DropoutTiles string
GameID uuid.UUID `sql:"primary_key"`
Variant string
DictVersion string
Seed int64
Status string
Players int16
ToMove int16
TurnStartedAt time.Time
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
MoveCount int32
EndReason *string
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
DropoutTiles string
MultipleWordsPerTurn bool
}
@@ -17,18 +17,19 @@ type gameInvitationsTable struct {
postgres.Table
// Columns
InvitationID postgres.ColumnString
InviterID postgres.ColumnString
Variant postgres.ColumnString
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
DropoutTiles postgres.ColumnString
Status postgres.ColumnString
GameID postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
InvitationID postgres.ColumnString
InviterID postgres.ColumnString
Variant postgres.ColumnString
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
Status postgres.ColumnString
GameID postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -70,39 +71,41 @@ func newGameInvitationsTable(schemaName, tableName, alias string) *GameInvitatio
func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvitationsTable {
var (
InvitationIDColumn = postgres.StringColumn("invitation_id")
InviterIDColumn = postgres.StringColumn("inviter_id")
VariantColumn = postgres.StringColumn("variant")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
StatusColumn = postgres.StringColumn("status")
GameIDColumn = postgres.StringColumn("game_id")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
InvitationIDColumn = postgres.StringColumn("invitation_id")
InviterIDColumn = postgres.StringColumn("inviter_id")
VariantColumn = postgres.StringColumn("variant")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
StatusColumn = postgres.StringColumn("status")
GameIDColumn = postgres.StringColumn("game_id")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
)
return gameInvitationsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
InvitationID: InvitationIDColumn,
InviterID: InviterIDColumn,
Variant: VariantColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
DropoutTiles: DropoutTilesColumn,
Status: StatusColumn,
GameID: GameIDColumn,
ExpiresAt: ExpiresAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
InvitationID: InvitationIDColumn,
InviterID: InviterIDColumn,
Variant: VariantColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
Status: StatusColumn,
GameID: GameIDColumn,
ExpiresAt: ExpiresAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -17,23 +17,24 @@ type gamesTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Variant postgres.ColumnString
DictVersion postgres.ColumnString
Seed postgres.ColumnInteger
Status postgres.ColumnString
Players postgres.ColumnInteger
ToMove postgres.ColumnInteger
TurnStartedAt postgres.ColumnTimestampz
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
MoveCount postgres.ColumnInteger
EndReason postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString
GameID postgres.ColumnString
Variant postgres.ColumnString
DictVersion postgres.ColumnString
Seed postgres.ColumnInteger
Status postgres.ColumnString
Players postgres.ColumnInteger
ToMove postgres.ColumnInteger
TurnStartedAt postgres.ColumnTimestampz
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
MoveCount postgres.ColumnInteger
EndReason postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -75,49 +76,51 @@ func newGamesTable(schemaName, tableName, alias string) *GamesTable {
func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
VariantColumn = postgres.StringColumn("variant")
DictVersionColumn = postgres.StringColumn("dict_version")
SeedColumn = postgres.IntegerColumn("seed")
StatusColumn = postgres.StringColumn("status")
PlayersColumn = postgres.IntegerColumn("players")
ToMoveColumn = postgres.IntegerColumn("to_move")
TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
MoveCountColumn = postgres.IntegerColumn("move_count")
EndReasonColumn = postgres.StringColumn("end_reason")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn}
GameIDColumn = postgres.StringColumn("game_id")
VariantColumn = postgres.StringColumn("variant")
DictVersionColumn = postgres.StringColumn("dict_version")
SeedColumn = postgres.IntegerColumn("seed")
StatusColumn = postgres.StringColumn("status")
PlayersColumn = postgres.IntegerColumn("players")
ToMoveColumn = postgres.IntegerColumn("to_move")
TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
MoveCountColumn = postgres.IntegerColumn("move_count")
EndReasonColumn = postgres.StringColumn("end_reason")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
)
return gamesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Variant: VariantColumn,
DictVersion: DictVersionColumn,
Seed: SeedColumn,
Status: StatusColumn,
Players: PlayersColumn,
ToMove: ToMoveColumn,
TurnStartedAt: TurnStartedAtColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
MoveCount: MoveCountColumn,
EndReason: EndReasonColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
DropoutTiles: DropoutTilesColumn,
GameID: GameIDColumn,
Variant: VariantColumn,
DictVersion: DictVersionColumn,
Seed: SeedColumn,
Status: StatusColumn,
Players: PlayersColumn,
ToMove: ToMoveColumn,
TurnStartedAt: TurnStartedAtColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
MoveCount: MoveCountColumn,
EndReason: EndReasonColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -97,6 +97,7 @@ CREATE TABLE games (
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true,
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
@@ -260,6 +261,7 @@ CREATE TABLE game_invitations (
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true,
status text NOT NULL DEFAULT 'pending',
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
+34 -30
View File
@@ -27,17 +27,18 @@ type invitationInviteeDTO struct {
// invitationDTO is a friend-game invitation with its settings and invitees.
type invitationDTO struct {
ID string `json:"id"`
Inviter accountRefDTO `json:"inviter"`
Invitees []invitationInviteeDTO `json:"invitees"`
Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"`
Status string `json:"status"`
GameID string `json:"game_id,omitempty"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
ID string `json:"id"`
Inviter accountRefDTO `json:"inviter"`
Invitees []invitationInviteeDTO `json:"invitees"`
Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
Status string `json:"status"`
GameID string `json:"game_id,omitempty"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
}
// invitationListDTO is the caller's open invitations.
@@ -47,27 +48,29 @@ type invitationListDTO struct {
// createInvitationRequest proposes a friend game to the named invitees.
type createInvitationRequest struct {
InviteeIDs []string `json:"invitee_ids"`
Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"`
InviteeIDs []string `json:"invitee_ids"`
Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
}
// invitationDTOFrom projects a lobby invitation, resolving names through memo.
func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, memo map[string]string) invitationDTO {
dto := invitationDTO{
ID: inv.ID.String(),
Inviter: s.namedRef(ctx, inv.InviterID, memo),
Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)),
Variant: inv.Settings.Variant.String(),
TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()),
HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles.String(),
Status: inv.Status,
ExpiresAtUnix: inv.ExpiresAt.Unix(),
ID: inv.ID.String(),
Inviter: s.namedRef(ctx, inv.InviterID, memo),
Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)),
Variant: inv.Settings.Variant.String(),
TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()),
HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles.String(),
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
Status: inv.Status,
ExpiresAtUnix: inv.ExpiresAt.Unix(),
}
if inv.GameID != nil {
dto.GameID = inv.GameID.String()
@@ -102,9 +105,10 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
return
}
settings := lobby.InvitationSettings{
Variant: variant,
HintsAllowed: req.HintsAllowed,
HintsPerPlayer: req.HintsPerPlayer,
Variant: variant,
HintsAllowed: req.HintsAllowed,
HintsPerPlayer: req.HintsPerPlayer,
MultipleWordsPerTurn: req.MultipleWordsPerTurn,
}
if req.TurnTimeoutSecs > 0 {
settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second
+4 -3
View File
@@ -133,9 +133,10 @@ func (s *Server) handleGameState(c *gin.Context) {
c.JSON(http.StatusOK, dto)
}
// enqueueRequest joins the per-variant auto-match pool.
// enqueueRequest joins the per-variant auto-match pool under a per-turn word rule.
type enqueueRequest struct {
Variant string `json:"variant"`
Variant string `json:"variant"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
}
// handleEnqueue joins the auto-match pool for a variant.
@@ -155,7 +156,7 @@ func (s *Server) handleEnqueue(c *gin.Context) {
abortBadRequest(c, "unknown variant")
return
}
res, err := s.matchmaker.Enqueue(c.Request.Context(), uid, variant)
res, err := s.matchmaker.Enqueue(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
if err != nil {
s.abortErr(c, err)
return