Files
scrabble-game/backend/internal/game/gcg_test.go
T
Ilia Denisov 751e74b14f
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s
Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
2026-06-02 17:33:49 +02:00

80 lines
2.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package game
import (
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
func TestWriteGCG(t *testing.T) {
g := Game{
ID: uuid.MustParse("00000000-0000-7000-8000-000000000001"),
Variant: engine.VariantEnglish,
DictVersion: "v1",
Players: 2,
}
moves := []HistoryMove{
{
Seq: 0, Seat: 0, Action: "play", Score: 10, RunningTotal: 10,
Dir: "H", MainRow: 7, MainCol: 7,
Tiles: []engine.TileRecord{{Row: 7, Col: 7, Letter: "c"}, {Row: 7, Col: 8, Letter: "a"}, {Row: 7, Col: 9, Letter: "t"}},
Words: []string{"cat"}, Rack: []string{"c", "a", "t", "s", "e", "r", "?"},
},
{
Seq: 1, Seat: 1, Action: "play", Score: 2, RunningTotal: 2,
Dir: "V", MainRow: 7, MainCol: 8,
Tiles: []engine.TileRecord{{Row: 8, Col: 8, Letter: "s", Blank: true}},
Words: []string{"as"}, Rack: []string{"a", "s", "?", "e"},
},
{Seq: 2, Seat: 0, Action: "pass", RunningTotal: 10, Rack: []string{"x", "y", "z"}},
{Seq: 3, Seat: 1, Action: "exchange", RunningTotal: 2, Exchanged: []string{"q", "u"}, Rack: []string{"q", "u", "i"}},
{Seq: 4, Seat: 0, Action: "resign", RunningTotal: 10, Rack: []string{"a", "b"}},
{Seq: 5, Seat: 1, Action: "timeout", RunningTotal: 2, Rack: []string{"c"}},
}
out := writeGCG(g, []string{"Alice", "Bob"}, moves)
wantLines := []string{
"#character-encoding UTF-8",
"#player1 p1 Alice",
"#player2 p2 Bob",
"#lexicon english/v1",
"#title game 00000000-0000-7000-8000-000000000001",
">p1: CATSER? 8H CAT +10 10",
">p2: AS?E I8 .s +2 2",
">p1: XYZ - +0 10",
">p2: QUI -QU +0 2",
"#note p1 resigned (rack AB)",
"#note p2 timed out (rack C)",
}
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
got := make(map[string]bool, len(lines))
for _, l := range lines {
got[l] = true
}
for _, want := range wantLines {
if !got[want] {
t.Errorf("GCG missing line %q\n--- full output ---\n%s", want, out)
}
}
}
func TestGCGTilesUppercasesCyrillic(t *testing.T) {
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
t.Errorf("gcgTiles = %q, want КОТ?", got)
}
}
func TestGCGPos(t *testing.T) {
across := gcgPos(HistoryMove{Dir: "H", MainRow: 7, MainCol: 6})
if across != "8G" {
t.Errorf("across pos = %q, want 8G", across)
}
down := gcgPos(HistoryMove{Dir: "V", MainRow: 6, MainCol: 7})
if down != "H7" {
t.Errorf("down pos = %q, want H7", down)
}
}