From b2aa7c5ca31173d103d54e49c88a1ebe2c67d3ef Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 01:07:14 -0400 Subject: [PATCH] Add DEBUG-gated panel to buy any card in the shop When the server's DEBUG env var is on, expose a card catalog endpoint and a collapsible client panel that drops any pet or food straight into your deck (free, off-turn). Gated server-side so it is inert in normal play. --- cmd/server/main.go | 16 +++++++- internal/game/cards.go | 54 ++++++++++++++++++++++++++ internal/game/game.go | 19 ++++++++++ internal/game/game_test.go | 30 +++++++++++++++ internal/game/view.go | 3 ++ internal/server/server.go | 18 +++++++-- internal/server/ws.go | 8 ++++ internal/server/zz_e2e_test.go | 2 +- web/src/api.ts | 8 +++- web/src/components/DebugPanel.tsx | 59 +++++++++++++++++++++++++++++ web/src/components/Table.tsx | 7 ++++ web/src/styles.css | 63 +++++++++++++++++++++++++++++++ web/src/types.ts | 2 + 13 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 web/src/components/DebugPanel.tsx diff --git a/cmd/server/main.go b/cmd/server/main.go index 6363382..951dff8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -17,6 +18,15 @@ import ( "github.com/greyson/super-auto-pets-board-game/internal/store" ) +// isTruthy reports whether an env value means "on". +func isTruthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + } + return false +} + func main() { if err := env.Load(".env"); err != nil { slog.Error("failed to read .env", "err", err) @@ -25,6 +35,10 @@ func main() { dataDir := env.Get("DATA_DIR", "data") port := env.Get("PORT", "8080") staticDir := env.Get("STATIC_DIR", "web/dist") + debug := isTruthy(env.Get("DEBUG", "")) + if debug { + slog.Warn("DEBUG mode on: the buy-any-card panel is enabled") + } st, err := store.Open(dataDir) if err != nil { @@ -40,7 +54,7 @@ func main() { srv := &http.Server{ Addr: ":" + port, - Handler: server.New(st, staticDir).Handler(), + Handler: server.New(st, staticDir, debug).Handler(), } go func() { diff --git a/internal/game/cards.go b/internal/game/cards.go index 45a9601..145052e 100644 --- a/internal/game/cards.go +++ b/internal/game/cards.go @@ -519,6 +519,60 @@ func (g *Game) buildShopDecks() { } } +// Catalog returns one representative card for every pet and food in the game, +// tier by tier, for the debug "buy any card" panel. IDs are name-based +// placeholders (not real instances); pets use their first printed suit. +func Catalog() []Card { + var cards []Card + for tierIdx := range petTiers { + for _, t := range petTiers[tierIdx] { + suit := SuitRed + if len(t.Suits) > 0 { + suit = t.Suits[0] + } + cards = append(cards, Card{ + ID: "pet-" + t.Name, Kind: KindPet, Name: t.Name, Tier: tierIdx + 1, + Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText, + }) + } + for _, f := range foodTiers[tierIdx] { + cards = append(cards, Card{ + ID: "food-" + f.Name, Kind: KindFood, Name: f.Name, Tier: tierIdx + 1, + Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText, + }) + } + } + return cards +} + +// cardByName mints a fresh instance of the named pet or food from its +// template (pets take their first printed suit). Returns false if unknown. +func (g *Game) cardByName(name string) (Card, bool) { + for tierIdx := range petTiers { + for _, t := range petTiers[tierIdx] { + if t.Name == name { + suit := SuitRed + if len(t.Suits) > 0 { + suit = t.Suits[0] + } + return Card{ + ID: g.newCardID(), Kind: KindPet, Name: t.Name, Tier: tierIdx + 1, + Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText, + }, true + } + } + for _, f := range foodTiers[tierIdx] { + if f.Name == name { + return Card{ + ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1, + Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText, + }, true + } + } + } + return Card{}, false +} + // newApple mints an apple food card. Apples are temporary: they vanish from // the deck after the next battle. func (g *Game) newApple() Card { diff --git a/internal/game/game.go b/internal/game/game.go index 8f993e0..8be89f6 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -430,6 +430,25 @@ func (g *Game) TradeChoose(playerID string, pick int) error { return nil } +// DebugGrant drops any card straight into a player's deck during the shop, +// free and off-turn — a testing aid gated behind the server's DEBUG flag, not +// a normal action. No buy effects fire. +func (g *Game) DebugGrant(playerID, name string) error { + if g.Phase != PhaseShop && g.Phase != PhaseCleanup { + return ErrWrongPhase + } + p := g.PlayerByID(playerID) + if p == nil { + return errors.New("unknown player") + } + c, ok := g.cardByName(name) + if !ok { + return fmt.Errorf("%w: no card named %q", ErrInvalidAction, name) + } + p.Deck = append(p.Deck, c) + return nil +} + // Pass forfeits the player's remaining coins and ends their shopping. func (g *Game) Pass(playerID string) error { p, err := g.requireShopTurn(playerID) diff --git a/internal/game/game_test.go b/internal/game/game_test.go index d7d1b6c..3857b69 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -482,3 +482,33 @@ func TestViewHidesSecrets(t *testing.T) { t.Fatal("trade options must be visible to the trader") } } + +func TestDebugGrant(t *testing.T) { + g, p1, _ := testGame(t) + before := len(p1.Deck) + if err := g.DebugGrant(p1.ID, "Ant"); err != nil { + t.Fatal(err) + } + if len(p1.Deck) != before+1 || p1.Deck[before].Name != "Ant" || !p1.Deck[before].IsPet() { + t.Fatalf("granted card should be an Ant appended to the deck: %+v", p1.Deck) + } + if p1.Deck[before].ID == "" { + t.Fatal("granted card should get a real instance ID") + } + // Unknown card name is rejected. + if err := g.DebugGrant(p1.ID, "Nonexistent"); err == nil { + t.Fatal("unknown card name should error") + } + // Foods can be granted too. + if err := g.DebugGrant(p1.ID, "Honey"); err != nil { + t.Fatal(err) + } + if !Catalog()[0].IsPet() { // sanity on the shared catalog helper + t.Fatal("catalog should start with a pet") + } + // Not allowed outside shop/cleanup. + g.Phase = PhaseBattle + if err := g.DebugGrant(p1.ID, "Ant"); err == nil { + t.Fatal("grant should be rejected outside the shop") + } +} diff --git a/internal/game/view.go b/internal/game/view.go index f435616..4c3fbff 100644 --- a/internal/game/view.go +++ b/internal/game/view.go @@ -35,6 +35,9 @@ type View struct { Pending *PendingTrade `json:"pending,omitempty"` Battle *BattleResult `json:"battle,omitempty"` WinnerSeat int `json:"winnerSeat"` + // Debug is set by the server when its DEBUG flag is on, unlocking the + // client's "buy any card" panel. Not part of the pure game state. + Debug bool `json:"debug,omitempty"` } // ViewFor builds the state visible to the given player. diff --git a/internal/server/server.go b/internal/server/server.go index 87e0957..7aa947d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,17 +22,20 @@ import ( type Server struct { store *store.Store staticDir string + debug bool // DEBUG mode: unlocks the "buy any card" panel mu sync.Mutex rooms map[string]*room // by game ID } // New creates a server. staticDir is the built frontend to serve (may be -// empty or missing during backend-only development). -func New(st *store.Store, staticDir string) *Server { +// empty or missing during backend-only development). debug unlocks testing +// aids like the debug card panel. +func New(st *store.Store, staticDir string, debug bool) *Server { return &Server{ store: st, staticDir: staticDir, + debug: debug, rooms: make(map[string]*room), } } @@ -43,15 +46,22 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/games", s.handleCreate) mux.HandleFunc("POST /api/join", s.handleJoin) mux.HandleFunc("GET /api/ws", s.handleWS) + mux.HandleFunc("GET /api/catalog", s.handleCatalog) mux.HandleFunc("/", s.handleStatic) return mux } +// handleCatalog returns every card in the game, for the debug panel. +func (s *Server) handleCatalog(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, game.Catalog()) +} + // room is one live game plus its connections. type room struct { mu sync.Mutex game *game.Game conns map[*client]struct{} + debug bool // mirrors Server.debug, for broadcastLocked } // getRoom returns the room for a game ID, loading it from the store if it @@ -66,7 +76,7 @@ func (s *Server) getRoom(gameID string) (*room, error) { if err != nil { return nil, err } - r := &room{game: g, conns: make(map[*client]struct{})} + r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug} s.rooms[gameID] = r return r, nil } @@ -117,7 +127,7 @@ func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) { httpError(w, http.StatusBadRequest, err.Error()) return } - r := &room{game: g, conns: make(map[*client]struct{})} + r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug} s.mu.Lock() s.rooms[g.ID] = r s.mu.Unlock() diff --git a/internal/server/ws.go b/internal/server/ws.go index fb171a3..d67f7cf 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -27,6 +27,7 @@ type clientMessage struct { Cards []string `json:"cards"` // discard, trade Pick int `json:"pick"` // tradeChoose Order []string `json:"order"` // arrange + Name string `json:"name"` // debugAdd } type serverMessage struct { @@ -133,6 +134,12 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) { err = g.SubmitOrder(c.playerID, msg.Order) case "ready": err = g.AcknowledgeBattle(c.playerID) + case "debugAdd": + if !s.debug { + err = game.ErrInvalidAction + } else { + err = g.DebugGrant(c.playerID, msg.Name) + } default: err = game.ErrInvalidAction } @@ -149,6 +156,7 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) { func (r *room) broadcastLocked() { for c := range r.conns { view := r.game.ViewFor(c.playerID) + view.Debug = r.debug data, err := json.Marshal(serverMessage{Type: "state", State: &view}) if err != nil { slog.Error("failed to marshal view", "err", err) diff --git a/internal/server/zz_e2e_test.go b/internal/server/zz_e2e_test.go index cd309e5..1467448 100644 --- a/internal/server/zz_e2e_test.go +++ b/internal/server/zz_e2e_test.go @@ -49,7 +49,7 @@ func TestE2EBattleAckReturnsToShop(t *testing.T) { t.Fatal(err) } defer st.Close() - srv := New(st, "") + srv := New(st, "", false) ts := httptest.NewServer(srv.Handler()) defer ts.Close() base := ts.URL diff --git a/web/src/api.ts b/web/src/api.ts index 53a63f7..d735100 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { Session } from './types' +import type { Card, Session } from './types' const SESSION_KEY = 'sapbg-session' @@ -21,6 +21,12 @@ export function joinGame(code: string, name: string): Promise { return post('/api/join', { code, name }) } +export async function fetchCatalog(): Promise { + const res = await fetch('/api/catalog') + if (!res.ok) throw new Error('failed to load catalog') + return (await res.json()) as Card[] +} + export function loadSession(): Session | null { try { const raw = localStorage.getItem(SESSION_KEY) diff --git a/web/src/components/DebugPanel.tsx b/web/src/components/DebugPanel.tsx new file mode 100644 index 0000000..09651d9 --- /dev/null +++ b/web/src/components/DebugPanel.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import type { Card, ClientMessage } from '../types' +import { fetchCatalog } from '../api' +import { CardView } from './CardView' + +interface Props { + canGrant: boolean // shop/cleanup phase — grants only land then + send: (msg: ClientMessage) => void +} + +// DebugPanel is a testing aid (server DEBUG mode only): a collapsible drawer +// listing every card in the game, tier by tier. Clicking one drops it into +// your deck for free, off-turn. +export function DebugPanel({ canGrant, send }: Props) { + const [open, setOpen] = useState(false) + const [catalog, setCatalog] = useState([]) + + useEffect(() => { + fetchCatalog() + .then(setCatalog) + .catch(() => setCatalog([])) + }, []) + + const tiers = [...new Set(catalog.map((c) => c.tier ?? 0))].sort((a, b) => a - b) + + return ( +
+ + {open && ( +
+
+ Buy any card + {!canGrant && · only in the shop} +
+ {tiers.map((tier) => ( +
+
Tier {tier}
+
+ {catalog + .filter((c) => (c.tier ?? 0) === tier) + .map((c) => ( + send({ type: 'debugAdd', name: c.name }) : undefined} + /> + ))} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index fea4ea6..9230498 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -5,6 +5,7 @@ import { ShopPhase } from './ShopPhase' import { ArrangePhase } from './ArrangePhase' import { BattlePhase } from './BattlePhase' import { GameOver } from './GameOver' +import { DebugPanel } from './DebugPanel' // Table connects to the game and routes to the right phase screen. export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) { @@ -69,6 +70,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
An opponent is disconnected…
)} {error &&
{error}
} + {view.debug && ( + + )} ) } diff --git a/web/src/styles.css b/web/src/styles.css index 0387154..43a1937 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1276,3 +1276,66 @@ h3 { height: 110px; } } + +/* --- debug panel (server DEBUG mode) --- */ + +.debug-panel { + position: fixed; + top: 64px; + right: 0; + z-index: 120; + display: flex; + align-items: flex-start; +} + +.debug-toggle { + background: #7a1f1f; + color: var(--cream); + border: 2px solid var(--cocoa); + border-right: none; + border-radius: 8px 0 0 8px; + font-weight: 800; + font-size: 0.8rem; + padding: 8px 10px; + cursor: pointer; + white-space: nowrap; + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.debug-panel.is-open .debug-toggle { + writing-mode: horizontal-tb; + transform: none; + border-radius: 8px 0 0 0; +} + +.debug-body { + width: min(340px, 80vw); + max-height: calc(100vh - 80px); + overflow-y: auto; + background: rgba(18, 53, 31, 0.97); + border: 2px solid var(--cocoa); + border-radius: 8px 0 0 8px; + padding: 12px; + box-shadow: -6px 6px 18px rgba(0, 0, 0, 0.45); +} + +.debug-head { + color: var(--gold); + font-weight: 800; + margin-bottom: 8px; +} + +.debug-tier-label { + color: var(--cream); + font-size: 0.75rem; + font-weight: 700; + opacity: 0.8; + margin: 8px 0 4px; +} + +.debug-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} diff --git a/web/src/types.ts b/web/src/types.ts index 9d87468..f57a4a5 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -89,6 +89,7 @@ export interface GameView { pending?: PendingTrade battle?: BattleResult winnerSeat: number + debug?: boolean // server DEBUG mode: unlocks the buy-any-card panel } export type ClientMessage = @@ -99,6 +100,7 @@ export type ClientMessage = | { type: 'pass' } | { type: 'arrange'; order: string[] } | { type: 'ready' } + | { type: 'debugAdd'; name: string } export interface Session { gameId: string