guandan.dev

guandan.dev

https://git.tonybtw.com/guandan.dev.git git://git.tonybtw.com/guandan.dev.git

Added test mode with bots, added logic for bots, fixed animations, added mprocs AND just to flake, with respective files.

Commit
facb5812d8d7c6913f9afcc0430a7596aed42e0d
Parent
a51e200
Author
tonybtw <tonybtw@tonybtw.com>
Date
2026-01-25 07:02:43

Diff

diff --git a/assets/player_log.png b/assets/player_log.png
new file mode 100644
index 0000000..7d27a9b
Binary files /dev/null and b/assets/player_log.png differ
diff --git a/client/src/App.tsx b/client/src/App.tsx
index fa831e6..6614b26 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -59,6 +59,9 @@ export default function App() {
   const [player_card_counts, set_player_card_counts] = useState([27, 27, 27, 27])
   const [team_levels, set_team_levels] = useState<[number, number]>([0, 0])
   const [error, set_error] = useState<string | null>(null)
+  const [play_log, set_play_log] = useState<Array<{ seat: number; cards: Card[]; combo_type: string; is_pass: boolean }>>([])
+  const [players_map, set_players_map] = useState<Record<number, string>>({})
+  const [last_play_seat, set_last_play_seat] = useState<number | null>(null)
 
   useEffect(() => {
     const unsub_room_state = on('room_state', (msg: Message) => {
@@ -71,6 +74,11 @@ export default function App() {
       if (me) {
         set_my_seat(me.seat)
       }
+      const pmap: Record<number, string> = {}
+      payload.players.forEach((p) => {
+        pmap[p.seat] = p.name
+      })
+      set_players_map(pmap)
     })
 
     const unsub_deal = on('deal_cards', (msg: Message) => {
@@ -82,6 +90,7 @@ export default function App() {
       set_combo_type('')
       set_selected_ids(new Set())
       set_player_card_counts([27, 27, 27, 27])
+      set_play_log([])
     })
 
     const unsub_turn = on('turn', (msg: Message) => {
@@ -92,6 +101,20 @@ export default function App() {
 
     const unsub_play_made = on('play_made', (msg: Message) => {
       const payload = msg.payload as Play_Made_Payload
+
+      set_play_log((prev) => {
+        const next = [...prev, {
+          seat: payload.seat,
+          cards: payload.cards || [],
+          combo_type: payload.combo_type || '',
+          is_pass: payload.is_pass,
+        }]
+        return next.slice(-8)
+      })
+
+      set_last_play_seat(payload.seat)
+      setTimeout(() => set_last_play_seat(null), 800)
+
       if (!payload.is_pass) {
         set_table_cards(payload.cards)
         set_combo_type(payload.combo_type)
@@ -146,6 +169,10 @@ export default function App() {
     [send]
   )
 
+  const handle_fill_bots = useCallback(() => {
+    send({ type: 'fill_bots', payload: {} })
+  }, [send])
+
   const handle_card_click = useCallback((id: number) => {
     set_selected_ids((prev) => {
       const next = new Set(prev)
@@ -189,6 +216,7 @@ export default function App() {
           players={players}
           on_create_room={handle_create_room}
           on_join_room={handle_join_room}
+          on_fill_bots={handle_fill_bots}
         />
         {error && <div style={styles.error}>{error}</div>}
       </>
@@ -211,6 +239,9 @@ export default function App() {
         can_pass={can_pass}
         player_card_counts={player_card_counts}
         team_levels={team_levels}
+        play_log={play_log}
+        players_map={players_map}
+        last_play_seat={last_play_seat}
       />
       {error && <div style={styles.error}>{error}</div>}
     </>
diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx
index 790719d..d416822 100644
--- a/client/src/components/Card.tsx
+++ b/client/src/components/Card.tsx
@@ -1,5 +1,5 @@
 import { motion } from 'framer-motion'
-import { Card as Card_Type, get_suit_symbol, get_rank_symbol, is_red_suit, is_wild, Rank, Rank_Black_Joker, Rank_Red_Joker, Suit_Joker } from '../game/types'
+import { Card as Card_Type, get_suit_symbol, get_rank_symbol, is_red_suit, is_wild, Rank, Rank_Red_Joker, Suit_Joker } from '../game/types'
 
 interface Card_Props {
   card: Card_Type
diff --git a/client/src/components/Game.tsx b/client/src/components/Game.tsx
index 2d32fec..26d7dd1 100644
--- a/client/src/components/Game.tsx
+++ b/client/src/components/Game.tsx
@@ -1,9 +1,16 @@
 import { motion } from 'framer-motion'
-import { Card as Card_Type, Rank, get_rank_symbol } from '../game/types'
+import { Card as Card_Type, Rank, get_rank_symbol, get_suit_symbol, is_red_suit, Suit_Joker, Rank_Red_Joker } from '../game/types'
 import { Hand } from './Hand'
 import { Table } from './Table'
 import { Card_Back } from './Card'
 
+interface Play_Log_Entry {
+  seat: number
+  cards: Card_Type[]
+  combo_type: string
+  is_pass: boolean
+}
+
 interface Game_Props {
   hand: Card_Type[]
   level: Rank
@@ -18,6 +25,9 @@ interface Game_Props {
   can_pass: boolean
   player_card_counts: number[]
   team_levels: [number, number]
+  play_log: Play_Log_Entry[]
+  players_map: Record<number, string>
+  last_play_seat: number | null
 }
 
 export function Game({
@@ -34,6 +44,9 @@ export function Game({
   can_pass,
   player_card_counts,
   team_levels,
+  play_log,
+  players_map,
+  last_play_seat,
 }: Game_Props) {
   const is_my_turn = current_turn === my_seat
   const relative_positions = get_relative_positions(my_seat)
@@ -50,40 +63,47 @@ export function Game({
         </div>
       </div>
 
-      <div style={styles.game_area}>
-        <div style={styles.opponent_top}>
-          <Opponent_Hand
-            count={player_card_counts[relative_positions.top]}
-            is_turn={current_turn === relative_positions.top}
-            seat={relative_positions.top}
-          />
-        </div>
-
-        <div style={styles.middle_row}>
-          <div style={styles.opponent_side}>
+      <div style={styles.main_layout}>
+        <div style={styles.game_area}>
+          <div style={styles.opponent_top}>
             <Opponent_Hand
-              count={player_card_counts[relative_positions.left]}
-              is_turn={current_turn === relative_positions.left}
-              seat={relative_positions.left}
-              vertical
+              count={player_card_counts[relative_positions.top]}
+              is_turn={current_turn === relative_positions.top}
+              just_played={last_play_seat === relative_positions.top}
+              seat={relative_positions.top}
+              name={players_map[relative_positions.top]}
             />
           </div>
 
-          <div style={styles.table_area}>
-            <Table cards={table_cards} level={level} combo_type={combo_type} />
-          </div>
+          <div style={styles.middle_row}>
+            <div style={styles.opponent_side}>
+              <Opponent_Hand
+                count={player_card_counts[relative_positions.left]}
+                is_turn={current_turn === relative_positions.left}
+                just_played={last_play_seat === relative_positions.left}
+                seat={relative_positions.left}
+                vertical
+                name={players_map[relative_positions.left]}
+              />
+            </div>
+
+            <div style={styles.table_area}>
+              <Table cards={table_cards} level={level} combo_type={combo_type} last_play_seat={last_play_seat} />
+            </div>
 
-          <div style={styles.opponent_side}>
+            <div style={styles.opponent_side}>
             <Opponent_Hand
               count={player_card_counts[relative_positions.right]}
               is_turn={current_turn === relative_positions.right}
+              just_played={last_play_seat === relative_positions.right}
               seat={relative_positions.right}
               vertical
+              name={players_map[relative_positions.right]}
             />
           </div>
-        </div>
+          </div>
 
-        <div style={styles.my_area}>
+          <div style={styles.my_area}>
           <Hand
             cards={hand}
             level={level}
@@ -96,10 +116,10 @@ export function Game({
               whileHover={{ scale: 1.05 }}
               whileTap={{ scale: 0.95 }}
               onClick={on_play}
-              disabled={!is_my_turn || selected_ids.size === 0}
+              disabled={!is_my_turn || selected_ids.size === 0 || hand.length === 0}
               style={{
                 ...styles.action_button,
-                backgroundColor: is_my_turn && selected_ids.size > 0 ? '#28a745' : '#444',
+                backgroundColor: is_my_turn && selected_ids.size > 0 && hand.length > 0 ? '#28a745' : '#444',
               }}
             >
               Play
@@ -108,17 +128,17 @@ export function Game({
               whileHover={{ scale: 1.05 }}
               whileTap={{ scale: 0.95 }}
               onClick={on_pass}
-              disabled={!is_my_turn || !can_pass}
+              disabled={!is_my_turn || !can_pass || hand.length === 0}
               style={{
                 ...styles.action_button,
-                backgroundColor: is_my_turn && can_pass ? '#dc3545' : '#444',
+                backgroundColor: is_my_turn && can_pass && hand.length > 0 ? '#dc3545' : '#444',
               }}
             >
               Pass
             </motion.button>
           </div>
 
-          {is_my_turn && (
+          {is_my_turn && hand.length > 0 && (
             <motion.div
               initial={{ opacity: 0 }}
               animate={{ opacity: 1 }}
@@ -127,6 +147,44 @@ export function Game({
               Your turn!
             </motion.div>
           )}
+          {hand.length === 0 && (
+            <motion.div
+              initial={{ opacity: 0 }}
+              animate={{ opacity: 1 }}
+              style={{ ...styles.turn_indicator, backgroundColor: '#28a745' }}
+            >
+              You finished!
+            </motion.div>
+          )}
+          </div>
+        </div>
+
+        <div style={styles.play_log}>
+          <div style={styles.play_log_title}>Play Log</div>
+          {play_log.map((entry, i) => (
+            <motion.div
+              key={i}
+              initial={{ opacity: 0, x: 20 }}
+              animate={{ opacity: 1, x: 0 }}
+              style={styles.play_log_entry}
+            >
+              <span style={{ color: entry.seat % 2 === 0 ? '#2196f3' : '#e91e63', fontWeight: 'bold' }}>
+                {players_map[entry.seat] || `Seat ${entry.seat + 1}`}:
+              </span>{' '}
+              {entry.is_pass ? (
+                <span style={{ color: '#888' }}>Pass</span>
+              ) : (
+                <span>
+                  {entry.cards.map((c, j) => (
+                    <span key={j} style={{ color: c.Suit === Suit_Joker ? (c.Rank === Rank_Red_Joker ? '#dc3545' : '#000') : is_red_suit(c.Suit) ? '#dc3545' : '#000' }}>
+                      {get_rank_symbol(c.Rank)}{get_suit_symbol(c.Suit)}{j < entry.cards.length - 1 ? ' ' : ''}
+                    </span>
+                  ))}
+                  {entry.combo_type && <span style={{ color: '#888', marginLeft: 4 }}>({entry.combo_type})</span>}
+                </span>
+              )}
+            </motion.div>
+          ))}
         </div>
       </div>
     </div>
@@ -136,14 +194,22 @@ export function Game({
 interface Opponent_Hand_Props {
   count: number
   is_turn: boolean
+  just_played?: boolean
   seat: number
   vertical?: boolean
+  name?: string
 }
 
-function Opponent_Hand({ count, is_turn, seat, vertical }: Opponent_Hand_Props) {
+function Opponent_Hand({ count, is_turn, just_played, seat, vertical, name }: Opponent_Hand_Props) {
   const display_count = Math.min(count, 10)
   const overlap = vertical ? 15 : 20
 
+  const get_highlight_style = () => {
+    if (just_played) return { backgroundColor: 'rgba(76, 175, 80, 0.3)', border: '2px solid #4caf50' }
+    if (is_turn) return { backgroundColor: 'rgba(255,193,7,0.2)', border: '2px solid #ffc107' }
+    return { backgroundColor: 'transparent', border: '2px solid transparent' }
+  }
+
   return (
     <div
       style={{
@@ -152,9 +218,9 @@ function Opponent_Hand({ count, is_turn, seat, vertical }: Opponent_Hand_Props)
         alignItems: 'center',
         gap: 8,
         padding: 8,
-        backgroundColor: is_turn ? 'rgba(255,193,7,0.2)' : 'transparent',
         borderRadius: 8,
-        border: is_turn ? '2px solid #ffc107' : '2px solid transparent',
+        transition: 'all 0.2s ease',
+        ...get_highlight_style(),
       }}
     >
       <div
@@ -182,7 +248,7 @@ function Opponent_Hand({ count, is_turn, seat, vertical }: Opponent_Hand_Props)
         ))}
       </div>
       <div style={{ color: '#fff', fontSize: 12 }}>
-        Seat {seat + 1}: {count}
+        {name || `Seat ${seat + 1}`}: {count}
       </div>
     </div>
   )
@@ -279,4 +345,30 @@ const styles: Record<string, React.CSSProperties> = {
     borderRadius: 8,
     fontWeight: 'bold',
   },
+  main_layout: {
+    display: 'flex',
+    flex: 1,
+    overflow: 'hidden',
+  },
+  play_log: {
+    width: 220,
+    backgroundColor: '#16213e',
+    padding: 12,
+    overflowY: 'auto',
+    borderLeft: '2px solid #333',
+  },
+  play_log_title: {
+    color: '#fff',
+    fontSize: 14,
+    fontWeight: 'bold',
+    marginBottom: 12,
+    paddingBottom: 8,
+    borderBottom: '1px solid #333',
+  },
+  play_log_entry: {
+    fontSize: 12,
+    color: '#fff',
+    padding: '6px 0',
+    borderBottom: '1px solid rgba(255,255,255,0.1)',
+  },
 }
diff --git a/client/src/components/Lobby.tsx b/client/src/components/Lobby.tsx
index 6cbf30d..5eeb87f 100644
--- a/client/src/components/Lobby.tsx
+++ b/client/src/components/Lobby.tsx
@@ -7,9 +7,10 @@ interface Lobby_Props {
   players: Player_Info[]
   on_create_room: (name: string) => void
   on_join_room: (room_id: string, name: string) => void
+  on_fill_bots: () => void
 }
 
-export function Lobby({ room_id, players, on_create_room, on_join_room }: Lobby_Props) {
+export function Lobby({ room_id, players, on_create_room, on_join_room, on_fill_bots }: Lobby_Props) {
   const [name, set_name] = useState('')
   const [join_code, set_join_code] = useState('')
   const [mode, set_mode] = useState<'select' | 'create' | 'join'>('select')
@@ -66,6 +67,15 @@ export function Lobby({ room_id, players, on_create_room, on_join_room }: Lobby_
             })}
           </div>
 
+          <motion.button
+            whileHover={{ scale: 1.05 }}
+            whileTap={{ scale: 0.95 }}
+            onClick={on_fill_bots}
+            style={{ ...styles.button, backgroundColor: '#ff9800', marginBottom: 16 }}
+          >
+            Fill with Bots
+          </motion.button>
+
           <p style={styles.hint}>Share room code with friends to join</p>
         </motion.div>
       </div>
diff --git a/client/src/components/Table.tsx b/client/src/components/Table.tsx
index 90684d4..fb73d02 100644
--- a/client/src/components/Table.tsx
+++ b/client/src/components/Table.tsx
@@ -1,4 +1,3 @@
-import { motion, AnimatePresence } from 'framer-motion'
 import { Card as Card_Type, Rank } from '../game/types'
 import { Card } from './Card'
 
@@ -6,11 +5,13 @@ interface Table_Props {
   cards: Card_Type[]
   level: Rank
   combo_type: string
+  last_play_seat?: number | null
 }
 
-export function Table({ cards, level, combo_type }: Table_Props) {
+export function Table({ cards, level, combo_type, last_play_seat }: Table_Props) {
   const card_width = 70
   const overlap = 40
+  const show_highlight = last_play_seat !== null && last_play_seat !== undefined && cards.length > 0
 
   return (
     <div
@@ -21,6 +22,10 @@ export function Table({ cards, level, combo_type }: Table_Props) {
         justifyContent: 'center',
         minHeight: 180,
         padding: 20,
+        borderRadius: 12,
+        transition: 'background-color 0.2s ease, box-shadow 0.2s ease',
+        backgroundColor: show_highlight ? 'rgba(76, 175, 80, 0.15)' : 'transparent',
+        boxShadow: show_highlight ? '0 0 20px rgba(76, 175, 80, 0.4)' : 'none',
       }}
     >
       <div
@@ -32,50 +37,41 @@ export function Table({ cards, level, combo_type }: Table_Props) {
           justifyContent: 'center',
         }}
       >
-        <AnimatePresence mode="wait">
-          {cards.length > 0 ? (
-            cards.map((card, index) => (
-              <motion.div
-                key={`table-${card.Id}`}
-                initial={{ opacity: 0, scale: 0.5, y: 100 }}
-                animate={{ opacity: 1, scale: 1, y: 0 }}
-                exit={{ opacity: 0, scale: 0.5, y: -100 }}
-                transition={{ delay: index * 0.05, type: 'spring', stiffness: 300 }}
-                style={{
-                  position: 'absolute',
-                  left: index * overlap,
-                  zIndex: index,
-                }}
-              >
-                <Card
-                  card={card}
-                  level={level}
-                  selected={false}
-                  on_click={() => {}}
-                />
-              </motion.div>
-            ))
-          ) : (
-            <motion.div
-              initial={{ opacity: 0 }}
-              animate={{ opacity: 0.5 }}
+        {cards.length > 0 ? (
+          cards.map((card, index) => (
+            <div
+              key={`table-${card.Id}`}
               style={{
-                display: 'flex',
-                alignItems: 'center',
-                justifyContent: 'center',
-                color: '#666',
-                fontSize: 14,
+                position: 'absolute',
+                left: index * overlap,
+                zIndex: index,
               }}
             >
-              No cards played
-            </motion.div>
-          )}
-        </AnimatePresence>
+              <Card
+                card={card}
+                level={level}
+                selected={false}
+                on_click={() => {}}
+              />
+            </div>
+          ))
+        ) : (
+          <div
+            style={{
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              color: '#666',
+              fontSize: 14,
+              opacity: 0.5,
+            }}
+          >
+            No cards played
+          </div>
+        )}
       </div>
       {combo_type && (
-        <motion.div
-          initial={{ opacity: 0, y: 10 }}
-          animate={{ opacity: 1, y: 0 }}
+        <div
           style={{
             marginTop: 12,
             padding: '4px 12px',
@@ -87,7 +83,7 @@ export function Table({ cards, level, combo_type }: Table_Props) {
           }}
         >
           {combo_type}
-        </motion.div>
+        </div>
       )}
     </div>
   )
diff --git a/client/src/game/types.ts b/client/src/game/types.ts
index 5265310..c695aa4 100644
--- a/client/src/game/types.ts
+++ b/client/src/game/types.ts
@@ -74,6 +74,7 @@ export type Msg_Type =
   | 'error'
   | 'player_joined'
   | 'player_left'
+  | 'fill_bots'
 
 export interface Message<T = unknown> {
   type: Msg_Type
diff --git a/flake.nix b/flake.nix
index 7b0cdf2..5476c9b 100644
--- a/flake.nix
+++ b/flake.nix
@@ -22,9 +22,19 @@
           pkgs.nodePackages.typescript
           pkgs.nodePackages.typescript-language-server
           pkgs.just
+          pkgs.mprocs
         ];
         shellHook = ''
           export PS1="(guandan-dev) $PS1"
+          echo ""
+          echo "  guandan-dev"
+          echo "  -----------"
+          echo "  just dev      - run server + client"
+          echo "  just server   - run go server only"
+          echo "  just client   - run react client only"
+          echo "  just build    - build for production"
+          echo "  mprocs        - run with TUI"
+          echo ""
         '';
       };
     });
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..834773a
--- /dev/null
+++ b/justfile
@@ -0,0 +1,18 @@
+default:
+    @just --list
+
+dev:
+    just --parallel server client
+
+server:
+    cd server && air
+
+client:
+    cd client && npm run dev
+
+build:
+    cd server && go build -o bin/guandanbtw .
+    cd client && npm run build
+
+install:
+    cd client && npm install
diff --git a/mprocs.log b/mprocs.log
new file mode 100644
index 0000000..542c21d
--- /dev/null
+++ b/mprocs.log
@@ -0,0 +1 @@
+ERROR [lib::error] Error: channel closed
diff --git a/mprocs.yaml b/mprocs.yaml
new file mode 100644
index 0000000..fe1b418
--- /dev/null
+++ b/mprocs.yaml
@@ -0,0 +1,7 @@
+procs:
+  server:
+    cmd: ["air"]
+    cwd: "server"
+  client:
+    cmd: ["npm", "run", "dev"]
+    cwd: "client"
diff --git a/server/game/bomb.go b/server/game/bomb.go
index ef81d42..d81d6f4 100644
--- a/server/game/bomb.go
+++ b/server/game/bomb.go
@@ -38,11 +38,12 @@ func is_four_joker_bomb(cards []Card) bool {
 	black_count := 0
 
 	for _, c := range cards {
-		if c.Rank == Rank_Red_Joker {
+		switch c.Rank {
+		case Rank_Red_Joker:
 			red_count++
-		} else if c.Rank == Rank_Black_Joker {
+		case Rank_Black_Joker:
 			black_count++
-		} else {
+		default:
 			return false
 		}
 	}
diff --git a/server/game/card.go b/server/game/card.go
index 1b5ef27..a854537 100644
--- a/server/game/card.go
+++ b/server/game/card.go
@@ -109,6 +109,10 @@ func card_value(card Card, level Rank) int {
 	return rank_value(card.Rank, level)
 }
 
+func Card_Value(card Card, level Rank) int {
+	return rank_value(card.Rank, level)
+}
+
 func Is_Wild(card Card, level Rank) bool {
 	return card.Suit == Suit_Hearts && card.Rank == level
 }
diff --git a/server/go.mod b/server/go.mod
index 193363d..cee2cdf 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -1,5 +1,3 @@
 module guandanbtw
-
 go 1.23
-
 require github.com/gorilla/websocket v1.5.3
diff --git a/server/protocol/messages.go b/server/protocol/messages.go
index 128d338..a81252b 100644
--- a/server/protocol/messages.go
+++ b/server/protocol/messages.go
@@ -22,6 +22,7 @@ const (
 	Msg_Error         Msg_Type = "error"
 	Msg_Player_Joined Msg_Type = "player_joined"
 	Msg_Player_Left   Msg_Type = "player_left"
+	Msg_Fill_Bots     Msg_Type = "fill_bots"
 )
 
 type Message struct {
diff --git a/server/room/client.go b/server/room/client.go
index d596fe9..69ef5d6 100644
--- a/server/room/client.go
+++ b/server/room/client.go
@@ -17,12 +17,13 @@ const (
 )
 
 type Client struct {
-	id   string
-	name string
-	room *Room
-	conn *websocket.Conn
-	send chan []byte
-	mu   sync.Mutex
+	id     string
+	name   string
+	room   *Room
+	conn   *websocket.Conn
+	send   chan []byte
+	mu     sync.Mutex
+	is_bot bool
 }
 
 func new_client(id string, conn *websocket.Conn) *Client {
@@ -33,6 +34,17 @@ func new_client(id string, conn *websocket.Conn) *Client {
 	}
 }
 
+var bot_names = []string{"Bot Alice", "Bot Bob", "Bot Charlie"}
+
+func new_bot(id string, name string) *Client {
+	return &Client{
+		id:     id,
+		name:   name,
+		send:   make(chan []byte, 256),
+		is_bot: true,
+	}
+}
+
 func (c *Client) read_pump(hub *Hub) {
 	defer func() {
 		if c.room != nil {
@@ -111,7 +123,16 @@ func (c *Client) handle_message(hub *Hub, msg *protocol.Message) {
 		c.handle_pass()
 	case protocol.Msg_Tribute_Give:
 		c.handle_tribute_give(msg)
+	case protocol.Msg_Fill_Bots:
+		c.handle_fill_bots()
+	}
+}
+
+func (c *Client) handle_fill_bots() {
+	if c.room == nil {
+		return
 	}
+	c.room.fill_bots <- c
 }
 
 func (c *Client) handle_create_room(hub *Hub, msg *protocol.Message) {
diff --git a/server/room/room.go b/server/room/room.go
index 69e370a..eca687b 100644
--- a/server/room/room.go
+++ b/server/room/room.go
@@ -1,6 +1,8 @@
 package room
 
 import (
+	"time"
+
 	"guandanbtw/game"
 	"guandanbtw/protocol"
 )
@@ -16,24 +18,28 @@ type Tribute_Action struct {
 }
 
 type Room struct {
-	id      string
-	clients [4]*Client
-	game    *game.Game_State
-	join    chan *Client
-	leave   chan *Client
-	play    chan Play_Action
-	pass    chan *Client
-	tribute chan Tribute_Action
+	id        string
+	clients   [4]*Client
+	game      *game.Game_State
+	join      chan *Client
+	leave     chan *Client
+	play      chan Play_Action
+	pass      chan *Client
+	tribute   chan Tribute_Action
+	fill_bots chan *Client
+	bot_turn  chan int
 }
 
 func new_room(id string) *Room {
 	return &Room{
-		id:      id,
-		join:    make(chan *Client),
-		leave:   make(chan *Client),
-		play:    make(chan Play_Action),
-		pass:    make(chan *Client),
-		tribute: make(chan Tribute_Action),
+		id:        id,
+		join:      make(chan *Client),
+		leave:    make(chan *Client),
+		play:      make(chan Play_Action),
+		pass:      make(chan *Client),
+		tribute:   make(chan Tribute_Action),
+		fill_bots: make(chan *Client),
+		bot_turn:  make(chan int),
 	}
 }
 
@@ -50,6 +56,10 @@ func (r *Room) run() {
 			r.handle_pass(client)
 		case action := <-r.tribute:
 			r.handle_tribute(action)
+		case <-r.fill_bots:
+			r.handle_fill_bots()
+		case seat := <-r.bot_turn:
+			r.handle_bot_turn(seat)
 		}
 	}
 }
@@ -176,9 +186,15 @@ func (r *Room) handle_pass(client *Client) {
 
 	if r.game.Pass_Count >= 3 {
 		r.game.Current_Lead = game.Combination{Type: game.Comb_Invalid}
-		r.game.Current_Turn = r.game.Lead_Player
+		next_leader := r.game.Lead_Player
+		if r.is_finished(next_leader) {
+			teammate := (next_leader + 2) % 4
+			next_leader = teammate
+		}
+		r.game.Current_Turn = next_leader
 		r.game.Pass_Count = 0
 		r.send_turn_notification()
+		r.trigger_bot_turn_if_needed()
 		return
 	}
 
@@ -226,6 +242,7 @@ func (r *Room) handle_tribute(action Tribute_Action) {
 		r.game.Phase = game.Phase_Play
 		r.game.Current_Turn = r.game.Tribute_Leader
 		r.send_turn_notification()
+		r.trigger_bot_turn_if_needed()
 	}
 }
 
@@ -255,6 +272,7 @@ func (r *Room) start_game() {
 	r.game.Phase = game.Phase_Play
 	r.game.Current_Turn = 0
 	r.send_turn_notification()
+	r.trigger_bot_turn_if_needed()
 }
 
 func (r *Room) check_hand_end() bool {
@@ -403,6 +421,7 @@ func (r *Room) start_new_hand() {
 	r.game.Phase = game.Phase_Play
 	r.game.Current_Turn = r.game.Tribute_Leader
 	r.send_turn_notification()
+	r.trigger_bot_turn_if_needed()
 }
 
 func (r *Room) advance_turn() {
@@ -411,6 +430,7 @@ func (r *Room) advance_turn() {
 		if !r.is_finished(next) {
 			r.game.Current_Turn = next
 			r.send_turn_notification()
+			r.trigger_bot_turn_if_needed()
 			return
 		}
 	}
@@ -524,3 +544,168 @@ func seats_to_ids(seats []int, clients [4]*Client) []string {
 	}
 	return ids
 }
+
+func (r *Room) handle_fill_bots() {
+	bot_idx := 0
+	for i := 0; i < 4; i++ {
+		if r.clients[i] == nil && bot_idx < len(bot_names) {
+			bot := new_bot(generate_id(), bot_names[bot_idx])
+			bot.room = r
+			r.clients[i] = bot
+			bot_idx++
+		}
+	}
+
+	r.broadcast_room_state()
+
+	if r.is_full() {
+		r.start_game()
+	}
+}
+
+func (r *Room) handle_bot_turn(seat int) {
+	if r.game == nil || r.game.Current_Turn != seat {
+		return
+	}
+
+	client := r.clients[seat]
+	if client == nil || !client.is_bot {
+		return
+	}
+
+	time.Sleep(1500 * time.Millisecond)
+
+	hand := r.game.Hands[seat]
+	if len(hand) == 0 {
+		return
+	}
+
+	if r.game.Current_Lead.Type != game.Comb_Invalid {
+		lead_team := r.game.Lead_Player % 2
+		bot_team := seat % 2
+		if lead_team == bot_team {
+			r.handle_pass(client)
+			return
+		}
+
+		play := r.find_lowest_valid_play(seat)
+		if play == nil {
+			r.handle_pass(client)
+			return
+		}
+
+		r.handle_play(Play_Action{
+			client:   client,
+			card_ids: play,
+		})
+		return
+	}
+
+	card_ids := []int{hand[0].Id}
+	r.handle_play(Play_Action{
+		client:   client,
+		card_ids: card_ids,
+	})
+}
+
+func (r *Room) find_lowest_valid_play(seat int) []int {
+	hand := r.game.Hands[seat]
+	lead := r.game.Current_Lead
+
+	sorted_hand := make([]game.Card, len(hand))
+	copy(sorted_hand, hand)
+	for i := 0; i < len(sorted_hand)-1; i++ {
+		for j := i + 1; j < len(sorted_hand); j++ {
+			vi := game.Card_Value(sorted_hand[i], r.game.Level)
+			vj := game.Card_Value(sorted_hand[j], r.game.Level)
+			if vi > vj {
+				sorted_hand[i], sorted_hand[j] = sorted_hand[j], sorted_hand[i]
+			}
+		}
+	}
+
+	if lead.Type == game.Comb_Single {
+		for _, card := range sorted_hand {
+			combo := game.Detect_Combination([]game.Card{card}, r.game.Level)
+			if game.Can_Beat(combo, lead) {
+				return []int{card.Id}
+			}
+		}
+	}
+
+	if lead.Type == game.Comb_Pair {
+		rank_cards := make(map[game.Rank][]game.Card)
+		for _, card := range sorted_hand {
+			rank_cards[card.Rank] = append(rank_cards[card.Rank], card)
+		}
+
+		var valid_pairs [][]game.Card
+		for _, cards := range rank_cards {
+			if len(cards) >= 2 {
+				combo := game.Detect_Combination(cards[:2], r.game.Level)
+				if game.Can_Beat(combo, lead) {
+					valid_pairs = append(valid_pairs, cards[:2])
+				}
+			}
+		}
+
+		if len(valid_pairs) > 0 {
+			lowest := valid_pairs[0]
+			lowest_val := game.Card_Value(lowest[0], r.game.Level)
+			for _, pair := range valid_pairs[1:] {
+				val := game.Card_Value(pair[0], r.game.Level)
+				if val < lowest_val {
+					lowest = pair
+					lowest_val = val
+				}
+			}
+			return []int{lowest[0].Id, lowest[1].Id}
+		}
+	}
+
+	if lead.Type == game.Comb_Triple {
+		rank_cards := make(map[game.Rank][]game.Card)
+		for _, card := range sorted_hand {
+			rank_cards[card.Rank] = append(rank_cards[card.Rank], card)
+		}
+
+		var valid_triples [][]game.Card
+		for _, cards := range rank_cards {
+			if len(cards) >= 3 {
+				combo := game.Detect_Combination(cards[:3], r.game.Level)
+				if game.Can_Beat(combo, lead) {
+					valid_triples = append(valid_triples, cards[:3])
+				}
+			}
+		}
+
+		if len(valid_triples) > 0 {
+			lowest := valid_triples[0]
+			lowest_val := game.Card_Value(lowest[0], r.game.Level)
+			for _, triple := range valid_triples[1:] {
+				val := game.Card_Value(triple[0], r.game.Level)
+				if val < lowest_val {
+					lowest = triple
+					lowest_val = val
+				}
+			}
+			return []int{lowest[0].Id, lowest[1].Id, lowest[2].Id}
+		}
+	}
+
+	return nil
+}
+
+func (r *Room) trigger_bot_turn_if_needed() {
+	if r.game == nil {
+		return
+	}
+
+	seat := r.game.Current_Turn
+	client := r.clients[seat]
+	if client != nil && client.is_bot {
+		go func() {
+			r.bot_turn <- seat
+		}()
+	}
+}