fix(engine): single-word rule connects along the play line #57

Merged
developer merged 2 commits from feature/single-word-connectivity into development 2026-06-14 15:02:32 +00:00
20 changed files with 493 additions and 18 deletions
+2
View File
@@ -26,6 +26,8 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| R7 | Final stress run + tuning | 9b | **done** | | R7 | Final stress run + tuning | 9b | **done** |
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | | UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** | | MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
| MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** |
| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** |
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** | | OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** |
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
+1 -1
View File
@@ -3,7 +3,7 @@ module scrabble/backend
go 1.26.3 go 1.26.3
require ( require (
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1
github.com/XSAM/otelsql v0.42.0 github.com/XSAM/otelsql v0.42.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-jet/jet/v2 v2.14.1 github.com/go-jet/jet/v2 v2.14.1
+31
View File
@@ -0,0 +1,31 @@
package engine
import "testing"
// TestAbortFinishesAsDrawWithoutAdjustment covers Abort, the graceful close used when a
// committed game can no longer be reconstructed from its journal. The game ends as a draw
// (no winner) regardless of the running scores, and the scores are left untouched (no
// end-game rack adjustment).
func TestAbortFinishesAsDrawWithoutAdjustment(t *testing.T) {
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 2, Seed: 1})
if err != nil {
t.Fatalf("new game: %v", err)
}
g.scores[0], g.scores[1] = 10, 5 // a clear leader, so a draw cannot come from equal scores
g.Abort()
if !g.Over() {
t.Error("aborted game should be over")
}
if g.Reason() != EndAborted {
t.Errorf("reason = %v, want EndAborted", g.Reason())
}
res := g.Result()
if res.Winner != -1 {
t.Errorf("winner = %d, want -1 (draw)", res.Winner)
}
if res.Scores[0] != 10 || res.Scores[1] != 5 {
t.Errorf("scores = %v, want [10 5] (no rack adjustment on abort)", res.Scores)
}
}
+29 -2
View File
@@ -62,7 +62,7 @@ func (g *Game) SubmitPlay(tiles []TileRecord) (MoveRecord, error) {
if err != nil { if err != nil {
return MoveRecord{}, err return MoveRecord{}, err
} }
return g.Play(resolveDirection(g.board, placements), placements) return g.Play(g.playDirection(placements), placements)
} }
// SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred. // SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred.
@@ -106,7 +106,7 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
if err != nil { if err != nil {
return MoveRecord{}, err return MoveRecord{}, err
} }
move, err := g.solver.ValidatePlayOpts(g.board, resolveDirection(g.board, placements), placements, g.playOpts()) move, err := g.solver.ValidatePlayOpts(g.board, g.playDirection(placements), placements, g.playOpts())
if err != nil { if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
} }
@@ -169,6 +169,33 @@ func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
return out, nil return out, nil
} }
// playDirection resolves the orientation for a live play. resolveDirection infers it from
// geometry alone, preferring the longer word when a single tile abuts the board on both
// axes; under the single-word rule that can pick an orientation whose word is not in the
// dictionary while the other orientation's is. So for a single tile under that rule the
// engine tries both orientations through the solver and keeps the higher-scoring legal one
// (horizontal breaks a tie). Multi-tile plays, and every play under the standard rule, keep
// the geometric resolution: a multi-tile play's orientation is fixed by the line its tiles
// share, and under the standard rule every word the play forms must be valid regardless of
// which one is named the main word.
func (g *Game) playDirection(placements []scrabble.Placement) scrabble.Direction {
geo := resolveDirection(g.board, placements)
if len(placements) != 1 || g.multipleWords {
return geo
}
best, found, bestScore := geo, false, 0
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
m, err := g.solver.ValidatePlayOpts(g.board, dir, placements, g.playOpts())
if err != nil {
continue
}
if !found || m.Score > bestScore {
best, found, bestScore = dir, true, m.Score
}
}
return best
}
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a // encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
// concrete letter) into the internal byte form, wrapping a bad letter as // concrete letter) into the internal byte form, wrapping a bad letter as
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name). // ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
+20
View File
@@ -25,6 +25,10 @@ const (
EndScoreless EndScoreless
// EndResign fires when a player resigns. // EndResign fires when a player resigns.
EndResign EndResign
// EndAborted fires when a committed game can no longer be reconstructed from its
// journal — a recorded move became illegal under tightened rules — and is closed as a
// draw rather than left unopenable. See (*Game).Abort.
EndAborted
) )
// String renders the end reason for logs and diagnostics. // String renders the end reason for logs and diagnostics.
@@ -38,6 +42,8 @@ func (r EndReason) String() string {
return "scoreless" return "scoreless"
case EndResign: case EndResign:
return "resign" return "resign"
case EndAborted:
return "aborted"
} }
return "unknown" return "unknown"
} }
@@ -370,6 +376,17 @@ func (g *Game) finish(reason EndReason) {
g.applyEndAdjustment(reason) g.applyEndAdjustment(reason)
} }
// Abort closes a still-running game as a draw with EndAborted and no rack adjustment. The
// service calls it when a committed game can no longer be reconstructed from its journal —
// a recorded move became illegal under tightened rules — so the game ends gracefully
// instead of being left unopenable. It is a no-op on an already-finished game.
func (g *Game) Abort() {
if g.over {
return
}
g.finish(EndAborted)
}
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag // applyEndAdjustment settles the unplayed racks. When a player goes out (bag
// empty, rack empty) they gain the sum of every opponent's rack value and each // empty, rack empty) they gain the sum of every opponent's rack value and each
// opponent loses their own. A scoreless stalemate forfeits each player's own // opponent loses their own. A scoreless stalemate forfeits each player's own
@@ -453,6 +470,9 @@ func (g *Game) winner() int {
if !g.over { if !g.over {
return -1 return -1
} }
if g.reason == EndAborted {
return -1 // an aborted game is a draw regardless of the running scores
}
best, tie := -1, false best, tie := -1, false
for i := range g.scores { for i := range g.scores {
if g.resigned[i] { if g.resigned[i] {
+147 -6
View File
@@ -33,9 +33,12 @@ func TestSingleWordRuleWiring(t *testing.T) {
t.Error("single-word game must ignore cross-words") t.Error("single-word game must ignore cross-words")
} }
// Play the same opening (the standard game's top move) in both games, then compare // Play the same opening (the standard game's top move) in both games. Both share the
// the next player's candidate moves. Both games share the seed, so the next rack is // seed, so the next rack is identical and both still have legal replies. The single-word
// identical; relaxed (single-word) generation never drops a legal standard move. // rule is not a superset of the standard one — it forbids parallel plays the standard
// rule allows and admits in-line plays whose cross-words are invalid — so here the two
// move sets only need to be non-empty; their rule-specific differences are covered by the
// cross-word and connectivity tests.
hint, ok := std.HintView() hint, ok := std.HintView()
if !ok { if !ok {
t.Fatal("opening game has no hint") t.Fatal("opening game has no hint")
@@ -46,9 +49,8 @@ func TestSingleWordRuleWiring(t *testing.T) {
if _, err := single.SubmitPlay(hint.Tiles); err != nil { if _, err := single.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("single-word opening: %v", err) t.Fatalf("single-word opening: %v", err)
} }
stdMoves, singleMoves := len(std.GenerateMoves()), len(single.GenerateMoves()) if len(std.GenerateMoves()) == 0 || len(single.GenerateMoves()) == 0 {
if singleMoves < stdMoves { t.Error("both games should have legal replies after the opening")
t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves)
} }
} }
@@ -137,6 +139,145 @@ func TestEvaluatePlayHonorsSingleWordRule(t *testing.T) {
}) })
} }
// TestSingleWordRuleSingleTileDirection covers the single-tile half of the single-word
// rule: when a lone tile abuts the board on both axes, the engine picks the orientation that
// forms a real word, not the geometrically longer one. The lone 'о' spells the non-word
// "фоф" across (length 3) but the real word "до" down (length 2); the geometric resolver
// prefers the longer "фоф", so before the fix the play was wrongly rejected.
func TestSingleWordRuleSingleTileDirection(t *testing.T) {
if ok, err := testReg.Lookup(VariantErudit, testVersion, "до"); err != nil || !ok {
t.Fatalf("precondition: \"до\" must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
}
if ok, _ := testReg.Lookup(VariantErudit, testVersion, "фоф"); ok {
t.Fatal("precondition: \"фоф\" must not be a word")
}
g, err := New(testReg, Options{
Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1, MultipleWordsPerTurn: false,
})
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: 4, Col: 8, Letter: idx("д")}, // above 'о': the vertical word "до"
{Row: 5, Col: 7, Letter: idx("ф")}, // left of 'о': the across non-word "фоф"
{Row: 5, Col: 9, Letter: idx("ф")}, // right of 'о'
}})
g.hands[0] = []byte{idx("о")}
tiles := []TileRecord{{Row: 5, Col: 8, Letter: "о"}}
rec, err := g.EvaluatePlay(tiles)
if err != nil {
t.Fatalf("evaluate the single tile under the single-word rule: %v", err)
}
if rec.Dir != Vertical {
t.Errorf("dir = %v, want Vertical (the real word \"до\")", rec.Dir)
}
if len(rec.Words) != 1 || rec.Words[0] != "до" {
t.Errorf("words = %v, want [до]", rec.Words)
}
if _, err := g.SubmitPlay(tiles); err != nil {
t.Errorf("submit the single tile under the single-word rule: %v", err)
}
}
// TestSingleWordRuleSingleTileBestScore covers the rest of rule (2): when a single tile
// forms a real word on BOTH axes, the engine keeps the higher-scoring orientation (and
// horizontal on a tie), overriding the geometric resolver's tie preference. The lone 'с'
// spells "ас" across and "юс" down; the engine must pick whichever scores more.
func TestSingleWordRuleSingleTileBestScore(t *testing.T) {
for _, w := range []string{"ас", "юс"} {
if ok, err := testReg.Lookup(VariantErudit, testVersion, w); err != nil || !ok {
t.Fatalf("precondition: %q must be in the Erudit dictionary (ok=%v, err=%v)", w, ok, err)
}
}
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: 5, Col: 7, Letter: idx("а")}, // left of 'с': the across word "ас"
{Row: 4, Col: 8, Letter: idx("ю")}, // above 'с': the down word "юс"
}})
g.hands[0] = []byte{idx("с")}
tiles := []TileRecord{{Row: 5, Col: 8, Letter: "с"}}
ps := []scrabble.Placement{{Row: 5, Col: 8, Letter: idx("с")}}
across, aerr := g.solver.ValidatePlayOpts(g.board, scrabble.Horizontal, ps, g.playOpts())
down, derr := g.solver.ValidatePlayOpts(g.board, scrabble.Vertical, ps, g.playOpts())
if aerr != nil || derr != nil {
t.Fatalf("both orientations should be legal: across=%v down=%v", aerr, derr)
}
wantDir, wantScore := Horizontal, across.Score
if down.Score > across.Score {
wantDir, wantScore = Vertical, down.Score
}
rec, err := g.EvaluatePlay(tiles)
if err != nil {
t.Fatalf("evaluate the single tile: %v", err)
}
if rec.Dir != wantDir {
t.Errorf("dir = %v, want %v (higher of \"ас\"=%d, \"юс\"=%d)", rec.Dir, wantDir, across.Score, down.Score)
}
if rec.Score != wantScore {
t.Errorf("score = %d, want %d", rec.Score, wantScore)
}
}
// TestSingleWordRuleRejectsPerpendicularOnlyContour is the backend regression for the
// reported contour bug: a multi-tile play whose main word is a real word but which touches
// the board only perpendicular to its own line — forming a cross-word, not a word along that
// line — does not connect under the single-word rule and is rejected by both the preview and
// the submit path. Existing "до" sits down column 8; "кот" laid across row 6 is all-new along
// its row and touches the board only through the 'о' below the existing 'о'.
func TestSingleWordRuleRejectsPerpendicularOnlyContour(t *testing.T) {
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кот"); err != nil || !ok {
t.Fatalf("precondition: \"кот\" must be a real word (ok=%v, err=%v)", ok, err)
}
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: 4, Col: 8, Letter: idx("д")},
{Row: 5, Col: 8, Letter: idx("о")},
}})
g.hands[0] = []byte{idx("к"), idx("о"), idx("т")}
tiles := []TileRecord{
{Row: 6, Col: 7, Letter: "к"},
{Row: 6, Col: 8, Letter: "о"},
{Row: 6, Col: 9, Letter: "т"},
}
if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("EvaluatePlay = %v, want ErrIllegalPlay (connects only perpendicular)", err)
}
if _, err := g.SubmitPlay(tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("SubmitPlay = %v, want ErrIllegalPlay (connects only perpendicular)", err)
}
}
// TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same // TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same
// cross-word check while searching for its move: its move source, Candidates -> // cross-word check while searching for its move: its move source, Candidates ->
// GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged // GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged
+5
View File
@@ -36,6 +36,11 @@ func writeGCG(g Game, names []string, moves []HistoryMove) string {
fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack) fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack)
} }
} }
// An aborted game ends in a draw because it could no longer be reconstructed; record it
// as an impersonal organizer note (free-text #note; GCG readers ignore pragmas).
if g.EndReason == "aborted" {
fmt.Fprintln(&b, "#note [organizer] game could not be continued and was ended in a draw")
}
return b.String() return b.String()
} }
+15
View File
@@ -61,6 +61,21 @@ func TestWriteGCG(t *testing.T) {
} }
} }
func TestWriteGCGAbortedNote(t *testing.T) {
g := Game{
ID: uuid.MustParse("00000000-0000-7000-8000-000000000002"),
Variant: engine.VariantErudit,
DictVersion: "v1",
Players: 2,
Status: StatusFinished,
EndReason: "aborted",
}
out := writeGCG(g, []string{"Alice", "Bob"}, nil)
if !strings.Contains(out, "#note [organizer]") {
t.Errorf("aborted game GCG missing the organizer note:\n%s", out)
}
}
func TestGCGTilesUppercasesCyrillic(t *testing.T) { func TestGCGTilesUppercasesCyrillic(t *testing.T) {
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" { if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
t.Errorf("gcgTiles = %q, want КОТ?", got) t.Errorf("gcgTiles = %q, want КОТ?", got)
+42
View File
@@ -950,6 +950,12 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
if err != nil { if err != nil {
return StateView{}, err return StateView{}, err
} }
if g.Reason() == engine.EndAborted {
// liveGame voided the game; re-read so the view reflects the finished/aborted state.
if pre, err = svc.store.GetGame(ctx, gameID); err != nil {
return StateView{}, err
}
}
return StateView{ return StateView{
Game: pre, Game: pre,
Seat: seat, Seat: seat,
@@ -1088,6 +1094,13 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
if err != nil { if err != nil {
return nil, err return nil, err
} }
if g.Reason() == engine.EndAborted && pre.Status != StatusFinished {
// First open after the game became unreplayable: persist the void (a finished draw)
// so the lobby and later opens see it settled. Guarded so it runs exactly once.
if err := svc.voidGame(ctx, pre, g); err != nil {
return nil, err
}
}
if !g.Over() { if !g.Over() {
svc.cache.put(pre.ID, g, pre.Variant.String()) svc.cache.put(pre.ID, g, pre.Variant.String())
} }
@@ -1120,12 +1133,41 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
} }
for _, mv := range moves { for _, mv := range moves {
if err := replayMove(g, mv); err != nil { if err := replayMove(g, mv); err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
// A committed move is no longer legal under the current rules, so the game
// cannot be reconstructed past it: close it as a draw (liveGame persists the
// void) rather than leave it unopenable. Other errors are genuine and propagate.
g.Abort()
break
}
return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err) return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err)
} }
} }
return g, nil return g, nil
} }
// voidGame closes pre as a draw because its journal can no longer be replayed; g is the
// partial reconstruction, already Aborted. It persists the finish (end_reason 'aborted'),
// each seat's partial score as a draw, and the draw statistics for the non-guest seats. The
// journal is left intact.
func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) error {
scores := make([]int, g.Players())
for i := range scores {
scores[i] = g.Score(i)
}
statSeats, err := svc.nonGuestSeats(ctx, pre.Seats)
if err != nil {
return err
}
return svc.store.VoidGame(ctx, voidCommit{
gameID: pre.ID,
endReason: g.Reason().String(),
scores: scores,
now: svc.clock(),
stats: buildStats(g, statSeats),
})
}
// replayMove re-applies one journalled move to g through the decoded engine API. // replayMove re-applies one journalled move to g through the decoded engine API.
func replayMove(g *engine.Game, mv HistoryMove) error { func replayMove(g *engine.Game, mv HistoryMove) error {
switch mv.Action { switch mv.Action {
+39
View File
@@ -87,6 +87,17 @@ type commit struct {
stats []statDelta stats []statDelta
} }
// voidCommit is everything voiding an unreplayable game persists: the finish stamp with its
// end reason, each seat's partial score as a draw, and the draw statistics. It appends no
// journal row and leaves the move cursor untouched, so the journal is preserved.
type voidCommit struct {
gameID uuid.UUID
endReason string
scores []int
now time.Time
stats []statDelta
}
// activeGame is the sweeper's view of an in-progress game's turn clock. // activeGame is the sweeper's view of an in-progress game's turn clock.
type activeGame struct { type activeGame struct {
gameID uuid.UUID gameID uuid.UUID
@@ -571,6 +582,34 @@ func (s *Store) CommitMove(ctx context.Context, c commit) error {
}) })
} }
// VoidGame closes a game that can no longer be reconstructed from its journal: it stamps the
// finish (status 'finished', the end reason, finished_at), writes each seat's partial score
// as a draw (is_winner false for all) and upserts the draw statistics, in one transaction.
// Unlike CommitMove it appends no journal row and leaves the move cursor untouched.
func (s *Store) VoidGame(ctx context.Context, v voidCommit) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
gu := table.Games.UPDATE(
table.Games.Status, table.Games.EndReason, table.Games.UpdatedAt, table.Games.FinishedAt,
).SET(
postgres.String(StatusFinished), postgres.String(v.endReason), postgres.TimestampzT(v.now), postgres.TimestampzT(v.now),
).WHERE(table.Games.GameID.EQ(postgres.UUID(v.gameID)))
if _, err := gu.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("void game: %w", err)
}
for seat, score := range v.scores {
if err := updateSeatScore(ctx, tx, v.gameID, seat, score, true, false); err != nil {
return fmt.Errorf("void seat %d: %w", seat, err)
}
}
for _, d := range v.stats {
if err := upsertStats(ctx, tx, d, v.now); err != nil {
return err
}
}
return nil
})
}
// updateSeatScore writes a seat's running score, also stamping is_winner when the // updateSeatScore writes a seat's running score, also stamping is_winner when the
// game has finished. // game has finished.
func updateSeatScore(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, seat, score int, finished, isWinner bool) error { func updateSeatScore(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, seat, score int, finished, isWinner bool) error {
+98
View File
@@ -0,0 +1,98 @@
//go:build integration
package inttest
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// TestGameStateVoidsUnreplayableGame proves the lazy-void path: a game whose journal holds a
// move the current rules reject — here an illegal off-centre first move, standing in for a
// move that was legal when made but became illegal under tightened rules — is, on open,
// finished as a draw with end_reason 'aborted' instead of surfacing an "illegal play" error.
func TestGameStateVoidsUnreplayableGame(t *testing.T) {
ctx := context.Background()
svc := newGameService()
acc0, acc1 := provisionAccount(t), provisionAccount(t)
const seed = 1
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{acc0, acc1}, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Seat 0's deterministic opening rack; pick two non-blank tiles for the crafted move.
mirror, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
if err != nil {
t.Fatalf("mirror: %v", err)
}
var letters []string
for _, l := range mirror.Hand(0) {
if l != "?" {
letters = append(letters, l)
}
if len(letters) == 2 {
break
}
}
if len(letters) < 2 {
t.Fatal("need two non-blank opening tiles")
}
// Insert an illegal first move (off-centre, so the engine rejects it) as the only journal row.
payload, err := json.Marshal(map[string]any{
"rack": mirror.Hand(0),
"dir": "H",
"tiles": []map[string]any{
{"row": 0, "col": 0, "letter": letters[0]},
{"row": 0, "col": 1, "letter": letters[1]},
},
"words": []string{letters[0] + letters[1]},
})
if err != nil {
t.Fatalf("payload: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`INSERT INTO backend.game_moves (game_id, seq, seat, action, score, running_total, exchanged_count, payload)
VALUES ($1, 0, 0, 'play', 0, 0, 0, $2)`, g.ID, string(payload)); err != nil {
t.Fatalf("insert crafted move: %v", err)
}
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET move_count = 1 WHERE game_id = $1`, g.ID); err != nil {
t.Fatalf("bump move_count: %v", err)
}
// Open through a fresh service so its live-game cache is cold and the crafted journal is
// replayed (the real scenario: a player opening the game in a new session). It must not
// error; the game comes back voided as a draw.
svc2 := newGameService()
view, err := svc2.GameState(ctx, g.ID, acc0)
if err != nil {
t.Fatalf("GameState on an unreplayable game should not error, got: %v", err)
}
if view.Game.Status != game.StatusFinished {
t.Errorf("status = %q, want %q", view.Game.Status, game.StatusFinished)
}
if view.Game.EndReason != "aborted" {
t.Errorf("end_reason = %q, want aborted", view.Game.EndReason)
}
for _, s := range view.Game.Seats {
if s.IsWinner {
t.Errorf("seat %d marked winner; an aborted game is a draw", s.Seat)
}
}
// Idempotent: a second open (fresh cold cache) also succeeds and stays voided.
if _, err := newGameService().GameState(ctx, g.ID, acc1); err != nil {
t.Fatalf("second GameState should not error: %v", err)
}
}
@@ -0,0 +1,16 @@
-- +goose Up
-- Allow end_reason = 'aborted': a game whose journal can no longer be reconstructed (a
-- recorded move became illegal under tightened rules) is closed as a draw rather than left
-- unopenable. See engine.EndAborted and Service.voidGame.
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout', 'aborted')
);
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
);
+19 -6
View File
@@ -301,10 +301,15 @@ Key points:
**single-word rule**, chosen on New Game (default **off** = single word; on = standard **single-word rule**, chosen on New Game (default **off** = single word; on = standard
Scrabble). Off, only the **main word** along the play direction is validated and scored — Scrabble). Off, only the **main word** along the play direction is validated and scored —
perpendicular cross-words are ignored, including in robot move generation and the perpendicular cross-words are ignored, including in robot move generation and the
unlimited move preview; on, every cross-word must be a real word and is scored. The unlimited move preview; on, every cross-word must be a real word and is scored. The main
engine threads it as word must still run **through an existing tile along its own line** to connect: a play that
`scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.0`); connectivity and the forms no word along the direction it is laid — touching the board only perpendicular to
first-move centre rule are unaffected. The "Russian-only" limit is a **UI affordance**: itself — is illegal even though its cross-word is never checked, and for a single tile that
abuts the board on both axes the engine plays the higher-scoring legal orientation. The
single-word rule is therefore **not a superset** of the standard rule: it forbids parallel
plays the standard rule allows and admits in-line plays whose cross-words are invalid. The
engine threads it as `scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.1`); the
first-move centre rule is unaffected. The "Russian-only" limit is a **UI affordance**:
the backend and engine are variant-agnostic about the flag, and English games always send the backend and engine are variant-agnostic about the flag, and English games always send
it on (standard). For auto-match the rule is part of the matchmaking key, so only players it on (standard). For auto-match the rule is part of the matchmaking key, so only players
who chose the same rule are paired (the rule field rides every create/enqueue request, so who chose the same rule are paired (the rule field rides every create/enqueue request, so
@@ -316,7 +321,9 @@ Key points:
becomes an automatic resignation, applied by a background sweeper. The sweeper becomes an automatic resignation, applied by a background sweeper. The sweeper
honours each player's **away window** — a daily local-time sleep interval on the honours each player's **away window** — a daily local-time sleep interval on the
account (default 00:0007:00, midnight-cross aware) — so a player is never account (default 00:0007:00, midnight-cross aware) — so a player is never
timed out while asleep. timed out while asleep. A game whose journal can no longer be replayed — a
committed move made illegal by a later rule change — is instead closed as a
**draw** (`aborted`) on the next open, never left unopenable (§9.1).
- **Players**: auto-match is always 2 players; friend games are 24 players. - **Players**: auto-match is always 2 players; friend games are 24 players.
`backend` owns turn order and the bag for any player count. A resignation or `backend` owns turn order and the bag for any player count. A resignation or
timeout in a two-player game ends it with the other player winning. In a game timeout in a two-player game ends it with the other player winning. In a game
@@ -520,7 +527,13 @@ re-deriving it, so the rebuild matches the committed game) and to render history
dictionary**: the board for visual replay is reconstructed by applying placements dictionary**: the board for visual replay is reconstructed by applying placements
onto an empty grid, since moves were validated at play time and scores are onto an empty grid, since moves were validated at play time and scores are
stored. `variant` and `dict_version` are kept as **metadata only** (audit, stored. `variant` and `dict_version` are kept as **metadata only** (audit,
complaint review), never as a replay dependency. **GCG export** is derived from complaint review), never as a replay dependency. Engine replay re-validates each
move, so a committed move that **later becomes illegal** under a tightened rule
(e.g. the single-word connectivity rule) would make the rebuild fail; rather than
leave the game unopenable, the next open closes it gracefully as a **draw**
(`engine.EndAborted``end_reason='aborted'`, all seats marked non-winners),
preserves the journal intact, and surfaces an impersonal **organizer note** at the
end of the history and in the GCG export (a free-text `#note`). **GCG export** is derived from
the same rows and is likewise self-contained — we ship our own writer (the solver the same rows and is likewise self-contained — we ship our own writer (the solver
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon` exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
+6 -1
View File
@@ -93,7 +93,8 @@ takes the empty seat after **1.53 minutes**, so a game always starts — and
you can close the app while you wait and come back later. For Russian games (auto-match or friend you can close the app while you wait and come back later. For Russian games (auto-match or friend
invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays
the simplified **single-word rule** — only the word laid along the player's line must be a the simplified **single-word rule** — only the word laid along the player's line must be a
real word, and any incidental perpendicular words are ignored and not scored — while on is real word (and it must still cross or extend letters already on the board along that line),
and any incidental perpendicular words are ignored and not scored — while on is
standard Scrabble. English games are always standard and show no such toggle. In auto-match standard Scrabble. English games are always standard and show no such toggle. In auto-match
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (24) are the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (24) are
formed by inviting players from the friend list (an invitation, like a friend code, formed by inviting players from the friend list (an invitation, like a friend code,
@@ -176,6 +177,10 @@ would leak the move journal), and the client shares the `.gcg` file where the
platform supports it, otherwise downloads it. Statistics (durable accounts only): platform supports it, otherwise downloads it. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). best play, which already includes every word it formed plus the all-tiles bonus).
A game that can no longer be continued — because a rule changed and an earlier
move would now be illegal — is closed as a **draw** the moment someone opens it,
never left stuck on an error: the move history shows an impersonal organizer note
at its end (the game could not be continued), and the same note rides the GCG export.
### Administration ### Administration
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
+6 -1
View File
@@ -96,7 +96,8 @@ nudge) приходят от бота **этой партии** — по язы
позже. Для русских игр (авто-подбор или приглашение) на экране позже. Для русских игр (авто-подбор или приглашение) на экране
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена — новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются; вдоль линии хода (и оно должно пересекать или продолжать уже стоящие на доске буквы вдоль
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4) кто выбрал то же правило. Игры с друзьями (2–4)
@@ -181,6 +182,10 @@ UTC), суточного окна отсутствия (away; сетка по 10
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов): поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). ход, уже включающий все образованные им слова и бонус за все фишки).
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
организатора (партию не удалось продолжить), и та же заметка попадает в экспорт GCG.
### Администрирование ### Администрирование
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
+2
View File
@@ -8,6 +8,8 @@ gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 h1:ntN6m4cOB+4FelleO2nkAIZ
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY= gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 h1:92jWbAZ5IK3ROrn1g3FjY0wZjeYpVWOJsl/GGT5HN1U= gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 h1:92jWbAZ5IK3ROrn1g3FjY0wZjeYpVWOJsl/GGT5HN1U=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY= gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1 h1:3HpPw1gFKX05/O4u3sQ93dxob+ANfyqOSUcpbNqm0Yo=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.3
require ( require (
connectrpc.com/connect v1.19.2 connectrpc.com/connect v1.19.2
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1
github.com/google/flatbuffers v23.5.26+incompatible github.com/google/flatbuffers v23.5.26+incompatible
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/iliadenisov/dafsa v1.1.0 github.com/iliadenisov/dafsa v1.1.0
+12
View File
@@ -917,6 +917,9 @@
{/each} {/each}
</div> </div>
</div> </div>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
</div> </div>
{/if} {/if}
@@ -1174,6 +1177,15 @@
.hsc { .hsc {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* The impersonal organizer note closing an aborted (un-replayable) game, under the grid. */
.horganizer {
margin: 0;
padding: 8px var(--pad);
color: var(--text-muted);
font-size: 0.85rem;
font-style: italic;
text-align: center;
}
.boardwrap { .boardwrap {
padding: 6px; padding: 6px;
transition: transform 0.3s ease; transition: transform 0.3s ease;
+1
View File
@@ -81,6 +81,7 @@ export const en = {
'game.won': 'You won', 'game.won': 'You won',
'game.lost': 'You lost', 'game.lost': 'You lost',
'game.tied': 'Draw', 'game.tied': 'Draw',
'game.abortedNote': 'This game could not be continued by the organizer and was ended in a draw.',
'game.checkWordPrompt': 'Enter a word', 'game.checkWordPrompt': 'Enter a word',
'game.wordLegal': '“{word}” is valid', 'game.wordLegal': '“{word}” is valid',
'game.wordIllegal': '“{word}” is not valid', 'game.wordIllegal': '“{word}” is not valid',
+1
View File
@@ -82,6 +82,7 @@ export const ru: Record<MessageKey, string> = {
'game.won': 'Вы выиграли', 'game.won': 'Вы выиграли',
'game.lost': 'Вы проиграли', 'game.lost': 'Вы проиграли',
'game.tied': 'Ничья', 'game.tied': 'Ничья',
'game.abortedNote': 'Партия не может быть продолжена по вине организатора и завершена ничьёй.',
'game.checkWordPrompt': 'Введите слово', 'game.checkWordPrompt': 'Введите слово',
'game.wordLegal': '«{word}» допустимо', 'game.wordLegal': '«{word}» допустимо',
'game.wordIllegal': '«{word}» недопустимо', 'game.wordIllegal': '«{word}» недопустимо',