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
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.
125 lines
3.7 KiB
Go
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
|
|
}
|