Add support for up to 6 players.

This commit is contained in:
Greyson Parrelli
2026-07-28 07:36:09 -04:00
parent e542118175
commit a4f5f6910d
38 changed files with 2306 additions and 713 deletions
+5 -3
View File
@@ -23,9 +23,11 @@ export function joinGame(code: string, name: string): Promise<Session> {
return post('/api/join', { code, name })
}
export async function fetchCatalog(pack?: string): Promise<Card[]> {
const url = pack ? `/api/catalog?pack=${encodeURIComponent(pack)}` : '/api/catalog'
const res = await fetch(url)
export async function fetchCatalog(packs?: string[]): Promise<Card[]> {
const query = (packs ?? [])
.map((p) => `pack=${encodeURIComponent(p)}`)
.join('&')
const res = await fetch(query ? `/api/catalog?${query}` : '/api/catalog')
if (!res.ok) throw new Error('failed to load catalog')
return (await res.json()) as Card[]
}
+12 -3
View File
@@ -313,14 +313,18 @@ export function ArrangePhase({ view, you, send }: Props) {
return n
})()
const opponent = view.players.find((p) => p.seat !== view.youSeat)
// Everyone arranges at the same time, so the wait is on whoever is left.
const waitingOn = view.players.filter((p) => p.seat !== view.youSeat && !p.ready)
const rival = view.players.find((p) => p.seat === view.yourOpponent)
if (locked) {
return (
<div className="centered">
<h2>Order locked in </h2>
<p className="muted">
Waiting for {opponent?.name ?? 'your opponent'} to arrange their deck
{waitingOn.length === 1
? `Waiting for ${waitingOn[0].name} to arrange their deck…`
: `Waiting for ${waitingOn.length} more players to arrange their decks…`}
</p>
</div>
)
@@ -329,7 +333,12 @@ export function ArrangePhase({ view, you, send }: Props) {
return (
<div className="arrange">
<div className="shop-status">
<span className="status-hot">Arrange your battle line</span>
<span className="status-hot">
Arrange your battle line
{/* Who you face is public (the pairings are printed in the rulebook),
and at a bigger table it's a different rival every round. */}
{rival && <> you fight {rival.name} this round</>}
</span>
</div>
<p className="hint">
The <strong>topmost</strong> card fights first. Food cards power up the
+135 -52
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import type { BattleEvent, BattleResult, Card, ClientMessage, GameView } from '../types'
import { sideOf } from '../types'
import { CardView, CardZoom } from './CardView'
import { DiceRoll, ROLL_MS } from './DiceRoll'
import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed'
@@ -17,6 +18,13 @@ interface Props {
// Step is owned by Table so the event log can stay in sync with the replay.
step: number
setStep: Dispatch<SetStateAction<number>>
// The battle being replayed, and the switcher for picking another table's.
// With more than two players several battles resolve at once and any of them
// can be watched; at two players there is only ever one.
battle: BattleResult
battles: BattleResult[]
selected: number
onSelect: (idx: number) => void
}
interface UnitVis {
@@ -245,40 +253,40 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
// unitPop decides the floating effect text over a seat's pet for the event
// currently playing. Null = nothing.
function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], step: number): string | null {
function unitPop(ev: BattleEvent | null, side: number, events: BattleEvent[], step: number): string | null {
if (!ev) return null
switch (ev.type) {
case 'rock':
if (ev.target !== seat) return null
if (ev.target !== side) return null
return ev.roll === 0 ? 'miss!' : `${ev.roll}`
case 'clash': {
const taken = clashDamageTaken(events, step - 1, seat)
const taken = clashDamageTaken(events, step - 1, side)
return taken > 0 ? `${taken}` : null
}
case 'shield':
return ev.seat === seat ? '🛡️' : null
return ev.seat === side ? '🛡️' : null
case 'prevent':
return ev.seat === seat ? `🛡️ ${ev.count}` : null
return ev.seat === side ? `🛡️ ${ev.count}` : null
case 'trumpet':
if (ev.seat !== seat) return null
if (ev.seat !== side) return null
return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}`
case 'mana':
if (ev.seat !== seat) return null
if (ev.seat !== side) return null
return (ev.count ?? 0) >= 0 ? `🔮 +${ev.count}` : `🔮 ${ev.count}`
case 'ailment':
if (ev.seat !== seat) return null
if (ev.seat !== side) return null
return ev.card?.ailment === 'spooked' ? '👻' : '🎯'
case 'bounce':
return ev.target === seat ? '🌀' : null
return ev.target === side ? '🌀' : null
case 'eat':
return ev.seat === seat ? '🍎' : null
return ev.seat === side ? '🍎' : null
case 'heal':
return ev.seat === seat ? '💚 +1' : null
return ev.seat === side ? '💚 +1' : null
case 'strip':
return ev.target === seat ? '💨' : null
return ev.target === side ? '💨' : null
case 'steal':
if (ev.seat === seat) return `+🍎×${ev.count}`
if (ev.target === seat) return `−🍎×${ev.count}`
if (ev.seat === side) return `+🍎×${ev.count}`
if (ev.target === side) return `−🍎×${ev.count}`
return null
default:
return null
@@ -287,8 +295,16 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st
// BattlePhase plays back the battle log: cards flip off each deck, rocks
// fly, pets clash, the fallen fade out, then the round result lands.
export function BattlePhase({ view, send, step, setStep }: Props) {
const battle = view.battle!
export function BattlePhase({
view,
send,
step,
setStep,
battle,
battles,
selected,
onSelect,
}: Props) {
const events = battle.events ?? []
// acked = the player committed to the next round (waiting on the opponent).
const [acked, setAcked] = useState(false)
@@ -299,7 +315,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
// and hides the other — so an absolutely-positioned popover gets clipped to
// the battlefield. We anchor it to the hovered stack's viewport rect and
// portal it to <body> so it floats over the whole window instead.
const [peek, setPeek] = useState<{ seat: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>(
const [peek, setPeek] = useState<{ side: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>(
null,
)
const lineups = battle.lineups
@@ -375,12 +391,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
[events, battle.stackSizes, upto],
)
const youSeat = view.youSeat
const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1
const you = view.players[youSeat]
const opp = view.players[oppSeat]
const won = battle.winnerSeat === youSeat
const draw = battle.winnerSeat < 0
// Everything inside a battle is indexed by side (0 or 1), not by seat, so the
// arena works in sides and maps out to players only for names and results.
// When you're watching someone else's table you have no side in it: side 1
// takes the bottom half so the board still reads as two halves facing off.
const seatAt = (side: number) => view.players.find((p) => p.seat === battle.seats?.[side])
const mySide = sideOf(battle, view.youSeat)
const spectating = mySide < 0
const bottomSide = spectating ? 1 : mySide
const topSide = 1 - bottomSide
const bottom = seatAt(bottomSide)
const top = seatAt(topSide)
// The result banner and the round hand-off always speak about *your* battle,
// even while you're watching another table play out.
const ownBattle = battles.find((b) => sideOf(b, view.youSeat) >= 0) ?? battle
const won = ownBattle.winnerSeat === view.youSeat
const draw = ownBattle.winnerSeat < 0
// Commit to the next round: ack and hand off to the server.
const proceed = () => {
@@ -405,19 +432,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
// two pets meet at a horizontal clash line. Each half is a row —
// [set-aside | pet | apples] — with the deck under the pet (bottom) or over
// it (top). Set-aside stays on the left and apples on the right for both.
function renderSide(seat: number, pos: 'top' | 'bottom') {
const s = sides[seat]
function renderSide(side: number, pos: 'top' | 'bottom') {
const s = sides[side]
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === side
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === side
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === side
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === side
// A food (apple) that just landed on this side's fan — reveal off the deck or
// a Battle Prep hand-out — animates in from the deck rather than popping.
const newFoodId =
!done &&
lastEvent?.seat === seat &&
lastEvent?.seat === side &&
(lastEvent.type === 'prep' ||
(lastEvent.type === 'reveal' &&
(lastEvent.card?.kind === 'food' || lastEvent.card?.kind === 'ailment')))
@@ -425,23 +452,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
: null
// A pet just set aside slides into the set-aside row beside the arena.
const newSetAsideId =
!done && lastEvent?.type === 'setaside' && lastEvent.seat === seat
!done && lastEvent?.type === 'setaside' && lastEvent.seat === side
? lastEvent.card?.id
: null
// Hold the N / miss! pop until the dice settle.
const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, seat, events, step)
const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, side, events, step)
const lineup = lineups?.[seat] ?? []
const lineup = lineups?.[side] ?? []
const stackEl = (
<div className={`battle-deck battle-deck-${pos}`}>
<div
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
onMouseEnter={
lineup.length
? (e) => setPeek({ seat, pos, rect: e.currentTarget.getBoundingClientRect() })
? (e) => setPeek({ side, pos, rect: e.currentTarget.getBoundingClientRect() })
: undefined
}
onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))}
onMouseLeave={() => setPeek((p) => (p?.side === side ? null : p))}
title={lineup.length ? 'Hover to see the whole deck' : undefined}
>
{s.stack > 0 ? (
@@ -464,7 +491,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
<CardView card={lastEvent.card} size="sm" dead />
</div>
)}
{rockDice && lastEvent?.seat === seat && (
{rockDice && lastEvent?.seat === side && (
// The dice roll sits beside the throwing side's deck.
<div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={pos} speed={speed} />
@@ -602,7 +629,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
return (
<div className="battle" style={{ '--battle-speed': speed } as React.CSSProperties}>
<div className="battle-header">
<h2>Battle! Round {battle.round}</h2>
<h2>
{spectating ? 'Watching' : 'Battle!'} Round {battle.round}
</h2>
<div className="battle-controls">
<button className="btn btn-ghost btn-sm" onClick={restart} title="Replay from the start">
@@ -667,14 +696,43 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div>
</div>
{/* With four or six players several battles resolve at once. Tabs switch
the arena between them so you can watch the whole field, not just your
own fight; at two players there's only one battle and no tab bar. */}
{battles.length > 1 && (
<div className="battle-tabs" role="tablist" aria-label="Battles this round">
{battles.map((b, i) => {
const mine = sideOf(b, view.youSeat) >= 0
const names = (b.seats ?? []).map(
(s) => view.players.find((p) => p.seat === s)?.name ?? '?',
)
return (
<button
key={i}
role="tab"
aria-selected={i === selected}
className={`battle-tab ${i === selected ? 'is-active' : ''} ${mine ? 'is-mine' : ''}`}
onClick={() => onSelect(i)}
title={mine ? 'Your battle' : `Watch ${names.join(' vs ')}`}
>
{mine ? 'Your fight' : names.join(' vs ')}
</button>
)
})}
</div>
)}
<div className="battle-names">
<span>{you?.name} (you)</span>
<span>
{bottom?.name}
{!spectating && ' (you)'}
</span>
<span className="battle-vs">VS</span>
<span>{opp?.name}</span>
<span>{top?.name}</span>
</div>
<div className="battlefield">
{renderSide(oppSeat, 'top')}
{renderSide(topSide, 'top')}
<div
key={centerClash ? `clash-${step}` : 'center'}
className={`battle-center ${centerClash ? 'battle-center-clash' : ''}`}
@@ -682,12 +740,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
>
<span className="battle-center-bolt"></span>
</div>
{renderSide(youSeat, 'bottom')}
{renderSide(bottomSide, 'bottom')}
</div>
{peek &&
(() => {
const lineup = lineups?.[peek.seat] ?? []
const lineup = lineups?.[peek.side] ?? []
if (!lineup.length) return null
// Your deck sits at the bottom of the board, so float the peek above
// it; the rival's sits at the top, so float it below.
@@ -699,13 +757,14 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
? { bottom: window.innerHeight - peek.rect.top + 8 }
: { top: peek.rect.bottom + 8 }),
}
const mine = peek.seat === youSeat
const owner = seatAt(peek.side)
const whose = owner?.seat === view.youSeat ? 'Your' : `${owner?.name ?? 'Their'}s`
// Each player's first pet is the one nearest the clash line; show the
// lineup first-to-last, left to right, for both.
return createPortal(
<div className={`deck-peek deck-peek-${peek.pos}`} style={style}>
<div className="deck-peek-label">
{mine ? 'Your' : 'Opponents'} deck · {lineup.length} card
{whose} deck · {lineup.length} card
{lineup.length !== 1 ? 's' : ''} (first on the left)
</div>
<div className="deck-peek-cards first-left">
@@ -731,13 +790,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div>
{!draw ? (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins {'🏆'.repeat(battle.trophies)}
{view.players.find((p) => p.seat === ownBattle.winnerSeat)?.name} wins{' '}
{'🏆'.repeat(ownBattle.trophies)}
</div>
) : (
<div className="battle-result-sub">No trophies awarded</div>
)}
{/* At a bigger table the rest of the field matters as much as your
own result, so the round's other tables are summarised here. */}
{battles.length > 1 && (
<div className="battle-result-others">
<div className="battle-result-others-title">Elsewhere this round</div>
{battles
.filter((b) => b !== ownBattle)
.map((b, i) => {
const winner = view.players.find((p) => p.seat === b.winnerSeat)
const names = (b.seats ?? []).map(
(s) => view.players.find((p) => p.seat === s)?.name ?? '?',
)
return (
<div key={i} className="battle-result-other">
<span>{names.join(' vs ')}</span>
<span className="muted">
{winner ? `${winner.name} ${'🏆'.repeat(b.trophies)}` : 'draw'}
</span>
</div>
)
})}
</div>
)}
{acked ? (
<p className="muted">Waiting for opponent</p>
<p className="muted">Waiting for the other players</p>
) : (
<div className="actions">
<button className="btn btn-ghost" onClick={() => setResultDismissed(true)}>
@@ -768,21 +851,21 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
// clashDamageTaken computes how much damage a seat's pet took in the clash
// at event index `idx` (its damage total there minus its total beforehand).
function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number {
function clashDamageTaken(events: BattleEvent[], idx: number, side: number): number {
const ev = events[idx]
if (ev?.type !== 'clash') return 0
const after = ev.damage?.[seat] ?? 0
const after = ev.damage?.[side] ?? 0
// Walk back to the pet's damage before this clash.
let before = 0
for (let k = idx - 1; k >= 0; k--) {
const e = events[k]
if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === seat) {
if (e.type === 'reveal' && e.seat === side && e.card?.kind === 'pet') break
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === side) {
before = e.damageAfter ?? 0
break
}
if (e.type === 'clash') {
before = e.damage?.[seat] ?? 0
before = e.damage?.[side] ?? 0
break
}
}
+7 -5
View File
@@ -5,22 +5,24 @@ import { CardView } from './CardView'
interface Props {
canGrant: boolean // shop phase — grants only land then
pack: string // active pack, so the catalog matches the game
packs: string[] // packs in play, so the catalog matches the game
send: (msg: ClientMessage) => void
}
// DebugPanel is a testing aid (server DEBUG mode only): a collapsible drawer
// listing every card in the active pack, tier by tier. Clicking one drops it
// listing every card in the packs in play, tier by tier. Clicking one drops it
// into your deck for free, off-turn.
export function DebugPanel({ canGrant, pack, send }: Props) {
export function DebugPanel({ canGrant, packs, send }: Props) {
const [open, setOpen] = useState(false)
const [catalog, setCatalog] = useState<Card[]>([])
// Joined into a stable key so a fresh array identity each render doesn't refetch.
const packKey = packs.join(',')
useEffect(() => {
fetchCatalog(pack)
fetchCatalog(packKey.split(','))
.then(setCatalog)
.catch(() => setCatalog([]))
}, [pack])
}, [packKey])
const tiers = [...new Set(catalog.map((c) => c.tier ?? 0))].sort((a, b) => a - b)
+51 -18
View File
@@ -1,31 +1,64 @@
import type { GameView } from '../types'
export function GameOver({ view, onLeave }: { view: GameView; onLeave: () => void }) {
const winner = view.winnerSeat >= 0 ? view.players[view.winnerSeat] : null
const youWon = view.winnerSeat === view.youSeat
// The title can be shared: players level on trophies whose round-by-round
// records are also identical split it (the rulebook's "share that victory!").
const winners = view.winnerSeats ?? (view.winnerSeat >= 0 ? [view.winnerSeat] : [])
const youWon = winners.includes(view.youSeat)
const shared = winners.length > 1
const winnerNames = winners
.map((s) => view.players.find((p) => p.seat === s)?.name ?? '?')
.join(' & ')
const title = !winners.length
? "It's a tie!"
: youWon
? shared
? 'You share the win!'
: 'You win!'
: shared
? `${winnerNames} share the win!`
: `${winnerNames} wins!`
// Standings run by trophies, and the countback that decided any tie is worth
// showing: which rounds each player actually took.
const standings = [...view.players].sort(
(a, b) => b.trophies - a.trophies || a.seat - b.seat,
)
return (
<div className="gameover">
<div className="gameover-emoji" aria-hidden>
{winner ? (youWon ? '🎉' : '💀') : '🤝'}
{!winners.length ? '🤝' : youWon ? '🎉' : shared ? '🤝' : '💀'}
</div>
<h1 className="gameover-title">
{winner ? (youWon ? 'You win!' : `${winner.name} wins!`) : "It's a tie!"}
</h1>
<h1 className="gameover-title">{title}</h1>
<div className="gameover-scores">
{[...view.players]
.sort((a, b) => b.trophies - a.trophies)
.map((p) => (
<div key={p.id} className={`score-line ${p.seat === view.youSeat ? 'is-you' : ''}`}>
<span className="score-name">
{p.name}
{p.seat === view.youSeat ? ' (you)' : ''}
{standings.map((p) => (
<div
key={p.id}
className={[
'score-line',
p.seat === view.youSeat ? 'is-you' : '',
winners.includes(p.seat) ? 'is-winner' : '',
]
.filter(Boolean)
.join(' ')}
>
<span className="score-name">
{winners.includes(p.seat) && <span aria-label="winner">👑 </span>}
{p.name}
{p.seat === view.youSeat ? ' (you)' : ''}
</span>
{(p.roundWins?.length ?? 0) > 0 && (
<span className="score-rounds muted" title="Rounds won">
won {p.roundWins!.map((r) => `R${r}`).join(' ')}
</span>
<span className="score-trophies">
{'🏆'.repeat(p.trophies) || '—'} <strong>{p.trophies}</strong>
</span>
</div>
))}
)}
<span className="score-trophies">
{'🏆'.repeat(p.trophies) || '—'} <strong>{p.trophies}</strong>
</span>
</div>
))}
</div>
<button className="btn btn-primary btn-big" onClick={onLeave}>
Back to the den
+77 -24
View File
@@ -6,9 +6,13 @@ const BOT_LEVELS: { value: string; label: string; blurb: string }[] = [
{ value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' },
]
// Lobby is the pre-game setup screen. The host picks a pack, fills the second
// seat with a bot or a friend, can remove players, and starts the game.
// Lobby is the pre-game setup screen. The host picks the packs, fills seats
// with friends or computer players, can remove anyone, and starts the game.
// Everyone else sees the same lineup read-only and waits for the host.
//
// Two rules gate the start button, and the host needs to see both at a glance:
// the table has to be an even size (players battle in pairs every round), and
// the rulebook asks for one card pack per pair.
export function Lobby({
view,
send,
@@ -17,10 +21,33 @@ export function Lobby({
send: (m: ClientMessage) => void
}) {
const isHost = view.youSeat === view.hostSeat
const openSeats = view.maxPlayers - view.players.length
const selectedPlayable =
view.packs.find((p) => p.id === view.pack)?.playable ?? false
const canStart = view.players.length >= view.minPlayers && selectedPlayable
const seated = view.players.length
const openSeats = view.maxPlayers - seated
const packs = view.packs ?? []
const evenTable = view.playerCounts.includes(seated)
const enoughPacks = packs.length >= view.packsNeeded
const allPlayable = packs.every(
(id) => view.packCatalog.find((p) => p.id === id)?.playable ?? false,
)
const canStart = evenTable && enoughPacks && allPlayable
// Toggling a pack sends the whole new selection — the server validates it as
// a set, so there's no partial state to get stuck in.
const togglePack = (id: string) => {
const next = packs.includes(id) ? packs.filter((p) => p !== id) : [...packs, id]
if (next.length === 0) return // there's always at least one pack in play
send({ type: 'setPacks', packs: next })
}
// The one thing standing between the host and a game, in their words.
const blocker = !evenTable
? seated < view.minPlayers
? 'Waiting for a second player…'
: `${seated} players cant pair off — add or remove a seat`
: !enoughPacks
? `${seated} players needs ${view.packsNeeded} packs shuffled together`
: null
return (
<div className="lobby">
@@ -38,41 +65,66 @@ export function Lobby({
</div>
<section className="lobby-section">
<h3 className="lobby-section-title">Card pack</h3>
<h3 className="lobby-section-title">
Card packs ({packs.length}/{view.packCatalog.length})
</h3>
<p className="muted lobby-note">
{view.packsNeeded > 1
? `Every packs tier 1 cards shuffle together, tier 2 together, and so on. ${seated} players needs at least ${view.packsNeeded}.`
: 'Pick one, or combine several for a deeper shop.'}
</p>
<div className="pack-grid">
{view.packs.map((pack) => {
const selected = pack.id === view.pack
const disabled = !pack.playable || !isHost
{view.packCatalog.map((pack) => {
const selected = packs.includes(pack.id)
const onlyOne = selected && packs.length === 1
return (
<button
key={pack.id}
className={`pack-card ${selected ? 'is-selected' : ''} ${
pack.playable ? '' : 'is-locked'
}`}
disabled={disabled}
title={pack.playable ? pack.name : `${pack.name} — coming soon`}
onClick={() => send({ type: 'setPack', pack: pack.id })}
disabled={!pack.playable || !isHost || onlyOne}
title={
!pack.playable
? `${pack.name} — coming soon`
: onlyOne
? 'At least one pack has to stay in play'
: selected
? `Remove ${pack.name}`
: `Add ${pack.name}`
}
onClick={() => togglePack(pack.id)}
>
<span className="pack-emoji" aria-hidden>
{pack.emoji}
</span>
<span className="pack-name">{pack.name}</span>
<span className="pack-tag">
{pack.playable ? (selected ? 'Selected' : 'Available') : 'Coming soon'}
{!pack.playable ? 'Coming soon' : selected ? '✓ In play' : 'Add'}
</span>
</button>
)
})}
</div>
{!isHost && (
<p className="muted lobby-note">Only the host can change the pack.</p>
<p className="muted lobby-note">Only the host can change the packs.</p>
)}
{isHost && !enoughPacks && (
<p className="lobby-warn">
Add {view.packsNeeded - packs.length} more pack
{view.packsNeeded - packs.length !== 1 ? 's' : ''} to seat {seated} players.
</p>
)}
</section>
<section className="lobby-section">
<h3 className="lobby-section-title">
Players ({view.players.length}/{view.maxPlayers})
Players ({seated}/{view.maxPlayers})
</h3>
<p className="muted lobby-note">
Play happens in pairs, so the table needs {view.playerCounts.join(', ')} players.
Fill any odd seat with a computer player.
</p>
<ul className="seat-list">
{view.players.map((p) => (
<SeatRow
@@ -85,12 +137,13 @@ export function Lobby({
/>
))}
{Array.from({ length: openSeats }).map((_, i) => (
<li key={`open-${i}`} className="seat-row seat-open">
{openSeats > 0 && (
<li className="seat-row seat-open">
{isHost ? (
<div className="seat-open-host">
<span className="muted">
Add a computer opponent, or share the code to invite a friend:
{openSeats} open seat{openSeats !== 1 ? 's' : ''} add a computer
player, or share the code to invite a friend:
</span>
<div className="seat-bot-picker">
{BOT_LEVELS.map((b) => (
@@ -106,10 +159,12 @@ export function Lobby({
</div>
</div>
) : (
<span className="muted">Waiting for the host to fill this seat</span>
<span className="muted">
{openSeats} open seat{openSeats !== 1 ? 's' : ''} waiting for the host
</span>
)}
</li>
))}
)}
</ul>
</section>
@@ -119,9 +174,7 @@ export function Lobby({
disabled={!canStart}
onClick={() => send({ type: 'start' })}
>
{view.players.length < view.minPlayers
? 'Waiting for a second player…'
: 'Start game'}
{blocker ?? 'Start game'}
</button>
) : (
<p className="lobby-waiting muted">Waiting for the host to start the game</p>
+10 -4
View File
@@ -122,7 +122,10 @@ export function ShopPhase({ view, you, send }: Props) {
const totalCoins = purse.current.max
const deck = you.deck ?? []
const opponent = view.players.find((p) => p.seat !== view.youSeat)
// Whoever the shop is currently waiting on, by name — at a bigger table
// that's a specific player taking their turn, not "the opponent".
const acting = view.players.find((p) => p.seat === view.turn)
const stillShopping = view.players.filter((p) => p.seat !== view.youSeat && !p.ready)
const pending = view.pending
const myPending = pending?.playerId === you.id
@@ -263,12 +266,15 @@ export function ShopPhase({ view, you, send }: Props) {
<div className="shop-status">
{pending && !myPending ? (
<span className="muted">
{opponent?.name ?? 'Opponent'} is tripling up a tier
{acting?.name ?? 'Someone'} is tripling up a tier
</span>
) : you.ready ? (
<span className="muted">
You passed waiting for {opponent?.name ?? 'opponent'} to finish
shopping
You passed waiting for{' '}
{stillShopping.length === 1
? stillShopping[0].name
: `${stillShopping.length} more players`}{' '}
to finish shopping
</span>
) : myTurn && overPets ? (
<span className="status-hot">
+88 -30
View File
@@ -17,23 +17,40 @@ import { EventLog, battleLogLines } from './EventLog'
export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) {
const { view, error, connected, send } = useGame(session)
// Battle replay step lives here (not inside BattlePhase) so the event log,
// a sibling, can render the battle narration up to the same step. Deriving
// the effective step from the current battle round resets it to 0 whenever a
// new battle arrives, without a separate effect. These hooks must run on
// every render (before any early return) to satisfy the rules of hooks.
const battleRound = view?.battle?.round ?? -1
const [stepState, setStepState] = useState<{ round: number; step: number }>({
round: -1,
// A round runs one battle per pairing, so with four or six players there are
// several to watch. Which one is on screen lives here, alongside the replay
// step, because the event log is a sibling and narrates whichever battle is
// playing. Both reset when a new round's battles arrive.
//
// These hooks must run on every render (before any early return) to satisfy
// the rules of hooks.
const battles = view?.battles ?? []
const battleRound = battles[0]?.round ?? -1
// Your own fight is what a round opens on; spectators of a game they aren't
// seated in (or a malformed round) fall back to the first table.
const ownIdx = Math.max(
0,
battles.findIndex((b) => (b.seats ?? []).includes(view?.youSeat ?? -1)),
)
const [sel, setSel] = useState<{ round: number; idx: number }>({ round: -1, idx: 0 })
const selected = sel.round === battleRound ? Math.min(sel.idx, battles.length - 1) : ownIdx
const battle = battles[selected]
// Deriving the effective step from the round and the selected battle resets
// it to 0 whenever either changes, without a separate effect.
const [stepState, setStepState] = useState<{ key: string; step: number }>({
key: '',
step: 0,
})
const step = stepState.round === battleRound ? stepState.step : 0
const stepKey = `${battleRound}:${selected}`
const step = stepState.key === stepKey ? stepState.step : 0
const setStep: Dispatch<SetStateAction<number>> = (upd) =>
setStepState((prev) => {
const cur = prev.round === battleRound ? prev.step : 0
const cur = prev.key === stepKey ? prev.step : 0
const next = typeof upd === 'function' ? (upd as (n: number) => number)(cur) : upd
return { round: battleRound, step: next }
return { key: stepKey, step: next }
})
const selectBattle = (idx: number) => setSel({ round: battleRound, idx })
// A shop-phase peek at an opponent's deck from the previous round's battle
// (its arranged lineup is already public). Shown as a centered modal.
@@ -53,7 +70,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
// battle and appended as the final battle line only once the replay reaches
// the end (and reappears in the persistent log in later phases).
const inBattle = view?.phase === 'battle'
const events = view?.battle?.events ?? null
const events = battle?.events ?? null
const battleDone = !!(inBattle && events && step >= events.length)
const entries = useMemo(() => {
@@ -66,9 +83,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
if (!inBattle || !events) return undefined
const lines = battleLogLines(events, step)
if (battleDone) {
const result = (view?.log ?? []).find(
// The engine logs one result per battle, in the same order it resolves
// them into view.battles — so the selected battle's line is at the same
// index among the round's results.
const result = (view?.log ?? []).filter(
(e) => e.kind === 'result' && e.round === battleRound,
)
)[selected]
if (result) {
lines.push({
key: `result-${result.seq}`,
@@ -79,14 +99,14 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
}
}
return lines
}, [view, inBattle, events, step, battleDone, battleRound])
}, [view, inBattle, events, step, battleDone, battleRound, selected])
// Map every card name we know about to a representative card, so the event
// log can preview pets/foods it mentions on hover. The catalog covers all
// buyable pets and foods for the pack (even ones not currently in view);
// concrete cards from the view fill in tokens and summons (Bee, Apple, …)
// that never appear in the shop.
const catalog = useCatalog(view?.pack)
// buyable pets and foods across the packs in play (even ones not currently in
// view); concrete cards from the view fill in tokens and summons (Bee,
// Apple, …) that never appear in the shop.
const catalog = useCatalog(view?.packs)
const cardLookup = useMemo(() => {
const map = new Map<string, Card>()
for (const c of catalog) if (c.name) map.set(c.name, c)
@@ -96,8 +116,10 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
if (view) {
view.shopRow?.forEach(add)
view.players?.forEach((p) => p.deck?.forEach(add))
view.battle?.lineups?.forEach((line) => line?.forEach(add))
view.battle?.events?.forEach((ev) => add(ev.card))
view.battles?.forEach((b) => {
b.lineups?.forEach((line) => line?.forEach(add))
b.events?.forEach((ev) => add(ev.card))
})
}
return map
}, [catalog, view])
@@ -129,6 +151,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
const you = view.players[view.youSeat]
const opponents = view.players.filter((p) => p.seat !== view.youSeat)
// Last round's lineups are public, so any player's deck can be peeked at
// during the shop — whichever table they fought at. Indexed by seat here;
// inside a battle result the lineups are indexed by side.
const lastLineups = new Map<number, Card[]>()
for (const b of battles) {
;(b.seats ?? []).forEach((seat, side) => {
const line = b.lineups?.[side]
if (line?.length) lastLineups.set(seat, line)
})
}
return (
<div className="table">
<header className="topbar">
@@ -144,11 +177,21 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<DieFace value={view.round} className="topbar-die" />
</div>
)}
<div className="topbar-players">
{/* Up to six seats ride here, so each one stays terse: the extra chips
only appear when they have something to say, and this round's
opponent is flagged so you know who you're preparing for. */}
<div className={`topbar-players count-${view.players.length}`}>
{view.players.map((p) => (
<div
key={p.id}
className={`topbar-player ${p.seat === view.youSeat ? 'is-you' : ''}`}
className={[
'topbar-player',
p.seat === view.youSeat ? 'is-you' : '',
p.seat === view.yourOpponent ? 'is-rival' : '',
view.phase === 'shop' && p.seat === view.turn ? 'is-turn' : '',
]
.filter(Boolean)
.join(' ')}
>
{p.isBot ? (
<span className="bot-dot" title="Computer player">
@@ -158,16 +201,22 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<span className={`conn-dot ${p.connected ? 'on' : 'off'}`} />
)}
<span className="topbar-name">{p.name}</span>
{p.seat === view.yourOpponent && view.phase !== 'lobby' && (
<span className="chip chip-rival" title="You fight them this round">
</span>
)}
<span className="chip">🏆 {p.trophies}</span>
{/* Your own gold shows as big discs above the buy row (ShopPhase);
the opponent's stays as a compact chip here. */}
everyone else's stays as a compact chip here. */}
{view.phase === 'shop' && p.seat !== view.youSeat && (
<span className="chip">🪙 {p.coins}</span>
)}
{/* Peek at the opponent's deck from last round's battle. */}
{/* Peek at anyone's deck from last round's battle — every table's
lineups are public once fought. */}
{view.phase === 'shop' &&
p.seat !== view.youSeat &&
(view.battle?.lineups?.[p.seat]?.length ?? 0) > 0 && (
(lastLineups.get(p.seat)?.length ?? 0) > 0 && (
<button
className={`chip chip-btn ${deckPeek?.seat === p.seat ? 'is-active' : ''}`}
title="See their deck from last round's battle"
@@ -231,8 +280,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.phase === 'lobby' && <Lobby view={view} send={send} />}
{view.phase === 'shop' && <ShopPhase view={view} you={you} send={send} />}
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
{view.phase === 'battle' && (
<BattlePhase view={view} send={send} step={step} setStep={setStep} />
{view.phase === 'battle' && battle && (
<BattlePhase
view={view}
send={send}
step={step}
setStep={setStep}
battle={battle}
battles={battles}
selected={selected}
onSelect={selectBattle}
/>
)}
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
</main>
@@ -265,13 +323,13 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)}
{error && <div className="toast">{error}</div>}
{view.debug && (
<DebugPanel canGrant={view.phase === 'shop'} pack={view.pack} send={send} />
<DebugPanel canGrant={view.phase === 'shop'} packs={view.packs} send={send} />
)}
{deckPeek &&
view.phase === 'shop' &&
(() => {
const lineup = view.battle?.lineups?.[deckPeek.seat] ?? []
const lineup = lastLineups.get(deckPeek.seat) ?? []
if (!lineup.length) return null
const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent'
return createPortal(
+143
View File
@@ -488,6 +488,43 @@ h3 {
0 1px 3px rgba(0, 0, 0, 0.35);
}
/* This round's opponent gets a cooler outline than your own gold one — enough
to pick them out of five other seats without competing with it. */
.topbar-player.is-rival {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 0 0 2px rgba(255, 120, 120, 0.75),
0 1px 3px rgba(0, 0, 0, 0.35);
}
/* Whoever the shop is waiting on, so a six-seat rail still shows the turn. */
.topbar-player.is-turn {
background: linear-gradient(180deg, rgba(255, 214, 102, 0.22), rgba(0, 0, 0, 0.28));
}
/* Four or six pins won't fit at full size, so the rail tightens as it fills.
Names ellipsis rather than wrap, keeping the topbar one row deep. */
.topbar-players.count-4 .topbar-player,
.topbar-players.count-6 .topbar-player {
padding: 4px 9px;
font-size: 0.82rem;
gap: 5px;
min-width: 0;
}
/* Names still have to fit, but not so tight that "Robo Rookie" and "Robo
Rival" both collapse to "Robo R…" and the seats stop being tellable apart. */
.topbar-players.count-6 .topbar-name {
max-width: 12ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-rival {
filter: drop-shadow(0 0 3px rgba(255, 120, 120, 0.8));
}
.topbar-name {
font-weight: 800;
}
@@ -921,6 +958,14 @@ h3 {
font-size: 1.05rem;
}
/* What's still standing between the host and a startable game. */
.lobby-warn {
text-align: center;
font-size: 0.9rem;
font-weight: 700;
color: #ffcf8f;
}
/* ---------- pack picker ---------- */
.pack-grid {
@@ -2054,6 +2099,56 @@ h3 {
text-align: center;
}
/* With four or six players a round holds several battles at once; these tabs
switch the arena between them. They read as folder tabs sitting on top of
the battlefield, with your own fight marked. */
.battle-tabs {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.battle-tab {
border: 0;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
color: inherit;
padding: 5px 14px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
opacity: 0.72;
transition:
opacity 0.15s ease,
background 0.15s ease;
}
.battle-tab:hover {
opacity: 1;
}
.battle-tab.is-active {
opacity: 1;
background: rgba(255, 255, 255, 0.14);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 1px 3px rgba(0, 0, 0, 0.35);
}
.battle-tab.is-mine {
color: var(--gold);
}
.battle-tab.is-mine.is-active {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 0 0 2px var(--gold);
}
.battle-names {
display: flex;
justify-content: center;
@@ -2844,6 +2939,33 @@ h3 {
font-weight: 700;
}
/* How the rest of the field did this round, under your own result. */
.battle-result-others {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 12px;
margin-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
font-size: 0.88rem;
}
.battle-result-others-title {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.6;
}
.battle-result-other {
display: flex;
justify-content: space-between;
gap: 16px;
font-weight: 600;
}
/* The toolbar's "advance the round" button stands out from the ghost controls. */
.battle-next {
margin-left: 2px;
@@ -2889,6 +3011,7 @@ h3 {
.score-line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
font-size: 1.1rem;
@@ -2901,6 +3024,26 @@ h3 {
font-weight: 900;
}
.score-line.is-winner {
background: rgba(255, 214, 102, 0.1);
border-radius: 8px;
padding: 4px 8px;
margin: 0 -8px;
}
/* The rounds a player took — the countback that settles a tie, spelled out. */
.score-rounds {
font-size: 0.78rem;
font-weight: 600;
margin-left: auto;
margin-right: 8px;
white-space: nowrap;
}
.score-name {
white-space: nowrap;
}
/* ---------- toasts & banners ---------- */
.toast {
+24 -6
View File
@@ -25,6 +25,7 @@ export interface PlayerView {
seat: number
coins: number
trophies: number
roundWins?: number[] // rounds this player won a battle in (public)
ready: boolean
connected: boolean
isBot?: boolean
@@ -100,15 +101,25 @@ export interface BattleEvent {
text?: string
}
// A round runs one battle per pairing, so a six-player round has three of
// these. Everything inside is indexed by *side* — 0 or 1 within this battle —
// rather than by seat at the table, including BattleEvent.seat/target. `seats`
// maps the two apart. winnerSeat is the exception: it's a real seat.
export interface BattleResult {
round: number
seats: number[] // the two seats fighting, first player first
stackSizes: number[]
lineups?: Card[][] // each seat's arranged deck (top first); public for peeking
lineups?: Card[][] // each side's arranged deck (top first); public for peeking
events: BattleEvent[] | null
winnerSeat: number
trophies: number
}
// sideOf maps a seat to its side index in a battle, or -1 if it wasn't in it.
export function sideOf(battle: BattleResult, seat: number): number {
return battle.seats?.indexOf(seat) ?? -1
}
export interface LogEntry {
seq: number
round: number
@@ -135,27 +146,34 @@ export interface GameView {
round: number
maxRounds: number
maxPets: number
pack: string
packs: PackInfo[]
packs: string[] // selected packs, shuffled together
packCatalog: PackInfo[] // the choices offered in the lobby
packsNeeded: number // packs the current table size requires
hostSeat: number
minPlayers: number
maxPlayers: number
playerCounts: number[] // table sizes a game can start at (2, 4, 6)
youSeat: number
turn: number
prioritySeat: number
shopRow: Card[]
deckCounts: number[]
players: PlayerView[]
matchups?: [number, number][] // this round's battle pairings (public)
yourOpponent: number // the seat you face this round, or -1
pending?: PendingTrade
pendingReveal?: PendingReveal
pendingSacrifice?: PendingSacrifice
battle?: BattleResult
winnerSeat: number
battle?: BattleResult // your own battle this round
battles?: BattleResult[] // every table's, all replayable
winnerSeat: number // outright winner, or -1 when shared
winnerSeats?: number[] // everyone holding the title
log?: LogEntry[]
debug?: boolean // server DEBUG mode: unlocks the buy-any-card panel
}
export type ClientMessage =
| { type: 'setPack'; pack: string }
| { type: 'setPacks'; packs: string[] }
| { type: 'addBot'; difficulty: string }
| { type: 'removePlayer'; target: string }
| { type: 'start' }
+9 -7
View File
@@ -2,15 +2,17 @@ import { useEffect, useState } from 'react'
import { fetchCatalog } from './api'
import type { Card } from './types'
// useCatalog loads the representative card for every pet and food in a pack and
// caches the result per pack. It's used to look up cards by name (e.g. to
// preview pets mentioned in the event log), even ones not currently in view.
export function useCatalog(pack?: string): Card[] {
// useCatalog loads the representative card for every pet and food across the
// packs in play. It's used to look up cards by name (e.g. to preview pets
// mentioned in the event log), even ones not currently in view. The packs are
// joined into a stable key so a new array identity each render doesn't refetch.
export function useCatalog(packs?: string[]): Card[] {
const [cards, setCards] = useState<Card[]>([])
const key = (packs ?? []).join(',')
useEffect(() => {
if (!pack) return
if (!key) return
let cancelled = false
fetchCatalog(pack)
fetchCatalog(key.split(','))
.then((c) => {
if (!cancelled) setCards(c)
})
@@ -20,6 +22,6 @@ export function useCatalog(pack?: string): Card[] {
return () => {
cancelled = true
}
}, [pack])
}, [key])
return cards
}