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** |
|
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
|
||||||
| R7 | Final stress run + tuning | 9b | **done** |
|
| R7 | Final stress run + tuning | 9b | **done** |
|
||||||
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **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) |
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
## 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 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
|
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
|
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||||
invitee accepts). `internal/social` owns the friend graph (request/accept),
|
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
|
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
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
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/XSAM/otelsql v0.42.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-jet/jet/v2 v2.14.1
|
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
|
// 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.
|
// with three or more seats; the zero value removes them from play.
|
||||||
DropoutTiles DropoutTiles
|
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
|
// 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
|
reason EndReason
|
||||||
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
||||||
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
||||||
|
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
|
||||||
log []MoveRecord
|
log []MoveRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +157,7 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
|||||||
scores: make([]int, opts.Players),
|
scores: make([]int, opts.Players),
|
||||||
resigned: make([]bool, opts.Players),
|
resigned: make([]bool, opts.Players),
|
||||||
dropoutTiles: opts.DropoutTiles,
|
dropoutTiles: opts.DropoutTiles,
|
||||||
|
multipleWords: opts.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
for i := range g.hands {
|
for i := range g.hands {
|
||||||
g.hands[i] = g.bag.Draw(rs.RackSize)
|
g.hands[i] = g.bag.Draw(rs.RackSize)
|
||||||
@@ -157,6 +165,13 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
|||||||
return g, nil
|
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
|
// 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,
|
// 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
|
// 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 {
|
if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
|
||||||
return MoveRecord{}, err
|
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 {
|
if err != nil {
|
||||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
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
|
// 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.
|
// by descending score. It is empty when the player has no legal play.
|
||||||
func (g *Game) GenerateMoves() []scrabble.Move {
|
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,
|
// 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),
|
Players: len(params.Seats),
|
||||||
Seed: seed,
|
Seed: seed,
|
||||||
DropoutTiles: params.DropoutTiles,
|
DropoutTiles: params.DropoutTiles,
|
||||||
|
MultipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
|
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,
|
hintsAllowed: params.HintsAllowed,
|
||||||
hintsPerPlayer: params.HintsPerPlayer,
|
hintsPerPlayer: params.HintsPerPlayer,
|
||||||
dropoutTiles: params.DropoutTiles.String(),
|
dropoutTiles: params.DropoutTiles.String(),
|
||||||
|
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
|
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
|
||||||
return Game{}, err
|
return Game{}, err
|
||||||
@@ -939,6 +941,7 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
|
|||||||
Players: pre.Players,
|
Players: pre.Players,
|
||||||
Seed: seed,
|
Seed: seed,
|
||||||
DropoutTiles: pre.DropoutTiles,
|
DropoutTiles: pre.DropoutTiles,
|
||||||
|
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ type gameInsert struct {
|
|||||||
hintsAllowed bool
|
hintsAllowed bool
|
||||||
hintsPerPlayer int
|
hintsPerPlayer int
|
||||||
dropoutTiles string
|
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.
|
// 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(
|
gi := table.Games.INSERT(
|
||||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
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.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
|
||||||
table.Games.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)
|
).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 {
|
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("insert game: %w", err)
|
return fmt.Errorf("insert game: %w", err)
|
||||||
}
|
}
|
||||||
@@ -761,6 +763,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
|||||||
CreatedAt: g.CreatedAt,
|
CreatedAt: g.CreatedAt,
|
||||||
UpdatedAt: g.UpdatedAt,
|
UpdatedAt: g.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
||||||
if g.EndReason != nil {
|
if g.EndReason != nil {
|
||||||
out.EndReason = *g.EndReason
|
out.EndReason = *g.EndReason
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ type CreateParams struct {
|
|||||||
HintsPerPlayer int // starting per-seat hint allowance
|
HintsPerPlayer int // starting per-seat hint allowance
|
||||||
DropoutTiles engine.DropoutTiles // disposition of a dropped-out seat's tiles (3+ players); zero → remove
|
DropoutTiles engine.DropoutTiles // disposition of a dropped-out seat's tiles (3+ players); zero → remove
|
||||||
Seed int64 // zero → a random seed is chosen
|
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.
|
// 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
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
FinishedAt *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.
|
// 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)
|
mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second)
|
||||||
a, b := provisionAccount(t), provisionAccount(t)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("enqueue a: %v", err)
|
t.Fatalf("enqueue a: %v", err)
|
||||||
}
|
}
|
||||||
if r1.Matched {
|
if r1.Matched {
|
||||||
t.Fatal("first enqueue must wait")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("enqueue b: %v", err)
|
t.Fatalf("enqueue b: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
|
|||||||
human := provisionAccount(t)
|
human := provisionAccount(t)
|
||||||
|
|
||||||
before := time.Now()
|
before := time.Now()
|
||||||
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish)
|
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("enqueue: %v", err)
|
t.Fatalf("enqueue: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ type InvitationSettings struct {
|
|||||||
HintsAllowed bool
|
HintsAllowed bool
|
||||||
HintsPerPlayer int
|
HintsPerPlayer int
|
||||||
DropoutTiles engine.DropoutTiles
|
DropoutTiles engine.DropoutTiles
|
||||||
|
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
|
||||||
|
MultipleWordsPerTurn bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invitee is one invited player's seat and response.
|
// 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,
|
hintsAllowed: settings.HintsAllowed,
|
||||||
hintsPerPlayer: settings.HintsPerPlayer,
|
hintsPerPlayer: settings.HintsPerPlayer,
|
||||||
dropoutTiles: settings.DropoutTiles.String(),
|
dropoutTiles: settings.DropoutTiles.String(),
|
||||||
|
multipleWordsPerTurn: settings.MultipleWordsPerTurn,
|
||||||
expiresAt: svc.now().Add(invitationTTL),
|
expiresAt: svc.now().Add(invitationTTL),
|
||||||
}
|
}
|
||||||
if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
|
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,
|
HintsAllowed: inv.Settings.HintsAllowed,
|
||||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||||
DropoutTiles: inv.Settings.DropoutTiles,
|
DropoutTiles: inv.Settings.DropoutTiles,
|
||||||
|
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -322,6 +326,8 @@ type invitationInsert struct {
|
|||||||
hintsPerPlayer int
|
hintsPerPlayer int
|
||||||
dropoutTiles string
|
dropoutTiles string
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
|
// multipleWordsPerTurn false selects the single-word rule.
|
||||||
|
multipleWordsPerTurn bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondResult reports the state after an invitee response.
|
// 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(
|
ii := table.GameInvitations.INSERT(
|
||||||
table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant,
|
table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant,
|
||||||
table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer,
|
table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer,
|
||||||
table.GameInvitations.DropoutTiles, table.GameInvitations.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.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 {
|
if _, err := ii.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("insert invitation: %w", err)
|
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,
|
HintsAllowed: row.HintsAllowed,
|
||||||
HintsPerPlayer: int(row.HintsPerPlayer),
|
HintsPerPlayer: int(row.HintsPerPlayer),
|
||||||
DropoutTiles: dropout,
|
DropoutTiles: dropout,
|
||||||
|
MultipleWordsPerTurn: row.MultipleWordsPerTurn,
|
||||||
},
|
},
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
GameID: row.GameID,
|
GameID: row.GameID,
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ import (
|
|||||||
"scrabble/backend/internal/notify"
|
"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
|
// 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 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
|
// 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
|
log *zap.Logger
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
queues map[engine.Variant][]uuid.UUID
|
queues map[matchKey][]uuid.UUID
|
||||||
queued map[uuid.UUID]engine.Variant
|
queued map[uuid.UUID]matchKey
|
||||||
waitingSince map[uuid.UUID]time.Time
|
waitingSince map[uuid.UUID]time.Time
|
||||||
results map[uuid.UUID]game.Game
|
results map[uuid.UUID]game.Game
|
||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
@@ -55,8 +63,8 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat
|
|||||||
clock: func() time.Time { return time.Now().UTC() },
|
clock: func() time.Time { return time.Now().UTC() },
|
||||||
pub: notify.Nop{},
|
pub: notify.Nop{},
|
||||||
log: log,
|
log: log,
|
||||||
queues: make(map[engine.Variant][]uuid.UUID),
|
queues: make(map[matchKey][]uuid.UUID),
|
||||||
queued: make(map[uuid.UUID]engine.Variant),
|
queued: make(map[uuid.UUID]matchKey),
|
||||||
waitingSince: make(map[uuid.UUID]time.Time),
|
waitingSince: make(map[uuid.UUID]time.Time),
|
||||||
results: make(map[uuid.UUID]game.Game),
|
results: make(map[uuid.UUID]game.Game),
|
||||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
@@ -101,34 +109,36 @@ type EnqueueResult struct {
|
|||||||
Game game.Game
|
Game game.Game
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue joins accountID to the variant pool. If an opponent already waits, the
|
// Enqueue joins accountID to the auto-match pool for variant under the chosen
|
||||||
// two are paired (seat order randomised for first-move fairness) and a game starts
|
// per-turn word rule (multipleWords). If an opponent already waits for the same
|
||||||
// immediately; otherwise the account waits, and a later pairing or robot
|
// variant and rule, the two are paired (seat order randomised for first-move
|
||||||
// substitution is delivered through Poll. An account already waiting in any pool
|
// fairness) and a game starts immediately; otherwise the account waits, and a later
|
||||||
// gets ErrAlreadyQueued.
|
// pairing or robot substitution is delivered through Poll. An account already waiting
|
||||||
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) {
|
// 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()
|
m.mu.Lock()
|
||||||
if _, ok := m.queued[accountID]; ok {
|
if _, ok := m.queued[accountID]; ok {
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
return EnqueueResult{}, ErrAlreadyQueued
|
return EnqueueResult{}, ErrAlreadyQueued
|
||||||
}
|
}
|
||||||
q := m.queues[variant]
|
q := m.queues[key]
|
||||||
if len(q) == 0 {
|
if len(q) == 0 {
|
||||||
m.queues[variant] = append(q, accountID)
|
m.queues[key] = append(q, accountID)
|
||||||
m.queued[accountID] = variant
|
m.queued[accountID] = key
|
||||||
m.waitingSince[accountID] = m.clock()
|
m.waitingSince[accountID] = m.clock()
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
return EnqueueResult{}, nil
|
return EnqueueResult{}, nil
|
||||||
}
|
}
|
||||||
opponent := q[0]
|
opponent := q[0]
|
||||||
m.removeLocked(opponent, variant)
|
m.removeLocked(opponent, key)
|
||||||
seats := []uuid.UUID{opponent, accountID}
|
seats := []uuid.UUID{opponent, accountID}
|
||||||
if m.rng.Intn(2) == 0 {
|
if m.rng.Intn(2) == 0 {
|
||||||
seats[0], seats[1] = seats[1], seats[0]
|
seats[0], seats[1] = seats[1], seats[0]
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
g, err := m.games.Create(ctx, autoMatchParams(variant, seats))
|
g, err := m.games.Create(ctx, autoMatchParams(key, seats))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EnqueueResult{}, err
|
return EnqueueResult{}, err
|
||||||
}
|
}
|
||||||
@@ -161,19 +171,21 @@ func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
delete(m.results, accountID)
|
delete(m.results, accountID)
|
||||||
variant, ok := m.queued[accountID]
|
key, ok := m.queued[accountID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
m.removeLocked(accountID, variant)
|
m.removeLocked(accountID, key)
|
||||||
return true
|
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 {
|
func (m *Matchmaker) QueueLen(variant engine.Variant) int {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
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,
|
// 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) {
|
func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
||||||
type sub struct {
|
type sub struct {
|
||||||
human uuid.UUID
|
human uuid.UUID
|
||||||
variant engine.Variant
|
key matchKey
|
||||||
seats []uuid.UUID
|
seats []uuid.UUID
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -211,23 +223,23 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
|||||||
}
|
}
|
||||||
var subs []sub
|
var subs []sub
|
||||||
for _, acc := range due {
|
for _, acc := range due {
|
||||||
variant := m.queued[acc]
|
key := m.queued[acc]
|
||||||
robotID, err := m.robots.Pick(variant)
|
robotID, err := m.robots.Pick(key.variant)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.log.Warn("robot substitution deferred", zap.Error(err))
|
m.log.Warn("robot substitution deferred", zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.removeLocked(acc, variant)
|
m.removeLocked(acc, key)
|
||||||
seats := []uuid.UUID{acc, robotID}
|
seats := []uuid.UUID{acc, robotID}
|
||||||
if m.rng.Intn(2) == 0 {
|
if m.rng.Intn(2) == 0 {
|
||||||
seats[0], seats[1] = seats[1], seats[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()
|
m.mu.Unlock()
|
||||||
|
|
||||||
for _, s := range subs {
|
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 {
|
if err != nil {
|
||||||
m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err))
|
m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err))
|
||||||
continue
|
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
|
// removeLocked drops accountID from the queue, the queued index and the waiting
|
||||||
// clock. The caller holds m.mu.
|
// 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.queued, accountID)
|
||||||
delete(m.waitingSince, accountID)
|
delete(m.waitingSince, accountID)
|
||||||
q := m.queues[variant]
|
q := m.queues[key]
|
||||||
for i, id := range q {
|
for i, id := range q {
|
||||||
if id == accountID {
|
if id == accountID {
|
||||||
m.queues[variant] = append(q[:i], q[i+1:]...)
|
m.queues[key] = append(q[:i], q[i+1:]...)
|
||||||
break
|
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
|
// autoMatchParams builds the create parameters for a two-player auto-match with
|
||||||
// the casual defaults.
|
// 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{
|
return game.CreateParams{
|
||||||
Variant: variant,
|
Variant: key.variant,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
TurnTimeout: game.DefaultTurnTimeout,
|
TurnTimeout: game.DefaultTurnTimeout,
|
||||||
HintsAllowed: autoMatchHintsAllowed,
|
HintsAllowed: autoMatchHintsAllowed,
|
||||||
HintsPerPlayer: autoMatchHintsPerPlayer,
|
HintsPerPlayer: autoMatchHintsPerPlayer,
|
||||||
|
MultipleWordsPerTurn: key.multipleWords,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a, b := uuid.New(), uuid.New()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("enqueue a: %v", err)
|
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))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("enqueue b: %v", err)
|
t.Fatalf("enqueue b: %v", err)
|
||||||
}
|
}
|
||||||
@@ -129,10 +129,10 @@ func TestMatchmakerAlreadyQueued(t *testing.T) {
|
|||||||
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a := uuid.New()
|
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)
|
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)
|
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ func TestMatchmakerCancel(t *testing.T) {
|
|||||||
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a := uuid.New()
|
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)
|
t.Fatalf("enqueue: %v", err)
|
||||||
}
|
}
|
||||||
if !mm.Cancel(ctx, a) {
|
if !mm.Cancel(ctx, a) {
|
||||||
@@ -159,10 +159,10 @@ func TestMatchmakerVariantsAreSeparate(t *testing.T) {
|
|||||||
creator := &fakeCreator{}
|
creator := &fakeCreator{}
|
||||||
mm := newTestMatchmaker(creator, uuid.New())
|
mm := newTestMatchmaker(creator, uuid.New())
|
||||||
ctx := context.Background()
|
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)
|
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)
|
t.Fatalf("enqueue ru: %v", err)
|
||||||
}
|
}
|
||||||
if len(creator.created) != 0 {
|
if len(creator.created) != 0 {
|
||||||
@@ -179,7 +179,7 @@ func TestMatchmakerFIFO(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a, b, c := uuid.New(), uuid.New(), uuid.New()
|
a, b, c := uuid.New(), uuid.New(), uuid.New()
|
||||||
for _, id := range []uuid.UUID{a, b, c} {
|
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)
|
t.Fatalf("enqueue %s: %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +204,7 @@ func TestMatchmakerReaperSubstitutesRobot(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a := uuid.New()
|
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)
|
t.Fatalf("enqueue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a := uuid.New()
|
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)
|
t.Fatalf("enqueue: %v", err)
|
||||||
}
|
}
|
||||||
mm.Cancel(ctx, a)
|
mm.Cancel(ctx, a)
|
||||||
@@ -258,7 +258,7 @@ func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a := uuid.New()
|
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)
|
t.Fatalf("enqueue: %v", err)
|
||||||
}
|
}
|
||||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
|
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()
|
ctx := context.Background()
|
||||||
a := uuid.New()
|
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)
|
t.Fatalf("enqueue: %v", err)
|
||||||
}
|
}
|
||||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
|
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))
|
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
|
HintsAllowed bool
|
||||||
HintsPerPlayer int16
|
HintsPerPlayer int16
|
||||||
DropoutTiles string
|
DropoutTiles string
|
||||||
|
MultipleWordsPerTurn bool
|
||||||
Status string
|
Status string
|
||||||
GameID *uuid.UUID
|
GameID *uuid.UUID
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ type Games struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
FinishedAt *time.Time
|
FinishedAt *time.Time
|
||||||
DropoutTiles string
|
DropoutTiles string
|
||||||
|
MultipleWordsPerTurn bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type gameInvitationsTable struct {
|
|||||||
HintsAllowed postgres.ColumnBool
|
HintsAllowed postgres.ColumnBool
|
||||||
HintsPerPlayer postgres.ColumnInteger
|
HintsPerPlayer postgres.ColumnInteger
|
||||||
DropoutTiles postgres.ColumnString
|
DropoutTiles postgres.ColumnString
|
||||||
|
MultipleWordsPerTurn postgres.ColumnBool
|
||||||
Status postgres.ColumnString
|
Status postgres.ColumnString
|
||||||
GameID postgres.ColumnString
|
GameID postgres.ColumnString
|
||||||
ExpiresAt postgres.ColumnTimestampz
|
ExpiresAt postgres.ColumnTimestampz
|
||||||
@@ -77,14 +78,15 @@ func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvita
|
|||||||
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
|
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
|
||||||
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
|
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
|
||||||
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
||||||
|
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
|
||||||
StatusColumn = postgres.StringColumn("status")
|
StatusColumn = postgres.StringColumn("status")
|
||||||
GameIDColumn = postgres.StringColumn("game_id")
|
GameIDColumn = postgres.StringColumn("game_id")
|
||||||
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, 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, 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, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return gameInvitationsTable{
|
return gameInvitationsTable{
|
||||||
@@ -98,6 +100,7 @@ func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvita
|
|||||||
HintsAllowed: HintsAllowedColumn,
|
HintsAllowed: HintsAllowedColumn,
|
||||||
HintsPerPlayer: HintsPerPlayerColumn,
|
HintsPerPlayer: HintsPerPlayerColumn,
|
||||||
DropoutTiles: DropoutTilesColumn,
|
DropoutTiles: DropoutTilesColumn,
|
||||||
|
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
||||||
Status: StatusColumn,
|
Status: StatusColumn,
|
||||||
GameID: GameIDColumn,
|
GameID: GameIDColumn,
|
||||||
ExpiresAt: ExpiresAtColumn,
|
ExpiresAt: ExpiresAtColumn,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type gamesTable struct {
|
|||||||
UpdatedAt postgres.ColumnTimestampz
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
FinishedAt postgres.ColumnTimestampz
|
FinishedAt postgres.ColumnTimestampz
|
||||||
DropoutTiles postgres.ColumnString
|
DropoutTiles postgres.ColumnString
|
||||||
|
MultipleWordsPerTurn postgres.ColumnBool
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -92,9 +93,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
|||||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
|
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
|
||||||
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
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}
|
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
|
||||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
|
allColumns = postgres.ColumnList{GameIDColumn, 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}
|
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{
|
return gamesTable{
|
||||||
@@ -118,6 +120,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
|||||||
UpdatedAt: UpdatedAtColumn,
|
UpdatedAt: UpdatedAtColumn,
|
||||||
FinishedAt: FinishedAtColumn,
|
FinishedAt: FinishedAtColumn,
|
||||||
DropoutTiles: DropoutTilesColumn,
|
DropoutTiles: DropoutTilesColumn,
|
||||||
|
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ CREATE TABLE games (
|
|||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
finished_at timestamptz,
|
finished_at timestamptz,
|
||||||
dropout_tiles text NOT NULL DEFAULT 'remove',
|
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_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
|
||||||
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
|
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
|
||||||
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
|
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_allowed boolean NOT NULL DEFAULT true,
|
||||||
hints_per_player smallint NOT NULL DEFAULT 1,
|
hints_per_player smallint NOT NULL DEFAULT 1,
|
||||||
dropout_tiles text NOT NULL DEFAULT 'remove',
|
dropout_tiles text NOT NULL DEFAULT 'remove',
|
||||||
|
multiple_words_per_turn boolean NOT NULL DEFAULT true,
|
||||||
status text NOT NULL DEFAULT 'pending',
|
status text NOT NULL DEFAULT 'pending',
|
||||||
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
|
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
|
||||||
expires_at timestamptz NOT NULL,
|
expires_at timestamptz NOT NULL,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type invitationDTO struct {
|
|||||||
HintsAllowed bool `json:"hints_allowed"`
|
HintsAllowed bool `json:"hints_allowed"`
|
||||||
HintsPerPlayer int `json:"hints_per_player"`
|
HintsPerPlayer int `json:"hints_per_player"`
|
||||||
DropoutTiles string `json:"dropout_tiles"`
|
DropoutTiles string `json:"dropout_tiles"`
|
||||||
|
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
GameID string `json:"game_id,omitempty"`
|
GameID string `json:"game_id,omitempty"`
|
||||||
ExpiresAtUnix int64 `json:"expires_at_unix"`
|
ExpiresAtUnix int64 `json:"expires_at_unix"`
|
||||||
@@ -53,6 +54,7 @@ type createInvitationRequest struct {
|
|||||||
HintsAllowed bool `json:"hints_allowed"`
|
HintsAllowed bool `json:"hints_allowed"`
|
||||||
HintsPerPlayer int `json:"hints_per_player"`
|
HintsPerPlayer int `json:"hints_per_player"`
|
||||||
DropoutTiles string `json:"dropout_tiles"`
|
DropoutTiles string `json:"dropout_tiles"`
|
||||||
|
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// invitationDTOFrom projects a lobby invitation, resolving names through memo.
|
// 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,
|
HintsAllowed: inv.Settings.HintsAllowed,
|
||||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||||
DropoutTiles: inv.Settings.DropoutTiles.String(),
|
DropoutTiles: inv.Settings.DropoutTiles.String(),
|
||||||
|
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
|
||||||
Status: inv.Status,
|
Status: inv.Status,
|
||||||
ExpiresAtUnix: inv.ExpiresAt.Unix(),
|
ExpiresAtUnix: inv.ExpiresAt.Unix(),
|
||||||
}
|
}
|
||||||
@@ -105,6 +108,7 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
|
|||||||
Variant: variant,
|
Variant: variant,
|
||||||
HintsAllowed: req.HintsAllowed,
|
HintsAllowed: req.HintsAllowed,
|
||||||
HintsPerPlayer: req.HintsPerPlayer,
|
HintsPerPlayer: req.HintsPerPlayer,
|
||||||
|
MultipleWordsPerTurn: req.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
if req.TurnTimeoutSecs > 0 {
|
if req.TurnTimeoutSecs > 0 {
|
||||||
settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second
|
settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second
|
||||||
|
|||||||
@@ -133,9 +133,10 @@ func (s *Server) handleGameState(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, dto)
|
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 {
|
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.
|
// handleEnqueue joins the auto-match pool for a variant.
|
||||||
@@ -155,7 +156,7 @@ func (s *Server) handleEnqueue(c *gin.Context) {
|
|||||||
abortBadRequest(c, "unknown variant")
|
abortBadRequest(c, "unknown variant")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -282,6 +282,17 @@ Key points:
|
|||||||
|
|
||||||
- **Word legality: validate-at-submit.** An illegal play is rejected by
|
- **Word legality: validate-at-submit.** An illegal play is rejected by
|
||||||
`Solver.ValidatePlay`; there is no challenge phase.
|
`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**
|
- **End of game**: the bag is empty **and** a player empties their rack, **or**
|
||||||
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
|
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
|
||||||
a missed turn. The **per-game turn timeout** is chosen at creation
|
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
|
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
|
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;
|
(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,
|
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
|
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
|
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||||
|
|||||||
@@ -89,7 +89,13 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
||||||
любом языке. Авто-подбор (всегда 2 игрока)
|
любом языке. Авто-подбор (всегда 2 игрока)
|
||||||
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
||||||
без человека подставляется робот. Игры с друзьями (2–4)
|
без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране
|
||||||
|
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
|
||||||
|
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
|
||||||
|
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются;
|
||||||
|
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
|
||||||
|
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
||||||
|
кто выбрал то же правило. Игры с друзьями (2–4)
|
||||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
можно отправить 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 /
|
- **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
|
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
|
**"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
|
- **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
|
cards (wins / losses / draws / games / win-rate / best game / best move) — pure
|
||||||
numbers, no charts.
|
numbers, no charts.
|
||||||
|
|||||||
@@ -245,11 +245,12 @@ func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAl
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue joins the auto-match pool for a variant.
|
// Enqueue joins the auto-match pool for a variant under a per-turn word rule
|
||||||
func (c *Client) Enqueue(ctx context.Context, userID, variant string) (MatchResp, error) {
|
// (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
|
var out MatchResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "",
|
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
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ type InvitationParams struct {
|
|||||||
HintsAllowed bool
|
HintsAllowed bool
|
||||||
HintsPerPlayer int
|
HintsPerPlayer int
|
||||||
DropoutTiles string
|
DropoutTiles string
|
||||||
|
// MultipleWordsPerTurn true is standard Scrabble; false the single-word rule.
|
||||||
|
MultipleWordsPerTurn bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- friends ---
|
// --- friends ---
|
||||||
@@ -195,6 +197,8 @@ func (c *Client) CreateInvitation(ctx context.Context, userID string, p Invitati
|
|||||||
"hints_allowed": p.HintsAllowed,
|
"hints_allowed": p.HintsAllowed,
|
||||||
"hints_per_player": p.HintsPerPlayer,
|
"hints_per_player": p.HintsPerPlayer,
|
||||||
"dropout_tiles": p.DropoutTiles,
|
"dropout_tiles": p.DropoutTiles,
|
||||||
|
|
||||||
|
"multiple_words_per_turn": p.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations", userID, "", body, &out)
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations", userID, "", body, &out)
|
||||||
return out, err
|
return out, err
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ func gameStateHandler(backend *backendclient.Client) Handler {
|
|||||||
func enqueueHandler(backend *backendclient.Client) Handler {
|
func enqueueHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsEnqueueRequest(req.Payload, 0)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ func invitationCreateHandler(backend *backendclient.Client) Handler {
|
|||||||
HintsAllowed: in.HintsAllowed(),
|
HintsAllowed: in.HintsAllowed(),
|
||||||
HintsPerPlayer: int(in.HintsPerPlayer()),
|
HintsPerPlayer: int(in.HintsPerPlayer()),
|
||||||
DropoutTiles: string(in.DropoutTiles()),
|
DropoutTiles: string(in.DropoutTiles()),
|
||||||
|
|
||||||
|
MultipleWordsPerTurn: in.MultipleWordsPerTurn(),
|
||||||
}
|
}
|
||||||
res, err := backend.CreateInvitation(ctx, req.UserID, params)
|
res, err := backend.CreateInvitation(ctx, req.UserID, params)
|
||||||
if err != nil {
|
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=
|
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 h1:ntN6m4cOB+4FelleO2nkAIZp8WSc+v25neetzfdUuuw=
|
||||||
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY=
|
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/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 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ go 1.26.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.19.2
|
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/flatbuffers v23.5.26+incompatible
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/iliadenisov/dafsa v1.1.0
|
github.com/iliadenisov/dafsa v1.1.0
|
||||||
|
|||||||
@@ -272,9 +272,11 @@ table GameList {
|
|||||||
|
|
||||||
// --- lobby (authenticated) ---
|
// --- 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 {
|
table EnqueueRequest {
|
||||||
variant:string;
|
variant:string;
|
||||||
|
multiple_words_per_turn:bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchResult reports whether the caller has been paired into a game yet.
|
// MatchResult reports whether the caller has been paired into a game yet.
|
||||||
@@ -457,6 +459,7 @@ table CreateInvitationRequest {
|
|||||||
hints_allowed:bool;
|
hints_allowed:bool;
|
||||||
hints_per_player:int;
|
hints_per_player:int;
|
||||||
dropout_tiles:string;
|
dropout_tiles:string;
|
||||||
|
multiple_words_per_turn:bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvitationActionRequest accepts / declines / cancels an invitation by id.
|
// InvitationActionRequest accepts / declines / cancels an invitation by id.
|
||||||
|
|||||||
@@ -110,8 +110,20 @@ func (rcv *CreateInvitationRequest) DropoutTiles() []byte {
|
|||||||
return nil
|
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) {
|
func CreateInvitationRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(6)
|
builder.StartObject(7)
|
||||||
}
|
}
|
||||||
func CreateInvitationRequestAddInviteeIds(builder *flatbuffers.Builder, inviteeIds flatbuffers.UOffsetT) {
|
func CreateInvitationRequestAddInviteeIds(builder *flatbuffers.Builder, inviteeIds flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(inviteeIds), 0)
|
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) {
|
func CreateInvitationRequestAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(dropoutTiles), 0)
|
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 {
|
func CreateInvitationRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,27 @@ func (rcv *EnqueueRequest) Variant() []byte {
|
|||||||
return nil
|
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) {
|
func EnqueueRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(1)
|
builder.StartObject(2)
|
||||||
}
|
}
|
||||||
func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
|
func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0)
|
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 {
|
func EnqueueRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
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
|
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 }) => {
|
test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => {
|
||||||
await openGame(page);
|
await openGame(page);
|
||||||
await page.locator('.rack .tile').first().click();
|
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;
|
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) {
|
static startCreateInvitationRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(6);
|
builder.startObject(7);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addInviteeIds(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset) {
|
static addInviteeIds(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset) {
|
||||||
@@ -101,12 +106,16 @@ static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffe
|
|||||||
builder.addFieldOffset(5, dropoutTilesOffset, 0);
|
builder.addFieldOffset(5, dropoutTilesOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) {
|
||||||
|
builder.addFieldInt8(6, +multipleWordsPerTurn, +false);
|
||||||
|
}
|
||||||
|
|
||||||
static endCreateInvitationRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endCreateInvitationRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startCreateInvitationRequest(builder);
|
||||||
CreateInvitationRequest.addInviteeIds(builder, inviteeIdsOffset);
|
CreateInvitationRequest.addInviteeIds(builder, inviteeIdsOffset);
|
||||||
CreateInvitationRequest.addVariant(builder, variantOffset);
|
CreateInvitationRequest.addVariant(builder, variantOffset);
|
||||||
@@ -114,6 +123,7 @@ static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffs
|
|||||||
CreateInvitationRequest.addHintsAllowed(builder, hintsAllowed);
|
CreateInvitationRequest.addHintsAllowed(builder, hintsAllowed);
|
||||||
CreateInvitationRequest.addHintsPerPlayer(builder, hintsPerPlayer);
|
CreateInvitationRequest.addHintsPerPlayer(builder, hintsPerPlayer);
|
||||||
CreateInvitationRequest.addDropoutTiles(builder, dropoutTilesOffset);
|
CreateInvitationRequest.addDropoutTiles(builder, dropoutTilesOffset);
|
||||||
|
CreateInvitationRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn);
|
||||||
return CreateInvitationRequest.endCreateInvitationRequest(builder);
|
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;
|
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) {
|
static startEnqueueRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(1);
|
builder.startObject(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
|
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
|
||||||
builder.addFieldOffset(0, variantOffset, 0);
|
builder.addFieldOffset(0, variantOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) {
|
||||||
|
builder.addFieldInt8(1, +multipleWordsPerTurn, +false);
|
||||||
|
}
|
||||||
|
|
||||||
static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startEnqueueRequest(builder);
|
||||||
EnqueueRequest.addVariant(builder, variantOffset);
|
EnqueueRequest.addVariant(builder, variantOffset);
|
||||||
|
EnqueueRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn);
|
||||||
return EnqueueRequest.endEnqueueRequest(builder);
|
return EnqueueRequest.endEnqueueRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export interface GatewayClient {
|
|||||||
gamesList(): Promise<GameList>;
|
gamesList(): Promise<GameList>;
|
||||||
|
|
||||||
// --- lobby ---
|
// --- lobby ---
|
||||||
lobbyEnqueue(variant: Variant): Promise<MatchResult>;
|
lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult>;
|
||||||
lobbyPoll(): Promise<MatchResult>;
|
lobbyPoll(): Promise<MatchResult>;
|
||||||
/** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
|
/** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
|
||||||
lobbyCancel(): Promise<void>;
|
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));
|
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 b = new Builder(64);
|
||||||
const v = b.createString(variant);
|
const v = b.createString(variant);
|
||||||
fb.EnqueueRequest.startEnqueueRequest(b);
|
fb.EnqueueRequest.startEnqueueRequest(b);
|
||||||
fb.EnqueueRequest.addVariant(b, v);
|
fb.EnqueueRequest.addVariant(b, v);
|
||||||
|
fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords);
|
||||||
return finish(b, fb.EnqueueRequest.endEnqueueRequest(b));
|
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.addHintsAllowed(b, st.hintsAllowed);
|
||||||
fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer);
|
fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer);
|
||||||
fb.CreateInvitationRequest.addDropoutTiles(b, dropout);
|
fb.CreateInvitationRequest.addDropoutTiles(b, dropout);
|
||||||
|
fb.CreateInvitationRequest.addMultipleWordsPerTurn(b, st.multipleWordsPerTurn);
|
||||||
return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b));
|
return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ export const en = {
|
|||||||
'new.invite': 'Send invitation',
|
'new.invite': 'Send invitation',
|
||||||
'new.moveTime': 'Move time',
|
'new.moveTime': 'Move time',
|
||||||
'new.hintsPerPlayer': 'Hints per player',
|
'new.hintsPerPlayer': 'Hints per player',
|
||||||
|
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
||||||
'new.invited': 'Invitation sent.',
|
'new.invited': 'Invitation sent.',
|
||||||
'new.noFriends': 'Add friends first to invite them.',
|
'new.noFriends': 'Add friends first to invite them.',
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.invite': 'Отправить приглашение',
|
'new.invite': 'Отправить приглашение',
|
||||||
'new.moveTime': 'Время на ход',
|
'new.moveTime': 'Время на ход',
|
||||||
'new.hintsPerPlayer': 'Подсказок на игрока',
|
'new.hintsPerPlayer': 'Подсказок на игрока',
|
||||||
|
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
||||||
'new.invited': 'Приглашение отправлено.',
|
'new.invited': 'Приглашение отправлено.',
|
||||||
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- lobby ---
|
// --- 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.
|
// Simulate a 10s-style robot substitution, sped up: match found shortly.
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const g: MockGame = {
|
const g: MockGame = {
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ export interface InvitationSettings {
|
|||||||
hintsAllowed: boolean;
|
hintsAllowed: boolean;
|
||||||
hintsPerPlayer: number;
|
hintsPerPlayer: number;
|
||||||
dropoutTiles: 'remove' | 'return';
|
dropoutTiles: 'remove' | 'return';
|
||||||
|
/** true = standard Scrabble; false = the single-word rule (Russian games). */
|
||||||
|
multipleWordsPerTurn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvitationInvitee {
|
export interface InvitationInvitee {
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
return codec.decodeGameList(await exec('games.list', codec.empty()));
|
return codec.decodeGameList(await exec('games.list', codec.empty()));
|
||||||
},
|
},
|
||||||
|
|
||||||
async lobbyEnqueue(variant) {
|
async lobbyEnqueue(variant, multipleWords) {
|
||||||
return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant)));
|
return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords)));
|
||||||
},
|
},
|
||||||
async lobbyPoll() {
|
async lobbyPoll() {
|
||||||
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
|
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
import { ALL_VARIANTS, availableVariants } from './variants';
|
import {
|
||||||
|
ALL_VARIANTS,
|
||||||
|
availableVariants,
|
||||||
|
supportsMultipleWordsToggle,
|
||||||
|
multipleWordsForRequest,
|
||||||
|
} from './variants';
|
||||||
|
|
||||||
describe('availableVariants', () => {
|
describe('availableVariants', () => {
|
||||||
it('is ungated (all variants) for an empty or absent set', () => {
|
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']);
|
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;
|
if (langs.length === 0) return ALL_VARIANTS;
|
||||||
return ALL_VARIANTS.filter((v) => langs.includes(VARIANT_LANGUAGE[v.id]));
|
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 { navigate } from '../lib/router.svelte';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import type { AccountRef, Variant } from '../lib/model';
|
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).
|
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
|
||||||
const AUTO_MATCH_HOURS = 24;
|
const AUTO_MATCH_HOURS = 24;
|
||||||
@@ -15,6 +21,10 @@
|
|||||||
// The offered variants are gated by the languages the sign-in service supports;
|
// 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.
|
// the auto-match list and the friend-invite picker both use this.
|
||||||
const variants = $derived(availableVariants(app.session?.supportedLanguages));
|
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 = [
|
const timeouts = [
|
||||||
{ secs: 300, key: 'time.minutes' as MessageKey, n: 5 },
|
{ secs: 300, key: 'time.minutes' as MessageKey, n: 5 },
|
||||||
{ secs: 1800, key: 'time.minutes' as MessageKey, n: 30 },
|
{ secs: 1800, key: 'time.minutes' as MessageKey, n: 30 },
|
||||||
@@ -70,7 +80,7 @@
|
|||||||
searching = true;
|
searching = true;
|
||||||
matched = false;
|
matched = false;
|
||||||
try {
|
try {
|
||||||
const r = await gateway.lobbyEnqueue(v);
|
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords));
|
||||||
if (r.matched && r.game) {
|
if (r.matched && r.game) {
|
||||||
matched = true;
|
matched = true;
|
||||||
searching = false;
|
searching = false;
|
||||||
@@ -137,6 +147,7 @@
|
|||||||
hintsAllowed: hints > 0,
|
hintsAllowed: hints > 0,
|
||||||
hintsPerPlayer: hints,
|
hintsPerPlayer: hints,
|
||||||
dropoutTiles: 'remove',
|
dropoutTiles: 'remove',
|
||||||
|
multipleWordsPerTurn: multipleWordsForRequest(inviteVariant, multipleWords),
|
||||||
});
|
});
|
||||||
showToast(t('new.invited'));
|
showToast(t('new.invited'));
|
||||||
navigate('/');
|
navigate('/');
|
||||||
@@ -171,6 +182,12 @@
|
|||||||
|
|
||||||
{#if mode === 'auto'}
|
{#if mode === 'auto'}
|
||||||
<p class="subtitle">{t('new.subtitle')}</p>
|
<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">
|
<div class="variants">
|
||||||
{#each variants as v (v.id)}
|
{#each variants as v (v.id)}
|
||||||
<button class="variant" onclick={() => find(v.id)} disabled={!connection.online}>
|
<button class="variant" onclick={() => find(v.id)} disabled={!connection.online}>
|
||||||
@@ -225,6 +242,12 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -385,6 +408,21 @@
|
|||||||
.field select.placeholder {
|
.field select.placeholder {
|
||||||
color: var(--text-muted);
|
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 {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user