Add support for up to 6 players.

This commit is contained in:
Greyson Parrelli
2026-07-28 07:36:09 -04:00
parent e542118175
commit a4f5f6910d
38 changed files with 2306 additions and 713 deletions
+70 -21
View File
@@ -32,24 +32,36 @@ func forcePlayable(id string) func() {
func playBotGamePack(t *testing.T, pack string, levelA, levelB float64) *game.Game {
t.Helper()
defer forcePlayable(pack)()
return playBotTable(t, []string{pack}, levelA, levelB)
}
// playBotTable drives a full game with a bot in every seat — one per level
// given, so it covers tables of two, four, or six — the same way the server
// would: observe on every state change, then act when input is owed. It fails
// the test if a bot ever produces an illegal action or the game stops making
// progress.
func playBotTable(t *testing.T, packs []string, levels ...float64) *game.Game {
t.Helper()
for _, pack := range packs {
defer forcePlayable(pack)()
}
g := game.New()
pa, err := g.AddBot("Bot A", levelA)
if err != nil {
t.Fatalf("AddBot A: %v", err)
bots := map[string]*Bot{}
mems := map[string]*Memory{}
for i, level := range levels {
p, err := g.AddBot(fmt.Sprintf("Bot %c", 'A'+i), level)
if err != nil {
t.Fatalf("AddBot %d: %v", i, err)
}
bots[p.ID] = New(level)
mems[p.ID] = &Memory{}
}
pb, err := g.AddBot("Bot B", levelB)
if err != nil {
t.Fatalf("AddBot B: %v", err)
}
if err := g.SetPack(pack); err != nil {
t.Fatalf("SetPack %s: %v", pack, err)
if err := g.SetPacks(packs); err != nil {
t.Fatalf("SetPacks %v: %v", packs, err)
}
if err := g.StartGame(); err != nil {
t.Fatalf("StartGame: %v", err)
}
bots := map[string]*Bot{pa.ID: New(levelA), pb.ID: New(levelB)}
mems := map[string]*Memory{pa.ID: {}, pb.ID: {}}
observe := func() {
for _, p := range g.Players {
@@ -128,6 +140,43 @@ func TestBotsFinishGames(t *testing.T) {
}
}
// TestBotsFinishMultiplayerGames plays complete four- and six-bot games on
// combined packs — the multiplayer setup the rulebook calls for. Beyond the
// usual "no illegal action" safety net, it exercises the bot against a rotating
// opponent (a different rival every round), several battles resolving in one
// round, and a card pool spanning more than one pack.
func TestBotsFinishMultiplayerGames(t *testing.T) {
tables := []struct {
name string
packs []string
levels []float64
}{
{"4p", []string{"turtle", "golden"}, []float64{1, 0.6, 0.25, 0.6}},
{"6p", []string{"turtle", "golden", "unicorn"}, []float64{1, 0.6, 0.25, 0, 1, 0.6}},
}
for _, tc := range tables {
t.Run(tc.name, func(t *testing.T) {
g := playBotTable(t, tc.packs, tc.levels...)
if g.Round != game.MaxRounds {
t.Errorf("game ended on round %d, want %d", g.Round, game.MaxRounds)
}
// Every seat should have fought all six rounds, so the trophies in
// play must add up to the six battles per seat-pair.
total := 0
for _, p := range g.Players {
total += p.Trophies
}
maxPossible := len(tc.levels) / 2 * (game.MaxRounds + 1) // round 6 pays double
if total > maxPossible {
t.Errorf("%d trophies awarded, only %d were available", total, maxPossible)
}
if len(g.WinnerSeats) == 0 {
t.Error("a finished game should name at least one winner")
}
})
}
}
// TestBotsFinishGoldenGame plays complete games on the Golden pack (tiers 1-3
// printed; 4-6 empty). It exercises the Trumpet/Golden Retriever/Cone Snail
// battle mechanics via SimulateBattle rollouts and the new shop effects, and
@@ -233,11 +282,11 @@ func assertModelMatches(t *testing.T, mem *Memory, opp *game.Player) {
want[c.Name]++
}
got := map[string]int{}
for _, c := range mem.Opp.Known {
for _, c := range mem.Opp(1).Known {
got[c.Name]++
}
if len(mem.Opp.Hidden) != 0 {
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp.Hidden))
if len(mem.Opp(1).Hidden) != 0 {
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp(1).Hidden))
}
for name, n := range want {
if got[name] != n {
@@ -306,8 +355,9 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
Round: game.MaxRounds, // alpha == 1: score is win-now only
MaxRounds: game.MaxRounds,
MaxPets: game.MaxPets,
Pack: game.DefaultPack,
Packs: []string{game.DefaultPack},
YouSeat: 0,
YourOpponent: 1,
Turn: 0,
PrioritySeat: 0,
DeckCounts: make([]int, game.MaxRounds+1),
@@ -319,9 +369,8 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
}
// Model a crushing opponent so every simulated battle is a loss.
mem := &Memory{}
mem.Opp.Seat = 1
for i := range 5 {
mem.Opp.Known = append(mem.Opp.Known,
mem.opp(1).Known = append(mem.opp(1).Known,
game.Card{ID: fmt.Sprintf("o%d", i), Kind: game.KindPet, Name: "Wall", Tier: 1, Power: 50})
}
@@ -360,8 +409,9 @@ func TestDecideShopKeepsHealthyBoard(t *testing.T) {
Round: 3, // mid-game: future value still carries real weight
MaxRounds: game.MaxRounds,
MaxPets: game.MaxPets,
Pack: game.DefaultPack,
Packs: []string{game.DefaultPack},
YouSeat: 0,
YourOpponent: 1,
Turn: 0,
PrioritySeat: 0,
DeckCounts: make([]int, game.MaxRounds+1),
@@ -379,9 +429,8 @@ func TestDecideShopKeepsHealthyBoard(t *testing.T) {
// A comparable opponent, so battles are genuinely competitive — selling is
// a real temptation, not a hopeless-position tie-break.
mem := &Memory{}
mem.Opp.Seat = 1
for i, n := range []string{"Dog", "Sheep", "Ant", "Cricket"} {
mem.Opp.Known = append(mem.Opp.Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed))
mem.opp(1).Known = append(mem.opp(1).Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed))
}
bot := New(1.0) // a capable bot should almost never make this trade