751e74b14f
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.
80 lines
2.4 KiB
Go
80 lines
2.4 KiB
Go
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)
|
||
}
|
||
}
|