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

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

- Engine: consume scrabble-solver v1.1.0's PlayOptions{IgnoreCrossWords}, threaded
  through engine.Options.MultipleWordsPerTurn -> playOpts() into validate, score
  and generate.
- Backend: thread the flag through game CreateParams/Game + store (games column),
  lobby InvitationSettings + invitation row, and the matchmaker queue key (variant
  + rule); persisted, so a rebuilt-from-journal game keeps it. Baseline migration
  gains multiple_words_per_turn (DB not versioned); jet regenerated.
- Edge: multiple_words_per_turn added to the EnqueueRequest / CreateInvitationRequest
  FlatBuffers tables (Go + TS regenerated) and threaded through the gateway.
- UI: a "Multiple words per turn" toggle on New Game, shown for Russian variants
  only (auto-match and friend invite), default off; English silently sends standard.
- Tests: backend engine/matchmaker; UI unit (gating) + Playwright e2e (solver
  corner-case + GCG fixtures ship in v1.1.0). Docs + PRERELEASE tracker updated.
This commit is contained in:
Ilia Denisov
2026-06-12 02:17:30 +02:00
parent d4a1616d03
commit 74455c7b12
46 changed files with 643 additions and 296 deletions
+38 -23
View File
@@ -92,6 +92,12 @@ type Options struct {
// DropoutTiles is the disposition of a dropped-out player's tiles in a game
// with three or more seats; the zero value removes them from play.
DropoutTiles DropoutTiles
// MultipleWordsPerTurn selects standard Scrabble when true: every cross-word a
// play forms must be a valid word and is scored. When false the game uses the
// "single word per turn" rule — only the main word is validated and scored and
// perpendicular cross-words are ignored. Callers always set this explicitly; the
// zero value (false) is the single-word rule.
MultipleWordsPerTurn bool
}
// Game is the in-memory state of a single match and the pure rules engine over
@@ -104,17 +110,18 @@ type Game struct {
variant Variant
version string
board *board.Board
bag *Bag
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
scores []int
toMove int
scorelessRun int
over bool
reason EndReason
resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
log []MoveRecord
board *board.Board
bag *Bag
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
scores []int
toMove int
scorelessRun int
over bool
reason EndReason
resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
log []MoveRecord
}
// New starts a game described by opts over a dictionary from reg. It resolves
@@ -140,16 +147,17 @@ func New(reg *Registry, opts Options) (*Game, error) {
rs := solver.Rules()
g := &Game{
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles,
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles,
multipleWords: opts.MultipleWordsPerTurn,
}
for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -157,6 +165,13 @@ func New(reg *Registry, opts Options) (*Game, error) {
return g, nil
}
// playOpts returns the solver play options for this game's rules. Under the single-word
// rule (multipleWords false) the solver ignores perpendicular cross-words: only the main
// word is validated and scored, and move generation is not constrained by cross-words.
func (g *Game) playOpts() scrabble.PlayOptions {
return scrabble.PlayOptions{IgnoreCrossWords: !g.multipleWords}
}
// Play validates and applies the current player's placement of tiles forming a
// word in direction dir. It scores the play, refills the rack from the bag,
// advances the turn and may end the game. It returns ErrTilesNotOnRack when the
@@ -170,7 +185,7 @@ func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRec
if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
return MoveRecord{}, err
}
move, err := g.solver.ValidatePlay(g.board, dir, tiles)
move, err := g.solver.ValidatePlayOpts(g.board, dir, tiles, g.playOpts())
if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
@@ -279,7 +294,7 @@ func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
// GenerateMoves returns every legal play for the current player's rack, ranked
// by descending score. It is empty when the player has no legal play.
func (g *Game) GenerateMoves() []scrabble.Move {
return g.solver.GenerateMoves(g.board, g.rackOf(g.toMove), scrabble.Both)
return g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
}
// Hint returns the highest-scoring legal play for the current player and true,
@@ -0,0 +1,48 @@
package engine
import "testing"
// TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The
// single-word game ignores perpendicular cross-words, so move generation from a shared
// position is a superset of the standard game's; the standard game does not relax them.
func TestSingleWordRuleWiring(t *testing.T) {
const seed = 7
mk := func(multipleWords bool) *Game {
g, err := New(testReg, Options{
Variant: VariantEnglish,
Version: testVersion,
Players: 2,
Seed: seed,
MultipleWordsPerTurn: multipleWords,
})
if err != nil {
t.Fatalf("new game: %v", err)
}
return g
}
std, single := mk(true), mk(false)
if std.playOpts().IgnoreCrossWords {
t.Error("standard game must not ignore cross-words")
}
if !single.playOpts().IgnoreCrossWords {
t.Error("single-word game must ignore cross-words")
}
// Play the same opening (the standard game's top move) in both games, then compare
// the next player's candidate moves. Both games share the seed, so the next rack is
// identical; relaxed (single-word) generation never drops a legal standard move.
hint, ok := std.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := std.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("standard opening: %v", err)
}
if _, err := single.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("single-word opening: %v", err)
}
stdMoves, singleMoves := len(std.GenerateMoves()), len(single.GenerateMoves())
if singleMoves < stdMoves {
t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves)
}
}