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
+12 -40
View File
@@ -287,27 +287,18 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int
// attack manages to hurt. A clash that changes nothing ends the battle as a
// stalemate.
//
// resolveBattle is the orchestrator: it re-runs the (deterministic) simulation
// from the recorded dice/decision tapes, publishing either a completed result
// or a suspended one awaiting a mid-battle decision (Golden pack: Nurse Shark).
// resolveBattle is the orchestrator: it runs the (deterministic) simulation and
// publishes the completed result.
func (g *Game) resolveBattle() {
g.NextCardID = g.BattleCardBase
g.battleRollCursor = 0
g.battleDecisionCursor = 0
res, pending := g.runBattle()
res := g.runBattle()
g.Battle = res
if pending != nil {
g.PendingBattle = pending
return
}
g.PendingBattle = nil
g.finalizeBattle(res)
}
// finalizeBattle applies the persistent effects of a completed battle: trophies,
// the priority token hand-off, the result log line, and clearing the per-round
// apples-in-play bank. Deferred here (not inside runBattle) because runBattle
// may re-run several times before the battle actually completes.
// apples-in-play bank. Kept separate from runBattle, which mutates no persistent
// player state.
func (g *Game) finalizeBattle(res *BattleResult) {
n := len(g.Players)
winner := res.WinnerSeat
@@ -332,13 +323,11 @@ func (g *Game) finalizeBattle(res *BattleResult) {
}
}
// runBattle plays the simulation to completion or until it needs a mid-battle
// decision, returning the (partial) result and a non-nil pending in the latter
// case. It mutates no persistent player state — that is finalizeBattle's job.
func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
// runBattle plays the simulation to completion, returning the result. It
// mutates no persistent player state — that is finalizeBattle's job.
func (g *Game) runBattle() *BattleResult {
n := len(g.Players)
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
var suspended *PendingBattleDecision
sides := make([]*battleSide, n)
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
// pname is the owning player's display name for a seat, for log text.
@@ -1190,19 +1179,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
Text: fmt.Sprintf("%s shuffles an apple into %s's deck.", q.unit.Card.Name, pname(q.seat))})
}
case ActionSpendRocks:
// Nurse Shark: the owner chooses how many Trumpets (0..available,
// capped at Count) to spend; each throws two rocks. This is the one
// mid-battle decision — it may suspend the whole simulation.
// Nurse Shark: spend as many Trumpets as available (up to Count) to
// throw two rocks each.
s := sides[q.seat]
maxSpend := min(q.effect.count(), s.trumpets)
choice, pending := g.decideBattle(PendingBattleDecision{
Seat: q.seat, Kind: "nurseShark", PetName: q.unit.Card.Name,
Min: 0, Max: maxSpend, Trumpets: s.trumpets,
})
if pending != nil {
suspended = pending
break
}
choice := min(q.effect.count(), s.trumpets)
if choice > 0 {
s.trumpets -= choice
emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice,
@@ -1214,14 +1194,6 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
}
}
}
if suspended != nil {
break // stop mid-plays; the battle re-runs once the choice is in
}
}
// A pending decision unwinds the whole simulation; the events emitted so
// far are a valid prefix the re-run reproduces exactly.
if suspended != nil {
return res, suspended
}
// Battle over? A side that can no longer field a pet is out (checked
// after play effects so parting shots land, using canField so a pet that
@@ -1337,5 +1309,5 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
res.Trophies = 2
}
}
return res, nil
return res
}
+2 -3
View File
@@ -185,9 +185,8 @@ const (
// ActionStripApples (play) discards every apple attached to the enemy pet
// in play (Durian perk) — like ActionStripFoods but apples only.
ActionStripApples EffectAction = "stripApples"
// ActionSpendRocks (play) asks the owner how many Trumpets to spend (0..Count)
// and throws twice that many rocks at the enemy (Nurse Shark). This is the
// one battle-time player decision.
// ActionSpendRocks (play) spends as many Trumpets as available (up to Count)
// and throws twice that many rocks at the enemy (Nurse Shark).
ActionSpendRocks EffectAction = "spendRocks"
// ActionFirstBuyFree (shop start) makes the player's first Buy this round
// cost no gold, if they hold Count or fewer pets (Manta Ray).
+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")
+32 -41
View File
@@ -2,11 +2,11 @@ package game
import "testing"
// --- Nurse Shark: the mid-battle decision channel ---
// --- Nurse Shark ---
// Nurse Shark suspends the battle to ask how many Trumpets to spend, then
// resumes and throws two rocks per Trumpet spent.
func TestNurseSharkSuspendsAndResumes(t *testing.T) {
// Nurse Shark automatically spends every available Trumpet (up to 3), throwing
// two rocks per Trumpet spent.
func TestNurseSharkSpendsAllTrumpets(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 2 } // each rock deals 2
res := forceBattle(t, g,
@@ -14,25 +14,9 @@ func TestNurseSharkSuspendsAndResumes(t *testing.T) {
[]Card{g.pet("Big", 4), g.pet("Tank", 6)},
)
// Nyala trades with Big (banking 2 Trumpets), then Nurse Shark enters and
// the battle suspends on its choice.
if g.PendingBattle == nil {
t.Fatal("expected the battle to suspend on Nurse Shark's choice")
}
if g.PendingBattle.Seat != 0 || g.PendingBattle.Max != 2 || g.PendingBattle.Kind != "nurseShark" {
t.Fatalf("unexpected pending decision: %+v", g.PendingBattle)
}
if res.WinnerSeat != -1 {
t.Fatalf("a suspended battle has no winner yet, got %d", res.WinnerSeat)
}
// Spend both Trumpets: 4 rocks (2+2+2+2 = 8) kill the 6-power Tank.
if err := g.BattleChoose(g.Players[0].ID, 2); err != nil {
t.Fatal(err)
}
if g.PendingBattle != nil {
t.Fatalf("battle should have completed: %+v", g.PendingBattle)
}
// spends both: 4 rocks (2+2+2+2 = 8) kill the 6-power Tank.
spends := 0
for _, ev := range eventsOfType(g.Battle, "trumpet") {
for _, ev := range eventsOfType(res, "trumpet") {
if ev.Count < 0 {
spends++
}
@@ -40,44 +24,51 @@ func TestNurseSharkSuspendsAndResumes(t *testing.T) {
if spends != 1 {
t.Fatalf("expected one trumpet spend, got %d", spends)
}
rocks := eventsOfType(g.Battle, "rock")
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || len(rocks[0].Dice) != 4 || !rocks[0].TargetDied {
t.Fatalf("Nurse Shark should throw 4 rocks (2 per Trumpet) and kill Tank: %+v", rocks)
}
if g.Battle.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat)
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Choosing to spend zero Trumpets throws no rocks.
func TestNurseSharkSpendZero(t *testing.T) {
// The spend is capped at 3 Trumpets even when more are banked.
func TestNurseSharkCapsAtThree(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 2 }
forceBattle(t, g,
[]Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Nurse Shark")},
[]Card{g.pet("Big", 4), g.pet("Tank", 6)},
g.RollDie = func() int { return 1 } // each rock deals 1
g.Players[0].PendingTrumpets = 5 // seed a pool larger than the cap
res := forceBattle(t, g,
[]Card{g.goldenPet(t, "Nurse Shark")},
[]Card{g.pet("Wall", 20)},
)
if g.PendingBattle == nil {
t.Fatal("expected a pending decision")
var spend *BattleEvent
for i, ev := range res.Events {
if ev.Type == "trumpet" && ev.Count < 0 {
spend = &res.Events[i]
}
}
if err := g.BattleChoose(g.Players[0].ID, 0); err != nil {
t.Fatal(err)
if spend == nil || spend.Count != -3 {
t.Fatalf("Nurse Shark should spend exactly 3 Trumpets: %+v", eventsOfType(res, "trumpet"))
}
if len(eventsOfType(g.Battle, "rock")) != 0 {
t.Fatal("spending zero Trumpets should throw no rocks")
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || len(rocks[0].Dice) != 6 {
t.Fatalf("spending 3 Trumpets should throw 6 rocks: %+v", rocks)
}
}
// With no Trumpets, Nurse Shark's choice is trivial (max 0) and the rollout
// path auto-resolves without suspending.
func TestNurseSharkAutoResolvesInRollout(t *testing.T) {
// With no Trumpets, Nurse Shark throws no rocks and the battle still resolves.
func TestNurseSharkNoTrumpets(t *testing.T) {
res := SimulateBattle(1, 0,
[]Card{cardWithName("Nurse Shark", 3, []Effect{{Trigger: TriggerPlay, Action: ActionSpendRocks, Count: 3}})},
[]Card{{ID: "x", Kind: KindPet, Name: "Foe", Power: 2}},
func() int { return 2 },
)
if res == nil || res.WinnerSeat < -1 {
t.Fatalf("rollout should complete without suspending: %+v", res)
t.Fatalf("battle should resolve: %+v", res)
}
if len(eventsOfType(res, "rock")) != 0 {
t.Fatal("with no Trumpets, Nurse Shark should throw no rocks")
}
}
-3
View File
@@ -17,9 +17,6 @@ func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func()
{Name: "A", Seat: 0, Deck: append([]Card(nil), deckA...)},
{Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)},
},
// Rollouts never pause for a mid-battle decision; a fixed policy resolves
// them (Golden pack: Nurse Shark).
autoBattleDecide: true,
}
g.startBattle()
return g.Battle
+2 -6
View File
@@ -53,11 +53,8 @@ type View struct {
// see a reveal is in progress; the eligible options are only sent to the
// buyer (they name the buyer's own hidden pets).
PendingReveal *PendingReveal `json:"pendingReveal,omitempty"`
// PendingBattle (Golden pack: Nurse Shark) is a mid-battle decision owed by
// one seat; public since the battle replay is public.
PendingBattle *PendingBattleDecision `json:"pendingBattle,omitempty"`
Battle *BattleResult `json:"battle,omitempty"`
WinnerSeat int `json:"winnerSeat"`
Battle *BattleResult `json:"battle,omitempty"`
WinnerSeat int `json:"winnerSeat"`
// Log is the shared, public event log shown across every phase.
Log []LogEntry `json:"log,omitempty"`
// Debug is set by the server when its DEBUG flag is on, unlocking the
@@ -125,7 +122,6 @@ func (g *Game) ViewFor(playerID string) View {
}
v.PendingReveal = &reveal
}
v.PendingBattle = g.PendingBattle
// Battle results (lineups, events) are public once resolved. Keep the
// battle around during the following shop phase too, so late joiners /
// reconnects can still see the last result.