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.

Commit
852047304756505b7bb09721bd527f334286df65
Parent
7ad0c9e
Author
tonybanters <tonybanters@gmail.com>
Date
2026-03-16 15:08:44

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": {}
+}