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
@@ -13,16 +13,17 @@ import (
)
type GameInvitations struct {
InvitationID uuid.UUID `sql:"primary_key"`
InviterID uuid.UUID
Variant string
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
DropoutTiles string
Status string
GameID *uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
InvitationID uuid.UUID `sql:"primary_key"`
InviterID uuid.UUID
Variant string
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
DropoutTiles string
MultipleWordsPerTurn bool
Status string
GameID *uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -13,21 +13,22 @@ import (
)
type Games struct {
GameID uuid.UUID `sql:"primary_key"`
Variant string
DictVersion string
Seed int64
Status string
Players int16
ToMove int16
TurnStartedAt time.Time
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
MoveCount int32
EndReason *string
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
DropoutTiles string
GameID uuid.UUID `sql:"primary_key"`
Variant string
DictVersion string
Seed int64
Status string
Players int16
ToMove int16
TurnStartedAt time.Time
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
MoveCount int32
EndReason *string
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
DropoutTiles string
MultipleWordsPerTurn bool
}
@@ -17,18 +17,19 @@ type gameInvitationsTable struct {
postgres.Table
// Columns
InvitationID postgres.ColumnString
InviterID postgres.ColumnString
Variant postgres.ColumnString
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
DropoutTiles postgres.ColumnString
Status postgres.ColumnString
GameID postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
InvitationID postgres.ColumnString
InviterID postgres.ColumnString
Variant postgres.ColumnString
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
Status postgres.ColumnString
GameID postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -70,39 +71,41 @@ func newGameInvitationsTable(schemaName, tableName, alias string) *GameInvitatio
func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvitationsTable {
var (
InvitationIDColumn = postgres.StringColumn("invitation_id")
InviterIDColumn = postgres.StringColumn("inviter_id")
VariantColumn = postgres.StringColumn("variant")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
StatusColumn = postgres.StringColumn("status")
GameIDColumn = postgres.StringColumn("game_id")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
InvitationIDColumn = postgres.StringColumn("invitation_id")
InviterIDColumn = postgres.StringColumn("inviter_id")
VariantColumn = postgres.StringColumn("variant")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
StatusColumn = postgres.StringColumn("status")
GameIDColumn = postgres.StringColumn("game_id")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
)
return gameInvitationsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
InvitationID: InvitationIDColumn,
InviterID: InviterIDColumn,
Variant: VariantColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
DropoutTiles: DropoutTilesColumn,
Status: StatusColumn,
GameID: GameIDColumn,
ExpiresAt: ExpiresAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
InvitationID: InvitationIDColumn,
InviterID: InviterIDColumn,
Variant: VariantColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
Status: StatusColumn,
GameID: GameIDColumn,
ExpiresAt: ExpiresAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -17,23 +17,24 @@ type gamesTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Variant postgres.ColumnString
DictVersion postgres.ColumnString
Seed postgres.ColumnInteger
Status postgres.ColumnString
Players postgres.ColumnInteger
ToMove postgres.ColumnInteger
TurnStartedAt postgres.ColumnTimestampz
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
MoveCount postgres.ColumnInteger
EndReason postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString
GameID postgres.ColumnString
Variant postgres.ColumnString
DictVersion postgres.ColumnString
Seed postgres.ColumnInteger
Status postgres.ColumnString
Players postgres.ColumnInteger
ToMove postgres.ColumnInteger
TurnStartedAt postgres.ColumnTimestampz
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
MoveCount postgres.ColumnInteger
EndReason postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -75,49 +76,51 @@ func newGamesTable(schemaName, tableName, alias string) *GamesTable {
func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
VariantColumn = postgres.StringColumn("variant")
DictVersionColumn = postgres.StringColumn("dict_version")
SeedColumn = postgres.IntegerColumn("seed")
StatusColumn = postgres.StringColumn("status")
PlayersColumn = postgres.IntegerColumn("players")
ToMoveColumn = postgres.IntegerColumn("to_move")
TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
MoveCountColumn = postgres.IntegerColumn("move_count")
EndReasonColumn = postgres.StringColumn("end_reason")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn}
GameIDColumn = postgres.StringColumn("game_id")
VariantColumn = postgres.StringColumn("variant")
DictVersionColumn = postgres.StringColumn("dict_version")
SeedColumn = postgres.IntegerColumn("seed")
StatusColumn = postgres.StringColumn("status")
PlayersColumn = postgres.IntegerColumn("players")
ToMoveColumn = postgres.IntegerColumn("to_move")
TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
MoveCountColumn = postgres.IntegerColumn("move_count")
EndReasonColumn = postgres.StringColumn("end_reason")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn}
)
return gamesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Variant: VariantColumn,
DictVersion: DictVersionColumn,
Seed: SeedColumn,
Status: StatusColumn,
Players: PlayersColumn,
ToMove: ToMoveColumn,
TurnStartedAt: TurnStartedAtColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
MoveCount: MoveCountColumn,
EndReason: EndReasonColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
DropoutTiles: DropoutTilesColumn,
GameID: GameIDColumn,
Variant: VariantColumn,
DictVersion: DictVersionColumn,
Seed: SeedColumn,
Status: StatusColumn,
Players: PlayersColumn,
ToMove: ToMoveColumn,
TurnStartedAt: TurnStartedAtColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
MoveCount: MoveCountColumn,
EndReason: EndReasonColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -97,6 +97,7 @@ CREATE TABLE games (
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true,
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
@@ -260,6 +261,7 @@ CREATE TABLE game_invitations (
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
dropout_tiles text NOT NULL DEFAULT 'remove',
multiple_words_per_turn boolean NOT NULL DEFAULT true,
status text NOT NULL DEFAULT 'pending',
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,