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
+17 -3
View File
@@ -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 {
+23
View File
@@ -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
+3 -21
View File
@@ -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
View File
@@ -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":