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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user