Files
Greyson Parrelli 8ccde03023 Add replayable debug reports.
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.
2026-08-10 10:06:06 -04:00

93 lines
3.7 KiB
Go

package game
import "fmt"
// SimulateBattle resolves a hypothetical battle between the given arranged
// decks (top of deck first) and returns the result. deckA sits at seat 0 and
// deckB at seat 1; firstSeat (0 or 1) is the one holding priority. It runs on
// a scratch game and awards nothing, so it never touches real state — callers
// (notably the AI player) can roll out as many what-if battles as they like.
// Dice rolls are random unless rollDie is non-nil.
func SimulateBattle(round, firstSeat int, deckA, deckB []Card, rollDie func() int) *BattleResult {
g := &Game{
Round: round,
RollDie: rollDie,
// Cards minted during the simulation (apples, bees) get IDs far away
// from real ones, purely to avoid confusion when reading results.
NextCardID: 1_000_000,
Players: []*Player{
{Name: "A", Seat: 0, Deck: append([]Card(nil), deckA...)},
{Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)},
},
}
if firstSeat == 1 {
return g.runBattle(1, 0)
}
return g.runBattle(0, 1)
}
// ReplayResult re-runs a recorded battle from the result it produced. A
// BattleResult carries everything the fight started from — both lineups, each
// side's banked Mana/Trumpets/apples, and the tape of every die it rolled — so
// replaying one reproduces it move for move. That is what makes a debug report
// reproducible: drop the report in a test, replay the battle, and step through
// the same fight the player saw.
//
// The two sides keep their real seat numbers, so WinnerSeat means what it did
// in the original. Player names are not part of a result, though, so they come
// back as "Seat N" and every event's Text reads accordingly — compare
// structure, not prose. DebugReport.ReplayBattle, which has the real game to
// replay against, reproduces the text too. Returns nil for a malformed result.
func ReplayResult(res *BattleResult) *BattleResult {
if res == nil || len(res.Seats) != 2 || len(res.Lineups) != 2 {
return nil
}
g := &Game{Round: res.Round, NextCardID: res.StartCardID,
drawReplay: append([]int(nil), res.Draws...)}
if g.NextCardID == 0 {
g.NextCardID = 1_000_000
}
// Seat the fighters where they really sat: at a bigger table the two sides
// of one battle are not seats 0 and 1, and WinnerSeat is a table seat.
for seat := range max(res.Seats[0], res.Seats[1]) + 1 {
g.Players = append(g.Players, &Player{Name: fmt.Sprintf("Seat %d", seat), Seat: seat})
}
applyBattleInputs(g, res)
return g.runBattle(res.Seats[0], res.Seats[1])
}
// applyBattleInputs stages a game's players for a replay of res: each side's
// lineup and the banked resources it fought with.
func applyBattleInputs(g *Game, res *BattleResult) {
for side, seat := range res.Seats {
if seat < 0 || seat >= len(g.Players) {
continue
}
p := g.Players[seat]
p.Deck = append([]Card(nil), res.Lineups[side]...)
if side < len(res.Inputs) {
in := res.Inputs[side]
p.Mana, p.PendingTrumpets, p.PendingApplesInPlay = in.Mana, in.Trumpets, in.ApplesInPlay
}
}
}
// TierContents returns the full printed contents of a tier's shop deck for the
// default pack. Cards carry placeholder IDs; they are reference data, not live
// instances.
func TierContents(tier int) []Card {
return TierContentsForPacks([]string{DefaultPack}, tier)
}
// TierContentsForPacks returns the printed tier contents of a pack selection,
// combined the way the shop decks combine them — public information from the
// boxes. Used by the AI, which decides from a View that names its packs.
func TierContentsForPacks(packs []string, tier int) []Card {
scratch := &Game{Packs: packs}
scratch.buildShopDecks()
if tier < 1 || tier > len(scratch.ShopDecks) {
return nil
}
return scratch.ShopDecks[tier-1]
}