Add lobby and pack concept.
This commit is contained in:
@@ -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