From e74f983470f1248dacceee40aef2d3c8ade98278 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 22:01:54 -0400 Subject: [PATCH] Add lobby and pack concept. --- internal/ai/ai_test.go | 6 + internal/game/battle_test.go | 8 +- internal/game/cards.go | 19 ++- internal/game/game.go | 78 ++++++++++-- internal/game/game_test.go | 20 ++- internal/game/packs.go | 35 ++++++ internal/game/view.go | 17 ++- internal/server/bot_e2e_test.go | 20 ++- internal/server/bots.go | 23 ++++ internal/server/server.go | 24 +--- internal/server/ws.go | 31 ++++- web/src/api.ts | 4 +- web/src/components/CardView.tsx | 33 ++++- web/src/components/Home.tsx | 29 +---- web/src/components/Lobby.tsx | 180 +++++++++++++++++++++++++-- web/src/components/Table.tsx | 20 ++- web/src/styles.css | 213 +++++++++++++++++++++++++++++--- web/src/types.ts | 16 +++ 18 files changed, 665 insertions(+), 111 deletions(-) create mode 100644 internal/game/packs.go diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go index bc3fa02..9b6dbe5 100644 --- a/internal/ai/ai_test.go +++ b/internal/ai/ai_test.go @@ -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) diff --git a/internal/game/battle_test.go b/internal/game/battle_test.go index 1bc6281..3f741f6 100644 --- a/internal/game/battle_test.go +++ b/internal/game/battle_test.go @@ -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) } diff --git a/internal/game/cards.go b/internal/game/cards.go index 145052e..bf66d4d 100644 --- a/internal/game/cards.go +++ b/internal/game/cards.go @@ -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(), diff --git a/internal/game/game.go b/internal/game/game.go index b0a65e6..d40280d 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -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 { diff --git a/internal/game/game_test.go b/internal/game/game_test.go index a17535d..b356a1b 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -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) diff --git a/internal/game/packs.go b/internal/game/packs.go new file mode 100644 index 0000000..69bf32e --- /dev/null +++ b/internal/game/packs.go @@ -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 +} diff --git a/internal/game/view.go b/internal/game/view.go index 16269f5..98157b3 100644 --- a/internal/game/view.go +++ b/internal/game/view.go @@ -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, diff --git a/internal/server/bot_e2e_test.go b/internal/server/bot_e2e_test.go index 800f3d0..b4ca5ab 100644 --- a/internal/server/bot_e2e_test.go +++ b/internal/server/bot_e2e_test.go @@ -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 { diff --git a/internal/server/bots.go b/internal/server/bots.go index b6e8a22..cca80a3 100644 --- a/internal/server/bots.go +++ b/internal/server/bots.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index e99709f..e3ebd14 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/server/ws.go b/internal/server/ws.go index b23c9aa..1fe8840 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -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": diff --git a/web/src/api.ts b/web/src/api.ts index 1a3c185..09fe012 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -15,8 +15,8 @@ async function post(path: string, body: unknown): Promise { export type BotDifficulty = 'easy' | 'medium' | 'hard' -export function createGame(name: string, bot?: BotDifficulty): Promise { - return post('/api/games', bot ? { name, bot } : { name }) +export function createGame(name: string): Promise { + return post('/api/games', { name }) } export function joinGame(code: string, name: string): Promise { diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index 1bd9c89..8cfafd7 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -30,6 +30,31 @@ function useFitText(dep: unknown) { return ref } +// renderEffect bolds each effect's trigger and renders the colon separating it +// from the action as an arrow, so "Sell: add 1 extra Apple" shows "Sell →" in +// bold. Cards can list several effects joined by " · "; each gets its own +// bolded trigger. +function renderEffect(text: string) { + return text.split(' · ').map((segment, i) => { + const colon = segment.indexOf(':') + const node = + colon === -1 ? ( + segment + ) : ( + <> + {segment.slice(0, colon)} → + {segment.slice(colon + 1)} + + ) + return ( + + {i > 0 && ' · '} + {node} + + ) + }) +} + interface Props { card: Card size?: 'sm' | 'md' | 'lg' @@ -121,7 +146,7 @@ export function CardView({ <> {card.effectText && (
- {card.effectText} + {renderEffect(card.effectText)}
)}
@@ -131,7 +156,11 @@ export function CardView({ ) : (
- {card.effectText ?? (card.food === 'apple' ? '+1 power (this battle)' : '')} + {card.effectText + ? renderEffect(card.effectText) + : card.food === 'apple' + ? '+1 power (this battle)' + : ''}
)} {selected &&
} diff --git a/web/src/components/Home.tsx b/web/src/components/Home.tsx index e7c412a..b4b4c74 100644 --- a/web/src/components/Home.tsx +++ b/web/src/components/Home.tsx @@ -1,14 +1,7 @@ import { useState } from 'react' import { createGame, joinGame } from '../api' -import type { BotDifficulty } from '../api' import type { Session } from '../types' -const BOT_LEVELS: { value: BotDifficulty; label: string; blurb: string }[] = [ - { value: 'easy', label: '🐣 Easy', blurb: 'Learns you the ropes' }, - { value: 'medium', label: '🐺 Medium', blurb: 'Puts up a fight' }, - { value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' }, -] - // Home is the create/join screen shown when there's no active session. export function Home({ onSession }: { onSession: (s: Session) => void }) { const [name, setName] = useState('') @@ -56,24 +49,10 @@ export function Home({ onSession }: { onSession: (s: Session) => void }) { > Host a new game - -
- or challenge the computer -
- -
- {BOT_LEVELS.map((b) => ( - - ))} -
+

+ Set up your lobby next — pick a pack, add a computer opponent, or + invite a friend. +

or join a friend diff --git a/web/src/components/Lobby.tsx b/web/src/components/Lobby.tsx index df37075..0542b57 100644 --- a/web/src/components/Lobby.tsx +++ b/web/src/components/Lobby.tsx @@ -1,20 +1,174 @@ -import type { GameView } from '../types' +import type { ClientMessage, GameView, PlayerView } from '../types' + +const BOT_LEVELS: { value: string; label: string; blurb: string }[] = [ + { value: 'easy', label: '🐣 Easy', blurb: 'Learns you the ropes' }, + { value: 'medium', label: '🐺 Medium', blurb: 'Puts up a fight' }, + { value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' }, +] + +// Lobby is the pre-game setup screen. The host picks a pack, fills the second +// seat with a bot or a friend, can remove players, and starts the game. +// Everyone else sees the same lineup read-only and waits for the host. +export function Lobby({ + view, + send, +}: { + view: GameView + send: (m: ClientMessage) => void +}) { + const isHost = view.youSeat === view.hostSeat + const openSeats = view.maxPlayers - view.players.length + const selectedPlayable = + view.packs.find((p) => p.id === view.pack)?.playable ?? false + const canStart = view.players.length >= view.minPlayers && selectedPlayable -export function Lobby({ view }: { view: GameView }) { return (
-
- 🐟 +

Game lobby

+ +
+ Invite code + {view.code} +
-

Waiting for an opponent…

-

Share this code so a friend can join:

-
{view.code}
- + +
+

Card pack

+
+ {view.packs.map((pack) => { + const selected = pack.id === view.pack + const disabled = !pack.playable || !isHost + return ( + + ) + })} +
+ {!isHost && ( +

Only the host can change the pack.

+ )} +
+ +
+

+ Players ({view.players.length}/{view.maxPlayers}) +

+
    + {view.players.map((p) => ( + send({ type: 'removePlayer', target: p.id })} + /> + ))} + + {Array.from({ length: openSeats }).map((_, i) => ( +
  • + {isHost ? ( +
    + + Add a computer opponent, or share the code to invite a friend: + +
    + {BOT_LEVELS.map((b) => ( + + ))} +
    +
    + ) : ( + Waiting for the host to fill this seat… + )} +
  • + ))} +
+
+ + {isHost ? ( + + ) : ( +

Waiting for the host to start the game…

+ )}
) } + +function SeatRow({ + player, + isHostSeat, + youSeat, + canRemove, + onRemove, +}: { + player: PlayerView + isHostSeat: boolean + youSeat: number + canRemove: boolean + onRemove: () => void +}) { + return ( +
  • + + {player.isBot ? '🤖' : '🧑'} + + + {player.name} + {player.seat === youSeat && (you)} + + + {isHostSeat && ( + + 👑 Host + + )} + {player.isBot ? ( + Computer + ) : ( + + )} + + {canRemove && ( + + )} +
  • + ) +} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index 27b992f..5c5faf6 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -73,6 +73,22 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v ) } + // A player the host removed from the lobby no longer has a seat. + if (view.youSeat < 0) { + return ( +
    +
    + 👋 +
    +

    You were removed from the game

    +

    The host removed you from this lobby.

    + +
    + ) + } + const you = view.players[view.youSeat] const opponents = view.players.filter((p) => p.seat !== view.youSeat) @@ -82,7 +98,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
    🐾 SAP
    - {view.phase !== 'gameover' && ( + {view.phase !== 'gameover' && view.phase !== 'lobby' && (
    Round {view.round} / {view.maxRounds}
    @@ -118,7 +134,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
    - {view.phase === 'lobby' && } + {view.phase === 'lobby' && } {view.phase === 'shop' && } {view.phase === 'arrange' && } {view.phase === 'battle' && ( diff --git a/web/src/styles.css b/web/src/styles.css index 3513250..263e49a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -371,15 +371,10 @@ h3 { gap: 10px; } -/* The three computer-opponent difficulty buttons share the row evenly. */ -.home-bots { - display: flex; - gap: 10px; -} - -.home-bots .btn { - flex: 1; - white-space: nowrap; +.home-hint { + text-align: center; + font-size: 0.9rem; + margin: 0; } .home-error { @@ -659,13 +654,17 @@ h3 { /* ---------- lobby ---------- */ .lobby { - min-height: 60vh; + max-width: 620px; + margin: 0 auto; + padding: 8px 16px 32px; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; + gap: 22px; +} + +.lobby-heading { text-align: center; + color: var(--gold); } .lobby-bounce { @@ -674,19 +673,199 @@ h3 { animation: float 2s ease-in-out infinite; } +.lobby-removed { + gap: 14px; + text-align: center; +} + +.lobby-code-row { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + .lobby-code { font-family: var(--font-display); - font-size: 3rem; - letter-spacing: 0.35em; - padding: 14px 30px 14px calc(30px + 0.35em); + font-size: 1.8rem; + letter-spacing: 0.3em; + padding: 6px 16px 6px calc(16px + 0.3em); color: var(--gold); background: rgba(0, 0, 0, 0.25); border: 3px dashed rgba(246, 201, 78, 0.5); - border-radius: 16px; + border-radius: 14px; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); box-shadow: var(--tray-inset); } +.lobby-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.lobby-section-title { + font-family: var(--font-display); + font-size: 1.15rem; + color: var(--cream); +} + +.lobby-note, +.lobby-waiting { + text-align: center; +} + +.lobby-waiting { + font-size: 1.05rem; +} + +/* ---------- pack picker ---------- */ + +.pack-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.pack-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 16px 10px 12px; + border-radius: var(--card-radius); + border: 3px solid transparent; + background: rgba(0, 0, 0, 0.22); + color: var(--cream); + cursor: pointer; + transition: transform 0.12s ease, border-color 0.12s ease; +} + +.pack-card:not(:disabled):hover { + transform: translateY(-2px); +} + +.pack-card.is-selected { + border-color: var(--gold); + background: rgba(246, 201, 78, 0.14); +} + +.pack-card.is-locked { + opacity: 0.45; + cursor: not-allowed; + filter: grayscale(0.7); +} + +.pack-card:disabled { + cursor: not-allowed; +} + +.pack-emoji { + font-size: 2.4rem; + line-height: 1; +} + +.pack-name { + font-family: var(--font-display); + font-size: 0.95rem; +} + +.pack-tag { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--gold); +} + +.pack-card.is-locked .pack-tag { + color: var(--cream); +} + +/* ---------- seat list ---------- */ + +.seat-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.seat-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.22); + box-shadow: var(--tray-inset); +} + +.seat-open { + border: 2px dashed rgba(251, 241, 215, 0.25); + background: rgba(0, 0, 0, 0.12); + box-shadow: none; +} + +.seat-avatar { + font-size: 1.5rem; + line-height: 1; +} + +.seat-name { + font-weight: 800; + flex: 1; +} + +.seat-you { + color: var(--gold); + font-weight: 700; +} + +.seat-badges { + display: flex; + align-items: center; + gap: 8px; +} + +.seat-remove { + border: none; + background: rgba(217, 74, 56, 0.2); + color: var(--red-soft); + font-weight: 900; + width: 26px; + height: 26px; + border-radius: 50%; + cursor: pointer; + line-height: 1; +} + +.seat-remove:hover { + background: var(--red); + color: #fff; +} + +.seat-open-host { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.seat-bot-picker { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.seat-bot-picker .btn { + flex: 1; + white-space: nowrap; +} + /* ---------- cards ---------- */ /* Thick printed stock: a warm parchment face, a soft ink border with an inner keyline, a physical shadow, and a lit sheen along the top. */ diff --git a/web/src/types.ts b/web/src/types.ts index 6584d35..b164712 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -92,6 +92,13 @@ export interface LogEntry { spawn?: string // 'apple' | 'bee' } +export interface PackInfo { + id: string + name: string + emoji: string + playable: boolean +} + export interface GameView { gameId: string code: string @@ -99,6 +106,11 @@ export interface GameView { round: number maxRounds: number maxPets: number + pack: string + packs: PackInfo[] + hostSeat: number + minPlayers: number + maxPlayers: number youSeat: number turn: number shopRow: Card[] @@ -112,6 +124,10 @@ export interface GameView { } export type ClientMessage = + | { type: 'setPack'; pack: string } + | { type: 'addBot'; difficulty: string } + | { type: 'removePlayer'; target: string } + | { type: 'start' } | { type: 'buy'; row: number } | { type: 'sell'; cards: string[] } | { type: 'trade'; cards: string[] }