Multiple words per turn rule for Russian games #46

Merged
developer merged 1 commits from feature/single-word-rule into development 2026-06-12 07:22:55 +00:00
46 changed files with 643 additions and 296 deletions
+1
View File
@@ -25,6 +25,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** | | R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
| R7 | Final stress run + tuning | 9b | **done** | | R7 | Final stress run + tuning | 9b | **done** |
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | | UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase) ## Key findings (these reshaped the raw list — read before starting a phase)
+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 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 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 friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept), 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 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 go 1.26.3
require ( 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/XSAM/otelsql v0.42.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-jet/jet/v2 v2.14.1 github.com/go-jet/jet/v2 v2.14.1
+17 -2
View File
@@ -92,6 +92,12 @@ type Options struct {
// DropoutTiles is the disposition of a dropped-out player's tiles in a game // 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. // with three or more seats; the zero value removes them from play.
DropoutTiles DropoutTiles 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 // Game is the in-memory state of a single match and the pure rules engine over
@@ -114,6 +120,7 @@ type Game struct {
reason EndReason reason EndReason
resigned []bool // per seat; a resigned seat is skipped and cannot win resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
log []MoveRecord log []MoveRecord
} }
@@ -150,6 +157,7 @@ func New(reg *Registry, opts Options) (*Game, error) {
scores: make([]int, opts.Players), scores: make([]int, opts.Players),
resigned: make([]bool, opts.Players), resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles, dropoutTiles: opts.DropoutTiles,
multipleWords: opts.MultipleWordsPerTurn,
} }
for i := range g.hands { for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize) g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -157,6 +165,13 @@ func New(reg *Registry, opts Options) (*Game, error) {
return g, nil 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 // 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, // 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 // 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 { if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
return MoveRecord{}, err 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 { if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) 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 // 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. // by descending score. It is empty when the player has no legal play.
func (g *Game) GenerateMoves() []scrabble.Move { 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, // 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)
}
}
+3
View File
@@ -112,6 +112,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
Players: len(params.Seats), Players: len(params.Seats),
Seed: seed, Seed: seed,
DropoutTiles: params.DropoutTiles, DropoutTiles: params.DropoutTiles,
MultipleWordsPerTurn: params.MultipleWordsPerTurn,
}) })
if err != nil { if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) { if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
@@ -134,6 +135,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
hintsAllowed: params.HintsAllowed, hintsAllowed: params.HintsAllowed,
hintsPerPlayer: params.HintsPerPlayer, hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(), dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn,
} }
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil { if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
return Game{}, err return Game{}, err
@@ -939,6 +941,7 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
Players: pre.Players, Players: pre.Players,
Seed: seed, Seed: seed,
DropoutTiles: pre.DropoutTiles, DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
+5 -2
View File
@@ -38,6 +38,8 @@ type gameInsert struct {
hintsAllowed bool hintsAllowed bool
hintsPerPlayer int hintsPerPlayer int
dropoutTiles string 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. // 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( gi := table.Games.INSERT(
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed, 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.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
table.Games.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) ).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 { if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err) return fmt.Errorf("insert game: %w", err)
} }
@@ -761,6 +763,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
CreatedAt: g.CreatedAt, CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt, UpdatedAt: g.UpdatedAt,
} }
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
if g.EndReason != nil { if g.EndReason != nil {
out.EndReason = *g.EndReason out.EndReason = *g.EndReason
} }
+5
View File
@@ -80,6 +80,9 @@ type CreateParams struct {
HintsPerPlayer int // starting per-seat hint allowance HintsPerPlayer int // starting per-seat hint allowance
DropoutTiles engine.DropoutTiles // disposition of a dropped-out seat's tiles (3+ players); zero → remove DropoutTiles engine.DropoutTiles // disposition of a dropped-out seat's tiles (3+ players); zero → remove
Seed int64 // zero → a random seed is chosen 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. // 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 CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
FinishedAt *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. // 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) mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second)
a, b := provisionAccount(t), provisionAccount(t) 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 { if err != nil {
t.Fatalf("enqueue a: %v", err) t.Fatalf("enqueue a: %v", err)
} }
if r1.Matched { if r1.Matched {
t.Fatal("first enqueue must wait") 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 { if err != nil {
t.Fatalf("enqueue b: %v", err) t.Fatalf("enqueue b: %v", err)
} }
+1 -1
View File
@@ -150,7 +150,7 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
human := provisionAccount(t) human := provisionAccount(t)
before := time.Now() before := time.Now()
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish) r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true)
if err != nil { if err != nil {
t.Fatalf("enqueue: %v", err) t.Fatalf("enqueue: %v", err)
} }
+9 -2
View File
@@ -49,6 +49,8 @@ type InvitationSettings struct {
HintsAllowed bool HintsAllowed bool
HintsPerPlayer int HintsPerPlayer int
DropoutTiles engine.DropoutTiles DropoutTiles engine.DropoutTiles
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
MultipleWordsPerTurn bool
} }
// Invitee is one invited player's seat and response. // Invitee is one invited player's seat and response.
@@ -221,6 +223,7 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
hintsAllowed: settings.HintsAllowed, hintsAllowed: settings.HintsAllowed,
hintsPerPlayer: settings.HintsPerPlayer, hintsPerPlayer: settings.HintsPerPlayer,
dropoutTiles: settings.DropoutTiles.String(), dropoutTiles: settings.DropoutTiles.String(),
multipleWordsPerTurn: settings.MultipleWordsPerTurn,
expiresAt: svc.now().Add(invitationTTL), expiresAt: svc.now().Add(invitationTTL),
} }
if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil { if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
@@ -271,6 +274,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
HintsAllowed: inv.Settings.HintsAllowed, HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer, HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles, DropoutTiles: inv.Settings.DropoutTiles,
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
}) })
if err != nil { if err != nil {
return err return err
@@ -322,6 +326,8 @@ type invitationInsert struct {
hintsPerPlayer int hintsPerPlayer int
dropoutTiles string dropoutTiles string
expiresAt time.Time expiresAt time.Time
// multipleWordsPerTurn false selects the single-word rule.
multipleWordsPerTurn bool
} }
// respondResult reports the state after an invitee response. // 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( ii := table.GameInvitations.INSERT(
table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant, table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant,
table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer, table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer,
table.GameInvitations.DropoutTiles, table.GameInvitations.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.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 { if _, err := ii.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert invitation: %w", err) return fmt.Errorf("insert invitation: %w", err)
} }
@@ -382,6 +388,7 @@ func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, e
HintsAllowed: row.HintsAllowed, HintsAllowed: row.HintsAllowed,
HintsPerPlayer: int(row.HintsPerPlayer), HintsPerPlayer: int(row.HintsPerPlayer),
DropoutTiles: dropout, DropoutTiles: dropout,
MultipleWordsPerTurn: row.MultipleWordsPerTurn,
}, },
Status: row.Status, Status: row.Status,
GameID: row.GameID, GameID: row.GameID,
+43 -30
View File
@@ -14,6 +14,14 @@ import (
"scrabble/backend/internal/notify" "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 // 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 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 // 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 log *zap.Logger
mu sync.Mutex mu sync.Mutex
queues map[engine.Variant][]uuid.UUID queues map[matchKey][]uuid.UUID
queued map[uuid.UUID]engine.Variant queued map[uuid.UUID]matchKey
waitingSince map[uuid.UUID]time.Time waitingSince map[uuid.UUID]time.Time
results map[uuid.UUID]game.Game results map[uuid.UUID]game.Game
rng *rand.Rand rng *rand.Rand
@@ -55,8 +63,8 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat
clock: func() time.Time { return time.Now().UTC() }, clock: func() time.Time { return time.Now().UTC() },
pub: notify.Nop{}, pub: notify.Nop{},
log: log, log: log,
queues: make(map[engine.Variant][]uuid.UUID), queues: make(map[matchKey][]uuid.UUID),
queued: make(map[uuid.UUID]engine.Variant), queued: make(map[uuid.UUID]matchKey),
waitingSince: make(map[uuid.UUID]time.Time), waitingSince: make(map[uuid.UUID]time.Time),
results: make(map[uuid.UUID]game.Game), results: make(map[uuid.UUID]game.Game),
rng: rand.New(rand.NewSource(time.Now().UnixNano())), rng: rand.New(rand.NewSource(time.Now().UnixNano())),
@@ -101,34 +109,36 @@ type EnqueueResult struct {
Game game.Game Game game.Game
} }
// Enqueue joins accountID to the variant pool. If an opponent already waits, the // Enqueue joins accountID to the auto-match pool for variant under the chosen
// two are paired (seat order randomised for first-move fairness) and a game starts // per-turn word rule (multipleWords). If an opponent already waits for the same
// immediately; otherwise the account waits, and a later pairing or robot // variant and rule, the two are paired (seat order randomised for first-move
// substitution is delivered through Poll. An account already waiting in any pool // fairness) and a game starts immediately; otherwise the account waits, and a later
// gets ErrAlreadyQueued. // pairing or robot substitution is delivered through Poll. An account already waiting
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) { // 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() m.mu.Lock()
if _, ok := m.queued[accountID]; ok { if _, ok := m.queued[accountID]; ok {
m.mu.Unlock() m.mu.Unlock()
return EnqueueResult{}, ErrAlreadyQueued return EnqueueResult{}, ErrAlreadyQueued
} }
q := m.queues[variant] q := m.queues[key]
if len(q) == 0 { if len(q) == 0 {
m.queues[variant] = append(q, accountID) m.queues[key] = append(q, accountID)
m.queued[accountID] = variant m.queued[accountID] = key
m.waitingSince[accountID] = m.clock() m.waitingSince[accountID] = m.clock()
m.mu.Unlock() m.mu.Unlock()
return EnqueueResult{}, nil return EnqueueResult{}, nil
} }
opponent := q[0] opponent := q[0]
m.removeLocked(opponent, variant) m.removeLocked(opponent, key)
seats := []uuid.UUID{opponent, accountID} seats := []uuid.UUID{opponent, accountID}
if m.rng.Intn(2) == 0 { if m.rng.Intn(2) == 0 {
seats[0], seats[1] = seats[1], seats[0] seats[0], seats[1] = seats[1], seats[0]
} }
m.mu.Unlock() m.mu.Unlock()
g, err := m.games.Create(ctx, autoMatchParams(variant, seats)) g, err := m.games.Create(ctx, autoMatchParams(key, seats))
if err != nil { if err != nil {
return EnqueueResult{}, err return EnqueueResult{}, err
} }
@@ -161,19 +171,21 @@ func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
delete(m.results, accountID) delete(m.results, accountID)
variant, ok := m.queued[accountID] key, ok := m.queued[accountID]
if !ok { if !ok {
return false return false
} }
m.removeLocked(accountID, variant) m.removeLocked(accountID, key)
return true 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 { func (m *Matchmaker) QueueLen(variant engine.Variant) int {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() 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, // RunReaper substitutes a robot for any player that has waited past waitDelay,
@@ -199,7 +211,7 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
type sub struct { type sub struct {
human uuid.UUID human uuid.UUID
variant engine.Variant key matchKey
seats []uuid.UUID seats []uuid.UUID
} }
m.mu.Lock() m.mu.Lock()
@@ -211,23 +223,23 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
} }
var subs []sub var subs []sub
for _, acc := range due { for _, acc := range due {
variant := m.queued[acc] key := m.queued[acc]
robotID, err := m.robots.Pick(variant) robotID, err := m.robots.Pick(key.variant)
if err != nil { if err != nil {
m.log.Warn("robot substitution deferred", zap.Error(err)) m.log.Warn("robot substitution deferred", zap.Error(err))
continue continue
} }
m.removeLocked(acc, variant) m.removeLocked(acc, key)
seats := []uuid.UUID{acc, robotID} seats := []uuid.UUID{acc, robotID}
if m.rng.Intn(2) == 0 { if m.rng.Intn(2) == 0 {
seats[0], seats[1] = seats[1], seats[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() m.mu.Unlock()
for _, s := range subs { 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 { if err != nil {
m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err)) m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err))
continue 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 // removeLocked drops accountID from the queue, the queued index and the waiting
// clock. The caller holds m.mu. // 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.queued, accountID)
delete(m.waitingSince, accountID) delete(m.waitingSince, accountID)
q := m.queues[variant] q := m.queues[key]
for i, id := range q { for i, id := range q {
if id == accountID { if id == accountID {
m.queues[variant] = append(q[:i], q[i+1:]...) m.queues[key] = append(q[:i], q[i+1:]...)
break 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 // autoMatchParams builds the create parameters for a two-player auto-match with
// the casual defaults. // 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{ return game.CreateParams{
Variant: variant, Variant: key.variant,
Seats: seats, Seats: seats,
TurnTimeout: game.DefaultTurnTimeout, TurnTimeout: game.DefaultTurnTimeout,
HintsAllowed: autoMatchHintsAllowed, HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer, HintsPerPlayer: autoMatchHintsPerPlayer,
MultipleWordsPerTurn: key.multipleWords,
} }
} }
+66 -12
View File
@@ -80,7 +80,7 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) {
ctx := context.Background() ctx := context.Background()
a, b := uuid.New(), uuid.New() 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 { if err != nil {
t.Fatalf("enqueue a: %v", err) 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)) 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 { if err != nil {
t.Fatalf("enqueue b: %v", err) t.Fatalf("enqueue b: %v", err)
} }
@@ -129,10 +129,10 @@ func TestMatchmakerAlreadyQueued(t *testing.T) {
mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
ctx := context.Background() ctx := context.Background()
a := uuid.New() 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) 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) t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
} }
} }
@@ -141,7 +141,7 @@ func TestMatchmakerCancel(t *testing.T) {
mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
ctx := context.Background() ctx := context.Background()
a := uuid.New() 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) t.Fatalf("enqueue: %v", err)
} }
if !mm.Cancel(ctx, a) { if !mm.Cancel(ctx, a) {
@@ -159,10 +159,10 @@ func TestMatchmakerVariantsAreSeparate(t *testing.T) {
creator := &fakeCreator{} creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New()) mm := newTestMatchmaker(creator, uuid.New())
ctx := context.Background() 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) 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) t.Fatalf("enqueue ru: %v", err)
} }
if len(creator.created) != 0 { if len(creator.created) != 0 {
@@ -179,7 +179,7 @@ func TestMatchmakerFIFO(t *testing.T) {
ctx := context.Background() ctx := context.Background()
a, b, c := uuid.New(), uuid.New(), uuid.New() a, b, c := uuid.New(), uuid.New(), uuid.New()
for _, id := range []uuid.UUID{a, b, c} { 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) t.Fatalf("enqueue %s: %v", id, err)
} }
} }
@@ -204,7 +204,7 @@ func TestMatchmakerReaperSubstitutesRobot(t *testing.T) {
ctx := context.Background() ctx := context.Background()
a := uuid.New() 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) t.Fatalf("enqueue: %v", err)
} }
@@ -237,7 +237,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
ctx := context.Background() ctx := context.Background()
a := uuid.New() 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) t.Fatalf("enqueue: %v", err)
} }
mm.Cancel(ctx, a) mm.Cancel(ctx, a)
@@ -258,7 +258,7 @@ func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
ctx := context.Background() ctx := context.Background()
a := uuid.New() 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) t.Fatalf("enqueue: %v", err)
} }
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result 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() ctx := context.Background()
a := uuid.New() 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) t.Fatalf("enqueue: %v", err)
} }
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) 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)) 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")
}
}
@@ -20,6 +20,7 @@ type GameInvitations struct {
HintsAllowed bool HintsAllowed bool
HintsPerPlayer int16 HintsPerPlayer int16
DropoutTiles string DropoutTiles string
MultipleWordsPerTurn bool
Status string Status string
GameID *uuid.UUID GameID *uuid.UUID
ExpiresAt time.Time ExpiresAt time.Time
@@ -30,4 +30,5 @@ type Games struct {
UpdatedAt time.Time UpdatedAt time.Time
FinishedAt *time.Time FinishedAt *time.Time
DropoutTiles string DropoutTiles string
MultipleWordsPerTurn bool
} }
@@ -24,6 +24,7 @@ type gameInvitationsTable struct {
HintsAllowed postgres.ColumnBool HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger HintsPerPlayer postgres.ColumnInteger
DropoutTiles postgres.ColumnString DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
Status postgres.ColumnString Status postgres.ColumnString
GameID postgres.ColumnString GameID postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz ExpiresAt postgres.ColumnTimestampz
@@ -77,14 +78,15 @@ func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvita
HintsAllowedColumn = postgres.BoolColumn("hints_allowed") HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player") HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles") DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
StatusColumn = postgres.StringColumn("status") StatusColumn = postgres.StringColumn("status")
GameIDColumn = postgres.StringColumn("game_id") GameIDColumn = postgres.StringColumn("game_id")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at") ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn} 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, 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, StatusColumn, CreatedAtColumn, UpdatedAtColumn} defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
) )
return gameInvitationsTable{ return gameInvitationsTable{
@@ -98,6 +100,7 @@ func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvita
HintsAllowed: HintsAllowedColumn, HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn, HintsPerPlayer: HintsPerPlayerColumn,
DropoutTiles: DropoutTilesColumn, DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
Status: StatusColumn, Status: StatusColumn,
GameID: GameIDColumn, GameID: GameIDColumn,
ExpiresAt: ExpiresAtColumn, ExpiresAt: ExpiresAtColumn,
@@ -34,6 +34,7 @@ type gamesTable struct {
UpdatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz FinishedAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -92,9 +93,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
UpdatedAtColumn = postgres.TimestampzColumn("updated_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at") FinishedAtColumn = postgres.TimestampzColumn("finished_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles") 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} MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn} allColumns = postgres.ColumnList{GameIDColumn, 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} 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{ return gamesTable{
@@ -118,6 +120,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
UpdatedAt: UpdatedAtColumn, UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn, FinishedAt: FinishedAtColumn,
DropoutTiles: DropoutTilesColumn, DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -97,6 +97,7 @@ CREATE TABLE games (
updated_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz, finished_at timestamptz,
dropout_tiles text NOT NULL DEFAULT 'remove', 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_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')), CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4), 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_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1, hints_per_player smallint NOT NULL DEFAULT 1,
dropout_tiles text NOT NULL DEFAULT 'remove', dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true,
status text NOT NULL DEFAULT 'pending', status text NOT NULL DEFAULT 'pending',
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL, game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
@@ -35,6 +35,7 @@ type invitationDTO struct {
HintsAllowed bool `json:"hints_allowed"` HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"` HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"` DropoutTiles string `json:"dropout_tiles"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
Status string `json:"status"` Status string `json:"status"`
GameID string `json:"game_id,omitempty"` GameID string `json:"game_id,omitempty"`
ExpiresAtUnix int64 `json:"expires_at_unix"` ExpiresAtUnix int64 `json:"expires_at_unix"`
@@ -53,6 +54,7 @@ type createInvitationRequest struct {
HintsAllowed bool `json:"hints_allowed"` HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"` HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"` DropoutTiles string `json:"dropout_tiles"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
} }
// invitationDTOFrom projects a lobby invitation, resolving names through memo. // invitationDTOFrom projects a lobby invitation, resolving names through memo.
@@ -66,6 +68,7 @@ func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, me
HintsAllowed: inv.Settings.HintsAllowed, HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer, HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles.String(), DropoutTiles: inv.Settings.DropoutTiles.String(),
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
Status: inv.Status, Status: inv.Status,
ExpiresAtUnix: inv.ExpiresAt.Unix(), ExpiresAtUnix: inv.ExpiresAt.Unix(),
} }
@@ -105,6 +108,7 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
Variant: variant, Variant: variant,
HintsAllowed: req.HintsAllowed, HintsAllowed: req.HintsAllowed,
HintsPerPlayer: req.HintsPerPlayer, HintsPerPlayer: req.HintsPerPlayer,
MultipleWordsPerTurn: req.MultipleWordsPerTurn,
} }
if req.TurnTimeoutSecs > 0 { if req.TurnTimeoutSecs > 0 {
settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second
+3 -2
View File
@@ -133,9 +133,10 @@ func (s *Server) handleGameState(c *gin.Context) {
c.JSON(http.StatusOK, dto) 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 { 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. // handleEnqueue joins the auto-match pool for a variant.
@@ -155,7 +156,7 @@ func (s *Server) handleEnqueue(c *gin.Context) {
abortBadRequest(c, "unknown variant") abortBadRequest(c, "unknown variant")
return 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 { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
+11
View File
@@ -282,6 +282,17 @@ Key points:
- **Word legality: validate-at-submit.** An illegal play is rejected by - **Word legality: validate-at-submit.** An illegal play is rejected by
`Solver.ValidatePlay`; there is no challenge phase. `Solver.ValidatePlay`; there is no challenge phase.
- **Multiple words per turn (Russian games).** Russian variants carry a per-game
**single-word rule**, chosen on New Game (default **off** = single word; 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; on, every
cross-word must be a real word and is scored. The engine threads it as
`scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.0`); connectivity and the
first-move centre rule are unaffected. The "Russian-only" limit is a **UI affordance**:
the backend and engine are variant-agnostic about the flag, and English games always send
it on (standard). For auto-match the rule is part of the matchmaking key, so only players
who chose the same rule are paired (the rule field rides every create/enqueue request, so
matchmaking stays one uniform path).
- **End of game**: the bag is empty **and** a player empties their rack, **or** - **End of game**: the bag is empty **and** a player empties their rack, **or**
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or **6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
a missed turn. The **per-game turn timeout** is chosen at creation a missed turn. The **per-game turn timeout** is chosen at creation
+6 -1
View File
@@ -85,7 +85,12 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language. Auto-match invitation — so a player still sees and plays existing games of any language. Auto-match
(always 2 players) joins a per-variant pool and is paired with the next waiting human; (always 2 players) joins a per-variant pool and is paired with the next waiting human;
after 10 s with no human the robot substitutes. Friend games (24) are after 10 s with no human the robot substitutes. For Russian games (auto-match or friend
invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays
the simplified **single-word rule** — only the word laid along the player's line must be a
real word, and any incidental perpendicular words are ignored and not scored — while on is
standard Scrabble. English games are always standard and show no such toggle. In auto-match
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (24) are
formed by inviting players from the friend list (an invitation, like a friend code, formed by inviting players from the friend list (an invitation, like a friend code,
is shareable as a Telegram deep link that opens it directly): the inviter chooses the is shareable as a Telegram deep link that opens it directly): the inviter chooses the
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
+7 -1
View File
@@ -89,7 +89,13 @@ nudge) приходят от бота **этой партии** — по язы
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока) любом языке. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот. Игры с друзьями (2–4) без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются;
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга, формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
+3 -1
View File
@@ -182,7 +182,9 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
- **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept / - **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept /
Decline for an invitee and a waiting/Cancel state for the inviter; creating one is the Decline for an invitee and a waiting/Cancel state for the inviter; creating one is the
**"Play with friends"** mode in `NewGame.svelte` (pick invitees, then variant / move **"Play with friends"** mode in `NewGame.svelte` (pick invitees, then variant / move
time / hints). time / hints). For a **Russian** variant (auto-match or invite) a **"Multiple words per
turn"** checkbox (`.toggle`, **default off** = the single-word rule) appears; English
variants never show it.
- **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat
cards (wins / losses / draws / games / win-rate / best game / best move) — pure cards (wins / losses / draws / games / win-rate / best game / best move) — pure
numbers, no charts. numbers, no charts.
+4 -3
View File
@@ -245,11 +245,12 @@ func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAl
return out, err return out, err
} }
// Enqueue joins the auto-match pool for a variant. // Enqueue joins the auto-match pool for a variant under a per-turn word rule
func (c *Client) Enqueue(ctx context.Context, userID, variant string) (MatchResp, error) { // (multipleWords true is standard Scrabble, false the single-word rule).
func (c *Client) Enqueue(ctx context.Context, userID, variant string, multipleWords bool) (MatchResp, error) {
var out MatchResp var out MatchResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "", err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "",
map[string]string{"variant": variant}, &out) map[string]any{"variant": variant, "multiple_words_per_turn": multipleWords}, &out)
return out, err return out, err
} }
@@ -99,6 +99,8 @@ type InvitationParams struct {
HintsAllowed bool HintsAllowed bool
HintsPerPlayer int HintsPerPlayer int
DropoutTiles string DropoutTiles string
// MultipleWordsPerTurn true is standard Scrabble; false the single-word rule.
MultipleWordsPerTurn bool
} }
// --- friends --- // --- friends ---
@@ -195,6 +197,8 @@ func (c *Client) CreateInvitation(ctx context.Context, userID string, p Invitati
"hints_allowed": p.HintsAllowed, "hints_allowed": p.HintsAllowed,
"hints_per_player": p.HintsPerPlayer, "hints_per_player": p.HintsPerPlayer,
"dropout_tiles": p.DropoutTiles, "dropout_tiles": p.DropoutTiles,
"multiple_words_per_turn": p.MultipleWordsPerTurn,
} }
err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations", userID, "", body, &out) err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations", userID, "", body, &out)
return out, err return out, err
+1 -1
View File
@@ -224,7 +224,7 @@ func gameStateHandler(backend *backendclient.Client) Handler {
func enqueueHandler(backend *backendclient.Client) Handler { func enqueueHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEnqueueRequest(req.Payload, 0) in := fb.GetRootAsEnqueueRequest(req.Payload, 0)
m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant())) m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant()), in.MultipleWordsPerTurn())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -205,6 +205,8 @@ func invitationCreateHandler(backend *backendclient.Client) Handler {
HintsAllowed: in.HintsAllowed(), HintsAllowed: in.HintsAllowed(),
HintsPerPlayer: int(in.HintsPerPlayer()), HintsPerPlayer: int(in.HintsPerPlayer()),
DropoutTiles: string(in.DropoutTiles()), DropoutTiles: string(in.DropoutTiles()),
MultipleWordsPerTurn: in.MultipleWordsPerTurn(),
} }
res, err := backend.CreateInvitation(ctx, req.UserID, params) res, err := backend.CreateInvitation(ctx, req.UserID, params)
if err != nil { if err != nil {
+2
View File
@@ -6,6 +6,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 h1:ntN6m4cOB+4FelleO2nkAIZp8WSc+v25neetzfdUuuw= gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 h1:ntN6m4cOB+4FelleO2nkAIZp8WSc+v25neetzfdUuuw=
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY= gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 h1:92jWbAZ5IK3ROrn1g3FjY0wZjeYpVWOJsl/GGT5HN1U=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.3
require ( require (
connectrpc.com/connect v1.19.2 connectrpc.com/connect v1.19.2
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0
github.com/google/flatbuffers v23.5.26+incompatible github.com/google/flatbuffers v23.5.26+incompatible
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/iliadenisov/dafsa v1.1.0 github.com/iliadenisov/dafsa v1.1.0
+4 -1
View File
@@ -272,9 +272,11 @@ table GameList {
// --- lobby (authenticated) --- // --- lobby (authenticated) ---
// EnqueueRequest joins the per-variant auto-match pool. // EnqueueRequest joins the auto-match pool for a variant under a per-turn word rule.
// multiple_words_per_turn true is standard Scrabble; false is the single-word rule.
table EnqueueRequest { table EnqueueRequest {
variant:string; variant:string;
multiple_words_per_turn:bool;
} }
// MatchResult reports whether the caller has been paired into a game yet. // MatchResult reports whether the caller has been paired into a game yet.
@@ -457,6 +459,7 @@ table CreateInvitationRequest {
hints_allowed:bool; hints_allowed:bool;
hints_per_player:int; hints_per_player:int;
dropout_tiles:string; dropout_tiles:string;
multiple_words_per_turn:bool;
} }
// InvitationActionRequest accepts / declines / cancels an invitation by id. // InvitationActionRequest accepts / declines / cancels an invitation by id.
+16 -1
View File
@@ -110,8 +110,20 @@ func (rcv *CreateInvitationRequest) DropoutTiles() []byte {
return nil return nil
} }
func (rcv *CreateInvitationRequest) MultipleWordsPerTurn() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *CreateInvitationRequest) MutateMultipleWordsPerTurn(n bool) bool {
return rcv._tab.MutateBoolSlot(16, n)
}
func CreateInvitationRequestStart(builder *flatbuffers.Builder) { func CreateInvitationRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(6) builder.StartObject(7)
} }
func CreateInvitationRequestAddInviteeIds(builder *flatbuffers.Builder, inviteeIds flatbuffers.UOffsetT) { func CreateInvitationRequestAddInviteeIds(builder *flatbuffers.Builder, inviteeIds flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(inviteeIds), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(inviteeIds), 0)
@@ -134,6 +146,9 @@ func CreateInvitationRequestAddHintsPerPlayer(builder *flatbuffers.Builder, hint
func CreateInvitationRequestAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) { func CreateInvitationRequestAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(dropoutTiles), 0) builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(dropoutTiles), 0)
} }
func CreateInvitationRequestAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) {
builder.PrependBoolSlot(6, multipleWordsPerTurn, false)
}
func CreateInvitationRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func CreateInvitationRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+16 -1
View File
@@ -49,12 +49,27 @@ func (rcv *EnqueueRequest) Variant() []byte {
return nil return nil
} }
func (rcv *EnqueueRequest) MultipleWordsPerTurn() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *EnqueueRequest) MutateMultipleWordsPerTurn(n bool) bool {
return rcv._tab.MutateBoolSlot(6, n)
}
func EnqueueRequestStart(builder *flatbuffers.Builder) { func EnqueueRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1) builder.StartObject(2)
} }
func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0)
} }
func EnqueueRequestAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) {
builder.PrependBoolSlot(1, multipleWordsPerTurn, false)
}
func EnqueueRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func EnqueueRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+12
View File
@@ -70,6 +70,18 @@ test('new game: variant buttons show a rules summary and the move-limit', async
await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons
}); });
test('new game: Russian games offer the "multiple words per turn" toggle, off by default', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // auto-match
// The mock session supports Russian, so the single-word-rule toggle is offered and starts off.
const toggle = page.getByLabel('Multiple words per turn');
await expect(toggle).toBeVisible();
await expect(toggle).not.toBeChecked();
await toggle.check();
await expect(toggle).toBeChecked();
});
test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => { test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => {
await openGame(page); await openGame(page);
await page.locator('.rack .tile').first().click(); await page.locator('.rack .tile').first().click();
@@ -61,8 +61,13 @@ dropoutTiles(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
multipleWordsPerTurn():boolean {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startCreateInvitationRequest(builder:flatbuffers.Builder) { static startCreateInvitationRequest(builder:flatbuffers.Builder) {
builder.startObject(6); builder.startObject(7);
} }
static addInviteeIds(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset) { static addInviteeIds(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset) {
@@ -101,12 +106,16 @@ static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffe
builder.addFieldOffset(5, dropoutTilesOffset, 0); builder.addFieldOffset(5, dropoutTilesOffset, 0);
} }
static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) {
builder.addFieldInt8(6, +multipleWordsPerTurn, +false);
}
static endCreateInvitationRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endCreateInvitationRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, turnTimeoutSecs:number, hintsAllowed:boolean, hintsPerPlayer:number, dropoutTilesOffset:flatbuffers.Offset):flatbuffers.Offset { static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, turnTimeoutSecs:number, hintsAllowed:boolean, hintsPerPlayer:number, dropoutTilesOffset:flatbuffers.Offset, multipleWordsPerTurn:boolean):flatbuffers.Offset {
CreateInvitationRequest.startCreateInvitationRequest(builder); CreateInvitationRequest.startCreateInvitationRequest(builder);
CreateInvitationRequest.addInviteeIds(builder, inviteeIdsOffset); CreateInvitationRequest.addInviteeIds(builder, inviteeIdsOffset);
CreateInvitationRequest.addVariant(builder, variantOffset); CreateInvitationRequest.addVariant(builder, variantOffset);
@@ -114,6 +123,7 @@ static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffs
CreateInvitationRequest.addHintsAllowed(builder, hintsAllowed); CreateInvitationRequest.addHintsAllowed(builder, hintsAllowed);
CreateInvitationRequest.addHintsPerPlayer(builder, hintsPerPlayer); CreateInvitationRequest.addHintsPerPlayer(builder, hintsPerPlayer);
CreateInvitationRequest.addDropoutTiles(builder, dropoutTilesOffset); CreateInvitationRequest.addDropoutTiles(builder, dropoutTilesOffset);
CreateInvitationRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn);
return CreateInvitationRequest.endCreateInvitationRequest(builder); return CreateInvitationRequest.endCreateInvitationRequest(builder);
} }
} }
+12 -2
View File
@@ -27,22 +27,32 @@ variant(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
multipleWordsPerTurn():boolean {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startEnqueueRequest(builder:flatbuffers.Builder) { static startEnqueueRequest(builder:flatbuffers.Builder) {
builder.startObject(1); builder.startObject(2);
} }
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) { static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, variantOffset, 0); builder.addFieldOffset(0, variantOffset, 0);
} }
static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) {
builder.addFieldInt8(1, +multipleWordsPerTurn, +false);
}
static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createEnqueueRequest(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset):flatbuffers.Offset { static createEnqueueRequest(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, multipleWordsPerTurn:boolean):flatbuffers.Offset {
EnqueueRequest.startEnqueueRequest(builder); EnqueueRequest.startEnqueueRequest(builder);
EnqueueRequest.addVariant(builder, variantOffset); EnqueueRequest.addVariant(builder, variantOffset);
EnqueueRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn);
return EnqueueRequest.endEnqueueRequest(builder); return EnqueueRequest.endEnqueueRequest(builder);
} }
} }
+1 -1
View File
@@ -63,7 +63,7 @@ export interface GatewayClient {
gamesList(): Promise<GameList>; gamesList(): Promise<GameList>;
// --- lobby --- // --- lobby ---
lobbyEnqueue(variant: Variant): Promise<MatchResult>; lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult>;
lobbyPoll(): Promise<MatchResult>; lobbyPoll(): Promise<MatchResult>;
/** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */ /** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
lobbyCancel(): Promise<void>; lobbyCancel(): Promise<void>;
+3 -1
View File
@@ -149,11 +149,12 @@ export function encodeComplaint(gameId: string, word: string, note: string): Uin
return finish(b, fb.ComplaintRequest.endComplaintRequest(b)); return finish(b, fb.ComplaintRequest.endComplaintRequest(b));
} }
export function encodeEnqueue(variant: Variant): Uint8Array { export function encodeEnqueue(variant: Variant, multipleWords: boolean): Uint8Array {
const b = new Builder(64); const b = new Builder(64);
const v = b.createString(variant); const v = b.createString(variant);
fb.EnqueueRequest.startEnqueueRequest(b); fb.EnqueueRequest.startEnqueueRequest(b);
fb.EnqueueRequest.addVariant(b, v); fb.EnqueueRequest.addVariant(b, v);
fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords);
return finish(b, fb.EnqueueRequest.endEnqueueRequest(b)); return finish(b, fb.EnqueueRequest.endEnqueueRequest(b));
} }
@@ -523,6 +524,7 @@ export function encodeCreateInvitation(inviteeIds: string[], st: InvitationSetti
fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed); fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed);
fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer); fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer);
fb.CreateInvitationRequest.addDropoutTiles(b, dropout); fb.CreateInvitationRequest.addDropoutTiles(b, dropout);
fb.CreateInvitationRequest.addMultipleWordsPerTurn(b, st.multipleWordsPerTurn);
return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b)); return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b));
} }
+1
View File
@@ -232,6 +232,7 @@ export const en = {
'new.invite': 'Send invitation', 'new.invite': 'Send invitation',
'new.moveTime': 'Move time', 'new.moveTime': 'Move time',
'new.hintsPerPlayer': 'Hints per player', 'new.hintsPerPlayer': 'Hints per player',
'new.multipleWordsPerTurn': 'Multiple words per turn',
'new.invited': 'Invitation sent.', 'new.invited': 'Invitation sent.',
'new.noFriends': 'Add friends first to invite them.', 'new.noFriends': 'Add friends first to invite them.',
+1
View File
@@ -233,6 +233,7 @@ export const ru: Record<MessageKey, string> = {
'new.invite': 'Отправить приглашение', 'new.invite': 'Отправить приглашение',
'new.moveTime': 'Время на ход', 'new.moveTime': 'Время на ход',
'new.hintsPerPlayer': 'Подсказок на игрока', 'new.hintsPerPlayer': 'Подсказок на игрока',
'new.multipleWordsPerTurn': 'Несколько слов за ход',
'new.invited': 'Приглашение отправлено.', 'new.invited': 'Приглашение отправлено.',
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
+1 -1
View File
@@ -142,7 +142,7 @@ export class MockGateway implements GatewayClient {
} }
// --- lobby --- // --- lobby ---
async lobbyEnqueue(variant: Variant): Promise<MatchResult> { async lobbyEnqueue(variant: Variant, _multipleWords: boolean): Promise<MatchResult> {
// Simulate a 10s-style robot substitution, sped up: match found shortly. // Simulate a 10s-style robot substitution, sped up: match found shortly.
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const g: MockGame = { const g: MockGame = {
+2
View File
@@ -158,6 +158,8 @@ export interface InvitationSettings {
hintsAllowed: boolean; hintsAllowed: boolean;
hintsPerPlayer: number; hintsPerPlayer: number;
dropoutTiles: 'remove' | 'return'; dropoutTiles: 'remove' | 'return';
/** true = standard Scrabble; false = the single-word rule (Russian games). */
multipleWordsPerTurn: boolean;
} }
export interface InvitationInvitee { export interface InvitationInvitee {
+2 -2
View File
@@ -81,8 +81,8 @@ export function createTransport(baseUrl: string): GatewayClient {
return codec.decodeGameList(await exec('games.list', codec.empty())); return codec.decodeGameList(await exec('games.list', codec.empty()));
}, },
async lobbyEnqueue(variant) { async lobbyEnqueue(variant, multipleWords) {
return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant))); return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords)));
}, },
async lobbyPoll() { async lobbyPoll() {
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty())); return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
+27 -1
View File
@@ -1,6 +1,11 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ALL_VARIANTS, availableVariants } from './variants'; import {
ALL_VARIANTS,
availableVariants,
supportsMultipleWordsToggle,
multipleWordsForRequest,
} from './variants';
describe('availableVariants', () => { describe('availableVariants', () => {
it('is ungated (all variants) for an empty or absent set', () => { it('is ungated (all variants) for an empty or absent set', () => {
@@ -20,3 +25,24 @@ describe('availableVariants', () => {
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']); expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']);
}); });
}); });
describe('supportsMultipleWordsToggle', () => {
it('is true for Russian variants only', () => {
expect(supportsMultipleWordsToggle('scrabble_ru')).toBe(true);
expect(supportsMultipleWordsToggle('erudit_ru')).toBe(true);
expect(supportsMultipleWordsToggle('scrabble_en')).toBe(false);
});
});
describe('multipleWordsForRequest', () => {
it('carries the toggle for Russian games', () => {
expect(multipleWordsForRequest('scrabble_ru', false)).toBe(false);
expect(multipleWordsForRequest('scrabble_ru', true)).toBe(true);
expect(multipleWordsForRequest('erudit_ru', false)).toBe(false);
});
it('forces standard (true) for English whatever the toggle', () => {
expect(multipleWordsForRequest('scrabble_en', false)).toBe(true);
expect(multipleWordsForRequest('scrabble_en', true)).toBe(true);
});
});
+14
View File
@@ -55,3 +55,17 @@ export function availableVariants(supportedLanguages: string[] | undefined): Var
if (langs.length === 0) return ALL_VARIANTS; if (langs.length === 0) return ALL_VARIANTS;
return ALL_VARIANTS.filter((v) => langs.includes(VARIANT_LANGUAGE[v.id])); return ALL_VARIANTS.filter((v) => langs.includes(VARIANT_LANGUAGE[v.id]));
} }
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
// applies to a variant. Only Russian games choose the rule; English is always standard, so
// its toggle is not shown.
export function supportsMultipleWordsToggle(v: Variant): boolean {
return VARIANT_LANGUAGE[v] === 'ru';
}
// multipleWordsForRequest resolves the per-turn word rule sent when starting a game of the
// variant: Russian games carry the toggle's value, English games are silently standard
// (true), so matchmaking and game creation stay one uniform path.
export function multipleWordsForRequest(v: Variant, toggle: boolean): boolean {
return supportsMultipleWordsToggle(v) ? toggle : true;
}
+40 -2
View File
@@ -7,7 +7,13 @@
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { AccountRef, Variant } from '../lib/model'; import type { AccountRef, Variant } from '../lib/model';
import { availableVariants, VARIANT_FLAG, VARIANT_RULES } from '../lib/variants'; import {
availableVariants,
VARIANT_FLAG,
VARIANT_RULES,
supportsMultipleWordsToggle,
multipleWordsForRequest,
} from '../lib/variants';
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h). // The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
const AUTO_MATCH_HOURS = 24; const AUTO_MATCH_HOURS = 24;
@@ -15,6 +21,10 @@
// The offered variants are gated by the languages the sign-in service supports; // The offered variants are gated by the languages the sign-in service supports;
// the auto-match list and the friend-invite picker both use this. // the auto-match list and the friend-invite picker both use this.
const variants = $derived(availableVariants(app.session?.supportedLanguages)); const variants = $derived(availableVariants(app.session?.supportedLanguages));
// "Multiple words per turn" off is the single-word rule; it is offered for Russian games
// only (English is always standard and shows no toggle). Shared by both flows.
let multipleWords = $state(false);
const autoHasRussian = $derived(variants.some((v) => supportsMultipleWordsToggle(v.id)));
const timeouts = [ const timeouts = [
{ secs: 300, key: 'time.minutes' as MessageKey, n: 5 }, { secs: 300, key: 'time.minutes' as MessageKey, n: 5 },
{ secs: 1800, key: 'time.minutes' as MessageKey, n: 30 }, { secs: 1800, key: 'time.minutes' as MessageKey, n: 30 },
@@ -70,7 +80,7 @@
searching = true; searching = true;
matched = false; matched = false;
try { try {
const r = await gateway.lobbyEnqueue(v); const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords));
if (r.matched && r.game) { if (r.matched && r.game) {
matched = true; matched = true;
searching = false; searching = false;
@@ -137,6 +147,7 @@
hintsAllowed: hints > 0, hintsAllowed: hints > 0,
hintsPerPlayer: hints, hintsPerPlayer: hints,
dropoutTiles: 'remove', dropoutTiles: 'remove',
multipleWordsPerTurn: multipleWordsForRequest(inviteVariant, multipleWords),
}); });
showToast(t('new.invited')); showToast(t('new.invited'));
navigate('/'); navigate('/');
@@ -171,6 +182,12 @@
{#if mode === 'auto'} {#if mode === 'auto'}
<p class="subtitle">{t('new.subtitle')}</p> <p class="subtitle">{t('new.subtitle')}</p>
{#if autoHasRussian}
<label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span>
<input type="checkbox" bind:checked={multipleWords} />
</label>
{/if}
<div class="variants"> <div class="variants">
{#each variants as v (v.id)} {#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)} disabled={!connection.online}> <button class="variant" onclick={() => find(v.id)} disabled={!connection.online}>
@@ -225,6 +242,12 @@
</select> </select>
</label> </label>
</div> </div>
{#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)}
<label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span>
<input type="checkbox" bind:checked={multipleWords} />
</label>
{/if}
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button> <button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div> </div>
{/if} {/if}
@@ -385,6 +408,21 @@
.field select.placeholder { .field select.placeholder {
color: var(--text-muted); color: var(--text-muted);
} }
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 11px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
user-select: none;
}
.toggle span {
font-size: 0.85rem;
color: var(--text);
}
.muted { .muted {
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;