Initial pass at unicorn pack.

This commit is contained in:
Greyson Parrelli
2026-07-25 00:37:44 -04:00
parent f3783b44bf
commit a1d57fe1dd
21 changed files with 2419 additions and 44 deletions
+31 -2
View File
@@ -35,12 +35,12 @@ import (
// Action is one move the bot wants to make, mirroring the client protocol.
type Action struct {
Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose
Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose | sacrificeChoose
Row int // buy / buyAvocado
Cards []string // sell / trade
Pick int // tradeChoose
Order []string // arrange
CardID string // revealChoose (Cockatoo): the pet to reveal
CardID string // revealChoose (Cockatoo) / sacrificeChoose (Water of Youth): the pet
}
// Bot is a computer player at a fixed difficulty level.
@@ -62,6 +62,12 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
me := &v.Players[v.YouSeat]
switch v.Phase {
case game.PhaseShop:
if v.PendingSacrifice != nil {
if v.PendingSacrifice.PlayerID == me.ID {
return b.decideSacrifice(v)
}
return nil
}
if v.PendingReveal != nil {
if v.PendingReveal.PlayerID == me.ID {
return b.decideReveal(v)
@@ -107,6 +113,26 @@ func (b *Bot) decideReveal(v *game.View) *Action {
return &Action{Type: "revealChoose", CardID: best}
}
// decideSacrifice resolves Water of Youth (Unicorn pack): give up the
// lowest-value eligible pet to upgrade into a next-tier card.
func (b *Bot) decideSacrifice(v *game.View) *Action {
me := &v.Players[v.YouSeat]
worst, worstVal := "", math.Inf(1)
for _, id := range v.PendingSacrifice.Options {
for _, c := range me.Deck {
if c.ID == id {
if val := keepValue(c); val < worstVal {
worst, worstVal = id, val
}
}
}
}
if worst == "" && len(v.PendingSacrifice.Options) > 0 {
worst = v.PendingSacrifice.Options[0]
}
return &Action{Type: "sacrificeChoose", CardID: worst}
}
// 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 {
@@ -116,6 +142,9 @@ func Pending(v *game.View) bool {
me := &v.Players[v.YouSeat]
switch v.Phase {
case game.PhaseShop:
if v.PendingSacrifice != nil {
return v.PendingSacrifice.PlayerID == me.ID
}
if v.PendingReveal != nil {
return v.PendingReveal.PlayerID == me.ID
}
+16
View File
@@ -102,6 +102,8 @@ func applyAction(g *game.Game, playerID string, a *Action) error {
return g.TradeChoose(playerID, a.Pick)
case "revealChoose":
return g.RevealChoose(playerID, a.CardID)
case "sacrificeChoose":
return g.SacrificeChoose(playerID, a.CardID)
case "pass":
return g.Pass(playerID)
case "arrange":
@@ -139,6 +141,20 @@ func TestBotsFinishGoldenGame(t *testing.T) {
}
}
// TestBotsFinishUnicornGame plays complete games on the Unicorn pack (tiers
// 1-3 printed; 4-6 still in progress, so late shops are barren but battles
// still resolve). It exercises the Mana and Ailment mechanics via
// SimulateBattle rollouts plus the new shop effects (Water of Youth's sacrifice
// choice, Bigfoot), and fails on any illegal or missing bot action.
func TestBotsFinishUnicornGame(t *testing.T) {
for range 5 {
g := playBotGamePack(t, "unicorn", 1, 0.6)
if g.Round != game.MaxRounds {
t.Errorf("unicorn 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.
+47
View File
@@ -52,6 +52,20 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
for range best {
deck = append(deck, cx.simApple())
}
case game.ActionRevealTierForApples:
// Quetzalcoatl: a fixed apple reward if a low-tier pet can be revealed.
eligible := false
for _, d := range deck {
if d.IsPet() && d.ID != c.ID && (e.Cap <= 0 || d.Tier <= e.Cap) {
eligible = true
break
}
}
if eligible {
for range max(e.Count, 1) {
deck = append(deck, cx.simApple())
}
}
case game.ActionDoubleApples:
apples := 0
for _, dc := range deck {
@@ -87,6 +101,39 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 {
deck = slices.Delete(deck, i, i+1)
}
case game.ActionUpgradeNextTier:
// Unicorn pack (Water of Youth): discard this food and the lowest-value
// pet, then gain a sampled top card of the next tier for free.
nextTier := cx.v.Round + 1
if nextTier > game.MaxRounds {
continue
}
if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 {
deck = slices.Delete(deck, i, i+1)
}
worst, worstVal := -1, 0.0
for i, d := range deck {
if !d.IsPet() {
continue
}
if v := keepValue(d); worst < 0 || v < worstVal {
worst, worstVal = i, v
}
}
if worst < 0 {
continue // no pet to sacrifice; the food is wasted
}
deck = slices.Delete(deck, worst, worst+1)
pool := cx.unseenPool(nextTier)
if len(pool) == 0 {
pool = game.TierContentsForPack(cx.v.Pack, nextTier)
}
if len(pool) > 0 {
rc := pool[rand.IntN(len(pool))]
rc.ID = cx.nextSimID()
deck = append(deck, rc)
deck = cx.applyTemplateShopEffects(deck, rc, game.TriggerBuy)
}
}
}
return deck