Add lobby and pack concept.
This commit is contained in:
@@ -21,6 +21,9 @@ func playBotGame(t *testing.T, levelA, levelB float64) *game.Game {
|
||||
if err != nil {
|
||||
t.Fatalf("AddBot B: %v", 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: {}}
|
||||
|
||||
@@ -102,6 +105,9 @@ func TestObserveTracksOpponentDeck(t *testing.T) {
|
||||
g := game.New()
|
||||
pa, _ := g.AddBot("Bot A", 1)
|
||||
pb, _ := g.AddBot("Bot B", 1)
|
||||
if err := g.StartGame(); err != nil {
|
||||
t.Fatalf("StartGame: %v", err)
|
||||
}
|
||||
mem := &Memory{}
|
||||
obs := func() {
|
||||
v := g.ViewFor(pa.ID)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestE2EBotGame(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
resp, err := http.Post(ts.URL+"/api/games", "application/json",
|
||||
bytes.NewBufferString(`{"name":"Human","bot":"easy"}`))
|
||||
bytes.NewBufferString(`{"name":"Human"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -51,9 +51,23 @@ func TestE2EBotGame(t *testing.T) {
|
||||
}
|
||||
defer ws.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
v := readState(t, ctx, ws)
|
||||
// The host opens in the lobby, adds a bot, then starts the game — the
|
||||
// unified lobby flow a real client drives.
|
||||
if p := readState(t, ctx, ws).Phase; p != game.PhaseLobby {
|
||||
t.Fatalf("phase = %s, want lobby on create", p)
|
||||
}
|
||||
send(t, ctx, ws, map[string]any{"type": "addBot", "difficulty": "easy"})
|
||||
send(t, ctx, ws, map[string]any{"type": "start"})
|
||||
|
||||
var v *game.View
|
||||
startDeadline := time.Now().Add(5 * time.Second)
|
||||
for (v == nil || v.Phase != game.PhaseShop) && time.Now().Before(startDeadline) {
|
||||
rctx, rcancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
v = readState(t, rctx, ws)
|
||||
rcancel()
|
||||
}
|
||||
if v.Phase != game.PhaseShop {
|
||||
t.Fatalf("phase = %s, want shop (bot fills the lobby instantly)", v.Phase)
|
||||
t.Fatalf("phase = %s, want shop after host starts", v.Phase)
|
||||
}
|
||||
var bot *game.PlayerView
|
||||
for i := range v.Players {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
@@ -20,6 +21,28 @@ var botDifficulty = map[string]struct {
|
||||
"hard": {1.00, "Robo Ace"},
|
||||
}
|
||||
|
||||
// requireHost authorizes a lobby-management action: only the host (seat 0)
|
||||
// may set the pack, add or remove players, or start the game. Identity lives
|
||||
// at this trust boundary, keeping the game engine free of it.
|
||||
func requireHost(g *game.Game, playerID string) error {
|
||||
if len(g.Players) == 0 || g.Players[0].ID != playerID {
|
||||
return errors.New("only the host can do that")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -124,38 +124,20 @@ type joinResponse struct {
|
||||
func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
// Bot, when set, fills the other seat with a computer player:
|
||||
// "easy" | "medium" | "hard".
|
||||
Bot string `json:"bot"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
var bot struct {
|
||||
level float64
|
||||
name string
|
||||
}
|
||||
if body.Bot != "" {
|
||||
var ok bool
|
||||
bot, ok = botDifficulty[body.Bot]
|
||||
if !ok {
|
||||
httpError(w, http.StatusBadRequest, "unknown bot difficulty")
|
||||
return
|
||||
}
|
||||
}
|
||||
// The creator becomes the host (seat 0). They then set up the lobby —
|
||||
// choosing a pack, adding a bot, or waiting for a friend — and start the
|
||||
// game when ready.
|
||||
g := game.New()
|
||||
p, err := g.AddPlayer(strings.TrimSpace(body.Name))
|
||||
if err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if body.Bot != "" {
|
||||
if _, err := g.AddBot(bot.name, bot.level); err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
|
||||
s.mu.Lock()
|
||||
s.rooms[g.ID] = r
|
||||
|
||||
+25
-6
@@ -22,12 +22,15 @@ type client struct {
|
||||
// clientMessage is anything a player can ask the server to do. Type decides
|
||||
// which other fields matter.
|
||||
type clientMessage struct {
|
||||
Type string `json:"type"`
|
||||
Row int `json:"row"` // buy
|
||||
Cards []string `json:"cards"` // discard, trade
|
||||
Pick int `json:"pick"` // tradeChoose
|
||||
Order []string `json:"order"` // arrange
|
||||
Name string `json:"name"` // debugAdd
|
||||
Type string `json:"type"`
|
||||
Row int `json:"row"` // buy
|
||||
Cards []string `json:"cards"` // discard, trade
|
||||
Pick int `json:"pick"` // tradeChoose
|
||||
Order []string `json:"order"` // arrange
|
||||
Name string `json:"name"` // debugAdd
|
||||
Pack string `json:"pack"` // setPack
|
||||
Difficulty string `json:"difficulty"` // addBot
|
||||
Target string `json:"target"` // removePlayer (player ID)
|
||||
}
|
||||
|
||||
type serverMessage struct {
|
||||
@@ -119,6 +122,22 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
|
||||
g := r.game
|
||||
var err error
|
||||
switch msg.Type {
|
||||
case "setPack":
|
||||
if err = requireHost(g, c.playerID); err == nil {
|
||||
err = g.SetPack(msg.Pack)
|
||||
}
|
||||
case "addBot":
|
||||
if err = requireHost(g, c.playerID); err == nil {
|
||||
err = addBot(g, msg.Difficulty)
|
||||
}
|
||||
case "removePlayer":
|
||||
if err = requireHost(g, c.playerID); err == nil {
|
||||
err = g.RemovePlayer(msg.Target)
|
||||
}
|
||||
case "start":
|
||||
if err = requireHost(g, c.playerID); err == nil {
|
||||
err = g.StartGame()
|
||||
}
|
||||
case "buy":
|
||||
err = g.Buy(c.playerID, msg.Row)
|
||||
case "sell":
|
||||
|
||||
Reference in New Issue
Block a user