Files
super-auto-pets-board-game/web/src/components/ShopPhase.tsx
T
Greyson Parrelli 72e198a750 Fix shop action costs and pass semantics.
Only buying costs gold; selling and trading (Triple) are free. Pass is
now a final action, legal only at or under the pet limit: it forfeits
remaining gold and ends that player's shopping for the round, with the
shop closing once everyone has passed. That makes the separate cleanup
phase unreachable (you sell down in-shop before passing), so it is
removed. The client asks for confirmation before passing, and the bot
knows buys are the only coin sink, when passing is legal, and that it
must sell down before it can pass.
2026-07-23 16:11:07 -04:00

292 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView'
interface Flyer {
key: string
emoji: string
x: number
y: number
}
interface Props {
view: GameView
you: PlayerView
send: (msg: ClientMessage) => void
}
export function ShopPhase({ view, you, send }: Props) {
const [selected, setSelected] = useState<string[]>([])
const [confirmPass, setConfirmPass] = useState(false)
const myTurn = view.turn === view.youSeat && !you.ready
const canBuy = myTurn && you.coins > 0
const overPets = you.petCount > view.maxPets
const deck = you.deck ?? []
const opponent = view.players.find((p) => p.seat !== view.youSeat)
const pending = view.pending
const myPending = pending?.playerId === you.id
// Drop selections that no longer exist (bought/traded/discarded cards).
useEffect(() => {
setSelected((sel) => sel.filter((id) => deck.some((c) => c.id === id)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [you.deck])
// --- spawn animation: apples/bees fly out from the creature that made them.
// The log tells us which card (source) spawned what; we fly an emoji from
// that card's on-screen position. For sells the source is already gone, so
// we grab its rect at click time (see act()).
const seenSeqRef = useRef<number | null>(null)
const capturedRef = useRef<Map<string, DOMRect>>(new Map())
const [flyers, setFlyers] = useState<Flyer[]>([])
useEffect(() => {
const log = view.log ?? []
const last = log.length ? log[log.length - 1].seq : 0
// First render just marks where we came in, so we never replay history.
if (seenSeqRef.current === null) {
seenSeqRef.current = last
return
}
if (last <= seenSeqRef.current) return
const fresh = log.filter((e) => e.seq > seenSeqRef.current! && e.spawn && e.source)
seenSeqRef.current = last
const added: Flyer[] = []
for (const e of fresh) {
const src = e.source!
let rect: DOMRect | undefined
const el = document.querySelector(`[data-card-id="${src}"]`)
if (el) rect = el.getBoundingClientRect()
else if (capturedRef.current.has(src)) rect = capturedRef.current.get(src)
capturedRef.current.delete(src)
if (!rect) continue
added.push({
key: `spawn-${e.seq}`,
emoji: e.spawn === 'bee' ? '🐝' : '🍎',
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
})
}
if (added.length) setFlyers((f) => [...f, ...added])
}, [view.log])
function toggle(id: string) {
setSelected((sel) =>
sel.includes(id) ? sel.filter((s) => s !== id) : [...sel, id],
)
}
const selectedCards = selected
.map((id) => deck.find((c) => c.id === id))
.filter((c): c is Card => !!c)
const sameSuit =
selectedCards.length === 3 &&
selectedCards.every((c) => c.suit && c.suit === selectedCards[0].suit)
function act(msg: ClientMessage) {
// Sold cards vanish before the apple entry arrives, so remember where they
// were now, keyed by id, for the spawn animation to fly from.
if (msg.type === 'sell') {
for (const id of msg.cards) {
const el = document.querySelector(`[data-card-id="${id}"]`)
if (el) capturedRef.current.set(id, el.getBoundingClientRect())
}
}
send(msg)
setSelected([])
}
return (
<div className="shop">
{/* Status line */}
<div className="shop-status">
{pending && !myPending ? (
<span className="muted">
{opponent?.name ?? 'Opponent'} is trading up a tier
</span>
) : you.ready ? (
<span className="muted">
You passed waiting for {opponent?.name ?? 'opponent'} to finish
shopping
</span>
) : myTurn && overPets ? (
<span className="status-hot">
Too many pets! Sell down to {view.maxPets} before you can pass 🍎
</span>
) : myTurn ? (
<span className="status-hot">Your turn buy, sell, trade, or pass</span>
) : (
<span className="muted">
{view.players[view.turn]?.name ?? 'Opponent'}s turn
</span>
)}
</div>
{/* Shop row */}
<section className="shop-row-wrap">
<div className="section-label">
Shop · Tier {view.round}
<span className="muted"> · {view.deckCounts[view.round - 1]} left in deck</span>
</div>
<div className="shop-row">
{view.shopRow.map((c, i) =>
c.id ? (
<CardView
key={c.id}
card={c}
size="lg"
disabled={!canBuy}
onClick={canBuy ? () => act({ type: 'buy', row: i }) : undefined}
/>
) : (
<div key={`empty-${i}`} className="card-slot-empty" />
),
)}
</div>
{myTurn && (
<div className="hint">
{canBuy
? 'Tap a card to buy it for 1 🪙 — selling and trading are free'
: 'No coins left — you can still sell, trade, or pass'}
</div>
)}
</section>
{/* Your deck */}
<section className="deck-wrap">
<div className="section-label">
Your deck
<span className="muted">
{' '}
· {you.petCount}/{view.maxPets} pets
</span>
</div>
{deck.length === 0 ? (
<div className="muted deck-empty">No cards yet buy something!</div>
) : (
<div className="deck-row">
{deck.map((c) => (
<CardView
key={c.id}
card={c}
selected={selected.includes(c.id)}
onClick={() => toggle(c.id)}
/>
))}
</div>
)}
</section>
{/* Actions */}
<div className="actions">
<button
className="btn btn-secondary"
disabled={!myTurn || selected.length === 0}
onClick={() => act({ type: 'sell', cards: selected })}
title="Convert selected cards into apples (+1 power each, this battle only) — free"
>
Sell {selected.length > 0 ? selected.length : ''} 🍎
</button>
<button
className="btn btn-secondary"
disabled={!myTurn || !sameSuit || view.round >= view.maxRounds}
onClick={() => act({ type: 'trade', cards: selected })}
title="Trade 3 same-suit pets for a pick from the next tier — free"
>
Trade 3{' '}
{sameSuit && selectedCards[0].suit ? (
<span className={`suit-dot suit-${selectedCards[0].suit}`} />
) : (
'matching'
)}{' '}
Tier {Math.min(view.round + 1, view.maxRounds)}
</button>
<button
className="btn btn-ghost"
disabled={!myTurn || overPets}
onClick={() => setConfirmPass(true)}
title={
overPets
? `Sell down to ${view.maxPets} pets before passing`
: 'End your shopping for this round'
}
>
Pass
</button>
</div>
{/* Pass confirmation */}
{confirmPass && (
<div className="modal-backdrop" onClick={() => setConfirmPass(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Done shopping?</h3>
<p className="muted">
Passing ends your shopping for the rest of this round
{you.coins > 0 && (
<>
{' '}
and gives up your remaining {you.coins} 🪙
</>
)}
.
</p>
<div className="actions">
<button className="btn btn-ghost" onClick={() => setConfirmPass(false)}>
Keep shopping
</button>
<button
className="btn btn-primary"
onClick={() => {
setConfirmPass(false)
act({ type: 'pass' })
}}
>
Pass
</button>
</div>
</div>
</div>
)}
{/* Trade picker */}
{myPending && pending && (
<div className="modal-backdrop">
<div className="modal">
<h3>Pick one the other goes under the tier {pending.tier} deck</h3>
<div className="modal-cards">
{pending.options.map((c, i) => (
<CardView
key={c.id}
card={c}
size="lg"
onClick={() => send({ type: 'tradeChoose', pick: i })}
/>
))}
</div>
</div>
</div>
)}
{flyers.length > 0 &&
createPortal(
<>
{flyers.map((fl) => (
<div
key={fl.key}
className="spawn-flyer"
style={{ left: fl.x, top: fl.y }}
onAnimationEnd={() =>
setFlyers((f) => f.filter((x) => x.key !== fl.key))
}
>
{fl.emoji}
</div>
))}
</>,
document.body,
)}
</div>
)
}