diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index c1986fe..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(curl:*)", - "Bash(python3 -m venv --help)", - "Bash(.venv/bin/maturin develop:*)", - "Bash(.venv/bin/python examples/calc.py -d \"6♠ 6♥ 5♦ 5♣\" -b \"9♣ 6♦ 5♥ 5♠ 8♠\")", - "Bash(find /Users/christoph/src/github.com/folkengine/pkcore/src -type f -name *.rs)", - "Bash(.venv/bin/python examples/gto.py -p \"K♠ K♥\" -v \"66+,AJs+,KQs,AJo+,KQo\" 2>&1)", - "Bash(.venv/bin/python examples/gto.py -p \"K♠ K♥\" -v \"AA,QQ\" -b \"9♣ 6♦ 5♥\")", - "Bash(grep:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index db4b379..7039d1b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ *.dylib *.dylib.dSYM/ Cargo.lock +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9c0535e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project tracks [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The crate version is kept in lockstep with the underlying `pkcore` dependency. + +## [0.0.54] - 2026-04-30 + +### Changed + +- Bumped `pkcore` dependency from `0.0.53` to `0.0.54`. +- Bumped `pkpy` crate version to `0.0.54` to stay in lockstep with `pkcore`. + +### Fixed (inherited from `pkcore` 0.0.54) + +No pkpy code was modified for this release, but the upstream fix changes +observable behavior on one Python-exposed method, `TableNoCell.to_call()`. + +- **Short-stacked big blind — call target now anchored to the configured BB.** + When the BB is all-in for less than the configured big blind (e.g. BB=100 + but stack=30), `TableNoCell.to_call()` now returns the full configured BB + (`100`) instead of the amount the BB physically posted (`30`). Other + players must call the full configured amount; chip conservation is + preserved at showdown via side-pot stratification (multiway) or + uncalled-bet returns (heads-up / no second contestant at that tier). + This matches standard cardroom rules (TDA, WSOP). +- **`act_call` now degrades gracefully when the caller is short.** When a + caller cannot cover the call target, the action is converted to an + all-in for the caller's remaining stack rather than erroring on + insufficient chips. Surfaced through pkpy via `PokerSession.apply_action`. +- **`min_raise` stays anchored to the configured BB** even when the BB is + all-in for less. Prior behavior could allow under-sized raises in the + short-BB scenario. + +### Migration notes + +- Public Rust API of `pkcore` is **unchanged**. No method signatures, types, + or imports moved. pkpy compiles clean against `pkcore 0.0.54` with no + source changes. +- If you have Python code that asserts specific chip math against a + short-stacked-BB scenario built on pkcore 0.0.53 semantics, those + assertions will need to be updated. The pkpy test suite does not + currently exercise this scenario, so the in-tree tests remain green. + +--- + +Earlier releases pre-date this changelog. See `git log` for prior history. diff --git a/Cargo.toml b/Cargo.toml index 71163c9..92ca15f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pkpy" -version = "0.0.39" +version = "0.0.54" edition = "2021" description = "Python bindings for pkcore, a high-performance poker analysis library" license = "GPL-3.0-or-later" @@ -11,7 +11,7 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.28", features = ["extension-module"] } -pkcore = "0.0.39" +pkcore = "0.0.54" [profile.release] lto = true diff --git a/docs/superpowers/plans/2026-04-28-pkpy-0.0.52-upgrade.md b/docs/superpowers/plans/2026-04-28-pkpy-0.0.52-upgrade.md new file mode 100644 index 0000000..887a3a6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-pkpy-0.0.52-upgrade.md @@ -0,0 +1,1742 @@ +# pkpy 0.0.52 Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade `pkcore` 0.0.39 → 0.0.52 in `pkpy`, fix the only material breakage point, then bind the major new pkcore surface (bot module, hand history, player stats, poker session, no-cell table) with the existing 1:1 wrapper pattern, splitting `lib.rs` into per-domain modules along the way. + +**Architecture:** Newtype-wrapper pattern continues unchanged: each pkcore type gets a `#[pyclass(name = "X")]` Rust newtype `X(Pk)` with `#[pymethods]`. New domains live in their own `src/.rs` files, each exporting a `pub(crate) fn register(m: &Bound) -> PyResult<()>` that the top-level `#[pymodule]` in `lib.rs` calls. Existing wrappers stay in `lib.rs` to keep diff size manageable. Python `__init__.py` re-exports every new class. + +**Tech Stack:** Rust 1.94+ (edition 2021), pyo3 0.28, maturin 1.7+, pkcore 0.0.52 (default features: `bot-profiles`, `hand-histories`, `player-stats`, `player-stats-persistence`), pytest. + +**Reference spec:** `docs/superpowers/specs/2026-04-28-pkpy-0.0.52-upgrade-design.md` + +--- + +## File Map + +| File | New / Modified | Responsibility | +|---|---|---| +| `Cargo.toml` | Modified | bump pkcore + pkpy versions | +| `python/pkpy/__init__.py` | Modified | export new classes | +| `src/lib.rs` | Modified | add `mod` declarations, register new classes, add explicit TableAction arms | +| `src/hand_history.rs` | New | bind `HandHistory`, `HandCollection`, streets, `Action`, `Outcome`, etc. | +| `src/stats.rs` | New | bind `PlayerStats`, `StatsRegistry`, `Confidence`, `YamlPlayerStatsStore` | +| `src/bot.rs` | New | bind `BotProfile`, `Playbook`, deciders, `BotSim`, etc. | +| `src/session.rs` | New | bind `PokerSession` | +| `src/table_no_cell.rs` | New | bind `TableNoCell`, `SeatsNoCell`, `SeatNoCell`, `PlayerNoCell` | +| `tests/test_pkpy.py` | Touched only if a test breaks | existing surface | +| `tests/test_hand_history.py` | New | round-trip + walk + collection | +| `tests/test_stats.py` | New | register, ingest, query, persist | +| `tests/test_bot.py` | New | profile + playbook + decide + sim smoke | +| `tests/test_session.py` | New | start hand, run, inspect log | +| `tests/test_table_no_cell.py` | New | heads-up parity vs `Dealer` | + +--- + +## Conventions + +**Build/test gate after every task:** `make build && make test`. The existing Makefile rebuilds the maturin extension into the venv and runs pytest against it. + +**Binding pattern (canonical, copy from existing wrappers):** + +```rust +use pkcore:::: as Pk; + +/// Doc. +#[pyclass(from_py_object, name = "")] +#[derive(Clone)] +pub struct (pub(crate) Pk); + +#[pymethods] +impl { + #[new] + fn new(/* args */) -> PyResult { /* ... */ } + + #[staticmethod] + fn parse(s: &str) -> PyResult { + Pk::from_str(s).map().map_err(to_py_err) + } + + fn __repr__(&self) -> String { format!("{}", self.0) } + fn __str__(&self) -> String { format!("{}", self.0) } +} +``` + +`to_py_err` is already defined at `lib.rs:61`. Re-export it from `lib.rs` as `pub(crate) fn to_py_err` so child modules can use it (this is a tiny modification done in Phase 2). + +**Each module file ends with:** + +```rust +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +**Test convention** (from `tests/test_pkpy.py`): + +```python +class TestThing: + def test_specific_behavior(self): + thing = Thing.parse("...") + assert thing.method() == expected +``` + +**Commit cadence:** one commit per task. Stage only the files the task touched. Conventional commit prefix: `feat:` for new bindings, `chore:` for dep bumps, `refactor:` for code reorgs, `test:` for test-only additions, `fix:` for bug fixes. + +**IMPORTANT: per project rules, the implementing agent must not run `git commit` itself.** Each "Commit" step in this plan should be presented to the user as a ready-to-commit set of files (paths + suggested message). The user creates every commit themselves. + +--- + +## Phase 1 — Dep Bump + +### Task 1: Bump pkcore and pkpy versions + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: Edit Cargo.toml** + +```toml +[package] +name = "pkpy" +version = "0.0.52" +edition = "2021" +description = "Python bindings for pkcore, a high-performance poker analysis library" +license = "GPL-3.0-or-later" + +[lib] +name = "pkpy" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.28", features = ["extension-module"] } +pkcore = "0.0.52" + +[profile.release] +lto = true +codegen-units = 1 +opt-level = 3 +``` + +(Only the `version` field on package and the `pkcore` dependency change. Default features are inherited — no `default-features = false` line.) + +- [ ] **Step 2: Build to verify the wildcard absorbs new variants** + +Run: `make build` +Expected: clean build. The existing `_ => "Other".into()` wildcard at `lib.rs:2725` absorbs the new `TableAction::ChipAuditFailed` variant; no source change needed yet. + +- [ ] **Step 3: Run existing tests** + +Run: `make test` +Expected: all 1136 lines of `tests/test_pkpy.py` pass. None of them assert on the new behavior of `Player::act_bet_blind` or new variants. + +- [ ] **Step 4: Stage for commit** + +``` +git add Cargo.toml Cargo.lock +``` + +Commit message: `chore: bump pkcore to 0.0.52 and pkpy to 0.0.52` + +### Task 2: Add explicit ChipAuditFailed and ResetTable arms in TableAction.kind() + +**Files:** +- Modify: `src/lib.rs:2724-2725` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_pkpy.py` in the existing `TestTableAction` class (or create one if absent — search the file for `TableAction` class scope; if a `TestTableAction` exists, append; otherwise create at the bottom of the file): + +```python +class TestTableActionKind: + def test_chip_audit_failed_kind_is_explicit(self): + # We can't construct a ChipAuditFailed from Python directly; this test + # documents the expected return string for when one shows up via TableLog. + # The assertion is on the *match arm* — verified indirectly by ensuring + # the kind() method returns "ChipAuditFailed" rather than "Other" for + # a forged log entry. If pkcore ever exposes a constructor we can wire + # it in; for now this test asserts via the existing test_pkpy.py + # fixtures that a ResetTable kind is also "ResetTable" not "Other". + pass +``` + +If no path exists today to construct these variants from Python, this task degrades to a Rust-side smoke check. Add at the bottom of `src/lib.rs` (within `mod` if a tests module exists, otherwise inline inside the impl): + +```rust +#[cfg(test)] +mod table_action_kind_tests { + use super::*; + + #[test] + fn chip_audit_failed_returns_explicit_kind() { + let ta = TableAction(PkTableAction::ChipAuditFailed(100, 99)); + assert_eq!("ChipAuditFailed", ta.kind()); + } + + #[test] + fn reset_table_returns_explicit_kind() { + let ta = TableAction(PkTableAction::ResetTable); + assert_eq!("ResetTable", ta.kind()); + } +} +``` + +- [ ] **Step 2: Run test, expect failure** + +Run: `cargo test table_action_kind_tests` +Expected: both tests FAIL — the wildcard returns `"Other"`. + +- [ ] **Step 3: Add the explicit arms** + +In `src/lib.rs`, locate the `TableAction.kind()` match (currently `lib.rs:2685–2725`). Insert the two new arms above the wildcard `_ => "Other".into()` line: + +```rust + PkTableAction::EndHand => "EndHand".into(), + PkTableAction::ChipAuditFailed(_, _) => "ChipAuditFailed".into(), + PkTableAction::ResetTable => "ResetTable".into(), + _ => "Other".into(), +``` + +- [ ] **Step 4: Run test, expect pass** + +Run: `cargo test table_action_kind_tests` +Expected: both tests PASS. + +- [ ] **Step 5: Run full test suite** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/lib.rs +``` + +Commit message: `feat: surface ChipAuditFailed and ResetTable in TableAction.kind()` + +--- + +## Phase 2 — Module Split Scaffold + +### Task 3: Make `to_py_err` and `PkTableAction` re-imports available across modules + +**Files:** +- Modify: `src/lib.rs` + +- [ ] **Step 1: Promote `to_py_err` from `fn` to `pub(crate) fn`** + +Edit `src/lib.rs:61`: + +```rust +pub(crate) fn to_py_err(e: impl std::fmt::Display) -> PyErr { + PyValueError::new_err(e.to_string()) +} +``` + +- [ ] **Step 2: Build to confirm no regressions** + +Run: `make build` +Expected: clean build. + +- [ ] **Step 3: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 4: Stage for commit** + +``` +git add src/lib.rs +``` + +Commit message: `refactor: expose to_py_err to sibling modules` + +### Task 4: Scaffold five empty domain modules + +**Files:** +- Create: `src/hand_history.rs` +- Create: `src/stats.rs` +- Create: `src/bot.rs` +- Create: `src/session.rs` +- Create: `src/table_no_cell.rs` +- Modify: `src/lib.rs` + +- [ ] **Step 1: Create each empty module with a no-op `register`** + +Same content for each of the five files (substitute the file name into the doc comment): + +```rust +//! Bindings for pkcore's module. + +use pyo3::prelude::*; + +pub(crate) fn register(_m: &Bound<'_, PyModule>) -> PyResult<()> { + Ok(()) +} +``` + +For each file replace `` with: `hand_history`, `analysis::player_stats`, `bot`, `casino::session`, `casino::table_no_cell` respectively. + +- [ ] **Step 2: Wire the modules into `src/lib.rs`** + +Near the top of `src/lib.rs` (after the existing `use` statements, before the first wrapper definition), add: + +```rust +mod bot; +mod hand_history; +mod session; +mod stats; +mod table_no_cell; +``` + +In the `#[pymodule] fn _pkpy(...)` body at the bottom of `src/lib.rs`, just before the final `Ok(())`, add: + +```rust + hand_history::register(m)?; + stats::register(m)?; + bot::register(m)?; + session::register(m)?; + table_no_cell::register(m)?; +``` + +- [ ] **Step 3: Build** + +Run: `make build` +Expected: clean build (modules are no-ops). + +- [ ] **Step 4: Run existing tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 5: Stage for commit** + +``` +git add src/lib.rs src/hand_history.rs src/stats.rs src/bot.rs src/session.rs src/table_no_cell.rs +``` + +Commit message: `refactor: scaffold per-domain binding modules` + +--- + +## Phase 3 — Hand History Bindings + +**Reference:** pkcore source at `src/hand_history.rs` in the `pkcore` crate (≈3,627 lines). All types listed in this phase are exported from `pkcore::prelude`. Read the relevant Rust struct/enum definition before writing each binding. + +### Task 5: Discovery — read pkcore's hand_history surface + +**Files:** +- (Read-only; no file changes) + +- [ ] **Step 1: Inspect each public type's signature** + +Run, from the pkpy repo root: + +```bash +cargo doc --no-deps -p pkcore && open target/doc/pkcore/hand_history/index.html +``` + +Or inspect source directly: + +```bash +find ~/.cargo/registry/src -name 'pkcore-0.0.52' -type d | head -1 +``` + +Cd into the resulting directory and read `src/hand_history.rs`. + +- [ ] **Step 2: Note for each type** + +For each of `HandHistory`, `HandCollection`, `HandMeta`, `HandVariant`, `Stakes`, `TableInfo`, `PlayerEntry`, `ResultEntry`, `PostedBlind`, `Streets`, `PreflopStreet`, `FlopStreet`, `TurnStreet`, `RiverStreet`, `Action`, `ActionType`, `Outcome`, `AnalysisContext`: + +- struct or enum? +- public fields (for structs) or variants (for enums)? +- public methods worth exposing (`new`, `parse`, `from_*`, getters)? +- Display/FromStr impls? +- Serde impls (yes for hand_history — used for YAML round-trip)? + +This is a reading task. No code changes. Output is mental model used by Tasks 6–10. + +- [ ] **Step 3: No commit (read-only task)** + +### Task 6: Bind primitive enums — `ActionType`, `Outcome`, `HandVariant` + +**Files:** +- Create: `tests/test_hand_history.py` +- Modify: `src/hand_history.rs` + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_hand_history.py` with: + +```python +"""Tests for pkpy hand_history bindings.""" + +import pytest +from pkpy import ActionType, Outcome, HandVariant + + +class TestActionType: + def test_known_variants_have_kinds(self): + # ActionType is an enum; pkpy exposes string round-trip via parse + str + for name in ["fold", "check", "call", "bet", "raise"]: + assert isinstance(ActionType.parse(name), ActionType) + + def test_unknown_raises(self): + with pytest.raises(ValueError): + ActionType.parse("not_an_action") + + +class TestOutcome: + def test_fold_is_constructible(self): + assert isinstance(Outcome.parse("fold"), Outcome) + + +class TestHandVariant: + def test_nlh_is_constructible(self): + assert isinstance(HandVariant.parse("NLH"), HandVariant) +``` + +(If pkcore's actual variant strings differ from what's listed above, adjust to match — discovery in Task 5 dictates.) + +- [ ] **Step 2: Run tests, expect ImportError or NameError** + +Run: `make test` +Expected: failure on import — `ActionType`, `Outcome`, `HandVariant` not yet exported from `pkpy`. + +- [ ] **Step 3: Implement bindings** + +Edit `src/hand_history.rs`: + +```rust +//! Bindings for pkcore's hand_history module. + +use crate::to_py_err; +use pkcore::hand_history::{ + ActionType as PkActionType, HandVariant as PkHandVariant, Outcome as PkOutcome, +}; +use pyo3::prelude::*; +use std::str::FromStr; + +#[pyclass(from_py_object, name = "ActionType")] +#[derive(Clone)] +pub struct ActionType(pub(crate) PkActionType); + +#[pymethods] +impl ActionType { + #[staticmethod] + fn parse(s: &str) -> PyResult { + PkActionType::from_str(s).map(ActionType).map_err(to_py_err) + } + + fn __repr__(&self) -> String { + format!("ActionType({})", self.0) + } + + fn __str__(&self) -> String { + format!("{}", self.0) + } +} + +#[pyclass(from_py_object, name = "Outcome")] +#[derive(Clone)] +pub struct Outcome(pub(crate) PkOutcome); + +#[pymethods] +impl Outcome { + #[staticmethod] + fn parse(s: &str) -> PyResult { + PkOutcome::from_str(s).map(Outcome).map_err(to_py_err) + } + + fn __repr__(&self) -> String { + format!("Outcome({})", self.0) + } + + fn __str__(&self) -> String { + format!("{}", self.0) + } +} + +#[pyclass(from_py_object, name = "HandVariant")] +#[derive(Clone)] +pub struct HandVariant(pub(crate) PkHandVariant); + +#[pymethods] +impl HandVariant { + #[staticmethod] + fn parse(s: &str) -> PyResult { + PkHandVariant::from_str(s).map(HandVariant).map_err(to_py_err) + } + + fn __repr__(&self) -> String { + format!("HandVariant({})", self.0) + } + + fn __str__(&self) -> String { + format!("{}", self.0) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +If any of `PkActionType`, `PkOutcome`, `PkHandVariant` does not implement `FromStr` (read pkcore source to confirm), replace `parse` with constructor functions matching what pkcore actually offers (e.g., `Outcome.fold()`, `Outcome.win()`). + +- [ ] **Step 4: Update `python/pkpy/__init__.py`** + +Add to the import list (alphabetized): + +```python +from pkpy._pkpy import ( + ActionType, + ... + HandVariant, + ... + Outcome, + ... +) +``` + +And to `__all__` in alphabetical order. + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS for the new tests; existing tests continue to pass. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/hand_history.rs python/pkpy/__init__.py tests/test_hand_history.py +``` + +Commit message: `feat: bind hand_history primitive enums (ActionType, Outcome, HandVariant)` + +### Task 7: Bind `Action` and `PostedBlind` + +**Files:** +- Modify: `tests/test_hand_history.py` +- Modify: `src/hand_history.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_hand_history.py`: + +```python +class TestAction: + def test_construct_check_action(self): + # An Action has at minimum: action_type, seat, amount; signature + # determined by pkcore's Action struct fields. + a = Action(seat=1, action_type=ActionType.parse("check"), amount=0) + assert a.seat() == 1 + assert a.amount() == 0 + + +class TestPostedBlind: + def test_construct(self): + b = PostedBlind(seat=2, amount=50) + assert b.seat() == 2 + assert b.amount() == 50 +``` + +(Adjust constructor args after Task 5 discovery confirms the actual `Action::new` / `PostedBlind::new` signatures or public fields.) + +Add `Action`, `PostedBlind` to the module's import block at the top. + +- [ ] **Step 2: Run tests, expect failure** + +Run: `make test` +Expected: ImportError on `Action`, `PostedBlind`. + +- [ ] **Step 3: Implement bindings** + +Following the same wrapper pattern, add `Action(PkAction)` and `PostedBlind(PkPostedBlind)` to `src/hand_history.rs`. Constructor args match pkcore's struct fields. Each gets `seat()`, `amount()`, and `__repr__`/`__str__` accessors. Register both in `register(...)`. + +If pkcore's `Action` has additional fields (e.g., `player_id: Option`, `street: Street`), expose getters for each as `Option<...>` returns. + +- [ ] **Step 4: Update `__init__.py`** + +Add `Action`, `PostedBlind` to the imports and `__all__`. + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/hand_history.rs python/pkpy/__init__.py tests/test_hand_history.py +``` + +Commit message: `feat: bind hand_history Action and PostedBlind` + +### Task 8: Bind street types — `PreflopStreet`, `FlopStreet`, `TurnStreet`, `RiverStreet`, `Streets` + +**Files:** +- Modify: `tests/test_hand_history.py` +- Modify: `src/hand_history.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_hand_history.py`: + +```python +class TestStreets: + def test_empty_streets_has_no_actions(self): + s = Streets() + assert s.preflop_action_count() == 0 + assert s.flop_action_count() == 0 + + def test_walk_streets_after_construction(self): + # Construct from a known event log if pkcore offers a builder + # (Streets::from_event_log). If not, leave this as a stub asserting + # that Streets() exists and is iterable. + s = Streets() + assert s is not None +``` + +(Update once pkcore's actual `Streets` constructor signature is known — e.g., `Streets::from_event_log(log)` or `Streets::default()`.) + +- [ ] **Step 2: Run tests, expect failure** + +Run: `make test` +Expected: ImportError on `Streets`. + +- [ ] **Step 3: Implement bindings** + +Add five wrappers to `src/hand_history.rs`: + +- `PreflopStreet(PkPreflopStreet)` +- `FlopStreet(PkFlopStreet)` +- `TurnStreet(PkTurnStreet)` +- `RiverStreet(PkRiverStreet)` +- `Streets(PkStreets)` + +For `Streets`, expose: +- A `#[new]` constructor that returns `Streets(PkStreets::default())` if `Default` is implemented; otherwise mirror pkcore's primary constructor. +- Counter accessors (`preflop_action_count`, `flop_action_count`, etc.) returning `usize`. +- Action-list accessors returning `Vec` for each street. + +For each `*Street` wrapper, expose `actions() -> Vec` and any street-level metadata (board cards for flop, the new card for turn/river). + +Register all five in `register`. + +- [ ] **Step 4: Update `__init__.py`** + +Add the five class names to imports and `__all__`. + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/hand_history.rs python/pkpy/__init__.py tests/test_hand_history.py +``` + +Commit message: `feat: bind hand_history streets` + +### Task 9: Bind `HandMeta`, `Stakes`, `TableInfo`, `PlayerEntry`, `ResultEntry`, `AnalysisContext` + +**Files:** +- Modify: `tests/test_hand_history.py` +- Modify: `src/hand_history.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests** (one short test per type, asserting construction and one accessor each) + +```python +class TestStakes: + def test_construct(self): + s = Stakes(small_blind=1, big_blind=2, ante=0) + assert s.big_blind() == 2 + + +class TestTableInfo: + def test_construct(self): + t = TableInfo(name="Table 1", max_seats=6) + assert t.max_seats() == 6 + + +class TestPlayerEntry: + def test_construct(self): + p = PlayerEntry(seat=0, handle="Alice", starting_stack=1000) + assert p.handle() == "Alice" + + +class TestResultEntry: + def test_construct(self): + # pkcore's ResultEntry shape determines this constructor — use what + # cargo doc / src reveals. + r = ResultEntry(seat=0, net=100) + assert r.net() == 100 + + +class TestHandMeta: + def test_construct(self): + m = HandMeta(hand_id="h-1", variant=HandVariant.parse("NLH")) + assert m.hand_id() == "h-1" + + +class TestAnalysisContext: + def test_default_construct(self): + ctx = AnalysisContext() + assert ctx is not None +``` + +- [ ] **Step 2: Run, expect ImportError** + +Run: `make test` +Expected: failure. + +- [ ] **Step 3: Implement bindings** + +Six wrappers, same pattern. Constructor fields match pkcore. Add a `__repr__` for each. Register all six. + +- [ ] **Step 4: Update `__init__.py`** + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/hand_history.rs python/pkpy/__init__.py tests/test_hand_history.py +``` + +Commit message: `feat: bind hand_history metadata and result types` + +### Task 10: Bind `HandHistory`, `HandCollection`, and `FORMAT_VERSION` + +**Files:** +- Modify: `tests/test_hand_history.py` +- Modify: `src/hand_history.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests with YAML round-trip** + +```python +import tempfile +import os + + +class TestHandHistory: + def test_construct_minimal(self): + meta = HandMeta(hand_id="h-1", variant=HandVariant.parse("NLH")) + hh = HandHistory(meta=meta) + assert hh.hand_id() == "h-1" + + def test_yaml_round_trip(self, tmp_path): + meta = HandMeta(hand_id="h-2", variant=HandVariant.parse("NLH")) + hh = HandHistory(meta=meta) + path = str(tmp_path / "hand.yaml") + hh.save_yaml(path) + loaded = HandHistory.load_yaml(path) + assert loaded.hand_id() == "h-2" + + +class TestHandCollection: + def test_empty_collection(self): + c = HandCollection() + assert c.len() == 0 + + def test_append_and_iterate(self): + c = HandCollection() + meta = HandMeta(hand_id="h-3", variant=HandVariant.parse("NLH")) + c.append(HandHistory(meta=meta)) + assert c.len() == 1 + + +class TestFormatVersion: + def test_format_version_is_string(self): + from pkpy import FORMAT_VERSION + assert isinstance(FORMAT_VERSION, str) + assert len(FORMAT_VERSION) > 0 +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `make test` +Expected: ImportError. + +- [ ] **Step 3: Implement bindings** + +Add `HandHistory(PkHandHistory)` with: +- `#[new]` constructor matching pkcore's primary constructor +- `hand_id()`, `meta()`, `streets()`, `players()`, `results()` getters +- `save_yaml(path: &str) -> PyResult<()>` — uses pkcore's persistence API (`serde_yaml_bw::to_writer` against a `File`, or whatever pkcore exposes) +- `load_yaml(path: &str) -> PyResult` — staticmethod +- `__repr__` + +Add `HandCollection(PkHandCollection)` with `len`, `append(HandHistory)`, `iter()` returning `Vec`, and YAML save/load. + +Expose `FORMAT_VERSION` as a module-level string: + +```rust +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + // ... existing add_class calls ... + m.add_class::()?; + m.add_class::()?; + m.add("FORMAT_VERSION", pkcore::hand_history::FORMAT_VERSION)?; + Ok(()) +} +``` + +- [ ] **Step 4: Update `__init__.py`** + +Add `HandHistory`, `HandCollection`, `FORMAT_VERSION` to imports and `__all__`. + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS, including the YAML round-trip. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/hand_history.rs python/pkpy/__init__.py tests/test_hand_history.py +``` + +Commit message: `feat: bind HandHistory, HandCollection and FORMAT_VERSION` + +--- + +## Phase 4 — Player Stats Bindings + +### Task 11: Discovery — read pkcore's player_stats surface + +**Files:** +- (Read-only) + +- [ ] **Step 1: Read pkcore source** + +Read `pkcore/src/analysis/player_stats.rs` and `pkcore/src/analysis/player_stats_store.rs`. Note signatures for: + +- `Confidence` (enum: variants and Display impl) +- `PlayerStats` (struct fields: hands_dealt, hands_played, vpip, pfr, etc., depending on what pkcore actually tracks) +- `StatsRegistry` (constructor, `ingest_hand(&HandHistory)`, `get(player_id) -> Option<&PlayerStats>`, `iter`) +- `PlayerStatsStore` (trait — skip) +- `YamlPlayerStatsStore` (struct: `new(path)`, `save(&StatsRegistry)`, `load() -> Result`) + +- [ ] **Step 2: No commit (read-only)** + +### Task 12: Bind `Confidence` and `PlayerStats` + +**Files:** +- Create: `tests/test_stats.py` +- Modify: `src/stats.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_stats.py`: + +```python +"""Tests for pkpy player_stats bindings.""" + +import pytest +from pkpy import Confidence, PlayerStats + + +class TestConfidence: + def test_low_med_high(self): + for level in ["low", "med", "high"]: + assert isinstance(Confidence.parse(level), Confidence) + + def test_invalid_raises(self): + with pytest.raises(ValueError): + Confidence.parse("ultra") + + +class TestPlayerStats: + def test_default_is_zeroed(self): + ps = PlayerStats() + assert ps.hands_dealt() == 0 + assert ps.hands_played() == 0 +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `make test` +Expected: ImportError. + +- [ ] **Step 3: Implement bindings** + +Edit `src/stats.rs`: + +```rust +//! Bindings for pkcore's analysis::player_stats module. + +use crate::to_py_err; +use pkcore::analysis::player_stats::{ + Confidence as PkConfidence, PlayerStats as PkPlayerStats, +}; +use pyo3::prelude::*; +use std::str::FromStr; + +#[pyclass(from_py_object, name = "Confidence")] +#[derive(Clone)] +pub struct Confidence(pub(crate) PkConfidence); + +#[pymethods] +impl Confidence { + #[staticmethod] + fn parse(s: &str) -> PyResult { + PkConfidence::from_str(s).map(Confidence).map_err(to_py_err) + } + + fn __str__(&self) -> String { format!("{}", self.0) } + fn __repr__(&self) -> String { format!("Confidence({})", self.0) } +} + +#[pyclass(from_py_object, name = "PlayerStats")] +#[derive(Clone)] +pub struct PlayerStats(pub(crate) PkPlayerStats); + +#[pymethods] +impl PlayerStats { + #[new] + fn new() -> Self { + PlayerStats(PkPlayerStats::default()) + } + + fn hands_dealt(&self) -> usize { self.0.hands_dealt } + fn hands_played(&self) -> usize { self.0.hands_played } + // Add accessors for every other public field (vpip, pfr, etc.) discovered + // in Task 11. One getter per field, returning the field's type. + + fn __repr__(&self) -> String { format!("PlayerStats({})", self.0) } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +If `PkPlayerStats` does not implement `Display`, drop the `__repr__` body or substitute `format!("PlayerStats(...)")`. + +- [ ] **Step 4: Update `__init__.py`** + +Add `Confidence`, `PlayerStats` to imports and `__all__`. + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/stats.rs python/pkpy/__init__.py tests/test_stats.py +``` + +Commit message: `feat: bind Confidence and PlayerStats` + +### Task 13: Bind `StatsRegistry` + +**Files:** +- Modify: `tests/test_stats.py` +- Modify: `src/stats.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_stats.py`: + +```python +from pkpy import StatsRegistry, HandHistory, HandMeta, HandVariant + + +class TestStatsRegistry: + def test_empty_registry_returns_none(self): + r = StatsRegistry() + assert r.get("nonexistent-id") is None + + def test_ingest_increments_hands_dealt(self): + r = StatsRegistry() + meta = HandMeta(hand_id="h-1", variant=HandVariant.parse("NLH")) + hh = HandHistory(meta=meta) + r.ingest_hand(hh) + # Player ids will depend on hh.players() — refine after Task 11/12. +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `make test` +Expected: ImportError. + +- [ ] **Step 3: Implement bindings** + +Add to `src/stats.rs`: + +```rust +use pkcore::analysis::player_stats::StatsRegistry as PkStatsRegistry; +use crate::hand_history::HandHistory; + +#[pyclass(from_py_object, name = "StatsRegistry")] +pub struct StatsRegistry(pub(crate) PkStatsRegistry); + +#[pymethods] +impl StatsRegistry { + #[new] + fn new() -> Self { + StatsRegistry(PkStatsRegistry::default()) + } + + fn ingest_hand(&mut self, hh: &HandHistory) -> PyResult<()> { + self.0.ingest_hand(&hh.0).map_err(to_py_err) + } + + fn get(&self, player_id: &str) -> Option { + self.0.get(player_id).cloned().map(PlayerStats) + } + + fn len(&self) -> usize { self.0.len() } + + fn __repr__(&self) -> String { format!("StatsRegistry(len={})", self.0.len()) } +} +``` + +(Adjust `ingest_hand`'s argument type and return type to match pkcore's actual signature — it might take `&HandHistory` or `&PkStatsMidHand` mid-hand events. Confirm during Task 11.) + +Update `register` to add `StatsRegistry`. + +- [ ] **Step 4: Update `__init__.py`** + +Add `StatsRegistry` to imports and `__all__`. + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/stats.rs python/pkpy/__init__.py tests/test_stats.py +``` + +Commit message: `feat: bind StatsRegistry` + +### Task 14: Bind `YamlPlayerStatsStore` (persistence) + +**Files:** +- Modify: `tests/test_stats.py` +- Modify: `src/stats.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_stats.py`: + +```python +from pkpy import YamlPlayerStatsStore + + +class TestYamlPlayerStatsStore: + def test_save_and_load_round_trip(self, tmp_path): + path = str(tmp_path / "stats.yaml") + registry = StatsRegistry() + store = YamlPlayerStatsStore(path) + store.save(registry) + loaded = store.load() + assert loaded.len() == 0 +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `make test` +Expected: ImportError. + +- [ ] **Step 3: Implement binding** + +Add to `src/stats.rs`: + +```rust +use pkcore::analysis::player_stats_store::YamlPlayerStatsStore as PkYamlStore; + +#[pyclass(name = "YamlPlayerStatsStore")] +pub struct YamlPlayerStatsStore(pub(crate) PkYamlStore); + +#[pymethods] +impl YamlPlayerStatsStore { + #[new] + fn new(path: &str) -> Self { + YamlPlayerStatsStore(PkYamlStore::new(path)) + } + + fn save(&self, registry: &StatsRegistry) -> PyResult<()> { + self.0.save(®istry.0).map_err(to_py_err) + } + + fn load(&self) -> PyResult { + self.0.load().map(StatsRegistry).map_err(to_py_err) + } +} +``` + +Update `register` to add `YamlPlayerStatsStore`. + +- [ ] **Step 4: Update `__init__.py`** + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS, including round-trip. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/stats.rs python/pkpy/__init__.py tests/test_stats.py +``` + +Commit message: `feat: bind YamlPlayerStatsStore for persistence` + +--- + +## Phase 5 — Bot Bindings + +### Task 15: Discovery — read pkcore's bot module + +**Files:** +- (Read-only) + +- [ ] **Step 1: Read pkcore source** + +Read each of: +- `src/bot/profile.rs` — `BotProfile`, `PlayStyle` +- `src/bot/playbook.rs` — `Playbook`, `PlaybookEntry` +- `src/bot/position_ranges.rs` — `PositionRanges`, `ActionRanges` +- `src/bot/positional_betting.rs` — `PositionalBetting` +- `src/bot/betting_strategy.rs` — `BettingStrategy` +- `src/bot/range_strategy.rs` — `RangeStrategy` +- `src/bot/weighted_range.rs` — `WeightedRange`, `ComboWeight` +- `src/bot/table_size.rs` — `TableSize` +- `src/bot/table_snapshot.rs` — `TableSnapshot` +- `src/bot/decider.rs` — `RuleBasedDecider` (note: `decide` returns what?) +- `src/bot/sim.rs` — `BotSim` (note: `run_n_hands` signature) +- `src/casino/action.rs` — `PlayerAction` + +For each, note the constructor, key methods, and any builder pattern (e.g., `.with_playbook(...)`). + +- [ ] **Step 2: No commit (read-only)** + +### Task 16: Bind `PlayStyle`, `TableSize`, `BettingStrategy`, `RangeStrategy`, `PlayerAction` + +**Files:** +- Create: `tests/test_bot.py` +- Modify: `src/bot.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_bot.py`: + +```python +"""Tests for pkpy bot bindings.""" + +import pytest +from pkpy import PlayStyle, TableSize, BettingStrategy, RangeStrategy, PlayerAction + + +class TestPlayStyle: + def test_built_in_styles(self): + for name in ["gto", "tight_passive", "loose_aggressive", "maniac"]: + assert isinstance(PlayStyle.parse(name), PlayStyle) + + +class TestTableSize: + def test_heads_up(self): + ts = TableSize.parse("heads_up") + assert isinstance(ts, TableSize) + + +class TestBettingStrategy: + def test_default(self): + bs = BettingStrategy() + assert bs is not None + + +class TestRangeStrategy: + def test_default(self): + rs = RangeStrategy() + assert rs is not None + + +class TestPlayerAction: + def test_fold(self): + a = PlayerAction.parse("fold") + assert isinstance(a, PlayerAction) +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `make test` +Expected: ImportError. + +- [ ] **Step 3: Implement bindings** + +Edit `src/bot.rs` (header + five wrappers, one block each, all using the pattern from Phase 3): + +```rust +//! Bindings for pkcore's bot module. + +use crate::to_py_err; +use pkcore::bot::betting_strategy::BettingStrategy as PkBettingStrategy; +use pkcore::bot::profile::PlayStyle as PkPlayStyle; +use pkcore::bot::range_strategy::RangeStrategy as PkRangeStrategy; +use pkcore::bot::table_size::TableSize as PkTableSize; +use pkcore::casino::action::PlayerAction as PkPlayerAction; +use pyo3::prelude::*; +use std::str::FromStr; + +// PlayStyle, TableSize, BettingStrategy, RangeStrategy, PlayerAction wrappers +// follow the same `from_py_object` newtype shape as in src/hand_history.rs. +// Each gets `#[new]` (or `#[staticmethod] parse`), `__str__`, `__repr__`, +// plus exposed accessors for any public fields/methods uncovered in Task 15. + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +Fill in each wrapper following the canonical pattern from Phase 3 Task 6. Each one is a `pub struct X(PkX)` with `#[pyclass]` + `#[pymethods]`. + +- [ ] **Step 4: Update `__init__.py`** + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/bot.rs python/pkpy/__init__.py tests/test_bot.py +``` + +Commit message: `feat: bind bot primitives (PlayStyle, TableSize, BettingStrategy, RangeStrategy, PlayerAction)` + +### Task 17: Bind `WeightedRange`, `ComboWeight`, `PositionRanges`, `ActionRanges`, `PositionalBetting`, `Playbook`, `PlaybookEntry` + +**Files:** +- Modify: `tests/test_bot.py` +- Modify: `src/bot.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests** (one per type, each asserting construction + one accessor) + +```python +from pkpy import ( + WeightedRange, + ComboWeight, + PositionRanges, + ActionRanges, + PositionalBetting, + Playbook, + PlaybookEntry, +) + + +class TestComboWeight: + def test_construct(self): + cw = ComboWeight(combo_str="AA", weight=1.0) + assert cw.weight() == 1.0 + + +class TestWeightedRange: + def test_empty(self): + wr = WeightedRange() + assert wr.len() == 0 + + +class TestPositionRanges: + def test_default(self): + pr = PositionRanges() + assert pr is not None + + +class TestActionRanges: + def test_default(self): + ar = ActionRanges() + assert ar is not None + + +class TestPositionalBetting: + def test_default(self): + pb = PositionalBetting() + assert pb is not None + + +class TestPlaybook: + def test_empty_playbook(self): + p = Playbook() + assert p.entry_count() == 0 + + +class TestPlaybookEntry: + def test_construct(self): + # Args determined by pkcore's PlaybookEntry struct fields. + e = PlaybookEntry() + assert e is not None +``` + +- [ ] **Step 2: Run, expect failure** + +- [ ] **Step 3: Implement bindings (seven wrappers, same pattern)** + +Each follows the canonical wrapper template. For builder-style methods like `Playbook::with_entry(entry)`, expose them as `&mut self` mutators returning `PyResult<()>` or as `&self -> Self` clone-then-mutate. + +- [ ] **Step 4: Update `__init__.py`** + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/bot.rs python/pkpy/__init__.py tests/test_bot.py +``` + +Commit message: `feat: bind playbook and range strategy types` + +### Task 18: Bind `BotProfile`, `TableSnapshot`, `RuleBasedDecider` + +**Files:** +- Modify: `tests/test_bot.py` +- Modify: `src/bot.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_bot.py`: + +```python +from pkpy import BotProfile, TableSnapshot, RuleBasedDecider + + +class TestBotProfile: + def test_gto_profile(self): + p = BotProfile.gto() + assert isinstance(p, BotProfile) + + def test_tight_passive(self): + p = BotProfile.tight_passive() + assert isinstance(p, BotProfile) + + def test_loose_aggressive(self): + p = BotProfile.loose_aggressive() + assert isinstance(p, BotProfile) + + def test_maniac(self): + p = BotProfile.maniac() + assert isinstance(p, BotProfile) + + +class TestTableSnapshot: + def test_construct_from_dealer(self): + # If pkcore offers TableSnapshot::from_table(&Table), mirror that here. + # Otherwise, this test asserts a default-constructed snapshot. + s = TableSnapshot() + assert s is not None + + +class TestRuleBasedDecider: + def test_decide_returns_player_action(self): + profile = BotProfile.gto() + snapshot = TableSnapshot() + decider = RuleBasedDecider(profile) + action = decider.decide(snapshot) + assert isinstance(action, PlayerAction) +``` + +(Refine `TableSnapshot()` once Task 15 reveals the actual constructor signature — likely `TableSnapshot::from_table(&Dealer)` or similar.) + +- [ ] **Step 2: Run, expect failure** + +- [ ] **Step 3: Implement bindings** + +`BotProfile`: bind built-in factory methods (`gto`, `tight_passive`, `loose_aggressive`, `maniac`) as `#[staticmethod]`s, plus `with_playbook(playbook: &Playbook) -> Self`. Each returns a fresh `BotProfile` clone. + +`TableSnapshot`: constructor matching pkcore (likely takes a `&TableCelled` or `&Dealer`; if so, accept `&Dealer` and reach in for `&dealer.0.table`). Expose accessors: `current_seat()`, `pot_size()`, `to_call()`, `checked_this_street()`, etc. + +`RuleBasedDecider`: `#[new]` taking `&BotProfile`, then `decide(&self, &TableSnapshot) -> PyResult`. + +- [ ] **Step 4: Update `__init__.py`** + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS, including a real `decide` call. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/bot.rs python/pkpy/__init__.py tests/test_bot.py +``` + +Commit message: `feat: bind BotProfile, TableSnapshot, RuleBasedDecider` + +### Task 19: Bind `BotSim` + +**Files:** +- Modify: `tests/test_bot.py` +- Modify: `src/bot.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing test** + +Append to `tests/test_bot.py`: + +```python +from pkpy import BotSim + + +class TestBotSim: + def test_run_ten_hands_smoke(self): + # Two seats, GTO vs tight_passive, 10 hands. + sim = BotSim(seats=2, big_blind=2, small_blind=1) + sim.add_bot(0, BotProfile.gto(), starting_stack=1000) + sim.add_bot(1, BotProfile.tight_passive(), starting_stack=1000) + results = sim.run_n_hands(10) + # results is List[HandHistory] (or similar — refine after Task 15) + assert len(results) == 10 +``` + +- [ ] **Step 2: Run, expect failure** + +- [ ] **Step 3: Implement binding** + +`BotSim`: constructor with seat count + blinds; `add_bot(seat, profile, starting_stack)`; `run_n_hands(n: usize) -> Vec`. + +- [ ] **Step 4: Update `__init__.py`** + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS — 10 hands run end-to-end and return histories. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/bot.rs python/pkpy/__init__.py tests/test_bot.py +``` + +Commit message: `feat: bind BotSim runner` + +--- + +## Phase 6 — Poker Session Bindings + +### Task 20: Discovery — read pkcore's session module + +**Files:** +- (Read-only) + +- [ ] **Step 1: Read pkcore source** + +Read `pkcore/src/casino/session.rs`. Note `PokerSession` constructor, `start_hand`, `act`, `event_log`, `shuffled_deck` accessor, and any iteration helpers. + +- [ ] **Step 2: No commit (read-only)** + +### Task 21: Bind `PokerSession` + +**Files:** +- Create: `tests/test_session.py` +- Modify: `src/session.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing test** + +Create `tests/test_session.py`: + +```python +"""Tests for pkpy poker session bindings.""" + +import pytest +from pkpy import PokerSession, ForcedBets + + +class TestPokerSession: + def test_start_session(self): + forced = ForcedBets(small_blind=1, big_blind=2) + session = PokerSession(forced=forced, seats=2) + assert session is not None + + def test_run_one_hand_records_event_log(self): + forced = ForcedBets(small_blind=1, big_blind=2) + session = PokerSession(forced=forced, seats=2) + # Seat two default players, start a hand, run to completion. + session.seat_default_player(0, "Alice", 1000) + session.seat_default_player(1, "Bob", 1000) + session.start_hand() + log = session.event_log() + assert len(log) > 0 +``` + +- [ ] **Step 2: Run, expect ImportError** + +- [ ] **Step 3: Implement binding** + +The wrapper file shape is fixed; the only field that depends on Task 20 discovery is the `#[new]` constructor's parameter list. Use the parameter list pkcore exposes (likely `(forced: &ForcedBets, seats: u8)` based on `Dealer::new`'s shape, but confirm). + +```rust +//! Bindings for pkcore's casino::session module. + +use crate::to_py_err; +use crate::{ForcedBets, TableAction}; +use pkcore::casino::session::PokerSession as PkPokerSession; +use pyo3::prelude::*; + +#[pyclass(name = "PokerSession")] +pub struct PokerSession(pub(crate) PkPokerSession); + +#[pymethods] +impl PokerSession { + #[new] + fn new(forced: &ForcedBets, seats: u8) -> PyResult { + // If pkcore's PokerSession::new returns Result, propagate via to_py_err. + // If it returns PokerSession directly, drop the PyResult and return Self. + Ok(PokerSession(PkPokerSession::new(forced.0.clone(), seats))) + } + + fn seat_default_player(&mut self, seat: u8, handle: String, chips: usize) -> PyResult<()> { + // Mirror Dealer's existing seat-population helper. If pkcore's API + // differs (e.g., takes a Player by value), adapt accordingly. + self.0 + .seat_default_player(seat, handle, chips) + .map_err(to_py_err) + } + + fn start_hand(&mut self) -> PyResult<()> { + self.0.start_hand().map_err(to_py_err) + } + + fn event_log(&self) -> Vec { + self.0 + .event_log() + .iter() + .cloned() + .map(TableAction) + .collect() + } + + fn shuffled_deck(&self) -> Option { + self.0.shuffled_deck().map(String::from) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} +``` + +If Task 20 reveals a different constructor or method name, edit each call site to match — the wrapper structure stays the same. + +For `crate::TableAction` and `crate::ForcedBets` to resolve, ensure the `TableAction` and `ForcedBets` types in `lib.rs` are declared `pub(crate)` (they are, by default — `pub struct X(...)` inside a non-`pub mod` is reachable across the crate). + +- [ ] **Step 4: Update `__init__.py`** + +Add `PokerSession`. + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/session.rs python/pkpy/__init__.py tests/test_session.py +``` + +Commit message: `feat: bind PokerSession` + +--- + +## Phase 7 — TableNoCell Bindings + +### Task 22: Discovery — read pkcore's table_no_cell module + +**Files:** +- (Read-only) + +- [ ] **Step 1: Read pkcore source** + +Read `pkcore/src/casino/table_no_cell.rs` (3,657 lines). Note signatures for: + +- `TableNoCell::nlh_from_seats(seats, forced)` — likely mirror of `TableCelled` +- `TableNoCell::act(action)` / equivalent +- `SeatsNoCell`, `SeatNoCell`, `PlayerNoCell` — accessors only + +- [ ] **Step 2: No commit** + +### Task 23: Bind `TableNoCell`, `SeatsNoCell`, `SeatNoCell`, `PlayerNoCell` + +**Files:** +- Create: `tests/test_table_no_cell.py` +- Modify: `src/table_no_cell.rs` +- Modify: `python/pkpy/__init__.py` + +- [ ] **Step 1: Write failing test** + +Create `tests/test_table_no_cell.py`: + +```python +"""Parity tests for TableNoCell vs Dealer.""" + +import pytest +from pkpy import TableNoCell, ForcedBets, PlayerNoCell + + +class TestTableNoCell: + def test_construct_heads_up(self): + forced = ForcedBets(small_blind=1, big_blind=2) + table = TableNoCell.heads_up(forced=forced) + assert table is not None + + def test_seat_count(self): + forced = ForcedBets(small_blind=1, big_blind=2) + table = TableNoCell.heads_up(forced=forced) + assert table.seat_count() == 2 +``` + +- [ ] **Step 2: Run, expect failure** + +- [ ] **Step 3: Implement bindings** + +Four wrappers. Constructor for `TableNoCell` mirrors `TableCelled::nlh_from_seats` — most likely a `#[staticmethod] heads_up(forced: &ForcedBets)` that builds two default seats. + +- [ ] **Step 4: Update `__init__.py`** + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6: Stage for commit** + +``` +git add src/table_no_cell.rs python/pkpy/__init__.py tests/test_table_no_cell.py +``` + +Commit message: `feat: bind TableNoCell and its companions` + +--- + +## Phase 8 — Wrap-up + +### Task 24: Run full ayce gate + +**Files:** +- (none — verification only) + +- [ ] **Step 1: Format** + +Run: `make fmt` +Expected: no diff produced (or, if produced, review and accept). + +- [ ] **Step 2: Clippy** + +Run: `make clippy` +Expected: no warnings under `-W clippy::pedantic`. Fix any that appear. + +- [ ] **Step 3: Build + test + demo** + +Run: `make ayce` +Expected: build succeeds, all tests pass, demo runs to completion. + +- [ ] **Step 4: Stage for commit (if any fmt/clippy adjustments)** + +``` +git add -p # review hunks +``` + +Commit message: `chore: run fmt + clippy across upgrade` + +### Task 25: Update README and demo + +**Files:** +- Modify: `README.md` +- Modify: `demo.py` (only if a new feature is worth demoing) + +- [ ] **Step 1: Update README badges and feature list** + +In `README.md`, find the version badge and bump to `0.0.52`. Find the feature list section and append a brief mention of the new modules (hand history, player stats, bot profiles, poker session). + +- [ ] **Step 2: Optionally extend demo.py** + +If the user wants a demo of the new functionality, add a short `BotSim` smoke or `HandHistory` round-trip to `demo.py`. Otherwise leave alone. + +- [ ] **Step 3: Run demo** + +Run: `make demo` +Expected: PASS. + +- [ ] **Step 4: Stage for commit** + +``` +git add README.md demo.py +``` + +Commit message: `docs: bump README to 0.0.52 and note new modules` + +### Task 26: Final review pass + +- [ ] **Step 1: List of files changed** + +Run: `git status` and `git diff --stat main` +Expected: every changed file is intentional. No leftover scaffold or stub. + +- [ ] **Step 2: Confirm Python __all__ alphabetized** + +Open `python/pkpy/__init__.py` and visually confirm both the import block and `__all__` are alphabetized. + +- [ ] **Step 3: Confirm test coverage** + +Run: `pytest --collect-only tests/` +Expected: every new test file is picked up; total test count grew by at least the count of new test classes. + +- [ ] **Step 4: Cargo.lock sanity** + +Run: `git diff Cargo.lock | head -50` +Expected: pkcore version matches `0.0.52`; no unexpected major version bumps elsewhere. + +- [ ] **Step 5: Hand off to user** + +Surface the final commit set to the user. Do not push or open a PR — that is the user's call. + +--- + +## Notes for the implementing agent + +- **The "discovery" tasks (5, 11, 15, 20, 22) are non-negotiable.** Skipping them and guessing at signatures will produce code that fails to compile or fails wrong tests. Read the pkcore source first. +- **Per project rules, do not run `git commit`.** Each "Stage for commit" step should leave files staged and present the suggested message to the user. +- **If a binding turns out to be impractical** (e.g., a pkcore type holds a non-Send field or uses lifetimes that don't bridge to Python), document why in a comment in the relevant `src/.rs` and skip — note the omission in the task's commit message. +- **Tests that depend on pkcore behavior we can't easily set up** (e.g., a fully populated `TableSnapshot` that requires running a real hand first) should be marked `pytest.mark.skip` with a reason rather than left as half-broken assertions. +- **If `cargo build` produces deprecation warnings** from pkcore 0.0.52, address them inline if they touch our wrappers. Do not silence them globally. diff --git a/docs/superpowers/plans/2026-04-29-pkpy-pokersession-tablenocell.md b/docs/superpowers/plans/2026-04-29-pkpy-pokersession-tablenocell.md new file mode 100644 index 0000000..46c3790 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-pkpy-pokersession-tablenocell.md @@ -0,0 +1,1215 @@ +# pkpy PokerSession + TableNoCell Bindings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bind the no-cell session/table primitives from pkcore 0.0.53 to Python so a user can drive a multi-hand poker session end-to-end, with the new 0.0.53 blinds-management methods (`set_blinds`, `forced_at_hand_start`) folded in. + +**Architecture:** Two pkpy scaffold modules (`src/session.rs`, `src/table_no_cell.rs`) get filled in. Build bottom-up by dependency: enums first (no deps), then primitives in dependency order (`PlayerNoCell` → `SeatNoCell` → `SeatsNoCell` → `TableNoCell`), then `PokerSession` last. Each task ships with TDD-style tests; `make ayce` stays green at every commit boundary. + +**Tech Stack:** Rust 1.94+ (edition 2021), pyo3 0.28, maturin 1.7+, pkcore 0.0.53 (default features), pytest. + +**Reference spec:** `docs/superpowers/specs/2026-04-29-pkpy-pokersession-tablenocell-design.md` + +**Note on git commits:** This project's owner runs all `git` state-changing commands manually (per global CLAUDE.md). Each task ends with a "suggested commit" command — the executor should print/log it for the owner rather than running `git add` / `git commit` directly. + +--- + +## File Map + +| File | Action | Purpose | +|---|---|---| +| `src/session.rs` | Modify (currently 7-line scaffold) | Bind `PlayerAction`, `SessionStep`, `PokerSession` | +| `src/table_no_cell.rs` | Modify (currently 7-line scaffold) | Bind `PlayerNoCell`, `SeatNoCell`, `SeatsNoCell`, `TableNoCell` | +| `python/pkpy/__init__.py` | Modify | Re-export the seven new classes | +| `tests/test_session.py` | Create | Tests for `PlayerAction`, `SessionStep`, `PokerSession` | +| `tests/test_table_no_cell.py` | Create | Tests for `PlayerNoCell`, `SeatNoCell`, `SeatsNoCell`, `TableNoCell` | + +`src/lib.rs` requires no changes — `mod session;` / `mod table_no_cell;` and their register calls were added in commit `b17c327`. The needed types `ForcedBets`, `Winnings`, `PotWin`, `to_py_err` are already at crate-visible scope. + +--- + +## Conventions + +All new pyo3 bindings follow the established pkpy pattern, exemplified by `TableAction` (`src/lib.rs:2683`) and `Dealer` (`src/lib.rs:2823`): + +```rust +#[pyclass(from_py_object, name = "X")] +#[derive(Clone)] +pub struct X(pub(crate) PkX); + +#[pymethods] +impl X { + // methods +} +``` + +`PokerSession` is the one exception — `pkcore::casino::session::PokerSession` does **not** derive `Clone`, so it uses plain `#[pyclass]` (no `from_py_object`). + +Errors map via the existing `pub(crate) fn to_py_err` helper at `src/lib.rs:61`. + +Each new module starts with a `pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()>` that adds every class. The `#[pymodule]` block at `src/lib.rs:3937` already calls `session::register(m)?` and `table_no_cell::register(m)?`. + +--- + +## Phase 1 — `PlayerAction` enum + +### Task 1: Bind `PlayerAction` + +**Files:** +- Modify: `src/session.rs` +- Create: `tests/test_session.py` + +- [ ] **Step 1: Write failing tests for `PlayerAction`** + +Create `tests/test_session.py`: + +```python +"""Tests for pkpy poker session bindings.""" + +import pytest + +from pkpy import PlayerAction + + +class TestPlayerAction: + def test_fold(self): + a = PlayerAction.fold() + assert a.kind() == "Fold" + assert a.amount() is None + + def test_check(self): + a = PlayerAction.check() + assert a.kind() == "Check" + assert a.amount() is None + + def test_call(self): + a = PlayerAction.call() + assert a.kind() == "Call" + assert a.amount() is None + + def test_bet(self): + a = PlayerAction.bet(200) + assert a.kind() == "Bet" + assert a.amount() == 200 + + def test_raise_(self): + a = PlayerAction.raise_(400) + assert a.kind() == "Raise" + assert a.amount() == 400 + + def test_all_in(self): + a = PlayerAction.all_in() + assert a.kind() == "AllIn" + assert a.amount() is None + + def test_equality(self): + assert PlayerAction.bet(200) == PlayerAction.bet(200) + assert PlayerAction.bet(200) != PlayerAction.bet(300) + assert PlayerAction.fold() == PlayerAction.fold() + assert PlayerAction.fold() != PlayerAction.check() + + def test_repr_contains_kind(self): + assert "Bet" in repr(PlayerAction.bet(200)) + assert "200" in repr(PlayerAction.bet(200)) + assert "Fold" in repr(PlayerAction.fold()) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make build && pytest tests/test_session.py -v` +Expected: ImportError — `PlayerAction` does not exist in `pkpy`. + +- [ ] **Step 3: Implement `PlayerAction` binding** + +Replace the contents of `src/session.rs` with: + +```rust +//! Bindings for pkcore's casino::session module. + +use pkcore::casino::action::PlayerAction as PkPlayerAction; +use pyo3::prelude::*; + +/// A player's action in a poker hand. +/// +/// Construct via the static methods (`fold()`, `check()`, `call()`, +/// `bet(n)`, `raise_(n)`, `all_in()`). Inspect via `kind()` and `amount()`. +/// +/// `raise` is a Python keyword, hence the trailing-underscore naming +/// convention for that constructor. +#[pyclass(from_py_object, name = "PlayerAction")] +#[derive(Clone)] +pub struct PlayerAction(pub(crate) PkPlayerAction); + +#[pymethods] +impl PlayerAction { + #[staticmethod] + fn fold() -> Self { + Self(PkPlayerAction::Fold) + } + + #[staticmethod] + fn check() -> Self { + Self(PkPlayerAction::Check) + } + + #[staticmethod] + fn call() -> Self { + Self(PkPlayerAction::Call) + } + + #[staticmethod] + fn bet(amount: usize) -> Self { + Self(PkPlayerAction::Bet(amount)) + } + + #[staticmethod] + #[pyo3(name = "raise_")] + fn raise_(amount: usize) -> Self { + Self(PkPlayerAction::Raise(amount)) + } + + #[staticmethod] + fn all_in() -> Self { + Self(PkPlayerAction::AllIn) + } + + fn kind(&self) -> &'static str { + match self.0 { + PkPlayerAction::Fold => "Fold", + PkPlayerAction::Check => "Check", + PkPlayerAction::Call => "Call", + PkPlayerAction::Bet(_) => "Bet", + PkPlayerAction::Raise(_) => "Raise", + PkPlayerAction::AllIn => "AllIn", + } + } + + fn amount(&self) -> Option { + match self.0 { + PkPlayerAction::Bet(n) | PkPlayerAction::Raise(n) => Some(n), + _ => None, + } + } + + fn __repr__(&self) -> String { + match self.0 { + PkPlayerAction::Bet(n) => format!("PlayerAction.Bet({n})"), + PkPlayerAction::Raise(n) => format!("PlayerAction.Raise({n})"), + other => format!("PlayerAction.{other:?}"), + } + } + + fn __eq__(&self, other: &PlayerAction) -> bool { + self.0 == other.0 + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} +``` + +- [ ] **Step 4: Add `PlayerAction` to `python/pkpy/__init__.py`** + +Add `PlayerAction` to the `from pkpy._pkpy import (...)` import block (alphabetical order). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `make build && pytest tests/test_session.py -v` +Expected: 8 passed. + +- [ ] **Step 6: Suggested commit (owner runs)** + +``` +git add src/session.rs python/pkpy/__init__.py tests/test_session.py && git commit -m "feat: bind PlayerAction enum with TableAction-style accessors" +``` + +--- + +## Phase 2 — `SessionStep` enum + +### Task 2: Bind `SessionStep` + +**Files:** +- Modify: `src/session.rs` +- Modify: `tests/test_session.py` + +- [ ] **Step 1: Write failing tests for `SessionStep`** + +Append to `tests/test_session.py`: + +```python +class TestSessionStep: + """SessionStep is read-only — produced by PokerSession.next_step(). + + These tests construct one indirectly via a session in Phase 7. For now, + we just confirm the type exists in the module so import doesn't fail. + """ + + def test_import(self): + from pkpy import SessionStep + # Class must exist; instances are created by PokerSession.next_step. + assert SessionStep is not None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make build && pytest tests/test_session.py::TestSessionStep -v` +Expected: ImportError — `SessionStep` does not exist in `pkpy`. + +- [ ] **Step 3: Add `SessionStep` binding to `src/session.rs`** + +Append to `src/session.rs` (after the `PlayerAction` block, before `register`): + +```rust +use pkcore::casino::session::SessionStep as PkSessionStep; + +/// A snapshot of where a hand is in its lifecycle. +/// +/// Returned by `PokerSession.next_step()`. Read-only; inspect via `kind()` +/// and (for `PlayerToAct`) `seat()`. +#[pyclass(from_py_object, name = "SessionStep")] +#[derive(Clone)] +pub struct SessionStep(pub(crate) PkSessionStep); + +#[pymethods] +impl SessionStep { + fn kind(&self) -> &'static str { + match self.0 { + PkSessionStep::PlayerToAct(_) => "PlayerToAct", + PkSessionStep::StreetAdvanced => "StreetAdvanced", + PkSessionStep::HandComplete => "HandComplete", + } + } + + fn seat(&self) -> Option { + match self.0 { + PkSessionStep::PlayerToAct(s) => Some(s), + _ => None, + } + } + + fn __repr__(&self) -> String { + match self.0 { + PkSessionStep::PlayerToAct(s) => format!("SessionStep.PlayerToAct(seat={s})"), + PkSessionStep::StreetAdvanced => "SessionStep.StreetAdvanced".to_string(), + PkSessionStep::HandComplete => "SessionStep.HandComplete".to_string(), + } + } +} +``` + +Update the `register` function at the bottom of `src/session.rs`: + +```rust +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +- [ ] **Step 4: Add `SessionStep` to `python/pkpy/__init__.py`** + +Add `SessionStep` to the import block (alphabetical order). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `make build && pytest tests/test_session.py -v` +Expected: 9 passed (8 from Task 1 + 1 new). + +- [ ] **Step 6: Suggested commit** + +``` +git add src/session.rs python/pkpy/__init__.py tests/test_session.py && git commit -m "feat: bind SessionStep enum (read-only, returned by next_step)" +``` + +--- + +## Phase 3 — `PlayerNoCell` + +### Task 3: Bind `PlayerNoCell` + +**Files:** +- Modify: `src/table_no_cell.rs` +- Create: `tests/test_table_no_cell.py` + +- [ ] **Step 1: Write failing tests for `PlayerNoCell`** + +Create `tests/test_table_no_cell.py`: + +```python +"""Tests for pkpy no-cell table primitive bindings.""" + +import pytest + +from pkpy import PlayerNoCell + + +class TestPlayerNoCell: + def test_construct_default_chips(self): + p = PlayerNoCell("Alice") + assert p.total_chip_count() == 0 + assert p.is_clear() + + def test_construct_with_chips(self): + p = PlayerNoCell("Alice", chips=1000) + assert p.total_chip_count() == 1000 + + def test_construct_with_positional_chips(self): + p = PlayerNoCell("Alice", 1000) + assert p.total_chip_count() == 1000 + + def test_state_predicates_default(self): + p = PlayerNoCell("Alice", chips=1000) + assert not p.is_all_in() + assert not p.has_bet() + + def test_repr_contains_handle(self): + r = repr(PlayerNoCell("Alice", chips=1000)) + assert "Alice" in r +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make build && pytest tests/test_table_no_cell.py -v` +Expected: ImportError — `PlayerNoCell` does not exist in `pkpy`. + +- [ ] **Step 3: Implement `PlayerNoCell` binding** + +Replace the contents of `src/table_no_cell.rs` with: + +```rust +//! Bindings for pkcore's casino::table_no_cell module. + +use pkcore::casino::table_no_cell::PlayerNoCell as PkPlayerNoCell; +use pyo3::prelude::*; + +/// A no-cell player record (handle + chip stack + state flags). +/// +/// Constructed standalone or via `PlayerNoCell(handle, chips=N)`. Wrapped +/// in `SeatNoCell` for table assembly. +#[pyclass(from_py_object, name = "PlayerNoCell")] +#[derive(Clone)] +pub struct PlayerNoCell(pub(crate) PkPlayerNoCell); + +#[pymethods] +impl PlayerNoCell { + #[new] + #[pyo3(signature = (handle, chips=0))] + fn new(handle: String, chips: usize) -> Self { + if chips == 0 { + Self(PkPlayerNoCell::new(handle)) + } else { + Self(PkPlayerNoCell::new_with_chips(handle, chips)) + } + } + + fn total_chip_count(&self) -> usize { + self.0.total_chip_count() + } + + fn is_active(&self) -> bool { + self.0.is_active() + } + + fn is_all_in(&self) -> bool { + self.0.is_all_in() + } + + fn is_in_hand(&self) -> bool { + self.0.is_in_hand() + } + + fn is_out(&self) -> bool { + self.0.is_out() + } + + fn is_tapped_out(&self) -> bool { + self.0.is_tapped_out() + } + + fn is_clear(&self) -> bool { + self.0.is_clear() + } + + fn has_bet(&self) -> bool { + self.0.has_bet() + } + + fn __repr__(&self) -> String { + format!("PlayerNoCell({})", self.0) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} +``` + +- [ ] **Step 4: Add `PlayerNoCell` to `python/pkpy/__init__.py`** + +Add `PlayerNoCell` to the import block. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `make build && pytest tests/test_table_no_cell.py -v` +Expected: 5 passed. + +- [ ] **Step 6: Suggested commit** + +``` +git add src/table_no_cell.rs python/pkpy/__init__.py tests/test_table_no_cell.py && git commit -m "feat: bind PlayerNoCell with construction and state inspection" +``` + +--- + +## Phase 4 — `SeatNoCell` + +### Task 4: Bind `SeatNoCell` + +**Files:** +- Modify: `src/table_no_cell.rs` +- Modify: `tests/test_table_no_cell.py` + +- [ ] **Step 1: Append failing tests for `SeatNoCell`** + +Append to `tests/test_table_no_cell.py`: + +```python +class TestSeatNoCell: + def test_construct_from_player(self): + from pkpy import SeatNoCell + seat = SeatNoCell(PlayerNoCell("Alice", chips=1000)) + assert not seat.is_empty() + + def test_default_state_predicates(self): + from pkpy import SeatNoCell + seat = SeatNoCell(PlayerNoCell("Alice", chips=1000)) + # Before any hand starts, the seat should not be in a hand. + assert not seat.is_in_hand() + assert not seat.is_all_in() + + def test_repr_contains_handle(self): + from pkpy import SeatNoCell + r = repr(SeatNoCell(PlayerNoCell("Alice", chips=1000))) + assert "Alice" in r +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make build && pytest tests/test_table_no_cell.py::TestSeatNoCell -v` +Expected: ImportError — `SeatNoCell` does not exist in `pkpy`. + +- [ ] **Step 3: Append `SeatNoCell` binding to `src/table_no_cell.rs`** + +Add to the `use` block at the top: + +```rust +use pkcore::casino::table_no_cell::SeatNoCell as PkSeatNoCell; +``` + +Append (after the `PlayerNoCell` block, before `register`): + +```rust +/// A seat at a no-cell table, wrapping a `PlayerNoCell`. +#[pyclass(from_py_object, name = "SeatNoCell")] +#[derive(Clone)] +pub struct SeatNoCell(pub(crate) PkSeatNoCell); + +#[pymethods] +impl SeatNoCell { + #[new] + fn new(player: &PlayerNoCell) -> Self { + Self(PkSeatNoCell::new(player.0.clone())) + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn is_active(&self) -> bool { + self.0.is_active() + } + + fn is_all_in(&self) -> bool { + self.0.is_all_in() + } + + fn is_in_hand(&self) -> bool { + self.0.is_in_hand() + } + + fn is_yet_to_act(&self) -> bool { + self.0.is_yet_to_act() + } + + fn is_yet_to_act_or_blind(&self) -> bool { + self.0.is_yet_to_act_or_blind() + } + + fn is_clear(&self) -> bool { + self.0.is_clear() + } + + fn __repr__(&self) -> String { + format!("SeatNoCell({})", self.0) + } +} +``` + +Update `register`: + +```rust +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +- [ ] **Step 4: Add `SeatNoCell` to `python/pkpy/__init__.py`** + +Add `SeatNoCell` to the import block. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `make build && pytest tests/test_table_no_cell.py -v` +Expected: 8 passed (5 from Task 3 + 3 new). + +- [ ] **Step 6: Suggested commit** + +``` +git add src/table_no_cell.rs python/pkpy/__init__.py tests/test_table_no_cell.py && git commit -m "feat: bind SeatNoCell with construction and state inspection" +``` + +--- + +## Phase 5 — `SeatsNoCell` + +### Task 5: Bind `SeatsNoCell` + +**Files:** +- Modify: `src/table_no_cell.rs` +- Modify: `tests/test_table_no_cell.py` + +- [ ] **Step 1: Append failing tests for `SeatsNoCell`** + +Append to `tests/test_table_no_cell.py`: + +```python +class TestSeatsNoCell: + def _two_seats(self): + from pkpy import SeatNoCell + return [ + SeatNoCell(PlayerNoCell("Alice", chips=1000)), + SeatNoCell(PlayerNoCell("Bob", chips=2000)), + ] + + def test_construct_from_list(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + assert seats.size() == 2 + + def test_total_chip_count(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + assert seats.total_chip_count() == 3000 + + def test_get_seat(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + seat = seats.get_seat(0) + assert seat is not None + assert not seat.is_empty() + + def test_get_seat_out_of_range(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + assert seats.get_seat(99) is None + + def test_default_betting_state(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + # Before any hand starts, no betting state. + assert seats.current_bet() == 0 + assert seats.count_active_in_hand() == 0 + assert not seats.are_dealt() + + def test_repr_includes_size(self): + from pkpy import SeatsNoCell + r = repr(SeatsNoCell(self._two_seats())) + assert "size=2" in r +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make build && pytest tests/test_table_no_cell.py::TestSeatsNoCell -v` +Expected: ImportError — `SeatsNoCell` does not exist in `pkpy`. + +- [ ] **Step 3: Append `SeatsNoCell` binding to `src/table_no_cell.rs`** + +Add to the `use` block: + +```rust +use pkcore::casino::table_no_cell::SeatsNoCell as PkSeatsNoCell; +``` + +Append (after the `SeatNoCell` block, before `register`): + +```rust +/// A vector of `SeatNoCell` representing a table's seats. +#[pyclass(from_py_object, name = "SeatsNoCell")] +#[derive(Clone)] +pub struct SeatsNoCell(pub(crate) PkSeatsNoCell); + +#[pymethods] +impl SeatsNoCell { + #[new] + fn new(seats: Vec) -> Self { + Self(PkSeatsNoCell::new(seats.into_iter().map(|s| s.0).collect())) + } + + fn size(&self) -> u8 { + self.0.size() + } + + fn get_seat(&self, idx: u8) -> Option { + self.0.get_seat(idx).cloned().map(SeatNoCell) + } + + fn is_seat_in_hand(&self, idx: u8) -> bool { + self.0.is_seat_in_hand(idx) + } + + fn current_bet(&self) -> usize { + self.0.current_bet() + } + + fn to_call(&self, player_idx: u8) -> usize { + self.0.to_call(player_idx) + } + + fn total_chip_count(&self) -> usize { + self.0.total_chip_count() + } + + fn count_active_in_hand(&self) -> usize { + self.0.count_active_in_hand() + } + + fn active_in_hand(&self) -> Vec { + self.0.active_in_hand() + } + + fn are_dealt(&self) -> bool { + self.0.are_dealt() + } + + fn are_clear(&self) -> bool { + self.0.are_clear() + } + + fn is_betting_complete(&self) -> bool { + self.0.is_betting_complete() + } + + fn __repr__(&self) -> String { + format!("SeatsNoCell(size={})", self.0.size()) + } +} +``` + +Update `register`: + +```rust +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +- [ ] **Step 4: Add `SeatsNoCell` to `python/pkpy/__init__.py`** + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `make build && pytest tests/test_table_no_cell.py -v` +Expected: 14 passed (8 from prior tasks + 6 new). + +- [ ] **Step 6: Suggested commit** + +``` +git add src/table_no_cell.rs python/pkpy/__init__.py tests/test_table_no_cell.py && git commit -m "feat: bind SeatsNoCell with construction and inspection helpers" +``` + +--- + +## Phase 6 — `TableNoCell` + +### Task 6: Bind `TableNoCell` + +**Files:** +- Modify: `src/table_no_cell.rs` +- Modify: `src/lib.rs` — confirm `ForcedBets` reachability (read-only check; no edit expected) +- Modify: `tests/test_table_no_cell.py` + +- [ ] **Step 1: Confirm `ForcedBets` is `pub(crate)`-reachable** + +Read `src/lib.rs:2272` — the `pub struct ForcedBets(PkForcedBets);` declaration. Confirm it's `pub` (or `pub(crate)`) so a sibling module can `use crate::ForcedBets`. If the executor finds it is not reachable, fix that line first by changing `pub struct ForcedBets` to `pub(crate) struct ForcedBets` (or leaving it `pub` if already `pub`); no other change. + +- [ ] **Step 2: Append failing tests for `TableNoCell`** + +Append to `tests/test_table_no_cell.py`: + +```python +class TestTableNoCell: + def test_nlh_from_seats(self): + from pkpy import ForcedBets, SeatNoCell, SeatsNoCell, TableNoCell + seats = SeatsNoCell([ + SeatNoCell(PlayerNoCell("Alice", chips=1000)), + SeatNoCell(PlayerNoCell("Bob", chips=1000)), + ]) + forced = ForcedBets(50, 100) + table = TableNoCell.nlh_from_seats(seats, forced) + assert table.seat_count() == 2 + + def test_heads_up_defaults(self): + from pkpy import ForcedBets, TableNoCell + forced = ForcedBets(50, 100) + table = TableNoCell.heads_up(forced) + assert table.seat_count() == 2 + # Default stacks are (1000, 1000). + seats = table.seats() + assert seats.total_chip_count() == 2000 + + def test_heads_up_custom_stacks_and_names(self): + from pkpy import ForcedBets, TableNoCell + forced = ForcedBets(50, 100) + table = TableNoCell.heads_up(forced, stacks=(500, 1500), names=("X", "Y")) + seats = table.seats() + assert seats.total_chip_count() == 2000 + assert seats.size() == 2 + + def test_blind_position_lookups(self): + from pkpy import ForcedBets, TableNoCell + table = TableNoCell.heads_up(ForcedBets(50, 100)) + # In heads-up, button is small blind. We don't assert specific seat + # numbers here — just that the methods return seat indices in range. + sb = table.determine_small_blind() + bb = table.determine_big_blind() + assert sb < table.seat_count() + assert bb < table.seat_count() + + def test_repr_includes_seat_count(self): + from pkpy import ForcedBets, TableNoCell + r = repr(TableNoCell.heads_up(ForcedBets(50, 100))) + assert "seats=2" in r +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `make build && pytest tests/test_table_no_cell.py::TestTableNoCell -v` +Expected: ImportError — `TableNoCell` does not exist in `pkpy`. + +- [ ] **Step 4: Append `TableNoCell` binding to `src/table_no_cell.rs`** + +Add to the `use` block: + +```rust +use crate::ForcedBets; +use pkcore::casino::table_no_cell::TableNoCell as PkTableNoCell; +``` + +Append (after the `SeatsNoCell` block, before `register`): + +```rust +/// A no-Cell poker table — same semantics as `TableCelled` but without +/// the interior mutability indirection. Wrapped by `PokerSession` for +/// multi-hand session management. +#[pyclass(from_py_object, name = "TableNoCell")] +#[derive(Clone)] +pub struct TableNoCell(pub(crate) PkTableNoCell); + +#[pymethods] +impl TableNoCell { + /// Construct a NLH table from existing seats and forced-bet config. + /// Faithful pkcore mirror. + #[staticmethod] + fn nlh_from_seats(seats: &SeatsNoCell, forced: &ForcedBets) -> Self { + Self(PkTableNoCell::nlh_from_seats(seats.0.clone(), forced.0)) + } + + /// Convenience: heads-up table with two named, equally-stacked players. + /// Default stacks are (1000, 1000); default names are ("A", "B"). + #[staticmethod] + #[pyo3(signature = (forced, stacks=(1000, 1000), names=("A".to_string(), "B".to_string())))] + fn heads_up( + forced: &ForcedBets, + stacks: (usize, usize), + names: (String, String), + ) -> Self { + let seats = PkSeatsNoCell::new(vec![ + PkSeatNoCell::new(PkPlayerNoCell::new_with_chips(names.0, stacks.0)), + PkSeatNoCell::new(PkPlayerNoCell::new_with_chips(names.1, stacks.1)), + ]); + Self(PkTableNoCell::nlh_from_seats(seats, forced.0)) + } + + fn seat_count(&self) -> u8 { + self.0.seats.size() + } + + fn seats(&self) -> SeatsNoCell { + SeatsNoCell(self.0.seats.clone()) + } + + fn determine_small_blind(&self) -> u8 { + self.0.determine_small_blind() + } + + fn determine_big_blind(&self) -> u8 { + self.0.determine_big_blind() + } + + fn next_occupied_seat_after(&self, start: u8, n: usize) -> u8 { + self.0.next_occupied_seat_after(start, n) + } + + fn __repr__(&self) -> String { + format!("TableNoCell(seats={})", self.0.seats.size()) + } +} +``` + +Update `register`: + +```rust +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +- [ ] **Step 5: Add `TableNoCell` to `python/pkpy/__init__.py`** + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `make build && pytest tests/test_table_no_cell.py -v` +Expected: 19 passed (14 from prior tasks + 5 new). + +- [ ] **Step 7: Suggested commit** + +``` +git add src/table_no_cell.rs python/pkpy/__init__.py tests/test_table_no_cell.py && git commit -m "feat: bind TableNoCell with nlh_from_seats and heads_up factories" +``` + +--- + +## Phase 7 — `PokerSession` (incl. 0.0.53 NEW methods) + +### Task 7: Bind `PokerSession` + +**Files:** +- Modify: `src/session.rs` +- Modify: `tests/test_session.py` + +- [ ] **Step 1: Append failing tests for `PokerSession`** + +Append to `tests/test_session.py`: + +```python +class TestPokerSession: + def _heads_up(self, sb=50, bb=100, stacks=(1000, 1000)): + from pkpy import ForcedBets, PokerSession + return PokerSession.heads_up(ForcedBets(sb, bb), stacks=stacks) + + def test_construct_from_table(self): + from pkpy import ForcedBets, PokerSession, TableNoCell + table = TableNoCell.heads_up(ForcedBets(50, 100)) + session = PokerSession(table) + assert session.hand_number == 0 + assert session.shuffled_deck_str is None + + def test_heads_up_factory(self): + session = self._heads_up() + assert session.hand_number == 0 + assert not session.is_hand_in_progress() + + def test_start_hand_increments_hand_number(self): + session = self._heads_up() + session.start_hand() + assert session.hand_number == 1 + assert session.is_hand_in_progress() + + def test_next_step_after_start_is_player_to_act(self): + session = self._heads_up() + session.start_hand() + step = session.next_step() + assert step.kind() == "PlayerToAct" + assert step.seat() is not None + + def test_count_funded(self): + session = self._heads_up() + assert session.count_funded() == 2 + + def test_apply_action_fold_ends_hand(self): + from pkpy import PlayerAction + session = self._heads_up() + session.start_hand() + actor = session.next_actor() + assert actor is not None + session.apply_action(actor, PlayerAction.fold()) + winnings = session.end_hand() + assert not winnings.is_empty() + assert len(winnings) >= 1 + + # ── 0.0.53 regression ports ────────────────────────────────────────── + # Direct translations of pkcore unit tests at casino/session.rs:970-1010. + + def test_set_blinds_between_hands_applies_immediately(self): + from pkpy import ForcedBets + session = self._heads_up() + session.set_blinds(ForcedBets(100, 200)) + # Before any hand starts, the snapshot reflects the *new* blinds + # because PokerSession::new captures the table's current forced + # bets, and set_blinds (with no hand in progress) overwrites them. + # We check the snapshot via forced_at_hand_start AFTER start_hand, + # which is the documented stable surface. + session.start_hand() + assert session.forced_at_hand_start().small_blind == 100 + assert session.forced_at_hand_start().big_blind == 200 + + def test_set_blinds_during_hand_defers_to_next_hand(self): + from pkpy import ForcedBets, PlayerAction + session = self._heads_up() + session.start_hand() + # Mid-hand: bump blinds. + session.set_blinds(ForcedBets(100, 200)) + # forced_at_hand_start still reflects what was posted this hand. + assert session.forced_at_hand_start().small_blind == 50 + assert session.forced_at_hand_start().big_blind == 100 + + def test_deferred_blinds_take_effect_on_next_start_hand(self): + from pkpy import ForcedBets, PlayerAction + session = self._heads_up() + session.start_hand() + session.set_blinds(ForcedBets(100, 200)) + # Finish the hand by folding the next actor. + actor = session.next_actor() + session.apply_action(actor, PlayerAction.fold()) + session.end_hand() + # Next hand picks up the deferred blinds. + session.start_hand() + assert session.forced_at_hand_start().small_blind == 100 + assert session.forced_at_hand_start().big_blind == 200 + + def test_forced_at_hand_start_stable_during_hand(self): + from pkpy import ForcedBets + session = self._heads_up() + session.start_hand() + snap1 = session.forced_at_hand_start() + session.set_blinds(ForcedBets(400, 800)) + snap2 = session.forced_at_hand_start() + assert snap1.small_blind == snap2.small_blind == 50 + assert snap1.big_blind == snap2.big_blind == 100 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make build && pytest tests/test_session.py::TestPokerSession -v` +Expected: ImportError — `PokerSession` does not exist in `pkpy`. + +- [ ] **Step 3: Append `PokerSession` binding to `src/session.rs`** + +Add to the `use` block at the top: + +```rust +use crate::table_no_cell::TableNoCell; +use crate::{ForcedBets, Winnings, to_py_err}; +use pkcore::casino::session::PokerSession as PkPokerSession; +``` + +Append (after the `SessionStep` block, before `register`): + +```rust +/// A multi-hand poker session wrapping a `TableNoCell`. +/// +/// Drive a hand: `start_hand()` → loop on `next_step()`, calling +/// `apply_action(seat, PlayerAction.X)` for each `PlayerToAct` — +/// `end_hand()` to settle. Use `set_blinds` (deferred mid-hand) to +/// adjust forced bets between hands. +#[pyclass(name = "PokerSession")] +pub struct PokerSession(pub(crate) PkPokerSession); + +#[pymethods] +impl PokerSession { + #[new] + fn new(table: &TableNoCell) -> Self { + Self(PkPokerSession::new(table.0.clone())) + } + + /// Convenience: heads-up session in one call. Mirrors + /// `TableNoCell.heads_up`'s defaults. + #[staticmethod] + #[pyo3(signature = (forced, stacks=(1000, 1000), names=("A".to_string(), "B".to_string())))] + fn heads_up( + forced: &ForcedBets, + stacks: (usize, usize), + names: (String, String), + ) -> Self { + let table = TableNoCell::heads_up(forced, stacks, names); + Self(PkPokerSession::new(table.0)) + } + + // ── 0.0.53 NEW ─────────────────────────────────────────────────────── + + fn set_blinds(&mut self, forced: &ForcedBets) { + self.0.set_blinds(forced.0); + } + + fn forced_at_hand_start(&self) -> ForcedBets { + ForcedBets(self.0.forced_at_hand_start()) + } + + // ── Hand lifecycle ────────────────────────────────────────────────── + + fn start_hand(&mut self) -> PyResult<()> { + self.0.start_hand().map_err(to_py_err) + } + + fn end_hand(&mut self) -> PyResult { + self.0.end_hand().map(Winnings).map_err(to_py_err) + } + + fn is_hand_in_progress(&self) -> bool { + self.0.is_hand_in_progress() + } + + fn is_hand_complete(&self) -> bool { + self.0.is_hand_complete() + } + + fn next_actor(&mut self) -> Option { + self.0.next_actor() + } + + fn next_step(&mut self) -> SessionStep { + SessionStep(self.0.next_step()) + } + + fn apply_action(&mut self, seat: u8, action: &PlayerAction) -> PyResult<()> { + self.0.apply_action(seat, action.0).map_err(to_py_err) + } + + // ── Session-wide ──────────────────────────────────────────────────── + + fn count_funded(&self) -> usize { + self.0.count_funded() + } + + fn eliminate_busted(&mut self) -> Vec { + self.0.eliminate_busted() + } + + // ── Field accessors ───────────────────────────────────────────────── + + #[getter] + fn hand_number(&self) -> u64 { + self.0.hand_number + } + + #[getter] + fn shuffled_deck_str(&self) -> Option { + self.0.shuffled_deck_str.clone() + } +} +``` + +Update `register`: + +```rust +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +- [ ] **Step 4: Add `PokerSession` to `python/pkpy/__init__.py`** + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `make build && pytest tests/test_session.py -v` +Expected: 19 passed (9 from Tasks 1-2 + 10 new). + +- [ ] **Step 6: Suggested commit** + +``` +git add src/session.rs python/pkpy/__init__.py tests/test_session.py && git commit -m "feat: bind PokerSession (incl. 0.0.53 set_blinds and forced_at_hand_start)" +``` + +--- + +## Phase 8 — Wrap-up gate + +### Task 8: Full `make ayce` and final review + +**Files:** +- (Verification only — read-only checks) + +- [ ] **Step 1: Run the full ayce gate** + +Run: `make ayce` +Expected: +- `cargo fmt` clean. +- `maturin develop` succeeds with no warnings introduced by the new bindings. +- `pytest` passes all tests including the 19 new ones (187 prior + 19 = 206 total minimum, modulo any existing tests that count differently). +- `demo.py` still runs unchanged. + +- [ ] **Step 2: Verify Python re-exports** + +Run from a Python REPL inside the venv: + +```python +from pkpy import ( + PlayerAction, SessionStep, PokerSession, + PlayerNoCell, SeatNoCell, SeatsNoCell, TableNoCell, + ForcedBets, +) +print(PlayerAction.bet(200)) +print(PokerSession.heads_up(ForcedBets(50, 100))) +``` + +Expected: no ImportError; the prints show the `__repr__` strings. + +- [ ] **Step 3: Run clippy on the new modules** + +Run: `make clippy` +Expected: no new warnings introduced by `src/session.rs` or `src/table_no_cell.rs`. + +- [ ] **Step 4: No commit (verification gate)** + +If everything passes, the implementation is done. If any step fails, the cause is in one of Tasks 1-7; fix it there and re-run `make ayce`. + +--- + +## Notes for the implementing agent + +- **`PkPokerSession::new` takes `TableNoCell` by value** — the binding clones the underlying `PkTableNoCell` to satisfy the Python ownership model. This is fine: `TableNoCell` is `Clone`. +- **`PkPokerSession` is not `Clone`** — that's why `PokerSession` uses plain `#[pyclass]` (without `from_py_object`). +- **`ForcedBets` is `Copy`** — the existing pkpy binding at `src/lib.rs:2272` already takes advantage of this; pass `forced.0` directly without `.clone()`. +- **`PkPlayerAction`, `PkSessionStep` are `Copy`** — same. +- **`PkPlayerNoCell::new(handle)` and `PkPlayerNoCell::new_with_chips(handle, chips)` are observationally equivalent at `chips=0`** — pkcore's `new` produces a 0-chip player. The `if chips == 0` branch in the binding's `#[new]` exists only to keep both pkcore constructors reachable from the binding code in case they diverge later; it does not affect runtime behavior at chips=0. +- **Re-imports at the top of each module file** — follow the existing pattern (`use pkcore::X as PkX;`). This is the convention in `src/lib.rs:30`. +- **`from_py_object` on `#[pyclass]`** — preserve it for everything except `PokerSession`. It's the existing pkpy convention. +- **Testing strategy** — TDD throughout. If a test fails for a reason that's not "the type doesn't exist yet" during the failing-test step, stop and investigate before implementing. +- **Verification** — run `make ayce` after each commit. Tasks are designed so that intermediate commits leave the build green even though only some types are exposed. +- **If `make build` produces deprecation warnings from pkcore 0.0.53**, address them inline if they touch our wrappers. Do not silence them globally. diff --git a/docs/superpowers/specs/2026-04-28-pkpy-0.0.52-upgrade-design.md b/docs/superpowers/specs/2026-04-28-pkpy-0.0.52-upgrade-design.md new file mode 100644 index 0000000..094a7fb --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-pkpy-0.0.52-upgrade-design.md @@ -0,0 +1,137 @@ +# pkpy 0.0.52 Upgrade — Design + +**Date:** 2026-04-28 +**Scope:** Comprehensive — bump `pkcore` 0.0.39 → 0.0.52 and bind the new public surface +**Target version:** `pkpy 0.0.52` (mirrors `pkcore`, per existing convention) + +--- + +## Goal + +Upgrade `pkpy` from `pkcore` 0.0.39 to 0.0.52. Fix the one known compile break, then expose the major new pkcore functionality (bot module, hand history, player stats, poker session, no-cell table) to Python with the same wrapper pattern used today. + +## Context + +`pkcore` 0.0.39 → 0.0.52 is a 13-version jump: ~19,000 inserted lines across 64 files. Most of the change is additive (new modules). The existing pkpy bindings should continue to compile against 0.0.52 without source changes — the `TableAction.kind()` match in `lib.rs:2685–2725` already has a `_ => "Other".into()` wildcard that absorbs the new `ChipAuditFailed(usize, usize)` variant. We add explicit arms anyway, for parity with sibling variants: + +- `PkTableAction::ChipAuditFailed(_, _) => "ChipAuditFailed".into(),` +- `PkTableAction::ResetTable => "ResetTable".into(),` — already silently falling into the wildcard today; surfacing it for consistency. + +Soft / non-breaking changes that show up in the diff but do not require pkpy changes: + +- `Table` was renamed to `TableCelled` in pkcore's prelude. pkpy never imports `Table` directly — it touches `Game`, `Board`, `Dealer`, `ForcedBets`, `Player`, and event types whose paths are unchanged. +- `Player::act_bet_blind` now auto-degrades to all-in when the player can't cover the blind. Behavioral, observable through existing tests, but no signature change. +- `Pile::to_eight_or_better_bits` had a fold-accumulator bug fixed. Same signature. + +`pkcore` 0.0.52 default features: `bot-profiles`, `hand-histories`, `player-stats`, `player-stats-persistence` — all four enabled by default. The persistence feature pulls in `serde_yaml_bw`. We accept the wheel-size cost for a uniform Python API. + +## Architecture + +### Cargo.toml + +```toml +[package] +name = "pkpy" +version = "0.0.52" +edition = "2021" + +[dependencies] +pyo3 = { version = "0.28", features = ["extension-module"] } +pkcore = "0.0.52" # default features inherited (all four flags on) +``` + +Edition stays at 2021. PyO3 0.28 supports it; pkcore's move to 2024 is internal and does not require pkpy to follow. + +### Source layout (Rust) + +`src/lib.rs` becomes the registration entry point and continues to host the existing core wrappers (Card, Rank, Suit, Cards, Combo, HoleCards, Board, Game, Eval, FlopEval, TurnEval, the GTO types, Dealer, ForcedBets, Player, Bard, Pluribus/Kuhn families, etc.). New functionality lives in sibling modules: + +| File | Public Python classes / functions | +|---|---| +| `src/lib.rs` | existing wrappers + `#[pymodule]` registration | +| `src/bot.rs` | `BotProfile`, `PlayStyle`, `Playbook`, `PlaybookEntry`, `PositionRanges`, `ActionRanges`, `PositionalBetting`, `BettingStrategy`, `RangeStrategy`, `WeightedRange`, `ComboWeight`, `TableSize`, `TableSnapshot`, `RuleBasedDecider`, `BotSim`, `PlayerAction` | +| `src/hand_history.rs` | `HandHistory`, `HandCollection`, `HandMeta`, `HandVariant`, `Stakes`, `TableInfo`, `PlayerEntry`, `ResultEntry`, `PostedBlind`, `Streets`, `PreflopStreet`, `FlopStreet`, `TurnStreet`, `RiverStreet`, `Action`, `ActionType`, `Outcome`, `AnalysisContext`, module constant `FORMAT_VERSION` | +| `src/stats.rs` | `PlayerStats`, `StatsRegistry`, `Confidence`, `YamlPlayerStatsStore` | +| `src/session.rs` | `PokerSession` | +| `src/table_no_cell.rs` | `TableNoCell`, `SeatsNoCell`, `SeatNoCell`, `PlayerNoCell` | + +Each new module follows the existing wrapper pattern: + +- Import pkcore types aliased as `Pk`. +- Define `pub struct (Pk);` with `#[pyclass(name = "")]`. +- Add `#[pymethods] impl { ... }` blocks. Constructors take `&str`/primitives, parses go through `FromStr` and `to_py_err`. +- Each module exposes a `pub(crate) fn register(m: &Bound) -> PyResult<()>` that calls `m.add_class::()` for everything it owns. `lib.rs`'s `#[pymodule]` function calls each `register`. + +### Python surface + +`python/pkpy/__init__.py` re-exports every new class from `pkpy._pkpy`, alphabetized into `__all__` so callers continue to do `from pkpy import HandHistory, BotProfile, ...`. + +## Compile-time fixes + +None strictly required — the wildcard arm at `lib.rs:2725` keeps the existing match valid after the new `ChipAuditFailed` variant lands. + +For Python-visible parity, add explicit arms in `TableAction.kind()`: + +- `PkTableAction::ChipAuditFailed(_, _) => "ChipAuditFailed".into(),` +- `PkTableAction::ResetTable => "ResetTable".into(),` + +The wildcard stays as a safety net for any future pkcore variants. No other call sites in pkpy reference removed or renamed pkcore APIs. + +## Data flow + +Unchanged. The wrapper pattern stays: + +``` +Python call → PyO3 → Rust newtype method → pkcore call → wrap result → Python +``` + +`to_py_err` continues to bridge `PKError` and any `Display` error type to `PyValueError`. New `PKError::InvalidFrequency` and `PKError::ChipAuditFailed { expected, actual }` variants format through `Display` and need no special handling. + +For methods that return collections (e.g., `BotSim::run_n_hands` returning `Vec`), wrappers return `Vec` — PyO3 converts to a Python `list` automatically. + +For methods that take callbacks or closures from Rust (rare in this surface), we expose them as plain methods with concrete inputs and skip closure-based APIs. + +## Error handling + +- All fallible pkcore calls use `to_py_err` to surface a `ValueError` to Python. +- Persistence errors from `YamlPlayerStatsStore` (IO, parse) bubble up as `ValueError` via `to_py_err` on the `Display` impl. +- Invalid Python inputs (e.g., a `Confidence` string that doesn't match `low`/`med`/`high`) are validated at the wrapper boundary and raise `ValueError`. + +## Testing + +### Existing coverage + +`tests/test_pkpy.py` (1136 lines) must continue to pass after the upgrade. The `TableAction.kind()` change does not affect any existing assertion. The `Player::act_bet_blind` behavior change is visible only when a player has fewer chips than the blind — none of the existing fixtures exercise that case. + +### New tests (one file per new module) + +| File | Coverage | +|---|---| +| `tests/test_bot.py` | Build a `BotProfile`, attach a `Playbook`, decide an action via `RuleBasedDecider` against a `TableSnapshot`, smoke-test `BotSim.run_n_hands(n=10)` to confirm hands run end-to-end | +| `tests/test_hand_history.py` | Construct a `HandHistory` from a known event log, round-trip through YAML save/load, walk `Streets` and inspect `Action`/`Outcome`, parse a multi-hand `HandCollection` | +| `tests/test_stats.py` | Register a player in `StatsRegistry`, ingest one or more hands from `HandHistory`, query `PlayerStats` (VPIP, PFR, etc., as exposed), save/load via `YamlPlayerStatsStore` to a `tmp_path` fixture | +| `tests/test_session.py` | Start a `PokerSession`, run one hand, assert the captured event log + shuffled-deck string match expectations | +| `tests/test_table_no_cell.py` | Build a `TableNoCell`, run a heads-up hand end-to-end, compare a small set of observable outcomes (winnings, final chip counts) against an equivalent `Dealer`-driven hand | + +### Verification gate + +`make ayce` (fmt + build + test + demo) must succeed. + +## Build sequence (high-level — detailed plan to be authored by writing-plans) + +1. Bump `Cargo.toml` (`pkcore = "0.0.52"`, `pkpy = "0.0.52"`); run `cargo build` and `make test`. Expectation: clean build (wildcard absorbs new `TableAction` variant) and existing test suite passes — minimal-bump milestone reached. +2. Add explicit `ChipAuditFailed` and `ResetTable` arms in `TableAction.kind()`; rerun `make test`. +3. Scaffold the empty new modules (`bot.rs`, `hand_history.rs`, `stats.rs`, `session.rs`, `table_no_cell.rs`), each with a `register` function. Wire them into `lib.rs`'s `#[pymodule]`. +4. Implement bindings module by module, each with its Python test file. Order: `hand_history` → `stats` → `bot` → `session` → `table_no_cell` (each later module is allowed to depend on earlier ones). +5. Update `python/pkpy/__init__.py` exports as classes are added. +6. Bump `pyproject.toml` if version is mirrored there (currently `pkpython = "0.1.0"`, separate stream — leave unless user requests). +7. Final `make ayce` and a manual `make demo` smoke test. + +## Out of scope + +- WASM / browser builds. +- Re-binding `Solver` / `SolverConfig` — already bound and unchanged in 0.0.52. +- Async / streaming APIs. +- Exposing Rust traits (e.g., `PlayerStatsStore`) to Python — only the concrete `YamlPlayerStatsStore` is bound. +- Edition 2024 migration of `pkpy` itself. +- Updating `pyproject.toml`'s `pkpython` version (different release stream). diff --git a/docs/superpowers/specs/2026-04-29-pkpy-pokersession-tablenocell-design.md b/docs/superpowers/specs/2026-04-29-pkpy-pokersession-tablenocell-design.md new file mode 100644 index 0000000..1419b0e --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-pkpy-pokersession-tablenocell-design.md @@ -0,0 +1,319 @@ +# pkpy `PokerSession` + `TableNoCell` Bindings — Design + +**Date:** 2026-04-29 +**Scope:** Targeted slice of pkcore 0.0.53 — bind the no-cell session/table primitives needed to drive a multi-hand poker session from Python, with the new 0.0.53 blinds-management methods (`set_blinds`, `forced_at_hand_start`) folded in. +**Target version:** `pkpy 0.0.53` (no version bump; this work happens on top of the just-shipped 0.0.53 dep bump). + +## Context + +`pkcore 0.0.53` added `PokerSession::set_blinds` and `PokerSession::forced_at_hand_start` to support hand-history pipelines that need stable blinds across a hand. Surfacing those methods in pkpy requires `PokerSession` itself to be bound — and that requires its `TableNoCell` constructor argument, which in turn requires `SeatsNoCell`, `SeatNoCell`, `PlayerNoCell`. None of these are bound today; `src/session.rs` and `src/table_no_cell.rs` are the no-op scaffold modules created by the 0.0.52 bump (commit `b17c327`). + +The existing 0.0.52 plan (`docs/superpowers/plans/2026-04-28-pkpy-0.0.52-upgrade.md`) sketched Phase 6 (PokerSession) and Phase 7 (TableNoCell), but (a) was written against pkcore 0.0.52 so it omits the new methods, (b) put the phases in the wrong dependency order (PokerSession before TableNoCell), and (c) guessed a constructor signature that doesn't match 0.0.53 (`PokerSession::new(forced, seats: u8)` was wrong; the actual signature is `PokerSession::new(table: TableNoCell)`). This spec supersedes those two phases. + +**Intended outcome:** A Python user can construct a heads-up table, run a hand to completion, observe the event log, change blinds between hands, and access the new 0.0.53 stable-blinds snapshot — all without writing Rust or touching primitives via cumbersome literal-mirror constructors. + +## Scope + +**In scope:** + +- `PokerSession` — full surface (12 methods + 2 field getters), excluding `run_hand` (callback-style; not worth the FFI friction). +- `TableNoCell` — faithful constructor `nlh_from_seats(seats, forced)` plus convenience `heads_up(forced, stacks, names)`. Read-only inspection helpers. +- `SeatsNoCell`, `SeatNoCell`, `PlayerNoCell` — constructors + read-only inspection accessors. **No** `act_*` methods. +- `PlayerAction` — full enum, bound TableAction-style (static constructors + `kind()`/`amount()` accessors). +- `SessionStep` — read-only enum, bound TableAction-style (`kind()`/`seat()` accessors). + +**Reused (already bound, no work):** `ForcedBets`, `Winnings`, `PotWin`. + +**Out of scope (explicit):** + +- Hand-history bindings (Phase 3 of the 0.0.52 plan). +- Player-stats bindings (Phase 4). +- Bot bindings (Phase 5). +- Action methods on `SeatsNoCell` / `SeatNoCell` / `PlayerNoCell` — `act_bet`, `act_raise`, `act_call`, `act_check`, `act_fold`, `act_all_in`, `act_forced_bet`, `bring_it_in`, `close_it_out`, etc. These are accessible through `PokerSession::apply_action`; binding them directly invites confusion with the existing cell-based `Dealer` action API. +- `PokerSession::run_hand` (callback-driven hand runner). Python users can drive the session via `next_step` / `apply_action` / `end_hand` in a loop. +- Any pkpy version bump. This is feature work *under* the 0.0.53 line, not a new release. + +## Architecture + +The 0.0.52 bump already created the empty scaffold modules. We fill two of them: + +| File | Status | Content | +|---|---|---| +| `src/table_no_cell.rs` | scaffold → bindings | `PlayerNoCell`, `SeatNoCell`, `SeatsNoCell`, `TableNoCell` | +| `src/session.rs` | scaffold → bindings | `PlayerAction`, `SessionStep`, `PokerSession` | +| `src/lib.rs` | minor edit | `pub(crate)` confirmation for `ForcedBets` / `Winnings` / `PotWin` reachability from sibling modules. `mod` declarations + register calls already in place. | +| `python/pkpy/__init__.py` | re-export | Add the seven new classes. | +| `tests/test_session.py` | new | TDD tests for `PlayerAction`, `SessionStep`, `PokerSession`. | +| `tests/test_table_no_cell.py` | new | TDD tests for the four primitive types. | + +**Build sequence (dependency order):** + +1. `PlayerAction` + `SessionStep` in `session.rs` (no dependencies on other new types). +2. `PlayerNoCell` → `SeatNoCell` → `SeatsNoCell` → `TableNoCell` in `table_no_cell.rs` (each depends on the previous). +3. `PokerSession` in `session.rs` (depends on all of the above). + +`make ayce` must stay green at each step's commit boundary. + +## Conventions + +- Existing pkpy convention: `#[pyclass(from_py_object, name = "X")] #[derive(Clone)] pub struct X(PkX);` with `#[pymethods]` block. Follow it everywhere. +- Errors propagate via the existing `to_py_err` helper (`lib.rs:61`, `pub(crate)`). +- Enum bindings follow `TableAction`'s pattern: opaque wrapper, `kind()` returning a `&'static str`, optional payload accessors (`seat()`, `amount()`). Construction-needing enums (`PlayerAction`) add `#[staticmethod]` constructors per variant. +- `raise` is a Python keyword, so the `PlayerAction::Raise` constructor binds as `raise_`. The trailing-underscore convention is standard Python (cf. `class_`, `from_`). +- `__repr__` on every type for usable Python REPL output. +- `__eq__` only on types with semantic equality (`PlayerAction`); not added speculatively elsewhere. + +## Type Bindings (detail) + +### `PlayerAction` (`src/session.rs`) + +```rust +#[pyclass(from_py_object, name = "PlayerAction")] +#[derive(Clone)] +pub struct PlayerAction(pub(crate) PkPlayerAction); + +#[pymethods] +impl PlayerAction { + #[staticmethod] fn fold() -> Self { Self(PkPlayerAction::Fold) } + #[staticmethod] fn check() -> Self { Self(PkPlayerAction::Check) } + #[staticmethod] fn call() -> Self { Self(PkPlayerAction::Call) } + #[staticmethod] fn bet(amount: usize) -> Self { Self(PkPlayerAction::Bet(amount)) } + #[staticmethod] fn raise_(amount: usize) -> Self { Self(PkPlayerAction::Raise(amount)) } + #[staticmethod] fn all_in() -> Self { Self(PkPlayerAction::AllIn) } + + fn kind(&self) -> &'static str { /* "Fold"|"Check"|"Call"|"Bet"|"Raise"|"AllIn" */ } + fn amount(&self) -> Option { /* Some(n) for Bet/Raise; None otherwise */ } + fn __repr__(&self) -> String { /* e.g. "PlayerAction.Bet(200)" */ } + fn __eq__(&self, other: &PlayerAction) -> bool { self.0 == other.0 } +} +``` + +### `SessionStep` (`src/session.rs`) + +Read-only opaque wrapper, returned from `PokerSession::next_step`: + +```rust +#[pyclass(from_py_object, name = "SessionStep")] +#[derive(Clone)] +pub struct SessionStep(PkSessionStep); + +#[pymethods] +impl SessionStep { + fn kind(&self) -> &'static str { /* "PlayerToAct"|"StreetAdvanced"|"HandComplete" */ } + fn seat(&self) -> Option { /* Some(seat) for PlayerToAct; None otherwise */ } + fn __repr__(&self) -> String { /* e.g. "SessionStep.PlayerToAct(seat=2)" */ } +} +``` + +### `PlayerNoCell` (`src/table_no_cell.rs`) + +```rust +#[pyclass(from_py_object, name = "PlayerNoCell")] +#[derive(Clone)] +pub struct PlayerNoCell(pub(crate) PkPlayerNoCell); + +#[pymethods] +impl PlayerNoCell { + #[new] + #[pyo3(signature = (handle, chips=0))] + fn new(handle: String, chips: usize) -> Self { + if chips == 0 { Self(PkPlayerNoCell::new(handle)) } + else { Self(PkPlayerNoCell::new_with_chips(handle, chips)) } + } + + fn total_chip_count(&self) -> usize { self.0.total_chip_count() } + fn is_active(&self) -> bool { self.0.is_active() } + fn is_all_in(&self) -> bool { self.0.is_all_in() } + fn is_in_hand(&self) -> bool { self.0.is_in_hand() } + fn is_out(&self) -> bool { self.0.is_out() } + fn is_tapped_out(&self) -> bool { self.0.is_tapped_out() } + fn is_clear(&self) -> bool { self.0.is_clear() } + fn has_bet(&self) -> bool { self.0.has_bet() } + fn __repr__(&self) -> String { format!("PlayerNoCell({})", self.0) } +} +``` + +### `SeatNoCell` (`src/table_no_cell.rs`) + +```rust +#[pyclass(from_py_object, name = "SeatNoCell")] +#[derive(Clone)] +pub struct SeatNoCell(pub(crate) PkSeatNoCell); + +#[pymethods] +impl SeatNoCell { + #[new] fn new(player: &PlayerNoCell) -> Self { Self(PkSeatNoCell::new(player.0.clone())) } + + fn is_empty(&self) -> bool { self.0.is_empty() } + fn is_active(&self) -> bool { self.0.is_active() } + fn is_all_in(&self) -> bool { self.0.is_all_in() } + fn is_in_hand(&self) -> bool { self.0.is_in_hand() } + fn is_yet_to_act(&self) -> bool { self.0.is_yet_to_act() } + fn is_yet_to_act_or_blind(&self) -> bool { self.0.is_yet_to_act_or_blind() } + fn is_clear(&self) -> bool { self.0.is_clear() } + fn __repr__(&self) -> String { format!("SeatNoCell({})", self.0) } +} +``` + +### `SeatsNoCell` (`src/table_no_cell.rs`) + +```rust +#[pyclass(from_py_object, name = "SeatsNoCell")] +#[derive(Clone)] +pub struct SeatsNoCell(pub(crate) PkSeatsNoCell); + +#[pymethods] +impl SeatsNoCell { + #[new] fn new(seats: Vec) -> Self { + Self(PkSeatsNoCell::new(seats.into_iter().map(|s| s.0).collect())) + } + + fn size(&self) -> u8 { self.0.size() } + fn get_seat(&self, idx: u8) -> Option { + self.0.get_seat(idx).cloned().map(SeatNoCell) + } + fn is_seat_in_hand(&self, idx: u8) -> bool { self.0.is_seat_in_hand(idx) } + fn current_bet(&self) -> usize { self.0.current_bet() } + fn to_call(&self, player_idx: u8) -> usize { self.0.to_call(player_idx) } + fn total_chip_count(&self) -> usize { self.0.total_chip_count() } + fn count_active_in_hand(&self) -> usize { self.0.count_active_in_hand() } + fn active_in_hand(&self) -> Vec { self.0.active_in_hand() } + fn are_dealt(&self) -> bool { self.0.are_dealt() } + fn are_clear(&self) -> bool { self.0.are_clear() } + fn is_betting_complete(&self) -> bool { self.0.is_betting_complete() } + fn __repr__(&self) -> String { format!("SeatsNoCell(size={})", self.0.size()) } +} +``` + +### `TableNoCell` (`src/table_no_cell.rs`) + +Faithful pkcore mirror plus a Python-friendly heads-up factory. + +```rust +#[pyclass(from_py_object, name = "TableNoCell")] +#[derive(Clone)] +pub struct TableNoCell(pub(crate) PkTableNoCell); + +#[pymethods] +impl TableNoCell { + /// Faithful pkcore mirror. + #[staticmethod] + fn nlh_from_seats(seats: &SeatsNoCell, forced: &ForcedBets) -> Self { + Self(PkTableNoCell::nlh_from_seats(seats.0.clone(), forced.0)) + } + + /// Convenience: heads-up table with two named, equally-stacked players. + #[staticmethod] + #[pyo3(signature = (forced, stacks=(1000, 1000), names=("A".to_string(), "B".to_string())))] + fn heads_up(forced: &ForcedBets, stacks: (usize, usize), names: (String, String)) -> Self { + let seats = PkSeatsNoCell::new(vec![ + PkSeatNoCell::new(PkPlayerNoCell::new_with_chips(names.0, stacks.0)), + PkSeatNoCell::new(PkPlayerNoCell::new_with_chips(names.1, stacks.1)), + ]); + Self(PkTableNoCell::nlh_from_seats(seats, forced.0)) + } + + fn seat_count(&self) -> u8 { self.0.seats.size() } + fn determine_small_blind(&self) -> u8 { self.0.determine_small_blind() } + fn determine_big_blind(&self) -> u8 { self.0.determine_big_blind() } + fn next_occupied_seat_after(&self, start: u8, n: usize) -> u8 { + self.0.next_occupied_seat_after(start, n) + } + fn seats(&self) -> SeatsNoCell { SeatsNoCell(self.0.seats.clone()) } + fn __repr__(&self) -> String { format!("TableNoCell(seats={})", self.0.seats.size()) } +} +``` + +### `PokerSession` (`src/session.rs`) + +```rust +#[pyclass(name = "PokerSession")] +pub struct PokerSession(pub(crate) PkPokerSession); + +#[pymethods] +impl PokerSession { + #[new] fn new(table: &TableNoCell) -> Self { Self(PkPokerSession::new(table.0.clone())) } + + /// Convenience: heads-up session in one call. + #[staticmethod] + #[pyo3(signature = (forced, stacks=(1000, 1000), names=("A".to_string(), "B".to_string())))] + fn heads_up(forced: &ForcedBets, stacks: (usize, usize), names: (String, String)) -> Self { + let table = TableNoCell::heads_up(forced, stacks, names); + Self(PkPokerSession::new(table.0)) + } + + // 0.0.53 NEW + fn set_blinds(&mut self, forced: &ForcedBets) { self.0.set_blinds(forced.0) } + fn forced_at_hand_start(&self) -> ForcedBets { ForcedBets(self.0.forced_at_hand_start()) } + + // Hand lifecycle + fn start_hand(&mut self) -> PyResult<()> { self.0.start_hand().map_err(to_py_err) } + fn end_hand(&mut self) -> PyResult { self.0.end_hand().map(Winnings).map_err(to_py_err) } + fn is_hand_in_progress(&self) -> bool { self.0.is_hand_in_progress() } + fn is_hand_complete(&self) -> bool { self.0.is_hand_complete() } + fn next_actor(&mut self) -> Option { self.0.next_actor() } + fn next_step(&mut self) -> SessionStep { SessionStep(self.0.next_step()) } + fn apply_action(&mut self, seat: u8, action: &PlayerAction) -> PyResult<()> { + self.0.apply_action(seat, action.0).map_err(to_py_err) + } + + // Session-wide + fn count_funded(&self) -> usize { self.0.count_funded() } + fn eliminate_busted(&mut self) -> Vec { self.0.eliminate_busted() } + + // Field accessors (pkcore exposes these as plain pub fields) + #[getter] fn hand_number(&self) -> u64 { self.0.hand_number } + #[getter] fn shuffled_deck_str(&self) -> Option { self.0.shuffled_deck_str.clone() } +} +``` + +## Test Plan + +TDD-style: write failing test → implement → make green. One test file per source module. + +### `tests/test_table_no_cell.py` + +- `PlayerNoCell` construction with and without chips; default state checks. +- `SeatNoCell` wrapping a `PlayerNoCell`; empty/active state. +- `SeatsNoCell` from a list; `size`, `get_seat(idx)`, `total_chip_count`, `active_in_hand`. +- `TableNoCell.nlh_from_seats` literal-mirror construction. +- `TableNoCell.heads_up` convenience constructor + default-stack/name verification. +- `seat_count`, `determine_small_blind`, `determine_big_blind`. + +### `tests/test_session.py` + +- `PlayerAction.fold/check/call/bet/raise_/all_in` constructors; `kind()` and `amount()` accessors; `__eq__`. +- `SessionStep.kind()` shapes — `PlayerToAct` returns a `seat()`; `StreetAdvanced` and `HandComplete` return `None`. +- `PokerSession.heads_up(...)` constructs successfully; `start_hand()` succeeds; `next_step()` returns `PlayerToAct`. +- **0.0.53 regression ports** (direct translations of pkcore's three new unit tests at `casino/session.rs:970-1010`): + - `set_blinds` between hands applies immediately (next `start_hand` posts new blinds). + - `set_blinds` mid-hand defers — current hand's blinds unchanged until next `start_hand`. + - `forced_at_hand_start` returns the snapshot taken at the most recent `start_hand`, stable across mid-hand `set_blinds`. +- End-to-end: heads-up hand played to fold → `end_hand` returns a `Winnings` with `len() > 0`. + +## Verification + +- `make ayce` (fmt + maturin develop + pytest + demo) passes after each commit in the build sequence. +- `pytest tests/test_table_no_cell.py tests/test_session.py -v` — all new tests pass. +- Spot-check via REPL: + ```python + from pkpy import PokerSession, ForcedBets, PlayerAction + s = PokerSession.heads_up(ForcedBets(50, 100)) + s.start_hand() + print(s.next_step()) # SessionStep.PlayerToAct(seat=...) + s.set_blinds(ForcedBets(100, 200)) # deferred + print(s.forced_at_hand_start()) # still ForcedBets(50, 100) + ``` + +## Risks / Open Questions + +- **`PlayerNoCell::new_with_chips` vs `new`:** the chips=0 sentinel-based dispatch in the constructor is mildly awkward. Acceptable because pkcore's `PlayerNoCell::new` produces a player with 0 chips anyway, so the two paths are observationally equivalent for `chips=0`. If pkcore later diverges these constructors, switch to two named static methods (`PlayerNoCell.new_handle`, `PlayerNoCell.with_chips`). +- **`TableNoCell.seats.size()` reaches into a public field.** If pkcore later seals that field, change the binding to `self.0.seats().size()` or equivalent accessor. +- **`SessionStep` `Display` impl** — pkcore may not expose `Display` for `SessionStep`. The `__repr__` implementation may need to construct the string explicitly from `kind()` + `seat()` rather than delegating. Verify when implementing; trivial either way. + +## Notes for the implementing agent + +- Use the `to_py_err` helper that's already `pub(crate)` from the 0.0.52 work — don't re-derive it. +- Re-imports at the top of each new module file follow the existing pattern in `lib.rs:30` (`use pkcore::casino::game::ForcedBets as PkForcedBets;`). +- `from_py_object` on the `#[pyclass]` attribute is the existing pkpy convention; preserve it. (`PokerSession` is the exception — pkcore's `PokerSession` doesn't implement `Clone`, so `from_py_object` won't work; use plain `#[pyclass]` and accept `&PokerSession` references in any future binding that needs one.) +- `python/pkpy/__init__.py` re-exports: add `PokerSession`, `PlayerAction`, `SessionStep`, `TableNoCell`, `SeatsNoCell`, `SeatNoCell`, `PlayerNoCell` to the import block and the `__all__` if there is one. diff --git a/python/pkpy/__init__.py b/python/pkpy/__init__.py index 1292ae1..8d7f3c8 100644 --- a/python/pkpy/__init__.py +++ b/python/pkpy/__init__.py @@ -49,18 +49,25 @@ IndexCardMap, Outs, Player, + PlayerNoCell, + PlayerAction, PlayerState, Pluribus, + PokerSession, PluribusEvent, Qualifier, Rank, SeatEquity, + SeatNoCell, + SeatsNoCell, Seatbit, + SessionStep, SevenFiveBCM, Stack, Suit, TableAction, TableLog, + TableNoCell, TurnEval, Two, Twos, @@ -103,18 +110,25 @@ "IndexCardMap", "Outs", "Player", + "PlayerNoCell", + "PlayerAction", "PlayerState", "Pluribus", + "PokerSession", "PluribusEvent", "Qualifier", "Rank", "SeatEquity", + "SeatNoCell", + "SeatsNoCell", "Seatbit", + "SessionStep", "SevenFiveBCM", "Stack", "Suit", "TableAction", "TableLog", + "TableNoCell", "TurnEval", "Two", "Twos", diff --git a/src/bot.rs b/src/bot.rs new file mode 100644 index 0000000..bf9ab69 --- /dev/null +++ b/src/bot.rs @@ -0,0 +1,7 @@ +//! Bindings for pkcore's bot module. + +use pyo3::prelude::*; + +pub(crate) fn register(_m: &Bound<'_, PyModule>) -> PyResult<()> { + Ok(()) +} diff --git a/src/hand_history.rs b/src/hand_history.rs new file mode 100644 index 0000000..9046692 --- /dev/null +++ b/src/hand_history.rs @@ -0,0 +1,7 @@ +//! Bindings for pkcore's hand_history module. + +use pyo3::prelude::*; + +pub(crate) fn register(_m: &Bound<'_, PyModule>) -> PyResult<()> { + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 42b2f5e..c38f2bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,7 +58,13 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::str::FromStr; -fn to_py_err(e: impl std::fmt::Display) -> PyErr { +mod bot; +mod hand_history; +mod session; +mod stats; +mod table_no_cell; + +pub(crate) fn to_py_err(e: impl std::fmt::Display) -> PyErr { PyValueError::new_err(e.to_string()) } @@ -2265,7 +2271,7 @@ impl Deck { /// 100 #[pyclass(from_py_object, name = "ForcedBets")] #[derive(Clone)] -pub struct ForcedBets(PkForcedBets); +pub struct ForcedBets(pub(crate) PkForcedBets); #[pymethods] impl ForcedBets { @@ -2637,7 +2643,7 @@ impl PotWin { /// The payout results from a completed hand — a list of Win records. #[pyclass(from_py_object, name = "Winnings")] #[derive(Clone)] -pub struct Winnings(PkWinnings); +pub struct Winnings(pub(crate) PkWinnings); #[pymethods] impl Winnings { @@ -2722,6 +2728,8 @@ impl TableAction { PkTableAction::ClosesTheAction(_) => "ClosesTheAction".into(), PkTableAction::CloseItOut(_) => "CloseItOut".into(), PkTableAction::EndHand => "EndHand".into(), + PkTableAction::ChipAuditFailed(_, _) => "ChipAuditFailed".into(), + PkTableAction::ResetTable => "ResetTable".into(), _ => "Other".into(), } } @@ -4036,5 +4044,27 @@ fn _pkpy(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(distinct_5_card_hands, m)?)?; m.add_function(wrap_pyfunction!(unique_2_card_hands, m)?)?; m.add_function(wrap_pyfunction!(distinct_2_card_hands, m)?)?; + hand_history::register(m)?; + stats::register(m)?; + bot::register(m)?; + session::register(m)?; + table_no_cell::register(m)?; Ok(()) } + +#[cfg(test)] +mod table_action_kind_tests { + use super::*; + + #[test] + fn chip_audit_failed_returns_explicit_kind() { + let ta = TableAction(PkTableAction::ChipAuditFailed(100, 99)); + assert_eq!("ChipAuditFailed", ta.kind()); + } + + #[test] + fn reset_table_returns_explicit_kind() { + let ta = TableAction(PkTableAction::ResetTable); + assert_eq!("ResetTable", ta.kind()); + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..39212ce --- /dev/null +++ b/src/session.rs @@ -0,0 +1,212 @@ +//! Bindings for pkcore's `casino::session` module. + +use crate::table_no_cell::TableNoCell; +use crate::{to_py_err, ForcedBets, Winnings}; +use pkcore::casino::action::PlayerAction as PkPlayerAction; +use pkcore::casino::session::PokerSession as PkPokerSession; +use pkcore::casino::session::SessionStep as PkSessionStep; +use pyo3::prelude::*; + +/// A player's action in a poker hand. +/// +/// Construct via the static methods (`fold()`, `check()`, `call()`, +/// `bet(n)`, `raise_(n)`, `all_in()`). Inspect via `kind()` and `amount()`. +/// +/// `raise` is a Python keyword, hence the trailing-underscore naming +/// convention for that constructor. +#[pyclass(from_py_object, name = "PlayerAction")] +#[derive(Clone)] +pub struct PlayerAction(pub(crate) PkPlayerAction); + +#[pymethods] +impl PlayerAction { + #[staticmethod] + fn fold() -> Self { + Self(PkPlayerAction::Fold) + } + + #[staticmethod] + fn check() -> Self { + Self(PkPlayerAction::Check) + } + + #[staticmethod] + fn call() -> Self { + Self(PkPlayerAction::Call) + } + + #[staticmethod] + fn bet(amount: usize) -> Self { + Self(PkPlayerAction::Bet(amount)) + } + + #[staticmethod] + #[pyo3(name = "raise_")] + fn raise_(amount: usize) -> Self { + Self(PkPlayerAction::Raise(amount)) + } + + #[staticmethod] + fn all_in() -> Self { + Self(PkPlayerAction::AllIn) + } + + fn kind(&self) -> &'static str { + match self.0 { + PkPlayerAction::Fold => "Fold", + PkPlayerAction::Check => "Check", + PkPlayerAction::Call => "Call", + PkPlayerAction::Bet(_) => "Bet", + PkPlayerAction::Raise(_) => "Raise", + PkPlayerAction::AllIn => "AllIn", + } + } + + fn amount(&self) -> Option { + match self.0 { + PkPlayerAction::Bet(n) | PkPlayerAction::Raise(n) => Some(n), + _ => None, + } + } + + fn __repr__(&self) -> String { + match self.0 { + PkPlayerAction::Bet(n) => format!("PlayerAction.Bet({n})"), + PkPlayerAction::Raise(n) => format!("PlayerAction.Raise({n})"), + other => format!("PlayerAction.{other:?}"), + } + } + + fn __eq__(&self, other: &PlayerAction) -> bool { + self.0 == other.0 + } +} + +/// A snapshot of where a hand is in its lifecycle. +/// +/// Returned by `PokerSession.next_step()`. Read-only; inspect via `kind()` +/// and (for `PlayerToAct`) `seat()`. +#[pyclass(from_py_object, name = "SessionStep")] +#[derive(Clone)] +pub struct SessionStep(pub(crate) PkSessionStep); + +#[pymethods] +impl SessionStep { + fn kind(&self) -> &'static str { + match self.0 { + PkSessionStep::PlayerToAct(_) => "PlayerToAct", + PkSessionStep::StreetAdvanced => "StreetAdvanced", + PkSessionStep::HandComplete => "HandComplete", + } + } + + fn seat(&self) -> Option { + match self.0 { + PkSessionStep::PlayerToAct(s) => Some(s), + _ => None, + } + } + + fn __repr__(&self) -> String { + match self.0 { + PkSessionStep::PlayerToAct(s) => format!("SessionStep.PlayerToAct(seat={s})"), + PkSessionStep::StreetAdvanced => "SessionStep.StreetAdvanced".to_string(), + PkSessionStep::HandComplete => "SessionStep.HandComplete".to_string(), + } + } +} + +/// A multi-hand poker session wrapping a `TableNoCell`. +/// +/// Drive a hand: `start_hand()` → loop on `next_step()`, calling +/// `apply_action(seat, PlayerAction.X)` for each `PlayerToAct` — +/// `end_hand()` to settle. Use `set_blinds` (deferred mid-hand) to +/// adjust forced bets between hands. +#[pyclass(name = "PokerSession")] +pub struct PokerSession(pub(crate) PkPokerSession); + +#[pymethods] +impl PokerSession { + #[new] + fn new(table: &TableNoCell) -> Self { + Self(PkPokerSession::new(table.0.clone())) + } + + /// Convenience: heads-up session in one call. Mirrors + /// `TableNoCell.heads_up`'s defaults. + #[staticmethod] + #[pyo3(signature = (forced, stacks=(1000, 1000), names=("A".to_string(), "B".to_string())))] + fn heads_up(forced: &ForcedBets, stacks: (usize, usize), names: (String, String)) -> Self { + let table = TableNoCell::heads_up(forced, stacks, names); + Self(PkPokerSession::new(table.0)) + } + + // ── 0.0.53 NEW ─────────────────────────────────────────────────────── + + fn set_blinds(&mut self, forced: &ForcedBets) { + self.0.set_blinds(forced.0); + } + + fn forced_at_hand_start(&self) -> ForcedBets { + ForcedBets(self.0.forced_at_hand_start()) + } + + // ── Hand lifecycle ────────────────────────────────────────────────── + + fn start_hand(&mut self) -> PyResult<()> { + self.0.start_hand().map_err(to_py_err) + } + + fn end_hand(&mut self) -> PyResult { + self.0.end_hand().map(Winnings).map_err(to_py_err) + } + + fn is_hand_in_progress(&self) -> bool { + self.0.is_hand_in_progress() + } + + fn is_hand_complete(&self) -> bool { + self.0.is_hand_complete() + } + + fn next_actor(&mut self) -> Option { + self.0.next_actor() + } + + fn next_step(&mut self) -> SessionStep { + SessionStep(self.0.next_step()) + } + + fn apply_action(&mut self, seat: u8, action: &PlayerAction) -> PyResult<()> { + self.0.apply_action(seat, action.0).map_err(to_py_err) + } + + // ── Session-wide ──────────────────────────────────────────────────── + + fn count_funded(&self) -> usize { + self.0.count_funded() + } + + fn eliminate_busted(&mut self) -> Vec { + self.0.eliminate_busted() + } + + // ── Field accessors ───────────────────────────────────────────────── + + #[getter] + fn hand_number(&self) -> u32 { + self.0.hand_number + } + + #[getter] + fn shuffled_deck_str(&self) -> Option { + self.0.shuffled_deck_str.clone() + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..3b07c90 --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,7 @@ +//! Bindings for pkcore's analysis::player_stats module. + +use pyo3::prelude::*; + +pub(crate) fn register(_m: &Bound<'_, PyModule>) -> PyResult<()> { + Ok(()) +} diff --git a/src/table_no_cell.rs b/src/table_no_cell.rs new file mode 100644 index 0000000..3ed4bce --- /dev/null +++ b/src/table_no_cell.rs @@ -0,0 +1,232 @@ +//! Bindings for pkcore's `casino::table_no_cell` module. + +use crate::ForcedBets; +use pkcore::casino::table_no_cell::PlayerNoCell as PkPlayerNoCell; +use pkcore::casino::table_no_cell::SeatNoCell as PkSeatNoCell; +use pkcore::casino::table_no_cell::SeatsNoCell as PkSeatsNoCell; +use pkcore::casino::table_no_cell::TableNoCell as PkTableNoCell; +use pyo3::prelude::*; + +/// A no-cell player record (handle + chip stack + state flags). +/// +/// Constructed standalone or via `PlayerNoCell(handle, chips=N)`. Wrapped +/// in `SeatNoCell` for table assembly. +#[pyclass(from_py_object, name = "PlayerNoCell")] +#[derive(Clone)] +pub struct PlayerNoCell(pub(crate) PkPlayerNoCell); + +#[pymethods] +impl PlayerNoCell { + #[new] + #[pyo3(signature = (handle, chips=0))] + fn new(handle: String, chips: usize) -> Self { + if chips == 0 { + Self(PkPlayerNoCell::new(handle)) + } else { + Self(PkPlayerNoCell::new_with_chips(handle, chips)) + } + } + + fn total_chip_count(&self) -> usize { + self.0.total_chip_count() + } + + fn is_active(&self) -> bool { + self.0.is_active() + } + + fn is_all_in(&self) -> bool { + self.0.is_all_in() + } + + fn is_in_hand(&self) -> bool { + self.0.is_in_hand() + } + + fn is_out(&self) -> bool { + self.0.is_out() + } + + fn is_tapped_out(&self) -> bool { + self.0.is_tapped_out() + } + + fn is_clear(&self) -> bool { + self.0.is_clear() + } + + fn has_bet(&self) -> bool { + self.0.has_bet() + } + + fn __repr__(&self) -> String { + format!("PlayerNoCell({})", self.0) + } +} + +/// A seat at a no-cell table, wrapping a `PlayerNoCell`. +#[pyclass(from_py_object, name = "SeatNoCell")] +#[derive(Clone)] +pub struct SeatNoCell(pub(crate) PkSeatNoCell); + +#[pymethods] +impl SeatNoCell { + #[new] + fn new(player: &PlayerNoCell) -> Self { + Self(PkSeatNoCell::new(player.0.clone())) + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn is_active(&self) -> bool { + self.0.is_active() + } + + fn is_all_in(&self) -> bool { + self.0.is_all_in() + } + + fn is_in_hand(&self) -> bool { + self.0.is_in_hand() + } + + fn is_yet_to_act(&self) -> bool { + self.0.is_yet_to_act() + } + + fn is_yet_to_act_or_blind(&self) -> bool { + self.0.is_yet_to_act_or_blind() + } + + fn is_clear(&self) -> bool { + self.0.is_clear() + } + + fn __repr__(&self) -> String { + format!("SeatNoCell({})", self.0) + } +} + +/// A vector of `SeatNoCell` representing a table's seats. +#[pyclass(from_py_object, name = "SeatsNoCell")] +#[derive(Clone)] +pub struct SeatsNoCell(pub(crate) PkSeatsNoCell); + +#[pymethods] +impl SeatsNoCell { + #[new] + fn new(seats: Vec) -> Self { + Self(PkSeatsNoCell::new(seats.into_iter().map(|s| s.0).collect())) + } + + fn size(&self) -> u8 { + self.0.size() + } + + fn get_seat(&self, idx: u8) -> Option { + self.0.get_seat(idx).cloned().map(SeatNoCell) + } + + fn is_seat_in_hand(&self, idx: u8) -> bool { + self.0.is_seat_in_hand(idx) + } + + fn current_bet(&self) -> usize { + self.0.current_bet() + } + + fn to_call(&self, player_idx: u8) -> usize { + self.0.to_call(player_idx) + } + + fn total_chip_count(&self) -> usize { + self.0.total_chip_count() + } + + fn count_active_in_hand(&self) -> usize { + self.0.count_active_in_hand() + } + + fn active_in_hand(&self) -> Vec { + self.0.active_in_hand() + } + + fn are_dealt(&self) -> bool { + self.0.are_dealt() + } + + fn are_clear(&self) -> bool { + self.0.are_clear() + } + + fn is_betting_complete(&self) -> bool { + self.0.is_betting_complete() + } + + fn __repr__(&self) -> String { + format!("SeatsNoCell(size={})", self.0.size()) + } +} + +/// A no-Cell poker table — same semantics as `TableCelled` but without +/// the interior mutability indirection. Wrapped by `PokerSession` for +/// multi-hand session management. +#[pyclass(from_py_object, name = "TableNoCell")] +#[derive(Clone)] +pub struct TableNoCell(pub(crate) PkTableNoCell); + +#[pymethods] +impl TableNoCell { + /// Construct a NLH table from existing seats and forced-bet config. + /// Faithful pkcore mirror. + #[staticmethod] + fn nlh_from_seats(seats: &SeatsNoCell, forced: &ForcedBets) -> Self { + Self(PkTableNoCell::nlh_from_seats(seats.0.clone(), forced.0)) + } + + /// Convenience: heads-up table with two named, equally-stacked players. + /// Default stacks are (1000, 1000); default names are ("A", "B"). + #[staticmethod] + #[pyo3(signature = (forced, stacks=(1000, 1000), names=("A".to_string(), "B".to_string())))] + pub fn heads_up(forced: &ForcedBets, stacks: (usize, usize), names: (String, String)) -> Self { + let seats = PkSeatsNoCell::new(vec![ + PkSeatNoCell::new(PkPlayerNoCell::new_with_chips(names.0, stacks.0)), + PkSeatNoCell::new(PkPlayerNoCell::new_with_chips(names.1, stacks.1)), + ]); + Self(PkTableNoCell::nlh_from_seats(seats, forced.0)) + } + + fn seat_count(&self) -> u8 { + self.0.seats.size() + } + + fn seats(&self) -> SeatsNoCell { + SeatsNoCell(self.0.seats.clone()) + } + + fn determine_small_blind(&self) -> u8 { + self.0.determine_small_blind() + } + + fn determine_big_blind(&self) -> u8 { + self.0.determine_big_blind() + } + + fn next_occupied_seat_after(&self, start: u8, n: usize) -> u8 { + self.0.next_occupied_seat_after(start, n) + } + + fn __repr__(&self) -> String { + format!("TableNoCell(seats={})", self.0.seats.size()) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..87ee0c9 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,157 @@ +"""Tests for pkpy poker session bindings.""" + +import pytest + +from pkpy import PlayerAction + + +class TestPlayerAction: + def test_fold(self): + a = PlayerAction.fold() + assert a.kind() == "Fold" + assert a.amount() is None + + def test_check(self): + a = PlayerAction.check() + assert a.kind() == "Check" + assert a.amount() is None + + def test_call(self): + a = PlayerAction.call() + assert a.kind() == "Call" + assert a.amount() is None + + def test_bet(self): + a = PlayerAction.bet(200) + assert a.kind() == "Bet" + assert a.amount() == 200 + + def test_raise_(self): + a = PlayerAction.raise_(400) + assert a.kind() == "Raise" + assert a.amount() == 400 + + def test_all_in(self): + a = PlayerAction.all_in() + assert a.kind() == "AllIn" + assert a.amount() is None + + def test_equality(self): + assert PlayerAction.bet(200) == PlayerAction.bet(200) + assert PlayerAction.bet(200) != PlayerAction.bet(300) + assert PlayerAction.fold() == PlayerAction.fold() + assert PlayerAction.fold() != PlayerAction.check() + + def test_repr_contains_kind(self): + assert "Bet" in repr(PlayerAction.bet(200)) + assert "200" in repr(PlayerAction.bet(200)) + assert "Fold" in repr(PlayerAction.fold()) + + +class TestSessionStep: + """SessionStep is read-only — produced by PokerSession.next_step(). + + These tests construct one indirectly via a session in Phase 7. For now, + we just confirm the type exists in the module so import doesn't fail. + """ + + def test_import(self): + from pkpy import SessionStep + # Class must exist; instances are created by PokerSession.next_step. + assert SessionStep is not None + + +class TestPokerSession: + def _heads_up(self, sb=50, bb=100, stacks=(1000, 1000)): + from pkpy import ForcedBets, PokerSession + return PokerSession.heads_up(ForcedBets(sb, bb), stacks=stacks) + + def test_construct_from_table(self): + from pkpy import ForcedBets, PokerSession, TableNoCell + table = TableNoCell.heads_up(ForcedBets(50, 100)) + session = PokerSession(table) + assert session.hand_number == 0 + assert session.shuffled_deck_str is None + + def test_heads_up_factory(self): + session = self._heads_up() + assert session.hand_number == 0 + assert not session.is_hand_in_progress() + + def test_start_hand_increments_hand_number(self): + session = self._heads_up() + session.start_hand() + assert session.hand_number == 1 + assert session.is_hand_in_progress() + + def test_next_step_after_start_is_player_to_act(self): + session = self._heads_up() + session.start_hand() + step = session.next_step() + assert step.kind() == "PlayerToAct" + assert step.seat() is not None + + def test_count_funded(self): + session = self._heads_up() + assert session.count_funded() == 2 + + def test_apply_action_fold_ends_hand(self): + from pkpy import PlayerAction + session = self._heads_up() + session.start_hand() + actor = session.next_actor() + assert actor is not None + session.apply_action(actor, PlayerAction.fold()) + winnings = session.end_hand() + assert not winnings.is_empty() + assert len(winnings) >= 1 + + # ── 0.0.53 regression ports ────────────────────────────────────────── + # Direct translations of pkcore unit tests at casino/session.rs:970-1010. + + def test_set_blinds_between_hands_applies_immediately(self): + from pkpy import ForcedBets + session = self._heads_up() + session.set_blinds(ForcedBets(100, 200)) + # Before any hand starts, the snapshot reflects the *new* blinds + # because PokerSession::new captures the table's current forced + # bets, and set_blinds (with no hand in progress) overwrites them. + # We check the snapshot via forced_at_hand_start AFTER start_hand, + # which is the documented stable surface. + session.start_hand() + assert session.forced_at_hand_start().small_blind == 100 + assert session.forced_at_hand_start().big_blind == 200 + + def test_set_blinds_during_hand_defers_to_next_hand(self): + from pkpy import ForcedBets, PlayerAction + session = self._heads_up() + session.start_hand() + # Mid-hand: bump blinds. + session.set_blinds(ForcedBets(100, 200)) + # forced_at_hand_start still reflects what was posted this hand. + assert session.forced_at_hand_start().small_blind == 50 + assert session.forced_at_hand_start().big_blind == 100 + + def test_deferred_blinds_take_effect_on_next_start_hand(self): + from pkpy import ForcedBets, PlayerAction + session = self._heads_up() + session.start_hand() + session.set_blinds(ForcedBets(100, 200)) + # Finish the hand by folding the next actor. + actor = session.next_actor() + session.apply_action(actor, PlayerAction.fold()) + session.end_hand() + # Next hand picks up the deferred blinds. + session.start_hand() + assert session.forced_at_hand_start().small_blind == 100 + assert session.forced_at_hand_start().big_blind == 200 + + def test_forced_at_hand_start_stable_during_hand(self): + from pkpy import ForcedBets + session = self._heads_up() + session.start_hand() + snap1 = session.forced_at_hand_start() + session.set_blinds(ForcedBets(400, 800)) + snap2 = session.forced_at_hand_start() + assert snap1.small_blind == snap2.small_blind == 50 + assert snap1.big_blind == snap2.big_blind == 100 diff --git a/tests/test_table_no_cell.py b/tests/test_table_no_cell.py new file mode 100644 index 0000000..c5ddba7 --- /dev/null +++ b/tests/test_table_no_cell.py @@ -0,0 +1,140 @@ +"""Tests for pkpy no-cell table primitive bindings.""" + +import pytest + +from pkpy import PlayerNoCell + + +class TestPlayerNoCell: + def test_construct_default_chips(self): + p = PlayerNoCell("Alice") + assert p.total_chip_count() == 0 + assert p.is_clear() + + def test_construct_with_chips(self): + p = PlayerNoCell("Alice", chips=1000) + assert p.total_chip_count() == 1000 + + def test_construct_with_positional_chips(self): + p = PlayerNoCell("Alice", 1000) + assert p.total_chip_count() == 1000 + + def test_state_predicates_default(self): + p = PlayerNoCell("Alice", chips=1000) + assert not p.is_all_in() + assert not p.has_bet() + + def test_repr_contains_handle(self): + r = repr(PlayerNoCell("Alice", chips=1000)) + assert "Alice" in r + + +class TestSeatNoCell: + def test_construct_from_player(self): + from pkpy import SeatNoCell + seat = SeatNoCell(PlayerNoCell("Alice", chips=1000)) + assert not seat.is_empty() + + def test_default_state_predicates(self): + from pkpy import SeatNoCell + seat = SeatNoCell(PlayerNoCell("Alice", chips=1000)) + # A fresh, funded seat is "yet to act" and not all-in. + # Note: is_in_hand() is True for any funded, non-folded seat — it + # answers "is this seat eligible to play?" not "is a hand in progress?" + assert seat.is_yet_to_act() + assert not seat.is_all_in() + + def test_repr_contains_handle(self): + from pkpy import SeatNoCell + r = repr(SeatNoCell(PlayerNoCell("Alice", chips=1000))) + assert "Alice" in r + + +class TestSeatsNoCell: + def _two_seats(self): + from pkpy import SeatNoCell + return [ + SeatNoCell(PlayerNoCell("Alice", chips=1000)), + SeatNoCell(PlayerNoCell("Bob", chips=2000)), + ] + + def test_construct_from_list(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + assert seats.size() == 2 + + def test_total_chip_count(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + assert seats.total_chip_count() == 3000 + + def test_get_seat(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + seat = seats.get_seat(0) + assert seat is not None + assert not seat.is_empty() + + def test_get_seat_out_of_range(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + assert seats.get_seat(99) is None + + def test_default_betting_state(self): + from pkpy import SeatsNoCell + seats = SeatsNoCell(self._two_seats()) + # Before any hand starts: no bets posted, no cards dealt. + # count_active_in_hand reflects funded seats (= 2), not whether a + # hand is in progress — see TestSeatNoCell on the same distinction. + assert seats.current_bet() == 0 + assert not seats.are_dealt() + assert seats.count_active_in_hand() == 2 + + def test_repr_includes_size(self): + from pkpy import SeatsNoCell + r = repr(SeatsNoCell(self._two_seats())) + assert "size=2" in r + + +class TestTableNoCell: + def test_nlh_from_seats(self): + from pkpy import ForcedBets, SeatNoCell, SeatsNoCell, TableNoCell + seats = SeatsNoCell([ + SeatNoCell(PlayerNoCell("Alice", chips=1000)), + SeatNoCell(PlayerNoCell("Bob", chips=1000)), + ]) + forced = ForcedBets(50, 100) + table = TableNoCell.nlh_from_seats(seats, forced) + assert table.seat_count() == 2 + + def test_heads_up_defaults(self): + from pkpy import ForcedBets, TableNoCell + forced = ForcedBets(50, 100) + table = TableNoCell.heads_up(forced) + assert table.seat_count() == 2 + # Default stacks are (1000, 1000). + seats = table.seats() + assert seats.total_chip_count() == 2000 + + def test_heads_up_custom_stacks_and_names(self): + from pkpy import ForcedBets, TableNoCell + forced = ForcedBets(50, 100) + table = TableNoCell.heads_up(forced, stacks=(500, 1500), names=("X", "Y")) + seats = table.seats() + assert seats.total_chip_count() == 2000 + assert seats.size() == 2 + + def test_blind_position_lookups(self): + from pkpy import ForcedBets, TableNoCell + table = TableNoCell.heads_up(ForcedBets(50, 100)) + # In heads-up, button is small blind. We don't assert specific seat + # numbers here — just that the methods return seat indices in range. + sb = table.determine_small_blind() + bb = table.determine_big_blind() + assert sb < table.seat_count() + assert bb < table.seat_count() + + def test_repr_includes_seat_count(self): + from pkpy import ForcedBets, TableNoCell + r = repr(TableNoCell.heads_up(ForcedBets(50, 100))) + assert "seats=2" in r