Initial pass at Golden Pack.
This commit is contained in:
+46
-6
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user