Backend infers play direction; UI previews words and gates submit on legality
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
A single tile that only extended a word perpendicular to the client-declared direction was rejected: the UI always sent dir=H for one-tile plays (the dirOverride/Controls toggle was orphaned in the Stage 7 game rework), so placing "А" above "БАК" to form "АБАК" failed the solver's main-word-length check even though the word is in the dictionary. Make the backend infer a play's orientation from the placed tiles and the board (internal/engine.resolveDirection): two or more tiles by the line they share, a lone tile by the axis it abuts (longer word wins, horizontal on a tie). Direction becomes an output, not an input: drop dir from the SubmitPlay/Eval wire requests and add it to EvalResult. Journal replay keeps trusting the stored "H"/"V" (SubmitPlayDir) so a rebuilt game matches the one committed. UI: stop computing/sending direction; the preview now shows the words a move forms with its total score (game.previewWords); the make-move control is disabled until the play is confirmed legal; the "your turn" label hides while tiles are pending. Delete the orphaned Controls.svelte. Regenerate the FlatBuffers bindings (Go + TS) and update the gateway transcode and the loadtest edge client to the new contract. Bake the decision into ARCHITECTURE.md (§5/§9.1), FUNCTIONAL.md (+ _ru) and the backend README.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user