From 7eb4812f86fc4cf8ac306dc001886bbc598aa2a9 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 01:12:36 -0400 Subject: [PATCH] Let players peek both decks during and after a battle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record each seat's arranged lineup on the battle result (already public) and reveal the full deck in a popover when hovering a deck pile in the battle view — the opponent's included. --- internal/game/battle.go | 15 ++++++----- internal/game/battle_test.go | 18 +++++++++++++ web/src/components/BattlePhase.tsx | 23 ++++++++++++++++- web/src/styles.css | 41 ++++++++++++++++++++++++++++++ web/src/types.ts | 1 + 5 files changed, 91 insertions(+), 7 deletions(-) diff --git a/internal/game/battle.go b/internal/game/battle.go index 32dee33..b755cce 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -102,12 +102,14 @@ type BattleEvent struct { Bonus int `json:"bonus"` } -// BattleResult is the full, public record of one round's battle. Only what -// was revealed appears in Events; the rest of the winner's stack stays -// hidden. +// BattleResult is the full, public record of one round's battle. type BattleResult struct { - Round int `json:"round"` - StackSizes []int `json:"stackSizes"` // starting deck size per seat + Round int `json:"round"` + StackSizes []int `json:"stackSizes"` // starting deck size per seat + // Lineups is each seat's arranged deck at battle start (top of deck + // first). Public so players can review the whole matchup — including the + // opponent's cards — during and after the fight. + Lineups [][]Card `json:"lineups,omitempty"` Events []BattleEvent `json:"events"` WinnerSeat int `json:"winnerSeat"` // -1 = draw Trophies int `json:"trophies"` // awarded to the winner @@ -189,7 +191,7 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit) int { // stalemate. func (g *Game) resolveBattle() { n := len(g.Players) - res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n)} + res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)} sides := make([]*battleSide, n) emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) } @@ -197,6 +199,7 @@ func (g *Game) resolveBattle() { s := &battleSide{stack: append([]Card(nil), p.Deck...)} sides[p.Seat] = s res.StackSizes[p.Seat] = len(p.Deck) + res.Lineups[p.Seat] = append([]Card(nil), p.Deck...) } // seatOrder resolves the priority-token holder first, then everyone else. // Reveals, queued play effects, and cross-side triggers all follow it, so diff --git a/internal/game/battle_test.go b/internal/game/battle_test.go index 40bc2f2..e7e7f76 100644 --- a/internal/game/battle_test.go +++ b/internal/game/battle_test.go @@ -696,3 +696,21 @@ func TestPriorityTokenTransfer(t *testing.T) { t.Fatalf("a draw should leave the token put, priority=%d", g.PrioritySeat) } } + +// TestBattleResultExposesLineups: both seats' arranged decks are recorded on +// the result so players can peek the whole matchup. +func TestBattleResultExposesLineups(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.pet("A", 3), g.pet("B", 2)}, + []Card{g.pet("C", 4)}) + if len(res.Lineups) != 2 { + t.Fatalf("expected a lineup per seat, got %d", len(res.Lineups)) + } + if len(res.Lineups[0]) != 2 || res.Lineups[0][0].Name != "A" { + t.Fatalf("seat 0 lineup should mirror its arranged deck: %+v", res.Lineups[0]) + } + if len(res.Lineups[1]) != 1 || res.Lineups[1][0].Name != "C" { + t.Fatalf("seat 1 lineup should mirror its arranged deck: %+v", res.Lineups[1]) + } +} diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index fdb5060..31271f8 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -175,6 +175,8 @@ export function BattlePhase({ view, send }: Props) { const events = battle.events ?? [] const [step, setStep] = useState(0) const [acked, setAcked] = useState(false) + const [peekSeat, setPeekSeat] = useState(null) + const lineups = battle.lineups const done = step >= events.length const lastEvent = step > 0 ? events[step - 1] : null @@ -207,8 +209,14 @@ export function BattlePhase({ view, send }: Props) { const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat const pop = done ? null : unitPop(lastEvent, seat, events, step) + const lineup = lineups?.[seat] ?? [] const stackEl = ( -
+
setPeekSeat(seat) : undefined} + onMouseLeave={() => setPeekSeat((s) => (s === seat ? null : s))} + title={lineup.length ? 'Hover to see the whole deck' : undefined} + > {s.stack > 0 ? (
{s.stack} @@ -226,6 +234,19 @@ export function BattlePhase({ view, send }: Props) {
)} + {peekSeat === seat && lineup.length > 0 && ( +
+
+ {seat === youSeat ? 'Your' : 'Opponent’s'} deck · {lineup.length} card + {lineup.length !== 1 ? 's' : ''} (top first) +
+
+ {lineup.map((c, i) => ( + + ))} +
+
+ )}
) diff --git a/web/src/styles.css b/web/src/styles.css index b0bb3b5..008e175 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1377,3 +1377,44 @@ h3 { transform: scale(1.5); } } + +/* --- deck peek (battle) --- */ + +.stackpile.peekable { + cursor: help; +} + +.deck-peek { + position: absolute; + bottom: calc(100% + 8px); + z-index: 60; + width: max-content; + max-width: 60vw; + background: rgba(18, 53, 31, 0.98); + border: 2px solid var(--cocoa); + border-radius: 10px; + padding: 8px 10px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); +} + +.deck-peek-left { + left: 0; +} + +.deck-peek-right { + right: 0; +} + +.deck-peek-label { + color: var(--gold); + font-size: 0.72rem; + font-weight: 800; + margin-bottom: 6px; +} + +.deck-peek-cards { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-width: min(60vw, 560px); +} diff --git a/web/src/types.ts b/web/src/types.ts index f57a4a5..101b9bd 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -69,6 +69,7 @@ export interface BattleEvent { export interface BattleResult { round: number stackSizes: number[] + lineups?: Card[][] // each seat's arranged deck (top first); public for peeking events: BattleEvent[] | null winnerSeat: number trophies: number