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