Merge pull request 'Backend infers play direction; UI previews words and gates submit on legality' (#44) from feature/auto-play-direction into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 44s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s

This commit was merged in pull request #44.
This commit is contained in:
2026-06-11 21:02:47 +00:00
50 changed files with 426 additions and 403 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ the game domain wires it into the process.
event-sourced: a `games` row plus an append-only decoded move journal, with the event-sourced: a `games` row plus an append-only decoded move journal, with the
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
provides create, the play/pass/exchange/resign transitions, an unlimited provides create, the play/pass/exchange/resign transitions, an unlimited
score/legality preview, the hint (per-game allowance plus a profile wallet), the word/score/legality preview, the hint (per-game allowance plus a profile wallet), the
word-check tool with complaint capture, per-player game state, history and GCG word-check tool with complaint capture, per-player game state, history and GCG
export, per-account statistics on finish, and a background turn-timeout sweeper export, per-account statistics on finish, and a background turn-timeout sweeper
that auto-resigns overdue turns (honouring each player's daily away window). Like that auto-resigns overdue turns (honouring each player's daily away window). Like
+63
View File
@@ -0,0 +1,63 @@
package engine
import (
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// resolveDirection infers a play's orientation from the placed tiles and the
// board, so a caller need not declare it (docs/ARCHITECTURE.md §5). Two or more
// tiles fix the orientation by the line they share: a common row reads
// horizontally, otherwise vertically (a non-linear placement is left for
// Evaluate to reject). A single tile is ambiguous on its own — it may extend a
// word down a column or across a row — so the orientation is the axis along
// which it abuts existing tiles, preferring the axis that yields the longer word
// and horizontal on a tie. A tile that abuts nothing falls back to horizontal
// and is rejected downstream as disconnected (or, on the first move, as too
// short).
func resolveDirection(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
if len(placements) >= 2 {
row := placements[0].Row
for _, p := range placements[1:] {
if p.Row != row {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
if len(placements) == 1 {
p := placements[0]
h := runLength(b, p.Row, p.Col, scrabble.Horizontal)
v := runLength(b, p.Row, p.Col, scrabble.Vertical)
if v >= 2 && v > h {
return scrabble.Vertical
}
if h >= 2 {
return scrabble.Horizontal
}
if v >= 2 {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
// runLength returns how many cells the word through (row, col) along dir would
// span once a tile is placed on the empty target square: the square itself plus
// the runs of filled cells immediately before and after it along dir. A result
// below two means the tile forms no word on that axis. Filled treats
// off-board coordinates as empty, so the walks stop at the board edge.
func runLength(b *board.Board, row, col int, dir scrabble.Direction) int {
dr, dc := 0, 1
if dir == scrabble.Vertical {
dr, dc = 1, 0
}
n := 1
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
n++
}
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
n++
}
return n
}
+159
View File
@@ -0,0 +1,159 @@
package engine
import (
"errors"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// boardWith returns a 15x15 board with the given (row, col) cells occupied. The
// concrete letter is irrelevant to direction inference, which reads only
// occupancy, so every filler uses alphabet index 0.
func boardWith(cells ...[2]int) *board.Board {
b := board.New(15, 15)
ps := make([]scrabble.Placement, len(cells))
for i, c := range cells {
ps[i] = scrabble.Placement{Row: c[0], Col: c[1], Letter: 0}
}
scrabble.Apply(b, scrabble.Move{Tiles: ps})
return b
}
// TestRunLength covers the word-span measurement that drives single-tile
// direction inference, including the board edge.
func TestRunLength(t *testing.T) {
tests := []struct {
name string
filled [][2]int
row, col int
dir scrabble.Direction
want int
}{
{"isolated horizontal", nil, 7, 7, scrabble.Horizontal, 1},
{"isolated vertical", nil, 7, 7, scrabble.Vertical, 1},
{"neighbour below", [][2]int{{8, 7}}, 7, 7, scrabble.Vertical, 2},
{"neighbour above", [][2]int{{6, 7}}, 7, 7, scrabble.Vertical, 2},
{"run below", [][2]int{{8, 7}, {9, 7}}, 7, 7, scrabble.Vertical, 3},
{"bridge vertical", [][2]int{{6, 7}, {8, 7}}, 7, 7, scrabble.Vertical, 3},
{"neighbour left", [][2]int{{7, 6}}, 7, 7, scrabble.Horizontal, 2},
{"neighbour right", [][2]int{{7, 8}}, 7, 7, scrabble.Horizontal, 2},
{"bridge horizontal", [][2]int{{7, 6}, {7, 8}}, 7, 7, scrabble.Horizontal, 3},
{"perpendicular ignored", [][2]int{{7, 6}}, 7, 7, scrabble.Vertical, 1},
{"top edge", [][2]int{{1, 0}}, 0, 0, scrabble.Vertical, 2},
{"left edge", [][2]int{{0, 1}}, 0, 0, scrabble.Horizontal, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
b := boardWith(tt.filled...)
if got := runLength(b, tt.row, tt.col, tt.dir); got != tt.want {
t.Errorf("runLength(%d,%d,%v) = %d, want %d", tt.row, tt.col, tt.dir, got, tt.want)
}
})
}
}
// TestResolveDirection covers orientation inference for both multi-tile plays
// (fixed by the shared line) and the ambiguous single tile (the axis it abuts,
// longer word winning and horizontal on a tie; disconnected falls back to
// horizontal for the downstream rejection).
func TestResolveDirection(t *testing.T) {
at := func(cells ...[2]int) []scrabble.Placement {
ps := make([]scrabble.Placement, len(cells))
for i, c := range cells {
ps[i] = scrabble.Placement{Row: c[0], Col: c[1]}
}
return ps
}
tests := []struct {
name string
filled [][2]int
play []scrabble.Placement
want scrabble.Direction
}{
{"single extends down", [][2]int{{8, 7}, {9, 7}}, at([2]int{7, 7}), scrabble.Vertical},
{"single extends up", [][2]int{{6, 7}, {5, 7}}, at([2]int{7, 7}), scrabble.Vertical},
{"single extends left", [][2]int{{7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single extends right", [][2]int{{7, 8}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single both axes vertical longer", [][2]int{{6, 7}, {8, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Vertical},
{"single both axes horizontal longer", [][2]int{{7, 6}, {7, 8}, {6, 7}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single both axes equal prefers horizontal", [][2]int{{6, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single disconnected falls back to horizontal", nil, at([2]int{7, 7}), scrabble.Horizontal},
{"multi shared row is horizontal", nil, at([2]int{7, 7}, [2]int{7, 8}), scrabble.Horizontal},
{"multi shared column is vertical", nil, at([2]int{7, 7}, [2]int{8, 7}), scrabble.Vertical},
{"multi non-linear is vertical", nil, at([2]int{7, 7}, [2]int{8, 8}), scrabble.Vertical},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
b := boardWith(tt.filled...)
if got := resolveDirection(b, tt.play); got != tt.want {
t.Errorf("resolveDirection = %v, want %v", got, tt.want)
}
})
}
}
// TestResolveDirectionEmpty checks the degenerate empty placement does not panic
// and falls back to horizontal (Evaluate rejects the empty play downstream).
func TestResolveDirectionEmpty(t *testing.T) {
if got := resolveDirection(boardWith(), nil); got != scrabble.Horizontal {
t.Errorf("resolveDirection(empty) = %v, want Horizontal", got)
}
}
// TestSubmitPlaySingleTileVerticalExtension is the regression for the reported
// bug: a single tile placed above an existing vertical word forms a legal play
// the engine must accept by inferring the vertical orientation. Trusting a
// horizontal orientation (the pre-fix client default) wrongly rejects it.
func TestSubmitPlaySingleTileVerticalExtension(t *testing.T) {
// БАК runs down column 7 (rows 7..9); the mover holds А and plays it at
// (6,7), prefixing АБАК. This mirrors the contour game that surfaced the bug.
setup := func(t *testing.T) (*Game, []TileRecord) {
t.Helper()
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
if err != nil {
t.Fatalf("new erudit game: %v", err)
}
idx := func(s string) byte {
i, err := g.rules.Alphabet.Index(s)
if err != nil {
t.Fatalf("index %q: %v", s, err)
}
return i
}
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 7, Col: 7, Letter: idx("б")},
{Row: 8, Col: 7, Letter: idx("а")},
{Row: 9, Col: 7, Letter: idx("к")},
}})
g.hands[0] = []byte{idx("а")}
return g, []TileRecord{{Row: 6, Col: 7, Letter: "а"}}
}
t.Run("inferred direction accepts the play", func(t *testing.T) {
g, tiles := setup(t)
rec, err := g.SubmitPlay(tiles)
if err != nil {
t.Fatalf("submit play: %v", err)
}
if rec.Dir != Vertical {
t.Errorf("direction = %v, want Vertical", rec.Dir)
}
if len(rec.Words) == 0 || rec.Words[0] != "абак" {
t.Errorf("words = %v, want main word абак", rec.Words)
}
if rec.Score <= 0 {
t.Errorf("score = %d, want positive", rec.Score)
}
})
t.Run("trusting horizontal rejects it", func(t *testing.T) {
g, tiles := setup(t)
if _, err := g.SubmitPlayDir(Horizontal, tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("submit horizontal = %v, want ErrIllegalPlay", err)
}
})
}
+22 -7
View File
@@ -52,11 +52,25 @@ func fromScrabbleDir(d scrabble.Direction) Direction {
// SubmitPlay validates and applies the current player's play described in decoded // SubmitPlay validates and applies the current player's play described in decoded
// terms: each TileRecord carries a concrete letter (the letter a blank stands for // terms: each TileRecord carries a concrete letter (the letter a blank stands for
// when Blank is set) and a board coordinate. It encodes the tiles through the // when Blank is set) and a board coordinate. It infers the play's orientation
// from the tiles and the board (resolveDirection), encodes the tiles through the
// ruleset alphabet and delegates to Play, so it returns the same errors // ruleset alphabet and delegates to Play, so it returns the same errors
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a // (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
// letter is outside the variant's alphabet. // letter is outside the variant's alphabet.
func (g *Game) SubmitPlay(dir Direction, tiles []TileRecord) (MoveRecord, error) { func (g *Game) SubmitPlay(tiles []TileRecord) (MoveRecord, error) {
placements, err := g.placements(tiles)
if err != nil {
return MoveRecord{}, err
}
return g.Play(resolveDirection(g.board, placements), placements)
}
// SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred.
// It exists for journal replay, which reproduces a committed game exactly from
// the stored "H"/"V" rather than re-deriving it (docs/ARCHITECTURE.md §9.1):
// re-derivation would tie historical reconstruction to the current resolver, so
// replay trusts the recorded direction. Live play uses SubmitPlay.
func (g *Game) SubmitPlayDir(dir Direction, tiles []TileRecord) (MoveRecord, error) {
placements, err := g.placements(tiles) placements, err := g.placements(tiles)
if err != nil { if err != nil {
return MoveRecord{}, err return MoveRecord{}, err
@@ -78,10 +92,11 @@ func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
// EvaluatePlay scores and validates a tentative play without committing it, // EvaluatePlay scores and validates a tentative play without committing it,
// backing the unlimited "what would my next move score, and is it legal?" tool. // backing the unlimited "what would my next move score, and is it legal?" tool.
// It returns the decoded move (placed tiles, the words it forms and its score) // It infers the play's orientation from the tiles and the board exactly as
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn // SubmitPlay does, then returns the decoded move (placed tiles, the words it
// are left untouched. // forms, its orientation and its score) or ErrIllegalPlay when the solver
func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) { // rejects it. The board, racks, bag and turn are left untouched.
func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
if g.over { if g.over {
return MoveRecord{}, ErrGameOver return MoveRecord{}, ErrGameOver
} }
@@ -89,7 +104,7 @@ func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, erro
if err != nil { if err != nil {
return MoveRecord{}, err return MoveRecord{}, err
} }
move, err := g.solver.ValidatePlay(g.board, dir.scrabbleDir(), placements) move, err := g.solver.ValidatePlay(g.board, resolveDirection(g.board, placements), placements)
if err != nil { if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
} }
+3 -3
View File
@@ -25,7 +25,7 @@ func TestSubmitPlayMatchesHint(t *testing.T) {
if !ok { if !ok {
t.Fatal("opening game has no hint") t.Fatal("opening game has no hint")
} }
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles) rec, err := g.SubmitPlay(hint.Tiles)
if err != nil { if err != nil {
t.Fatalf("submit play: %v", err) t.Fatalf("submit play: %v", err)
} }
@@ -85,7 +85,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
boardBefore := g.BoardClone() boardBefore := g.BoardClone()
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen() scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles) rec, err := g.EvaluatePlay(hint.Tiles)
if err != nil { if err != nil {
t.Fatalf("evaluate play: %v", err) t.Fatalf("evaluate play: %v", err)
} }
@@ -106,7 +106,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
func TestEvaluatePlayRejectsIllegal(t *testing.T) { func TestEvaluatePlayRejectsIllegal(t *testing.T) {
g := newEnglishGame(t, 1) g := newEnglishGame(t, 1)
letter := g.Hand(0)[0] letter := g.Hand(0)[0]
_, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}}) _, err := g.EvaluatePlay([]TileRecord{{Row: 0, Col: 0, Letter: letter}})
if !errors.Is(err, ErrIllegalPlay) { if !errors.Is(err, ErrIllegalPlay) {
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err) t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
} }
+5 -5
View File
@@ -12,7 +12,7 @@ func TestResignLeadingPlayerStillLoses(t *testing.T) {
if !ok { if !ok {
t.Fatal("opening game has no hint") t.Fatal("opening game has no hint")
} }
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) played, err := g.SubmitPlay(hint.Tiles)
if err != nil { if err != nil {
t.Fatalf("player 0 play: %v", err) t.Fatalf("player 0 play: %v", err)
} }
@@ -56,7 +56,7 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
if !ok { if !ok {
t.Fatal("opening game has no hint") t.Fatal("opening game has no hint")
} }
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 scores if _, err := g.SubmitPlay(hint.Tiles); err != nil { // player 0 scores
t.Fatalf("player 0 play: %v", err) t.Fatalf("player 0 play: %v", err)
} }
@@ -79,7 +79,7 @@ func TestResignSeatOffTurn(t *testing.T) {
if !ok { if !ok {
t.Fatal("opening game has no hint") t.Fatal("opening game has no hint")
} }
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 moves if _, err := g.SubmitPlay(hint.Tiles); err != nil { // player 0 moves
t.Fatalf("player 0 play: %v", err) t.Fatalf("player 0 play: %v", err)
} }
if g.ToMove() != 1 { if g.ToMove() != 1 {
@@ -165,7 +165,7 @@ func TestMultiplayerLastActiveWins(t *testing.T) {
if !ok { if !ok {
t.Fatal("opening game has no hint") t.Fatal("opening game has no hint")
} }
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 takes the lead played, err := g.SubmitPlay(hint.Tiles) // seat 0 takes the lead
if err != nil { if err != nil {
t.Fatalf("seat 0 play: %v", err) t.Fatalf("seat 0 play: %v", err)
} }
@@ -245,7 +245,7 @@ func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
if !ok { if !ok {
t.Fatal("opening game has no hint") t.Fatal("opening game has no hint")
} }
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 leads played, err := g.SubmitPlay(hint.Tiles) // seat 0 leads
if err != nil { if err != nil {
t.Fatalf("seat 0 play: %v", err) t.Fatalf("seat 0 play: %v", err)
} }
+9 -7
View File
@@ -147,10 +147,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
// and, for an exchange, the swapped tiles. // and, for an exchange, the swapped tiles.
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error) type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
// SubmitPlay validates, scores and commits the player's placement. // SubmitPlay validates, scores and commits the player's placement. The engine
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (MoveResult, error) { // infers the play's orientation from the tiles and the board, so the caller
// supplies only the placed tiles (docs/ARCHITECTURE.md §5).
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) { return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.SubmitPlay(dir, tiles) rec, err := g.SubmitPlay(tiles)
return rec, nil, err return rec, nil, err
}) })
} }
@@ -528,7 +530,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
// EvaluatePlay previews a tentative play for a seated player against the current // EvaluatePlay previews a tentative play for a seated player against the current
// board without committing it: whether it is legal and what it would score. // board without committing it: whether it is legal and what it would score.
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (EvalResult, error) { func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (EvalResult, error) {
pre, err := svc.store.GetGame(ctx, gameID) pre, err := svc.store.GetGame(ctx, gameID)
if err != nil { if err != nil {
return EvalResult{}, err return EvalResult{}, err
@@ -547,7 +549,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
return EvalResult{}, err return EvalResult{}, err
} }
validateStart := time.Now() validateStart := time.Now()
rec, err := g.EvaluatePlay(dir, tiles) rec, err := g.EvaluatePlay(tiles)
svc.metrics.recordValidate(ctx, pre.Variant, validateStart) svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
if err != nil { if err != nil {
if errors.Is(err, engine.ErrIllegalPlay) { if errors.Is(err, engine.ErrIllegalPlay) {
@@ -555,7 +557,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
} }
return EvalResult{}, err return EvalResult{}, err
} }
return EvalResult{Valid: true, Score: rec.Score, Words: rec.Words}, nil return EvalResult{Valid: true, Score: rec.Score, Words: rec.Words, Dir: rec.Dir.String()}, nil
} }
// CheckWord reports whether word is in the game's pinned dictionary. It is the // CheckWord reports whether word is in the game's pinned dictionary. It is the
@@ -961,7 +963,7 @@ func replayMove(g *engine.Game, mv HistoryMove) error {
if mv.Dir == "V" { if mv.Dir == "V" {
dir = engine.Vertical dir = engine.Vertical
} }
_, err := g.SubmitPlay(dir, mv.Tiles) _, err := g.SubmitPlayDir(dir, mv.Tiles)
return err return err
case "pass": case "pass":
_, err := g.Pass() _, err := g.Pass()
+4 -1
View File
@@ -141,11 +141,14 @@ type HintResult struct {
HintsRemaining int HintsRemaining int
} }
// EvalResult previews a tentative play without committing it. // EvalResult previews a tentative play without committing it. Dir is the
// orientation the engine inferred for the play ("H"/"V"), empty when the play is
// illegal; Words lists the words it would form, the main word first.
type EvalResult struct { type EvalResult struct {
Valid bool Valid bool
Score int Score int
Words []string Words []string
Dir string
} }
// StateView is a player's view of a game: the shared game plus their private // StateView is a player's view of a game: the shared game plus their private
+2 -2
View File
@@ -35,7 +35,7 @@ func TestDraftPersistAndConflictReset(t *testing.T) {
t.Fatalf("save draft 1: %v", err) t.Fatalf("save draft 1: %v", err)
} }
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil { if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Tiles); err != nil {
t.Fatalf("seat0 play: %v", err) t.Fatalf("seat0 play: %v", err)
} }
@@ -61,7 +61,7 @@ func TestDraftSurvivesNonConflictingMove(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("save draft 1: %v", err) t.Fatalf("save draft 1: %v", err)
} }
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil { if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Tiles); err != nil {
t.Fatalf("seat0 play: %v", err) t.Fatalf("seat0 play: %v", err)
} }
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" { if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" {
+13 -10
View File
@@ -96,11 +96,11 @@ func TestGameLifecycleAndStats(t *testing.T) {
for i := 0; i < 300 && !mirror.Over(); i++ { for i := 0; i < 300 && !mirror.Over(); i++ {
cur := seats[mirror.ToMove()] cur := seats[mirror.ToMove()]
if hint, ok := mirror.HintView(); ok { if hint, ok := mirror.HintView(); ok {
last, err = svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles) last, err = svc.SubmitPlay(ctx, g.ID, cur, hint.Tiles)
if err != nil { if err != nil {
t.Fatalf("submit play: %v", err) t.Fatalf("submit play: %v", err)
} }
if _, err := mirror.SubmitPlay(hint.Dir, hint.Tiles); err != nil { if _, err := mirror.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("mirror play: %v", err) t.Fatalf("mirror play: %v", err)
} }
} else { } else {
@@ -154,10 +154,10 @@ func TestReplayEquivalence(t *testing.T) {
for i := 0; i < 6 && !mirror.Over(); i++ { for i := 0; i < 6 && !mirror.Over(); i++ {
cur := seats[mirror.ToMove()] cur := seats[mirror.ToMove()]
if hint, ok := mirror.HintView(); ok { if hint, ok := mirror.HintView(); ok {
if _, err := svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles); err != nil { if _, err := svc.SubmitPlay(ctx, g.ID, cur, hint.Tiles); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
mirror.SubmitPlay(hint.Dir, hint.Tiles) mirror.SubmitPlay(hint.Tiles)
} else { } else {
if _, err := svc.Pass(ctx, g.ID, cur); err != nil { if _, err := svc.Pass(ctx, g.ID, cur); err != nil {
t.Fatalf("pass: %v", err) t.Fatalf("pass: %v", err)
@@ -201,7 +201,7 @@ func TestResignWinnerAndStats(t *testing.T) {
if !ok { if !ok {
t.Fatal("no opening move") t.Fatal("no opening move")
} }
played, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles) // p0 scores played, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles) // p0 scores
if err != nil { if err != nil {
t.Fatalf("p0 play: %v", err) t.Fatalf("p0 play: %v", err)
} }
@@ -248,7 +248,7 @@ func TestResignOnOpponentTurn(t *testing.T) {
if !ok { if !ok {
t.Fatal("no opening move") t.Fatal("no opening move")
} }
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err != nil { // p0 scores, now p1's turn
t.Fatalf("p0 play: %v", err) t.Fatalf("p0 play: %v", err)
} }
@@ -455,19 +455,22 @@ func TestEvaluatePlayPreview(t *testing.T) {
} }
hint, _ := newMirror(t, seed, 2).HintView() hint, _ := newMirror(t, seed, 2).HintView()
eval, err := svc.EvaluatePlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles) eval, err := svc.EvaluatePlay(ctx, g.ID, seats[0], hint.Tiles)
if err != nil { if err != nil {
t.Fatalf("evaluate: %v", err) t.Fatalf("evaluate: %v", err)
} }
if !eval.Valid || eval.Score <= 0 { if !eval.Valid || eval.Score <= 0 {
t.Errorf("legal preview = %+v, want valid with score", eval) t.Errorf("legal preview = %+v, want valid with score", eval)
} }
if eval.Dir != hint.Dir.String() || len(eval.Words) == 0 {
t.Errorf("legal preview dir/words = (%q, %v), want dir %q with the words formed", eval.Dir, eval.Words, hint.Dir.String())
}
// The same play must still be available afterwards (no commit). // The same play must still be available afterwards (no commit).
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err != nil {
t.Fatalf("submit after evaluate: %v", err) t.Fatalf("submit after evaluate: %v", err)
} }
bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], engine.Horizontal, []engine.TileRecord{{Row: 0, Col: 0, Letter: "q"}}) bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], []engine.TileRecord{{Row: 0, Col: 0, Letter: "q"}})
if err != nil { if err != nil {
t.Fatalf("evaluate illegal: %v", err) t.Fatalf("evaluate illegal: %v", err)
} }
@@ -498,7 +501,7 @@ func TestConcurrentSubmitSerialized(t *testing.T) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err == nil { if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err == nil {
mu.Lock() mu.Lock()
ok++ ok++
mu.Unlock() mu.Unlock()
+1 -1
View File
@@ -237,7 +237,7 @@ func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, hum
t.Fatalf("human candidates: %v", err) t.Fatalf("human candidates: %v", err)
} }
if len(cands) > 0 { if len(cands) > 0 {
if _, err := svc.SubmitPlay(ctx, gameID, human, cands[0].Dir, cands[0].Tiles); err != nil { if _, err := svc.SubmitPlay(ctx, gameID, human, cands[0].Tiles); err != nil {
t.Fatalf("human play: %v", err) t.Fatalf("human play: %v", err)
} }
return return
+1 -1
View File
@@ -138,7 +138,7 @@ func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) err
var res game.MoveResult var res game.MoveResult
switch d.kind { switch d.kind {
case decidePlay: case decidePlay:
res, err = s.games.SubmitPlay(ctx, rt.GameID, rt.RobotID, d.move.Dir, d.move.Tiles) res, err = s.games.SubmitPlay(ctx, rt.GameID, rt.RobotID, d.move.Tiles)
case decideExchange: case decideExchange:
res, err = s.games.Exchange(ctx, rt.GameID, rt.RobotID, d.exchange) res, err = s.games.Exchange(ctx, rt.GameID, rt.RobotID, d.exchange)
default: default:
+1 -1
View File
@@ -42,7 +42,7 @@ type GameDriver interface {
Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error)
Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error)
GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, error) GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, error)
SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (game.MoveResult, error) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (game.MoveResult, error)
Pass(ctx context.Context, gameID, accountID uuid.UUID) (game.MoveResult, error) Pass(ctx context.Context, gameID, accountID uuid.UUID) (game.MoveResult, error)
Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (game.MoveResult, error) Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (game.MoveResult, error)
} }
-14
View File
@@ -1,8 +1,6 @@
package server package server
import ( import (
"strings"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
@@ -297,15 +295,3 @@ func chatDTOFrom(m social.Message) chatDTO {
CreatedAtUnix: m.CreatedAt.Unix(), CreatedAtUnix: m.CreatedAt.Unix(),
} }
} }
// parseDirection maps the wire direction string to an engine.Direction.
func parseDirection(s string) (engine.Direction, bool) {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "H":
return engine.Horizontal, true
case "V":
return engine.Vertical, true
default:
return 0, false
}
}
-24
View File
@@ -14,30 +14,6 @@ import (
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
) )
func TestParseDirection(t *testing.T) {
cases := map[string]struct {
in string
want engine.Direction
ok bool
}{
"horizontal": {"H", engine.Horizontal, true},
"vertical": {"V", engine.Vertical, true},
"lowercase": {"h", engine.Horizontal, true},
"trimmed": {" V ", engine.Vertical, true},
"invalid": {"X", 0, false},
"empty": {"", 0, false},
"diagonal-is-not": {"D", 0, false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got, ok := parseDirection(tc.in)
if ok != tc.ok || (ok && got != tc.want) {
t.Fatalf("parseDirection(%q) = (%v, %v), want (%v, %v)", tc.in, got, ok, tc.want, tc.ok)
}
})
}
}
func TestStatusForError(t *testing.T) { func TestStatusForError(t *testing.T) {
cases := map[string]struct { cases := map[string]struct {
err error err error
+6 -8
View File
@@ -22,11 +22,14 @@ type hintResultDTO struct {
HintsRemaining int `json:"hints_remaining"` HintsRemaining int `json:"hints_remaining"`
} }
// evalResultDTO is an unlimited move preview: legality, score and the words formed. // evalResultDTO is an unlimited move preview: legality, score, the words formed
// (main word first) and the orientation the engine inferred ("H"/"V", empty when
// illegal).
type evalResultDTO struct { type evalResultDTO struct {
Legal bool `json:"legal"` Legal bool `json:"legal"`
Score int `json:"score"` Score int `json:"score"`
Words []string `json:"words"` Words []string `json:"words"`
Dir string `json:"dir"`
} }
// wordCheckDTO is the result of the unlimited dictionary lookup tool. // wordCheckDTO is the result of the unlimited dictionary lookup tool.
@@ -186,11 +189,6 @@ func (s *Server) handleEvaluate(c *gin.Context) {
abortBadRequest(c, "invalid request body") abortBadRequest(c, "invalid request body")
return return
} }
dir, ok := parseDirection(req.Dir)
if !ok {
abortBadRequest(c, "dir must be H or V")
return
}
variant, err := s.games.GameVariant(c.Request.Context(), gameID) variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
@@ -201,12 +199,12 @@ func (s *Server) handleEvaluate(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, dir, tiles) ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, tiles)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words}) c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words, Dir: ev.Dir})
} }
// handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as // handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as
+1 -10
View File
@@ -75,18 +75,9 @@ func TestRateLimitReportEndpoint(t *testing.T) {
} }
} }
func TestSubmitPlayRejectsBadDirection(t *testing.T) {
headers := map[string]string{"X-User-ID": uuid.New().String()}
path := "/api/v1/user/games/" + uuid.New().String() + "/play"
rec := do(t, newRoutingServer(), http.MethodPost, path, `{"dir":"X","tiles":[]}`, headers)
if rec.Code != http.StatusBadRequest {
t.Fatalf("submit play bad dir = %d, want 400", rec.Code)
}
}
func TestSubmitPlayRejectsBadGameID(t *testing.T) { func TestSubmitPlayRejectsBadGameID(t *testing.T) {
headers := map[string]string{"X-User-ID": uuid.New().String()} headers := map[string]string{"X-User-ID": uuid.New().String()}
rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/user/games/not-a-uuid/play", `{"dir":"H"}`, headers) rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/user/games/not-a-uuid/play", `{}`, headers)
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("submit play bad game id = %d, want 400", rec.Code) t.Fatalf("submit play bad game id = %d, want 400", rec.Code)
} }
+4 -9
View File
@@ -26,10 +26,10 @@ func (s *Server) handleProfile(c *gin.Context) {
c.JSON(http.StatusOK, profileResponseFor(acc)) c.JSON(http.StatusOK, profileResponseFor(acc))
} }
// submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter // submitPlayRequest places tiles on the player's turn; the engine infers the
// is a wire alphabet index; for a blank it is the designated letter's index. // play's orientation from the tiles and the board. Each tile's Letter is a wire
// alphabet index; for a blank it is the designated letter's index.
type submitPlayRequest struct { type submitPlayRequest struct {
Dir string `json:"dir"`
Tiles []struct { Tiles []struct {
Row int `json:"row"` Row int `json:"row"`
Col int `json:"col"` Col int `json:"col"`
@@ -70,11 +70,6 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
abortBadRequest(c, "invalid request body") abortBadRequest(c, "invalid request body")
return return
} }
dir, ok := parseDirection(req.Dir)
if !ok {
abortBadRequest(c, "dir must be H or V")
return
}
variant, err := s.games.GameVariant(c.Request.Context(), gameID) variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
@@ -85,7 +80,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, dir, tiles) res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, tiles)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
+9 -1
View File
@@ -262,6 +262,13 @@ Key points:
check for resign. The engine exposes a check for resign. The engine exposes a
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/ decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
`HintView`/`Hand`) so `internal/game` drives it without importing the solver. `HintView`/`Hand`) so `internal/game` drives it without importing the solver.
A play's **orientation (H/V) is inferred from the placed tiles and the board**,
not supplied by the caller: two or more tiles fix it by the line they share; a
lone tile takes the axis along which it abuts existing tiles (the longer word
winning, horizontal on a tie), so a single tile extending an existing word
vertically is accepted. Journal replay instead trusts the **stored** direction
(`SubmitPlayDir`, §9.1) to reproduce a committed game exactly rather than
re-deriving it.
- The **game domain** (`internal/game`) owns everything the engine does not — - The **game domain** (`internal/game`) owns everything the engine does not —
persistence, turn scheduling, the configurable turn timeout / auto-resign, the persistence, turn scheduling, the configurable turn timeout / auto-resign, the
hint budget, word-check complaints, history and GCG — and is the engine's only hint budget, word-check complaints, history and GCG — and is the engine's only
@@ -469,7 +476,8 @@ and — in a per-move JSON payload — the acting player's rack before the move
`?` for a blank), and for a play its direction, main-word anchor, placed tiles `?` for a blank), and for a play its direction, main-word anchor, placed tiles
(letter as text, coordinate, blank flag) and the words formed; for an exchange, (letter as text, coordinate, blank flag) and the words formed; for an exchange,
the swapped tiles. This is exactly what is needed both to **replay the game the swapped tiles. This is exactly what is needed both to **replay the game
through the engine** (a cache miss) and to render history or emit GCG **without a through the engine** (a cache miss; replay trusts the stored direction rather than
re-deriving it, so the rebuild matches the committed game) and to render history or emit GCG **without a
dictionary**: the board for visual replay is reconstructed by applying placements dictionary**: the board for visual replay is reconstructed by applying placements
onto an empty grid, since moves were validated at play time and scores are onto an empty grid, since moves were validated at play time and scores are
stored. `variant` and `dict_version` are kept as **metadata only** (audit, stored. `variant` and `dict_version` are kept as **metadata only** (audit,
+6 -3
View File
@@ -92,9 +92,12 @@ settings and the game starts once every invitee has accepted — any decline can
expires after seven days. expires after seven days.
### Playing a game ### Playing a game
Place tiles, pass, exchange, or resign. A play is validated against the game's Place tiles, pass, exchange, or resign. Tiles are laid without choosing a
dictionary at submit time and scored; an unlimited preview reports what a direction — the game infers the play's orientation, so a single tile that extends
tentative move would score and whether it is legal. The dictionary check tool is an existing word (down a column or across a row) is accepted. A play is validated
against the game's dictionary at submit time and scored; an unlimited preview
reports the word(s) a tentative move would form and its score, or that it is not
legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is
unlimited and offers a complaint on any result. Hints are governed per game — unlimited and offers a complaint on any result. Hints are governed per game —
whether they are allowed and how many each player starts with — and draw on a whether they are allowed and how many each player starts with — and draw on a
personal hint wallet once the per-game allowance is spent. The game ends when the personal hint wallet once the per-game allowance is spent. The game ends when the
+6 -3
View File
@@ -96,9 +96,12 @@ nudge) приходят от бота **этой партии** — по язы
ответа приглашение протухает через семь дней. ответа приглашение протухает через семь дней.
### Игровой процесс ### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при Выкладывание фишек, пас, обмен или сдача. Фишки кладутся без выбора направления —
сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая уже лежащее
предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат. Подсказки управляются настройками предлагает пожаловаться на любой результат. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
+7 -5
View File
@@ -225,9 +225,9 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet // SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
// index. // index.
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (MoveResultResp, error) { func (c *Client) SubmitPlay(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (MoveResultResp, error) {
var out MoveResultResp var out MoveResultResp
body := map[string]any{"dir": dir, "tiles": tiles} body := map[string]any{"tiles": tiles}
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out) err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
return out, err return out, err
} }
@@ -279,11 +279,13 @@ type HintResultResp struct {
HintsRemaining int `json:"hints_remaining"` HintsRemaining int `json:"hints_remaining"`
} }
// EvalResultResp is an unlimited move preview. // EvalResultResp is an unlimited move preview. Dir is the orientation the backend
// inferred ("H"/"V", empty when illegal); Words lists the words formed, main word first.
type EvalResultResp struct { type EvalResultResp struct {
Legal bool `json:"legal"` Legal bool `json:"legal"`
Score int `json:"score"` Score int `json:"score"`
Words []string `json:"words"` Words []string `json:"words"`
Dir string `json:"dir"`
} }
// WordCheckResp is a dictionary lookup outcome. // WordCheckResp is a dictionary lookup outcome.
@@ -365,10 +367,10 @@ func (c *Client) HideGame(ctx context.Context, userID, gameID string) error {
// Evaluate previews a tentative play's legality and score. The tiles are addressed by // Evaluate previews a tentative play's legality and score. The tiles are addressed by
// alphabet index. // alphabet index.
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) { func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (EvalResultResp, error) {
var out EvalResultResp var out EvalResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "", err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
map[string]any{"dir": dir, "tiles": tiles}, &out) map[string]any{"tiles": tiles}, &out)
return out, err return out, err
} }
+2
View File
@@ -219,10 +219,12 @@ func encodeHintResult(r backendclient.HintResultResp) []byte {
func encodeEvalResult(r backendclient.EvalResultResp) []byte { func encodeEvalResult(r backendclient.EvalResultResp) []byte {
b := flatbuffers.NewBuilder(256) b := flatbuffers.NewBuilder(256)
words := buildStringVector(b, r.Words, fb.EvalResultStartWordsVector) words := buildStringVector(b, r.Words, fb.EvalResultStartWordsVector)
dir := b.CreateString(r.Dir)
fb.EvalResultStart(b) fb.EvalResultStart(b)
fb.EvalResultAddLegal(b, r.Legal) fb.EvalResultAddLegal(b, r.Legal)
fb.EvalResultAddScore(b, int32(r.Score)) fb.EvalResultAddScore(b, int32(r.Score))
fb.EvalResultAddWords(b, words) fb.EvalResultAddWords(b, words)
fb.EvalResultAddDir(b, dir)
b.Finish(fb.EvalResultEnd(b)) b.Finish(fb.EvalResultEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
+2 -2
View File
@@ -202,7 +202,7 @@ func profileHandler(backend *backendclient.Client) Handler {
func submitPlayHandler(backend *backendclient.Client) Handler { func submitPlayHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0) in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0)
res, err := backend.SubmitPlay(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeTiles(in)) res, err := backend.SubmitPlay(ctx, req.UserID, string(in.GameId()), decodeTiles(in))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -367,7 +367,7 @@ func hintHandler(backend *backendclient.Client) Handler {
func evaluateHandler(backend *backendclient.Client) Handler { func evaluateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEvalRequest(req.Payload, 0) in := fb.GetRootAsEvalRequest(req.Payload, 0)
res, err := backend.Evaluate(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeEvalTiles(in)) res, err := backend.Evaluate(ctx, req.UserID, string(in.GameId()), decodeEvalTiles(in))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -110,7 +110,6 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
b := flatbuffers.NewBuilder(64) b := flatbuffers.NewBuilder(64)
gid := b.CreateString("g-5") gid := b.CreateString("g-5")
dir := b.CreateString("H")
fb.PlayTileStart(b) fb.PlayTileStart(b)
fb.PlayTileAddRow(b, 7) fb.PlayTileAddRow(b, 7)
fb.PlayTileAddCol(b, 7) fb.PlayTileAddCol(b, 7)
@@ -122,7 +121,6 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
tiles := b.EndVector(1) tiles := b.EndVector(1)
fb.SubmitPlayRequestStart(b) fb.SubmitPlayRequestStart(b)
fb.SubmitPlayRequestAddGameId(b, gid) fb.SubmitPlayRequestAddGameId(b, gid)
fb.SubmitPlayRequestAddDir(b, dir)
fb.SubmitPlayRequestAddTiles(b, tiles) fb.SubmitPlayRequestAddTiles(b, tiles)
b.Finish(fb.SubmitPlayRequestEnd(b)) b.Finish(fb.SubmitPlayRequestEnd(b))
+3 -5
View File
@@ -37,12 +37,11 @@ func stateReq(gameID string, includeAlphabet bool) []byte {
return b.FinishedBytes() return b.FinishedBytes()
} }
// submitPlay builds a SubmitPlayRequest payload. dir is "H" or "V"; tiles are the // submitPlay builds a SubmitPlayRequest payload. tiles are the newly-placed tiles in
// newly-placed tiles in main-word order. // main-word order; the backend infers the play's orientation from them and the board.
func submitPlay(gameID, dir string, tiles []PlayTile) []byte { func submitPlay(gameID string, tiles []PlayTile) []byte {
b := flatbuffers.NewBuilder(256) b := flatbuffers.NewBuilder(256)
gid := b.CreateString(gameID) gid := b.CreateString(gameID)
d := b.CreateString(dir)
offs := make([]flatbuffers.UOffsetT, len(tiles)) offs := make([]flatbuffers.UOffsetT, len(tiles))
for i, t := range tiles { for i, t := range tiles {
fb.PlayTileStart(b) fb.PlayTileStart(b)
@@ -59,7 +58,6 @@ func submitPlay(gameID, dir string, tiles []PlayTile) []byte {
tilesVec := b.EndVector(len(offs)) tilesVec := b.EndVector(len(offs))
fb.SubmitPlayRequestStart(b) fb.SubmitPlayRequestStart(b)
fb.SubmitPlayRequestAddGameId(b, gid) fb.SubmitPlayRequestAddGameId(b, gid)
fb.SubmitPlayRequestAddDir(b, d)
fb.SubmitPlayRequestAddTiles(b, tilesVec) fb.SubmitPlayRequestAddTiles(b, tilesVec)
b.Finish(fb.SubmitPlayRequestEnd(b)) b.Finish(fb.SubmitPlayRequestEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
+2 -2
View File
@@ -27,8 +27,8 @@ func (c *Client) History(ctx context.Context, token, gameID string) ([]Move, str
} }
// SubmitPlay commits a play and returns the post-move game. // SubmitPlay commits a play and returns the post-move game.
func (c *Client) SubmitPlay(ctx context.Context, token, gameID, dir string, tiles []PlayTile) (Game, string, error) { func (c *Client) SubmitPlay(ctx context.Context, token, gameID string, tiles []PlayTile) (Game, string, error) {
r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, dir, tiles)) r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, tiles))
if err != nil || r.Code != "ok" { if err != nil || r.Code != "ok" {
return Game{}, r.Code, err return Game{}, r.Code, err
} }
+3 -12
View File
@@ -78,11 +78,10 @@ func (r *Registry) Close() {
} }
} }
// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Dir // Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Tiles
// ("H"/"V") and Tiles; an exchange carries Exchange (rack indices to swap). // (the backend infers the orientation); an exchange carries Exchange (rack indices to swap).
type Action struct { type Action struct {
Kind string Kind string
Dir string
Tiles []edge.PlayTile Tiles []edge.PlayTile
Exchange []byte Exchange []byte
} }
@@ -106,7 +105,7 @@ func (r *Registry) Pick(variant string, history []edge.Move, rackIdx []byte, bag
return noPlay(rackIdx, bagLen >= e.rs.RackSize), nil return noPlay(rackIdx, bagLen >= e.rs.RackSize), nil
} }
m := midRanked(legal, rng) m := midRanked(legal, rng)
return Action{Kind: "play", Dir: dirString(m.Dir), Tiles: toPlayTiles(m.Tiles)}, nil return Action{Kind: "play", Tiles: toPlayTiles(m.Tiles)}, nil
} }
// toPlayTiles maps the solver's newly-placed tiles to the edge submit-play tiles // toPlayTiles maps the solver's newly-placed tiles to the edge submit-play tiles
@@ -176,11 +175,3 @@ func noPlay(rackIdx []byte, canExchange bool) Action {
} }
return Action{Kind: "pass"} return Action{Kind: "pass"}
} }
// dirString renders a solver direction as the "H"/"V" the edge submit-play expects.
func dirString(d scrabble.Direction) string {
if d == scrabble.Vertical {
return "V"
}
return "H"
}
-3
View File
@@ -146,9 +146,6 @@ func TestPickWithDawg(t *testing.T) {
if len(act.Tiles) == 0 { if len(act.Tiles) == 0 {
t.Error("play action has no tiles") t.Error("play action has no tiles")
} }
if act.Dir != "H" && act.Dir != "V" {
t.Errorf("dir = %q, want H or V", act.Dir)
}
case "exchange", "pass": case "exchange", "pass":
// acceptable when the rack has no legal first move // acceptable when the rack has no legal first move
default: default:
+1 -1
View File
@@ -197,7 +197,7 @@ func (d *Driver) playTurn(ctx context.Context, c *edge.Client, p seed.Account, g
switch action.Kind { switch action.Kind {
case "play": case "play":
t0 = time.Now() t0 = time.Now()
_, code, _ := c.SubmitPlay(ctx, p.Token, g.ID, action.Dir, action.Tiles) _, code, _ := c.SubmitPlay(ctx, p.Token, g.ID, action.Tiles)
d.rec.Record("game.submit_play", code, time.Since(t0)) d.rec.Record("game.submit_play", code, time.Since(t0))
case "exchange": case "exchange":
t0 = time.Now() t0 = time.Now()
+6 -6
View File
@@ -151,11 +151,10 @@ table Profile {
// --- game (authenticated) --- // --- game (authenticated) ---
// SubmitPlayRequest places tiles in a direction on the player's turn. tiles are addressed // SubmitPlayRequest places tiles on the player's turn; the backend infers the play's
// by alphabet index. // orientation from the tiles and the board. tiles are addressed by alphabet index.
table SubmitPlayRequest { table SubmitPlayRequest {
game_id:string; game_id:string;
dir:string;
tiles:[PlayTile]; tiles:[PlayTile];
} }
@@ -205,18 +204,19 @@ table ExchangeRequest {
} }
// EvalRequest previews a tentative play without committing it. tiles are addressed by // EvalRequest previews a tentative play without committing it. tiles are addressed by
// alphabet index. // alphabet index; the backend infers the play's orientation from the tiles and the board.
table EvalRequest { table EvalRequest {
game_id:string; game_id:string;
dir:string;
tiles:[PlayTile]; tiles:[PlayTile];
} }
// EvalResult is an unlimited move preview: legality, score and the words formed. // EvalResult is an unlimited move preview: legality, score, the words formed (main word
// first) and the orientation the backend inferred (dir is "H"/"V", empty when illegal).
table EvalResult { table EvalResult {
legal:bool; legal:bool;
score:int; score:int;
words:[string]; words:[string];
dir:string;
} }
// CheckWordRequest looks a word up in the game's pinned dictionary. word is a sequence of // CheckWordRequest looks a word up in the game's pinned dictionary. word is a sequence of
+4 -15
View File
@@ -49,16 +49,8 @@ func (rcv *EvalRequest) GameId() []byte {
return nil return nil
} }
func (rcv *EvalRequest) Dir() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *EvalRequest) Tiles(obj *PlayTile, j int) bool { func (rcv *EvalRequest) Tiles(obj *PlayTile, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 { if o != 0 {
x := rcv._tab.Vector(o) x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4 x += flatbuffers.UOffsetT(j) * 4
@@ -70,7 +62,7 @@ func (rcv *EvalRequest) Tiles(obj *PlayTile, j int) bool {
} }
func (rcv *EvalRequest) TilesLength() int { func (rcv *EvalRequest) TilesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 { if o != 0 {
return rcv._tab.VectorLen(o) return rcv._tab.VectorLen(o)
} }
@@ -78,16 +70,13 @@ func (rcv *EvalRequest) TilesLength() int {
} }
func EvalRequestStart(builder *flatbuffers.Builder) { func EvalRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(3) builder.StartObject(2)
} }
func EvalRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { func EvalRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
} }
func EvalRequestAddDir(builder *flatbuffers.Builder, dir flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(dir), 0)
}
func EvalRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) { func EvalRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(tiles), 0) builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0)
} }
func EvalRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { func EvalRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) return builder.StartVector(4, numElems, 4)
+12 -1
View File
@@ -82,8 +82,16 @@ func (rcv *EvalResult) WordsLength() int {
return 0 return 0
} }
func (rcv *EvalResult) Dir() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func EvalResultStart(builder *flatbuffers.Builder) { func EvalResultStart(builder *flatbuffers.Builder) {
builder.StartObject(3) builder.StartObject(4)
} }
func EvalResultAddLegal(builder *flatbuffers.Builder, legal bool) { func EvalResultAddLegal(builder *flatbuffers.Builder, legal bool) {
builder.PrependBoolSlot(0, legal, false) builder.PrependBoolSlot(0, legal, false)
@@ -97,6 +105,9 @@ func EvalResultAddWords(builder *flatbuffers.Builder, words flatbuffers.UOffsetT
func EvalResultStartWordsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { func EvalResultStartWordsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) return builder.StartVector(4, numElems, 4)
} }
func EvalResultAddDir(builder *flatbuffers.Builder, dir flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(dir), 0)
}
func EvalResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func EvalResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+4 -15
View File
@@ -49,16 +49,8 @@ func (rcv *SubmitPlayRequest) GameId() []byte {
return nil return nil
} }
func (rcv *SubmitPlayRequest) Dir() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *SubmitPlayRequest) Tiles(obj *PlayTile, j int) bool { func (rcv *SubmitPlayRequest) Tiles(obj *PlayTile, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 { if o != 0 {
x := rcv._tab.Vector(o) x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4 x += flatbuffers.UOffsetT(j) * 4
@@ -70,7 +62,7 @@ func (rcv *SubmitPlayRequest) Tiles(obj *PlayTile, j int) bool {
} }
func (rcv *SubmitPlayRequest) TilesLength() int { func (rcv *SubmitPlayRequest) TilesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 { if o != 0 {
return rcv._tab.VectorLen(o) return rcv._tab.VectorLen(o)
} }
@@ -78,16 +70,13 @@ func (rcv *SubmitPlayRequest) TilesLength() int {
} }
func SubmitPlayRequestStart(builder *flatbuffers.Builder) { func SubmitPlayRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(3) builder.StartObject(2)
} }
func SubmitPlayRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { func SubmitPlayRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
} }
func SubmitPlayRequestAddDir(builder *flatbuffers.Builder, dir flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(dir), 0)
}
func SubmitPlayRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) { func SubmitPlayRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(tiles), 0) builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0)
} }
func SubmitPlayRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { func SubmitPlayRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) return builder.StartVector(4, numElems, 4)
-101
View File
@@ -1,101 +0,0 @@
<script lang="ts">
import type { EvalResult } from '../lib/model';
import { t } from '../lib/i18n/index.svelte';
let {
preview,
hints,
busy,
ambiguous,
dir,
ondraw,
onskip,
onshuffle,
onhint,
ondir,
}: {
preview: EvalResult | null;
hints: number;
busy: boolean;
ambiguous: boolean;
dir: 'H' | 'V';
ondraw: () => void;
onskip: () => void;
onshuffle: () => void;
onhint: () => void;
ondir: () => void;
} = $props();
</script>
<div class="controls">
<div class="preview">
{#if preview}
{#if preview.legal}
<span class="ok">{t('game.preview', { n: preview.score })}</span>
{:else}
<span class="bad">{t('game.previewIllegal')}</span>
{/if}
{/if}
{#if ambiguous}
<button class="dir" onclick={ondir} title="direction">{dir === 'H' ? '↔' : '↕'}</button>
{/if}
</div>
<div class="row">
<button onclick={ondraw} disabled={busy}>{t('game.draw')}</button>
<button onclick={onskip} disabled={busy}>{t('game.skip')}</button>
<button onclick={onshuffle} disabled={busy}>{t('game.shuffle')}</button>
<button class="hint" onclick={onhint} disabled={busy || hints <= 0}>
{t('game.hint')}{hints > 0 ? ` (${hints})` : ''}
</button>
</div>
</div>
<style>
.controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.preview {
min-height: 22px;
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
}
.ok {
color: var(--ok);
}
.bad {
color: var(--danger);
}
.dir {
margin-left: auto;
width: 34px;
height: 28px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 1rem;
}
.row {
display: flex;
gap: 6px;
}
.row button {
flex: 1;
padding: 11px 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.row button:disabled {
opacity: 0.45;
}
.hint {
color: var(--accent);
}
</style>
+8 -12
View File
@@ -12,7 +12,7 @@
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
import { lastMoveCells, replay } from '../lib/board'; import { lastMoveCells, replay } from '../lib/board';
import { historyGrid } from '../lib/history'; import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums'; import { centre, premiumGrid } from '../lib/premiums';
@@ -42,7 +42,6 @@
let moves = $state<MoveRecord[]>([]); let moves = $state<MoveRecord[]>([]);
let placement = $state<Placement>(newPlacement([])); let placement = $state<Placement>(newPlacement([]));
let preview = $state<EvalResult | null>(null); let preview = $state<EvalResult | null>(null);
let dirOverride = $state<Direction | undefined>(undefined);
let busy = $state(false); let busy = $state(false);
let zoomed = $state(false); let zoomed = $state(false);
let selected = $state<number | null>(null); let selected = $state<number | null>(null);
@@ -130,7 +129,6 @@
moves = hist.moves; moves = hist.moves;
setCachedGame(id, st, hist.moves); setCachedGame(id, st, hist.moves);
selected = null; selected = null;
dirOverride = undefined;
await applyDraft(st); await applyDraft(st);
recompute(); recompute();
refreshRecent(); refreshRecent();
@@ -491,11 +489,11 @@
if (previewTimer) clearTimeout(previewTimer); if (previewTimer) clearTimeout(previewTimer);
// Off-turn the composition is position-only: no score preview or evaluate. // Off-turn the composition is position-only: no score preview or evaluate.
if (!isMyTurn) return; if (!isMyTurn) return;
const sub = toSubmit(placement, dirOverride); const sub = toSubmit(placement);
if (!sub) return; if (!sub) return;
previewTimer = setTimeout(async () => { previewTimer = setTimeout(async () => {
try { try {
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant); preview = await gateway.evaluate(id, sub.tiles, variant);
} catch { } catch {
/* best-effort */ /* best-effort */
} }
@@ -511,17 +509,16 @@
rackIds = r.rack.map((_, i) => i); rackIds = r.rack.map((_, i) => i);
placement = newPlacement(r.rack); placement = newPlacement(r.rack);
selected = null; selected = null;
dirOverride = undefined;
recompute(); recompute();
refreshRecent(); refreshRecent();
} }
async function commit() { async function commit() {
const sub = toSubmit(placement, dirOverride); const sub = toSubmit(placement);
if (!sub) return; if (!sub) return;
busy = true; busy = true;
try { try {
applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant)); applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
telegramHaptic('success'); telegramHaptic('success');
zoomed = false; zoomed = false;
} catch (e) { } catch (e) {
@@ -534,7 +531,6 @@
placement = reset(placement); placement = reset(placement);
preview = null; preview = null;
selected = null; selected = null;
dirOverride = undefined;
scheduleDraftSave(); scheduleDraftSave();
} }
@@ -857,11 +853,11 @@
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span> <span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver} {#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong> <strong class="over">{t('game.over')}{resultText()}</strong>
{:else} {:else if placement.pending.length === 0}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span> <span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
{/if} {/if}
<span class="scores"> <span class="scores">
{#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if} {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
</span> </span>
</div> </div>
@@ -880,7 +876,7 @@
/> />
</div> </div>
{#if !gameOver && placement.pending.length > 0} {#if !gameOver && placement.pending.length > 0}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button> <button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if} {/if}
</div> </div>
{:else} {:else}
+5 -17
View File
@@ -30,37 +30,26 @@ gameId(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;
} }
dir():string|null
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dir(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
tiles(index: number, obj?:PlayTile):PlayTile|null { tiles(index: number, obj?:PlayTile):PlayTile|null {
const offset = this.bb!.__offset(this.bb_pos, 8); const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
} }
tilesLength():number { tilesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8); const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
} }
static startEvalRequest(builder:flatbuffers.Builder) { static startEvalRequest(builder:flatbuffers.Builder) {
builder.startObject(3); builder.startObject(2);
} }
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0); builder.addFieldOffset(0, gameIdOffset, 0);
} }
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, dirOffset, 0);
}
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) { static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, tilesOffset, 0); builder.addFieldOffset(1, tilesOffset, 0);
} }
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
@@ -80,10 +69,9 @@ static endEvalRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
return offset; return offset;
} }
static createEvalRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset { static createEvalRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
EvalRequest.startEvalRequest(builder); EvalRequest.startEvalRequest(builder);
EvalRequest.addGameId(builder, gameIdOffset); EvalRequest.addGameId(builder, gameIdOffset);
EvalRequest.addDir(builder, dirOffset);
EvalRequest.addTiles(builder, tilesOffset); EvalRequest.addTiles(builder, tilesOffset);
return EvalRequest.endEvalRequest(builder); return EvalRequest.endEvalRequest(builder);
} }
+14 -2
View File
@@ -42,8 +42,15 @@ wordsLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
} }
dir():string|null
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dir(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEvalResult(builder:flatbuffers.Builder) { static startEvalResult(builder:flatbuffers.Builder) {
builder.startObject(3); builder.startObject(4);
} }
static addLegal(builder:flatbuffers.Builder, legal:boolean) { static addLegal(builder:flatbuffers.Builder, legal:boolean) {
@@ -70,16 +77,21 @@ static startWordsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4); builder.startVector(4, numElems, 4);
} }
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, dirOffset, 0);
}
static endEvalResult(builder:flatbuffers.Builder):flatbuffers.Offset { static endEvalResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createEvalResult(builder:flatbuffers.Builder, legal:boolean, score:number, wordsOffset:flatbuffers.Offset):flatbuffers.Offset { static createEvalResult(builder:flatbuffers.Builder, legal:boolean, score:number, wordsOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset):flatbuffers.Offset {
EvalResult.startEvalResult(builder); EvalResult.startEvalResult(builder);
EvalResult.addLegal(builder, legal); EvalResult.addLegal(builder, legal);
EvalResult.addScore(builder, score); EvalResult.addScore(builder, score);
EvalResult.addWords(builder, wordsOffset); EvalResult.addWords(builder, wordsOffset);
EvalResult.addDir(builder, dirOffset);
return EvalResult.endEvalResult(builder); return EvalResult.endEvalResult(builder);
} }
} }
@@ -30,37 +30,26 @@ gameId(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;
} }
dir():string|null
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dir(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
tiles(index: number, obj?:PlayTile):PlayTile|null { tiles(index: number, obj?:PlayTile):PlayTile|null {
const offset = this.bb!.__offset(this.bb_pos, 8); const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
} }
tilesLength():number { tilesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8); const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
} }
static startSubmitPlayRequest(builder:flatbuffers.Builder) { static startSubmitPlayRequest(builder:flatbuffers.Builder) {
builder.startObject(3); builder.startObject(2);
} }
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0); builder.addFieldOffset(0, gameIdOffset, 0);
} }
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, dirOffset, 0);
}
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) { static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, tilesOffset, 0); builder.addFieldOffset(1, tilesOffset, 0);
} }
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
@@ -80,10 +69,9 @@ static endSubmitPlayRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
return offset; return offset;
} }
static createSubmitPlayRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset { static createSubmitPlayRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
SubmitPlayRequest.startSubmitPlayRequest(builder); SubmitPlayRequest.startSubmitPlayRequest(builder);
SubmitPlayRequest.addGameId(builder, gameIdOffset); SubmitPlayRequest.addGameId(builder, gameIdOffset);
SubmitPlayRequest.addDir(builder, dirOffset);
SubmitPlayRequest.addTiles(builder, tilesOffset); SubmitPlayRequest.addTiles(builder, tilesOffset);
return SubmitPlayRequest.endSubmitPlayRequest(builder); return SubmitPlayRequest.endSubmitPlayRequest(builder);
} }
+2 -2
View File
@@ -74,12 +74,12 @@ export interface GatewayClient {
// table), and gameState's includeAlphabet asks the server to embed that table. // table), and gameState's includeAlphabet asks the server to embed that table.
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>; gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
gameHistory(gameId: string): Promise<History>; gameHistory(gameId: string): Promise<History>;
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<MoveResult>; submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
pass(gameId: string): Promise<MoveResult>; pass(gameId: string): Promise<MoveResult>;
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>; exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
resign(gameId: string): Promise<MoveResult>; resign(gameId: string): Promise<MoveResult>;
hint(gameId: string): Promise<HintResult>; hint(gameId: string): Promise<HintResult>;
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>; evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>; checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
complaint(gameId: string, word: string, note: string): Promise<void>; complaint(gameId: string, word: string, note: string): Promise<void>;
/** Hide a finished game from the caller's own lobby list; per-account, irreversible. */ /** Hide a finished game from the caller's own lobby list; per-account, irreversible. */
-2
View File
@@ -43,7 +43,6 @@ describe('codec', () => {
// A placed blank carries its designated letter's index with the blank flag set. // A placed blank carries its designated letter's index with the blank flag set.
const buf = encodeSubmitPlay( const buf = encodeSubmitPlay(
'g1', 'g1',
'H',
[ [
{ row: 7, col: 7, letter: 'A', blank: false }, { row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true }, { row: 7, col: 8, letter: 'B', blank: true },
@@ -52,7 +51,6 @@ describe('codec', () => {
); );
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf)); const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
expect(r.gameId()).toBe('g1'); expect(r.gameId()).toBe('g1');
expect(r.dir()).toBe('H');
expect(r.tilesLength()).toBe(2); expect(r.tilesLength()).toBe(2);
expect(r.tiles(0)?.letter()).toBe(0); expect(r.tiles(0)?.letter()).toBe(0);
expect(r.tiles(1)?.letter()).toBe(1); expect(r.tiles(1)?.letter()).toBe(1);
+2 -12
View File
@@ -87,7 +87,6 @@ export function encodeDraftSave(gameId: string, json: string): Uint8Array {
export function encodeSubmitPlay( export function encodeSubmitPlay(
gameId: string, gameId: string,
dir: 'H' | 'V',
tiles: PlacedTile[], tiles: PlacedTile[],
variant: Variant, variant: Variant,
): Uint8Array { ): Uint8Array {
@@ -95,28 +94,19 @@ export function encodeSubmitPlay(
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant)); const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs); const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId); const gid = b.createString(gameId);
const d = b.createString(dir);
fb.SubmitPlayRequest.startSubmitPlayRequest(b); fb.SubmitPlayRequest.startSubmitPlayRequest(b);
fb.SubmitPlayRequest.addGameId(b, gid); fb.SubmitPlayRequest.addGameId(b, gid);
fb.SubmitPlayRequest.addDir(b, d);
fb.SubmitPlayRequest.addTiles(b, vec); fb.SubmitPlayRequest.addTiles(b, vec);
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b)); return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
} }
export function encodeEval( export function encodeEval(gameId: string, tiles: PlacedTile[], variant: Variant): Uint8Array {
gameId: string,
dir: 'H' | 'V',
tiles: PlacedTile[],
variant: Variant,
): Uint8Array {
const b = new Builder(256); const b = new Builder(256);
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant)); const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
const vec = fb.EvalRequest.createTilesVector(b, tileOffs); const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId); const gid = b.createString(gameId);
const d = b.createString(dir);
fb.EvalRequest.startEvalRequest(b); fb.EvalRequest.startEvalRequest(b);
fb.EvalRequest.addGameId(b, gid); fb.EvalRequest.addGameId(b, gid);
fb.EvalRequest.addDir(b, d);
fb.EvalRequest.addTiles(b, vec); fb.EvalRequest.addTiles(b, vec);
return finish(b, fb.EvalRequest.endEvalRequest(b)); return finish(b, fb.EvalRequest.endEvalRequest(b));
} }
@@ -377,7 +367,7 @@ export function decodeEvalResult(buf: Uint8Array): EvalResult {
const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf)); const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf));
const words: string[] = []; const words: string[] = [];
for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i))); for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i)));
return { legal: r.legal(), score: r.score(), words }; return { legal: r.legal(), score: r.score(), words, dir: s(r.dir()) };
} }
export function decodeWordCheck(buf: Uint8Array): WordCheckResult { export function decodeWordCheck(buf: Uint8Array): WordCheckResult {
+1 -2
View File
@@ -67,7 +67,7 @@ export const en = {
'game.checkWord': 'Check word', 'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary', 'game.dictionary': 'Dictionary',
'game.dropGame': 'Drop game', 'game.dropGame': 'Drop game',
'game.preview': 'Scores {n}', 'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Not a legal move', 'game.previewIllegal': 'Not a legal move',
'game.chooseBlank': 'Choose a letter for the blank', 'game.chooseBlank': 'Choose a letter for the blank',
'game.exchangeTitle': 'Select tiles to exchange', 'game.exchangeTitle': 'Select tiles to exchange',
@@ -86,7 +86,6 @@ export const en = {
'game.check': 'Check', 'game.check': 'Check',
'game.checkWait': 'Please wait a moment.', 'game.checkWait': 'Please wait a moment.',
'game.noHintOptions': 'No options with your letters.', 'game.noHintOptions': 'No options with your letters.',
'game.scores': 'Scores: {n}',
'game.thinking': 'thinking…', 'game.thinking': 'thinking…',
'move.pass': 'pass', 'move.pass': 'pass',
+1 -2
View File
@@ -68,7 +68,7 @@ export const ru: Record<MessageKey, string> = {
'game.checkWord': 'Проверить слово', 'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь', 'game.dictionary': 'Словарь',
'game.dropGame': 'Покинуть игру', 'game.dropGame': 'Покинуть игру',
'game.preview': 'Очков: {n}', 'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Недопустимый ход', 'game.previewIllegal': 'Недопустимый ход',
'game.chooseBlank': 'Выберите букву для бланка', 'game.chooseBlank': 'Выберите букву для бланка',
'game.exchangeTitle': 'Выберите фишки для обмена', 'game.exchangeTitle': 'Выберите фишки для обмена',
@@ -87,7 +87,6 @@ export const ru: Record<MessageKey, string> = {
'game.check': 'Проверить', 'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.', 'game.checkWait': 'Секунду, пожалуйста.',
'game.noHintOptions': 'Нет вариантов с вашим набором.', 'game.noHintOptions': 'Нет вариантов с вашим набором.',
'game.scores': 'Очков: {n}',
'game.thinking': 'думает…', 'game.thinking': 'думает…',
'move.pass': 'пас', 'move.pass': 'пас',
+6 -4
View File
@@ -205,7 +205,7 @@ export class MockGateway implements GatewayClient {
return { gameId, moves: structuredClone(g.moves) }; return { gameId, moves: structuredClone(g.moves) };
} }
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> { async submitPlay(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> {
const g = this.game(gameId); const g = this.game(gameId);
const seat = this.mySeat(g); const seat = this.mySeat(g);
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn'); if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
@@ -213,6 +213,7 @@ export class MockGateway implements GatewayClient {
let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0); let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0);
if (tiles.length === 7) score += 50; if (tiles.length === 7) score += 50;
const total = g.view.seats[seat].score + score; const total = g.view.seats[seat].score + score;
const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
const move = { const move = {
player: seat, player: seat,
action: 'play' as const, action: 'play' as const,
@@ -311,12 +312,13 @@ export class MockGateway implements GatewayClient {
}; };
} }
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> { async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
const g = this.game(gameId); const g = this.game(gameId);
if (tiles.length === 0) return { legal: false, score: 0, words: [] }; if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' };
let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0); let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
if (tiles.length === 7) score += 50; if (tiles.length === 7) score += 50;
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] }; const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')], dir };
} }
async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> { async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
+2
View File
@@ -84,6 +84,8 @@ export interface EvalResult {
legal: boolean; legal: boolean;
score: number; score: number;
words: string[]; words: string[];
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
dir: string;
} }
export interface WordCheckResult { export interface WordCheckResult {
+2 -22
View File
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import { import {
BLANK, BLANK,
cellOccupied, cellOccupied,
direction,
isBlankSlot, isBlankSlot,
newPlacement, newPlacement,
place, place,
@@ -47,23 +46,11 @@ describe('placement state machine', () => {
expect(reset(place(p, 0, 7, 7)).pending).toHaveLength(0); expect(reset(place(p, 0, 7, 7)).pending).toHaveLength(0);
}); });
it('infers direction H for a row, V for a column, null for a single tile', () => { it('builds a sorted submit payload and returns null when empty', () => {
let h = place(newPlacement(rack), 0, 7, 7);
h = place(h, 1, 7, 8);
expect(direction(h)).toBe('H');
let v = place(newPlacement(rack), 0, 7, 7);
v = place(v, 1, 8, 7);
expect(direction(v)).toBe('V');
expect(direction(place(newPlacement(rack), 0, 7, 7))).toBeNull();
});
it('builds a sorted submit payload and honours a direction override', () => {
let p = place(newPlacement(rack), 1, 7, 9); let p = place(newPlacement(rack), 1, 7, 9);
p = place(p, 0, 7, 7); p = place(p, 0, 7, 7);
const sub = toSubmit(p); const sub = toSubmit(p);
expect(sub?.dir).toBe('H');
expect(sub?.tiles.map((t) => t.col)).toEqual([7, 9]); expect(sub?.tiles.map((t) => t.col)).toEqual([7, 9]);
expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V');
expect(toSubmit(newPlacement(rack))).toBeNull(); expect(toSubmit(newPlacement(rack))).toBeNull();
}); });
@@ -78,15 +65,8 @@ describe('placement state machine', () => {
expect(isBlankSlot(newPlacement(rack), 0)).toBe(false); expect(isBlankSlot(newPlacement(rack), 0)).toBe(false);
}); });
it('treats a non-linear placement as no inferred direction', () => { it('submits a single tile as a one-tile payload', () => {
let p = place(newPlacement(rack), 0, 7, 7);
p = place(p, 1, 8, 8); // diagonal
expect(direction(p)).toBeNull();
});
it('defaults a single-tile submit to H without an override', () => {
const sub = toSubmit(place(newPlacement(rack), 0, 7, 7)); const sub = toSubmit(place(newPlacement(rack), 0, 7, 7));
expect(sub?.dir).toBe('H');
expect(sub?.tiles).toHaveLength(1); expect(sub?.tiles).toHaveLength(1);
}); });
}); });
+5 -22
View File
@@ -4,7 +4,7 @@
// payload. It is board-agnostic (the gateway/engine does full legality validation at // payload. It is board-agnostic (the gateway/engine does full legality validation at
// submit), which keeps it trivially unit-testable. // submit), which keeps it trivially unit-testable.
import type { Direction, Tile } from './model'; import type { Tile } from './model';
import type { PlacedTile } from './client'; import type { PlacedTile } from './client';
export interface PendingTile { export interface PendingTile {
@@ -119,30 +119,13 @@ export function reorderIndices(n: number, from: number, toSlot: number): number[
return order; return order;
} }
/** /** toSubmit builds the submit payload: the placed tiles in board order. The backend
* direction infers the play orientation from the pending tiles: H if they share a row, * infers the play's orientation from the tiles and the board, so none is sent. */
* V if they share a column, null if a single tile (ambiguous) or non-linear (invalid). export function toSubmit(p: Placement): { tiles: PlacedTile[] } | null {
*/
export function direction(p: Placement): Direction | null {
if (p.pending.length < 2) return null;
const rows = new Set(p.pending.map((t) => t.row));
const cols = new Set(p.pending.map((t) => t.col));
if (rows.size === 1 && cols.size === p.pending.length) return 'H';
if (cols.size === 1 && rows.size === p.pending.length) return 'V';
return null;
}
/** toSubmit builds the submit payload. dirOverride resolves a single-tile play, where
* the orientation cannot be inferred; otherwise the inferred direction is used. */
export function toSubmit(
p: Placement,
dirOverride?: Direction,
): { dir: Direction; tiles: PlacedTile[] } | null {
if (p.pending.length === 0) return null; if (p.pending.length === 0) return null;
const dir = dirOverride ?? direction(p) ?? 'H';
const tiles: PlacedTile[] = p.pending const tiles: PlacedTile[] = p.pending
.slice() .slice()
.sort((a, b) => a.row - b.row || a.col - b.col) .sort((a, b) => a.row - b.row || a.col - b.col)
.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank })); .map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank }));
return { dir, tiles }; return { tiles };
} }
+4 -4
View File
@@ -97,8 +97,8 @@ export function createTransport(baseUrl: string): GatewayClient {
async gameHistory(id) { async gameHistory(id) {
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id))); return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
}, },
async submitPlay(id, dir, tiles, variant) { async submitPlay(id, tiles, variant) {
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant))); return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, tiles, variant)));
}, },
async pass(id) { async pass(id) {
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id))); return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
@@ -112,8 +112,8 @@ export function createTransport(baseUrl: string): GatewayClient {
async hint(id) { async hint(id) {
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id))); return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
}, },
async evaluate(id, dir, tiles, variant) { async evaluate(id, tiles, variant) {
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant))); return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant)));
}, },
async checkWord(id, word, variant) { async checkWord(id, word, variant) {
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant))); return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
+7 -2
View File
@@ -324,8 +324,13 @@
user-select: none; user-select: none;
touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */ touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */
} }
.open:active { /* A tap/click on a game row leaves no highlight: drop the WebKit tap-flash on
background: var(--surface-2); both tappable areas (the open body and the chevron / kebab) and the held
:active background, which added nothing and only spoiled the look. */
.open,
.chev,
.kebab {
-webkit-tap-highlight-color: transparent;
} }
.kebab { .kebab {
flex: 0 0 auto; flex: 0 0 auto;