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 @@ - - -