@@ -619,8 +616,33 @@ type CardTileProps = {
onClick?: () => void
}
+type ActionButtonProps = {
+ action: ActionView
+ context: string
+ selected?: boolean
+ disabled?: boolean
+ onClick: () => void
+}
+
const oneOffRanks = new Set(['ACE', 'THREE', 'FOUR', 'FIVE', 'SIX'])
const faceRanks = new Set(['JACK', 'QUEEN', 'KING', 'EIGHT'])
+const oneOffDescriptions: Record = {
+ ACE: 'Destroy all point cards on the field.',
+ TWO: 'Scrap a target royal or Glasses Eight.',
+ THREE: 'Take one card from the scrap pile.',
+ FOUR: 'Opponent discards two cards of their choice.',
+ FIVE: 'Discard one card, then draw up to three (max 8 in hand).',
+ SIX: 'Destroy all face cards (royals) on the field.',
+ SEVEN: 'Reveal the top two cards of the deck; choose one to play.',
+ NINE: "Return an opponent's field card to their hand (cannot play it next turn).",
+ TEN: 'No effect.',
+}
+const faceCardDescriptions: Record = {
+ KING: 'Reduce points needed to win while in play.',
+ QUEEN: 'Protect your other cards from targeted effects.',
+ JACK: 'Steal a target point card while in play.',
+ EIGHT: "Reveal your opponent's hand while in play.",
+}
function cardTag(card: CardView) {
if (card.purpose === 'POINTS') return 'points'
@@ -650,6 +672,61 @@ function rankShort(rank: string) {
return map[rank] ?? rank
}
+function actionDescription(action: ActionView): string | null {
+ if (action.type === 'Draw') {
+ return 'Draw one card (max 8 in hand).'
+ }
+ if (action.type === 'Points') {
+ return 'Play for points equal to its rank.'
+ }
+ if (action.type === 'Scuttle') {
+ return 'Scrap an opponent point card with a higher card (suit breaks ties).'
+ }
+ if (action.type === 'Face Card') {
+ if (action.card?.rank && faceCardDescriptions[action.card.rank]) {
+ return faceCardDescriptions[action.card.rank]
+ }
+ return 'Play a royal for an ongoing effect.'
+ }
+ if (action.type === 'Jack') {
+ return 'Steal a target point card while in play.'
+ }
+ if (action.type === 'One-Off') {
+ if (action.card?.rank && oneOffDescriptions[action.card.rank]) {
+ return oneOffDescriptions[action.card.rank]
+ }
+ return "Trigger this card's one-off effect."
+ }
+ if (action.type === 'Counter') {
+ return 'Counter a one-off with a Two.'
+ }
+ if (action.type === 'Resolve') {
+ return 'Let the pending one-off resolve.'
+ }
+ if (action.type === 'Take From Discard') {
+ return 'Take one card from the scrap pile.'
+ }
+ if (action.type === 'Discard From Hand') {
+ return 'Discard a card from your hand.'
+ }
+ if (action.type === 'Discard Revealed') {
+ return 'Discard a revealed card.'
+ }
+ if (action.type === 'Request Stalemate') {
+ return 'Ask to end the game in a stalemate.'
+ }
+ if (action.type === 'Accept Stalemate') {
+ return 'Accept the stalemate request.'
+ }
+ if (action.type === 'Reject Stalemate') {
+ return 'Reject the stalemate request.'
+ }
+ if (action.type === 'Concede') {
+ return 'Concede the game.'
+ }
+ return null
+}
+
function SuitIcon({ suit, size = 20 }: { suit: string; size?: number }) {
const suitProps = { size, strokeWidth: 2.5 }
@@ -667,6 +744,34 @@ function SuitIcon({ suit, size = 20 }: { suit: string; size?: number }) {
}
}
+function ActionButton({
+ action,
+ context,
+ selected,
+ disabled,
+ onClick,
+}: ActionButtonProps) {
+ const description = actionDescription(action)
+ const descId = description ? `action-desc-${context}-${action.id}` : undefined
+
+ return (
+
+ )
+}
+
function CardTile({ card, selected, isHand, onClick }: CardTileProps) {
const tag = cardTag(card)
const rank = rankShort(card.rank)