Initial pass at Golden Pack.
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
- When you're out of coins, highlight the pass button so you know to click it
|
||||||
|
- When it becomes your turn in the shop phase, do some big animation of "your turn" across the screen or something, with a sound. It's easy to not realize it's your turn right now.
|
||||||
|
- I found one occurrence of the rock animation not playing for the opponent. It was deterministic for this battle (if I rewound and replayed, it still skipped) but it worked in other battles.
|
||||||
|
- Under the hover state of the card, have a explainer box for the trigger
|
||||||
|
- In the shop, the coints you have remaining should be big golden discs above the buy row, instead of a little icon in the toolbar.
|
||||||
|
- The squirrel card has two colums to list it's two abilities, but we should just show them stacked vertically instead.
|
||||||
|
- The crocodile didn't seem to work. I played my last pet, and my opponent had a crocodile set aside, but it didn't roll the rocks.
|
||||||
|
- If you have lots of apples, they can go off-screen when they stack. Maybe make the play area larger?
|
||||||
+46
-6
@@ -35,11 +35,13 @@ 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" | "sell" | "trade" | "tradeChoose" | "pass" | "arrange" | "ready"
|
Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose | battleChoose
|
||||||
Row int // buy
|
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
|
||||||
|
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.
|
||||||
@@ -61,6 +63,12 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
|
|||||||
me := &v.Players[v.YouSeat]
|
me := &v.Players[v.YouSeat]
|
||||||
switch v.Phase {
|
switch v.Phase {
|
||||||
case game.PhaseShop:
|
case game.PhaseShop:
|
||||||
|
if v.PendingReveal != nil {
|
||||||
|
if v.PendingReveal.PlayerID == me.ID {
|
||||||
|
return b.decideReveal(v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if v.Pending != nil {
|
if v.Pending != nil {
|
||||||
if v.Pending.PlayerID == me.ID {
|
if v.Pending.PlayerID == me.ID {
|
||||||
return b.decideTradeChoose(v, mem)
|
return b.decideTradeChoose(v, mem)
|
||||||
@@ -78,6 +86,13 @@ 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"}
|
||||||
}
|
}
|
||||||
@@ -85,6 +100,21 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decideReveal picks the highest-power eligible pet for Cockatoo's reveal, for
|
||||||
|
// the most apples.
|
||||||
|
func (b *Bot) decideReveal(v *game.View) *Action {
|
||||||
|
me := &v.Players[v.YouSeat]
|
||||||
|
best, bestPow := "", -1
|
||||||
|
for _, id := range v.PendingReveal.Options {
|
||||||
|
for _, c := range me.Deck {
|
||||||
|
if c.ID == id && c.Power > bestPow {
|
||||||
|
best, bestPow = id, c.Power
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Action{Type: "revealChoose", CardID: best}
|
||||||
|
}
|
||||||
|
|
||||||
// Pending reports whether the seat owes the game an action right now — the
|
// Pending reports whether the seat owes the game an action right now — the
|
||||||
// server uses it to decide when to schedule a bot move.
|
// server uses it to decide when to schedule a bot move.
|
||||||
func Pending(v *game.View) bool {
|
func Pending(v *game.View) bool {
|
||||||
@@ -94,11 +124,21 @@ func Pending(v *game.View) bool {
|
|||||||
me := &v.Players[v.YouSeat]
|
me := &v.Players[v.YouSeat]
|
||||||
switch v.Phase {
|
switch v.Phase {
|
||||||
case game.PhaseShop:
|
case game.PhaseShop:
|
||||||
|
if v.PendingReveal != nil {
|
||||||
|
return v.PendingReveal.PlayerID == me.ID
|
||||||
|
}
|
||||||
if v.Pending != nil {
|
if v.Pending != nil {
|
||||||
return v.Pending.PlayerID == me.ID
|
return v.Pending.PlayerID == me.ID
|
||||||
}
|
}
|
||||||
return v.Turn == v.YouSeat && !me.Ready
|
return v.Turn == v.YouSeat && !me.Ready
|
||||||
case game.PhaseArrange, game.PhaseBattle:
|
case game.PhaseArrange:
|
||||||
|
return !me.Ready
|
||||||
|
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
|
||||||
|
|||||||
@@ -11,7 +11,26 @@ import (
|
|||||||
// It fails the test if a bot ever produces an illegal action or the game
|
// It fails the test if a bot ever produces an illegal action or the game
|
||||||
// stops making progress.
|
// stops making progress.
|
||||||
func playBotGame(t *testing.T, levelA, levelB float64) *game.Game {
|
func playBotGame(t *testing.T, levelA, levelB float64) *game.Game {
|
||||||
|
return playBotGamePack(t, game.DefaultPack, levelA, levelB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forcePlayable temporarily marks a (possibly gated) pack Playable so tests can
|
||||||
|
// start a game on it, returning a restore func.
|
||||||
|
func forcePlayable(id string) func() {
|
||||||
|
for i := range game.Packs {
|
||||||
|
if game.Packs[i].ID == id {
|
||||||
|
prev := game.Packs[i].Playable
|
||||||
|
game.Packs[i].Playable = true
|
||||||
|
idx := i
|
||||||
|
return func() { game.Packs[idx].Playable = prev }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func playBotGamePack(t *testing.T, pack string, levelA, levelB float64) *game.Game {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
defer forcePlayable(pack)()
|
||||||
g := game.New()
|
g := game.New()
|
||||||
pa, err := g.AddBot("Bot A", levelA)
|
pa, err := g.AddBot("Bot A", levelA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -21,6 +40,9 @@ func playBotGame(t *testing.T, levelA, levelB float64) *game.Game {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddBot B: %v", err)
|
t.Fatalf("AddBot B: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := g.SetPack(pack); err != nil {
|
||||||
|
t.Fatalf("SetPack %s: %v", pack, err)
|
||||||
|
}
|
||||||
if err := g.StartGame(); err != nil {
|
if err := g.StartGame(); err != nil {
|
||||||
t.Fatalf("StartGame: %v", err)
|
t.Fatalf("StartGame: %v", err)
|
||||||
}
|
}
|
||||||
@@ -68,12 +90,18 @@ func applyAction(g *game.Game, playerID string, a *Action) error {
|
|||||||
switch a.Type {
|
switch a.Type {
|
||||||
case "buy":
|
case "buy":
|
||||||
return g.Buy(playerID, a.Row)
|
return g.Buy(playerID, a.Row)
|
||||||
|
case "buyAvocado":
|
||||||
|
return g.BuyAvocado(playerID, a.Row)
|
||||||
case "sell":
|
case "sell":
|
||||||
return g.Sell(playerID, a.Cards)
|
return g.Sell(playerID, a.Cards)
|
||||||
case "trade":
|
case "trade":
|
||||||
return g.TradeStart(playerID, a.Cards)
|
return g.TradeStart(playerID, a.Cards)
|
||||||
case "tradeChoose":
|
case "tradeChoose":
|
||||||
return g.TradeChoose(playerID, a.Pick)
|
return g.TradeChoose(playerID, a.Pick)
|
||||||
|
case "revealChoose":
|
||||||
|
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":
|
||||||
@@ -98,6 +126,19 @@ func TestBotsFinishGames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBotsFinishGoldenGame plays complete games on the Golden pack (tiers 1-3
|
||||||
|
// printed; 4-6 empty). It exercises the Trumpet/Golden Retriever/Cone Snail
|
||||||
|
// battle mechanics via SimulateBattle rollouts and the new shop effects, and
|
||||||
|
// fails on any illegal or missing bot action.
|
||||||
|
func TestBotsFinishGoldenGame(t *testing.T) {
|
||||||
|
for range 5 {
|
||||||
|
g := playBotGamePack(t, "golden", 1, 0.6)
|
||||||
|
if g.Round != game.MaxRounds {
|
||||||
|
t.Errorf("golden game ended on round %d, want %d", g.Round, game.MaxRounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestObserveTracksOpponentDeck checks the memory's opponent model against
|
// TestObserveTracksOpponentDeck checks the memory's opponent model against
|
||||||
// the opponent's real deck after known public actions. The model may only
|
// the opponent's real deck after known public actions. The model may only
|
||||||
// contain information a human spectator would have.
|
// contain information a human spectator would have.
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func placeSpread(pets []game.Card, foodIdx int, _ game.Card) int {
|
|||||||
func placeSynergy(pets []game.Card, foodIdx int, food game.Card) int {
|
func placeSynergy(pets []game.Card, foodIdx int, food game.Card) int {
|
||||||
for i, p := range pets {
|
for i, p := range pets {
|
||||||
switch p.Name {
|
switch p.Name {
|
||||||
case "Rooster", "Dodo", "Leopard", "Peacock", "Scorpion":
|
case "Rooster", "Dodo", "Leopard", "Peacock", "Scorpion", "Bulldog", "Macaque":
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ func leadScore(c game.Card) float64 {
|
|||||||
s += 0.8
|
s += 0.8
|
||||||
case game.TriggerHurt:
|
case game.TriggerHurt:
|
||||||
s += 0.5
|
s += 0.5
|
||||||
|
case game.TriggerAfterAttack:
|
||||||
|
// Wants to survive its clashes to keep triggering (Bulldog).
|
||||||
|
s += 0.6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
|||||||
+14
-8
@@ -94,9 +94,15 @@ func Observe(v *game.View, m *Memory) {
|
|||||||
switch {
|
switch {
|
||||||
case e.Kind == game.LogBuy:
|
case e.Kind == game.LogBuy:
|
||||||
if c, ok := cardByID(m.PrevShopRow, e.Source); ok {
|
if c, ok := cardByID(m.PrevShopRow, e.Source); ok {
|
||||||
m.Opp.Known = append(m.Opp.Known, c)
|
// An Avocado buy is set aside, not kept in the deck (Golden
|
||||||
} else if c, ok := templateByName(e.CardName); ok {
|
// pack): don't add it to the deck model.
|
||||||
m.Opp.Known = append(m.Opp.Known, c)
|
if c.Food != game.FoodAvocado {
|
||||||
|
m.Opp.Known = append(m.Opp.Known, c)
|
||||||
|
}
|
||||||
|
} else if c, ok := templateByName(v.Pack, e.CardName); ok {
|
||||||
|
if c.Food != game.FoodAvocado {
|
||||||
|
m.Opp.Known = append(m.Opp.Known, c)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case e.Kind == game.LogSell:
|
case e.Kind == game.LogSell:
|
||||||
m.removeOppCard(e.Source, e.CardName)
|
m.removeOppCard(e.Source, e.CardName)
|
||||||
@@ -179,15 +185,15 @@ func cardByID(cards []game.Card, id string) (game.Card, bool) {
|
|||||||
return game.Card{}, false
|
return game.Card{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// templateByName mints a reference copy of a named card from the printed
|
// templateByName mints a reference copy of a named card from the pack's
|
||||||
// tier contents. The suit is whatever the first printed copy has — callers
|
// printed tier contents. The suit is whatever the first printed copy has —
|
||||||
// only rely on stats and effects.
|
// callers only rely on stats and effects.
|
||||||
func templateByName(name string) (game.Card, bool) {
|
func templateByName(pack, name string) (game.Card, bool) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return game.Card{}, false
|
return game.Card{}, false
|
||||||
}
|
}
|
||||||
for tier := 1; tier <= game.MaxRounds; tier++ {
|
for tier := 1; tier <= game.MaxRounds; tier++ {
|
||||||
for _, c := range game.TierContents(tier) {
|
for _, c := range game.TierContentsForPack(pack, tier) {
|
||||||
if c.Name == name {
|
if c.Name == name {
|
||||||
return c, true
|
return c, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (cx *ctx) unseenPool(tier int) []game.Card {
|
|||||||
note(c)
|
note(c)
|
||||||
}
|
}
|
||||||
var pool []game.Card
|
var pool []game.Card
|
||||||
for _, c := range game.TierContents(tier) {
|
for _, c := range game.TierContentsForPack(cx.v.Pack, tier) {
|
||||||
if seen[c.Name] > 0 {
|
if seen[c.Name] > 0 {
|
||||||
seen[c.Name]--
|
seen[c.Name]--
|
||||||
continue
|
continue
|
||||||
@@ -78,7 +78,7 @@ func (cx *ctx) sampleOppDeck() []game.Card {
|
|||||||
deck := append([]game.Card(nil), cx.m.Opp.Known...)
|
deck := append([]game.Card(nil), cx.m.Opp.Known...)
|
||||||
for _, h := range cx.m.Opp.Hidden {
|
for _, h := range cx.m.Opp.Hidden {
|
||||||
var c game.Card
|
var c game.Card
|
||||||
if t, ok := templateByName(h.Name); ok {
|
if t, ok := templateByName(cx.v.Pack, h.Name); ok {
|
||||||
c = t
|
c = t
|
||||||
} else if pool := cx.unseenPool(h.Tier); len(pool) > 0 {
|
} else if pool := cx.unseenPool(h.Tier); len(pool) > 0 {
|
||||||
c = pool[rand.IntN(len(pool))]
|
c = pool[rand.IntN(len(pool))]
|
||||||
|
|||||||
+83
-2
@@ -18,7 +18,26 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
|
|||||||
}
|
}
|
||||||
switch e.Action {
|
switch e.Action {
|
||||||
case game.ActionGainApple:
|
case game.ActionGainApple:
|
||||||
for range max(e.Count, 1) {
|
n := max(e.Count, 1)
|
||||||
|
switch e.Per {
|
||||||
|
case game.PerShopFaintPets:
|
||||||
|
n *= countShopFaintPets(cx.v.ShopRow)
|
||||||
|
case game.PerBuysThisRound:
|
||||||
|
// This buy will bump the counter, so count it (Blue-Ringed Octopus).
|
||||||
|
n *= cx.me.BuysThisRound + 1
|
||||||
|
}
|
||||||
|
for range n {
|
||||||
|
deck = append(deck, cx.simApple())
|
||||||
|
}
|
||||||
|
case game.ActionRevealForApples:
|
||||||
|
// Cockatoo: the bot would reveal its highest-power other pet.
|
||||||
|
best := 0
|
||||||
|
for _, d := range deck {
|
||||||
|
if d.IsPet() && d.ID != c.ID && d.Power > best {
|
||||||
|
best = d.Power
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for range best {
|
||||||
deck = append(deck, cx.simApple())
|
deck = append(deck, cx.simApple())
|
||||||
}
|
}
|
||||||
case game.ActionDoubleApples:
|
case game.ActionDoubleApples:
|
||||||
@@ -31,11 +50,54 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
|
|||||||
for range apples {
|
for range apples {
|
||||||
deck = append(deck, cx.simApple())
|
deck = append(deck, cx.simApple())
|
||||||
}
|
}
|
||||||
|
case game.ActionApplesInPlay:
|
||||||
|
// Golden pack (Hercules Beetle, sold): in-play apples aren't in the
|
||||||
|
// deck, but they buff the front pet, so approximate them as deck
|
||||||
|
// apples for scoring. MinRound is already gated above.
|
||||||
|
for range max(e.Count, 1) {
|
||||||
|
deck = append(deck, cx.simApple())
|
||||||
|
}
|
||||||
|
case game.ActionBuyTopFree:
|
||||||
|
// Golden pack (Stoat): grabs an unknown top-of-deck card; approximate
|
||||||
|
// with a sampled card of the current tier.
|
||||||
|
pool := cx.unseenPool(cx.v.Round)
|
||||||
|
if len(pool) == 0 {
|
||||||
|
pool = game.TierContentsForPack(cx.v.Pack, cx.v.Round)
|
||||||
|
}
|
||||||
|
if len(pool) > 0 {
|
||||||
|
rc := pool[rand.IntN(len(pool))]
|
||||||
|
rc.ID = cx.nextSimID()
|
||||||
|
deck = append(deck, rc)
|
||||||
|
}
|
||||||
|
case game.ActionSetAside:
|
||||||
|
// Golden pack (Avocado): the just-bought token is set aside, not
|
||||||
|
// kept in the deck being scored.
|
||||||
|
if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 {
|
||||||
|
deck = slices.Delete(deck, i, i+1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return deck
|
return deck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countShopFaintPets counts pets in the shop row with a Faint effect (mirrors
|
||||||
|
// the engine's Opossum payout).
|
||||||
|
func countShopFaintPets(row []game.Card) int {
|
||||||
|
n := 0
|
||||||
|
for _, c := range row {
|
||||||
|
if c.ID == "" || !c.IsPet() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range c.Effects {
|
||||||
|
if e.Trigger == game.TriggerFaint {
|
||||||
|
n++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func (cx *ctx) simApple() game.Card {
|
func (cx *ctx) simApple() game.Card {
|
||||||
return game.Card{
|
return game.Card{
|
||||||
ID: cx.nextSimID(),
|
ID: cx.nextSimID(),
|
||||||
@@ -133,6 +195,25 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Golden pack: buying by discarding an Avocado yields the same deck as a
|
||||||
|
// coin buy, so it's only preferred when coins are scarce — a small negative
|
||||||
|
// bias keeps the token in reserve otherwise.
|
||||||
|
if cx.me.Avocados > 0 {
|
||||||
|
for i, c := range v.ShopRow {
|
||||||
|
if c.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nd := append(slices.Clone(deck), c)
|
||||||
|
nd = cx.applyTemplateShopEffects(nd, c, game.TriggerBuy)
|
||||||
|
nd = cx.previewSellDown(nd)
|
||||||
|
cands = append(cands, candidate{
|
||||||
|
act: &Action{Type: "buyAvocado", Row: i},
|
||||||
|
decks: [][]game.Card{nd},
|
||||||
|
bias: -0.05,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sell candidates: the worst 1, 2, or 3 keepers. Selling is free and
|
// Sell candidates: the worst 1, 2, or 3 keepers. Selling is free and
|
||||||
// takes any number of cards, so bulk-dumping junk before a battle is one
|
// takes any number of cards, so bulk-dumping junk before a battle is one
|
||||||
// action. Temporary cards are excluded — selling an apple for an apple
|
// action. Temporary cards are excluded — selling an apple for an apple
|
||||||
@@ -201,7 +282,7 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
|
|||||||
}
|
}
|
||||||
pool := cx.unseenPool(v.Round + 1)
|
pool := cx.unseenPool(v.Round + 1)
|
||||||
if len(pool) == 0 {
|
if len(pool) == 0 {
|
||||||
pool = game.TierContents(v.Round + 1)
|
pool = game.TierContentsForPack(v.Pack, v.Round+1)
|
||||||
}
|
}
|
||||||
var decks [][]game.Card
|
var decks [][]game.Card
|
||||||
for range 3 {
|
for range 3 {
|
||||||
|
|||||||
+507
-68
@@ -1,6 +1,9 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
// BattleUnit is a pet in play with its attached foods applied. Power
|
// BattleUnit is a pet in play with its attached foods applied. Power
|
||||||
// (attack) is unaffected by damage; a unit dies when Damage >= Power.
|
// (attack) is unaffected by damage; a unit dies when Damage >= Power.
|
||||||
@@ -13,6 +16,11 @@ type BattleUnit struct {
|
|||||||
// its source so the log can name it (Gorilla's innate block, or a Melon
|
// its source so the log can name it (Gorilla's innate block, or a Melon
|
||||||
// perk) and the client can drop the spent card.
|
// perk) and the client can drop the spent card.
|
||||||
Shields []shieldCharge
|
Shields []shieldCharge
|
||||||
|
// afterAttackUsed gates once-per-battle After-Attack effects (Bulldog).
|
||||||
|
afterAttackUsed bool
|
||||||
|
// hitPrevent are this pet's own one-shot partial damage preventions
|
||||||
|
// (Potato perk: two charges of 2). Consumed before any side-level charge.
|
||||||
|
hitPrevent []int
|
||||||
}
|
}
|
||||||
|
|
||||||
// shieldCharge is one full-hit block a pet carries. source is the granting
|
// shieldCharge is one full-hit block a pet carries. source is the granting
|
||||||
@@ -32,6 +40,15 @@ type shieldBlock struct {
|
|||||||
release *Card
|
release *Card
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preventInfo describes a partial damage prevention a hit just consumed (Cone
|
||||||
|
// Snail): amount is the damage shaved off, release is the set-aside card to
|
||||||
|
// remove from the board. The caller narrates it after its own hit event so the
|
||||||
|
// replay order stays correct.
|
||||||
|
type preventInfo struct {
|
||||||
|
amount int
|
||||||
|
release *Card
|
||||||
|
}
|
||||||
|
|
||||||
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
||||||
func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() }
|
func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() }
|
||||||
|
|
||||||
@@ -160,6 +177,13 @@ type lastPetVolley struct {
|
|||||||
src Card // the fainted pet, for the set-aside display
|
src Card // the fainted pet, for the set-aside display
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// feedAside is Giant Isopod's set-aside: each time the owner plays a pet, spend
|
||||||
|
// one Trumpet to feed that pet `apples` apples.
|
||||||
|
type feedAside struct {
|
||||||
|
apples int
|
||||||
|
src Card // the fainted pet, for the set-aside display
|
||||||
|
}
|
||||||
|
|
||||||
// battleSide is one seat's live state during the simulation.
|
// battleSide is one seat's live state during the simulation.
|
||||||
type battleSide struct {
|
type battleSide struct {
|
||||||
stack []Card // remaining face-down cards, top first
|
stack []Card // remaining face-down cards, top first
|
||||||
@@ -175,6 +199,17 @@ type battleSide struct {
|
|||||||
recurringRocks []setAsideRocks // Snake: on every own pet play
|
recurringRocks []setAsideRocks // Snake: on every own pet play
|
||||||
lastPetRocks []lastPetVolley // Crocodile: when the enemy plays their last pet
|
lastPetRocks []lastPetVolley // Crocodile: when the enemy plays their last pet
|
||||||
shieldCards []Card // Turtle set-aside cards, parallel to shields
|
shieldCards []Card // Turtle set-aside cards, parallel to shields
|
||||||
|
|
||||||
|
// --- Golden pack ---
|
||||||
|
trumpets int // ephemeral Trumpet pool (earned/spent in battle)
|
||||||
|
faintedHats map[Suit]bool // distinct suits among friendly fainted pets (Honduran White Bat)
|
||||||
|
grSummoned bool // Golden Retriever already summoned this battle
|
||||||
|
hitPrevent []int // Cone Snail: pending one-shot partial damage preventions
|
||||||
|
preventCards []Card // Cone Snail set-aside cards, parallel to hitPrevent
|
||||||
|
beePlayRocks []setAsideRocks // Poison Dart Frog: rocks each time a Bee is played
|
||||||
|
feedOnPlay []feedAside // Giant Isopod: feed apples on each pet played
|
||||||
|
petsPlayed int // pets fielded so far (Komodo's "first pet")
|
||||||
|
retrieverGuards []int // German Shepherd: damage the Golden Retriever prevents on its first hits
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasPetInStack reports whether any pet remains face-down in the stack.
|
// hasPetInStack reports whether any pet remains face-down in the stack.
|
||||||
@@ -187,6 +222,17 @@ func (s *battleSide) hasPetInStack() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canField reports whether the side has, or can still produce, a pet to fight:
|
||||||
|
// one in play, a pet left in the stack, or a Golden Retriever waiting to be
|
||||||
|
// summoned. Used to decide the battle is over only after play effects (and any
|
||||||
|
// parting shots) have resolved.
|
||||||
|
func (s *battleSide) canField() bool {
|
||||||
|
if s.unit != nil || s.hasPetInStack() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned
|
||||||
|
}
|
||||||
|
|
||||||
// queuedPlay is a play-time effect waiting to resolve after reveals.
|
// queuedPlay is a play-time effect waiting to resolve after reveals.
|
||||||
type queuedPlay struct {
|
type queuedPlay struct {
|
||||||
seat int
|
seat int
|
||||||
@@ -198,8 +244,9 @@ type queuedPlay struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// effectCount resolves an effect's final count: base × Per statistic,
|
// effectCount resolves an effect's final count: base × Per statistic,
|
||||||
// limited by Cap.
|
// limited by Cap. enemy is the opposing side (for enemy-relative multipliers);
|
||||||
func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
|
// it may be nil when the effect has no such Per.
|
||||||
|
func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int {
|
||||||
n := e.count()
|
n := e.count()
|
||||||
switch e.Per {
|
switch e.Per {
|
||||||
case PerFaintedBees:
|
case PerFaintedBees:
|
||||||
@@ -210,6 +257,14 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
|
|||||||
n *= appleCount(u.Foods)
|
n *= appleCount(u.Foods)
|
||||||
case PerPower:
|
case PerPower:
|
||||||
n *= u.Power()
|
n *= u.Power()
|
||||||
|
case PerUniqueFaintedHats:
|
||||||
|
n *= len(s.faintedHats)
|
||||||
|
case PerEnemyFaintedPets:
|
||||||
|
if enemy != nil {
|
||||||
|
n *= enemy.petsFainted
|
||||||
|
} else {
|
||||||
|
n = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if e.Cap > 0 && n > e.Cap {
|
if e.Cap > 0 && n > e.Cap {
|
||||||
n = e.Cap
|
n = e.Cap
|
||||||
@@ -231,20 +286,72 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
|
|||||||
// entire hits; Garlic shaves 1 from each; Scorpion KOs whatever its clash
|
// entire hits; Garlic shaves 1 from each; Scorpion KOs whatever its clash
|
||||||
// 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
|
||||||
|
// from the recorded dice/decision tapes, publishing either a 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
|
||||||
|
g.battleRollCursor = 0
|
||||||
|
g.battleDecisionCursor = 0
|
||||||
|
res, pending := 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.
|
||||||
|
func (g *Game) finalizeBattle(res *BattleResult) {
|
||||||
|
n := len(g.Players)
|
||||||
|
winner := res.WinnerSeat
|
||||||
|
if winner >= 0 {
|
||||||
|
g.Players[winner].Trophies += res.Trophies
|
||||||
|
// Priority token: the winner hands it to the other player; a loser who
|
||||||
|
// held it keeps it; a draw leaves it put. (Two-player rule.)
|
||||||
|
if winner == g.PrioritySeat {
|
||||||
|
g.PrioritySeat = (winner + 1) % n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if winner < 0 {
|
||||||
|
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
|
||||||
|
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
|
||||||
|
} else {
|
||||||
|
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
|
||||||
|
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", g.Players[winner].Name, g.Round, res.Trophies)})
|
||||||
|
}
|
||||||
|
for _, p := range g.Players {
|
||||||
|
p.PendingApplesInPlay = 0
|
||||||
|
p.PendingTrumpets = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
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.
|
||||||
pname := func(seat int) string { return g.Players[seat].Name }
|
pname := func(seat int) string { return g.Players[seat].Name }
|
||||||
|
|
||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
s := &battleSide{stack: append([]Card(nil), p.Deck...)}
|
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}}
|
||||||
sides[p.Seat] = s
|
sides[p.Seat] = s
|
||||||
res.StackSizes[p.Seat] = len(p.Deck)
|
res.StackSizes[p.Seat] = len(p.Deck)
|
||||||
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
|
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
|
||||||
}
|
}
|
||||||
|
// enemyOf returns the opposing side (two-player; generalizes later).
|
||||||
|
enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] }
|
||||||
// seatOrder resolves the priority-token holder first, then everyone else.
|
// seatOrder resolves the priority-token holder first, then everyone else.
|
||||||
// Reveals, queued play effects, and cross-side triggers all follow it, so
|
// Reveals, queued play effects, and cross-side triggers all follow it, so
|
||||||
// when two pets would act simultaneously (e.g. both throwing rocks) the
|
// when two pets would act simultaneously (e.g. both throwing rocks) the
|
||||||
@@ -257,6 +364,13 @@ func (g *Game) resolveBattle() {
|
|||||||
seatOrder = append(seatOrder, seat)
|
seatOrder = append(seatOrder, seat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// startApple seeds one in-play apple onto a seat's first pet.
|
||||||
|
startApple := func(seat int) {
|
||||||
|
apple := g.newApple()
|
||||||
|
sides[seat].pending = append(sides[seat].pending, apple)
|
||||||
|
emit(BattleEvent{Type: "prep", Seat: seat, Card: &apple,
|
||||||
|
Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(seat))})
|
||||||
|
}
|
||||||
// Battle-prep effects that start apples in play (Monkey): they attach
|
// Battle-prep effects that start apples in play (Monkey): they attach
|
||||||
// to the owner's first pet.
|
// to the owner's first pet.
|
||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
@@ -264,14 +378,23 @@ func (g *Game) resolveBattle() {
|
|||||||
for _, e := range c.Effects {
|
for _, e := range c.Effects {
|
||||||
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
|
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
|
||||||
for range e.count() {
|
for range e.count() {
|
||||||
apple := g.newApple()
|
startApple(p.Seat)
|
||||||
sides[p.Seat].pending = append(sides[p.Seat].pending, apple)
|
|
||||||
emit(BattleEvent{Type: "prep", Seat: p.Seat, Card: &apple,
|
|
||||||
Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(p.Seat))})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Golden pack: apples-in-play banked by a sold Hercules Beetle this
|
||||||
|
// round (read-only here; finalizeBattle clears it once the battle ends,
|
||||||
|
// so re-runs bank the same amount).
|
||||||
|
for range p.PendingApplesInPlay {
|
||||||
|
startApple(p.Seat)
|
||||||
|
}
|
||||||
|
// Bird of Paradise: start the battle with Trumpets in the pool.
|
||||||
|
if p.PendingTrumpets > 0 {
|
||||||
|
sides[p.Seat].trumpets += p.PendingTrumpets
|
||||||
|
emit(BattleEvent{Type: "trumpet", Seat: p.Seat, Count: p.PendingTrumpets,
|
||||||
|
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(p.Seat), p.PendingTrumpets, plural(p.PendingTrumpets))})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
summon := func(seat int, c Card, cause string) {
|
summon := func(seat int, c Card, cause string) {
|
||||||
@@ -280,12 +403,43 @@ func (g *Game) resolveBattle() {
|
|||||||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
|
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
|
||||||
Text: fmt.Sprintf("%s summons %s %s.", cause, article(c.Name), c.Name)})
|
Text: fmt.Sprintf("%s summons %s %s.", cause, article(c.Name), c.Name)})
|
||||||
}
|
}
|
||||||
|
// summonBottom puts a card on the BOTTOM of a seat's stack (Bear).
|
||||||
|
summonBottom := func(seat int, c Card, cause string) {
|
||||||
|
s := sides[seat]
|
||||||
|
s.stack = append(s.stack, c)
|
||||||
|
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
|
||||||
|
Text: fmt.Sprintf("%s puts %s %s on the bottom of %s's deck.", cause, article(c.Name), c.Name, pname(seat))})
|
||||||
|
}
|
||||||
mintFor := func(kind string) Card {
|
mintFor := func(kind string) Card {
|
||||||
if kind == "bee" {
|
if kind == "bee" {
|
||||||
return g.newBee()
|
return g.newBee()
|
||||||
}
|
}
|
||||||
return g.newApple()
|
return g.newApple()
|
||||||
}
|
}
|
||||||
|
// gainTrumpets adds trumpets to a side and narrates it.
|
||||||
|
gainTrumpets := func(seat, n int, cause string) {
|
||||||
|
if n <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sides[seat].trumpets += n
|
||||||
|
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: n,
|
||||||
|
Text: fmt.Sprintf("%s gains %d Trumpet%s.", cause, n, plural(n))})
|
||||||
|
}
|
||||||
|
// spend pays an effect's Trumpet cost from a side's pool, narrating the
|
||||||
|
// spend. Returns false (without paying) when the side can't afford it, so
|
||||||
|
// the caller skips the effect. A zero-cost effect always "pays".
|
||||||
|
spend := func(seat int, e Effect, cause string) bool {
|
||||||
|
if e.CostTrumpet <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sides[seat].trumpets < e.CostTrumpet {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sides[seat].trumpets -= e.CostTrumpet
|
||||||
|
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -e.CostTrumpet,
|
||||||
|
Text: fmt.Sprintf("%s spends %d Trumpet%s.", cause, e.CostTrumpet, plural(e.CostTrumpet))})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// allowed gates battle-time effects on their conditions.
|
// allowed gates battle-time effects on their conditions.
|
||||||
allowed := func(e Effect, u *BattleUnit) bool {
|
allowed := func(e Effect, u *BattleUnit) bool {
|
||||||
@@ -306,10 +460,10 @@ func (g *Game) resolveBattle() {
|
|||||||
// when a shield absorbed the hit, a shieldBlock describing it (nil
|
// when a shield absorbed the hit, a shieldBlock describing it (nil
|
||||||
// otherwise). A set-aside Turtle shield is spent before the pet's own
|
// otherwise). A set-aside Turtle shield is spent before the pet's own
|
||||||
// shields (Melon/Gorilla) so the borrowed card clears the board first.
|
// shields (Melon/Gorilla) so the borrowed card clears the board first.
|
||||||
hitUnit := func(seat, amount int) (dealt int, block *shieldBlock) {
|
hitUnit := func(seat, amount int) (dealt int, block *shieldBlock, prevent *preventInfo) {
|
||||||
u := sides[seat].unit
|
u := sides[seat].unit
|
||||||
if u == nil || amount <= 0 {
|
if u == nil || amount <= 0 {
|
||||||
return 0, nil
|
return 0, nil, nil
|
||||||
}
|
}
|
||||||
if sides[seat].shields > 0 {
|
if sides[seat].shields > 0 {
|
||||||
sides[seat].shields--
|
sides[seat].shields--
|
||||||
@@ -321,16 +475,36 @@ func (g *Game) resolveBattle() {
|
|||||||
card = &c
|
card = &c
|
||||||
source = c.Name
|
source = c.Name
|
||||||
}
|
}
|
||||||
return 0, &shieldBlock{source: source, release: card}
|
return 0, &shieldBlock{source: source, release: card}, nil
|
||||||
}
|
}
|
||||||
if n := len(u.Shields); n > 0 {
|
if n := len(u.Shields); n > 0 {
|
||||||
sc := u.Shields[n-1]
|
sc := u.Shields[n-1]
|
||||||
u.Shields = u.Shields[:n-1]
|
u.Shields = u.Shields[:n-1]
|
||||||
return 0, &shieldBlock{source: sc.source, release: sc.card}
|
return 0, &shieldBlock{source: sc.source, release: sc.card}, nil
|
||||||
}
|
}
|
||||||
dealt = max(0, amount-u.prevention())
|
reduce := u.prevention()
|
||||||
|
// One partial-prevention charge per hit (whether or not it fully absorbs
|
||||||
|
// the blow): the pet's own first (Potato), else a side-level set-aside
|
||||||
|
// (Cone Snail).
|
||||||
|
if len(u.hitPrevent) > 0 {
|
||||||
|
amt := u.hitPrevent[0]
|
||||||
|
u.hitPrevent = u.hitPrevent[1:]
|
||||||
|
reduce += amt
|
||||||
|
prevent = &preventInfo{amount: amt}
|
||||||
|
} else if len(sides[seat].hitPrevent) > 0 {
|
||||||
|
amt := sides[seat].hitPrevent[0]
|
||||||
|
sides[seat].hitPrevent = sides[seat].hitPrevent[1:]
|
||||||
|
reduce += amt
|
||||||
|
prevent = &preventInfo{amount: amt}
|
||||||
|
if len(sides[seat].preventCards) > 0 {
|
||||||
|
c := sides[seat].preventCards[0]
|
||||||
|
sides[seat].preventCards = sides[seat].preventCards[1:]
|
||||||
|
prevent.release = &c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dealt = max(0, amount-reduce)
|
||||||
u.Damage += dealt
|
u.Damage += dealt
|
||||||
return dealt, nil
|
return dealt, nil, prevent
|
||||||
}
|
}
|
||||||
|
|
||||||
// emitShield narrates a blocked hit — naming the source rather than a bare
|
// emitShield narrates a blocked hit — naming the source rather than a bare
|
||||||
@@ -347,6 +521,21 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitPrevent narrates a Cone Snail partial prevention after the hit that
|
||||||
|
// consumed it, and drops the spent set-aside card.
|
||||||
|
emitPrevent := func(seat int, petName string, prev *preventInfo) {
|
||||||
|
u := sides[seat].unit
|
||||||
|
after := 0
|
||||||
|
if u != nil {
|
||||||
|
after = u.Damage
|
||||||
|
}
|
||||||
|
emit(BattleEvent{Type: "prevent", Seat: seat, Count: prev.amount, DamageAfter: after,
|
||||||
|
Text: fmt.Sprintf("A set-aside Cone Snail shields %s, preventing %d damage.", petName, prev.amount)})
|
||||||
|
if prev.release != nil {
|
||||||
|
emit(BattleEvent{Type: "release", Seat: seat, Card: prev.release})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// faint fires the unit's faint effects (its own and its perk's) in
|
// faint fires the unit's faint effects (its own and its perk's) in
|
||||||
// effect order, updates faint counters, and notifies enemy pets
|
// effect order, updates faint counters, and notifies enemy pets
|
||||||
// (Hippo's heal).
|
// (Hippo's heal).
|
||||||
@@ -357,6 +546,11 @@ func (g *Game) resolveBattle() {
|
|||||||
if isBee(u.Card) {
|
if isBee(u.Card) {
|
||||||
s.beesFainted++
|
s.beesFainted++
|
||||||
}
|
}
|
||||||
|
// Track distinct suits among friendly fainted pets (Honduran White
|
||||||
|
// Bat). Bees and the Golden Retriever have no suit.
|
||||||
|
if !isBee(u.Card) && u.Card.Suit != "" {
|
||||||
|
s.faintedHats[u.Card.Suit] = true
|
||||||
|
}
|
||||||
// setAside marks the fainted pet as kept beside the arena with a
|
// setAside marks the fainted pet as kept beside the arena with a
|
||||||
// pending effect, so the client can show its card until it resolves.
|
// pending effect, so the client can show its card until it resolves.
|
||||||
setAside := func() {
|
setAside := func() {
|
||||||
@@ -364,19 +558,47 @@ func (g *Game) resolveBattle() {
|
|||||||
emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c,
|
emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c,
|
||||||
Text: fmt.Sprintf("%s is set aside.", c.Name)})
|
Text: fmt.Sprintf("%s is set aside.", c.Name)})
|
||||||
}
|
}
|
||||||
|
cause := fmt.Sprintf("%s's faint effect", u.Card.Name)
|
||||||
for _, e := range u.effects() {
|
for _, e := range u.effects() {
|
||||||
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !spend(seat, e, u.Card.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch e.Action {
|
switch e.Action {
|
||||||
case ActionSummonTop:
|
case ActionSummonTop:
|
||||||
target := seat
|
target := seat
|
||||||
if e.Target == "enemy" {
|
if e.Target == "enemy" {
|
||||||
target = (seat + 1) % n
|
target = (seat + 1) % n
|
||||||
}
|
}
|
||||||
for range effectCount(e, s, u) {
|
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||||
summon(target, mintFor(e.Card), fmt.Sprintf("%s's faint effect", u.Card.Name))
|
summon(target, mintFor(e.Card), cause)
|
||||||
}
|
}
|
||||||
|
case ActionSummonBottom:
|
||||||
|
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||||
|
if e.Target == "all" {
|
||||||
|
for other := range sides {
|
||||||
|
summonBottom(other, mintFor(e.Card), cause)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
summonBottom(seat, mintFor(e.Card), cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ActionGainTrumpet:
|
||||||
|
gainTrumpets(seat, effectCount(e, s, u, enemyOf(seat)), cause)
|
||||||
|
case ActionDrainTrumpet:
|
||||||
|
es := enemyOf(seat)
|
||||||
|
lost := min(e.count(), es.trumpets)
|
||||||
|
if lost > 0 {
|
||||||
|
es.trumpets -= lost
|
||||||
|
emit(BattleEvent{Type: "trumpet", Seat: (seat + 1) % n, Count: -lost,
|
||||||
|
Text: fmt.Sprintf("%s drains %d Trumpet%s from the enemy.", cause, lost, plural(lost))})
|
||||||
|
}
|
||||||
|
case ActionPreventNextHit:
|
||||||
|
s.hitPrevent = append(s.hitPrevent, e.count())
|
||||||
|
s.preventCards = append(s.preventCards, u.Card)
|
||||||
|
setAside()
|
||||||
case ActionRecycleApples:
|
case ActionRecycleApples:
|
||||||
recycled := 0
|
recycled := 0
|
||||||
for _, f := range u.Foods {
|
for _, f := range u.Foods {
|
||||||
@@ -385,6 +607,28 @@ func (g *Game) resolveBattle() {
|
|||||||
recycled++
|
recycled++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case ActionRecyclePerkApples:
|
||||||
|
// Macaque: recycle up to Count apples, then the active perk on
|
||||||
|
// top (so the perk reveals first and re-attaches to the next pet).
|
||||||
|
recycled := 0
|
||||||
|
for _, f := range u.Foods {
|
||||||
|
if f.Food == FoodApple && recycled < e.count() {
|
||||||
|
summon(seat, f, cause)
|
||||||
|
recycled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if perk := u.activePerk(); perk != nil {
|
||||||
|
summon(seat, *perk, cause)
|
||||||
|
}
|
||||||
|
case ActionBeeRocks:
|
||||||
|
s.beePlayRocks = append(s.beePlayRocks, setAsideRocks{dice: e.count(), src: u.Card})
|
||||||
|
setAside()
|
||||||
|
case ActionFeedOnPlay:
|
||||||
|
s.feedOnPlay = append(s.feedOnPlay, feedAside{apples: e.count(), src: u.Card})
|
||||||
|
setAside()
|
||||||
|
case ActionGuardRetriever:
|
||||||
|
s.retrieverGuards = append(s.retrieverGuards, e.count())
|
||||||
|
setAside()
|
||||||
case ActionDelayedRocks:
|
case ActionDelayedRocks:
|
||||||
s.oneShotRocks = append(s.oneShotRocks,
|
s.oneShotRocks = append(s.oneShotRocks,
|
||||||
setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card})
|
setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card})
|
||||||
@@ -417,7 +661,7 @@ func (g *Game) resolveBattle() {
|
|||||||
if e.Trigger != TriggerEnemyFaint || e.Action != ActionHeal || !allowed(e, os.unit) {
|
if e.Trigger != TriggerEnemyFaint || e.Action != ActionHeal || !allowed(e, os.unit) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
healed := min(effectCount(e, os, os.unit), os.unit.Damage)
|
healed := min(effectCount(e, os, os.unit, sides[seat]), os.unit.Damage)
|
||||||
if healed > 0 {
|
if healed > 0 {
|
||||||
os.unit.Damage -= healed
|
os.unit.Damage -= healed
|
||||||
emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage,
|
emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage,
|
||||||
@@ -436,18 +680,23 @@ func (g *Game) resolveBattle() {
|
|||||||
if e.Trigger != TriggerHurt || !allowed(e, u) {
|
if e.Trigger != TriggerHurt || !allowed(e, u) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !spend(seat, e, u.Card.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch e.Action {
|
switch e.Action {
|
||||||
case ActionEatApple:
|
case ActionEatApple:
|
||||||
for range effectCount(e, sides[seat], u) {
|
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
|
||||||
u.Foods = append(u.Foods, g.newApple())
|
u.Foods = append(u.Foods, g.newApple())
|
||||||
u.Bonus++
|
u.Bonus++
|
||||||
}
|
}
|
||||||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||||||
Text: fmt.Sprintf("%s eats an apple after being hurt (now +%d).", u.Card.Name, u.Bonus)})
|
Text: fmt.Sprintf("%s eats an apple after being hurt (now +%d).", u.Card.Name, u.Bonus)})
|
||||||
case ActionSummonTop:
|
case ActionSummonTop:
|
||||||
for range effectCount(e, sides[seat], u) {
|
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
|
||||||
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
||||||
}
|
}
|
||||||
|
case ActionGainTrumpet:
|
||||||
|
gainTrumpets(seat, effectCount(e, sides[seat], u, enemyOf(seat)), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
||||||
case ActionShieldSelf:
|
case ActionShieldSelf:
|
||||||
// Gorilla's own hurt-triggered block: innate, no card to drop.
|
// Gorilla's own hurt-triggered block: innate, no card to drop.
|
||||||
for range e.count() {
|
for range e.count() {
|
||||||
@@ -457,6 +706,32 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// afterAttack fires After-Attack effects on a pet that survived a clash it
|
||||||
|
// fought in (Bulldog eats an apple). Effects here are once per battle.
|
||||||
|
afterAttack := func(seat int, u *BattleUnit) {
|
||||||
|
if !u.Alive() || u.afterAttackUsed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range u.effects() {
|
||||||
|
if e.Trigger != TriggerAfterAttack || !allowed(e, u) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !spend(seat, e, u.Card.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch e.Action {
|
||||||
|
case ActionEatApple:
|
||||||
|
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
|
||||||
|
u.Foods = append(u.Foods, g.newApple())
|
||||||
|
u.Bonus++
|
||||||
|
}
|
||||||
|
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||||||
|
Text: fmt.Sprintf("%s eats an apple after attacking (now +%d).", u.Card.Name, u.Bonus)})
|
||||||
|
}
|
||||||
|
u.afterAttackUsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// throwRocks rolls `dice` rock dice against one seat's pet. Rocks are
|
// throwRocks rolls `dice` rock dice against one seat's pet. Rocks are
|
||||||
// not "attacks with" the pet, so no knockout applies. Reports a kill.
|
// not "attacks with" the pet, so no knockout applies. Reports a kill.
|
||||||
throwRocks := func(from, target, dice int, source *Card) (killed bool) {
|
throwRocks := func(from, target, dice int, source *Card) (killed bool) {
|
||||||
@@ -470,7 +745,7 @@ func (g *Game) resolveBattle() {
|
|||||||
faces[i] = g.rollRockDie()
|
faces[i] = g.rollRockDie()
|
||||||
roll += faces[i]
|
roll += faces[i]
|
||||||
}
|
}
|
||||||
dealt, block := hitUnit(target, roll)
|
dealt, block, prev := hitUnit(target, roll)
|
||||||
died := !tu.Alive()
|
died := !tu.Alive()
|
||||||
// A set-aside pet (Snake/Blowfish/Badger/Croc) throws its rocks after a
|
// A set-aside pet (Snake/Blowfish/Badger/Croc) throws its rocks after a
|
||||||
// different pet has been played, so credit `source` explicitly; only
|
// different pet has been played, so credit `source` explicitly; only
|
||||||
@@ -506,6 +781,9 @@ func (g *Game) resolveBattle() {
|
|||||||
if block != nil {
|
if block != nil {
|
||||||
emitShield(target, tu.Card.Name, block)
|
emitShield(target, tu.Card.Name, block)
|
||||||
}
|
}
|
||||||
|
if prev != nil {
|
||||||
|
emitPrevent(target, tu.Card.Name, prev)
|
||||||
|
}
|
||||||
if died {
|
if died {
|
||||||
faint(target, tu)
|
faint(target, tu)
|
||||||
sides[target].unit = nil
|
sides[target].unit = nil
|
||||||
@@ -571,6 +849,7 @@ func (g *Game) resolveBattle() {
|
|||||||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus, Text: revealTxt})
|
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus, Text: revealTxt})
|
||||||
s.pending = nil
|
s.pending = nil
|
||||||
s.unit = u
|
s.unit = u
|
||||||
|
s.petsPlayed++
|
||||||
newlyPlayed[seat] = true
|
newlyPlayed[seat] = true
|
||||||
// Set-aside payouts fire before the new pet's own play
|
// Set-aside payouts fire before the new pet's own play
|
||||||
// effects.
|
// effects.
|
||||||
@@ -585,6 +864,31 @@ func (g *Game) resolveBattle() {
|
|||||||
plays = append(plays, queuedPlay{seat: seat,
|
plays = append(plays, queuedPlay{seat: seat,
|
||||||
effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src})
|
effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src})
|
||||||
}
|
}
|
||||||
|
// Poison Dart Frog: rocks whenever a Bee is played.
|
||||||
|
if isBee(c) {
|
||||||
|
for _, r := range s.beePlayRocks {
|
||||||
|
src := r.src
|
||||||
|
plays = append(plays, queuedPlay{seat: seat,
|
||||||
|
effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Giant Isopod: each set-aside spends one Trumpet (mandatory when
|
||||||
|
// affordable) to feed the just-played pet its apples.
|
||||||
|
for i := range s.feedOnPlay {
|
||||||
|
if s.trumpets <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.trumpets--
|
||||||
|
src := s.feedOnPlay[i].src
|
||||||
|
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -1,
|
||||||
|
Text: fmt.Sprintf("%s spends 1 Trumpet.", src.Name)})
|
||||||
|
for range s.feedOnPlay[i].apples {
|
||||||
|
u.Foods = append(u.Foods, g.newApple())
|
||||||
|
u.Bonus++
|
||||||
|
}
|
||||||
|
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||||||
|
Text: fmt.Sprintf("%s feeds %s %d apples (now +%d).", src.Name, c.Name, s.feedOnPlay[i].apples, u.Bonus)})
|
||||||
|
}
|
||||||
// Walk the pet's own play effects, then its active perk's, so a
|
// Walk the pet's own play effects, then its active perk's, so a
|
||||||
// play-time shield knows its source (an innate pet block vs a
|
// play-time shield knows its source (an innate pet block vs a
|
||||||
// Melon perk that should be shown and later dropped).
|
// Melon perk that should be shown and later dropped).
|
||||||
@@ -593,8 +897,11 @@ func (g *Game) resolveBattle() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Shields apply the instant the pet enters play, ahead of
|
// Shields apply the instant the pet enters play, ahead of
|
||||||
// any queued rocks (Melon).
|
// any queued rocks (Melon; Wildebeest, which pays Trumpets).
|
||||||
if e.Action == ActionShieldSelf {
|
if e.Action == ActionShieldSelf {
|
||||||
|
if !spend(seat, e, u.Card.Name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
for range e.count() {
|
for range e.count() {
|
||||||
ch := shieldCharge{}
|
ch := shieldCharge{}
|
||||||
if perk != nil {
|
if perk != nil {
|
||||||
@@ -605,6 +912,13 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Potato's partial preventions likewise arm on entry.
|
||||||
|
if e.Action == ActionPreventSelf {
|
||||||
|
for range max(e.Cap, 1) {
|
||||||
|
u.hitPrevent = append(u.hitPrevent, e.count())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
|
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
|
||||||
}
|
}
|
||||||
for _, e := range u.Card.Effects {
|
for _, e := range u.Card.Effects {
|
||||||
@@ -616,6 +930,24 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Golden pack: a side that has run out of cards but still holds
|
||||||
|
// Trumpets fields a one-time Golden Retriever, its Power equal to
|
||||||
|
// those Trumpets. This happens before the "anyone out?" check so it
|
||||||
|
// can still clash.
|
||||||
|
if s.unit == nil && len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned {
|
||||||
|
gr := g.newGoldenRetriever(s.trumpets)
|
||||||
|
grUnit := &BattleUnit{Card: gr}
|
||||||
|
// German Shepherd: each set-aside guards the Golden Retriever's
|
||||||
|
// first hits (partial preventions).
|
||||||
|
grUnit.hitPrevent = append(grUnit.hitPrevent, s.retrieverGuards...)
|
||||||
|
s.unit = grUnit
|
||||||
|
s.grSummoned = true
|
||||||
|
s.petsPlayed++
|
||||||
|
newlyPlayed[seat] = true
|
||||||
|
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &gr, Count: s.trumpets, Bonus: 0,
|
||||||
|
Text: fmt.Sprintf("%s is out of cards — a Golden Retriever charges in with %d Trumpet%s (Power %d).",
|
||||||
|
pname(seat), s.trumpets, plural(s.trumpets), s.trumpets)})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Cross-side play triggers: Rhino rocks anyone who just played;
|
// Cross-side play triggers: Rhino rocks anyone who just played;
|
||||||
// Crocodile volleys when the enemy plays their last pet.
|
// Crocodile volleys when the enemy plays their last pet.
|
||||||
@@ -655,20 +987,9 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Battle over? A side that couldn't field a pet is out; no further
|
// Resolve play effects. Any of these can faint a pet before the clash.
|
||||||
// effects resolve.
|
// This runs before the "battle over" check so a parting shot fires even
|
||||||
anyOut := false
|
// when its own side is already out (Crocodile's last-pet volley).
|
||||||
for _, s := range sides {
|
|
||||||
if s.unit == nil {
|
|
||||||
anyOut = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if anyOut {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve play effects. Any of these can faint a pet before the
|
|
||||||
// clash.
|
|
||||||
anyDeath := false
|
anyDeath := false
|
||||||
for _, q := range plays {
|
for _, q := range plays {
|
||||||
// Effects sourced from a specific unit fizzle if it's gone.
|
// Effects sourced from a specific unit fizzle if it's gone.
|
||||||
@@ -678,26 +999,97 @@ func (g *Game) resolveBattle() {
|
|||||||
if q.unit != nil && !allowed(q.effect, q.unit) {
|
if q.unit != nil && !allowed(q.effect, q.unit) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
costName := pname(q.seat)
|
||||||
|
if q.unit != nil {
|
||||||
|
costName = q.unit.Card.Name
|
||||||
|
}
|
||||||
|
if !spend(q.seat, q.effect, costName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch q.effect.Action {
|
switch q.effect.Action {
|
||||||
case ActionThrowRock:
|
case ActionThrowRock:
|
||||||
dice := q.effect.count()
|
dice := q.effect.count()
|
||||||
if q.unit != nil {
|
if q.unit != nil {
|
||||||
dice = effectCount(q.effect, sides[q.seat], q.unit)
|
dice = effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||||||
}
|
}
|
||||||
if q.everyone {
|
switch {
|
||||||
|
case q.everyone:
|
||||||
for seat := range sides {
|
for seat := range sides {
|
||||||
if throwRocks(q.seat, seat, dice, q.source) {
|
if throwRocks(q.seat, seat, dice, q.source) {
|
||||||
anyDeath = true
|
anyDeath = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if t := nextTarget(q.seat); t >= 0 {
|
case q.effect.Target == "self":
|
||||||
if throwRocks(q.seat, t, dice, q.source) {
|
// Manatee pelts its own pet.
|
||||||
|
if throwRocks(q.seat, q.seat, dice, q.source) {
|
||||||
anyDeath = true
|
anyDeath = true
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
if t := nextTarget(q.seat); t >= 0 {
|
||||||
|
if throwRocks(q.seat, t, dice, q.source) {
|
||||||
|
anyDeath = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if q.release != nil {
|
if q.release != nil {
|
||||||
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release})
|
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release})
|
||||||
}
|
}
|
||||||
|
case ActionGainTrumpet:
|
||||||
|
gainTrumpets(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName)
|
||||||
|
case ActionDoubleTrumpets:
|
||||||
|
gain := sides[q.seat].trumpets
|
||||||
|
if q.effect.Cap > 0 && gain > q.effect.Cap {
|
||||||
|
gain = q.effect.Cap
|
||||||
|
}
|
||||||
|
gainTrumpets(q.seat, gain, costName)
|
||||||
|
case ActionStealPerk:
|
||||||
|
t := nextTarget(q.seat)
|
||||||
|
if t < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tu := sides[t].unit
|
||||||
|
perk := tu.activePerk()
|
||||||
|
if perk == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stolen := *perk
|
||||||
|
// Drop that perk food from the enemy pet (perks add no power, so
|
||||||
|
// no bonus change) and put it on top of the thief's deck.
|
||||||
|
for i := range tu.Foods {
|
||||||
|
if tu.Foods[i].ID == stolen.ID {
|
||||||
|
tu.Foods = append(tu.Foods[:i], tu.Foods[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summon(q.seat, stolen, fmt.Sprintf("%s's ability", q.unit.Card.Name))
|
||||||
|
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t,
|
||||||
|
Text: fmt.Sprintf("%s snatches %s's %s.", q.unit.Card.Name, tu.Card.Name, stolen.Name)})
|
||||||
|
case ActionStripApples:
|
||||||
|
t := nextTarget(q.seat)
|
||||||
|
if t < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tu := sides[t].unit
|
||||||
|
apples := appleCount(tu.Foods)
|
||||||
|
if apples == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept := tu.Foods[:0]
|
||||||
|
for _, f := range tu.Foods {
|
||||||
|
if f.Food != FoodApple {
|
||||||
|
kept = append(kept, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tu.Foods = kept
|
||||||
|
tu.Bonus -= apples
|
||||||
|
died := !tu.Alive()
|
||||||
|
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died,
|
||||||
|
Text: fmt.Sprintf("%s discards %s's apples.", q.unit.Card.Name, tu.Card.Name)})
|
||||||
|
if died {
|
||||||
|
faint(t, tu)
|
||||||
|
sides[t].unit = nil
|
||||||
|
anyDeath = true
|
||||||
|
}
|
||||||
case ActionStripFoods:
|
case ActionStripFoods:
|
||||||
t := nextTarget(q.seat)
|
t := nextTarget(q.seat)
|
||||||
if t < 0 {
|
if t < 0 {
|
||||||
@@ -758,11 +1150,11 @@ func (g *Game) resolveBattle() {
|
|||||||
Text: fmt.Sprintf("%s burns %s off %s's deck.", q.unit.Card.Name, top.Name, pname(t))})
|
Text: fmt.Sprintf("%s burns %s off %s's deck.", q.unit.Card.Name, top.Name, pname(t))})
|
||||||
}
|
}
|
||||||
case ActionSummonTop:
|
case ActionSummonTop:
|
||||||
for range effectCount(q.effect, sides[q.seat], q.unit) {
|
for range effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) {
|
||||||
summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name))
|
summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name))
|
||||||
}
|
}
|
||||||
case ActionEatApple:
|
case ActionEatApple:
|
||||||
count := effectCount(q.effect, sides[q.seat], q.unit)
|
count := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
for range count {
|
for range count {
|
||||||
q.unit.Foods = append(q.unit.Foods, g.newApple())
|
q.unit.Foods = append(q.unit.Foods, g.newApple())
|
||||||
@@ -771,7 +1163,65 @@ func (g *Game) resolveBattle() {
|
|||||||
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
|
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
|
||||||
Text: fmt.Sprintf("%s eats an apple (now +%d).", q.unit.Card.Name, q.unit.Bonus)})
|
Text: fmt.Sprintf("%s eats an apple (now +%d).", q.unit.Card.Name, q.unit.Bonus)})
|
||||||
}
|
}
|
||||||
|
case ActionShuffleApples:
|
||||||
|
// Komodo: only if it's the side's first pet, shuffle apples into
|
||||||
|
// random deck positions (deterministic via the battle draw tape).
|
||||||
|
if q.effect.Condition == ConditionFirstPet && sides[q.seat].petsPlayed != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := sides[q.seat]
|
||||||
|
for range q.effect.count() {
|
||||||
|
pos := g.battleDraw(len(s.stack) + 1)
|
||||||
|
apple := g.newApple()
|
||||||
|
s.stack = slices.Insert(s.stack, pos, apple)
|
||||||
|
emit(BattleEvent{Type: "summon", Seat: q.seat, Card: &apple,
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if choice > 0 {
|
||||||
|
s.trumpets -= choice
|
||||||
|
emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice,
|
||||||
|
Text: fmt.Sprintf("%s spends %d Trumpet%s.", q.unit.Card.Name, choice, plural(choice))})
|
||||||
|
if t := nextTarget(q.seat); t >= 0 {
|
||||||
|
if throwRocks(q.seat, t, 2*choice, nil) {
|
||||||
|
anyDeath = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
// will simply refill next reveal doesn't count as out).
|
||||||
|
anyOut := false
|
||||||
|
for _, s := range sides {
|
||||||
|
if !s.canField() {
|
||||||
|
anyOut = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anyOut {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if anyDeath {
|
if anyDeath {
|
||||||
continue // refill before any clash
|
continue // refill before any clash
|
||||||
@@ -781,8 +1231,8 @@ func (g *Game) resolveBattle() {
|
|||||||
// (the surrounding state is already per-seat).
|
// (the surrounding state is already per-seat).
|
||||||
ua, ub := sides[0].unit, sides[1].unit
|
ua, ub := sides[0].unit, sides[1].unit
|
||||||
powA, powB := ua.Power(), ub.Power()
|
powA, powB := ua.Power(), ub.Power()
|
||||||
dealtA, blockA := hitUnit(0, powB)
|
dealtA, blockA, prevA := hitUnit(0, powB)
|
||||||
dealtB, blockB := hitUnit(1, powA)
|
dealtB, blockB, prevB := hitUnit(1, powA)
|
||||||
// Scorpion: a clash attack that hurts, KOs.
|
// Scorpion: a clash attack that hurts, KOs.
|
||||||
if dealtA > 0 && ub.hasKnockout() {
|
if dealtA > 0 && ub.hasKnockout() {
|
||||||
ua.Damage = max(ua.Damage, ua.Power())
|
ua.Damage = max(ua.Damage, ua.Power())
|
||||||
@@ -828,7 +1278,14 @@ func (g *Game) resolveBattle() {
|
|||||||
if blockB != nil {
|
if blockB != nil {
|
||||||
emitShield(1, ub.Card.Name, blockB)
|
emitShield(1, ub.Card.Name, blockB)
|
||||||
}
|
}
|
||||||
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && blockA == nil && blockB == nil {
|
if prevA != nil {
|
||||||
|
emitPrevent(0, ua.Card.Name, prevA)
|
||||||
|
}
|
||||||
|
if prevB != nil {
|
||||||
|
emitPrevent(1, ub.Card.Name, prevB)
|
||||||
|
}
|
||||||
|
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 &&
|
||||||
|
blockA == nil && blockB == nil && prevA == nil && prevB == nil {
|
||||||
break // stalemate: nothing can ever change
|
break // stalemate: nothing can ever change
|
||||||
}
|
}
|
||||||
dealt := []int{dealtA, dealtB}
|
dealt := []int{dealtA, dealtB}
|
||||||
@@ -836,8 +1293,11 @@ func (g *Game) resolveBattle() {
|
|||||||
if !u.Alive() {
|
if !u.Alive() {
|
||||||
faint(seat, u)
|
faint(seat, u)
|
||||||
sides[seat].unit = nil
|
sides[seat].unit = nil
|
||||||
} else if dealt[seat] > 0 {
|
} else {
|
||||||
hurt(seat, u)
|
if dealt[seat] > 0 {
|
||||||
|
hurt(seat, u)
|
||||||
|
}
|
||||||
|
afterAttack(seat, u)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,27 +1320,6 @@ func (g *Game) resolveBattle() {
|
|||||||
if g.Round == MaxRounds {
|
if g.Round == MaxRounds {
|
||||||
res.Trophies = 2
|
res.Trophies = 2
|
||||||
}
|
}
|
||||||
g.Players[winner].Trophies += res.Trophies
|
|
||||||
// Priority token: the winner hands it to the other player; a loser
|
|
||||||
// who held it keeps it; a draw leaves it put. (Two-player rule; the
|
|
||||||
// "other player" is unambiguous only at n == 2.)
|
|
||||||
if winner == g.PrioritySeat {
|
|
||||||
g.PrioritySeat = (winner + 1) % n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g.Battle = res
|
|
||||||
g.Phase = PhaseBattle
|
|
||||||
// Tagged "result" so the client can hold it back until the replay finishes
|
|
||||||
// (the outcome is known now, but showing it early would spoil the battle).
|
|
||||||
if winner < 0 {
|
|
||||||
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
|
|
||||||
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
|
|
||||||
} else {
|
|
||||||
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
|
|
||||||
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)})
|
|
||||||
}
|
|
||||||
for _, p := range g.Players {
|
|
||||||
p.Ready = false
|
|
||||||
}
|
}
|
||||||
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+395
-7
@@ -29,6 +29,14 @@ const (
|
|||||||
FoodPineapple = "pineapple"
|
FoodPineapple = "pineapple"
|
||||||
FoodChili = "chili"
|
FoodChili = "chili"
|
||||||
FoodMelon = "melon"
|
FoodMelon = "melon"
|
||||||
|
// FoodAvocado (Golden pack) is a persistent set-aside token: buying it puts
|
||||||
|
// it aside rather than in the deck, and it can later be discarded in place
|
||||||
|
// of spending a coin on a buy. It is neither a perk nor a battle food.
|
||||||
|
FoodAvocado = "avocado"
|
||||||
|
// FoodPotato / FoodDurian / FoodTomato (Golden pack) are perk foods.
|
||||||
|
FoodPotato = "potato"
|
||||||
|
FoodDurian = "durian"
|
||||||
|
FoodTomato = "tomato"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EffectTrigger is when an effect fires.
|
// EffectTrigger is when an effect fires.
|
||||||
@@ -51,6 +59,13 @@ const (
|
|||||||
// TriggerPassive marks always-on effects (e.g. Garlic's damage
|
// TriggerPassive marks always-on effects (e.g. Garlic's damage
|
||||||
// prevention); they're consulted contextually rather than fired.
|
// prevention); they're consulted contextually rather than fired.
|
||||||
TriggerPassive EffectTrigger = "passive"
|
TriggerPassive EffectTrigger = "passive"
|
||||||
|
// TriggerAfterAttack fires on a pet right after it survives a clash it
|
||||||
|
// took part in (Golden pack: Bulldog). Effects here may be marked once
|
||||||
|
// per battle via the unit's after-attack flag.
|
||||||
|
TriggerAfterAttack EffectTrigger = "afterAttack"
|
||||||
|
// TriggerShopStart fires on a pet in the deck at the moment a shop round
|
||||||
|
// opens (Golden pack: Manta Ray).
|
||||||
|
TriggerShopStart EffectTrigger = "shopStart"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EffectAction is what the effect does.
|
// EffectAction is what the effect does.
|
||||||
@@ -124,6 +139,77 @@ const (
|
|||||||
// ActionPetAura (faint) sets the pet aside: the owner's pets have
|
// ActionPetAura (faint) sets the pet aside: the owner's pets have
|
||||||
// +Count power for the rest of the battle (Mammoth).
|
// +Count power for the rest of the battle (Mammoth).
|
||||||
ActionPetAura EffectAction = "petAura"
|
ActionPetAura EffectAction = "petAura"
|
||||||
|
|
||||||
|
// --- Golden pack ---
|
||||||
|
// ActionGainTrumpet adds Count Trumpets to the acting side's battle pool
|
||||||
|
// (Groundhog, Black-Necked Stilt, Guinea Fowl, Osprey, Honduran White
|
||||||
|
// Bat). With Per set, Count is multiplied by a battle statistic.
|
||||||
|
ActionGainTrumpet EffectAction = "gainTrumpet"
|
||||||
|
// ActionDrainTrumpet removes up to Count Trumpets from the enemy side's
|
||||||
|
// pool (Flea).
|
||||||
|
ActionDrainTrumpet EffectAction = "drainTrumpet"
|
||||||
|
// ActionPreventNextHit (faint) sets the pet aside: the next time a friendly
|
||||||
|
// pet is hit, prevent Count damage from that hit (Cone Snail). Unlike
|
||||||
|
// ActionShieldNext (Turtle), it reduces rather than fully blocks.
|
||||||
|
ActionPreventNextHit EffectAction = "preventNextHit"
|
||||||
|
// ActionSummonBottom puts Count cards (Effect.Card) on the BOTTOM of a
|
||||||
|
// deck. With Target "all" it hits every player's deck (Bear).
|
||||||
|
ActionSummonBottom EffectAction = "summonBottom"
|
||||||
|
// ActionBuyTopFree (sell) takes the top card of the current tier's shop
|
||||||
|
// deck into the player's deck for free, firing its Buy effect (Stoat).
|
||||||
|
ActionBuyTopFree EffectAction = "buyTopFree"
|
||||||
|
// ActionSetAside (buy) diverts the just-bought card out of the deck into a
|
||||||
|
// persistent set-aside zone (Avocado). Shop-time.
|
||||||
|
ActionSetAside EffectAction = "setAside"
|
||||||
|
|
||||||
|
// --- Golden pack, tiers 4-5 ---
|
||||||
|
// ActionDoubleTrumpets (play) grants the side extra Trumpets equal to what
|
||||||
|
// it holds, up to Cap (Vaquita).
|
||||||
|
ActionDoubleTrumpets EffectAction = "doubleTrumpets"
|
||||||
|
// ActionBeeRocks (faint) sets the pet aside: each time its owner plays a
|
||||||
|
// Bee, throw Count rocks at the enemy (Poison Dart Frog).
|
||||||
|
ActionBeeRocks EffectAction = "beeRocks"
|
||||||
|
// ActionFeedOnPlay (faint) sets the pet aside: each time its owner plays a
|
||||||
|
// pet, spend 1 Trumpet to feed that pet Count apples — mandatory when
|
||||||
|
// affordable (Giant Isopod).
|
||||||
|
ActionFeedOnPlay EffectAction = "feedOnPlay"
|
||||||
|
// ActionStealPerk (play) takes the enemy pet's active perk and puts it on
|
||||||
|
// top of this pet's owner's deck (Raccoon).
|
||||||
|
ActionStealPerk EffectAction = "stealPerk"
|
||||||
|
// ActionRecyclePerkApples (faint) puts this pet's active perk and up to
|
||||||
|
// Count of its apples on top of the owner's deck (Macaque).
|
||||||
|
ActionRecyclePerkApples EffectAction = "recyclePerkApples"
|
||||||
|
// ActionPreventSelf (play) gives the pet Cap one-shot charges that each
|
||||||
|
// prevent Count damage from a hit (Potato perk).
|
||||||
|
ActionPreventSelf EffectAction = "preventSelf"
|
||||||
|
// 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 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).
|
||||||
|
ActionFirstBuyFree EffectAction = "firstBuyFree"
|
||||||
|
// ActionRevealForApples (buy) asks the player to reveal another pet in hand;
|
||||||
|
// they gain apples equal to its power (Cockatoo). Shop-time decision.
|
||||||
|
ActionRevealForApples EffectAction = "revealForApples"
|
||||||
|
|
||||||
|
// --- Golden pack, tier 6 ---
|
||||||
|
// ActionShuffleApples (play) shuffles Count apples into random positions of
|
||||||
|
// the owner's remaining deck (Komodo). Gated by ConditionFirstPet.
|
||||||
|
ActionShuffleApples EffectAction = "shuffleApples"
|
||||||
|
// ActionReactivateBuys (battle prep) re-fires the Buy ability of every pet
|
||||||
|
// in the owner's hand (Catfish).
|
||||||
|
ActionReactivateBuys EffectAction = "reactivateBuys"
|
||||||
|
// ActionStartTrumpets (buy) banks Count Trumpets to start the next battle
|
||||||
|
// with (Bird of Paradise).
|
||||||
|
ActionStartTrumpets EffectAction = "startTrumpets"
|
||||||
|
// ActionGuardRetriever (faint) sets the pet aside: the owner's Golden
|
||||||
|
// Retriever, when summoned, prevents Count damage on its first hit (German
|
||||||
|
// Shepherd).
|
||||||
|
ActionGuardRetriever EffectAction = "guardRetriever"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Per multipliers for dynamic effect counts.
|
// Per multipliers for dynamic effect counts.
|
||||||
@@ -132,12 +218,25 @@ const (
|
|||||||
PerFaintedPets = "faintedPets" // × friendly pets fainted this battle
|
PerFaintedPets = "faintedPets" // × friendly pets fainted this battle
|
||||||
PerEatenApples = "eatenApples" // × apples this pet ate (its attached apples)
|
PerEatenApples = "eatenApples" // × apples this pet ate (its attached apples)
|
||||||
PerPower = "power" // × this pet's current power
|
PerPower = "power" // × this pet's current power
|
||||||
|
// PerUniqueFaintedHats × distinct suits ("hats") among friendly fainted
|
||||||
|
// pets this battle (Honduran White Bat). Battle-time.
|
||||||
|
PerUniqueFaintedHats = "uniqueFaintedHats"
|
||||||
|
// PerEnemyFaintedPets × pets the ENEMY side has fainted this battle (Royal
|
||||||
|
// Flycatcher). Battle-time.
|
||||||
|
PerEnemyFaintedPets = "enemyFaintedPets"
|
||||||
|
// PerShopFaintPets × pets currently in the shop row with a Faint effect
|
||||||
|
// (Opossum). Shop-time.
|
||||||
|
PerShopFaintPets = "shopFaintPets"
|
||||||
|
// PerBuysThisRound × Buy actions the player has taken this round, including
|
||||||
|
// the current one (Blue-Ringed Octopus). Shop-time.
|
||||||
|
PerBuysThisRound = "buysThisRound"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Effect conditions.
|
// Effect conditions.
|
||||||
const (
|
const (
|
||||||
ConditionTripled = "tripledThisRound" // player Tripled during this round's shop
|
ConditionTripled = "tripledThisRound" // player Tripled during this round's shop
|
||||||
ConditionHasPerk = "hasPerk" // this pet has a perk attached
|
ConditionHasPerk = "hasPerk" // this pet has a perk attached
|
||||||
|
ConditionFirstPet = "firstPet" // this is the first pet the side has played (Komodo)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Effect is one trigger→action pair printed on a card.
|
// Effect is one trigger→action pair printed on a card.
|
||||||
@@ -155,6 +254,10 @@ type Effect struct {
|
|||||||
Condition string `json:"condition,omitempty"`
|
Condition string `json:"condition,omitempty"`
|
||||||
// MinRound gates the effect to round >= MinRound (0 = always).
|
// MinRound gates the effect to round >= MinRound (0 = always).
|
||||||
MinRound int `json:"minRound,omitempty"`
|
MinRound int `json:"minRound,omitempty"`
|
||||||
|
// CostTrumpet, when > 0, is a Trumpet cost the acting side must pay for the
|
||||||
|
// effect to fire (Golden pack). It's auto-paid when affordable and the
|
||||||
|
// effect is skipped otherwise — battles take no player input.
|
||||||
|
CostTrumpet int `json:"costTrumpet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// count normalizes the zero value to 1.
|
// count normalizes the zero value to 1.
|
||||||
@@ -476,6 +579,272 @@ var foodTiers = [MaxRounds][]foodTemplate{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// goldenPetTiers defines the Golden pack's pets — all six tiers. Each pet ships
|
||||||
|
// as two copies (one per listed suit).
|
||||||
|
var goldenPetTiers = [MaxRounds][]petTemplate{
|
||||||
|
{ // Tier 1
|
||||||
|
{
|
||||||
|
Name: "Groundhog", Power: 1, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionGainTrumpet}},
|
||||||
|
EffectText: "Faint: gain 1 Trumpet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Pied Tamarin", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, CostTrumpet: 1}},
|
||||||
|
EffectText: "Play: spend 1 Trumpet to throw 2 Rocks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Chipmunk", Power: 1, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerSell, Action: ActionGainApple, Count: 2}},
|
||||||
|
EffectText: "Sell: add 2 extra Apples to your hand",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Cone Snail", Power: 1, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionPreventNextHit, Count: 2}},
|
||||||
|
EffectText: "Faint: set aside — the next time a friendly pet is hit, prevent 2 damage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Bulldog", Power: 2, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple}},
|
||||||
|
EffectText: "After Attacking: if it hasn't fainted, eat 1 Apple (once per round)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Opossum", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerSell, Action: ActionGainApple, Per: PerShopFaintPets}},
|
||||||
|
EffectText: "Sell: add 1 extra Apple to your hand for each Faint pet in the shop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 2
|
||||||
|
{
|
||||||
|
Name: "Black-Necked Stilt", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionGainTrumpet}},
|
||||||
|
EffectText: "Faint: gain 1 Trumpet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Lizard", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionSummonTop, Card: "bee"}},
|
||||||
|
EffectText: "Hurt: add a Bee on top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Hercules Beetle", Power: 1, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerSell, Action: ActionApplesInPlay, Count: 3, MinRound: 3}},
|
||||||
|
EffectText: "Sell: if it is round 3 or later, start the battle with 3 Apples in play",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Stoat", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerSell, Action: ActionBuyTopFree}},
|
||||||
|
EffectText: "Sell: buy the top card of the shop deck for free",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Desert Rain Frog", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Count: 2, CostTrumpet: 1}},
|
||||||
|
EffectText: "Faint: spend 1 Trumpet to add 2 Bees on top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Honduran White Bat", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainTrumpet, Per: PerUniqueFaintedHats}},
|
||||||
|
EffectText: "Play: gain 1 Trumpet for each unique suit among friendly fainted pets",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 3
|
||||||
|
{
|
||||||
|
Name: "Guinea Fowl", Power: 3, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionGainTrumpet}},
|
||||||
|
EffectText: "Hurt: gain 1 Trumpet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Surgeon Fish", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 3, CostTrumpet: 1}},
|
||||||
|
EffectText: "Play: spend 1 Trumpet to add 3 Apples on top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Osprey", Power: 3, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{
|
||||||
|
{Trigger: TriggerFaint, Action: ActionGainTrumpet},
|
||||||
|
{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee"},
|
||||||
|
},
|
||||||
|
EffectText: "Faint: gain 1 Trumpet and add a Bee on top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Anteater", Power: 3, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{
|
||||||
|
{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple"},
|
||||||
|
{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee"},
|
||||||
|
},
|
||||||
|
EffectText: "Faint: add an Apple, then a Bee, on top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Bear", Power: 4, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonBottom, Card: "bee", Target: "all"}},
|
||||||
|
EffectText: "Faint: add a Bee to the bottom of every player's deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Royal Flycatcher", Power: 1, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Per: PerEnemyFaintedPets}},
|
||||||
|
EffectText: "Play: throw a Rock for each enemy fainted pet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Flea", Power: 2, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionDrainTrumpet, Count: 3}},
|
||||||
|
EffectText: "Faint: your opponent loses 3 Trumpets",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 4
|
||||||
|
{
|
||||||
|
Name: "Saiga Antelope", Power: 1, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainTrumpet, Per: PerFaintedPets}},
|
||||||
|
EffectText: "Play: gain 1 Trumpet for each friendly fainted pet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Vaquita", Power: 2, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionDoubleTrumpets, Cap: 4}},
|
||||||
|
EffectText: "Play: double your Trumpets (up to 4 gained)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Poison Dart Frog", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionBeeRocks, Count: 2}},
|
||||||
|
EffectText: "Faint: set aside — each time you play a Bee, throw 2 Rocks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Manta Ray", Power: 4, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerShopStart, Action: ActionFirstBuyFree, Count: 4}},
|
||||||
|
EffectText: "Shop start: if you have 4 or fewer pets, your first Buy is free",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Slug", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{
|
||||||
|
{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Count: 2},
|
||||||
|
{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple"},
|
||||||
|
},
|
||||||
|
EffectText: "Faint: add 2 Bees, then an Apple, on top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Cockatoo", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionRevealForApples}},
|
||||||
|
EffectText: "Buy: reveal another pet in your hand — gain Apples equal to its Power",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Manatee", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{
|
||||||
|
{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, Target: "self"},
|
||||||
|
{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 4},
|
||||||
|
},
|
||||||
|
EffectText: "Play: throw 2 Rocks at itself, then add 4 Apples on top of your deck",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 5
|
||||||
|
{
|
||||||
|
Name: "Nyala", Power: 4, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionGainTrumpet, Count: 2}},
|
||||||
|
EffectText: "Faint: gain 2 Trumpets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Nurse Shark", Power: 3, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSpendRocks, Count: 3}},
|
||||||
|
EffectText: "Play: spend up to 3 Trumpets to throw twice as many Rocks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Giant Isopod", Power: 4, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionFeedOnPlay, Count: 2}},
|
||||||
|
EffectText: "Faint: set aside — each time you play a pet, spend 1 Trumpet to feed it 2 Apples",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Blue-Ringed Octopus", Power: 4, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainApple, Per: PerBuysThisRound}},
|
||||||
|
EffectText: "Buy: add an Apple to your hand for each Buy you've made this round",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Raccoon", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionStealPerk}},
|
||||||
|
EffectText: "Play: steal the enemy pet's Perk onto the top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Fire Ant", Power: 2, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple", Count: 4}},
|
||||||
|
EffectText: "Faint: add 4 Apples on top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Macaque", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionRecyclePerkApples, Count: 4}},
|
||||||
|
EffectText: "Faint: put this pet's Perk and up to 4 of its Apples on top of your deck",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 6
|
||||||
|
{
|
||||||
|
Name: "Highland Cow", Power: 3, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainTrumpet, Per: PerPower}},
|
||||||
|
EffectText: "Play: gain Trumpets equal to this pet's Power",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Wildebeest", Power: 6, Suits: []Suit{SuitYellow, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionShieldSelf, CostTrumpet: 3}},
|
||||||
|
EffectText: "Play: spend 3 Trumpets to prevent all damage the first time it is hit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Grizzly Bear", Power: 5, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Per: PerFaintedPets}},
|
||||||
|
EffectText: "Play: throw a Rock for each friendly fainted pet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Catfish", Power: 5, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerBattlePrep, Action: ActionReactivateBuys}},
|
||||||
|
EffectText: "Battle Prep: reactivate every Buy ability on the pets in your hand",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Komodo", Power: 6, Suits: []Suit{SuitYellow, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionShuffleApples, Count: 6, Condition: ConditionFirstPet}},
|
||||||
|
EffectText: "Play: if this is your first pet, shuffle 6 Apples into your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Bird of Paradise", Power: 4, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
|
Effects: []Effect{
|
||||||
|
{Trigger: TriggerBuy, Action: ActionApplesInPlay, Count: 2},
|
||||||
|
{Trigger: TriggerBuy, Action: ActionStartTrumpets, Count: 2},
|
||||||
|
},
|
||||||
|
EffectText: "Buy: start the next battle with 2 Apples and 2 Trumpets in play",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "German Shepherd", Power: 5, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionGuardRetriever, Count: 5}},
|
||||||
|
EffectText: "Faint: set aside — your Golden Retriever prevents 5 damage the first time it is hit",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// goldenFoodTiers defines the Golden pack's food cards. Only Avocado (tier 3)
|
||||||
|
// exists so far.
|
||||||
|
var goldenFoodTiers = [MaxRounds][]foodTemplate{
|
||||||
|
{}, {}, // Tiers 1-2
|
||||||
|
{ // Tier 3
|
||||||
|
{
|
||||||
|
Name: "Avocado", Food: FoodAvocado, Copies: 2,
|
||||||
|
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionSetAside}},
|
||||||
|
EffectText: "Buy: set aside — later discard it instead of a coin to buy a card",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 4
|
||||||
|
{
|
||||||
|
Name: "Potato", Food: FoodPotato, Copies: 2, Perk: true,
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionPreventSelf, Count: 2, Cap: 2}},
|
||||||
|
EffectText: "The first two times this pet is hit, prevent 2 damage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 5
|
||||||
|
{
|
||||||
|
Name: "Durian", Food: FoodDurian, Copies: 2, Perk: true,
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionStripApples}},
|
||||||
|
EffectText: "Play: discard all enemy Apples in play",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 6
|
||||||
|
{
|
||||||
|
Name: "Tomato", Food: FoodTomato, Copies: 2, Perk: true,
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 4}},
|
||||||
|
EffectText: "Play: throw 4 Rocks",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// newCardID mints a unique card ID within the game.
|
// newCardID mints a unique card ID within the game.
|
||||||
func (g *Game) newCardID() string {
|
func (g *Game) newCardID() string {
|
||||||
g.NextCardID++
|
g.NextCardID++
|
||||||
@@ -487,6 +856,8 @@ func (g *Game) newCardID() string {
|
|||||||
// back to it so this is the single seam future packs plug their cards into.
|
// back to it so this is the single seam future packs plug their cards into.
|
||||||
func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTemplate) {
|
func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTemplate) {
|
||||||
switch pack {
|
switch pack {
|
||||||
|
case "golden":
|
||||||
|
return &goldenPetTiers, &goldenFoodTiers
|
||||||
default: // turtle (and the placeholder packs, until they ship)
|
default: // turtle (and the placeholder packs, until they ship)
|
||||||
return &petTiers, &foodTiers
|
return &petTiers, &foodTiers
|
||||||
}
|
}
|
||||||
@@ -556,11 +927,13 @@ func Catalog() []Card {
|
|||||||
return cards
|
return cards
|
||||||
}
|
}
|
||||||
|
|
||||||
// cardByName mints a fresh instance of the named pet or food from its
|
// cardByName mints a fresh instance of the named pet or food from the current
|
||||||
// template (pets take their first printed suit). Returns false if unknown.
|
// pack's templates (pets take their first printed suit). Returns false if
|
||||||
|
// unknown.
|
||||||
func (g *Game) cardByName(name string) (Card, bool) {
|
func (g *Game) cardByName(name string) (Card, bool) {
|
||||||
for tierIdx := range petTiers {
|
pets, foods := packTiers(g.Pack)
|
||||||
for _, t := range petTiers[tierIdx] {
|
for tierIdx := range pets {
|
||||||
|
for _, t := range pets[tierIdx] {
|
||||||
if t.Name == name {
|
if t.Name == name {
|
||||||
suit := SuitRed
|
suit := SuitRed
|
||||||
if len(t.Suits) > 0 {
|
if len(t.Suits) > 0 {
|
||||||
@@ -572,7 +945,7 @@ func (g *Game) cardByName(name string) (Card, bool) {
|
|||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, f := range foodTiers[tierIdx] {
|
for _, f := range foods[tierIdx] {
|
||||||
if f.Name == name {
|
if f.Name == name {
|
||||||
return Card{
|
return Card{
|
||||||
ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1,
|
ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1,
|
||||||
@@ -608,3 +981,18 @@ func (g *Game) newBee() Card {
|
|||||||
Temporary: true,
|
Temporary: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newGoldenRetriever mints the Golden pack's supply pet: a temporary,
|
||||||
|
// unbuyable pet whose base Power equals the Trumpets that summoned it. Those
|
||||||
|
// Trumpets can't be added to or removed, so it takes no auras and eats no
|
||||||
|
// apples (documented no-op today: tiers 1-3 have no auras).
|
||||||
|
func (g *Game) newGoldenRetriever(power int) Card {
|
||||||
|
return Card{
|
||||||
|
ID: g.newCardID(),
|
||||||
|
Kind: KindPet,
|
||||||
|
Name: "Golden Retriever",
|
||||||
|
Power: power,
|
||||||
|
Temporary: true,
|
||||||
|
EffectText: "Its Power equals its Trumpets, which can't be changed.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+337
-12
@@ -49,6 +49,23 @@ type Player struct {
|
|||||||
// TripledThisRound records whether the player used the Triple (trade-in)
|
// TripledThisRound records whether the player used the Triple (trade-in)
|
||||||
// action during the current round's shop (Bison's Battle Prep).
|
// action during the current round's shop (Bison's Battle Prep).
|
||||||
TripledThisRound bool `json:"tripledThisRound"`
|
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
|
// IsBot marks a computer-controlled seat. The engine treats bots exactly
|
||||||
// like humans; the server drives their actions. BotLevel is the bot's
|
// 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
|
// 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"`
|
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
|
// Game is the complete authoritative state. It is a pure state machine: no
|
||||||
// goroutines, no clocks, no I/O. Callers are responsible for locking.
|
// goroutines, no clocks, no I/O. Callers are responsible for locking.
|
||||||
type Game struct {
|
type Game struct {
|
||||||
@@ -101,24 +127,104 @@ type Game struct {
|
|||||||
// it put.
|
// it put.
|
||||||
PrioritySeat int `json:"prioritySeat"`
|
PrioritySeat int `json:"prioritySeat"`
|
||||||
Pending *PendingTrade `json:"pending,omitempty"`
|
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"`
|
NextCardID int `json:"nextCardId"`
|
||||||
WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie
|
WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie
|
||||||
// Log is the running, human-readable event log shown across every phase.
|
// Log is the running, human-readable event log shown across every phase.
|
||||||
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
|
||||||
|
// 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.
|
// rollRockDie rolls one rock die: 0, 1, or 2 with equal probability.
|
||||||
func (g *Game) rollRockDie() int {
|
func (g *Game) rollRockDie() int { return g.battleDraw(3) }
|
||||||
if g.RollDie != nil {
|
|
||||||
return g.RollDie()
|
// 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 (
|
var (
|
||||||
@@ -302,15 +408,24 @@ func (g *Game) start() {
|
|||||||
func (g *Game) startShopRound() {
|
func (g *Game) startShopRound() {
|
||||||
g.Phase = PhaseShop
|
g.Phase = PhaseShop
|
||||||
g.Pending = nil
|
g.Pending = nil
|
||||||
|
g.PendingReveal = nil
|
||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
p.Coins = CoinsPerRound
|
p.Coins = CoinsPerRound
|
||||||
p.Ready = false
|
p.Ready = false
|
||||||
p.TripledThisRound = false
|
p.TripledThisRound = false
|
||||||
|
p.FirstBuyFree = false
|
||||||
|
p.BuysThisRound = 0
|
||||||
}
|
}
|
||||||
g.ShopRow = make([]Card, ShopRowSize)
|
g.ShopRow = make([]Card, ShopRowSize)
|
||||||
for i := range g.ShopRow {
|
for i := range g.ShopRow {
|
||||||
g.ShopRow[i] = g.drawFromTier(g.Round)
|
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.
|
// The priority-token holder shops first.
|
||||||
g.Turn = g.PrioritySeat
|
g.Turn = g.PrioritySeat
|
||||||
g.logf(-1, "🛒", "Round %d — shop opens (%s goes first).", g.Round, g.Players[g.PrioritySeat].Name)
|
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 {
|
if g.Pending != nil {
|
||||||
return nil, fmt.Errorf("%w: finish your trade first", ErrInvalidAction)
|
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
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buy spends one coin to take the card at rowIdx into the player's deck.
|
// 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
|
// 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 {
|
func (g *Game) Buy(playerID string, rowIdx int) error {
|
||||||
p, err := g.requireShopTurn(playerID)
|
p, err := g.requireShopTurn(playerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
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 == "" {
|
if rowIdx < 0 || rowIdx >= len(g.ShopRow) || g.ShopRow[rowIdx].ID == "" {
|
||||||
return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction)
|
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]
|
bought := g.ShopRow[rowIdx]
|
||||||
p.Deck = append(p.Deck, bought)
|
p.Deck = append(p.Deck, bought)
|
||||||
g.addLog(LogEntry{Seat: p.Seat, Icon: "🛒", Kind: LogBuy, Source: bought.ID, CardName: bought.Name,
|
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.ShopRow[rowIdx] = g.drawFromTier(g.Round)
|
||||||
g.applyShopTrigger(p, bought, TriggerBuy)
|
g.applyShopTrigger(p, bought, TriggerBuy)
|
||||||
g.advanceShopTurn()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sell converts any number (>=1) of the player's cards: each becomes an
|
// 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 {
|
switch e.Action {
|
||||||
case ActionGainApple:
|
case ActionGainApple:
|
||||||
n := e.count()
|
n := e.count()
|
||||||
|
switch e.Per {
|
||||||
|
case PerShopFaintPets:
|
||||||
|
n *= g.countShopFaintPets()
|
||||||
|
case PerBuysThisRound:
|
||||||
|
n *= p.BuysThisRound
|
||||||
|
}
|
||||||
|
if n <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
for range n {
|
for range n {
|
||||||
p.Deck = append(p.Deck, g.newApple())
|
p.Deck = append(p.Deck, g.newApple())
|
||||||
}
|
}
|
||||||
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: n,
|
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)})
|
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:
|
case ActionRefreshGold:
|
||||||
p.Coins = min(p.Coins+e.count(), CoinsPerRound)
|
p.Coins = min(p.Coins+e.count(), CoinsPerRound)
|
||||||
g.logf(p.Seat, "🪙", "%s refreshes %s's coins.", c.Name, p.Name)
|
g.logf(p.Seat, "🪙", "%s refreshes %s's coins.", c.Name, p.Name)
|
||||||
@@ -466,6 +713,24 @@ func hasBuyEffect(c Card) bool {
|
|||||||
return false
|
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
|
// 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
|
// the top two cards of the next tier's deck — a free action. The player must
|
||||||
// then call TradeChoose before anything else happens.
|
// 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.
|
// Pets obtained via the Triple action trigger their Buy effects.
|
||||||
g.applyShopTrigger(p, chosen, TriggerBuy)
|
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()
|
g.advanceShopTurn()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -664,17 +957,49 @@ func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error {
|
|||||||
p.Deck = ordered
|
p.Deck = ordered
|
||||||
p.Ready = true
|
p.Ready = true
|
||||||
if g.allReady() {
|
if g.allReady() {
|
||||||
g.resolveBattle()
|
g.startBattle()
|
||||||
}
|
}
|
||||||
return nil
|
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
|
// 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")
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// --- Nurse Shark: the mid-battle decision channel ---
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 2 } // each rock deals 2
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Nurse Shark")}, // Nyala faint: +2 trumpets
|
||||||
|
[]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 := 0
|
||||||
|
for _, ev := range eventsOfType(g.Battle, "trumpet") {
|
||||||
|
if ev.Count < 0 {
|
||||||
|
spends++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spends != 1 {
|
||||||
|
t.Fatalf("expected one trumpet spend, got %d", spends)
|
||||||
|
}
|
||||||
|
rocks := eventsOfType(g.Battle, "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choosing to spend zero Trumpets throws no rocks.
|
||||||
|
func TestNurseSharkSpendZero(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)},
|
||||||
|
)
|
||||||
|
if g.PendingBattle == nil {
|
||||||
|
t.Fatal("expected a pending decision")
|
||||||
|
}
|
||||||
|
if err := g.BattleChoose(g.Players[0].ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(eventsOfType(g.Battle, "rock")) != 0 {
|
||||||
|
t.Fatal("spending zero Trumpets should throw no 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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cardWithName(name string, power int, eff []Effect) Card {
|
||||||
|
return Card{ID: "t-" + name, Kind: KindPet, Name: name, Power: power, Effects: eff}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Other tier 4-5 battle mechanics ---
|
||||||
|
|
||||||
|
// Vaquita doubles the side's Trumpets, capped at 4 gained.
|
||||||
|
func TestVaquitaDoublesTrumpets(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Vaquita")}, // Nyala faint: +2
|
||||||
|
[]Card{g.pet("Big", 4), g.pet("Wall", 20)},
|
||||||
|
)
|
||||||
|
// Nyala trades with Big (+2 Trumpets), Vaquita enters and doubles to 4.
|
||||||
|
var gain *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 2 {
|
||||||
|
// two events: Nyala's +2 gain and Vaquita's +2 double.
|
||||||
|
gain = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gain == nil {
|
||||||
|
t.Fatalf("Vaquita should double 2 Trumpets into 2 more: %+v", eventsOfType(res, "trumpet"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manatee throws rocks at itself.
|
||||||
|
func TestManateeSelfRock(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 1 } // each rock deals 1
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Manatee")},
|
||||||
|
[]Card{g.pet("Wall", 20)},
|
||||||
|
)
|
||||||
|
var self *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "rock" && ev.Seat == 0 && ev.Target == 0 {
|
||||||
|
self = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self == nil || self.Roll != 2 {
|
||||||
|
t.Fatalf("Manatee should pelt itself for 2: %+v", eventsOfType(res, "rock"))
|
||||||
|
}
|
||||||
|
// It also stacks 4 apples on top of the deck.
|
||||||
|
apples := 0
|
||||||
|
for _, ev := range eventsOfType(res, "summon") {
|
||||||
|
if ev.Card != nil && ev.Card.Name == "Apple" {
|
||||||
|
apples++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apples != 4 {
|
||||||
|
t.Fatalf("Manatee should add 4 apples, got %d", apples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poison Dart Frog, once set aside, throws rocks each time a Bee is played.
|
||||||
|
func TestPoisonDartFrogBeeRocks(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 2 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Poison Dart Frog"), g.newBee()},
|
||||||
|
[]Card{g.pet("Killer", 2), g.pet("Tank", 1)},
|
||||||
|
)
|
||||||
|
// Frog trades with Killer (set aside); the Bee enters and triggers 2 rocks
|
||||||
|
// (roll 4) that kill the 1-power Tank.
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied {
|
||||||
|
t.Fatalf("playing a Bee should throw Poison Dart Frog's 2 rocks: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Giant Isopod, once set aside, spends a Trumpet to feed each played pet.
|
||||||
|
func TestGiantIsopodFeedsOnPlay(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Giant Isopod"), g.pet("Ally", 1)},
|
||||||
|
[]Card{g.pet("K1", 4), g.pet("K2", 4), g.pet("Wall", 1)},
|
||||||
|
)
|
||||||
|
// Nyala banks 2 Trumpets, Isopod faints (set aside), then Ally enters and is
|
||||||
|
// fed 2 apples (spending 1 Trumpet), reaching power 3 to beat the Wall.
|
||||||
|
fed := false
|
||||||
|
for _, ev := range eventsOfType(res, "eat") {
|
||||||
|
if ev.Seat == 0 && ev.Bonus == 2 {
|
||||||
|
fed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !fed {
|
||||||
|
t.Fatalf("Giant Isopod should feed Ally 2 apples: %+v", eventsOfType(res, "eat"))
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("fed Ally should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raccoon steals the enemy perk onto the top of its own deck.
|
||||||
|
func TestRaccoonStealsPerk(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Raccoon")},
|
||||||
|
[]Card{g.realFood(t, "Melon"), g.pet("Foe", 2)},
|
||||||
|
)
|
||||||
|
stole := false
|
||||||
|
for _, ev := range eventsOfType(res, "summon") {
|
||||||
|
if ev.Card != nil && ev.Card.Name == "Melon" && ev.Seat == 0 {
|
||||||
|
stole = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stole {
|
||||||
|
t.Fatalf("Raccoon should summon the stolen Melon onto its own deck: %+v", eventsOfType(res, "summon"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Macaque recycles its perk and apples onto the top of the deck.
|
||||||
|
func TestMacaqueRecyclesPerkAndApples(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
// Macaque wears Honey (a perk) and carries an apple, then faints.
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realFood(t, "Honey"), g.newApple(), g.goldenPet(t, "Macaque")},
|
||||||
|
[]Card{g.pet("Big", 9)},
|
||||||
|
)
|
||||||
|
// On faint it puts the apple and the Honey perk back on top of the deck.
|
||||||
|
var sawApple, sawHoney bool
|
||||||
|
for _, ev := range eventsOfType(res, "summon") {
|
||||||
|
if ev.Card == nil || ev.Seat != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ev.Card.Name {
|
||||||
|
case "Apple":
|
||||||
|
sawApple = true
|
||||||
|
case "Honey":
|
||||||
|
sawHoney = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawApple || !sawHoney {
|
||||||
|
t.Fatalf("Macaque should recycle its apple and Honey perk: %+v", eventsOfType(res, "summon"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Potato prevents 2 damage on each of the first two hits.
|
||||||
|
func TestPotatoPreventsTwice(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenFood(t, "Potato"), g.pet("Hero", 5)},
|
||||||
|
[]Card{g.pet("A", 3), g.pet("B", 3)},
|
||||||
|
)
|
||||||
|
prevents := eventsOfType(res, "prevent")
|
||||||
|
if len(prevents) != 2 {
|
||||||
|
t.Fatalf("Potato should prevent damage twice, got %d: %+v", len(prevents), prevents)
|
||||||
|
}
|
||||||
|
for _, p := range prevents {
|
||||||
|
if p.Count != 2 || p.Seat != 0 {
|
||||||
|
t.Fatalf("each prevention should shave 2 for seat 0: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("Hero should survive both hits and win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durian discards the enemy's apples in play.
|
||||||
|
func TestDurianStripsEnemyApples(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenFood(t, "Durian"), g.pet("Hero", 3)},
|
||||||
|
[]Card{g.newApple(), g.newApple(), g.pet("Foe", 1)}, // Foe buffed to power 3
|
||||||
|
)
|
||||||
|
// Without the strip, a power-3 Foe would trade evenly; stripped back to
|
||||||
|
// power 1, it just dies.
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("Durian should strip Foe's apples so Hero wins, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
if len(eventsOfType(res, "strip")) == 0 {
|
||||||
|
t.Fatal("expected a strip event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saiga Antelope banks a Trumpet per friendly fainted pet on entry.
|
||||||
|
func TestSaigaTrumpetsPerFainted(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("F1", 1), g.pet("F2", 1), g.goldenPet(t, "Saiga Antelope")},
|
||||||
|
[]Card{g.pet("K1", 1), g.pet("K2", 1), g.pet("Last", 1)},
|
||||||
|
)
|
||||||
|
// F1/F2 trade with K1/K2 (2 friendly faints), then Saiga banks 2 Trumpets.
|
||||||
|
var gain *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 2 {
|
||||||
|
gain = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gain == nil {
|
||||||
|
t.Fatalf("Saiga should bank 2 Trumpets for 2 friendly faints: %+v", eventsOfType(res, "trumpet"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tier 4-5 shop mechanics ---
|
||||||
|
|
||||||
|
// Manta Ray waives the cost of the first buy each round (with few pets).
|
||||||
|
func TestMantaRayFirstBuyFree(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
p1.Deck = append(p1.Deck, g.goldenPet(t, "Manta Ray"))
|
||||||
|
g.startShopRound() // re-open the shop with Manta Ray in the deck
|
||||||
|
if !p1.FirstBuyFree {
|
||||||
|
t.Fatal("Manta Ray should ready a free first buy at 4 or fewer pets")
|
||||||
|
}
|
||||||
|
g.Turn = 0
|
||||||
|
coins := p1.Coins
|
||||||
|
g.ShopRow[0] = g.goldenPet(t, "Nyala")
|
||||||
|
if err := g.Buy(p1.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p1.Coins != coins {
|
||||||
|
t.Fatalf("the first buy should be free: coins %d -> %d", coins, p1.Coins)
|
||||||
|
}
|
||||||
|
if p1.FirstBuyFree {
|
||||||
|
t.Fatal("the free-buy flag should clear after use")
|
||||||
|
}
|
||||||
|
// The next buy costs a coin.
|
||||||
|
g.Turn = 0
|
||||||
|
if err := g.Buy(p1.ID, 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p1.Coins != coins-1 {
|
||||||
|
t.Fatalf("the second buy should cost a coin: %d", p1.Coins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blue-Ringed Octopus grants an apple per buy made this round.
|
||||||
|
func TestOctopusApplesPerBuy(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
g.Turn = 0
|
||||||
|
g.ShopRow[0] = g.goldenPet(t, "Nyala") // a plain first buy
|
||||||
|
if err := g.Buy(p1.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Turn = 0
|
||||||
|
g.ShopRow[1] = g.goldenPet(t, "Blue-Ringed Octopus") // the second buy
|
||||||
|
if err := g.Buy(p1.ID, 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Second buy: buysThisRound == 2 -> 2 apples.
|
||||||
|
if countApplesIn(p1.Deck) != 2 {
|
||||||
|
t.Fatalf("Octopus bought as the 2nd buy should add 2 apples, got %d", countApplesIn(p1.Deck))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cockatoo makes the buyer reveal a pet for apples equal to its power.
|
||||||
|
func TestCockatooReveal(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
beefy := g.pet("Beefy", 5)
|
||||||
|
p1.Deck = append(p1.Deck, beefy)
|
||||||
|
g.Turn = 0
|
||||||
|
g.ShopRow[0] = g.goldenPet(t, "Cockatoo")
|
||||||
|
if err := g.Buy(p1.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if g.PendingReveal == nil || g.PendingReveal.PlayerID != p1.ID {
|
||||||
|
t.Fatalf("buying Cockatoo should open a reveal: %+v", g.PendingReveal)
|
||||||
|
}
|
||||||
|
if g.Turn != 0 {
|
||||||
|
t.Fatalf("the turn should stay on the buyer during the reveal, got %d", g.Turn)
|
||||||
|
}
|
||||||
|
if err := g.RevealChoose(p1.ID, beefy.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if g.PendingReveal != nil {
|
||||||
|
t.Fatal("the reveal should be resolved")
|
||||||
|
}
|
||||||
|
if countApplesIn(p1.Deck) != 5 {
|
||||||
|
t.Fatalf("revealing a power-5 pet should add 5 apples, got %d", countApplesIn(p1.Deck))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Highland Cow banks Trumpets equal to its power on entry.
|
||||||
|
func TestHighlandCowTrumpetsFromPower(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Highland Cow")}, // power 3
|
||||||
|
[]Card{g.pet("Foe", 1)},
|
||||||
|
)
|
||||||
|
var gain *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 3 {
|
||||||
|
gain = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gain == nil {
|
||||||
|
t.Fatalf("Highland Cow should bank 3 Trumpets (its power): %+v", eventsOfType(res, "trumpet"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildebeest spends 3 Trumpets on entry to shield its first hit.
|
||||||
|
func TestWildebeestSpendsForShield(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Highland Cow"), g.goldenPet(t, "Wildebeest")}, // Cow banks 3
|
||||||
|
[]Card{g.pet("Chip", 1), g.pet("Hitter", 6)},
|
||||||
|
)
|
||||||
|
// Cow banks 3 Trumpets and trades with Chip, then dies to Hitter; Wildebeest
|
||||||
|
// enters, spends the 3 Trumpets for a shield, and blocks Hitter's blow.
|
||||||
|
spent := false
|
||||||
|
for _, ev := range eventsOfType(res, "trumpet") {
|
||||||
|
if ev.Seat == 0 && ev.Count == -3 {
|
||||||
|
spent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !spent {
|
||||||
|
t.Fatalf("Wildebeest should spend 3 Trumpets: %+v", eventsOfType(res, "trumpet"))
|
||||||
|
}
|
||||||
|
if len(eventsOfType(res, "shield")) == 0 {
|
||||||
|
t.Fatal("Wildebeest should block a hit with its shield")
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("Wildebeest should survive the block and win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grizzly Bear throws a rock per friendly fainted pet.
|
||||||
|
func TestGrizzlyRocksPerFainted(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 2 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("F1", 1), g.pet("F2", 1), g.goldenPet(t, "Grizzly Bear")},
|
||||||
|
[]Card{g.pet("K1", 1), g.pet("K2", 1), g.pet("Tank", 2)},
|
||||||
|
)
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || len(rocks[0].Dice) != 2 || !rocks[0].TargetDied {
|
||||||
|
t.Fatalf("Grizzly should throw 2 rocks (one per friendly faint) and kill Tank: %+v", rocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catfish reactivates every pet's Buy ability at battle prep.
|
||||||
|
func TestCatfishReactivatesBuys(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
p1.Deck = []Card{g.goldenPet(t, "Catfish"), g.realPet(t, "Otter")} // Otter buy: +1 apple
|
||||||
|
g.beginArrange()
|
||||||
|
if countApplesIn(p1.Deck) != 1 {
|
||||||
|
t.Fatalf("Catfish should re-fire Otter's buy for 1 apple, got %d", countApplesIn(p1.Deck))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Komodo shuffles 6 apples into the deck when it is the first pet.
|
||||||
|
func TestKomodoShufflesApplesFirstPet(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Komodo")}, // power 6, first pet
|
||||||
|
[]Card{g.pet("Foe", 1)},
|
||||||
|
)
|
||||||
|
apples := 0
|
||||||
|
for _, ev := range eventsOfType(res, "summon") {
|
||||||
|
if ev.Card != nil && ev.Card.Name == "Apple" && ev.Seat == 0 {
|
||||||
|
apples++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apples != 6 {
|
||||||
|
t.Fatalf("Komodo should shuffle 6 apples in as the first pet, got %d", apples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Komodo does nothing when it is not the first pet played.
|
||||||
|
func TestKomodoNoShuffleWhenNotFirst(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("Weak", 1), g.goldenPet(t, "Komodo")},
|
||||||
|
[]Card{g.pet("K", 1), g.pet("Foe", 1)},
|
||||||
|
)
|
||||||
|
for _, ev := range eventsOfType(res, "summon") {
|
||||||
|
if ev.Card != nil && ev.Card.Name == "Apple" {
|
||||||
|
t.Fatal("Komodo should not shuffle apples when it isn't the first pet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bird of Paradise banks 2 apples-in-play and 2 Trumpets for the next battle.
|
||||||
|
func TestBirdOfParadiseBuy(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
g.ShopRow[0] = g.goldenPet(t, "Bird of Paradise")
|
||||||
|
g.Turn = 0
|
||||||
|
if err := g.Buy(p1.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p1.PendingApplesInPlay != 2 || p1.PendingTrumpets != 2 {
|
||||||
|
t.Fatalf("Bird of Paradise should bank 2 apples + 2 Trumpets, got %d/%d",
|
||||||
|
p1.PendingApplesInPlay, p1.PendingTrumpets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The banked Trumpets appear in the pool at battle start, and reset after.
|
||||||
|
func TestPendingTrumpetsSeedBattle(t *testing.T) {
|
||||||
|
g, p1, _ := testGame(t)
|
||||||
|
p1.PendingTrumpets = 2
|
||||||
|
res := forceBattle(t, g, []Card{g.pet("Hero", 3)}, []Card{g.pet("Foe", 1)})
|
||||||
|
var seeded *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 2 {
|
||||||
|
seeded = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seeded == nil {
|
||||||
|
t.Fatalf("the battle should start with 2 Trumpets in the pool: %+v", eventsOfType(res, "trumpet"))
|
||||||
|
}
|
||||||
|
if p1.PendingTrumpets != 0 {
|
||||||
|
t.Fatalf("PendingTrumpets should reset after the battle, got %d", p1.PendingTrumpets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// German Shepherd guards the Golden Retriever's first hit.
|
||||||
|
func TestGermanShepherdGuardsRetriever(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "German Shepherd"), g.goldenPet(t, "Nyala")}, // Nyala banks 2 Trumpets
|
||||||
|
[]Card{g.pet("BigA", 5), g.pet("BigB", 9)},
|
||||||
|
)
|
||||||
|
// German Shepherd faints (guard aside), Nyala faints (+2 Trumpets), then the
|
||||||
|
// Golden Retriever arrives and its first hit is shaved by 5.
|
||||||
|
var guarded *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "prevent" && ev.Seat == 0 && ev.Count == 5 {
|
||||||
|
guarded = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if guarded == nil {
|
||||||
|
t.Fatalf("the Golden Retriever's first hit should be guarded for 5: %+v", eventsOfType(res, "prevent"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tomato is a perk that throws 4 rocks on play.
|
||||||
|
func TestTomatoThrowsRocks(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 1 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenFood(t, "Tomato"), g.pet("Holder", 2)},
|
||||||
|
[]Card{g.pet("Tank", 3)},
|
||||||
|
)
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied {
|
||||||
|
t.Fatalf("Tomato should throw 4 rocks and kill the 3-power Tank: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// --- Golden pack test helpers ---
|
||||||
|
|
||||||
|
// goldenGame builds a started 2-player game on the Golden pack, bypassing the
|
||||||
|
// lobby and the Playable gate (tiers 4-6 aren't printed yet, so the pack isn't
|
||||||
|
// selectable in a real lobby — but tiers 1-3 are fully exercisable here).
|
||||||
|
func goldenGame(t *testing.T) (*Game, *Player, *Player) {
|
||||||
|
t.Helper()
|
||||||
|
g := New()
|
||||||
|
g.Pack = "golden"
|
||||||
|
g.buildDecks()
|
||||||
|
p1, err := g.AddPlayer("Alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p2, err := g.AddPlayer("Bob")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.start()
|
||||||
|
g.PrioritySeat = p1.Seat
|
||||||
|
g.Turn = p1.Seat
|
||||||
|
return g, p1, p2
|
||||||
|
}
|
||||||
|
|
||||||
|
// goldenPet mints a copy of a Golden pack pet (with effects) by name.
|
||||||
|
func (g *Game) goldenPet(t *testing.T, name string) Card {
|
||||||
|
t.Helper()
|
||||||
|
for tierIdx, tier := range goldenPetTiers {
|
||||||
|
for _, tmpl := range tier {
|
||||||
|
if tmpl.Name == name {
|
||||||
|
return Card{
|
||||||
|
ID: g.newCardID(), Kind: KindPet, Name: tmpl.Name, Tier: tierIdx + 1,
|
||||||
|
Power: tmpl.Power, Suit: tmpl.Suits[0],
|
||||||
|
Effects: tmpl.Effects, EffectText: tmpl.EffectText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no golden pet named %s", name)
|
||||||
|
return Card{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goldenFood mints a Golden pack food (Avocado) by name.
|
||||||
|
func (g *Game) goldenFood(t *testing.T, name string) Card {
|
||||||
|
t.Helper()
|
||||||
|
for tierIdx, tier := range goldenFoodTiers {
|
||||||
|
for _, f := range tier {
|
||||||
|
if f.Name == name {
|
||||||
|
return Card{
|
||||||
|
ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1,
|
||||||
|
Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no golden food named %s", name)
|
||||||
|
return Card{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// suitedPet mints a plain effect-less pet with a chosen suit.
|
||||||
|
func (g *Game) suitedPet(name string, power int, suit Suit) Card {
|
||||||
|
return Card{ID: g.newCardID(), Kind: KindPet, Name: name, Tier: 1, Power: power, Suit: suit}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Trumpets & Golden Retriever ---
|
||||||
|
|
||||||
|
// A fainting Groundhog banks a Trumpet; with no cards left, the side fields a
|
||||||
|
// Golden Retriever whose Power equals its Trumpets — and it wins.
|
||||||
|
func TestGroundhogTrumpetThenGoldenRetriever(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Groundhog")}, // pow 1, faint: +1 trumpet
|
||||||
|
[]Card{g.pet("Weak", 1)},
|
||||||
|
)
|
||||||
|
trumpets := eventsOfType(res, "trumpet")
|
||||||
|
if len(trumpets) != 1 || trumpets[0].Seat != 0 || trumpets[0].Count != 1 {
|
||||||
|
t.Fatalf("expected one +1 trumpet event for seat 0: %+v", trumpets)
|
||||||
|
}
|
||||||
|
var gr *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "reveal" && ev.Card != nil && ev.Card.Name == "Golden Retriever" {
|
||||||
|
gr = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gr == nil {
|
||||||
|
t.Fatal("expected a Golden Retriever to be summoned")
|
||||||
|
}
|
||||||
|
if gr.Card.Power != 1 || gr.Count != 1 {
|
||||||
|
t.Fatalf("Golden Retriever should have Power 1 from 1 trumpet: power=%d count=%d", gr.Card.Power, gr.Count)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("the Golden Retriever should win for seat 0, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pied Tamarin spends a banked Trumpet on entry to throw 2 Rocks.
|
||||||
|
func TestPiedTamarinSpendsTrumpetForRocks(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 2 } // each rock deals 2
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Groundhog"), g.goldenPet(t, "Pied Tamarin")},
|
||||||
|
[]Card{g.pet("Weak", 1), g.pet("Tank", 3)},
|
||||||
|
)
|
||||||
|
// Groundhog trades with Weak (+1 trumpet). Pied Tamarin enters, spends the
|
||||||
|
// trumpet, throws 2 rocks (2+2=4) and kills the 3-power Tank.
|
||||||
|
spends := 0
|
||||||
|
for _, ev := range eventsOfType(res, "trumpet") {
|
||||||
|
if ev.Count < 0 {
|
||||||
|
spends++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spends != 1 {
|
||||||
|
t.Fatalf("expected exactly one trumpet spend, got %d: %+v", spends, eventsOfType(res, "trumpet"))
|
||||||
|
}
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied {
|
||||||
|
t.Fatalf("Pied Tamarin should throw 2 rocks (roll 4) and kill the Tank: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a Trumpet, Pied Tamarin's paid Play effect does nothing.
|
||||||
|
func TestPiedTamarinNoTrumpetNoRocks(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 2 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Pied Tamarin")},
|
||||||
|
[]Card{g.pet("Tank", 3)},
|
||||||
|
)
|
||||||
|
if rocks := eventsOfType(res, "rock"); len(rocks) != 0 {
|
||||||
|
t.Fatalf("no trumpet means no rocks: %+v", rocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Honduran White Bat banks a Trumpet per distinct suit among friendly fainted
|
||||||
|
// pets.
|
||||||
|
func TestHonduranBatTrumpetPerFaintedSuit(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{
|
||||||
|
g.suitedPet("Red", 1, SuitRed),
|
||||||
|
g.suitedPet("Blue", 1, SuitBlue),
|
||||||
|
g.goldenPet(t, "Honduran White Bat"),
|
||||||
|
},
|
||||||
|
[]Card{g.pet("K1", 1), g.pet("K2", 1), g.pet("Last", 1)},
|
||||||
|
)
|
||||||
|
// Red and Blue trade with K1/K2 (two distinct fainted suits). The Bat
|
||||||
|
// enters and banks 2 Trumpets.
|
||||||
|
var gain *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count > 0 {
|
||||||
|
gain = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gain == nil || gain.Count != 2 {
|
||||||
|
t.Fatalf("Bat should bank 2 trumpets for 2 unique fainted suits: %+v", eventsOfType(res, "trumpet"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flea drains Trumpets from the opponent when it faints.
|
||||||
|
func TestFleaDrainsEnemyTrumpets(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Flea")}, // pow 2
|
||||||
|
[]Card{g.goldenPet(t, "Groundhog"), g.pet("Big", 5)}, // Groundhog banks a trumpet for seat 1
|
||||||
|
)
|
||||||
|
// Flea kills Groundhog (seat 1 +1 trumpet), survives, then dies to Big and
|
||||||
|
// drains that trumpet back down to 0.
|
||||||
|
var drain *BattleEvent
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == "trumpet" && ev.Seat == 1 && ev.Count < 0 {
|
||||||
|
drain = &res.Events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if drain == nil {
|
||||||
|
t.Fatalf("Flea should drain a trumpet from seat 1: %+v", eventsOfType(res, "trumpet"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cone Snail ---
|
||||||
|
|
||||||
|
// Cone Snail's faint sets aside a one-shot 2-damage prevention for the next
|
||||||
|
// friendly hit.
|
||||||
|
func TestConeSnailPreventsTwoDamage(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Cone Snail"), g.pet("Ally", 5)},
|
||||||
|
[]Card{g.pet("Big", 6)},
|
||||||
|
)
|
||||||
|
// Cone Snail dies to the 6 (Big takes 1). Ally enters; Big's next hit of 6
|
||||||
|
// is shaved to 4, so Ally (5) survives and kills Big.
|
||||||
|
prevents := eventsOfType(res, "prevent")
|
||||||
|
if len(prevents) != 1 || prevents[0].Seat != 0 || prevents[0].Count != 2 {
|
||||||
|
t.Fatalf("expected one 2-damage prevention for seat 0: %+v", prevents)
|
||||||
|
}
|
||||||
|
clashes := eventsOfType(res, "clash")
|
||||||
|
final := clashes[len(clashes)-1]
|
||||||
|
if final.Damage[0] != 4 || !final.Died[1] {
|
||||||
|
t.Fatalf("Ally should take 4 (6-2) and Big should die: %+v", final)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bulldog ---
|
||||||
|
|
||||||
|
// Bulldog eats an apple after attacking, but only once per battle.
|
||||||
|
func TestBulldogAfterAttackOncePerRound(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Bulldog")}, // pow 2
|
||||||
|
[]Card{g.pet("W1", 1), g.pet("W2", 1)},
|
||||||
|
)
|
||||||
|
eats := eventsOfType(res, "eat")
|
||||||
|
if len(eats) != 1 || eats[0].Seat != 0 || eats[0].Bonus != 1 {
|
||||||
|
t.Fatalf("Bulldog should eat exactly one apple across the battle: %+v", eats)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("Bulldog should survive and win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bear ---
|
||||||
|
|
||||||
|
// Bear's faint drops a Bee on the bottom of BOTH decks.
|
||||||
|
func TestBearBeeToBothDecks(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.goldenPet(t, "Bear")}, // pow 4
|
||||||
|
[]Card{g.pet("Big", 5)},
|
||||||
|
)
|
||||||
|
seats := map[int]bool{}
|
||||||
|
for _, ev := range eventsOfType(res, "summon") {
|
||||||
|
if ev.Card != nil && ev.Card.Name == "Bee" {
|
||||||
|
seats[ev.Seat] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seats[0] || !seats[1] {
|
||||||
|
t.Fatalf("Bear should summon a Bee onto both decks, saw seats %v", seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Royal Flycatcher ---
|
||||||
|
|
||||||
|
// Royal Flycatcher throws one Rock per enemy pet already fainted.
|
||||||
|
func TestRoyalFlycatcherRocksPerEnemyFaint(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 2 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("K1", 1), g.pet("K2", 1), g.goldenPet(t, "Royal Flycatcher")},
|
||||||
|
[]Card{g.pet("E1", 1), g.pet("E2", 1), g.pet("Tank", 3)},
|
||||||
|
)
|
||||||
|
// K1/K2 trade with E1/E2 (2 enemy faints). Royal Flycatcher enters and
|
||||||
|
// throws 2 rocks (2+2=4), killing the 3-power Tank.
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || len(rocks[0].Dice) != 2 || !rocks[0].TargetDied {
|
||||||
|
t.Fatalf("Royal Flycatcher should throw 2 rocks (one per enemy faint) and kill Tank: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shop-time effects ---
|
||||||
|
|
||||||
|
// Selling a Chipmunk yields its base apple plus 2 extra.
|
||||||
|
func TestChipmunkSellApples(t *testing.T) {
|
||||||
|
g, p1, p2 := goldenGame(t)
|
||||||
|
chip := g.goldenPet(t, "Chipmunk")
|
||||||
|
p1.Deck = append(p1.Deck, chip)
|
||||||
|
// Hand the turn back to seat 0 after seat 1 acts.
|
||||||
|
_ = p2
|
||||||
|
g.Turn = 0
|
||||||
|
if err := g.Sell(p1.ID, []string{chip.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if countApplesIn(p1.Deck) != 3 {
|
||||||
|
t.Fatalf("selling a Chipmunk should add 3 apples (1 base + 2 extra), got %d", countApplesIn(p1.Deck))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opossum's sell adds an apple per Faint pet currently in the shop.
|
||||||
|
func TestOpossumSellApplesPerShopFaint(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
op := g.goldenPet(t, "Opossum")
|
||||||
|
p1.Deck = append(p1.Deck, op)
|
||||||
|
g.ShopRow = []Card{
|
||||||
|
g.goldenPet(t, "Groundhog"), // faint
|
||||||
|
g.goldenPet(t, "Cone Snail"), // faint
|
||||||
|
g.goldenPet(t, "Bulldog"), // after-attack, no faint
|
||||||
|
g.goldenPet(t, "Pied Tamarin"), // play, no faint
|
||||||
|
}
|
||||||
|
g.Turn = 0
|
||||||
|
if err := g.Sell(p1.ID, []string{op.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 1 base apple + 2 (one per faint pet in shop).
|
||||||
|
if countApplesIn(p1.Deck) != 3 {
|
||||||
|
t.Fatalf("selling Opossum with 2 shop faint pets should add 3 apples, got %d", countApplesIn(p1.Deck))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stoat's sell pulls the top of the current tier's shop deck for free.
|
||||||
|
func TestStoatSellBuysTopOfDeck(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
stoat := g.goldenPet(t, "Stoat")
|
||||||
|
p1.Deck = append(p1.Deck, stoat)
|
||||||
|
g.Turn = 0
|
||||||
|
deckBefore := len(g.ShopDecks[g.Round-1])
|
||||||
|
petsBefore := p1.PetCount()
|
||||||
|
if err := g.Sell(p1.ID, []string{stoat.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := len(g.ShopDecks[g.Round-1]); got != deckBefore-1 {
|
||||||
|
t.Fatalf("Stoat should draw one card from the tier deck: before %d, after %d", deckBefore, got)
|
||||||
|
}
|
||||||
|
// Stoat itself became an apple, but a fresh pet came from the deck, so the
|
||||||
|
// pet count is unchanged (–1 Stoat, +1 grabbed pet, assuming a pet on top).
|
||||||
|
if p1.PetCount() < petsBefore {
|
||||||
|
t.Fatalf("Stoat should have replaced itself with a grabbed pet: before %d, after %d", petsBefore, p1.PetCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hercules Beetle banks apples-in-play only from round 3 on, and the battle
|
||||||
|
// consumes them onto the first pet.
|
||||||
|
func TestHerculesBeetleApplesInPlay(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
|
||||||
|
// Round 2: no effect.
|
||||||
|
g.Round = 2
|
||||||
|
hb2 := g.goldenPet(t, "Hercules Beetle")
|
||||||
|
p1.Deck = append(p1.Deck, hb2)
|
||||||
|
g.Turn = 0
|
||||||
|
if err := g.Sell(p1.ID, []string{hb2.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p1.PendingApplesInPlay != 0 {
|
||||||
|
t.Fatalf("Hercules Beetle should do nothing before round 3, got %d", p1.PendingApplesInPlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round 3: banks 3.
|
||||||
|
g.Round = 3
|
||||||
|
hb3 := g.goldenPet(t, "Hercules Beetle")
|
||||||
|
p1.Deck = append(p1.Deck, hb3)
|
||||||
|
g.Turn = 0
|
||||||
|
if err := g.Sell(p1.ID, []string{hb3.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p1.PendingApplesInPlay != 3 {
|
||||||
|
t.Fatalf("Hercules Beetle in round 3 should bank 3 apples-in-play, got %d", p1.PendingApplesInPlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The battle seeds those apples onto the first pet, then resets.
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("Hero", 3)},
|
||||||
|
[]Card{g.pet("Foe", 1)},
|
||||||
|
)
|
||||||
|
if preps := eventsOfType(res, "prep"); len(preps) != 3 {
|
||||||
|
t.Fatalf("expected 3 apples-in-play prep events, got %d", len(preps))
|
||||||
|
}
|
||||||
|
if p1.PendingApplesInPlay != 0 {
|
||||||
|
t.Fatalf("PendingApplesInPlay should reset after the battle, got %d", p1.PendingApplesInPlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Avocado ---
|
||||||
|
|
||||||
|
// Buying an Avocado sets it aside (not into the deck); a later buy can spend it
|
||||||
|
// instead of a coin.
|
||||||
|
func TestAvocadoBuyAndDiscardToBuy(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
g.ShopRow[0] = g.goldenFood(t, "Avocado")
|
||||||
|
g.Turn = 0
|
||||||
|
coins := p1.Coins
|
||||||
|
if err := g.Buy(p1.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p1.Avocados != 1 {
|
||||||
|
t.Fatalf("buying an Avocado should set aside 1, got %d", p1.Avocados)
|
||||||
|
}
|
||||||
|
if p1.Coins != coins-1 {
|
||||||
|
t.Fatalf("buying an Avocado still costs a coin: before %d, after %d", coins, p1.Coins)
|
||||||
|
}
|
||||||
|
for _, c := range p1.Deck {
|
||||||
|
if c.Food == FoodAvocado {
|
||||||
|
t.Fatal("the Avocado should not be in the deck")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now discard the Avocado to buy the card in slot 1, spending no coin.
|
||||||
|
g.Turn = 0
|
||||||
|
deckLen := len(p1.Deck)
|
||||||
|
coins = p1.Coins
|
||||||
|
if err := g.BuyAvocado(p1.ID, 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p1.Avocados != 0 {
|
||||||
|
t.Fatalf("the discard should consume the Avocado, got %d", p1.Avocados)
|
||||||
|
}
|
||||||
|
if p1.Coins != coins {
|
||||||
|
t.Fatalf("a discard-buy should spend no coin: before %d, after %d", coins, p1.Coins)
|
||||||
|
}
|
||||||
|
if len(p1.Deck) != deckLen+1 {
|
||||||
|
t.Fatalf("the discard-buy should add a card to the deck: before %d, after %d", deckLen, len(p1.Deck))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyAvocado is refused with no Avocado in the stash.
|
||||||
|
func TestBuyAvocadoRequiresToken(t *testing.T) {
|
||||||
|
g, p1, _ := goldenGame(t)
|
||||||
|
g.Turn = 0
|
||||||
|
if err := g.BuyAvocado(p1.ID, 0); err == nil {
|
||||||
|
t.Fatal("expected an error buying with no Avocado")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
// Card packs are the selectable sets of pets and food a game is played with.
|
// Card packs are the selectable sets of pets and food a game is played with.
|
||||||
// Only the Turtle pack ships with real card data today; Golden and Unicorn are
|
// Turtle and Golden ship with full card data; Unicorn is declared here as
|
||||||
// declared here as infrastructure (shown but not yet playable) so the lobby,
|
// infrastructure (shown but not yet playable) so the lobby, views, and
|
||||||
// views, and deck-building all have a single source of truth to grow into.
|
// deck-building all have a single source of truth to grow into.
|
||||||
|
|
||||||
// PackInfo describes one selectable pack. Playable gates whether a lobby may
|
// PackInfo describes one selectable pack. Playable gates whether a lobby may
|
||||||
// choose it and start a game with it.
|
// choose it and start a game with it.
|
||||||
@@ -20,7 +20,7 @@ const DefaultPack = "turtle"
|
|||||||
// Packs is the ordered catalog of packs shown in the lobby.
|
// Packs is the ordered catalog of packs shown in the lobby.
|
||||||
var Packs = []PackInfo{
|
var Packs = []PackInfo{
|
||||||
{ID: "turtle", Name: "Turtle Pack", Emoji: "🐢", Playable: true},
|
{ID: "turtle", Name: "Turtle Pack", Emoji: "🐢", Playable: true},
|
||||||
{ID: "golden", Name: "Golden Pack", Emoji: "🥇", Playable: false},
|
{ID: "golden", Name: "Golden Pack", Emoji: "🥇", Playable: true},
|
||||||
{ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: false},
|
{ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Regression (improvements.txt): a Crocodile set aside must still throw its
|
||||||
|
// last-pet volley when the enemy plays their final pet, even though the
|
||||||
|
// Crocodile's own side is already out of pets. Previously the "battle over"
|
||||||
|
// check fired first and swallowed the parting shot.
|
||||||
|
func TestCrocodileLastPetVolleyFiresWhenOwnerOut(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 2 } // 3 rocks -> 6 damage
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("Killer", 4), g.pet("Last", 3)}, // seat 0 plays its last pet
|
||||||
|
[]Card{g.realPet(t, "Crocodile")}, // seat 1's Croc faints, sets aside
|
||||||
|
)
|
||||||
|
// Killer trades with Crocodile (set aside). Seat 0 then plays its last pet,
|
||||||
|
// and the Crocodile's 3 rocks (roll 6) pelt it dead — turning a win into a
|
||||||
|
// mutual knockout.
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || rocks[0].Seat != 1 || rocks[0].Roll != 6 || !rocks[0].TargetDied {
|
||||||
|
t.Fatalf("Crocodile should volley the last pet for 6: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != -1 {
|
||||||
|
t.Fatalf("both sides should be out (draw), got winner %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-5
@@ -17,16 +17,26 @@ 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.resolveBattle()
|
g.startBattle()
|
||||||
return g.Battle
|
return g.Battle
|
||||||
}
|
}
|
||||||
|
|
||||||
// TierContents returns the full printed contents of a tier's shop deck —
|
// TierContents returns the full printed contents of a tier's shop deck for the
|
||||||
// public information from the box. Cards carry placeholder IDs; they are
|
// default pack. Cards carry placeholder IDs; they are reference data, not live
|
||||||
// reference data, not live instances.
|
// instances.
|
||||||
func TierContents(tier int) []Card {
|
func TierContents(tier int) []Card {
|
||||||
scratch := &Game{}
|
return TierContentsForPack(DefaultPack, tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierContentsForPack returns a pack's printed tier contents — public
|
||||||
|
// information from the box. Used by the AI, which decides from a View that
|
||||||
|
// names its pack.
|
||||||
|
func TierContentsForPack(pack string, tier int) []Card {
|
||||||
|
scratch := &Game{Pack: pack}
|
||||||
scratch.buildShopDecks()
|
scratch.buildShopDecks()
|
||||||
if tier < 1 || tier > len(scratch.ShopDecks) {
|
if tier < 1 || tier > len(scratch.ShopDecks) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+29
-4
@@ -13,7 +13,14 @@ type PlayerView struct {
|
|||||||
IsBot bool `json:"isBot,omitempty"`
|
IsBot bool `json:"isBot,omitempty"`
|
||||||
DeckSize int `json:"deckSize"`
|
DeckSize int `json:"deckSize"`
|
||||||
PetCount int `json:"petCount"`
|
PetCount int `json:"petCount"`
|
||||||
Deck []Card `json:"deck,omitempty"` // self only
|
// Avocados is the player's set-aside Avocado token count (Golden pack).
|
||||||
|
// Public: buying an Avocado is a public shop event.
|
||||||
|
Avocados int `json:"avocados,omitempty"`
|
||||||
|
// FirstBuyFree / BuysThisRound (Golden pack) are self-only: they'd reveal
|
||||||
|
// hidden deck facts (a Manta Ray) to opponents.
|
||||||
|
FirstBuyFree bool `json:"firstBuyFree,omitempty"`
|
||||||
|
BuysThisRound int `json:"buysThisRound,omitempty"`
|
||||||
|
Deck []Card `json:"deck,omitempty"` // self only
|
||||||
}
|
}
|
||||||
|
|
||||||
// View is the full game state as seen by one player.
|
// View is the full game state as seen by one player.
|
||||||
@@ -41,9 +48,16 @@ type View struct {
|
|||||||
Players []PlayerView `json:"players"`
|
Players []PlayerView `json:"players"`
|
||||||
// Pending is included for everyone so opponents see a trade is in
|
// Pending is included for everyone so opponents see a trade is in
|
||||||
// progress, but the revealed options are only shown to the trader.
|
// progress, but the revealed options are only shown to the trader.
|
||||||
Pending *PendingTrade `json:"pending,omitempty"`
|
Pending *PendingTrade `json:"pending,omitempty"`
|
||||||
Battle *BattleResult `json:"battle,omitempty"`
|
// PendingReveal (Golden pack: Cockatoo) is shown to everyone so opponents
|
||||||
WinnerSeat int `json:"winnerSeat"`
|
// 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"`
|
||||||
// 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
|
||||||
@@ -87,10 +101,13 @@ func (g *Game) ViewFor(playerID string) View {
|
|||||||
IsBot: p.IsBot,
|
IsBot: p.IsBot,
|
||||||
DeckSize: len(p.Deck),
|
DeckSize: len(p.Deck),
|
||||||
PetCount: p.PetCount(),
|
PetCount: p.PetCount(),
|
||||||
|
Avocados: p.Avocados,
|
||||||
}
|
}
|
||||||
if p.ID == playerID {
|
if p.ID == playerID {
|
||||||
v.YouSeat = p.Seat
|
v.YouSeat = p.Seat
|
||||||
pv.Deck = p.Deck
|
pv.Deck = p.Deck
|
||||||
|
pv.FirstBuyFree = p.FirstBuyFree
|
||||||
|
pv.BuysThisRound = p.BuysThisRound
|
||||||
}
|
}
|
||||||
v.Players = append(v.Players, pv)
|
v.Players = append(v.Players, pv)
|
||||||
}
|
}
|
||||||
@@ -101,6 +118,14 @@ func (g *Game) ViewFor(playerID string) View {
|
|||||||
}
|
}
|
||||||
v.Pending = &pending
|
v.Pending = &pending
|
||||||
}
|
}
|
||||||
|
if g.PendingReveal != nil {
|
||||||
|
reveal := *g.PendingReveal
|
||||||
|
if reveal.PlayerID != playerID {
|
||||||
|
reveal.Options = nil // hide which of the buyer's pets are eligible
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
|||||||
@@ -147,12 +147,18 @@ func applyBotAction(g *game.Game, playerID string, a *ai.Action) error {
|
|||||||
switch a.Type {
|
switch a.Type {
|
||||||
case "buy":
|
case "buy":
|
||||||
return g.Buy(playerID, a.Row)
|
return g.Buy(playerID, a.Row)
|
||||||
|
case "buyAvocado":
|
||||||
|
return g.BuyAvocado(playerID, a.Row)
|
||||||
case "sell":
|
case "sell":
|
||||||
return g.Sell(playerID, a.Cards)
|
return g.Sell(playerID, a.Cards)
|
||||||
case "trade":
|
case "trade":
|
||||||
return g.TradeStart(playerID, a.Cards)
|
return g.TradeStart(playerID, a.Cards)
|
||||||
case "tradeChoose":
|
case "tradeChoose":
|
||||||
return g.TradeChoose(playerID, a.Pick)
|
return g.TradeChoose(playerID, a.Pick)
|
||||||
|
case "revealChoose":
|
||||||
|
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":
|
||||||
@@ -174,6 +180,9 @@ func botFallback(g *game.Game, playerID string) error {
|
|||||||
}
|
}
|
||||||
switch g.Phase {
|
switch g.Phase {
|
||||||
case game.PhaseShop:
|
case game.PhaseShop:
|
||||||
|
if g.PendingReveal != nil && g.PendingReveal.PlayerID == playerID {
|
||||||
|
return g.RevealChoose(playerID, g.PendingReveal.Options[0])
|
||||||
|
}
|
||||||
if g.Pending != nil && g.Pending.PlayerID == playerID {
|
if g.Pending != nil && g.Pending.PlayerID == playerID {
|
||||||
return g.TradeChoose(playerID, 0)
|
return g.TradeChoose(playerID, 0)
|
||||||
}
|
}
|
||||||
@@ -194,6 +203,9 @@ 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
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type clientMessage struct {
|
|||||||
Pack string `json:"pack"` // setPack
|
Pack string `json:"pack"` // setPack
|
||||||
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
|
||||||
|
Value int `json:"value"` // battleChoose (Nurse Shark): Trumpets to spend
|
||||||
}
|
}
|
||||||
|
|
||||||
type serverMessage struct {
|
type serverMessage struct {
|
||||||
@@ -140,12 +142,18 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
|
|||||||
}
|
}
|
||||||
case "buy":
|
case "buy":
|
||||||
err = g.Buy(c.playerID, msg.Row)
|
err = g.Buy(c.playerID, msg.Row)
|
||||||
|
case "buyAvocado":
|
||||||
|
err = g.BuyAvocado(c.playerID, msg.Row)
|
||||||
case "sell":
|
case "sell":
|
||||||
err = g.Sell(c.playerID, msg.Cards)
|
err = g.Sell(c.playerID, msg.Cards)
|
||||||
case "trade":
|
case "trade":
|
||||||
err = g.TradeStart(c.playerID, msg.Cards)
|
err = g.TradeStart(c.playerID, msg.Cards)
|
||||||
case "tradeChoose":
|
case "tradeChoose":
|
||||||
err = g.TradeChoose(c.playerID, msg.Pick)
|
err = g.TradeChoose(c.playerID, msg.Pick)
|
||||||
|
case "revealChoose":
|
||||||
|
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 } from '../types'
|
import type { BattleEvent, Card, ClientMessage, GameView, PendingBattleDecision } from '../types'
|
||||||
import { CardView } from './CardView'
|
import { CardView } from './CardView'
|
||||||
import { DiceRoll, ROLL_MS } from './DiceRoll'
|
import { DiceRoll, ROLL_MS } from './DiceRoll'
|
||||||
|
|
||||||
@@ -52,6 +52,8 @@ const EVENT_MS: Record<BattleEvent['type'], number> = {
|
|||||||
heal: 900,
|
heal: 900,
|
||||||
setaside: 700,
|
setaside: 700,
|
||||||
release: 500,
|
release: 500,
|
||||||
|
trumpet: 800,
|
||||||
|
prevent: 900,
|
||||||
}
|
}
|
||||||
|
|
||||||
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
|
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
|
||||||
@@ -86,6 +88,25 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
|
|||||||
break
|
break
|
||||||
case 'reveal': {
|
case 'reveal': {
|
||||||
const s = sides[ev.seat!]
|
const s = sides[ev.seat!]
|
||||||
|
// The Golden Retriever is summoned straight into play, not flipped off
|
||||||
|
// the deck, so it doesn't decrement the stack.
|
||||||
|
if (ev.card?.name === 'Golden Retriever') {
|
||||||
|
const trumpets = ev.count ?? 0
|
||||||
|
s.unit = {
|
||||||
|
card: ev.card!,
|
||||||
|
// Show the Trumpets that powered it, fanned like food tokens.
|
||||||
|
foods: Array.from({ length: trumpets }, (_, i) => ({
|
||||||
|
id: `${ev.card!.id}-t${i}`,
|
||||||
|
kind: 'food' as const,
|
||||||
|
name: 'Trumpet',
|
||||||
|
food: 'trumpet',
|
||||||
|
})),
|
||||||
|
bonus: 0,
|
||||||
|
damage: 0,
|
||||||
|
dying: false,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
s.stack--
|
s.stack--
|
||||||
const card = ev.card!
|
const card = ev.card!
|
||||||
if (card.kind === 'food') {
|
if (card.kind === 'food') {
|
||||||
@@ -164,6 +185,14 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
|
|||||||
}
|
}
|
||||||
case 'shield':
|
case 'shield':
|
||||||
break // pure animation; no state change
|
break // pure animation; no state change
|
||||||
|
case 'trumpet':
|
||||||
|
break // pure animation; the pool isn't drawn on the board
|
||||||
|
case 'prevent': {
|
||||||
|
// Cone Snail shaved damage off the hit; the reduced total rides along.
|
||||||
|
const u = sides[ev.seat!].unit
|
||||||
|
if (u) u.damage = ev.damageAfter ?? u.damage
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'setaside':
|
case 'setaside':
|
||||||
if (ev.card) sides[ev.seat!].setAside.push(ev.card)
|
if (ev.card) sides[ev.seat!].setAside.push(ev.card)
|
||||||
break
|
break
|
||||||
@@ -194,6 +223,11 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st
|
|||||||
}
|
}
|
||||||
case 'shield':
|
case 'shield':
|
||||||
return ev.seat === seat ? '🛡️' : null
|
return ev.seat === seat ? '🛡️' : null
|
||||||
|
case 'prevent':
|
||||||
|
return ev.seat === seat ? `🛡️ −${ev.count}` : null
|
||||||
|
case 'trumpet':
|
||||||
|
if (ev.seat !== seat) return null
|
||||||
|
return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}`
|
||||||
case 'eat':
|
case 'eat':
|
||||||
return ev.seat === seat ? '🍎' : null
|
return ev.seat === seat ? '🍎' : null
|
||||||
case 'heal':
|
case 'heal':
|
||||||
@@ -519,37 +553,94 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{done && (
|
{done && view.pendingBattle ? (
|
||||||
<div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
|
<BattleDecision
|
||||||
<div className="battle-result-title">
|
pd={view.pendingBattle}
|
||||||
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
|
youSeat={youSeat}
|
||||||
</div>
|
oppName={opp?.name ?? 'Opponent'}
|
||||||
{!draw && (
|
send={send}
|
||||||
<div className="battle-result-sub">
|
/>
|
||||||
{view.players[battle.winnerSeat]?.name} wins{' '}
|
) : (
|
||||||
{'🏆'.repeat(battle.trophies)}
|
done && (
|
||||||
|
<div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
|
||||||
|
<div className="battle-result-title">
|
||||||
|
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{!draw && (
|
||||||
{draw && <div className="battle-result-sub">No trophies awarded</div>}
|
<div className="battle-result-sub">
|
||||||
{acked ? (
|
{view.players[battle.winnerSeat]?.name} wins{' '}
|
||||||
<p className="muted">Waiting for opponent…</p>
|
{'🏆'.repeat(battle.trophies)}
|
||||||
) : (
|
</div>
|
||||||
<button
|
)}
|
||||||
className="btn btn-primary btn-big"
|
{draw && <div className="battle-result-sub">No trophies awarded</div>}
|
||||||
onClick={() => {
|
{acked ? (
|
||||||
setAcked(true)
|
<p className="muted">Waiting for opponent…</p>
|
||||||
send({ type: 'ready' })
|
) : (
|
||||||
}}
|
<button
|
||||||
>
|
className="btn btn-primary btn-big"
|
||||||
{battle.round >= view.maxRounds ? 'See final results' : 'Next round →'}
|
onClick={() => {
|
||||||
</button>
|
setAcked(true)
|
||||||
)}
|
send({ type: 'ready' })
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
{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 {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const BATTLE_ICONS: Record<BattleEvent['type'], string> = {
|
|||||||
heal: '💚',
|
heal: '💚',
|
||||||
setaside: '🃏',
|
setaside: '🃏',
|
||||||
release: '↩️',
|
release: '↩️',
|
||||||
|
trumpet: '🎺',
|
||||||
|
prevent: '🛡️',
|
||||||
}
|
}
|
||||||
|
|
||||||
// battleLogLines turns the battle events revealed up to `step` into readable
|
// battleLogLines turns the battle events revealed up to `step` into readable
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ interface Props {
|
|||||||
export function ShopPhase({ view, you, send }: Props) {
|
export function ShopPhase({ view, you, send }: Props) {
|
||||||
const [selected, setSelected] = useState<string[]>([])
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
const [confirmPass, setConfirmPass] = useState(false)
|
const [confirmPass, setConfirmPass] = useState(false)
|
||||||
|
// When set, the next buy is paid by discarding an Avocado instead of a coin
|
||||||
|
// (Golden pack). It also kicks in automatically when out of coins.
|
||||||
|
const [useAvocado, setUseAvocado] = useState(false)
|
||||||
const myTurn = view.turn === view.youSeat && !you.ready
|
const myTurn = view.turn === view.youSeat && !you.ready
|
||||||
const canBuy = myTurn && you.coins > 0
|
const avocados = you.avocados ?? 0
|
||||||
|
const freeBuy = !!you.firstBuyFree // Manta Ray: next buy costs no gold
|
||||||
|
const canBuy = myTurn && (you.coins > 0 || avocados > 0 || freeBuy)
|
||||||
const overPets = you.petCount > view.maxPets
|
const overPets = you.petCount > view.maxPets
|
||||||
const deck = you.deck ?? []
|
const deck = you.deck ?? []
|
||||||
const opponent = view.players.find((p) => p.seat !== view.youSeat)
|
const opponent = view.players.find((p) => p.seat !== view.youSeat)
|
||||||
@@ -151,7 +156,11 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
// Safety net: if the card never shows up in the deck, stop hiding its slot.
|
// Safety net: if the card never shows up in the deck, stop hiding its slot.
|
||||||
window.setTimeout(() => setBuyFly((b) => (b?.id === card.id ? null : b)), 2000)
|
window.setTimeout(() => setBuyFly((b) => (b?.id === card.id ? null : b)), 2000)
|
||||||
}
|
}
|
||||||
act({ type: 'buy', row })
|
// A free first buy (Manta Ray) always goes through the normal buy; otherwise
|
||||||
|
// pay with an Avocado when chosen, or when out of coins.
|
||||||
|
const payAvocado = !freeBuy && avocados > 0 && (useAvocado || you.coins <= 0)
|
||||||
|
act({ type: payAvocado ? 'buyAvocado' : 'buy', row })
|
||||||
|
setUseAvocado(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -212,13 +221,45 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{myTurn && (
|
{myTurn && (
|
||||||
<div className="hint">
|
<div className="hint">
|
||||||
{canBuy
|
{freeBuy
|
||||||
? 'Tap a card to buy it for 1 🪙 — selling and trading are free'
|
? 'Your first buy this round is free 🎉 — tap a card'
|
||||||
: 'No coins left — you can still sell, trade, or pass'}
|
: you.coins > 0
|
||||||
|
? 'Tap a card to buy it for 1 🪙 — selling and trading are free'
|
||||||
|
: avocados > 0
|
||||||
|
? 'No coins — tap a card to buy it by discarding an Avocado 🥑'
|
||||||
|
: 'No coins left — you can still sell, trade, or pass'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Set-aside Avocados (Golden pack) */}
|
||||||
|
{avocados > 0 && (
|
||||||
|
<section className="avocado-zone">
|
||||||
|
<div className="section-label">
|
||||||
|
Set aside
|
||||||
|
<span className="muted"> · discard instead of paying 1 🪙</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`avocado-token ${useAvocado ? 'is-active' : ''}`}
|
||||||
|
disabled={!myTurn || you.coins <= 0}
|
||||||
|
onClick={() => setUseAvocado((v) => !v)}
|
||||||
|
title={
|
||||||
|
you.coins <= 0
|
||||||
|
? 'Out of coins — buys will spend an Avocado'
|
||||||
|
: useAvocado
|
||||||
|
? 'Your next buy will spend an Avocado (click to cancel)'
|
||||||
|
: 'Spend an Avocado on your next buy instead of a coin'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
🥑 ×{avocados}
|
||||||
|
</button>
|
||||||
|
{myTurn && (useAvocado || you.coins <= 0) && (
|
||||||
|
<div className="hint">Your next buy will discard an Avocado — no coin spent.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Your deck */}
|
{/* Your deck */}
|
||||||
<section className="deck-wrap">
|
<section className="deck-wrap">
|
||||||
<div className="section-label">
|
<div className="section-label">
|
||||||
@@ -350,6 +391,28 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cockatoo reveal picker (Golden pack) */}
|
||||||
|
{view.pendingReveal?.playerId === you.id && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<h3>Reveal a pet — gain Apples equal to its Power</h3>
|
||||||
|
<div className="modal-cards">
|
||||||
|
{(view.pendingReveal.options ?? []).map((id) => {
|
||||||
|
const c = deck.find((d) => d.id === id)
|
||||||
|
return c ? (
|
||||||
|
<CardView
|
||||||
|
key={id}
|
||||||
|
card={c}
|
||||||
|
size="lg"
|
||||||
|
onClick={() => send({ type: 'revealChoose', card: id })}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{cardFlyer &&
|
{cardFlyer &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
|||||||
{view.phase === 'shop' && (
|
{view.phase === 'shop' && (
|
||||||
<span className="chip">🪙 {p.coins}</span>
|
<span className="chip">🪙 {p.coins}</span>
|
||||||
)}
|
)}
|
||||||
|
{(p.avocados ?? 0) > 0 && (
|
||||||
|
<span className="chip" title="Set-aside Avocados">🥑 {p.avocados}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ const PET_EMOJI: Record<string, string> = {
|
|||||||
Monkey: '🐒', Rhino: '🦏', Crocodile: '🐊', Scorpion: '🦂', Seal: '🦭', Shark: '🦈', Turkey: '🦃',
|
Monkey: '🐒', Rhino: '🦏', Crocodile: '🐊', Scorpion: '🦂', Seal: '🦭', Shark: '🦈', Turkey: '🦃',
|
||||||
// Tier 6
|
// Tier 6
|
||||||
Gorilla: '🦍', Fly: '🪰', Leopard: '🐆', Mammoth: '🦣', Cat: '🐱', Snake: '🐍', Wolverine: '🐺',
|
Gorilla: '🦍', Fly: '🪰', Leopard: '🐆', Mammoth: '🦣', Cat: '🐱', Snake: '🐍', Wolverine: '🐺',
|
||||||
|
// Golden pack — Tier 1
|
||||||
|
Groundhog: '🦫', 'Pied Tamarin': '🐒', Chipmunk: '🐿️', 'Cone Snail': '🐚', Bulldog: '🐕', Opossum: '🐀',
|
||||||
|
// Golden pack — Tier 2
|
||||||
|
'Black-Necked Stilt': '🦤', Lizard: '🦎', 'Hercules Beetle': '🪲', Stoat: '🦡', 'Desert Rain Frog': '🐸', 'Honduran White Bat': '🦇',
|
||||||
|
// Golden pack — Tier 3
|
||||||
|
'Guinea Fowl': '🐔', 'Surgeon Fish': '🐠', Osprey: '🦅', Anteater: '🐜', Bear: '🐻', 'Royal Flycatcher': '🐦', Flea: '🦟',
|
||||||
|
// Golden pack — Tier 4
|
||||||
|
'Saiga Antelope': '🦌', Vaquita: '🐬', 'Poison Dart Frog': '🐸', 'Manta Ray': '🐟', Slug: '🐌', Cockatoo: '🦜', Manatee: '🦭',
|
||||||
|
// Golden pack — Tier 5
|
||||||
|
Nyala: '🦌', 'Nurse Shark': '🦈', 'Giant Isopod': '🦞', 'Blue-Ringed Octopus': '🐙', Raccoon: '🦝', 'Fire Ant': '🐜', Macaque: '🐒',
|
||||||
|
// Golden pack — Tier 6
|
||||||
|
'Highland Cow': '🐄', Wildebeest: '🐃', 'Grizzly Bear': '🐻', Catfish: '🐟', Komodo: '🦎', 'Bird of Paradise': '🦚', 'German Shepherd': '🐕🦺',
|
||||||
// Summons & foods
|
// Summons & foods
|
||||||
Bee: '🐝',
|
Bee: '🐝',
|
||||||
Apple: '🍎',
|
Apple: '🍎',
|
||||||
@@ -19,6 +31,13 @@ const PET_EMOJI: Record<string, string> = {
|
|||||||
Pineapple: '🍍',
|
Pineapple: '🍍',
|
||||||
Chili: '🌶️',
|
Chili: '🌶️',
|
||||||
Melon: '🍈',
|
Melon: '🍈',
|
||||||
|
// Golden pack tokens & perk foods
|
||||||
|
'Golden Retriever': '🦮',
|
||||||
|
Trumpet: '🎺',
|
||||||
|
Avocado: '🥑',
|
||||||
|
Potato: '🥔',
|
||||||
|
Durian: '🥭',
|
||||||
|
Tomato: '🍅',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function artFor(name: string): string {
|
export function artFor(name: string): string {
|
||||||
|
|||||||
@@ -1224,6 +1224,56 @@ h3 {
|
|||||||
inset 0 0 0 1px rgba(246, 201, 78, 0.06);
|
inset 0 0 0 1px rgba(246, 201, 78, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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. */
|
||||||
|
.avocado-zone {
|
||||||
|
background: rgba(0, 0, 0, 0.24);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px 18px 14px;
|
||||||
|
box-shadow:
|
||||||
|
var(--tray-inset),
|
||||||
|
inset 0 0 0 1px rgba(246, 201, 78, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avocado-token {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(246, 201, 78, 0.3);
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
color: var(--gold);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avocado-token:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avocado-token.is-active {
|
||||||
|
border-color: var(--gold);
|
||||||
|
box-shadow: 0 0 10px rgba(246, 201, 78, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avocado-token:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-row,
|
.shop-row,
|
||||||
.deck-row {
|
.deck-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface PlayerView {
|
|||||||
isBot?: boolean
|
isBot?: boolean
|
||||||
deckSize: number
|
deckSize: number
|
||||||
petCount: number
|
petCount: number
|
||||||
|
avocados?: number // set-aside Avocado tokens (Golden pack)
|
||||||
|
firstBuyFree?: boolean // Manta Ray: next buy is free (self only)
|
||||||
|
buysThisRound?: number // Blue-Ringed Octopus counter (self only)
|
||||||
deck?: Card[]
|
deck?: Card[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +41,23 @@ export interface PendingTrade {
|
|||||||
options: [Card, Card]
|
options: [Card, Card]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cockatoo (Golden pack): the buyer must reveal one of their pets.
|
||||||
|
export interface PendingReveal {
|
||||||
|
playerId: string
|
||||||
|
source: string
|
||||||
|
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'
|
||||||
@@ -53,6 +73,8 @@ export interface BattleEvent {
|
|||||||
| 'heal'
|
| 'heal'
|
||||||
| 'setaside'
|
| 'setaside'
|
||||||
| 'release'
|
| 'release'
|
||||||
|
| 'trumpet' // a side gains (+) or spends/loses (-) Trumpets (Golden pack)
|
||||||
|
| 'prevent' // a Cone Snail shaves damage off a hit (Golden pack)
|
||||||
seat?: number
|
seat?: number
|
||||||
target?: number
|
target?: number
|
||||||
card?: Card
|
card?: Card
|
||||||
@@ -117,6 +139,8 @@ export interface GameView {
|
|||||||
deckCounts: number[]
|
deckCounts: number[]
|
||||||
players: PlayerView[]
|
players: PlayerView[]
|
||||||
pending?: PendingTrade
|
pending?: PendingTrade
|
||||||
|
pendingReveal?: PendingReveal
|
||||||
|
pendingBattle?: PendingBattleDecision
|
||||||
battle?: BattleResult
|
battle?: BattleResult
|
||||||
winnerSeat: number
|
winnerSeat: number
|
||||||
log?: LogEntry[]
|
log?: LogEntry[]
|
||||||
@@ -129,9 +153,12 @@ export type ClientMessage =
|
|||||||
| { type: 'removePlayer'; target: string }
|
| { type: 'removePlayer'; target: string }
|
||||||
| { type: 'start' }
|
| { type: 'start' }
|
||||||
| { type: 'buy'; row: number }
|
| { type: 'buy'; row: number }
|
||||||
|
| { type: 'buyAvocado'; row: number }
|
||||||
| { type: 'sell'; cards: string[] }
|
| { type: 'sell'; cards: string[] }
|
||||||
| { type: 'trade'; cards: string[] }
|
| { type: 'trade'; cards: string[] }
|
||||||
| { type: 'tradeChoose'; pick: number }
|
| { type: 'tradeChoose'; pick: number }
|
||||||
|
| { 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