Add support for up to 6 players.
This commit is contained in:
+183
-54
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user