Files
scrabble-game/backend/internal/game/gcg.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

120 lines
3.4 KiB
Go

package game
import (
"fmt"
"strconv"
"strings"
)
// writeGCG renders a game as GCG text in the standard (Poslfit) dialect, plus
// #note lines for resignations and timeouts, which the standard does not cover.
// It is derived entirely from the decoded journal, so it needs no dictionary
// (docs/ARCHITECTURE.md §9.1). names supplies each seat's display name; the
// GCG nicknames are p1, p2, … .
func writeGCG(g Game, names []string, moves []HistoryMove) string {
var b strings.Builder
fmt.Fprintln(&b, "#character-encoding UTF-8")
for seat := 0; seat < g.Players; seat++ {
fmt.Fprintf(&b, "#player%d %s %s\n", seat+1, nick(seat), playerName(names, seat))
}
fmt.Fprintf(&b, "#lexicon %s/%s\n", g.Variant, g.DictVersion)
fmt.Fprintf(&b, "#title game %s\n", g.ID)
for _, mv := range moves {
rack := gcgTiles(mv.Rack)
switch mv.Action {
case "play":
fmt.Fprintf(&b, ">%s: %s %s %s +%d %d\n",
nick(mv.Seat), rack, gcgPos(mv), gcgWord(mv), mv.Score, mv.RunningTotal)
case "pass":
fmt.Fprintf(&b, ">%s: %s - +0 %d\n", nick(mv.Seat), rack, mv.RunningTotal)
case "exchange":
fmt.Fprintf(&b, ">%s: %s -%s +0 %d\n", nick(mv.Seat), rack, gcgTiles(mv.Exchanged), mv.RunningTotal)
case "resign":
fmt.Fprintf(&b, "#note %s resigned (rack %s)\n", nick(mv.Seat), rack)
case "timeout":
fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack)
}
}
return b.String()
}
// nick is the GCG nickname for a seat: p1, p2, … (space-free, as GCG requires).
func nick(seat int) string { return "p" + strconv.Itoa(seat+1) }
// playerName returns the display name for a seat, or a generic fallback.
func playerName(names []string, seat int) string {
if seat < len(names) && names[seat] != "" {
return names[seat]
}
return "Player " + strconv.Itoa(seat+1)
}
// gcgTiles renders a rack or exchanged set in GCG form: upper-cased letters with
// "?" for a blank.
func gcgTiles(tiles []string) string {
var b strings.Builder
for _, t := range tiles {
if t == "?" {
b.WriteByte('?')
continue
}
b.WriteString(strings.ToUpper(t))
}
return b.String()
}
// gcgPos renders a play's board coordinate: row-then-column (e.g. 8G) for an
// across play, column-then-row (e.g. H8) for a down play. Rows are 1-based and
// columns are lettered from A.
func gcgPos(mv HistoryMove) string {
col := string(rune('A' + mv.MainCol))
row := strconv.Itoa(mv.MainRow + 1)
if mv.Dir == "V" {
return col + row
}
return row + col
}
// gcgWord renders the main word: each cell along it is the newly-placed tile's
// letter (lower-cased for a blank, upper-cased otherwise) or "." for a tile
// already on the board.
func gcgWord(mv HistoryMove) string {
placed := make(map[[2]int]tileLetter, len(mv.Tiles))
for _, t := range mv.Tiles {
placed[[2]int{t.Row, t.Col}] = tileLetter{letter: t.Letter, blank: t.Blank}
}
var word string
if len(mv.Words) > 0 {
word = mv.Words[0]
}
n := len([]rune(word))
var b strings.Builder
for i := range n {
row, col := mv.MainRow, mv.MainCol
if mv.Dir == "V" {
row += i
} else {
col += i
}
t, ok := placed[[2]int{row, col}]
if !ok {
b.WriteByte('.')
continue
}
if t.blank {
b.WriteString(strings.ToLower(t.letter))
} else {
b.WriteString(strings.ToUpper(t.letter))
}
}
return b.String()
}
// tileLetter is a placed tile's concrete letter and blank flag, keyed by cell in
// gcgWord.
type tileLetter struct {
letter string
blank bool
}