Files

416 lines
14 KiB
Go

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))
}
}