Add support for up to 6 players.

This commit is contained in:
Greyson Parrelli
2026-07-28 07:36:09 -04:00
parent e542118175
commit a4f5f6910d
38 changed files with 2306 additions and 713 deletions
+22
View File
@@ -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
+51 -14
View File
@@ -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.
+9 -2
View File
@@ -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)
}
+70 -21
View File
@@ -32,24 +32,36 @@ func forcePlayable(id string) func() {
func playBotGamePack(t *testing.T, pack string, levelA, levelB float64) *game.Game {
t.Helper()
defer forcePlayable(pack)()
return playBotTable(t, []string{pack}, levelA, levelB)
}
// playBotTable drives a full game with a bot in every seat — one per level
// given, so it covers tables of two, four, or six — the same way the server
// would: observe on every state change, then act when input is owed. It fails
// the test if a bot ever produces an illegal action or the game stops making
// progress.
func playBotTable(t *testing.T, packs []string, levels ...float64) *game.Game {
t.Helper()
for _, pack := range packs {
defer forcePlayable(pack)()
}
g := game.New()
pa, err := g.AddBot("Bot A", levelA)
if err != nil {
t.Fatalf("AddBot A: %v", err)
bots := map[string]*Bot{}
mems := map[string]*Memory{}
for i, level := range levels {
p, err := g.AddBot(fmt.Sprintf("Bot %c", 'A'+i), level)
if err != nil {
t.Fatalf("AddBot %d: %v", i, err)
}
bots[p.ID] = New(level)
mems[p.ID] = &Memory{}
}
pb, err := g.AddBot("Bot B", levelB)
if err != nil {
t.Fatalf("AddBot B: %v", err)
}
if err := g.SetPack(pack); err != nil {
t.Fatalf("SetPack %s: %v", pack, err)
if err := g.SetPacks(packs); err != nil {
t.Fatalf("SetPacks %v: %v", packs, err)
}
if err := g.StartGame(); err != nil {
t.Fatalf("StartGame: %v", err)
}
bots := map[string]*Bot{pa.ID: New(levelA), pb.ID: New(levelB)}
mems := map[string]*Memory{pa.ID: {}, pb.ID: {}}
observe := func() {
for _, p := range g.Players {
@@ -128,6 +140,43 @@ func TestBotsFinishGames(t *testing.T) {
}
}
// TestBotsFinishMultiplayerGames plays complete four- and six-bot games on
// combined packs — the multiplayer setup the rulebook calls for. Beyond the
// usual "no illegal action" safety net, it exercises the bot against a rotating
// opponent (a different rival every round), several battles resolving in one
// round, and a card pool spanning more than one pack.
func TestBotsFinishMultiplayerGames(t *testing.T) {
tables := []struct {
name string
packs []string
levels []float64
}{
{"4p", []string{"turtle", "golden"}, []float64{1, 0.6, 0.25, 0.6}},
{"6p", []string{"turtle", "golden", "unicorn"}, []float64{1, 0.6, 0.25, 0, 1, 0.6}},
}
for _, tc := range tables {
t.Run(tc.name, func(t *testing.T) {
g := playBotTable(t, tc.packs, tc.levels...)
if g.Round != game.MaxRounds {
t.Errorf("game ended on round %d, want %d", g.Round, game.MaxRounds)
}
// Every seat should have fought all six rounds, so the trophies in
// play must add up to the six battles per seat-pair.
total := 0
for _, p := range g.Players {
total += p.Trophies
}
maxPossible := len(tc.levels) / 2 * (game.MaxRounds + 1) // round 6 pays double
if total > maxPossible {
t.Errorf("%d trophies awarded, only %d were available", total, maxPossible)
}
if len(g.WinnerSeats) == 0 {
t.Error("a finished game should name at least one winner")
}
})
}
}
// TestBotsFinishGoldenGame plays complete games on the Golden pack (tiers 1-3
// printed; 4-6 empty). It exercises the Trumpet/Golden Retriever/Cone Snail
// battle mechanics via SimulateBattle rollouts and the new shop effects, and
@@ -233,11 +282,11 @@ func assertModelMatches(t *testing.T, mem *Memory, opp *game.Player) {
want[c.Name]++
}
got := map[string]int{}
for _, c := range mem.Opp.Known {
for _, c := range mem.Opp(1).Known {
got[c.Name]++
}
if len(mem.Opp.Hidden) != 0 {
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp.Hidden))
if len(mem.Opp(1).Hidden) != 0 {
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp(1).Hidden))
}
for name, n := range want {
if got[name] != n {
@@ -306,8 +355,9 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
Round: game.MaxRounds, // alpha == 1: score is win-now only
MaxRounds: game.MaxRounds,
MaxPets: game.MaxPets,
Pack: game.DefaultPack,
Packs: []string{game.DefaultPack},
YouSeat: 0,
YourOpponent: 1,
Turn: 0,
PrioritySeat: 0,
DeckCounts: make([]int, game.MaxRounds+1),
@@ -319,9 +369,8 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
}
// Model a crushing opponent so every simulated battle is a loss.
mem := &Memory{}
mem.Opp.Seat = 1
for i := range 5 {
mem.Opp.Known = append(mem.Opp.Known,
mem.opp(1).Known = append(mem.opp(1).Known,
game.Card{ID: fmt.Sprintf("o%d", i), Kind: game.KindPet, Name: "Wall", Tier: 1, Power: 50})
}
@@ -360,8 +409,9 @@ func TestDecideShopKeepsHealthyBoard(t *testing.T) {
Round: 3, // mid-game: future value still carries real weight
MaxRounds: game.MaxRounds,
MaxPets: game.MaxPets,
Pack: game.DefaultPack,
Packs: []string{game.DefaultPack},
YouSeat: 0,
YourOpponent: 1,
Turn: 0,
PrioritySeat: 0,
DeckCounts: make([]int, game.MaxRounds+1),
@@ -379,9 +429,8 @@ func TestDecideShopKeepsHealthyBoard(t *testing.T) {
// A comparable opponent, so battles are genuinely competitive — selling is
// a real temptation, not a hopeless-position tie-break.
mem := &Memory{}
mem.Opp.Seat = 1
for i, n := range []string{"Dog", "Sheep", "Ant", "Cricket"} {
mem.Opp.Known = append(mem.Opp.Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed))
mem.opp(1).Known = append(mem.opp(1).Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed))
}
bot := New(1.0) // a capable bot should almost never make this trade
+22 -9
View File
@@ -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.
+4 -1
View File
@@ -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)
+112 -58
View File
@@ -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
}
+21 -10
View File
@@ -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))]
+3 -4
View File
@@ -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
}
+2 -2
View File
@@ -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)
+299 -231
View File
@@ -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
+7 -7
View File
@@ -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)
+94 -73
View File
@@ -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
}
}
}
}
+183 -54
View File
@@ -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)
}
}
+12 -4
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+415
View File
@@ -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))
}
}
+75
View File
@@ -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]
}
}
+87
View File
@@ -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)
}
+19 -17
View File
@@ -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
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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 {
+69 -29
View File
@@ -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
}
+110
View File
@@ -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)
}
+38 -13
View File
@@ -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
+15 -6
View File
@@ -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.
+3 -3
View File
@@ -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 {
+5 -3
View File
@@ -23,9 +23,11 @@ export function joinGame(code: string, name: string): Promise<Session> {
return post('/api/join', { code, name })
}
export async function fetchCatalog(pack?: string): Promise<Card[]> {
const url = pack ? `/api/catalog?pack=${encodeURIComponent(pack)}` : '/api/catalog'
const res = await fetch(url)
export async function fetchCatalog(packs?: string[]): Promise<Card[]> {
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[]
}
+12 -3
View File
@@ -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 (
<div className="centered">
<h2>Order locked in </h2>
<p className="muted">
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…`}
</p>
</div>
)
@@ -329,7 +333,12 @@ export function ArrangePhase({ view, you, send }: Props) {
return (
<div className="arrange">
<div className="shop-status">
<span className="status-hot">Arrange your battle line</span>
<span className="status-hot">
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</>}
</span>
</div>
<p className="hint">
The <strong>topmost</strong> card fights first. Food cards power up the
+135 -52
View File
@@ -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<SetStateAction<number>>
// 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 <body> 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 = (
<div className={`battle-deck battle-deck-${pos}`}>
<div
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
onMouseEnter={
lineup.length
? (e) => 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) {
<CardView card={lastEvent.card} size="sm" dead />
</div>
)}
{rockDice && lastEvent?.seat === seat && (
{rockDice && lastEvent?.seat === side && (
// The dice roll sits beside the throwing side's deck.
<div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={pos} speed={speed} />
@@ -602,7 +629,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
return (
<div className="battle" style={{ '--battle-speed': speed } as React.CSSProperties}>
<div className="battle-header">
<h2>Battle! Round {battle.round}</h2>
<h2>
{spectating ? 'Watching' : 'Battle!'} Round {battle.round}
</h2>
<div className="battle-controls">
<button className="btn btn-ghost btn-sm" onClick={restart} title="Replay from the start">
@@ -667,14 +696,43 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div>
</div>
{/* 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 && (
<div className="battle-tabs" role="tablist" aria-label="Battles this round">
{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 (
<button
key={i}
role="tab"
aria-selected={i === selected}
className={`battle-tab ${i === selected ? 'is-active' : ''} ${mine ? 'is-mine' : ''}`}
onClick={() => onSelect(i)}
title={mine ? 'Your battle' : `Watch ${names.join(' vs ')}`}
>
{mine ? 'Your fight' : names.join(' vs ')}
</button>
)
})}
</div>
)}
<div className="battle-names">
<span>{you?.name} (you)</span>
<span>
{bottom?.name}
{!spectating && ' (you)'}
</span>
<span className="battle-vs">VS</span>
<span>{opp?.name}</span>
<span>{top?.name}</span>
</div>
<div className="battlefield">
{renderSide(oppSeat, 'top')}
{renderSide(topSide, 'top')}
<div
key={centerClash ? `clash-${step}` : 'center'}
className={`battle-center ${centerClash ? 'battle-center-clash' : ''}`}
@@ -682,12 +740,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
>
<span className="battle-center-bolt"></span>
</div>
{renderSide(youSeat, 'bottom')}
{renderSide(bottomSide, 'bottom')}
</div>
{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(
<div className={`deck-peek deck-peek-${peek.pos}`} style={style}>
<div className="deck-peek-label">
{mine ? 'Your' : 'Opponents'} deck · {lineup.length} card
{whose} deck · {lineup.length} card
{lineup.length !== 1 ? 's' : ''} (first on the left)
</div>
<div className="deck-peek-cards first-left">
@@ -731,13 +790,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div>
{!draw ? (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins {'🏆'.repeat(battle.trophies)}
{view.players.find((p) => p.seat === ownBattle.winnerSeat)?.name} wins{' '}
{'🏆'.repeat(ownBattle.trophies)}
</div>
) : (
<div className="battle-result-sub">No trophies awarded</div>
)}
{/* 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 && (
<div className="battle-result-others">
<div className="battle-result-others-title">Elsewhere this round</div>
{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 (
<div key={i} className="battle-result-other">
<span>{names.join(' vs ')}</span>
<span className="muted">
{winner ? `${winner.name} ${'🏆'.repeat(b.trophies)}` : 'draw'}
</span>
</div>
)
})}
</div>
)}
{acked ? (
<p className="muted">Waiting for opponent</p>
<p className="muted">Waiting for the other players</p>
) : (
<div className="actions">
<button className="btn btn-ghost" onClick={() => setResultDismissed(true)}>
@@ -768,21 +851,21 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
// clashDamageTaken computes how much damage a seat's pet took in the clash
// at event index `idx` (its damage total there minus its total beforehand).
function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number {
function clashDamageTaken(events: BattleEvent[], idx: number, side: number): number {
const ev = events[idx]
if (ev?.type !== 'clash') return 0
const after = ev.damage?.[seat] ?? 0
const after = ev.damage?.[side] ?? 0
// Walk back to the pet's damage before this clash.
let before = 0
for (let k = idx - 1; k >= 0; k--) {
const e = events[k]
if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === seat) {
if (e.type === 'reveal' && e.seat === side && e.card?.kind === 'pet') break
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === side) {
before = e.damageAfter ?? 0
break
}
if (e.type === 'clash') {
before = e.damage?.[seat] ?? 0
before = e.damage?.[side] ?? 0
break
}
}
+7 -5
View File
@@ -5,22 +5,24 @@ import { CardView } from './CardView'
interface Props {
canGrant: boolean // shop phase — grants only land then
pack: string // active pack, so the catalog matches the game
packs: string[] // packs in play, so the catalog matches the game
send: (msg: ClientMessage) => void
}
// DebugPanel is a testing aid (server DEBUG mode only): a collapsible drawer
// listing every card in the active pack, tier by tier. Clicking one drops it
// listing every card in the packs in play, tier by tier. Clicking one drops it
// into your deck for free, off-turn.
export function DebugPanel({ canGrant, pack, send }: Props) {
export function DebugPanel({ canGrant, packs, send }: Props) {
const [open, setOpen] = useState(false)
const [catalog, setCatalog] = useState<Card[]>([])
// Joined into a stable key so a fresh array identity each render doesn't refetch.
const packKey = packs.join(',')
useEffect(() => {
fetchCatalog(pack)
fetchCatalog(packKey.split(','))
.then(setCatalog)
.catch(() => setCatalog([]))
}, [pack])
}, [packKey])
const tiers = [...new Set(catalog.map((c) => c.tier ?? 0))].sort((a, b) => a - b)
+51 -18
View File
@@ -1,31 +1,64 @@
import type { GameView } from '../types'
export function GameOver({ view, onLeave }: { view: GameView; onLeave: () => void }) {
const winner = view.winnerSeat >= 0 ? view.players[view.winnerSeat] : null
const youWon = view.winnerSeat === view.youSeat
// The title can be shared: players level on trophies whose round-by-round
// records are also identical split it (the rulebook's "share that victory!").
const winners = view.winnerSeats ?? (view.winnerSeat >= 0 ? [view.winnerSeat] : [])
const youWon = winners.includes(view.youSeat)
const shared = winners.length > 1
const winnerNames = winners
.map((s) => view.players.find((p) => p.seat === s)?.name ?? '?')
.join(' & ')
const title = !winners.length
? "It's a tie!"
: youWon
? shared
? 'You share the win!'
: 'You win!'
: shared
? `${winnerNames} share the win!`
: `${winnerNames} wins!`
// Standings run by trophies, and the countback that decided any tie is worth
// showing: which rounds each player actually took.
const standings = [...view.players].sort(
(a, b) => b.trophies - a.trophies || a.seat - b.seat,
)
return (
<div className="gameover">
<div className="gameover-emoji" aria-hidden>
{winner ? (youWon ? '🎉' : '💀') : '🤝'}
{!winners.length ? '🤝' : youWon ? '🎉' : shared ? '🤝' : '💀'}
</div>
<h1 className="gameover-title">
{winner ? (youWon ? 'You win!' : `${winner.name} wins!`) : "It's a tie!"}
</h1>
<h1 className="gameover-title">{title}</h1>
<div className="gameover-scores">
{[...view.players]
.sort((a, b) => b.trophies - a.trophies)
.map((p) => (
<div key={p.id} className={`score-line ${p.seat === view.youSeat ? 'is-you' : ''}`}>
<span className="score-name">
{p.name}
{p.seat === view.youSeat ? ' (you)' : ''}
{standings.map((p) => (
<div
key={p.id}
className={[
'score-line',
p.seat === view.youSeat ? 'is-you' : '',
winners.includes(p.seat) ? 'is-winner' : '',
]
.filter(Boolean)
.join(' ')}
>
<span className="score-name">
{winners.includes(p.seat) && <span aria-label="winner">👑 </span>}
{p.name}
{p.seat === view.youSeat ? ' (you)' : ''}
</span>
{(p.roundWins?.length ?? 0) > 0 && (
<span className="score-rounds muted" title="Rounds won">
won {p.roundWins!.map((r) => `R${r}`).join(' ')}
</span>
<span className="score-trophies">
{'🏆'.repeat(p.trophies) || '—'} <strong>{p.trophies}</strong>
</span>
</div>
))}
)}
<span className="score-trophies">
{'🏆'.repeat(p.trophies) || '—'} <strong>{p.trophies}</strong>
</span>
</div>
))}
</div>
<button className="btn btn-primary btn-big" onClick={onLeave}>
Back to the den
+77 -24
View File
@@ -6,9 +6,13 @@ const BOT_LEVELS: { value: string; label: string; blurb: string }[] = [
{ value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' },
]
// Lobby is the pre-game setup screen. The host picks a pack, fills the second
// seat with a bot or a friend, can remove players, and starts the game.
// Lobby is the pre-game setup screen. The host picks the packs, fills seats
// with friends or computer players, can remove anyone, and starts the game.
// Everyone else sees the same lineup read-only and waits for the host.
//
// Two rules gate the start button, and the host needs to see both at a glance:
// the table has to be an even size (players battle in pairs every round), and
// the rulebook asks for one card pack per pair.
export function Lobby({
view,
send,
@@ -17,10 +21,33 @@ export function Lobby({
send: (m: ClientMessage) => void
}) {
const isHost = view.youSeat === view.hostSeat
const openSeats = view.maxPlayers - view.players.length
const selectedPlayable =
view.packs.find((p) => p.id === view.pack)?.playable ?? false
const canStart = view.players.length >= view.minPlayers && selectedPlayable
const seated = view.players.length
const openSeats = view.maxPlayers - seated
const packs = view.packs ?? []
const evenTable = view.playerCounts.includes(seated)
const enoughPacks = packs.length >= view.packsNeeded
const allPlayable = packs.every(
(id) => view.packCatalog.find((p) => p.id === id)?.playable ?? false,
)
const canStart = evenTable && enoughPacks && allPlayable
// Toggling a pack sends the whole new selection — the server validates it as
// a set, so there's no partial state to get stuck in.
const togglePack = (id: string) => {
const next = packs.includes(id) ? packs.filter((p) => p !== id) : [...packs, id]
if (next.length === 0) return // there's always at least one pack in play
send({ type: 'setPacks', packs: next })
}
// The one thing standing between the host and a game, in their words.
const blocker = !evenTable
? seated < view.minPlayers
? 'Waiting for a second player…'
: `${seated} players cant pair off — add or remove a seat`
: !enoughPacks
? `${seated} players needs ${view.packsNeeded} packs shuffled together`
: null
return (
<div className="lobby">
@@ -38,41 +65,66 @@ export function Lobby({
</div>
<section className="lobby-section">
<h3 className="lobby-section-title">Card pack</h3>
<h3 className="lobby-section-title">
Card packs ({packs.length}/{view.packCatalog.length})
</h3>
<p className="muted lobby-note">
{view.packsNeeded > 1
? `Every packs tier 1 cards shuffle together, tier 2 together, and so on. ${seated} players needs at least ${view.packsNeeded}.`
: 'Pick one, or combine several for a deeper shop.'}
</p>
<div className="pack-grid">
{view.packs.map((pack) => {
const selected = pack.id === view.pack
const disabled = !pack.playable || !isHost
{view.packCatalog.map((pack) => {
const selected = packs.includes(pack.id)
const onlyOne = selected && packs.length === 1
return (
<button
key={pack.id}
className={`pack-card ${selected ? 'is-selected' : ''} ${
pack.playable ? '' : 'is-locked'
}`}
disabled={disabled}
title={pack.playable ? pack.name : `${pack.name} — coming soon`}
onClick={() => send({ type: 'setPack', pack: pack.id })}
disabled={!pack.playable || !isHost || onlyOne}
title={
!pack.playable
? `${pack.name} — coming soon`
: onlyOne
? 'At least one pack has to stay in play'
: selected
? `Remove ${pack.name}`
: `Add ${pack.name}`
}
onClick={() => togglePack(pack.id)}
>
<span className="pack-emoji" aria-hidden>
{pack.emoji}
</span>
<span className="pack-name">{pack.name}</span>
<span className="pack-tag">
{pack.playable ? (selected ? 'Selected' : 'Available') : 'Coming soon'}
{!pack.playable ? 'Coming soon' : selected ? '✓ In play' : 'Add'}
</span>
</button>
)
})}
</div>
{!isHost && (
<p className="muted lobby-note">Only the host can change the pack.</p>
<p className="muted lobby-note">Only the host can change the packs.</p>
)}
{isHost && !enoughPacks && (
<p className="lobby-warn">
Add {view.packsNeeded - packs.length} more pack
{view.packsNeeded - packs.length !== 1 ? 's' : ''} to seat {seated} players.
</p>
)}
</section>
<section className="lobby-section">
<h3 className="lobby-section-title">
Players ({view.players.length}/{view.maxPlayers})
Players ({seated}/{view.maxPlayers})
</h3>
<p className="muted lobby-note">
Play happens in pairs, so the table needs {view.playerCounts.join(', ')} players.
Fill any odd seat with a computer player.
</p>
<ul className="seat-list">
{view.players.map((p) => (
<SeatRow
@@ -85,12 +137,13 @@ export function Lobby({
/>
))}
{Array.from({ length: openSeats }).map((_, i) => (
<li key={`open-${i}`} className="seat-row seat-open">
{openSeats > 0 && (
<li className="seat-row seat-open">
{isHost ? (
<div className="seat-open-host">
<span className="muted">
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:
</span>
<div className="seat-bot-picker">
{BOT_LEVELS.map((b) => (
@@ -106,10 +159,12 @@ export function Lobby({
</div>
</div>
) : (
<span className="muted">Waiting for the host to fill this seat</span>
<span className="muted">
{openSeats} open seat{openSeats !== 1 ? 's' : ''} waiting for the host
</span>
)}
</li>
))}
)}
</ul>
</section>
@@ -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'}
</button>
) : (
<p className="lobby-waiting muted">Waiting for the host to start the game</p>
+10 -4
View File
@@ -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) {
<div className="shop-status">
{pending && !myPending ? (
<span className="muted">
{opponent?.name ?? 'Opponent'} is tripling up a tier
{acting?.name ?? 'Someone'} is tripling up a tier
</span>
) : you.ready ? (
<span className="muted">
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
</span>
) : myTurn && overPets ? (
<span className="status-hot">
+88 -30
View File
@@ -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<SetStateAction<number>> = (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<string, Card>()
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<number, Card[]>()
for (const b of battles) {
;(b.seats ?? []).forEach((seat, side) => {
const line = b.lineups?.[side]
if (line?.length) lastLineups.set(seat, line)
})
}
return (
<div className="table">
<header className="topbar">
@@ -144,11 +177,21 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<DieFace value={view.round} className="topbar-die" />
</div>
)}
<div className="topbar-players">
{/* 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. */}
<div className={`topbar-players count-${view.players.length}`}>
{view.players.map((p) => (
<div
key={p.id}
className={`topbar-player ${p.seat === view.youSeat ? 'is-you' : ''}`}
className={[
'topbar-player',
p.seat === view.youSeat ? 'is-you' : '',
p.seat === view.yourOpponent ? 'is-rival' : '',
view.phase === 'shop' && p.seat === view.turn ? 'is-turn' : '',
]
.filter(Boolean)
.join(' ')}
>
{p.isBot ? (
<span className="bot-dot" title="Computer player">
@@ -158,16 +201,22 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<span className={`conn-dot ${p.connected ? 'on' : 'off'}`} />
)}
<span className="topbar-name">{p.name}</span>
{p.seat === view.yourOpponent && view.phase !== 'lobby' && (
<span className="chip chip-rival" title="You fight them this round">
</span>
)}
<span className="chip">🏆 {p.trophies}</span>
{/* 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 && (
<span className="chip">🪙 {p.coins}</span>
)}
{/* 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 && (
<button
className={`chip chip-btn ${deckPeek?.seat === p.seat ? 'is-active' : ''}`}
title="See their deck from last round's battle"
@@ -231,8 +280,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.phase === 'lobby' && <Lobby view={view} send={send} />}
{view.phase === 'shop' && <ShopPhase view={view} you={you} send={send} />}
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
{view.phase === 'battle' && (
<BattlePhase view={view} send={send} step={step} setStep={setStep} />
{view.phase === 'battle' && battle && (
<BattlePhase
view={view}
send={send}
step={step}
setStep={setStep}
battle={battle}
battles={battles}
selected={selected}
onSelect={selectBattle}
/>
)}
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
</main>
@@ -265,13 +323,13 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)}
{error && <div className="toast">{error}</div>}
{view.debug && (
<DebugPanel canGrant={view.phase === 'shop'} pack={view.pack} send={send} />
<DebugPanel canGrant={view.phase === 'shop'} packs={view.packs} send={send} />
)}
{deckPeek &&
view.phase === 'shop' &&
(() => {
const lineup = view.battle?.lineups?.[deckPeek.seat] ?? []
const lineup = lastLineups.get(deckPeek.seat) ?? []
if (!lineup.length) return null
const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent'
return createPortal(
+143
View File
@@ -488,6 +488,43 @@ h3 {
0 1px 3px rgba(0, 0, 0, 0.35);
}
/* This round's opponent gets a cooler outline than your own gold one — enough
to pick them out of five other seats without competing with it. */
.topbar-player.is-rival {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 0 0 2px rgba(255, 120, 120, 0.75),
0 1px 3px rgba(0, 0, 0, 0.35);
}
/* Whoever the shop is waiting on, so a six-seat rail still shows the turn. */
.topbar-player.is-turn {
background: linear-gradient(180deg, rgba(255, 214, 102, 0.22), rgba(0, 0, 0, 0.28));
}
/* Four or six pins won't fit at full size, so the rail tightens as it fills.
Names ellipsis rather than wrap, keeping the topbar one row deep. */
.topbar-players.count-4 .topbar-player,
.topbar-players.count-6 .topbar-player {
padding: 4px 9px;
font-size: 0.82rem;
gap: 5px;
min-width: 0;
}
/* Names still have to fit, but not so tight that "Robo Rookie" and "Robo
Rival" both collapse to "Robo R…" and the seats stop being tellable apart. */
.topbar-players.count-6 .topbar-name {
max-width: 12ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-rival {
filter: drop-shadow(0 0 3px rgba(255, 120, 120, 0.8));
}
.topbar-name {
font-weight: 800;
}
@@ -921,6 +958,14 @@ h3 {
font-size: 1.05rem;
}
/* What's still standing between the host and a startable game. */
.lobby-warn {
text-align: center;
font-size: 0.9rem;
font-weight: 700;
color: #ffcf8f;
}
/* ---------- pack picker ---------- */
.pack-grid {
@@ -2054,6 +2099,56 @@ h3 {
text-align: center;
}
/* With four or six players a round holds several battles at once; these tabs
switch the arena between them. They read as folder tabs sitting on top of
the battlefield, with your own fight marked. */
.battle-tabs {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.battle-tab {
border: 0;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
color: inherit;
padding: 5px 14px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
opacity: 0.72;
transition:
opacity 0.15s ease,
background 0.15s ease;
}
.battle-tab:hover {
opacity: 1;
}
.battle-tab.is-active {
opacity: 1;
background: rgba(255, 255, 255, 0.14);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 1px 3px rgba(0, 0, 0, 0.35);
}
.battle-tab.is-mine {
color: var(--gold);
}
.battle-tab.is-mine.is-active {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 0 0 2px var(--gold);
}
.battle-names {
display: flex;
justify-content: center;
@@ -2844,6 +2939,33 @@ h3 {
font-weight: 700;
}
/* How the rest of the field did this round, under your own result. */
.battle-result-others {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 12px;
margin-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
font-size: 0.88rem;
}
.battle-result-others-title {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.6;
}
.battle-result-other {
display: flex;
justify-content: space-between;
gap: 16px;
font-weight: 600;
}
/* The toolbar's "advance the round" button stands out from the ghost controls. */
.battle-next {
margin-left: 2px;
@@ -2889,6 +3011,7 @@ h3 {
.score-line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
font-size: 1.1rem;
@@ -2901,6 +3024,26 @@ h3 {
font-weight: 900;
}
.score-line.is-winner {
background: rgba(255, 214, 102, 0.1);
border-radius: 8px;
padding: 4px 8px;
margin: 0 -8px;
}
/* The rounds a player took — the countback that settles a tie, spelled out. */
.score-rounds {
font-size: 0.78rem;
font-weight: 600;
margin-left: auto;
margin-right: 8px;
white-space: nowrap;
}
.score-name {
white-space: nowrap;
}
/* ---------- toasts & banners ---------- */
.toast {
+24 -6
View File
@@ -25,6 +25,7 @@ export interface PlayerView {
seat: number
coins: number
trophies: number
roundWins?: number[] // rounds this player won a battle in (public)
ready: boolean
connected: boolean
isBot?: boolean
@@ -100,15 +101,25 @@ export interface BattleEvent {
text?: string
}
// A round runs one battle per pairing, so a six-player round has three of
// these. Everything inside is indexed by *side* — 0 or 1 within this battle —
// rather than by seat at the table, including BattleEvent.seat/target. `seats`
// maps the two apart. winnerSeat is the exception: it's a real seat.
export interface BattleResult {
round: number
seats: number[] // the two seats fighting, first player first
stackSizes: number[]
lineups?: Card[][] // each seat's arranged deck (top first); public for peeking
lineups?: Card[][] // each side's arranged deck (top first); public for peeking
events: BattleEvent[] | null
winnerSeat: number
trophies: number
}
// sideOf maps a seat to its side index in a battle, or -1 if it wasn't in it.
export function sideOf(battle: BattleResult, seat: number): number {
return battle.seats?.indexOf(seat) ?? -1
}
export interface LogEntry {
seq: number
round: number
@@ -135,27 +146,34 @@ export interface GameView {
round: number
maxRounds: number
maxPets: number
pack: string
packs: PackInfo[]
packs: string[] // selected packs, shuffled together
packCatalog: PackInfo[] // the choices offered in the lobby
packsNeeded: number // packs the current table size requires
hostSeat: number
minPlayers: number
maxPlayers: number
playerCounts: number[] // table sizes a game can start at (2, 4, 6)
youSeat: number
turn: number
prioritySeat: number
shopRow: Card[]
deckCounts: number[]
players: PlayerView[]
matchups?: [number, number][] // this round's battle pairings (public)
yourOpponent: number // the seat you face this round, or -1
pending?: PendingTrade
pendingReveal?: PendingReveal
pendingSacrifice?: PendingSacrifice
battle?: BattleResult
winnerSeat: number
battle?: BattleResult // your own battle this round
battles?: BattleResult[] // every table's, all replayable
winnerSeat: number // outright winner, or -1 when shared
winnerSeats?: number[] // everyone holding the title
log?: LogEntry[]
debug?: boolean // server DEBUG mode: unlocks the buy-any-card panel
}
export type ClientMessage =
| { type: 'setPack'; pack: string }
| { type: 'setPacks'; packs: string[] }
| { type: 'addBot'; difficulty: string }
| { type: 'removePlayer'; target: string }
| { type: 'start' }
+9 -7
View File
@@ -2,15 +2,17 @@ import { useEffect, useState } from 'react'
import { fetchCatalog } from './api'
import type { Card } from './types'
// useCatalog loads the representative card for every pet and food in a pack and
// caches the result per pack. It's used to look up cards by name (e.g. to
// preview pets mentioned in the event log), even ones not currently in view.
export function useCatalog(pack?: string): Card[] {
// useCatalog loads the representative card for every pet and food across the
// packs in play. It's used to look up cards by name (e.g. to preview pets
// mentioned in the event log), even ones not currently in view. The packs are
// joined into a stable key so a new array identity each render doesn't refetch.
export function useCatalog(packs?: string[]): Card[] {
const [cards, setCards] = useState<Card[]>([])
const key = (packs ?? []).join(',')
useEffect(() => {
if (!pack) return
if (!key) return
let cancelled = false
fetchCatalog(pack)
fetchCatalog(key.split(','))
.then((c) => {
if (!cancelled) setCards(c)
})
@@ -20,6 +22,6 @@ export function useCatalog(pack?: string): Card[] {
return () => {
cancelled = true
}
}, [pack])
}, [key])
return cards
}