92f48a3b12
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.
160 lines
6.1 KiB
Go
160 lines
6.1 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|