From 506c6e5b558994b553f61014e5a6558d033319b6 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 08:00:59 -0400 Subject: [PATCH] Add a persistent event log for shop and prep actions. --- internal/game/game.go | 20 ++++- internal/game/log.go | 55 +++++++++++++ internal/game/view.go | 3 + web/src/components/EventLog.tsx | 91 +++++++++++++++++++++ web/src/components/Table.tsx | 22 ++++-- web/src/styles.css | 136 ++++++++++++++++++++++++++++++++ web/src/types.ts | 12 +++ 7 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 internal/game/log.go create mode 100644 web/src/components/EventLog.tsx diff --git a/internal/game/game.go b/internal/game/game.go index 8be89f6..076eb81 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -93,6 +93,9 @@ type Game struct { Battle *BattleResult `json:"battle,omitempty"` // most recent battle NextCardID int `json:"nextCardId"` WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie + // Log is the running, human-readable event log shown across every phase. + Log []LogEntry `json:"log,omitempty"` + LogSeq int `json:"logSeq"` // last assigned entry sequence number // RollDie overrides the rock die (faces 0,0,1,1,2,2) for tests. Nil // (including after loading from storage) means a fair random roll. @@ -225,6 +228,7 @@ func (g *Game) startShopRound() { } // The priority-token holder shops first. g.Turn = g.PrioritySeat + g.logf(-1, "πŸ›’", "Round %d β€” shop opens (%s goes first).", g.Round, g.Players[g.PrioritySeat].Name) } // drawFromTier pops the top card of the given tier's deck (1-based tier). @@ -273,6 +277,7 @@ func (g *Game) Buy(playerID string, rowIdx int) error { p.Coins-- bought := g.ShopRow[rowIdx] p.Deck = append(p.Deck, bought) + g.logf(p.Seat, "πŸ›’", "%s bought %s %s.", p.Name, article(bought.Name), bought.Name) g.ShopRow[rowIdx] = g.drawFromTier(g.Round) g.applyShopTrigger(p, bought, TriggerBuy) g.advanceShopTurn() @@ -310,11 +315,15 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) { } switch e.Action { case ActionGainApple: - for range e.count() { + n := e.count() + for range n { p.Deck = append(p.Deck, g.newApple()) } + g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", + Text: fmt.Sprintf("%s adds %d apple%s to %s's deck.", c.Name, n, plural(n), p.Name)}) case ActionRefreshGold: p.Coins = min(p.Coins+e.count(), CoinsPerRound) + g.logf(p.Seat, "πŸͺ™", "%s refreshes %s's coins.", c.Name, p.Name) case ActionDoubleApples: apples := 0 for _, dc := range p.Deck { @@ -325,6 +334,10 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) { for range apples { p.Deck = append(p.Deck, g.newApple()) } + if apples > 0 { + g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", + Text: fmt.Sprintf("%s doubles %s's apples (+%d).", c.Name, p.Name, apples)}) + } } } } @@ -349,6 +362,8 @@ func (g *Game) sellCards(p *Player, cardIDs []string) error { } for _, c := range sold { p.Deck = append(p.Deck, g.newApple()) + g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", + Text: fmt.Sprintf("%s sold %s β€” it becomes an apple.", p.Name, c.Name)}) g.applyShopTrigger(p, c, TriggerSell) } return nil @@ -397,6 +412,7 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error { } p.Coins-- p.TripledThisRound = true + g.logf(p.Seat, "πŸ”„", "%s traded in 3 %s pets for a tier %d pick.", p.Name, suit, nextTier) g.Pending = &PendingTrade{ PlayerID: playerID, Tier: nextTier, @@ -424,6 +440,7 @@ func (g *Game) TradeChoose(playerID string, pick int) error { tierIdx := g.Pending.Tier - 1 g.ShopDecks[tierIdx] = append(g.ShopDecks[tierIdx], other) g.Pending = nil + g.logf(p.Seat, "πŸ”„", "%s picked %s from the trade.", p.Name, chosen.Name) // Pets obtained via the Triple action trigger their Buy effects. g.applyShopTrigger(p, chosen, TriggerBuy) g.advanceShopTurn() @@ -456,6 +473,7 @@ func (g *Game) Pass(playerID string) error { return err } p.Coins = 0 + g.logf(p.Seat, "βœ‹", "%s passed.", p.Name) g.advanceShopTurn() return nil } diff --git a/internal/game/log.go b/internal/game/log.go new file mode 100644 index 0000000..012a9dc --- /dev/null +++ b/internal/game/log.go @@ -0,0 +1,55 @@ +package game + +import ( + "fmt" + "strings" +) + +// LogEntry is one human-readable line in the game's running event log. The +// log is public β€” both players see the same entries across every phase β€” and +// explains not just what happened but why (e.g. which pet granted an apple). +type LogEntry struct { + Seq int `json:"seq"` // stable, monotonically increasing id + Round int `json:"round"` // round the entry belongs to + Phase Phase `json:"phase"` + Seat int `json:"seat"` // acting seat, or -1 when none + Icon string `json:"icon,omitempty"` // leading emoji + Text string `json:"text"` // the sentence itself + Source string `json:"source,omitempty"` // card id that caused a spawn + Spawn string `json:"spawn,omitempty"` // "apple" | "bee" for spawn entries +} + +// addLog appends an entry, stamping it with the next sequence number and the +// current round/phase. Callers set Seat/Icon/Text (and Source/Spawn when the +// entry represents something spawning off a card). +func (g *Game) addLog(e LogEntry) { + g.LogSeq++ + e.Seq = g.LogSeq + e.Round = g.Round + e.Phase = g.Phase + g.Log = append(g.Log, e) +} + +// logf is the common case: an entry with just a seat, icon, and message. +func (g *Game) logf(seat int, icon, format string, args ...any) { + g.addLog(LogEntry{Seat: seat, Icon: icon, Text: fmt.Sprintf(format, args...)}) +} + +// article returns "a" or "an" to suit the following word. +func article(word string) string { + if word == "" { + return "a" + } + if strings.ContainsRune("aeiouAEIOU", rune(word[0])) { + return "an" + } + return "a" +} + +// plural returns "s" when n is not 1, for simple "N apple(s)" phrasing. +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/internal/game/view.go b/internal/game/view.go index 4c3fbff..f93b420 100644 --- a/internal/game/view.go +++ b/internal/game/view.go @@ -35,6 +35,8 @@ type View struct { Pending *PendingTrade `json:"pending,omitempty"` Battle *BattleResult `json:"battle,omitempty"` WinnerSeat int `json:"winnerSeat"` + // Log is the shared, public event log shown across every phase. + Log []LogEntry `json:"log,omitempty"` // 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"` @@ -54,6 +56,7 @@ func (g *Game) ViewFor(playerID string) View { PrioritySeat: g.PrioritySeat, ShopRow: g.ShopRow, WinnerSeat: g.WinnerSeat, + Log: g.Log, } for _, deck := range g.ShopDecks { v.DeckCounts = append(v.DeckCounts, len(deck)) diff --git a/web/src/components/EventLog.tsx b/web/src/components/EventLog.tsx new file mode 100644 index 0000000..67e4eff --- /dev/null +++ b/web/src/components/EventLog.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from 'react' +import type { LogEntry } from '../types' + +// A single battle line, derived from the battle event stream and fed in by +// BattlePhase so the log stays in step with the replay controls. +export interface BattleLogLine { + key: string + icon?: string + text: string + seat: number // -1 = neutral/system +} + +interface Props { + entries: LogEntry[] + youSeat: number + // During a battle, the lines revealed up to the current replay step. The + // last one is the "current" step and gets highlighted. + battleLines?: BattleLogLine[] +} + +function seatClass(seat: number, youSeat: number): string { + if (seat < 0) return 'log-system' + return seat === youSeat ? 'log-you' : 'log-opp' +} + +// EventLog is the persistent, human-readable narration of the game, shown in +// every phase. Shop/prep entries come from the server; battle entries are fed +// in step-by-step so stepping back through the replay walks back up the log. +export function EventLog({ entries, youSeat, battleLines }: Props) { + const [open, setOpen] = useState(true) + const bodyRef = useRef(null) + const lastSeq = entries.length ? entries[entries.length - 1].seq : 0 + const battleLen = battleLines?.length ?? 0 + + // Keep the newest line in view as the log grows or the replay advances. + useEffect(() => { + const el = bodyRef.current + if (el) el.scrollTop = el.scrollHeight + }, [lastSeq, battleLen, open]) + + return ( + + ) +} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index 9230498..41c4e5e 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -6,6 +6,7 @@ import { ArrangePhase } from './ArrangePhase' import { BattlePhase } from './BattlePhase' import { GameOver } from './GameOver' import { DebugPanel } from './DebugPanel' +import { EventLog } from './EventLog' // Table connects to the game and routes to the right phase screen. export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) { @@ -56,15 +57,20 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v -
- {view.phase === 'lobby' && } - {(view.phase === 'shop' || view.phase === 'cleanup') && ( - +
+
+ {view.phase === 'lobby' && } + {(view.phase === 'shop' || view.phase === 'cleanup') && ( + + )} + {view.phase === 'arrange' && } + {view.phase === 'battle' && } + {view.phase === 'gameover' && } +
+ {view.phase !== 'lobby' && ( + )} - {view.phase === 'arrange' && } - {view.phase === 'battle' && } - {view.phase === 'gameover' && } -
+ {opponents.some((p) => !p.connected) && view.phase !== 'lobby' && (
An opponent is disconnected…
diff --git a/web/src/styles.css b/web/src/styles.css index 650907f..be105be 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -330,6 +330,13 @@ h3 { padding: 4px 10px; } +.table-body { + flex: 1; + display: flex; + align-items: flex-start; + min-height: 0; +} + .table-main { flex: 1; padding: 20px 16px 40px; @@ -338,6 +345,135 @@ h3 { margin: 0 auto; } +/* ---------- event log ---------- */ + +.event-log { + width: 300px; + flex-shrink: 0; + align-self: stretch; + position: sticky; + top: 0; + max-height: calc(100vh - 58px); + display: flex; + flex-direction: column; + background: rgba(0, 0, 0, 0.28); + border-left: 3px solid rgba(0, 0, 0, 0.35); +} + +.event-log.is-collapsed { + width: 42px; +} + +.event-log-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 8px 10px; + font-family: var(--font-display); + font-size: 0.9rem; + color: var(--gold); + background: rgba(0, 0, 0, 0.25); + border-bottom: 2px solid rgba(0, 0, 0, 0.3); + white-space: nowrap; +} + +.event-log.is-collapsed .event-log-head { + writing-mode: vertical-rl; + justify-content: flex-start; + gap: 10px; + padding: 10px 8px; + height: 100%; +} + +.event-log.is-collapsed .event-log-head button { + writing-mode: horizontal-tb; +} + +.event-log-body { + flex: 1; + overflow-y: auto; + padding: 8px 10px 12px; + display: flex; + flex-direction: column; + gap: 5px; + font-size: 0.82rem; + line-height: 1.25; +} + +.log-empty { + padding: 8px 2px; + font-style: italic; +} + +.log-line { + display: flex; + gap: 6px; + align-items: baseline; + padding: 3px 6px; + border-radius: 6px; + border-left: 3px solid transparent; + background: rgba(255, 255, 255, 0.03); +} + +.log-icon { + flex-shrink: 0; +} + +.log-text { + min-width: 0; + word-break: break-word; +} + +.log-line.log-you { + border-left-color: var(--gold); +} + +.log-line.log-opp { + border-left-color: var(--red); +} + +.log-line.log-system { + background: none; + color: var(--cream-dark); + justify-content: center; + font-family: var(--font-display); + font-size: 0.8rem; + opacity: 0.85; + margin-top: 4px; +} + +.log-line.log-divider { + border-top: 1px dashed rgba(253, 243, 220, 0.25); + padding-top: 8px; + color: var(--gold); +} + +.log-line.log-current { + background: rgba(255, 207, 92, 0.18); + outline: 1px solid rgba(255, 207, 92, 0.4); +} + +@media (max-width: 860px) { + .table-body { + flex-direction: column; + } + .event-log { + width: 100%; + position: static; + max-height: 220px; + border-left: none; + border-top: 3px solid rgba(0, 0, 0, 0.35); + } + .event-log.is-collapsed { + width: 100%; + } + .event-log.is-collapsed .event-log-head { + writing-mode: horizontal-tb; + height: auto; + } +} + /* ---------- lobby ---------- */ .lobby { diff --git a/web/src/types.ts b/web/src/types.ts index 101b9bd..84e73a5 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -75,6 +75,17 @@ export interface BattleResult { trophies: number } +export interface LogEntry { + seq: number + round: number + phase: Phase + seat: number // acting seat, or -1 + icon?: string + text: string + source?: string // card id that caused a spawn + spawn?: string // 'apple' | 'bee' +} + export interface GameView { gameId: string code: string @@ -90,6 +101,7 @@ export interface GameView { pending?: PendingTrade battle?: BattleResult winnerSeat: number + log?: LogEntry[] debug?: boolean // server DEBUG mode: unlocks the buy-any-card panel }