pkpython 0.1.0__cp313-cp313-win_amd64.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.
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pkpython
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
7
|
+
Classifier: Topic :: Games/Entertainment
|
|
8
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Summary: Python bindings for pkcore, a high-performance poker analysis library
|
|
11
|
+
Author-email: folkengine <gaoler@electronicpanopticon.com>
|
|
12
|
+
License: GPL-3.0-or-later
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
15
|
+
Project-URL: Bug Tracker, https://github.com/ImperialBower/pkpy/issues
|
|
16
|
+
Project-URL: Homepage, https://github.com/ImperialBower/pkpy
|
|
17
|
+
Project-URL: Repository, https://github.com/ImperialBower/pkpy
|
|
18
|
+
|
|
19
|
+
[](https://github.com/ImperialBower/pkpy/actions/workflows/ci.yml)
|
|
20
|
+
[](LICENSE)
|
|
21
|
+
|
|
22
|
+
# pkpy
|
|
23
|
+
|
|
24
|
+
Python bindings for [pkcore](https://github.com/folkengine/pkcore), a high-performance poker analysis library written in Rust.
|
|
25
|
+
|
|
26
|
+
## What This Project Does
|
|
27
|
+
|
|
28
|
+
pkpy lets Python developers use pkcore's poker engine — card parsing, hand evaluation, Texas Hold'em game simulation, outs calculation, and more — without writing any Rust. The Rust library runs natively and is called directly from Python with no subprocess overhead or serialization round-trips.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Dependencies
|
|
33
|
+
|
|
34
|
+
- [pkcore](https://crates.io/crates/pkcore) — the underlying Rust poker analysis library
|
|
35
|
+
- [PyO3](https://pyo3.rs) — Rust/Python bindings framework
|
|
36
|
+
- [Maturin](https://maturin.rs) — build tool for PyO3 extension modules
|
|
37
|
+
|
|
38
|
+
See [docs/STACK.md](docs/STACK.md) for more details on the technology stack.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Cactus Kev Binary Card Representation
|
|
43
|
+
|
|
44
|
+
pkcore represents each card as a single `u32` using a variation of [Cactus Kev's binary encoding](https://suffe.cool/poker/evaluator.html), designed for O(1) hand evaluation via lookup tables.
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
+--------+--------+--------+--------+
|
|
48
|
+
|mmmbbbbb|bbbbbbbb|SHDCrrrr|xxpppppp|
|
|
49
|
+
+--------+--------+--------+--------+
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Bits | Meaning |
|
|
53
|
+
|------|---------|
|
|
54
|
+
| `p` (6 bits) | Prime number for the rank (Deuce=2, Trey=3, ..., Ace=41) |
|
|
55
|
+
| `r` (4 bits) | Rank index (Deuce=0, Trey=1, ..., Ace=12) |
|
|
56
|
+
| `SHDC` (4 bits) | Suit flags — one bit per suit |
|
|
57
|
+
| `b` (13 bits) | One bit set per rank — used for flush/straight detection |
|
|
58
|
+
| `m` (3 bits) | Frequency flags (paired, tripped, quaded) — stripped during eval |
|
|
59
|
+
|
|
60
|
+
This encoding makes many operations branch-free bit manipulations. For example, detecting a flush is a single bitwise AND across five cards' suit bits.
|
|
61
|
+
|
|
62
|
+
## Hand Evaluation
|
|
63
|
+
|
|
64
|
+
pkcore uses a two-level lookup table strategy (the same approach as the original Cactus Kev evaluator):
|
|
65
|
+
|
|
66
|
+
1. **Flushes and straights** are detected via the rank-bit field (`b` bits). A 13-bit mask uniquely identifies every possible straight and flush pattern.
|
|
67
|
+
2. **All other hands** are identified by multiplying the five rank primes together. Since every rank maps to a distinct prime, the product uniquely identifies the rank multiset — pairs, trips, quads, and full houses all have unique products. The product indexes into a lookup table that returns the `HandRankValue`.
|
|
68
|
+
|
|
69
|
+
A lower `HandRankValue` is a stronger hand (1 = royal flush, 7462 = worst high card).
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Project Structure
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
pkpy/
|
|
77
|
+
├── Cargo.toml # Rust crate manifest
|
|
78
|
+
├── pyproject.toml # Python build config (maturin)
|
|
79
|
+
├── src/
|
|
80
|
+
│ └── lib.rs # All PyO3 bindings
|
|
81
|
+
├── python/
|
|
82
|
+
│ └── pkpy/
|
|
83
|
+
│ └── __init__.py # Python package — re-exports everything from the extension
|
|
84
|
+
└── tests/
|
|
85
|
+
└── test_pkpy.py # pytest test suite
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The `python/pkpy/` directory is the Python package. The compiled Rust extension (`_pkpy.so`) is dropped into it by maturin. `__init__.py` re-exports everything so users write `from pkpy import Card` rather than `from pkpy._pkpy import Card`.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## API Reference
|
|
93
|
+
|
|
94
|
+
### `Card`
|
|
95
|
+
|
|
96
|
+
A single playing card. Internally a `u32` in Cactus Kev format.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from pkpy import Card, Rank, Suit
|
|
100
|
+
|
|
101
|
+
# Parse from string — accepts "As", "A♠", "a♠", "AH", etc.
|
|
102
|
+
ace_spades = Card.parse("As")
|
|
103
|
+
king_hearts = Card.parse("K♥")
|
|
104
|
+
|
|
105
|
+
# Construct from rank and suit
|
|
106
|
+
card = Card.from_rank_suit(Rank.QUEEN, Suit.DIAMONDS)
|
|
107
|
+
|
|
108
|
+
# Inspect
|
|
109
|
+
card.rank() # -> Rank
|
|
110
|
+
card.suit() # -> Suit
|
|
111
|
+
card.is_dealt() # -> bool (False for blank/sentinel cards)
|
|
112
|
+
card.as_u32() # -> int (raw Cactus Kev encoding)
|
|
113
|
+
card.bit_string() # -> str (binary representation of the encoding)
|
|
114
|
+
card.get_rank_prime() # -> int (rank prime used in hand evaluation)
|
|
115
|
+
card.get_letter_index() # -> str (letter-index form, e.g. "As")
|
|
116
|
+
|
|
117
|
+
str(card) # -> "Q♦"
|
|
118
|
+
card == Card.parse("Qd") # -> True
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `Deck`
|
|
122
|
+
|
|
123
|
+
A standard 52-card deck. All methods are static — `Deck` is a namespace for deck-level operations.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from pkpy import Deck
|
|
127
|
+
|
|
128
|
+
deck = Deck.poker_cards() # -> Cards, ordered A♠ down to 2♣
|
|
129
|
+
shuffled = Deck.poker_cards_shuffled() # -> Cards, randomly shuffled
|
|
130
|
+
Deck.get(0) # -> Card (A♠, the first card in deck order)
|
|
131
|
+
Deck.len() # -> 52
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### `Cards`
|
|
135
|
+
|
|
136
|
+
An ordered, unique collection of cards backed by an `IndexSet` (ordered hash set). Duplicate inserts are silently ignored.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from pkpy import Cards
|
|
140
|
+
|
|
141
|
+
hand = Cards.parse("As Ks Qh")
|
|
142
|
+
deck = Cards.deck() # full 52-card deck in order
|
|
143
|
+
|
|
144
|
+
len(hand) # -> 3
|
|
145
|
+
hand.is_empty() # -> False
|
|
146
|
+
hand.contains(Card.parse("As")) # -> True
|
|
147
|
+
hand.remaining() # -> Cards with 49 cards (deck minus hand)
|
|
148
|
+
hand.remaining_after(board) # -> deck minus hand minus board
|
|
149
|
+
hand.is_dealt() # -> True if no blank cards
|
|
150
|
+
hand.are_unique() # -> True if no duplicates
|
|
151
|
+
|
|
152
|
+
for card in hand: # iterable
|
|
153
|
+
print(card)
|
|
154
|
+
|
|
155
|
+
hand.to_list() # -> list[Card]
|
|
156
|
+
hand.get_index(0) # -> Card | None (card at position 0)
|
|
157
|
+
|
|
158
|
+
# Mutation
|
|
159
|
+
hand.insert(Card.parse("Jh")) # -> bool (True if card was new)
|
|
160
|
+
hand.remove(Card.parse("As")) # -> bool (True if card was present)
|
|
161
|
+
hand.append(Cards.parse("Tc 9c")) # merge another Cards in place
|
|
162
|
+
hand.shuffle_in_place() # shuffle in place
|
|
163
|
+
|
|
164
|
+
# Non-mutating transformations
|
|
165
|
+
hand.shuffle() # -> Cards (shuffled copy)
|
|
166
|
+
hand.sort() # -> Cards (sorted highest rank first)
|
|
167
|
+
hand.minus(other) # -> Cards (this minus other)
|
|
168
|
+
hand.filter_by_suit(Suit.SPADES) # -> Cards (only spades)
|
|
169
|
+
hand.combinations(2) # -> list[Cards] (all 2-card combos)
|
|
170
|
+
|
|
171
|
+
# Drawing (mutates the source collection)
|
|
172
|
+
card = hand.draw_one() # -> Card (removes and returns the top card)
|
|
173
|
+
drawn = hand.draw(3) # -> Cards (removes and returns 3 cards)
|
|
174
|
+
rest = hand.draw_all() # -> Cards (empties the collection)
|
|
175
|
+
|
|
176
|
+
# Deck-relative operations
|
|
177
|
+
hand.deck_minus() # -> Cards (52-card deck minus this collection)
|
|
178
|
+
hand.deck_primed() # -> Cards (this collection first, then rest of deck)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `HoleCards`
|
|
182
|
+
|
|
183
|
+
A collection of two-card hands for one or more players. Cards are parsed in pairs: the first two belong to player 1, the next two to player 2, and so on.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from pkpy import HoleCards
|
|
187
|
+
|
|
188
|
+
# Two players
|
|
189
|
+
hc = HoleCards.parse("As Kh 8d Kc")
|
|
190
|
+
len(hc) # -> 2
|
|
191
|
+
hc.is_empty() # -> False
|
|
192
|
+
hc.get(0) # -> Two | None (0-indexed)
|
|
193
|
+
hc.to_list() # -> list[Two]
|
|
194
|
+
|
|
195
|
+
# Build programmatically
|
|
196
|
+
hc = HoleCards.parse("As Kh")
|
|
197
|
+
hc.push(Two.parse("Qd Jc"))
|
|
198
|
+
len(hc) # -> 2
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### `Board`
|
|
202
|
+
|
|
203
|
+
The community cards (flop, turn, river).
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from pkpy import Board
|
|
207
|
+
|
|
208
|
+
board = Board.parse("Ac 8h 7h 9s") # flop + turn
|
|
209
|
+
board = Board.parse("Ac 8h 7h 9s 5s") # full board
|
|
210
|
+
|
|
211
|
+
board.turn_cards() # -> Cards (flop + turn, 4 cards)
|
|
212
|
+
str(board) # -> "FLOP: A♣ 8♥ 7♥, TURN: 9♠, RIVER: _"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### `Game`
|
|
216
|
+
|
|
217
|
+
Combines hole cards and a board. The main entry point for analysis.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from pkpy import Game, HoleCards, Board, Outs
|
|
221
|
+
|
|
222
|
+
hc = HoleCards.parse("As Kh 8d Kc")
|
|
223
|
+
board = Board.parse("Ac 8h 7h 9s")
|
|
224
|
+
game = Game(hc, board)
|
|
225
|
+
|
|
226
|
+
game.has_dealt_turn() # -> bool (True if board has a turn card)
|
|
227
|
+
case_evals = game.turn_case_evals() # evaluates all possible river cards
|
|
228
|
+
game.turn_eval_for_player(0) # -> Eval for player at index 0 (raises on missing turn)
|
|
229
|
+
game.turn_remaining_board() # -> Cards (deck cards not yet on the board or in hands)
|
|
230
|
+
game.flop_and_turn() # -> Cards (the 4 board cards through the turn)
|
|
231
|
+
|
|
232
|
+
flop_eval = game.flop_eval() # -> FlopEval | None
|
|
233
|
+
turn_eval = game.turn_eval() # -> TurnEval | None
|
|
234
|
+
|
|
235
|
+
print(game.turn_nuts_display()) # best hands possible at the turn
|
|
236
|
+
print(game.river_display()) # final result with winner
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### `CaseEvals`
|
|
240
|
+
|
|
241
|
+
The result of `game.turn_case_evals()`. Contains one evaluation per possible river card (typically 44–46 entries depending on how many cards are already accounted for).
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
len(case_evals) # -> number of possible river cards evaluated
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### `Outs`
|
|
248
|
+
|
|
249
|
+
Cards that, if dealt on the river, cause a specific player to win. Built from `CaseEvals`.
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from pkpy import Outs
|
|
253
|
+
|
|
254
|
+
outs = Outs.from_case_evals(case_evals)
|
|
255
|
+
|
|
256
|
+
outs.len_for_player(1) # -> int: number of winning river cards for player 1
|
|
257
|
+
outs.len_for_player(2) # -> int: number of winning river cards for player 2
|
|
258
|
+
outs.get(1) # -> Cards | None: the actual out cards for player 1
|
|
259
|
+
outs.longest_player() # -> int: player id with the most outs
|
|
260
|
+
outs.is_longest(2) # -> bool
|
|
261
|
+
outs.len_longest() # -> int: how many outs the leading player has
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Players are 1-indexed.
|
|
265
|
+
|
|
266
|
+
### `HandRank` and `HandRankClass`
|
|
267
|
+
|
|
268
|
+
`HandRank` holds the numeric strength of a five-card hand. Lower `value` = stronger hand.
|
|
269
|
+
|
|
270
|
+
`HandRankClass` is the detailed category (e.g., `RoyalFlush`, `FourAces`, `AcesOverKings`).
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
from pkpy import HandRankClass
|
|
274
|
+
|
|
275
|
+
HandRankClass.ROYAL_FLUSH.is_straight_flush() # -> True
|
|
276
|
+
str(HandRankClass.ROYAL_FLUSH) # -> "RoyalFlush"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`HandRank` is obtained from `Eval` objects, which come out of `CaseEvals`. Direct construction is not exposed since you'd normally get them via game evaluation.
|
|
280
|
+
|
|
281
|
+
### Constants
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
from pkpy import (
|
|
285
|
+
unique_5_card_hands, # 2,598,960
|
|
286
|
+
distinct_5_card_hands, # 7,462
|
|
287
|
+
unique_2_card_hands, # 1,326
|
|
288
|
+
distinct_2_card_hands, # 169
|
|
289
|
+
)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## GTO Range Analysis
|
|
295
|
+
|
|
296
|
+
### `Combo`
|
|
297
|
+
|
|
298
|
+
An abstract hand combination defined by rank(s) and a suit qualifier.
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
from pkpy import Combo
|
|
302
|
+
|
|
303
|
+
c = Combo.parse("AKs")
|
|
304
|
+
c.is_suited() # -> True
|
|
305
|
+
c.is_pair() # -> False
|
|
306
|
+
c.is_ace_x() # -> True
|
|
307
|
+
c.total_pairs() # -> 4 (four suited AK combos)
|
|
308
|
+
c.first # -> Rank.ACE
|
|
309
|
+
c.second # -> Rank.KING
|
|
310
|
+
c.plus # -> False
|
|
311
|
+
|
|
312
|
+
Combo.parse("JJ+").plus # -> True
|
|
313
|
+
Combo.parse("QQ").total_pairs() # -> 6 (six ways to make QQ)
|
|
314
|
+
Combo.parse("AKo").total_pairs()# -> 12 (twelve offsuit AK combos)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### `Combos`
|
|
318
|
+
|
|
319
|
+
A range of abstract hand combinations, parsed from standard poker range notation.
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
from pkpy import Combos
|
|
323
|
+
|
|
324
|
+
r = Combos.parse("QQ+, AK")
|
|
325
|
+
len(r) # -> 5 (QQ, KK, AA, AKs, AKo as abstract combos)
|
|
326
|
+
|
|
327
|
+
twos = r.explode()
|
|
328
|
+
len(twos) # -> 34 (all concrete two-card hands)
|
|
329
|
+
|
|
330
|
+
# Predefined ranges (returned as strings, pass to Combos.parse)
|
|
331
|
+
Combos.PERCENT_2_5 # "QQ+, AK" — top ~2.5% of hands
|
|
332
|
+
Combos.PERCENT_5 # "TT+, AQ+" — top ~5%
|
|
333
|
+
Combos.PERCENT_10 # "44+, AJ+, ..." — top ~10%
|
|
334
|
+
Combos.PERCENT_20 # top ~20%
|
|
335
|
+
Combos.PERCENT_33 # top ~33%
|
|
336
|
+
|
|
337
|
+
# Parse a predefined range
|
|
338
|
+
tight = Combos.parse(Combos.PERCENT_2_5)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### `Two`
|
|
342
|
+
|
|
343
|
+
A concrete two-card hand — the unit produced by combo explosion.
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
from pkpy import Two
|
|
347
|
+
|
|
348
|
+
t = Two.parse("As Kh")
|
|
349
|
+
t.first() # -> Card (A♠)
|
|
350
|
+
t.second() # -> Card (K♥)
|
|
351
|
+
t.is_suited() # -> False
|
|
352
|
+
t.is_pair() # -> False
|
|
353
|
+
t.contains_rank(Rank.ACE) # -> True
|
|
354
|
+
t.contains_suit(Suit.SPADES) # -> True
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### `Twos`
|
|
358
|
+
|
|
359
|
+
The collection returned by `Combos.explode()`. Supports filtering.
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
from pkpy import Combos
|
|
363
|
+
|
|
364
|
+
twos = Combos.parse("QQ+, AK").explode()
|
|
365
|
+
|
|
366
|
+
twos.filter_is_paired() # -> Twos (only pocket pairs)
|
|
367
|
+
twos.filter_is_not_paired() # -> Twos (only non-paired hands)
|
|
368
|
+
twos.filter_is_suited() # -> Twos (only suited hands)
|
|
369
|
+
twos.filter_is_not_suited() # -> Twos (only offsuit hands)
|
|
370
|
+
twos.filter_on_rank(Rank.ACE) # -> Twos (hands containing an Ace)
|
|
371
|
+
twos.filter_on_card(Card.parse("As")) # -> Twos (hands containing A♠)
|
|
372
|
+
|
|
373
|
+
twos.to_list() # -> list[Two]
|
|
374
|
+
twos.contains(Two.parse("As Kh")) # -> bool
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### `Qualifier`
|
|
378
|
+
|
|
379
|
+
The suit qualifier for a combo: `SUITED`, `OFFSUIT`, or `ALL`.
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
from pkpy import Combo, Qualifier
|
|
383
|
+
|
|
384
|
+
Combo.parse("AKs").qualifier == Qualifier.SUITED # -> True
|
|
385
|
+
Combo.parse("AKo").qualifier == Qualifier.OFFSUIT # -> True
|
|
386
|
+
Combo.parse("AK").qualifier == Qualifier.ALL # -> True
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### GTO Example
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
from pkpy import Combos, Rank
|
|
393
|
+
|
|
394
|
+
# Villain's opening range
|
|
395
|
+
villain_range = Combos.parse("66+,AJs+,KQs,AJo+,KQo")
|
|
396
|
+
|
|
397
|
+
# Expand to all concrete two-card hands
|
|
398
|
+
twos = villain_range.explode()
|
|
399
|
+
print(f"Total hands in range: {len(twos)}")
|
|
400
|
+
|
|
401
|
+
# How many are pocket pairs vs. unpaired?
|
|
402
|
+
pairs = twos.filter_is_paired()
|
|
403
|
+
unpaired = twos.filter_is_not_paired()
|
|
404
|
+
print(f"Pairs: {len(pairs)}, Unpaired: {len(unpaired)}")
|
|
405
|
+
|
|
406
|
+
# Hands containing an Ace
|
|
407
|
+
ace_hands = twos.filter_on_rank(Rank.ACE)
|
|
408
|
+
print(f"Ace-x hands: {len(ace_hands)}")
|
|
409
|
+
|
|
410
|
+
# Suited vs. offsuit breakdowns
|
|
411
|
+
print(f"Suited: {len(twos.filter_is_suited())}")
|
|
412
|
+
print(f"Offsuit: {len(twos.filter_is_not_suited())}")
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Binary Card Maps
|
|
418
|
+
|
|
419
|
+
pkpy exposes pkcore's binary card map types, which provide compact, high-performance hand evaluation storage. These are the building blocks for precomputed lookup tables.
|
|
420
|
+
|
|
421
|
+
### `Bard`
|
|
422
|
+
|
|
423
|
+
A 64-bit bitset where each of the 52 cards occupies one bit. Set operations (union, intersection, membership) are single CPU instructions.
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
from pkpy import Bard, Card, Cards
|
|
427
|
+
|
|
428
|
+
# Construct
|
|
429
|
+
b = Bard.from_card(Card.parse("As")) # single card
|
|
430
|
+
b = Bard.from_cards(Cards.parse("As Ks")) # from a Cards collection
|
|
431
|
+
b = Bard.from_u64(4_362_862_139_015_168) # from a raw u64
|
|
432
|
+
|
|
433
|
+
# Constants
|
|
434
|
+
Bard.BLANK # all bits zero
|
|
435
|
+
Bard.ALL # all 52 card bits set
|
|
436
|
+
|
|
437
|
+
# Operations
|
|
438
|
+
b2 = b.fold_in(Card.parse("Qs")) # returns new Bard with that card added
|
|
439
|
+
b.as_u64() # -> int (raw bit value)
|
|
440
|
+
b.to_cards() # -> Cards (reconstruct card set)
|
|
441
|
+
b.as_guided_string() # -> str (debug visualization)
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### `SevenFiveBCM`
|
|
445
|
+
|
|
446
|
+
A binary card map entry for a 5- or 7-card hand. Stores the hand's `Bard`, the best 5-card sub-hand's `Bard`, and the hand rank value. This is the format used by pkcore's precomputed CSV lookup table.
|
|
447
|
+
|
|
448
|
+
`rank` follows the Cactus Kev convention: **lower is stronger** (1 = royal flush, 7462 = worst high card).
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
from pkpy import Cards, SevenFiveBCM
|
|
452
|
+
|
|
453
|
+
# Build from a 5-card hand
|
|
454
|
+
bcm = SevenFiveBCM.from_cards(Cards.parse("As Ks Qs Js Ts"))
|
|
455
|
+
bcm.rank # -> 1 (royal flush)
|
|
456
|
+
bcm.bc # -> Bard (bitset of the full hand)
|
|
457
|
+
bcm.best # -> Bard (bitset of the best 5-card sub-hand; same as bc for 5 cards)
|
|
458
|
+
|
|
459
|
+
# Build from a 7-card hand — bc is the full 7-card bard, best is the best 5
|
|
460
|
+
bcm7 = SevenFiveBCM.from_cards(Cards.parse("As Ks Qs Js Ts 9s 8s"))
|
|
461
|
+
bcm7.rank # -> 1
|
|
462
|
+
bcm7.bc.to_cards() # -> Cards (7 cards)
|
|
463
|
+
bcm7.best.to_cards() # -> Cards (best 5 cards)
|
|
464
|
+
|
|
465
|
+
# CSV generation (produces the ~5 GB bcm.csv lookup file — slow)
|
|
466
|
+
SevenFiveBCM.default_csv_path # -> "generated/bcm.csv"
|
|
467
|
+
SevenFiveBCM.generate_csv("bcm.csv") # enumerate all 5- and 7-card combos
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### `IndexCardMap`
|
|
471
|
+
|
|
472
|
+
Like `SevenFiveBCM` but stores card hands as human-readable display strings instead of `Bard` bitsets. Useful for inspectable CSV output.
|
|
473
|
+
|
|
474
|
+
```python
|
|
475
|
+
from pkpy import Cards, IndexCardMap
|
|
476
|
+
|
|
477
|
+
icm = IndexCardMap.from_cards(Cards.parse("As Ks Qs Js Ts"))
|
|
478
|
+
icm.rank # -> 1
|
|
479
|
+
icm.cards # -> "A♠ K♠ Q♠ J♠ T♠"
|
|
480
|
+
icm.best # -> "A♠ K♠ Q♠ J♠ T♠" (same for 5-card hand)
|
|
481
|
+
|
|
482
|
+
icm7 = IndexCardMap.from_cards(Cards.parse("As Ks Qs Js Ts 9s 8s"))
|
|
483
|
+
icm7.cards # -> "A♠ K♠ Q♠ J♠ T♠ 9♠ 8♠" (all 7 cards)
|
|
484
|
+
icm7.best # -> "A♠ K♠ Q♠ J♠ T♠" (best 5)
|
|
485
|
+
|
|
486
|
+
IndexCardMap.generate_csv("icm.csv")
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### BCM example
|
|
490
|
+
|
|
491
|
+
```python
|
|
492
|
+
from pkpy import Cards, SevenFiveBCM, IndexCardMap
|
|
493
|
+
|
|
494
|
+
hands = [
|
|
495
|
+
Cards.parse("As Ks Qs Js Ts"), # royal flush
|
|
496
|
+
Cards.parse("As Ks Qs Js 9s"), # king-high straight flush
|
|
497
|
+
Cards.parse("As Ad Ah Ac Ks"), # four aces
|
|
498
|
+
]
|
|
499
|
+
|
|
500
|
+
for hand in hands:
|
|
501
|
+
bcm = SevenFiveBCM.from_cards(hand)
|
|
502
|
+
icm = IndexCardMap.from_cards(hand)
|
|
503
|
+
print(f"{icm.cards} rank={bcm.rank} best={icm.best}")
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## Pluribus Log Parsing
|
|
509
|
+
|
|
510
|
+
pkpy can parse hand histories from the [Pluribus](https://en.wikipedia.org/wiki/Pluribus_(poker_bot)) AI poker logs. Each line in a log file is a `STATE` record encoding one hand.
|
|
511
|
+
|
|
512
|
+
### Log format
|
|
513
|
+
|
|
514
|
+
```
|
|
515
|
+
STATE:{index}:{rounds}:{cards}:{winnings}:{players}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
- **rounds** — slash-separated betting round strings, e.g. `r200ffcfc/cr850cf`. Each character is `f` (fold), `c` (call), or `r{n}` (raise to n).
|
|
519
|
+
- **cards** — pipe-separated two-card hands, optionally followed by `/board`, e.g. `Qc4h|Tc9c|5h5d/3h7s5c/Qs/6c`.
|
|
520
|
+
- **winnings** — pipe-separated signed integers, one per player.
|
|
521
|
+
- **players** — pipe-separated player names.
|
|
522
|
+
|
|
523
|
+
### `PluribusEvent`
|
|
524
|
+
|
|
525
|
+
A single action: fold, call, or raise.
|
|
526
|
+
|
|
527
|
+
```python
|
|
528
|
+
from pkpy import Pluribus
|
|
529
|
+
|
|
530
|
+
hand = Pluribus.parse("STATE:0:ffr225fff:3c9s|6d5s|9dTs|2sQs|AdKd|7cTc:-50|-100|0|0|150|0:MrWhite|Gogo|Budd|Eddie|Bill|Pluribus")
|
|
531
|
+
|
|
532
|
+
for event in hand.actions():
|
|
533
|
+
print(event) # "Fold", "Call", "Raise(225)", etc.
|
|
534
|
+
event.is_fold() # -> bool
|
|
535
|
+
event.is_call() # -> bool
|
|
536
|
+
event.is_raise() # -> bool
|
|
537
|
+
event.raise_amount() # -> int | None
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### `Pluribus`
|
|
541
|
+
|
|
542
|
+
A parsed hand record.
|
|
543
|
+
|
|
544
|
+
```python
|
|
545
|
+
from pkpy import Pluribus
|
|
546
|
+
|
|
547
|
+
# Parse a single log line
|
|
548
|
+
hand = Pluribus.parse("STATE:27:r200ffcfc/cr850cf/cr1825r3775c/r10000c:Qc4h|Tc9c|8sAs|Qh7c|JcQd|5h5d/3h7s5c/Qs/6c:-50|-200|-10000|0|0|10250:Eddie|Bill|Pluribus|MrWhite|Gogo|Budd")
|
|
549
|
+
|
|
550
|
+
hand.index # -> 27
|
|
551
|
+
hand.players # -> ['Eddie', 'Bill', 'Pluribus', 'MrWhite', 'Gogo', 'Budd']
|
|
552
|
+
hand.winnings # -> [-50, -200, -10000, 0, 0, 10250]
|
|
553
|
+
hand.hole_cards # -> HoleCards (6 players' hands)
|
|
554
|
+
hand.board # -> Board (3h 7s 5c Qs 6c)
|
|
555
|
+
hand.raw # -> the original log line string
|
|
556
|
+
|
|
557
|
+
hand.rounds() # -> list[str] raw round strings
|
|
558
|
+
hand.actions() # -> list[PluribusEvent] all actions flat
|
|
559
|
+
hand.actions_for_round(0) # -> list[PluribusEvent] actions in round 0
|
|
560
|
+
hand.display_results() # -> str formatted winnings summary
|
|
561
|
+
|
|
562
|
+
# Parse an entire log file — invalid lines are silently skipped
|
|
563
|
+
hands = Pluribus.read_log("/path/to/pluribus.log")
|
|
564
|
+
print(f"Loaded {len(hands)} hands")
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Pluribus example
|
|
568
|
+
|
|
569
|
+
```python
|
|
570
|
+
from pkpy import Pluribus
|
|
571
|
+
|
|
572
|
+
LOG_LINE = "STATE:27:r200ffcfc/cr850cf/cr1825r3775c/r10000c:Qc4h|Tc9c|8sAs|Qh7c|JcQd|5h5d/3h7s5c/Qs/6c:-50|-200|-10000|0|0|10250:Eddie|Bill|Pluribus|MrWhite|Gogo|Budd"
|
|
573
|
+
|
|
574
|
+
hand = Pluribus.parse(LOG_LINE)
|
|
575
|
+
|
|
576
|
+
print(f"Hand #{hand.index}")
|
|
577
|
+
print(f"Players: {', '.join(hand.players)}")
|
|
578
|
+
print(f"Board: {hand.board}")
|
|
579
|
+
print(f"Hole cards dealt: {len(hand.hole_cards)} players")
|
|
580
|
+
|
|
581
|
+
raises = [e for e in hand.actions() if e.is_raise()]
|
|
582
|
+
print(f"Raises this hand: {len(raises)}")
|
|
583
|
+
for r in raises:
|
|
584
|
+
print(f" {r.raise_amount()}")
|
|
585
|
+
|
|
586
|
+
print(hand.display_results())
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
591
|
+
## Casino Table Simulation
|
|
592
|
+
|
|
593
|
+
pkpy exposes pkcore's casino table simulation layer, which models a heads-up or multi-player poker table with blinds, betting, and chip accounting. The key types are `Dealer` (the engine), `Player`, `ForcedBets`, and the log/result types.
|
|
594
|
+
|
|
595
|
+
### `ForcedBets`
|
|
596
|
+
|
|
597
|
+
Configures the blinds and optional ante for a hand.
|
|
598
|
+
|
|
599
|
+
```python
|
|
600
|
+
from pkpy import ForcedBets
|
|
601
|
+
|
|
602
|
+
bets = ForcedBets(small_blind=50, big_blind=100)
|
|
603
|
+
bets = ForcedBets(small_blind=50, big_blind=100, ante=25)
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
### `Stack`
|
|
607
|
+
|
|
608
|
+
A chip count wrapper.
|
|
609
|
+
|
|
610
|
+
```python
|
|
611
|
+
from pkpy import Stack
|
|
612
|
+
|
|
613
|
+
s = Stack(1000)
|
|
614
|
+
s.count() # -> 1000
|
|
615
|
+
s.is_empty() # -> False
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
### `Player`
|
|
619
|
+
|
|
620
|
+
A player seated at the table with a name and chip stack.
|
|
621
|
+
|
|
622
|
+
```python
|
|
623
|
+
from pkpy import Player
|
|
624
|
+
|
|
625
|
+
p = Player("Alice", 1000)
|
|
626
|
+
p.handle # -> "Alice"
|
|
627
|
+
p.chips() # -> 1000 (current stack, excluding chips committed to pot)
|
|
628
|
+
p.total_chips() # -> 1000 (chips + any committed amount)
|
|
629
|
+
p.state() # -> PlayerState
|
|
630
|
+
p.is_active() # -> bool
|
|
631
|
+
p.is_folded() # -> bool
|
|
632
|
+
p.is_all_in() # -> bool
|
|
633
|
+
p.is_sitting_out()# -> bool
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
### `PlayerState`
|
|
637
|
+
|
|
638
|
+
Describes what a player is currently doing at the table.
|
|
639
|
+
|
|
640
|
+
```python
|
|
641
|
+
state = player.state()
|
|
642
|
+
state.kind() # -> str ("Active", "Folded", "AllIn", "SittingOut")
|
|
643
|
+
state.amount() # -> int (chips committed in current state, e.g. blind amount)
|
|
644
|
+
state.is_active() # -> bool
|
|
645
|
+
state.is_folded() # -> bool
|
|
646
|
+
state.is_all_in() # -> bool
|
|
647
|
+
state.is_sitting_out()# -> bool
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
### `Seatbit`
|
|
651
|
+
|
|
652
|
+
A compact bitset of occupied seat numbers (seats 0–15).
|
|
653
|
+
|
|
654
|
+
```python
|
|
655
|
+
from pkpy import Seatbit
|
|
656
|
+
|
|
657
|
+
sb = dealer.ready()
|
|
658
|
+
sb.contains(0) # -> bool (is seat 0 occupied?)
|
|
659
|
+
sb.count() # -> int (number of occupied seats)
|
|
660
|
+
sb.as_u16() # -> int (raw bitset value)
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
### `SeatEquity`
|
|
664
|
+
|
|
665
|
+
Chip allocation tied to a set of seats — used inside `Win` to record who wins what.
|
|
666
|
+
|
|
667
|
+
```python
|
|
668
|
+
se = win.equity
|
|
669
|
+
se.chips # -> int (chip amount)
|
|
670
|
+
se.seats # -> Seatbit
|
|
671
|
+
se.count() # -> int (number of winning seats)
|
|
672
|
+
se.is_nada() # -> bool (True if chips == 0)
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
### `Win`
|
|
676
|
+
|
|
677
|
+
One entry in a `Winnings` result. Pairs an equity award with the `Eval` that justified it.
|
|
678
|
+
|
|
679
|
+
```python
|
|
680
|
+
win = winnings.first()
|
|
681
|
+
win.equity # -> SeatEquity
|
|
682
|
+
win.eval # -> Eval
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### `Winnings`
|
|
686
|
+
|
|
687
|
+
The payout result returned by `Dealer.end_hand()`.
|
|
688
|
+
|
|
689
|
+
```python
|
|
690
|
+
winnings = dealer.end_hand()
|
|
691
|
+
len(winnings) # -> int (number of pots/side-pots awarded)
|
|
692
|
+
winnings.first() # -> Win (main pot winner)
|
|
693
|
+
winnings.to_list() # -> list[Win]
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
### `TableAction`
|
|
697
|
+
|
|
698
|
+
A single event recorded in the table log.
|
|
699
|
+
|
|
700
|
+
```python
|
|
701
|
+
action = log.last()
|
|
702
|
+
action.kind() # -> str ("Bet", "Raise", "Call", "Check", "Fold", "PostBlind", etc.)
|
|
703
|
+
action.seat() # -> int (seat number that took the action)
|
|
704
|
+
action.amount() # -> int (chip amount, 0 for non-chip actions like fold/check)
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
### `TableLog`
|
|
708
|
+
|
|
709
|
+
A running record of all actions taken during the hand.
|
|
710
|
+
|
|
711
|
+
```python
|
|
712
|
+
log = dealer.event_log()
|
|
713
|
+
log.entries() # -> list[TableAction] (all recorded events)
|
|
714
|
+
log.last() # -> TableAction | None
|
|
715
|
+
log.last_player_action() # -> TableAction | None (last non-system action)
|
|
716
|
+
log.have_posted_blinds() # -> bool
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### `Dealer`
|
|
720
|
+
|
|
721
|
+
The table engine. Manages seating, hand flow, betting, and chip accounting.
|
|
722
|
+
|
|
723
|
+
```python
|
|
724
|
+
from pkpy import Dealer, ForcedBets, Player
|
|
725
|
+
|
|
726
|
+
dealer = Dealer(ForcedBets(50, 100))
|
|
727
|
+
|
|
728
|
+
# Seat players — consumes the Player object (ownership transfer)
|
|
729
|
+
seat0 = dealer.seat_player(alice) # -> int (assigned seat number)
|
|
730
|
+
seat1 = dealer.seat_player(bob)
|
|
731
|
+
|
|
732
|
+
# Hand lifecycle
|
|
733
|
+
dealer.start_hand() # post blinds, deal hole cards
|
|
734
|
+
dealer.advance_street() # deal flop / turn / river
|
|
735
|
+
winnings = dealer.end_hand() # showdown, chip transfer
|
|
736
|
+
|
|
737
|
+
# Betting actions (seat is the acting seat number)
|
|
738
|
+
dealer.bet(seat, amount)
|
|
739
|
+
dealer.call(seat)
|
|
740
|
+
dealer.check(seat)
|
|
741
|
+
dealer.raise_to(seat, amount)
|
|
742
|
+
dealer.all_in(seat)
|
|
743
|
+
dealer.fold(seat)
|
|
744
|
+
|
|
745
|
+
# State queries
|
|
746
|
+
dealer.ready() # -> Seatbit (seats with players ready to play)
|
|
747
|
+
dealer.next_to_act() # -> int | None (seat that must act next)
|
|
748
|
+
dealer.pot() # -> int (current pot total)
|
|
749
|
+
dealer.chips_at(seat) # -> int (chip count at a seat, 0 if empty)
|
|
750
|
+
dealer.event_log() # -> TableLog
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
### Casino example
|
|
754
|
+
|
|
755
|
+
```python
|
|
756
|
+
from pkpy import Dealer, ForcedBets, Player, Winnings
|
|
757
|
+
|
|
758
|
+
# Set up a heads-up table: 50/100 blinds
|
|
759
|
+
dealer = Dealer(ForcedBets(50, 100))
|
|
760
|
+
|
|
761
|
+
alice = Player("Alice", 1000)
|
|
762
|
+
bob = Player("Bob", 1000)
|
|
763
|
+
|
|
764
|
+
s_alice = dealer.seat_player(alice)
|
|
765
|
+
s_bob = dealer.seat_player(bob)
|
|
766
|
+
|
|
767
|
+
# Start the hand — posts blinds, deals hole cards
|
|
768
|
+
dealer.start_hand()
|
|
769
|
+
|
|
770
|
+
print(f"Pot after blinds: {dealer.pot()}") # -> 0 (blinds not in pot yet)
|
|
771
|
+
print(f"Next to act: {dealer.next_to_act()}") # -> seat of first actor
|
|
772
|
+
|
|
773
|
+
# Simple action: big blind checks, small blind raises, BB calls
|
|
774
|
+
acting = dealer.next_to_act()
|
|
775
|
+
dealer.call(acting) # SB calls
|
|
776
|
+
acting = dealer.next_to_act()
|
|
777
|
+
dealer.check(acting) # BB checks
|
|
778
|
+
|
|
779
|
+
# Deal the flop, turn, river
|
|
780
|
+
dealer.advance_street() # flop
|
|
781
|
+
dealer.advance_street() # turn
|
|
782
|
+
dealer.advance_street() # river
|
|
783
|
+
|
|
784
|
+
# Showdown
|
|
785
|
+
winnings = dealer.end_hand()
|
|
786
|
+
winner = winnings.first()
|
|
787
|
+
print(f"Pot won: {winner.equity.chips}")
|
|
788
|
+
print(f"Winning seat(s): {winner.equity.seats.as_u16()}")
|
|
789
|
+
|
|
790
|
+
# Inspect the action log
|
|
791
|
+
for action in dealer.event_log().entries():
|
|
792
|
+
print(f" seat {action.seat()}: {action.kind()} {action.amount() or ''}")
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
---
|
|
796
|
+
|
|
797
|
+
## Complete Example
|
|
798
|
+
|
|
799
|
+
```python
|
|
800
|
+
from pkpy import HoleCards, Board, Game, Outs
|
|
801
|
+
|
|
802
|
+
# Recreate the famous Negreanu vs Hansen hand:
|
|
803
|
+
# Daniel holds 6♠ 6♥, Gus holds 5♦ 5♣
|
|
804
|
+
# Flop: 9♣ 6♦ 5♥ — Daniel flops top set, Gus flops bottom set
|
|
805
|
+
# Turn: 5♠ — Gus rivers quads. What are the outs for each player?
|
|
806
|
+
|
|
807
|
+
hc = HoleCards.parse("6s 6h 5d 5c")
|
|
808
|
+
board = Board.parse("9c 6d 5h 5s")
|
|
809
|
+
game = Game(hc, board)
|
|
810
|
+
|
|
811
|
+
outs = Outs.from_case_evals(game.turn_case_evals())
|
|
812
|
+
|
|
813
|
+
print(f"Player 1 (Daniel, 6♠6♥) outs: {outs.len_for_player(1)}")
|
|
814
|
+
print(f"Player 2 (Gus, 5♦5♣) outs: {outs.len_for_player(2)}")
|
|
815
|
+
print(f"Leading player: {outs.longest_player()}")
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
---
|
|
819
|
+
|
|
820
|
+
## Development Setup
|
|
821
|
+
|
|
822
|
+
**Prerequisites:** Rust toolchain (`rustup`), Python 3.8+
|
|
823
|
+
|
|
824
|
+
```bash
|
|
825
|
+
# Clone and enter the project
|
|
826
|
+
git clone <repo-url> pkpy
|
|
827
|
+
cd pkpy
|
|
828
|
+
|
|
829
|
+
# Create a virtual environment
|
|
830
|
+
python3 -m venv .venv
|
|
831
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
832
|
+
|
|
833
|
+
# Install build and test tools
|
|
834
|
+
pip install maturin pytest
|
|
835
|
+
|
|
836
|
+
# Compile the Rust extension and install it into the venv
|
|
837
|
+
python3 -m maturin develop
|
|
838
|
+
|
|
839
|
+
# Run tests
|
|
840
|
+
pytest
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
After changing `src/lib.rs`, re-run `python3 -m maturin develop` to recompile. Only the Rust source is recompiled on subsequent runs — Cargo's incremental compilation keeps this fast.
|
|
844
|
+
|
|
845
|
+
### Building a Release Wheel
|
|
846
|
+
|
|
847
|
+
```bash
|
|
848
|
+
python3 -m maturin build --release
|
|
849
|
+
# Wheel lands in target/wheels/pkpy-*.whl
|
|
850
|
+
pip install target/wheels/pkpy-*.whl
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
For distribution, maturin can also publish directly to PyPI:
|
|
854
|
+
|
|
855
|
+
```bash
|
|
856
|
+
python3 -m maturin publish
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
---
|
|
860
|
+
|
|
861
|
+
## Relationship to pkcore
|
|
862
|
+
|
|
863
|
+
This project wraps pkcore as a versioned crates.io dependency. The wrapper exposes the
|
|
864
|
+
analysis-focused surface most useful from Python: card/deck primitives, hand evaluation, outs
|
|
865
|
+
calculation, GTO range analysis, heads-up equity, binary card maps, Pluribus log parsing, and
|
|
866
|
+
casino table simulation. Lower-level types (SQLite storage) are not exposed.
|
|
867
|
+
|
|
868
|
+
---
|
|
869
|
+
|
|
870
|
+
## License
|
|
871
|
+
|
|
872
|
+
GPL-3.0-or-later, matching pkcore.
|
|
873
|
+
|