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 — and that is an accepted trade: the in-game "Report a bug" button // hands a player their own report on any server, DEBUG or not, because a bug // nobody can reproduce costs more than the little a cheat would gain. // `go run ./cmd/report` reads the same report out of the database. // 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 }