Skip to main content

rbp_gameplay/
partial.rs

1use super::*;
2use rbp_cards::*;
3use rbp_core::*;
4use std::ops::Not;
5
6/// Perfect-recall game history from a **single player's** perspective.
7///
8/// While [`Game`] is memoryless, `Partial` tracks the complete action sequence
9/// from the start of a hand. This is the primary type for **inference time**:
10/// hero knows their own cards but not the opponent's.
11///
12/// # Information Boundary
13///
14/// | Type | Perspective | Used For |
15/// |------|-------------|----------|
16/// | `Partial` | Hero only (own cards) | Inference, UI, opponent iteration |
17/// | `Perfect` | God's view (both hands) | Training CFR traversal |
18///
19/// # Key Operations
20///
21/// - `NlheInfo::from((&partial, abstraction))` for strategy lookup
22/// - `Perfect::from((&partial, hole))` for opponent modeling
23/// - `partial.histories()` → iterate all possible opponent hands
24///
25/// # Structure
26///
27/// - `pov` — Which player's perspective we're tracking
28/// - `actions` — Action sequence excluding blinds (bets, draws)
29/// - `reveals` — The card arrangement for this hand (hero's observation)
30///
31/// # Invariants
32///
33/// Assumes default stacks (100bb) and P0 on button. Blinds are constant
34/// and handled by `root()` returning a POST-blind state.
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct Partial {
37    pov: Turn,
38    actions: Vec<Action>,
39    reveals: Arrangement,
40}
41
42impl Arbitrary for Partial {
43    fn random() -> Self {
44        Self::initial(Turn::Choice(0))
45    }
46}
47
48impl Partial {
49    /// Creates a recall at the start of a hand (blinds posted, no decisions).
50    pub fn initial(pov: Turn) -> Self {
51        Self {
52            pov,
53            actions: Vec::new(),
54            reveals: Arrangement::from(Street::Pref),
55        }
56    }
57    /// Returns a new recall with the given perspective.
58    pub fn with_pov(&self, pov: Turn) -> Self {
59        Self {
60            pov,
61            actions: self.actions.clone(),
62            reveals: self.reveals.clone(),
63        }
64    }
65}
66
67impl Recall for Partial {
68    fn root(&self) -> Game {
69        Game::blinds()
70            .into_iter()
71            .fold(self.base(), |mut g, a| g.consume(a))
72    }
73    fn actions(&self) -> &[Action] {
74        &self.actions
75    }
76}
77
78/// Strategy lookup methods.
79impl Partial {
80    /// Returns all betting edges (Open or Raise) available at the current state.
81    pub fn betting_edges(&self) -> Vec<Edge> {
82        let game = self.head();
83        self.choices()
84            .into_iter()
85            .filter(|edge| matches!(edge, Edge::Open(_) | Edge::Raise(_)))
86            .filter(|edge| matches!(game.actionize(*edge), Action::Raise(_)))
87            .collect()
88    }
89
90    /// Iterates over all possible opponent hands.
91    ///
92    /// For each opponent observation, yields a complete-information
93    /// [`Perfect`] that can compute exact reach probabilities.
94    /// Since `Partial` has partial information (only hero's cards),
95    /// this method enumerates the unknown opponent hands.
96    pub fn histories(&self) -> Vec<(Observation, Perfect)> {
97        self.seen()
98            .opponents()
99            .map(|villain| {
100                let hole = Hole::from(villain.pocket().clone());
101                (villain, Perfect::from((self, hole)))
102            })
103            .collect()
104    }
105}
106
107/// Constructs recall from a POV and arrangement (no decisions yet).
108impl From<(Turn, Arrangement)> for Partial {
109    fn from((pov, reveals): (Turn, Arrangement)) -> Self {
110        let actions = Vec::new();
111        Self {
112            pov,
113            actions,
114            reveals,
115        }
116    }
117}
118
119/// random non-folding actions lead to this street
120impl From<Street> for Partial {
121    fn from(_: Street) -> Self {
122        todo!()
123    }
124}
125
126impl From<(Turn, Observation, Vec<Action>)> for Partial {
127    fn from((pov, seen, actions): (Turn, Observation, Vec<Action>)) -> Self {
128        Self::try_build(pov, seen, actions).expect("valid action sequence")
129    }
130}
131
132impl Partial {
133    /// Fallible constructor from (POV, observation, actions).
134    ///
135    /// Returns `Err` if any action in the sequence is illegal,
136    /// enabling graceful error handling for untrusted input.
137    /// The `actions` parameter should NOT include blinds.
138    pub fn try_build(pov: Turn, seen: Observation, actions: Vec<Action>) -> anyhow::Result<Self> {
139        let reveals = Arrangement::from(seen);
140        let initial = Self {
141            pov,
142            actions: Vec::new(),
143            reveals,
144        };
145        actions.into_iter().try_fold(initial, |r, a| r.try_push(a))
146    }
147}
148
149/// State reconstruction methods.
150impl Partial {
151    /// Returns the initial game state (before blinds, with hero's hole cards).
152    pub fn base(&self) -> Game {
153        // @const-stacks
154        // @const-dealer
155        Game::default().wipe(Hole::from(self.seen()))
156    }
157    /// The current betting street.
158    pub fn street(&self) -> Street {
159        self.head().street()
160    }
161    /// The street based on Draw actions in the action sequence.
162    pub fn dealt(&self) -> Street {
163        Street::from(self.actions.iter().filter(|a| a.is_chance()).count() as isize)
164    }
165    /// The player perspective for this recall.
166    pub fn turn(&self) -> Turn {
167        self.pov
168    }
169    /// The card arrangement for this recall.
170    pub fn arr(&self) -> Arrangement {
171        self.reveals.clone()
172    }
173    /// The observation (hole cards + board) for this recall.
174    pub fn seen(&self) -> Observation {
175        self.reveals.observation()
176    }
177    /// Resets to initial state (no decisions), preserving POV and cards.
178    pub fn reset(&self) -> Self {
179        Self {
180            pov: self.turn(),
181            reveals: self.reveals.clone(),
182            actions: Vec::new(),
183        }
184    }
185    /// Node index for graph traversal.
186    pub fn cursor(&self) -> petgraph::graph::NodeIndex {
187        petgraph::graph::NodeIndex::new(self.actions().len().saturating_sub(1))
188    }
189    /// Returns (position, action, street) for each action in the sequence.
190    pub fn plays(&self) -> Vec<(Position, Action, Street)> {
191        self.states()
192            .windows(2)
193            .zip(self.actions().iter().cloned())
194            .filter_map(|(pair, action)| {
195                action
196                    .is_choice()
197                    .then(|| (pair[0].turn().position(), action, pair[0].street()))
198            })
199            .collect()
200    }
201    /// Finds the last aggressor on the final betting street.
202    /// Returns None if no aggressive action was taken (all checks/calls).
203    pub fn aggressor(&self) -> Option<Position> {
204        self.plays()
205            .into_iter()
206            .filter_map(|(pos, action, _)| action.is_aggro().then_some(pos))
207            .last()
208    }
209    /// Truncates actions to a specific street.
210    pub fn truncate(&self, street: Street) -> Self {
211        let pov = self.turn();
212        let reveals = self.reveals.clone();
213        let actions = self
214            .states()
215            .into_iter()
216            .skip(1)
217            .zip(self.actions().iter().cloned())
218            .map(|(game, action)| (action, game))
219            .collect::<Vec<(Action, Game)>>()
220            .into_iter()
221            .take_while(|(_, game)| game.street() <= street)
222            .map(|(action, _)| action)
223            .collect::<Vec<Action>>();
224        let recall = Self {
225            pov,
226            reveals,
227            actions,
228        };
229        recall.sprout()
230    }
231
232    /// Swaps the card arrangement, updating draw actions to match.
233    pub fn replace(&self, reveals: Arrangement) -> Self {
234        let mut actions = self.actions().to_vec();
235        actions
236            .iter_mut()
237            .filter(|a| a.is_chance())
238            .zip(reveals.draws())
239            .for_each(|(old, new)| *old = new);
240        Self {
241            pov: self.turn(),
242            actions,
243            reveals,
244        }
245    }
246
247    /// Player decisions (non-draw) for a specific street.
248    pub fn decisions(&self, street: Street) -> Vec<Action> {
249        let mut actions = Vec::new();
250        let mut current = Street::Pref;
251        for action in self.actions().iter().cloned() {
252            if action.is_chance() {
253                current = current.next();
254            } else if current == street {
255                actions.push(action);
256            }
257        }
258        actions
259    }
260
261    /// Community cards dealt so far (in deal order).
262    pub fn board(&self) -> Vec<Card> {
263        let street = self.head().street();
264        Street::all()
265            .iter()
266            .skip(1)
267            .filter(|s| **s <= street)
268            .cloned()
269            .flat_map(|s| self.revealed(s))
270            .collect()
271    }
272
273    /// Cards revealed on a specific street.
274    pub fn revealed(&self, street: Street) -> Vec<Card> {
275        self.reveals.revealed(street)
276    }
277    /// The canonical form of the observation.
278    pub fn isomorphism(&self) -> Isomorphism {
279        Isomorphism::from(self.seen())
280    }
281
282    /// True if no decisions have been made.
283    pub fn empty(&self) -> bool {
284        self.actions().is_empty()
285    }
286
287    /// True if observation's public cards match the dealt draw actions.
288    pub fn aligned(&self) -> bool {
289        self.seen().public().clone()
290            == self
291                .actions()
292                .iter()
293                .filter(|a| a.is_chance())
294                .filter_map(|a| a.hand())
295                .fold(Hand::empty(), Hand::add)
296    }
297}
298
299/// Action modification methods.
300impl Partial {
301    /// Removes the most recent action and any trailing draws.
302    pub fn undo(&self) -> Self {
303        debug_assert!(self.can_undo());
304        let mut copy = self.clone();
305        copy.actions.pop();
306        copy.recoil()
307    }
308    /// Adds an action, auto-inserting draw actions when needed.
309    pub fn push(&self, action: Action) -> Self {
310        self.try_push(action).expect("valid action")
311    }
312    /// Fallible version of [`push`](Self::push).
313    ///
314    /// Returns `Err` if the action is not legal in the current state,
315    /// enabling graceful error handling instead of panicking.
316    pub fn try_push(&self, action: Action) -> anyhow::Result<Self> {
317        if !self.can_push(&action) {
318            return Err(anyhow::anyhow!(
319                "illegal action {:?} at {:?}",
320                action,
321                self.head().turn()
322            ));
323        }
324        let mut copy = self.clone();
325        copy.actions.push(action);
326        Ok(copy.sprout())
327    }
328}
329
330/// Validation.
331impl Partial {
332    /// Validates alignment and playability, returning error if invalid.
333    pub fn validate(self) -> anyhow::Result<Self> {
334        let recall = self.sprout();
335        if !recall.aligned() {
336            return Err(anyhow::anyhow!("recall is not aligned {}", self));
337        }
338        if !recall.can_play() {
339            return Err(anyhow::anyhow!("recall is not playable {}", self));
340        }
341        Ok(recall)
342    }
343}
344
345/// Auto-advancement to non-chance states.
346impl Partial {
347    /// Advances by inserting draw actions until at a decision point.
348    fn sprout(&self) -> Self {
349        let mut copy = self.clone();
350        while copy.can_deal() {
351            let street = copy.head().street().next();
352            let reveal = copy.revealed(street).into();
353            copy.actions.push(Action::Draw(reveal));
354        }
355        copy
356    }
357
358    /// Retreats by removing draw actions until at a decision point.
359    fn recoil(&self) -> Self {
360        let mut copy = self.clone();
361        while copy.can_deal() {
362            copy.actions.pop();
363        }
364        copy
365    }
366}
367
368/// State predicates.
369impl Partial {
370    /// True if it's hero's turn and observation is current.
371    pub fn can_play(&self) -> bool {
372        self.head().turn() == self.turn() //               is it our turn right now?
373            && self.head().street() == self.seen().street() //    have we exhausted info from Obs?
374    }
375
376    /// True if the action is legal in the current state.
377    pub fn can_push(&self, action: &Action) -> bool {
378        self.head().is_allowed(action)
379    }
380
381    /// True if there are actions to undo.
382    pub fn can_undo(&self) -> bool {
383        !self.actions.is_empty()
384    }
385
386    /// True if a draw action should be auto-inserted.
387    fn can_deal(&self) -> bool {
388        self.can_know() && self.head().turn() == Turn::Chance
389    }
390
391    /// True if observation reveals more cards than current state.
392    fn can_know(&self) -> bool {
393        self.head().street() < self.seen().street()
394    }
395}
396
397/// Display shows a compact visual representation of the game history
398/// Format: table with cards from arrangement (preserving deal order)
399/// and actions in a fixed-width grid layout
400impl std::fmt::Display for Partial {
401    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        const L: usize = 4;
403        const R: usize = 44;
404        const A: usize = 8;
405        let hole = self
406            .reveals
407            .pocket()
408            .iter()
409            .map(|c| format!("{}", c))
410            .collect::<Vec<_>>()
411            .join(" ");
412        let board = self
413            .board()
414            .iter()
415            .map(|c| format!("{}", c))
416            .collect::<Vec<_>>()
417            .join(" ");
418        let cards = if board.is_empty() {
419            format!("{}", hole)
420        } else {
421            format!("{} │ {}", hole, board)
422        };
423        writeln!(f, "┌{}┬{}┐", "─".repeat(L), "─".repeat(R))?;
424        writeln!(
425            f,
426            "│ {:>2} │ {:<w$} │",
427            self.turn().label(),
428            cards,
429            w = R - 2
430        )?;
431        writeln!(f, "├{}┼{}┤", "─".repeat(L), "─".repeat(R))?;
432        Street::all()
433            .iter()
434            .filter_map(|street| {
435                let actions = self.decisions(*street);
436                actions.is_empty().not().then_some((street, actions))
437            })
438            .try_for_each(|(street, actions)| {
439                let grid = actions
440                    .iter()
441                    .map(|a| format!("{:<w$}", a.symbol(), w = A))
442                    .collect::<String>();
443                writeln!(f, "│ {:>2} │ {:<w$} │", street.symbol(), grid, w = R - 2)
444            })?;
445        write!(f, "└{}┴{}┘", "─".repeat(L), "─".repeat(R))
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use std::ops::Not;
453
454    /// initial recall: aligned, at preflop, empty (no decisions yet), reset is identity
455    #[test]
456    fn initial_invariants() {
457        let r = Partial::initial(Turn::Choice(0));
458        assert!(r.empty());
459        assert!(r.aligned());
460        assert_eq!(r.reset(), r);
461        assert_eq!(r.seen().street(), Street::Pref);
462        assert_eq!(r.head().street(), Street::Pref);
463        assert_eq!(r.actions().len(), 0);
464    }
465
466    /// reset preserves pov and reveals, clears decisions back to just blinds
467    /// reset is idempotent: reset(reset(x)) == reset(x)
468    #[test]
469    fn reset_idempotent() {
470        let r = Partial::initial(Turn::Choice(0))
471            .push(Action::Call(1))
472            .push(Action::Raise(5))
473            .push(Action::Raise(20))
474            .push(Action::Call(15));
475        assert_eq!(r.reset(), r.reset().reset());
476    }
477
478    /// push then undo returns to original path length
479    #[test]
480    fn push_undo_inverse() {
481        let r = Partial::initial(Turn::Choice(0));
482        let a = r.head().legal().first().cloned().expect("legal");
483        assert_eq!(r.push(a).undo().subgame().length(), r.subgame().length());
484    }
485
486    /// base() returns Game::default with hero's hole cards; no blinds posted yet
487    /// root() returns game state after blinds are posted
488    /// head() returns current state after applying all actions to root
489    #[test]
490    fn base_vs_root_vs_head() {
491        let r = Partial::initial(Turn::Choice(0));
492        let base = r.base();
493        let root = r.root();
494        let head = r.head();
495        assert_eq!(base.street(), Street::Pref);
496        assert_eq!(root.street(), Street::Pref);
497        assert_eq!(head.street(), Street::Pref);
498        assert_eq!(base.pot(), 0); // no blinds yet
499        assert_eq!(root.pot(), Game::sblind() + Game::bblind()); // blinds posted
500        assert_eq!(head.pot(), Game::sblind() + Game::bblind()); // same as root when empty
501    }
502
503    /// states reconstructs game states: [root, after_action_0, after_action_1, ..., head]
504    /// states length = actions length + 1 (root state plus one state per action)
505    #[test]
506    fn states_reconstruction() {
507        let r = Partial::initial(Turn::Choice(0)).push(Action::Call(1));
508        let states = r.states();
509        assert_eq!(states.len(), r.actions().len() + 1);
510        assert_eq!(states.first(), Some(&r.root()));
511        assert_eq!(states.last(), Some(&r.head()));
512        states
513            .windows(2)
514            .zip(r.actions().iter())
515            .for_each(|(pair, &act)| assert_eq!(pair[1], pair[0].apply(act)));
516    }
517
518    /// subgame returns current street edges only
519    #[test]
520    fn subgame_current_street() {
521        let r = Partial::initial(Turn::Choice(0));
522        assert_eq!(r.subgame().length(), 0);
523        let r = r.push(Action::Call(1));
524        assert_eq!(r.subgame().length(), 1);
525    }
526
527    /// aligned: observation street matches draws in actions
528    /// From tuple uses push() which sprouts, so both approaches align
529    #[test]
530    fn alignment_check() {
531        let obs = Observation::from(Street::Flop);
532        let act = vec![
533            Action::Call(1), //
534            Action::Check,
535        ];
536        assert!(Partial::from((Turn::Choice(0), obs, act)).aligned());
537        assert!(
538            Partial::from((Turn::Choice(0), Arrangement::from(Street::Flop)))
539                .push(Action::Call(1))
540                .push(Action::Check)
541                .aligned()
542        );
543    }
544
545    /// behindness: seen().street() > head().street() means recall is behind
546    /// this is valid when user sets observation before adding all actions
547    #[test]
548    fn behindness_observation_ahead() {
549        let behind = Partial {
550            pov: Turn::Choice(0),
551            actions: Vec::new(),
552            reveals: Arrangement::from(Street::Turn),
553        };
554        assert!(behind.seen().street() > behind.head().street()); // behind
555        assert!(behind.aligned().not()); // not aligned until actions catch up
556    }
557
558    /// board length: pref=0, flop=3, turn=4, river=5
559    #[test]
560    fn board_by_street() {
561        let r = Partial::from((Turn::Choice(0), Arrangement::from(Street::Rive)));
562        assert_eq!(r.board().len(), 0);
563        let r = r.push(Action::Call(1)).push(Action::Check);
564        assert_eq!(r.board().len(), 3);
565        let r = r.push(Action::Check).push(Action::Check);
566        assert_eq!(r.board().len(), 4);
567        let r = r.push(Action::Check).push(Action::Check);
568        assert_eq!(r.board().len(), 5);
569    }
570
571    /// truncate cuts actions to specified street, then sprout advances if obs allows
572    /// to test pure truncation, use observation matching target street
573    #[test]
574    fn truncate_to_street() {
575        let r = Partial::from((Turn::Choice(0), Arrangement::from(Street::Flop)))
576            .push(Action::Call(1)) // P0 pref
577            .push(Action::Check) // P1 pref -> flop
578            .push(Action::Check) // P1 flop
579            .push(Action::Check); // P0 flop (no turn, obs is flop)
580        let t = r.truncate(Street::Pref);
581        // sprout advances to flop since obs has flop cards
582        assert!(r.head().street() == Street::Flop);
583        assert!(t.head().street() == Street::Flop);
584        assert!(t.actions().len() < r.actions().len());
585    }
586
587    /// decisions(street) returns non-blind, non-draw actions for that street
588    #[test]
589    fn decisions_per_street() {
590        let r = Partial::from((Turn::Choice(0), Arrangement::from(Street::Flop)))
591            .push(Action::Call(1))
592            .push(Action::Check)
593            .push(Action::Check)
594            .push(Action::Check);
595        assert_eq!(r.decisions(Street::Pref).len(), 2);
596        assert_eq!(r.decisions(Street::Flop).len(), 2);
597        assert!(r.decisions(Street::Pref).iter().all(|a| a.is_choice()));
598        assert!(r.decisions(Street::Flop).iter().all(|a| a.is_choice()));
599    }
600
601    /// walk through all streets: P0 first preflop, P1 first postflop
602    #[test]
603    fn playability_all_streets() {
604        let r = Partial::from((Turn::Choice(0), Arrangement::from(Street::Rive)));
605        assert_eq!(r.head().turn(), Turn::Choice(0));
606        assert_eq!(r.head().street(), Street::Pref);
607        let r = r.push(Action::Call(1)).push(Action::Check);
608        assert_eq!(r.head().street(), Street::Flop);
609        assert_eq!(r.head().turn(), Turn::Choice(1));
610        let r = r.push(Action::Check).push(Action::Check);
611        assert_eq!(r.head().street(), Street::Turn);
612        assert_eq!(r.head().turn(), Turn::Choice(1));
613        let r = r.push(Action::Check).push(Action::Check);
614        assert_eq!(r.head().street(), Street::Rive);
615        assert_eq!(r.head().turn(), Turn::Choice(1));
616        assert!(r.aligned());
617    }
618
619    /// when not hero's turn, head().turn() != pov
620    #[test]
621    fn playability_not_our_turn() {
622        let r =
623            Partial::from((Turn::Choice(0), Arrangement::from(Street::Pref))).push(Action::Call(1));
624        assert_eq!(r.head().turn(), Turn::Choice(1));
625    }
626
627    /// from Arrangement starts with empty actions (blinds in root)
628    #[test]
629    fn from_arrangement_empty_actions() {
630        let r = Partial::from((Turn::Choice(0), Arrangement::from(Street::Pref)));
631        assert_eq!(r.actions().len(), 0);
632        // but root() has blinds posted
633        assert_eq!(r.root().pot(), Game::sblind() + Game::bblind());
634    }
635
636    /// from tuple stores only provided actions (no blinds)
637    #[test]
638    fn from_tuple_stores_actions() {
639        let obs = Observation::from(Street::Pref);
640        let act = vec![
641            Action::Call(1), //
642        ];
643        let r = Partial::from((Turn::Choice(0), obs, act.clone()));
644        assert_eq!(r.actions().len(), act.len());
645        // all_actions() includes blinds for display
646        assert_eq!(r.complete().len(), Game::blinds().len() + act.len());
647    }
648
649    /// replace swaps arrangement, updates draw actions
650    #[test]
651    fn replace_swaps_arrangement() {
652        let obs = Observation::from(Street::Flop);
653        let act = vec![
654            Action::Call(1), //
655            Action::Check,
656        ];
657        let old = Partial::from((Turn::Choice(0), obs, act));
658        let new = old.replace(Arrangement::from(Street::Flop));
659        assert_ne!(new.seen(), old.seen());
660        assert_eq!(new.turn(), old.turn());
661    }
662
663    /// revealed(street) returns cards for that street
664    #[test]
665    fn revealed_per_street() {
666        let r = Partial::from((Turn::Choice(0), Arrangement::from(Street::Turn)));
667        assert_eq!(r.revealed(Street::Flop).len(), 3);
668        assert_eq!(r.revealed(Street::Turn).len(), 1);
669        assert_eq!(r.revealed(Street::Rive).len(), 0);
670    }
671
672    /// empty: no decisions beyond blinds
673    #[test]
674    fn empty_means_no_decisions() {
675        assert!(Partial::initial(Turn::Choice(0)).empty());
676        assert!(
677            Partial::initial(Turn::Choice(0))
678                .push(Action::Call(1))
679                .empty()
680                .not()
681        );
682    }
683
684    /// aggression counts trailing aggressive edges
685    #[test]
686    fn aggression_counts_trailing() {
687        let obs = Observation::from(Street::Pref);
688        let act = vec![
689            Action::Raise(4), //
690            Action::Raise(8),
691        ];
692        let r = Partial::from((Turn::Choice(0), obs, act));
693        assert_eq!(
694            r.aggression(),
695            r.subgame()
696                .into_iter()
697                .rev()
698                .take_while(|e| e.is_choice())
699                .filter(|e| e.is_aggro())
700                .count()
701        );
702    }
703
704    /// choices returns nonempty abstracted edges
705    #[test]
706    fn choices_nonempty() {
707        assert!(
708            Partial::from((Turn::Choice(0), Arrangement::from(Street::Pref)))
709                .choices()
710                .length()
711                > 0
712        );
713    }
714
715    /// can_play: hero's turn and at observation street
716    #[test]
717    fn can_play_conditions() {
718        let r = Partial::from((Turn::Choice(0), Arrangement::from(Street::Pref)));
719        assert_eq!(r.can_play(), r.turn() == Turn::Choice(0)); // can_play iff pov matches head's turn
720        let s = r.push(Action::Call(1));
721        assert_eq!(s.can_play(), s.turn() == Turn::Choice(1)); // after P0 acts, it's P1's turn
722    }
723
724    /// can_undo: false at initial, true after push
725    #[test]
726    fn can_undo_conditions() {
727        let r = Partial::initial(Turn::Choice(0));
728        assert!(r.can_undo().not());
729        assert!(r.push(Action::Call(1)).can_undo());
730    }
731
732    /// can_push: legal actions pass, illegal fail
733    #[test]
734    fn can_push_conditions() {
735        let r = Partial::initial(Turn::Choice(0));
736        assert!(r.can_push(&Action::Call(1)));
737        assert!(r.can_push(&Action::Check).not());
738    }
739}