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** |
| 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** |
| 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** |
| 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) |
+1 -1
View File
@@ -3,7 +3,7 @@ module scrabble/backend
go 1.26.3
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/gin-gonic/gin v1.12.0
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 {
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.
@@ -106,7 +106,7 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
if err != nil {
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 {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
@@ -169,6 +169,33 @@ func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
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
// concrete letter) into the internal byte form, wrapping a bad letter as
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
+20
View File
@@ -25,6 +25,10 @@ const (
EndScoreless
// EndResign fires when a player resigns.
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.
@@ -38,6 +42,8 @@ func (r EndReason) String() string {
return "scoreless"
case EndResign:
return "resign"
case EndAborted:
return "aborted"
}
return "unknown"
}
@@ -370,6 +376,17 @@ func (g *Game) finish(reason EndReason) {
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
// 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
@@ -453,6 +470,9 @@ func (g *Game) winner() int {
if !g.over {
return -1
}
if g.reason == EndAborted {
return -1 // an aborted game is a draw regardless of the running scores
}
best, tie := -1, false
for i := range g.scores {
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")
}
// Play the same opening (the standard game's top move) in both games, then compare
// the next player's candidate moves. Both games share the seed, so the next rack is
// identical; relaxed (single-word) generation never drops a legal standard move.
// Play the same opening (the standard game's top move) in both games. Both share the
// seed, so the next rack is identical and both still have legal replies. The single-word
// 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()
if !ok {
t.Fatal("opening game has no hint")
@@ -46,9 +49,8 @@ func TestSingleWordRuleWiring(t *testing.T) {
if _, err := single.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("single-word opening: %v", err)
}
stdMoves, singleMoves := len(std.GenerateMoves()), len(single.GenerateMoves())
if singleMoves < stdMoves {
t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves)
if len(std.GenerateMoves()) == 0 || len(single.GenerateMoves()) == 0 {
t.Error("both games should have legal replies after the opening")
}
}
@@ -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
// cross-word check while searching for its move: its move source, Candidates ->
// 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)
}
}
// 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()
}
+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) {
if got := gcgTiles([]string{"к", "о", "т", "?"}); 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 {
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{
Game: pre,
Seat: seat,
@@ -1088,6 +1094,13 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
if err != nil {
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() {
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 {
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 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.
func replayMove(g *engine.Game, mv HistoryMove) error {
switch mv.Action {
+39
View File
@@ -87,6 +87,17 @@ type commit struct {
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.
type activeGame struct {
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
// game has finished.
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
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
unlimited move preview; on, every cross-word must be a real word and is scored. The
engine threads it as
`scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.0`); connectivity and the
first-move centre rule are unaffected. The "Russian-only" limit is a **UI affordance**:
unlimited move preview; on, every cross-word must be a real word and is scored. The main
word must still run **through an existing tile along its own line** to connect: a play that
forms no word along the direction it is laid — touching the board only perpendicular to
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
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
@@ -316,7 +321,9 @@ Key points:
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
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.
`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
@@ -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
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,
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
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
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
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
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
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,
@@ -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):
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).
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
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
+6 -1
View File
@@ -96,7 +96,8 @@ nudge) приходят от бота **этой партии** — по язы
позже. Для русских игр (авто-подбор или приглашение) на экране
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются;
вдоль линии хода (и оно должно пересекать или продолжать уже стоящие на доске буквы вдоль
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4)
@@ -181,6 +182,10 @@ UTC), суточного окна отсутствия (away; сетка по 10
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки).
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
организатора (партию не удалось продолжить), и та же заметка попадает в экспорт GCG.
### Администрирование
Оператор открывает серверную админ-консоль по адресу `${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.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.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/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
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 (
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/uuid v1.6.0
github.com/iliadenisov/dafsa v1.1.0
+12
View File
@@ -917,6 +917,9 @@
{/each}
</div>
</div>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
</div>
{/if}
@@ -1174,6 +1177,15 @@
.hsc {
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 {
padding: 6px;
transition: transform 0.3s ease;
+1
View File
@@ -81,6 +81,7 @@ export const en = {
'game.won': 'You won',
'game.lost': 'You lost',
'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.wordLegal': '“{word}” is valid',
'game.wordIllegal': '“{word}” is not valid',
+1
View File
@@ -82,6 +82,7 @@ export const ru: Record<MessageKey, string> = {
'game.won': 'Вы выиграли',
'game.lost': 'Вы проиграли',
'game.tied': 'Ничья',
'game.abortedNote': 'Партия не может быть продолжена по вине организатора и завершена ничьёй.',
'game.checkWordPrompt': 'Введите слово',
'game.wordLegal': '«{word}» допустимо',
'game.wordIllegal': '«{word}» недопустимо',