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.
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
+ }()
+ }
+}