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' import { ArrangePhase } from './ArrangePhase' import { BattlePhase } from './BattlePhase' import { GameOver } from './GameOver' import { DebugPanel } from './DebugPanel' import { EventLog, battleLogLines } from './EventLog' // Table connects to the game and routes to the right phase screen. 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, step: 0, }) const step = stepState.round === battleRound ? stepState.step : 0 const setStep: Dispatch> = (upd) => setStepState((prev) => { const cur = prev.round === battleRound ? prev.step : 0 const next = typeof upd === 'function' ? (upd as (n: number) => number)(cur) : upd 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 // the end (and reappears in the persistent log in later phases). const inBattle = view?.phase === 'battle' const events = view?.battle?.events ?? null const battleDone = !!(inBattle && events && step >= events.length) const entries = useMemo(() => { const log = view?.log ?? [] if (!inBattle) return log return log.filter((e) => !(e.kind === 'result' && e.round === battleRound)) }, [view, inBattle, battleRound]) const battleLines = useMemo(() => { if (!inBattle || !events) return undefined const lines = battleLogLines(events, step) if (battleDone) { const result = (view?.log ?? []).find( (e) => e.kind === 'result' && e.round === battleRound, ) if (result) { lines.push({ key: `result-${result.seq}`, icon: result.icon, text: result.text, seat: result.seat, }) } } return lines }, [view, inBattle, events, step, battleDone, battleRound]) // 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) const cardLookup = useMemo(() => { const map = new Map() for (const c of catalog) if (c.name) map.set(c.name, c) const add = (c?: Card) => { if (c?.name && !map.has(c.name)) map.set(c.name, c) } 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)) } return map }, [catalog, view]) if (!view) { return (
{connected ? 'Loading game…' : 'Connecting…'}
) } // A player the host removed from the lobby no longer has a seat. if (view.youSeat < 0) { return (
👋

You were removed from the game

The host removed you from this lobby.

) } const you = view.players[view.youSeat] const opponents = view.players.filter((p) => p.seat !== view.youSeat) return (
🐾 SAP
{view.phase !== 'gameover' && view.phase !== 'lobby' && (
Round {view.round} / {view.maxRounds}
)}
{view.players.map((p) => (
{p.isBot ? ( 🤖 ) : ( )} {p.name} 🏆 {p.trophies} {/* Your own gold shows as big discs above the buy row (ShopPhase); the opponent's stays as a compact chip here. */} {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} )}
))}
{view.code}
{view.phase === 'lobby' && } {view.phase === 'shop' && } {view.phase === 'arrange' && } {view.phase === 'battle' && ( )} {view.phase === 'gameover' && }
{view.phase !== 'lobby' && ( )}
{opponents.some((p) => !p.connected && !p.isBot) && view.phase !== 'lobby' && (
An opponent is disconnected…
)} {error &&
{error}
} {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, ) })()}
) }