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 {
|
if err != nil {
|
||||||
t.Fatalf("AddBot B: %v", err)
|
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)}
|
bots := map[string]*Bot{pa.ID: New(levelA), pb.ID: New(levelB)}
|
||||||
mems := map[string]*Memory{pa.ID: {}, pb.ID: {}}
|
mems := map[string]*Memory{pa.ID: {}, pb.ID: {}}
|
||||||
|
|
||||||
@@ -102,6 +105,9 @@ func TestObserveTracksOpponentDeck(t *testing.T) {
|
|||||||
g := game.New()
|
g := game.New()
|
||||||
pa, _ := g.AddBot("Bot A", 1)
|
pa, _ := g.AddBot("Bot A", 1)
|
||||||
pb, _ := g.AddBot("Bot B", 1)
|
pb, _ := g.AddBot("Bot B", 1)
|
||||||
|
if err := g.StartGame(); err != nil {
|
||||||
|
t.Fatalf("StartGame: %v", err)
|
||||||
|
}
|
||||||
mem := &Memory{}
|
mem := &Memory{}
|
||||||
obs := func() {
|
obs := func() {
|
||||||
v := g.ViewFor(pa.ID)
|
v := g.ViewFor(pa.ID)
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ func testGame(t *testing.T) (*Game, *Player, *Player) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := g.StartGame(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if g.Phase != PhaseShop {
|
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
|
// Pin the (otherwise random) priority token to seat 0 so tests are
|
||||||
// deterministic: seat 0 shops first and wins battle simultaneity races.
|
// 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 {
|
if _, err := g.AddPlayer("Bob"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := g.StartGame(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if g.PrioritySeat < 0 || g.PrioritySeat >= len(g.Players) {
|
if g.PrioritySeat < 0 || g.PrioritySeat >= len(g.Players) {
|
||||||
t.Fatalf("priority token should start on a real seat, got %d", g.PrioritySeat)
|
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)
|
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() {
|
func (g *Game) buildShopDecks() {
|
||||||
|
pets, foods := packTiers(g.Pack)
|
||||||
g.ShopDecks = make([][]Card, MaxRounds)
|
g.ShopDecks = make([][]Card, MaxRounds)
|
||||||
for tierIdx := range petTiers {
|
for tierIdx := range pets {
|
||||||
var deck []Card
|
var deck []Card
|
||||||
for _, t := range petTiers[tierIdx] {
|
for _, t := range pets[tierIdx] {
|
||||||
for _, suit := range t.Suits {
|
for _, suit := range t.Suits {
|
||||||
deck = append(deck, Card{
|
deck = append(deck, Card{
|
||||||
ID: g.newCardID(),
|
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 {
|
for range f.Copies {
|
||||||
deck = append(deck, Card{
|
deck = append(deck, Card{
|
||||||
ID: g.newCardID(),
|
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
|
// Game is the complete authoritative state. It is a pure state machine: no
|
||||||
// goroutines, no clocks, no I/O. Callers are responsible for locking.
|
// goroutines, no clocks, no I/O. Callers are responsible for locking.
|
||||||
type Game struct {
|
type Game struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Code string `json:"code"`
|
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"`
|
Phase Phase `json:"phase"`
|
||||||
Round int `json:"round"` // 1-based
|
Round int `json:"round"` // 1-based
|
||||||
Players []*Player `json:"players"`
|
Players []*Player `json:"players"`
|
||||||
@@ -168,18 +171,27 @@ func New() *Game {
|
|||||||
g := &Game{
|
g := &Game{
|
||||||
ID: randomID(16),
|
ID: randomID(16),
|
||||||
Code: randomCode(),
|
Code: randomCode(),
|
||||||
|
Pack: DefaultPack,
|
||||||
Phase: PhaseLobby,
|
Phase: PhaseLobby,
|
||||||
WinnerSeat: -1,
|
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()
|
g.buildShopDecks()
|
||||||
for i := range g.ShopDecks {
|
for i := range g.ShopDecks {
|
||||||
shuffle(g.ShopDecks[i])
|
shuffle(g.ShopDecks[i])
|
||||||
}
|
}
|
||||||
return g
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddPlayer seats a new player during the lobby phase and returns them (with
|
// 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) {
|
func (g *Game) AddPlayer(name string) (*Player, error) {
|
||||||
if g.Phase != PhaseLobby {
|
if g.Phase != PhaseLobby {
|
||||||
return nil, fmt.Errorf("%w: game already started", ErrWrongPhase)
|
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),
|
Seat: len(g.Players),
|
||||||
}
|
}
|
||||||
g.Players = append(g.Players, p)
|
g.Players = append(g.Players, p)
|
||||||
if len(g.Players) == MaxPlayers {
|
|
||||||
g.start()
|
|
||||||
}
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +225,61 @@ func (g *Game) AddBot(name string, level float64) (*Player, error) {
|
|||||||
return p, nil
|
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.
|
// PlayerByID returns the player, or nil.
|
||||||
func (g *Game) PlayerByID(id string) *Player {
|
func (g *Game) PlayerByID(id string) *Player {
|
||||||
for _, p := range g.Players {
|
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 current(g *Game) *Player { return g.Players[g.Turn] }
|
||||||
|
|
||||||
func TestLobbyStartsWhenFull(t *testing.T) {
|
func TestLobbyManualStart(t *testing.T) {
|
||||||
g := New()
|
g := New()
|
||||||
if g.Phase != PhaseLobby {
|
if g.Phase != PhaseLobby {
|
||||||
t.Fatalf("new game should be in lobby, got %s", g.Phase)
|
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 {
|
if _, err := g.AddPlayer("Alice"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if g.Phase != PhaseLobby {
|
// One player isn't enough to start.
|
||||||
t.Fatal("game should wait for second player")
|
if err := g.StartGame(); err == nil {
|
||||||
|
t.Fatal("start should be rejected with fewer than MinPlayers")
|
||||||
}
|
}
|
||||||
if _, err := g.AddPlayer("Bob"); err != nil {
|
if _, err := g.AddPlayer("Bob"); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if _, err := g.AddPlayer("Carol"); err == nil {
|
||||||
t.Fatal("third player should be rejected while MaxPlayers=2")
|
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 {
|
for _, p := range g.Players {
|
||||||
if p.Coins != CoinsPerRound {
|
if p.Coins != CoinsPerRound {
|
||||||
t.Fatalf("player should start with %d 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"`
|
Round int `json:"round"`
|
||||||
MaxRounds int `json:"maxRounds"`
|
MaxRounds int `json:"maxRounds"`
|
||||||
MaxPets int `json:"maxPets"`
|
MaxPets int `json:"maxPets"`
|
||||||
YouSeat int `json:"youSeat"`
|
// Pack is the selected card pack; Packs is the catalog of choices for the
|
||||||
Turn int `json:"turn"`
|
// 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 is the seat currently holding the priority token.
|
||||||
PrioritySeat int `json:"prioritySeat"`
|
PrioritySeat int `json:"prioritySeat"`
|
||||||
ShopRow []Card `json:"shopRow"`
|
ShopRow []Card `json:"shopRow"`
|
||||||
@@ -52,6 +60,11 @@ func (g *Game) ViewFor(playerID string) View {
|
|||||||
Round: g.Round,
|
Round: g.Round,
|
||||||
MaxRounds: MaxRounds,
|
MaxRounds: MaxRounds,
|
||||||
MaxPets: MaxPets,
|
MaxPets: MaxPets,
|
||||||
|
Pack: g.Pack,
|
||||||
|
Packs: Packs,
|
||||||
|
HostSeat: 0,
|
||||||
|
MinPlayers: MinPlayers,
|
||||||
|
MaxPlayers: MaxPlayers,
|
||||||
YouSeat: -1,
|
YouSeat: -1,
|
||||||
Turn: g.Turn,
|
Turn: g.Turn,
|
||||||
PrioritySeat: g.PrioritySeat,
|
PrioritySeat: g.PrioritySeat,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestE2EBotGame(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
resp, err := http.Post(ts.URL+"/api/games", "application/json",
|
resp, err := http.Post(ts.URL+"/api/games", "application/json",
|
||||||
bytes.NewBufferString(`{"name":"Human","bot":"easy"}`))
|
bytes.NewBufferString(`{"name":"Human"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -51,9 +51,23 @@ func TestE2EBotGame(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer ws.Close(websocket.StatusNormalClosure, "")
|
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 {
|
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
|
var bot *game.PlayerView
|
||||||
for i := range v.Players {
|
for i := range v.Players {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,6 +21,28 @@ var botDifficulty = map[string]struct {
|
|||||||
"hard": {1.00, "Robo Ace"},
|
"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
|
// commitLocked is the one path every game mutation goes through: bots update
|
||||||
// their memories from the new public state, the game is persisted, every
|
// 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
|
// 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) {
|
func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Name string `json:"name"`
|
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 {
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
httpError(w, http.StatusBadRequest, "invalid JSON body")
|
httpError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var bot struct {
|
// The creator becomes the host (seat 0). They then set up the lobby —
|
||||||
level float64
|
// choosing a pack, adding a bot, or waiting for a friend — and start the
|
||||||
name string
|
// game when ready.
|
||||||
}
|
|
||||||
if body.Bot != "" {
|
|
||||||
var ok bool
|
|
||||||
bot, ok = botDifficulty[body.Bot]
|
|
||||||
if !ok {
|
|
||||||
httpError(w, http.StatusBadRequest, "unknown bot difficulty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g := game.New()
|
g := game.New()
|
||||||
p, err := g.AddPlayer(strings.TrimSpace(body.Name))
|
p, err := g.AddPlayer(strings.TrimSpace(body.Name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, http.StatusBadRequest, err.Error())
|
httpError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
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}
|
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.rooms[g.ID] = r
|
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
|
// clientMessage is anything a player can ask the server to do. Type decides
|
||||||
// which other fields matter.
|
// which other fields matter.
|
||||||
type clientMessage struct {
|
type clientMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Row int `json:"row"` // buy
|
Row int `json:"row"` // buy
|
||||||
Cards []string `json:"cards"` // discard, trade
|
Cards []string `json:"cards"` // discard, trade
|
||||||
Pick int `json:"pick"` // tradeChoose
|
Pick int `json:"pick"` // tradeChoose
|
||||||
Order []string `json:"order"` // arrange
|
Order []string `json:"order"` // arrange
|
||||||
Name string `json:"name"` // debugAdd
|
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 {
|
type serverMessage struct {
|
||||||
@@ -119,6 +122,22 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
|
|||||||
g := r.game
|
g := r.game
|
||||||
var err error
|
var err error
|
||||||
switch msg.Type {
|
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":
|
case "buy":
|
||||||
err = g.Buy(c.playerID, msg.Row)
|
err = g.Buy(c.playerID, msg.Row)
|
||||||
case "sell":
|
case "sell":
|
||||||
|
|||||||
+2
-2
@@ -15,8 +15,8 @@ async function post(path: string, body: unknown): Promise<Session> {
|
|||||||
|
|
||||||
export type BotDifficulty = 'easy' | 'medium' | 'hard'
|
export type BotDifficulty = 'easy' | 'medium' | 'hard'
|
||||||
|
|
||||||
export function createGame(name: string, bot?: BotDifficulty): Promise<Session> {
|
export function createGame(name: string): Promise<Session> {
|
||||||
return post('/api/games', bot ? { name, bot } : { name })
|
return post('/api/games', { name })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinGame(code: string, name: string): Promise<Session> {
|
export function joinGame(code: string, name: string): Promise<Session> {
|
||||||
|
|||||||
@@ -30,6 +30,31 @@ function useFitText(dep: unknown) {
|
|||||||
return ref
|
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
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>{segment.slice(0, colon)} →</strong>
|
||||||
|
{segment.slice(colon + 1)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && ' · '}
|
||||||
|
{node}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
card: Card
|
card: Card
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
@@ -121,7 +146,7 @@ export function CardView({
|
|||||||
<>
|
<>
|
||||||
{card.effectText && (
|
{card.effectText && (
|
||||||
<div className="card-effect" ref={effectRef}>
|
<div className="card-effect" ref={effectRef}>
|
||||||
{card.effectText}
|
{renderEffect(card.effectText)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="card-bottom">
|
<div className="card-bottom">
|
||||||
@@ -131,7 +156,11 @@ export function CardView({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="card-effect" ref={effectRef}>
|
<div className="card-effect" ref={effectRef}>
|
||||||
{card.effectText ?? (card.food === 'apple' ? '+1 power (this battle)' : '')}
|
{card.effectText
|
||||||
|
? renderEffect(card.effectText)
|
||||||
|
: card.food === 'apple'
|
||||||
|
? '+1 power (this battle)'
|
||||||
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selected && <div className="card-check">✓</div>}
|
{selected && <div className="card-check">✓</div>}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { createGame, joinGame } from '../api'
|
import { createGame, joinGame } from '../api'
|
||||||
import type { BotDifficulty } from '../api'
|
|
||||||
import type { Session } from '../types'
|
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.
|
// Home is the create/join screen shown when there's no active session.
|
||||||
export function Home({ onSession }: { onSession: (s: Session) => void }) {
|
export function Home({ onSession }: { onSession: (s: Session) => void }) {
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
@@ -56,24 +49,10 @@ export function Home({ onSession }: { onSession: (s: Session) => void }) {
|
|||||||
>
|
>
|
||||||
Host a new game
|
Host a new game
|
||||||
</button>
|
</button>
|
||||||
|
<p className="home-hint muted">
|
||||||
<div className="home-divider">
|
Set up your lobby next — pick a pack, add a computer opponent, or
|
||||||
<span>or challenge the computer</span>
|
invite a friend.
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div className="home-bots">
|
|
||||||
{BOT_LEVELS.map((b) => (
|
|
||||||
<button
|
|
||||||
key={b.value}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={busy}
|
|
||||||
title={b.blurb}
|
|
||||||
onClick={() => run(() => createGame(name, b.value))}
|
|
||||||
>
|
|
||||||
{b.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="home-divider">
|
<div className="home-divider">
|
||||||
<span>or join a friend</span>
|
<span>or join a friend</span>
|
||||||
|
|||||||
+167
-13
@@ -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 (
|
return (
|
||||||
<div className="lobby">
|
<div className="lobby">
|
||||||
<div className="lobby-bounce" aria-hidden>
|
<h2 className="lobby-heading">Game lobby</h2>
|
||||||
🐟
|
|
||||||
|
<div className="lobby-code-row">
|
||||||
|
<span className="muted">Invite code</span>
|
||||||
|
<span className="lobby-code">{view.code}</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => navigator.clipboard?.writeText(view.code)}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h2>Waiting for an opponent…</h2>
|
|
||||||
<p className="muted">Share this code so a friend can join:</p>
|
<section className="lobby-section">
|
||||||
<div className="lobby-code">{view.code}</div>
|
<h3 className="lobby-section-title">Card pack</h3>
|
||||||
<button
|
<div className="pack-grid">
|
||||||
className="btn btn-secondary"
|
{view.packs.map((pack) => {
|
||||||
onClick={() => navigator.clipboard?.writeText(view.code)}
|
const selected = pack.id === view.pack
|
||||||
>
|
const disabled = !pack.playable || !isHost
|
||||||
Copy code
|
return (
|
||||||
</button>
|
<button
|
||||||
|
key={pack.id}
|
||||||
|
className={`pack-card ${selected ? 'is-selected' : ''} ${
|
||||||
|
pack.playable ? '' : 'is-locked'
|
||||||
|
}`}
|
||||||
|
disabled={disabled}
|
||||||
|
title={pack.playable ? pack.name : `${pack.name} — coming soon`}
|
||||||
|
onClick={() => send({ type: 'setPack', pack: pack.id })}
|
||||||
|
>
|
||||||
|
<span className="pack-emoji" aria-hidden>
|
||||||
|
{pack.emoji}
|
||||||
|
</span>
|
||||||
|
<span className="pack-name">{pack.name}</span>
|
||||||
|
<span className="pack-tag">
|
||||||
|
{pack.playable ? (selected ? 'Selected' : 'Available') : 'Coming soon'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!isHost && (
|
||||||
|
<p className="muted lobby-note">Only the host can change the pack.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lobby-section">
|
||||||
|
<h3 className="lobby-section-title">
|
||||||
|
Players ({view.players.length}/{view.maxPlayers})
|
||||||
|
</h3>
|
||||||
|
<ul className="seat-list">
|
||||||
|
{view.players.map((p) => (
|
||||||
|
<SeatRow
|
||||||
|
key={p.id}
|
||||||
|
player={p}
|
||||||
|
isHostSeat={p.seat === view.hostSeat}
|
||||||
|
youSeat={view.youSeat}
|
||||||
|
canRemove={isHost && p.seat !== view.hostSeat}
|
||||||
|
onRemove={() => send({ type: 'removePlayer', target: p.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Array.from({ length: openSeats }).map((_, i) => (
|
||||||
|
<li key={`open-${i}`} className="seat-row seat-open">
|
||||||
|
{isHost ? (
|
||||||
|
<div className="seat-open-host">
|
||||||
|
<span className="muted">
|
||||||
|
Add a computer opponent, or share the code to invite a friend:
|
||||||
|
</span>
|
||||||
|
<div className="seat-bot-picker">
|
||||||
|
{BOT_LEVELS.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.value}
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
title={b.blurb}
|
||||||
|
onClick={() => send({ type: 'addBot', difficulty: b.value })}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="muted">Waiting for the host to fill this seat…</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isHost ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-big"
|
||||||
|
disabled={!canStart}
|
||||||
|
onClick={() => send({ type: 'start' })}
|
||||||
|
>
|
||||||
|
{view.players.length < view.minPlayers
|
||||||
|
? 'Waiting for a second player…'
|
||||||
|
: 'Start game'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="lobby-waiting muted">Waiting for the host to start the game…</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SeatRow({
|
||||||
|
player,
|
||||||
|
isHostSeat,
|
||||||
|
youSeat,
|
||||||
|
canRemove,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
player: PlayerView
|
||||||
|
isHostSeat: boolean
|
||||||
|
youSeat: number
|
||||||
|
canRemove: boolean
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="seat-row">
|
||||||
|
<span className="seat-avatar" aria-hidden>
|
||||||
|
{player.isBot ? '🤖' : '🧑'}
|
||||||
|
</span>
|
||||||
|
<span className="seat-name">
|
||||||
|
{player.name}
|
||||||
|
{player.seat === youSeat && <span className="seat-you"> (you)</span>}
|
||||||
|
</span>
|
||||||
|
<span className="seat-badges">
|
||||||
|
{isHostSeat && (
|
||||||
|
<span className="chip" title="Host">
|
||||||
|
👑 Host
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{player.isBot ? (
|
||||||
|
<span className="chip">Computer</span>
|
||||||
|
) : (
|
||||||
|
<span className={`conn-dot ${player.connected ? 'on' : 'off'}`} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{canRemove && (
|
||||||
|
<button className="seat-remove" title="Remove from game" onClick={onRemove}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="centered lobby-removed">
|
||||||
|
<div className="lobby-bounce" aria-hidden>
|
||||||
|
👋
|
||||||
|
</div>
|
||||||
|
<h2>You were removed from the game</h2>
|
||||||
|
<p className="muted">The host removed you from this lobby.</p>
|
||||||
|
<button className="btn btn-primary" onClick={onLeave}>
|
||||||
|
Back to home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const you = view.players[view.youSeat]
|
const you = view.players[view.youSeat]
|
||||||
const opponents = view.players.filter((p) => p.seat !== 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
|
|||||||
<div className="topbar-brand" title="Super Auto Pets: The Board Game">
|
<div className="topbar-brand" title="Super Auto Pets: The Board Game">
|
||||||
🐾 <span>SAP</span>
|
🐾 <span>SAP</span>
|
||||||
</div>
|
</div>
|
||||||
{view.phase !== 'gameover' && (
|
{view.phase !== 'gameover' && view.phase !== 'lobby' && (
|
||||||
<div className="topbar-round">
|
<div className="topbar-round">
|
||||||
Round <strong>{view.round}</strong> / {view.maxRounds}
|
Round <strong>{view.round}</strong> / {view.maxRounds}
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +134,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
|||||||
|
|
||||||
<div className="table-body">
|
<div className="table-body">
|
||||||
<main className="table-main">
|
<main className="table-main">
|
||||||
{view.phase === 'lobby' && <Lobby view={view} />}
|
{view.phase === 'lobby' && <Lobby view={view} send={send} />}
|
||||||
{view.phase === 'shop' && <ShopPhase view={view} you={you} send={send} />}
|
{view.phase === 'shop' && <ShopPhase view={view} you={you} send={send} />}
|
||||||
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
|
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
|
||||||
{view.phase === 'battle' && (
|
{view.phase === 'battle' && (
|
||||||
|
|||||||
+196
-17
@@ -371,15 +371,10 @@ h3 {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The three computer-opponent difficulty buttons share the row evenly. */
|
.home-hint {
|
||||||
.home-bots {
|
text-align: center;
|
||||||
display: flex;
|
font-size: 0.9rem;
|
||||||
gap: 10px;
|
margin: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.home-bots .btn {
|
|
||||||
flex: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-error {
|
.home-error {
|
||||||
@@ -659,13 +654,17 @@ h3 {
|
|||||||
/* ---------- lobby ---------- */
|
/* ---------- lobby ---------- */
|
||||||
|
|
||||||
.lobby {
|
.lobby {
|
||||||
min-height: 60vh;
|
max-width: 620px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 8px 16px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 22px;
|
||||||
justify-content: center;
|
}
|
||||||
gap: 16px;
|
|
||||||
|
.lobby-heading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: var(--gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobby-bounce {
|
.lobby-bounce {
|
||||||
@@ -674,19 +673,199 @@ h3 {
|
|||||||
animation: float 2s ease-in-out infinite;
|
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 {
|
.lobby-code {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 3rem;
|
font-size: 1.8rem;
|
||||||
letter-spacing: 0.35em;
|
letter-spacing: 0.3em;
|
||||||
padding: 14px 30px 14px calc(30px + 0.35em);
|
padding: 6px 16px 6px calc(16px + 0.3em);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
border: 3px dashed rgba(246, 201, 78, 0.5);
|
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);
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||||
box-shadow: var(--tray-inset);
|
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 ---------- */
|
/* ---------- cards ---------- */
|
||||||
/* Thick printed stock: a warm parchment face, a soft ink border with an inner
|
/* 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. */
|
keyline, a physical shadow, and a lit sheen along the top. */
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ export interface LogEntry {
|
|||||||
spawn?: string // 'apple' | 'bee'
|
spawn?: string // 'apple' | 'bee'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PackInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
playable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameView {
|
export interface GameView {
|
||||||
gameId: string
|
gameId: string
|
||||||
code: string
|
code: string
|
||||||
@@ -99,6 +106,11 @@ export interface GameView {
|
|||||||
round: number
|
round: number
|
||||||
maxRounds: number
|
maxRounds: number
|
||||||
maxPets: number
|
maxPets: number
|
||||||
|
pack: string
|
||||||
|
packs: PackInfo[]
|
||||||
|
hostSeat: number
|
||||||
|
minPlayers: number
|
||||||
|
maxPlayers: number
|
||||||
youSeat: number
|
youSeat: number
|
||||||
turn: number
|
turn: number
|
||||||
shopRow: Card[]
|
shopRow: Card[]
|
||||||
@@ -112,6 +124,10 @@ export interface GameView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
|
| { type: 'setPack'; pack: string }
|
||||||
|
| { type: 'addBot'; difficulty: string }
|
||||||
|
| { type: 'removePlayer'; target: string }
|
||||||
|
| { type: 'start' }
|
||||||
| { type: 'buy'; row: number }
|
| { type: 'buy'; row: number }
|
||||||
| { type: 'sell'; cards: string[] }
|
| { type: 'sell'; cards: string[] }
|
||||||
| { type: 'trade'; cards: string[] }
|
| { type: 'trade'; cards: string[] }
|
||||||
|
|||||||
Reference in New Issue
Block a user