diff --git a/internal/game/battle.go b/internal/game/battle.go index 4d8c27c..b418c47 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "slices" ) // BattleUnit is a pet in play with its attached foods applied. Power @@ -1165,19 +1164,20 @@ func (g *Game) runBattle() *BattleResult { Text: fmt.Sprintf("%s eats an apple (now +%d).", q.unit.Card.Name, q.unit.Bonus)}) } case ActionShuffleApples: - // Komodo: only if it's the side's first pet, shuffle apples into - // random deck positions (deterministic via the battle draw tape). + // Komodo: only if it's the side's first pet, add apples to the + // remaining deck and shuffle the whole thing so the apples and the + // existing pets intermix (not just apples dropped in). if q.effect.Condition == ConditionFirstPet && sides[q.seat].petsPlayed != 1 { continue } s := sides[q.seat] for range q.effect.count() { - pos := g.battleDraw(len(s.stack) + 1) apple := g.newApple() - s.stack = slices.Insert(s.stack, pos, apple) + s.stack = append(s.stack, apple) emit(BattleEvent{Type: "summon", Seat: q.seat, Card: &apple, Text: fmt.Sprintf("%s shuffles an apple into %s's deck.", q.unit.Card.Name, pname(q.seat))}) } + g.battleShuffle(s.stack) case ActionSpendRocks: // Nurse Shark: spend as many Trumpets as available (up to Count) to // throw two rocks each. diff --git a/internal/game/cards.go b/internal/game/cards.go index e06a221..9e6d60d 100644 --- a/internal/game/cards.go +++ b/internal/game/cards.go @@ -196,8 +196,8 @@ const ( ActionRevealForApples EffectAction = "revealForApples" // --- Golden pack, tier 6 --- - // ActionShuffleApples (play) shuffles Count apples into random positions of - // the owner's remaining deck (Komodo). Gated by ConditionFirstPet. + // ActionShuffleApples (play) adds Count apples to the owner's remaining + // deck and shuffles the whole deck (Komodo). Gated by ConditionFirstPet. ActionShuffleApples EffectAction = "shuffleApples" // ActionReactivateBuys (battle prep) re-fires the Buy ability of every pet // in the owner's hand (Catfish). diff --git a/internal/game/game.go b/internal/game/game.go index a23ad82..05f4d9b 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -159,6 +159,15 @@ func (g *Game) battleDraw(n int) int { // rollRockDie rolls one rock die: 0, 1, or 2 with equal probability. func (g *Game) rollRockDie() int { return g.battleDraw(3) } +// battleShuffle does an in-place Fisher-Yates over a battle deck, routing its +// randomness through battleDraw like every other in-battle draw (Komodo). +func (g *Game) battleShuffle(s []Card) { + for i := len(s) - 1; i > 0; i-- { + j := g.battleDraw(i + 1) + s[i], s[j] = s[j], s[i] + } +} + var ( ErrNotYourTurn = errors.New("not your turn") ErrWrongPhase = errors.New("action not allowed in this phase") diff --git a/internal/game/view.go b/internal/game/view.go index 06e3a83..6b1c111 100644 --- a/internal/game/view.go +++ b/internal/game/view.go @@ -18,9 +18,12 @@ type PlayerView struct { Avocados int `json:"avocados,omitempty"` // FirstBuyFree / BuysThisRound (Golden pack) are self-only: they'd reveal // hidden deck facts (a Manta Ray) to opponents. - FirstBuyFree bool `json:"firstBuyFree,omitempty"` - BuysThisRound int `json:"buysThisRound,omitempty"` - Deck []Card `json:"deck,omitempty"` // self only + FirstBuyFree bool `json:"firstBuyFree,omitempty"` + BuysThisRound int `json:"buysThisRound,omitempty"` + // Trumpets is the count banked for the next battle (Bird of Paradise). + // Self-only — shown under the player's own deck. + Trumpets int `json:"trumpets,omitempty"` + Deck []Card `json:"deck,omitempty"` // self only } // View is the full game state as seen by one player. @@ -105,6 +108,7 @@ func (g *Game) ViewFor(playerID string) View { pv.Deck = p.Deck pv.FirstBuyFree = p.FirstBuyFree pv.BuysThisRound = p.BuysThisRound + pv.Trumpets = p.PendingTrumpets } v.Players = append(v.Players, pv) } diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 4a2f0f7..f2a6ce0 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -541,7 +541,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
{peek.seat === youSeat ? 'Your' : 'Opponent’s'} deck · {lineup.length} card - {lineup.length !== 1 ? 's' : ''} (top first) + {lineup.length !== 1 ? 's' : ''} (first on the right)
{lineup.map((c, i) => ( diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index bc40b63..395fa26 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -206,19 +206,19 @@ export function CardView({ onMouseLeave={preview ? undefined : () => setHover(null)} > {previewEl} - {/* Power sits in a spiky burst badge in the top-left corner (pets only), - with any battle damage marker beside it. It's absolutely placed so it - doesn't eat into the room the name and ability text need. */} +
+ {card.tier ? `T${card.tier}` : '·'} + {card.suit && } +
+ {/* Power sits in a spiky burst badge on its own centered row (pets only), + with any battle damage marker beside it, so it never overlaps the art, + name, or ability text. */} {card.kind === 'pet' && (
0 ? 'is-buffed' : ''}`}>{power} {damage > 0 && !dead && −{damage}}
)} -
- {card.tier ? `T${card.tier}` : '·'} - {card.suit && } -
{artFor(card.name)}
diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index a7bbe92..d0ca718 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -73,6 +73,7 @@ export function ShopPhase({ view, you, send }: Props) { const [useAvocado, setUseAvocado] = useState(false) const myTurn = view.turn === view.youSeat && !you.ready const avocados = you.avocados ?? 0 + const trumpets = you.trumpets ?? 0 const freeBuy = !!you.firstBuyFree // Manta Ray: next buy costs no gold const canBuy = myTurn && (you.coins > 0 || avocados > 0 || freeBuy) // Nudge the player to pass once there's nothing left to buy. @@ -376,6 +377,11 @@ export function ShopPhase({ view, you, send }: Props) { ))}
)} + {trumpets > 0 && ( +
+ 🎺 ×{trumpets} +
+ )} {/* Actions */} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index 8ccb690..e4b72fc 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -224,7 +224,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v >
{oppName}’s deck last round · {lineup.length} card - {lineup.length !== 1 ? 's' : ''} (top first) + {lineup.length !== 1 ? 's' : ''} (first on the right)
{lineup.map((c, i) => ( diff --git a/web/src/styles.css b/web/src/styles.css index cda9598..d815e83 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -983,9 +983,8 @@ h3 { .card-top { width: 100%; display: flex; - /* The power badge now floats over the top-left corner, so tier + suit sit - together at the top-right. */ - justify-content: flex-end; + /* Tier sits at the top-left, suit dot at the top-right. */ + justify-content: space-between; align-items: center; gap: 5px; font-size: 0.72rem; @@ -993,16 +992,15 @@ h3 { min-height: 14px; } -/* Combat stats (power badge + battle damage marker) pinned to the top-left - corner. Absolute so they don't push the name and ability text down. */ +/* Combat stats (power badge + battle damage marker) get their own centered + row just under the tier/suit line, so they never overlap the art or text. */ .card-combat { - position: absolute; - top: 5px; - left: 5px; z-index: 2; display: flex; align-items: center; + justify-content: center; gap: 4px; + margin: 2px 0 1px; } .card-tier { @@ -1290,6 +1288,19 @@ h3 { inset 0 0 0 1px rgba(246, 201, 78, 0.06); } +/* Trumpets banked for the next battle, shown as a pill under the deck. */ +.trumpet-indicator { + margin: 12px auto 0; + width: fit-content; + font-family: var(--font-display); + font-size: 1.05rem; + padding: 5px 14px; + border-radius: 999px; + border: 1px solid rgba(246, 201, 78, 0.3); + background: rgba(0, 0, 0, 0.28); + color: var(--gold); +} + /* Coins shown as big golden discs above the buy row. */ .shop-coins { display: flex; @@ -2592,6 +2603,14 @@ h3 { flex-wrap: wrap; gap: 6px; max-width: min(60vw, 560px); + /* Read right-to-left like the arrangement screen: the first (top) card sits + on the right and later pets flow left. */ + direction: rtl; +} + +/* Keep each card's own contents left-to-right despite the rtl row. */ +.deck-peek-cards > * { + direction: ltr; } /* Backdrop behind the toolbar deck-peek: any click outside dismisses it. Sits diff --git a/web/src/types.ts b/web/src/types.ts index ec61843..4b87591 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -32,6 +32,7 @@ export interface PlayerView { avocados?: number // set-aside Avocado tokens (Golden pack) firstBuyFree?: boolean // Manta Ray: next buy is free (self only) buysThisRound?: number // Blue-Ringed Octopus counter (self only) + trumpets?: number // banked Trumpets for next battle (Bird of Paradise, self only) deck?: Card[] }