package game import ( "fmt" "slices" "testing" ) // pairKey names an unordered pairing, so a schedule can be checked for repeats // regardless of which seat the book printed first. func pairKey(m Matchup) string { a, b := min(m[0], m[1]), max(m[0], m[1]) return fmt.Sprintf("%d-%d", a, b) } // TestPairingTablesAreWellFormed checks the transcribed rulebook tables against // the properties they must have: every round seats everyone exactly once, and // the opening rounds run a true round-robin (three rounds cover all six pairs // at four players; five rounds cover all fifteen at six) before the schedule // starts replaying earlier rounds to fill out the six. func TestPairingTablesAreWellFormed(t *testing.T) { for _, players := range PlayerCounts { for round := 1; round <= MaxRounds; round++ { ms := Pairings(players, round) if len(ms) != players/2 { t.Fatalf("%dp round %d: got %d battles, want %d", players, round, len(ms), players/2) } seen := map[int]bool{} for _, m := range ms { for _, seat := range m { if seat < 0 || seat >= players { t.Fatalf("%dp round %d: seat %d out of range", players, round, seat) } if seen[seat] { t.Fatalf("%dp round %d: seat %d fights twice", players, round, seat) } seen[seat] = true } if m[0] == m[1] { t.Fatalf("%dp round %d: seat %d paired with itself", players, round, m[0]) } } } // The round-robin prefix: enough rounds to pair everyone with everyone, // with no pairing used twice along the way. robin := players - 1 if players == 2 { robin = 1 } distinct := map[string]bool{} for round := 1; round <= robin; round++ { for _, m := range Pairings(players, round) { key := pairKey(m) if distinct[key] { t.Errorf("%dp: pairing %s repeats inside the first %d rounds", players, key, robin) } distinct[key] = true } } if want := players * (players - 1) / 2; players > 2 && len(distinct) != want { t.Errorf("%dp: first %d rounds cover %d pairings, want all %d", players, robin, len(distinct), want) } } } // TestOpponentOfMatchesPairings checks the seat-to-opponent lookup agrees with // the table it reads, in both directions, for every seat and round. func TestOpponentOfMatchesPairings(t *testing.T) { for _, players := range PlayerCounts { for round := 1; round <= MaxRounds; round++ { for seat := range players { opp := OpponentOf(players, round, seat) if opp < 0 { t.Fatalf("%dp round %d: seat %d has no opponent", players, round, seat) } if back := OpponentOf(players, round, opp); back != seat { t.Errorf("%dp round %d: seat %d fights %d, but %d fights %d", players, round, seat, opp, opp, back) } } } } if got := OpponentOf(3, 1, 0); got != -1 { t.Errorf("an unplayable table should have no pairings, got opponent %d", got) } } // TestCombinedPacksShuffleTogether checks the rulebook's multi-pack rule: the // packs' tier decks merge into one deck per tier, so a combined game's tier 1 // holds exactly the tier 1 cards of every pack chosen. func TestCombinedPacksShuffleTogether(t *testing.T) { sizeOf := func(packs ...string) []int { g := &Game{Packs: packs} g.buildShopDecks() sizes := make([]int, MaxRounds) for i, d := range g.ShopDecks { sizes[i] = len(d) } return sizes } turtle, golden := sizeOf("turtle"), sizeOf("golden") both := sizeOf("turtle", "golden") for tier := range MaxRounds { if want := turtle[tier] + golden[tier]; both[tier] != want { t.Errorf("tier %d of the combined decks holds %d cards, want %d+%d=%d", tier+1, both[tier], turtle[tier], golden[tier], want) } } // Both packs' cards really are in the same deck, and every card is a // distinct instance — two packs means two of everything, not shared IDs. g := &Game{Packs: []string{"turtle", "golden"}} g.buildShopDecks() names, ids := map[string]bool{}, map[string]bool{} for _, deck := range g.ShopDecks { for _, c := range deck { names[c.Name] = true if ids[c.ID] { t.Fatalf("duplicate card id %q across the combined decks", c.ID) } ids[c.ID] = true } } for _, want := range []string{"Ant", "Cricket", "Groundhog", "Bulldog"} { if !names[want] { t.Errorf("combined Turtle+Golden decks are missing %s", want) } } } // TestStartGameRequiresEvenTableAndEnoughPacks pins the lobby rules: play // happens in pairs, so the table must be even, and the rulebook asks for one // pack per pair. func TestStartGameRequiresEvenTableAndEnoughPacks(t *testing.T) { newLobby := func(t *testing.T, players int, packs ...string) *Game { t.Helper() g := New() if err := g.SetPacks(packs); err != nil { t.Fatal(err) } for i := range players { if _, err := g.AddPlayer(fmt.Sprintf("P%d", i)); err != nil { t.Fatal(err) } } return g } if err := newLobby(t, 3, "turtle", "golden").StartGame(); err == nil { t.Error("three players can't pair off and should not start") } if err := newLobby(t, 5, "turtle", "golden", "unicorn").StartGame(); err == nil { t.Error("five players can't pair off and should not start") } if err := newLobby(t, 4, "turtle").StartGame(); err == nil { t.Error("four players on a single pack should not start") } if err := newLobby(t, 6, "turtle", "golden").StartGame(); err == nil { t.Error("six players on two packs should not start") } if err := newLobby(t, 4, "turtle", "golden").StartGame(); err != nil { t.Errorf("four players on two packs should start: %v", err) } if err := newLobby(t, 6, "turtle", "golden", "unicorn").StartGame(); err != nil { t.Errorf("six players on three packs should start: %v", err) } // Two players may still combine packs if they want a deeper shop. if err := newLobby(t, 2, "turtle", "unicorn").StartGame(); err != nil { t.Errorf("two players should be free to combine packs: %v", err) } g := New() if err := g.SetPacks([]string{"turtle", "turtle"}); err == nil { t.Error("the same pack twice should be rejected") } if err := g.SetPacks(nil); err == nil { t.Error("an empty pack selection should be rejected") } } // TestMaxPlayersCapacity checks the lobby fills to six seats and no further. func TestMaxPlayersCapacity(t *testing.T) { g := New() for i := range MaxPlayers { if _, err := g.AddPlayer(fmt.Sprintf("P%d", i)); err != nil { t.Fatalf("seating player %d: %v", i, err) } } if _, err := g.AddPlayer("one too many"); err == nil { t.Errorf("a %dth player should be turned away", MaxPlayers+1) } } // startMulti builds a running game with the given number of seats, enough // packs to cover it, and every player holding one plain pet so battles resolve. func startMulti(t *testing.T, players int) *Game { t.Helper() g := New() packs := []string{"turtle", "golden", "unicorn"}[:PacksNeeded(players)] if err := g.SetPacks(packs); err != nil { t.Fatal(err) } for i := range players { if _, err := g.AddPlayer(fmt.Sprintf("P%d", i)); err != nil { t.Fatal(err) } } if err := g.StartGame(); err != nil { t.Fatal(err) } return g } // playRound walks a started game through one full round: everyone passes the // shop, submits their deck as-is, and acknowledges the battles. func playRound(t *testing.T, g *Game) { t.Helper() for g.Phase == PhaseShop { p := g.Players[g.Turn] if err := g.Pass(p.ID); err != nil { t.Fatalf("round %d: %s could not pass: %v", g.Round, p.Name, err) } } if g.Phase != PhaseArrange { t.Fatalf("round %d: shop should hand off to arrange, got %s", g.Round, g.Phase) } for _, p := range g.Players { ids := make([]string, len(p.Deck)) for i, c := range p.Deck { ids[i] = c.ID } if err := g.SubmitOrder(p.ID, ids); err != nil { t.Fatalf("round %d: %s could not submit: %v", g.Round, p.Name, err) } } if g.Phase != PhaseBattle { t.Fatalf("round %d: arrange should hand off to battle, got %s", g.Round, g.Phase) } for _, p := range g.Players { if err := g.AcknowledgeBattle(p.ID); err != nil { t.Fatalf("round %d: %s could not acknowledge: %v", g.Round, p.Name, err) } } } // TestMultiplayerRoundFightsEveryPairing plays 4- and 6-player games end to end // and checks each round resolves exactly the scheduled battles, that every // player is in exactly one of them, and that the trophies handed out match the // results recorded. func TestMultiplayerRoundFightsEveryPairing(t *testing.T) { for _, players := range []int{4, 6} { t.Run(fmt.Sprintf("%dp", players), func(t *testing.T) { g := startMulti(t, players) awarded := make([]int, players) for round := 1; round <= MaxRounds; round++ { if g.Round != round { t.Fatalf("expected round %d, got %d", round, g.Round) } want := Pairings(players, round) playRound(t, g) if len(g.Battles) != len(want) { t.Fatalf("round %d resolved %d battles, want %d", round, len(g.Battles), len(want)) } fought := map[int]bool{} for i, b := range g.Battles { if len(b.Seats) != 2 { t.Fatalf("round %d battle %d has %d seats", round, i, len(b.Seats)) } if got, wantKey := pairKey(Matchup{b.Seats[0], b.Seats[1]}), pairKey(want[i]); got != wantKey { t.Errorf("round %d battle %d paired %s, want %s", round, i, got, wantKey) } for _, seat := range b.Seats { if fought[seat] { t.Errorf("round %d: seat %d fought twice", round, seat) } fought[seat] = true } if b.WinnerSeat >= 0 { if !b.Has(b.WinnerSeat) { t.Errorf("round %d: winner seat %d wasn't in the battle", round, b.WinnerSeat) } awarded[b.WinnerSeat] += b.Trophies } } if len(fought) != players { t.Errorf("round %d: %d of %d players fought", round, len(fought), players) } } if g.Phase != PhaseGameOver { t.Fatalf("game should be over after %d rounds, got %s", MaxRounds, g.Phase) } for _, p := range g.Players { if p.Trophies != awarded[p.Seat] { t.Errorf("%s holds %d trophies, but won %d", p.Name, p.Trophies, awarded[p.Seat]) } if len(p.RoundWins) != countWins(g, p.Seat) { t.Errorf("%s recorded %d round wins, want %d", p.Name, len(p.RoundWins), countWins(g, p.Seat)) } } }) } } // countWins is an independent tally of a seat's round wins, read back off the // event log rather than the player record it is checking. func countWins(g *Game, seat int) int { n := 0 for _, e := range g.Log { if e.Kind == LogResult && e.Seat == seat { n++ } } return n } // TestFirstShopperTokenWalksTheTable checks the multiplayer shop order: the // token starts on seat A and passes one seat along at the end of every round, // and the round's shopping starts with whoever holds it. func TestFirstShopperTokenWalksTheTable(t *testing.T) { g := startMulti(t, 4) for round := 1; round <= MaxRounds; round++ { want := (round - 1) % len(g.Players) if g.PrioritySeat != want { t.Errorf("round %d: first shopper is seat %d, want %d", round, g.PrioritySeat, want) } if g.Turn != want { t.Errorf("round %d: shopping starts at seat %d, want %d", round, g.Turn, want) } playRound(t, g) } } // TestTieBreakCountsBackFromTheLastRound pins the rulebook's tie-break: level // on trophies, the title goes to whoever won the latest round that separates // them; identical records share it. func TestTieBreakCountsBackFromTheLastRound(t *testing.T) { // finishWith runs finish() over a table whose trophies and round wins are // set directly, which is the only state the tie-break reads. finishWith := func(records ...[]int) *Game { g := &Game{Phase: PhaseBattle, Round: MaxRounds, WinnerSeat: -1} for i, wins := range records { trophies := 0 for _, r := range wins { trophies++ if r == MaxRounds { trophies++ // the final round is worth double } } g.Players = append(g.Players, &Player{ Name: string(rune('A' + i)), Seat: i, Trophies: trophies, RoundWins: wins, }) } g.finish() return g } // Different trophy counts need no tie-break at all. if g := finishWith([]int{1, 2}, []int{3}); g.WinnerSeat != 0 { t.Errorf("most trophies should win outright, got seat %d", g.WinnerSeat) } // Level on trophies: seat 1 took the final round, so it takes the title. g := finishWith([]int{1, 2, 3}, []int{1, 2, MaxRounds}) if g.WinnerSeat != 1 { t.Errorf("the round-%d winner should break the tie, got seat %d", MaxRounds, g.WinnerSeat) } // Neither won the last round, so the countback keeps going: both won round // 3, which separates nobody, and round 2 decides it. g = finishWith([]int{2, 3}, []int{1, 3}) if g.WinnerSeat != 0 { t.Errorf("countback should reach round 2 and pick seat 0, got seat %d", g.WinnerSeat) } // Identical records share the victory. g = finishWith([]int{1, 3}, []int{1, 3}, []int{2}) if g.WinnerSeat != -1 { t.Errorf("an unbreakable tie should have no outright winner, got seat %d", g.WinnerSeat) } if want := []int{0, 1}; !slices.Equal(g.WinnerSeats, want) { t.Errorf("shared victory listed %v, want %v", g.WinnerSeats, want) } } // TestViewShowsEveryTableButKeepsSecrets checks a player's view of a six-player // round: all three battles are public and replayable, their own is singled out, // and nobody else's hand leaks. func TestViewShowsEveryTableButKeepsSecrets(t *testing.T) { g := startMulti(t, 6) playRound(t, g) me := g.Players[2] v := g.ViewFor(me.ID) if len(v.Battles) != 3 { t.Fatalf("view shows %d battles, want all 3", len(v.Battles)) } if v.Battle == nil || !v.Battle.Has(me.Seat) { t.Fatal("the view should single out the battle the viewer fought") } for _, b := range v.Battles { if len(b.Lineups) != 2 { t.Errorf("a battle result should carry both sides' lineups, got %d", len(b.Lineups)) } } for _, pv := range v.Players { if pv.Seat != me.Seat && pv.Deck != nil { t.Errorf("seat %d's hand leaked into seat %d's view", pv.Seat, me.Seat) } } // The pairings are printed in the rulebook, so they're public. if v.YourOpponent != OpponentOf(6, v.Round, me.Seat) { t.Errorf("view names opponent %d, schedule says %d", v.YourOpponent, OpponentOf(6, v.Round, me.Seat)) } }