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,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user