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

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:
Ilia Denisov
2026-06-11 22:42:33 +02:00
parent feee3d6511
commit 92f48a3b12
49 changed files with 419 additions and 401 deletions
+1 -1
View File
@@ -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
+63
View File
@@ -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
}
+159
View File
@@ -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)
}
})
}
+22 -7
View File
@@ -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)
}
+3 -3
View File
@@ -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)
}
+5 -5
View File
@@ -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)
}
+9 -7
View File
@@ -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()
+4 -1
View File
@@ -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
+2 -2
View File
@@ -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" {
+13 -10
View File
@@ -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()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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)
}
-14
View File
@@ -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
}
}
-24
View File
@@ -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
+6 -8
View File
@@ -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
+1 -10
View File
@@ -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)
}
+4 -9
View File
@@ -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
+9 -1
View File
@@ -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
View File
@@ -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
+6 -3
View File
@@ -96,9 +96,12 @@ nudge) приходят от бота **этой партии** — по язы
ответа приглашение протухает через семь дней.
### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы
предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и
Выкладывание фишек, пас, обмен или сдача. Фишки кладутся без выбора направления —
игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая уже лежащее
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
+7 -5
View File
@@ -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
}
+2
View File
@@ -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()
}
+2 -2
View File
@@ -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))
+3 -5
View File
@@ -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()
+2 -2
View File
@@ -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
}
+3 -12
View File
@@ -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"
}
-3
View File
@@ -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:
+1 -1
View File
@@ -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()
+6 -6
View File
@@ -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
+4 -15
View File
@@ -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)
+12 -1
View File
@@ -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()
}
+4 -15
View File
@@ -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)
-101
View File
@@ -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
View File
@@ -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}
+5 -17
View File
@@ -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);
}
+14 -2
View File
@@ -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);
}
+2 -2
View File
@@ -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. */
-2
View File
@@ -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
View File
@@ -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 {
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -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': 'пас',
+6 -4
View File
@@ -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> {
+2
View File
@@ -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 -22
View File
@@ -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
View File
@@ -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 };
}
+4 -4
View File
@@ -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)));