Backend infers play direction; UI previews words and gates submit on legality
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
A single tile that only extended a word perpendicular to the client-declared direction was rejected: the UI always sent dir=H for one-tile plays (the dirOverride/Controls toggle was orphaned in the Stage 7 game rework), so placing "А" above "БАК" to form "АБАК" failed the solver's main-word-length check even though the word is in the dictionary. Make the backend infer a play's orientation from the placed tiles and the board (internal/engine.resolveDirection): two or more tiles by the line they share, a lone tile by the axis it abuts (longer word wins, horizontal on a tie). Direction becomes an output, not an input: drop dir from the SubmitPlay/Eval wire requests and add it to EvalResult. Journal replay keeps trusting the stored "H"/"V" (SubmitPlayDir) so a rebuilt game matches the one committed. UI: stop computing/sending direction; the preview now shows the words a move forms with its total score (game.previewWords); the make-move control is disabled until the play is confirmed legal; the "your turn" label hides while tiles are pending. Delete the orphaned Controls.svelte. Regenerate the FlatBuffers bindings (Go + TS) and update the gateway transcode and the loadtest edge client to the new contract. Bake the decision into ARCHITECTURE.md (§5/§9.1), FUNCTIONAL.md (+ _ru) and the backend README.
This commit is contained in:
+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
|
||||
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
|
||||
provides create, the play/pass/exchange/resign transitions, an unlimited
|
||||
score/legality preview, the hint (per-game allowance plus a profile wallet), the
|
||||
word/score/legality preview, the hint (per-game allowance plus a profile wallet), the
|
||||
word-check tool with complaint capture, per-player game state, history and GCG
|
||||
export, per-account statistics on finish, and a background turn-timeout sweeper
|
||||
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// resolveDirection infers a play's orientation from the placed tiles and the
|
||||
// board, so a caller need not declare it (docs/ARCHITECTURE.md §5). Two or more
|
||||
// tiles fix the orientation by the line they share: a common row reads
|
||||
// horizontally, otherwise vertically (a non-linear placement is left for
|
||||
// Evaluate to reject). A single tile is ambiguous on its own — it may extend a
|
||||
// word down a column or across a row — so the orientation is the axis along
|
||||
// which it abuts existing tiles, preferring the axis that yields the longer word
|
||||
// and horizontal on a tie. A tile that abuts nothing falls back to horizontal
|
||||
// and is rejected downstream as disconnected (or, on the first move, as too
|
||||
// short).
|
||||
func resolveDirection(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
|
||||
if len(placements) >= 2 {
|
||||
row := placements[0].Row
|
||||
for _, p := range placements[1:] {
|
||||
if p.Row != row {
|
||||
return scrabble.Vertical
|
||||
}
|
||||
}
|
||||
return scrabble.Horizontal
|
||||
}
|
||||
if len(placements) == 1 {
|
||||
p := placements[0]
|
||||
h := runLength(b, p.Row, p.Col, scrabble.Horizontal)
|
||||
v := runLength(b, p.Row, p.Col, scrabble.Vertical)
|
||||
if v >= 2 && v > h {
|
||||
return scrabble.Vertical
|
||||
}
|
||||
if h >= 2 {
|
||||
return scrabble.Horizontal
|
||||
}
|
||||
if v >= 2 {
|
||||
return scrabble.Vertical
|
||||
}
|
||||
}
|
||||
return scrabble.Horizontal
|
||||
}
|
||||
|
||||
// runLength returns how many cells the word through (row, col) along dir would
|
||||
// span once a tile is placed on the empty target square: the square itself plus
|
||||
// the runs of filled cells immediately before and after it along dir. A result
|
||||
// below two means the tile forms no word on that axis. Filled treats
|
||||
// off-board coordinates as empty, so the walks stop at the board edge.
|
||||
func runLength(b *board.Board, row, col int, dir scrabble.Direction) int {
|
||||
dr, dc := 0, 1
|
||||
if dir == scrabble.Vertical {
|
||||
dr, dc = 1, 0
|
||||
}
|
||||
n := 1
|
||||
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
|
||||
n++
|
||||
}
|
||||
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// boardWith returns a 15x15 board with the given (row, col) cells occupied. The
|
||||
// concrete letter is irrelevant to direction inference, which reads only
|
||||
// occupancy, so every filler uses alphabet index 0.
|
||||
func boardWith(cells ...[2]int) *board.Board {
|
||||
b := board.New(15, 15)
|
||||
ps := make([]scrabble.Placement, len(cells))
|
||||
for i, c := range cells {
|
||||
ps[i] = scrabble.Placement{Row: c[0], Col: c[1], Letter: 0}
|
||||
}
|
||||
scrabble.Apply(b, scrabble.Move{Tiles: ps})
|
||||
return b
|
||||
}
|
||||
|
||||
// TestRunLength covers the word-span measurement that drives single-tile
|
||||
// direction inference, including the board edge.
|
||||
func TestRunLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filled [][2]int
|
||||
row, col int
|
||||
dir scrabble.Direction
|
||||
want int
|
||||
}{
|
||||
{"isolated horizontal", nil, 7, 7, scrabble.Horizontal, 1},
|
||||
{"isolated vertical", nil, 7, 7, scrabble.Vertical, 1},
|
||||
{"neighbour below", [][2]int{{8, 7}}, 7, 7, scrabble.Vertical, 2},
|
||||
{"neighbour above", [][2]int{{6, 7}}, 7, 7, scrabble.Vertical, 2},
|
||||
{"run below", [][2]int{{8, 7}, {9, 7}}, 7, 7, scrabble.Vertical, 3},
|
||||
{"bridge vertical", [][2]int{{6, 7}, {8, 7}}, 7, 7, scrabble.Vertical, 3},
|
||||
{"neighbour left", [][2]int{{7, 6}}, 7, 7, scrabble.Horizontal, 2},
|
||||
{"neighbour right", [][2]int{{7, 8}}, 7, 7, scrabble.Horizontal, 2},
|
||||
{"bridge horizontal", [][2]int{{7, 6}, {7, 8}}, 7, 7, scrabble.Horizontal, 3},
|
||||
{"perpendicular ignored", [][2]int{{7, 6}}, 7, 7, scrabble.Vertical, 1},
|
||||
{"top edge", [][2]int{{1, 0}}, 0, 0, scrabble.Vertical, 2},
|
||||
{"left edge", [][2]int{{0, 1}}, 0, 0, scrabble.Horizontal, 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := boardWith(tt.filled...)
|
||||
if got := runLength(b, tt.row, tt.col, tt.dir); got != tt.want {
|
||||
t.Errorf("runLength(%d,%d,%v) = %d, want %d", tt.row, tt.col, tt.dir, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDirection covers orientation inference for both multi-tile plays
|
||||
// (fixed by the shared line) and the ambiguous single tile (the axis it abuts,
|
||||
// longer word winning and horizontal on a tie; disconnected falls back to
|
||||
// horizontal for the downstream rejection).
|
||||
func TestResolveDirection(t *testing.T) {
|
||||
at := func(cells ...[2]int) []scrabble.Placement {
|
||||
ps := make([]scrabble.Placement, len(cells))
|
||||
for i, c := range cells {
|
||||
ps[i] = scrabble.Placement{Row: c[0], Col: c[1]}
|
||||
}
|
||||
return ps
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
filled [][2]int
|
||||
play []scrabble.Placement
|
||||
want scrabble.Direction
|
||||
}{
|
||||
{"single extends down", [][2]int{{8, 7}, {9, 7}}, at([2]int{7, 7}), scrabble.Vertical},
|
||||
{"single extends up", [][2]int{{6, 7}, {5, 7}}, at([2]int{7, 7}), scrabble.Vertical},
|
||||
{"single extends left", [][2]int{{7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
|
||||
{"single extends right", [][2]int{{7, 8}}, at([2]int{7, 7}), scrabble.Horizontal},
|
||||
{"single both axes vertical longer", [][2]int{{6, 7}, {8, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Vertical},
|
||||
{"single both axes horizontal longer", [][2]int{{7, 6}, {7, 8}, {6, 7}}, at([2]int{7, 7}), scrabble.Horizontal},
|
||||
{"single both axes equal prefers horizontal", [][2]int{{6, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
|
||||
{"single disconnected falls back to horizontal", nil, at([2]int{7, 7}), scrabble.Horizontal},
|
||||
{"multi shared row is horizontal", nil, at([2]int{7, 7}, [2]int{7, 8}), scrabble.Horizontal},
|
||||
{"multi shared column is vertical", nil, at([2]int{7, 7}, [2]int{8, 7}), scrabble.Vertical},
|
||||
{"multi non-linear is vertical", nil, at([2]int{7, 7}, [2]int{8, 8}), scrabble.Vertical},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := boardWith(tt.filled...)
|
||||
if got := resolveDirection(b, tt.play); got != tt.want {
|
||||
t.Errorf("resolveDirection = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDirectionEmpty checks the degenerate empty placement does not panic
|
||||
// and falls back to horizontal (Evaluate rejects the empty play downstream).
|
||||
func TestResolveDirectionEmpty(t *testing.T) {
|
||||
if got := resolveDirection(boardWith(), nil); got != scrabble.Horizontal {
|
||||
t.Errorf("resolveDirection(empty) = %v, want Horizontal", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubmitPlaySingleTileVerticalExtension is the regression for the reported
|
||||
// bug: a single tile placed above an existing vertical word forms a legal play
|
||||
// the engine must accept by inferring the vertical orientation. Trusting a
|
||||
// horizontal orientation (the pre-fix client default) wrongly rejects it.
|
||||
func TestSubmitPlaySingleTileVerticalExtension(t *testing.T) {
|
||||
// БАК runs down column 7 (rows 7..9); the mover holds А and plays it at
|
||||
// (6,7), prefixing АБАК. This mirrors the contour game that surfaced the bug.
|
||||
setup := func(t *testing.T) (*Game, []TileRecord) {
|
||||
t.Helper()
|
||||
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("new erudit game: %v", err)
|
||||
}
|
||||
idx := func(s string) byte {
|
||||
i, err := g.rules.Alphabet.Index(s)
|
||||
if err != nil {
|
||||
t.Fatalf("index %q: %v", s, err)
|
||||
}
|
||||
return i
|
||||
}
|
||||
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
|
||||
{Row: 7, Col: 7, Letter: idx("б")},
|
||||
{Row: 8, Col: 7, Letter: idx("а")},
|
||||
{Row: 9, Col: 7, Letter: idx("к")},
|
||||
}})
|
||||
g.hands[0] = []byte{idx("а")}
|
||||
return g, []TileRecord{{Row: 6, Col: 7, Letter: "а"}}
|
||||
}
|
||||
|
||||
t.Run("inferred direction accepts the play", func(t *testing.T) {
|
||||
g, tiles := setup(t)
|
||||
rec, err := g.SubmitPlay(tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("submit play: %v", err)
|
||||
}
|
||||
if rec.Dir != Vertical {
|
||||
t.Errorf("direction = %v, want Vertical", rec.Dir)
|
||||
}
|
||||
if len(rec.Words) == 0 || rec.Words[0] != "абак" {
|
||||
t.Errorf("words = %v, want main word абак", rec.Words)
|
||||
}
|
||||
if rec.Score <= 0 {
|
||||
t.Errorf("score = %d, want positive", rec.Score)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trusting horizontal rejects it", func(t *testing.T) {
|
||||
g, tiles := setup(t)
|
||||
if _, err := g.SubmitPlayDir(Horizontal, tiles); !errors.Is(err, ErrIllegalPlay) {
|
||||
t.Errorf("submit horizontal = %v, want ErrIllegalPlay", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -52,11 +52,25 @@ func fromScrabbleDir(d scrabble.Direction) Direction {
|
||||
|
||||
// SubmitPlay validates and applies the current player's play described in decoded
|
||||
// terms: each TileRecord carries a concrete letter (the letter a blank stands for
|
||||
// when Blank is set) and a board coordinate. It encodes the tiles through the
|
||||
// when Blank is set) and a board coordinate. It infers the play's orientation
|
||||
// from the tiles and the board (resolveDirection), encodes the tiles through the
|
||||
// ruleset alphabet and delegates to Play, so it returns the same errors
|
||||
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
||||
// letter is outside the variant's alphabet.
|
||||
func (g *Game) SubmitPlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
||||
func (g *Game) SubmitPlay(tiles []TileRecord) (MoveRecord, error) {
|
||||
placements, err := g.placements(tiles)
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
return g.Play(resolveDirection(g.board, placements), placements)
|
||||
}
|
||||
|
||||
// SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred.
|
||||
// It exists for journal replay, which reproduces a committed game exactly from
|
||||
// the stored "H"/"V" rather than re-deriving it (docs/ARCHITECTURE.md §9.1):
|
||||
// re-derivation would tie historical reconstruction to the current resolver, so
|
||||
// replay trusts the recorded direction. Live play uses SubmitPlay.
|
||||
func (g *Game) SubmitPlayDir(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
||||
placements, err := g.placements(tiles)
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
@@ -78,10 +92,11 @@ func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
|
||||
|
||||
// EvaluatePlay scores and validates a tentative play without committing it,
|
||||
// backing the unlimited "what would my next move score, and is it legal?" tool.
|
||||
// It returns the decoded move (placed tiles, the words it forms and its score)
|
||||
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn
|
||||
// are left untouched.
|
||||
func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
||||
// It infers the play's orientation from the tiles and the board exactly as
|
||||
// SubmitPlay does, then returns the decoded move (placed tiles, the words it
|
||||
// forms, its orientation and its score) or ErrIllegalPlay when the solver
|
||||
// rejects it. The board, racks, bag and turn are left untouched.
|
||||
func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
|
||||
if g.over {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
@@ -89,7 +104,7 @@ func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, erro
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
move, err := g.solver.ValidatePlay(g.board, dir.scrabbleDir(), placements)
|
||||
move, err := g.solver.ValidatePlay(g.board, resolveDirection(g.board, placements), placements)
|
||||
if err != nil {
|
||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestSubmitPlayMatchesHint(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
||||
rec, err := g.SubmitPlay(hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("submit play: %v", err)
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
||||
boardBefore := g.BoardClone()
|
||||
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
|
||||
|
||||
rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles)
|
||||
rec, err := g.EvaluatePlay(hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate play: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
||||
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
letter := g.Hand(0)[0]
|
||||
_, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}})
|
||||
_, err := g.EvaluatePlay([]TileRecord{{Row: 0, Col: 0, Letter: letter}})
|
||||
if !errors.Is(err, ErrIllegalPlay) {
|
||||
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestResignLeadingPlayerStillLoses(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
||||
played, err := g.SubmitPlay(hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("player 0 play: %v", err)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 scores
|
||||
if _, err := g.SubmitPlay(hint.Tiles); err != nil { // player 0 scores
|
||||
t.Fatalf("player 0 play: %v", err)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestResignSeatOffTurn(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 moves
|
||||
if _, err := g.SubmitPlay(hint.Tiles); err != nil { // player 0 moves
|
||||
t.Fatalf("player 0 play: %v", err)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
@@ -165,7 +165,7 @@ func TestMultiplayerLastActiveWins(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 takes the lead
|
||||
played, err := g.SubmitPlay(hint.Tiles) // seat 0 takes the lead
|
||||
if err != nil {
|
||||
t.Fatalf("seat 0 play: %v", err)
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 leads
|
||||
played, err := g.SubmitPlay(hint.Tiles) // seat 0 leads
|
||||
if err != nil {
|
||||
t.Fatalf("seat 0 play: %v", err)
|
||||
}
|
||||
|
||||
@@ -147,10 +147,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
// and, for an exchange, the swapped tiles.
|
||||
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
|
||||
|
||||
// SubmitPlay validates, scores and commits the player's placement.
|
||||
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (MoveResult, error) {
|
||||
// SubmitPlay validates, scores and commits the player's placement. The engine
|
||||
// infers the play's orientation from the tiles and the board, so the caller
|
||||
// supplies only the placed tiles (docs/ARCHITECTURE.md §5).
|
||||
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (MoveResult, error) {
|
||||
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
|
||||
rec, err := g.SubmitPlay(dir, tiles)
|
||||
rec, err := g.SubmitPlay(tiles)
|
||||
return rec, nil, err
|
||||
})
|
||||
}
|
||||
@@ -528,7 +530,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
|
||||
|
||||
// EvaluatePlay previews a tentative play for a seated player against the current
|
||||
// board without committing it: whether it is legal and what it would score.
|
||||
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (EvalResult, error) {
|
||||
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (EvalResult, error) {
|
||||
pre, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return EvalResult{}, err
|
||||
@@ -547,7 +549,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
|
||||
return EvalResult{}, err
|
||||
}
|
||||
validateStart := time.Now()
|
||||
rec, err := g.EvaluatePlay(dir, tiles)
|
||||
rec, err := g.EvaluatePlay(tiles)
|
||||
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
|
||||
if err != nil {
|
||||
if errors.Is(err, engine.ErrIllegalPlay) {
|
||||
@@ -555,7 +557,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
|
||||
}
|
||||
return EvalResult{}, err
|
||||
}
|
||||
return EvalResult{Valid: true, Score: rec.Score, Words: rec.Words}, nil
|
||||
return EvalResult{Valid: true, Score: rec.Score, Words: rec.Words, Dir: rec.Dir.String()}, nil
|
||||
}
|
||||
|
||||
// CheckWord reports whether word is in the game's pinned dictionary. It is the
|
||||
@@ -961,7 +963,7 @@ func replayMove(g *engine.Game, mv HistoryMove) error {
|
||||
if mv.Dir == "V" {
|
||||
dir = engine.Vertical
|
||||
}
|
||||
_, err := g.SubmitPlay(dir, mv.Tiles)
|
||||
_, err := g.SubmitPlayDir(dir, mv.Tiles)
|
||||
return err
|
||||
case "pass":
|
||||
_, err := g.Pass()
|
||||
|
||||
@@ -141,11 +141,14 @@ type HintResult struct {
|
||||
HintsRemaining int
|
||||
}
|
||||
|
||||
// EvalResult previews a tentative play without committing it.
|
||||
// EvalResult previews a tentative play without committing it. Dir is the
|
||||
// orientation the engine inferred for the play ("H"/"V"), empty when the play is
|
||||
// illegal; Words lists the words it would form, the main word first.
|
||||
type EvalResult struct {
|
||||
Valid bool
|
||||
Score int
|
||||
Words []string
|
||||
Dir string
|
||||
}
|
||||
|
||||
// StateView is a player's view of a game: the shared game plus their private
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestDraftPersistAndConflictReset(t *testing.T) {
|
||||
t.Fatalf("save draft 1: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
|
||||
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Tiles); err != nil {
|
||||
t.Fatalf("seat0 play: %v", err)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestDraftSurvivesNonConflictingMove(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("save draft 1: %v", err)
|
||||
}
|
||||
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
|
||||
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Tiles); err != nil {
|
||||
t.Fatalf("seat0 play: %v", err)
|
||||
}
|
||||
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" {
|
||||
|
||||
@@ -96,11 +96,11 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
||||
for i := 0; i < 300 && !mirror.Over(); i++ {
|
||||
cur := seats[mirror.ToMove()]
|
||||
if hint, ok := mirror.HintView(); ok {
|
||||
last, err = svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles)
|
||||
last, err = svc.SubmitPlay(ctx, g.ID, cur, hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("submit play: %v", err)
|
||||
}
|
||||
if _, err := mirror.SubmitPlay(hint.Dir, hint.Tiles); err != nil {
|
||||
if _, err := mirror.SubmitPlay(hint.Tiles); err != nil {
|
||||
t.Fatalf("mirror play: %v", err)
|
||||
}
|
||||
} else {
|
||||
@@ -154,10 +154,10 @@ func TestReplayEquivalence(t *testing.T) {
|
||||
for i := 0; i < 6 && !mirror.Over(); i++ {
|
||||
cur := seats[mirror.ToMove()]
|
||||
if hint, ok := mirror.HintView(); ok {
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles); err != nil {
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, cur, hint.Tiles); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
mirror.SubmitPlay(hint.Dir, hint.Tiles)
|
||||
mirror.SubmitPlay(hint.Tiles)
|
||||
} else {
|
||||
if _, err := svc.Pass(ctx, g.ID, cur); err != nil {
|
||||
t.Fatalf("pass: %v", err)
|
||||
@@ -201,7 +201,7 @@ func TestResignWinnerAndStats(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("no opening move")
|
||||
}
|
||||
played, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles) // p0 scores
|
||||
played, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles) // p0 scores
|
||||
if err != nil {
|
||||
t.Fatalf("p0 play: %v", err)
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func TestResignOnOpponentTurn(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("no opening move")
|
||||
}
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err != nil { // p0 scores, now p1's turn
|
||||
t.Fatalf("p0 play: %v", err)
|
||||
}
|
||||
|
||||
@@ -455,19 +455,22 @@ func TestEvaluatePlayPreview(t *testing.T) {
|
||||
}
|
||||
hint, _ := newMirror(t, seed, 2).HintView()
|
||||
|
||||
eval, err := svc.EvaluatePlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles)
|
||||
eval, err := svc.EvaluatePlay(ctx, g.ID, seats[0], hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate: %v", err)
|
||||
}
|
||||
if !eval.Valid || eval.Score <= 0 {
|
||||
t.Errorf("legal preview = %+v, want valid with score", eval)
|
||||
}
|
||||
if eval.Dir != hint.Dir.String() || len(eval.Words) == 0 {
|
||||
t.Errorf("legal preview dir/words = (%q, %v), want dir %q with the words formed", eval.Dir, eval.Words, hint.Dir.String())
|
||||
}
|
||||
// The same play must still be available afterwards (no commit).
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil {
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err != nil {
|
||||
t.Fatalf("submit after evaluate: %v", err)
|
||||
}
|
||||
|
||||
bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], engine.Horizontal, []engine.TileRecord{{Row: 0, Col: 0, Letter: "q"}})
|
||||
bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], []engine.TileRecord{{Row: 0, Col: 0, Letter: "q"}})
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate illegal: %v", err)
|
||||
}
|
||||
@@ -498,7 +501,7 @@ func TestConcurrentSubmitSerialized(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err == nil {
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err == nil {
|
||||
mu.Lock()
|
||||
ok++
|
||||
mu.Unlock()
|
||||
|
||||
@@ -237,7 +237,7 @@ func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, hum
|
||||
t.Fatalf("human candidates: %v", err)
|
||||
}
|
||||
if len(cands) > 0 {
|
||||
if _, err := svc.SubmitPlay(ctx, gameID, human, cands[0].Dir, cands[0].Tiles); err != nil {
|
||||
if _, err := svc.SubmitPlay(ctx, gameID, human, cands[0].Tiles); err != nil {
|
||||
t.Fatalf("human play: %v", err)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -138,7 +138,7 @@ func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) err
|
||||
var res game.MoveResult
|
||||
switch d.kind {
|
||||
case decidePlay:
|
||||
res, err = s.games.SubmitPlay(ctx, rt.GameID, rt.RobotID, d.move.Dir, d.move.Tiles)
|
||||
res, err = s.games.SubmitPlay(ctx, rt.GameID, rt.RobotID, d.move.Tiles)
|
||||
case decideExchange:
|
||||
res, err = s.games.Exchange(ctx, rt.GameID, rt.RobotID, d.exchange)
|
||||
default:
|
||||
|
||||
@@ -42,7 +42,7 @@ type GameDriver interface {
|
||||
Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error)
|
||||
Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error)
|
||||
GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, error)
|
||||
SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (game.MoveResult, error)
|
||||
SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (game.MoveResult, error)
|
||||
Pass(ctx context.Context, gameID, accountID uuid.UUID) (game.MoveResult, error)
|
||||
Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (game.MoveResult, error)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
@@ -297,15 +295,3 @@ func chatDTOFrom(m social.Message) chatDTO {
|
||||
CreatedAtUnix: m.CreatedAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// parseDirection maps the wire direction string to an engine.Direction.
|
||||
func parseDirection(s string) (engine.Direction, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "H":
|
||||
return engine.Horizontal, true
|
||||
case "V":
|
||||
return engine.Vertical, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,30 +14,6 @@ import (
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
func TestParseDirection(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
in string
|
||||
want engine.Direction
|
||||
ok bool
|
||||
}{
|
||||
"horizontal": {"H", engine.Horizontal, true},
|
||||
"vertical": {"V", engine.Vertical, true},
|
||||
"lowercase": {"h", engine.Horizontal, true},
|
||||
"trimmed": {" V ", engine.Vertical, true},
|
||||
"invalid": {"X", 0, false},
|
||||
"empty": {"", 0, false},
|
||||
"diagonal-is-not": {"D", 0, false},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, ok := parseDirection(tc.in)
|
||||
if ok != tc.ok || (ok && got != tc.want) {
|
||||
t.Fatalf("parseDirection(%q) = (%v, %v), want (%v, %v)", tc.in, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusForError(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
err error
|
||||
|
||||
@@ -22,11 +22,14 @@ type hintResultDTO struct {
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
}
|
||||
|
||||
// evalResultDTO is an unlimited move preview: legality, score and the words formed.
|
||||
// evalResultDTO is an unlimited move preview: legality, score, the words formed
|
||||
// (main word first) and the orientation the engine inferred ("H"/"V", empty when
|
||||
// illegal).
|
||||
type evalResultDTO struct {
|
||||
Legal bool `json:"legal"`
|
||||
Score int `json:"score"`
|
||||
Words []string `json:"words"`
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
|
||||
// wordCheckDTO is the result of the unlimited dictionary lookup tool.
|
||||
@@ -186,11 +189,6 @@ func (s *Server) handleEvaluate(c *gin.Context) {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
dir, ok := parseDirection(req.Dir)
|
||||
if !ok {
|
||||
abortBadRequest(c, "dir must be H or V")
|
||||
return
|
||||
}
|
||||
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
@@ -201,12 +199,12 @@ func (s *Server) handleEvaluate(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, dir, tiles)
|
||||
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, tiles)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words})
|
||||
c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words, Dir: ev.Dir})
|
||||
}
|
||||
|
||||
// handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as
|
||||
|
||||
@@ -75,18 +75,9 @@ func TestRateLimitReportEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitPlayRejectsBadDirection(t *testing.T) {
|
||||
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
||||
path := "/api/v1/user/games/" + uuid.New().String() + "/play"
|
||||
rec := do(t, newRoutingServer(), http.MethodPost, path, `{"dir":"X","tiles":[]}`, headers)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("submit play bad dir = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitPlayRejectsBadGameID(t *testing.T) {
|
||||
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
||||
rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/user/games/not-a-uuid/play", `{"dir":"H"}`, headers)
|
||||
rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/user/games/not-a-uuid/play", `{}`, headers)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("submit play bad game id = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ func (s *Server) handleProfile(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, profileResponseFor(acc))
|
||||
}
|
||||
|
||||
// submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter
|
||||
// is a wire alphabet index; for a blank it is the designated letter's index.
|
||||
// submitPlayRequest places tiles on the player's turn; the engine infers the
|
||||
// play's orientation from the tiles and the board. Each tile's Letter is a wire
|
||||
// alphabet index; for a blank it is the designated letter's index.
|
||||
type submitPlayRequest struct {
|
||||
Dir string `json:"dir"`
|
||||
Tiles []struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
@@ -70,11 +70,6 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
dir, ok := parseDirection(req.Dir)
|
||||
if !ok {
|
||||
abortBadRequest(c, "dir must be H or V")
|
||||
return
|
||||
}
|
||||
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
@@ -85,7 +80,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, dir, tiles)
|
||||
res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, tiles)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
|
||||
@@ -262,6 +262,13 @@ Key points:
|
||||
check for resign. The engine exposes a
|
||||
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
|
||||
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
|
||||
A play's **orientation (H/V) is inferred from the placed tiles and the board**,
|
||||
not supplied by the caller: two or more tiles fix it by the line they share; a
|
||||
lone tile takes the axis along which it abuts existing tiles (the longer word
|
||||
winning, horizontal on a tie), so a single tile extending an existing word
|
||||
vertically is accepted. Journal replay instead trusts the **stored** direction
|
||||
(`SubmitPlayDir`, §9.1) to reproduce a committed game exactly rather than
|
||||
re-deriving it.
|
||||
- The **game domain** (`internal/game`) owns everything the engine does not —
|
||||
persistence, turn scheduling, the configurable turn timeout / auto-resign, the
|
||||
hint budget, word-check complaints, history and GCG — and is the engine's only
|
||||
@@ -469,7 +476,8 @@ and — in a per-move JSON payload — the acting player's rack before the move
|
||||
`?` for a blank), and for a play its direction, main-word anchor, placed tiles
|
||||
(letter as text, coordinate, blank flag) and the words formed; for an exchange,
|
||||
the swapped tiles. This is exactly what is needed both to **replay the game
|
||||
through the engine** (a cache miss) and to render history or emit GCG **without a
|
||||
through the engine** (a cache miss; replay trusts the stored direction rather than
|
||||
re-deriving it, so the rebuild matches the committed game) and to render history or emit GCG **without a
|
||||
dictionary**: the board for visual replay is reconstructed by applying placements
|
||||
onto an empty grid, since moves were validated at play time and scores are
|
||||
stored. `variant` and `dict_version` are kept as **metadata only** (audit,
|
||||
|
||||
+6
-3
@@ -92,9 +92,12 @@ settings and the game starts once every invitee has accepted — any decline can
|
||||
expires after seven days.
|
||||
|
||||
### Playing a game
|
||||
Place tiles, pass, exchange, or resign. A play is validated against the game's
|
||||
dictionary at submit time and scored; an unlimited preview reports what a
|
||||
tentative move would score and whether it is legal. The dictionary check tool is
|
||||
Place tiles, pass, exchange, or resign. Tiles are laid without choosing a
|
||||
direction — the game infers the play's orientation, so a single tile that extends
|
||||
an existing word (down a column or across a row) is accepted. A play is validated
|
||||
against the game's dictionary at submit time and scored; an unlimited preview
|
||||
reports the word(s) a tentative move would form and its score, or that it is not
|
||||
legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is
|
||||
unlimited and offers a complaint on any result. Hints are governed per game —
|
||||
whether they are allowed and how many each player starts with — and draw on a
|
||||
personal hint wallet once the per-game allowance is spent. The game ends when the
|
||||
|
||||
@@ -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
|
||||
// index.
|
||||
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
||||
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
||||
var out MoveResultResp
|
||||
body := map[string]any{"dir": dir, "tiles": tiles}
|
||||
body := map[string]any{"tiles": tiles}
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -279,11 +279,13 @@ type HintResultResp struct {
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
}
|
||||
|
||||
// EvalResultResp is an unlimited move preview.
|
||||
// EvalResultResp is an unlimited move preview. Dir is the orientation the backend
|
||||
// inferred ("H"/"V", empty when illegal); Words lists the words formed, main word first.
|
||||
type EvalResultResp struct {
|
||||
Legal bool `json:"legal"`
|
||||
Score int `json:"score"`
|
||||
Words []string `json:"words"`
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
|
||||
// WordCheckResp is a dictionary lookup outcome.
|
||||
@@ -365,10 +367,10 @@ func (c *Client) HideGame(ctx context.Context, userID, gameID string) error {
|
||||
|
||||
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
||||
// alphabet index.
|
||||
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
||||
func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
||||
var out EvalResultResp
|
||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
||||
map[string]any{"dir": dir, "tiles": tiles}, &out)
|
||||
map[string]any{"tiles": tiles}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
|
||||
@@ -219,10 +219,12 @@ func encodeHintResult(r backendclient.HintResultResp) []byte {
|
||||
func encodeEvalResult(r backendclient.EvalResultResp) []byte {
|
||||
b := flatbuffers.NewBuilder(256)
|
||||
words := buildStringVector(b, r.Words, fb.EvalResultStartWordsVector)
|
||||
dir := b.CreateString(r.Dir)
|
||||
fb.EvalResultStart(b)
|
||||
fb.EvalResultAddLegal(b, r.Legal)
|
||||
fb.EvalResultAddScore(b, int32(r.Score))
|
||||
fb.EvalResultAddWords(b, words)
|
||||
fb.EvalResultAddDir(b, dir)
|
||||
b.Finish(fb.EvalResultEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func profileHandler(backend *backendclient.Client) Handler {
|
||||
func submitPlayHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0)
|
||||
res, err := backend.SubmitPlay(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeTiles(in))
|
||||
res, err := backend.SubmitPlay(ctx, req.UserID, string(in.GameId()), decodeTiles(in))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -367,7 +367,7 @@ func hintHandler(backend *backendclient.Client) Handler {
|
||||
func evaluateHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsEvalRequest(req.Payload, 0)
|
||||
res, err := backend.Evaluate(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeEvalTiles(in))
|
||||
res, err := backend.Evaluate(ctx, req.UserID, string(in.GameId()), decodeEvalTiles(in))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
|
||||
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
gid := b.CreateString("g-5")
|
||||
dir := b.CreateString("H")
|
||||
fb.PlayTileStart(b)
|
||||
fb.PlayTileAddRow(b, 7)
|
||||
fb.PlayTileAddCol(b, 7)
|
||||
@@ -122,7 +121,6 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
|
||||
tiles := b.EndVector(1)
|
||||
fb.SubmitPlayRequestStart(b)
|
||||
fb.SubmitPlayRequestAddGameId(b, gid)
|
||||
fb.SubmitPlayRequestAddDir(b, dir)
|
||||
fb.SubmitPlayRequestAddTiles(b, tiles)
|
||||
b.Finish(fb.SubmitPlayRequestEnd(b))
|
||||
|
||||
|
||||
@@ -37,12 +37,11 @@ func stateReq(gameID string, includeAlphabet bool) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// submitPlay builds a SubmitPlayRequest payload. dir is "H" or "V"; tiles are the
|
||||
// newly-placed tiles in main-word order.
|
||||
func submitPlay(gameID, dir string, tiles []PlayTile) []byte {
|
||||
// submitPlay builds a SubmitPlayRequest payload. tiles are the newly-placed tiles in
|
||||
// main-word order; the backend infers the play's orientation from them and the board.
|
||||
func submitPlay(gameID string, tiles []PlayTile) []byte {
|
||||
b := flatbuffers.NewBuilder(256)
|
||||
gid := b.CreateString(gameID)
|
||||
d := b.CreateString(dir)
|
||||
offs := make([]flatbuffers.UOffsetT, len(tiles))
|
||||
for i, t := range tiles {
|
||||
fb.PlayTileStart(b)
|
||||
@@ -59,7 +58,6 @@ func submitPlay(gameID, dir string, tiles []PlayTile) []byte {
|
||||
tilesVec := b.EndVector(len(offs))
|
||||
fb.SubmitPlayRequestStart(b)
|
||||
fb.SubmitPlayRequestAddGameId(b, gid)
|
||||
fb.SubmitPlayRequestAddDir(b, d)
|
||||
fb.SubmitPlayRequestAddTiles(b, tilesVec)
|
||||
b.Finish(fb.SubmitPlayRequestEnd(b))
|
||||
return b.FinishedBytes()
|
||||
|
||||
@@ -27,8 +27,8 @@ func (c *Client) History(ctx context.Context, token, gameID string) ([]Move, str
|
||||
}
|
||||
|
||||
// SubmitPlay commits a play and returns the post-move game.
|
||||
func (c *Client) SubmitPlay(ctx context.Context, token, gameID, dir string, tiles []PlayTile) (Game, string, error) {
|
||||
r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, dir, tiles))
|
||||
func (c *Client) SubmitPlay(ctx context.Context, token, gameID string, tiles []PlayTile) (Game, string, error) {
|
||||
r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, tiles))
|
||||
if err != nil || r.Code != "ok" {
|
||||
return Game{}, r.Code, err
|
||||
}
|
||||
|
||||
@@ -78,11 +78,10 @@ func (r *Registry) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Dir
|
||||
// ("H"/"V") and Tiles; an exchange carries Exchange (rack indices to swap).
|
||||
// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Tiles
|
||||
// (the backend infers the orientation); an exchange carries Exchange (rack indices to swap).
|
||||
type Action struct {
|
||||
Kind string
|
||||
Dir string
|
||||
Tiles []edge.PlayTile
|
||||
Exchange []byte
|
||||
}
|
||||
@@ -106,7 +105,7 @@ func (r *Registry) Pick(variant string, history []edge.Move, rackIdx []byte, bag
|
||||
return noPlay(rackIdx, bagLen >= e.rs.RackSize), nil
|
||||
}
|
||||
m := midRanked(legal, rng)
|
||||
return Action{Kind: "play", Dir: dirString(m.Dir), Tiles: toPlayTiles(m.Tiles)}, nil
|
||||
return Action{Kind: "play", Tiles: toPlayTiles(m.Tiles)}, nil
|
||||
}
|
||||
|
||||
// toPlayTiles maps the solver's newly-placed tiles to the edge submit-play tiles
|
||||
@@ -176,11 +175,3 @@ func noPlay(rackIdx []byte, canExchange bool) Action {
|
||||
}
|
||||
return Action{Kind: "pass"}
|
||||
}
|
||||
|
||||
// dirString renders a solver direction as the "H"/"V" the edge submit-play expects.
|
||||
func dirString(d scrabble.Direction) string {
|
||||
if d == scrabble.Vertical {
|
||||
return "V"
|
||||
}
|
||||
return "H"
|
||||
}
|
||||
|
||||
@@ -146,9 +146,6 @@ func TestPickWithDawg(t *testing.T) {
|
||||
if len(act.Tiles) == 0 {
|
||||
t.Error("play action has no tiles")
|
||||
}
|
||||
if act.Dir != "H" && act.Dir != "V" {
|
||||
t.Errorf("dir = %q, want H or V", act.Dir)
|
||||
}
|
||||
case "exchange", "pass":
|
||||
// acceptable when the rack has no legal first move
|
||||
default:
|
||||
|
||||
@@ -197,7 +197,7 @@ func (d *Driver) playTurn(ctx context.Context, c *edge.Client, p seed.Account, g
|
||||
switch action.Kind {
|
||||
case "play":
|
||||
t0 = time.Now()
|
||||
_, code, _ := c.SubmitPlay(ctx, p.Token, g.ID, action.Dir, action.Tiles)
|
||||
_, code, _ := c.SubmitPlay(ctx, p.Token, g.ID, action.Tiles)
|
||||
d.rec.Record("game.submit_play", code, time.Since(t0))
|
||||
case "exchange":
|
||||
t0 = time.Now()
|
||||
|
||||
@@ -151,11 +151,10 @@ table Profile {
|
||||
|
||||
// --- game (authenticated) ---
|
||||
|
||||
// SubmitPlayRequest places tiles in a direction on the player's turn. tiles are addressed
|
||||
// by alphabet index.
|
||||
// SubmitPlayRequest places tiles on the player's turn; the backend infers the play's
|
||||
// orientation from the tiles and the board. tiles are addressed by alphabet index.
|
||||
table SubmitPlayRequest {
|
||||
game_id:string;
|
||||
dir:string;
|
||||
tiles:[PlayTile];
|
||||
}
|
||||
|
||||
@@ -205,18 +204,19 @@ table ExchangeRequest {
|
||||
}
|
||||
|
||||
// EvalRequest previews a tentative play without committing it. tiles are addressed by
|
||||
// alphabet index.
|
||||
// alphabet index; the backend infers the play's orientation from the tiles and the board.
|
||||
table EvalRequest {
|
||||
game_id:string;
|
||||
dir:string;
|
||||
tiles:[PlayTile];
|
||||
}
|
||||
|
||||
// EvalResult is an unlimited move preview: legality, score and the words formed.
|
||||
// EvalResult is an unlimited move preview: legality, score, the words formed (main word
|
||||
// first) and the orientation the backend inferred (dir is "H"/"V", empty when illegal).
|
||||
table EvalResult {
|
||||
legal:bool;
|
||||
score:int;
|
||||
words:[string];
|
||||
dir:string;
|
||||
}
|
||||
|
||||
// CheckWordRequest looks a word up in the game's pinned dictionary. word is a sequence of
|
||||
|
||||
@@ -49,16 +49,8 @@ func (rcv *EvalRequest) GameId() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *EvalRequest) Dir() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *EvalRequest) Tiles(obj *PlayTile, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
x += flatbuffers.UOffsetT(j) * 4
|
||||
@@ -70,7 +62,7 @@ func (rcv *EvalRequest) Tiles(obj *PlayTile, j int) bool {
|
||||
}
|
||||
|
||||
func (rcv *EvalRequest) TilesLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
@@ -78,16 +70,13 @@ func (rcv *EvalRequest) TilesLength() int {
|
||||
}
|
||||
|
||||
func EvalRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(3)
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func EvalRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||
}
|
||||
func EvalRequestAddDir(builder *flatbuffers.Builder, dir flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(dir), 0)
|
||||
}
|
||||
func EvalRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(tiles), 0)
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0)
|
||||
}
|
||||
func EvalRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
|
||||
@@ -82,8 +82,16 @@ func (rcv *EvalResult) WordsLength() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *EvalResult) Dir() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EvalResultStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(3)
|
||||
builder.StartObject(4)
|
||||
}
|
||||
func EvalResultAddLegal(builder *flatbuffers.Builder, legal bool) {
|
||||
builder.PrependBoolSlot(0, legal, false)
|
||||
@@ -97,6 +105,9 @@ func EvalResultAddWords(builder *flatbuffers.Builder, words flatbuffers.UOffsetT
|
||||
func EvalResultStartWordsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func EvalResultAddDir(builder *flatbuffers.Builder, dir flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(dir), 0)
|
||||
}
|
||||
func EvalResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -49,16 +49,8 @@ func (rcv *SubmitPlayRequest) GameId() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *SubmitPlayRequest) Dir() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *SubmitPlayRequest) Tiles(obj *PlayTile, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
x += flatbuffers.UOffsetT(j) * 4
|
||||
@@ -70,7 +62,7 @@ func (rcv *SubmitPlayRequest) Tiles(obj *PlayTile, j int) bool {
|
||||
}
|
||||
|
||||
func (rcv *SubmitPlayRequest) TilesLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
@@ -78,16 +70,13 @@ func (rcv *SubmitPlayRequest) TilesLength() int {
|
||||
}
|
||||
|
||||
func SubmitPlayRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(3)
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func SubmitPlayRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||
}
|
||||
func SubmitPlayRequestAddDir(builder *flatbuffers.Builder, dir flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(dir), 0)
|
||||
}
|
||||
func SubmitPlayRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(tiles), 0)
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0)
|
||||
}
|
||||
func SubmitPlayRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
|
||||
@@ -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 { GatewayError } from '../lib/client';
|
||||
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 { historyGrid } from '../lib/history';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
@@ -42,7 +42,6 @@
|
||||
let moves = $state<MoveRecord[]>([]);
|
||||
let placement = $state<Placement>(newPlacement([]));
|
||||
let preview = $state<EvalResult | null>(null);
|
||||
let dirOverride = $state<Direction | undefined>(undefined);
|
||||
let busy = $state(false);
|
||||
let zoomed = $state(false);
|
||||
let selected = $state<number | null>(null);
|
||||
@@ -130,7 +129,6 @@
|
||||
moves = hist.moves;
|
||||
setCachedGame(id, st, hist.moves);
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
await applyDraft(st);
|
||||
recompute();
|
||||
refreshRecent();
|
||||
@@ -491,11 +489,11 @@
|
||||
if (previewTimer) clearTimeout(previewTimer);
|
||||
// Off-turn the composition is position-only: no score preview or evaluate.
|
||||
if (!isMyTurn) return;
|
||||
const sub = toSubmit(placement, dirOverride);
|
||||
const sub = toSubmit(placement);
|
||||
if (!sub) return;
|
||||
previewTimer = setTimeout(async () => {
|
||||
try {
|
||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
|
||||
preview = await gateway.evaluate(id, sub.tiles, variant);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
@@ -511,17 +509,16 @@
|
||||
rackIds = r.rack.map((_, i) => i);
|
||||
placement = newPlacement(r.rack);
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
recompute();
|
||||
refreshRecent();
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const sub = toSubmit(placement, dirOverride);
|
||||
const sub = toSubmit(placement);
|
||||
if (!sub) return;
|
||||
busy = true;
|
||||
try {
|
||||
applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant));
|
||||
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
|
||||
telegramHaptic('success');
|
||||
zoomed = false;
|
||||
} catch (e) {
|
||||
@@ -534,7 +531,6 @@
|
||||
placement = reset(placement);
|
||||
preview = null;
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
scheduleDraftSave();
|
||||
}
|
||||
|
||||
@@ -857,11 +853,11 @@
|
||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||
{#if gameOver}
|
||||
<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>
|
||||
{/if}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -880,7 +876,7 @@
|
||||
/>
|
||||
</div>
|
||||
{#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}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -30,37 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static startEvalRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
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) {
|
||||
builder.addFieldOffset(2, tilesOffset, 0);
|
||||
builder.addFieldOffset(1, tilesOffset, 0);
|
||||
}
|
||||
|
||||
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
@@ -80,10 +69,9 @@ static endEvalRequest(builder:flatbuffers.Builder):flatbuffers.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.addGameId(builder, gameIdOffset);
|
||||
EvalRequest.addDir(builder, dirOffset);
|
||||
EvalRequest.addTiles(builder, tilesOffset);
|
||||
return EvalRequest.endEvalRequest(builder);
|
||||
}
|
||||
|
||||
@@ -42,8 +42,15 @@ wordsLength():number {
|
||||
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) {
|
||||
builder.startObject(3);
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addLegal(builder:flatbuffers.Builder, legal:boolean) {
|
||||
@@ -70,16 +77,21 @@ static startWordsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
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 {
|
||||
const offset = builder.endObject();
|
||||
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.addLegal(builder, legal);
|
||||
EvalResult.addScore(builder, score);
|
||||
EvalResult.addWords(builder, wordsOffset);
|
||||
EvalResult.addDir(builder, dirOffset);
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static startSubmitPlayRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
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) {
|
||||
builder.addFieldOffset(2, tilesOffset, 0);
|
||||
builder.addFieldOffset(1, tilesOffset, 0);
|
||||
}
|
||||
|
||||
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
@@ -80,10 +69,9 @@ static endSubmitPlayRequest(builder:flatbuffers.Builder):flatbuffers.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.addGameId(builder, gameIdOffset);
|
||||
SubmitPlayRequest.addDir(builder, dirOffset);
|
||||
SubmitPlayRequest.addTiles(builder, tilesOffset);
|
||||
return SubmitPlayRequest.endSubmitPlayRequest(builder);
|
||||
}
|
||||
|
||||
@@ -74,12 +74,12 @@ export interface GatewayClient {
|
||||
// table), and gameState's includeAlphabet asks the server to embed that table.
|
||||
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
|
||||
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>;
|
||||
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
|
||||
resign(gameId: string): Promise<MoveResult>;
|
||||
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>;
|
||||
complaint(gameId: string, word: string, note: string): Promise<void>;
|
||||
/** 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.
|
||||
const buf = encodeSubmitPlay(
|
||||
'g1',
|
||||
'H',
|
||||
[
|
||||
{ row: 7, col: 7, letter: 'A', blank: false },
|
||||
{ row: 7, col: 8, letter: 'B', blank: true },
|
||||
@@ -52,7 +51,6 @@ describe('codec', () => {
|
||||
);
|
||||
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
|
||||
expect(r.gameId()).toBe('g1');
|
||||
expect(r.dir()).toBe('H');
|
||||
expect(r.tilesLength()).toBe(2);
|
||||
expect(r.tiles(0)?.letter()).toBe(0);
|
||||
expect(r.tiles(1)?.letter()).toBe(1);
|
||||
|
||||
+2
-12
@@ -87,7 +87,6 @@ export function encodeDraftSave(gameId: string, json: string): Uint8Array {
|
||||
|
||||
export function encodeSubmitPlay(
|
||||
gameId: string,
|
||||
dir: 'H' | 'V',
|
||||
tiles: PlacedTile[],
|
||||
variant: Variant,
|
||||
): Uint8Array {
|
||||
@@ -95,28 +94,19 @@ export function encodeSubmitPlay(
|
||||
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
||||
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
|
||||
const gid = b.createString(gameId);
|
||||
const d = b.createString(dir);
|
||||
fb.SubmitPlayRequest.startSubmitPlayRequest(b);
|
||||
fb.SubmitPlayRequest.addGameId(b, gid);
|
||||
fb.SubmitPlayRequest.addDir(b, d);
|
||||
fb.SubmitPlayRequest.addTiles(b, vec);
|
||||
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEval(
|
||||
gameId: string,
|
||||
dir: 'H' | 'V',
|
||||
tiles: PlacedTile[],
|
||||
variant: Variant,
|
||||
): Uint8Array {
|
||||
export function encodeEval(gameId: string, tiles: PlacedTile[], variant: Variant): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
||||
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
|
||||
const gid = b.createString(gameId);
|
||||
const d = b.createString(dir);
|
||||
fb.EvalRequest.startEvalRequest(b);
|
||||
fb.EvalRequest.addGameId(b, gid);
|
||||
fb.EvalRequest.addDir(b, d);
|
||||
fb.EvalRequest.addTiles(b, vec);
|
||||
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 words: string[] = [];
|
||||
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 {
|
||||
|
||||
@@ -67,7 +67,7 @@ export const en = {
|
||||
'game.checkWord': 'Check word',
|
||||
'game.dictionary': 'Dictionary',
|
||||
'game.dropGame': 'Drop game',
|
||||
'game.preview': 'Scores {n}',
|
||||
'game.previewWords': '{words}: {n}',
|
||||
'game.previewIllegal': 'Not a legal move',
|
||||
'game.chooseBlank': 'Choose a letter for the blank',
|
||||
'game.exchangeTitle': 'Select tiles to exchange',
|
||||
@@ -86,7 +86,6 @@ export const en = {
|
||||
'game.check': 'Check',
|
||||
'game.checkWait': 'Please wait a moment.',
|
||||
'game.noHintOptions': 'No options with your letters.',
|
||||
'game.scores': 'Scores: {n}',
|
||||
'game.thinking': 'thinking…',
|
||||
|
||||
'move.pass': 'pass',
|
||||
|
||||
@@ -68,7 +68,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.checkWord': 'Проверить слово',
|
||||
'game.dictionary': 'Словарь',
|
||||
'game.dropGame': 'Покинуть игру',
|
||||
'game.preview': 'Очков: {n}',
|
||||
'game.previewWords': '{words}: {n}',
|
||||
'game.previewIllegal': 'Недопустимый ход',
|
||||
'game.chooseBlank': 'Выберите букву для бланка',
|
||||
'game.exchangeTitle': 'Выберите фишки для обмена',
|
||||
@@ -87,7 +87,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.check': 'Проверить',
|
||||
'game.checkWait': 'Секунду, пожалуйста.',
|
||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||
'game.scores': 'Очков: {n}',
|
||||
'game.thinking': 'думает…',
|
||||
|
||||
'move.pass': 'пас',
|
||||
|
||||
@@ -205,7 +205,7 @@ export class MockGateway implements GatewayClient {
|
||||
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 seat = this.mySeat(g);
|
||||
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);
|
||||
if (tiles.length === 7) score += 50;
|
||||
const total = g.view.seats[seat].score + score;
|
||||
const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
|
||||
const move = {
|
||||
player: seat,
|
||||
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);
|
||||
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);
|
||||
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> {
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface EvalResult {
|
||||
legal: boolean;
|
||||
score: number;
|
||||
words: string[];
|
||||
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
|
||||
dir: string;
|
||||
}
|
||||
|
||||
export interface WordCheckResult {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BLANK,
|
||||
cellOccupied,
|
||||
direction,
|
||||
isBlankSlot,
|
||||
newPlacement,
|
||||
place,
|
||||
@@ -47,23 +46,11 @@ describe('placement state machine', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
it('builds a sorted submit payload and returns null when empty', () => {
|
||||
let p = place(newPlacement(rack), 1, 7, 9);
|
||||
p = place(p, 0, 7, 7);
|
||||
const sub = toSubmit(p);
|
||||
expect(sub?.dir).toBe('H');
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -78,15 +65,8 @@ describe('placement state machine', () => {
|
||||
expect(isBlankSlot(newPlacement(rack), 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a non-linear placement as no inferred direction', () => {
|
||||
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', () => {
|
||||
it('submits a single tile as a one-tile payload', () => {
|
||||
const sub = toSubmit(place(newPlacement(rack), 0, 7, 7));
|
||||
expect(sub?.dir).toBe('H');
|
||||
expect(sub?.tiles).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-22
@@ -4,7 +4,7 @@
|
||||
// payload. It is board-agnostic (the gateway/engine does full legality validation at
|
||||
// submit), which keeps it trivially unit-testable.
|
||||
|
||||
import type { Direction, Tile } from './model';
|
||||
import type { Tile } from './model';
|
||||
import type { PlacedTile } from './client';
|
||||
|
||||
export interface PendingTile {
|
||||
@@ -119,30 +119,13 @@ export function reorderIndices(n: number, from: number, toSlot: number): number[
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* direction infers the play orientation from the pending tiles: H if they share a row,
|
||||
* V if they share a column, null if a single tile (ambiguous) or non-linear (invalid).
|
||||
*/
|
||||
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 {
|
||||
/** toSubmit builds the submit payload: the placed tiles in board order. The backend
|
||||
* infers the play's orientation from the tiles and the board, so none is sent. */
|
||||
export function toSubmit(p: Placement): { tiles: PlacedTile[] } | null {
|
||||
if (p.pending.length === 0) return null;
|
||||
const dir = dirOverride ?? direction(p) ?? 'H';
|
||||
const tiles: PlacedTile[] = p.pending
|
||||
.slice()
|
||||
.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 }));
|
||||
return { dir, tiles };
|
||||
return { tiles };
|
||||
}
|
||||
|
||||
@@ -97,8 +97,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async gameHistory(id) {
|
||||
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
|
||||
},
|
||||
async submitPlay(id, dir, tiles, variant) {
|
||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant)));
|
||||
async submitPlay(id, tiles, variant) {
|
||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, tiles, variant)));
|
||||
},
|
||||
async pass(id) {
|
||||
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
|
||||
@@ -112,8 +112,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async hint(id) {
|
||||
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
|
||||
},
|
||||
async evaluate(id, dir, tiles, variant) {
|
||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant)));
|
||||
async evaluate(id, tiles, variant) {
|
||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant)));
|
||||
},
|
||||
async checkWord(id, word, variant) {
|
||||
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
|
||||
|
||||
Reference in New Issue
Block a user