564 lines
15 KiB
Go
564 lines
15 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)
|
|
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-1 {
|
|
t.Fatalf("sell should cost 1 coin, 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-1 {
|
|
t.Fatalf("trade should cost 1 coin, 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)
|
|
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"))
|
|
spendAllCoins(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")
|
|
}
|
|
}
|
|
|
|
// spendAllCoins has both players pass until the shop ends.
|
|
func spendAllCoins(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)
|
|
spendAllCoins(t, g)
|
|
if g.Phase != PhaseArrange {
|
|
t.Fatalf("shop should end into arrange when no one is over the pet limit, got %s", g.Phase)
|
|
}
|
|
}
|
|
|
|
func TestForcedDiscardOverPetLimit(t *testing.T) {
|
|
g, p1, _ := testGame(t)
|
|
for range MaxPets + 2 {
|
|
p1.Deck = append(p1.Deck, g.pet("Extra", 1))
|
|
}
|
|
spendAllCoins(t, g)
|
|
if g.Phase != PhaseCleanup {
|
|
t.Fatalf("player with %d pets must be forced to discard, got phase %s", MaxPets+2, g.Phase)
|
|
}
|
|
// Wrong count rejected.
|
|
if err := g.CleanupSell(p1.ID, deckIDs(p1, Card.IsPet)[:1]); err == nil {
|
|
t.Fatal("must sell exactly the excess")
|
|
}
|
|
if err := g.CleanupSell(p1.ID, deckIDs(p1, Card.IsPet)[:2]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p1.PetCount() != MaxPets {
|
|
t.Fatalf("expected %d pets after cleanup, 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)
|
|
}
|
|
if g.Phase != PhaseArrange {
|
|
t.Fatalf("cleanup 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))
|
|
spendAllCoins(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)
|
|
}
|
|
}
|
|
spendAllCoins(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 shop/cleanup.
|
|
g.Phase = PhaseBattle
|
|
if err := g.DebugGrant(p1.ID, "Ant"); err == nil {
|
|
t.Fatal("grant should be rejected outside the shop")
|
|
}
|
|
}
|