Initial pass at Golden Pack.

This commit is contained in:
Greyson Parrelli
2026-07-24 07:47:05 -04:00
parent e74f983470
commit dd395f4bbf
27 changed files with 2770 additions and 150 deletions
+46 -6
View File
@@ -35,11 +35,13 @@ import (
// Action is one move the bot wants to make, mirroring the client protocol.
type Action struct {
Type string // "buy" | "sell" | "trade" | "tradeChoose" | "pass" | "arrange" | "ready"
Row int // buy
Cards []string // sell / trade
Pick int // tradeChoose
Order []string // arrange
Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose | battleChoose
Row int // buy / buyAvocado
Cards []string // sell / trade
Pick int // tradeChoose
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.
@@ -61,6 +63,12 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
me := &v.Players[v.YouSeat]
switch v.Phase {
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.PlayerID == me.ID {
return b.decideTradeChoose(v, mem)
@@ -78,6 +86,13 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
return b.decideArrange(v, mem)
}
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 {
return &Action{Type: "ready"}
}
@@ -85,6 +100,21 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
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
// server uses it to decide when to schedule a bot move.
func Pending(v *game.View) bool {
@@ -94,11 +124,21 @@ func Pending(v *game.View) bool {
me := &v.Players[v.YouSeat]
switch v.Phase {
case game.PhaseShop:
if v.PendingReveal != nil {
return v.PendingReveal.PlayerID == me.ID
}
if v.Pending != nil {
return v.Pending.PlayerID == me.ID
}
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 false
+41
View File
@@ -11,7 +11,26 @@ import (
// It fails the test if a bot ever produces an illegal action or the game
// stops making progress.
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()
defer forcePlayable(pack)()
g := game.New()
pa, err := g.AddBot("Bot A", levelA)
if err != nil {
@@ -21,6 +40,9 @@ func playBotGame(t *testing.T, levelA, levelB float64) *game.Game {
if err != nil {
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 {
t.Fatalf("StartGame: %v", err)
}
@@ -68,12 +90,18 @@ func applyAction(g *game.Game, playerID string, a *Action) error {
switch a.Type {
case "buy":
return g.Buy(playerID, a.Row)
case "buyAvocado":
return g.BuyAvocado(playerID, a.Row)
case "sell":
return g.Sell(playerID, a.Cards)
case "trade":
return g.TradeStart(playerID, a.Cards)
case "tradeChoose":
return g.TradeChoose(playerID, a.Pick)
case "revealChoose":
return g.RevealChoose(playerID, a.CardID)
case "battleChoose":
return g.BattleChoose(playerID, a.Value)
case "pass":
return g.Pass(playerID)
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
// the opponent's real deck after known public actions. The model may only
// contain information a human spectator would have.
+1 -1
View File
@@ -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 {
for i, p := range pets {
switch p.Name {
case "Rooster", "Dodo", "Leopard", "Peacock", "Scorpion":
case "Rooster", "Dodo", "Leopard", "Peacock", "Scorpion", "Bulldog", "Macaque":
return i
}
}
+3
View File
@@ -113,6 +113,9 @@ func leadScore(c game.Card) float64 {
s += 0.8
case game.TriggerHurt:
s += 0.5
case game.TriggerAfterAttack:
// Wants to survive its clashes to keep triggering (Bulldog).
s += 0.6
}
}
return s
+14 -8
View File
@@ -94,9 +94,15 @@ func Observe(v *game.View, m *Memory) {
switch {
case e.Kind == game.LogBuy:
if c, ok := cardByID(m.PrevShopRow, e.Source); ok {
m.Opp.Known = append(m.Opp.Known, c)
} else if c, ok := templateByName(e.CardName); ok {
m.Opp.Known = append(m.Opp.Known, c)
// An Avocado buy is set aside, not kept in the deck (Golden
// pack): don't add it to the deck model.
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:
m.removeOppCard(e.Source, e.CardName)
@@ -179,15 +185,15 @@ func cardByID(cards []game.Card, id string) (game.Card, bool) {
return game.Card{}, false
}
// templateByName mints a reference copy of a named card from the printed
// tier contents. The suit is whatever the first printed copy has — callers
// only rely on stats and effects.
func templateByName(name string) (game.Card, bool) {
// templateByName mints a reference copy of a named card from the pack's
// printed tier contents. The suit is whatever the first printed copy has —
// callers only rely on stats and effects.
func templateByName(pack, name string) (game.Card, bool) {
if name == "" {
return game.Card{}, false
}
for tier := 1; tier <= game.MaxRounds; tier++ {
for _, c := range game.TierContents(tier) {
for _, c := range game.TierContentsForPack(pack, tier) {
if c.Name == name {
return c, true
}
+2 -2
View File
@@ -60,7 +60,7 @@ func (cx *ctx) unseenPool(tier int) []game.Card {
note(c)
}
var pool []game.Card
for _, c := range game.TierContents(tier) {
for _, c := range game.TierContentsForPack(cx.v.Pack, tier) {
if seen[c.Name] > 0 {
seen[c.Name]--
continue
@@ -78,7 +78,7 @@ func (cx *ctx) sampleOppDeck() []game.Card {
deck := append([]game.Card(nil), cx.m.Opp.Known...)
for _, h := range cx.m.Opp.Hidden {
var c game.Card
if t, ok := templateByName(h.Name); ok {
if t, ok := templateByName(cx.v.Pack, h.Name); ok {
c = t
} else if pool := cx.unseenPool(h.Tier); len(pool) > 0 {
c = pool[rand.IntN(len(pool))]
+83 -2
View File
@@ -18,7 +18,26 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
}
switch e.Action {
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())
}
case game.ActionDoubleApples:
@@ -31,11 +50,54 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
for range apples {
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
}
// 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 {
return game.Card{
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
// 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
@@ -201,7 +282,7 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
}
pool := cx.unseenPool(v.Round + 1)
if len(pool) == 0 {
pool = game.TierContents(v.Round + 1)
pool = game.TierContentsForPack(v.Pack, v.Round+1)
}
var decks [][]game.Card
for range 3 {