Initial commit.

This commit is contained in:
Greyson Parrelli
2026-07-22 23:07:29 -04:00
commit 612a4e6227
38 changed files with 6106 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react'
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView'
import { SUIT_EMOJI } from '../petArt'
interface Props {
view: GameView
you: PlayerView
send: (msg: ClientMessage) => void
}
export function ShopPhase({ view, you, send }: Props) {
const [selected, setSelected] = useState<string[]>([])
const cleanup = view.phase === 'cleanup'
const myTurn = !cleanup && view.turn === view.youSeat && you.coins > 0
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])
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)
const excessPets = you.petCount - view.maxPets
const cleanupReady =
cleanup &&
excessPets > 0 &&
selectedCards.length === excessPets &&
selectedCards.every((c) => c.kind === 'pet')
function act(msg: ClientMessage) {
send(msg)
setSelected([])
}
return (
<div className="shop">
{/* Status line */}
<div className="shop-status">
{cleanup ? (
excessPets > 0 ? (
<span className="status-hot">
Too many pets! Discard <strong>{excessPets}</strong> they become
apples 🍎
</span>
) : (
<span className="muted">
Waiting for {opponent?.name ?? 'opponent'} to discard down to{' '}
{view.maxPets} pets
</span>
)
) : pending && !myPending ? (
<span className="muted">
{opponent?.name ?? 'Opponent'} is trading up a tier
</span>
) : myTurn ? (
<span className="status-hot">Your turn spend a coin 🪙</span>
) : (
<span className="muted">
{view.players[view.turn]?.name ?? 'Opponent'}s turn
</span>
)}
</div>
{/* Shop row */}
{!cleanup && (
<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={!myTurn}
onClick={myTurn ? () => act({ type: 'buy', row: i }) : undefined}
/>
) : (
<div key={`empty-${i}`} className="card-slot-empty" />
),
)}
</div>
{myTurn && <div className="hint">Tap a card to buy it for 1 🪙</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">
{cleanup ? (
excessPets > 0 && (
<button
className="btn btn-primary"
disabled={!cleanupReady}
onClick={() => act({ type: 'discard', cards: selected })}
>
Discard {excessPets} pet{excessPets > 1 ? 's' : ''} 🍎
</button>
)
) : (
<>
<button
className="btn btn-secondary"
disabled={!myTurn || selected.length === 0}
onClick={() => act({ type: 'discard', cards: selected })}
title="Convert selected cards into apples (+1 power each)"
>
Discard {selected.length > 0 ? selected.length : ''} 🍎 (1 🪙)
</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"
>
Trade 3 {sameSuit && selectedCards[0].suit ? SUIT_EMOJI[selectedCards[0].suit] : 'matching'} Tier{' '}
{Math.min(view.round + 1, view.maxRounds)} (1 🪙)
</button>
<button
className="btn btn-ghost"
disabled={!myTurn}
onClick={() => act({ type: 'pass' })}
title="Give up your remaining coins"
>
Pass
</button>
</>
)}
</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>
)}
</div>
)
}