feat: "multiple words per turn" rule for Russian games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Add a per-game rule chosen on New Game for Russian variants (default off = the
single-word rule; on = standard Scrabble). Off, only the main word along the play
direction is validated and scored; perpendicular cross-words are ignored,
including in robot move generation. The rule rides every create and enqueue
request and joins the matchmaking key, so games and auto-match stay one uniform
path; "Russian-only" is a UI affordance (English always sends standard and shows
no toggle).
- Engine: consume scrabble-solver v1.1.0's PlayOptions{IgnoreCrossWords}, threaded
through engine.Options.MultipleWordsPerTurn -> playOpts() into validate, score
and generate.
- Backend: thread the flag through game CreateParams/Game + store (games column),
lobby InvitationSettings + invitation row, and the matchmaker queue key (variant
+ rule); persisted, so a rebuilt-from-journal game keeps it. Baseline migration
gains multiple_words_per_turn (DB not versioned); jet regenerated.
- Edge: multiple_words_per_turn added to the EnqueueRequest / CreateInvitationRequest
FlatBuffers tables (Go + TS regenerated) and threaded through the gateway.
- UI: a "Multiple words per turn" toggle on New Game, shown for Russian variants
only (auto-match and friend invite), default off; English silently sends standard.
- Tests: backend engine/matchmaker; UI unit (gating) + Playwright e2e (solver
corner-case + GCG fixtures ship in v1.1.0). Docs + PRERELEASE tracker updated.
This commit is contained in:
@@ -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)
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -114,6 +120,7 @@ type Game struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -150,6 +157,7 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
||||
scores: make([]int, opts.Players),
|
||||
resigned: make([]bool, opts.Players),
|
||||
dropoutTiles: opts.DropoutTiles,
|
||||
multipleWords: opts.MultipleWordsPerTurn,
|
||||
}
|
||||
for i := range g.hands {
|
||||
g.hands[i] = g.bag.Draw(rs.RackSize)
|
||||
@@ -157,6 +165,13 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// playOpts returns the solver play options for this game's rules. Under the single-word
|
||||
// rule (multipleWords false) the solver ignores perpendicular cross-words: only the main
|
||||
// word is validated and scored, and move generation is not constrained by cross-words.
|
||||
func (g *Game) playOpts() scrabble.PlayOptions {
|
||||
return scrabble.PlayOptions{IgnoreCrossWords: !g.multipleWords}
|
||||
}
|
||||
|
||||
// Play validates and applies the current player's placement of tiles forming a
|
||||
// word in direction dir. It scores the play, refills the rack from the bag,
|
||||
// advances the turn and may end the game. It returns ErrTilesNotOnRack when the
|
||||
@@ -170,7 +185,7 @@ func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRec
|
||||
if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
move, err := g.solver.ValidatePlay(g.board, dir, tiles)
|
||||
move, err := g.solver.ValidatePlayOpts(g.board, dir, tiles, g.playOpts())
|
||||
if err != nil {
|
||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||
}
|
||||
@@ -279,7 +294,7 @@ func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
|
||||
// GenerateMoves returns every legal play for the current player's rack, ranked
|
||||
// by descending score. It is empty when the player has no legal play.
|
||||
func (g *Game) GenerateMoves() []scrabble.Move {
|
||||
return g.solver.GenerateMoves(g.board, g.rackOf(g.toMove), scrabble.Both)
|
||||
return g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
|
||||
}
|
||||
|
||||
// Hint returns the highest-scoring legal play for the current player and true,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The
|
||||
// single-word game ignores perpendicular cross-words, so move generation from a shared
|
||||
// position is a superset of the standard game's; the standard game does not relax them.
|
||||
func TestSingleWordRuleWiring(t *testing.T) {
|
||||
const seed = 7
|
||||
mk := func(multipleWords bool) *Game {
|
||||
g, err := New(testReg, Options{
|
||||
Variant: VariantEnglish,
|
||||
Version: testVersion,
|
||||
Players: 2,
|
||||
Seed: seed,
|
||||
MultipleWordsPerTurn: multipleWords,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new game: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
std, single := mk(true), mk(false)
|
||||
if std.playOpts().IgnoreCrossWords {
|
||||
t.Error("standard game must not ignore cross-words")
|
||||
}
|
||||
if !single.playOpts().IgnoreCrossWords {
|
||||
t.Error("single-word game must ignore cross-words")
|
||||
}
|
||||
|
||||
// Play the same opening (the standard game's top move) in both games, then compare
|
||||
// the next player's candidate moves. Both games share the seed, so the next rack is
|
||||
// identical; relaxed (single-word) generation never drops a legal standard move.
|
||||
hint, ok := std.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
if _, err := std.SubmitPlay(hint.Tiles); err != nil {
|
||||
t.Fatalf("standard opening: %v", err)
|
||||
}
|
||||
if _, err := single.SubmitPlay(hint.Tiles); err != nil {
|
||||
t.Fatalf("single-word opening: %v", err)
|
||||
}
|
||||
stdMoves, singleMoves := len(std.GenerateMoves()), len(single.GenerateMoves())
|
||||
if singleMoves < stdMoves {
|
||||
t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
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) {
|
||||
@@ -134,6 +135,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
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
|
||||
@@ -939,6 +941,7 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
|
||||
Players: pre.Players,
|
||||
Seed: seed,
|
||||
DropoutTiles: pre.DropoutTiles,
|
||||
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -221,6 +223,7 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
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 {
|
||||
@@ -271,6 +274,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
|
||||
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)
|
||||
}
|
||||
@@ -382,6 +388,7 @@ func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, e
|
||||
HintsAllowed: row.HintsAllowed,
|
||||
HintsPerPlayer: int(row.HintsPerPlayer),
|
||||
DropoutTiles: dropout,
|
||||
MultipleWordsPerTurn: row.MultipleWordsPerTurn,
|
||||
},
|
||||
Status: row.Status,
|
||||
GameID: row.GameID,
|
||||
|
||||
@@ -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,
|
||||
@@ -199,7 +211,7 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
|
||||
func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
||||
type sub struct {
|
||||
human uuid.UUID
|
||||
variant engine.Variant
|
||||
key matchKey
|
||||
seats []uuid.UUID
|
||||
}
|
||||
m.mu.Lock()
|
||||
@@ -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,
|
||||
Variant: key.variant,
|
||||
Seats: seats,
|
||||
TurnTimeout: game.DefaultTurnTimeout,
|
||||
HintsAllowed: autoMatchHintsAllowed,
|
||||
HintsPerPlayer: autoMatchHintsPerPlayer,
|
||||
MultipleWordsPerTurn: key.multipleWords,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type GameInvitations struct {
|
||||
HintsAllowed bool
|
||||
HintsPerPlayer int16
|
||||
DropoutTiles string
|
||||
MultipleWordsPerTurn bool
|
||||
Status string
|
||||
GameID *uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
|
||||
@@ -30,4 +30,5 @@ type Games struct {
|
||||
UpdatedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
DropoutTiles string
|
||||
MultipleWordsPerTurn bool
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type gameInvitationsTable struct {
|
||||
HintsAllowed postgres.ColumnBool
|
||||
HintsPerPlayer postgres.ColumnInteger
|
||||
DropoutTiles postgres.ColumnString
|
||||
MultipleWordsPerTurn postgres.ColumnBool
|
||||
Status postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
ExpiresAt postgres.ColumnTimestampz
|
||||
@@ -77,14 +78,15 @@ func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvita
|
||||
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, 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}
|
||||
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{
|
||||
@@ -98,6 +100,7 @@ func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvita
|
||||
HintsAllowed: HintsAllowedColumn,
|
||||
HintsPerPlayer: HintsPerPlayerColumn,
|
||||
DropoutTiles: DropoutTilesColumn,
|
||||
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
||||
Status: StatusColumn,
|
||||
GameID: GameIDColumn,
|
||||
ExpiresAt: ExpiresAtColumn,
|
||||
|
||||
@@ -34,6 +34,7 @@ type gamesTable struct {
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
FinishedAt postgres.ColumnTimestampz
|
||||
DropoutTiles postgres.ColumnString
|
||||
MultipleWordsPerTurn postgres.ColumnBool
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -92,9 +93,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
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}
|
||||
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{
|
||||
@@ -118,6 +120,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
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,
|
||||
|
||||
@@ -35,6 +35,7 @@ type invitationDTO struct {
|
||||
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"`
|
||||
@@ -53,6 +54,7 @@ type createInvitationRequest struct {
|
||||
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.
|
||||
@@ -66,6 +68,7 @@ func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, me
|
||||
HintsAllowed: inv.Settings.HintsAllowed,
|
||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||
DropoutTiles: inv.Settings.DropoutTiles.String(),
|
||||
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
|
||||
Status: inv.Status,
|
||||
ExpiresAtUnix: inv.ExpiresAt.Unix(),
|
||||
}
|
||||
@@ -105,6 +108,7 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
|
||||
Variant: variant,
|
||||
HintsAllowed: req.HintsAllowed,
|
||||
HintsPerPlayer: req.HintsPerPlayer,
|
||||
MultipleWordsPerTurn: req.MultipleWordsPerTurn,
|
||||
}
|
||||
if req.TurnTimeoutSecs > 0 {
|
||||
settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second
|
||||
|
||||
@@ -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"`
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -89,7 +89,13 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
||||
любом языке. Авто-подбор (всегда 2 игрока)
|
||||
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
||||
без человека подставляется робот. Игры с друзьями (2–4)
|
||||
без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране
|
||||
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
|
||||
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
|
||||
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются;
|
||||
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
|
||||
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
||||
кто выбрал то же правило. Игры с друзьями (2–4)
|
||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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=
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface GatewayClient {
|
||||
gamesList(): Promise<GameList>;
|
||||
|
||||
// --- lobby ---
|
||||
lobbyEnqueue(variant: Variant): Promise<MatchResult>;
|
||||
lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult>;
|
||||
lobbyPoll(): Promise<MatchResult>;
|
||||
/** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
|
||||
lobbyCancel(): Promise<void>;
|
||||
|
||||
+3
-1
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'new.invite': 'Отправить приглашение',
|
||||
'new.moveTime': 'Время на ход',
|
||||
'new.hintsPerPlayer': 'Подсказок на игрока',
|
||||
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
||||
'new.invited': 'Приглашение отправлено.',
|
||||
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
|
||||
// --- lobby ---
|
||||
async lobbyEnqueue(variant: Variant): Promise<MatchResult> {
|
||||
async lobbyEnqueue(variant: Variant, _multipleWords: boolean): Promise<MatchResult> {
|
||||
// Simulate a 10s-style robot substitution, sped up: match found shortly.
|
||||
const id = crypto.randomUUID();
|
||||
const g: MockGame = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
<p class="subtitle">{t('new.subtitle')}</p>
|
||||
{#if autoHasRussian}
|
||||
<label class="toggle">
|
||||
<span>{t('new.multipleWordsPerTurn')}</span>
|
||||
<input type="checkbox" bind:checked={multipleWords} />
|
||||
</label>
|
||||
{/if}
|
||||
<div class="variants">
|
||||
{#each variants as v (v.id)}
|
||||
<button class="variant" onclick={() => find(v.id)} disabled={!connection.online}>
|
||||
@@ -225,6 +242,12 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)}
|
||||
<label class="toggle">
|
||||
<span>{t('new.multipleWordsPerTurn')}</span>
|
||||
<input type="checkbox" bind:checked={multipleWords} />
|
||||
</label>
|
||||
{/if}
|
||||
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
|
||||
</div>
|
||||
{/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;
|
||||
|
||||
Reference in New Issue
Block a user