Initial pass at Golden Pack.
This commit is contained in:
+337
-12
@@ -49,6 +49,23 @@ type Player struct {
|
||||
// TripledThisRound records whether the player used the Triple (trade-in)
|
||||
// action during the current round's shop (Bison's Battle Prep).
|
||||
TripledThisRound bool `json:"tripledThisRound"`
|
||||
// Avocados (Golden pack) is the player's stash of set-aside Avocado tokens,
|
||||
// each discardable in place of a coin to buy a card. It persists across
|
||||
// rounds and is not part of the deck.
|
||||
Avocados int `json:"avocados,omitempty"`
|
||||
// PendingApplesInPlay (Golden pack) is apples banked this round by a sold
|
||||
// Hercules Beetle; the next battle starts the player's first pet with that
|
||||
// many apples, then it resets.
|
||||
PendingApplesInPlay int `json:"pendingApplesInPlay,omitempty"`
|
||||
// FirstBuyFree (Golden pack: Manta Ray) waives the gold cost of the
|
||||
// player's first Buy this round; set at shop start, cleared when used.
|
||||
FirstBuyFree bool `json:"firstBuyFree,omitempty"`
|
||||
// BuysThisRound (Golden pack: Blue-Ringed Octopus) counts Buy actions the
|
||||
// player has taken in the current shop round.
|
||||
BuysThisRound int `json:"buysThisRound,omitempty"`
|
||||
// PendingTrumpets (Golden pack: Bird of Paradise) is Trumpets the next
|
||||
// battle starts with in the pool; reset after that battle.
|
||||
PendingTrumpets int `json:"pendingTrumpets,omitempty"`
|
||||
// IsBot marks a computer-controlled seat. The engine treats bots exactly
|
||||
// like humans; the server drives their actions. BotLevel is the bot's
|
||||
// skill in [0, 1]; BotMemory is the bot's private notebook, opaque to the
|
||||
@@ -81,6 +98,15 @@ type PendingTrade struct {
|
||||
Options [2]Card `json:"options"`
|
||||
}
|
||||
|
||||
// PendingReveal is an in-progress Cockatoo buy (Golden pack): the buyer must
|
||||
// reveal another pet in their hand, gaining apples equal to its power. Options
|
||||
// lists the card ids they may reveal.
|
||||
type PendingReveal struct {
|
||||
PlayerID string `json:"playerId"`
|
||||
Source string `json:"source"` // the Cockatoo's card id
|
||||
Options []string `json:"options"` // eligible pet card ids in the buyer's deck
|
||||
}
|
||||
|
||||
// Game is the complete authoritative state. It is a pure state machine: no
|
||||
// goroutines, no clocks, no I/O. Callers are responsible for locking.
|
||||
type Game struct {
|
||||
@@ -101,24 +127,104 @@ type Game struct {
|
||||
// it put.
|
||||
PrioritySeat int `json:"prioritySeat"`
|
||||
Pending *PendingTrade `json:"pending,omitempty"`
|
||||
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
|
||||
// PendingReveal is an in-progress Cockatoo reveal (Golden pack); it blocks
|
||||
// other shop actions on that seat until resolved, like Pending.
|
||||
PendingReveal *PendingReveal `json:"pendingReveal,omitempty"`
|
||||
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
|
||||
NextCardID int `json:"nextCardId"`
|
||||
WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie
|
||||
// Log is the running, human-readable event log shown across every phase.
|
||||
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.
|
||||
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
|
||||
case n == 3 && g.RollDie != nil:
|
||||
v = g.RollDie() // test override applies to rock dice
|
||||
default:
|
||||
v = 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 {
|
||||
if g.RollDie != nil {
|
||||
return g.RollDie()
|
||||
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
|
||||
}
|
||||
return randInt(3)
|
||||
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 (
|
||||
@@ -302,15 +408,24 @@ func (g *Game) start() {
|
||||
func (g *Game) startShopRound() {
|
||||
g.Phase = PhaseShop
|
||||
g.Pending = nil
|
||||
g.PendingReveal = nil
|
||||
for _, p := range g.Players {
|
||||
p.Coins = CoinsPerRound
|
||||
p.Ready = false
|
||||
p.TripledThisRound = false
|
||||
p.FirstBuyFree = false
|
||||
p.BuysThisRound = 0
|
||||
}
|
||||
g.ShopRow = make([]Card, ShopRowSize)
|
||||
for i := range g.ShopRow {
|
||||
g.ShopRow[i] = g.drawFromTier(g.Round)
|
||||
}
|
||||
// Shop-start deck effects fire now (Golden pack: Manta Ray's free first buy).
|
||||
for _, p := range g.Players {
|
||||
for _, c := range slices.Clone(p.Deck) {
|
||||
g.applyShopTrigger(p, c, TriggerShopStart)
|
||||
}
|
||||
}
|
||||
// The priority-token holder shops first.
|
||||
g.Turn = g.PrioritySeat
|
||||
g.logf(-1, "🛒", "Round %d — shop opens (%s goes first).", g.Round, g.Players[g.PrioritySeat].Name)
|
||||
@@ -343,32 +458,90 @@ func (g *Game) requireShopTurn(playerID string) (*Player, error) {
|
||||
if g.Pending != nil {
|
||||
return nil, fmt.Errorf("%w: finish your trade first", ErrInvalidAction)
|
||||
}
|
||||
if g.PendingReveal != nil {
|
||||
return nil, fmt.Errorf("%w: finish your reveal first", ErrInvalidAction)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Buy spends one coin to take the card at rowIdx into the player's deck.
|
||||
// The slot refills from the current round's tier deck. Buying is the only
|
||||
// action that costs gold.
|
||||
// action that costs gold (or, in the Golden pack, an Avocado — see BuyAvocado).
|
||||
func (g *Game) Buy(playerID string, rowIdx int) error {
|
||||
p, err := g.requireShopTurn(playerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Coins <= 0 {
|
||||
// Golden pack: Manta Ray waives the cost of the first buy this round.
|
||||
free := p.FirstBuyFree
|
||||
if !free && p.Coins <= 0 {
|
||||
return ErrNoCoins
|
||||
}
|
||||
if err := g.validRow(rowIdx); err != nil {
|
||||
return err
|
||||
}
|
||||
if free {
|
||||
p.FirstBuyFree = false
|
||||
} else {
|
||||
p.Coins--
|
||||
}
|
||||
p.BuysThisRound++
|
||||
verb := "bought"
|
||||
if free {
|
||||
verb = "bought (free)"
|
||||
}
|
||||
g.finishBuy(p, rowIdx, verb)
|
||||
g.advanceAfterBuy()
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuyAvocado (Golden pack) takes the card at rowIdx into the player's deck by
|
||||
// discarding a set-aside Avocado instead of spending a coin.
|
||||
func (g *Game) BuyAvocado(playerID string, rowIdx int) error {
|
||||
p, err := g.requireShopTurn(playerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Avocados <= 0 {
|
||||
return fmt.Errorf("%w: no Avocado to discard", ErrInvalidAction)
|
||||
}
|
||||
if err := g.validRow(rowIdx); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Avocados--
|
||||
p.BuysThisRound++
|
||||
g.finishBuy(p, rowIdx, "bought (with an Avocado)")
|
||||
g.advanceAfterBuy()
|
||||
return nil
|
||||
}
|
||||
|
||||
// advanceAfterBuy hands off the turn after a buy, unless the buy opened a
|
||||
// Cockatoo reveal that the same player must resolve first.
|
||||
func (g *Game) advanceAfterBuy() {
|
||||
if g.PendingReveal != nil {
|
||||
return
|
||||
}
|
||||
g.advanceShopTurn()
|
||||
}
|
||||
|
||||
// validRow checks that a shop slot holds a card.
|
||||
func (g *Game) validRow(rowIdx int) error {
|
||||
if rowIdx < 0 || rowIdx >= len(g.ShopRow) || g.ShopRow[rowIdx].ID == "" {
|
||||
return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction)
|
||||
}
|
||||
p.Coins--
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishBuy takes the row card into the deck, logs the (public) buy, refills
|
||||
// the slot, and fires the card's Buy effect — shared by every buy path. The
|
||||
// how phrase describes how it was paid for. Payment is the caller's job.
|
||||
func (g *Game) finishBuy(p *Player, rowIdx int, how string) {
|
||||
bought := g.ShopRow[rowIdx]
|
||||
p.Deck = append(p.Deck, bought)
|
||||
g.addLog(LogEntry{Seat: p.Seat, Icon: "🛒", Kind: LogBuy, Source: bought.ID, CardName: bought.Name,
|
||||
Text: fmt.Sprintf("%s bought %s %s.", p.Name, article(bought.Name), bought.Name)})
|
||||
Text: fmt.Sprintf("%s %s %s %s.", p.Name, how, article(bought.Name), bought.Name)})
|
||||
g.ShopRow[rowIdx] = g.drawFromTier(g.Round)
|
||||
g.applyShopTrigger(p, bought, TriggerBuy)
|
||||
g.advanceShopTurn()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sell converts any number (>=1) of the player's cards: each becomes an
|
||||
@@ -402,11 +575,85 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
|
||||
switch e.Action {
|
||||
case ActionGainApple:
|
||||
n := e.count()
|
||||
switch e.Per {
|
||||
case PerShopFaintPets:
|
||||
n *= g.countShopFaintPets()
|
||||
case PerBuysThisRound:
|
||||
n *= p.BuysThisRound
|
||||
}
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
for range n {
|
||||
p.Deck = append(p.Deck, g.newApple())
|
||||
}
|
||||
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: n,
|
||||
Text: fmt.Sprintf("%s adds %d apple%s to %s's deck.", c.Name, n, plural(n), p.Name)})
|
||||
case ActionFirstBuyFree:
|
||||
// Golden pack: Manta Ray, at shop start.
|
||||
if trigger == TriggerShopStart && p.PetCount() <= e.count() {
|
||||
p.FirstBuyFree = true
|
||||
g.logf(p.Seat, "🛒", "%s readies %s's first buy for free.", c.Name, p.Name)
|
||||
}
|
||||
case ActionRevealForApples:
|
||||
// Golden pack: Cockatoo — reveal another pet in hand for apples. Only
|
||||
// during a real shop turn (skipped when Catfish reactivates buys at
|
||||
// battle prep), and needs at least one other pet.
|
||||
if g.Phase != PhaseShop {
|
||||
continue
|
||||
}
|
||||
var opts []string
|
||||
for _, dc := range p.Deck {
|
||||
if dc.IsPet() && dc.ID != c.ID {
|
||||
opts = append(opts, dc.ID)
|
||||
}
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts}
|
||||
}
|
||||
case ActionApplesInPlay:
|
||||
// Golden pack: apples-in-play banked when sold (Hercules Beetle) or
|
||||
// bought (Bird of Paradise). Monkey's battle-prep version is resolved
|
||||
// inside resolveBattle, so battlePrep is excluded here.
|
||||
if trigger == TriggerSell || trigger == TriggerBuy {
|
||||
p.PendingApplesInPlay += e.count()
|
||||
g.logf(p.Seat, "🍎", "%s sets up %d apple%s in play for the battle.", c.Name, e.count(), plural(e.count()))
|
||||
}
|
||||
case ActionStartTrumpets:
|
||||
// Golden pack: Bird of Paradise banks Trumpets for the next battle.
|
||||
p.PendingTrumpets += e.count()
|
||||
g.logf(p.Seat, "🎺", "%s sets up %d Trumpet%s in play for the battle.", c.Name, e.count(), plural(e.count()))
|
||||
case ActionReactivateBuys:
|
||||
// Golden pack: Catfish re-fires every pet's Buy ability at battle prep.
|
||||
if trigger == TriggerBattlePrep {
|
||||
for _, pet := range slices.Clone(p.Deck) {
|
||||
if pet.IsPet() && pet.ID != c.ID {
|
||||
g.applyShopTrigger(p, pet, TriggerBuy)
|
||||
}
|
||||
}
|
||||
}
|
||||
case ActionBuyTopFree:
|
||||
// Golden pack: Stoat pulls the top of the current tier deck for free.
|
||||
top := g.drawFromTier(g.Round)
|
||||
if top.ID == "" {
|
||||
continue
|
||||
}
|
||||
p.Deck = append(p.Deck, top)
|
||||
if hasBuyEffect(top) {
|
||||
g.addLog(LogEntry{Seat: p.Seat, Icon: "🛒", Kind: LogBuy, Source: top.ID, CardName: top.Name,
|
||||
Text: fmt.Sprintf("%s grabs %s %s from the shop deck for free — its buy ability triggers.", c.Name, article(top.Name), top.Name)})
|
||||
} else {
|
||||
g.logf(p.Seat, "🛒", "%s grabs a card from the shop deck for free.", c.Name)
|
||||
}
|
||||
g.applyShopTrigger(p, top, TriggerBuy)
|
||||
case ActionSetAside:
|
||||
// Golden pack: Avocado is diverted out of the deck into the
|
||||
// persistent set-aside stash (the caller already appended it).
|
||||
if idx := p.cardIndex(c.ID); idx >= 0 {
|
||||
p.Deck = slices.Delete(p.Deck, idx, idx+1)
|
||||
}
|
||||
p.Avocados++
|
||||
g.logf(p.Seat, "🥑", "%s sets %s aside.", p.Name, c.Name)
|
||||
case ActionRefreshGold:
|
||||
p.Coins = min(p.Coins+e.count(), CoinsPerRound)
|
||||
g.logf(p.Seat, "🪙", "%s refreshes %s's coins.", c.Name, p.Name)
|
||||
@@ -466,6 +713,24 @@ func hasBuyEffect(c Card) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// countShopFaintPets counts pets currently in the shop row that carry a Faint
|
||||
// effect (Opossum's Sell payout, Golden pack).
|
||||
func (g *Game) countShopFaintPets() int {
|
||||
n := 0
|
||||
for _, c := range g.ShopRow {
|
||||
if c.ID == "" || !c.IsPet() {
|
||||
continue
|
||||
}
|
||||
for _, e := range c.Effects {
|
||||
if e.Trigger == TriggerFaint {
|
||||
n++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TradeStart trades three same-suit pets from the player's deck to reveal
|
||||
// the top two cards of the next tier's deck — a free action. The player must
|
||||
// then call TradeChoose before anything else happens.
|
||||
@@ -559,6 +824,34 @@ func (g *Game) TradeChoose(playerID string, pick int) error {
|
||||
}
|
||||
// Pets obtained via the Triple action trigger their Buy effects.
|
||||
g.applyShopTrigger(p, chosen, TriggerBuy)
|
||||
g.advanceAfterBuy()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevealChoose resolves a pending Cockatoo reveal (Golden pack): the buyer
|
||||
// reveals one of their other pets and gains apples equal to its power.
|
||||
func (g *Game) RevealChoose(playerID, cardID string) error {
|
||||
if g.Phase != PhaseShop || g.PendingReveal == nil || g.PendingReveal.PlayerID != playerID {
|
||||
return fmt.Errorf("%w: no reveal waiting on you", ErrInvalidAction)
|
||||
}
|
||||
if !slices.Contains(g.PendingReveal.Options, cardID) {
|
||||
return fmt.Errorf("%w: reveal one of your other pets", ErrInvalidAction)
|
||||
}
|
||||
p := g.PlayerByID(playerID)
|
||||
idx := p.cardIndex(cardID)
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("%w: card not in your deck", ErrInvalidAction)
|
||||
}
|
||||
revealed := p.Deck[idx]
|
||||
n := revealed.Power
|
||||
for range n {
|
||||
p.Deck = append(p.Deck, g.newApple())
|
||||
}
|
||||
// The reveal is public (the opponent learns the pet); the apple spawn rides
|
||||
// on this entry so observers count it (Kind "" + Spawn "apple").
|
||||
g.addLog(LogEntry{Seat: p.Seat, Icon: "🦜", Source: revealed.ID, Spawn: "apple", Count: n, CardName: revealed.Name,
|
||||
Text: fmt.Sprintf("%s reveals %s (power %d) — adds %d apple%s to hand.", p.Name, revealed.Name, revealed.Power, n, plural(n))})
|
||||
g.PendingReveal = nil
|
||||
g.advanceShopTurn()
|
||||
return nil
|
||||
}
|
||||
@@ -664,17 +957,49 @@ func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error {
|
||||
p.Deck = ordered
|
||||
p.Ready = true
|
||||
if g.allReady() {
|
||||
g.resolveBattle()
|
||||
g.startBattle()
|
||||
}
|
||||
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.
|
||||
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
|
||||
}
|
||||
g.Phase = PhaseBattle
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user