Files
super-auto-pets-board-game/internal/game/game_test.go
T
Greyson Parrelli 72e198a750 Fix shop action costs and pass semantics.
Only buying costs gold; selling and trading (Triple) are free. Pass is
now a final action, legal only at or under the pet limit: it forfeits
remaining gold and ends that player's shopping for the round, with the
shop closing once everyone has passed. That makes the separate cleanup
phase unreachable (you sell down in-shop before passing), so it is
removed. The client asks for confirmation before passing, and the bot
knows buys are the only coin sink, when passing is legal, and that it
must sell down before it can pass.
2026-07-23 16:11:07 -04:00

591 lines
16 KiB
Go

package game
import (
"slices"
"strings"
"testing"
)
func deckIDs(p *Player, filter func(Card) bool) []string {
var ids []string
for _, c := range p.Deck {
if filter == nil || filter(c) {
ids = append(ids, c.ID)
}
}
return ids
}
func current(g *Game) *Player { return g.Players[g.Turn] }
func TestLobbyStartsWhenFull(t *testing.T) {
g := New()
if g.Phase != PhaseLobby {
t.Fatalf("new game should be in lobby, got %s", g.Phase)
}
if _, err := g.AddPlayer("Alice"); err != nil {
t.Fatal(err)
}
if g.Phase != PhaseLobby {
t.Fatal("game should wait for second player")
}
if _, err := g.AddPlayer("Bob"); err != nil {
t.Fatal(err)
}
if g.Phase != PhaseShop || g.Round != 1 {
t.Fatalf("game should start round 1 shop, got phase=%s round=%d", g.Phase, g.Round)
}
if _, err := g.AddPlayer("Carol"); err == nil {
t.Fatal("third player should be rejected while MaxPlayers=2")
}
for _, p := range g.Players {
if p.Coins != CoinsPerRound {
t.Fatalf("player should start with %d coins", CoinsPerRound)
}
}
if len(g.ShopRow) != ShopRowSize {
t.Fatalf("shop row should have %d cards", ShopRowSize)
}
for _, c := range g.ShopRow {
if c.Tier != 1 {
t.Fatalf("round 1 shop should deal tier 1 cards, got tier %d", c.Tier)
}
}
}
func TestBuyTakesCardAndRefills(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
// Pin the slot to an effect-less pet: a random draw could be a buy-apple
// pet (e.g. Otter), which would add an apple and make len(deck) != 1.
g.ShopRow[0] = g.pet("Plain", 3)
want := g.ShopRow[0]
deckBefore := len(g.ShopDecks[0])
if err := g.Buy(p.ID, 0); err != nil {
t.Fatal(err)
}
if p.Coins != CoinsPerRound-1 {
t.Fatalf("buy should cost 1 coin, coins=%d", p.Coins)
}
if len(p.Deck) != 1 || p.Deck[0].ID != want.ID {
t.Fatalf("bought card should be in deck")
}
if g.ShopRow[0].ID == "" || g.ShopRow[0].ID == want.ID {
t.Fatal("shop slot should refill with a new card")
}
if len(g.ShopDecks[0]) != deckBefore-1 {
t.Fatal("refill should come from the tier deck")
}
if current(g).ID == p.ID {
t.Fatal("turn should pass after an action")
}
}
func TestTurnValidation(t *testing.T) {
g, _, _ := testGame(t)
other := g.Players[(g.Turn+1)%2]
if err := g.Buy(other.ID, 0); err == nil {
t.Fatal("acting out of turn should fail")
}
}
func TestSellConvertsToApples(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
// Sell a plain pet (no sell effect) for exactly one apple.
plain := g.pet("Plain", 2)
p.Deck = append(p.Deck, plain)
if err := g.Sell(p.ID, []string{plain.ID}); err != nil {
t.Fatal(err)
}
if p.Coins != CoinsPerRound {
t.Fatalf("selling should be free, coins=%d", p.Coins)
}
if p.PetCount() != 0 || len(p.Deck) != 1 || p.Deck[0].Food != FoodApple {
t.Fatalf("sold pet should become an apple: %+v", p.Deck)
}
}
func TestSellDuckAddsExtraApple(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
duck := g.tier1(t, "Duck")
p.Deck = append(p.Deck, duck)
if err := g.Sell(p.ID, []string{duck.ID}); err != nil {
t.Fatal(err)
}
apples := 0
for _, c := range p.Deck {
if c.Food == FoodApple {
apples++
}
}
if apples != 2 {
t.Fatalf("selling a Duck should yield 2 apples (1 base + 1 effect), got %d", apples)
}
}
func TestBuyOtterAddsApple(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
g.ShopRow[0] = g.tier1(t, "Otter")
if err := g.Buy(p.ID, 0); err != nil {
t.Fatal(err)
}
hasOtter, apples := false, 0
for _, c := range p.Deck {
if c.Name == "Otter" {
hasOtter = true
}
if c.Food == FoodApple {
apples++
}
}
if !hasOtter || apples != 1 {
t.Fatalf("buying an Otter should also add an apple: %+v", p.Deck)
}
}
func TestTradeInThreeMatchingSuits(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
// Hand p three same-suit tier-1 pets directly.
for range 3 {
c := g.pet("Fodder", 1)
c.Suit = SuitBlue
p.Deck = append(p.Deck, c)
}
nextDeckBefore := len(g.ShopDecks[1])
if err := g.TradeStart(p.ID, deckIDs(p, nil)); err != nil {
t.Fatal(err)
}
if g.Pending == nil || g.Pending.Tier != 2 {
t.Fatalf("trade should reveal two tier-2 cards: %+v", g.Pending)
}
if len(p.Deck) != 0 {
t.Fatal("traded cards should leave the deck")
}
if current(g).ID != p.ID {
t.Fatal("turn should not pass until the trade is chosen")
}
if err := g.Buy(p.ID, 0); err == nil {
t.Fatal("other actions should be blocked while a trade is pending")
}
chosen := g.Pending.Options[0]
rejected := g.Pending.Options[1]
if err := g.TradeChoose(p.ID, 0); err != nil {
t.Fatal(err)
}
// The chosen card joins the deck (its Buy effect may add apples too).
if slices.IndexFunc(p.Deck, func(c Card) bool { return c.ID == chosen.ID }) < 0 {
t.Fatal("chosen card should join the deck")
}
deck2 := g.ShopDecks[1]
if len(deck2) != nextDeckBefore-1 {
t.Fatalf("tier 2 deck should be down exactly one card, was %d now %d", nextDeckBefore, len(deck2))
}
if deck2[len(deck2)-1].ID != rejected.ID {
t.Fatal("rejected card should go to the bottom of the tier deck")
}
if p.Coins != CoinsPerRound {
t.Fatalf("trading should be free, coins=%d", p.Coins)
}
}
// Fish fires its Triple effect when traded in, and a pet received from the
// trade fires its Buy effect.
func TestTradeTriggersTripleAndBuyEffects(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
countApples := func() int {
n := 0
for _, c := range p.Deck {
if c.Food == FoodApple {
n++
}
}
return n
}
var ids []string
for range 3 {
f := g.tier1(t, "Fish")
f.Suit = SuitYellow
p.Deck = append(p.Deck, f)
ids = append(ids, f.ID)
}
if err := g.TradeStart(p.ID, ids); err != nil {
t.Fatal(err)
}
if countApples() != 3 {
t.Fatalf("each traded Fish should add an apple, got %d", countApples())
}
// Rig the revealed options so the chosen card is an Otter (Buy effect).
g.Pending.Options[0] = g.tier1(t, "Otter")
if err := g.TradeChoose(p.ID, 0); err != nil {
t.Fatal(err)
}
if countApples() != 4 {
t.Fatalf("trade-received Otter should fire its Buy effect, got %d apples", countApples())
}
}
// A triple makes the three discarded pets public, but keeps the chosen pet
// secret — unless it has a Buy ability, which performs publicly.
func TestTradeVisibilityHidesPickUnlessBuyEffect(t *testing.T) {
logContains := func(g *Game, sub string) bool {
for _, e := range g.Log {
if strings.Contains(e.Text, sub) {
return true
}
}
return false
}
trade := func(g *Game, pick Card) *Game {
p := current(g)
var ids []string
for range 3 {
c := g.pet("Newt", 1)
c.Suit = SuitBlue
p.Deck = append(p.Deck, c)
ids = append(ids, c.ID)
}
if err := g.TradeStart(p.ID, ids); err != nil {
t.Fatal(err)
}
g.Pending.Options[0] = pick
if err := g.TradeChoose(p.ID, 0); err != nil {
t.Fatal(err)
}
return g
}
// Discarded trio is named; an effect-less pick stays hidden.
g, _, _ := testGame(t)
trade(g, g.pet("SecretPet", 3))
if !logContains(g, "Newt") {
t.Fatal("the discarded pets should be public in the log")
}
if logContains(g, "SecretPet") {
t.Fatal("a pick with no buy ability must stay hidden")
}
// A pick with a Buy ability (Otter) is revealed.
g2, _, _ := testGame(t)
trade(g2, g2.realPet(t, "Otter"))
if !logContains(g2, "Otter") {
t.Fatal("a pick with a buy ability should be revealed in the log")
}
}
func TestBuyWormAddsTwoApples(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
g.ShopRow[0] = g.realPet(t, "Worm")
if err := g.Buy(p.ID, 0); err != nil {
t.Fatal(err)
}
apples := 0
for _, c := range p.Deck {
if c.Food == FoodApple {
apples++
}
}
if apples != 2 {
t.Fatalf("buying a Worm should add 2 apples, got %d", apples)
}
}
// Swan's Triple refreshes a spent gold, but only from round 3 on.
func TestSwanTripleRefreshesGold(t *testing.T) {
for _, tc := range []struct {
round int
wantCoins int
}{
{round: 1, wantCoins: CoinsPerRound - 1}, // too early: coin stays spent
{round: 3, wantCoins: CoinsPerRound}, // refreshed (capped at 3)
} {
g, _, _ := testGame(t)
g.Round = tc.round
p := current(g)
p.Coins-- // a spent coin, so the refresh has something to restore
var ids []string
for range 3 {
s := g.realPet(t, "Swan")
s.Suit = SuitRed
p.Deck = append(p.Deck, s)
ids = append(ids, s.ID)
}
if err := g.TradeStart(p.ID, ids); err != nil {
t.Fatal(err)
}
if p.Coins != tc.wantCoins {
t.Fatalf("round %d: coins after swan trade = %d, want %d", tc.round, p.Coins, tc.wantCoins)
}
}
}
// Giraffe's Battle Prep hands out apples the moment arranging begins.
func TestGiraffeBattlePrep(t *testing.T) {
g, p1, _ := testGame(t)
p1.Deck = append(p1.Deck, g.realPet(t, "Giraffe"))
passShop(t, g)
if g.Phase != PhaseArrange {
t.Fatalf("expected arrange, got %s", g.Phase)
}
apples := 0
for _, c := range p1.Deck {
if c.Food == FoodApple {
apples++
}
}
if apples != 2 {
t.Fatalf("giraffe should add 2 apples at battle prep, got %d", apples)
}
}
func TestTradeRequiresMatchingSuit(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
a, b, c := g.pet("A", 1), g.pet("B", 1), g.pet("C", 1)
a.Suit, b.Suit, c.Suit = SuitRed, SuitRed, SuitBlue
p.Deck = append(p.Deck, a, b, c)
if err := g.TradeStart(p.ID, []string{a.ID, b.ID, c.ID}); err == nil {
t.Fatal("mismatched suits should be rejected")
}
if len(p.Deck) != 3 || p.Coins != CoinsPerRound {
t.Fatal("failed trade must not mutate state")
}
}
func TestTradeBlockedOnFinalRound(t *testing.T) {
g, _, _ := testGame(t)
g.Round = MaxRounds
p := current(g)
for range 3 {
c := g.pet("Fodder", 1)
c.Suit = SuitBlue
p.Deck = append(p.Deck, c)
}
if err := g.TradeStart(p.ID, deckIDs(p, nil)); err == nil {
t.Fatal("trading should be impossible in the final round")
}
}
// passShop has both players pass until the shop ends.
func passShop(t *testing.T, g *Game) {
t.Helper()
for g.Phase == PhaseShop {
if err := g.Pass(current(g).ID); err != nil {
t.Fatal(err)
}
}
}
func TestShopEndsIntoArrange(t *testing.T) {
g, _, _ := testGame(t)
passShop(t, g)
if g.Phase != PhaseArrange {
t.Fatalf("shop should end into arrange once both players pass, got %s", g.Phase)
}
}
// Passing is final: a passed player's turn never comes back, and their coins
// are forfeit, while the other player keeps shopping.
func TestPassEndsShoppingForTheRound(t *testing.T) {
g, _, _ := testGame(t)
p, other := current(g), g.Players[(g.Turn+1)%2]
if err := g.Pass(p.ID); err != nil {
t.Fatal(err)
}
if p.Coins != 0 {
t.Fatalf("passing should forfeit remaining coins, got %d", p.Coins)
}
if g.Phase != PhaseShop || current(g).ID != other.ID {
t.Fatalf("shop should continue with the other player, phase=%s turn=%d", g.Phase, g.Turn)
}
// A free action by the remaining player must not hand the turn back.
junk := g.pet("Junk", 1)
other.Deck = append(other.Deck, junk)
if err := g.Sell(other.ID, []string{junk.ID}); err != nil {
t.Fatal(err)
}
if current(g).ID != other.ID {
t.Fatal("turn must stay with the only player still shopping")
}
}
func TestPassBlockedOverPetLimit(t *testing.T) {
g, p1, _ := testGame(t)
g.Turn = p1.Seat
for range MaxPets + 2 {
p1.Deck = append(p1.Deck, g.pet("Extra", 1))
}
if err := g.Pass(p1.ID); err == nil {
t.Fatalf("passing with %d pets must be rejected", MaxPets+2)
}
// Selling down (free) unblocks the pass; the discards become apples.
if err := g.Sell(p1.ID, deckIDs(p1, Card.IsPet)[:2]); err != nil {
t.Fatal(err)
}
if p1.PetCount() != MaxPets {
t.Fatalf("expected %d pets after selling down, got %d", MaxPets, p1.PetCount())
}
apples := 0
for _, c := range p1.Deck {
if c.Food == FoodApple {
apples++
}
}
if apples != 2 {
t.Fatalf("discarded pets should become apples, got %d", apples)
}
passShop(t, g)
if g.Phase != PhaseArrange {
t.Fatalf("shop should flow into arrange, got %s", g.Phase)
}
}
func TestArrangeRejectsBadPermutation(t *testing.T) {
g, p1, _ := testGame(t)
p1.Deck = append(p1.Deck, g.pet("A", 1), g.pet("B", 2))
passShop(t, g)
if err := g.SubmitOrder(p1.ID, []string{p1.Deck[0].ID}); err == nil {
t.Fatal("partial order should be rejected")
}
if err := g.SubmitOrder(p1.ID, []string{p1.Deck[0].ID, p1.Deck[0].ID}); err == nil {
t.Fatal("duplicate IDs should be rejected")
}
if err := g.SubmitOrder(p1.ID, []string{p1.Deck[1].ID, p1.Deck[0].ID}); err != nil {
t.Fatal(err)
}
if p1.Deck[0].Name != "B" {
t.Fatal("submitted order should be applied to the deck")
}
}
// Full game: six rounds of pass-through shops and battles, trophy totals,
// and game over with a winner.
func TestFullGameFlow(t *testing.T) {
g, p1, p2 := testGame(t)
p1.Deck = append(p1.Deck, g.pet("Champ", 9))
p2.Deck = append(p2.Deck, g.pet("Chump", 1))
// testGame pins the priority token to seat 0, and seat 0 wins every
// battle, so the token moves to seat 1 after round 1 and stays there (a
// loser keeps it). Whoever holds it shops first.
wantPriority := p1.Seat
for round := 1; round <= MaxRounds; round++ {
if g.Round != round || g.Phase != PhaseShop {
t.Fatalf("expected shop of round %d, got round %d phase %s", round, g.Round, g.Phase)
}
if g.PrioritySeat != wantPriority || g.Turn != wantPriority {
t.Fatalf("round %d: priority holder should shop first (want seat %d, priority=%d turn=%d)",
round, wantPriority, g.PrioritySeat, g.Turn)
}
for _, c := range g.ShopRow {
if c.ID != "" && c.Tier != round {
t.Fatalf("round %d shop dealt tier %d card", round, c.Tier)
}
}
passShop(t, g)
for _, p := range g.Players {
if err := g.SubmitOrder(p.ID, deckIDs(p, nil)); err != nil {
t.Fatal(err)
}
}
if g.Phase != PhaseBattle {
t.Fatalf("expected battle after both arrange, got %s", g.Phase)
}
if g.Battle.WinnerSeat != p1.Seat {
t.Fatalf("round %d: seat 0 should win", round)
}
// The winner hands the token to the loser; a loser keeps it.
if wantPriority == g.Battle.WinnerSeat {
wantPriority = (wantPriority + 1) % len(g.Players)
}
for _, p := range g.Players {
if err := g.AcknowledgeBattle(p.ID); err != nil {
t.Fatal(err)
}
}
}
if g.Phase != PhaseGameOver {
t.Fatalf("game should be over after %d rounds, got %s", MaxRounds, g.Phase)
}
// 1 trophy for rounds 1-5, 2 for round 6.
if p1.Trophies != 7 {
t.Fatalf("winner should have 7 trophies, got %d", p1.Trophies)
}
if g.WinnerSeat != p1.Seat {
t.Fatalf("winner seat should be %d, got %d", p1.Seat, g.WinnerSeat)
}
}
func TestViewHidesSecrets(t *testing.T) {
g, p1, p2 := testGame(t)
p1.Deck = append(p1.Deck, g.pet("Secret", 3))
v := g.ViewFor(p2.ID)
if v.YouSeat != p2.Seat {
t.Fatalf("view should identify the viewer's seat")
}
for _, pv := range v.Players {
if pv.Seat == p1.Seat && pv.Deck != nil {
t.Fatal("opponent deck contents must be hidden")
}
if pv.Seat == p1.Seat && pv.DeckSize != 1 {
t.Fatal("opponent deck size should be visible")
}
}
// Pending trade options hidden from the opponent.
for range 3 {
c := g.pet("Fodder", 1)
c.Suit = SuitYellow
p1.Deck = append(p1.Deck, c)
}
g.Turn = p1.Seat
var fodder []string
for _, c := range p1.Deck {
if c.Name == "Fodder" {
fodder = append(fodder, c.ID)
}
}
if err := g.TradeStart(p1.ID, fodder); err != nil {
t.Fatal(err)
}
if opts := g.ViewFor(p2.ID).Pending.Options; opts[0].ID != "" || opts[1].ID != "" {
t.Fatal("trade options must be hidden from opponents")
}
if opts := g.ViewFor(p1.ID).Pending.Options; opts[0].ID == "" {
t.Fatal("trade options must be visible to the trader")
}
}
func TestDebugGrant(t *testing.T) {
g, p1, _ := testGame(t)
before := len(p1.Deck)
if err := g.DebugGrant(p1.ID, "Ant"); err != nil {
t.Fatal(err)
}
if len(p1.Deck) != before+1 || p1.Deck[before].Name != "Ant" || !p1.Deck[before].IsPet() {
t.Fatalf("granted card should be an Ant appended to the deck: %+v", p1.Deck)
}
if p1.Deck[before].ID == "" {
t.Fatal("granted card should get a real instance ID")
}
// Unknown card name is rejected.
if err := g.DebugGrant(p1.ID, "Nonexistent"); err == nil {
t.Fatal("unknown card name should error")
}
// Foods can be granted too.
if err := g.DebugGrant(p1.ID, "Honey"); err != nil {
t.Fatal(err)
}
if !Catalog()[0].IsPet() { // sanity on the shared catalog helper
t.Fatal("catalog should start with a pet")
}
// Not allowed outside the shop.
g.Phase = PhaseBattle
if err := g.DebugGrant(p1.ID, "Ant"); err == nil {
t.Fatal("grant should be rejected outside the shop")
}
}