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 }