pokerutils 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pokerutils/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # pokerutils
2
+
3
+ A terminal app for practicing Texas Hold'em hand reading — deal a hand, reveal the board street by street, and see your hand strength, draws, and odds update live.
4
+
5
+ ## Features
6
+
7
+ - Deals hole cards and a full community board from a shuffled deck
8
+ - Reveals the flop, turn, and river one street at a time
9
+ - Evaluates your best five-card hand (pair through straight flush, with set-vs-trips and made-straight naming)
10
+ - Detects live draws — flush, straight, gutshot, double gutshot, and backdoor draws — with out counts, hit chance, and odds against
11
+ - Remembers your chosen color theme between sessions
12
+ - Six-seat no-limit Texas Hold'em table trainer with five simulated opponents, legal betting, blinds, and payouts
13
+
14
+ ## Screenshots
15
+
16
+ | Table trainer | Outs and odds |
17
+ | --- | --- |
18
+ | ![Six-seat table trainer](docs/images/table-trainer.png) | ![Outs and odds exercise](docs/images/outs-odds.png) |
19
+
20
+ ## Controls
21
+
22
+ | Key | Action |
23
+ | -------- | ------------------------ |
24
+ | `d` | Deal a fresh hand |
25
+ | `n` | Reveal the next street |
26
+ | `s` | Show / hide draw details |
27
+ | `o` | Open the odds exercise |
28
+ | `t` | Open the table trainer |
29
+ | `r` | Return to the README |
30
+ | `escape` | Clear focus |
31
+ | `q` | Quit |
32
+
33
+ ## Outs and odds exercise
34
+
35
+ Press `o` from the home screen. You get hole cards and a hidden board; `n` reveals
36
+ the flop, then the turn, then the river. Enter the outs and the odds against
37
+ hitting on the **next card** and press **Submit** to have them graded against the
38
+ widest direct draw. Odds accept `4.2` or `4.2:1`, with a tolerance of 0.1; outs
39
+ must be exact. Press `s` to hide the side panel while you work, and again to check
40
+ your reasoning against the full breakdown.
41
+
42
+ ## Install
43
+
44
+ Run it without installing anything, using [uv](https://docs.astral.sh/uv/):
45
+
46
+ ```sh
47
+ uvx pokerutils
48
+ ```
49
+
50
+ Or install it with `pip` (Python 3.13+):
51
+
52
+ ```sh
53
+ pip install pokerutils
54
+ ```
55
+
56
+ Or with [Homebrew](https://brew.sh/):
57
+
58
+ ```sh
59
+ brew install markosnarinian/tap/pokerutils
60
+ ```
61
+
62
+ Then start it with:
63
+
64
+ ```sh
65
+ pokerutils
66
+ ```
67
+
68
+ ### From source
69
+
70
+ ```sh
71
+ git clone https://github.com/markosnarinian/pokerutils
72
+ cd pokerutils
73
+ uv run pokerutils
74
+ ```
75
+
76
+ ## Table trainer
77
+
78
+ Press `t` from the home screen. You sit with five simulated opponents with tight,
79
+ loose, aggressive, passive, and balanced tendencies. These are lightweight
80
+ randomized policies, not live humans or trained human-behavior models; they use
81
+ only their own cards and the public board.
82
+
83
+ - Press `n` or **Next action** to observe one opponent action. On your turn, use
84
+ **Fold**, **Check / Call**, or enter a street-total amount and **Bet / Raise**.
85
+
86
+ ### Table trainer shortcuts
87
+
88
+ Every control has a key, shown in its label and the footer. Letters work when no
89
+ field is focused; `escape` clears focus (and `enter` submits a focused field).
90
+ Shortcuts for disabled controls are greyed out.
91
+
92
+ | Key | Action |
93
+ | -------- | ------------------------------------------------------------------- |
94
+ | `n` | Next opponent action |
95
+ | `f` | Fold |
96
+ | `c` | Check / Call |
97
+ | `b` | Focus the raise field; `enter` bets / raises |
98
+ | `h` | New hand |
99
+ | `p` | Focus the pot answer |
100
+ | `e` | Focus the pot odds answer |
101
+ | `d` | Focus the draw odds answer |
102
+ | `v` | Check / Reveal (`enter` in an answer moves on, then reveals) |
103
+ | `a` | Toggle auto-continue |
104
+ | `i`, `j` | Focus average delay / jitter (`enter` applies timing) |
105
+ | `u` | Apply timing |
106
+ | `escape` | Clear focus |
107
+ | `r` | Return to the README |
108
+ - Press `a` or **Auto: Off/On** to toggle automatic opponent actions (off by
109
+ default). It pauses for your turn and at hand completion, and resumes after
110
+ your action or when you start a new hand. Leaving the screen stops its timer.
111
+ - Set **Average (s)** and **Jitter ± (s)**, then **Apply timing** to override the
112
+ pace. Each delay is sampled uniformly from average − jitter to average + jitter.
113
+ Defaults are 3 ± 1 seconds (2–4 seconds), a practice pace rather than a measured
114
+ human average. Use zero jitter for a fixed delay; the minimum delay must be at
115
+ least 0.1 seconds. Settings last for the current app session. Manual steps and
116
+ timing changes replace the pending timer; pause auto to study between actions.
117
+ - Track the pot from the action history, including the small blind (1), big blind
118
+ (2), and outstanding bets. Enter your answer and use **Check / Reveal**.
119
+ - At priced decisions, practice pot odds as `pot before calling / cost to call`.
120
+ Feedback also shows break-even equity: `call / (pot + call)`.
121
+ - On the flop and turn, an available direct draw supplies an outs count; calculate
122
+ odds against hitting on the **next card**, not by the river. Hidden opponent
123
+ cards are still counted as unseen. Completing a draw does not guarantee winning.
124
+ - Ratios accept `4.2` or `4.2:1`, with a tolerance of 0.1; pot totals must be exact.
125
+ - All-ins and side pots are settled by the engine, but their pot-odds questions
126
+ are omitted to avoid misleading eligibility calculations. There is no rake.
127
+ - After settlement, **New hand** rotates the button and resets all six stacks to
128
+ 200 chips (100 big blinds). These are independent drills, not a bankroll session.
129
+
130
+ Use a terminal at least 110 columns wide for all action controls at once; smaller
131
+ terminals can scroll the controls horizontally and the page vertically.
132
+
133
+ ### Engine and historical hands
134
+
135
+ The rules engine is [PokerKit](https://github.com/uoftcprg/pokerkit), which supports
136
+ Python 3.13, arbitrary no-limit sizing, forced blinds, all-ins, side pots, and
137
+ showdown settlement. We also considered
138
+ [PyPokerEngine](https://github.com/ishikota/PyPokerEngine) (older Python support and
139
+ example bots rather than human models) and [RLCard](https://github.com/datamllab/rlcard)
140
+ (RL-oriented, abstracted bet sizes, no bundled human-like six-seat NLHE policy).
141
+
142
+ [Poker Hand History (PHH)](https://phh.readthedocs.io/) is an open, TOML-based
143
+ interchange format, not a universal standard used by every poker site.
144
+ [PokerKit supports PHH loading and action-by-action replay](https://pokerkit.readthedocs.io/en/stable/notation.html),
145
+ as well as parsers for some site-specific histories. Real histories could provide
146
+ authentic opponent decisions in a future replay exercise. They may omit hidden
147
+ cards, and recorded actions cannot simply continue after the learner takes a
148
+ different action. **Import/replay is not implemented in this mode**; no historical
149
+ dataset is downloaded or bundled.
150
+
151
+ ## Tests
152
+
153
+ ```sh
154
+ PYTHONPATH=src uv run python -m unittest discover -s tests -v
155
+ ```
156
+
157
+ ## License
158
+
159
+ Released under the [GNU General Public License v3.0 or later](LICENSE).
160
+
161
+ ## Tech stack
162
+
163
+ Built with [Textual](https://textual.textualize.io/) for the terminal UI, with preferences persisted via `platformdirs`.
pokerutils/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Terminal poker practice tools."""
pokerutils/app.py ADDED
@@ -0,0 +1,49 @@
1
+ from typing import ClassVar
2
+
3
+ from textual.app import App, ComposeResult
4
+ from textual.binding import Binding
5
+ from textual.widgets import Footer, Header, Markdown
6
+
7
+ from .screens.outs_odds import OutsOdds
8
+ from .screens.table_trainer import TableTrainer
9
+ from .utils.config import load_theme, save_theme
10
+ from .utils.readme import load_readme
11
+
12
+
13
+ class PokerutilsApp(App):
14
+ TITLE = "pokerutils"
15
+
16
+ CSS_PATH = "app.tcss"
17
+
18
+ AUTO_FOCUS = None
19
+
20
+ SCREENS: ClassVar = {"outs_odds": OutsOdds, "table_trainer": TableTrainer}
21
+
22
+ BINDINGS: ClassVar = [
23
+ ("o", "push_screen('outs_odds')", "Outs/Odds"),
24
+ ("t", "push_screen('table_trainer')", "Table trainer"),
25
+ Binding("escape", "blur", "Remove focus", show=True),
26
+ ("q", "quit", "Quit"),
27
+ ]
28
+
29
+ def __init__(self) -> None:
30
+ super().__init__()
31
+ saved_theme = load_theme()
32
+ if saved_theme is not None:
33
+ self.theme = saved_theme
34
+
35
+ def watch_theme(self, theme: str) -> None:
36
+ save_theme(theme)
37
+
38
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
39
+ """Open exercises from the home screen, without stacking modes."""
40
+ if action == "push_screen":
41
+ return not isinstance(self.screen, (OutsOdds, TableTrainer))
42
+ return True
43
+
44
+ def compose(self) -> ComposeResult:
45
+ """Create child widgets for the app."""
46
+ readme = load_readme()
47
+ yield Header()
48
+ yield Markdown(readme, id="readme")
49
+ yield Footer(show_command_palette=True)
pokerutils/app.tcss ADDED
@@ -0,0 +1,79 @@
1
+ #main {
2
+ height: 1fr;
3
+ }
4
+
5
+ Screen {
6
+ align: center middle;
7
+ }
8
+
9
+ #readme {
10
+ padding: 3;
11
+ max-width: 100;
12
+ }
13
+
14
+ Table {
15
+ width: 1fr;
16
+ }
17
+
18
+ SidePanel {
19
+ width: 45;
20
+ border: round white;
21
+ overflow-y: auto;
22
+ }
23
+
24
+ SidePanel.-details-hidden {
25
+ hatch: right $foreground 15%;
26
+ }
27
+
28
+ CommunityCards {
29
+ height: 1fr;
30
+ align: center middle;
31
+ border: round white;
32
+ padding: 1;
33
+ }
34
+
35
+ #bottom {
36
+ height: 1fr;
37
+ }
38
+
39
+ PlayerHand {
40
+ align: center middle;
41
+ border: round white;
42
+ padding: 1;
43
+ }
44
+
45
+ AnswerPanel {
46
+ align: center middle;
47
+ border: round white;
48
+ padding: 1;
49
+ }
50
+
51
+ AnswerPanel HorizontalGroup {
52
+ align: center middle;
53
+ width: auto;
54
+ margin-bottom: 1;
55
+ }
56
+
57
+ #odds-against {
58
+ margin-left: 1;
59
+ }
60
+
61
+ #outs {
62
+ margin-left: 9;
63
+ }
64
+
65
+ AnswerPanel Input {
66
+ width: 15;
67
+ }
68
+
69
+ AnswerPanel Button {
70
+ width: 29;
71
+ }
72
+
73
+ PlayingCard {
74
+ margin-left: 1;
75
+ }
76
+
77
+ PlayingCard:first-of-type {
78
+ margin-left: 0;
79
+ }
pokerutils/main.py ADDED
@@ -0,0 +1,10 @@
1
+ from .app import PokerutilsApp
2
+
3
+
4
+ def main():
5
+ app = PokerutilsApp()
6
+ app.run()
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
@@ -0,0 +1,109 @@
1
+ import random
2
+ from typing import ClassVar
3
+
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal
7
+ from textual.screen import Screen
8
+ from textual.widgets import Footer, Header
9
+
10
+ from ..utils.config import load_theme, save_theme
11
+ from ..widgets import (
12
+ AnswerPanel,
13
+ CommunityCards,
14
+ PlayerHand,
15
+ PlayingCard,
16
+ Rank,
17
+ SidePanel,
18
+ Suit,
19
+ Table,
20
+ )
21
+
22
+
23
+ class OutsOdds(Screen):
24
+ BINDINGS: ClassVar = [
25
+ ("d", "deal", "Deal cards"),
26
+ ("n", "next", "Next round"),
27
+ ("s", "hide_details", "Hide details"),
28
+ ("s", "show_details", "Show details"),
29
+ ("r", "app.pop_screen", "Return to README"),
30
+ Binding("escape", "blur", "Unfocus", show=True),
31
+ ]
32
+
33
+ def __init__(self) -> None:
34
+ super().__init__()
35
+ saved_theme = load_theme()
36
+ if saved_theme is not None:
37
+ self.theme = saved_theme
38
+
39
+ def watch_theme(self, theme: str) -> None:
40
+ save_theme(theme)
41
+
42
+ def compose(self) -> ComposeResult:
43
+ """Create child widgets for the app."""
44
+ yield Header()
45
+ with Horizontal(id="main"):
46
+ yield Table()
47
+ yield SidePanel()
48
+ yield Footer(show_command_palette=True)
49
+
50
+ def on_mount(self) -> None:
51
+ self.action_deal()
52
+
53
+ def action_deal(self) -> None:
54
+ """Shuffle a fresh deck and deal new hole cards and community cards."""
55
+ deck = [(rank, suit) for suit in Suit for rank in Rank]
56
+ random.shuffle(deck)
57
+
58
+ for card in self.query(PlayerHand).first().query(PlayingCard):
59
+ card.rank, card.suit = deck.pop()
60
+ card.face_up = True
61
+
62
+ for card in self.query(CommunityCards).first().query(PlayingCard):
63
+ card.rank, card.suit = deck.pop()
64
+ card.face_up = False
65
+
66
+ self._refresh_side_panel()
67
+
68
+ def action_next(self) -> None:
69
+ """Reveal the flop (3 cards), then the turn, then the river."""
70
+ cards = list(self.query(CommunityCards).first().query(PlayingCard))
71
+ face_down = [card for card in cards if not card.face_up]
72
+ if not face_down:
73
+ return
74
+
75
+ reveal_count = 3 if len(face_down) == len(cards) else 1
76
+ for card in face_down[:reveal_count]:
77
+ card.face_up = True
78
+
79
+ self._refresh_side_panel()
80
+
81
+ def action_blur(self) -> None:
82
+ """Remove focus from whichever widget currently has it."""
83
+ self.screen.set_focus(None)
84
+
85
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
86
+ """Show only the "hide details" or "show details" binding that currently applies."""
87
+ if action in ("hide_details", "show_details"):
88
+ side_panels = self.query(SidePanel)
89
+ hidden = side_panels.first().hidden if side_panels else False
90
+ return hidden == (action == "show_details")
91
+ return True
92
+
93
+ def action_hide_details(self) -> None:
94
+ """Hide the side panel's details behind a shaded pattern."""
95
+ self.query_one(SidePanel).hidden = True
96
+ self.refresh_bindings()
97
+
98
+ def action_show_details(self) -> None:
99
+ """Reveal the side panel's details."""
100
+ self.query_one(SidePanel).hidden = False
101
+ self.refresh_bindings()
102
+
103
+ def _refresh_side_panel(self) -> None:
104
+ hole = list(self.query(PlayerHand).first().query(PlayingCard))
105
+ board = list(self.query(CommunityCards).first().query(PlayingCard))
106
+ summary = self.query_one(SidePanel).refresh_info(hole, board)
107
+ # Grade against the widest direct draw, the same one the side panel leads with.
108
+ direct = [draw for draw in summary.draws if "Backdoor" not in draw.name]
109
+ self.query_one(AnswerPanel).set_expected(direct[0] if direct else None)