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
+7 -1
View File
@@ -17,8 +17,11 @@ func testGame(t *testing.T) (*Game, *Player, *Player) {
if err != nil {
t.Fatal(err)
}
if err := g.StartGame(); err != nil {
t.Fatal(err)
}
if g.Phase != PhaseShop {
t.Fatalf("expected shop phase after both players join, got %s", g.Phase)
t.Fatalf("expected shop phase after the game starts, got %s", g.Phase)
}
// Pin the (otherwise random) priority token to seat 0 so tests are
// deterministic: seat 0 shops first and wins battle simultaneity races.
@@ -635,6 +638,9 @@ func TestPriorityTokenInitialAssignment(t *testing.T) {
if _, err := g.AddPlayer("Bob"); err != nil {
t.Fatal(err)
}
if err := g.StartGame(); err != nil {
t.Fatal(err)
}
if g.PrioritySeat < 0 || g.PrioritySeat >= len(g.Players) {
t.Fatalf("priority token should start on a real seat, got %d", g.PrioritySeat)
}
+15 -4
View File
@@ -482,12 +482,23 @@ func (g *Game) newCardID() string {
return fmt.Sprintf("c%d", g.NextCardID)
}
// buildShopDecks creates all six tier decks (unshuffled).
// packTiers returns the pet and food templates for a pack, tier by tier. Only
// the Turtle pack has real data today; the other (not-yet-playable) packs fall
// back to it so this is the single seam future packs plug their cards into.
func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTemplate) {
switch pack {
default: // turtle (and the placeholder packs, until they ship)
return &petTiers, &foodTiers
}
}
// buildShopDecks creates all six tier decks (unshuffled) for the game's pack.
func (g *Game) buildShopDecks() {
pets, foods := packTiers(g.Pack)
g.ShopDecks = make([][]Card, MaxRounds)
for tierIdx := range petTiers {
for tierIdx := range pets {
var deck []Card
for _, t := range petTiers[tierIdx] {
for _, t := range pets[tierIdx] {
for _, suit := range t.Suits {
deck = append(deck, Card{
ID: g.newCardID(),
@@ -501,7 +512,7 @@ func (g *Game) buildShopDecks() {
})
}
}
for _, f := range foodTiers[tierIdx] {
for _, f := range foods[tierIdx] {
for range f.Copies {
deck = append(deck, Card{
ID: g.newCardID(),
+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 {
+14 -6
View File
@@ -18,7 +18,7 @@ func deckIDs(p *Player, filter func(Card) bool) []string {
func current(g *Game) *Player { return g.Players[g.Turn] }
func TestLobbyStartsWhenFull(t *testing.T) {
func TestLobbyManualStart(t *testing.T) {
g := New()
if g.Phase != PhaseLobby {
t.Fatalf("new game should be in lobby, got %s", g.Phase)
@@ -26,18 +26,26 @@ func TestLobbyStartsWhenFull(t *testing.T) {
if _, err := g.AddPlayer("Alice"); err != nil {
t.Fatal(err)
}
if g.Phase != PhaseLobby {
t.Fatal("game should wait for second player")
// One player isn't enough to start.
if err := g.StartGame(); err == nil {
t.Fatal("start should be rejected with fewer than MinPlayers")
}
if _, err := g.AddPlayer("Bob"); err != nil {
t.Fatal(err)
}
if g.Phase != PhaseShop || g.Round != 1 {
t.Fatalf("game should start round 1 shop, got phase=%s round=%d", g.Phase, g.Round)
}
if _, err := g.AddPlayer("Carol"); err == nil {
t.Fatal("third player should be rejected while MaxPlayers=2")
}
// The lobby stays open until the host explicitly starts.
if g.Phase != PhaseLobby {
t.Fatalf("game should wait in the lobby for the host to start, got %s", g.Phase)
}
if err := g.StartGame(); err != nil {
t.Fatal(err)
}
if g.Phase != PhaseShop || g.Round != 1 {
t.Fatalf("game should start round 1 shop, got phase=%s round=%d", g.Phase, g.Round)
}
for _, p := range g.Players {
if p.Coins != CoinsPerRound {
t.Fatalf("player should start with %d coins", CoinsPerRound)
+35
View File
@@ -0,0 +1,35 @@
package game
// Card packs are the selectable sets of pets and food a game is played with.
// Only the Turtle pack ships with real card data today; Golden and Unicorn are
// declared here as infrastructure (shown but not yet playable) so the lobby,
// views, and deck-building all have a single source of truth to grow into.
// PackInfo describes one selectable pack. Playable gates whether a lobby may
// choose it and start a game with it.
type PackInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Emoji string `json:"emoji"`
Playable bool `json:"playable"`
}
// DefaultPack is the pack a freshly created game starts on.
const DefaultPack = "turtle"
// Packs is the ordered catalog of packs shown in the lobby.
var Packs = []PackInfo{
{ID: "turtle", Name: "Turtle Pack", Emoji: "🐢", Playable: true},
{ID: "golden", Name: "Golden Pack", Emoji: "🥇", Playable: false},
{ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: false},
}
// packByID looks up a pack, returning false if the id is unknown.
func packByID(id string) (PackInfo, bool) {
for _, p := range Packs {
if p.ID == id {
return p, true
}
}
return PackInfo{}, false
}
+15 -2
View File
@@ -24,8 +24,16 @@ type View struct {
Round int `json:"round"`
MaxRounds int `json:"maxRounds"`
MaxPets int `json:"maxPets"`
YouSeat int `json:"youSeat"`
Turn int `json:"turn"`
// 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.
PrioritySeat int `json:"prioritySeat"`
ShopRow []Card `json:"shopRow"`
@@ -52,6 +60,11 @@ func (g *Game) ViewFor(playerID string) View {
Round: g.Round,
MaxRounds: MaxRounds,
MaxPets: MaxPets,
Pack: g.Pack,
Packs: Packs,
HostSeat: 0,
MinPlayers: MinPlayers,
MaxPlayers: MaxPlayers,
YouSeat: -1,
Turn: g.Turn,
PrioritySeat: g.PrioritySeat,