Files
scrabble-game/backend/internal/game/gcg.go
T
Ilia Denisov 222eaf730f
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
feat(game): void unreplayable games as a draw instead of erroring
A committed move that becomes illegal under a tightened rule (the single-word
connectivity fix) makes engine replay fail, which left such games unopenable —
an empty screen and an 'illegal play' error. Now the first open closes the game
gracefully as a draw (engine.EndAborted -> end_reason 'aborted', no winner),
preserves the journal, and surfaces an impersonal organizer note at the end of
the move history and in the GCG export.

- engine: EndAborted + Abort() (draw, no rack adjustment; winner -1).
- service: replay aborts on ErrIllegalPlay; liveGame persists the void once
  (lazy, on open); GameState re-reads for the settled view.
- store: VoidGame finishes the game and stamps a draw without a journal row.
- migration 00002: allow end_reason 'aborted'.
- ui: organizer note under the history grid; i18n en/ru.
- docs: ARCHITECTURE 6/9.1, FUNCTIONAL(+ru), PRERELEASE MW3.
2026-06-14 16:57:27 +02:00

125 lines
3.7 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)
}
}
// 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()
}
// 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
}