A report dumps everything needed to understand a game that went wrong:
the full state (decks card by card, shop row and tier deck order,
discards, pending choices, banked Mana/Trumpets/apples), the round's
battles, and the whole event log. Available as JSON, which replays, or
text, which reads — and the text ends with a paste-ready repro test.
Battles now record the randomness they consume alongside what they
started from, so a result can be replayed long after the round cleared
those banks: same lineups, same dice, same events, down to the log text.
Results predating the recording say so rather than quietly re-rolling.
Three ways in: the 🐛 panel's download/copy buttons, GET
/api/debug/report (DEBUG-only, since a report holds both players' hands
and the shop deck order), and `mise run report`, which reads the
database directly so a live game can be dumped without DEBUG.
521 lines
18 KiB
Go
521 lines
18 KiB
Go
package game
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// A debug report is a snapshot of a game that went wrong, complete enough to
|
|
// reproduce it: the full authoritative state (every deck card by card, the shop
|
|
// row and remaining decks, discards, pending choices, per-player banks), every
|
|
// battle fought that round with its lineups and its recording of every die, and
|
|
// the entire event log.
|
|
//
|
|
// It exists in two forms. The JSON form is exact — feed it back through
|
|
// ParseDebugReport and you have the same Game the server had, ready to replay.
|
|
// The text form is for reading: the same content laid out for a human trying to
|
|
// work out which effect misfired. Report.Text() ends with a paste-ready test.
|
|
//
|
|
// Both forms contain hidden information — opponents' decks, the order of the
|
|
// shop decks — so a report is an operator's artifact, not a player's. The
|
|
// server only serves one in DEBUG mode; `go run ./cmd/report` pulls one out of
|
|
// the database with no such gate, because by then you are the operator.
|
|
|
|
// DebugReportVersion is the report format's version, so an old report found on
|
|
// disk can be recognized for what it is.
|
|
const DebugReportVersion = 1
|
|
|
|
// DebugReport is a captured game, ready to read or replay.
|
|
type DebugReport struct {
|
|
Version int `json:"version"`
|
|
// CapturedAt is an RFC3339 timestamp stamped by whoever took the report;
|
|
// the engine is clockless and leaves it empty. Note is free text — what
|
|
// looked wrong, in the reporter's words.
|
|
CapturedAt string `json:"capturedAt,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
// Summary is the at-a-glance header, so a pile of reports can be triaged
|
|
// (and filenames built) without unmarshalling the state.
|
|
Summary DebugSummary `json:"summary"`
|
|
// State is the complete Game, byte for byte as the server persists it.
|
|
// Everything the report can tell you is derived from this.
|
|
State json.RawMessage `json:"state"`
|
|
}
|
|
|
|
// DebugSummary identifies a report at a glance.
|
|
type DebugSummary struct {
|
|
GameID string `json:"gameId"`
|
|
Code string `json:"code"`
|
|
Packs []string `json:"packs"`
|
|
Phase Phase `json:"phase"`
|
|
Round int `json:"round"`
|
|
Seats []string `json:"seats"` // display names, by seat
|
|
Battles int `json:"battles"` // battles recorded in this round
|
|
LogEntries int `json:"logEntries"` // size of the event log
|
|
WinnerSeat int `json:"winnerSeat"` // at gameover; -1 otherwise
|
|
Bots []int `json:"bots,omitempty"` // seats played by the computer
|
|
}
|
|
|
|
// DebugReport captures the game as it stands. The caller owns the game lock;
|
|
// the report is a copy and shares nothing with it afterwards.
|
|
func (g *Game) DebugReport() (*DebugReport, error) {
|
|
state, err := json.Marshal(g)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("capture game state: %w", err)
|
|
}
|
|
sum := DebugSummary{
|
|
GameID: g.ID,
|
|
Code: g.Code,
|
|
Packs: g.packList(),
|
|
Phase: g.Phase,
|
|
Round: g.Round,
|
|
Battles: len(g.Battles),
|
|
LogEntries: len(g.Log),
|
|
WinnerSeat: g.WinnerSeat,
|
|
}
|
|
for _, p := range g.Players {
|
|
sum.Seats = append(sum.Seats, p.Name)
|
|
if p.IsBot {
|
|
sum.Bots = append(sum.Bots, p.Seat)
|
|
}
|
|
}
|
|
return &DebugReport{Version: DebugReportVersion, Summary: sum, State: state}, nil
|
|
}
|
|
|
|
// ParseDebugReport reads a report. It also accepts a bare game state — the raw
|
|
// JSON blob out of the store, or the `state` field pulled from a report — so
|
|
// anything game-shaped you can lay hands on can be replayed.
|
|
func ParseDebugReport(data []byte) (*DebugReport, error) {
|
|
var r DebugReport
|
|
if err := json.Unmarshal(data, &r); err != nil {
|
|
return nil, fmt.Errorf("parse debug report: %w", err)
|
|
}
|
|
if len(r.State) == 0 {
|
|
// Not a report — assume it's a game state and wrap it in one.
|
|
var g Game
|
|
if err := json.Unmarshal(data, &g); err != nil {
|
|
return nil, fmt.Errorf("parse debug report: not a report or a game state: %w", err)
|
|
}
|
|
if g.ID == "" && g.Code == "" && len(g.Players) == 0 {
|
|
return nil, fmt.Errorf("parse debug report: no game state found")
|
|
}
|
|
return g.DebugReport()
|
|
}
|
|
if _, err := r.Game(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// LoadDebugReportFile reads a report (or a bare game state) off disk. This is
|
|
// the entry point for a test built around a report: keep the JSON in testdata
|
|
// and load it here.
|
|
func LoadDebugReportFile(path string) (*DebugReport, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseDebugReport(data)
|
|
}
|
|
|
|
// Game rebuilds the captured game. Each call returns a fresh copy, so callers
|
|
// are free to poke at it — replay a battle, take another action, run the state
|
|
// forward — without disturbing the report or each other.
|
|
func (r *DebugReport) Game() (*Game, error) {
|
|
var g Game
|
|
if err := json.Unmarshal(r.State, &g); err != nil {
|
|
return nil, fmt.Errorf("restore game state: %w", err)
|
|
}
|
|
return &g, nil
|
|
}
|
|
|
|
// JSON renders the report as indented JSON: what you save to a file, attach to
|
|
// a bug, or drop into testdata.
|
|
func (r *DebugReport) JSON() ([]byte, error) {
|
|
return json.MarshalIndent(r, "", " ")
|
|
}
|
|
|
|
// Filename is a descriptive, filesystem-safe name for this report.
|
|
func (r *DebugReport) Filename() string {
|
|
code := r.Summary.Code
|
|
if code == "" {
|
|
code = "game"
|
|
}
|
|
return fmt.Sprintf("sap-debug-%s-r%d-%s.json", strings.ToLower(code), r.Summary.Round, r.Summary.Phase)
|
|
}
|
|
|
|
// ReplayBattle re-runs one of the round's recorded battles — index 0 is the
|
|
// first pairing, matching Game.Battles — against a fresh copy of the captured
|
|
// game. The recording supplies the dice, so the fight plays out exactly as it
|
|
// did for the player, right down to the event text. Compare the result against
|
|
// the recorded one to see whether the engine still does what it did, or step
|
|
// through it to find where it went wrong.
|
|
func (r *DebugReport) ReplayBattle(i int) (*BattleResult, error) {
|
|
g, err := r.Game()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if i < 0 || i >= len(g.Battles) {
|
|
return nil, fmt.Errorf("no battle %d in this report (it has %d)", i, len(g.Battles))
|
|
}
|
|
rec := g.Battles[i]
|
|
if len(rec.Seats) != 2 || len(rec.Lineups) != 2 {
|
|
return nil, fmt.Errorf("battle %d has no recorded lineups to replay", i)
|
|
}
|
|
for _, seat := range rec.Seats {
|
|
if seat < 0 || seat >= len(g.Players) {
|
|
return nil, fmt.Errorf("battle %d names seat %d, which isn't at this table", i, seat)
|
|
}
|
|
}
|
|
// Refuse rather than hand back a battle that quietly differs from the one
|
|
// the player saw: a game saved before the engine recorded its dice can only
|
|
// be re-rolled, not replayed.
|
|
if !rec.Replayable() {
|
|
return nil, fmt.Errorf("battle %d predates dice recording, so it can't be replayed faithfully — "+
|
|
"its lineups are in the report, and ReplayResult will fight it again with fresh dice", i)
|
|
}
|
|
// Rewind the two fighters to how they went in: the round has since cleared
|
|
// their banks and written their Mana back, and the battle itself spent card
|
|
// ids minting apples.
|
|
applyBattleInputs(g, rec)
|
|
g.Round = rec.Round
|
|
g.NextCardID = rec.StartCardID
|
|
g.drawReplay = append([]int(nil), rec.Draws...)
|
|
return g.runBattle(rec.Seats[0], rec.Seats[1]), nil
|
|
}
|
|
|
|
// Text renders the report for reading.
|
|
func (r *DebugReport) Text() string {
|
|
var b strings.Builder
|
|
g, err := r.Game()
|
|
if err != nil {
|
|
fmt.Fprintf(&b, "debug report v%d — UNREADABLE STATE: %v\n", r.Version, err)
|
|
return b.String()
|
|
}
|
|
|
|
fmt.Fprintf(&b, "Super Auto Pets debug report (v%d)\n", r.Version)
|
|
if r.CapturedAt != "" {
|
|
fmt.Fprintf(&b, "captured %s\n", r.CapturedAt)
|
|
}
|
|
if r.Note != "" {
|
|
fmt.Fprintf(&b, "note: %s\n", r.Note)
|
|
}
|
|
packs := strings.Join(g.packList(), " + ")
|
|
fmt.Fprintf(&b, "\ngame %s · code %s · packs %s\n", g.ID, g.Code, packs)
|
|
fmt.Fprintf(&b, "round %d/%d · phase %s · %d players\n", g.Round, MaxRounds, g.Phase, len(g.Players))
|
|
fmt.Fprintf(&b, "priority seat %d · shop turn seat %d · next card id %d\n",
|
|
g.PrioritySeat, g.Turn, g.NextCardID)
|
|
if g.Phase == PhaseGameOver {
|
|
fmt.Fprintf(&b, "winner: %s\n", seatList(g, g.WinnerSeats))
|
|
}
|
|
if pairs := g.Pairings(); len(pairs) > 0 {
|
|
var parts []string
|
|
for _, m := range pairs {
|
|
parts = append(parts, fmt.Sprintf("%d v %d", m[0], m[1]))
|
|
}
|
|
fmt.Fprintf(&b, "this round's pairings: %s\n", strings.Join(parts, ", "))
|
|
}
|
|
|
|
b.WriteString("\n=== Players ===\n")
|
|
for _, p := range g.Players {
|
|
writePlayer(&b, p)
|
|
}
|
|
|
|
b.WriteString("\n=== Shop ===\n")
|
|
writeShop(&b, g)
|
|
|
|
if len(g.Battles) > 0 {
|
|
fmt.Fprintf(&b, "\n=== Battles (round %d) ===\n", g.Battles[0].Round)
|
|
for i, res := range g.Battles {
|
|
writeBattle(&b, g, i, res)
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(&b, "\n=== Event log (%d entries) ===\n", len(g.Log))
|
|
for _, e := range g.Log {
|
|
seat := " -"
|
|
if e.Seat >= 0 {
|
|
seat = fmt.Sprintf("s%d", e.Seat)
|
|
}
|
|
fmt.Fprintf(&b, " #%-4d r%d %-8s %-3s %s %s", e.Seq, e.Round, e.Phase, seat, e.Icon, e.Text)
|
|
if e.Kind != "" {
|
|
fmt.Fprintf(&b, " [kind=%s]", e.Kind)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString("\n=== Reproducing this ===\n")
|
|
b.WriteString(r.GoTest())
|
|
return b.String()
|
|
}
|
|
|
|
// GoTest is a paste-ready test that reproduces the report's battle. Save the
|
|
// report's JSON next to it and the test replays the exact fight — same
|
|
// lineups, same dice — so you can assert on what should have happened and then
|
|
// step into the resolver to find out why it didn't.
|
|
func (r *DebugReport) GoTest() string {
|
|
var b strings.Builder
|
|
name := r.Filename()
|
|
fmt.Fprintf(&b, "Save the JSON report as internal/game/testdata/%s, then in\n"+
|
|
"internal/game (package game):\n\n", name)
|
|
fmt.Fprintf(&b, "func TestReproFrom%s(t *testing.T) {\n", identifier(r.Summary.Code))
|
|
fmt.Fprintf(&b, "\trep, err := LoadDebugReportFile(\"testdata/%s\")\n", name)
|
|
b.WriteString("\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n")
|
|
b.WriteString("\tres, err := rep.ReplayBattle(0) // 0 = the round's first pairing\n")
|
|
b.WriteString("\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n")
|
|
b.WriteString("\tfor i, ev := range res.Events {\n\t\tt.Logf(\"%3d %-8s %s\", i, ev.Type, ev.Text)\n\t}\n")
|
|
// The assertion is seeded with what actually happened, since that's the
|
|
// thing under suspicion: flip it to what should have happened and the test
|
|
// becomes the bug report.
|
|
winner := -1
|
|
if g, err := r.Game(); err == nil && len(g.Battles) > 0 {
|
|
winner = g.Battles[0].WinnerSeat
|
|
}
|
|
outcome := fmt.Sprintf("seat %d to win", winner)
|
|
if winner < 0 {
|
|
outcome = "a draw"
|
|
}
|
|
fmt.Fprintf(&b, "\tif res.WinnerSeat != %d { // what happened; assert what should have\n", winner)
|
|
fmt.Fprintf(&b, "\t\tt.Fatalf(\"expected %s, got winner seat %%d\", res.WinnerSeat)\n", outcome)
|
|
b.WriteString("\t}\n}\n\n")
|
|
b.WriteString("The whole game is there too, not just the battle: rep.Game() hands back\n" +
|
|
"the exact state the server held, so you can carry on from the shop, replay\n" +
|
|
"a different pairing, or read a deck card by card.\n")
|
|
return b.String()
|
|
}
|
|
|
|
// writePlayer renders one seat: its banks, then its deck in order.
|
|
func writePlayer(b *strings.Builder, p *Player) {
|
|
who := "human"
|
|
if p.IsBot {
|
|
who = fmt.Sprintf("bot %.2f", p.BotLevel)
|
|
}
|
|
conn := "connected"
|
|
if !p.Connected {
|
|
conn = "DISCONNECTED"
|
|
}
|
|
ready := ""
|
|
if p.Ready {
|
|
ready = " · ready/passed"
|
|
}
|
|
wins := ""
|
|
if len(p.RoundWins) > 0 {
|
|
wins = fmt.Sprintf(" (won rounds %v)", p.RoundWins)
|
|
}
|
|
fmt.Fprintf(b, "\nSeat %d %s (%s, %s)%s\n", p.Seat, p.Name, who, conn, ready)
|
|
fmt.Fprintf(b, " %d coins · %d trophies%s · %d pets\n", p.Coins, p.Trophies, wins, p.PetCount())
|
|
var banks []string
|
|
for _, bank := range []struct {
|
|
label string
|
|
n int
|
|
}{
|
|
{"mana", p.Mana}, {"avocados", p.Avocados}, {"trumpets banked", p.PendingTrumpets},
|
|
{"apples in play next battle", p.PendingApplesInPlay}, {"apples next round", p.NextRoundApples},
|
|
{"buys this round", p.BuysThisRound},
|
|
} {
|
|
if bank.n != 0 {
|
|
banks = append(banks, fmt.Sprintf("%s %d", bank.label, bank.n))
|
|
}
|
|
}
|
|
if p.FirstBuyFree {
|
|
banks = append(banks, "first buy free")
|
|
}
|
|
if p.TripledThisRound {
|
|
banks = append(banks, "tripled this round")
|
|
}
|
|
if len(banks) > 0 {
|
|
fmt.Fprintf(b, " %s\n", strings.Join(banks, " · "))
|
|
}
|
|
if p.ShopPeek != nil {
|
|
fmt.Fprintf(b, " peeked at the shop deck: %s\n", cardLine(*p.ShopPeek))
|
|
}
|
|
fmt.Fprintf(b, " deck, in order (%d):\n", len(p.Deck))
|
|
if len(p.Deck) == 0 {
|
|
b.WriteString(" (empty)\n")
|
|
}
|
|
for i, c := range p.Deck {
|
|
fmt.Fprintf(b, " %d. %s\n", i+1, cardLine(c))
|
|
}
|
|
}
|
|
|
|
// writeShop renders the shop row, what's left in each tier deck, the discards,
|
|
// and any choice a player is mid-way through.
|
|
func writeShop(b *strings.Builder, g *Game) {
|
|
if len(g.ShopRow) == 0 {
|
|
b.WriteString(" row: (none — the shop is closed)\n")
|
|
}
|
|
for i, c := range g.ShopRow {
|
|
if c.ID == "" {
|
|
fmt.Fprintf(b, " row %d: (bought)\n", i)
|
|
continue
|
|
}
|
|
fmt.Fprintf(b, " row %d: %s\n", i, cardLine(c))
|
|
}
|
|
for tier, deck := range g.ShopDecks {
|
|
fmt.Fprintf(b, " tier %d deck: %d left", tier+1, len(deck))
|
|
// The order is the whole point of dumping the deck: it decides what the
|
|
// next buy, peek or trade-in turns up.
|
|
if len(deck) > 0 {
|
|
fmt.Fprintf(b, " — next: %s", cardNames(deck[:min(len(deck), 6)]))
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
for tier := 1; tier <= MaxRounds; tier++ {
|
|
if pile := g.Discards[tier]; len(pile) > 0 {
|
|
fmt.Fprintf(b, " tier %d discards: %s\n", tier, cardNames(pile))
|
|
}
|
|
}
|
|
if t := g.Pending; t != nil {
|
|
fmt.Fprintf(b, " PENDING trade-in by %s from tier %d: %s | %s\n",
|
|
playerName(g, t.PlayerID), t.Tier, cardLine(t.Options[0]), cardLine(t.Options[1]))
|
|
}
|
|
if rv := g.PendingReveal; rv != nil {
|
|
fmt.Fprintf(b, " PENDING reveal by %s (source %s, %d apples), options %v\n",
|
|
playerName(g, rv.PlayerID), rv.Source, rv.Apples, rv.Options)
|
|
}
|
|
if s := g.PendingSacrifice; s != nil {
|
|
fmt.Fprintf(b, " PENDING sacrifice by %s (source %s, tier %d), options %v\n",
|
|
playerName(g, s.PlayerID), s.Source, s.Tier, s.Options)
|
|
}
|
|
}
|
|
|
|
// writeBattle renders one battle: who fought, what they fielded, the dice it
|
|
// rolled, every event in order, and how it ended.
|
|
func writeBattle(b *strings.Builder, g *Game, i int, res *BattleResult) {
|
|
fmt.Fprintf(b, "\n-- battle %d: ", i)
|
|
if len(res.Seats) == 2 {
|
|
fmt.Fprintf(b, "seat %d (%s, first) v seat %d (%s) --\n",
|
|
res.Seats[0], seatName(g, res.Seats[0]), res.Seats[1], seatName(g, res.Seats[1]))
|
|
} else {
|
|
fmt.Fprintf(b, "seats %v --\n", res.Seats)
|
|
}
|
|
outcome := "a draw"
|
|
if res.WinnerSeat >= 0 {
|
|
outcome = fmt.Sprintf("seat %d (%s) won %d trophy(s)",
|
|
res.WinnerSeat, seatName(g, res.WinnerSeat), res.Trophies)
|
|
}
|
|
fmt.Fprintf(b, " outcome: %s · survivors by side %v\n", outcome, res.Survivors)
|
|
for side, lineup := range res.Lineups {
|
|
fmt.Fprintf(b, " side %d (seat %d) fielded, top of deck first:\n", side, res.SeatOf(side))
|
|
for j, c := range lineup {
|
|
fmt.Fprintf(b, " %d. %s\n", j+1, cardLine(c))
|
|
}
|
|
if side < len(res.Inputs) {
|
|
in := res.Inputs[side]
|
|
fmt.Fprintf(b, " started with: %d mana, %d trumpets, %d apples in play\n",
|
|
in.Mana, in.Trumpets, in.ApplesInPlay)
|
|
}
|
|
}
|
|
if res.Replayable() {
|
|
fmt.Fprintf(b, " dice tape (%d draws): %v\n", len(res.Draws), res.Draws)
|
|
} else {
|
|
b.WriteString(" dice tape: NOT RECORDED — this battle was played before the engine\n" +
|
|
" recorded its dice, so it can only be re-fought, not replayed\n")
|
|
}
|
|
fmt.Fprintf(b, " events (%d):\n", len(res.Events))
|
|
for j, ev := range res.Events {
|
|
fmt.Fprintf(b, " %3d %-9s side %d", j, ev.Type, ev.Seat)
|
|
if ev.Card != nil {
|
|
fmt.Fprintf(b, " %s", ev.Card.Name)
|
|
}
|
|
if len(ev.Dice) > 0 {
|
|
fmt.Fprintf(b, " dice=%v(%d)", ev.Dice, ev.Roll)
|
|
}
|
|
if len(ev.Damage) > 0 {
|
|
fmt.Fprintf(b, " dmg=%v died=%v", ev.Damage, ev.Died)
|
|
}
|
|
if ev.Count != 0 {
|
|
fmt.Fprintf(b, " count=%d", ev.Count)
|
|
}
|
|
if ev.Text != "" {
|
|
fmt.Fprintf(b, " · %s", ev.Text)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// cardLine describes one card in full: what it is, what it does, and the id the
|
|
// log and battle events refer to it by.
|
|
func cardLine(c Card) string {
|
|
var b strings.Builder
|
|
b.WriteString(c.Name)
|
|
switch {
|
|
case c.IsPet():
|
|
fmt.Fprintf(&b, " (pet, power %d", c.Power)
|
|
if c.Suit != "" {
|
|
fmt.Fprintf(&b, ", %s", c.Suit)
|
|
}
|
|
case c.IsAilment():
|
|
fmt.Fprintf(&b, " (ailment %s", c.Ailment)
|
|
default:
|
|
b.WriteString(" (food")
|
|
if c.Food != "" {
|
|
fmt.Fprintf(&b, " %s", c.Food)
|
|
}
|
|
if c.Perk {
|
|
b.WriteString(", perk")
|
|
}
|
|
}
|
|
if c.Tier > 0 {
|
|
fmt.Fprintf(&b, ", tier %d", c.Tier)
|
|
}
|
|
if c.Temporary {
|
|
b.WriteString(", temporary")
|
|
}
|
|
fmt.Fprintf(&b, ", id %s)", c.ID)
|
|
if c.EffectText != "" {
|
|
fmt.Fprintf(&b, " — %s", c.EffectText)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// cardNames lists cards by name alone, for places where the detail would drown
|
|
// the point (deck order, discard piles).
|
|
func cardNames(cards []Card) string {
|
|
names := make([]string, len(cards))
|
|
for i, c := range cards {
|
|
names[i] = c.Name
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func seatName(g *Game, seat int) string {
|
|
if seat < 0 || seat >= len(g.Players) {
|
|
return "?"
|
|
}
|
|
return g.Players[seat].Name
|
|
}
|
|
|
|
func seatList(g *Game, seats []int) string {
|
|
if len(seats) == 0 {
|
|
return "(none)"
|
|
}
|
|
names := make([]string, len(seats))
|
|
for i, s := range seats {
|
|
names[i] = fmt.Sprintf("seat %d (%s)", s, seatName(g, s))
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// identifier makes a string safe to paste into a Go function name.
|
|
func identifier(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToUpper(s) {
|
|
if r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
if b.Len() == 0 || b.String()[0] >= '0' && b.String()[0] <= '9' {
|
|
return "Game" + b.String()
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func playerName(g *Game, playerID string) string {
|
|
if p := g.PlayerByID(playerID); p != nil {
|
|
return fmt.Sprintf("seat %d (%s)", p.Seat, p.Name)
|
|
}
|
|
return playerID
|
|
}
|