Merge pull request 'Backend infers play direction; UI previews words and gates submit on legality' (#44) from feature/auto-play-direction into development
This commit was merged in pull request #44.
This commit is contained in:
+1
-1
@@ -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
|
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
|
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
|
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
|
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
|
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
|
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// ruleset alphabet and delegates to Play, so it returns the same errors
|
||||||
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
||||||
// letter is outside the variant's alphabet.
|
// 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)
|
placements, err := g.placements(tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, err
|
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,
|
// EvaluatePlay scores and validates a tentative play without committing it,
|
||||||
// backing the unlimited "what would my next move score, and is it legal?" tool.
|
// 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)
|
// It infers the play's orientation from the tiles and the board exactly as
|
||||||
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn
|
// SubmitPlay does, then returns the decoded move (placed tiles, the words it
|
||||||
// are left untouched.
|
// forms, its orientation and its score) or ErrIllegalPlay when the solver
|
||||||
func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
// rejects it. The board, racks, bag and turn are left untouched.
|
||||||
|
func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
|
||||||
if g.over {
|
if g.over {
|
||||||
return MoveRecord{}, ErrGameOver
|
return MoveRecord{}, ErrGameOver
|
||||||
}
|
}
|
||||||
@@ -89,7 +104,7 @@ func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, err
|
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 {
|
if err != nil {
|
||||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestSubmitPlayMatchesHint(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
rec, err := g.SubmitPlay(hint.Tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("submit play: %v", err)
|
t.Fatalf("submit play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
|||||||
boardBefore := g.BoardClone()
|
boardBefore := g.BoardClone()
|
||||||
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("evaluate play: %v", err)
|
t.Fatalf("evaluate play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
|||||||
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
||||||
g := newEnglishGame(t, 1)
|
g := newEnglishGame(t, 1)
|
||||||
letter := g.Hand(0)[0]
|
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) {
|
if !errors.Is(err, ErrIllegalPlay) {
|
||||||
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func TestResignLeadingPlayerStillLoses(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
played, err := g.SubmitPlay(hint.Tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("player 0 play: %v", err)
|
t.Fatalf("player 0 play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
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)
|
t.Fatalf("player 0 play: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ func TestResignSeatOffTurn(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
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)
|
t.Fatalf("player 0 play: %v", err)
|
||||||
}
|
}
|
||||||
if g.ToMove() != 1 {
|
if g.ToMove() != 1 {
|
||||||
@@ -165,7 +165,7 @@ func TestMultiplayerLastActiveWins(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("seat 0 play: %v", err)
|
t.Fatalf("seat 0 play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,7 @@ func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("seat 0 play: %v", err)
|
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.
|
// and, for an exchange, the swapped tiles.
|
||||||
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
|
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
|
||||||
|
|
||||||
// SubmitPlay validates, scores and commits the player's placement.
|
// SubmitPlay validates, scores and commits the player's placement. The engine
|
||||||
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (MoveResult, error) {
|
// 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) {
|
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
|
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
|
// 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.
|
// 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)
|
pre, err := svc.store.GetGame(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EvalResult{}, err
|
return EvalResult{}, err
|
||||||
@@ -547,7 +549,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
|
|||||||
return EvalResult{}, err
|
return EvalResult{}, err
|
||||||
}
|
}
|
||||||
validateStart := time.Now()
|
validateStart := time.Now()
|
||||||
rec, err := g.EvaluatePlay(dir, tiles)
|
rec, err := g.EvaluatePlay(tiles)
|
||||||
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
|
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, engine.ErrIllegalPlay) {
|
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{}, 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
|
// 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" {
|
if mv.Dir == "V" {
|
||||||
dir = engine.Vertical
|
dir = engine.Vertical
|
||||||
}
|
}
|
||||||
_, err := g.SubmitPlay(dir, mv.Tiles)
|
_, err := g.SubmitPlayDir(dir, mv.Tiles)
|
||||||
return err
|
return err
|
||||||
case "pass":
|
case "pass":
|
||||||
_, err := g.Pass()
|
_, err := g.Pass()
|
||||||
|
|||||||
@@ -141,11 +141,14 @@ type HintResult struct {
|
|||||||
HintsRemaining int
|
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 {
|
type EvalResult struct {
|
||||||
Valid bool
|
Valid bool
|
||||||
Score int
|
Score int
|
||||||
Words []string
|
Words []string
|
||||||
|
Dir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateView is a player's view of a game: the shared game plus their private
|
// 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)
|
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)
|
t.Fatalf("seat0 play: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ func TestDraftSurvivesNonConflictingMove(t *testing.T) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("save draft 1: %v", err)
|
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)
|
t.Fatalf("seat0 play: %v", err)
|
||||||
}
|
}
|
||||||
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" {
|
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++ {
|
for i := 0; i < 300 && !mirror.Over(); i++ {
|
||||||
cur := seats[mirror.ToMove()]
|
cur := seats[mirror.ToMove()]
|
||||||
if hint, ok := mirror.HintView(); ok {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("submit play: %v", err)
|
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)
|
t.Fatalf("mirror play: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -154,10 +154,10 @@ func TestReplayEquivalence(t *testing.T) {
|
|||||||
for i := 0; i < 6 && !mirror.Over(); i++ {
|
for i := 0; i < 6 && !mirror.Over(); i++ {
|
||||||
cur := seats[mirror.ToMove()]
|
cur := seats[mirror.ToMove()]
|
||||||
if hint, ok := mirror.HintView(); ok {
|
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)
|
t.Fatalf("submit: %v", err)
|
||||||
}
|
}
|
||||||
mirror.SubmitPlay(hint.Dir, hint.Tiles)
|
mirror.SubmitPlay(hint.Tiles)
|
||||||
} else {
|
} else {
|
||||||
if _, err := svc.Pass(ctx, g.ID, cur); err != nil {
|
if _, err := svc.Pass(ctx, g.ID, cur); err != nil {
|
||||||
t.Fatalf("pass: %v", err)
|
t.Fatalf("pass: %v", err)
|
||||||
@@ -201,7 +201,7 @@ func TestResignWinnerAndStats(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("no opening move")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("p0 play: %v", err)
|
t.Fatalf("p0 play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -248,7 +248,7 @@ func TestResignOnOpponentTurn(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("no opening move")
|
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)
|
t.Fatalf("p0 play: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,19 +455,22 @@ func TestEvaluatePlayPreview(t *testing.T) {
|
|||||||
}
|
}
|
||||||
hint, _ := newMirror(t, seed, 2).HintView()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("evaluate: %v", err)
|
t.Fatalf("evaluate: %v", err)
|
||||||
}
|
}
|
||||||
if !eval.Valid || eval.Score <= 0 {
|
if !eval.Valid || eval.Score <= 0 {
|
||||||
t.Errorf("legal preview = %+v, want valid with score", eval)
|
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).
|
// 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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("evaluate illegal: %v", err)
|
t.Fatalf("evaluate illegal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -498,7 +501,7 @@ func TestConcurrentSubmitSerialized(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
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()
|
mu.Lock()
|
||||||
ok++
|
ok++
|
||||||
mu.Unlock()
|
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)
|
t.Fatalf("human candidates: %v", err)
|
||||||
}
|
}
|
||||||
if len(cands) > 0 {
|
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)
|
t.Fatalf("human play: %v", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) err
|
|||||||
var res game.MoveResult
|
var res game.MoveResult
|
||||||
switch d.kind {
|
switch d.kind {
|
||||||
case decidePlay:
|
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:
|
case decideExchange:
|
||||||
res, err = s.games.Exchange(ctx, rt.GameID, rt.RobotID, d.exchange)
|
res, err = s.games.Exchange(ctx, rt.GameID, rt.RobotID, d.exchange)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ type GameDriver interface {
|
|||||||
Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error)
|
Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error)
|
||||||
Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error)
|
Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error)
|
||||||
GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, 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)
|
Pass(ctx context.Context, gameID, accountID uuid.UUID) (game.MoveResult, error)
|
||||||
Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (game.MoveResult, error)
|
Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (game.MoveResult, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
@@ -297,15 +295,3 @@ func chatDTOFrom(m social.Message) chatDTO {
|
|||||||
CreatedAtUnix: m.CreatedAt.Unix(),
|
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"
|
"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) {
|
func TestStatusForError(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
err error
|
err error
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ type hintResultDTO struct {
|
|||||||
HintsRemaining int `json:"hints_remaining"`
|
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 {
|
type evalResultDTO struct {
|
||||||
Legal bool `json:"legal"`
|
Legal bool `json:"legal"`
|
||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
Words []string `json:"words"`
|
Words []string `json:"words"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// wordCheckDTO is the result of the unlimited dictionary lookup tool.
|
// 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")
|
abortBadRequest(c, "invalid request body")
|
||||||
return
|
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)
|
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
@@ -201,12 +199,12 @@ func (s *Server) handleEvaluate(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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
|
// 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) {
|
func TestSubmitPlayRejectsBadGameID(t *testing.T) {
|
||||||
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
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 {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("submit play bad game id = %d, want 400", rec.Code)
|
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))
|
c.JSON(http.StatusOK, profileResponseFor(acc))
|
||||||
}
|
}
|
||||||
|
|
||||||
// submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter
|
// submitPlayRequest places tiles on the player's turn; the engine infers the
|
||||||
// is a wire alphabet index; for a blank it is the designated letter's index.
|
// 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 {
|
type submitPlayRequest struct {
|
||||||
Dir string `json:"dir"`
|
|
||||||
Tiles []struct {
|
Tiles []struct {
|
||||||
Row int `json:"row"`
|
Row int `json:"row"`
|
||||||
Col int `json:"col"`
|
Col int `json:"col"`
|
||||||
@@ -70,11 +70,6 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
|
|||||||
abortBadRequest(c, "invalid request body")
|
abortBadRequest(c, "invalid request body")
|
||||||
return
|
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)
|
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
@@ -85,7 +80,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -262,6 +262,13 @@ Key points:
|
|||||||
check for resign. The engine exposes a
|
check for resign. The engine exposes a
|
||||||
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
|
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
|
||||||
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
|
`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 —
|
- The **game domain** (`internal/game`) owns everything the engine does not —
|
||||||
persistence, turn scheduling, the configurable turn timeout / auto-resign, the
|
persistence, turn scheduling, the configurable turn timeout / auto-resign, the
|
||||||
hint budget, word-check complaints, history and GCG — and is the engine's only
|
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
|
`?` 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,
|
(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
|
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
|
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
|
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,
|
stored. `variant` and `dict_version` are kept as **metadata only** (audit,
|
||||||
|
|||||||
+6
-3
@@ -92,9 +92,12 @@ settings and the game starts once every invitee has accepted — any decline can
|
|||||||
expires after seven days.
|
expires after seven days.
|
||||||
|
|
||||||
### Playing a game
|
### Playing a game
|
||||||
Place tiles, pass, exchange, or resign. A play is validated against the game's
|
Place tiles, pass, exchange, or resign. Tiles are laid without choosing a
|
||||||
dictionary at submit time and scored; an unlimited preview reports what a
|
direction — the game infers the play's orientation, so a single tile that extends
|
||||||
tentative move would score and whether it is legal. The dictionary check tool is
|
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 —
|
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
|
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
|
personal hint wallet once the per-game allowance is spent. The game ends when the
|
||||||
|
|||||||
@@ -96,9 +96,12 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
ответа приглашение протухает через семь дней.
|
ответа приглашение протухает через семь дней.
|
||||||
|
|
||||||
### Игровой процесс
|
### Игровой процесс
|
||||||
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
|
Выкладывание фишек, пас, обмен или сдача. Фишки кладутся без выбора направления —
|
||||||
сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы
|
игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая уже лежащее
|
||||||
предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и
|
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
||||||
|
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
||||||
|
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
||||||
|
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
|
||||||
предлагает пожаловаться на любой результат. Подсказки управляются настройками
|
предлагает пожаловаться на любой результат. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
||||||
|
|||||||
@@ -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
|
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
|
||||||
// index.
|
// 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
|
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)
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -279,11 +279,13 @@ type HintResultResp struct {
|
|||||||
HintsRemaining int `json:"hints_remaining"`
|
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 {
|
type EvalResultResp struct {
|
||||||
Legal bool `json:"legal"`
|
Legal bool `json:"legal"`
|
||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
Words []string `json:"words"`
|
Words []string `json:"words"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WordCheckResp is a dictionary lookup outcome.
|
// 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
|
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
||||||
// alphabet index.
|
// 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
|
var out EvalResultResp
|
||||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
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
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,10 +219,12 @@ func encodeHintResult(r backendclient.HintResultResp) []byte {
|
|||||||
func encodeEvalResult(r backendclient.EvalResultResp) []byte {
|
func encodeEvalResult(r backendclient.EvalResultResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(256)
|
b := flatbuffers.NewBuilder(256)
|
||||||
words := buildStringVector(b, r.Words, fb.EvalResultStartWordsVector)
|
words := buildStringVector(b, r.Words, fb.EvalResultStartWordsVector)
|
||||||
|
dir := b.CreateString(r.Dir)
|
||||||
fb.EvalResultStart(b)
|
fb.EvalResultStart(b)
|
||||||
fb.EvalResultAddLegal(b, r.Legal)
|
fb.EvalResultAddLegal(b, r.Legal)
|
||||||
fb.EvalResultAddScore(b, int32(r.Score))
|
fb.EvalResultAddScore(b, int32(r.Score))
|
||||||
fb.EvalResultAddWords(b, words)
|
fb.EvalResultAddWords(b, words)
|
||||||
|
fb.EvalResultAddDir(b, dir)
|
||||||
b.Finish(fb.EvalResultEnd(b))
|
b.Finish(fb.EvalResultEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func profileHandler(backend *backendclient.Client) Handler {
|
|||||||
func submitPlayHandler(backend *backendclient.Client) Handler {
|
func submitPlayHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -367,7 +367,7 @@ func hintHandler(backend *backendclient.Client) Handler {
|
|||||||
func evaluateHandler(backend *backendclient.Client) Handler {
|
func evaluateHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsEvalRequest(req.Payload, 0)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
|
|||||||
|
|
||||||
b := flatbuffers.NewBuilder(64)
|
b := flatbuffers.NewBuilder(64)
|
||||||
gid := b.CreateString("g-5")
|
gid := b.CreateString("g-5")
|
||||||
dir := b.CreateString("H")
|
|
||||||
fb.PlayTileStart(b)
|
fb.PlayTileStart(b)
|
||||||
fb.PlayTileAddRow(b, 7)
|
fb.PlayTileAddRow(b, 7)
|
||||||
fb.PlayTileAddCol(b, 7)
|
fb.PlayTileAddCol(b, 7)
|
||||||
@@ -122,7 +121,6 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
|
|||||||
tiles := b.EndVector(1)
|
tiles := b.EndVector(1)
|
||||||
fb.SubmitPlayRequestStart(b)
|
fb.SubmitPlayRequestStart(b)
|
||||||
fb.SubmitPlayRequestAddGameId(b, gid)
|
fb.SubmitPlayRequestAddGameId(b, gid)
|
||||||
fb.SubmitPlayRequestAddDir(b, dir)
|
|
||||||
fb.SubmitPlayRequestAddTiles(b, tiles)
|
fb.SubmitPlayRequestAddTiles(b, tiles)
|
||||||
b.Finish(fb.SubmitPlayRequestEnd(b))
|
b.Finish(fb.SubmitPlayRequestEnd(b))
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,11 @@ func stateReq(gameID string, includeAlphabet bool) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
// submitPlay builds a SubmitPlayRequest payload. dir is "H" or "V"; tiles are the
|
// submitPlay builds a SubmitPlayRequest payload. tiles are the newly-placed tiles in
|
||||||
// newly-placed tiles in main-word order.
|
// main-word order; the backend infers the play's orientation from them and the board.
|
||||||
func submitPlay(gameID, dir string, tiles []PlayTile) []byte {
|
func submitPlay(gameID string, tiles []PlayTile) []byte {
|
||||||
b := flatbuffers.NewBuilder(256)
|
b := flatbuffers.NewBuilder(256)
|
||||||
gid := b.CreateString(gameID)
|
gid := b.CreateString(gameID)
|
||||||
d := b.CreateString(dir)
|
|
||||||
offs := make([]flatbuffers.UOffsetT, len(tiles))
|
offs := make([]flatbuffers.UOffsetT, len(tiles))
|
||||||
for i, t := range tiles {
|
for i, t := range tiles {
|
||||||
fb.PlayTileStart(b)
|
fb.PlayTileStart(b)
|
||||||
@@ -59,7 +58,6 @@ func submitPlay(gameID, dir string, tiles []PlayTile) []byte {
|
|||||||
tilesVec := b.EndVector(len(offs))
|
tilesVec := b.EndVector(len(offs))
|
||||||
fb.SubmitPlayRequestStart(b)
|
fb.SubmitPlayRequestStart(b)
|
||||||
fb.SubmitPlayRequestAddGameId(b, gid)
|
fb.SubmitPlayRequestAddGameId(b, gid)
|
||||||
fb.SubmitPlayRequestAddDir(b, d)
|
|
||||||
fb.SubmitPlayRequestAddTiles(b, tilesVec)
|
fb.SubmitPlayRequestAddTiles(b, tilesVec)
|
||||||
b.Finish(fb.SubmitPlayRequestEnd(b))
|
b.Finish(fb.SubmitPlayRequestEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
|
|||||||
@@ -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.
|
// 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) {
|
func (c *Client) SubmitPlay(ctx context.Context, token, gameID string, tiles []PlayTile) (Game, string, error) {
|
||||||
r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, dir, tiles))
|
r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, tiles))
|
||||||
if err != nil || r.Code != "ok" {
|
if err != nil || r.Code != "ok" {
|
||||||
return Game{}, r.Code, err
|
return Game{}, r.Code, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,11 +78,10 @@ func (r *Registry) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Dir
|
// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Tiles
|
||||||
// ("H"/"V") and Tiles; an exchange carries Exchange (rack indices to swap).
|
// (the backend infers the orientation); an exchange carries Exchange (rack indices to swap).
|
||||||
type Action struct {
|
type Action struct {
|
||||||
Kind string
|
Kind string
|
||||||
Dir string
|
|
||||||
Tiles []edge.PlayTile
|
Tiles []edge.PlayTile
|
||||||
Exchange []byte
|
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
|
return noPlay(rackIdx, bagLen >= e.rs.RackSize), nil
|
||||||
}
|
}
|
||||||
m := midRanked(legal, rng)
|
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
|
// 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"}
|
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"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -146,9 +146,6 @@ func TestPickWithDawg(t *testing.T) {
|
|||||||
if len(act.Tiles) == 0 {
|
if len(act.Tiles) == 0 {
|
||||||
t.Error("play action has no tiles")
|
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":
|
case "exchange", "pass":
|
||||||
// acceptable when the rack has no legal first move
|
// acceptable when the rack has no legal first move
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ func (d *Driver) playTurn(ctx context.Context, c *edge.Client, p seed.Account, g
|
|||||||
switch action.Kind {
|
switch action.Kind {
|
||||||
case "play":
|
case "play":
|
||||||
t0 = time.Now()
|
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))
|
d.rec.Record("game.submit_play", code, time.Since(t0))
|
||||||
case "exchange":
|
case "exchange":
|
||||||
t0 = time.Now()
|
t0 = time.Now()
|
||||||
|
|||||||
@@ -151,11 +151,10 @@ table Profile {
|
|||||||
|
|
||||||
// --- game (authenticated) ---
|
// --- game (authenticated) ---
|
||||||
|
|
||||||
// SubmitPlayRequest places tiles in a direction on the player's turn. tiles are addressed
|
// SubmitPlayRequest places tiles on the player's turn; the backend infers the play's
|
||||||
// by alphabet index.
|
// orientation from the tiles and the board. tiles are addressed by alphabet index.
|
||||||
table SubmitPlayRequest {
|
table SubmitPlayRequest {
|
||||||
game_id:string;
|
game_id:string;
|
||||||
dir:string;
|
|
||||||
tiles:[PlayTile];
|
tiles:[PlayTile];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,18 +204,19 @@ table ExchangeRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EvalRequest previews a tentative play without committing it. tiles are addressed by
|
// 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 {
|
table EvalRequest {
|
||||||
game_id:string;
|
game_id:string;
|
||||||
dir:string;
|
|
||||||
tiles:[PlayTile];
|
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 {
|
table EvalResult {
|
||||||
legal:bool;
|
legal:bool;
|
||||||
score:int;
|
score:int;
|
||||||
words:[string];
|
words:[string];
|
||||||
|
dir:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckWordRequest looks a word up in the game's pinned dictionary. word is a sequence of
|
// CheckWordRequest looks a word up in the game's pinned dictionary. word is a sequence of
|
||||||
|
|||||||
@@ -49,16 +49,8 @@ func (rcv *EvalRequest) GameId() []byte {
|
|||||||
return nil
|
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 {
|
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 {
|
if o != 0 {
|
||||||
x := rcv._tab.Vector(o)
|
x := rcv._tab.Vector(o)
|
||||||
x += flatbuffers.UOffsetT(j) * 4
|
x += flatbuffers.UOffsetT(j) * 4
|
||||||
@@ -70,7 +62,7 @@ func (rcv *EvalRequest) Tiles(obj *PlayTile, j int) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rcv *EvalRequest) TilesLength() int {
|
func (rcv *EvalRequest) TilesLength() int {
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
return rcv._tab.VectorLen(o)
|
return rcv._tab.VectorLen(o)
|
||||||
}
|
}
|
||||||
@@ -78,16 +70,13 @@ func (rcv *EvalRequest) TilesLength() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func EvalRequestStart(builder *flatbuffers.Builder) {
|
func EvalRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(3)
|
builder.StartObject(2)
|
||||||
}
|
}
|
||||||
func EvalRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
func EvalRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
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) {
|
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 {
|
func EvalRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
return builder.StartVector(4, numElems, 4)
|
||||||
|
|||||||
@@ -82,8 +82,16 @@ func (rcv *EvalResult) WordsLength() int {
|
|||||||
return 0
|
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) {
|
func EvalResultStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(3)
|
builder.StartObject(4)
|
||||||
}
|
}
|
||||||
func EvalResultAddLegal(builder *flatbuffers.Builder, legal bool) {
|
func EvalResultAddLegal(builder *flatbuffers.Builder, legal bool) {
|
||||||
builder.PrependBoolSlot(0, legal, false)
|
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 {
|
func EvalResultStartWordsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
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 {
|
func EvalResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,16 +49,8 @@ func (rcv *SubmitPlayRequest) GameId() []byte {
|
|||||||
return nil
|
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 {
|
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 {
|
if o != 0 {
|
||||||
x := rcv._tab.Vector(o)
|
x := rcv._tab.Vector(o)
|
||||||
x += flatbuffers.UOffsetT(j) * 4
|
x += flatbuffers.UOffsetT(j) * 4
|
||||||
@@ -70,7 +62,7 @@ func (rcv *SubmitPlayRequest) Tiles(obj *PlayTile, j int) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rcv *SubmitPlayRequest) TilesLength() int {
|
func (rcv *SubmitPlayRequest) TilesLength() int {
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
return rcv._tab.VectorLen(o)
|
return rcv._tab.VectorLen(o)
|
||||||
}
|
}
|
||||||
@@ -78,16 +70,13 @@ func (rcv *SubmitPlayRequest) TilesLength() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SubmitPlayRequestStart(builder *flatbuffers.Builder) {
|
func SubmitPlayRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(3)
|
builder.StartObject(2)
|
||||||
}
|
}
|
||||||
func SubmitPlayRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
func SubmitPlayRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
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) {
|
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 {
|
func SubmitPlayRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
return builder.StartVector(4, numElems, 4)
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { EvalResult } from '../lib/model';
|
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
|
||||||
|
|
||||||
let {
|
|
||||||
preview,
|
|
||||||
hints,
|
|
||||||
busy,
|
|
||||||
ambiguous,
|
|
||||||
dir,
|
|
||||||
ondraw,
|
|
||||||
onskip,
|
|
||||||
onshuffle,
|
|
||||||
onhint,
|
|
||||||
ondir,
|
|
||||||
}: {
|
|
||||||
preview: EvalResult | null;
|
|
||||||
hints: number;
|
|
||||||
busy: boolean;
|
|
||||||
ambiguous: boolean;
|
|
||||||
dir: 'H' | 'V';
|
|
||||||
ondraw: () => void;
|
|
||||||
onskip: () => void;
|
|
||||||
onshuffle: () => void;
|
|
||||||
onhint: () => void;
|
|
||||||
ondir: () => void;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<div class="preview">
|
|
||||||
{#if preview}
|
|
||||||
{#if preview.legal}
|
|
||||||
<span class="ok">{t('game.preview', { n: preview.score })}</span>
|
|
||||||
{:else}
|
|
||||||
<span class="bad">{t('game.previewIllegal')}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if ambiguous}
|
|
||||||
<button class="dir" onclick={ondir} title="direction">{dir === 'H' ? '↔' : '↕'}</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<button onclick={ondraw} disabled={busy}>{t('game.draw')}</button>
|
|
||||||
<button onclick={onskip} disabled={busy}>{t('game.skip')}</button>
|
|
||||||
<button onclick={onshuffle} disabled={busy}>{t('game.shuffle')}</button>
|
|
||||||
<button class="hint" onclick={onhint} disabled={busy || hints <= 0}>
|
|
||||||
{t('game.hint')}{hints > 0 ? ` (${hints})` : ''}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.preview {
|
|
||||||
min-height: 22px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.ok {
|
|
||||||
color: var(--ok);
|
|
||||||
}
|
|
||||||
.bad {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
.dir {
|
|
||||||
margin-left: auto;
|
|
||||||
width: 34px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.row button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 11px 6px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.row button:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+8
-12
@@ -12,7 +12,7 @@
|
|||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||||
import { lastMoveCells, replay } from '../lib/board';
|
import { lastMoveCells, replay } from '../lib/board';
|
||||||
import { historyGrid } from '../lib/history';
|
import { historyGrid } from '../lib/history';
|
||||||
import { centre, premiumGrid } from '../lib/premiums';
|
import { centre, premiumGrid } from '../lib/premiums';
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
let moves = $state<MoveRecord[]>([]);
|
let moves = $state<MoveRecord[]>([]);
|
||||||
let placement = $state<Placement>(newPlacement([]));
|
let placement = $state<Placement>(newPlacement([]));
|
||||||
let preview = $state<EvalResult | null>(null);
|
let preview = $state<EvalResult | null>(null);
|
||||||
let dirOverride = $state<Direction | undefined>(undefined);
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let zoomed = $state(false);
|
let zoomed = $state(false);
|
||||||
let selected = $state<number | null>(null);
|
let selected = $state<number | null>(null);
|
||||||
@@ -130,7 +129,6 @@
|
|||||||
moves = hist.moves;
|
moves = hist.moves;
|
||||||
setCachedGame(id, st, hist.moves);
|
setCachedGame(id, st, hist.moves);
|
||||||
selected = null;
|
selected = null;
|
||||||
dirOverride = undefined;
|
|
||||||
await applyDraft(st);
|
await applyDraft(st);
|
||||||
recompute();
|
recompute();
|
||||||
refreshRecent();
|
refreshRecent();
|
||||||
@@ -491,11 +489,11 @@
|
|||||||
if (previewTimer) clearTimeout(previewTimer);
|
if (previewTimer) clearTimeout(previewTimer);
|
||||||
// Off-turn the composition is position-only: no score preview or evaluate.
|
// Off-turn the composition is position-only: no score preview or evaluate.
|
||||||
if (!isMyTurn) return;
|
if (!isMyTurn) return;
|
||||||
const sub = toSubmit(placement, dirOverride);
|
const sub = toSubmit(placement);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
previewTimer = setTimeout(async () => {
|
previewTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
|
preview = await gateway.evaluate(id, sub.tiles, variant);
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
@@ -511,17 +509,16 @@
|
|||||||
rackIds = r.rack.map((_, i) => i);
|
rackIds = r.rack.map((_, i) => i);
|
||||||
placement = newPlacement(r.rack);
|
placement = newPlacement(r.rack);
|
||||||
selected = null;
|
selected = null;
|
||||||
dirOverride = undefined;
|
|
||||||
recompute();
|
recompute();
|
||||||
refreshRecent();
|
refreshRecent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commit() {
|
async function commit() {
|
||||||
const sub = toSubmit(placement, dirOverride);
|
const sub = toSubmit(placement);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant));
|
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
|
||||||
telegramHaptic('success');
|
telegramHaptic('success');
|
||||||
zoomed = false;
|
zoomed = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -534,7 +531,6 @@
|
|||||||
placement = reset(placement);
|
placement = reset(placement);
|
||||||
preview = null;
|
preview = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
dirOverride = undefined;
|
|
||||||
scheduleDraftSave();
|
scheduleDraftSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,11 +853,11 @@
|
|||||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
||||||
{:else}
|
{:else if placement.pending.length === 0}
|
||||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
|
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="scores">
|
<span class="scores">
|
||||||
{#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if}
|
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -880,7 +876,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if !gameOver && placement.pending.length > 0}
|
{#if !gameOver && placement.pending.length > 0}
|
||||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -30,37 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
dir():string|null
|
|
||||||
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
|
||||||
dir(optionalEncoding?:any):string|Uint8Array|null {
|
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
|
||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
tilesLength():number {
|
tilesLength():number {
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static startEvalRequest(builder:flatbuffers.Builder) {
|
static startEvalRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(3);
|
builder.startObject(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||||
builder.addFieldOffset(0, gameIdOffset, 0);
|
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
|
|
||||||
builder.addFieldOffset(1, dirOffset, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
|
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
|
||||||
builder.addFieldOffset(2, tilesOffset, 0);
|
builder.addFieldOffset(1, tilesOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||||
@@ -80,10 +69,9 @@ static endEvalRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
|||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createEvalRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createEvalRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
EvalRequest.startEvalRequest(builder);
|
EvalRequest.startEvalRequest(builder);
|
||||||
EvalRequest.addGameId(builder, gameIdOffset);
|
EvalRequest.addGameId(builder, gameIdOffset);
|
||||||
EvalRequest.addDir(builder, dirOffset);
|
|
||||||
EvalRequest.addTiles(builder, tilesOffset);
|
EvalRequest.addTiles(builder, tilesOffset);
|
||||||
return EvalRequest.endEvalRequest(builder);
|
return EvalRequest.endEvalRequest(builder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,15 @@ wordsLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dir():string|null
|
||||||
|
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
dir(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startEvalResult(builder:flatbuffers.Builder) {
|
static startEvalResult(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(3);
|
builder.startObject(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addLegal(builder:flatbuffers.Builder, legal:boolean) {
|
static addLegal(builder:flatbuffers.Builder, legal:boolean) {
|
||||||
@@ -70,16 +77,21 @@ static startWordsVector(builder:flatbuffers.Builder, numElems:number) {
|
|||||||
builder.startVector(4, numElems, 4);
|
builder.startVector(4, numElems, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(3, dirOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endEvalResult(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endEvalResult(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createEvalResult(builder:flatbuffers.Builder, legal:boolean, score:number, wordsOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createEvalResult(builder:flatbuffers.Builder, legal:boolean, score:number, wordsOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
EvalResult.startEvalResult(builder);
|
EvalResult.startEvalResult(builder);
|
||||||
EvalResult.addLegal(builder, legal);
|
EvalResult.addLegal(builder, legal);
|
||||||
EvalResult.addScore(builder, score);
|
EvalResult.addScore(builder, score);
|
||||||
EvalResult.addWords(builder, wordsOffset);
|
EvalResult.addWords(builder, wordsOffset);
|
||||||
|
EvalResult.addDir(builder, dirOffset);
|
||||||
return EvalResult.endEvalResult(builder);
|
return EvalResult.endEvalResult(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,37 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
dir():string|null
|
|
||||||
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
|
||||||
dir(optionalEncoding?:any):string|Uint8Array|null {
|
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
|
||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
tilesLength():number {
|
tilesLength():number {
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static startSubmitPlayRequest(builder:flatbuffers.Builder) {
|
static startSubmitPlayRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(3);
|
builder.startObject(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||||
builder.addFieldOffset(0, gameIdOffset, 0);
|
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
|
|
||||||
builder.addFieldOffset(1, dirOffset, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
|
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
|
||||||
builder.addFieldOffset(2, tilesOffset, 0);
|
builder.addFieldOffset(1, tilesOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||||
@@ -80,10 +69,9 @@ static endSubmitPlayRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
|||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createSubmitPlayRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createSubmitPlayRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
SubmitPlayRequest.startSubmitPlayRequest(builder);
|
SubmitPlayRequest.startSubmitPlayRequest(builder);
|
||||||
SubmitPlayRequest.addGameId(builder, gameIdOffset);
|
SubmitPlayRequest.addGameId(builder, gameIdOffset);
|
||||||
SubmitPlayRequest.addDir(builder, dirOffset);
|
|
||||||
SubmitPlayRequest.addTiles(builder, tilesOffset);
|
SubmitPlayRequest.addTiles(builder, tilesOffset);
|
||||||
return SubmitPlayRequest.endSubmitPlayRequest(builder);
|
return SubmitPlayRequest.endSubmitPlayRequest(builder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ export interface GatewayClient {
|
|||||||
// table), and gameState's includeAlphabet asks the server to embed that table.
|
// table), and gameState's includeAlphabet asks the server to embed that table.
|
||||||
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
|
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
|
||||||
gameHistory(gameId: string): Promise<History>;
|
gameHistory(gameId: string): Promise<History>;
|
||||||
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
|
submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
|
||||||
pass(gameId: string): Promise<MoveResult>;
|
pass(gameId: string): Promise<MoveResult>;
|
||||||
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
|
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
|
||||||
resign(gameId: string): Promise<MoveResult>;
|
resign(gameId: string): Promise<MoveResult>;
|
||||||
hint(gameId: string): Promise<HintResult>;
|
hint(gameId: string): Promise<HintResult>;
|
||||||
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
|
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
|
||||||
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
||||||
complaint(gameId: string, word: string, note: string): Promise<void>;
|
complaint(gameId: string, word: string, note: string): Promise<void>;
|
||||||
/** Hide a finished game from the caller's own lobby list; per-account, irreversible. */
|
/** Hide a finished game from the caller's own lobby list; per-account, irreversible. */
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ describe('codec', () => {
|
|||||||
// A placed blank carries its designated letter's index with the blank flag set.
|
// A placed blank carries its designated letter's index with the blank flag set.
|
||||||
const buf = encodeSubmitPlay(
|
const buf = encodeSubmitPlay(
|
||||||
'g1',
|
'g1',
|
||||||
'H',
|
|
||||||
[
|
[
|
||||||
{ row: 7, col: 7, letter: 'A', blank: false },
|
{ row: 7, col: 7, letter: 'A', blank: false },
|
||||||
{ row: 7, col: 8, letter: 'B', blank: true },
|
{ row: 7, col: 8, letter: 'B', blank: true },
|
||||||
@@ -52,7 +51,6 @@ describe('codec', () => {
|
|||||||
);
|
);
|
||||||
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
|
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
|
||||||
expect(r.gameId()).toBe('g1');
|
expect(r.gameId()).toBe('g1');
|
||||||
expect(r.dir()).toBe('H');
|
|
||||||
expect(r.tilesLength()).toBe(2);
|
expect(r.tilesLength()).toBe(2);
|
||||||
expect(r.tiles(0)?.letter()).toBe(0);
|
expect(r.tiles(0)?.letter()).toBe(0);
|
||||||
expect(r.tiles(1)?.letter()).toBe(1);
|
expect(r.tiles(1)?.letter()).toBe(1);
|
||||||
|
|||||||
+2
-12
@@ -87,7 +87,6 @@ export function encodeDraftSave(gameId: string, json: string): Uint8Array {
|
|||||||
|
|
||||||
export function encodeSubmitPlay(
|
export function encodeSubmitPlay(
|
||||||
gameId: string,
|
gameId: string,
|
||||||
dir: 'H' | 'V',
|
|
||||||
tiles: PlacedTile[],
|
tiles: PlacedTile[],
|
||||||
variant: Variant,
|
variant: Variant,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
@@ -95,28 +94,19 @@ export function encodeSubmitPlay(
|
|||||||
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
||||||
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
|
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
|
||||||
const gid = b.createString(gameId);
|
const gid = b.createString(gameId);
|
||||||
const d = b.createString(dir);
|
|
||||||
fb.SubmitPlayRequest.startSubmitPlayRequest(b);
|
fb.SubmitPlayRequest.startSubmitPlayRequest(b);
|
||||||
fb.SubmitPlayRequest.addGameId(b, gid);
|
fb.SubmitPlayRequest.addGameId(b, gid);
|
||||||
fb.SubmitPlayRequest.addDir(b, d);
|
|
||||||
fb.SubmitPlayRequest.addTiles(b, vec);
|
fb.SubmitPlayRequest.addTiles(b, vec);
|
||||||
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
|
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeEval(
|
export function encodeEval(gameId: string, tiles: PlacedTile[], variant: Variant): Uint8Array {
|
||||||
gameId: string,
|
|
||||||
dir: 'H' | 'V',
|
|
||||||
tiles: PlacedTile[],
|
|
||||||
variant: Variant,
|
|
||||||
): Uint8Array {
|
|
||||||
const b = new Builder(256);
|
const b = new Builder(256);
|
||||||
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
||||||
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
|
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
|
||||||
const gid = b.createString(gameId);
|
const gid = b.createString(gameId);
|
||||||
const d = b.createString(dir);
|
|
||||||
fb.EvalRequest.startEvalRequest(b);
|
fb.EvalRequest.startEvalRequest(b);
|
||||||
fb.EvalRequest.addGameId(b, gid);
|
fb.EvalRequest.addGameId(b, gid);
|
||||||
fb.EvalRequest.addDir(b, d);
|
|
||||||
fb.EvalRequest.addTiles(b, vec);
|
fb.EvalRequest.addTiles(b, vec);
|
||||||
return finish(b, fb.EvalRequest.endEvalRequest(b));
|
return finish(b, fb.EvalRequest.endEvalRequest(b));
|
||||||
}
|
}
|
||||||
@@ -377,7 +367,7 @@ export function decodeEvalResult(buf: Uint8Array): EvalResult {
|
|||||||
const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf));
|
const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf));
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i)));
|
for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i)));
|
||||||
return { legal: r.legal(), score: r.score(), words };
|
return { legal: r.legal(), score: r.score(), words, dir: s(r.dir()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeWordCheck(buf: Uint8Array): WordCheckResult {
|
export function decodeWordCheck(buf: Uint8Array): WordCheckResult {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const en = {
|
|||||||
'game.checkWord': 'Check word',
|
'game.checkWord': 'Check word',
|
||||||
'game.dictionary': 'Dictionary',
|
'game.dictionary': 'Dictionary',
|
||||||
'game.dropGame': 'Drop game',
|
'game.dropGame': 'Drop game',
|
||||||
'game.preview': 'Scores {n}',
|
'game.previewWords': '{words}: {n}',
|
||||||
'game.previewIllegal': 'Not a legal move',
|
'game.previewIllegal': 'Not a legal move',
|
||||||
'game.chooseBlank': 'Choose a letter for the blank',
|
'game.chooseBlank': 'Choose a letter for the blank',
|
||||||
'game.exchangeTitle': 'Select tiles to exchange',
|
'game.exchangeTitle': 'Select tiles to exchange',
|
||||||
@@ -86,7 +86,6 @@ export const en = {
|
|||||||
'game.check': 'Check',
|
'game.check': 'Check',
|
||||||
'game.checkWait': 'Please wait a moment.',
|
'game.checkWait': 'Please wait a moment.',
|
||||||
'game.noHintOptions': 'No options with your letters.',
|
'game.noHintOptions': 'No options with your letters.',
|
||||||
'game.scores': 'Scores: {n}',
|
|
||||||
'game.thinking': 'thinking…',
|
'game.thinking': 'thinking…',
|
||||||
|
|
||||||
'move.pass': 'pass',
|
'move.pass': 'pass',
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.checkWord': 'Проверить слово',
|
'game.checkWord': 'Проверить слово',
|
||||||
'game.dictionary': 'Словарь',
|
'game.dictionary': 'Словарь',
|
||||||
'game.dropGame': 'Покинуть игру',
|
'game.dropGame': 'Покинуть игру',
|
||||||
'game.preview': 'Очков: {n}',
|
'game.previewWords': '{words}: {n}',
|
||||||
'game.previewIllegal': 'Недопустимый ход',
|
'game.previewIllegal': 'Недопустимый ход',
|
||||||
'game.chooseBlank': 'Выберите букву для бланка',
|
'game.chooseBlank': 'Выберите букву для бланка',
|
||||||
'game.exchangeTitle': 'Выберите фишки для обмена',
|
'game.exchangeTitle': 'Выберите фишки для обмена',
|
||||||
@@ -87,7 +87,6 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.check': 'Проверить',
|
'game.check': 'Проверить',
|
||||||
'game.checkWait': 'Секунду, пожалуйста.',
|
'game.checkWait': 'Секунду, пожалуйста.',
|
||||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||||
'game.scores': 'Очков: {n}',
|
|
||||||
'game.thinking': 'думает…',
|
'game.thinking': 'думает…',
|
||||||
|
|
||||||
'move.pass': 'пас',
|
'move.pass': 'пас',
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
return { gameId, moves: structuredClone(g.moves) };
|
return { gameId, moves: structuredClone(g.moves) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> {
|
async submitPlay(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> {
|
||||||
const g = this.game(gameId);
|
const g = this.game(gameId);
|
||||||
const seat = this.mySeat(g);
|
const seat = this.mySeat(g);
|
||||||
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
|
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
|
||||||
@@ -213,6 +213,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0);
|
let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0);
|
||||||
if (tiles.length === 7) score += 50;
|
if (tiles.length === 7) score += 50;
|
||||||
const total = g.view.seats[seat].score + score;
|
const total = g.view.seats[seat].score + score;
|
||||||
|
const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
|
||||||
const move = {
|
const move = {
|
||||||
player: seat,
|
player: seat,
|
||||||
action: 'play' as const,
|
action: 'play' as const,
|
||||||
@@ -311,12 +312,13 @@ export class MockGateway implements GatewayClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
|
async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
|
||||||
const g = this.game(gameId);
|
const g = this.game(gameId);
|
||||||
if (tiles.length === 0) return { legal: false, score: 0, words: [] };
|
if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' };
|
||||||
let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
|
let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
|
||||||
if (tiles.length === 7) score += 50;
|
if (tiles.length === 7) score += 50;
|
||||||
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] };
|
const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
|
||||||
|
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')], dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
|
async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export interface EvalResult {
|
|||||||
legal: boolean;
|
legal: boolean;
|
||||||
score: number;
|
score: number;
|
||||||
words: string[];
|
words: string[];
|
||||||
|
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
|
||||||
|
dir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WordCheckResult {
|
export interface WordCheckResult {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
BLANK,
|
BLANK,
|
||||||
cellOccupied,
|
cellOccupied,
|
||||||
direction,
|
|
||||||
isBlankSlot,
|
isBlankSlot,
|
||||||
newPlacement,
|
newPlacement,
|
||||||
place,
|
place,
|
||||||
@@ -47,23 +46,11 @@ describe('placement state machine', () => {
|
|||||||
expect(reset(place(p, 0, 7, 7)).pending).toHaveLength(0);
|
expect(reset(place(p, 0, 7, 7)).pending).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('infers direction H for a row, V for a column, null for a single tile', () => {
|
it('builds a sorted submit payload and returns null when empty', () => {
|
||||||
let h = place(newPlacement(rack), 0, 7, 7);
|
|
||||||
h = place(h, 1, 7, 8);
|
|
||||||
expect(direction(h)).toBe('H');
|
|
||||||
let v = place(newPlacement(rack), 0, 7, 7);
|
|
||||||
v = place(v, 1, 8, 7);
|
|
||||||
expect(direction(v)).toBe('V');
|
|
||||||
expect(direction(place(newPlacement(rack), 0, 7, 7))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds a sorted submit payload and honours a direction override', () => {
|
|
||||||
let p = place(newPlacement(rack), 1, 7, 9);
|
let p = place(newPlacement(rack), 1, 7, 9);
|
||||||
p = place(p, 0, 7, 7);
|
p = place(p, 0, 7, 7);
|
||||||
const sub = toSubmit(p);
|
const sub = toSubmit(p);
|
||||||
expect(sub?.dir).toBe('H');
|
|
||||||
expect(sub?.tiles.map((t) => t.col)).toEqual([7, 9]);
|
expect(sub?.tiles.map((t) => t.col)).toEqual([7, 9]);
|
||||||
expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V');
|
|
||||||
expect(toSubmit(newPlacement(rack))).toBeNull();
|
expect(toSubmit(newPlacement(rack))).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,15 +65,8 @@ describe('placement state machine', () => {
|
|||||||
expect(isBlankSlot(newPlacement(rack), 0)).toBe(false);
|
expect(isBlankSlot(newPlacement(rack), 0)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats a non-linear placement as no inferred direction', () => {
|
it('submits a single tile as a one-tile payload', () => {
|
||||||
let p = place(newPlacement(rack), 0, 7, 7);
|
|
||||||
p = place(p, 1, 8, 8); // diagonal
|
|
||||||
expect(direction(p)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults a single-tile submit to H without an override', () => {
|
|
||||||
const sub = toSubmit(place(newPlacement(rack), 0, 7, 7));
|
const sub = toSubmit(place(newPlacement(rack), 0, 7, 7));
|
||||||
expect(sub?.dir).toBe('H');
|
|
||||||
expect(sub?.tiles).toHaveLength(1);
|
expect(sub?.tiles).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-22
@@ -4,7 +4,7 @@
|
|||||||
// payload. It is board-agnostic (the gateway/engine does full legality validation at
|
// payload. It is board-agnostic (the gateway/engine does full legality validation at
|
||||||
// submit), which keeps it trivially unit-testable.
|
// submit), which keeps it trivially unit-testable.
|
||||||
|
|
||||||
import type { Direction, Tile } from './model';
|
import type { Tile } from './model';
|
||||||
import type { PlacedTile } from './client';
|
import type { PlacedTile } from './client';
|
||||||
|
|
||||||
export interface PendingTile {
|
export interface PendingTile {
|
||||||
@@ -119,30 +119,13 @@ export function reorderIndices(n: number, from: number, toSlot: number): number[
|
|||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** toSubmit builds the submit payload: the placed tiles in board order. The backend
|
||||||
* direction infers the play orientation from the pending tiles: H if they share a row,
|
* infers the play's orientation from the tiles and the board, so none is sent. */
|
||||||
* V if they share a column, null if a single tile (ambiguous) or non-linear (invalid).
|
export function toSubmit(p: Placement): { tiles: PlacedTile[] } | null {
|
||||||
*/
|
|
||||||
export function direction(p: Placement): Direction | null {
|
|
||||||
if (p.pending.length < 2) return null;
|
|
||||||
const rows = new Set(p.pending.map((t) => t.row));
|
|
||||||
const cols = new Set(p.pending.map((t) => t.col));
|
|
||||||
if (rows.size === 1 && cols.size === p.pending.length) return 'H';
|
|
||||||
if (cols.size === 1 && rows.size === p.pending.length) return 'V';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** toSubmit builds the submit payload. dirOverride resolves a single-tile play, where
|
|
||||||
* the orientation cannot be inferred; otherwise the inferred direction is used. */
|
|
||||||
export function toSubmit(
|
|
||||||
p: Placement,
|
|
||||||
dirOverride?: Direction,
|
|
||||||
): { dir: Direction; tiles: PlacedTile[] } | null {
|
|
||||||
if (p.pending.length === 0) return null;
|
if (p.pending.length === 0) return null;
|
||||||
const dir = dirOverride ?? direction(p) ?? 'H';
|
|
||||||
const tiles: PlacedTile[] = p.pending
|
const tiles: PlacedTile[] = p.pending
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.row - b.row || a.col - b.col)
|
.sort((a, b) => a.row - b.row || a.col - b.col)
|
||||||
.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank }));
|
.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank }));
|
||||||
return { dir, tiles };
|
return { tiles };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async gameHistory(id) {
|
async gameHistory(id) {
|
||||||
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
|
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
|
||||||
},
|
},
|
||||||
async submitPlay(id, dir, tiles, variant) {
|
async submitPlay(id, tiles, variant) {
|
||||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant)));
|
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, tiles, variant)));
|
||||||
},
|
},
|
||||||
async pass(id) {
|
async pass(id) {
|
||||||
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
|
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
|
||||||
@@ -112,8 +112,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async hint(id) {
|
async hint(id) {
|
||||||
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
|
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
|
||||||
},
|
},
|
||||||
async evaluate(id, dir, tiles, variant) {
|
async evaluate(id, tiles, variant) {
|
||||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant)));
|
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant)));
|
||||||
},
|
},
|
||||||
async checkWord(id, word, variant) {
|
async checkWord(id, word, variant) {
|
||||||
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
|
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
|
||||||
|
|||||||
@@ -324,8 +324,13 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */
|
touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */
|
||||||
}
|
}
|
||||||
.open:active {
|
/* A tap/click on a game row leaves no highlight: drop the WebKit tap-flash on
|
||||||
background: var(--surface-2);
|
both tappable areas (the open body and the chevron / kebab) and the held
|
||||||
|
:active background, which added nothing and only spoiled the look. */
|
||||||
|
.open,
|
||||||
|
.chev,
|
||||||
|
.kebab {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
.kebab {
|
.kebab {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user