diff --git a/backend/README.md b/backend/README.md index 8ecff49..f28cbe6 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 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 -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 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 diff --git a/backend/internal/engine/direction.go b/backend/internal/engine/direction.go new file mode 100644 index 0000000..301f4ef --- /dev/null +++ b/backend/internal/engine/direction.go @@ -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 +} diff --git a/backend/internal/engine/direction_test.go b/backend/internal/engine/direction_test.go new file mode 100644 index 0000000..aafa903 --- /dev/null +++ b/backend/internal/engine/direction_test.go @@ -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) + } + }) +} diff --git a/backend/internal/engine/domain.go b/backend/internal/engine/domain.go index 4291436..50c58f2 100644 --- a/backend/internal/engine/domain.go +++ b/backend/internal/engine/domain.go @@ -52,11 +52,25 @@ func fromScrabbleDir(d scrabble.Direction) Direction { // 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 -// 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 // (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a // 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) if err != nil { 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, // 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) -// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn -// are left untouched. -func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) { +// It infers the play's orientation from the tiles and the board exactly as +// SubmitPlay does, then returns the decoded move (placed tiles, the words it +// forms, its orientation and its score) or ErrIllegalPlay when the solver +// rejects it. The board, racks, bag and turn are left untouched. +func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) { if g.over { return MoveRecord{}, ErrGameOver } @@ -89,7 +104,7 @@ func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, erro if err != nil { 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 { return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) } diff --git a/backend/internal/engine/domain_test.go b/backend/internal/engine/domain_test.go index 974b895..d29616c 100644 --- a/backend/internal/engine/domain_test.go +++ b/backend/internal/engine/domain_test.go @@ -25,7 +25,7 @@ func TestSubmitPlayMatchesHint(t *testing.T) { if !ok { t.Fatal("opening game has no hint") } - rec, err := g.SubmitPlay(hint.Dir, hint.Tiles) + rec, err := g.SubmitPlay(hint.Tiles) if err != nil { t.Fatalf("submit play: %v", err) } @@ -85,7 +85,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) { boardBefore := g.BoardClone() 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 { t.Fatalf("evaluate play: %v", err) } @@ -106,7 +106,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) { func TestEvaluatePlayRejectsIllegal(t *testing.T) { g := newEnglishGame(t, 1) 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) { t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err) } diff --git a/backend/internal/engine/resign_test.go b/backend/internal/engine/resign_test.go index 8c08b44..779659d 100644 --- a/backend/internal/engine/resign_test.go +++ b/backend/internal/engine/resign_test.go @@ -12,7 +12,7 @@ func TestResignLeadingPlayerStillLoses(t *testing.T) { if !ok { t.Fatal("opening game has no hint") } - played, err := g.SubmitPlay(hint.Dir, hint.Tiles) + played, err := g.SubmitPlay(hint.Tiles) if err != nil { t.Fatalf("player 0 play: %v", err) } @@ -56,7 +56,7 @@ func TestResignTrailingPlayerLoses(t *testing.T) { if !ok { 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) } @@ -79,7 +79,7 @@ func TestResignSeatOffTurn(t *testing.T) { if !ok { 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) } if g.ToMove() != 1 { @@ -165,7 +165,7 @@ func TestMultiplayerLastActiveWins(t *testing.T) { if !ok { 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 { t.Fatalf("seat 0 play: %v", err) } @@ -245,7 +245,7 @@ func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) { if !ok { 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 { t.Fatalf("seat 0 play: %v", err) } diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index e6419cf..caf8aeb 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -147,10 +147,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro // and, for an exchange, the swapped tiles. type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error) -// SubmitPlay validates, scores and commits the player's placement. -func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (MoveResult, error) { +// SubmitPlay validates, scores and commits the player's placement. The engine +// 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) { - rec, err := g.SubmitPlay(dir, tiles) + rec, err := g.SubmitPlay(tiles) 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 // 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) if err != nil { return EvalResult{}, err @@ -547,7 +549,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI return EvalResult{}, err } validateStart := time.Now() - rec, err := g.EvaluatePlay(dir, tiles) + rec, err := g.EvaluatePlay(tiles) svc.metrics.recordValidate(ctx, pre.Variant, validateStart) if err != nil { 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{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 @@ -961,7 +963,7 @@ func replayMove(g *engine.Game, mv HistoryMove) error { if mv.Dir == "V" { dir = engine.Vertical } - _, err := g.SubmitPlay(dir, mv.Tiles) + _, err := g.SubmitPlayDir(dir, mv.Tiles) return err case "pass": _, err := g.Pass() diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 53d40d2..ded5378 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -141,11 +141,14 @@ type HintResult struct { 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 { Valid bool Score int Words []string + Dir string } // StateView is a player's view of a game: the shared game plus their private diff --git a/backend/internal/inttest/draft_test.go b/backend/internal/inttest/draft_test.go index 4538629..ff94a58 100644 --- a/backend/internal/inttest/draft_test.go +++ b/backend/internal/inttest/draft_test.go @@ -35,7 +35,7 @@ func TestDraftPersistAndConflictReset(t *testing.T) { 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) } @@ -61,7 +61,7 @@ func TestDraftSurvivesNonConflictingMove(t *testing.T) { }); err != nil { 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) } if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" { diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 18e72fc..e409443 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -96,11 +96,11 @@ func TestGameLifecycleAndStats(t *testing.T) { for i := 0; i < 300 && !mirror.Over(); i++ { cur := seats[mirror.ToMove()] 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 { 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) } } else { @@ -154,10 +154,10 @@ func TestReplayEquivalence(t *testing.T) { for i := 0; i < 6 && !mirror.Over(); i++ { cur := seats[mirror.ToMove()] 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) } - mirror.SubmitPlay(hint.Dir, hint.Tiles) + mirror.SubmitPlay(hint.Tiles) } else { if _, err := svc.Pass(ctx, g.ID, cur); err != nil { t.Fatalf("pass: %v", err) @@ -201,7 +201,7 @@ func TestResignWinnerAndStats(t *testing.T) { if !ok { 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 { t.Fatalf("p0 play: %v", err) } @@ -248,7 +248,7 @@ func TestResignOnOpponentTurn(t *testing.T) { if !ok { 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) } @@ -455,19 +455,22 @@ func TestEvaluatePlayPreview(t *testing.T) { } 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 { t.Fatalf("evaluate: %v", err) } if !eval.Valid || eval.Score <= 0 { 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). - 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) } - 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 { t.Fatalf("evaluate illegal: %v", err) } @@ -498,7 +501,7 @@ func TestConcurrentSubmitSerialized(t *testing.T) { wg.Add(1) go func() { 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() ok++ mu.Unlock() diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index 738e4c7..53998f9 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -237,7 +237,7 @@ func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, hum t.Fatalf("human candidates: %v", err) } 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) } return diff --git a/backend/internal/robot/driver.go b/backend/internal/robot/driver.go index 2a0e1ae..3c3466b 100644 --- a/backend/internal/robot/driver.go +++ b/backend/internal/robot/driver.go @@ -138,7 +138,7 @@ func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) err var res game.MoveResult switch d.kind { 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: res, err = s.games.Exchange(ctx, rt.GameID, rt.RobotID, d.exchange) default: diff --git a/backend/internal/robot/robot.go b/backend/internal/robot/robot.go index e6aa40d..ffa0908 100644 --- a/backend/internal/robot/robot.go +++ b/backend/internal/robot/robot.go @@ -42,7 +42,7 @@ type GameDriver interface { Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, 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) Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (game.MoveResult, error) } diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 09b6d88..8d225b0 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -1,8 +1,6 @@ package server import ( - "strings" - "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" @@ -297,15 +295,3 @@ func chatDTOFrom(m social.Message) chatDTO { 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 - } -} diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index 39d6281..06ac282 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -14,30 +14,6 @@ import ( "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) { cases := map[string]struct { err error diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index e0af299..ba9158f 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -22,11 +22,14 @@ type hintResultDTO struct { 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 { Legal bool `json:"legal"` Score int `json:"score"` Words []string `json:"words"` + Dir string `json:"dir"` } // 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") 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) if err != nil { s.abortErr(c, err) @@ -201,12 +199,12 @@ func (s *Server) handleEvaluate(c *gin.Context) { s.abortErr(c, err) 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 { s.abortErr(c, err) 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 diff --git a/backend/internal/server/handlers_test.go b/backend/internal/server/handlers_test.go index 3ddfc1f..8f3d824 100644 --- a/backend/internal/server/handlers_test.go +++ b/backend/internal/server/handlers_test.go @@ -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) { 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 { t.Fatalf("submit play bad game id = %d, want 400", rec.Code) } diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 661258e..150af11 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -26,10 +26,10 @@ func (s *Server) handleProfile(c *gin.Context) { c.JSON(http.StatusOK, profileResponseFor(acc)) } -// submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter -// is a wire alphabet index; for a blank it is the designated letter's index. +// submitPlayRequest places tiles on the player's turn; the engine infers the +// 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 { - Dir string `json:"dir"` Tiles []struct { Row int `json:"row"` Col int `json:"col"` @@ -70,11 +70,6 @@ func (s *Server) handleSubmitPlay(c *gin.Context) { abortBadRequest(c, "invalid request body") 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) if err != nil { s.abortErr(c, err) @@ -85,7 +80,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) { s.abortErr(c, err) 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 { s.abortErr(c, err) return diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e3a481e..b4f8d1a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -262,6 +262,13 @@ Key points: check for resign. The engine exposes a decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/ `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 — persistence, turn scheduling, the configurable turn timeout / auto-resign, the 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 (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 -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 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, diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 307b4eb..c98d6b1 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -92,9 +92,12 @@ settings and the game starts once every invitee has accepted — any decline can expires after seven days. ### Playing a game -Place tiles, pass, exchange, or resign. A play is validated against the game's -dictionary at submit time and scored; an unlimited preview reports what a -tentative move would score and whether it is legal. The dictionary check tool is +Place tiles, pass, exchange, or resign. Tiles are laid without choosing a +direction — the game infers the play's orientation, so a single tile that extends +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 — 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 599d60f..6460d45 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -96,9 +96,12 @@ nudge) приходят от бота **этой партии** — по язы ответа приглашение протухает через семь дней. ### Игровой процесс -Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при -сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы -предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и +Выкладывание фишек, пас, обмен или сдача. Фишки кладутся без выбора направления — +игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая уже лежащее +слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при +сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое +образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно +отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и предлагает пожаловаться на любой результат. Подсказки управляются настройками партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 1d3e858..cb84729 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -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 // 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 - 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) return out, err } @@ -279,11 +279,13 @@ type HintResultResp struct { 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 { Legal bool `json:"legal"` Score int `json:"score"` Words []string `json:"words"` + Dir string `json:"dir"` } // 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 // 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 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 } diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 584a925..ae90e6b 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -219,10 +219,12 @@ func encodeHintResult(r backendclient.HintResultResp) []byte { func encodeEvalResult(r backendclient.EvalResultResp) []byte { b := flatbuffers.NewBuilder(256) words := buildStringVector(b, r.Words, fb.EvalResultStartWordsVector) + dir := b.CreateString(r.Dir) fb.EvalResultStart(b) fb.EvalResultAddLegal(b, r.Legal) fb.EvalResultAddScore(b, int32(r.Score)) fb.EvalResultAddWords(b, words) + fb.EvalResultAddDir(b, dir) b.Finish(fb.EvalResultEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 7fd55f1..3e645f2 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -202,7 +202,7 @@ func profileHandler(backend *backendclient.Client) Handler { func submitPlayHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { 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 { return nil, err } @@ -367,7 +367,7 @@ func hintHandler(backend *backendclient.Client) Handler { func evaluateHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { 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 { return nil, err } diff --git a/gateway/internal/transcode/transcode_alphabet_test.go b/gateway/internal/transcode/transcode_alphabet_test.go index 873aac6..d45fc02 100644 --- a/gateway/internal/transcode/transcode_alphabet_test.go +++ b/gateway/internal/transcode/transcode_alphabet_test.go @@ -110,7 +110,6 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) { b := flatbuffers.NewBuilder(64) gid := b.CreateString("g-5") - dir := b.CreateString("H") fb.PlayTileStart(b) fb.PlayTileAddRow(b, 7) fb.PlayTileAddCol(b, 7) @@ -122,7 +121,6 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) { tiles := b.EndVector(1) fb.SubmitPlayRequestStart(b) fb.SubmitPlayRequestAddGameId(b, gid) - fb.SubmitPlayRequestAddDir(b, dir) fb.SubmitPlayRequestAddTiles(b, tiles) b.Finish(fb.SubmitPlayRequestEnd(b)) diff --git a/loadtest/internal/edge/encode.go b/loadtest/internal/edge/encode.go index e68633c..eeb6c3a 100644 --- a/loadtest/internal/edge/encode.go +++ b/loadtest/internal/edge/encode.go @@ -37,12 +37,11 @@ func stateReq(gameID string, includeAlphabet bool) []byte { return b.FinishedBytes() } -// submitPlay builds a SubmitPlayRequest payload. dir is "H" or "V"; tiles are the -// newly-placed tiles in main-word order. -func submitPlay(gameID, dir string, tiles []PlayTile) []byte { +// submitPlay builds a SubmitPlayRequest payload. tiles are the newly-placed tiles in +// main-word order; the backend infers the play's orientation from them and the board. +func submitPlay(gameID string, tiles []PlayTile) []byte { b := flatbuffers.NewBuilder(256) gid := b.CreateString(gameID) - d := b.CreateString(dir) offs := make([]flatbuffers.UOffsetT, len(tiles)) for i, t := range tiles { fb.PlayTileStart(b) @@ -59,7 +58,6 @@ func submitPlay(gameID, dir string, tiles []PlayTile) []byte { tilesVec := b.EndVector(len(offs)) fb.SubmitPlayRequestStart(b) fb.SubmitPlayRequestAddGameId(b, gid) - fb.SubmitPlayRequestAddDir(b, d) fb.SubmitPlayRequestAddTiles(b, tilesVec) b.Finish(fb.SubmitPlayRequestEnd(b)) return b.FinishedBytes() diff --git a/loadtest/internal/edge/ops.go b/loadtest/internal/edge/ops.go index 7aa5d9d..ab86fa1 100644 --- a/loadtest/internal/edge/ops.go +++ b/loadtest/internal/edge/ops.go @@ -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. -func (c *Client) SubmitPlay(ctx context.Context, token, gameID, dir string, tiles []PlayTile) (Game, string, error) { - r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, dir, tiles)) +func (c *Client) SubmitPlay(ctx context.Context, token, gameID string, tiles []PlayTile) (Game, string, error) { + r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, tiles)) if err != nil || r.Code != "ok" { return Game{}, r.Code, err } diff --git a/loadtest/internal/moves/moves.go b/loadtest/internal/moves/moves.go index a64e864..8e8a13b 100644 --- a/loadtest/internal/moves/moves.go +++ b/loadtest/internal/moves/moves.go @@ -78,11 +78,10 @@ func (r *Registry) Close() { } } -// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Dir -// ("H"/"V") and Tiles; an exchange carries Exchange (rack indices to swap). +// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Tiles +// (the backend infers the orientation); an exchange carries Exchange (rack indices to swap). type Action struct { Kind string - Dir string Tiles []edge.PlayTile 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 } 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 @@ -176,11 +175,3 @@ func noPlay(rackIdx []byte, canExchange bool) Action { } 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" -} diff --git a/loadtest/internal/moves/moves_test.go b/loadtest/internal/moves/moves_test.go index 54327dc..b896f26 100644 --- a/loadtest/internal/moves/moves_test.go +++ b/loadtest/internal/moves/moves_test.go @@ -146,9 +146,6 @@ func TestPickWithDawg(t *testing.T) { if len(act.Tiles) == 0 { 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": // acceptable when the rack has no legal first move default: diff --git a/loadtest/internal/scenario/scenario.go b/loadtest/internal/scenario/scenario.go index e86a8ca..29bf611 100644 --- a/loadtest/internal/scenario/scenario.go +++ b/loadtest/internal/scenario/scenario.go @@ -197,7 +197,7 @@ func (d *Driver) playTurn(ctx context.Context, c *edge.Client, p seed.Account, g switch action.Kind { case "play": 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)) case "exchange": t0 = time.Now() diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index bfc4bc6..b0e74e2 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -151,11 +151,10 @@ table Profile { // --- game (authenticated) --- -// SubmitPlayRequest places tiles in a direction on the player's turn. tiles are addressed -// by alphabet index. +// SubmitPlayRequest places tiles on the player's turn; the backend infers the play's +// orientation from the tiles and the board. tiles are addressed by alphabet index. table SubmitPlayRequest { game_id:string; - dir:string; tiles:[PlayTile]; } @@ -205,18 +204,19 @@ table ExchangeRequest { } // 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 { game_id:string; - dir:string; 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 { legal:bool; score:int; words:[string]; + dir:string; } // CheckWordRequest looks a word up in the game's pinned dictionary. word is a sequence of diff --git a/pkg/fbs/scrabblefb/EvalRequest.go b/pkg/fbs/scrabblefb/EvalRequest.go index 1687a67..07ebb49 100644 --- a/pkg/fbs/scrabblefb/EvalRequest.go +++ b/pkg/fbs/scrabblefb/EvalRequest.go @@ -49,16 +49,8 @@ func (rcv *EvalRequest) GameId() []byte { 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 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) if o != 0 { x := rcv._tab.Vector(o) x += flatbuffers.UOffsetT(j) * 4 @@ -70,7 +62,7 @@ func (rcv *EvalRequest) Tiles(obj *PlayTile, j int) bool { } func (rcv *EvalRequest) TilesLength() int { - o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) if o != 0 { return rcv._tab.VectorLen(o) } @@ -78,16 +70,13 @@ func (rcv *EvalRequest) TilesLength() int { } func EvalRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(3) + builder.StartObject(2) } func EvalRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { 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) { - builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(tiles), 0) + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0) } func EvalRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) diff --git a/pkg/fbs/scrabblefb/EvalResult.go b/pkg/fbs/scrabblefb/EvalResult.go index 0255618..6fc90a9 100644 --- a/pkg/fbs/scrabblefb/EvalResult.go +++ b/pkg/fbs/scrabblefb/EvalResult.go @@ -82,8 +82,16 @@ func (rcv *EvalResult) WordsLength() int { 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) { - builder.StartObject(3) + builder.StartObject(4) } func EvalResultAddLegal(builder *flatbuffers.Builder, legal bool) { 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 { 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 { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/SubmitPlayRequest.go b/pkg/fbs/scrabblefb/SubmitPlayRequest.go index 9ae7be0..10697d6 100644 --- a/pkg/fbs/scrabblefb/SubmitPlayRequest.go +++ b/pkg/fbs/scrabblefb/SubmitPlayRequest.go @@ -49,16 +49,8 @@ func (rcv *SubmitPlayRequest) GameId() []byte { 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 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) if o != 0 { x := rcv._tab.Vector(o) x += flatbuffers.UOffsetT(j) * 4 @@ -70,7 +62,7 @@ func (rcv *SubmitPlayRequest) Tiles(obj *PlayTile, j int) bool { } func (rcv *SubmitPlayRequest) TilesLength() int { - o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) if o != 0 { return rcv._tab.VectorLen(o) } @@ -78,16 +70,13 @@ func (rcv *SubmitPlayRequest) TilesLength() int { } func SubmitPlayRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(3) + builder.StartObject(2) } func SubmitPlayRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { 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) { - builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(tiles), 0) + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0) } func SubmitPlayRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) diff --git a/ui/src/game/Controls.svelte b/ui/src/game/Controls.svelte deleted file mode 100644 index 2edc105..0000000 --- a/ui/src/game/Controls.svelte +++ /dev/null @@ -1,101 +0,0 @@ - - -
-
- {#if preview} - {#if preview.legal} - {t('game.preview', { n: preview.score })} - {:else} - {t('game.previewIllegal')} - {/if} - {/if} - {#if ambiguous} - - {/if} -
-
- - - - -
-
- - diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index ffc16de..4f4b292 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -12,7 +12,7 @@ import { connection } from '../lib/connection.svelte'; import { GatewayError } from '../lib/client'; 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 { historyGrid } from '../lib/history'; import { centre, premiumGrid } from '../lib/premiums'; @@ -42,7 +42,6 @@ let moves = $state([]); let placement = $state(newPlacement([])); let preview = $state(null); - let dirOverride = $state(undefined); let busy = $state(false); let zoomed = $state(false); let selected = $state(null); @@ -130,7 +129,6 @@ moves = hist.moves; setCachedGame(id, st, hist.moves); selected = null; - dirOverride = undefined; await applyDraft(st); recompute(); refreshRecent(); @@ -491,11 +489,11 @@ if (previewTimer) clearTimeout(previewTimer); // Off-turn the composition is position-only: no score preview or evaluate. if (!isMyTurn) return; - const sub = toSubmit(placement, dirOverride); + const sub = toSubmit(placement); if (!sub) return; previewTimer = setTimeout(async () => { try { - preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant); + preview = await gateway.evaluate(id, sub.tiles, variant); } catch { /* best-effort */ } @@ -511,17 +509,16 @@ rackIds = r.rack.map((_, i) => i); placement = newPlacement(r.rack); selected = null; - dirOverride = undefined; recompute(); refreshRecent(); } async function commit() { - const sub = toSubmit(placement, dirOverride); + const sub = toSubmit(placement); if (!sub) return; busy = true; try { - applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant)); + applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant)); telegramHaptic('success'); zoomed = false; } catch (e) { @@ -534,7 +531,6 @@ placement = reset(placement); preview = null; selected = null; - dirOverride = undefined; scheduleDraftSave(); } @@ -857,11 +853,11 @@ {view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })} {#if gameOver} {t('game.over')} — {resultText()} - {:else} + {:else if placement.pending.length === 0} {isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''} {/if} - {#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} @@ -880,7 +876,7 @@ /> {#if !gameOver && placement.pending.length > 0} - + {/if} {:else} diff --git a/ui/src/gen/fbs/scrabblefb/eval-request.ts b/ui/src/gen/fbs/scrabblefb/eval-request.ts index 64a2cf1..8ce5914 100644 --- a/ui/src/gen/fbs/scrabblefb/eval-request.ts +++ b/ui/src/gen/fbs/scrabblefb/eval-request.ts @@ -30,37 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|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 { - 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; } 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; } static startEvalRequest(builder:flatbuffers.Builder) { - builder.startObject(3); + builder.startObject(2); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { 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) { - builder.addFieldOffset(2, tilesOffset, 0); + builder.addFieldOffset(1, tilesOffset, 0); } static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { @@ -80,10 +69,9 @@ static endEvalRequest(builder:flatbuffers.Builder):flatbuffers.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.addGameId(builder, gameIdOffset); - EvalRequest.addDir(builder, dirOffset); EvalRequest.addTiles(builder, tilesOffset); return EvalRequest.endEvalRequest(builder); } diff --git a/ui/src/gen/fbs/scrabblefb/eval-result.ts b/ui/src/gen/fbs/scrabblefb/eval-result.ts index f8e9c4e..6f451a7 100644 --- a/ui/src/gen/fbs/scrabblefb/eval-result.ts +++ b/ui/src/gen/fbs/scrabblefb/eval-result.ts @@ -42,8 +42,15 @@ wordsLength():number { 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) { - builder.startObject(3); + builder.startObject(4); } static addLegal(builder:flatbuffers.Builder, legal:boolean) { @@ -70,16 +77,21 @@ static startWordsVector(builder:flatbuffers.Builder, numElems:number) { 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 { const offset = builder.endObject(); 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.addLegal(builder, legal); EvalResult.addScore(builder, score); EvalResult.addWords(builder, wordsOffset); + EvalResult.addDir(builder, dirOffset); return EvalResult.endEvalResult(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/submit-play-request.ts b/ui/src/gen/fbs/scrabblefb/submit-play-request.ts index 207d7ed..93dd00d 100644 --- a/ui/src/gen/fbs/scrabblefb/submit-play-request.ts +++ b/ui/src/gen/fbs/scrabblefb/submit-play-request.ts @@ -30,37 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|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 { - 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; } 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; } static startSubmitPlayRequest(builder:flatbuffers.Builder) { - builder.startObject(3); + builder.startObject(2); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { 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) { - builder.addFieldOffset(2, tilesOffset, 0); + builder.addFieldOffset(1, tilesOffset, 0); } static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { @@ -80,10 +69,9 @@ static endSubmitPlayRequest(builder:flatbuffers.Builder):flatbuffers.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.addGameId(builder, gameIdOffset); - SubmitPlayRequest.addDir(builder, dirOffset); SubmitPlayRequest.addTiles(builder, tilesOffset); return SubmitPlayRequest.endSubmitPlayRequest(builder); } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 8f2f0af..2c957e0 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -74,12 +74,12 @@ export interface GatewayClient { // table), and gameState's includeAlphabet asks the server to embed that table. gameState(gameId: string, includeAlphabet: boolean): Promise; gameHistory(gameId: string): Promise; - submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise; + submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; pass(gameId: string): Promise; exchange(gameId: string, tiles: string[], variant: Variant): Promise; resign(gameId: string): Promise; hint(gameId: string): Promise; - evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise; + evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; checkWord(gameId: string, word: string, variant: Variant): Promise; complaint(gameId: string, word: string, note: string): Promise; /** Hide a finished game from the caller's own lobby list; per-account, irreversible. */ diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 547de5c..3e17cf9 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -43,7 +43,6 @@ describe('codec', () => { // A placed blank carries its designated letter's index with the blank flag set. const buf = encodeSubmitPlay( 'g1', - 'H', [ { row: 7, col: 7, letter: 'A', blank: false }, { row: 7, col: 8, letter: 'B', blank: true }, @@ -52,7 +51,6 @@ describe('codec', () => { ); const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf)); expect(r.gameId()).toBe('g1'); - expect(r.dir()).toBe('H'); expect(r.tilesLength()).toBe(2); expect(r.tiles(0)?.letter()).toBe(0); expect(r.tiles(1)?.letter()).toBe(1); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 0d14875..ae38b75 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -87,7 +87,6 @@ export function encodeDraftSave(gameId: string, json: string): Uint8Array { export function encodeSubmitPlay( gameId: string, - dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant, ): Uint8Array { @@ -95,28 +94,19 @@ export function encodeSubmitPlay( const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant)); const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs); const gid = b.createString(gameId); - const d = b.createString(dir); fb.SubmitPlayRequest.startSubmitPlayRequest(b); fb.SubmitPlayRequest.addGameId(b, gid); - fb.SubmitPlayRequest.addDir(b, d); fb.SubmitPlayRequest.addTiles(b, vec); return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b)); } -export function encodeEval( - gameId: string, - dir: 'H' | 'V', - tiles: PlacedTile[], - variant: Variant, -): Uint8Array { +export function encodeEval(gameId: string, tiles: PlacedTile[], variant: Variant): Uint8Array { const b = new Builder(256); const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant)); const vec = fb.EvalRequest.createTilesVector(b, tileOffs); const gid = b.createString(gameId); - const d = b.createString(dir); fb.EvalRequest.startEvalRequest(b); fb.EvalRequest.addGameId(b, gid); - fb.EvalRequest.addDir(b, d); fb.EvalRequest.addTiles(b, vec); 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 words: string[] = []; 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 { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 2472a96..45971c7 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -67,7 +67,7 @@ export const en = { 'game.checkWord': 'Check word', 'game.dictionary': 'Dictionary', 'game.dropGame': 'Drop game', - 'game.preview': 'Scores {n}', + 'game.previewWords': '{words}: {n}', 'game.previewIllegal': 'Not a legal move', 'game.chooseBlank': 'Choose a letter for the blank', 'game.exchangeTitle': 'Select tiles to exchange', @@ -86,7 +86,6 @@ export const en = { 'game.check': 'Check', 'game.checkWait': 'Please wait a moment.', 'game.noHintOptions': 'No options with your letters.', - 'game.scores': 'Scores: {n}', 'game.thinking': 'thinking…', 'move.pass': 'pass', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 6738b95..77c9d85 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -68,7 +68,7 @@ export const ru: Record = { 'game.checkWord': 'Проверить слово', 'game.dictionary': 'Словарь', 'game.dropGame': 'Покинуть игру', - 'game.preview': 'Очков: {n}', + 'game.previewWords': '{words}: {n}', 'game.previewIllegal': 'Недопустимый ход', 'game.chooseBlank': 'Выберите букву для бланка', 'game.exchangeTitle': 'Выберите фишки для обмена', @@ -87,7 +87,6 @@ export const ru: Record = { 'game.check': 'Проверить', 'game.checkWait': 'Секунду, пожалуйста.', 'game.noHintOptions': 'Нет вариантов с вашим набором.', - 'game.scores': 'Очков: {n}', 'game.thinking': 'думает…', 'move.pass': 'пас', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 76366d9..73bc7c9 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -205,7 +205,7 @@ export class MockGateway implements GatewayClient { return { gameId, moves: structuredClone(g.moves) }; } - async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise { + async submitPlay(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise { const g = this.game(gameId); const seat = this.mySeat(g); 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); if (tiles.length === 7) score += 50; const total = g.view.seats[seat].score + score; + const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V'; const move = { player: seat, 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 { + async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise { 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); 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 { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 394d8bf..2cc1603 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -84,6 +84,8 @@ export interface EvalResult { legal: boolean; score: number; words: string[]; + /** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */ + dir: string; } export interface WordCheckResult { diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts index f46cb74..0f388d9 100644 --- a/ui/src/lib/placement.test.ts +++ b/ui/src/lib/placement.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { BLANK, cellOccupied, - direction, isBlankSlot, newPlacement, place, @@ -47,23 +46,11 @@ describe('placement state machine', () => { 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', () => { - 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', () => { + it('builds a sorted submit payload and returns null when empty', () => { let p = place(newPlacement(rack), 1, 7, 9); p = place(p, 0, 7, 7); const sub = toSubmit(p); - expect(sub?.dir).toBe('H'); 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(); }); @@ -78,15 +65,8 @@ describe('placement state machine', () => { expect(isBlankSlot(newPlacement(rack), 0)).toBe(false); }); - it('treats a non-linear placement as no inferred direction', () => { - 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', () => { + it('submits a single tile as a one-tile payload', () => { const sub = toSubmit(place(newPlacement(rack), 0, 7, 7)); - expect(sub?.dir).toBe('H'); expect(sub?.tiles).toHaveLength(1); }); }); diff --git a/ui/src/lib/placement.ts b/ui/src/lib/placement.ts index d38de1c..28727e8 100644 --- a/ui/src/lib/placement.ts +++ b/ui/src/lib/placement.ts @@ -4,7 +4,7 @@ // payload. It is board-agnostic (the gateway/engine does full legality validation at // submit), which keeps it trivially unit-testable. -import type { Direction, Tile } from './model'; +import type { Tile } from './model'; import type { PlacedTile } from './client'; export interface PendingTile { @@ -119,30 +119,13 @@ export function reorderIndices(n: number, from: number, toSlot: number): number[ return order; } -/** - * direction infers the play orientation from the pending tiles: H if they share a row, - * V if they share a column, null if a single tile (ambiguous) or non-linear (invalid). - */ -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 { +/** toSubmit builds the submit payload: the placed tiles in board order. The backend + * infers the play's orientation from the tiles and the board, so none is sent. */ +export function toSubmit(p: Placement): { tiles: PlacedTile[] } | null { if (p.pending.length === 0) return null; - const dir = dirOverride ?? direction(p) ?? 'H'; const tiles: PlacedTile[] = p.pending .slice() .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 })); - return { dir, tiles }; + return { tiles }; } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 9e8366c..725e6ab 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -97,8 +97,8 @@ export function createTransport(baseUrl: string): GatewayClient { async gameHistory(id) { return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id))); }, - async submitPlay(id, dir, tiles, variant) { - return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant))); + async submitPlay(id, tiles, variant) { + return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, tiles, variant))); }, async pass(id) { return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id))); @@ -112,8 +112,8 @@ export function createTransport(baseUrl: string): GatewayClient { async hint(id) { return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id))); }, - async evaluate(id, dir, tiles, variant) { - return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant))); + async evaluate(id, tiles, variant) { + return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant))); }, async checkWord(id, word, variant) { return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant))); diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index da1056f..112e0ee 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -324,8 +324,13 @@ user-select: none; touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */ } - .open:active { - background: var(--surface-2); + /* A tap/click on a game row leaves no highlight: drop the WebKit tap-flash on + 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 { flex: 0 0 auto;