From 094f38593e4f3ab7df3367a8153614836d5d8aaf Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 24 Jul 2026 09:51:25 -0400 Subject: [PATCH] Fix a bunch of bugs. --- internal/game/battle.go | 26 ++++-- internal/game/battle_test.go | 47 +++++++++- internal/game/cards.go | 15 +++- internal/game/golden45_test.go | 29 ++++++ web/src/components/CardView.tsx | 37 ++++---- web/src/components/ShopPhase.tsx | 37 ++++++-- web/src/components/Table.tsx | 50 +++++++++++ web/src/styles.css | 146 +++++++++++++++++++++++-------- 8 files changed, 308 insertions(+), 79 deletions(-) diff --git a/internal/game/battle.go b/internal/game/battle.go index 964d2f2..23426d4 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -671,15 +671,19 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) { } } - // hurt fires Hurt effects on a pet that was damaged and survived. + // hurt fires Hurt effects on a pet that took damage. It fires even when the + // hit was fatal — the Lizard drops its Bee even when killed in one shot — + // except for self-buffs marked SurviveOnly (Peacock, Gorilla), which need + // the pet to live on. hurt := func(seat int, u *BattleUnit) { - if !u.Alive() { - return - } + alive := u.Alive() for _, e := range u.effects() { if e.Trigger != TriggerHurt || !allowed(e, u) { continue } + if !alive && e.SurviveOnly { + continue + } if !spend(seat, e, u.Card.Name) { continue } @@ -787,6 +791,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) { emitPrevent(target, tu.Card.Name, prev) } if died { + // Hurt still fires on a fatal hit (Lizard's Bee), before the faint. + if dealt > 0 { + hurt(target, tu) + } faint(target, tu) sides[target].unit = nil return true @@ -994,8 +1002,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) { // when its own side is already out (Crocodile's last-pet volley). anyDeath := false for _, q := range plays { - // Effects sourced from a specific unit fizzle if it's gone. - if q.unit != nil && sides[q.seat].unit != q.unit { + // Effects sourced from a specific unit fizzle if it's gone — unless + // they're posthumous (the Manatee still adds its Apples after rocking + // itself to death). + if q.unit != nil && sides[q.seat].unit != q.unit && !q.effect.Posthumous { continue } if q.unit != nil && !allowed(q.effect, q.unit) { @@ -1293,6 +1303,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) { dealt := []int{dealtA, dealtB} for seat, u := range []*BattleUnit{ua, ub} { if !u.Alive() { + // Hurt still fires on a fatal clash (Lizard's Bee), before the faint. + if dealt[seat] > 0 { + hurt(seat, u) + } faint(seat, u) sides[seat].unit = nil } else { diff --git a/internal/game/battle_test.go b/internal/game/battle_test.go index 3f741f6..f38db91 100644 --- a/internal/game/battle_test.go +++ b/internal/game/battle_test.go @@ -141,6 +141,38 @@ func TestBattleDamageMarkers(t *testing.T) { } } +// A Hurt effect still fires on a fatal hit: the Lizard drops a Bee on top of +// its deck even when killed in one clash. +func TestLizardBeeOnFatalHit(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Lizard")}, + []Card{g.pet("Wall", 20)}, // one clash kills the 2-power Lizard + ) + bees := 0 + for _, ev := range eventsOfType(res, "summon") { + if ev.Card != nil && ev.Card.Name == "Bee" { + bees++ + } + } + if bees == 0 { + t.Fatal("Lizard should add a Bee even when killed in one hit") + } +} + +// A Hurt effect marked SurviveOnly does NOT fire on a fatal hit: the Peacock's +// "if this pet hasn't fainted" apple is skipped when the clash kills it. +func TestPeacockNoEatOnFatalHit(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.realPet(t, "Peacock")}, + []Card{g.pet("Wall", 20)}, // one clash kills the 2-power Peacock + ) + if len(eventsOfType(res, "eat")) != 0 { + t.Fatal("Peacock should not eat an apple when it faints") + } +} + func TestBattleEqualPowerBothDie(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, @@ -486,7 +518,8 @@ func TestDolphinThrowsThreeRocks(t *testing.T) { } } -// Camel pushes an apple onto its own stack whenever it's hurt and survives. +// Camel pushes an apple onto its own stack whenever it's hurt — including the +// hit that finishes it, since Hurt now fires on faint. func TestCamelHurtSummonsApple(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, @@ -494,10 +527,16 @@ func TestCamelHurtSummonsApple(t *testing.T) { []Card{g.pet("Chip", 1), g.pet("Chip2", 3)}, ) // Clash 1: camel takes 1 (survives) → apple onto A's stack. Clash 2 vs - // Chip2: both die. A reveals apple + Ally (3 power) and wins. + // Chip2: camel dies but its Hurt still fires → a second apple. A reveals + // both apples onto Ally and wins. summons := eventsOfType(res, "summon") - if len(summons) != 1 || summons[0].Seat != 0 || summons[0].Card.Food != FoodApple { - t.Fatalf("camel should summon one apple onto its own stack: %+v", summons) + if len(summons) != 2 { + t.Fatalf("camel should summon an apple each time it's hurt (twice): %+v", summons) + } + for _, s := range summons { + if s.Seat != 0 || s.Card.Food != FoodApple { + t.Fatalf("camel apples should land on its own stack: %+v", s) + } } if res.WinnerSeat != 0 { t.Fatalf("apple-buffed ally should win, got %d", res.WinnerSeat) diff --git a/internal/game/cards.go b/internal/game/cards.go index 98dda1b..6b32593 100644 --- a/internal/game/cards.go +++ b/internal/game/cards.go @@ -258,6 +258,15 @@ type Effect struct { // effect to fire (Golden pack). It's auto-paid when affordable and the // effect is skipped otherwise — battles take no player input. CostTrumpet int `json:"costTrumpet,omitempty"` + // SurviveOnly marks a Hurt effect that fires only when the pet lives through + // the hit. By default a Hurt effect still fires on a fatal hit (the Lizard + // drops its Bee even when killed); self-buffs that are pointless once the + // pet is gone (Peacock's apple, Gorilla's shield) opt out with this. + SurviveOnly bool `json:"surviveOnly,omitempty"` + // Posthumous marks a Play effect that still resolves even after its own pet + // has left play — so the Manatee still adds its Apples after rocking itself + // to death. + Posthumous bool `json:"posthumous,omitempty"` } // count normalizes the zero value to 1. @@ -361,7 +370,7 @@ var petTiers = [MaxRounds][]petTemplate{ }, { Name: "Peacock", Power: 2, Suits: []Suit{SuitBlue, SuitRed}, - Effects: []Effect{{Trigger: TriggerHurt, Action: ActionEatApple}}, + Effects: []Effect{{Trigger: TriggerHurt, Action: ActionEatApple, SurviveOnly: true}}, EffectText: "Hurt: if this pet hasn't fainted, it eats 1 Apple", }, { @@ -500,7 +509,7 @@ var petTiers = [MaxRounds][]petTemplate{ { // Tier 6 { Name: "Gorilla", Power: 6, Suits: []Suit{SuitBlue, SuitYellow}, - Effects: []Effect{{Trigger: TriggerHurt, Action: ActionShieldSelf}}, + Effects: []Effect{{Trigger: TriggerHurt, Action: ActionShieldSelf, SurviveOnly: true}}, EffectText: "Hurt: the next time this pet is hit, prevent all damage", }, { @@ -727,7 +736,7 @@ var goldenPetTiers = [MaxRounds][]petTemplate{ Name: "Manatee", Power: 3, Suits: []Suit{SuitBlue, SuitYellow}, Effects: []Effect{ {Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, Target: "self"}, - {Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 4}, + {Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 4, Posthumous: true}, }, EffectText: "Play: throw 2 Rocks at itself, then add 4 Apples on top of your deck", }, diff --git a/internal/game/golden45_test.go b/internal/game/golden45_test.go index e27ef1d..877e707 100644 --- a/internal/game/golden45_test.go +++ b/internal/game/golden45_test.go @@ -136,6 +136,35 @@ func TestManateeSelfRock(t *testing.T) { } } +// Even when its own rocks are lethal, the Manatee still stacks all 4 apples — +// the summon is posthumous, so it fires after the Manatee faints. +func TestManateeSelfRockFatalStillAddsApples(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } // 2 rocks = 4 damage, past its 3 power + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Manatee")}, + []Card{g.pet("Wall", 20)}, + ) + var self *BattleEvent + for i, ev := range res.Events { + if ev.Type == "rock" && ev.Target == 0 { + self = &res.Events[i] + } + } + if self == nil || !self.TargetDied { + t.Fatalf("Manatee's own rocks should faint it: %+v", eventsOfType(res, "rock")) + } + apples := 0 + for _, ev := range eventsOfType(res, "summon") { + if ev.Card != nil && ev.Card.Name == "Apple" { + apples++ + } + } + if apples != 4 { + t.Fatalf("Manatee should still add 4 apples after fainting, got %d", apples) + } +} + // Poison Dart Frog, once set aside, throws rocks each time a Bee is played. func TestPoisonDartFrogBeeRocks(t *testing.T) { g, _, _ := testGame(t) diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index 506af8c..bc40b63 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -206,6 +206,15 @@ 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.kind === 'pet' && ( +
+ 0 ? 'is-buffed' : ''}`}>{power} + {damage > 0 && !dead && −{damage}} +
+ )}
{card.tier ? `T${card.tier}` : '·'} {card.suit && } @@ -214,27 +223,13 @@ export function CardView({ {artFor(card.name)}
{card.name}
- {card.kind === 'pet' ? ( - <> - {card.effectText && ( -
- {renderEffect(card.effectText)} -
- )} -
- 0 ? 'is-buffed' : ''}`}>{power} - {damage > 0 && !dead && −{damage}} -
- - ) : ( -
- {card.effectText - ? renderEffect(card.effectText) - : card.food === 'apple' - ? '+1 power (this battle)' - : ''} -
- )} +
+ {card.effectText + ? renderEffect(card.effectText) + : card.kind === 'food' && card.food === 'apple' + ? '+1 power (this battle)' + : ''} +
{selected &&
} ) diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 59bb98c..a7bbe92 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -93,6 +93,17 @@ export function ShopPhase({ view, you, send }: Props) { wasMyTurn.current = myTurn }, [myTurn, view.pending, view.pendingReveal]) const overPets = you.petCount > view.maxPets + + // Show every coin you started the round with, fading the spent ones rather + // than dropping them. Coins only fall within a round, so the highest count + // seen this round is the starting purse. Reset when the round changes. + const purse = useRef({ round: view.round, max: you.coins }) + if (purse.current.round !== view.round) { + purse.current = { round: view.round, max: you.coins } + } + purse.current.max = Math.max(purse.current.max, you.coins) + const totalCoins = purse.current.max + const deck = you.deck ?? [] const opponent = view.players.find((p) => p.seat !== view.youSeat) const pending = view.pending @@ -235,16 +246,24 @@ export function ShopPhase({ view, you, send }: Props) { )} - {turnBanner &&
Your turn!
} + {turnBanner && ( +
+ Your turn! +
+ )} - {/* Coins as big golden discs above the buy row. */} -
- {Array.from({ length: Math.max(you.coins, 0) }, (_, i) => ( - - 🪙 - - ))} - {you.coins === 0 && out of gold} + {/* Coins as big golden discs above the buy row. Spent coins stay put but + grey out, so you can see what you started the round with. */} +
+ {totalCoins > 0 ? ( + Array.from({ length: totalCoins }, (_, i) => ( + = you.coins ? 'is-spent' : ''}`}> + 🪙 + + )) + ) : ( + out of gold + )}
{/* Shop row */} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index dd661e1..8ccb690 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -1,7 +1,9 @@ import { useMemo, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' +import { createPortal } from 'react-dom' import { useGame } from '../useGame' import { useCatalog } from '../useCatalog' +import { CardView } from './CardView' import type { Card, Session } from '../types' import { Lobby } from './Lobby' import { ShopPhase } from './ShopPhase' @@ -33,6 +35,10 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v return { round: battleRound, step: next } }) + // A shop-phase peek at an opponent's deck from the previous round's battle + // (its arranged lineup is already public). Anchored under the clicked button. + const [deckPeek, setDeckPeek] = useState<{ seat: number; rect: DOMRect } | null>(null) + // The battle outcome is known before the replay plays out, so we hold its // "result" log entry back: it's dropped from the persistent list during the // battle and appended as the final battle line only once the replay reaches @@ -145,6 +151,21 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v {view.phase === 'shop' && p.seat !== view.youSeat && ( 🪙 {p.coins} )} + {/* Peek at the opponent's deck from last round's battle. */} + {view.phase === 'shop' && + p.seat !== view.youSeat && + (view.battle?.lineups?.[p.seat]?.length ?? 0) > 0 && ( + + )} {(p.avocados ?? 0) > 0 && ( 🥑 {p.avocados} )} @@ -186,6 +207,35 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v {view.debug && ( )} + + {deckPeek && + view.phase === 'shop' && + (() => { + const lineup = view.battle?.lineups?.[deckPeek.seat] ?? [] + if (!lineup.length) return null + const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent' + const left = Math.max(8, Math.min(deckPeek.rect.left, window.innerWidth - 380)) + return createPortal( + <> +
setDeckPeek(null)} /> +
+
+ {oppName}’s deck last round · {lineup.length} card + {lineup.length !== 1 ? 's' : ''} (top first) +
+
+ {lineup.map((c, i) => ( + + ))} +
+
+ , + document.body, + ) + })()}
) } diff --git a/web/src/styles.css b/web/src/styles.css index 58bf770..808b9fa 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -468,6 +468,28 @@ h3 { font-weight: 700; } +/* A chip that's actually a button (e.g. peek at the opponent's deck). */ +.chip-btn { + font-family: var(--font-body); + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.08); + color: inherit; + border-radius: 999px; + padding: 1px 8px; + transition: background 120ms ease, border-color 120ms ease; +} + +.chip-btn:hover { + background: rgba(255, 255, 255, 0.18); +} + +.chip-btn.is-active { + background: var(--gold); + color: var(--cocoa); + border-color: var(--gold); +} + .conn-dot { width: 8px; height: 8px; @@ -961,10 +983,26 @@ h3 { .card-top { width: 100%; display: flex; - justify-content: space-between; + /* The power badge now floats over the top-left corner, so tier + suit sit + together at the top-right. */ + justify-content: flex-end; align-items: center; + gap: 5px; font-size: 0.72rem; z-index: 1; + 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. */ +.card-combat { + position: absolute; + top: 5px; + left: 5px; + z-index: 2; + display: flex; + align-items: center; + gap: 4px; } .card-tier { @@ -1017,6 +1055,11 @@ h3 { flex: 0 0 auto; z-index: 1; color: var(--cocoa); + /* Always center the name — including when a long name wraps to two lines, so + the second line stays centered instead of drifting off to one side. */ + width: 100%; + text-align: center; + overflow-wrap: break-word; } .card-lg .card-name { @@ -1060,14 +1103,6 @@ h3 { display: none; } -.card-bottom { - display: flex; - gap: 6px; - align-items: center; - margin-top: auto; - z-index: 1; -} - /* Suit markers as glossy enamel dots. */ .suit-dot { display: inline-block; @@ -1094,36 +1129,43 @@ h3 { background: radial-gradient(circle at 35% 28%, #ffdd6b, #e6ac1f); } -/* The power badge is a stamped honey coin with a raised rim. */ +/* The power badge is a fiery starburst — like a little explosion — carrying the + pet's attack. The spikes are cut with clip-path, so the drop shadow lives on + `filter` (a box-shadow would be clipped away with the corners). */ .card-power { font-family: var(--font-display); - background: radial-gradient(circle at 35% 28%, #ffcf6b, var(--coral-dark)); + background: radial-gradient(circle at 38% 30%, #ffcf6b 4%, var(--coral) 42%, var(--coral-dark)); color: #fff; - text-shadow: 0 1px 2px rgba(120, 45, 5, 0.55); - border-radius: 50%; - width: 31px; - height: 31px; + text-shadow: 0 1px 2px rgba(120, 45, 5, 0.7); + width: 32px; + height: 32px; display: grid; place-items: center; - font-size: 0.98rem; - /* The rim is drawn with an inset ring rather than a real border: a - border-radius:50% element with a semi-transparent border renders a - visible seam at its 6 o'clock point in Chromium (scale-invariant). */ - box-shadow: - inset 0 0 0 2px rgba(120, 45, 5, 0.35), - inset 0 1.5px 1px rgba(255, 255, 255, 0.55), - inset 0 -2px 3px rgba(120, 45, 5, 0.4), - 0 2px 3px rgba(0, 0, 0, 0.3); + font-size: 0.9rem; + clip-path: polygon( + 50% 0%, 61% 18%, 82% 12%, 78% 34%, 100% 43%, 83% 58%, + 93% 79%, 70% 76%, 62% 98%, 50% 82%, 38% 98%, 30% 76%, + 7% 79%, 17% 58%, 0% 43%, 22% 34%, 18% 12%, 39% 18% + ); + filter: drop-shadow(0 2px 2px rgba(20, 12, 4, 0.5)); } .card-power.is-buffed { - background: radial-gradient(circle at 35% 28%, #9ff0af, #1f9e55); - text-shadow: 0 1px 2px rgba(15, 60, 30, 0.55); - box-shadow: - inset 0 0 0 2px rgba(15, 80, 40, 0.4), - inset 0 1.5px 1px rgba(255, 255, 255, 0.55), - inset 0 -2px 3px rgba(15, 80, 40, 0.4), - 0 0 8px rgba(80, 220, 120, 0.5); + background: radial-gradient(circle at 38% 30%, #c9ffd4 4%, #4fd07f 42%, #1f9e55); + text-shadow: 0 1px 2px rgba(15, 60, 30, 0.7); + filter: drop-shadow(0 0 5px rgba(80, 220, 120, 0.75)); +} + +.card-lg .card-power { + width: 37px; + height: 37px; + font-size: 1.02rem; +} + +.card-sm .card-power { + width: 27px; + height: 27px; + font-size: 0.76rem; } .card-damage { @@ -1263,6 +1305,15 @@ h3 { line-height: 1; filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.4)); animation: coin-pop 0.25s ease; + transition: filter 200ms ease, opacity 200ms ease, transform 200ms ease; +} + +/* A spent coin stays in place but greys out and fades. */ +.coin-disc.is-spent { + filter: grayscale(1) brightness(0.7); + opacity: 0.4; + transform: scale(0.88); + animation: none; } @keyframes coin-pop { @@ -1285,10 +1336,23 @@ h3 { place-items: center; pointer-events: none; z-index: 50; +} + +/* Light text on a golden rounded-rect plate, so the flourish reads as a solid + badge rather than bare text floating over the board. */ +.turn-banner-pill { font-family: var(--font-display); - font-size: clamp(2.5rem, 9vw, 6rem); - color: var(--gold); - text-shadow: 0 4px 24px rgba(0, 0, 0, 0.6); + font-size: clamp(2rem, 7vw, 4.5rem); + color: #fff8e6; + background: linear-gradient(180deg, var(--gold), var(--gold-deep)); + padding: 0.28em 0.8em; + border-radius: 22px; + border: 3px solid rgba(255, 255, 255, 0.55); + text-shadow: 0 2px 5px rgba(120, 78, 8, 0.55); + box-shadow: + inset 0 2px 0 rgba(255, 255, 255, 0.55), + inset 0 -6px 14px rgba(150, 100, 20, 0.35), + 0 14px 34px rgba(0, 0, 0, 0.5); animation: turn-flash 1.4s ease forwards; } @@ -1978,7 +2042,9 @@ h3 { .battle-unit .card { width: 96px; - height: 132px; + /* Taller than a shop card so the full ability text fits without shrinking to + an unreadable size during the battle. */ + height: 162px; } /* Battle cards are short, so give the ability text more of the card by @@ -2389,7 +2455,7 @@ h3 { } .battle-unit .card { width: 78px; - height: 110px; + height: 134px; } } @@ -2540,3 +2606,11 @@ h3 { gap: 6px; max-width: min(60vw, 560px); } + +/* Backdrop behind the toolbar deck-peek: any click outside dismisses it. Sits + below the popover (z 60) but above the board. */ +.deck-peek-backdrop { + position: fixed; + inset: 0; + z-index: 55; +}