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
+ [![CI](https://github.com/ImperialBower/pkpy/actions/workflows/ci.yml/badge.svg)](https://github.com/ImperialBower/pkpy/actions/workflows/ci.yml)
20
+ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](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
+