Skip to main content

chipzen_bot/
models.rs

1//! Core data models — `Card`, `Action`, `GameState`.
2//!
3//! Field naming follows Rust's snake_case convention. The on-the-wire
4//! JSON the protocol uses is also snake_case; the parsers in this
5//! module bridge the two.
6
7use crate::error::Error;
8use serde_json::Value;
9use std::str::FromStr;
10
11// ---------------------------------------------------------------------------
12// Card
13// ---------------------------------------------------------------------------
14
15/// A standard playing card.
16///
17/// `rank` is one of `2`-`9`, `T`, `J`, `Q`, `K`, `A`. `suit` is one of
18/// `h` (hearts), `d` (diamonds), `c` (clubs), `s` (spades).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub struct Card {
21    pub rank: char,
22    pub suit: char,
23}
24
25const VALID_RANKS: &[char] = &[
26    '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A',
27];
28const VALID_SUITS: &[char] = &['h', 'd', 'c', 's'];
29
30impl Card {
31    pub fn new(rank: char, suit: char) -> Result<Self, Error> {
32        if !VALID_RANKS.contains(&rank) {
33            return Err(Error::Protocol(format!("invalid card rank: {rank:?}")));
34        }
35        if !VALID_SUITS.contains(&suit) {
36            return Err(Error::Protocol(format!("invalid card suit: {suit:?}")));
37        }
38        Ok(Self { rank, suit })
39    }
40}
41
42impl FromStr for Card {
43    type Err = Error;
44
45    fn from_str(s: &str) -> Result<Self, Self::Err> {
46        let mut chars = s.chars();
47        let rank = chars
48            .next()
49            .ok_or_else(|| Error::Protocol(format!("empty card string: {s:?}")))?;
50        let suit = chars
51            .next()
52            .ok_or_else(|| Error::Protocol(format!("card string too short: {s:?}")))?;
53        if chars.next().is_some() {
54            return Err(Error::Protocol(format!(
55                "card string too long: {s:?} (expected 2 chars)"
56            )));
57        }
58        Card::new(rank, suit)
59    }
60}
61
62impl std::fmt::Display for Card {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "{}{}", self.rank, self.suit)
65    }
66}
67
68/// Parse a card from its 2-character wire form (e.g. `"Ah"`). Mirrors
69/// the Python and JavaScript helpers of the same name; equivalent to
70/// `s.parse::<Card>()`.
71pub fn parse_card(s: &str) -> Result<Card, Error> {
72    s.parse()
73}
74
75// ---------------------------------------------------------------------------
76// Action
77// ---------------------------------------------------------------------------
78
79/// The five action kinds a bot can take. Matches the wire `action`
80/// strings byte-for-byte.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub enum ActionKind {
83    Fold,
84    Check,
85    Call,
86    Raise,
87    AllIn,
88}
89
90impl ActionKind {
91    pub fn as_str(&self) -> &'static str {
92        match self {
93            ActionKind::Fold => "fold",
94            ActionKind::Check => "check",
95            ActionKind::Call => "call",
96            ActionKind::Raise => "raise",
97            ActionKind::AllIn => "all_in",
98        }
99    }
100
101    pub fn parse(s: &str) -> Option<Self> {
102        match s {
103            "fold" => Some(ActionKind::Fold),
104            "check" => Some(ActionKind::Check),
105            "call" => Some(ActionKind::Call),
106            "raise" => Some(ActionKind::Raise),
107            "all_in" => Some(ActionKind::AllIn),
108            _ => None,
109        }
110    }
111}
112
113/// The action a bot returns from [`crate::Bot::decide`].
114///
115/// Construct via the variants directly — `Action::Fold`, `Action::Check`,
116/// `Action::Call`, `Action::AllIn`, or `Action::Raise(amount)`. The
117/// raise amount is a `u64` and must be the *target stack-to-pot total*
118/// (the wire field is also called `amount`); the runtime clamps to
119/// `state.min_raise..=state.max_raise` server-side.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum Action {
122    Fold,
123    Check,
124    Call,
125    Raise(u64),
126    AllIn,
127}
128
129impl Action {
130    pub fn kind(&self) -> ActionKind {
131        match self {
132            Action::Fold => ActionKind::Fold,
133            Action::Check => ActionKind::Check,
134            Action::Call => ActionKind::Call,
135            Action::Raise(_) => ActionKind::Raise,
136            Action::AllIn => ActionKind::AllIn,
137        }
138    }
139
140    /// Serialize to the two-layer `turn_action` payload shape the
141    /// server expects. Returns `(action, params)` ready to drop into
142    /// the outbound `turn_action` envelope.
143    pub fn to_wire(&self) -> (&'static str, Value) {
144        let action = self.kind().as_str();
145        let params = match self {
146            Action::Raise(amount) => serde_json::json!({ "amount": *amount }),
147            _ => serde_json::json!({}),
148        };
149        (action, params)
150    }
151}
152
153// ---------------------------------------------------------------------------
154// GameState
155// ---------------------------------------------------------------------------
156
157/// One entry from `state.action_history`. Synthetic blind/ante entries
158/// (`post_small_blind`, `post_big_blind`, `post_ante`) appear here too
159/// — the server generates them; bots do not submit them.
160#[derive(Debug, Clone)]
161pub struct ActionHistoryEntry {
162    pub seat: i64,
163    pub action: String,
164    pub amount: Option<i64>,
165    pub is_timeout: Option<bool>,
166}
167
168/// Built from the server's `turn_request` message. The parser in
169/// [`parse_game_state`] converts the wire-format snake_case to the
170/// owned-string fields below.
171///
172/// Field semantics mirror the Python and JavaScript SDKs exactly so a
173/// bot strategy translates 1:1 between languages.
174#[derive(Debug, Clone)]
175pub struct GameState {
176    pub hand_number: i64,
177    pub phase: String,
178    pub hole_cards: Vec<Card>,
179    pub board: Vec<Card>,
180    pub pot: i64,
181    pub your_stack: i64,
182    pub opponent_stacks: Vec<i64>,
183    pub your_seat: i64,
184    pub dealer_seat: i64,
185    pub to_call: i64,
186    pub min_raise: i64,
187    pub max_raise: i64,
188    pub valid_actions: Vec<String>,
189    pub action_history: Vec<ActionHistoryEntry>,
190    pub round_id: String,
191    pub request_id: String,
192}
193
194/// Parse a `turn_request` envelope into a [`GameState`].
195///
196/// The wire shape is documented in
197/// `docs/protocol/POKER-GAME-STATE-PROTOCOL.md`. All fields default to
198/// safe values when absent — but a real server always sends them.
199pub fn parse_game_state(message: &Value) -> GameState {
200    let state = message.get("state").cloned().unwrap_or(Value::Null);
201    let state_obj = state.as_object();
202
203    let hole_strs = state_obj
204        .and_then(|o| o.get("your_hole_cards"))
205        .and_then(|v| v.as_array());
206    let board_strs = state_obj
207        .and_then(|o| o.get("board"))
208        .and_then(|v| v.as_array());
209
210    let valid_actions = message
211        .get("valid_actions")
212        .and_then(|v| v.as_array())
213        .or_else(|| {
214            state_obj
215                .and_then(|o| o.get("valid_actions"))
216                .and_then(|v| v.as_array())
217        })
218        .map(|arr| {
219            arr.iter()
220                .filter_map(|v| v.as_str().map(String::from))
221                .collect()
222        })
223        .unwrap_or_default();
224
225    let action_history = state_obj
226        .and_then(|o| o.get("action_history"))
227        .and_then(|v| v.as_array())
228        .map(|arr| arr.iter().map(parse_history_entry).collect())
229        .unwrap_or_default();
230
231    GameState {
232        hand_number: state_obj
233            .and_then(|o| o.get("hand_number"))
234            .and_then(Value::as_i64)
235            .unwrap_or(0),
236        phase: state_obj
237            .and_then(|o| o.get("phase"))
238            .and_then(|v| v.as_str())
239            .unwrap_or("preflop")
240            .to_string(),
241        hole_cards: hole_strs
242            .map(|arr| {
243                arr.iter()
244                    .filter_map(|v| v.as_str())
245                    .filter_map(|s| s.parse::<Card>().ok())
246                    .collect()
247            })
248            .unwrap_or_default(),
249        board: board_strs
250            .map(|arr| {
251                arr.iter()
252                    .filter_map(|v| v.as_str())
253                    .filter_map(|s| s.parse::<Card>().ok())
254                    .collect()
255            })
256            .unwrap_or_default(),
257        pot: state_obj
258            .and_then(|o| o.get("pot"))
259            .and_then(Value::as_i64)
260            .unwrap_or(0),
261        your_stack: state_obj
262            .and_then(|o| o.get("your_stack"))
263            .and_then(Value::as_i64)
264            .unwrap_or(0),
265        opponent_stacks: state_obj
266            .and_then(|o| o.get("opponent_stacks"))
267            .and_then(|v| v.as_array())
268            .map(|arr| arr.iter().filter_map(Value::as_i64).collect())
269            .unwrap_or_default(),
270        your_seat: state_obj
271            .and_then(|o| o.get("your_seat"))
272            .and_then(Value::as_i64)
273            .unwrap_or(0),
274        dealer_seat: state_obj
275            .and_then(|o| o.get("dealer_seat"))
276            .and_then(Value::as_i64)
277            .unwrap_or(0),
278        to_call: state_obj
279            .and_then(|o| o.get("to_call"))
280            .and_then(Value::as_i64)
281            .unwrap_or(0),
282        min_raise: state_obj
283            .and_then(|o| o.get("min_raise"))
284            .and_then(Value::as_i64)
285            .unwrap_or(0),
286        max_raise: state_obj
287            .and_then(|o| o.get("max_raise"))
288            .and_then(Value::as_i64)
289            .unwrap_or(0),
290        valid_actions,
291        action_history,
292        round_id: message
293            .get("round_id")
294            .and_then(|v| v.as_str())
295            .unwrap_or("")
296            .to_string(),
297        request_id: message
298            .get("request_id")
299            .and_then(|v| v.as_str())
300            .unwrap_or("")
301            .to_string(),
302    }
303}
304
305fn parse_history_entry(raw: &Value) -> ActionHistoryEntry {
306    ActionHistoryEntry {
307        seat: raw.get("seat").and_then(Value::as_i64).unwrap_or(0),
308        action: raw
309            .get("action")
310            .and_then(|v| v.as_str())
311            .unwrap_or("")
312            .to_string(),
313        amount: raw.get("amount").and_then(Value::as_i64),
314        is_timeout: raw.get("is_timeout").and_then(Value::as_bool),
315    }
316}