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
+2 -3
View File
@@ -115,9 +115,8 @@ The Golden pack adds mechanics the Turtle pack doesn't have:
- **Golden Retriever** — once per battle, a side that runs out of cards but - **Golden Retriever** — once per battle, a side that runs out of cards but
still holds Trumpets fields one, its power equal to those Trumpets. still holds Trumpets fields one, its power equal to those Trumpets.
- **Avocado** — a persistent set-aside token you can discard in place of gold. - **Avocado** — a persistent set-aside token you can discard in place of gold.
- **Nurse Shark** — the one interactive battle moment: the fight pauses and - **Nurse Shark** — on entry it spends every Trumpet it can (up to 3),
asks how many Trumpets to spend on rocks (the bot answers with a fixed throwing two rocks at the enemy per Trumpet spent.
policy, so simulated rollouts stay valid).
- **Cockatoo** — a shop-time reveal, plus Manta Ray's free first buy, - **Cockatoo** — a shop-time reveal, plus Manta Ray's free first buy,
Blue-Ringed Octopus' per-buy apples, and more. Blue-Ringed Octopus' per-buy apples, and more.
+1 -14
View File
@@ -35,13 +35,12 @@ import (
// Action is one move the bot wants to make, mirroring the client protocol. // Action is one move the bot wants to make, mirroring the client protocol.
type Action struct { type Action struct {
Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose | battleChoose Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose
Row int // buy / buyAvocado Row int // buy / buyAvocado
Cards []string // sell / trade Cards []string // sell / trade
Pick int // tradeChoose Pick int // tradeChoose
Order []string // arrange Order []string // arrange
CardID string // revealChoose (Cockatoo): the pet to reveal CardID string // revealChoose (Cockatoo): the pet to reveal
Value int // battleChoose (Nurse Shark): Trumpets to spend
} }
// Bot is a computer player at a fixed difficulty level. // Bot is a computer player at a fixed difficulty level.
@@ -86,13 +85,6 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
return b.decideArrange(v, mem) return b.decideArrange(v, mem)
} }
case game.PhaseBattle: case game.PhaseBattle:
if v.PendingBattle != nil {
if v.PendingBattle.Seat == v.YouSeat {
// Spend as many Trumpets as allowed — more rocks is better.
return &Action{Type: "battleChoose", Value: v.PendingBattle.Max}
}
return nil
}
if !me.Ready { if !me.Ready {
return &Action{Type: "ready"} return &Action{Type: "ready"}
} }
@@ -134,11 +126,6 @@ func Pending(v *game.View) bool {
case game.PhaseArrange: case game.PhaseArrange:
return !me.Ready return !me.Ready
case game.PhaseBattle: case game.PhaseBattle:
// A pending mid-battle decision is owed only by the deciding seat;
// otherwise everyone owes the battle acknowledgement.
if v.PendingBattle != nil {
return v.PendingBattle.Seat == v.YouSeat
}
return !me.Ready return !me.Ready
} }
return false return false
-2
View File
@@ -100,8 +100,6 @@ func applyAction(g *game.Game, playerID string, a *Action) error {
return g.TradeChoose(playerID, a.Pick) return g.TradeChoose(playerID, a.Pick)
case "revealChoose": case "revealChoose":
return g.RevealChoose(playerID, a.CardID) return g.RevealChoose(playerID, a.CardID)
case "battleChoose":
return g.BattleChoose(playerID, a.Value)
case "pass": case "pass":
return g.Pass(playerID) return g.Pass(playerID)
case "arrange": case "arrange":
+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 // attack manages to hurt. A clash that changes nothing ends the battle as a
// stalemate. // stalemate.
// //
// resolveBattle is the orchestrator: it re-runs the (deterministic) simulation // resolveBattle is the orchestrator: it runs the (deterministic) simulation and
// from the recorded dice/decision tapes, publishing either a completed result // publishes the completed result.
// or a suspended one awaiting a mid-battle decision (Golden pack: Nurse Shark).
func (g *Game) resolveBattle() { func (g *Game) resolveBattle() {
g.NextCardID = g.BattleCardBase res := g.runBattle()
g.battleRollCursor = 0
g.battleDecisionCursor = 0
res, pending := g.runBattle()
g.Battle = res g.Battle = res
if pending != nil {
g.PendingBattle = pending
return
}
g.PendingBattle = nil
g.finalizeBattle(res) g.finalizeBattle(res)
} }
// finalizeBattle applies the persistent effects of a completed battle: trophies, // finalizeBattle applies the persistent effects of a completed battle: trophies,
// the priority token hand-off, the result log line, and clearing the per-round // 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 // apples-in-play bank. Kept separate from runBattle, which mutates no persistent
// may re-run several times before the battle actually completes. // player state.
func (g *Game) finalizeBattle(res *BattleResult) { func (g *Game) finalizeBattle(res *BattleResult) {
n := len(g.Players) n := len(g.Players)
winner := res.WinnerSeat 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 // runBattle plays the simulation to completion, returning the result. It
// decision, returning the (partial) result and a non-nil pending in the latter // mutates no persistent player state — that is finalizeBattle's job.
// case. It mutates no persistent player state — that is finalizeBattle's job. func (g *Game) runBattle() *BattleResult {
func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
n := len(g.Players) n := len(g.Players)
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)} res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
var suspended *PendingBattleDecision
sides := make([]*battleSide, n) sides := make([]*battleSide, n)
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) } emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
// pname is the owning player's display name for a seat, for log text. // 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))}) Text: fmt.Sprintf("%s shuffles an apple into %s's deck.", q.unit.Card.Name, pname(q.seat))})
} }
case ActionSpendRocks: case ActionSpendRocks:
// Nurse Shark: the owner chooses how many Trumpets (0..available, // Nurse Shark: spend as many Trumpets as available (up to Count) to
// capped at Count) to spend; each throws two rocks. This is the one // throw two rocks each.
// mid-battle decision — it may suspend the whole simulation.
s := sides[q.seat] s := sides[q.seat]
maxSpend := min(q.effect.count(), s.trumpets) choice := 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
}
if choice > 0 { if choice > 0 {
s.trumpets -= choice s.trumpets -= choice
emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -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 // 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 // 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 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 // ActionStripApples (play) discards every apple attached to the enemy pet
// in play (Durian perk) — like ActionStripFoods but apples only. // in play (Durian perk) — like ActionStripFoods but apples only.
ActionStripApples EffectAction = "stripApples" ActionStripApples EffectAction = "stripApples"
// ActionSpendRocks (play) asks the owner how many Trumpets to spend (0..Count) // ActionSpendRocks (play) spends as many Trumpets as available (up to Count)
// and throws twice that many rocks at the enemy (Nurse Shark). This is the // and throws twice that many rocks at the enemy (Nurse Shark).
// one battle-time player decision.
ActionSpendRocks EffectAction = "spendRocks" ActionSpendRocks EffectAction = "spendRocks"
// ActionFirstBuyFree (shop start) makes the player's first Buy this round // ActionFirstBuyFree (shop start) makes the player's first Buy this round
// cost no gold, if they hold Count or fewer pets (Manta Ray). // 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"` Log []LogEntry `json:"log,omitempty"`
LogSeq int `json:"logSeq"` // last assigned entry sequence number 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 // 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. // (including after loading from storage) means a fair random roll.
RollDie func() int `json:"-"` 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 // battleDraw returns a random value in [0, n) for a battle's randomness — rock
// Shark). The battle suspends until BattleChoose supplies a value in [Min, Max]. // dice and Komodo's apple shuffle alike. The RollDie test override applies to
type PendingBattleDecision struct { // rock dice (n == 3).
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.
func (g *Game) battleDraw(n int) int { 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 { switch {
case n <= 0: case n <= 0:
v = 0 return 0
case n == 3 && g.RollDie != nil: 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: 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. // rollRockDie rolls one rock die: 0, 1, or 2 with equal probability.
func (g *Game) rollRockDie() int { return g.battleDraw(3) } 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 ( var (
ErrNotYourTurn = errors.New("not your turn") ErrNotYourTurn = errors.New("not your turn")
ErrWrongPhase = errors.New("action not allowed in this phase") ErrWrongPhase = errors.New("action not allowed in this phase")
@@ -962,14 +894,8 @@ func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error {
return nil return nil
} }
// startBattle enters the battle phase and resolves it. It snapshots the card-id // startBattle enters the battle phase and resolves it.
// base and clears the dice/decision tapes so the (possibly resumable) battle
// re-runs deterministically as decisions come in.
func (g *Game) startBattle() { func (g *Game) startBattle() {
g.BattleCardBase = g.NextCardID
g.BattleDice = nil
g.BattleDecisions = nil
g.PendingBattle = nil
for _, p := range g.Players { for _, p := range g.Players {
p.Ready = false p.Ready = false
} }
@@ -977,29 +903,12 @@ func (g *Game) startBattle() {
g.resolveBattle() 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 // AcknowledgeBattle marks the player done reviewing the battle. When all
// players acknowledge, the next round starts (or the game ends). // players acknowledge, the next round starts (or the game ends).
func (g *Game) AcknowledgeBattle(playerID string) error { func (g *Game) AcknowledgeBattle(playerID string) error {
if g.Phase != PhaseBattle { if g.Phase != PhaseBattle {
return ErrWrongPhase return ErrWrongPhase
} }
if g.PendingBattle != nil {
return fmt.Errorf("%w: a battle decision is still pending", ErrInvalidAction)
}
p := g.PlayerByID(playerID) p := g.PlayerByID(playerID)
if p == nil { if p == nil {
return errors.New("unknown player") return errors.New("unknown player")
+32 -41
View File
@@ -2,11 +2,11 @@ package game
import "testing" import "testing"
// --- Nurse Shark: the mid-battle decision channel --- // --- Nurse Shark ---
// Nurse Shark suspends the battle to ask how many Trumpets to spend, then // Nurse Shark automatically spends every available Trumpet (up to 3), throwing
// resumes and throws two rocks per Trumpet spent. // two rocks per Trumpet spent.
func TestNurseSharkSuspendsAndResumes(t *testing.T) { func TestNurseSharkSpendsAllTrumpets(t *testing.T) {
g, _, _ := testGame(t) g, _, _ := testGame(t)
g.RollDie = func() int { return 2 } // each rock deals 2 g.RollDie = func() int { return 2 } // each rock deals 2
res := forceBattle(t, g, res := forceBattle(t, g,
@@ -14,25 +14,9 @@ func TestNurseSharkSuspendsAndResumes(t *testing.T) {
[]Card{g.pet("Big", 4), g.pet("Tank", 6)}, []Card{g.pet("Big", 4), g.pet("Tank", 6)},
) )
// Nyala trades with Big (banking 2 Trumpets), then Nurse Shark enters and // Nyala trades with Big (banking 2 Trumpets), then Nurse Shark enters and
// the battle suspends on its choice. // spends both: 4 rocks (2+2+2+2 = 8) kill the 6-power Tank.
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 := 0 spends := 0
for _, ev := range eventsOfType(g.Battle, "trumpet") { for _, ev := range eventsOfType(res, "trumpet") {
if ev.Count < 0 { if ev.Count < 0 {
spends++ spends++
} }
@@ -40,44 +24,51 @@ func TestNurseSharkSuspendsAndResumes(t *testing.T) {
if spends != 1 { if spends != 1 {
t.Fatalf("expected one trumpet spend, got %d", spends) 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 { 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) t.Fatalf("Nurse Shark should throw 4 rocks (2 per Trumpet) and kill Tank: %+v", rocks)
} }
if g.Battle.WinnerSeat != 0 { if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat) t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
} }
} }
// Choosing to spend zero Trumpets throws no rocks. // The spend is capped at 3 Trumpets even when more are banked.
func TestNurseSharkSpendZero(t *testing.T) { func TestNurseSharkCapsAtThree(t *testing.T) {
g, _, _ := testGame(t) g, _, _ := testGame(t)
g.RollDie = func() int { return 2 } g.RollDie = func() int { return 1 } // each rock deals 1
forceBattle(t, g, g.Players[0].PendingTrumpets = 5 // seed a pool larger than the cap
[]Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Nurse Shark")}, res := forceBattle(t, g,
[]Card{g.pet("Big", 4), g.pet("Tank", 6)}, []Card{g.goldenPet(t, "Nurse Shark")},
[]Card{g.pet("Wall", 20)},
) )
if g.PendingBattle == nil { var spend *BattleEvent
t.Fatal("expected a pending decision") 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 { if spend == nil || spend.Count != -3 {
t.Fatal(err) t.Fatalf("Nurse Shark should spend exactly 3 Trumpets: %+v", eventsOfType(res, "trumpet"))
} }
if len(eventsOfType(g.Battle, "rock")) != 0 { rocks := eventsOfType(res, "rock")
t.Fatal("spending zero Trumpets should throw no rocks") 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 // With no Trumpets, Nurse Shark throws no rocks and the battle still resolves.
// path auto-resolves without suspending. func TestNurseSharkNoTrumpets(t *testing.T) {
func TestNurseSharkAutoResolvesInRollout(t *testing.T) {
res := SimulateBattle(1, 0, res := SimulateBattle(1, 0,
[]Card{cardWithName("Nurse Shark", 3, []Effect{{Trigger: TriggerPlay, Action: ActionSpendRocks, Count: 3}})}, []Card{cardWithName("Nurse Shark", 3, []Effect{{Trigger: TriggerPlay, Action: ActionSpendRocks, Count: 3}})},
[]Card{{ID: "x", Kind: KindPet, Name: "Foe", Power: 2}}, []Card{{ID: "x", Kind: KindPet, Name: "Foe", Power: 2}},
func() int { return 2 }, func() int { return 2 },
) )
if res == nil || res.WinnerSeat < -1 { 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: "A", Seat: 0, Deck: append([]Card(nil), deckA...)},
{Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)}, {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() g.startBattle()
return g.Battle 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 // see a reveal is in progress; the eligible options are only sent to the
// buyer (they name the buyer's own hidden pets). // buyer (they name the buyer's own hidden pets).
PendingReveal *PendingReveal `json:"pendingReveal,omitempty"` PendingReveal *PendingReveal `json:"pendingReveal,omitempty"`
// PendingBattle (Golden pack: Nurse Shark) is a mid-battle decision owed by Battle *BattleResult `json:"battle,omitempty"`
// one seat; public since the battle replay is public. WinnerSeat int `json:"winnerSeat"`
PendingBattle *PendingBattleDecision `json:"pendingBattle,omitempty"`
Battle *BattleResult `json:"battle,omitempty"`
WinnerSeat int `json:"winnerSeat"`
// Log is the shared, public event log shown across every phase. // Log is the shared, public event log shown across every phase.
Log []LogEntry `json:"log,omitempty"` Log []LogEntry `json:"log,omitempty"`
// Debug is set by the server when its DEBUG flag is on, unlocking the // 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.PendingReveal = &reveal
} }
v.PendingBattle = g.PendingBattle
// Battle results (lineups, events) are public once resolved. Keep the // Battle results (lineups, events) are public once resolved. Keep the
// battle around during the following shop phase too, so late joiners / // battle around during the following shop phase too, so late joiners /
// reconnects can still see the last result. // reconnects can still see the last result.
-5
View File
@@ -157,8 +157,6 @@ func applyBotAction(g *game.Game, playerID string, a *ai.Action) error {
return g.TradeChoose(playerID, a.Pick) return g.TradeChoose(playerID, a.Pick)
case "revealChoose": case "revealChoose":
return g.RevealChoose(playerID, a.CardID) return g.RevealChoose(playerID, a.CardID)
case "battleChoose":
return g.BattleChoose(playerID, a.Value)
case "pass": case "pass":
return g.Pass(playerID) return g.Pass(playerID)
case "arrange": case "arrange":
@@ -203,9 +201,6 @@ func botFallback(g *game.Game, playerID string) error {
} }
return g.SubmitOrder(playerID, ids) return g.SubmitOrder(playerID, ids)
case game.PhaseBattle: case game.PhaseBattle:
if g.PendingBattle != nil && g.Players[g.PendingBattle.Seat].ID == playerID {
return g.BattleChoose(playerID, g.PendingBattle.Max)
}
return g.AcknowledgeBattle(playerID) return g.AcknowledgeBattle(playerID)
} }
return game.ErrInvalidAction return game.ErrInvalidAction
-3
View File
@@ -32,7 +32,6 @@ type clientMessage struct {
Difficulty string `json:"difficulty"` // addBot Difficulty string `json:"difficulty"` // addBot
Target string `json:"target"` // removePlayer (player ID) Target string `json:"target"` // removePlayer (player ID)
Card string `json:"card"` // revealChoose (Cockatoo): pet card id Card string `json:"card"` // revealChoose (Cockatoo): pet card id
Value int `json:"value"` // battleChoose (Nurse Shark): Trumpets to spend
} }
type serverMessage struct { type serverMessage struct {
@@ -152,8 +151,6 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
err = g.TradeChoose(c.playerID, msg.Pick) err = g.TradeChoose(c.playerID, msg.Pick)
case "revealChoose": case "revealChoose":
err = g.RevealChoose(c.playerID, msg.Card) err = g.RevealChoose(c.playerID, msg.Card)
case "battleChoose":
err = g.BattleChoose(c.playerID, msg.Value)
case "pass": case "pass":
err = g.Pass(c.playerID) err = g.Pass(c.playerID)
case "arrange": case "arrange":
+26 -83
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import type { BattleEvent, Card, ClientMessage, GameView, PendingBattleDecision } from '../types' import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import { CardView } from './CardView' import { CardView } from './CardView'
import { DiceRoll, ROLL_MS } from './DiceRoll' import { DiceRoll, ROLL_MS } from './DiceRoll'
@@ -553,94 +553,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
) )
})()} })()}
{done && view.pendingBattle ? ( {done && (
<BattleDecision <div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
pd={view.pendingBattle} <div className="battle-result-title">
youSeat={youSeat} {draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
oppName={opp?.name ?? 'Opponent'}
send={send}
/>
) : (
done && (
<div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
<div className="battle-result-title">
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
</div>
{!draw && (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins{' '}
{'🏆'.repeat(battle.trophies)}
</div>
)}
{draw && <div className="battle-result-sub">No trophies awarded</div>}
{acked ? (
<p className="muted">Waiting for opponent</p>
) : (
<button
className="btn btn-primary btn-big"
onClick={() => {
setAcked(true)
send({ type: 'ready' })
}}
>
{battle.round >= view.maxRounds ? 'See final results' : 'Next round →'}
</button>
)}
</div> </div>
) {!draw && (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins{' '}
{'🏆'.repeat(battle.trophies)}
</div>
)}
{draw && <div className="battle-result-sub">No trophies awarded</div>}
{acked ? (
<p className="muted">Waiting for opponent</p>
) : (
<button
className="btn btn-primary btn-big"
onClick={() => {
setAcked(true)
send({ type: 'ready' })
}}
>
{battle.round >= view.maxRounds ? 'See final results' : 'Next round →'}
</button>
)}
</div>
)} )}
</div> </div>
) )
} }
// BattleDecision is the mid-battle prompt (Golden pack: Nurse Shark). The
// deciding player picks how many Trumpets to spend; the other player waits.
function BattleDecision({
pd,
youSeat,
oppName,
send,
}: {
pd: PendingBattleDecision
youSeat: number
oppName: string
send: (msg: ClientMessage) => void
}) {
const [sent, setSent] = useState(false)
// Reset when a fresh decision arrives (e.g. a second Nurse Shark).
useEffect(() => setSent(false), [pd.seat, pd.trumpets, pd.max])
if (pd.seat !== youSeat) {
return (
<div className="battle-result">
<p className="muted">{oppName} is deciding {pd.petName}</p>
</div>
)
}
return (
<div className="battle-result battle-decision">
<div className="battle-result-title">{pd.petName}</div>
<div className="battle-result-sub">
Spend Trumpets to throw 2 🪨 each you hold {pd.trumpets} 🎺
</div>
<div className="battle-decision-options">
{Array.from({ length: pd.max + 1 }, (_, n) => (
<button
key={n}
className="btn btn-primary"
disabled={sent}
onClick={() => {
setSent(true)
send({ type: 'battleChoose', value: n })
}}
>
{n === 0 ? 'Spend none' : `${n} 🎺 → ${2 * n} 🪨`}
</button>
))}
</div>
</div>
)
}
// clashDamageTaken computes how much damage a seat's pet took in the clash // clashDamageTaken computes how much damage a seat's pet took in the clash
// at event index `idx` (its damage total there minus its total beforehand). // at event index `idx` (its damage total there minus its total beforehand).
function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number { function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number {
-13
View File
@@ -1410,19 +1410,6 @@ h3 {
color: var(--gold); color: var(--gold);
} }
/* Mid-battle decision panel (Golden pack: Nurse Shark). */
.battle-decision-options {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
margin-top: 12px;
}
.battle-decision-options .btn {
font-family: var(--font-display);
}
/* Set-aside Avocado tokens (Golden pack): a slim tray with a toggle pill. */ /* Set-aside Avocado tokens (Golden pack): a slim tray with a toggle pill. */
.avocado-zone { .avocado-zone {
background: rgba(0, 0, 0, 0.24); background: rgba(0, 0, 0, 0.24);
-12
View File
@@ -48,16 +48,6 @@ export interface PendingReveal {
options?: string[] // eligible pet card ids (buyer only) options?: string[] // eligible pet card ids (buyer only)
} }
// Nurse Shark (Golden pack): a mid-battle Trumpet-spend choice.
export interface PendingBattleDecision {
seat: number
kind: string
petName: string
min: number
max: number
trumpets: number
}
export interface BattleEvent { export interface BattleEvent {
type: type:
| 'prep' | 'prep'
@@ -140,7 +130,6 @@ export interface GameView {
players: PlayerView[] players: PlayerView[]
pending?: PendingTrade pending?: PendingTrade
pendingReveal?: PendingReveal pendingReveal?: PendingReveal
pendingBattle?: PendingBattleDecision
battle?: BattleResult battle?: BattleResult
winnerSeat: number winnerSeat: number
log?: LogEntry[] log?: LogEntry[]
@@ -158,7 +147,6 @@ export type ClientMessage =
| { type: 'trade'; cards: string[] } | { type: 'trade'; cards: string[] }
| { type: 'tradeChoose'; pick: number } | { type: 'tradeChoose'; pick: number }
| { type: 'revealChoose'; card: string } | { type: 'revealChoose'; card: string }
| { type: 'battleChoose'; value: number }
| { type: 'pass' } | { type: 'pass' }
| { type: 'arrange'; order: string[] } | { type: 'arrange'; order: string[] }
| { type: 'ready' } | { type: 'ready' }