Skip to main content

rbp_gameplay/
edge.rs

1use super::*;
2use rbp_cards::Street;
3use rbp_core::*;
4use std::hash::Hash;
5
6/// An abstracted game tree transition.
7///
8/// Unlike [`Action`] which carries exact chip amounts, `Edge` abstracts
9/// betting actions for strategy lookup across different stack depths.
10///
11/// # Variants
12///
13/// - `Draw` — Chance node (card deal)
14/// - `Fold`, `Check`, `Call` — Standard player decisions
15/// - `Open(Chips)` — Preflop open in BB units (e.g., 2BB, 3BB)
16/// - `Raise(Odds)` — Pot-relative raise (e.g., 1/2 pot, 2x pot)
17/// - `Shove` — All-in bet
18#[derive(Debug, Clone, Copy, Hash, Ord, PartialOrd, PartialEq, Eq)]
19pub enum Edge {
20    Draw,
21    Fold,
22    Check,
23    Call,
24    Open(Chips),
25    Raise(Odds),
26    Shove,
27}
28
29
30impl Edge {
31    /// True if this is an all-in bet.
32    pub fn is_shove(&self) -> bool {
33        matches!(self, Edge::Shove)
34    }
35    /// True if this is a raise (including opens).
36    pub fn is_raise(&self) -> bool {
37        matches!(self, Edge::Raise(_) | Edge::Open(_))
38    }
39    /// True if this is a fold.
40    pub fn is_folded(&self) -> bool {
41        matches!(self, Edge::Fold)
42    }
43    /// True if this is a chance node (card deal).
44    pub fn is_chance(&self) -> bool {
45        matches!(self, Edge::Draw)
46    }
47    /// True if this is aggressive (raise, open, or shove).
48    pub fn is_aggro(&self) -> bool {
49        self.is_raise() || self.is_shove()
50    }
51    /// True if this is a player decision.
52    pub fn is_choice(&self) -> bool {
53        !self.is_chance()
54    }
55}
56
57impl Edge {
58    /// Initial regret bounds for CFR warmstart.
59    ///
60    /// Returns (min, max) regret to bias exploration toward certain actions.
61    pub fn regret(&self) -> (Utility, Utility) {
62        match self {
63            Edge::Open(_) => (Utility::default(), BIAS_RAISE),
64            Edge::Raise(_) => (Utility::default(), BIAS_RAISE),
65            Edge::Check => (Utility::default(), BIAS_OTHER),
66            Edge::Shove => (Utility::default(), BIAS_RAISE),
67            Edge::Call => (Utility::default(), BIAS_OTHER),
68            Edge::Fold => (Utility::default(), BIAS_FOLDS),
69            Edge::Draw => panic!("chance edges have no learned regret"),
70        }
71    }
72    /// Initial policy bounds (currently unused).
73    pub fn policy(&self) -> (Probability, Probability) {
74        (Probability::default(), Probability::default())
75    }
76}
77
78impl From<Action> for Edge {
79    fn from(action: Action) -> Self {
80        match action {
81            Action::Fold => Edge::Fold,
82            Action::Check => Edge::Check,
83            Action::Call(_) => Edge::Call,
84            Action::Draw(_) => Edge::Draw,
85            Action::Shove(_) => Edge::Shove,
86            Action::Raise(_) => panic!("raise must be converted via Game::edgify"),
87            Action::Blind(_) => panic!("blinds are not in any MCCFR trees"),
88        }
89    }
90}
91
92impl From<Odds> for Edge {
93    fn from(odds: Odds) -> Self {
94        Edge::Raise(odds)
95    }
96}
97
98/// Preflop open sizes in BB units.
99const OPENS_GRID: [Chips; 4] = [2, 3, 4, 8];
100/// Pot-relative raise sizes as Odds (must fit in u8 10-15, Path uses 4-bit encoding).
101const RAISE_GRID: [Odds; 6] = [
102    Odds::new(1, 3), // 0.33 pot
103    Odds::new(1, 2), // 0.50 pot
104    Odds::new(2, 3), // 0.66 pot
105    Odds::new(1, 1), // 1.00 pot
106    Odds::new(3, 2), // 1.50 pot
107    Odds::new(2, 1), // 2.00 pot
108];
109
110impl Edge {
111    /// Returns available raise/open edges for a given street and depth.
112    /// This is the **single source of truth** for which betting edges exist.
113    pub fn raises(street: Street, depth: usize) -> Vec<Self> {
114        if depth > MAX_RAISE_REPEATS {
115            return vec![];
116        }
117        match (street, depth) {
118            // Preflop depth=0: BB opens
119            (Street::Pref, 0) => OPENS_GRID.iter().map(|&n| Edge::Open(n)).collect(),
120            // Preflop depth>=1: pot-relative 3-bets/4-bets (1x, 1.5x, 2x pot)
121            (Street::Pref, 1) => vec![
122                Edge::Raise(Odds::new(1, 1)),
123                Edge::Raise(Odds::new(3, 2)),
124                Edge::Raise(Odds::new(2, 1)),
125            ],
126            (Street::Pref, _) => vec![Edge::Raise(Odds::new(1, 1)), Edge::Raise(Odds::new(2, 1))],
127            // Flop (1/3 probe bet instead of 1/4 due to 4-bit Path encoding limit)
128            (Street::Flop, 0) => vec![
129                Edge::Raise(Odds::new(1, 3)),
130                Edge::Raise(Odds::new(1, 2)),
131                Edge::Raise(Odds::new(1, 1)),
132                Edge::Raise(Odds::new(2, 1)),
133            ],
134            (Street::Flop, 1) => vec![
135                Edge::Raise(Odds::new(2, 3)),
136                Edge::Raise(Odds::new(1, 1)),
137                Edge::Raise(Odds::new(3, 2)),
138            ],
139            (Street::Flop, _) => vec![Edge::Raise(Odds::new(1, 1)), Edge::Raise(Odds::new(3, 2))],
140            // Turn
141            (Street::Turn, 0) => vec![
142                Edge::Raise(Odds::new(1, 3)),
143                Edge::Raise(Odds::new(2, 3)),
144                Edge::Raise(Odds::new(1, 1)),
145                Edge::Raise(Odds::new(2, 1)),
146            ],
147            (Street::Turn, _) => vec![Edge::Raise(Odds::new(1, 1)), Edge::Raise(Odds::new(3, 2))],
148            // River
149            (Street::Rive, 0) => vec![
150                Edge::Raise(Odds::new(1, 3)),
151                Edge::Raise(Odds::new(1, 2)),
152                Edge::Raise(Odds::new(1, 1)),
153                Edge::Raise(Odds::new(2, 1)),
154            ],
155            (Street::Rive, 1) => vec![
156                Edge::Raise(Odds::new(2, 3)),
157                Edge::Raise(Odds::new(1, 1)),
158                Edge::Raise(Odds::new(2, 1)),
159            ],
160            (Street::Rive, _) => vec![Edge::Raise(Odds::new(1, 1))],
161        }
162    }
163    /// Converts edge to chip amount given pot size.
164    pub fn into_chips(self, pot: Chips) -> Chips {
165        match self {
166            Edge::Open(n) => n * B_BLIND,
167            Edge::Raise(odds) => (pot as Utility * Probability::from(odds)) as Chips,
168            _ => 0,
169        }
170    }
171}
172
173/// u8 bijection
174/// Layout: 1-5 = basic edges, 6-9 = Open(OPENS_GRID), 10-15 = Raise(RAISE_GRID)
175impl From<Edge> for u8 {
176    fn from(edge: Edge) -> Self {
177        match edge {
178            Edge::Draw => 1,
179            Edge::Fold => 2,
180            Edge::Check => 3,
181            Edge::Call => 4,
182            Edge::Shove => 5,
183            Edge::Open(n) => {
184                6 + OPENS_GRID
185                    .iter()
186                    .position(|&b| b == n)
187                    .expect("invalid open size") as u8
188            }
189            Edge::Raise(odds) => {
190                10 + RAISE_GRID
191                    .iter()
192                    .position(|&o| o == odds)
193                    .expect("invalid raise odds") as u8
194            }
195        }
196    }
197}
198impl From<u8> for Edge {
199    fn from(value: u8) -> Self {
200        match value {
201            1 => Edge::Draw,
202            2 => Edge::Fold,
203            3 => Edge::Check,
204            4 => Edge::Call,
205            5 => Edge::Shove,
206            6..=9 => Edge::Open(OPENS_GRID[value as usize - 6]),
207            10..=15 => Edge::Raise(RAISE_GRID[value as usize - 10]),
208            _ => unreachable!("invalid edge encoding: {}", value),
209        }
210    }
211}
212
213/// u64 bijection with backwards compatibility for old Size encoding.
214/// Old format: tag 4 with bit 19 set = BBs → decoded as Open
215/// New format: tag 6 = Open, tag 4 = Raise(Odds)
216impl From<u64> for Edge {
217    fn from(value: u64) -> Self {
218        match value & 0b111 {
219            0 => Self::Draw,
220            1 => Self::Fold,
221            2 => Self::Check,
222            3 => Self::Call,
223            4 => {
224                // Check for old BBs encoding (bit 19 set)
225                if value & (1 << 19) != 0 {
226                    // Old format: Raise(BBs(n)) → convert to Open(n)
227                    Self::Open(((value >> 3) & 0xFF) as Chips)
228                } else {
229                    // SPR format: Raise(Odds(n, d))
230                    Self::Raise(Odds::new(
231                        ((value >> 3) & 0xFF) as Chips,
232                        ((value >> 11) & 0xFF) as Chips,
233                    ))
234                }
235            }
236            5 => Self::Shove,
237            6 => Self::Open(((value >> 3) & 0xFF) as Chips),
238            _ => unreachable!("invalid edge encoding"),
239        }
240    }
241}
242impl From<Edge> for u64 {
243    fn from(edge: Edge) -> Self {
244        match edge {
245            Edge::Draw => 0,
246            Edge::Fold => 1,
247            Edge::Check => 2,
248            Edge::Call => 3,
249            Edge::Raise(odds) => 4 | ((odds.numer() as u64) << 3) | ((odds.denom() as u64) << 11),
250            Edge::Shove => 5,
251            Edge::Open(n) => 6 | ((n as u64) << 3),
252        }
253    }
254}
255
256impl TryFrom<&str> for Edge {
257    type Error = anyhow::Error;
258    fn try_from(s: &str) -> Result<Self, Self::Error> {
259        match s {
260            "?" => Ok(Edge::Draw),
261            "F" => Ok(Edge::Fold),
262            "*" => Ok(Edge::Call),
263            "O" => Ok(Edge::Check),
264            "!" => Ok(Edge::Shove),
265            s if s.ends_with("bb") => {
266                let n = s
267                    .strip_suffix("bb")
268                    .and_then(|x| x.parse::<Chips>().ok())
269                    .ok_or_else(|| anyhow::anyhow!("invalid bb format"))?;
270                Ok(Edge::Open(n))
271            }
272            s if s.contains(':') => {
273                let (n, d) = s
274                    .split_once(':')
275                    .ok_or_else(|| anyhow::anyhow!("invalid ratio format"))?;
276                let n = n.parse::<Chips>()?;
277                let d = d.parse::<Chips>()?;
278                Ok(Edge::Raise(Odds::new(n, d)))
279            }
280            _ => Err(anyhow::anyhow!("invalid edge format: {}", s)),
281        }
282    }
283}
284
285impl std::fmt::Display for Edge {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        match self {
288            Edge::Draw => write!(f, "?"),
289            Edge::Fold => write!(f, "F"),
290            Edge::Call => write!(f, "*"),
291            Edge::Check => write!(f, "O"),
292            Edge::Shove => write!(f, "!"),
293            Edge::Open(n) => write!(f, "{}bb", n),
294            Edge::Raise(odds) => write!(f, "{}:{}", odds.numer(), odds.denom()),
295        }
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use rbp_cards::Street;
303    #[test]
304    fn bijective_u8() {
305        let edges = vec![Edge::Draw, Edge::Fold, Edge::Check, Edge::Call, Edge::Shove];
306        let opens = OPENS_GRID.iter().map(|&n| Edge::Open(n));
307        let raises = RAISE_GRID.iter().map(|&o| Edge::Raise(o));
308        for edge in edges.into_iter().chain(opens).chain(raises) {
309            assert_eq!(
310                edge,
311                Edge::from(u8::from(edge)),
312                "u8 roundtrip failed for {:?}",
313                edge
314            );
315        }
316    }
317    #[test]
318    fn bijective_u64() {
319        let edges = vec![Edge::Draw, Edge::Fold, Edge::Check, Edge::Call, Edge::Shove];
320        let opens = OPENS_GRID.iter().map(|&n| Edge::Open(n));
321        let raises = RAISE_GRID.iter().map(|&o| Edge::Raise(o));
322        for edge in edges.into_iter().chain(opens).chain(raises) {
323            assert_eq!(
324                edge,
325                Edge::from(u64::from(edge)),
326                "u64 roundtrip failed for {:?}",
327                edge
328            );
329        }
330    }
331    #[test]
332    fn string_roundtrip() {
333        let edges = vec![
334            Edge::Draw,
335            Edge::Fold,
336            Edge::Check,
337            Edge::Call,
338            Edge::Shove,
339            Edge::Open(2),
340            Edge::Open(3),
341            Edge::Open(8),
342            Edge::Raise(Odds::new(1, 2)),
343            Edge::Raise(Odds::new(1, 1)),
344            Edge::Raise(Odds::new(3, 2)),
345            Edge::Raise(Odds::new(2, 1)),
346        ];
347        for edge in edges {
348            let s = edge.to_string();
349            let parsed = Edge::try_from(s.as_str()).unwrap();
350            assert_eq!(edge, parsed, "string roundtrip failed for {:?}", edge);
351        }
352    }
353    #[test]
354    fn backwards_compat_u64_bbs() {
355        // Old encoding: Edge::Raise(Size::BBs(8)) = 4 | (1 << 19) | (8 << 3)
356        let old_bbs_8 = 4u64 | (1 << 19) | (8 << 3);
357        assert_eq!(Edge::from(old_bbs_8), Edge::Open(8));
358        let old_bbs_2 = 4u64 | (1 << 19) | (2 << 3);
359        assert_eq!(Edge::from(old_bbs_2), Edge::Open(2));
360    }
361    #[test]
362    fn raises_preflop_depth0_returns_opens() {
363        let edges = Edge::raises(Street::Pref, 0);
364        assert!(edges.iter().all(|e| matches!(e, Edge::Open(_))));
365        assert_eq!(edges.len(), 4);
366    }
367    #[test]
368    fn raises_postflop_returns_raises() {
369        for street in [Street::Flop, Street::Turn, Street::Rive] {
370            for depth in 0..=2 {
371                let edges = Edge::raises(street, depth);
372                assert!(edges.iter().all(|e| matches!(e, Edge::Raise(_))));
373            }
374        }
375    }
376}
377
378impl Arbitrary for Edge {
379    fn random() -> Self {
380        use rand::prelude::IndexedRandom;
381        match rand::random_range(0..7) {
382            0 => Self::Draw,
383            1 => Self::Fold,
384            2 => Self::Check,
385            3 => Self::Call,
386            4 => Self::Shove,
387            5 => Self::Open(*OPENS_GRID.choose(&mut rand::rng()).unwrap()),
388            6 => Self::Raise(*RAISE_GRID.choose(&mut rand::rng()).unwrap()),
389            _ => unreachable!(),
390        }
391    }
392}
393