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 +163 -0
- pokerutils/__init__.py +1 -0
- pokerutils/app.py +49 -0
- pokerutils/app.tcss +79 -0
- pokerutils/main.py +10 -0
- pokerutils/screens/outs_odds.py +109 -0
- pokerutils/screens/table_trainer.py +399 -0
- pokerutils/utils/__init__.py +0 -0
- pokerutils/utils/config.py +27 -0
- pokerutils/utils/poker.py +301 -0
- pokerutils/utils/readme.py +28 -0
- pokerutils/utils/simulation.py +149 -0
- pokerutils/widgets/__init__.py +17 -0
- pokerutils/widgets/answer_panel.py +98 -0
- pokerutils/widgets/community_cards.py +16 -0
- pokerutils/widgets/player_hand.py +16 -0
- pokerutils/widgets/playing_card.py +125 -0
- pokerutils/widgets/side_panel.py +37 -0
- pokerutils/widgets/table.py +20 -0
- pokerutils-0.1.0.dist-info/METADATA +187 -0
- pokerutils-0.1.0.dist-info/RECORD +24 -0
- pokerutils-0.1.0.dist-info/WHEEL +4 -0
- pokerutils-0.1.0.dist-info/entry_points.txt +2 -0
- pokerutils-0.1.0.dist-info/licenses/LICENSE +674 -0
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
|
+
|  |  |
|
|
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,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)
|