Files
Greyson Parrelli 6ae4d41976 Put the bug report behind an always-available button.
The debug report was only reachable in DEBUG mode, which is the one mode
a player hitting a real bug won't be running. Move it to a "🐛 Report a
bug" item in the game menu: a note field, a download, and a copy-as-text,
on any server.

That knowingly shows the reporter their opponents' hands and the shop
deck order. Credentials are still required, so a report only ever reaches
someone seated at that table. The buy-any-card panel stays DEBUG-only —
it changes the game; a report only reads it.
2026-08-10 13:42:31 -04:00

522 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 — 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
}