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.
This commit is contained in:
Greyson Parrelli
2026-07-23 16:11:07 -04:00
parent db1a6ef290
commit 72e198a750
15 changed files with 273 additions and 297 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { fetchCatalog } from '../api'
import { CardView } from './CardView'
interface Props {
canGrant: boolean // shop/cleanup phase — grants only land then
canGrant: boolean // shop phase — grants only land then
send: (msg: ClientMessage) => void
}
+109 -89
View File
@@ -18,8 +18,10 @@ interface Props {
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 [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
@@ -81,12 +83,6 @@ export function ShopPhase({ view, you, send }: Props) {
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) {
// Sold cards vanish before the apple entry arrives, so remember where they
@@ -105,24 +101,21 @@ export function ShopPhase({ view, you, send }: Props) {
<div className="shop">
{/* Status line */}
<div className="shop-status">
{cleanup ? (
excessPets > 0 ? (
<span className="status-hot">
Too many pets! Sell <strong>{excessPets}</strong> they become
apples 🍎
</span>
) : (
<span className="muted">
Waiting for {opponent?.name ?? 'opponent'} to sell down to{' '}
{view.maxPets} pets
</span>
)
) : pending && !myPending ? (
{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 spend a coin 🪙</span>
<span className="status-hot">Your turn buy, sell, trade, or pass</span>
) : (
<span className="muted">
{view.players[view.turn]?.name ?? 'Opponent'}s turn
@@ -131,30 +124,34 @@ export function ShopPhase({ view, you, send }: Props) {
</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>
<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>
<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>
)}
)}
</section>
{/* Your deck */}
<section className="deck-wrap">
@@ -183,52 +180,75 @@ export function ShopPhase({ view, you, send }: Props) {
{/* Actions */}
<div className="actions">
{cleanup ? (
excessPets > 0 && (
<button
className="btn btn-primary"
disabled={!cleanupReady}
onClick={() => act({ type: 'sell', cards: selected })}
>
Sell {excessPets} pet{excessPets > 1 ? 's' : ''} 🍎
</button>
)
) : (
<>
<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)"
>
Sell {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 ? (
<span className={`suit-dot suit-${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>
</>
)}
<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">
+3 -8
View File
@@ -102,7 +102,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)}
<span className="topbar-name">{p.name}</span>
<span className="chip">🏆 {p.trophies}</span>
{(view.phase === 'shop' || view.phase === 'cleanup') && (
{view.phase === 'shop' && (
<span className="chip">🪙 {p.coins}</span>
)}
</div>
@@ -119,9 +119,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<div className="table-body">
<main className="table-main">
{view.phase === 'lobby' && <Lobby view={view} />}
{(view.phase === 'shop' || view.phase === 'cleanup') && (
<ShopPhase view={view} you={you} 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} />
@@ -138,10 +136,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)}
{error && <div className="toast">{error}</div>}
{view.debug && (
<DebugPanel
canGrant={view.phase === 'shop' || view.phase === 'cleanup'}
send={send}
/>
<DebugPanel canGrant={view.phase === 'shop'} send={send} />
)}
</div>
)
+1 -1
View File
@@ -2,7 +2,7 @@
export type Suit = 'red' | 'blue' | 'yellow'
export type CardKind = 'pet' | 'food'
export type Phase = 'lobby' | 'shop' | 'cleanup' | 'arrange' | 'battle' | 'gameover'
export type Phase = 'lobby' | 'shop' | 'arrange' | 'battle' | 'gameover'
export interface Card {
id: string