diff --git a/PRERELEASE.md b/PRERELEASE.md index fc83d01..2617e5c 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -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** | | R7 | Final stress run + tuning | 9b | **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) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/README.md b/backend/README.md index f28cbe6..a02b99c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 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 2–4 player game once every 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 diff --git a/backend/go.mod b/backend/go.mod index c33416d..80fbf67 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,7 @@ module scrabble/backend go 1.26.3 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/gin-gonic/gin v1.12.0 github.com/go-jet/jet/v2 v2.14.1 diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go index ecbe5d0..36e4446 100644 --- a/backend/internal/engine/game.go +++ b/backend/internal/engine/game.go @@ -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, diff --git a/backend/internal/engine/singleword_test.go b/backend/internal/engine/singleword_test.go new file mode 100644 index 0000000..0a27555 --- /dev/null +++ b/backend/internal/engine/singleword_test.go @@ -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) + } +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index caf8aeb..fc726c8 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -107,11 +107,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro seed = svc.rng() } g, err := engine.New(svc.registry, engine.Options{ - Variant: params.Variant, - Version: svc.version, - Players: len(params.Seats), - Seed: seed, - DropoutTiles: params.DropoutTiles, + Variant: params.Variant, + Version: svc.version, + Players: len(params.Seats), + Seed: seed, + DropoutTiles: params.DropoutTiles, + MultipleWordsPerTurn: params.MultipleWordsPerTurn, }) if err != nil { 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) } ins := gameInsert{ - id: id, - variant: params.Variant.String(), - dictVersion: svc.version, - seed: seed, - players: len(params.Seats), - turnTimeoutSecs: int(timeout / time.Second), - hintsAllowed: params.HintsAllowed, - hintsPerPlayer: params.HintsPerPlayer, - dropoutTiles: params.DropoutTiles.String(), + id: id, + variant: params.Variant.String(), + dictVersion: svc.version, + seed: seed, + players: len(params.Seats), + turnTimeoutSecs: int(timeout / time.Second), + hintsAllowed: params.HintsAllowed, + hintsPerPlayer: params.HintsPerPlayer, + dropoutTiles: params.DropoutTiles.String(), + multipleWordsPerTurn: params.MultipleWordsPerTurn, } if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil { return Game{}, err @@ -934,11 +936,12 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) return nil, err } g, err := engine.New(svc.registry, engine.Options{ - Variant: pre.Variant, - Version: pre.DictVersion, - Players: pre.Players, - Seed: seed, - DropoutTiles: pre.DropoutTiles, + Variant: pre.Variant, + Version: pre.DictVersion, + Players: pre.Players, + Seed: seed, + DropoutTiles: pre.DropoutTiles, + MultipleWordsPerTurn: pre.MultipleWordsPerTurn, }) if err != nil { return nil, err diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index d6709ed..a1a9d44 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -38,6 +38,8 @@ type gameInsert struct { hintsAllowed bool hintsPerPlayer int 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. @@ -92,8 +94,8 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUI gi := table.Games.INSERT( 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.DropoutTiles, - ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.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, ins.multipleWordsPerTurn) if _, err := gi.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert game: %w", err) } @@ -761,6 +763,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) { CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } + out.MultipleWordsPerTurn = g.MultipleWordsPerTurn if g.EndReason != nil { out.EndReason = *g.EndReason } diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index ded5378..5b7684f 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -80,6 +80,9 @@ type CreateParams struct { HintsPerPlayer int // starting per-seat hint allowance DropoutTiles engine.DropoutTiles // disposition of a dropped-out seat's tiles (3+ players); zero → remove 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. @@ -101,6 +104,8 @@ type Game struct { CreatedAt time.Time UpdatedAt 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. diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index cbef58d..6025cf2 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -35,14 +35,14 @@ func TestMatchmakingPairsAndStartsGame(t *testing.T) { mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second) 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 { t.Fatalf("enqueue a: %v", err) } if r1.Matched { 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 { t.Fatalf("enqueue b: %v", err) } diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index 53998f9..15eda3f 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -150,7 +150,7 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) { human := provisionAccount(t) before := time.Now() - r, err := mm.Enqueue(ctx, human, engine.VariantEnglish) + r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue: %v", err) } diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index 97506e1..c5e866b 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -49,6 +49,8 @@ type InvitationSettings struct { HintsAllowed bool HintsPerPlayer int DropoutTiles engine.DropoutTiles + // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule. + MultipleWordsPerTurn bool } // 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) } ins := invitationInsert{ - id: id, - inviterID: inviterID, - variant: settings.Variant.String(), - turnTimeoutSecs: int(settings.TurnTimeout / time.Second), - hintsAllowed: settings.HintsAllowed, - hintsPerPlayer: settings.HintsPerPlayer, - dropoutTiles: settings.DropoutTiles.String(), - expiresAt: svc.now().Add(invitationTTL), + id: id, + inviterID: inviterID, + variant: settings.Variant.String(), + turnTimeoutSecs: int(settings.TurnTimeout / time.Second), + hintsAllowed: settings.HintsAllowed, + hintsPerPlayer: settings.HintsPerPlayer, + dropoutTiles: settings.DropoutTiles.String(), + multipleWordsPerTurn: settings.MultipleWordsPerTurn, + expiresAt: svc.now().Add(invitationTTL), } if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil { return Invitation{}, err @@ -265,12 +268,13 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U seats[iv.Seat] = iv.AccountID } g, err := svc.games.Create(ctx, game.CreateParams{ - Variant: inv.Settings.Variant, - Seats: seats, - TurnTimeout: inv.Settings.TurnTimeout, - HintsAllowed: inv.Settings.HintsAllowed, - HintsPerPlayer: inv.Settings.HintsPerPlayer, - DropoutTiles: inv.Settings.DropoutTiles, + Variant: inv.Settings.Variant, + Seats: seats, + TurnTimeout: inv.Settings.TurnTimeout, + HintsAllowed: inv.Settings.HintsAllowed, + HintsPerPlayer: inv.Settings.HintsPerPlayer, + DropoutTiles: inv.Settings.DropoutTiles, + MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn, }) if err != nil { return err @@ -322,6 +326,8 @@ type invitationInsert struct { hintsPerPlayer int dropoutTiles string expiresAt time.Time + // multipleWordsPerTurn false selects the single-word rule. + multipleWordsPerTurn bool } // 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( table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant, table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer, - table.GameInvitations.DropoutTiles, table.GameInvitations.ExpiresAt, - ).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.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.multipleWordsPerTurn, ins.expiresAt) if _, err := ii.ExecContext(ctx, tx); err != nil { 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, InviterID: row.InviterID, Settings: InvitationSettings{ - Variant: variant, - TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second, - HintsAllowed: row.HintsAllowed, - HintsPerPlayer: int(row.HintsPerPlayer), - DropoutTiles: dropout, + Variant: variant, + TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second, + HintsAllowed: row.HintsAllowed, + HintsPerPlayer: int(row.HintsPerPlayer), + DropoutTiles: dropout, + MultipleWordsPerTurn: row.MultipleWordsPerTurn, }, Status: row.Status, GameID: row.GameID, diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 4f30942..58de1bc 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -14,6 +14,14 @@ import ( "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 // 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 @@ -35,8 +43,8 @@ type Matchmaker struct { log *zap.Logger mu sync.Mutex - queues map[engine.Variant][]uuid.UUID - queued map[uuid.UUID]engine.Variant + queues map[matchKey][]uuid.UUID + queued map[uuid.UUID]matchKey waitingSince map[uuid.UUID]time.Time results map[uuid.UUID]game.Game rng *rand.Rand @@ -55,8 +63,8 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat clock: func() time.Time { return time.Now().UTC() }, pub: notify.Nop{}, log: log, - queues: make(map[engine.Variant][]uuid.UUID), - queued: make(map[uuid.UUID]engine.Variant), + queues: make(map[matchKey][]uuid.UUID), + queued: make(map[uuid.UUID]matchKey), waitingSince: make(map[uuid.UUID]time.Time), results: make(map[uuid.UUID]game.Game), rng: rand.New(rand.NewSource(time.Now().UnixNano())), @@ -101,34 +109,36 @@ type EnqueueResult struct { Game game.Game } -// Enqueue joins accountID to the variant pool. If an opponent already waits, the -// two are paired (seat order randomised for first-move fairness) and a game starts -// immediately; otherwise the account waits, and a later pairing or robot -// substitution is delivered through Poll. An account already waiting in any pool -// gets ErrAlreadyQueued. -func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) { +// Enqueue joins accountID to the auto-match pool for variant under the chosen +// per-turn word rule (multipleWords). If an opponent already waits for the same +// variant and rule, the two are paired (seat order randomised for first-move +// fairness) and a game starts immediately; otherwise the account waits, and a later +// pairing or robot substitution is delivered through Poll. An account already waiting +// 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() if _, ok := m.queued[accountID]; ok { m.mu.Unlock() return EnqueueResult{}, ErrAlreadyQueued } - q := m.queues[variant] + q := m.queues[key] if len(q) == 0 { - m.queues[variant] = append(q, accountID) - m.queued[accountID] = variant + m.queues[key] = append(q, accountID) + m.queued[accountID] = key m.waitingSince[accountID] = m.clock() m.mu.Unlock() return EnqueueResult{}, nil } opponent := q[0] - m.removeLocked(opponent, variant) + m.removeLocked(opponent, key) seats := []uuid.UUID{opponent, accountID} if m.rng.Intn(2) == 0 { seats[0], seats[1] = seats[1], seats[0] } m.mu.Unlock() - g, err := m.games.Create(ctx, autoMatchParams(variant, seats)) + g, err := m.games.Create(ctx, autoMatchParams(key, seats)) if err != nil { return EnqueueResult{}, err } @@ -161,19 +171,21 @@ func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool { m.mu.Lock() defer m.mu.Unlock() delete(m.results, accountID) - variant, ok := m.queued[accountID] + key, ok := m.queued[accountID] if !ok { return false } - m.removeLocked(accountID, variant) + m.removeLocked(accountID, key) 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 { m.mu.Lock() 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, @@ -198,9 +210,9 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) { // momentarily empty pool just defers substitution to a later tick. func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { type sub struct { - human uuid.UUID - variant engine.Variant - seats []uuid.UUID + human uuid.UUID + key matchKey + seats []uuid.UUID } m.mu.Lock() var due []uuid.UUID @@ -211,23 +223,23 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { } var subs []sub for _, acc := range due { - variant := m.queued[acc] - robotID, err := m.robots.Pick(variant) + key := m.queued[acc] + robotID, err := m.robots.Pick(key.variant) if err != nil { m.log.Warn("robot substitution deferred", zap.Error(err)) continue } - m.removeLocked(acc, variant) + m.removeLocked(acc, key) seats := []uuid.UUID{acc, robotID} if m.rng.Intn(2) == 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() 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 { m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err)) 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 // 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.waitingSince, accountID) - q := m.queues[variant] + q := m.queues[key] for i, id := range q { if id == accountID { - m.queues[variant] = append(q[:i], q[i+1:]...) + m.queues[key] = append(q[:i], q[i+1:]...) 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 // 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{ - Variant: variant, - Seats: seats, - TurnTimeout: game.DefaultTurnTimeout, - HintsAllowed: autoMatchHintsAllowed, - HintsPerPlayer: autoMatchHintsPerPlayer, + Variant: key.variant, + Seats: seats, + TurnTimeout: game.DefaultTurnTimeout, + HintsAllowed: autoMatchHintsAllowed, + HintsPerPlayer: autoMatchHintsPerPlayer, + MultipleWordsPerTurn: key.multipleWords, } } diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 89c41b1..6a7374e 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -80,7 +80,7 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) { ctx := context.Background() 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 { 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)) } - r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish) + r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue b: %v", err) } @@ -129,10 +129,10 @@ func TestMatchmakerAlreadyQueued(t *testing.T) { mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) ctx := context.Background() 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) } - 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) } } @@ -141,7 +141,7 @@ func TestMatchmakerCancel(t *testing.T) { mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) ctx := context.Background() 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) } if !mm.Cancel(ctx, a) { @@ -159,10 +159,10 @@ func TestMatchmakerVariantsAreSeparate(t *testing.T) { creator := &fakeCreator{} mm := newTestMatchmaker(creator, uuid.New()) 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) } - 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) } if len(creator.created) != 0 { @@ -179,7 +179,7 @@ func TestMatchmakerFIFO(t *testing.T) { ctx := context.Background() a, b, c := uuid.New(), uuid.New(), uuid.New() 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) } } @@ -204,7 +204,7 @@ func TestMatchmakerReaperSubstitutesRobot(t *testing.T) { ctx := context.Background() 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) } @@ -237,7 +237,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) { ctx := context.Background() 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) } mm.Cancel(ctx, a) @@ -258,7 +258,7 @@ func TestMatchmakerCancelClearsPendingResult(t *testing.T) { ctx := context.Background() 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) } 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() 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) } 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)) } } + +// 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") + } +} diff --git a/backend/internal/postgres/jet/backend/model/game_invitations.go b/backend/internal/postgres/jet/backend/model/game_invitations.go index d031bbe..abe242a 100644 --- a/backend/internal/postgres/jet/backend/model/game_invitations.go +++ b/backend/internal/postgres/jet/backend/model/game_invitations.go @@ -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 } diff --git a/backend/internal/postgres/jet/backend/model/games.go b/backend/internal/postgres/jet/backend/model/games.go index cd84aed..8b254b4 100644 --- a/backend/internal/postgres/jet/backend/model/games.go +++ b/backend/internal/postgres/jet/backend/model/games.go @@ -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 } diff --git a/backend/internal/postgres/jet/backend/table/game_invitations.go b/backend/internal/postgres/jet/backend/table/game_invitations.go index 190edbf..8c3a12d 100644 --- a/backend/internal/postgres/jet/backend/table/game_invitations.go +++ b/backend/internal/postgres/jet/backend/table/game_invitations.go @@ -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, diff --git a/backend/internal/postgres/jet/backend/table/games.go b/backend/internal/postgres/jet/backend/table/games.go index cbd44ae..8848c44 100644 --- a/backend/internal/postgres/jet/backend/table/games.go +++ b/backend/internal/postgres/jet/backend/table/games.go @@ -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, diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql index 8e56ab0..4bba8bd 100644 --- a/backend/internal/postgres/migrations/00001_baseline.sql +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -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, diff --git a/backend/internal/server/handlers_invitations.go b/backend/internal/server/handlers_invitations.go index 7412e8f..f70f10d 100644 --- a/backend/internal/server/handlers_invitations.go +++ b/backend/internal/server/handlers_invitations.go @@ -27,17 +27,18 @@ type invitationInviteeDTO struct { // invitationDTO is a friend-game invitation with its settings and invitees. type invitationDTO struct { - ID string `json:"id"` - Inviter accountRefDTO `json:"inviter"` - Invitees []invitationInviteeDTO `json:"invitees"` - Variant string `json:"variant"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - HintsAllowed bool `json:"hints_allowed"` - HintsPerPlayer int `json:"hints_per_player"` - DropoutTiles string `json:"dropout_tiles"` - Status string `json:"status"` - GameID string `json:"game_id,omitempty"` - ExpiresAtUnix int64 `json:"expires_at_unix"` + ID string `json:"id"` + Inviter accountRefDTO `json:"inviter"` + Invitees []invitationInviteeDTO `json:"invitees"` + Variant string `json:"variant"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + HintsAllowed bool `json:"hints_allowed"` + HintsPerPlayer int `json:"hints_per_player"` + DropoutTiles string `json:"dropout_tiles"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` + Status string `json:"status"` + GameID string `json:"game_id,omitempty"` + ExpiresAtUnix int64 `json:"expires_at_unix"` } // invitationListDTO is the caller's open invitations. @@ -47,27 +48,29 @@ type invitationListDTO struct { // createInvitationRequest proposes a friend game to the named invitees. type createInvitationRequest struct { - InviteeIDs []string `json:"invitee_ids"` - Variant string `json:"variant"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - HintsAllowed bool `json:"hints_allowed"` - HintsPerPlayer int `json:"hints_per_player"` - DropoutTiles string `json:"dropout_tiles"` + InviteeIDs []string `json:"invitee_ids"` + Variant string `json:"variant"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + HintsAllowed bool `json:"hints_allowed"` + HintsPerPlayer int `json:"hints_per_player"` + DropoutTiles string `json:"dropout_tiles"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` } // invitationDTOFrom projects a lobby invitation, resolving names through memo. func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, memo map[string]string) invitationDTO { dto := invitationDTO{ - ID: inv.ID.String(), - Inviter: s.namedRef(ctx, inv.InviterID, memo), - Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)), - Variant: inv.Settings.Variant.String(), - TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()), - HintsAllowed: inv.Settings.HintsAllowed, - HintsPerPlayer: inv.Settings.HintsPerPlayer, - DropoutTiles: inv.Settings.DropoutTiles.String(), - Status: inv.Status, - ExpiresAtUnix: inv.ExpiresAt.Unix(), + ID: inv.ID.String(), + Inviter: s.namedRef(ctx, inv.InviterID, memo), + Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)), + Variant: inv.Settings.Variant.String(), + TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()), + HintsAllowed: inv.Settings.HintsAllowed, + HintsPerPlayer: inv.Settings.HintsPerPlayer, + DropoutTiles: inv.Settings.DropoutTiles.String(), + MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn, + Status: inv.Status, + ExpiresAtUnix: inv.ExpiresAt.Unix(), } if inv.GameID != nil { dto.GameID = inv.GameID.String() @@ -102,9 +105,10 @@ func (s *Server) handleCreateInvitation(c *gin.Context) { return } settings := lobby.InvitationSettings{ - Variant: variant, - HintsAllowed: req.HintsAllowed, - HintsPerPlayer: req.HintsPerPlayer, + Variant: variant, + HintsAllowed: req.HintsAllowed, + HintsPerPlayer: req.HintsPerPlayer, + MultipleWordsPerTurn: req.MultipleWordsPerTurn, } if req.TurnTimeoutSecs > 0 { settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 150af11..834aa0a 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -133,9 +133,10 @@ func (s *Server) handleGameState(c *gin.Context) { 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 { - Variant string `json:"variant"` + Variant string `json:"variant"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` } // handleEnqueue joins the auto-match pool for a variant. @@ -155,7 +156,7 @@ func (s *Server) handleEnqueue(c *gin.Context) { abortBadRequest(c, "unknown variant") 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 { s.abortErr(c, err) return diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4f8d1a..9713a31 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -282,6 +282,17 @@ Key points: - **Word legality: validate-at-submit.** An illegal play is rejected by `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** **6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or a missed turn. The **per-game turn timeout** is chosen at creation diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index c98d6b1..839d17e 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 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; -after 10 s with no human the robot substitutes. Friend games (2–4) 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 (2–4) are 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 settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 6460d45..f201d97 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -89,7 +89,13 @@ nudge) приходят от бота **этой партии** — по язы приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на любом языке. Авто-подбор (всегда 2 игрока) встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с -без человека подставляется робот. Игры с друзьями (2–4) +без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране +новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена — +упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное +вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются; +включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не +показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, +кто выбрал то же правило. Игры с друзьями (2–4) формируются приглашением игроков из списка друзей (приглашение, как и код друга, можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 059af2e..89cc854 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 / 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 - 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 cards (wins / losses / draws / games / win-rate / best game / best move) — pure numbers, no charts. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index cb84729..aefa8ce 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -245,11 +245,12 @@ func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAl return out, err } -// Enqueue joins the auto-match pool for a variant. -func (c *Client) Enqueue(ctx context.Context, userID, variant string) (MatchResp, error) { +// Enqueue joins the auto-match pool for a variant under a per-turn word rule +// (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 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 } diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 36599e7..c58da38 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -99,6 +99,8 @@ type InvitationParams struct { HintsAllowed bool HintsPerPlayer int DropoutTiles string + // MultipleWordsPerTurn true is standard Scrabble; false the single-word rule. + MultipleWordsPerTurn bool } // --- friends --- @@ -195,6 +197,8 @@ func (c *Client) CreateInvitation(ctx context.Context, userID string, p Invitati "hints_allowed": p.HintsAllowed, "hints_per_player": p.HintsPerPlayer, "dropout_tiles": p.DropoutTiles, + + "multiple_words_per_turn": p.MultipleWordsPerTurn, } err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations", userID, "", body, &out) return out, err diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 3e645f2..9cfd7bc 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -224,7 +224,7 @@ func gameStateHandler(backend *backendclient.Client) Handler { func enqueueHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { 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 { return nil, err } diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index eef958d..576c58d 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -205,6 +205,8 @@ func invitationCreateHandler(backend *backendclient.Client) Handler { HintsAllowed: in.HintsAllowed(), HintsPerPlayer: int(in.HintsPerPlayer()), DropoutTiles: string(in.DropoutTiles()), + + MultipleWordsPerTurn: in.MultipleWordsPerTurn(), } res, err := backend.CreateInvitation(ctx, req.UserID, params) if err != nil { diff --git a/go.work.sum b/go.work.sum index 70cec45..9b21186 100644 --- a/go.work.sum +++ b/go.work.sum @@ -6,6 +6,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= 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/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/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= diff --git a/loadtest/go.mod b/loadtest/go.mod index 847ef76..f056023 100644 --- a/loadtest/go.mod +++ b/loadtest/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( 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/uuid v1.6.0 github.com/iliadenisov/dafsa v1.1.0 diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index b0e74e2..f54fdc3 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -272,9 +272,11 @@ table GameList { // --- 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 { variant:string; + multiple_words_per_turn:bool; } // MatchResult reports whether the caller has been paired into a game yet. @@ -457,6 +459,7 @@ table CreateInvitationRequest { hints_allowed:bool; hints_per_player:int; dropout_tiles:string; + multiple_words_per_turn:bool; } // InvitationActionRequest accepts / declines / cancels an invitation by id. diff --git a/pkg/fbs/scrabblefb/CreateInvitationRequest.go b/pkg/fbs/scrabblefb/CreateInvitationRequest.go index bc4b512..ec9c320 100644 --- a/pkg/fbs/scrabblefb/CreateInvitationRequest.go +++ b/pkg/fbs/scrabblefb/CreateInvitationRequest.go @@ -110,8 +110,20 @@ func (rcv *CreateInvitationRequest) DropoutTiles() []byte { 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) { - builder.StartObject(6) + builder.StartObject(7) } func CreateInvitationRequestAddInviteeIds(builder *flatbuffers.Builder, inviteeIds flatbuffers.UOffsetT) { 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) { 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 { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/EnqueueRequest.go b/pkg/fbs/scrabblefb/EnqueueRequest.go index 645ff5a..080e7f2 100644 --- a/pkg/fbs/scrabblefb/EnqueueRequest.go +++ b/pkg/fbs/scrabblefb/EnqueueRequest.go @@ -49,12 +49,27 @@ func (rcv *EnqueueRequest) Variant() []byte { 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) { - builder.StartObject(1) + builder.StartObject(2) } func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { 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 { return builder.EndObject() } diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index aa0c280..d568e3f 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -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 }); +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 }) => { await openGame(page); await page.locator('.rack .tile').first().click(); diff --git a/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts b/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts index 90efe4d..301edb3 100644 --- a/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts +++ b/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts @@ -61,8 +61,13 @@ dropoutTiles(optionalEncoding?:any):string|Uint8Array|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) { - builder.startObject(6); + builder.startObject(7); } static addInviteeIds(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset) { @@ -101,12 +106,16 @@ static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffe builder.addFieldOffset(5, dropoutTilesOffset, 0); } +static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) { + builder.addFieldInt8(6, +multipleWordsPerTurn, +false); +} + static endCreateInvitationRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); 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.addInviteeIds(builder, inviteeIdsOffset); CreateInvitationRequest.addVariant(builder, variantOffset); @@ -114,6 +123,7 @@ static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffs CreateInvitationRequest.addHintsAllowed(builder, hintsAllowed); CreateInvitationRequest.addHintsPerPlayer(builder, hintsPerPlayer); CreateInvitationRequest.addDropoutTiles(builder, dropoutTilesOffset); + CreateInvitationRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn); return CreateInvitationRequest.endCreateInvitationRequest(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/enqueue-request.ts b/ui/src/gen/fbs/scrabblefb/enqueue-request.ts index 293bba3..7dd5232 100644 --- a/ui/src/gen/fbs/scrabblefb/enqueue-request.ts +++ b/ui/src/gen/fbs/scrabblefb/enqueue-request.ts @@ -27,22 +27,32 @@ variant(optionalEncoding?:any):string|Uint8Array|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) { - builder.startObject(1); + builder.startObject(2); } static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) { builder.addFieldOffset(0, variantOffset, 0); } +static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) { + builder.addFieldInt8(1, +multipleWordsPerTurn, +false); +} + static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); 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.addVariant(builder, variantOffset); + EnqueueRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn); return EnqueueRequest.endEnqueueRequest(builder); } } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 2c957e0..efe63fb 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -63,7 +63,7 @@ export interface GatewayClient { gamesList(): Promise; // --- lobby --- - lobbyEnqueue(variant: Variant): Promise; + lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise; lobbyPoll(): Promise; /** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */ lobbyCancel(): Promise; diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index ae38b75..01aa273 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -149,11 +149,12 @@ export function encodeComplaint(gameId: string, word: string, note: string): Uin 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 v = b.createString(variant); fb.EnqueueRequest.startEnqueueRequest(b); fb.EnqueueRequest.addVariant(b, v); + fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords); 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.addHintsPerPlayer(b, st.hintsPerPlayer); fb.CreateInvitationRequest.addDropoutTiles(b, dropout); + fb.CreateInvitationRequest.addMultipleWordsPerTurn(b, st.multipleWordsPerTurn); return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b)); } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 45971c7..ee33a01 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -232,6 +232,7 @@ export const en = { 'new.invite': 'Send invitation', 'new.moveTime': 'Move time', 'new.hintsPerPlayer': 'Hints per player', + 'new.multipleWordsPerTurn': 'Multiple words per turn', 'new.invited': 'Invitation sent.', 'new.noFriends': 'Add friends first to invite them.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 77c9d85..9dd8980 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -233,6 +233,7 @@ export const ru: Record = { 'new.invite': 'Отправить приглашение', 'new.moveTime': 'Время на ход', 'new.hintsPerPlayer': 'Подсказок на игрока', + 'new.multipleWordsPerTurn': 'Несколько слов за ход', 'new.invited': 'Приглашение отправлено.', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 73bc7c9..3688ee5 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -142,7 +142,7 @@ export class MockGateway implements GatewayClient { } // --- lobby --- - async lobbyEnqueue(variant: Variant): Promise { + async lobbyEnqueue(variant: Variant, _multipleWords: boolean): Promise { // Simulate a 10s-style robot substitution, sped up: match found shortly. const id = crypto.randomUUID(); const g: MockGame = { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 2cc1603..42544ec 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -158,6 +158,8 @@ export interface InvitationSettings { hintsAllowed: boolean; hintsPerPlayer: number; dropoutTiles: 'remove' | 'return'; + /** true = standard Scrabble; false = the single-word rule (Russian games). */ + multipleWordsPerTurn: boolean; } export interface InvitationInvitee { diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 725e6ab..a976a21 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -81,8 +81,8 @@ export function createTransport(baseUrl: string): GatewayClient { return codec.decodeGameList(await exec('games.list', codec.empty())); }, - async lobbyEnqueue(variant) { - return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant))); + async lobbyEnqueue(variant, multipleWords) { + return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords))); }, async lobbyPoll() { return codec.decodeMatchResult(await exec('lobby.poll', codec.empty())); diff --git a/ui/src/lib/variants.test.ts b/ui/src/lib/variants.test.ts index 91af686..8c2671b 100644 --- a/ui/src/lib/variants.test.ts +++ b/ui/src/lib/variants.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { ALL_VARIANTS, availableVariants } from './variants'; +import { + ALL_VARIANTS, + availableVariants, + supportsMultipleWordsToggle, + multipleWordsForRequest, +} from './variants'; describe('availableVariants', () => { 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']); }); }); + +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); + }); +}); diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index 747337b..d93addc 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -55,3 +55,17 @@ export function availableVariants(supportedLanguages: string[] | undefined): Var if (langs.length === 0) return ALL_VARIANTS; 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; +} diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index e830d50..de103c6 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -7,7 +7,13 @@ import { navigate } from '../lib/router.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; 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). const AUTO_MATCH_HOURS = 24; @@ -15,6 +21,10 @@ // 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. 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 = [ { secs: 300, key: 'time.minutes' as MessageKey, n: 5 }, { secs: 1800, key: 'time.minutes' as MessageKey, n: 30 }, @@ -70,7 +80,7 @@ searching = true; matched = false; try { - const r = await gateway.lobbyEnqueue(v); + const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords)); if (r.matched && r.game) { matched = true; searching = false; @@ -137,6 +147,7 @@ hintsAllowed: hints > 0, hintsPerPlayer: hints, dropoutTiles: 'remove', + multipleWordsPerTurn: multipleWordsForRequest(inviteVariant, multipleWords), }); showToast(t('new.invited')); navigate('/'); @@ -171,6 +182,12 @@ {#if mode === 'auto'}

{t('new.subtitle')}

+ {#if autoHasRussian} + + {/if}
{#each variants as v (v.id)}
+ {#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)} + + {/if} {/if} @@ -385,6 +408,21 @@ .field select.placeholder { 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 { color: var(--text-muted); margin: 0;