guandan.dev
guandan.dev
https://git.tonybtw.com/guandan.dev.git
git://git.tonybtw.com/guandan.dev.git
updated ux to have cards stack vertically, and ability to drag / drop custom card combos.
Diff
diff --git a/client/src/App.tsx b/client/src/App.tsx
index c33297f..2824cb2 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -9,6 +9,14 @@ import {
Rank_Two,
Message,
} from './game/types'
+import {
+ find_same_rank,
+ select_pair,
+ select_triple,
+ select_bomb,
+ find_valid_plays,
+ detect_combo,
+} from './game/combos'
interface Deal_Cards_Payload {
cards: Card[]
@@ -173,6 +181,61 @@ export default function App() {
})
}, [])
+ const handle_select_same_rank = useCallback((rank: number) => {
+ set_hand((current_hand) => {
+ const same_rank_cards = find_same_rank(current_hand, rank)
+ set_selected_ids((prev) => {
+ const next = new Set(prev)
+ // Toggle: if all are selected, deselect all; otherwise select all
+ const all_selected = same_rank_cards.every(c => prev.has(c.Id))
+ if (all_selected) {
+ same_rank_cards.forEach(c => next.delete(c.Id))
+ } else {
+ same_rank_cards.forEach(c => next.add(c.Id))
+ }
+ return next
+ })
+ return current_hand
+ })
+ }, [])
+
+ const handle_quick_select = useCallback((type: 'pair' | 'triple' | 'bomb' | 'clear') => {
+ if (type === 'clear') {
+ set_selected_ids(new Set())
+ return
+ }
+
+ set_hand((current_hand) => {
+ let cards: Card[] | null = null
+ switch (type) {
+ case 'pair':
+ cards = select_pair(current_hand, level)
+ break
+ case 'triple':
+ cards = select_triple(current_hand, level)
+ break
+ case 'bomb':
+ cards = select_bomb(current_hand, level)
+ break
+ }
+ if (cards) {
+ set_selected_ids(new Set(cards.map(c => c.Id)))
+ }
+ return current_hand
+ })
+ }, [level])
+
+ const handle_suggest = useCallback(() => {
+ set_hand((current_hand) => {
+ const table_combo = table_cards.length > 0 ? detect_combo(table_cards, level) : null
+ const suggestions = find_valid_plays(current_hand, table_combo, level)
+ if (suggestions.length > 0) {
+ set_selected_ids(new Set(suggestions[0].map(c => c.Id)))
+ }
+ return current_hand
+ })
+ }, [table_cards, level])
+
const handle_play = useCallback(() => {
if (selected_ids.size === 0) return
@@ -218,6 +281,9 @@ export default function App() {
level={level}
selected_ids={selected_ids}
on_card_click={handle_card_click}
+ on_select_same_rank={handle_select_same_rank}
+ on_quick_select={handle_quick_select}
+ on_suggest={handle_suggest}
on_play={handle_play}
on_pass={handle_pass}
table_cards={table_cards}
diff --git a/client/src/components/Game.tsx b/client/src/components/Game.tsx
index c431c73..462ca9f 100644
--- a/client/src/components/Game.tsx
+++ b/client/src/components/Game.tsx
@@ -10,6 +10,9 @@ interface Game_Props {
level: Rank
selected_ids: Set<number>
on_card_click: (id: number) => void
+ on_select_same_rank: (rank: number) => void
+ on_quick_select: (type: 'pair' | 'triple' | 'bomb' | 'clear') => void
+ on_suggest: () => void
on_play: () => void
on_pass: () => void
table_cards: Card_Type[]
@@ -28,6 +31,9 @@ export function Game({
level,
selected_ids,
on_card_click,
+ on_select_same_rank,
+ on_quick_select,
+ on_suggest,
on_play,
on_pass,
table_cards,
@@ -70,11 +76,21 @@ export function Game({
</div>
<div style={mobile_styles.my_area}>
+ {/* Quick select buttons */}
+ <div style={mobile_styles.quick_select_row}>
+ <button onClick={() => on_quick_select('pair')} style={mobile_styles.quick_btn}>2x</button>
+ <button onClick={() => on_quick_select('triple')} style={mobile_styles.quick_btn}>3x</button>
+ <button onClick={() => on_quick_select('bomb')} style={mobile_styles.quick_btn_bomb}>Bomb</button>
+ <button onClick={on_suggest} style={mobile_styles.quick_btn_suggest}>Hint</button>
+ <button onClick={() => on_quick_select('clear')} style={mobile_styles.quick_btn_clear}>Clear</button>
+ </div>
+
<Hand
cards={hand}
level={level}
selected_ids={selected_ids}
on_card_click={on_card_click}
+ on_select_same_rank={on_select_same_rank}
/>
<div style={mobile_styles.actions}>
@@ -178,12 +194,22 @@ export function Game({
</div>
<div style={styles.my_area}>
- <Hand
- cards={hand}
- level={level}
- selected_ids={selected_ids}
- on_card_click={on_card_click}
- />
+ {/* Quick select buttons */}
+ <div style={styles.quick_select_row}>
+ <button onClick={() => on_quick_select('pair')} style={styles.quick_btn}>Pair</button>
+ <button onClick={() => on_quick_select('triple')} style={styles.quick_btn}>Triple</button>
+ <button onClick={() => on_quick_select('bomb')} style={styles.quick_btn_bomb}>Bomb</button>
+ <button onClick={on_suggest} style={styles.quick_btn_suggest}>Suggest</button>
+ <button onClick={() => on_quick_select('clear')} style={styles.quick_btn_clear}>Clear</button>
+ </div>
+
+ <Hand
+ cards={hand}
+ level={level}
+ selected_ids={selected_ids}
+ on_card_click={on_card_click}
+ on_select_same_rank={on_select_same_rank}
+ />
<div style={styles.actions}>
<motion.button
@@ -347,6 +373,15 @@ function get_relative_positions(my_seat: number) {
}
}
+const quick_btn_base: React.CSSProperties = {
+ padding: '6px 12px',
+ fontSize: 12,
+ border: 'none',
+ borderRadius: 6,
+ cursor: 'pointer',
+ fontWeight: 'bold',
+}
+
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
@@ -371,6 +406,15 @@ const styles: Record<string, React.CSSProperties> = {
fontWeight: 'bold',
fontSize: 14,
},
+ layout_toggle: {
+ padding: '6px 12px',
+ backgroundColor: '#4a5568',
+ color: '#fff',
+ border: 'none',
+ borderRadius: 6,
+ cursor: 'pointer',
+ fontSize: 12,
+ },
team_scores: {
color: '#fff',
fontSize: 12,
@@ -418,6 +462,31 @@ const styles: Record<string, React.CSSProperties> = {
borderTop: '2px solid #333',
flexShrink: 0,
},
+ quick_select_row: {
+ display: 'flex',
+ gap: 8,
+ marginBottom: 8,
+ },
+ quick_btn: {
+ ...quick_btn_base,
+ backgroundColor: '#2196f3',
+ color: '#fff',
+ },
+ quick_btn_bomb: {
+ ...quick_btn_base,
+ backgroundColor: '#ff5722',
+ color: '#fff',
+ },
+ quick_btn_suggest: {
+ ...quick_btn_base,
+ backgroundColor: '#9c27b0',
+ color: '#fff',
+ },
+ quick_btn_clear: {
+ ...quick_btn_base,
+ backgroundColor: '#607d8b',
+ color: '#fff',
+ },
actions: {
display: 'flex',
gap: 12,
@@ -448,6 +517,15 @@ const styles: Record<string, React.CSSProperties> = {
},
}
+const mobile_quick_btn_base: React.CSSProperties = {
+ padding: '4px 8px',
+ fontSize: 10,
+ border: 'none',
+ borderRadius: 4,
+ cursor: 'pointer',
+ fontWeight: 'bold',
+}
+
const mobile_styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
@@ -472,6 +550,15 @@ const mobile_styles: Record<string, React.CSSProperties> = {
fontWeight: 'bold',
fontSize: 12,
},
+ layout_toggle: {
+ padding: '4px 8px',
+ backgroundColor: '#4a5568',
+ color: '#fff',
+ border: 'none',
+ borderRadius: 4,
+ cursor: 'pointer',
+ fontSize: 12,
+ },
team_scores: {
color: '#fff',
fontSize: 11,
@@ -510,6 +597,31 @@ const mobile_styles: Record<string, React.CSSProperties> = {
borderTop: '2px solid #333',
flexShrink: 0,
},
+ quick_select_row: {
+ display: 'flex',
+ gap: 6,
+ marginBottom: 4,
+ },
+ quick_btn: {
+ ...mobile_quick_btn_base,
+ backgroundColor: '#2196f3',
+ color: '#fff',
+ },
+ quick_btn_bomb: {
+ ...mobile_quick_btn_base,
+ backgroundColor: '#ff5722',
+ color: '#fff',
+ },
+ quick_btn_suggest: {
+ ...mobile_quick_btn_base,
+ backgroundColor: '#9c27b0',
+ color: '#fff',
+ },
+ quick_btn_clear: {
+ ...mobile_quick_btn_base,
+ backgroundColor: '#607d8b',
+ color: '#fff',
+ },
actions: {
display: 'flex',
gap: 16,
diff --git a/client/src/components/Hand.tsx b/client/src/components/Hand.tsx
index b0e1bd6..dc40d2c 100644
--- a/client/src/components/Hand.tsx
+++ b/client/src/components/Hand.tsx
@@ -1,3 +1,4 @@
+import { useRef, useCallback, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Card as Card_Type, Rank } from '../game/types'
import { Card } from './Card'
@@ -8,64 +9,225 @@ interface Hand_Props {
level: Rank
selected_ids: Set<number>
on_card_click: (id: number) => void
+ on_select_same_rank: (rank: number) => void
}
-export function Hand({ cards, level, selected_ids, on_card_click }: Hand_Props) {
+interface Column {
+ id: string
+ card_ids: number[]
+ is_custom: boolean
+}
+
+export function Hand({ cards, level, selected_ids, on_card_click, on_select_same_rank }: Hand_Props) {
const is_mobile = use_is_mobile()
- const card_width = is_mobile ? 56 : 70
- const card_height = is_mobile ? 80 : 100
- const overlap = is_mobile ? 28 : 35
-
- if (is_mobile) {
- return (
- <div
- style={{
- width: '100%',
- overflowX: 'auto',
- overflowY: 'hidden',
- WebkitOverflowScrolling: 'touch',
- padding: '8px 0',
- minHeight: card_height + 30,
- }}
- >
- <div
- style={{
- display: 'flex',
- position: 'relative',
- width: cards.length > 0 ? card_width + (cards.length - 1) * overlap + 16 : 0,
- height: card_height + 20,
- margin: '0 auto',
- paddingLeft: 8,
- paddingRight: 8,
- }}
- >
- <AnimatePresence>
- {cards.map((card, index) => (
- <motion.div
- key={card.Id}
- initial={{ opacity: 0, y: 30, scale: 0.8 }}
- animate={{ opacity: 1, y: 0, scale: 1 }}
- exit={{ opacity: 0, y: -30, scale: 0.8 }}
- transition={{ delay: index * 0.015 }}
- style={{
- position: 'absolute',
- left: index * overlap,
- zIndex: index,
- }}
- >
- <Card
- card={card}
- level={level}
- selected={selected_ids.has(card.Id)}
- on_click={() => on_card_click(card.Id)}
- size={is_mobile ? 'small' : 'normal'}
- />
- </motion.div>
- ))}
- </AnimatePresence>
- </div>
- </div>
- )
+ const last_click = useRef<{ id: number; time: number } | null>(null)
+ const [custom_columns, set_custom_columns] = useState<Map<string, number[]>>(new Map())
+ const [drag_card_id, set_drag_card_id] = useState<number | null>(null)
+ const [drop_target, set_drop_target] = useState<string | null>(null)
+
+ // Touch drag state
+ const touch_start_pos = useRef<{ x: number; y: number } | null>(null)
+ const is_touch_dragging = useRef(false)
+ const column_refs = useRef<Map<string, HTMLDivElement>>(new Map())
+ const new_pile_ref = useRef<HTMLDivElement>(null)
+
+ const handle_card_click = useCallback((card: Card_Type) => {
+ // Don't trigger click if we were dragging
+ if (is_touch_dragging.current) {
+ is_touch_dragging.current = false
+ return
+ }
+
+ const now = Date.now()
+ const last = last_click.current
+
+ if (last && last.id === card.Id && now - last.time < 300) {
+ on_select_same_rank(card.Rank)
+ last_click.current = null
+ } else {
+ on_card_click(card.Id)
+ last_click.current = { id: card.Id, time: now }
+ }
+ }, [on_card_click, on_select_same_rank])
+
+ // Get all card IDs in custom columns
+ const cards_in_custom = new Set<number>()
+ custom_columns.forEach(ids => ids.forEach(id => cards_in_custom.add(id)))
+
+ // Filter to only cards that still exist in hand
+ const valid_card_ids = new Set(cards.map(c => c.Id))
+
+ // Build columns: custom columns first, then auto-sorted remaining cards
+ const columns: Column[] = []
+
+ // Add custom columns (filter out cards no longer in hand)
+ custom_columns.forEach((card_ids, col_id) => {
+ const valid_ids = card_ids.filter(id => valid_card_ids.has(id))
+ if (valid_ids.length > 0) {
+ columns.push({ id: col_id, card_ids: valid_ids, is_custom: true })
+ }
+ })
+
+ // Auto-group remaining cards by rank
+ const remaining_cards = cards.filter(c => !cards_in_custom.has(c.Id))
+ const by_rank = new Map<number, Card_Type[]>()
+ remaining_cards.forEach(card => {
+ const arr = by_rank.get(card.Rank) || []
+ arr.push(card)
+ by_rank.set(card.Rank, arr)
+ })
+
+ // Sort ranks high to low
+ const rank_order = (rank: number): number => {
+ if (rank === 14) return 1000
+ if (rank === 13) return 999
+ if (rank === level) return 998
+ if (rank === 0) return 15
+ if (rank === 12) return 14
+ return rank + 2
+ }
+
+ const sorted_ranks = Array.from(by_rank.keys()).sort((a, b) => rank_order(b) - rank_order(a))
+ sorted_ranks.forEach(rank => {
+ const rank_cards = by_rank.get(rank)!
+ columns.push({
+ id: `rank-${rank}`,
+ card_ids: rank_cards.map(c => c.Id),
+ is_custom: false
+ })
+ })
+
+ // Card lookup
+ const card_by_id = new Map(cards.map(c => [c.Id, c]))
+
+ const card_width = is_mobile ? 44 : 56
+ const card_height = is_mobile ? 62 : 78
+ const v_overlap = is_mobile ? 24 : 30
+ const h_gap = is_mobile ? 3 : 4
+ const selection_lift = 24
+
+ // Find drop target from touch position
+ const find_drop_target = (x: number, y: number): string | null => {
+ // Check new pile zone
+ if (new_pile_ref.current) {
+ const rect = new_pile_ref.current.getBoundingClientRect()
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
+ return 'new'
+ }
+ }
+
+ // Check custom columns
+ for (const [col_id, el] of column_refs.current) {
+ if (col_id.startsWith('custom-')) {
+ const rect = el.getBoundingClientRect()
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
+ return col_id
+ }
+ }
+ }
+
+ return null
+ }
+
+ // Commit the drop
+ const commit_drop = (card_id: number, target: string | null) => {
+ if (target === null) {
+ set_drag_card_id(null)
+ set_drop_target(null)
+ return
+ }
+
+ set_custom_columns(prev => {
+ const next = new Map(prev)
+
+ // Remove from any existing custom column
+ next.forEach((ids, col_id) => {
+ const filtered = ids.filter(id => id !== card_id)
+ if (filtered.length === 0) {
+ next.delete(col_id)
+ } else {
+ next.set(col_id, filtered)
+ }
+ })
+
+ if (target === 'new') {
+ const new_col_id = `custom-${Date.now()}`
+ next.set(new_col_id, [card_id])
+ } else if (target.startsWith('custom-')) {
+ const existing = next.get(target) || []
+ next.set(target, [...existing, card_id])
+ }
+
+ return next
+ })
+
+ set_drag_card_id(null)
+ set_drop_target(null)
+ }
+
+ // Mouse drag handlers
+ const handle_drag_start = (card_id: number) => {
+ set_drag_card_id(card_id)
+ }
+
+ const handle_drag_end = () => {
+ if (drag_card_id !== null && drop_target !== null) {
+ commit_drop(drag_card_id, drop_target)
+ } else {
+ set_drag_card_id(null)
+ set_drop_target(null)
+ }
+ }
+
+ const handle_drag_over_column = (col_id: string) => {
+ set_drop_target(col_id)
+ }
+
+ const handle_drag_leave = () => {
+ set_drop_target(null)
+ }
+
+ // Touch handlers
+ const handle_touch_start = (_card_id: number, e: React.TouchEvent) => {
+ const touch = e.touches[0]
+ touch_start_pos.current = { x: touch.clientX, y: touch.clientY }
+ is_touch_dragging.current = false
+ }
+
+ const handle_touch_move = (card_id: number, e: React.TouchEvent) => {
+ if (!touch_start_pos.current) return
+
+ const touch = e.touches[0]
+ const dx = Math.abs(touch.clientX - touch_start_pos.current.x)
+ const dy = Math.abs(touch.clientY - touch_start_pos.current.y)
+
+ // Start dragging if moved more than 10px
+ if (dx > 10 || dy > 10) {
+ is_touch_dragging.current = true
+ set_drag_card_id(card_id)
+
+ const target = find_drop_target(touch.clientX, touch.clientY)
+ set_drop_target(target)
+ }
+ }
+
+ const handle_touch_end = (_card_id: number) => {
+ if (is_touch_dragging.current && drag_card_id !== null) {
+ commit_drop(drag_card_id, drop_target)
+ }
+ touch_start_pos.current = null
+ // Don't reset is_touch_dragging here - let click handler check it
+ }
+
+ // Double-tap on custom column to dissolve it
+ const handle_column_double_click = (col_id: string) => {
+ if (col_id.startsWith('custom-')) {
+ set_custom_columns(prev => {
+ const next = new Map(prev)
+ next.delete(col_id)
+ return next
+ })
+ }
}
return (
@@ -73,41 +235,116 @@ export function Hand({ cards, level, selected_ids, on_card_click }: Hand_Props)
style={{
display: 'flex',
justifyContent: 'center',
- padding: 20,
- minHeight: 140,
+ padding: is_mobile ? '4px 8px' : '8px 16px',
+ paddingTop: selection_lift + (is_mobile ? 4 : 8),
+ overflowX: 'auto',
+ overflowY: 'visible',
+ WebkitOverflowScrolling: 'touch',
+ width: '100%',
}}
>
- <div
- style={{
- display: 'flex',
- position: 'relative',
- width: cards.length > 0 ? card_width + (cards.length - 1) * overlap : 0,
- height: card_height,
- }}
- >
- <AnimatePresence>
- {cards.map((card, index) => (
- <motion.div
- key={card.Id}
- initial={{ opacity: 0, y: 50, scale: 0.8 }}
- animate={{ opacity: 1, y: 0, scale: 1 }}
- exit={{ opacity: 0, y: -50, scale: 0.8 }}
- transition={{ delay: index * 0.02 }}
+ <div style={{ display: 'flex', gap: h_gap, alignItems: 'flex-start' }}>
+ {/* New pile drop zone - always visible */}
+ <div
+ ref={new_pile_ref}
+ onDragOver={(e) => { e.preventDefault(); set_drop_target('new') }}
+ onDragLeave={handle_drag_leave}
+ onDrop={handle_drag_end}
+ style={{
+ width: card_width,
+ height: card_height,
+ border: `2px dashed ${drop_target === 'new' ? '#4caf50' : drag_card_id !== null ? '#888' : '#444'}`,
+ borderRadius: 8,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: drop_target === 'new' ? '#4caf50' : drag_card_id !== null ? '#888' : '#555',
+ fontSize: is_mobile ? 18 : 24,
+ backgroundColor: drop_target === 'new' ? 'rgba(76,175,80,0.15)' : 'transparent',
+ opacity: drag_card_id !== null ? 1 : 0.4,
+ transition: 'all 0.15s ease',
+ flexShrink: 0,
+ }}
+ >
+ +
+ </div>
+
+ {columns.map((col, col_idx) => {
+ const col_cards = col.card_ids.map(id => card_by_id.get(id)!).filter(Boolean)
+ const col_height = card_height + (col_cards.length - 1) * v_overlap
+
+ return (
+ <div
+ key={col.id}
+ ref={(el) => { if (el) column_refs.current.set(col.id, el) }}
+ onDragOver={(e) => { e.preventDefault(); if (col.is_custom) handle_drag_over_column(col.id) }}
+ onDragLeave={handle_drag_leave}
+ onDrop={handle_drag_end}
+ onDoubleClick={() => handle_column_double_click(col.id)}
style={{
- position: 'absolute',
- left: index * overlap,
- zIndex: index,
+ position: 'relative',
+ width: card_width,
+ height: col_height,
+ borderLeft: col.is_custom ? '2px solid #9c27b0' : 'none',
+ paddingLeft: col.is_custom ? 2 : 0,
+ backgroundColor: drop_target === col.id ? 'rgba(156,39,176,0.15)' : 'transparent',
+ borderRadius: 4,
+ flexShrink: 0,
}}
>
- <Card
- card={card}
- level={level}
- selected={selected_ids.has(card.Id)}
- on_click={() => on_card_click(card.Id)}
- />
- </motion.div>
- ))}
- </AnimatePresence>
+ {col.is_custom && (
+ <div
+ style={{
+ position: 'absolute',
+ top: -14,
+ left: 0,
+ fontSize: 8,
+ color: '#9c27b0',
+ }}
+ >
+ ✦
+ </div>
+ )}
+ <AnimatePresence>
+ {col_cards.map((card, card_idx) => (
+ <motion.div
+ key={card.Id}
+ draggable
+ onDragStart={() => handle_drag_start(card.Id)}
+ onDragEnd={handle_drag_end}
+ onTouchStart={(e) => handle_touch_start(card.Id, e)}
+ onTouchMove={(e) => handle_touch_move(card.Id, e)}
+ onTouchEnd={() => handle_touch_end(card.Id)}
+ initial={{ opacity: 0, x: 20, scale: 0.8 }}
+ animate={{
+ opacity: drag_card_id === card.Id ? 0.5 : 1,
+ x: 0,
+ scale: 1
+ }}
+ exit={{ opacity: 0, x: -20, scale: 0.8 }}
+ transition={{ delay: (col_idx * 0.3 + card_idx) * 0.01 }}
+ style={{
+ position: 'absolute',
+ top: card_idx * v_overlap,
+ left: 0,
+ zIndex: card_idx,
+ cursor: 'grab',
+ touchAction: 'none',
+ }}
+ >
+ <Card
+ card={card}
+ level={level}
+ selected={selected_ids.has(card.Id)}
+ on_click={() => handle_card_click(card)}
+ size="small"
+ />
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ )
+ })}
</div>
</div>
)
diff --git a/client/src/game/combos.ts b/client/src/game/combos.ts
new file mode 100644
index 0000000..dec5159
--- /dev/null
+++ b/client/src/game/combos.ts
@@ -0,0 +1,400 @@
+import { Card, Rank, is_wild } from './types'
+
+export interface Card_Group {
+ type: 'bomb' | 'straight_flush' | 'triple' | 'pair' | 'wild' | 'single'
+ cards: Card[]
+}
+
+// Group cards for visual display - finds bombs, straights, pairs, etc.
+export function group_cards_for_display(cards: Card[], level: Rank): Card_Group[] {
+ const groups: Card_Group[] = []
+ const used = new Set<number>()
+
+ // First extract wild cards (heart level cards)
+ const wilds = cards.filter(c => is_wild(c, level))
+ wilds.forEach(c => {
+ used.add(c.Id)
+ groups.push({ type: 'wild', cards: [c] })
+ })
+
+ const remaining = cards.filter(c => !used.has(c.Id))
+
+ // Group by rank
+ const by_rank = new Map<number, Card[]>()
+ remaining.forEach(c => {
+ const arr = by_rank.get(c.Rank) || []
+ arr.push(c)
+ by_rank.set(c.Rank, arr)
+ })
+
+ // Find bombs (4+ of same rank, or joker bomb)
+ const jokers = remaining.filter(c => c.Rank === 13 || c.Rank === 14)
+ if (jokers.length === 4) {
+ jokers.forEach(c => used.add(c.Id))
+ groups.push({ type: 'bomb', cards: jokers })
+ }
+
+ by_rank.forEach((rank_cards) => {
+ const unused = rank_cards.filter(c => !used.has(c.Id))
+ if (unused.length >= 4) {
+ unused.forEach(c => used.add(c.Id))
+ groups.push({ type: 'bomb', cards: unused })
+ }
+ })
+
+ // Find straight flushes (5+ consecutive same suit)
+ const by_suit = new Map<number, Card[]>()
+ remaining.filter(c => !used.has(c.Id) && c.Rank < 13).forEach(c => {
+ const arr = by_suit.get(c.Suit) || []
+ arr.push(c)
+ by_suit.set(c.Suit, arr)
+ })
+
+ by_suit.forEach((suit_cards) => {
+ const sorted = [...suit_cards].sort((a, b) => a.Rank - b.Rank)
+ let run: Card[] = []
+
+ for (const card of sorted) {
+ if (used.has(card.Id)) continue
+ if (run.length === 0 || card.Rank === run[run.length - 1].Rank + 1) {
+ run.push(card)
+ } else {
+ if (run.length >= 5) {
+ run.forEach(c => used.add(c.Id))
+ groups.push({ type: 'straight_flush', cards: run })
+ }
+ run = [card]
+ }
+ }
+ if (run.length >= 5) {
+ run.forEach(c => used.add(c.Id))
+ groups.push({ type: 'straight_flush', cards: run })
+ }
+ })
+
+ // Find triples
+ by_rank.forEach((rank_cards) => {
+ const unused = rank_cards.filter(c => !used.has(c.Id))
+ if (unused.length === 3) {
+ unused.forEach(c => used.add(c.Id))
+ groups.push({ type: 'triple', cards: unused })
+ }
+ })
+
+ // Find pairs
+ by_rank.forEach((rank_cards) => {
+ const unused = rank_cards.filter(c => !used.has(c.Id))
+ if (unused.length === 2) {
+ unused.forEach(c => used.add(c.Id))
+ groups.push({ type: 'pair', cards: unused })
+ }
+ })
+
+ // Remaining singles
+ remaining.filter(c => !used.has(c.Id)).forEach(c => {
+ groups.push({ type: 'single', cards: [c] })
+ })
+
+ return groups
+}
+
+// Find all cards with same rank as the given card
+export function find_same_rank(cards: Card[], rank: number): Card[] {
+ return cards.filter(c => c.Rank === rank)
+}
+
+// Combo types for play validation
+export type Combo_Type =
+ | 'single'
+ | 'pair'
+ | 'triple'
+ | 'full_house'
+ | 'straight'
+ | 'tube' // consecutive pairs
+ | 'plate' // consecutive triples
+ | 'bomb_4'
+ | 'bomb_5'
+ | 'bomb_6'
+ | 'bomb_7'
+ | 'bomb_8'
+ | 'straight_flush'
+ | 'joker_bomb'
+
+interface Detected_Combo {
+ type: Combo_Type
+ cards: Card[]
+ value: number // for comparison
+}
+
+// Detect what combo a set of cards forms
+export function detect_combo(cards: Card[], level: Rank): Detected_Combo | null {
+ if (cards.length === 0) return null
+
+ const sorted = [...cards].sort((a, b) => a.Rank - b.Rank)
+ const n = sorted.length
+
+ // Check joker bomb (4 jokers)
+ if (n === 4 && sorted.every(c => c.Rank === 13 || c.Rank === 14)) {
+ return { type: 'joker_bomb', cards, value: 1000 }
+ }
+
+ // Group by rank
+ const by_rank = new Map<number, Card[]>()
+ sorted.forEach(c => {
+ const arr = by_rank.get(c.Rank) || []
+ arr.push(c)
+ by_rank.set(c.Rank, arr)
+ })
+
+ // Single
+ if (n === 1) {
+ return { type: 'single', cards, value: get_card_value(sorted[0], level) }
+ }
+
+ // Pair
+ if (n === 2 && by_rank.size === 1) {
+ return { type: 'pair', cards, value: get_card_value(sorted[0], level) }
+ }
+
+ // Triple
+ if (n === 3 && by_rank.size === 1) {
+ return { type: 'triple', cards, value: get_card_value(sorted[0], level) }
+ }
+
+ // Bombs (4-8 of same rank)
+ if (by_rank.size === 1 && n >= 4 && n <= 8) {
+ const bomb_types: Record<number, Combo_Type> = {
+ 4: 'bomb_4', 5: 'bomb_5', 6: 'bomb_6', 7: 'bomb_7', 8: 'bomb_8'
+ }
+ return { type: bomb_types[n], cards, value: get_card_value(sorted[0], level) + n * 100 }
+ }
+
+ // Full house (3+2)
+ if (n === 5) {
+ const counts = Array.from(by_rank.values()).map(arr => arr.length).sort()
+ if (counts.length === 2 && counts[0] === 2 && counts[1] === 3) {
+ const triple_rank = Array.from(by_rank.entries()).find(([_, arr]) => arr.length === 3)![0]
+ return { type: 'full_house', cards, value: get_rank_value(triple_rank, level) }
+ }
+ }
+
+ // Straight (5+ consecutive)
+ if (n >= 5 && by_rank.size === n) {
+ const ranks = sorted.map(c => c.Rank)
+ if (is_consecutive(ranks)) {
+ // Check if straight flush
+ if (sorted.every(c => c.Suit === sorted[0].Suit)) {
+ return { type: 'straight_flush', cards, value: get_card_value(sorted[n-1], level) + 500 + n * 10 }
+ }
+ return { type: 'straight', cards, value: get_card_value(sorted[n-1], level) }
+ }
+ }
+
+ // Tube (consecutive pairs)
+ if (n >= 4 && n % 2 === 0) {
+ const pairs = Array.from(by_rank.entries())
+ if (pairs.every(([_, arr]) => arr.length === 2)) {
+ const ranks = pairs.map(([r, _]) => r).sort((a, b) => a - b)
+ if (is_consecutive(ranks)) {
+ return { type: 'tube', cards, value: get_rank_value(ranks[ranks.length - 1], level) }
+ }
+ }
+ }
+
+ // Plate (consecutive triples)
+ if (n >= 6 && n % 3 === 0) {
+ const triples = Array.from(by_rank.entries())
+ if (triples.every(([_, arr]) => arr.length === 3)) {
+ const ranks = triples.map(([r, _]) => r).sort((a, b) => a - b)
+ if (is_consecutive(ranks)) {
+ return { type: 'plate', cards, value: get_rank_value(ranks[ranks.length - 1], level) }
+ }
+ }
+ }
+
+ return null
+}
+
+function is_consecutive(ranks: number[]): boolean {
+ const sorted = [...ranks].sort((a, b) => a - b)
+ for (let i = 1; i < sorted.length; i++) {
+ if (sorted[i] !== sorted[i - 1] + 1) return false
+ }
+ // Can't include 2 (rank 0) in straights
+ if (sorted.includes(0)) return false
+ return true
+}
+
+function get_card_value(card: Card, level: Rank): number {
+ return get_rank_value(card.Rank, level)
+}
+
+function get_rank_value(rank: number, level: Rank): number {
+ if (rank === 14) return 100 // red joker
+ if (rank === 13) return 99 // black joker
+ if (rank === level) return 98 // level card
+ // 2 is highest non-special
+ if (rank === 0) return 15
+ // A is second highest
+ if (rank === 12) return 14
+ // rest are 3-K (ranks 1-11)
+ return rank + 2
+}
+
+// Find valid plays that can beat the current table
+export function find_valid_plays(
+ hand: Card[],
+ table_combo: Detected_Combo | null,
+ level: Rank
+): Card[][] {
+ const suggestions: Card[][] = []
+
+ if (!table_combo) {
+ // Can play anything - suggest singles, pairs, triples
+ const by_rank = new Map<number, Card[]>()
+ hand.forEach(c => {
+ const arr = by_rank.get(c.Rank) || []
+ arr.push(c)
+ by_rank.set(c.Rank, arr)
+ })
+
+ // Suggest lowest single
+ const sorted = [...hand].sort((a, b) => get_card_value(a, level) - get_card_value(b, level))
+ if (sorted.length > 0) {
+ suggestions.push([sorted[0]])
+ }
+
+ // Suggest lowest pair
+ for (const [_, cards] of by_rank) {
+ if (cards.length >= 2) {
+ suggestions.push(cards.slice(0, 2))
+ break
+ }
+ }
+
+ return suggestions.slice(0, 3)
+ }
+
+ // Need to beat the table combo
+ const by_rank = new Map<number, Card[]>()
+ hand.forEach(c => {
+ const arr = by_rank.get(c.Rank) || []
+ arr.push(c)
+ by_rank.set(c.Rank, arr)
+ })
+
+ switch (table_combo.type) {
+ case 'single': {
+ const candidates = hand.filter(c => get_card_value(c, level) > table_combo.value)
+ .sort((a, b) => get_card_value(a, level) - get_card_value(b, level))
+ if (candidates.length > 0) {
+ suggestions.push([candidates[0]])
+ }
+ break
+ }
+ case 'pair': {
+ for (const [rank, cards] of by_rank) {
+ if (cards.length >= 2 && get_rank_value(rank, level) > table_combo.value) {
+ suggestions.push(cards.slice(0, 2))
+ if (suggestions.length >= 2) break
+ }
+ }
+ break
+ }
+ case 'triple': {
+ for (const [rank, cards] of by_rank) {
+ if (cards.length >= 3 && get_rank_value(rank, level) > table_combo.value) {
+ suggestions.push(cards.slice(0, 3))
+ if (suggestions.length >= 2) break
+ }
+ }
+ break
+ }
+ default:
+ // For complex combos, just return empty for now
+ break
+ }
+
+ // Always suggest bombs as alternatives
+ for (const [_, cards] of by_rank) {
+ if (cards.length >= 4) {
+ suggestions.push(cards)
+ }
+ }
+
+ // Joker bomb
+ const jokers = hand.filter(c => c.Rank === 13 || c.Rank === 14)
+ if (jokers.length === 4) {
+ suggestions.push(jokers)
+ }
+
+ return suggestions.slice(0, 3)
+}
+
+// Quick select helpers
+export function select_pair(hand: Card[], level: Rank): Card[] | null {
+ const by_rank = new Map<number, Card[]>()
+ hand.forEach(c => {
+ const arr = by_rank.get(c.Rank) || []
+ arr.push(c)
+ by_rank.set(c.Rank, arr)
+ })
+
+ // Find lowest pair
+ const sorted_ranks = Array.from(by_rank.keys())
+ .sort((a, b) => get_rank_value(a, level) - get_rank_value(b, level))
+
+ for (const rank of sorted_ranks) {
+ const cards = by_rank.get(rank)!
+ if (cards.length >= 2) {
+ return cards.slice(0, 2)
+ }
+ }
+ return null
+}
+
+export function select_triple(hand: Card[], level: Rank): Card[] | null {
+ const by_rank = new Map<number, Card[]>()
+ hand.forEach(c => {
+ const arr = by_rank.get(c.Rank) || []
+ arr.push(c)
+ by_rank.set(c.Rank, arr)
+ })
+
+ const sorted_ranks = Array.from(by_rank.keys())
+ .sort((a, b) => get_rank_value(a, level) - get_rank_value(b, level))
+
+ for (const rank of sorted_ranks) {
+ const cards = by_rank.get(rank)!
+ if (cards.length >= 3) {
+ return cards.slice(0, 3)
+ }
+ }
+ return null
+}
+
+export function select_bomb(hand: Card[], level: Rank): Card[] | null {
+ const by_rank = new Map<number, Card[]>()
+ hand.forEach(c => {
+ const arr = by_rank.get(c.Rank) || []
+ arr.push(c)
+ by_rank.set(c.Rank, arr)
+ })
+
+ // Joker bomb first
+ const jokers = hand.filter(c => c.Rank === 13 || c.Rank === 14)
+ if (jokers.length === 4) {
+ return jokers
+ }
+
+ const sorted_ranks = Array.from(by_rank.keys())
+ .sort((a, b) => get_rank_value(a, level) - get_rank_value(b, level))
+
+ for (const rank of sorted_ranks) {
+ const cards = by_rank.get(rank)!
+ if (cards.length >= 4) {
+ return cards
+ }
+ }
+ return null
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
index d47b1b5..be6bb29 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -4,6 +4,8 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
+ host: '127.0.0.1',
+ port: 5173,
proxy: {
'/ws': {
target: 'ws://localhost:8080',
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b3ea048
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "guandanbtw",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}