Fix nurse shark and remove mid-battle choice.
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
if len(eventsOfType(g.Battle, "rock")) != 0 {
|
if spend == nil || spend.Count != -3 {
|
||||||
t.Fatal("spending zero Trumpets should throw no rocks")
|
t.Fatalf("Nurse Shark should spend exactly 3 Trumpets: %+v", eventsOfType(res, "trumpet"))
|
||||||
|
}
|
||||||
|
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
|
// 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ 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
|
|
||||||
// one seat; public since the battle replay is public.
|
|
||||||
PendingBattle *PendingBattleDecision `json:"pendingBattle,omitempty"`
|
|
||||||
Battle *BattleResult `json:"battle,omitempty"`
|
Battle *BattleResult `json:"battle,omitempty"`
|
||||||
WinnerSeat int `json:"winnerSeat"`
|
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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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,15 +553,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{done && view.pendingBattle ? (
|
{done && (
|
||||||
<BattleDecision
|
|
||||||
pd={view.pendingBattle}
|
|
||||||
youSeat={youSeat}
|
|
||||||
oppName={opp?.name ?? 'Opponent'}
|
|
||||||
send={send}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
done && (
|
|
||||||
<div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
|
<div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
|
||||||
<div className="battle-result-title">
|
<div className="battle-result-title">
|
||||||
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
|
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
|
||||||
@@ -587,60 +579,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
Reference in New Issue
Block a user