Add lobby and pack concept.

This commit is contained in:
Greyson Parrelli
2026-07-23 22:01:54 -04:00
parent 9d9acc4577
commit e74f983470
18 changed files with 665 additions and 111 deletions
+71 -7
View File
@@ -84,8 +84,11 @@ type PendingTrade struct {
// Game is the complete authoritative state. It is a pure state machine: no
// goroutines, no clocks, no I/O. Callers are responsible for locking.
type Game struct {
ID string `json:"id"`
Code string `json:"code"`
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"`
Phase Phase `json:"phase"`
Round int `json:"round"` // 1-based
Players []*Player `json:"players"`
@@ -168,18 +171,27 @@ func New() *Game {
g := &Game{
ID: randomID(16),
Code: randomCode(),
Pack: DefaultPack,
Phase: PhaseLobby,
WinnerSeat: -1,
}
g.buildDecks()
return g
}
// 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.
func (g *Game) buildDecks() {
g.buildShopDecks()
for i := range g.ShopDecks {
shuffle(g.ShopDecks[i])
}
return g
}
// AddPlayer seats a new player during the lobby phase and returns them (with
// their secret token). The game starts automatically once full.
// their secret token). The host starts the game explicitly once the lobby is
// ready (see StartGame).
func (g *Game) AddPlayer(name string) (*Player, error) {
if g.Phase != PhaseLobby {
return nil, fmt.Errorf("%w: game already started", ErrWrongPhase)
@@ -197,9 +209,6 @@ func (g *Game) AddPlayer(name string) (*Player, error) {
Seat: len(g.Players),
}
g.Players = append(g.Players, p)
if len(g.Players) == MaxPlayers {
g.start()
}
return p, nil
}
@@ -216,6 +225,61 @@ 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 {
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)
}
if !pack.Playable {
return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction)
}
g.Pack = pack.ID
g.buildDecks()
return nil
}
// RemovePlayer drops a seat from the lobby. The host (seat 0) can't be
// removed. Remaining players are re-seated so seats stay contiguous.
func (g *Game) RemovePlayer(targetID string) error {
if g.Phase != PhaseLobby {
return fmt.Errorf("%w: game already started", ErrWrongPhase)
}
idx := slices.IndexFunc(g.Players, func(p *Player) bool { return p.ID == targetID })
if idx < 0 {
return fmt.Errorf("%w: no such player", ErrInvalidAction)
}
if idx == 0 {
return fmt.Errorf("%w: the host can't be removed", ErrInvalidAction)
}
g.Players = slices.Delete(g.Players, idx, idx+1)
for i, p := range g.Players {
p.Seat = i
}
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
// 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 {
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)
}
g.start()
return nil
}
// PlayerByID returns the player, or nil.
func (g *Game) PlayerByID(id string) *Player {
for _, p := range g.Players {