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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user