diff --git a/CLAUDE.md b/CLAUDE.md index 2f7d03b..6533620 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,28 @@ Go backend (pure rules engine in `internal/game`, WebSocket rooms in - `mise run test` — Go tests · `mise run check` — go vet + frontend tsc +## Seats, sides, and packs + +Three distinctions are easy to conflate and worth keeping straight: + +- **Seat vs. side.** A game seats 2, 4, or 6 players, and each round pairs + them off into simultaneous battles (`schedule.go`). Inside a + `BattleResult` everything is indexed by *side* — 0 or 1 within that one + battle — including `BattleEvent.Seat`/`Target`, `Lineups`, `Survivors`, + and `ManaAfter`. `Seats` maps side → seat, and `Side(seat)` maps back. + `WinnerSeat` is the deliberate exception: it's a real seat, because it's + the only field that means anything outside the battle. When adding a + per-side field to a battle, index it by side and say so. +- **One battle vs. the round.** `resolveBattles` loops the round's pairings; + `runBattle(first, second)` resolves one of them and mutates no persistent + state. Anything that should happen once per round rather than once per + battle (clearing per-round banks, say) belongs in `resolveBattles`, not + `finalizeBattle`. +- **Packs are plural.** `Game.Packs` is a list whose tier decks shuffle + together. Anything reading printed card data must go through + `TierContentsForPacks` / `CatalogForPacks` / `g.packList()`, never a + single pack — a game can hold Trumpets, Mana, and Ailments at once. + ## Keep the AI in sync with game behavior **Whenever game behavior changes — rules, cards, effects, phases, log diff --git a/README.md b/README.md index 2631fdc..ac6f912 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Super Auto Pets: The Board Game — Online -A web app for playing the Super Auto Pets board game remotely. 1v1 for now; -the engine is built to grow to more players. Play a friend by room code, or -play solo against a computer opponent (easy / medium / hard). +A web app for playing the Super Auto Pets board game remotely, at 2, 4, or 6 +players. Play friends by room code, fill any empty seat with a computer +opponent (easy / medium / hard), or play solo against a table of them. ## Stack @@ -81,10 +81,41 @@ Six rounds, each with its own shop tier deck. Per round: Apples and bees are **temporary**: they leave your deck after the battle. Most trophies after round 6 wins. +## 4 & 6 player mode + +A game seats an even number of players — 2, 4, or 6 — so everyone has an +opponent every round. A lobby with an odd number of humans fills the gap with +a bot; any seat can be a bot, so a table of one human and five computers is +just as valid as six humans. + +Above two players the shop is shared but the battles are not: each round +splits the table into pairs, and every pair fights its own battle +simultaneously. The pairings come from the fixed table printed in the rulebook +(`internal/game/schedule.go`) — a round-robin that runs everyone past everyone +before replaying earlier rounds to fill out the six. All of it is public: you +can replay any table's battle, not just your own, and peek at any player's +lineup afterwards. + +Two rules change with the bigger table: + +- **First shopper** — at two players the priority token does double duty + (shops first, acts first in battle) and passes from a winner to the loser. + With more players it's a separate token that starts at seat A and walks one + seat along each round, while every battle flips its own first player. +- **Packs** — the rulebook asks for one pack per pair (2 packs for 4 players, + 3 for 6). The host picks them in the lobby and their tier decks shuffle + together: all the tier 1 cards into one tier 1 deck, and so on. Two players + may combine packs too, for a deeper shop. + +Ties on trophies are settled by counting back from the last round: whoever won +round 6 takes it, else round 5, and so on. Players with identical records +share the victory. + ## Packs -Three card packs are playable, chosen by the host in the lobby. Each pack is -six tiers, one per round, and every pet ships as two copies. +Three card packs are playable, and the host combines one or more of them in +the lobby. Each pack is six tiers, one per round, and every pet ships as two +copies. ### Turtle pack @@ -164,12 +195,18 @@ web/ React frontend ## The computer opponent Any seat can be a bot (`Player.IsBot`); humans and bots are interchangeable -to the engine, which is what will let future >2-player games mix them -freely. The AI in `internal/ai` never touches the `Game` — it decides from a -`game.View`, the same per-player state a human client is sent, plus a -persisted memory of public observations (battle lineups, the event log, shop -row changes). It cannot see your deck order, hidden trade picks, or the -shuffled shop decks. It scores candidate moves by Monte-Carlo battle -rollouts (`game.SimulateBattle`) against sampled guesses of your deck and -ordering, blended with a long-term deck-value heuristic; difficulty tunes a -softmax over the scored moves plus the rollout budget. +to the engine, so a table can mix them freely. The AI in `internal/ai` never +touches the `Game` — it decides from a `game.View`, the same per-player state +a human client is sent, plus a persisted memory of public observations +(battle lineups, the event log, shop row changes). It cannot see your deck +order, hidden trade picks, or the shuffled shop decks. It scores candidate +moves by Monte-Carlo battle rollouts (`game.SimulateBattle`) against sampled +guesses of your deck and ordering, blended with a long-term deck-value +heuristic; difficulty tunes a softmax over the scored moves plus the rollout +budget. + +At a bigger table it keeps a model of *every* seat, not just one rival — it +will face each of them eventually, and every battle is fought in the open — +but plans each round against the specific opponent the schedule pairs it +with. Its card counting spans the whole table's known cards and the combined +contents of the packs in play. diff --git a/internal/ai/ai.go b/internal/ai/ai.go index 5b995cd..8425f16 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -247,11 +247,18 @@ func immediateWeight(v *game.View) float64 { if v.MaxRounds > 1 { w += 0.60 * float64(v.Round-1) / float64(v.MaxRounds-1) } - me := v.Players[v.YouSeat] + // How far behind the field the bot is. The yardstick is whoever is leading, + // not the sum of everyone — at a six-player table the title is a race + // against the front-runner, and summing would swamp the round term. + me := v.PlayerView(v.YouSeat) + best := 0 for _, p := range v.Players { if p.Seat != v.YouSeat { - w += 0.08 * float64(p.Trophies-me.Trophies) + best = max(best, p.Trophies) } } + if me != nil { + w += 0.08 * float64(best-me.Trophies) + } return min(max(w, 0.25), 1) } diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go index 10b1197..1661025 100644 --- a/internal/ai/ai_test.go +++ b/internal/ai/ai_test.go @@ -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 diff --git a/internal/ai/eval.go b/internal/ai/eval.go index c3c1de3..3364211 100644 --- a/internal/ai/eval.go +++ b/internal/ai/eval.go @@ -36,9 +36,11 @@ func winScore(res *game.BattleResult, mySeat int) float64 { default: base = 0 } + // Survivors is indexed by battle side, not by seat. + mySide := res.Side(mySeat) margin := 0 - for seat, s := range res.Survivors { - if seat == mySeat { + for side, s := range res.Survivors { + if side == mySide { margin += s } else { margin -= s @@ -58,19 +60,30 @@ func (cx *ctx) winProb(myDeck []game.Card, oppDecks [][]game.Card, simsPer int) total, n := 0.0, 0 for _, opp := range oppDecks { for range simsPer { - var res *game.BattleResult - if cx.me.Seat == 0 { - res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, myDeck, opp, nil) - } else { - res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, opp, myDeck, nil) - } - total += winScore(res, cx.me.Seat) + // The bot's own deck always takes scratch seat 0, so a rollout reads + // the same however the real table happens to be seated. + res := game.SimulateBattle(cx.v.Round, cx.simFirstSeat(), myDeck, opp, nil) + total += winScore(res, 0) n++ } } return total / float64(n) } +// simFirstSeat picks who acts first in a rollout, with the bot at seat 0. Two +// players pass a priority token the bot can see, so it plans against the real +// one; bigger tables flip a coin for each battle, which the bot can't know in +// advance — it rolls too, and averages over both possibilities. +func (cx *ctx) simFirstSeat() int { + if len(cx.v.Players) == 2 { + if cx.v.PrioritySeat == cx.me.Seat { + return 0 + } + return 1 + } + return rand.IntN(2) +} + // keepValue ranks a single card's worth to the bot's future: what it loses // by selling or trading it away. Temporary cards (apples) are nearly free to // lose — they vanish after the next battle anyway. diff --git a/internal/ai/eval_test.go b/internal/ai/eval_test.go index 551bed6..a0dd73a 100644 --- a/internal/ai/eval_test.go +++ b/internal/ai/eval_test.go @@ -11,8 +11,11 @@ import ( // to spare, a loss that took most of the enemy down), but no margin, however // lopsided, may ever raise a loss above a draw or a draw above a win. func TestWinScoreMarginBreaksTiesNotVerdicts(t *testing.T) { + // Seat 0 fights seat 1, and is side 0 of the battle — Survivors and the + // other per-side slices are indexed by side, so the mapping has to be there + // for winScore to read the margin from the right end. mk := func(winner, mine, theirs int) *game.BattleResult { - return &game.BattleResult{WinnerSeat: winner, Survivors: []int{mine, theirs}} + return &game.BattleResult{WinnerSeat: winner, Seats: []int{0, 1}, Survivors: []int{mine, theirs}} } decisiveWin := winScore(mk(0, 5, 0), 0) diff --git a/internal/ai/memory.go b/internal/ai/memory.go index 699bd6d..0ecf980 100644 --- a/internal/ai/memory.go +++ b/internal/ai/memory.go @@ -17,7 +17,11 @@ type Memory struct { LastSeq int `json:"lastSeq"` // last event-log entry processed LastBattleRound int `json:"lastBattleRound"` // last battle lineup ingested PrevShopRow []game.Card `json:"prevShopRow"` // shop row at the previous observation - Opp OppModel `json:"opp"` + // Opps models every other seat at the table, keyed by seat. A bot fights a + // different opponent each round (see the rulebook's pairings), and every + // battle is played in the open, so it tracks the whole field rather than + // one rival. + Opps map[int]*OppModel `json:"opps,omitempty"` } // OppModel is the bot's belief about one opponent's deck. Known holds cards @@ -30,6 +34,28 @@ type OppModel struct { Hidden []HiddenCard `json:"hidden,omitempty"` } +// opp returns the model for a seat, creating it on first sight. +func (m *Memory) opp(seat int) *OppModel { + if m.Opps == nil { + m.Opps = map[int]*OppModel{} + } + o, ok := m.Opps[seat] + if !ok { + o = &OppModel{Seat: seat} + m.Opps[seat] = o + } + return o +} + +// Opp returns the bot's model of one seat. A seat it has never seen comes back +// empty rather than nil, so callers can read it unconditionally. +func (m *Memory) Opp(seat int) *OppModel { + if o, ok := m.Opps[seat]; ok { + return o + } + return &OppModel{Seat: seat} +} + // HiddenCard is a card the opponent holds that the bot has not seen. Name is // set when the card was later named publicly (e.g. a trade pick revealed by // its buy ability) — the suit still isn't known, but the stats are. @@ -44,6 +70,14 @@ func LoadMemory(raw json.RawMessage) *Memory { m := &Memory{} if len(raw) > 0 { _ = json.Unmarshal(raw, m) + // Notebooks written before the bot tracked a whole field held a single + // "opp"; file it under its seat. + var legacy struct { + Opp *OppModel `json:"opp"` + } + if json.Unmarshal(raw, &legacy) == nil && legacy.Opp != nil && len(m.Opps) == 0 { + m.Opps = map[int]*OppModel{legacy.Opp.Seat: legacy.Opp} + } } return m } @@ -64,112 +98,132 @@ func (m *Memory) Marshal() json.RawMessage { // 1. new event-log entries, whose structured tags describe opponent shop // actions (buys name the card, sells name what left, trades list the // discarded trio, spawn entries count apples gained); -// 2. the latest battle's lineups, which reveal both decks in full and reset -// the model to ground truth every round (so any drift lasts one round); -// 3. the opponent's public deck size, as a reconciliation safety net. +// 2. the round's battle lineups, which reveal every deck in full and reset +// the models to ground truth every round (so any drift lasts one round); +// 3. each opponent's public deck size, as a reconciliation safety net. +// +// Every source is table-wide: the bot follows all its rivals, not only the one +// it happens to be paired against, because it will face each of them later. func Observe(v *game.View, m *Memory) { if v.YouSeat < 0 { return } - oppSeat := -1 - for _, p := range v.Players { - if p.Seat != v.YouSeat { - oppSeat = p.Seat - break - } + isOpponent := func(seat int) bool { + return seat >= 0 && seat != v.YouSeat && v.PlayerView(seat) != nil } - if oppSeat < 0 { - return - } - m.Opp.Seat = oppSeat for _, e := range v.Log { if e.Seq <= m.LastSeq { continue } m.LastSeq = e.Seq - if e.Seat != oppSeat { + if !isOpponent(e.Seat) { continue } + opp := m.opp(e.Seat) switch { case e.Kind == game.LogBuy: if c, ok := cardByID(m.PrevShopRow, e.Source); ok { // An Avocado buy is set aside, not kept in the deck (Golden // pack): don't add it to the deck model. if c.Food != game.FoodAvocado { - m.Opp.Known = append(m.Opp.Known, c) + opp.Known = append(opp.Known, c) } - } else if c, ok := templateByName(v.Pack, e.CardName); ok { + } else if c, ok := templateByName(v.Packs, e.CardName); ok { if c.Food != game.FoodAvocado { - m.Opp.Known = append(m.Opp.Known, c) + opp.Known = append(opp.Known, c) } } case e.Kind == game.LogSell: - m.removeOppCard(e.Source, e.CardName) - m.Opp.Known = append(m.Opp.Known, memApple(len(m.Opp.Known))) + opp.remove(e.Source, e.CardName) + opp.Known = append(opp.Known, memApple(len(opp.Known))) case e.Kind == game.LogTrade: for _, id := range e.Cards { - m.removeOppCard(id, "") + opp.remove(id, "") } case e.Kind == game.LogTradePick: - m.Opp.Hidden = append(m.Opp.Hidden, + opp.Hidden = append(opp.Hidden, HiddenCard{Tier: min(e.Round+1, game.MaxRounds), Name: e.CardName}) case e.Spawn == "apple" && e.Kind == "": n := max(e.Count, 1) for range n { - m.Opp.Known = append(m.Opp.Known, memApple(len(m.Opp.Known))) + opp.Known = append(opp.Known, memApple(len(opp.Known))) } } } - // Battle lineups are ground truth: rebuild the model from the opponent's - // revealed deck, minus temporary cards (they expire with the battle). - if v.Battle != nil && v.Battle.Round > m.LastBattleRound && oppSeat < len(v.Battle.Lineups) { - m.LastBattleRound = v.Battle.Round - m.Opp.Known = m.Opp.Known[:0] - m.Opp.Hidden = nil - for _, c := range v.Battle.Lineups[oppSeat] { - if !c.Temporary { - m.Opp.Known = append(m.Opp.Known, c) + // Battle lineups are ground truth: rebuild each opponent's model from their + // revealed deck, minus temporary cards (they expire with the battle). Every + // table's battle is public, so one round refreshes the whole field. + for _, b := range v.Battles { + if b == nil || b.Round <= m.LastBattleRound { + continue + } + for side, seat := range b.Seats { + if !isOpponent(seat) || side >= len(b.Lineups) { + continue + } + opp := m.opp(seat) + opp.Known = opp.Known[:0] + opp.Hidden = nil + for _, c := range b.Lineups[side] { + if !c.Temporary { + opp.Known = append(opp.Known, c) + } } } } + for _, b := range v.Battles { + if b != nil { + m.LastBattleRound = max(m.LastBattleRound, b.Round) + } + } - // Reconcile with the public deck size. Skipped during the battle phase, - // where the live deck still holds temporaries the model excludes. + // Reconcile with the public deck sizes. Skipped during the battle phase, + // where the live decks still hold temporaries the models exclude. if v.Phase == game.PhaseShop || v.Phase == game.PhaseArrange { - size := v.Players[slices.IndexFunc(v.Players, func(p game.PlayerView) bool { return p.Seat == oppSeat })].DeckSize - for len(m.Opp.Known)+len(m.Opp.Hidden) < size { - m.Opp.Hidden = append(m.Opp.Hidden, HiddenCard{Tier: v.Round}) - } - for len(m.Opp.Known)+len(m.Opp.Hidden) > size { - if len(m.Opp.Hidden) > 0 { - m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1] - } else { - m.Opp.Known = m.Opp.Known[:len(m.Opp.Known)-1] + for _, p := range v.Players { + if !isOpponent(p.Seat) { + continue } + m.opp(p.Seat).reconcile(p.DeckSize, v.Round) } } m.PrevShopRow = append(m.PrevShopRow[:0], v.ShopRow...) } -// removeOppCard drops one card from the model: by exact ID when we tracked -// it, by name as a fallback (model-minted apples have synthetic IDs), and -// failing both, one hidden card — something we didn't know they had left. -func (m *Memory) removeOppCard(id, name string) { - if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 { - m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1) +// remove drops one card from the model: by exact ID when we tracked it, by +// name as a fallback (model-minted apples have synthetic IDs), and failing +// both, one hidden card — something we didn't know they had, now gone. +func (o *OppModel) remove(id, name string) { + if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 { + o.Known = slices.Delete(o.Known, i, i+1) return } if name != "" { - if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 { - m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1) + if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 { + o.Known = slices.Delete(o.Known, i, i+1) return } } - if len(m.Opp.Hidden) > 0 { - m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1] + if len(o.Hidden) > 0 { + o.Hidden = o.Hidden[:len(o.Hidden)-1] + } +} + +// reconcile forces the model to hold exactly size cards, the count everyone can +// see, padding with unknowns of the current tier or dropping the excess. +func (o *OppModel) reconcile(size, round int) { + for len(o.Known)+len(o.Hidden) < size { + o.Hidden = append(o.Hidden, HiddenCard{Tier: round}) + } + for len(o.Known)+len(o.Hidden) > size { + if len(o.Hidden) > 0 { + o.Hidden = o.Hidden[:len(o.Hidden)-1] + } else { + o.Known = o.Known[:len(o.Known)-1] + } } } @@ -185,15 +239,15 @@ func cardByID(cards []game.Card, id string) (game.Card, bool) { return game.Card{}, false } -// templateByName mints a reference copy of a named card from the pack's -// printed tier contents. The suit is whatever the first printed copy has — -// callers only rely on stats and effects. -func templateByName(pack, name string) (game.Card, bool) { +// templateByName mints a reference copy of a named card from the printed tier +// contents of the packs in play. The suit is whatever the first printed copy +// has — callers only rely on stats and effects. +func templateByName(packs []string, name string) (game.Card, bool) { if name == "" { return game.Card{}, false } for tier := 1; tier <= game.MaxRounds; tier++ { - for _, c := range game.TierContentsForPack(pack, tier) { + for _, c := range game.TierContentsForPacks(packs, tier) { if c.Name == name { return c, true } diff --git a/internal/ai/predict.go b/internal/ai/predict.go index 2c84b5e..c5c1155 100644 --- a/internal/ai/predict.go +++ b/internal/ai/predict.go @@ -19,27 +19,35 @@ type ctx struct { } func newCtx(v *game.View, m *Memory) *ctx { - cx := &ctx{v: v, m: m, me: &v.Players[v.YouSeat], oppSeat: m.Opp.Seat, pools: map[int][]game.Card{}} + cx := &ctx{v: v, m: m, me: v.PlayerView(v.YouSeat), oppSeat: v.YourOpponent, pools: map[int][]game.Card{}} + // The round's pairing says exactly who the bot is preparing for, so it + // plans against that one rival even at a six-player table. Falling back to + // any other seat keeps a malformed view from wedging the bot. if cx.oppSeat == cx.me.Seat || cx.oppSeat < 0 { - // Memory hasn't observed yet (shouldn't happen in practice). for _, p := range v.Players { if p.Seat != v.YouSeat { cx.oppSeat = p.Seat + break } } } return cx } +// opp is the model of the opponent this round's battle is against. +func (cx *ctx) opp() *OppModel { return cx.m.Opp(cx.oppSeat) } + func (cx *ctx) nextSimID() string { cx.simID++ return fmt.Sprintf("sim-%d", cx.simID) } // unseenPool lists the printed cards of a tier that the bot cannot account -// for anywhere it can see — its own deck, the opponent model, the shop row. +// for anywhere it can see — its own deck, every opponent model, the shop row. // Hidden opponent cards are drawn from this pool, so the bot's guesses -// respect card counting without peeking at the real decks. +// respect card counting without peeking at the real decks. It counts against +// the whole table's known cards, and against the combined contents of every +// pack in play, which is what a human counting cards would be working from. func (cx *ctx) unseenPool(tier int) []game.Card { if pool, ok := cx.pools[tier]; ok { return pool @@ -53,14 +61,16 @@ func (cx *ctx) unseenPool(tier int) []game.Card { for _, c := range cx.me.Deck { note(c) } - for _, c := range cx.m.Opp.Known { - note(c) + for _, opp := range cx.m.Opps { + for _, c := range opp.Known { + note(c) + } } for _, c := range cx.v.ShopRow { note(c) } var pool []game.Card - for _, c := range game.TierContentsForPack(cx.v.Pack, tier) { + for _, c := range game.TierContentsForPacks(cx.v.Packs, tier) { if seen[c.Name] > 0 { seen[c.Name]-- continue @@ -75,10 +85,11 @@ func (cx *ctx) unseenPool(tier int) []game.Card { // known cards as-is, hidden cards drawn from the unseen pool of their tier // (or their named template, when a pick was later revealed). func (cx *ctx) sampleOppDeck() []game.Card { - deck := append([]game.Card(nil), cx.m.Opp.Known...) - for _, h := range cx.m.Opp.Hidden { + opp := cx.opp() + deck := append([]game.Card(nil), opp.Known...) + for _, h := range opp.Hidden { var c game.Card - if t, ok := templateByName(cx.v.Pack, h.Name); ok { + if t, ok := templateByName(cx.v.Packs, h.Name); ok { c = t } else if pool := cx.unseenPool(h.Tier); len(pool) > 0 { c = pool[rand.IntN(len(pool))] diff --git a/internal/ai/shop.go b/internal/ai/shop.go index 006b0e5..7e087ec 100644 --- a/internal/ai/shop.go +++ b/internal/ai/shop.go @@ -88,7 +88,7 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g // with a sampled card of the current tier. pool := cx.unseenPool(cx.v.Round) if len(pool) == 0 { - pool = game.TierContentsForPack(cx.v.Pack, cx.v.Round) + pool = game.TierContentsForPacks(cx.v.Packs, cx.v.Round) } if len(pool) > 0 { rc := pool[rand.IntN(len(pool))] @@ -126,7 +126,7 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g deck = slices.Delete(deck, worst, worst+1) pool := cx.unseenPool(nextTier) if len(pool) == 0 { - pool = game.TierContentsForPack(cx.v.Pack, nextTier) + pool = game.TierContentsForPacks(cx.v.Packs, nextTier) } if len(pool) > 0 { rc := pool[rand.IntN(len(pool))] @@ -370,7 +370,7 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action { } pool := cx.unseenPool(v.Round + 1) if len(pool) == 0 { - pool = game.TierContentsForPack(v.Pack, v.Round+1) + pool = game.TierContentsForPacks(v.Packs, v.Round+1) } var decks [][]game.Card for range 3 { @@ -409,4 +409,3 @@ func (b *Bot) decideTradeChoose(v *game.View, mem *Memory) *Action { b.score(cx, cands) return b.pick(cands).act } - diff --git a/internal/ai/winrate_test.go b/internal/ai/winrate_test.go index fdfccfd..589570d 100644 --- a/internal/ai/winrate_test.go +++ b/internal/ai/winrate_test.go @@ -15,8 +15,8 @@ func playHeadToHead(t *testing.T, pack string, level0, level1 float64) int { g := game.New() pa, _ := g.AddBot("Bot A", level0) pb, _ := g.AddBot("Bot B", level1) - if err := g.SetPack(pack); err != nil { - t.Fatalf("SetPack: %v", err) + if err := g.SetPacks([]string{pack}); err != nil { + t.Fatalf("SetPacks: %v", err) } if err := g.StartGame(); err != nil { t.Fatalf("StartGame: %v", err) diff --git a/internal/game/battle.go b/internal/game/battle.go index 3ba469a..808ce9a 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "slices" ) // BattleUnit is a pet in play with its attached foods applied. Power @@ -23,8 +24,8 @@ type BattleUnit struct { // Ailments (Unicorn pack) are debuffs on this pet: Spooked lowers the // damage it deals in a clash (min 0); Exposed raises the damage it takes on // each hit. bakuGuard, when set, discards the first Ailment it would gain. - Spooked int `json:"spooked,omitempty"` - Exposed int `json:"exposed,omitempty"` + Spooked int `json:"spooked,omitempty"` + Exposed int `json:"exposed,omitempty"` bakuGuard bool } @@ -179,29 +180,57 @@ type BattleEvent struct { Text string `json:"text,omitempty"` } -// BattleResult is the full, public record of one round's battle. +// BattleResult is the full, public record of one battle. +// +// A round runs one battle per pairing (see schedule.go), so a six-player round +// produces three of these. Everything inside a result is indexed by *side* — +// 0 or 1 within this battle — not by the player's seat at the table: Seats maps +// the two apart, and BattleEvent.Seat/Target are side indices too. WinnerSeat +// is the exception, and is a real seat, because it's the one field that means +// something outside the battle. type BattleResult struct { - Round int `json:"round"` - StackSizes []int `json:"stackSizes"` // starting deck size per seat - // Lineups is each seat's arranged deck at battle start (top of deck + Round int `json:"round"` + // Seats are the two players fighting, in first-player order: Seats[0] holds + // priority and acts first when two effects would land simultaneously. + Seats []int `json:"seats"` + StackSizes []int `json:"stackSizes"` // starting deck size per side + // Lineups is each side's arranged deck at battle start (top of deck // first). Public so players can review the whole matchup — including the // opponent's cards — during and after the fight. Lineups [][]Card `json:"lineups,omitempty"` Events []BattleEvent `json:"events"` - WinnerSeat int `json:"winnerSeat"` // -1 = draw + WinnerSeat int `json:"winnerSeat"` // a seat at the table; -1 = draw Trophies int `json:"trophies"` // awarded to the winner - // Survivors is each seat's remaining force at battle end: pets still in + // Survivors is each side's remaining force at battle end: pets still in // play plus any never reached in the stack. The loser is 0. It measures how // decisive the result was — the margin the AI uses to prefer a lineup that // fights harder, even in a battle it can't win. Survivors []int `json:"survivors,omitempty"` - // ManaAfter (Unicorn pack) is each seat's persistent Mana pool once the + // ManaAfter (Unicorn pack) is each side's persistent Mana pool once the // battle ends; finalizeBattle writes it back to the players. NextRoundApples - // is apples each seat banked for next round's hand (Skeleton Dog). + // is apples each side banked for next round's hand (Skeleton Dog). ManaAfter []int `json:"manaAfter,omitempty"` NextRoundApples []int `json:"nextRoundApples,omitempty"` } +// Side returns the battle-side index (0 or 1) for a seat at the table, or -1 +// if that player wasn't in this battle. Use it to read any of the per-side +// slices above from a seat. +func (r *BattleResult) Side(seat int) int { + return slices.Index(r.Seats, seat) +} + +// Has reports whether a seat fought in this battle. +func (r *BattleResult) Has(seat int) bool { return r.Side(seat) >= 0 } + +// SeatOf returns the seat holding a side of this battle, or -1. +func (r *BattleResult) SeatOf(side int) int { + if side < 0 || side >= len(r.Seats) { + return -1 + } + return r.Seats[side] +} + // setAsideRocks is a fainted pet's pending rock payout. type setAsideRocks struct { dice int @@ -240,11 +269,11 @@ type battleSide struct { shieldCards []Card // Turtle set-aside cards, parallel to shields // --- Golden pack --- - trumpets int // ephemeral Trumpet pool (earned/spent in battle) - faintedHats map[Suit]bool // distinct suits among friendly fainted pets (Honduran White Bat) - grSummoned bool // Golden Retriever already summoned this battle - hitPrevent []int // Cone Snail: pending one-shot partial damage preventions - preventCards []Card // Cone Snail set-aside cards, parallel to hitPrevent + trumpets int // ephemeral Trumpet pool (earned/spent in battle) + faintedHats map[Suit]bool // distinct suits among friendly fainted pets (Honduran White Bat) + grSummoned bool // Golden Retriever already summoned this battle + hitPrevent []int // Cone Snail: pending one-shot partial damage preventions + preventCards []Card // Cone Snail set-aside cards, parallel to hitPrevent beePlayRocks []setAsideRocks // Poison Dart Frog: rocks each time a Bee is played feedOnPlay []feedAside // Giant Isopod: feed apples on each pet played petsPlayed int // pets fielded so far (Komodo's "first pet") @@ -324,10 +353,10 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int return n } -// resolveBattle simulates the battle from the players' arranged decks, -// records the event log, awards trophies, and moves to PhaseBattle. +// resolveBattles fights every pairing of the current round, records the event +// logs, and awards trophies. // -// The battle is a stack machine: each side reveals cards off the top of +// Each battle is a stack machine: each side reveals cards off the top of // their deck until a pet is in play (foods along the way attach to it; only // the last-applied perk counts). If anyone can no longer field a pet the // battle ends. Otherwise play effects resolve (rocks, strips, steals, @@ -339,83 +368,119 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int // attack manages to hurt. A clash that changes nothing ends the battle as a // stalemate. // -// resolveBattle is the orchestrator: it runs the (deterministic) simulation and -// publishes the completed result. -func (g *Game) resolveBattle() { - res := g.runBattle() - g.Battle = res - g.finalizeBattle(res) -} - -// finalizeBattle applies the persistent effects of a completed battle: trophies, -// the priority token hand-off, the result log line, and clearing the per-round -// apples-in-play bank. Kept separate from runBattle, which mutates no persistent -// player state. -func (g *Game) finalizeBattle(res *BattleResult) { - n := len(g.Players) - winner := res.WinnerSeat - if winner >= 0 { - g.Players[winner].Trophies += res.Trophies - // Priority token: the winner hands it to the other player; a loser who - // held it keeps it; a draw leaves it put. (Two-player rule.) - if winner == g.PrioritySeat { - g.PrioritySeat = (winner + 1) % n - } - } - if winner < 0 { - g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult, - Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)}) - } else { - g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult, - Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", g.Players[winner].Name, g.Round, res.Trophies)}) +// resolveBattles is the orchestrator: it runs each (deterministic) simulation +// and publishes the completed results. +func (g *Game) resolveBattles() { + g.Battles = nil + for _, m := range g.Pairings() { + first, second := g.firstPlayer(m) + res := g.runBattle(first, second) + g.Battles = append(g.Battles, res) + g.finalizeBattle(res) } + // Per-round bookkeeping that isn't tied to one battle: the temporary + // resources every player banked for the fight are spent now, win or lose. for _, p := range g.Players { p.PendingApplesInPlay = 0 p.PendingTrumpets = 0 + } +} + +// firstPlayer decides which half of a pairing acts first — the side that wins +// simultaneity races during the battle. Two players settle it with the +// priority token they pass between them; a bigger table flips for it, as the +// rulebook's "determine the First Player for each battle by flipping a gold +// token" asks. +func (g *Game) firstPlayer(m Matchup) (first, second int) { + if len(g.Players) == 2 { + if m[1] == g.PrioritySeat { + return m[1], m[0] + } + return m[0], m[1] + } + if randInt(2) == 1 { + return m[1], m[0] + } + return m[0], m[1] +} + +// finalizeBattle applies the persistent effects of one completed battle: +// trophies, the round-win record, the priority token hand-off, and the result +// log line. Kept separate from runBattle, which mutates no persistent player +// state. +func (g *Game) finalizeBattle(res *BattleResult) { + winner := res.WinnerSeat + if winner >= 0 { + g.Players[winner].Trophies += res.Trophies + g.Players[winner].RoundWins = append(g.Players[winner].RoundWins, res.Round) + // Priority token (two-player rule): the winner hands it to the other + // player; a loser who held it keeps it; a draw leaves it put. At bigger + // tables the token instead walks the table each round (startShopRound). + if len(g.Players) == 2 && winner == g.PrioritySeat { + g.PrioritySeat = (winner + 1) % len(g.Players) + } + } + // The result line names the table it came from, since several resolve at once. + loser := res.SeatOf(0) + if loser == winner { + loser = res.SeatOf(1) + } + if winner < 0 { + g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult, + Text: fmt.Sprintf("%s vs %s ends in a draw.", g.seatName(res.SeatOf(0)), g.seatName(res.SeatOf(1)))}) + } else { + g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult, + Text: fmt.Sprintf("%s beats %s (+%d🏆).", g.seatName(winner), g.seatName(loser), res.Trophies)}) + } + for _, seat := range res.Seats { + p := g.Players[seat] + side := res.Side(seat) // Unicorn pack: persist the Mana pool as it stood at battle's end, and // bank any apples destined for next round's hand (Skeleton Dog). - if res.ManaAfter != nil && p.Seat < len(res.ManaAfter) { - p.Mana = res.ManaAfter[p.Seat] + if side < len(res.ManaAfter) { + p.Mana = res.ManaAfter[side] } - if res.NextRoundApples != nil && p.Seat < len(res.NextRoundApples) { - p.NextRoundApples += res.NextRoundApples[p.Seat] + if side < len(res.NextRoundApples) { + p.NextRoundApples += res.NextRoundApples[side] } } } -// runBattle plays the simulation to completion, returning the result. It -// mutates no persistent player state — that is finalizeBattle's job. -func (g *Game) runBattle() *BattleResult { - n := len(g.Players) - res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)} +// runBattle plays one pairing's simulation to completion, returning the +// result. It mutates no persistent player state — that is finalizeBattle's job. +// +// first and second are the seats fighting, first having priority. Everything +// below works in *side* indices — 0 is first, 1 is second — so the resolver +// only ever deals with two combatants no matter how big the table is; res.Seats +// maps back out. Read `seat` in this function as "side" throughout. +func (g *Game) runBattle(first, second int) *BattleResult { + const n = 2 // sides in a battle, not players at the table + seats := []int{first, second} + res := &BattleResult{Round: g.Round, WinnerSeat: -1, Seats: seats, + StackSizes: make([]int, n), Lineups: make([][]Card, n)} sides := make([]*battleSide, n) emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) } - // pname is the owning player's display name for a seat, for log text. - pname := func(seat int) string { return g.Players[seat].Name } + // pname is the owning player's display name for a side, for log text. + pname := func(side int) string { return g.Players[seats[side]].Name } - for _, p := range g.Players { + for side, seat := range seats { + p := g.Players[seat] s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}} // Unicorn pack: the persistent Mana pool comes into battle (read-only // here; written back by finalizeBattle so re-runs stay deterministic). s.mana = p.Mana - sides[p.Seat] = s - res.StackSizes[p.Seat] = len(p.Deck) - res.Lineups[p.Seat] = append([]Card(nil), p.Deck...) + sides[side] = s + res.StackSizes[side] = len(p.Deck) + res.Lineups[side] = append([]Card(nil), p.Deck...) } - // enemyOf returns the opposing side (two-player; generalizes later). + // enemyOf returns the opposing side. enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] } - // seatOrder resolves the priority-token holder first, then everyone else. - // Reveals, queued play effects, and cross-side triggers all follow it, so - // when two pets would act simultaneously (e.g. both throwing rocks) the - // holder acts first — its rocks can faint the enemy pet before that pet's - // own queued rocks resolve. - seatOrder := make([]int, 0, n) - seatOrder = append(seatOrder, g.PrioritySeat) - for seat := range sides { - if seat != g.PrioritySeat { - seatOrder = append(seatOrder, seat) - } - } + // seatOrder resolves the first player before the second. Reveals, queued + // play effects, and cross-side triggers all follow it, so when two pets + // would act simultaneously (e.g. both throwing rocks) the first player acts + // first — its rocks can faint the enemy pet before that pet's own queued + // rocks resolve. Side 0 is the first player by construction. + seatOrder := []int{0, 1} // startApple seeds one in-play apple onto a seat's first pet. startApple := func(seat int) { apple := g.newApple() @@ -425,27 +490,28 @@ func (g *Game) runBattle() *BattleResult { } // Battle-prep effects that start apples in play (Monkey): they attach // to the owner's first pet. - for _, p := range g.Players { + for side, seat := range seats { + p := g.Players[seat] for _, c := range p.Deck { for _, e := range c.Effects { if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay { for range e.count() { - startApple(p.Seat) + startApple(side) } } } } // Golden pack: apples-in-play banked by a sold Hercules Beetle this - // round (read-only here; finalizeBattle clears it once the battle ends, - // so re-runs bank the same amount). + // round (read-only here; resolveBattles clears it once the round's + // battles end, so re-runs bank the same amount). for range p.PendingApplesInPlay { - startApple(p.Seat) + startApple(side) } // Bird of Paradise: start the battle with Trumpets in the pool. if p.PendingTrumpets > 0 { - sides[p.Seat].trumpets += p.PendingTrumpets - emit(BattleEvent{Type: "trumpet", Seat: p.Seat, Count: p.PendingTrumpets, - Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(p.Seat), p.PendingTrumpets, plural(p.PendingTrumpets))}) + sides[side].trumpets += p.PendingTrumpets + emit(BattleEvent{Type: "trumpet", Seat: side, Count: p.PendingTrumpets, + Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(side), p.PendingTrumpets, plural(p.PendingTrumpets))}) } } @@ -731,160 +797,160 @@ func (g *Game) runBattle() *BattleResult { } } { - cause := fmt.Sprintf("%s's faint effect", u.Card.Name) - for _, e := range u.effects() { - if e.Trigger != TriggerFaint || !allowed(e, u) { - continue - } - if !spend(seat, e, u.Card.Name) { - continue - } - switch e.Action { - case ActionSummonTop: - target := seat - if e.Target == "enemy" { - target = (seat + 1) % n + cause := fmt.Sprintf("%s's faint effect", u.Card.Name) + for _, e := range u.effects() { + if e.Trigger != TriggerFaint || !allowed(e, u) { + continue } - for range effectCount(e, s, u, enemyOf(seat)) { - summon(target, mintFor(e.Card), cause) + if !spend(seat, e, u.Card.Name) { + continue } - case ActionSummonBottom: - for range effectCount(e, s, u, enemyOf(seat)) { - if e.Target == "all" { - for other := range sides { - summonBottom(other, mintFor(e.Card), cause) + switch e.Action { + case ActionSummonTop: + target := seat + if e.Target == "enemy" { + target = (seat + 1) % n + } + for range effectCount(e, s, u, enemyOf(seat)) { + summon(target, mintFor(e.Card), cause) + } + case ActionSummonBottom: + for range effectCount(e, s, u, enemyOf(seat)) { + if e.Target == "all" { + for other := range sides { + summonBottom(other, mintFor(e.Card), cause) + } + } else { + summonBottom(seat, mintFor(e.Card), cause) } - } else { - summonBottom(seat, mintFor(e.Card), cause) } - } - case ActionGainTrumpet: - gainTrumpets(seat, effectCount(e, s, u, enemyOf(seat)), cause) - case ActionDrainTrumpet: - es := enemyOf(seat) - lost := min(e.count(), es.trumpets) - if lost > 0 { - es.trumpets -= lost - emit(BattleEvent{Type: "trumpet", Seat: (seat + 1) % n, Count: -lost, - Text: fmt.Sprintf("%s drains %d Trumpet%s from the enemy.", cause, lost, plural(lost))}) - } - case ActionPreventNextHit: - s.hitPrevent = append(s.hitPrevent, e.count()) - s.preventCards = append(s.preventCards, u.Card) - setAside() - case ActionRecycleApples: - recycled := 0 - for _, f := range u.Foods { - if f.Food == FoodApple && recycled < e.count() { - summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name)) - recycled++ + case ActionGainTrumpet: + gainTrumpets(seat, effectCount(e, s, u, enemyOf(seat)), cause) + case ActionDrainTrumpet: + es := enemyOf(seat) + lost := min(e.count(), es.trumpets) + if lost > 0 { + es.trumpets -= lost + emit(BattleEvent{Type: "trumpet", Seat: (seat + 1) % n, Count: -lost, + Text: fmt.Sprintf("%s drains %d Trumpet%s from the enemy.", cause, lost, plural(lost))}) } - } - case ActionRecyclePerkApples: - // Macaque: recycle up to Count apples, then the active perk on - // top (so the perk reveals first and re-attaches to the next pet). - recycled := 0 - for _, f := range u.Foods { - if f.Food == FoodApple && recycled < e.count() { - summon(seat, f, cause) - recycled++ + case ActionPreventNextHit: + s.hitPrevent = append(s.hitPrevent, e.count()) + s.preventCards = append(s.preventCards, u.Card) + setAside() + case ActionRecycleApples: + recycled := 0 + for _, f := range u.Foods { + if f.Food == FoodApple && recycled < e.count() { + summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name)) + recycled++ + } } - } - if perk := u.activePerk(); perk != nil { - summon(seat, *perk, cause) - } - case ActionBeeRocks: - s.beePlayRocks = append(s.beePlayRocks, setAsideRocks{dice: e.count(), src: u.Card}) - setAside() - case ActionFeedOnPlay: - s.feedOnPlay = append(s.feedOnPlay, feedAside{apples: e.count(), src: u.Card}) - setAside() - case ActionGuardRetriever: - s.retrieverGuards = append(s.retrieverGuards, e.count()) - setAside() - case ActionDelayedRocks: - s.oneShotRocks = append(s.oneShotRocks, - setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card}) - setAside() - case ActionRecurringRocks: - s.recurringRocks = append(s.recurringRocks, - setAsideRocks{dice: e.count(), src: u.Card}) - setAside() - case ActionEnemyLastPetRocks: - s.lastPetRocks = append(s.lastPetRocks, lastPetVolley{dice: e.count(), src: u.Card}) - setAside() - case ActionShieldNext: - s.shields += e.count() - s.shieldCards = append(s.shieldCards, u.Card) - setAside() - case ActionBeeAura: - s.beeBonus += e.count() - setAside() - case ActionPetAura: - s.petBonus += e.count() - setAside() - case ActionGainMana: - gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause) - case ActionAddAilment: - addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause) - case ActionNextRoundApple: - // Banked for next round's hand; surfaced then in the shop log - // rather than as a battle-board change now. - s.nextRoundApples += e.count() - case ActionNegateEnemyFaint: - s.negators = append(s.negators, u.Card) - setAside() - case ActionReviveSelf: - // Slime: put a plain copy back on top of the deck — no faint - // ability, so it can't loop. "Once per round" falls out of that. - revived := u.Card - revived.ID = g.newCardID() - revived.Effects = nil - revived.EffectText = "" - summon(seat, revived, cause) - case ActionAilmentToApples: - s.unicornGuards = append(s.unicornGuards, u.Card) - setAside() - case ActionSmallPetAura: - s.smallPetBonus += e.count() - setAside() - case ActionAilmentBoost: - s.ailmentBoost += e.count() - setAside() - case ActionManaFeedOnPlay: - s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card}) - setAside() - case ActionReviveNextFaint: - s.fairyGuards = append(s.fairyGuards, u.Card) - setAside() - case ActionSummonFromDiscard: - // Chimera: add Count random cards from the FromTier discard pile as - // temporary copies on top of the deck. - pile := g.Discards[e.FromTier] - for range effectCount(e, s, u, enemyOf(seat)) { - if len(pile) == 0 { - break + case ActionRecyclePerkApples: + // Macaque: recycle up to Count apples, then the active perk on + // top (so the perk reveals first and re-attaches to the next pet). + recycled := 0 + for _, f := range u.Foods { + if f.Food == FoodApple && recycled < e.count() { + summon(seat, f, cause) + recycled++ + } } - pick := pile[g.battleDraw(len(pile))] - copyC := pick - copyC.ID = g.newCardID() - copyC.Temporary = true - summon(seat, copyC, cause) - } - case ActionSummonFromTierDeck: - // Pixiu: a temporary copy of the top of the FromTier shop deck. - if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) { - deck := g.ShopDecks[e.FromTier-1] - if len(deck) > 0 { - copyC := deck[0] + if perk := u.activePerk(); perk != nil { + summon(seat, *perk, cause) + } + case ActionBeeRocks: + s.beePlayRocks = append(s.beePlayRocks, setAsideRocks{dice: e.count(), src: u.Card}) + setAside() + case ActionFeedOnPlay: + s.feedOnPlay = append(s.feedOnPlay, feedAside{apples: e.count(), src: u.Card}) + setAside() + case ActionGuardRetriever: + s.retrieverGuards = append(s.retrieverGuards, e.count()) + setAside() + case ActionDelayedRocks: + s.oneShotRocks = append(s.oneShotRocks, + setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card}) + setAside() + case ActionRecurringRocks: + s.recurringRocks = append(s.recurringRocks, + setAsideRocks{dice: e.count(), src: u.Card}) + setAside() + case ActionEnemyLastPetRocks: + s.lastPetRocks = append(s.lastPetRocks, lastPetVolley{dice: e.count(), src: u.Card}) + setAside() + case ActionShieldNext: + s.shields += e.count() + s.shieldCards = append(s.shieldCards, u.Card) + setAside() + case ActionBeeAura: + s.beeBonus += e.count() + setAside() + case ActionPetAura: + s.petBonus += e.count() + setAside() + case ActionGainMana: + gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause) + case ActionAddAilment: + addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause) + case ActionNextRoundApple: + // Banked for next round's hand; surfaced then in the shop log + // rather than as a battle-board change now. + s.nextRoundApples += e.count() + case ActionNegateEnemyFaint: + s.negators = append(s.negators, u.Card) + setAside() + case ActionReviveSelf: + // Slime: put a plain copy back on top of the deck — no faint + // ability, so it can't loop. "Once per round" falls out of that. + revived := u.Card + revived.ID = g.newCardID() + revived.Effects = nil + revived.EffectText = "" + summon(seat, revived, cause) + case ActionAilmentToApples: + s.unicornGuards = append(s.unicornGuards, u.Card) + setAside() + case ActionSmallPetAura: + s.smallPetBonus += e.count() + setAside() + case ActionAilmentBoost: + s.ailmentBoost += e.count() + setAside() + case ActionManaFeedOnPlay: + s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card}) + setAside() + case ActionReviveNextFaint: + s.fairyGuards = append(s.fairyGuards, u.Card) + setAside() + case ActionSummonFromDiscard: + // Chimera: add Count random cards from the FromTier discard pile as + // temporary copies on top of the deck. + pile := g.Discards[e.FromTier] + for range effectCount(e, s, u, enemyOf(seat)) { + if len(pile) == 0 { + break + } + pick := pile[g.battleDraw(len(pile))] + copyC := pick copyC.ID = g.newCardID() copyC.Temporary = true summon(seat, copyC, cause) } + case ActionSummonFromTierDeck: + // Pixiu: a temporary copy of the top of the FromTier shop deck. + if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) { + deck := g.ShopDecks[e.FromTier-1] + if len(deck) > 0 { + copyC := deck[0] + copyC.ID = g.newCardID() + copyC.Temporary = true + summon(seat, copyC, cause) + } + } } } } - } enemyReactions: // Unicorn pack: a pre-existing Fairy recycles the fallen pet to the deck // bottom (a fresh copy, so it re-enters later and can faint again). @@ -1686,8 +1752,7 @@ func (g *Game) runBattle() *BattleResult { continue // refill before any clash } - // Clash. Two-player for now; >2-player battle pairings come later - // (the surrounding state is already per-seat). + // Clash: the two sides' pets trade blows. A is the first player's side. ua, ub := sides[0].unit, sides[1].unit // Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied // to the defender inside hitUnit); a Manticore boosts enemy ailments. @@ -1768,23 +1833,26 @@ func (g *Game) runBattle() *BattleResult { } } - // A single side that can still field a pet wins; anything else (everyone - // out, or a stalemate with pets on both sides) is a draw. We test canField, + // A single side that can still field a pet wins; anything else (both out, + // or a stalemate with pets on both sides) is a draw. We test canField, // not unit, because the loop can break the instant one side runs out while // the other's current pet has just fainted — that side still has pets left // in its stack (it simply wasn't refilled) and is the rightful winner. winner := -1 - for seat, s := range sides { + for side, s := range sides { if s.canField() { if winner >= 0 { - winner = -1 // stalemate / >2-player safety + winner = -1 // both still standing: a stalemate draw break } - winner = seat + winner = side } } - res.WinnerSeat = winner + // The winner leaves this function as a seat at the table, the one piece of + // the result that means anything outside the battle. if winner >= 0 { + res.WinnerSeat = seats[winner] + // The last round is worth double. res.Trophies = 1 if g.Round == MaxRounds { res.Trophies = 2 diff --git a/internal/game/battle_test.go b/internal/game/battle_test.go index f38db91..413a3a5 100644 --- a/internal/game/battle_test.go +++ b/internal/game/battle_test.go @@ -96,7 +96,7 @@ func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult { if g.Phase != PhaseBattle { t.Fatalf("expected battle phase, got %s", g.Phase) } - return g.Battle + return g.Battles[0] } func eventsOfType(res *BattleResult, typ string) []BattleEvent { @@ -712,8 +712,8 @@ func TestPriorityTokenTransfer(t *testing.T) { g, _, _ := testGame(t) g.PrioritySeat = 0 forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)}) - if g.Battle.WinnerSeat != 0 { - t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat) + if g.Battles[0].WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", g.Battles[0].WinnerSeat) } if g.PrioritySeat != 1 { t.Fatalf("winner should hand the token to the loser, priority=%d", g.PrioritySeat) @@ -723,8 +723,8 @@ func TestPriorityTokenTransfer(t *testing.T) { g, _, _ = testGame(t) g.PrioritySeat = 1 forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)}) - if g.Battle.WinnerSeat != 0 { - t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat) + if g.Battles[0].WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", g.Battles[0].WinnerSeat) } if g.PrioritySeat != 1 { t.Fatalf("a losing token holder should keep it, priority=%d", g.PrioritySeat) @@ -734,8 +734,8 @@ func TestPriorityTokenTransfer(t *testing.T) { g, _, _ = testGame(t) g.PrioritySeat = 0 forceBattle(t, g, []Card{g.pet("A", 3)}, []Card{g.pet("B", 3)}) - if g.Battle.WinnerSeat != -1 { - t.Fatalf("mutual KO should draw, got %d", g.Battle.WinnerSeat) + if g.Battles[0].WinnerSeat != -1 { + t.Fatalf("mutual KO should draw, got %d", g.Battles[0].WinnerSeat) } if g.PrioritySeat != 0 { t.Fatalf("a draw should leave the token put, priority=%d", g.PrioritySeat) diff --git a/internal/game/cards.go b/internal/game/cards.go index 6918181..4ffa191 100644 --- a/internal/game/cards.go +++ b/internal/game/cards.go @@ -1291,98 +1291,119 @@ func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTempla } } -// buildShopDecks creates all six tier decks (unshuffled) for the game's pack. +// buildShopDecks creates all six tier decks (unshuffled) from the game's +// selected packs. Combining packs is the rulebook's answer to seating more +// than two players: every pack's tier 1 cards shuffle together into one tier 1 +// deck, its tier 2 cards into one tier 2 deck, and so on. A game on a single +// pack is just the one-element case. func (g *Game) buildShopDecks() { - pets, foods := packTiers(g.Pack) g.ShopDecks = make([][]Card, MaxRounds) - for tierIdx := range pets { - var deck []Card - for _, t := range pets[tierIdx] { - for _, suit := range t.Suits { - deck = append(deck, Card{ - ID: g.newCardID(), - Kind: KindPet, - Name: t.Name, - Tier: tierIdx + 1, - Power: t.Power, - Suit: suit, - Effects: t.Effects, - EffectText: t.EffectText, - }) + for _, pack := range g.packList() { + pets, foods := packTiers(pack) + for tierIdx := range pets { + deck := g.ShopDecks[tierIdx] + for _, t := range pets[tierIdx] { + for _, suit := range t.Suits { + deck = append(deck, Card{ + ID: g.newCardID(), + Kind: KindPet, + Name: t.Name, + Tier: tierIdx + 1, + Power: t.Power, + Suit: suit, + Effects: t.Effects, + EffectText: t.EffectText, + }) + } } - } - for _, f := range foods[tierIdx] { - for range f.Copies { - deck = append(deck, Card{ - ID: g.newCardID(), - Kind: KindFood, - Name: f.Name, - Tier: tierIdx + 1, - Food: f.Food, - Perk: f.Perk, - Effects: f.Effects, - EffectText: f.EffectText, - }) + for _, f := range foods[tierIdx] { + for range f.Copies { + deck = append(deck, Card{ + ID: g.newCardID(), + Kind: KindFood, + Name: f.Name, + Tier: tierIdx + 1, + Food: f.Food, + Perk: f.Perk, + Effects: f.Effects, + EffectText: f.EffectText, + }) + } } + g.ShopDecks[tierIdx] = deck } - g.ShopDecks[tierIdx] = deck } } -// Catalog returns the default pack's representative cards. -func Catalog() []Card { return CatalogForPack(DefaultPack) } +// packList is the game's pack selection, defaulting to the base pack so a +// zero-value Game (scratch simulations, tests) still builds real decks. +func (g *Game) packList() []string { + if len(g.Packs) == 0 { + return []string{DefaultPack} + } + return g.Packs +} -// CatalogForPack returns one representative card for every pet and food in a -// pack, tier by tier, for the debug "buy any card" panel. IDs are name-based -// placeholders (not real instances); pets use their first printed suit. -func CatalogForPack(pack string) []Card { - pets, foods := packTiers(pack) +// Catalog returns the default pack's representative cards. +func Catalog() []Card { return CatalogForPacks([]string{DefaultPack}) } + +// CatalogForPacks returns one representative card for every pet and food in +// the given packs, tier by tier, for the debug "buy any card" panel. IDs are +// name-based placeholders (not real instances); pets use their first printed +// suit. Cards are grouped by tier across all packs, matching how the shop +// decks combine. +func CatalogForPacks(packs []string) []Card { var cards []Card - for tierIdx := range pets { - for _, t := range pets[tierIdx] { - suit := SuitRed - if len(t.Suits) > 0 { - suit = t.Suits[0] + for tierIdx := range MaxRounds { + for _, pack := range packs { + pets, foods := packTiers(pack) + for _, t := range pets[tierIdx] { + suit := SuitRed + if len(t.Suits) > 0 { + suit = t.Suits[0] + } + cards = append(cards, Card{ + ID: "pet-" + t.Name, Kind: KindPet, Name: t.Name, Tier: tierIdx + 1, + Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText, + }) + } + for _, f := range foods[tierIdx] { + cards = append(cards, Card{ + ID: "food-" + f.Name, Kind: KindFood, Name: f.Name, Tier: tierIdx + 1, + Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText, + }) } - cards = append(cards, Card{ - ID: "pet-" + t.Name, Kind: KindPet, Name: t.Name, Tier: tierIdx + 1, - Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText, - }) - } - for _, f := range foods[tierIdx] { - cards = append(cards, Card{ - ID: "food-" + f.Name, Kind: KindFood, Name: f.Name, Tier: tierIdx + 1, - Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText, - }) } } return cards } -// cardByName mints a fresh instance of the named pet or food from the current -// pack's templates (pets take their first printed suit). Returns false if -// unknown. +// cardByName mints a fresh instance of the named pet or food from the +// templates of any pack in play (pets take their first printed suit). Returns +// false if unknown. func (g *Game) cardByName(name string) (Card, bool) { - pets, foods := packTiers(g.Pack) - for tierIdx := range pets { - for _, t := range pets[tierIdx] { - if t.Name == name { - suit := SuitRed - if len(t.Suits) > 0 { - suit = t.Suits[0] + for _, pack := range g.packList() { + pets, foods := packTiers(pack) + for tierIdx := range pets { + for _, t := range pets[tierIdx] { + if t.Name == name { + suit := SuitRed + if len(t.Suits) > 0 { + suit = t.Suits[0] + } + return Card{ + ID: g.newCardID(), Kind: KindPet, Name: t.Name, Tier: tierIdx + 1, + Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText, + }, true } - return Card{ - ID: g.newCardID(), Kind: KindPet, Name: t.Name, Tier: tierIdx + 1, - Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText, - }, true } - } - for _, f := range foods[tierIdx] { - if f.Name == name { - return Card{ - ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1, - Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText, - }, true + for _, f := range foods[tierIdx] { + if f.Name == name { + return Card{ + ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1, + Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText, + }, true + } } } } diff --git a/internal/game/game.go b/internal/game/game.go index 3604a91..2005516 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -1,6 +1,7 @@ package game import ( + "cmp" "crypto/rand" "encoding/hex" "encoding/json" @@ -11,8 +12,8 @@ import ( "strings" ) -// Tunable rules. The engine supports any player count >= 2; MinPlayers / -// MaxPlayers gate when a lobby can start (2 for now, more later). +// Tunable rules. A game seats an even number of players (2, 4, or 6) so +// everyone has an opponent in every round's pairings; see schedule.go. const ( MaxRounds = 6 CoinsPerRound = 3 @@ -20,7 +21,7 @@ const ( MaxPets = 5 TradeInCount = 3 MinPlayers = 2 - MaxPlayers = 2 + MaxPlayers = 6 ) // Phase is the game's top-level state. @@ -37,15 +38,18 @@ const ( // Player holds everything about one seat. All fields are exported so a Game // serializes to JSON for persistence. type Player struct { - ID string `json:"id"` - Token string `json:"token"` // secret; never sent in views - Name string `json:"name"` - Seat int `json:"seat"` - Coins int `json:"coins"` - Deck []Card `json:"deck"` - Trophies int `json:"trophies"` - Ready bool `json:"ready"` // shop passed / arrange submitted / battle acknowledged - Connected bool `json:"connected"` + ID string `json:"id"` + Token string `json:"token"` // secret; never sent in views + Name string `json:"name"` + Seat int `json:"seat"` + Coins int `json:"coins"` + Deck []Card `json:"deck"` + Trophies int `json:"trophies"` + // RoundWins lists the rounds whose battle this player won, in order. The + // end-of-game tie-break counts back through it from the final round. + RoundWins []int `json:"roundWins,omitempty"` + Ready bool `json:"ready"` // shop passed / arrange submitted / battle acknowledged + Connected bool `json:"connected"` // TripledThisRound records whether the player used the Triple (trade-in) // action during the current round's shop (Bison's Battle Prep). TripledThisRound bool `json:"tripledThisRound"` @@ -77,7 +81,7 @@ type Player struct { // ShopPeekedRound (Unicorn pack: Bigfoot) is the round the player last used // Bigfoot's reveal (once per round); ShopPeek is the card they saw — a // snapshot of the shop deck's top, shown only in that player's own view. - ShopPeekedRound int `json:"shopPeekedRound,omitempty"` + ShopPeekedRound int `json:"shopPeekedRound,omitempty"` ShopPeek *Card `json:"shopPeek,omitempty"` // IsBot marks a computer-controlled seat. The engine treats bots exactly // like humans; the server drives their actions. BotLevel is the bot's @@ -139,9 +143,10 @@ type PendingSacrifice struct { type Game struct { ID string `json:"id"` Code string `json:"code"` - // Pack is the selected card pack (see packs.go). Chosen in the lobby by - // the host; determines which cards fill the shop decks. - Pack string `json:"pack"` + // Packs are the selected card packs (see packs.go). Chosen in the lobby by + // the host; their tier decks shuffle together to fill the shop. Seating + // more than two players requires more than one pack (see PacksNeeded). + Packs []string `json:"packs"` Phase Phase `json:"phase"` Round int `json:"round"` // 1-based Players []*Player `json:"players"` @@ -151,11 +156,13 @@ type Game struct { // decks and later left a player's deck (sold, traded, sacrificed), keyed by // tier. Chimera and Abomination draw from it. Temporary cards never enter. Discards map[int][]Card `json:"discards,omitempty"` - Turn int `json:"turn"` // seat with the current shop turn - // PrioritySeat holds the priority token: that seat shops first each round - // and wins simultaneity races in battle. Assigned randomly at game start; - // a battle winner hands it to the loser, a loser keeps it, a draw leaves - // it put. + Turn int `json:"turn"` // seat with the current shop turn + // PrioritySeat holds the first-shopper token: that seat shops first this + // round. How it moves depends on the table size. With two players it is + // also the battle's priority token — assigned randomly at game start, then + // handed by a winner to the loser (a loser who holds it keeps it, a draw + // leaves it put). With more players it starts at seat A and passes one seat + // along every round, and each battle flips separately for its first player. PrioritySeat int `json:"prioritySeat"` Pending *PendingTrade `json:"pending,omitempty"` // PendingReveal is an in-progress Cockatoo reveal (Golden pack); it blocks @@ -164,9 +171,15 @@ type Game struct { // PendingSacrifice is an in-progress Water of Youth choice (Unicorn pack); // it blocks other shop actions on that seat until resolved, like Pending. PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"` - Battle *BattleResult `json:"battle,omitempty"` // most recent battle - NextCardID int `json:"nextCardId"` - WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie + // Battles holds the most recent round's battles — one per pairing (see + // schedule.go), so two players produce one and six produce three. They are + // all public: everyone can replay every table. + Battles []*BattleResult `json:"battles,omitempty"` + NextCardID int `json:"nextCardId"` + // WinnerSeat is the outright winner at gameover, or -1 when the title is + // shared. WinnerSeats always lists every player holding it (see finish). + WinnerSeat int `json:"winnerSeat"` + WinnerSeats []int `json:"winnerSeats,omitempty"` // Log is the running, human-readable event log shown across every phase. Log []LogEntry `json:"log,omitempty"` LogSeq int `json:"logSeq"` // last assigned entry sequence number @@ -252,7 +265,7 @@ func New() *Game { g := &Game{ ID: randomID(16), Code: randomCode(), - Pack: DefaultPack, + Packs: []string{DefaultPack}, Phase: PhaseLobby, WinnerSeat: -1, } @@ -260,6 +273,33 @@ func New() *Game { return g } +// gameJSON aliases Game so UnmarshalJSON can decode into it without recursing. +type gameJSON Game + +// UnmarshalJSON decodes a persisted game, migrating states written before the +// game supported more than one pack (a single "pack" string) and before a +// round could hold more than one battle (a single "battle" object). +func (g *Game) UnmarshalJSON(data []byte) error { + aux := struct { + *gameJSON + LegacyPack string `json:"pack"` + LegacyBattle *BattleResult `json:"battle"` + }{gameJSON: (*gameJSON)(g)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + if len(g.Packs) == 0 { + g.Packs = []string{cmp.Or(aux.LegacyPack, DefaultPack)} + } + if len(g.Battles) == 0 && aux.LegacyBattle != nil { + if len(aux.LegacyBattle.Seats) == 0 { + aux.LegacyBattle.Seats = []int{0, 1} // pre-pairing battles were always A vs B + } + g.Battles = []*BattleResult{aux.LegacyBattle} + } + return nil +} + // buildDecks (re)creates and shuffles the shop decks for the current pack. // Called on creation and whenever the pack changes, so ShopDecks always match // g.Pack and are ready the moment the game starts. @@ -306,20 +346,19 @@ func (g *Game) AddBot(name string, level float64) (*Player, error) { return p, nil } -// SetPack changes the game's card pack during the lobby and rebuilds the shop -// decks to match. Only playable packs may be selected. -func (g *Game) SetPack(packID string) error { +// SetPacks changes the game's card packs during the lobby and rebuilds the +// shop decks to match. Only distinct, playable packs may be selected; how many +// are *required* depends on the final player count and is checked at start +// (see StartGame), so the host can pick packs and seats in either order. +func (g *Game) SetPacks(packIDs []string) error { if g.Phase != PhaseLobby { return fmt.Errorf("%w: game already started", ErrWrongPhase) } - pack, ok := packByID(packID) - if !ok { - return fmt.Errorf("%w: unknown pack", ErrInvalidAction) + packs, err := validatePacks(packIDs) + if err != nil { + return err } - if !pack.Playable { - return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction) - } - g.Pack = pack.ID + g.Packs = packs g.buildDecks() return nil } @@ -344,23 +383,46 @@ func (g *Game) RemovePlayer(targetID string) error { return nil } -// StartGame begins the match from the lobby once enough players are seated. -// The shop decks are already built for g.Pack (see buildDecks); this just +// StartGame begins the match from the lobby once the seats and packs line up. +// The shop decks are already built for g.Packs (see buildDecks); this just // validates and makes the transition. func (g *Game) StartGame() error { if g.Phase != PhaseLobby { return fmt.Errorf("%w: game already started", ErrWrongPhase) } - if len(g.Players) < MinPlayers { + n := len(g.Players) + if n < MinPlayers { return fmt.Errorf("%w: need at least %d players to start", ErrInvalidAction, MinPlayers) } - if pack, ok := packByID(g.Pack); !ok || !pack.Playable { - return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction) + // Every round pairs players off, so the table has to be even. A lobby with + // an odd number of people fills the empty seat with a bot. + if !ValidPlayerCount(n) { + return fmt.Errorf("%w: %d players can't pair off — play with %s (add or remove a seat)", + ErrInvalidAction, n, joinCounts(PlayerCounts)) + } + if _, err := validatePacks(g.Packs); err != nil { + return err + } + if need := PacksNeeded(n); len(g.Packs) < need { + return fmt.Errorf("%w: %d players needs at least %d packs shuffled together (%d selected)", + ErrInvalidAction, n, need, len(g.Packs)) } g.start() return nil } +// joinCounts renders the legal player counts as "2, 4, or 6". +func joinCounts(counts []int) string { + parts := make([]string, len(counts)) + for i, c := range counts { + parts[i] = fmt.Sprint(c) + } + if len(parts) < 2 { + return strings.Join(parts, "") + } + return strings.Join(parts[:len(parts)-1], ", ") + ", or " + parts[len(parts)-1] +} + // PlayerByID returns the player, or nil. func (g *Game) PlayerByID(id string) *Player { for _, p := range g.Players { @@ -373,8 +435,16 @@ func (g *Game) PlayerByID(id string) *Player { func (g *Game) start() { g.Round = 1 - // The priority token starts with a random seat. - g.PrioritySeat = randInt(len(g.Players)) + // Two players share one token for both jobs, and it starts on a random + // seat. With more players the first-shopper token is a separate thing that + // simply starts at seat A and walks the table (see startShopRound), while + // each battle flips for its own first player. + if len(g.Players) == 2 { + g.PrioritySeat = randInt(len(g.Players)) + } else { + g.PrioritySeat = 0 + } + g.logf(-1, "🎴", "Game on — %d players, %s.", len(g.Players), PackNames(g.Packs)) g.startShopRound() } @@ -385,6 +455,12 @@ func (g *Game) startShopRound() { g.Pending = nil g.PendingReveal = nil g.PendingSacrifice = nil + // With more than two players the first-shopper token starts on seat A and + // passes one seat along at the end of every round. (At two players it + // instead follows the battle results — see finalizeBattle.) + if len(g.Players) > 2 { + g.PrioritySeat = (g.Round - 1) % len(g.Players) + } for _, p := range g.Players { p.Coins = CoinsPerRound p.Ready = false @@ -1103,13 +1179,32 @@ func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error { return nil } -// startBattle enters the battle phase and resolves it. +// startBattle enters the battle phase and resolves every pairing in it. func (g *Game) startBattle() { for _, p := range g.Players { p.Ready = false } g.Phase = PhaseBattle - g.resolveBattle() + g.resolveBattles() +} + +// seatName is a seat's display name, for log text. +func (g *Game) seatName(seat int) string { + if seat < 0 || seat >= len(g.Players) { + return "nobody" + } + return g.Players[seat].Name +} + +// BattleFor returns the battle the given seat fought in the current round, or +// nil if there isn't one. +func (g *Game) BattleFor(seat int) *BattleResult { + for _, b := range g.Battles { + if b.Has(seat) { + return b + } + } + return nil } // AcknowledgeBattle marks the player done reviewing the battle. When all @@ -1139,21 +1234,55 @@ func (g *Game) AcknowledgeBattle(playerID string) error { return nil } +// finish ends the game and decides the title. Most trophies wins. Ties are +// broken by counting back through the rounds as the rulebook asks: "if only +// one of the tied players won round 6, they are the winner. If still tied, +// look to round 5, etc." Players still level after every round has been +// considered had identical records and share the victory. func (g *Game) finish() { g.Phase = PhaseGameOver - best, bestSeat, tie := -1, -1, false + best := -1 for _, p := range g.Players { - switch { - case p.Trophies > best: - best, bestSeat, tie = p.Trophies, p.Seat, false - case p.Trophies == best: - tie = true + best = max(best, p.Trophies) + } + var tied []*Player + for _, p := range g.Players { + if p.Trophies == best { + tied = append(tied, p) } } - if tie { - g.WinnerSeat = -1 - } else { - g.WinnerSeat = bestSeat + for round := MaxRounds; round > 0 && len(tied) > 1; round-- { + var won []*Player + for _, p := range tied { + if slices.Contains(p.RoundWins, round) { + won = append(won, p) + } + } + // A round only separates them if it split the field: if every remaining + // contender won it (or none did), it says nothing and we count back further. + if len(won) > 0 && len(won) < len(tied) { + tied = won + } + } + g.WinnerSeats = make([]int, len(tied)) + for i, p := range tied { + g.WinnerSeats[i] = p.Seat + } + slices.Sort(g.WinnerSeats) + // WinnerSeat names an outright winner only; a shared title reads as -1. + g.WinnerSeat = -1 + if len(g.WinnerSeats) == 1 { + g.WinnerSeat = g.WinnerSeats[0] + } + switch len(tied) { + case 1: + g.logf(tied[0].Seat, "👑", "%s wins the game with %d🏆!", tied[0].Name, best) + default: + names := make([]string, len(tied)) + for i, p := range tied { + names[i] = p.Name + } + g.logf(-1, "🤝", "%s share the victory with %d🏆 each.", strings.Join(names, " and "), best) } } diff --git a/internal/game/game_test.go b/internal/game/game_test.go index b356a1b..b3fde34 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -33,8 +33,16 @@ func TestLobbyManualStart(t *testing.T) { if _, err := g.AddPlayer("Bob"); err != nil { t.Fatal(err) } - if _, err := g.AddPlayer("Carol"); err == nil { - t.Fatal("third player should be rejected while MaxPlayers=2") + // An odd table can't pair off, so a third player has to be matched by a + // fourth (or removed) before the host can start. + if _, err := g.AddPlayer("Carol"); err != nil { + t.Fatal(err) + } + if err := g.StartGame(); err == nil { + t.Fatal("start should be rejected with an odd number of players") + } + if err := g.RemovePlayer(g.Players[2].ID); err != nil { + t.Fatal(err) } // The lobby stays open until the host explicitly starts. if g.Phase != PhaseLobby { @@ -503,11 +511,11 @@ func TestFullGameFlow(t *testing.T) { if g.Phase != PhaseBattle { t.Fatalf("expected battle after both arrange, got %s", g.Phase) } - if g.Battle.WinnerSeat != p1.Seat { + if g.Battles[0].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 { + if wantPriority == g.Battles[0].WinnerSeat { wantPriority = (wantPriority + 1) % len(g.Players) } for _, p := range g.Players { diff --git a/internal/game/golden_test.go b/internal/game/golden_test.go index 894a1f2..a3934f8 100644 --- a/internal/game/golden_test.go +++ b/internal/game/golden_test.go @@ -10,7 +10,7 @@ import "testing" func goldenGame(t *testing.T) (*Game, *Player, *Player) { t.Helper() g := New() - g.Pack = "golden" + g.Packs = []string{"golden"} g.buildDecks() p1, err := g.AddPlayer("Alice") if err != nil { diff --git a/internal/game/multiplayer_test.go b/internal/game/multiplayer_test.go new file mode 100644 index 0000000..4011393 --- /dev/null +++ b/internal/game/multiplayer_test.go @@ -0,0 +1,415 @@ +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)) + } +} diff --git a/internal/game/packs.go b/internal/game/packs.go index 98f407e..eb88b51 100644 --- a/internal/game/packs.go +++ b/internal/game/packs.go @@ -1,5 +1,10 @@ package game +import ( + "fmt" + "strings" +) + // Card packs are the selectable sets of pets and food a game is played with. // Turtle, Golden, and Unicorn all ship with full six-tier card data. @@ -31,3 +36,73 @@ func packByID(id string) (PackInfo, bool) { } return PackInfo{}, false } + +// PacksNeeded is how many packs a game must combine to seat n players: the +// rulebook asks for at least 2 packs at 4 players and 3 at 6, i.e. one per +// pair. More packs than the minimum are always allowed — a deeper shop just +// means fewer repeated pets. +func PacksNeeded(players int) int { + if players < MinPlayers { + return 1 + } + return players / 2 +} + +// sortPacks puts a pack selection into catalog order, so the same choice +// always reads the same way in views and logs. +func sortPacks(ids []string) []string { + out := make([]string, 0, len(ids)) + for _, p := range Packs { + for _, id := range ids { + if id == p.ID { + out = append(out, p.ID) + break + } + } + } + return out +} + +// validatePacks checks a pack selection: non-empty, known, playable, and free +// of duplicates. It returns the selection in catalog order. +func validatePacks(ids []string) ([]string, error) { + if len(ids) == 0 { + return nil, fmt.Errorf("%w: pick at least one pack", ErrInvalidAction) + } + seen := map[string]bool{} + for _, id := range ids { + pack, ok := packByID(id) + if !ok { + return nil, fmt.Errorf("%w: unknown pack %q", ErrInvalidAction, id) + } + if !pack.Playable { + return nil, fmt.Errorf("%w: the %s isn't available yet", ErrInvalidAction, pack.Name) + } + if seen[id] { + return nil, fmt.Errorf("%w: %s is selected twice", ErrInvalidAction, pack.Name) + } + seen[id] = true + } + return sortPacks(ids), nil +} + +// PackNames renders a pack selection as a readable list ("Turtle Pack and +// Golden Pack") for log lines. +func PackNames(ids []string) string { + names := make([]string, 0, len(ids)) + for _, id := range ids { + if p, ok := packByID(id); ok { + names = append(names, p.Name) + } + } + switch len(names) { + case 0: + return "no packs" + case 1: + return names[0] + case 2: + return names[0] + " and " + names[1] + default: + return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1] + } +} diff --git a/internal/game/schedule.go b/internal/game/schedule.go new file mode 100644 index 0000000..21f5744 --- /dev/null +++ b/internal/game/schedule.go @@ -0,0 +1,87 @@ +package game + +// Battle pairings for multiplayer games ("4 & 6 Player Mode" in the rulebook). +// +// Every round, players split into pairs and each pair fights its own battle. +// The pairings are a fixed table printed in the book — seats are lettered +// A, B, C… in seat order, so seat 0 is A. Both tables are transcribed +// literally rather than generated: each is a round-robin that runs out of +// fresh pairings before six rounds are up (three rounds exhaust four players, +// five rounds exhaust six), and the book's choice of which earlier rounds to +// replay for the remainder is a decision, not something a generator would +// reproduce. + +// Matchup is one battle: the two seats that fight it. The order is the printed +// order and carries no meaning on its own — the first player of each battle is +// decided separately (a coin flip; see Game.startBattles). +type Matchup [2]int + +// pairingTables maps a player count to its per-round pairings, indexed by +// round-1. Two players is the degenerate case: one pairing, every round. +var pairingTables = map[int][MaxRounds][]Matchup{ + 2: { + {{0, 1}}, // R1 A/B + {{0, 1}}, // R2 A/B + {{0, 1}}, // R3 A/B + {{0, 1}}, // R4 A/B + {{0, 1}}, // R5 A/B + {{0, 1}}, // R6 A/B + }, + 4: { + {{0, 1}, {2, 3}}, // R1 A/B C/D + {{0, 2}, {1, 3}}, // R2 A/C B/D + {{0, 3}, {1, 2}}, // R3 A/D B/C + {{0, 1}, {2, 3}}, // R4 A/B C/D + {{0, 2}, {1, 3}}, // R5 A/C B/D + {{0, 3}, {1, 2}}, // R6 A/D B/C + }, + 6: { + {{0, 1}, {2, 3}, {4, 5}}, // R1 A/B C/D E/F + {{0, 2}, {1, 4}, {3, 5}}, // R2 A/C B/E D/F + {{0, 3}, {1, 5}, {2, 4}}, // R3 A/D B/F C/E + {{0, 4}, {1, 3}, {2, 5}}, // R4 A/E B/D C/F + {{0, 5}, {1, 2}, {3, 4}}, // R5 A/F B/C D/E + {{0, 1}, {2, 3}, {4, 5}}, // R6 A/B C/D E/F + }, +} + +// PlayerCounts lists the player counts a game can be played at, in order. The +// game needs an even number of players so everyone has an opponent every +// round; a lobby with an odd number of humans fills the gap with bots. +var PlayerCounts = []int{2, 4, 6} + +// ValidPlayerCount reports whether n players can start a game. +func ValidPlayerCount(n int) bool { + _, ok := pairingTables[n] + return ok +} + +// Pairings returns the battles for a round (1-based) at the given player +// count, or nil if either is out of range. The returned slice is shared table +// data — treat it as read-only. +func Pairings(players, round int) []Matchup { + table, ok := pairingTables[players] + if !ok || round < 1 || round > MaxRounds { + return nil + } + return table[round-1] +} + +// OpponentOf returns the seat that `seat` fights in the given round, or -1 if +// it has no battle (which the fixed tables never produce for a valid count). +func OpponentOf(players, round, seat int) int { + for _, m := range Pairings(players, round) { + switch seat { + case m[0]: + return m[1] + case m[1]: + return m[0] + } + } + return -1 +} + +// Pairings returns this round's battle pairings for the game's player count. +func (g *Game) Pairings() []Matchup { + return Pairings(len(g.Players), g.Round) +} diff --git a/internal/game/sim.go b/internal/game/sim.go index 024bf5f..1bbec55 100644 --- a/internal/game/sim.go +++ b/internal/game/sim.go @@ -1,15 +1,15 @@ package game -// SimulateBattle resolves a hypothetical two-player battle between the given -// arranged decks (top of deck first) and returns the result. It runs on a -// scratch game, so it never touches real state — callers (notably the AI -// player) can roll out as many what-if battles as they like. Dice rolls are -// random unless rollDie is non-nil. -func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func() int) *BattleResult { +// SimulateBattle resolves a hypothetical battle between the given arranged +// decks (top of deck first) and returns the result. deckA sits at seat 0 and +// deckB at seat 1; firstSeat (0 or 1) is the one holding priority. It runs on +// a scratch game and awards nothing, so it never touches real state — callers +// (notably the AI player) can roll out as many what-if battles as they like. +// Dice rolls are random unless rollDie is non-nil. +func SimulateBattle(round, firstSeat int, deckA, deckB []Card, rollDie func() int) *BattleResult { g := &Game{ - Round: round, - PrioritySeat: prioritySeat, - RollDie: rollDie, + Round: round, + RollDie: rollDie, // Cards minted during the simulation (apples, bees) get IDs far away // from real ones, purely to avoid confusion when reading results. NextCardID: 1_000_000, @@ -18,22 +18,24 @@ func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func() {Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)}, }, } - g.startBattle() - return g.Battle + if firstSeat == 1 { + return g.runBattle(1, 0) + } + return g.runBattle(0, 1) } // TierContents returns the full printed contents of a tier's shop deck for the // default pack. Cards carry placeholder IDs; they are reference data, not live // instances. func TierContents(tier int) []Card { - return TierContentsForPack(DefaultPack, tier) + return TierContentsForPacks([]string{DefaultPack}, tier) } -// TierContentsForPack returns a pack's printed tier contents — public -// information from the box. Used by the AI, which decides from a View that -// names its pack. -func TierContentsForPack(pack string, tier int) []Card { - scratch := &Game{Pack: pack} +// TierContentsForPacks returns the printed tier contents of a pack selection, +// combined the way the shop decks combine them — public information from the +// boxes. Used by the AI, which decides from a View that names its packs. +func TierContentsForPacks(packs []string, tier int) []Card { + scratch := &Game{Packs: packs} scratch.buildShopDecks() if tier < 1 || tier > len(scratch.ShopDecks) { return nil diff --git a/internal/game/tier456_test.go b/internal/game/tier456_test.go index 03f729d..fe260d2 100644 --- a/internal/game/tier456_test.go +++ b/internal/game/tier456_test.go @@ -603,7 +603,7 @@ func TestSnakeRecurringRocks(t *testing.T) { func TestWolverineStealsApples(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, - []Card{g.pet("Chip", 4), g.realPet(t, "Wolverine")}, // wolverine: 5 + []Card{g.pet("Chip", 4), g.realPet(t, "Wolverine")}, // wolverine: 5 []Card{g.newApple(), g.newApple(), g.newApple(), g.newApple(), g.pet("Hoard", 2)}, // 2+4=6 ) // Chip (4) dies to Hoard (6); Hoard carries 4 damage (2 health). diff --git a/internal/game/unicorn_test.go b/internal/game/unicorn_test.go index 4004096..7f0ec64 100644 --- a/internal/game/unicorn_test.go +++ b/internal/game/unicorn_test.go @@ -9,7 +9,7 @@ import "testing" func unicornGame(t *testing.T) (*Game, *Player, *Player) { t.Helper() g := New() - g.Pack = "unicorn" + g.Packs = []string{"unicorn"} g.buildDecks() p1, err := g.AddPlayer("Alice") if err != nil { diff --git a/internal/game/view.go b/internal/game/view.go index f9c28fb..04892d4 100644 --- a/internal/game/view.go +++ b/internal/game/view.go @@ -3,16 +3,19 @@ package game // PlayerView is what any player may know about a seat. Deck contents are // only included for the viewer's own seat; opponents see counts. type PlayerView struct { - ID string `json:"id"` - Name string `json:"name"` - Seat int `json:"seat"` - Coins int `json:"coins"` - Trophies int `json:"trophies"` - Ready bool `json:"ready"` - Connected bool `json:"connected"` - IsBot bool `json:"isBot,omitempty"` - DeckSize int `json:"deckSize"` - PetCount int `json:"petCount"` + ID string `json:"id"` + Name string `json:"name"` + Seat int `json:"seat"` + Coins int `json:"coins"` + Trophies int `json:"trophies"` + // RoundWins are the rounds this player won a battle in. Public — every + // result is — and what the end-of-game countback tie-break runs on. + RoundWins []int `json:"roundWins,omitempty"` + Ready bool `json:"ready"` + Connected bool `json:"connected"` + IsBot bool `json:"isBot,omitempty"` + DeckSize int `json:"deckSize"` + PetCount int `json:"petCount"` // Avocados is the player's set-aside Avocado token count (Golden pack). // Public: buying an Avocado is a public shop event. Avocados int `json:"avocados,omitempty"` @@ -40,21 +43,31 @@ type View struct { Round int `json:"round"` MaxRounds int `json:"maxRounds"` MaxPets int `json:"maxPets"` - // Pack is the selected card pack; Packs is the catalog of choices for the - // lobby. HostSeat is the seat that controls the lobby (always 0 for now); - // MinPlayers is how many seats must be filled before the host can start. - Pack string `json:"pack"` - Packs []PackInfo `json:"packs"` - HostSeat int `json:"hostSeat"` - MinPlayers int `json:"minPlayers"` - MaxPlayers int `json:"maxPlayers"` - YouSeat int `json:"youSeat"` - Turn int `json:"turn"` - // PrioritySeat is the seat currently holding the priority token. + // Packs are the selected card packs, whose tiers shuffle together; + // PackCatalog is the list of choices for the lobby and PacksNeeded is how + // many the current table size requires. HostSeat is the seat that controls + // the lobby (always 0 for now); PlayerCounts lists the table sizes a game + // can start at. + Packs []string `json:"packs"` + PackCatalog []PackInfo `json:"packCatalog"` + PacksNeeded int `json:"packsNeeded"` + HostSeat int `json:"hostSeat"` + MinPlayers int `json:"minPlayers"` + MaxPlayers int `json:"maxPlayers"` + PlayerCounts []int `json:"playerCounts"` + YouSeat int `json:"youSeat"` + Turn int `json:"turn"` + // PrioritySeat is the seat holding the first-shopper token: it shops first + // this round (and, in a two-player game, also acts first in the battle). PrioritySeat int `json:"prioritySeat"` ShopRow []Card `json:"shopRow"` DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier Players []PlayerView `json:"players"` + // Matchups are this round's battle pairings — public, since the schedule is + // printed in the rulebook. YourOpponent is the seat you face this round, or + // -1 outside a running game. + Matchups []Matchup `json:"matchups,omitempty"` + YourOpponent int `json:"yourOpponent"` // Pending is included for everyone so opponents see a trade is in // progress, but the revealed options are only shown to the trader. Pending *PendingTrade `json:"pending,omitempty"` @@ -65,8 +78,14 @@ type View struct { // PendingSacrifice (Unicorn pack: Water of Youth) mirrors PendingReveal: the // options (the buyer's own pets) are only sent to the buyer. PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"` - Battle *BattleResult `json:"battle,omitempty"` - WinnerSeat int `json:"winnerSeat"` + // Battle is the viewer's own battle this round; Battles holds every table's, + // so a player can replay any of them. Both are public once resolved. + Battle *BattleResult `json:"battle,omitempty"` + Battles []*BattleResult `json:"battles,omitempty"` + // WinnerSeat is the outright winner at gameover (-1 when shared); + // WinnerSeats lists everyone holding the title. + WinnerSeat int `json:"winnerSeat"` + WinnerSeats []int `json:"winnerSeats,omitempty"` // Log is the shared, public event log shown across every phase. Log []LogEntry `json:"log,omitempty"` // Debug is set by the server when its DEBUG flag is on, unlocking the @@ -83,16 +102,21 @@ func (g *Game) ViewFor(playerID string) View { Round: g.Round, MaxRounds: MaxRounds, MaxPets: MaxPets, - Pack: g.Pack, - Packs: Packs, + Packs: g.packList(), + PackCatalog: Packs, + PacksNeeded: PacksNeeded(len(g.Players)), HostSeat: 0, MinPlayers: MinPlayers, MaxPlayers: MaxPlayers, + PlayerCounts: PlayerCounts, YouSeat: -1, + YourOpponent: -1, Turn: g.Turn, PrioritySeat: g.PrioritySeat, ShopRow: g.ShopRow, WinnerSeat: g.WinnerSeat, + WinnerSeats: g.WinnerSeats, + Matchups: g.Pairings(), Log: g.Log, } for _, deck := range g.ShopDecks { @@ -105,6 +129,7 @@ func (g *Game) ViewFor(playerID string) View { Seat: p.Seat, Coins: p.Coins, Trophies: p.Trophies, + RoundWins: p.RoundWins, Ready: p.Ready, Connected: p.Connected, IsBot: p.IsBot, @@ -115,6 +140,7 @@ func (g *Game) ViewFor(playerID string) View { } if p.ID == playerID { v.YouSeat = p.Seat + v.YourOpponent = OpponentOf(len(g.Players), g.Round, p.Seat) pv.Deck = p.Deck pv.FirstBuyFree = p.FirstBuyFree pv.BuysThisRound = p.BuysThisRound @@ -144,9 +170,23 @@ func (g *Game) ViewFor(playerID string) View { } v.PendingSacrifice = &sac } - // Battle results (lineups, events) are public once resolved. Keep the - // battle around during the following shop phase too, so late joiners / - // reconnects can still see the last result. - v.Battle = g.Battle + // Battle results (lineups, events) are public once resolved — every table's, + // not just your own, so players can watch how the rest of the field did. + // They stay around during the following shop phase too, so late joiners / + // reconnects can still see the last round. + v.Battles = g.Battles + if v.YouSeat >= 0 { + v.Battle = g.BattleFor(v.YouSeat) + } return v } + +// PlayerView returns the view of one seat within a View, or nil. +func (v *View) PlayerView(seat int) *PlayerView { + for i := range v.Players { + if v.Players[i].Seat == seat { + return &v.Players[i] + } + } + return nil +} diff --git a/internal/server/bot_e2e_test.go b/internal/server/bot_e2e_test.go index b4ca5ab..82ead7b 100644 --- a/internal/server/bot_e2e_test.go +++ b/internal/server/bot_e2e_test.go @@ -96,3 +96,113 @@ func TestE2EBotGame(t *testing.T) { } t.Logf("reached phase %s; bot played its shop turns", v.Phase) } + +// TestE2EFourPlayerBotGame runs a four-seat game (one human, three bots) over +// the API and plays a full round. It's the multiplayer counterpart to +// TestE2EBotGame: the bot driver has to keep three seats moving through a +// shop that takes turns four ways, and the round has to resolve two battles at +// once rather than one. +func TestE2EFourPlayerBotGame(t *testing.T) { + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer st.Close() + srv := New(st, "", false) + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + resp, err := http.Post(ts.URL+"/api/games", "application/json", + bytes.NewBufferString(`{"name":"Human"}`)) + if err != nil { + t.Fatal(err) + } + var join joinResponse + if err := json.NewDecoder(resp.Body).Decode(&join); err != nil { + t.Fatal(err) + } + resp.Body.Close() + + wsBase := "ws" + strings.TrimPrefix(ts.URL, "http") + ws, _, err := websocket.Dial(ctx, + wsBase+"/api/ws?game="+join.GameID+"&player="+join.PlayerID+"&token="+join.Token, nil) + if err != nil { + t.Fatal(err) + } + defer ws.Close(websocket.StatusNormalClosure, "") + + if p := readState(t, ctx, ws).Phase; p != game.PhaseLobby { + t.Fatalf("phase = %s, want lobby on create", p) + } + // Four seats need two packs shuffled together, per the rulebook. + send(t, ctx, ws, map[string]any{"type": "setPacks", "packs": []string{"turtle", "golden"}}) + for _, level := range []string{"easy", "medium", "hard"} { + send(t, ctx, ws, map[string]any{"type": "addBot", "difficulty": level}) + } + send(t, ctx, ws, map[string]any{"type": "start"}) + + var v *game.View + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + rctx, rcancel := context.WithTimeout(ctx, 20*time.Second) + v = readState(t, rctx, ws) + rcancel() + if v.Phase == game.PhaseLobby { + continue + } + if len(v.Players) != 4 { + t.Fatalf("expected 4 seats, got %d", len(v.Players)) + } + // Play the human side: pass as soon as it's our turn, then submit our + // deck as-is. The round can only progress if the three bots take their + // own turns around us. + switch v.Phase { + case game.PhaseShop: + if v.Turn == v.YouSeat && !v.Players[v.YouSeat].Ready && v.Pending == nil && + v.PendingReveal == nil && v.PendingSacrifice == nil { + send(t, ctx, ws, map[string]any{"type": "pass"}) + } + case game.PhaseArrange: + if !v.Players[v.YouSeat].Ready { + ids := []string{} + for _, c := range v.Players[v.YouSeat].Deck { + ids = append(ids, c.ID) + } + send(t, ctx, ws, map[string]any{"type": "arrange", "order": ids}) + } + case game.PhaseBattle: + goto resolved + } + } + t.Fatalf("never reached the battle phase; stuck in %s", v.Phase) + +resolved: + // Every bot name should be distinct, or the table is unreadable. + names := map[string]bool{} + for _, p := range v.Players { + if names[p.Name] { + t.Errorf("two seats are both called %q", p.Name) + } + names[p.Name] = true + } + // Four players pair into two simultaneous battles, together seating everyone. + if len(v.Battles) != 2 { + t.Fatalf("round resolved %d battles, want 2", len(v.Battles)) + } + fought := map[int]bool{} + for _, b := range v.Battles { + for _, seat := range b.Seats { + fought[seat] = true + } + } + if len(fought) != 4 { + t.Errorf("%d of 4 seats fought this round", len(fought)) + } + if v.Battle == nil || !v.Battle.Has(v.YouSeat) { + t.Error("the view should single out the battle the human fought") + } + t.Logf("four-player round resolved: %d battles, seats %v", len(v.Battles), fought) +} diff --git a/internal/server/bots.go b/internal/server/bots.go index 4d584af..736e04d 100644 --- a/internal/server/bots.go +++ b/internal/server/bots.go @@ -2,6 +2,7 @@ package server import ( "errors" + "fmt" "log/slog" "math/rand/v2" "time" @@ -10,19 +11,23 @@ import ( "github.com/greyson/super-auto-pets-board-game/internal/game" ) -// botDifficulty maps the API's difficulty names to a skill level and a -// table name for the bot. The levels are calibrated against a competent -// player (ai.competentLevel, ~0.60): easy loses ~95% of games to them, medium -// is an even match, and hard wins ~80% (see TestDiagWinRateCurve). Medium is -// pinned to the competent level itself; hard is the strongest bot the engine -// can field. +// botDifficulty maps the API's difficulty names to a skill level and a pool of +// table names. The levels are calibrated against a competent player +// (ai.competentLevel, ~0.60): easy loses ~95% of games to them, medium is an +// even match, and hard wins ~80% (see TestDiagWinRateCurve). Medium is pinned +// to the competent level itself; hard is the strongest bot the engine can +// field. +// +// A table can hold up to five bots, so each difficulty carries a list of names +// rather than one: the first unused name is taken, keeping every seat +// distinguishable while the name still hints at how hard it plays. var botDifficulty = map[string]struct { level float64 - name string + names []string }{ - "easy": {0.25, "Robo Rookie"}, - "medium": {0.60, "Robo Rival"}, - "hard": {1.00, "Robo Ace"}, + "easy": {0.25, []string{"Robo Rookie", "Bitsy", "Clunk", "Pip", "Sprocket"}}, + "medium": {0.60, []string{"Robo Rival", "Gizmo", "Widget", "Rusty", "Cogsworth"}}, + "hard": {1.00, []string{"Robo Ace", "Vex", "Apex", "Onyx", "Zenith"}}, } // requireHost authorizes a lobby-management action: only the host (seat 0) @@ -36,17 +41,37 @@ func requireHost(g *game.Game, playerID string) error { } // addBot seats a computer opponent for a difficulty name, translating the -// name to the engine's skill level. Capacity and phase are enforced by the -// engine's AddPlayer. +// name to the engine's skill level and giving it a name nobody at the table is +// already using. Capacity and phase are enforced by the engine's AddPlayer. func addBot(g *game.Game, difficulty string) error { bot, ok := botDifficulty[difficulty] if !ok { return errors.New("unknown bot difficulty") } - _, err := g.AddBot(bot.name, bot.level) + _, err := g.AddBot(botName(g, bot.names), bot.level) return err } +// botName picks the first name in the pool that no seat has taken. If a host +// somehow exhausts the pool, it falls back to numbering the first name. +func botName(g *game.Game, pool []string) string { + taken := make(map[string]bool, len(g.Players)) + for _, p := range g.Players { + taken[p.Name] = true + } + for _, name := range pool { + if !taken[name] { + return name + } + } + for n := 2; ; n++ { + name := fmt.Sprintf("%s %d", pool[0], n) + if !taken[name] { + return name + } + } +} + // commitLocked is the one path every game mutation goes through: bots update // their memories from the new public state, the game is persisted, every // client gets its view, and the next bot move (if any) is scheduled. Callers diff --git a/internal/server/server.go b/internal/server/server.go index 6120798..57e976d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -51,14 +51,23 @@ func (s *Server) Handler() http.Handler { return mux } -// handleCatalog returns every card in a pack (?pack=…, default Turtle), for the -// debug panel. Unknown packs fall back to the default. +// handleCatalog returns every card in the requested packs, for the debug panel +// and the event log's card previews. Packs come as a repeated or +// comma-separated ?pack= parameter and default to Turtle; unknown ids fall back +// to the default pack's cards. func (s *Server) handleCatalog(w http.ResponseWriter, req *http.Request) { - pack := req.URL.Query().Get("pack") - if pack == "" { - pack = game.DefaultPack + var packs []string + for _, v := range req.URL.Query()["pack"] { + for _, id := range strings.Split(v, ",") { + if id = strings.TrimSpace(id); id != "" { + packs = append(packs, id) + } + } } - writeJSON(w, game.CatalogForPack(pack)) + if len(packs) == 0 { + packs = []string{game.DefaultPack} + } + writeJSON(w, game.CatalogForPacks(packs)) } // room is one live game plus its connections. diff --git a/internal/server/ws.go b/internal/server/ws.go index 2f73480..693af8b 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -28,7 +28,7 @@ type clientMessage struct { Pick int `json:"pick"` // tradeChoose Order []string `json:"order"` // arrange Name string `json:"name"` // debugAdd - Pack string `json:"pack"` // setPack + Packs []string `json:"packs"` // setPacks Difficulty string `json:"difficulty"` // addBot Target string `json:"target"` // removePlayer (player ID) Card string `json:"card"` // revealChoose (Cockatoo): pet card id @@ -123,9 +123,9 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) { g := r.game var err error switch msg.Type { - case "setPack": + case "setPacks": if err = requireHost(g, c.playerID); err == nil { - err = g.SetPack(msg.Pack) + err = g.SetPacks(msg.Packs) } case "addBot": if err = requireHost(g, c.playerID); err == nil { diff --git a/web/src/api.ts b/web/src/api.ts index 199ac42..b3d78bc 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -23,9 +23,11 @@ export function joinGame(code: string, name: string): Promise { return post('/api/join', { code, name }) } -export async function fetchCatalog(pack?: string): Promise { - const url = pack ? `/api/catalog?pack=${encodeURIComponent(pack)}` : '/api/catalog' - const res = await fetch(url) +export async function fetchCatalog(packs?: string[]): Promise { + const query = (packs ?? []) + .map((p) => `pack=${encodeURIComponent(p)}`) + .join('&') + const res = await fetch(query ? `/api/catalog?${query}` : '/api/catalog') if (!res.ok) throw new Error('failed to load catalog') return (await res.json()) as Card[] } diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index 369a2f0..db8f477 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -313,14 +313,18 @@ export function ArrangePhase({ view, you, send }: Props) { return n })() - const opponent = view.players.find((p) => p.seat !== view.youSeat) + // Everyone arranges at the same time, so the wait is on whoever is left. + const waitingOn = view.players.filter((p) => p.seat !== view.youSeat && !p.ready) + const rival = view.players.find((p) => p.seat === view.yourOpponent) if (locked) { return (

Order locked in ⚔️

- Waiting for {opponent?.name ?? 'your opponent'} to arrange their deck… + {waitingOn.length === 1 + ? `Waiting for ${waitingOn[0].name} to arrange their deck…` + : `Waiting for ${waitingOn.length} more players to arrange their decks…`}

) @@ -329,7 +333,12 @@ export function ArrangePhase({ view, you, send }: Props) { return (
- Arrange your battle line + + Arrange your battle line + {/* Who you face is public (the pairings are printed in the rulebook), + and at a bigger table it's a different rival every round. */} + {rival && <> — you fight {rival.name} this round} +

The topmost card fights first. Food cards power up the diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index a645b3c..deba0bd 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import { createPortal } from 'react-dom' -import type { BattleEvent, Card, ClientMessage, GameView } from '../types' +import type { BattleEvent, BattleResult, Card, ClientMessage, GameView } from '../types' +import { sideOf } from '../types' import { CardView, CardZoom } from './CardView' import { DiceRoll, ROLL_MS } from './DiceRoll' import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed' @@ -17,6 +18,13 @@ interface Props { // Step is owned by Table so the event log can stay in sync with the replay. step: number setStep: Dispatch> + // The battle being replayed, and the switcher for picking another table's. + // With more than two players several battles resolve at once and any of them + // can be watched; at two players there is only ever one. + battle: BattleResult + battles: BattleResult[] + selected: number + onSelect: (idx: number) => void } interface UnitVis { @@ -245,40 +253,40 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side // unitPop decides the floating effect text over a seat's pet for the event // currently playing. Null = nothing. -function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], step: number): string | null { +function unitPop(ev: BattleEvent | null, side: number, events: BattleEvent[], step: number): string | null { if (!ev) return null switch (ev.type) { case 'rock': - if (ev.target !== seat) return null + if (ev.target !== side) return null return ev.roll === 0 ? 'miss!' : `−${ev.roll}` case 'clash': { - const taken = clashDamageTaken(events, step - 1, seat) + const taken = clashDamageTaken(events, step - 1, side) return taken > 0 ? `−${taken}` : null } case 'shield': - return ev.seat === seat ? '🛡️' : null + return ev.seat === side ? '🛡️' : null case 'prevent': - return ev.seat === seat ? `🛡️ −${ev.count}` : null + return ev.seat === side ? `🛡️ −${ev.count}` : null case 'trumpet': - if (ev.seat !== seat) return null + if (ev.seat !== side) return null return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}` case 'mana': - if (ev.seat !== seat) return null + if (ev.seat !== side) return null return (ev.count ?? 0) >= 0 ? `🔮 +${ev.count}` : `🔮 ${ev.count}` case 'ailment': - if (ev.seat !== seat) return null + if (ev.seat !== side) return null return ev.card?.ailment === 'spooked' ? '👻' : '🎯' case 'bounce': - return ev.target === seat ? '🌀' : null + return ev.target === side ? '🌀' : null case 'eat': - return ev.seat === seat ? '🍎' : null + return ev.seat === side ? '🍎' : null case 'heal': - return ev.seat === seat ? '💚 +1' : null + return ev.seat === side ? '💚 +1' : null case 'strip': - return ev.target === seat ? '💨' : null + return ev.target === side ? '💨' : null case 'steal': - if (ev.seat === seat) return `+🍎×${ev.count}` - if (ev.target === seat) return `−🍎×${ev.count}` + if (ev.seat === side) return `+🍎×${ev.count}` + if (ev.target === side) return `−🍎×${ev.count}` return null default: return null @@ -287,8 +295,16 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st // BattlePhase plays back the battle log: cards flip off each deck, rocks // fly, pets clash, the fallen fade out, then the round result lands. -export function BattlePhase({ view, send, step, setStep }: Props) { - const battle = view.battle! +export function BattlePhase({ + view, + send, + step, + setStep, + battle, + battles, + selected, + onSelect, +}: Props) { const events = battle.events ?? [] // acked = the player committed to the next round (waiting on the opponent). const [acked, setAcked] = useState(false) @@ -299,7 +315,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) { // and hides the other — so an absolutely-positioned popover gets clipped to // the battlefield. We anchor it to the hovered stack's viewport rect and // portal it to so it floats over the whole window instead. - const [peek, setPeek] = useState<{ seat: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>( + const [peek, setPeek] = useState<{ side: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>( null, ) const lineups = battle.lineups @@ -375,12 +391,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) { [events, battle.stackSizes, upto], ) - const youSeat = view.youSeat - const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1 - const you = view.players[youSeat] - const opp = view.players[oppSeat] - const won = battle.winnerSeat === youSeat - const draw = battle.winnerSeat < 0 + // Everything inside a battle is indexed by side (0 or 1), not by seat, so the + // arena works in sides and maps out to players only for names and results. + // When you're watching someone else's table you have no side in it: side 1 + // takes the bottom half so the board still reads as two halves facing off. + const seatAt = (side: number) => view.players.find((p) => p.seat === battle.seats?.[side]) + const mySide = sideOf(battle, view.youSeat) + const spectating = mySide < 0 + const bottomSide = spectating ? 1 : mySide + const topSide = 1 - bottomSide + const bottom = seatAt(bottomSide) + const top = seatAt(topSide) + + // The result banner and the round hand-off always speak about *your* battle, + // even while you're watching another table play out. + const ownBattle = battles.find((b) => sideOf(b, view.youSeat) >= 0) ?? battle + const won = ownBattle.winnerSeat === view.youSeat + const draw = ownBattle.winnerSeat < 0 // Commit to the next round: ack and hand off to the server. const proceed = () => { @@ -405,19 +432,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) { // two pets meet at a horizontal clash line. Each half is a row — // [set-aside | pet | apples] — with the deck under the pet (bottom) or over // it (top). Set-aside stays on the left and apples on the right for both. - function renderSide(seat: number, pos: 'top' | 'bottom') { - const s = sides[seat] + function renderSide(side: number, pos: 'top' | 'bottom') { + const s = sides[side] const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying const clashDying = lastEvent?.type === 'clash' && s.unit?.dying - const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat - const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat - const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat - const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat + const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === side + const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === side + const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === side + const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === side // A food (apple) that just landed on this side's fan — reveal off the deck or // a Battle Prep hand-out — animates in from the deck rather than popping. const newFoodId = !done && - lastEvent?.seat === seat && + lastEvent?.seat === side && (lastEvent.type === 'prep' || (lastEvent.type === 'reveal' && (lastEvent.card?.kind === 'food' || lastEvent.card?.kind === 'ailment'))) @@ -425,23 +452,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) { : null // A pet just set aside slides into the set-aside row beside the arena. const newSetAsideId = - !done && lastEvent?.type === 'setaside' && lastEvent.seat === seat + !done && lastEvent?.type === 'setaside' && lastEvent.seat === side ? lastEvent.card?.id : null // Hold the −N / miss! pop until the dice settle. - const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, seat, events, step) + const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, side, events, step) - const lineup = lineups?.[seat] ?? [] + const lineup = lineups?.[side] ?? [] const stackEl = (

setPeek({ seat, pos, rect: e.currentTarget.getBoundingClientRect() }) + ? (e) => setPeek({ side, pos, rect: e.currentTarget.getBoundingClientRect() }) : undefined } - onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))} + onMouseLeave={() => setPeek((p) => (p?.side === side ? null : p))} title={lineup.length ? 'Hover to see the whole deck' : undefined} > {s.stack > 0 ? ( @@ -464,7 +491,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
)} - {rockDice && lastEvent?.seat === seat && ( + {rockDice && lastEvent?.seat === side && ( // The dice roll sits beside the throwing side's deck.
@@ -602,7 +629,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) { return (
-

Battle! Round {battle.round}

+

+ {spectating ? 'Watching' : 'Battle!'} Round {battle.round} +

+ {/* With four or six players several battles resolve at once. Tabs switch + the arena between them so you can watch the whole field, not just your + own fight; at two players there's only one battle and no tab bar. */} + {battles.length > 1 && ( +
+ {battles.map((b, i) => { + const mine = sideOf(b, view.youSeat) >= 0 + const names = (b.seats ?? []).map( + (s) => view.players.find((p) => p.seat === s)?.name ?? '?', + ) + return ( + + ) + })} +
+ )} +
- {you?.name} (you) + + {bottom?.name} + {!spectating && ' (you)'} + VS - {opp?.name} + {top?.name}
- {renderSide(oppSeat, 'top')} + {renderSide(topSide, 'top')}
- {renderSide(youSeat, 'bottom')} + {renderSide(bottomSide, 'bottom')}
{peek && (() => { - const lineup = lineups?.[peek.seat] ?? [] + const lineup = lineups?.[peek.side] ?? [] if (!lineup.length) return null // Your deck sits at the bottom of the board, so float the peek above // it; the rival's sits at the top, so float it below. @@ -699,13 +757,14 @@ export function BattlePhase({ view, send, step, setStep }: Props) { ? { bottom: window.innerHeight - peek.rect.top + 8 } : { top: peek.rect.bottom + 8 }), } - const mine = peek.seat === youSeat + const owner = seatAt(peek.side) + const whose = owner?.seat === view.youSeat ? 'Your' : `${owner?.name ?? 'Their'}’s` // Each player's first pet is the one nearest the clash line; show the // lineup first-to-last, left to right, for both. return createPortal(
- {mine ? 'Your' : 'Opponent’s'} deck · {lineup.length} card + {whose} deck · {lineup.length} card {lineup.length !== 1 ? 's' : ''} (first on the left)
@@ -731,13 +790,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
{!draw ? (
- {view.players[battle.winnerSeat]?.name} wins {'🏆'.repeat(battle.trophies)} + {view.players.find((p) => p.seat === ownBattle.winnerSeat)?.name} wins{' '} + {'🏆'.repeat(ownBattle.trophies)}
) : (
No trophies awarded
)} + {/* At a bigger table the rest of the field matters as much as your + own result, so the round's other tables are summarised here. */} + {battles.length > 1 && ( +
+
Elsewhere this round
+ {battles + .filter((b) => b !== ownBattle) + .map((b, i) => { + const winner = view.players.find((p) => p.seat === b.winnerSeat) + const names = (b.seats ?? []).map( + (s) => view.players.find((p) => p.seat === s)?.name ?? '?', + ) + return ( +
+ {names.join(' vs ')} + + {winner ? `${winner.name} ${'🏆'.repeat(b.trophies)}` : 'draw'} + +
+ ) + })} +
+ )} {acked ? ( -

Waiting for opponent…

+

Waiting for the other players…

) : (
) })}
{!isHost && ( -

Only the host can change the pack.

+

Only the host can change the packs.

+ )} + {isHost && !enoughPacks && ( +

+ Add {view.packsNeeded - packs.length} more pack + {view.packsNeeded - packs.length !== 1 ? 's' : ''} to seat {seated} players. +

)}

- Players ({view.players.length}/{view.maxPlayers}) + Players ({seated}/{view.maxPlayers})

+

+ Play happens in pairs, so the table needs {view.playerCounts.join(', ')} players. + Fill any odd seat with a computer player. +

    {view.players.map((p) => ( ))} - {Array.from({ length: openSeats }).map((_, i) => ( -
  • + {openSeats > 0 && ( +
  • {isHost ? (
    - Add a computer opponent, or share the code to invite a friend: + {openSeats} open seat{openSeats !== 1 ? 's' : ''} — add a computer + player, or share the code to invite a friend:
    {BOT_LEVELS.map((b) => ( @@ -106,10 +159,12 @@ export function Lobby({
    ) : ( - Waiting for the host to fill this seat… + + {openSeats} open seat{openSeats !== 1 ? 's' : ''} — waiting for the host… + )}
  • - ))} + )}
@@ -119,9 +174,7 @@ export function Lobby({ disabled={!canStart} onClick={() => send({ type: 'start' })} > - {view.players.length < view.minPlayers - ? 'Waiting for a second player…' - : 'Start game'} + {blocker ?? 'Start game'} ) : (

Waiting for the host to start the game…

diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 96dd7ae..987d8b9 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -122,7 +122,10 @@ export function ShopPhase({ view, you, send }: Props) { const totalCoins = purse.current.max const deck = you.deck ?? [] - const opponent = view.players.find((p) => p.seat !== view.youSeat) + // Whoever the shop is currently waiting on, by name — at a bigger table + // that's a specific player taking their turn, not "the opponent". + const acting = view.players.find((p) => p.seat === view.turn) + const stillShopping = view.players.filter((p) => p.seat !== view.youSeat && !p.ready) const pending = view.pending const myPending = pending?.playerId === you.id @@ -263,12 +266,15 @@ export function ShopPhase({ view, you, send }: Props) {
{pending && !myPending ? ( - {opponent?.name ?? 'Opponent'} is tripling up a tier… + {acting?.name ?? 'Someone'} is tripling up a tier… ) : you.ready ? ( - You passed — waiting for {opponent?.name ?? 'opponent'} to finish - shopping… + You passed — waiting for{' '} + {stillShopping.length === 1 + ? stillShopping[0].name + : `${stillShopping.length} more players`}{' '} + to finish shopping… ) : myTurn && overPets ? ( diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index 702b5a3..fd050c9 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -17,23 +17,40 @@ import { EventLog, battleLogLines } from './EventLog' export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) { const { view, error, connected, send } = useGame(session) - // Battle replay step lives here (not inside BattlePhase) so the event log, - // a sibling, can render the battle narration up to the same step. Deriving - // the effective step from the current battle round resets it to 0 whenever a - // new battle arrives, without a separate effect. These hooks must run on - // every render (before any early return) to satisfy the rules of hooks. - const battleRound = view?.battle?.round ?? -1 - const [stepState, setStepState] = useState<{ round: number; step: number }>({ - round: -1, + // A round runs one battle per pairing, so with four or six players there are + // several to watch. Which one is on screen lives here, alongside the replay + // step, because the event log is a sibling and narrates whichever battle is + // playing. Both reset when a new round's battles arrive. + // + // These hooks must run on every render (before any early return) to satisfy + // the rules of hooks. + const battles = view?.battles ?? [] + const battleRound = battles[0]?.round ?? -1 + // Your own fight is what a round opens on; spectators of a game they aren't + // seated in (or a malformed round) fall back to the first table. + const ownIdx = Math.max( + 0, + battles.findIndex((b) => (b.seats ?? []).includes(view?.youSeat ?? -1)), + ) + const [sel, setSel] = useState<{ round: number; idx: number }>({ round: -1, idx: 0 }) + const selected = sel.round === battleRound ? Math.min(sel.idx, battles.length - 1) : ownIdx + const battle = battles[selected] + + // Deriving the effective step from the round and the selected battle resets + // it to 0 whenever either changes, without a separate effect. + const [stepState, setStepState] = useState<{ key: string; step: number }>({ + key: '', step: 0, }) - const step = stepState.round === battleRound ? stepState.step : 0 + const stepKey = `${battleRound}:${selected}` + const step = stepState.key === stepKey ? stepState.step : 0 const setStep: Dispatch> = (upd) => setStepState((prev) => { - const cur = prev.round === battleRound ? prev.step : 0 + const cur = prev.key === stepKey ? prev.step : 0 const next = typeof upd === 'function' ? (upd as (n: number) => number)(cur) : upd - return { round: battleRound, step: next } + return { key: stepKey, step: next } }) + const selectBattle = (idx: number) => setSel({ round: battleRound, idx }) // A shop-phase peek at an opponent's deck from the previous round's battle // (its arranged lineup is already public). Shown as a centered modal. @@ -53,7 +70,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v // battle and appended as the final battle line only once the replay reaches // the end (and reappears in the persistent log in later phases). const inBattle = view?.phase === 'battle' - const events = view?.battle?.events ?? null + const events = battle?.events ?? null const battleDone = !!(inBattle && events && step >= events.length) const entries = useMemo(() => { @@ -66,9 +83,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v if (!inBattle || !events) return undefined const lines = battleLogLines(events, step) if (battleDone) { - const result = (view?.log ?? []).find( + // The engine logs one result per battle, in the same order it resolves + // them into view.battles — so the selected battle's line is at the same + // index among the round's results. + const result = (view?.log ?? []).filter( (e) => e.kind === 'result' && e.round === battleRound, - ) + )[selected] if (result) { lines.push({ key: `result-${result.seq}`, @@ -79,14 +99,14 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v } } return lines - }, [view, inBattle, events, step, battleDone, battleRound]) + }, [view, inBattle, events, step, battleDone, battleRound, selected]) // Map every card name we know about to a representative card, so the event // log can preview pets/foods it mentions on hover. The catalog covers all - // buyable pets and foods for the pack (even ones not currently in view); - // concrete cards from the view fill in tokens and summons (Bee, Apple, …) - // that never appear in the shop. - const catalog = useCatalog(view?.pack) + // buyable pets and foods across the packs in play (even ones not currently in + // view); concrete cards from the view fill in tokens and summons (Bee, + // Apple, …) that never appear in the shop. + const catalog = useCatalog(view?.packs) const cardLookup = useMemo(() => { const map = new Map() for (const c of catalog) if (c.name) map.set(c.name, c) @@ -96,8 +116,10 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v if (view) { view.shopRow?.forEach(add) view.players?.forEach((p) => p.deck?.forEach(add)) - view.battle?.lineups?.forEach((line) => line?.forEach(add)) - view.battle?.events?.forEach((ev) => add(ev.card)) + view.battles?.forEach((b) => { + b.lineups?.forEach((line) => line?.forEach(add)) + b.events?.forEach((ev) => add(ev.card)) + }) } return map }, [catalog, view]) @@ -129,6 +151,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v const you = view.players[view.youSeat] const opponents = view.players.filter((p) => p.seat !== view.youSeat) + // Last round's lineups are public, so any player's deck can be peeked at + // during the shop — whichever table they fought at. Indexed by seat here; + // inside a battle result the lineups are indexed by side. + const lastLineups = new Map() + for (const b of battles) { + ;(b.seats ?? []).forEach((seat, side) => { + const line = b.lineups?.[side] + if (line?.length) lastLineups.set(seat, line) + }) + } + return (
@@ -144,11 +177,21 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)} -
+ {/* Up to six seats ride here, so each one stays terse: the extra chips + only appear when they have something to say, and this round's + opponent is flagged so you know who you're preparing for. */} +
{view.players.map((p) => (
{p.isBot ? ( @@ -158,16 +201,22 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v )} {p.name} + {p.seat === view.yourOpponent && view.phase !== 'lobby' && ( + + ⚔️ + + )} 🏆 {p.trophies} {/* Your own gold shows as big discs above the buy row (ShopPhase); - the opponent's stays as a compact chip here. */} + everyone else's stays as a compact chip here. */} {view.phase === 'shop' && p.seat !== view.youSeat && ( 🪙 {p.coins} )} - {/* Peek at the opponent's deck from last round's battle. */} + {/* Peek at anyone's deck from last round's battle — every table's + lineups are public once fought. */} {view.phase === 'shop' && p.seat !== view.youSeat && - (view.battle?.lineups?.[p.seat]?.length ?? 0) > 0 && ( + (lastLineups.get(p.seat)?.length ?? 0) > 0 && (