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
Showing only changes of commit 74455c7b12 - Show all commits
+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
+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 // 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
@@ -104,17 +110,18 @@ type Game struct {
variant Variant variant Variant
version string version string
board *board.Board board *board.Board
bag *Bag bag *Bag
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
scores []int scores []int
toMove int toMove int
scorelessRun int scorelessRun int
over bool over bool
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
log []MoveRecord 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 // 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() rs := solver.Rules()
g := &Game{ g := &Game{
solver: solver, solver: solver,
rules: rs, rules: rs,
variant: opts.Variant, variant: opts.Variant,
version: version, version: version,
board: board.New(rs.Rows, rs.Cols), board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed), bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players), hands: make([][]byte, opts.Players),
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)
}
}
+22 -19
View File
@@ -107,11 +107,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
seed = svc.rng() seed = svc.rng()
} }
g, err := engine.New(svc.registry, engine.Options{ g, err := engine.New(svc.registry, engine.Options{
Variant: params.Variant, Variant: params.Variant,
Version: svc.version, Version: svc.version,
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) {
@@ -125,15 +126,16 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
return Game{}, fmt.Errorf("game: new id: %w", err) return Game{}, fmt.Errorf("game: new id: %w", err)
} }
ins := gameInsert{ ins := gameInsert{
id: id, id: id,
variant: params.Variant.String(), variant: params.Variant.String(),
dictVersion: svc.version, dictVersion: svc.version,
seed: seed, seed: seed,
players: len(params.Seats), players: len(params.Seats),
turnTimeoutSecs: int(timeout / time.Second), turnTimeoutSecs: int(timeout / time.Second),
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
@@ -934,11 +936,12 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
return nil, err return nil, err
} }
g, err := engine.New(svc.registry, engine.Options{ g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant, Variant: pre.Variant,
Version: pre.DictVersion, Version: pre.DictVersion,
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)
} }
+28 -21
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.
@@ -214,14 +216,15 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
return Invitation{}, fmt.Errorf("lobby: new invitation id: %w", err) return Invitation{}, fmt.Errorf("lobby: new invitation id: %w", err)
} }
ins := invitationInsert{ ins := invitationInsert{
id: id, id: id,
inviterID: inviterID, inviterID: inviterID,
variant: settings.Variant.String(), variant: settings.Variant.String(),
turnTimeoutSecs: int(settings.TurnTimeout / time.Second), turnTimeoutSecs: int(settings.TurnTimeout / time.Second),
hintsAllowed: settings.HintsAllowed, hintsAllowed: settings.HintsAllowed,
hintsPerPlayer: settings.HintsPerPlayer, hintsPerPlayer: settings.HintsPerPlayer,
dropoutTiles: settings.DropoutTiles.String(), dropoutTiles: settings.DropoutTiles.String(),
expiresAt: svc.now().Add(invitationTTL), multipleWordsPerTurn: settings.MultipleWordsPerTurn,
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 {
return Invitation{}, err return Invitation{}, err
@@ -265,12 +268,13 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
seats[iv.Seat] = iv.AccountID seats[iv.Seat] = iv.AccountID
} }
g, err := svc.games.Create(ctx, game.CreateParams{ g, err := svc.games.Create(ctx, game.CreateParams{
Variant: inv.Settings.Variant, Variant: inv.Settings.Variant,
Seats: seats, Seats: seats,
TurnTimeout: inv.Settings.TurnTimeout, TurnTimeout: inv.Settings.TurnTimeout,
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)
} }
@@ -377,11 +383,12 @@ func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, e
ID: row.InvitationID, ID: row.InvitationID,
InviterID: row.InviterID, InviterID: row.InviterID,
Settings: InvitationSettings{ Settings: InvitationSettings{
Variant: variant, Variant: variant,
TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second, TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second,
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,
+49 -36
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,
@@ -198,9 +210,9 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
// momentarily empty pool just defers substitution to a later tick. // momentarily empty pool just defers substitution to a later tick.
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()
var due []uuid.UUID var due []uuid.UUID
@@ -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")
}
}
@@ -13,16 +13,17 @@ import (
) )
type GameInvitations struct { type GameInvitations struct {
InvitationID uuid.UUID `sql:"primary_key"` InvitationID uuid.UUID `sql:"primary_key"`
InviterID uuid.UUID InviterID uuid.UUID
Variant string Variant string
TurnTimeoutSecs int32 TurnTimeoutSecs int32
HintsAllowed bool HintsAllowed bool
HintsPerPlayer int16 HintsPerPlayer int16
DropoutTiles string DropoutTiles string
Status string MultipleWordsPerTurn bool
GameID *uuid.UUID Status string
ExpiresAt time.Time GameID *uuid.UUID
CreatedAt time.Time ExpiresAt time.Time
UpdatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time
} }
@@ -13,21 +13,22 @@ import (
) )
type Games struct { type Games struct {
GameID uuid.UUID `sql:"primary_key"` GameID uuid.UUID `sql:"primary_key"`
Variant string Variant string
DictVersion string DictVersion string
Seed int64 Seed int64
Status string Status string
Players int16 Players int16
ToMove int16 ToMove int16
TurnStartedAt time.Time TurnStartedAt time.Time
TurnTimeoutSecs int32 TurnTimeoutSecs int32
HintsAllowed bool HintsAllowed bool
HintsPerPlayer int16 HintsPerPlayer int16
MoveCount int32 MoveCount int32
EndReason *string EndReason *string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
FinishedAt *time.Time FinishedAt *time.Time
DropoutTiles string DropoutTiles string
MultipleWordsPerTurn bool
} }
@@ -17,18 +17,19 @@ type gameInvitationsTable struct {
postgres.Table postgres.Table
// Columns // Columns
InvitationID postgres.ColumnString InvitationID postgres.ColumnString
InviterID postgres.ColumnString InviterID postgres.ColumnString
Variant postgres.ColumnString Variant postgres.ColumnString
TurnTimeoutSecs postgres.ColumnInteger TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger HintsPerPlayer postgres.ColumnInteger
DropoutTiles postgres.ColumnString DropoutTiles postgres.ColumnString
Status postgres.ColumnString MultipleWordsPerTurn postgres.ColumnBool
GameID postgres.ColumnString Status postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz GameID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz ExpiresAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -70,39 +71,41 @@ func newGameInvitationsTable(schemaName, tableName, alias string) *GameInvitatio
func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvitationsTable { func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvitationsTable {
var ( var (
InvitationIDColumn = postgres.StringColumn("invitation_id") InvitationIDColumn = postgres.StringColumn("invitation_id")
InviterIDColumn = postgres.StringColumn("inviter_id") InviterIDColumn = postgres.StringColumn("inviter_id")
VariantColumn = postgres.StringColumn("variant") VariantColumn = postgres.StringColumn("variant")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs") TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
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")
StatusColumn = postgres.StringColumn("status") MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
GameIDColumn = postgres.StringColumn("game_id") StatusColumn = postgres.StringColumn("status")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at") GameIDColumn = postgres.StringColumn("game_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at") ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn} UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
mutableColumns = postgres.ColumnList{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}
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, 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{ return gameInvitationsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
InvitationID: InvitationIDColumn, InvitationID: InvitationIDColumn,
InviterID: InviterIDColumn, InviterID: InviterIDColumn,
Variant: VariantColumn, Variant: VariantColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn, TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn, HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn, HintsPerPlayer: HintsPerPlayerColumn,
DropoutTiles: DropoutTilesColumn, DropoutTiles: DropoutTilesColumn,
Status: StatusColumn, MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
GameID: GameIDColumn, Status: StatusColumn,
ExpiresAt: ExpiresAtColumn, GameID: GameIDColumn,
CreatedAt: CreatedAtColumn, ExpiresAt: ExpiresAtColumn,
UpdatedAt: UpdatedAtColumn, CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -17,23 +17,24 @@ type gamesTable struct {
postgres.Table postgres.Table
// Columns // Columns
GameID postgres.ColumnString GameID postgres.ColumnString
Variant postgres.ColumnString Variant postgres.ColumnString
DictVersion postgres.ColumnString DictVersion postgres.ColumnString
Seed postgres.ColumnInteger Seed postgres.ColumnInteger
Status postgres.ColumnString Status postgres.ColumnString
Players postgres.ColumnInteger Players postgres.ColumnInteger
ToMove postgres.ColumnInteger ToMove postgres.ColumnInteger
TurnStartedAt postgres.ColumnTimestampz TurnStartedAt postgres.ColumnTimestampz
TurnTimeoutSecs postgres.ColumnInteger TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger HintsPerPlayer postgres.ColumnInteger
MoveCount postgres.ColumnInteger MoveCount postgres.ColumnInteger
EndReason postgres.ColumnString EndReason postgres.ColumnString
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
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
@@ -75,49 +76,51 @@ func newGamesTable(schemaName, tableName, alias string) *GamesTable {
func newGamesTableImpl(schemaName, tableName, alias string) gamesTable { func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
var ( var (
GameIDColumn = postgres.StringColumn("game_id") GameIDColumn = postgres.StringColumn("game_id")
VariantColumn = postgres.StringColumn("variant") VariantColumn = postgres.StringColumn("variant")
DictVersionColumn = postgres.StringColumn("dict_version") DictVersionColumn = postgres.StringColumn("dict_version")
SeedColumn = postgres.IntegerColumn("seed") SeedColumn = postgres.IntegerColumn("seed")
StatusColumn = postgres.StringColumn("status") StatusColumn = postgres.StringColumn("status")
PlayersColumn = postgres.IntegerColumn("players") PlayersColumn = postgres.IntegerColumn("players")
ToMoveColumn = postgres.IntegerColumn("to_move") ToMoveColumn = postgres.IntegerColumn("to_move")
TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at") TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs") TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed") HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player") HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
MoveCountColumn = postgres.IntegerColumn("move_count") MoveCountColumn = postgres.IntegerColumn("move_count")
EndReasonColumn = postgres.StringColumn("end_reason") EndReasonColumn = postgres.StringColumn("end_reason")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
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{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
GameID: GameIDColumn, GameID: GameIDColumn,
Variant: VariantColumn, Variant: VariantColumn,
DictVersion: DictVersionColumn, DictVersion: DictVersionColumn,
Seed: SeedColumn, Seed: SeedColumn,
Status: StatusColumn, Status: StatusColumn,
Players: PlayersColumn, Players: PlayersColumn,
ToMove: ToMoveColumn, ToMove: ToMoveColumn,
TurnStartedAt: TurnStartedAtColumn, TurnStartedAt: TurnStartedAtColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn, TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn, HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn, HintsPerPlayer: HintsPerPlayerColumn,
MoveCount: MoveCountColumn, MoveCount: MoveCountColumn,
EndReason: EndReasonColumn, EndReason: EndReasonColumn,
CreatedAt: CreatedAtColumn, CreatedAt: CreatedAtColumn,
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,
+34 -30
View File
@@ -27,17 +27,18 @@ type invitationInviteeDTO struct {
// invitationDTO is a friend-game invitation with its settings and invitees. // invitationDTO is a friend-game invitation with its settings and invitees.
type invitationDTO struct { type invitationDTO struct {
ID string `json:"id"` ID string `json:"id"`
Inviter accountRefDTO `json:"inviter"` Inviter accountRefDTO `json:"inviter"`
Invitees []invitationInviteeDTO `json:"invitees"` Invitees []invitationInviteeDTO `json:"invitees"`
Variant string `json:"variant"` Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
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"`
Status string `json:"status"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
GameID string `json:"game_id,omitempty"` Status string `json:"status"`
ExpiresAtUnix int64 `json:"expires_at_unix"` GameID string `json:"game_id,omitempty"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
} }
// invitationListDTO is the caller's open invitations. // invitationListDTO is the caller's open invitations.
@@ -47,27 +48,29 @@ type invitationListDTO struct {
// createInvitationRequest proposes a friend game to the named invitees. // createInvitationRequest proposes a friend game to the named invitees.
type createInvitationRequest struct { type createInvitationRequest struct {
InviteeIDs []string `json:"invitee_ids"` InviteeIDs []string `json:"invitee_ids"`
Variant string `json:"variant"` Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
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.
func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, memo map[string]string) invitationDTO { func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, memo map[string]string) invitationDTO {
dto := invitationDTO{ dto := invitationDTO{
ID: inv.ID.String(), ID: inv.ID.String(),
Inviter: s.namedRef(ctx, inv.InviterID, memo), Inviter: s.namedRef(ctx, inv.InviterID, memo),
Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)), Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)),
Variant: inv.Settings.Variant.String(), Variant: inv.Settings.Variant.String(),
TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()), TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()),
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(),
Status: inv.Status, MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
ExpiresAtUnix: inv.ExpiresAt.Unix(), Status: inv.Status,
ExpiresAtUnix: inv.ExpiresAt.Unix(),
} }
if inv.GameID != nil { if inv.GameID != nil {
dto.GameID = inv.GameID.String() dto.GameID = inv.GameID.String()
@@ -102,9 +105,10 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
return return
} }
settings := lobby.InvitationSettings{ settings := lobby.InvitationSettings{
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
+4 -3
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;