Fix nurse shark and remove mid-battle choice.

This commit is contained in:
Greyson Parrelli
2026-07-24 10:12:17 -04:00
parent 094f38593e
commit 0fe0757e18
14 changed files with 84 additions and 326 deletions
+7 -98
View File
@@ -137,96 +137,28 @@ type Game struct {
Log []LogEntry `json:"log,omitempty"`
LogSeq int `json:"logSeq"` // last assigned entry sequence number
// --- Golden pack: resumable battle (Nurse Shark's mid-battle choice) ---
// A battle is deterministic given its decks, the recorded dice tape, and the
// recorded decisions, so it can be re-run from scratch each time a decision
// is made. BattleCardBase snapshots NextCardID at battle start so re-runs
// mint identical ephemeral card ids.
BattleDice []int `json:"battleDice,omitempty"`
BattleDecisions []int `json:"battleDecisions,omitempty"`
BattleCardBase int `json:"battleCardBase,omitempty"`
PendingBattle *PendingBattleDecision `json:"pendingBattle,omitempty"`
// RollDie overrides the rock die (faces 0,0,1,1,2,2) for tests. Nil
// (including after loading from storage) means a fair random roll.
RollDie func() int `json:"-"`
// Transient per-run battle state (not serialized): the tape cursors and the
// rollout auto-decide flag.
battleRollCursor int
battleDecisionCursor int
autoBattleDecide bool
}
// PendingBattleDecision is a choice a player owes mid-battle (Golden pack: Nurse
// Shark). The battle suspends until BattleChoose supplies a value in [Min, Max].
type PendingBattleDecision struct {
Seat int `json:"seat"`
Kind string `json:"kind"` // "nurseShark"
PetName string `json:"petName"` // for the prompt
Min int `json:"min"`
Max int `json:"max"`
Trumpets int `json:"trumpets"` // the side's current Trumpet pool
}
// battleDraw returns a random value in [0, n), replaying from the recorded
// battle tape when re-running a battle and recording fresh draws otherwise.
// This keeps re-runs (after a mid-battle decision) deterministic across every
// source of battle randomness — rock dice and Komodo's apple shuffle alike.
// battleDraw returns a random value in [0, n) for a battle's randomness — rock
// dice and Komodo's apple shuffle alike. The RollDie test override applies to
// rock dice (n == 3).
func (g *Game) battleDraw(n int) int {
if g.battleRollCursor < len(g.BattleDice) {
v := g.BattleDice[g.battleRollCursor]
g.battleRollCursor++
return v
}
var v int
switch {
case n <= 0:
v = 0
return 0
case n == 3 && g.RollDie != nil:
v = g.RollDie() // test override applies to rock dice
return g.RollDie() // test override applies to rock dice
default:
v = randInt(n)
return randInt(n)
}
g.BattleDice = append(g.BattleDice, v)
g.battleRollCursor++
return v
}
// rollRockDie rolls one rock die: 0, 1, or 2 with equal probability.
func (g *Game) rollRockDie() int { return g.battleDraw(3) }
// decideBattle resolves a mid-battle decision. In rollouts it auto-picks; when
// replaying it reads the recorded decision; otherwise it signals a suspend by
// returning a non-nil pending for the caller to surface.
func (g *Game) decideBattle(pd PendingBattleDecision) (int, *PendingBattleDecision) {
if g.autoBattleDecide {
return autoBattleChoice(pd), nil
}
if g.battleDecisionCursor < len(g.BattleDecisions) {
v := clampInt(g.BattleDecisions[g.battleDecisionCursor], pd.Min, pd.Max)
g.battleDecisionCursor++
return v, nil
}
return 0, &pd
}
// autoBattleChoice is the fixed policy used in rollouts and by bots: for Nurse
// Shark, spend as many Trumpets as allowed (more rocks is better).
func autoBattleChoice(pd PendingBattleDecision) int {
return pd.Max
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
var (
ErrNotYourTurn = errors.New("not your turn")
ErrWrongPhase = errors.New("action not allowed in this phase")
@@ -962,14 +894,8 @@ func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error {
return nil
}
// startBattle enters the battle phase and resolves it. It snapshots the card-id
// base and clears the dice/decision tapes so the (possibly resumable) battle
// re-runs deterministically as decisions come in.
// startBattle enters the battle phase and resolves it.
func (g *Game) startBattle() {
g.BattleCardBase = g.NextCardID
g.BattleDice = nil
g.BattleDecisions = nil
g.PendingBattle = nil
for _, p := range g.Players {
p.Ready = false
}
@@ -977,29 +903,12 @@ func (g *Game) startBattle() {
g.resolveBattle()
}
// BattleChoose supplies a value for the pending mid-battle decision (Golden
// pack: Nurse Shark) and re-runs the battle from the recorded tape.
func (g *Game) BattleChoose(playerID string, value int) error {
if g.PendingBattle == nil {
return fmt.Errorf("%w: no battle decision pending", ErrInvalidAction)
}
if g.Players[g.PendingBattle.Seat].ID != playerID {
return ErrNotYourTurn
}
g.BattleDecisions = append(g.BattleDecisions, clampInt(value, g.PendingBattle.Min, g.PendingBattle.Max))
g.resolveBattle()
return nil
}
// AcknowledgeBattle marks the player done reviewing the battle. When all
// players acknowledge, the next round starts (or the game ends).
func (g *Game) AcknowledgeBattle(playerID string) error {
if g.Phase != PhaseBattle {
return ErrWrongPhase
}
if g.PendingBattle != nil {
return fmt.Errorf("%w: a battle decision is still pending", ErrInvalidAction)
}
p := g.PlayerByID(playerID)
if p == nil {
return errors.New("unknown player")