diff --git a/.gitignore b/.gitignore index 5ceecdd..f97de50 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,9 @@ Thumbs.db # commands, hooks) but NOT per-user local overrides (machine-specific paths, # personal preferences). .claude/settings.local.json -CLAUDE.md \ No newline at end of file +CLAUDE.md + +# Superpowers planning documents (brainstorm specs, implementation plans) are +# session artifacts, not repo docs - keep them local. +docs/superpowers/ +docs/plans/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a17cb32..0a52848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,68 @@ All notable changes to the ContrAI workspace are documented here. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). All four workspace packages (`contrai-core`, `contrai-engine`, `contrai-analyzer`, `contrai-scraper`) are versioned in lockstep — a single version covers the whole workspace. ## [Unreleased] +## [0.2.0] - 2026-07-25 + +Hardening release: the play phase moves into the core as an immutable `PlayState` state machine with an imperfect-information `PlayObservation` for AI strategies, the expert AI bids and plays from sounder rules, and the whole TUI took a review pass. + +### Added + +- (core) `PlayState` play-phase state machine, the play-side sibling of `Auction`: an + immutable, trick-by-trick state that owns the follow/trump legality rules, enforces turn order and the new `OUT_OF_TURN`/`CARD_NOT_IN_HAND` violation reasons through `apply`, and can be forked onto replacement hands via `with_hands` for future search-based AIs. +- (core) `PlayObservation` imperfect-information view: the projection of a `PlayState` that a single player is allowed to see (own hand, public trick history, legal cards), the input surface for AI card-play strategies. +- (engine) The expert AI's card play now reasons from sound card tracking: fallen cards and + per-seat voids (trump and plain suits alike) are derived from the public trick history, with + careful inference — any failure to follow records a void, but a seat discarding behind its + master partner is not read as trump-void. On that foundation the AI stops pulling trumps once + both opponents are known void in trump (remaining unseen trumps can only sit in partner's + hand), preserves master cards behind a winning partner by giving the next-highest card instead + (partner's Ace promotes its Ten to suit master — the Ten is kept for a later trick, trump-led + tricks included), and anticipates opponent ruffs — when an opponent still to play is proven + void in the led suit and may still hold trump, it stops piling points behind a winning partner + and contests a losing trick with the smallest card that beats the current best rather than the + fattest. + +### Changed + +- (engine) Card-play strategies now receive a single frozen `PlayObservation` — the + seat's own hand, the public trick history, and its legal cards — and derive any card + tracking from it, so a strategy is sealed off from another seat's hand by construction through what it is handed; sealing the live player refs on `Play` is a noted follow-up. +- (engine) The trick loop is now driven by the core `PlayState`: `Round` seeds an + immutable play-phase state at the start of play, derives the legal cards from it, and + mirrors the players' hands and the current trick from it each play; card-play legality + moved to `contrai-core` wholesale. An absent or illegal card now raises + `IllegalPlayError` (`CARD_NOT_IN_HAND`) instead of being silently skipped. +- (engine) Unify the internal bid representation on core `Bid`/`Auction` objects and + remove the legacy wire-format bridge — the rule-based AI and the bidding view now operate directly on typed `Bid` objects. Behaviour is unchanged. +- (engine) The expert AI now resolves its best (contract, suit) pair once per bidding + turn — the duplicated best-contract scans in the overbid check and the initial-bid + builder are folded into a single helper that also owns the suit tie-break. + +### Removed + +- (engine) The `initialize_card_tracking` / `update_card_tracking` hooks on + `CardPlayStrategy` / `AiPlayer` (public since 0.1.0) — card tracking is now derived + from the observation rather than pushed to per-strategy state each play. + +### Fixed + +- (engine) Expert AI partner-support escalation: every supporting turn re-added the seat's full complement (+10 per external ace, +10 for the trump complement) on top of the *standing* contract, so partners alternately raised each other far past their combined strength (an 80 opening ratcheting to 160+, at which point the opponents' double heuristic armed itself on the inflated value), and a seat could even "support" its own bid after an opponent overbid. Support is now capped at a team ceiling — partner's opening bid plus the supporter's complement, announced once — and a seat never supports a suit it opened itself. +- (engine) Landing screen now labels the three AI seats `AI · expert` instead of `AI · medium` — the bots play the expert strategy, which is the only level wired today. +- (engine) Expert AI suit tie-break: with several suits tied for the best contract, the + AI always fell back to Spades — even when Spades never met the bidding table — and ignored the belote preference when more than one tied suit held a belote. It now picks among the tied suits only, preferring belote holders. +- (engine) The "Unrecognized bid" notice now suggests the cheapest raise the auction still allows (e.g. `'100 h'` once 90 stands) instead of a fixed `'80 h'`, dropping the numeric example entirely once only Slam-family raises remain. +- (engine) A game no longer ends without a winner when both teams finish a round level at or above the target score: the tie is sudden death — tiebreaker rounds are dealt until one team leads, the round recap announcing each one — so the game-over banner always names a winning team. +- (core) Illegal-play errors now name the acting seat: `PlayState.apply` attaches a ` card play` context to every `IllegalPlayError`, so rejection diagnostics say who misplayed as well as which card and why. + ## [0.1.0] - 2026-06-21 -First playable release: a complete CLI Contrée engine backed by a shared domain model, -plus the standalone hand analyzer and the spectator-mode scraper. +First playable release: a complete CLI Contrée engine backed by a shared domain model, plus the standalone hand analyzer and the spectator-mode scraper. ### Added @@ -32,5 +83,6 @@ plus the standalone hand analyzer and the spectator-mode scraper. Online → Spectator → Contree → Tournament navigation, seat identification, and `#tour` round polling. -[Unreleased]: https://github.com/valmathieu/ContrAI/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/valmathieu/ContrAI/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/valmathieu/ContrAI/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/valmathieu/ContrAI/releases/tag/v0.1.0 diff --git a/contree-domain.md b/contree-domain.md index fd2cdee..f5d0e1c 100644 --- a/contree-domain.md +++ b/contree-domain.md @@ -287,6 +287,28 @@ Slam grid below: always credited to the team **holding** K + Q of trump (not whoever captures those cards in a trick — see §6.5), on top of everything else, win or lose. +#### Unannounced capot + +If the declaring team wins **all 8 tricks** on a numeric contract *without +having bid a Slam*, the trick pile (152 cards + 10 *dix de der* = 162) is +replaced by a flat **250** substitute: the declarer scores `C + 250`, the +defense scores nothing, and the contract is necessarily **made** (sweeping +every trick cannot fail). This mirrors the announced-Slam shape but keeps the +numeric contract value `C` as the base. + +- Whether the contracting player took all 8 tricks **personally** (a *grand + slam*) or the team split them (a plain *slam*), the substitute is the same + 250 — the distinction is only descriptive. +- **Un-doubled only.** A doubled / redoubled sweep keeps the winner-takes-all + `160 + C × M` shape above; the 250 substitute does **not** apply. +- **Declaring team only.** If the *defense* takes all 8 tricks the declarer + has simply *chuté* — score it as an ordinary failed contract (`160 + C`), + not as a capot. +- **Belote (+20)** still layers on top for the holding team, as everywhere. + +> Worked example: contract `100 ♠`, declarer sweeps all 8 → declarer +> 350 (`100 + 250`), defense 0. + #### Slam and Solo Slam Slam-family contracts keep the same shape as numeric contracts — the at-risk @@ -342,6 +364,9 @@ defense. - A target score is agreed before the game (typical: **1500** or **2000**). - The first team to reach or exceed the target at the end of a round wins. - If both teams cross the target in the same round, the higher score wins. +- If both teams sit at the **same score** at or above the target, nobody has + won yet: play continues with additional rounds (sudden death) until one + team leads. --- @@ -487,7 +512,8 @@ threshold; details live alongside the AI implementation.* [Scoring] → sum cards + dix de der (+ belote if applicable) apply contract success/failure + multiplier ↓ -[Check] → if any team ≥ target (1500/2000): end game +[Check] → if one team strictly leads at ≥ target (1500/2000): end game + tie at ≥ target: sudden death, keep playing until one team leads else next round, dealer rotates right ``` diff --git a/docs/ai-ladder/rule_based.md b/docs/ai-ladder/rule_based.md index 93909bf..46469eb 100644 --- a/docs/ai-ladder/rule_based.md +++ b/docs/ai-ladder/rule_based.md @@ -1,7 +1,9 @@ # Rule-based AI -Hand-coded expert strategies. Currently implemented as `AiPlayer` in `contrai-engine` (expert bidding table + card-play heuristics; specs SF-09, SF-10). +Hand-coded expert strategies (expert bidding table + card-play heuristics; specs SF-09, SF-10). They are the **first concrete rung** of the AI ladder, implemented in `contrai-engine` as the `RuleBasedBiddingStrategy` / `RuleBasedCardPlayStrategy` pair — concrete implementations of the `BiddingStrategy` / `CardPlayStrategy` interfaces. They are injected into `AiPlayer` (the default strategies) and registered as `AI_LEVELS["expert"]`, so a future MCTS or learned level is a new strategy class rather than an edit to `AiPlayer`. See the [engine docs](../engine/index.md#ai-players) for the injection seam. -**Explainability:** the rule trace is the rationale — log which rule fired for each bid/play. +`RuleBasedCardPlayStrategy` is stateless between calls: every decision is a pure function of the single frozen `PlayObservation` it is handed for that turn. Whatever card tracking the heuristics need — which cards have fallen, which seats are known void in trump — is derived fresh each turn by replaying the observation's public trick history, never carried across calls or rounds. Because the observation is the only input a strategy ever receives, it is sealed off from another seat's hand by construction — through what it is handed; sealing the live player refs on `Play` is a noted follow-up. + +**Explainability:** the rule trace is the rationale — log which rule fired for each bid/play. The strategy object is the natural home for that trace (AI roadmap §6.1). > TODO: rule catalogue; planned extensions (deeper card counting, partner inference, signal-based bidding). diff --git a/docs/architecture.md b/docs/architecture.md index 3dbb50a..bfb7d2a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ Overview of how the four ContrAI packages fit together. The repository is a [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/) with four members under `packages/`: - **`contrai-core`** — shared domain model. Owns `Suit`/`Rank`/`CARD_SUITS`, `Card`, `Deck`, `Hand`, `Team`, `BasePlayer`, the frozen `Bid` sum type, the `Auction` state-and-rule oracle, `Contract`, `Trick`, and the model-level exceptions (a `ContraiError` base, plus `IllegalBidError` / `IllegalPlayError` and friends). Pure data and invariants, no orchestration. -- **`contrai-engine`** — game engine on top of `contrai-core`. Extends `BasePlayer` with `Player` / `HumanPlayer` / `AiPlayer`, owns `Game` and `Round` orchestration, and ships the Rich-based `contrai` terminal UI (`view/rich_view.py` + `cli.py`). See [Engine — CLI](engine/index.md#cli). +- **`contrai-engine`** — game engine on top of `contrai-core`. Extends `BasePlayer` with `Player` / `HumanPlayer` / `AiPlayer`, owns `Game` and `Round` orchestration, and ships the Rich-based `contrai` terminal UI (the `view/` package — `RichView` orchestrator plus per-screen builders — wired in `cli.py`). See [Engine — CLI](engine/index.md#cli). - **`contrai-analyzer`** — Streamlit dashboard for opening-hand strength (hypergeometric distribution + bidding truth-table). Deliberately independent of `contrai-core`; see [`analyzer/index.md`](analyzer/index.md) for the rationale behind the `SuitSlot` abstraction. - **`contrai-scraper`** — Playwright spectator-mode scraper for online Coinche games. v1 ships login + table navigation + per-round polling; bidding/play observation and persistence are still to be wired up. diff --git a/docs/core/index.md b/docs/core/index.md index 09c2b20..34cafe0 100644 --- a/docs/core/index.md +++ b/docs/core/index.md @@ -17,10 +17,11 @@ Source lives at `packages/contrai-core/src/contrai_core/`: | `bid.py` | `Bid`, `PassBid`, `ContractBid`, `DoubleBid`, `RedoubleBid` (frozen-dataclass sum type) | | `auction.py` | `Auction` (bidding-state rule oracle — see §below) | | `contract.py` | `Contract` | -| `trick.py` | `Trick` | +| `trick.py` | `Trick`, `current_winner` (module-level trick-winner rule, shared with `PlayState`) | +| `play.py` | `Play`, `PlayState` (play-phase rule oracle — see §below), `PlayObservation` | | `exceptions.py` | `ContraiError` (base), `InvalidPlayerCountError`, `InvalidCardCountError`, `IllegalBidError`, `IllegalPlayError` + `PlayRuleViolation`, `TrickStateError`, `InvalidContractError` | -Everything above is re-exported from `contrai_core/__init__.py` and is part of the public API. +Everything above is re-exported from `contrai_core/__init__.py` and is part of the public API — except `current_winner`, which stays a module-level import from `contrai_core.trick`. ## Class structure @@ -29,7 +30,11 @@ Everything above is re-exported from `contrai_core/__init__.py` and is part of t The full domain model in one view. `Trick` is a dumb container of plays that does **not** store trump — `Trick.get_current_winner(trump_suit)` takes trump as a *required* argument (mirroring `Card.get_order`/`get_points`) and works on partial tricks. The engine builds `Trick()` bare and passes the authoritative trump suit from the contract, consuming `get_current_winner` for trick-winner determination, the partner-master legality check, and the view's live winner highlight. `Bid` and its four variants are now frozen `@dataclass(frozen=True, slots=True)` value carriers — player is `field(compare=False)` so equality is *what was announced, not who announced it*, and the auction-state rules ("is this bid legal now?") live entirely on `Auction`. `Auction.is_legal` / `legal_actions` / `apply` replace what used to be `Bid.is_valid_after` and the `BidValidator` utility — including the *auction-freezes-after-a-Double* rule from `contree-domain.md §5.3`. `Auction.apply` raises `IllegalBidError` rather than silently downgrading an illegal bid to a Pass. The defending team is computed at the game level, where both teams are in scope — `Contract` only knows its own attacking side. See [Diagrams](../diagrams/) for the colour convention. -**Exception hierarchy.** Every domain error now subclasses a single `ContraiError` base, so one `except ContraiError` catches the whole family. Each concrete error *also* subclasses `ValueError` (dual inheritance, `ValueError` kept in the MRO) so legacy `except ValueError` call sites keep working unchanged. `IllegalPlayError` is the card-play counterpart to `IllegalBidError`: it carries the offending `Card`, a machine-readable `PlayRuleViolation` reason (`MUST_FOLLOW_SUIT` / `MUST_TRUMP` / `MUST_OVERTRUMP`, a `StrEnum` for clean logging/JSON), and the set of legal alternatives — serving the §6.1 explainability goal and future RL/scraper/server consumers. `TrickStateError` (adding to a complete trick) and `InvalidContractError` (bad contract value/suit, or a redouble without an underlying double) replace the last bare `ValueError`s raised by `Trick`, `ContractBid`, and `Contract`. +`play.py` adds the play phase's sibling to `Auction`: `PlayState` is an immutable frozen dataclass (`slots=True`) that owns the flat chronological tuple of `Play` records — `Play` is a `NamedTuple` of `(player, card)`, unpacking exactly like the tuples `Trick` and the winner rule already consume. Like `Auction`, the bare constructor performs no validation so tests and search forks can inject arbitrary mid-round states directly; `PlayState.start(contract, players, hands)` is the validated entry point for a fresh deal (exactly 4 players, 4 eight-card hands, 32 distinct cards). Every other view — `trick_number`, `current_trick`, `completed_tricks`, `trick_winners`, `to_act`, `is_terminal()` — is recomputed from `plays` on each access rather than cached; `slots=True` forbids stashing lazy state, keeping the play history the single source of truth. `legal_actions(player)` is the player-parametric legality oracle: it enforces the follow/trump obligations (follow suit; over-trump when trump is led; trump if void and the partner is not currently master, over-trumping an opponent's ruff if able; free discard once the partner is master, or once no obligation applies at all — no trump suit, or none held) but deliberately **no turn check** — the same idiom `Auction.legal_actions` uses for bids, so a caller can ask "what would be legal for this player" independent of whose turn it is. `apply(play)` is the single legality-enforcing transition: it checks turn order, then hand membership, then the obligations, raising `IllegalPlayError` with the matching `PlayRuleViolation` — `OUT_OF_TURN` (wrong player, or the phase is already over), `CARD_NOT_IN_HAND`, or one of `MUST_FOLLOW_SUIT` / `MUST_TRUMP` / `MUST_OVERTRUMP` — plus the legal alternatives for diagnostics. `with_hands(hands)` forks the same public history (contract, players, plays) onto replacement per-seat hands: the determinization primitive a future search-based AI would sample worlds from — no search runs on it yet, this is only the fork primitive. + +`observe(player, bids=...)` is `PlayState`'s sanctioned trust boundary: it projects the omniscient state — which holds every seat's hand — down to a `PlayObservation` carrying only `player`'s own hand, the public trick history, the established contract, the auction history the caller passes in, and `player`'s legal cards right now. This is the input surface AI card-play strategies are meant to be handed, never the raw `PlayState` — though the boundary is not fully sealed: the `Play` records in `completed_tricks` / `current_trick` still carry live `BasePlayer` references, so code reaching through `play.player.hand` could technically still see another seat's cards. That gap is a documented follow-up, not something this projection solves. Trick-winner determination for both `Trick` and `PlayState` shares one implementation, the module-level `current_winner(plays, trump_suit)` function in `trick.py`: `Trick.get_current_winner` delegates to it, and `PlayState.trick_winners` / `PlayObservation.current_winner` call it directly — `to_act` reaches the same rule indirectly, via `trick_winners` — one winner rule, not two that could drift apart. + +**Exception hierarchy.** Every domain error now subclasses a single `ContraiError` base, so one `except ContraiError` catches the whole family. Each concrete error *also* subclasses `ValueError` (dual inheritance, `ValueError` kept in the MRO) so legacy `except ValueError` call sites keep working unchanged. `IllegalPlayError` is the card-play counterpart to `IllegalBidError`: it carries the offending `Card`, a machine-readable `PlayRuleViolation` reason (`MUST_FOLLOW_SUIT` / `MUST_TRUMP` / `MUST_OVERTRUMP` / `OUT_OF_TURN` / `CARD_NOT_IN_HAND`, a `StrEnum` for clean logging/JSON), and the set of legal alternatives — serving the §6.1 explainability goal and future RL/scraper/server consumers. `TrickStateError` (adding to a complete trick) and `InvalidContractError` (bad contract value/suit, or a redouble without an underlying double) replace the last bare `ValueError`s raised by `Trick`, `ContractBid`, and `Contract`. ## Consumers @@ -47,6 +52,6 @@ The full domain model in one view. `Trick` is a dumb container of plays that doe ## Tests Coverage is now complete across every module: -`test_types.py`, `test_card.py`, `test_deck.py`, `test_hand.py`, `test_team.py`, `test_base_player.py`, `test_bid.py`, `test_auction.py`, `test_contract.py`, `test_trick.py`, `test_exceptions.py`. +`test_types.py`, `test_card.py`, `test_deck.py`, `test_hand.py`, `test_team.py`, `test_base_player.py`, `test_bid.py`, `test_auction.py`, `test_contract.py`, `test_trick.py`, `test_play_state.py`, `test_play_legality.py`, `test_play_observation.py`, `test_exceptions.py`. -`test_bid.py` covers the data contract of the frozen variants (construction validation, equality, ordering, `__str__`, immutability). The auction-state rules that used to be tested against `Bid.is_valid_after` and `BidValidator` now live in `test_auction.py` against `Auction.is_legal`, `legal_actions`, and `apply`. `test_exceptions.py` covers the dual-inheritance invariant (every domain error is a subclass of both `ContraiError` and `ValueError`), the `PlayRuleViolation` `StrEnum`, and the message/attribute contract of each error; the construction-validation tests in `test_bid.py` / `test_contract.py` / `test_trick.py` assert the specific new types. The remaining engine-side gap is `Round` — see [`engine/index.md`](../engine/index.md#open-work). +`test_bid.py` covers the data contract of the frozen variants (construction validation, equality, ordering, `__str__`, immutability). The auction-state rules that used to be tested against `Bid.is_valid_after` and `BidValidator` now live in `test_auction.py` against `Auction.is_legal`, `legal_actions`, and `apply`. `test_play_state.py` covers `PlayState.start`'s validation, `apply`'s turn-order/hand-membership/immutability guarantees, `with_hands`'s determinization-fork validation, and the derived views' boundaries (including the `NO_TRUMP` degrade). `test_play_legality.py` exercises `legal_actions` against the follow/over-trump/trump obligation matrix and `apply`'s resulting `PlayRuleViolation` classification. `test_play_observation.py` covers `observe`'s own-hand-only projection, its legal-cards parity with `PlayState.legal_actions`, bids pass-through, the derived properties, and immutability. `test_exceptions.py` covers the dual-inheritance invariant (every domain error is a subclass of both `ContraiError` and `ValueError`), the `PlayRuleViolation` `StrEnum`, and the message/attribute contract of each error; the construction-validation tests in `test_bid.py` / `test_contract.py` / `test_trick.py` assert the specific new types. The remaining engine-side gap is `Round` — see [`engine/index.md`](../engine/index.md#open-work). diff --git a/docs/diagrams/class_core.png b/docs/diagrams/class_core.png index 2d7aa6c..810c871 100644 Binary files a/docs/diagrams/class_core.png and b/docs/diagrams/class_core.png differ diff --git a/docs/diagrams/class_core.puml b/docs/diagrams/class_core.puml index 863198b..1c7dbeb 100644 --- a/docs/diagrams/class_core.puml +++ b/docs/diagrams/class_core.puml @@ -280,6 +280,92 @@ package "contract.py / trick.py" as p_contract { contract's suit for trick-winner determination, the partner-master legality check, and the view's live winner highlight. + The winner rule itself is the module-level + `current_winner(plays, trump_suit)` function + below — `get_current_winner` delegates to it, + and `PlayState` (play.py) calls it directly — + one implementation, not two that could drift. + end note +} + +' ========================================================= +' Play phase (play.py) — the play-phase rule oracle, +' Auction's sibling for card play +' ========================================================= +package "play.py" as p_play { + class Play <> { + + player : BasePlayer + + card : Card + } + + class PlayState <> { + + contract : Contract + + players : tuple + + hands : tuple> + + plays : tuple = () + -- + + {static} start(contract, players, hands) : PlayState + + hand_of(player: BasePlayer) : tuple + + legal_actions(player: BasePlayer) : tuple + + apply(play: Play) : PlayState ' raises IllegalPlayError + + with_hands(hands: tuple>) : PlayState + + observe(player, bids=()) : PlayObservation + + is_terminal() : bool + -- + + trick_number : int <> + + current_trick : tuple <> + + completed_tricks : tuple> <> + + trick_winners : tuple <> + + to_act : BasePlayer? <> + } + + class PlayObservation <> { + + player : BasePlayer + + hand : tuple + + contract : Contract + + bids : tuple + + completed_tricks : tuple> + + current_trick : tuple + + legal_cards : tuple + -- + + trick_number : int <> + + trump_suit : Suit? <> + + led_suit : Suit? <> + + played_cards : tuple <> + + current_winner : BasePlayer? <> + } + + note bottom of PlayState + Auction's sibling for the play phase. Bare + constructor performs no validation (tests / + search forks inject arbitrary mid-round + states directly); start() validates a fresh + deal (4 players, 4×8 cards, 32 distinct). + Derived views (trick_number, current_trick, + completed_tricks, trick_winners, to_act, + is_terminal()) recompute from plays on every + access — slots=True forbids caching them. + legal_actions(player) enforces only the + follow/trump obligations, deliberately no + turn check — mirroring Auction.legal_actions. + apply(play) is the single legality-enforcing + transition; turn order is checked there. + with_hands() forks the public history onto + replacement hands — the determinization + primitive a future search-based AI would + sample from; no search runs on it yet. + end note + + note bottom of PlayObservation + The imperfect-information projection + PlayState.observe(player, bids) builds: own + hand + public trick history + own legal + cards, never another seat's hand. Caveat: + Play.player is a live BasePlayer reference, + so a strategy reaching through + play.player.hand could still see another + seat's cards — sealing that off (e.g. an + opaque seat id) is a noted follow-up. end note } @@ -296,6 +382,8 @@ package "exceptions.py" as p_exc { MUST_FOLLOW_SUIT MUST_TRUMP MUST_OVERTRUMP + OUT_OF_TURN + CARD_NOT_IN_HAND } class InvalidPlayerCountError { @@ -397,6 +485,18 @@ Trick "1" *-- "0..4" Card : (via plays) Trick "1" o-- "0..4" BasePlayer : (via plays) Trick ..> Suit : get_current_winner(trump_suit) +' Play is a (player, card) pair — same ownership split as Trick's plays +Play "1" o-- "1" BasePlayer : player +Play "1" *-- "1" Card : card + +' PlayState references the established contract + seated players, +' composes the flat play history, and holds per-seat remaining hands +PlayState "1" o-- "1" Contract : contract +PlayState "1" o-- "4" BasePlayer : players +PlayState "1" *-- "0..32" Card : hands +PlayState "1" *-- "0..32" Play : plays +PlayState ..> PlayObservation : <> + ' Operations raising exceptions Team ..> InvalidPlayerCountError : <> Deck ..> InvalidPlayerCountError : <> @@ -404,5 +504,6 @@ Deck ..> InvalidCardCountError : <> ContractBid ..> InvalidContractError : <> Contract ..> InvalidContractError : <> Trick ..> TrickStateError : <> +PlayState ..> IllegalPlayError : <> @enduml diff --git a/docs/diagrams/class_engine.png b/docs/diagrams/class_engine.png index 594b154..81ea64a 100644 Binary files a/docs/diagrams/class_engine.png and b/docs/diagrams/class_engine.png differ diff --git a/docs/diagrams/class_engine.puml b/docs/diagrams/class_engine.puml index d7a3743..5c47c61 100644 --- a/docs/diagrams/class_engine.puml +++ b/docs/diagrams/class_engine.puml @@ -1,5 +1,5 @@ @startuml class_engine -title contrai-engine — model + MVC (current state, honest portrayal) +title contrai-engine — model + view + cli (current state, honest portrayal) ' --- Palette: engine orange (model), grey (stubs), core blue (boundary) --- skinparam shadowing false @@ -29,69 +29,105 @@ class BasePlayer <> #E1F0FF { } ' ========================================================= -' model/player.py — Player hierarchy +' model/player/ — Player hierarchy + pluggable AI strategies ' ========================================================= -package "model/player.py" as p_player { +package "model/player/ (subpackage)" as p_player { + + ' --- base.py: Player hierarchy --- abstract class Player <> { + is_human : bool <> -- + {abstract} choose_bid(auction: Auction) : Bid? - + {abstract} choose_card(trick, contract, playable_cards) : Card + + {abstract} choose_card(observation: PlayObservation) : Card? } class HumanPlayer { + choose_bid(auction) : None <> - + choose_card(trick, contract, playable_cards) : None <> + + choose_card(observation) : None <> } + ' --- ai.py: holds + delegates to strategies --- class AiPlayer { + + bidding : BiddingStrategy + + cardplay : CardPlayStrategy + -- + + __init__(name, position, bidding=…, cardplay=…) + + choose_bid(auction) : Bid <> + + choose_card(observation) : Card <> + } + + ' --- strategy.py: abstract interfaces --- + abstract class BiddingStrategy <> { + + {abstract} choose_bid(auction: Auction) : Bid + } + abstract class CardPlayStrategy <> { + + {abstract} choose_card(observation: PlayObservation) : Card + } + + ' --- rule_based.py: the first concrete level (expert) --- + class RuleBasedBiddingStrategy { + {static} BIDDING_TABLE : list + {static} SUIT_PREFERENCE : tuple -- - + choose_bid(auction: Auction) : Bid - + choose_card(trick, contract, playable_cards) : Card - + initialize_card_tracking() : None - + update_card_tracking(card, player, led_suit, trump_suit) : None - -- - - _choose_wire(current_bids) : str | tuple - - _fallen_cards : dict> - - _players_without_trump : set + + choose_bid(auction) : Bid + - _choose_open_bid(auction) : Bid + } + class RuleBasedCardPlayStrategy { + + choose_card(observation) : Card + - _derive_tracking(observation) : (fallen, voids) } - class "wire_to_bid / bid_to_wire" as p_bridge <> #FFEFD9 { - wire_to_bid(player, wire) : Bid - bid_to_wire(bid : Bid) : str | tuple + ' --- levels.py: registry + factory --- + class "levels.py" as p_levels <> #FFEFD9 { + AI_LEVELS : dict + make_ai_player(name, position, level="expert") : AiPlayer } note right of AiPlayer - <> private helpers (collapsed): - _evaluate_suits / _evaluate_suit_as_trump, - _estimate_tricks / _evaluate_trump_tricks, - _make_initial_bid / _support_partner_bid, - _check_double_redouble / _should_double / _should_redouble, - _play_opening_card / _play_leading_card / _play_following_card, - _play_when_team_winning / _play_when_team_losing, - _is_master_card / _is_stronger_card / _can_trump_win, - _opponents_might_have_trump / _get_higher_ranks, … - (~25 helpers — see player.py) + ``AiPlayer`` owns no strategy logic. It + holds an injected ``bidding`` and + ``cardplay`` strategy and delegates to + them. Strategies are passed as factories + (``player -> strategy``) so each can take + a back-reference to the player while the + player is still being built. Defaults + reproduce today's expert bot. end note - note bottom of AiPlayer - ``choose_bid(auction)`` is a thin - adapter: it projects ``auction.bids`` - to the legacy ``(player, wire_bid)`` - tuples the expert table still uses, - delegates to ``_choose_wire``, and - lifts the wire choice back to a - :class:`Bid` via ``wire_to_bid``. - Future AI families should read - ``auction.legal_actions(self)`` - directly and let the wire format go. - - **BIDDING_TABLE** rows (11 levels: - 80–160 + Slam + Solo Slam): - (contract, trump_req, trump_min, aces, - tricks_min, belote_required). + note bottom of RuleBasedBiddingStrategy + First concrete level (``AI_LEVELS["expert"]``). + ``choose_bid`` resolves the Double/Redouble freeze, + then ``_choose_open_bid`` walks ``auction.bids`` + directly, resolves the hand once to a single + (contract, suit) pair via ``_find_best_contract`` + (belote first, then preference-order tie-break) + and returns a typed Bid (PassBid / ContractBid / + DoubleBid) — no wire format. + ~13 collapsed strategy helpers (_evaluate_suits, + _find_best_contract, _make_initial_bid, + _check_double, …). + **BIDDING_TABLE** rows (11 levels: 80–160 + Slam + + Solo Slam): (contract, trump_req, trump_min, + aces, tricks_min, belote_required). + end note + + note bottom of RuleBasedCardPlayStrategy + First concrete card-play level. Stateless + between calls — every decision is a pure + function of the frozen PlayObservation handed + in. ``_derive_tracking`` rebuilds the fallen- + card map and the trump-void set by replaying + the observation's public trick history each + turn (compelled-only void inference: a discard + behind a master partner proves nothing; a + trump lead's proof is unconditional). ~15 + collapsed helpers (_play_opening/leading/ + following_card, _play_when_team_winning/losing, + _is_master_card, _is_stronger_card, + _can_trump_win, _opponents_might_have_trump, + _get_higher_ranks, …). + A future MCTS / learned level is a new + strategy class, never an edit to AiPlayer. end note note bottom of HumanPlayer @@ -102,23 +138,10 @@ package "model/player.py" as p_player { calls when ``player.is_human``. end note - note right of p_bridge - Module-level helpers in - ``contrai_engine.model.player``. - Bridge between the - :class:`contrai_core.Bid` boundary - (used at the engine perimeter) and - the legacy ``'Pass'`` / ``'Double'`` / - ``'Redouble'`` / ``(value, suit)`` - wire format the AI expert table - and the Rich view's renderer still - consume. Shared by AiPlayer, Round - (test scaffolding), and RichView. - end note } ' ========================================================= -' model/game.py & model/round.py — orchestration +' model/game.py & model/round/ package — orchestration ' ========================================================= package "model/game.py" as p_game { class Game { @@ -134,46 +157,82 @@ package "model/game.py" as p_game { -- + __init__(players: list) + start_new_round() : None - + manage_round(view=None) : dict - + check_game_over(target_score=1500) : dict + + manage_round(view=None) : None + + check_game_over(target_score=1500) : GameOverStatus + next_dealer() : None + set_players_order() : None } + + class GameOverStatus <> { + + game_over : bool + + winner : str? + + tied_teams : list? + + final_scores : dict + } + + Game ..> GameOverStatus : check_game_over()\nreturns + + note bottom of GameOverStatus + Frozen verdict returned by + ``Game.check_game_over(target)``. + Over only when one team strictly + leads at/above the target — + ``winner`` is then always set. + A tie at/above the target keeps + ``game_over`` False and fills + ``tied_teams``: sudden death, + tiebreaker rounds until one team + leads. ``cli.py`` loops on + ``.game_over`` and the end-game + banner reads off the rest. + end note } -package "model/round.py" as p_round { +package "model/round/ package" as p_round { + ' --- round.py: the lifecycle orchestrator --- class Round { + players_order : list + dealer : Player + deck : Deck + round_number : int + contract : Contract? + + auction : Auction? + + play_state : PlayState? + tricks : list + current_trick : Trick? + last_trick_winner : Player? + team_tricks : dict> + round_scores : dict + + contract_made : bool? + + unannounced_slam : UnannouncedSlam? + belote_holder : Player? + belote_state : dict -- + __init__(players_order, dealer, deck, round_number) + deal_cards() : None + manage_bidding(view=None) : Contract? - + play_trick(view=None) : Player? + + play_trick(view=None) : None + play_all_tricks(view=None) : dict> + calculate_round_scores() : dict + handle_failed_contract() : dict -- - _gather_bid(player, auction, view) : Bid - - _get_playable_cards(player) : list - - _classify_play_violation(player, card) : PlayRuleViolation - - _higher_trumps_than_played(trumps, plays, trump) : list - - _highest_opponent_trump(plays, team, trump) : Card? - _detect_belote_holder() : None - _is_belote_event(player, card) : bool - _transition_belote_state(player) : str? + - _sync_hands() : None + } + + ' --- scoring.py: the pure scoring transformation --- + class "scoring.py" as r_scoring <> #FFEFD9 { + score_round(round) : RoundScore + count_player_tricks(tricks, trump, player) : int + UnannouncedSlam <> : {SLAM, GRAND_SLAM} + RoundScore <> } + Round ..> r_scoring : calculate_round_scores()\ndelegates to score_round + note right of Round ``manage_bidding`` drives an :class:`contrai_core.Auction` through @@ -191,22 +250,30 @@ package "model/round.py" as p_round { ``Trick.get_current_winner(contract.suit)`` in contrai-core — no engine-side duplicate. ---- - ``_get_playable_cards`` enforces: - • follow suit if able, - • over-trump when trump is led, - • partner-master exemption (current - winner, not whoever led), - • over-trump opponents otherwise. - The partner-master check delegates to - ``Trick.get_current_winner(trump_suit)``. - ``play_trick`` plays only a card in - that legal set; a truthy-but-illegal - card raises :class:`IllegalPlayError` - (no silent fallback). The - ``PlayRuleViolation`` reason comes from - ``_classify_play_violation``, which - mirrors ``_get_playable_cards`` and - must stay in sync with it. + The trick loop is driven by the immutable + core ``contrai_core.play.PlayState``: + ``play_all_tricks`` seeds it via + ``PlayState.start`` (validated — 4 seats, 8 + distinct cards each); ``play_trick`` reads + ``play_state.to_act`` / + ``legal_actions(player)`` each turn and + advances via + ``play_state.apply(Play(player, card))`` — + the sole legality-enforcing transition, + raising :class:`IllegalPlayError` on an + out-of-turn, not-held, or obligation- + breaking play (no silent fallback). + ``_sync_hands`` then re-mirrors every seat's + ``Hand`` from the new ``play_state`` so the + view keeps reading ``player.hand``, + while AI seats read the frozen + ``PlayObservation`` projected by + ``play_state.observe(player, bids=...)``. + ``calculate_round_scores`` is + a thin wrapper that publishes + ``score_round``'s ``RoundScore`` onto + ``round_scores`` / ``contract_made`` / + ``unannounced_slam``. ---- During play Round fires (each ``hasattr``-guarded): • ``view.on_bid_made(player, bid, history)`` @@ -218,37 +285,26 @@ package "model/round.py" as p_round { • ``view.on_trick_complete(trick, winner, self)`` ``Game.manage_round`` additionally fires ``view.on_round_dealt(round_)`` after the deal - and ``view.on_all_pass_redeal(round_)`` when + and ``view.on_all_pass_redeal()`` when everyone passes. end note -} - -' ========================================================= -' controller/ — still a stub today -' ========================================================= -package "controller/" as p_ctrl { - class GameController <> #EEEEEE { - + running : bool = True - -- - + handle_events() : None - + update() : None - + render() : None - + run() : None - } - note right of GameController - **Stub.** References undefined `pygame`; - no integration with Game / Round. - Open question: keep for a future GUI - path, or delete now that the Rich CLI - handles input/output? + note bottom of r_scoring + Pure ``round -> RoundScore`` transform. + Four scoring shapes (numeric share-pile, + unannounced-Slam 250 substitute, doubled + winner-takes-all, Slam / Solo Slam grid), + belote +20 to the K+Q holder layered on + every shape. ``UnannouncedSlam`` tags an + undeclared all-trick sweep on a numeric + contract. end note } ' ========================================================= -' view/ — live Rich terminal UI +' view/ — live Rich terminal UI (split package) ' ========================================================= -package "view/rich_view.py" as p_view { +package "view/ package" as p_view { class RoundSummary <> { + round_number : int + contract : Contract? @@ -276,7 +332,7 @@ package "view/rich_view.py" as p_view { + request_bid_action(player, auction: Auction) : Bid + request_card_action(player, trick, contract, playable) : Card + on_round_dealt(round_) : None - + on_all_pass_redeal(round_) : None + + on_all_pass_redeal() : None + on_bid_made(player, bid, history) : None + on_contract_established(round_) : None + on_card_played(player, card, trick) : None @@ -288,49 +344,96 @@ package "view/rich_view.py" as p_view { + show_landing(selected_target=1500) : int + show_round_recap(round_, running_scores, *, is_final=False) : None + show_end_game(status) : str ('n' | 'r' | 'q') + -- + ' Orchestration seam (private) + - _render_in_game(*, phase, …) : None + } + + class "view helpers\n(pure functions)" as p_view_helpers <> #FFEFD9 { + theme.py + formatting.py + parsing.py + bidding_rules.py + state_helpers.py + layout.py + } + + class "view screens\n(pure (data)->Panel/Text)" as p_view_screens <> #FFEFD9 { + screens/landing.py + screens/bidding.py + screens/trick.py + screens/recap.py + screens/endgame.py } note right of RichView - Six-screen UI: landing, bidding, - mid-trick, trick-won, round-recap, - end-game. Per-round summaries tracked - here (`history`) so Game stays UI-free. - A rolling 5-line `event_log` is shown - below the hand in every in-game state. - Round / trick / recap panel titles use - the `#N` format (e.g. `Round #2`, + Stateful orchestrator (in `rich_view.py`). + Six-screen UI: landing, bidding, mid-trick, + trick-won, round-recap, end-game. Per-round + summaries tracked here (`history`) so Game + stays UI-free. A rolling 5-line `event_log` + is shown below the hand in every in-game + state. Round / trick / recap panel titles + use the `#N` format (e.g. `Round #2`, `Last trick (#7)`, `Current trick (#8)`). ---- - The round-recap panel breaks the round - score down per team: - contract bonus (attacker base on - normal made, 160+base×mult on doubled - made, (160+base)×mult on failed - defender) + card points + dix-de-der - + belote → `round_scores[team]` → - running total. The card/dix/belote - rows em-dash when the engine ignores - them (doubled-made attacker, failed - defender) so the addition matches - the engine's round_score. - Helpers: `_recap_breakdown`, - `_format_recap_table`, - `_belote_team_in_round`. + Rendering is the pure-function modules' job. + `_render_in_game` is the single seam that + pulls state off `self` and feeds the screen + builders; the hooks and input loops own all + console I/O. Re-exported from + `view/__init__.py` (so both + `view.RichView` and + `view.rich_view.RichView` resolve). ---- - AI pacing is tunable per-hook via the - env vars `CONTRAI_AI_BID_DELAY` (1.4s) - and `CONTRAI_AI_CARD_DELAY` (0.9s). - The belote hook reuses the card delay. + AI pacing is tunable per-hook via the env + vars `CONTRAI_AI_BID_DELAY` (1.4s) and + `CONTRAI_AI_CARD_DELAY` (0.9s). The belote + hook reuses the card delay. + end note + + note bottom of p_view_helpers + Stateless, `self`-free helpers, each in + its own module: + • theme — colour tokens + lookup tables + • formatting — seat / suit / contract / + trump labels + the compact `_bid_label` + • parsing — `_parse_bid_input`, + `_parse_card_input` + • bidding_rules — `_illegal_bid_reason` + (the prompt hint reads + `Auction.legal_actions` directly) + • state_helpers — `_current_winner`, + `_explain_constraint`, + `_sort_hand_for_display`, + `_belote_by_position`, `_resolve_delay` + • layout — `_two_column`, `_panel_prompt`, + `_panel_event_log`, `_panel_game_score` + (the top-left panel of every in-game + frame) + end note + + note bottom of p_view_screens + Pure `(data) -> Panel/Text` builders — no + state, no I/O. RichView composes + prints + them; screens reuse the helpers above. + The round-recap breakdown credits each + team: contract bonus (attacker base on + normal made, 160+base×mult on doubled + made, (160+base)×mult on failed defender) + + card points + last-trick bonus + belote + → `round_scores[team]`. The card / + last-trick / belote rows + em-dash when the engine ignores them so + the addition matches `round_score`. + Helpers `_recap_breakdown` / + `_format_recap_table` / + `_belote_team_in_round` live in + `screens/recap.py`. ---- - Pure helpers (parsers, sorter, - current-winner wrapper, hint, - `_redouble_available_to`, - `_resolve_delay`, `_bid_to_legacy`, - `_belote_by_position`) live at module - scope. Rendering panels are validated - by `uv run contrai` smoke testing plus - title/text smoke assertions in - `tests/test_view/test_rich_view.py`. + Tested per-module in + `tests/test_view/test_*.py`; the deepest + layouts are validated by `uv run contrai`. end note } @@ -350,9 +453,9 @@ package "cli.py" as p_cli { note right of Cli Hardcoded seating: South = `HumanPlayer`, N/E/W = `AiPlayer` - (medium). TODO marker for a - future seat picker on the landing - screen. + (expert — the AiPlayer default). + TODO marker for a future seat + picker on the landing screen. end note } @@ -365,6 +468,13 @@ BasePlayer <|-- Player Player <|-- HumanPlayer Player <|-- AiPlayer +' Pluggable AI strategies (injected into AiPlayer) +BiddingStrategy <|-- RuleBasedBiddingStrategy +CardPlayStrategy <|-- RuleBasedCardPlayStrategy +AiPlayer o-- "1" BiddingStrategy : bidding +AiPlayer o-- "1" CardPlayStrategy : cardplay +p_levels ..> AiPlayer : <> + ' Game composes / aggregates engine + core types Game "1" *-- "2" "Team" : teams Game "1" *-- "4" Player : players @@ -373,17 +483,21 @@ Game "1" o-- "0..1" Player : dealer Game "1" *-- "0..1" Round : current_round Game "1" o-- "0..1" "Contract" : current_contract -' Round composes Tricks, references Contract +' Round composes Tricks, references Contract / Auction / PlayState Round "1" *-- "0..8" "Trick" : tricks Round "1" o-- "0..1" "Contract" : contract +Round "1" o-- "0..1" "Auction" : auction +Round "1" o-- "0..1" "PlayState" : play_state Round "1" o-- "1" "Deck" : deck Round "1" o-- "4" Player : players_order -' MVC wiring -GameController ..> Game : <> +' View + CLI wiring (the controller role today = cli.py loop + view hooks) Round ..> RichView : <> Cli ..> Game : <> Cli ..> RichView : <> RichView "1" *-- "0..N" RoundSummary : history +RichView ..> p_view_screens : <> +RichView ..> p_view_helpers : <> +p_view_screens ..> p_view_helpers : <> @enduml diff --git a/docs/diagrams/class_workspace.png b/docs/diagrams/class_workspace.png index 17c77eb..5dca9b3 100644 Binary files a/docs/diagrams/class_workspace.png and b/docs/diagrams/class_workspace.png differ diff --git a/docs/diagrams/class_workspace.puml b/docs/diagrams/class_workspace.puml index 07e010e..07bfb22 100644 --- a/docs/diagrams/class_workspace.puml +++ b/docs/diagrams/class_workspace.puml @@ -51,7 +51,6 @@ package "contrai-engine\n(MVC + game loop)" as P_engine { class "Game" as E_game #FFEFD9 class "Round" as E_round #FFEFD9 - class "GameController" as E_ctrl #EEEEEE class "RichView" as E_view #FFEFD9 class "cli.py" as E_cli #FFEFD9 @@ -59,7 +58,6 @@ package "contrai-engine\n(MVC + game loop)" as P_engine { E_player <|-- E_ai E_game *-- E_round E_game *-- E_player - E_ctrl ..> E_game : <> E_round ..> E_view : <> E_cli ..> E_game : <> E_cli ..> E_view : <> diff --git a/docs/diagrams/flow_scoring.mmd b/docs/diagrams/flow_scoring.mmd new file mode 100644 index 0000000..bf908ab --- /dev/null +++ b/docs/diagrams/flow_scoring.mmd @@ -0,0 +1,55 @@ +--- +title: contrai-engine — round scoring decision tree (scoring.score_round) +--- +flowchart TD + Start(["score_round(round)"]) --> HasContract{"contract
established?"} + + HasContract -- "no (all passed)" --> Zero["every team → 0
contract_made = None"] + Zero --> End(["RoundScore"]) + + HasContract -- "yes" --> Points["card points per team (trump-aware)
+10 last-trick bonus → last-trick team
belote_team = holder of K+Q of trump"] + Points --> Family{"contract.is_slam_family()?"} + + %% ---------- Slam / Solo Slam path ---------- + Family -- "Slam / Solo Slam" --> SlamMade{"contract team won all 8?
(Solo Slam: bidder
personally all 8)"} + SlamMade -- "yes → made" --> SlamWin["at_risk = (base + substitute) × M
→ contract team
(500/1000/2000 Slam,
1000/2000/4000 Solo Slam)"] + SlamMade -- "no → failed" --> SlamLose["at_risk = (base + substitute) × M
→ defending side"] + SlamWin --> Belote + SlamLose --> Belote + + %% ---------- Numeric path (80–180) ---------- + Family -- "numeric" --> Sweep{"M == 1 AND
contract team won all 8?
(unannounced Slam)"} + Sweep -- "yes" --> SweepTag["pile = 250 substitute
tag GRAND_SLAM (bidder all 8)
else SLAM · made = true"] + Sweep -- "no" --> NumMade + SweepTag --> Mult + + NumMade["made = attacker_pts ≥ value
(attacker_pts = card pts + own belote)"] --> Mult{"multiplier M?"} + + %% --- M == 1: share the pile --- + Mult -- "M == 1 (share)" --> ShareMade{"contract made?"} + ShareMade -- "made" --> ShareWin["contract → value + pile + belote
(pile = 250 on unannounced Slam, else card pts)
defenders → own card pts + belote"] + ShareMade -- "failed" --> ShareLose["defenders → 160 + value + belote
declarer → belote only"] + ShareWin --> Belote + ShareLose --> Belote + + %% --- M > 1: winner takes all --- + Mult -- "M > 1 (doubled)" --> DblMade{"contract made?"} + DblMade -- "made" --> DblWin["contract → 160 + value×M + belote
defenders → belote only"] + DblMade -- "failed" --> DblLose["defenders → 160 + value×M + belote
declarer → belote only"] + DblWin --> Belote + DblLose --> Belote + + %% ---------- Shared belote footer ---------- + Belote["belote +20 preserved on every shape
→ the K+Q holder's team (even the round's loser)"] + Belote --> End + + %% ---------- Engine-orange palette ---------- + classDef entry fill:#FFD9B3,stroke:#B26A28,stroke-width:2px,color:#5C3A14; + classDef step fill:#FFEFD9,stroke:#B26A28,stroke-width:1px,color:#5C3A14; + classDef decision fill:#FFF3E0,stroke:#E89A4F,stroke-width:2px,color:#5C3A14; + classDef belote fill:#FFF8E1,stroke:#F9A825,stroke-width:2px,color:#5F4B00; + + class Start,End entry; + class HasContract,Family,SlamMade,Sweep,Mult,ShareMade,DblMade decision; + class Zero,Points,SlamWin,SlamLose,SweepTag,NumMade,ShareWin,ShareLose,DblWin,DblLose step; + class Belote belote; diff --git a/docs/diagrams/flow_scoring.png b/docs/diagrams/flow_scoring.png new file mode 100644 index 0000000..68a6591 Binary files /dev/null and b/docs/diagrams/flow_scoring.png differ diff --git a/docs/diagrams/index.md b/docs/diagrams/index.md index 19dd4eb..65da955 100644 --- a/docs/diagrams/index.md +++ b/docs/diagrams/index.md @@ -22,7 +22,7 @@ Colour encodes **which package owns the element**, reused consistently across ev | Stub / unimplemented | `#9E9E9E` | `#EEEEEE` | `#616161` | | `<>` | greyed | greyed | dashed | -Stubbed elements (e.g. the engine's `GameController` / `CliView` today) use the grey palette plus a `<>` stereotype. Planned-but-unwired elements (e.g. SQLite persistence in the scraper) use dashed arrows and the `<>` stereotype. +Stubbed elements — code that exists but isn't wired in — use the grey palette plus a `<>` stereotype (the engine carries none today). Planned-but-unwired elements (e.g. SQLite persistence in the scraper) use dashed arrows and the `<>` stereotype. ## Rendering @@ -60,8 +60,10 @@ Each row links to the canonical `.puml` source, the rendered `.png` preview, and | `class_engine.puml` | Class | contrai-engine + MVC | [source](class_engine.puml) | [png](class_engine.png) | [Engine overview](../engine/#class-structure) | | `class_analyzer.puml` | Class | contrai-analyzer | [source](class_analyzer.puml) | [png](class_analyzer.png) | [Analyzer overview](../analyzer/#class-structure) | | `class_workspace.puml` | Class | Workspace overview | [source](class_workspace.puml) | [png](class_workspace.png) | [Architecture](../architecture/#package-map) | +| `seq_cli.puml` | Sequence | Engine CLI game loop | [source](seq_cli.puml) | [png](seq_cli.png) | [Engine — game loop](../engine/#game-loop) | | `seq_round.puml` | Sequence | Engine round flow | [source](seq_round.puml) | [png](seq_round.png) | [Engine — round lifecycle](../engine/#round-lifecycle) | | `seq_bidding.puml` | Sequence | Bidding cycle zoom | [source](seq_bidding.puml) | [png](seq_bidding.png) | [Engine — bidding cycle zoom](../engine/#round-lifecycle) | | `seq_trick.puml` | Sequence | Single trick zoom | [source](seq_trick.puml) | [png](seq_trick.png) | [Engine — single trick zoom](../engine/#round-lifecycle) | +| `flow_scoring.mmd` | Flowchart| Round scoring tree | [source](flow_scoring.mmd) | [png](flow_scoring.png) | [Engine — scoring](../engine/#scoring) | | `seq_scraper.puml` | Sequence | contrai-scraper | [source](seq_scraper.puml) | [png](seq_scraper.png) | [Scraper overview](../scraper/#current-flow-v1) | | `state_cli_screens.mmd`| State | RichView screen flow | [source](state_cli_screens.mmd) | [png](state_cli_screens.png) | [Engine — CLI](../engine/#cli) | diff --git a/docs/diagrams/seq_bidding.png b/docs/diagrams/seq_bidding.png index 753021b..91fe6b6 100644 Binary files a/docs/diagrams/seq_bidding.png and b/docs/diagrams/seq_bidding.png differ diff --git a/docs/diagrams/seq_bidding.puml b/docs/diagrams/seq_bidding.puml index c49695d..81c1c21 100644 --- a/docs/diagrams/seq_bidding.puml +++ b/docs/diagrams/seq_bidding.puml @@ -81,14 +81,13 @@ loop **while not auction.is_terminal()** alt player has choose_bid (AI / Human) R -> P : player.choose_bid(auction) note right of P - AiPlayer adapts auction.bids - back to the legacy - ``(player, wire_bid)`` - tuples its expert table - still uses, picks a wire - choice, and lifts the - result to a Bid via - ``wire_to_bid``. + AiPlayer walks ``auction.bids`` + directly: its expert table + reads the standing contract + and partner bid off the typed + Bid history and returns a + concrete Bid (PassBid / + ContractBid / DoubleBid). HumanPlayer.choose_bid returns None by design. @@ -99,22 +98,18 @@ loop **while not auction.is_terminal()** alt view ≠ None AND player.is_human R -->> V : view.request_bid_action(\n player, auction\n) note right of V - RichView projects - ``auction.bids`` to the - legacy ``(player, wire_bid)`` - shape its renderer - internals still consume, - renders the bidding screen, + RichView feeds ``auction.bids`` + straight to its renderer + (now a plain list) and + derives the prompt hint from + ``auction.legal_actions``. It parses the human's input (``pass``, ``80 h``, ``double``, ``redouble``, - …), and loops on parse - errors. - - The parsed wire choice is - lifted to a Bid via - ``wire_to_bid`` and - returned. + …) into a Bid, loops on parse + errors, and re-prompts on any + bid ``Auction.is_legal`` + rejects before returning. end note V -->> R : Bid end @@ -178,14 +173,14 @@ end deactivate R note over R, V - **Wire format vs Bid objects.** Bidding flows through real + **Typed Bid objects end to end.** Bidding flows through real :class:`Bid` subclass instances at every boundary (``Player.choose_bid``, ``view.request_bid_action``, - ``Auction.apply``, ``view.on_bid_made``). The legacy + ``Auction.apply``, ``view.on_bid_made``) — and now *inside* + AiPlayer's expert table and RichView's renderer as well. The legacy ``'Pass'`` / ``'Double'`` / ``'Redouble'`` / ``(value, suit)`` wire - format only survives *inside* AiPlayer's expert table and RichView's - renderer; the ``wire_to_bid`` / ``bid_to_wire`` helpers in - ``contrai_engine.model.player`` are the bridge. + format and its ``wire_to_bid`` / ``bid_to_wire`` bridge have been + retired. end note @enduml diff --git a/docs/diagrams/seq_cli.png b/docs/diagrams/seq_cli.png new file mode 100644 index 0000000..5fc11db Binary files /dev/null and b/docs/diagrams/seq_cli.png differ diff --git a/docs/diagrams/seq_cli.puml b/docs/diagrams/seq_cli.puml new file mode 100644 index 0000000..b267834 --- /dev/null +++ b/docs/diagrams/seq_cli.puml @@ -0,0 +1,123 @@ +@startuml seq_cli +title contrai-engine — CLI game loop (cli.py main) + +' --- Palette: engine orange for engine participants, core blue for core types --- +skinparam shadowing false +skinparam sequence { + ArrowColor #B26A28 + LifeLineBorderColor #E89A4F + LifeLineBackgroundColor #FFEFD9 + ParticipantBorderColor #B26A28 + ParticipantBackgroundColor #FFEFD9 + ParticipantFontColor #5C3A14 + ActorBorderColor #B26A28 + ActorBackgroundColor #FFEFD9 + ActorFontColor #5C3A14 + GroupBorderColor #B26A28 + GroupBackgroundColor #FFF3E0 + GroupHeaderFontColor #5C3A14 + BoxBorderColor #B26A28 + BoxBackgroundColor #FFF3E0 + DividerBorderColor #B26A28 + DividerBackgroundColor #FFD9B3 + DividerFontColor #5C3A14 +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 + FontColor #5F4B00 +} + +actor "user" as U +participant "main()\n(cli.py)" as M <> +participant "RichView" as V <> +participant "Game" as G <> +participant "Round\n(per round)" as R <> + +[-> M : contrai +activate M + +M -> V : new RichView() +M -> V : show_landing() +note right of V + Target-score picker + (500 / 1000 / 1500 / 2000 / 3000). + Returns the chosen target. +end note +V --> M : target : int + +== New-game / rematch loop == + +loop while True + M -> M : game = _build_game() + note right of M + Hardcoded seating: South = ``HumanPlayer``, + N/E/W = ``AiPlayer`` (expert — the default + strategies). TODO: a seat picker on the + landing screen. + end note + M -> V : attach(game, target_score=target) + + == Round loop == + + loop while not game.check_game_over(target).game_over + M -> G : manage_round(view=view) + activate G + ref over G, R : **seq_round** — setup → bidding → 8 tricks → scoring + G --> M : (mutates current_round / scores in place) + deactivate G + + M -> V : on_round_complete(game.current_round, game.scores) + note right of V + Append a RoundSummary to ``history`` + for the end-game scoreboard. + end note + + M -> G : status = check_game_over(target) + M -> V : show_round_recap(current_round, scores,\n is_final=status.game_over,\n is_tiebreaker=status.tied_teams is not None) + note right of V + Between-round recap (contract, made/failed, + per-team round points, running totals). Blocks + on Enter. When ``is_final`` the prompt flips to + "see the final score…" so the next screen is + the end-game banner, not a fresh deal. When + ``is_tiebreaker`` (teams level at/above the + target) the panel announces sudden death and + the prompt deals the tiebreaker round. + end note + end + + == End-game routing == + + M -> G : status = check_game_over(target) + M -> V : show_end_game(status) + note right of V + Double-line winner banner + round-by-round + table. Returns the user's choice. + end note + V --> M : choice : 'n' | 'r' | 'q' + + alt choice == 'q' + M ->x M : break (leave the loop) + else choice == 'n' (new game) + M -> V : show_landing(selected_target=target) + V --> M : target (fresh pick) + else choice == 'r' (rematch) + note right of M + Same target; a fresh ``_build_game`` + starts on the next loop tick. + end note + end +end + +[<- M : return (clean exit) + +note over M, V + ``KeyboardInterrupt`` / ``EOFError`` anywhere in the + loop is caught at the top level and prints "Goodbye." — + a Ctrl-C / Ctrl-D quits gracefully rather than dumping a + traceback. ``main`` first reconfigures stdout/stderr to + UTF-8 so the suit glyphs render on legacy Windows consoles. +end note + +@enduml diff --git a/docs/diagrams/seq_round.png b/docs/diagrams/seq_round.png index 289571f..aea28e9 100644 Binary files a/docs/diagrams/seq_round.png and b/docs/diagrams/seq_round.png differ diff --git a/docs/diagrams/seq_round.puml b/docs/diagrams/seq_round.puml index a93dccc..f925802 100644 --- a/docs/diagrams/seq_round.puml +++ b/docs/diagrams/seq_round.puml @@ -60,7 +60,21 @@ G -> R : new Round(players_order, dealer,\n deck, round_number) activate R G -> R : deal_cards() R -> D : deal(players_order)\n(dealer gets last cards) -return +note right of R + `deal_cards` only deals — there is no + per-seat tracker to reset any more. + `play_state` stays `None` until play + begins (seeded by `play_all_tricks`, + below); each AI's card tracking is + derived fresh from the public trick + history on every `choose_card` call + instead of persisted counters — see + **seq_trick.puml**. +end note +' Plain reply arrow — a bare `return` here would close Round's +' activation (opened at `new Round`) and leave its lifeline +' inactive for the bidding/trick/scoring phases below. +R --> G G -->> V : on_round_dealt(current_round) note right of V @@ -73,6 +87,7 @@ end note G -> R : manage_bidding(view) ref over R, P : **seq_bidding** — bid loop, validation, termination +R -> R : self.auction = auction\n(retained for the play phase — attached to\n each AI's PlayObservation via play_state.observe) R -> R : _detect_belote_holder()\n(if a contract was made) R --> G : Contract | None G -> G : current_contract = contract @@ -81,26 +96,49 @@ alt all players passed (contract is None) G -> R : handle_failed_contract() R -> D : add_cards(all hands) R --> G : {team: 0} - G -->> V : on_all_pass_redeal(current_round) - G --> C : { contract: None,\n message: "All players passed.\n Cards redistributed." } - return + G -->> V : on_all_pass_redeal() + G --> C : returns None\n(current_contract = None,\n cards redistributed) + ' No deactivate here: PlantUML applies activations linearly across + ' alt branches, so closing Game/Round in this early-exit branch + ' would blank their bars for the trick-taking and scoring phases + ' drawn below. The explicit return arrow above marks the early exit. end == Trick-taking — 8 tricks == G -> R : play_all_tricks(view) +R -> R : play_state = PlayState.start(\n contract, players_order,\n tuple(tuple(player.hand) for player in players_order)) +note right of R + Validated seeding (contrai-core): exactly + 4 seats, 8 distinct cards each — the very + Card objects held in the dealt hands, so + the view keeps matching playable cards by + identity. `play_state` becomes the single + source of truth for whose turn it is, the + legal cards, and each seat's remaining + hand for the rest of the trick loop. +end note loop trick_num in 0..7 - ref over R, P : **seq_trick** — playable cards, choose, winner + ref over R, P : **seq_trick** — to_act, legal_actions,\nchoose/request, apply, _sync_hands end R --> G : team_tricks: dict> == Scoring == G -> R : calculate_round_scores() -R -> R : sum card_points per team\n(belote +20, dix-de-der +10) -R -> R : Slam/SoloSlam: at_risk = (base + substitute) * mult\nbase = substitute = 250 (Slam) or 500 (SoloSlam)\n→ 500/1000/2000 or 1000/2000/4000\nto winning side; trick pile replaced by substitute -R -> R : numeric: compare points to value\nelse defender = (160 + value) * mult -R -> R : apply double / redouble multiplier +R -> R : score_round(self)\n(scoring.py — pure round → RoundScore) +note right of R + ``calculate_round_scores`` is a thin + wrapper: it calls ``scoring.score_round`` + and publishes the returned ``RoundScore`` + onto ``round_scores`` / ``contract_made`` / + ``unannounced_slam``. The branch detail + (numeric share-pile vs. unannounced-Slam + vs. doubled winner-takes-all vs. Slam / + Solo Slam, belote +20 on every shape) lives + in **flow_scoring.mmd**. +end note +R -> R : round_scores, contract_made,\nunannounced_slam = RoundScore R --> G : round_scores: dict deactivate R @@ -108,7 +146,7 @@ loop team_name, points in round_scores G -> G : scores[team_name] += points end -G --> C : { contract, scores, total_scores,\n message: "Round completed" } +G --> C : returns None\n(current_contract + scores\n mutated in place) deactivate G == Between rounds — cli.py == @@ -119,7 +157,7 @@ note right of V `history` for the end-game scoreboard table. end note -C -->> V : show_round_recap(round_, scores,\n is_final=check_game_over(target)) +C -->> V : show_round_recap(round_, scores,\n is_final=status.game_over,\n is_tiebreaker=status.tied_teams is not None) note right of V Full-screen recap panel: contract, made/failed, per-team @@ -128,12 +166,17 @@ note right of V When `is_final` is true the prompt switches to "see the final score…" so the next screen is the EndGame - banner, not another deal. + banner, not another deal. When + `is_tiebreaker` is true (both teams + level at/above the target) the panel + announces sudden death and the + prompt deals the tiebreaker round. end note note over C, P Caller typically loops `manage_round` then `check_game_over(target)` - until a team crosses the target. Game ↔ Round delegation: + until one team strictly leads at/above the target (a tie there is + sudden death — more rounds follow). Game ↔ Round delegation: Game owns the loop; Round owns one round's lifecycle. The CLI (`cli.py`) owns the between-rounds choreography: `on_round_complete` feeds the end-game scoreboard, then `show_round_recap` blocks for diff --git a/docs/diagrams/seq_trick.png b/docs/diagrams/seq_trick.png index 71d7ace..28d4fb1 100644 Binary files a/docs/diagrams/seq_trick.png and b/docs/diagrams/seq_trick.png differ diff --git a/docs/diagrams/seq_trick.puml b/docs/diagrams/seq_trick.puml index af6bcc7..03986f6 100644 --- a/docs/diagrams/seq_trick.puml +++ b/docs/diagrams/seq_trick.puml @@ -29,6 +29,7 @@ skinparam note { } participant "Round" as R <> +participant "PlayState" as PS <> #E1F0FF participant "Trick" as T <> #E1F0FF participant "Player" as P <> participant "Hand" as H <> #E1F0FF @@ -38,38 +39,35 @@ participant "RichView" as V <> [-> R : play_trick(view) activate R +note over R, PS + Lazy-seed guard: if `play_state` is still None (a + caller drove `play_trick` directly, without going + through `play_all_tricks`), Round seeds it here + from the current contract, seating and hands via + the bare (unvalidated) `PlayState` constructor. +end note + R -> T : new Trick() note right of T - Note: `Trick()` is a bare container — it stores no trump. - Round passes `self.contract.suit` into - `Trick.get_current_winner(trump_suit)` (contrai-core) - when it needs the winner; see "Winner + bookkeeping". + `Trick()` is a bare container — it stores no + trump. Round passes `self.contract.suit` into + `Trick.get_current_winner(trump_suit)` when it + needs the winner; see "Winner + bookkeeping". + `current_trick` is a **mutable mirror**, kept in + lock-step with `play_state` for the view — it is + never the source of legality or turn order. end note -== Leader determination == - -alt last_trick_winner is None (first trick) - R -> R : trick_leader = players_order[0] -else - R -> R : trick_leader = last_trick_winner -end - -R -> R : leader_idx = players_order.index(trick_leader)\ntrick_order = rotate(players_order, leader_idx) - == Four players play in order == -loop **for player in trick_order** (×4) +loop **for _ in range(4)** - R -> R : playable_cards = _get_playable_cards(player) + R -> PS : to_act + PS --> R : player + R -> PS : legal_actions(player) + PS --> R : playable_cards - ref over R : **SF-09 / SF-10** — see legality rules below - - alt player has choose_card (AI / Human) - R -> P : choose_card(current_trick,\n contract, playable_cards) - P --> R : Card - else fallback - R -> R : card = playable_cards[0] - end + ref over PS : **SF-09 / SF-10** legality — see note at the foot of the diagram alt view ≠ None AND player.is_human R -->> V : view.request_card_action(player,\n current_trick, contract, playable_cards) @@ -78,19 +76,60 @@ loop **for player in trick_order** (×4) mid-trick screen, parses the human's card-number input, and returns the - chosen Card. + chosen Card. choose_card is + skipped — HumanPlayer's + override only returns None. end note V -->> R : Card + else player has choose_card (AI) + R -> PS : observe(player,\n bids=auction.bids if auction else ()) + PS --> R : PlayObservation + R -> P : choose_card(observation) + note right of P + The frozen PlayObservation carries only + this seat's own hand, the public trick + history, the contract/auction, and its + legal cards right now — the sole input a + strategy receives, so it can never read + another seat's hand by construction. + `RuleBasedCardPlayStrategy` derives its + fallen-card / trump-void tracking by + replaying the observation's public trick + history on every call — never carried + across calls or rounds. + end note + P --> R : Card + else fallback + R -> R : card = playable_cards[0] if playable_cards else None end - alt card ∈ playable_cards AND card ∈ player.hand - R -> H : player.hand.remove(card) - R -> T : current_trick.add_play(player, card) - else illegal choice → fallback to playable_cards[0] - R -> H : player.hand.remove(playable_cards[0]) - R -> T : current_trick.add_play(\n player, playable_cards[0]\n) + R -> PS : apply(Play(player, card)) + alt legal play + PS --> R : new PlayState\n(card removed from player's hand) + else illegal (out-of-turn / not held / obligation broken) + PS ->x R : raise IllegalPlayError(card,\n violation_reason, legal_alternatives) + note right of R + No silent correction. ``choose_card`` / + ``request_card_action`` are contracted to + return a card from ``playable_cards``; a + violation here is a wiring bug surfaced + loudly, not papered over. + end note end + R -> H : _sync_hands()\n(every seat, from the new play_state) + note right of H + Clears and re-extends each player's `Hand` in + place from `play_state.hand_of(player)` — + preserves object identity and card references + so the view keeps matching cards by identity. + `play_state` stays the single source of truth + for every seat's remaining cards; `Hand` is a + mirror kept in lock-step for the view, never + read to decide legality. + end note + R -> T : current_trick.add_play(player, card) + R -->> V : view.on_card_played(player, played_card,\n current_trick) note right of V RichView logs "X plays Y" in the @@ -152,26 +191,29 @@ alt view ≠ None AND hasattr(view, 'on_trick_complete') V -->> R : (returns) end -[<-- R : return winner (Player) +[<-- R : return (None — winner published on last_trick_winner) deactivate R -note over R, V - **Legality (SF-09 / SF-10) — `_get_playable_cards`:** +note over R, PS + **Legality (SF-09 / SF-10) — `PlayState.legal_actions`, contrai-core:** 1. **Empty trick** → any card. 2. Player has the **lead suit**: • lead = trump → must **over-trump** every trump on the table - if able (`_higher_trumps_than_played`); else play any trump. + if able; else play any trump. • lead ≠ trump → must follow suit (no over-trump duty). 3. No lead-suit cards, **partner is currently master** - (`Trick.get_current_winner(trump_suit)`) → any card. + (`current_winner(trick, trump_suit)`) → any card. A partner who led but has been over-trumped no longer protects you. 4. No lead-suit cards, partner is not master: • no trump suit → any card. - • opponent already trumped → must over-trump - (`_highest_opponent_trump`) if possible; else play any trump - if any; else discard. + • opponent already trumped → must over-trump if possible; + else play any trump if any; else discard. • no opponent trump yet → must trump if any; else discard. + + `apply(Play)` re-checks the same obligations before committing the + play — legality is now single-sourced in `contrai_core.play`, with + no engine-side duplicate. end note @enduml diff --git a/docs/diagrams/state_cli_screens.mmd b/docs/diagrams/state_cli_screens.mmd index b12ddfb..43af47b 100644 --- a/docs/diagrams/state_cli_screens.mmd +++ b/docs/diagrams/state_cli_screens.mmd @@ -10,7 +10,7 @@ stateDiagram-v2 Bidding : 1. Bidding
request_bid_action(player, history)
"80 h" / "pass" / "double" / "redouble"
(hint adapts to '(pass / redouble)'
when contractor was just doubled) MidTrick : 2. Mid-trick
request_card_action(player, trick, contract, playable)
diamond + live-winner gold pill
★ Belote badge under holder (Belote/Rebelote in log) TrickWon : 3. Trick won
on_trick_complete(trick, winner, round)
"Press [Enter] to continue…" - RoundRecap : 4. Round recap
show_round_recap(round, scores, is_final)
contract (doubled/redoubled verbose) · trump · made/failed
Outcome: tricks won · round points
Scoring: contract · tricks · last trick · belote · round score
running totals · target
"Press [Enter] to deal next round…"
(or "…to see the final score…" when is_final) + RoundRecap : 4. Round recap
show_round_recap(round, scores, is_final, is_tiebreaker)
contract (doubled/redoubled verbose) · trump · made/failed
Outcome: tricks won · round points
Scoring: contract · tricks · last trick · belote · round score
running totals · target
"Press [Enter] to deal next round…"
(or "…to see the final score…" when is_final,
"…to deal the tiebreaker round…" when is_tiebreaker) EndGame : 5. End game
show_end_game(status)
[n] new · [r] rematch · [q] quit Landing --> Bidding : Enter @@ -21,7 +21,7 @@ stateDiagram-v2 MidTrick --> TrickWon : 4th card played,
Round fires hook TrickWon --> MidTrick : next trick
(round not done) TrickWon --> RoundRecap : 8 tricks done,
round scored - RoundRecap --> Bidding : Enter,
target not reached,
next deal + RoundRecap --> Bidding : Enter,
no strict leader at target
(incl. tie = sudden death),
next deal RoundRecap --> EndGame : Enter,
check_game_over true EndGame --> Landing : [n] new game
(fresh target prompt) @@ -50,8 +50,11 @@ stateDiagram-v2 prompt flips to "see the final score…" on that final pass, so the EndGame banner is the next screen instead of a - fresh deal. Also shown for all-pass - rounds ("All passed — no contract"). + fresh deal. A tie at/above the target + is sudden death: the panel announces + the tiebreaker round and play goes on. + Also shown for all-pass rounds + ("All passed — no contract"). end note classDef screen0 fill:#FFEFD9,stroke:#B26A28,stroke-width:2px,color:#5C3A14; diff --git a/docs/diagrams/state_cli_screens.png b/docs/diagrams/state_cli_screens.png index 275a3b3..4a19dc7 100644 Binary files a/docs/diagrams/state_cli_screens.png and b/docs/diagrams/state_cli_screens.png differ diff --git a/docs/engine/index.md b/docs/engine/index.md index 4d421b5..9f742b4 100644 --- a/docs/engine/index.md +++ b/docs/engine/index.md @@ -1,17 +1,31 @@ # contrai-engine -Game engine for Coinche / Contrée. MVC architecture, sits on top of `contrai-core` for all shared types. +Game engine for Coinche / Contrée. Model–View architecture (the controller role is the `cli.py` game loop plus the view hooks today), sits on top of `contrai-core` for all shared types. ## Layout Source at `packages/contrai-engine/src/contrai_engine/`: - `model/` — engine-side model layer: - - `player.py` — `Player`, `HumanPlayer`, `AiPlayer` (all extending `BasePlayer` from `contrai-core`) + - `player/` — the player subpackage (all classes extend `BasePlayer` from `contrai-core`); `player/__init__.py` re-exports the public names so external imports (`from contrai_engine.model.player import …`) are unchanged: + - `base.py` — `Player` (abstract) and `HumanPlayer` + - `strategy.py` — the `BiddingStrategy` / `CardPlayStrategy` abstract interfaces and the `PlayerStateMixin` mix-in (live read access to the owning player's `hand` / `team` / `position`) + - `rule_based.py` — `RuleBasedBiddingStrategy` / `RuleBasedCardPlayStrategy`, the first concrete level (the expert `SF-09` / `SF-10` rules) + - `ai.py` — `AiPlayer`, which injects a bidding and a card-play strategy and delegates to them + - `levels.py` — the `AI_LEVELS` registry + `make_ai_player()` factory for difficulty selection - `game.py` — `Game` (fires `view.on_round_dealt(...)` after the deal and `view.on_all_pass_redeal(...)` when nobody contracts) - - `round.py` — `Round` (publishes `view.on_bid_made(...)`, `view.on_contract_established(...)`, `view.on_card_played(...)`, `view.on_trick_complete(...)`, and `view.on_belote_announced(...)` so the view can pace and narrate AI turns) -- `controller/` — `GameController` (partial stub — see [Open work](#open-work)) -- `view/` — `RichView` (terminal UI, see [CLI](#cli) below) + - `round/` — the round subpackage (the lifecycle orchestrator plus the pure scoring transformation it calls); `round/__init__.py` re-exports `Round` / `UnannouncedSlam` so external imports (`from contrai_engine.model.round import …`) are unchanged: + - `round.py` — `Round`, the lifecycle orchestrator: deal → `manage_bidding` → `play_trick` / `play_all_tricks` → the thin `calculate_round_scores` wrapper, plus the inline belote/rebelote helpers. The trick loop is driven by the immutable core `contrai_core.play.PlayState` — seeded at the start of play by `play_all_tricks` (or lazily by `play_trick` when driven directly) — which owns whose turn it is, the legal cards, and each seat's remaining hand; `Round` re-mirrors it after each play — `_sync_hands` re-extends every `player.hand`, and the same play is mirrored onto `current_trick` via `add_play` — so the view keeps reading the classic engine objects, while AI seats instead read the frozen `PlayObservation` projected from that same state. Publishes `view.on_bid_made(...)`, `view.on_contract_established(...)`, `view.on_card_played(...)`, `view.on_trick_complete(...)`, and `view.on_belote_announced(...)` so the view can pace and narrate AI turns + - `scoring.py` — the pure `score_round(round) -> RoundScore` transformation (the numeric / unannounced-Slam / doubled / Slam-family scoring shapes, belote +20), plus `count_player_tricks` and the `UnannouncedSlam` outcome tag +- `view/` — the terminal UI, split into focused modules (see [CLI](#cli) below): + - `rich_view.py` — `RichView`, the stateful orchestrator: console + per-game state, the engine hooks (`request_*_action`, `on_*`, `show_*`), the input loops, and `_render_in_game` (the single seam that pulls state off `self` and feeds the pure builders). `RoundSummary` lives here too. Re-exported from `view/__init__.py`, so both `from contrai_engine.view.rich_view import RichView` (used by `cli.py` / `model/game.py`) and `from contrai_engine.view import RichView` work. + - `theme.py` — design tokens (colour palette) and lookup tables (target-score options, position / team labels, bid aliases, valid contract values) + - `formatting.py` — stateless text / glyph / label builders (seat & suit labels, the shared contract / trump labels, and the compact `Bid` label) + - `parsing.py` — human-input parsers (`_parse_bid_input`, `_parse_card_input`) + - `bidding_rules.py` — the messaging-only `_illegal_bid_reason` mirror of the auction rules (the specific nudge shown when a human types an illegal bid); the adaptive prompt hint is derived directly from `Auction.legal_actions` + - `state_helpers.py` — small game-state readers (`_current_winner`, `_explain_constraint`, `_sort_hand_for_display`, `_belote_by_position`, `_resolve_delay`) + - `layout.py` — cross-screen layout (`_two_column`, the Prompt panel, the event-log panel, and the Game-score panel shown in every in-game frame's top-left) + - `screens/` — one module per screen of the five-screen design: `landing.py`, `bidding.py`, `trick.py`, `recap.py`, `endgame.py`. Each exposes pure `(data) -> Panel/Text` builders; `RichView` composes and prints them. - `cli.py` — `contrai` console-script entry point: landing → game-loop → end-game - `tests/` — pytest suite (`test_model/`, `test_view/`) @@ -22,11 +36,13 @@ Everything else (`Card`, `Deck`, `Hand`, `Suit`, `Rank`, `Bid`, `Contract`, `Tri ```plantuml format="svg" source="class_engine.puml" ``` -`Player` extends `BasePlayer` from `contrai-core` (drawn as a blue boundary element). The two concrete subclasses are `HumanPlayer` (whose `choose_bid` / `choose_card` still return `None` — the `RichView` is what actually services human input through `Round`'s `view.request_*_action` hooks) and `AiPlayer` (full bidding + strategy). `GameController` remains in the grey stub palette: it still references undefined `pygame` and isn't wired to `Game` / `Round`. `RichView` is the live engine view; the old `CliView` placeholder has been removed. See [Diagrams](../diagrams/) for the colour convention. +`Player` extends `BasePlayer` from `contrai-core` (drawn as a blue boundary element). The two concrete subclasses are `HumanPlayer` (whose `choose_bid` / `choose_card` still return `None` — the `RichView` is what actually services human input through `Round`'s `view.request_*_action` hooks) and `AiPlayer`, which holds an injected `BiddingStrategy` and `CardPlayStrategy` and delegates to them. There is no standalone controller class today: the controller role is the `cli.py` game loop plus the view hooks `Round` calls. `RichView` is the live engine view; the old `CliView` placeholder has been removed. See [Diagrams](../diagrams/) for the colour convention. ## AI players -`AiPlayer` implements the expert bidding table (80–160 plus Slam and Solo Slam) and the card-play strategy from the functional specs (`SF-09`, `SF-10`). The ~25 private strategy helpers are summarised on the class diagram above as a collapsed `<>` note. `choose_card` lazy-initialises card tracking on first call (no need for callers to remember `initialize_card_tracking`), and consumes the real `Contract` object from `Round` rather than the legacy `(player, value, suit)` tuple some older tests once passed. +`AiPlayer` owns no strategic logic of its own. It holds two strategy objects behind the abstract `BiddingStrategy` / `CardPlayStrategy` interfaces (`strategy.py`) and routes `choose_bid` / `choose_card` to them. Strategies are supplied at construction as **factories** (`player -> strategy`, i.e. the strategy class itself), resolving the chicken-and-egg of a strategy that needs a back-reference to the player while the player is still being built; the `PlayerStateMixin` mix-in then gives each strategy live read access to the player's `hand` / `team` / `position`. The defaults reproduce today's bot, so `AiPlayer("Bot", "South")` is unchanged. + +The first concrete level is the rule-based pair (`rule_based.py`): `RuleBasedBiddingStrategy` implements the expert bidding table (80–160 plus Slam and Solo Slam) and `RuleBasedCardPlayStrategy` the card-play strategy from the functional specs (`SF-09`, `SF-10`). Future AI levels (MCTS, learned policies — AI roadmap §6) are new strategy classes, never edits to `AiPlayer`; a thin `AI_LEVELS` registry + `make_ai_player(name, position, level="expert")` factory (`levels.py`) gives ergonomic difficulty selection on top, while the raw `AiPlayer(..., bidding=…, cardplay=…)` form stays available for mix-and-match (e.g. rule-based bidding + a learned card-play). The strategy object is also the natural home for a future explainability rule-trace (§6.1). `choose_card` takes a single frozen `contrai_core.PlayObservation` — the seat's own hand, the public trick history, the contract/auction, and its legal cards right now — projected by `PlayState.observe()`. Because that observation is the *only* input a card-play strategy ever receives, a strategy is sealed off from another seat's hand by construction through what it is handed — not a rule the strategy has to honour; sealing the live player refs carried by `Play` is a noted follow-up. `RuleBasedCardPlayStrategy` is stateless between calls: it derives whatever card tracking it needs (which cards have fallen, which seats are known void in trump) by replaying the observation's public trick history fresh on every turn (`_derive_tracking`), rather than carrying counters across calls or rounds. The trump-void inference stays compelled-only — a seat discarding behind its master partner is *not* marked void, since that discard is voluntary; on a trump lead the proof is unconditional, because holding trump forces playing it. When the AI's team is currently winning the trick (`_play_when_team_winning`) and the AI cannot follow the led suit, the rule is *don't waste trumps*: prefer a non-trump discard over playing a trump card, even though a trump would add more points to the pile. Within the non-trump discard pool the AI prefers non-master cards (preserving cards that can still win their suit later) and picks the highest-points to maximise this trick's value. Only when the hand has nothing left but trumps does the AI play one — and it picks the lowest trump in that case, so the Jack or 9 of trump aren't dumped onto an already-won trick. @@ -36,12 +52,12 @@ When the AI's team is currently winning the trick (`_play_when_team_winning`) an | Screen | Trigger | Notes | | ------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Landing** | program start, `n` from end-game | Target-score picker (500 / 1000 / 1500 / 2000 / 3000). Hardcoded seating today: South = `HumanPlayer`, N/E/W = `AiPlayer` (medium). | +| **Landing** | program start, `n` from end-game | Target-score picker (500 / 1000 / 1500 / 2000 / 3000). Hardcoded seating today: South = `HumanPlayer`, N/E/W = `AiPlayer` (expert — the default). | | **Bidding** | `Round.manage_bidding` → human turn | Game-score + Round panels (title shows `Round #N`), bid history with ` - ` separator between bidding rounds, hand + prompt. Accepts `pass`, `double`, `redouble`, ` ` (English only — the FR aliases `coinche`/`surcoinche` are rejected). When an opponent has doubled the contractor's contract, the hint switches to `(pass / redouble)`. When the player's **partner** has just doubled (or redoubled), the prompt is skipped entirely and the engine auto-passes them — pass is the only legal action and the human shouldn't have to confirm it. | | **Mid-trick** | `Round.play_trick` → human turn | Diamond seating (N top, E right, S bottom, W left). Live winner gets the gold pill. Hand row dim/green for legal vs. illegal plays. Once the holder plays a K or Q of trump, a persistent `★ Belote` badge appears under their seat for the rest of the round (the Belote / Rebelote distinction is kept in the event log). | | **Trick won** | `Round` fires `view.on_trick_complete` | Four-card diamond with the winner highlighted; "Press [Enter] to continue…". The hook is gated on `hasattr(view, 'on_trick_complete')`. | -| **Round recap** | `cli.py` calls `view.show_round_recap` after `view.on_round_complete` | Between-round panel: contract (or "All passed"; the label names the taker and any Coinche/Surcoinche caller, spelled out verbose as `doubled`/`redoubled` here — see below), a `Trump:` recall line (the contract suit, since the contract label omits it), made / failed badge, then **two stacked sub-tables** sharing the N-S / E-W columns. The **Outcome** table reports the factual play tally — `Tricks won` (count) and `Round points` (trump-aware pile + last-trick 10 + belote 20 each side captured, always the real total regardless of scoring shape). The **Scoring** table then traces how the contract converts that into the engine-computed round score — `Contract` (the bonus from the contract being made or failed), `Tricks` (the card pile), `Last trick`, `Belote` (label uses the actual trump glyph), a divider, then the `Round score` subtotal. When the engine substitutes a flat formula (doubled-made attacker, failed defender), the Scoring cards / last-trick / belote rows show em-dashes so the addition stays honest — but the Outcome table still surfaces the points genuinely captured. A final `Running` line carries the game totals and target, its numbers aligned under the team columns. Waits for Enter; shown after *every* round — when the same round just crossed the target, the prompt flips to "Press [Enter] to see the final score…" and the end-game banner is the next screen. | -| **Game over** | `Game.check_game_over(target)` true | Double-line gold banner, round-by-round summary table. `[n]` new game · `[r]` rematch · `[q]` quit. | +| **Round recap** | `cli.py` calls `view.show_round_recap` after `view.on_round_complete` | Between-round panel: contract (or "All passed"; the label names the taker and any Coinche/Surcoinche caller, spelled out verbose as `doubled`/`redoubled` here — see below), a `Trump:` recall line (the contract suit, since the contract label omits it), made / failed badge, then **two stacked sub-tables** sharing the N-S / E-W columns. The **Outcome** table reports the factual play tally — `Tricks won` (count) and `Round points` (trump-aware pile + last-trick 10 + belote 20 each side captured, always the real total regardless of scoring shape). The **Scoring** table then traces how the contract converts that into the engine-computed round score — `Contract` (the bonus from the contract being made or failed), `Tricks` (the card pile), `Last trick`, `Belote` (label uses the actual trump glyph), a divider, then the `Round score` subtotal. When the engine substitutes a flat formula (doubled-made attacker, failed defender), the Scoring cards / last-trick / belote rows show em-dashes so the addition stays honest — but the Outcome table still surfaces the points genuinely captured. A final `Running` line carries the game totals and target, its numbers aligned under the team columns. Waits for Enter; shown after *every* round — when the same round just crossed the target, the prompt flips to "Press [Enter] to see the final score…" and the end-game banner is the next screen. When both teams end the round **level at/above the target** (sudden death), the panel closes with a gold "tiebreaker round follows" notice and the prompt reads "Press [Enter] to deal the tiebreaker round…". | +| **Game over** | `Game.check_game_over(target)` true — one team strictly leads at/above the target (a tie there keeps the game running with tiebreaker rounds) | Double-line gold banner, round-by-round summary table. `[n]` new game · `[r]` rematch · `[q]` quit. | Every in-game screen also carries a rolling **event log** panel (5 lines, "Log") slotted between the hand and the prompt. It captures the last few engine events — deal, all-pass redeal, every bid, the *contract-set* bookmark when bidding ends on a deal, every card play, every trick winner, belote / rebelote announcements — so the user always sees the narrative continuity, even when AI players act faster than they can read. @@ -55,11 +71,11 @@ Per-round summaries shown on the end-game scoreboard are tracked **view-side** ( CONTRAI_AI_BID_DELAY=0.5 CONTRAI_AI_CARD_DELAY=0.3 uv run contrai ``` -**Play legality at the play boundary.** `Round.play_trick` plays a card only if it is in the `_get_playable_cards` legal set; a truthy-but-illegal card now raises `IllegalPlayError` (carrying the offending card, a `PlayRuleViolation` reason, and the legal alternatives) instead of being **silently corrected** to a legal fallback. The reason is computed by `_classify_play_violation`, which mirrors `_get_playable_cards`'s branch order and must stay in sync with it until the deferred `Ruleset` unifies the two. Both `AiPlayer.choose_card` and `RichView.request_card_action` are contracted to only ever return a card from `playable_cards`, so the raise is a safety net surfacing wiring bugs (cf. the `AiPlayer` cleanup in the open work) rather than a path hit in normal play — the headless 4-AI smoke run confirms it never fires. +**Play legality at the play boundary.** Card-play legality now lives entirely in `contrai_core.play.PlayState`: `Round.play_trick` reads the active player and their legal cards off `play_state.to_act` / `play_state.legal_actions(player)`, then advances the state with `play_state.apply(Play(player, card))`. `apply` is the single legality-enforcing transition — it checks turn order, then hand membership, then the follow/trump obligations — and raises `IllegalPlayError` (carrying the offending card, a `PlayRuleViolation` reason, and the legal alternatives) on an out-of-turn, not-held, or obligation-breaking play, instead of being **silently corrected** to a legal fallback. Both `AiPlayer.choose_card` (fed the `PlayObservation` projected from that same state) and `RichView.request_card_action` are contracted to only ever return a card from the legal set, so the raise is a safety net surfacing wiring bugs (cf. the `AiPlayer` cleanup in the open work) rather than a path hit in normal play — the headless 4-AI smoke run confirms it never fires. -**Bid legality at the input boundary.** `request_bid_action` parses raw input for *shape* (`_parse_bid_input`) and then validates it against `Auction.is_legal` before returning. An illegal-but-parseable bid — e.g. doubling your own partner's contract — re-prompts with a specific reason (`_illegal_bid_reason`) instead of escaping to `Auction.apply` and crashing the CLI. The model keeps its strict hard-raise contract; the human-input layer is where unvalidated input is filtered. The bid prompt hint is likewise adaptive: `double` / `redouble` are only advertised when `_double_available_to` / `_redouble_available_to` say they're legal for the seat to act, and the worked contract example tracks the auction via `_min_legal_contract_value` — it offers the cheapest legal raise (`100 H` once a `90` stands, not the bare `80` floor), and is dropped past `180` where only Slam outranks the standing contract. +**Bid legality at the input boundary.** `request_bid_action` parses raw input for *shape* (`_parse_bid_input`) and then validates it against `Auction.is_legal` before returning. An illegal-but-parseable bid — e.g. doubling your own partner's contract — re-prompts with a specific reason (`_illegal_bid_reason`) instead of escaping to `Auction.apply` and crashing the CLI. The model keeps its strict hard-raise contract; the human-input layer is where unvalidated input is filtered. The bid prompt hint is likewise adaptive, and reads straight off `Auction.legal_actions(player)`: `double` / `redouble` are only advertised when a `DoubleBid` / `RedoubleBid` is in the seat's legal set, and the worked contract example is the cheapest legal `ContractBid` value on offer (`100 H` once a `90` stands, not the bare `80` floor) — dropped past `180` where only Slam (a non-integer value) outranks the standing contract. -The pure helpers (bid parser, card parser, hand sorter, current-winner, constraint hint, double- and redouble-availability checks, minimum-legal-contract floor, illegal-bid reason, delay resolver, bid-to-legacy converter) live at module scope and are covered by `tests/test_view/test_rich_view.py`. The `Panel`/`Table` builders are validated end-to-end by smoke-running `uv run contrai`. +The pure helpers (bid parser, card parser, hand sorter, current-winner, constraint hint, illegal-bid reason, delay resolver, compact `Bid` label) are module-level functions in their respective modules (`parsing`, `state_helpers`, `bidding_rules`, `formatting`), and the per-screen `Panel` / `Table` builders are pure functions under `screens/`. The test suite mirrors that split — `tests/test_view/test_{formatting,parsing,bidding_rules,state_helpers,layout,recap,endgame}.py` test the extracted modules, while `test_rich_view.py` keeps the stateful `RichView` behaviour (hooks, input loops, in-game frame). The shared `four_players` fixture lives in `tests/test_view/conftest.py`. The deepest `Panel` / `Table` layouts not asserted on are validated end-to-end by smoke-running `uv run contrai`. ```mermaid format="svg" source="state_cli_screens.mmd" ``` @@ -68,12 +84,19 @@ The screen flow above is rendered from [`state_cli_screens.mmd`](../diagrams/sta See the [Rich TUI design handoff](../../ContrAI%20CLI/design_handoff_contrai_tui/README.md) for the visual spec, including all five SVG mockups (the design predates the recap screen and the event log panel, both of which build on top of the same vocabulary). +## Game loop + +```plantuml format="svg" source="seq_cli.puml" +``` + +`cli.py`'s `main()` is the controller role today — there is no separate controller class. It builds a `RichView`, asks for a target score on the landing screen, then runs two nested loops: an outer **new-game / rematch** loop (each iteration `_build_game()`s a fresh `Game` — South `HumanPlayer`, N/E/W expert `AiPlayer` — and `view.attach`es it) and an inner **round** loop that runs while `game.check_game_over(target).game_over` is false. Each round tick calls `game.manage_round(view=view)` (detailed in [Round lifecycle](#round-lifecycle)), then `view.on_round_complete` to feed the scoreboard and `view.show_round_recap(..., is_final=…)` to block on the between-round panel. When the game is over, `view.show_end_game(status)` returns `'q'` (break), `'n'` (re-prompt the landing target), or `'r'` (rematch on the same target). A top-level `try/except (KeyboardInterrupt, EOFError)` turns Ctrl-C / Ctrl-D into a graceful "Goodbye." rather than a traceback. + ## Round lifecycle ```plantuml format="svg" source="seq_round.puml" ``` -The end-to-end flow of `Game.manage_round`: setup (deal, dealer rotation, players_order, `view.on_round_dealt` notification) → bidding (delegated to `Round.manage_bidding`, which establishes the contract and snapshots the belote holder if any) → eight tricks (`Round.play_all_tricks`) → scoring (`calculate_round_scores`, with belote +20 and dix-de-der +10, applying the double / redouble multiplier). The failed-contract branch (everyone passed) returns zero scores, redistributes cards, and fires `view.on_all_pass_redeal`. After each `manage_round`, `cli.py` calls `view.on_round_complete` and then `view.show_round_recap(round_, scores, is_final=…)` — shown for every round, including the one that just clinched the game (the prompt flips to "see the final score…" so the end-game banner is what follows). +The end-to-end flow of `Game.manage_round`: setup (deal, dealer rotation, players_order, `view.on_round_dealt` notification) → bidding (delegated to `Round.manage_bidding`, which establishes the contract and snapshots the belote holder if any) → eight tricks (`Round.play_all_tricks`) → scoring (`calculate_round_scores`, with belote +20 and the last-trick bonus +10, applying the double / redouble multiplier). The failed-contract branch (everyone passed) records zero scores, redistributes cards, and fires `view.on_all_pass_redeal`. `manage_round` returns nothing — it mutates the `Game`/`Round` in place (`current_contract`, `scores`) and the caller reads the outcome off those. After each `manage_round`, `cli.py` calls `view.on_round_complete` and then `view.show_round_recap(round_, scores, is_final=…)` — shown for every round, including the one that just clinched the game (the prompt flips to "see the final score…" so the end-game banner is what follows). The two zoom diagrams below break out the dense parts. @@ -82,20 +105,25 @@ The two zoom diagrams below break out the dense parts. ```plantuml format="svg" source="seq_bidding.puml" ``` - The bid loop drives a `contrai_core.Auction` through `itertools.cycle(players_order)`. Each turn looks up `auction.legal_actions(player)`; when the only legal action is `PassBid` (partner just doubled or redoubled, or a pass closed the redouble window) the engine auto-applies it without prompting the player or the view. Otherwise `_gather_bid` consults `player.choose_bid(auction)` and — for the human seat — `view.request_bid_action(player, auction)`, both of which now return real `Bid` instances. The chosen bid is applied via `auction.apply(bid)`, which raises `IllegalBidError` rather than silently downgrading an illegal bid to a Pass. After every commit Round fires `view.on_bid_made(player, bid, history)` so the view can log the action and pause for AI bidders. Once `auction.is_terminal()`, the final `Contract` is materialised by `auction.contract()` and `Round._detect_belote_holder()` scans hands for the K + Q of trump (NO_TRUMP contracts skip the scan). The legacy `'Pass'` / `'Double'` / `'Redouble'` / `(value, suit)` wire format only survives inside the AI's expert table and the Rich view's renderer; the `wire_to_bid` / `bid_to_wire` helpers in `contrai_engine.model.player` bridge between the wire and the `Bid` boundary. + The bid loop drives a `contrai_core.Auction` through `itertools.cycle(players_order)`. Each turn looks up `auction.legal_actions(player)`; when the only legal action is `PassBid` (partner just doubled or redoubled, or a pass closed the redouble window) the engine auto-applies it without prompting the player or the view. Otherwise `_gather_bid` consults `player.choose_bid(auction)` and — for the human seat — `view.request_bid_action(player, auction)`, both of which now return real `Bid` instances. The chosen bid is applied via `auction.apply(bid)`, which raises `IllegalBidError` rather than silently downgrading an illegal bid to a Pass. After every commit Round fires `view.on_bid_made(player, bid, history)` so the view can log the action and pause for AI bidders. Once `auction.is_terminal()`, the final `Contract` is materialised by `auction.contract()` and `Round._detect_belote_holder()` scans hands for the K + Q of trump (NO_TRUMP contracts skip the scan). The AI's expert table and the Rich view's renderer both operate on typed `Bid` objects end to end — the legacy wire format (`'Pass'` / `'Double'` / `'Redouble'` / `(value, suit)`) and its `wire_to_bid` / `bid_to_wire` bridge have been retired. ??? note "Single trick zoom — `Round.play_trick`" ```plantuml format="svg" source="seq_trick.puml" ``` - Leader determination → four players play in order → winner + bookkeeping → `view.on_card_played(player, card, trick)` after each landing card → optional `view.on_belote_announced(player, kind, round_)` when the trump K-or-Q is played by the holder → `view.on_trick_complete(trick, winner, round_)` callback (each hook is gated on `hasattr(view, …)`, so non-Rich callers stay unaffected). Two subtleties to know: `Trick()` is built bare (it stores no trump), so `Round.play_trick` passes `self.contract.suit` into `Trick.get_current_winner(trump_suit)` to pick the winner — there is no engine-side duplicate of that rule; and an illegal `choose_card` result is silently replaced with `playable_cards[0]`. Legality (`_get_playable_cards`) now correctly forces over-trump when trump is led and keys the partner exemption on the *current master* of the partial trick — see the legality note at the foot of the diagram. + Leader determination is implicit in `play_state.to_act` (trick 0 is led by `players_order[0]`; every later trick by the previous winner). For each of the four plays: `play_state.to_act` / `play_state.legal_actions(player)` supply the active player and legal cards → the human seat is asked via `view.request_card_action`, an AI seat via `choose_card(play_state.observe(player, bids=...))` (the frozen `PlayObservation` is the only thing the strategy ever sees), any other seat falls back to the first legal card → `play_state = play_state.apply(Play(player, card))` advances the authoritative state (raising `IllegalPlayError` on an out-of-turn, not-held, or obligation-breaking card — no silent correction) → `_sync_hands` re-mirrors every seat's `Hand` from the new state → the same play is mirrored onto `current_trick` via `add_play` → `view.on_card_played(player, card, trick)` → optional `view.on_belote_announced(player, kind, round_)` when the trump K-or-Q is played by the holder. Once all four plays land: `view.on_trick_complete(trick, winner, round_)` callback (each hook is gated on `hasattr(view, …)`, so non-Rich callers stay unaffected). Two subtleties to know: `Trick()` is built bare (it stores no trump), so `Round.play_trick` passes `self.contract.suit` into `Trick.get_current_winner(trump_suit)` to pick the winner — there is no engine-side duplicate of that rule; and `PlayState.legal_actions` (contrai-core) correctly forces over-trump when trump is led and keys the partner exemption on the *current master* of the partial trick — see the legality note at the foot of the diagram. + +### Scoring + +`Round.calculate_round_scores` is a thin wrapper around `scoring.score_round(self)`, the pure `round → RoundScore` transformation: it reads the played-out round and publishes the result's `scores` / `contract_made` / `unannounced_slam` back onto the round. The decision tree below traces every scoring shape — the all-passed zero case, the Slam / Solo Slam at-risk grid, and the numeric path with its unannounced-Slam 250 substitute, share-the-pile (M = 1) and doubled winner-takes-all (M > 1) branches — with the Belote +20 to the K + Q holder layered on top of every one. + +```mermaid format="svg" source="flow_scoring.mmd" +``` ## Open work -- `Round` now has its first dedicated pytest file (`tests/test_model/test_round.py`) covering the `_get_playable_cards` legality oracle, the `_classify_play_violation` reason classifier and `play_trick`'s `IllegalPlayError` raise on an illegal card, the belote tracking helpers, and the auction-driven integration test that the human seat is never prompted when their partner has doubled. The lifecycle path (`manage_bidding` end-to-end / `play_all_tricks` / `calculate_round_scores`) is still un-tested past that single integration scenario — backfill needed. -- `RichView`'s `Panel`/`Table` *rendering* methods (`_panel_*`) are now lightly covered: title/text smoke tests for the round panel, bidding-history, event-log, and round-recap panels, plus the diamond's belote badge. Layouts that aren't asserted on are still validated by `uv run contrai` smoke-running. -- `GameController` is still the lone surviving stub (see the grey box on the class diagram). It references undefined `pygame` and isn't wired to `Game` / `Round` — open question whether to delete it now that the Rich CLI doesn't need a separate controller, or keep it for a future GUI path. -- Sweep `AiPlayer` private helpers for any remaining `contract[…]` indexing residues — the four visible call sites were fixed during CLI work but a defensive pass through the strategy code would not hurt. -- `_check_double_redouble` in `AiPlayer._choose_wire` still uses the legacy-format `last_bid` to detect a prior Double; the inner check `last_bid == 'Double'` shadows the earlier `isinstance(last_bid, tuple)` guard so AI redouble is effectively gated on a `TODO`. Worth revisiting alongside the next AI bidding refactor — the cleanest fix is to read `auction.has_double` / `auction.has_redouble` directly instead of inferring from the wire history. -- AI strategy consumes `Auction.legal_actions(self)` indirectly today (through the wire-format adapter). A future cleanup should make the expert helpers (`_get_last_bid`, `_get_partner_bid`, `_check_double_redouble`) query the Auction directly and let `wire_to_bid` / `bid_to_wire` retire. +- The round test suite mirrors the `model/round/` split (sharing the `players` fixture in `tests/test_model/conftest.py`): `test_round_scoring.py` covers the `scoring.score_round` grid (numeric / unannounced-Slam / doubled / Slam-family) plus a direct `RoundScore`/`score_round` purity check; `test_round.py` keeps the orchestrator concerns — `play_trick`'s `IllegalPlayError` raise on an illegal card (`TestPlayTrickRejectsIllegalCard`), the lazy `PlayState` seeding guard and the validated `play_all_tricks` seeding (`TestPlayStateSeeding`), `_sync_hands` mirroring and card-identity flow-through (`TestSyncHandsMirrorsPlayState`, `TestCardIdentityFlowsFromSeed`), the retained auction (`TestAuctionRetention`), the `PlayObservation` each AI seat is handed (`TestPlayTrickHandsObservation`), the belote tracking helpers, and the auction-driven integration test that the human seat is never prompted when their partner has doubled (`TestManageBiddingAutoPasses`); and `test_round_lifecycle.py` backfills the full end-to-end path — `deal_cards` → `manage_bidding` → `play_all_tricks` → `calculate_round_scores` — on a fully stacked deck with four `AiPlayer` seats and no view, pinning both a scoring invariant and a concrete regression split. +- The screen `Panel`/`Table` *builders* (now pure functions under `view/screens/` and `view/layout.py`, no longer `RichView` methods) are lightly covered: title/text smoke tests for the round panel, bidding-history, event-log, recap tables, and the diamond's belote badge, in the matching `test_view/test_*.py` files. Layouts that aren't asserted on are still validated by `uv run contrai` smoke-running. +- Sweep the rule-based strategy private helpers for any remaining `contract[…]` indexing residues — the four visible call sites were fixed during CLI work but a defensive pass through `rule_based.py` would not hurt. +- The rule-based strategy now reads the `Auction` directly: `choose_bid` / `_choose_open_bid` walk `auction.bids`, resolve the Double/Redouble freeze through `auction.has_double` / `has_redouble`, and the expert helpers (`_get_partner_bid`, `_check_double`) consume and return typed `Bid` objects. The open-bid path evaluates the hand once and resolves it to a single `(contract, suit)` pair via `_find_best_contract` — max-contract search and suit tie-break (belote first, then the Spades → Clubs preference order, picking among *qualifying* suits only) folded into one step. Partner support is anchored to the team's *opening* bid in the suit: the supporting seat announces its complement (+10 per external ace, +10 for the trump complement) exactly once, capped at `opening + complement`, and a seat never supports a suit it opened itself — previously each supporting turn re-added the full complement on top of the standing contract, so partners alternately ratcheted each other up (80 → 110 → 130 → 160 …) until the inflated value armed the opponents' double heuristic. The `RedoubleBid` decision lives on the frozen-auction path (`_choose_under_double`), gated on a `_should_redouble` stub that returns `False` today — a real Surcoinche heuristic is the open follow-up here. diff --git a/packages/contrai-analyzer/pyproject.toml b/packages/contrai-analyzer/pyproject.toml index ac8c302..720b3e4 100644 --- a/packages/contrai-analyzer/pyproject.toml +++ b/packages/contrai-analyzer/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "contrai-analyzer" -version = "0.1.0" +version = "0.2.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.14" diff --git a/packages/contrai-core/pyproject.toml b/packages/contrai-core/pyproject.toml index c2e4edf..5cd706b 100644 --- a/packages/contrai-core/pyproject.toml +++ b/packages/contrai-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "contrai-core" -version = "0.1.0" +version = "0.2.0" description = "Shared domain model for the ContrAI monorepo" readme = "README.md" requires-python = ">=3.14" diff --git a/packages/contrai-core/src/contrai_core/__init__.py b/packages/contrai-core/src/contrai_core/__init__.py index daf00c7..391d14b 100644 --- a/packages/contrai-core/src/contrai_core/__init__.py +++ b/packages/contrai-core/src/contrai_core/__init__.py @@ -14,6 +14,7 @@ from .auction import Auction from .contract import Contract from .trick import Trick +from .play import Play, PlayState, PlayObservation from .exceptions import ( ContraiError, InvalidPlayerCountError, @@ -43,6 +44,9 @@ "Auction", "Contract", "Trick", + "Play", + "PlayState", + "PlayObservation", "ContraiError", "InvalidPlayerCountError", "InvalidCardCountError", diff --git a/packages/contrai-core/src/contrai_core/card.py b/packages/contrai-core/src/contrai_core/card.py index 4102aad..e87e09e 100644 --- a/packages/contrai-core/src/contrai_core/card.py +++ b/packages/contrai-core/src/contrai_core/card.py @@ -1,4 +1,4 @@ -# Card class: represents a playing card +"""Card class: represents a playing card.""" from __future__ import annotations diff --git a/packages/contrai-core/src/contrai_core/contract.py b/packages/contrai-core/src/contrai_core/contract.py index 8abce0c..2b87dce 100644 --- a/packages/contrai-core/src/contrai_core/contract.py +++ b/packages/contrai-core/src/contrai_core/contract.py @@ -1,5 +1,7 @@ -# Contract class for the contrée card game. -# This class represents a contract established during bidding. +"""Contract class for the contrée card game. + +This class represents a contract established during bidding. +""" from __future__ import annotations from typing import Optional, TYPE_CHECKING diff --git a/packages/contrai-core/src/contrai_core/deck.py b/packages/contrai-core/src/contrai_core/deck.py index 0917ee6..fa1828e 100644 --- a/packages/contrai-core/src/contrai_core/deck.py +++ b/packages/contrai-core/src/contrai_core/deck.py @@ -1,4 +1,4 @@ -# Deck class for managing a deck of cards in the contrée game. +"""Deck class for managing a deck of cards in the contrée game.""" import random diff --git a/packages/contrai-core/src/contrai_core/exceptions.py b/packages/contrai-core/src/contrai_core/exceptions.py index e08c93c..ec3439a 100644 --- a/packages/contrai-core/src/contrai_core/exceptions.py +++ b/packages/contrai-core/src/contrai_core/exceptions.py @@ -148,6 +148,14 @@ class PlayRuleViolation(StrEnum): """Held a higher trump than required (trump led, or over an opponent's ruff) but played a lower trump.""" + OUT_OF_TURN = "out_of_turn" + """Attempted to play when it was not this player's turn — including + any play attempted once the play phase is over.""" + + CARD_NOT_IN_HAND = "card_not_in_hand" + """Attempted to play a card the player does not hold — either never + dealt to them or already played earlier in the round.""" + class IllegalPlayError(ContraiError, ValueError): """Raised when a card play violates a follow / trump obligation. diff --git a/packages/contrai-core/src/contrai_core/hand.py b/packages/contrai-core/src/contrai_core/hand.py index f50d502..d457b70 100644 --- a/packages/contrai-core/src/contrai_core/hand.py +++ b/packages/contrai-core/src/contrai_core/hand.py @@ -78,8 +78,8 @@ def clear(self) -> None: def copy(self) -> list[Card]: """Return a shallow ``list`` copy of the cards. - Mirrors ``list.copy`` so legacy engine code that used to treat - ``hand`` as a list (e.g. ``Round._get_playable_cards``) keeps + Mirrors ``list.copy`` so callers that treat ``hand`` as a list + (e.g. legal plays computed by ``PlayState.legal_actions``) keep working. Returns a ``list[Card]`` — not another ``Hand`` — to match the "list of cards" callers expect. """ diff --git a/packages/contrai-core/src/contrai_core/play.py b/packages/contrai-core/src/contrai_core/play.py new file mode 100644 index 0000000..56a3746 --- /dev/null +++ b/packages/contrai-core/src/contrai_core/play.py @@ -0,0 +1,754 @@ +"""PlayState: immutable play-phase state and card-legality oracle. + +The :class:`PlayState` is the sibling of :class:`contrai_core.Auction`. Where +``Auction`` owns the bidding history and its rules, ``PlayState`` owns the +chronological play history of one round's card play and the follow / trump +obligations that decide what may be played. It exposes the same shape a +search or reinforcement-learning game-state interface wants: + +- :meth:`PlayState.legal_actions` so callers filter to only the cards the + rules allow — the enforcement itself lives in :meth:`PlayState.apply`. +- :meth:`PlayState.apply` to produce a new ``PlayState`` with the play + appended; it raises :class:`IllegalPlayError` (carrying a + :class:`PlayRuleViolation`) on an out-of-turn, not-held, or + obligation-breaking play. +- Derived views (:attr:`PlayState.trick_number`, + :attr:`PlayState.current_trick`, :attr:`PlayState.completed_tricks`, + :attr:`PlayState.trick_winners`, :attr:`PlayState.to_act`, + :meth:`PlayState.is_terminal`) recomputed from the flat play history. +- :meth:`PlayState.with_hands` to fork the same public state onto + replacement hands — the determinization primitive search-based AIs need. +- :meth:`PlayState.observe` to project the full state — which holds every + seat's hand — down to a :class:`PlayObservation`, the imperfect- + information view a single player is allowed to see. This is the input + surface handed to AI card-play strategies, never the raw ``PlayState``. + +Play records are plain ``(player, card)`` pairs, so the same tuples flow +through the derived views and the winner rule (:func:`current_winner`) that +the rest of the domain already speaks. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, NamedTuple, Optional + +from .exceptions import IllegalPlayError, PlayRuleViolation +from .trick import current_winner + +if TYPE_CHECKING: + from .bid import Bid + from .card import Card + from .contract import Contract + from .player import BasePlayer + from .team import Team + from .types import Suit + + +class Play(NamedTuple): + """A single card play: the player who played and the card they played. + + A :class:`~typing.NamedTuple` rather than a dataclass so a ``Play`` + unpacks exactly like the ``(player, card)`` pairs the trick and winner + helpers already iterate over — existing tuple-consuming code stays + drop-in compatible. + + Attributes: + player: The player who made the play. + card: The card that was played. + """ + + player: "BasePlayer" + card: "Card" + + +@dataclass(frozen=True, slots=True) +class PlayState: + """Immutable play-phase state for one round of contrée. + + ``PlayState`` is the canonical view of card-play-so-far: it owns the + flat chronological tuple of :class:`Play` records, knows the follow / + trump obligations, and answers what is legal now, whose turn it is, + which tricks have completed and who won them, and whether the play + phase is over. + + The state is stored as **parallel tuples** — ``players`` alongside + ``hands`` — rather than a player-keyed mapping: it keeps the frozen + value honest (nothing to mutate through) and does not lean on + :class:`BasePlayer` hashing, which is identity-based. Everything else + is derived from ``plays`` and recomputed on access; ``slots=True`` + forbids stashing lazy caches, which is the intent — the history is the + single source of truth. + + Like :class:`contrai_core.Auction`, the bare constructor performs **no** + validation, so tests and search forks can inject arbitrary mid-round + states. Use :meth:`start` for a validated seeding from a fresh deal. + + ``PlayState`` is frozen but **not hashable**: :class:`Contract` defines + ``__eq__`` without ``__hash__``, so hashing a ``PlayState`` (which would + hash its contract) raises ``TypeError``. This is an accepted property — + the state is compared by value, never used as a dict key or set member. + + Attributes: + contract: The established :class:`Contract`; its ``suit`` supplies + the trump suit for every legality and winner decision. May be a + ``NO_TRUMP`` contract, in which case all trump obligations + collapse to plain follow-suit. + players: The seating order. ``players[0]`` leads trick 0; each later + trick is led by the previous trick's winner. + hands: Per-seat remaining cards, parallel to ``players``. + plays: The flat chronological play history. Every four plays form + one trick. Defaults to empty — a fresh play phase. + """ + + contract: "Contract" + players: tuple["BasePlayer", ...] + hands: tuple[tuple["Card", ...], ...] + plays: tuple[Play, ...] = field(default=()) + + # ------------------------------------------------------------------ + # Construction helpers + # ------------------------------------------------------------------ + + @classmethod + def start( + cls, + contract: "Contract", + players: tuple["BasePlayer", ...], + hands: tuple[tuple["Card", ...], ...], + ) -> "PlayState": + """Seed a validated play phase from a fresh deal. + + Args: + contract: The established contract supplying the trump suit. + players: The four players in seating order. + hands: Per-seat starting hands, parallel to ``players``. + + Returns: + A fresh :class:`PlayState` with no plays yet. + + Raises: + ValueError: If there are not exactly 4 players, any hand does + not hold exactly 8 cards, or the 32 dealt cards are not all + distinct. + """ + + players = tuple(players) + hands = tuple(tuple(hand) for hand in hands) + + if len(players) != 4: + raise ValueError( + f"A play phase needs exactly 4 players, got {len(players)}." + ) + if len(hands) != 4: + raise ValueError( + f"A play phase needs exactly 4 hands, got {len(hands)}." + ) + for seat, hand in enumerate(hands): + if len(hand) != 8: + raise ValueError( + f"Each starting hand must hold 8 cards; seat {seat} has " + f"{len(hand)}." + ) + all_cards = [card for hand in hands for card in hand] + if len(set(all_cards)) != 32: + raise ValueError( + "The 32 dealt cards must all be distinct; found duplicates " + "across the hands." + ) + + return cls(contract=contract, players=players, hands=hands, plays=()) + + # ------------------------------------------------------------------ + # Derived trick views + # ------------------------------------------------------------------ + + @property + def trick_number(self) -> int: + """The index of the trick in progress. + + Equals the number of completed tricks — so it advances only once the + fourth card of a trick lands, and reads as the completed count while + the state sits between tricks. + """ + + return len(self.plays) // 4 + + @property + def current_trick(self) -> tuple[Play, ...]: + """The plays of the in-progress trick. + + Empty at the start of the round and immediately after any trick + completes; otherwise the one to three plays made since the last + trick boundary. + """ + + return self.plays[self.trick_number * 4:] + + @property + def completed_tricks(self) -> tuple[tuple[Play, ...], ...]: + """The completed tricks, each a tuple of exactly four plays.""" + + return tuple( + self.plays[i:i + 4] for i in range(0, self.trick_number * 4, 4) + ) + + @property + def trick_winners(self) -> tuple["BasePlayer", ...]: + """The winning player of each completed trick, in trick order.""" + + trump_suit = self._trump_suit + return tuple( + current_winner(list(trick), trump_suit) + for trick in self.completed_tricks + ) + + @property + def to_act(self) -> Optional["BasePlayer"]: + """The player whose turn it is, or ``None`` once the phase is over. + + Within a trick the turn rotates in seating order from that trick's + leader. Trick 0 is led by ``players[0]``; every later trick is led + by the previous trick's winner. + """ + + if self.is_terminal(): + return None + leader = self._leader_of_current_trick() + leader_seat = self._seat_of(leader) + offset = len(self.current_trick) + return self.players[(leader_seat + offset) % 4] + + def is_terminal(self) -> bool: + """Return whether the play phase is over (all 32 cards played).""" + + return len(self.plays) == 32 + + # ------------------------------------------------------------------ + # Hand lookup + # ------------------------------------------------------------------ + + def hand_of(self, player: "BasePlayer") -> tuple["Card", ...]: + """Return ``player``'s remaining cards. + + Args: + player: The seated player to look up. + + Returns: + The player's remaining hand as it stands in this state. + + Raises: + ValueError: If ``player`` is not seated in this state. + """ + + return self.hands[self._seat_of(player)] + + # ------------------------------------------------------------------ + # Legality + # ------------------------------------------------------------------ + + def legal_actions(self, player: "BasePlayer") -> tuple["Card", ...]: + """Enumerate every card ``player`` may legally play right now. + + This is player-parametric and enforces **no** turn order — it + answers "what would be legal for this player" exactly as + :meth:`contrai_core.Auction.legal_actions` does for bids. Turn + enforcement lives in :meth:`apply`. + + The obligations, from ``contrée`` follow / trump rules: + + 1. Follow the led suit if able. + 2. When trump is led, over-trump the best trump on the table if you + hold a higher one; otherwise any card of the led (trump) suit. + 3. Void in the led suit with your partner not currently master: you + must trump, over-trumping an opponent's ruff if able. + 4. Partner-master exemption: if your partner is currently winning, + discard freely. + 5. Otherwise discard freely. + + The returned cards are the very objects held in ``player``'s hand + tuple — filtered, never reconstructed — so callers matching cards by + identity keep working. + + Args: + player: The player whose legal cards to enumerate. + + Returns: + The legal cards, a subset of the player's remaining hand. + + Raises: + ValueError: If ``player`` is not seated in this state. + """ + + hand = self.hand_of(player) + if not hand: + return () + + trump_suit = self._trump_suit + trick = self.current_trick + if not trick: + # First to play in this trick — anything goes. + return tuple(hand) + + lead_suit = trick[0].card.suit + lead_suit_cards = tuple(card for card in hand if card.suit == lead_suit) + trump_cards = ( + tuple(card for card in hand if card.suit == trump_suit) + if trump_suit + else () + ) + + # Rule 1/2 — follow suit, over-trumping when the led suit is trump. + if lead_suit_cards: + if trump_suit and lead_suit == trump_suit: + higher = _higher_trumps_than_played( + lead_suit_cards, trick, trump_suit + ) + return higher if higher else lead_suit_cards + return lead_suit_cards + + # Rule 4 — partner-master exemption. Applies only while the partner + # is *currently* winning; a partner since over-trumped no longer + # shields the player from the trump obligation. + current_master = current_winner(list(trick), trump_suit) + if current_master is not None and current_master.team == player.team: + return tuple(hand) + + # No trump suit (or the led suit is trump and we are void in it): + # nothing to over-trump, free discard. + if not trump_suit or lead_suit == trump_suit: + return tuple(hand) + + # Rule 3 — trump obligation. If an opponent has ruffed, beat them. + highest_opponent_trump = _highest_opponent_trump( + trick, player.team, trump_suit + ) + if highest_opponent_trump is not None: + higher_trumps = tuple( + card + for card in trump_cards + if card.get_order(trump_suit) + > highest_opponent_trump.get_order(trump_suit) + ) + if higher_trumps: + return higher_trumps + if trump_cards: + return trump_cards + return tuple(hand) + + # No opponent trump yet but the partner is not master → must trump + # if able. + if trump_cards: + return trump_cards + return tuple(hand) + + # ------------------------------------------------------------------ + # Apply + # ------------------------------------------------------------------ + + def apply(self, play: Play) -> "PlayState": + """Return a new state with ``play`` applied. + + Args: + play: The ``(player, card)`` play to make. The player must be + :attr:`to_act`, must hold the card, and the card must be + among :meth:`legal_actions` for that player. + + Returns: + A new :class:`PlayState` with the play appended and the card + removed from the player's hand (the relative order of the + remaining cards is preserved). The receiver is unchanged. + + Raises: + IllegalPlayError: Carrying the offending :class:`PlayRuleViolation` + — ``OUT_OF_TURN`` (wrong player, or the phase is over), + ``CARD_NOT_IN_HAND`` (the card is not held), or one of + ``MUST_FOLLOW_SUIT`` / ``MUST_TRUMP`` / ``MUST_OVERTRUMP`` — + and a ``context`` naming the acting player's seat. + """ + + player, card = play + # Name the seat in every rejection so diagnostics immediately say + # who misplayed, not just which card. + context = f"{player.position} card play" + + actor = self.to_act + if actor is None or player is not actor: + raise IllegalPlayError( + card, PlayRuleViolation.OUT_OF_TURN, (), context=context + ) + + hand = self.hand_of(player) + legal = self.legal_actions(player) + + if card not in hand: + raise IllegalPlayError( + card, PlayRuleViolation.CARD_NOT_IN_HAND, legal, context=context + ) + if card not in legal: + reason = _classify_violation( + player, card, self._trump_suit, self.current_trick, hand + ) + raise IllegalPlayError(card, reason, legal, context=context) + + seat = self._seat_of(player) + index = hand.index(card) + new_hand = hand[:index] + hand[index + 1:] + new_hands = self.hands[:seat] + (new_hand,) + self.hands[seat + 1:] + return PlayState( + contract=self.contract, + players=self.players, + hands=new_hands, + plays=self.plays + (play,), + ) + + # ------------------------------------------------------------------ + # Determinization fork + # ------------------------------------------------------------------ + + def with_hands( + self, hands: tuple[tuple["Card", ...], ...] + ) -> "PlayState": + """Fork this state onto replacement hands. + + The public history — contract, players, plays — is preserved; only + the per-seat hands change. This is the determinization primitive a + search-based AI uses to sample worlds consistent with what it has + seen. + + Args: + hands: Replacement per-seat hands, parallel to ``players``. + + Returns: + A new :class:`PlayState` with the same history and the given + hands. + + Raises: + ValueError: If a seat's replacement hand does not match its + current remaining card count, or any replacement card has + already been played in this round. + """ + + new_hands = tuple(tuple(hand) for hand in hands) + if len(new_hands) != len(self.players): + raise ValueError( + f"Expected {len(self.players)} hands, got {len(new_hands)}." + ) + for seat, hand in enumerate(new_hands): + if len(hand) != len(self.hands[seat]): + raise ValueError( + f"Seat {seat} must keep {len(self.hands[seat])} cards; " + f"replacement has {len(hand)}." + ) + played = {play.card for play in self.plays} + for hand in new_hands: + for card in hand: + if card in played: + raise ValueError( + f"Replacement hand contains an already-played card: " + f"{card!r}." + ) + return PlayState( + contract=self.contract, + players=self.players, + hands=new_hands, + plays=self.plays, + ) + + # ------------------------------------------------------------------ + # Observation + # ------------------------------------------------------------------ + + def observe( + self, player: "BasePlayer", bids: tuple["Bid", ...] = () + ) -> "PlayObservation": + """Project this state down to what ``player`` is allowed to see. + + This is the state's sanctioned trust boundary: the full + ``PlayState`` holds every seat's hand, but a card-play strategy + must reason from only what its own seat has observed. The + resulting :class:`PlayObservation` carries ``player``'s own hand, + the public trick history, and ``player``'s legal plays right now + — nothing else. + + Args: + player: The observing seat. + bids: The auction history to attach to the observation, + passed through unchanged. ``PlayState`` has no notion of + the auction itself — the caller (the engine's ``Round``) + supplies it. Defaults to an empty tuple. + + Returns: + A :class:`PlayObservation` seeded from this state, from + ``player``'s point of view. + + Raises: + ValueError: If ``player`` is not seated in this state. + """ + + return PlayObservation( + player=player, + hand=self.hand_of(player), + contract=self.contract, + bids=tuple(bids), + completed_tricks=self.completed_tricks, + current_trick=self.current_trick, + legal_cards=self.legal_actions(player), + ) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + @property + def _trump_suit(self) -> Optional["Suit"]: + """The contract's trump suit, or ``None`` when there is no contract.""" + + return self.contract.suit if self.contract else None + + def _seat_of(self, player: "BasePlayer") -> int: + """Return the seat index of ``player`` by identity. + + Args: + player: The player to locate. + + Returns: + The index of ``player`` within ``players``. + + Raises: + ValueError: If ``player`` is not seated in this state. + """ + + for seat, seated in enumerate(self.players): + if seated is player: + return seat + raise ValueError(f"Player {player!r} is not seated in this state.") + + def _leader_of_current_trick(self) -> "BasePlayer": + """Return the player who led the in-progress trick. + + Trick 0 is led by ``players[0]``; every later trick is led by the + winner of the trick before it. + """ + + trick_index = self.trick_number + if trick_index == 0: + return self.players[0] + return self.trick_winners[trick_index - 1] + + +@dataclass(frozen=True, slots=True) +class PlayObservation: + """The imperfect-information view of a play phase for one player. + + Where :class:`PlayState` is the omniscient state — every seat's hand, + all at once — ``PlayObservation`` is what a *single* player is allowed + to know: their own remaining hand, the publicly visible trick history, + the established contract and auction, and their own legal plays right + now. :meth:`PlayState.observe` builds one per player; this is the + input surface AI card-play strategies are handed, so a strategy can + never accidentally read another seat's hand through the object it was + given. + + Trust-boundary caveat: the :class:`Play` records carried in + ``completed_tricks`` and ``current_trick`` hold live ``BasePlayer`` + references (``Play.player``), so a strategy that reaches through + ``play.player.hand`` could technically still see another seat's cards + — this observation seals what is *handed over*, not every object path + reachable from it. Sealing that off (e.g. replacing ``Play.player`` + with an opaque seat identifier for observations) is a noted follow-up, + not something this projection solves. + + Attributes: + player: The observer — the seat this observation is from the + point of view of. + hand: The observer's own remaining cards, and only the observer's; + no other seat's hand is reachable from this field. + contract: The established contract, supplying the trump suit, + value, and bidder. + bids: The auction history, passed through unchanged from whatever + :meth:`PlayState.observe` was given — the play state itself + has no notion of the auction. + completed_tricks: The completed tricks, each a tuple of four + plays, exactly as :attr:`PlayState.completed_tricks` reports — + this history is public. + current_trick: The plays made so far in the in-progress trick — + also public. + legal_cards: The observer's legal plays right now, a subset of + ``hand``. + """ + + player: "BasePlayer" + hand: tuple["Card", ...] + contract: "Contract" + bids: tuple["Bid", ...] + completed_tricks: tuple[tuple[Play, ...], ...] + current_trick: tuple[Play, ...] + legal_cards: tuple["Card", ...] + + @property + def trick_number(self) -> int: + """The index of the trick in progress. + + Consistent with :attr:`PlayState.trick_number`: the count of + completed tricks, so it reads the same whether the state sits + between tricks or the phase has not started yet. + """ + + return len(self.completed_tricks) + + @property + def trump_suit(self) -> Optional["Suit"]: + """The contract's trump suit, or ``None`` when there is no contract. + + Same rule as the state this observation was derived from: for a + ``NO_TRUMP`` contract this is ``Suit.NO_TRUMP`` itself, not + ``None`` — no real card ever carries that suit, so every + trump-related rule (and :func:`current_winner`) already degrades + correctly when handed it. + """ + + return self.contract.suit if self.contract else None + + @property + def led_suit(self) -> Optional["Suit"]: + """The suit led in the in-progress trick, or ``None`` if it is empty.""" + + if not self.current_trick: + return None + return self.current_trick[0].card.suit + + @property + def played_cards(self) -> tuple["Card", ...]: + """Every publicly seen card, flattened in chronological play order. + + Completed tricks first (in trick order), then the in-progress + trick's plays so far. + """ + + return tuple( + play.card for trick in self.completed_tricks for play in trick + ) + tuple(play.card for play in self.current_trick) + + @property + def current_winner(self) -> Optional["BasePlayer"]: + """The player currently winning the in-progress trick. + + ``None`` while the trick is empty. Computed the same way + :attr:`PlayState.trick_winners` computes a completed trick's + winner — via the module-level :func:`current_winner` — so a + partially played trick and a just-completed one agree on who is + master. + """ + + return current_winner(list(self.current_trick), self.trump_suit) + + +def _higher_trumps_than_played( + trumps_in_hand: tuple["Card", ...], + plays: tuple[Play, ...], + trump_suit: "Suit", +) -> tuple["Card", ...]: + """Return the held trumps that beat every trump already in ``plays``. + + Used by the over-trump rule when the led suit is itself trump. Returns + an empty tuple when no trump has been played yet (defensive; the rule + only calls this once trump is on the table) or when no held trump beats + the current best. + + Args: + trumps_in_hand: The candidate trumps from the player's hand. + plays: The plays of the current trick. + trump_suit: The trump suit to rank by. + + Returns: + The subset of ``trumps_in_hand`` outranking the best trump played. + """ + + best_so_far = None + for _, card in plays: + if card.suit != trump_suit: + continue + if best_so_far is None or card.get_order(trump_suit) > best_so_far.get_order( + trump_suit + ): + best_so_far = card + if best_so_far is None: + return () + return tuple( + card + for card in trumps_in_hand + if card.get_order(trump_suit) > best_so_far.get_order(trump_suit) + ) + + +def _highest_opponent_trump( + plays: tuple[Play, ...], player_team: "Team", trump_suit: Optional["Suit"] +) -> Optional["Card"]: + """Return the highest trump an opponent of ``player_team`` has played. + + Args: + plays: The plays of the current trick. + player_team: The team whose opponents' trumps we scan for. + trump_suit: The trump suit to rank by; ``None``/``NO_TRUMP`` yields + no trumps at all. + + Returns: + The highest opposing trump card, or ``None`` if none was played. + """ + + highest = None + for trick_player, card in plays: + if card.suit != trump_suit or trick_player.team == player_team: + continue + if highest is None or card.get_order(trump_suit) > highest.get_order( + trump_suit + ): + highest = card + return highest + + +def _classify_violation( + player: "BasePlayer", + card: "Card", + trump_suit: Optional["Suit"], + trick: tuple[Play, ...], + hand: tuple["Card", ...], +) -> PlayRuleViolation: + """Classify *why* an in-hand card is an illegal play. + + Called only for a genuinely illegal card — held in hand, absent from + :meth:`PlayState.legal_actions`, with the current trick already holding + at least one play. The branch order mirrors ``legal_actions`` so the + reason matches the obligation that filtered the card out. + + Args: + player: The player whose illegal play is being explained. + card: The illegal card attempted. + trump_suit: The trump suit, or ``None``/``NO_TRUMP`` for none. + trick: The plays of the current trick. + hand: The player's remaining hand. + + Returns: + The :class:`PlayRuleViolation` for the broken obligation. + """ + + lead_suit = trick[0].card.suit + lead_suit_cards = [c for c in hand if c.suit == lead_suit] + + # Held the led suit. Trump led + a too-low trump is an over-trump + # failure; anything else off-suit is a follow failure. + if lead_suit_cards: + if trump_suit and lead_suit == trump_suit and card.suit == trump_suit: + return PlayRuleViolation.MUST_OVERTRUMP + return PlayRuleViolation.MUST_FOLLOW_SUIT + + # Void in the led suit (partner-master plays are legal, so never reach + # here). An opponent already ruffed and we under-trumped → over-trump + # failure; otherwise we discarded instead of trumping. + highest_opponent_trump = _highest_opponent_trump( + trick, player.team, trump_suit + ) + if highest_opponent_trump is not None and card.suit == trump_suit: + return PlayRuleViolation.MUST_OVERTRUMP + return PlayRuleViolation.MUST_TRUMP diff --git a/packages/contrai-core/src/contrai_core/team.py b/packages/contrai-core/src/contrai_core/team.py index b4b81d1..d912859 100644 --- a/packages/contrai-core/src/contrai_core/team.py +++ b/packages/contrai-core/src/contrai_core/team.py @@ -1,4 +1,4 @@ -# Team class for the contrée game, representing a team of two players. +"""Team class for the contrée game, representing a team of two players.""" from __future__ import annotations diff --git a/packages/contrai-core/src/contrai_core/trick.py b/packages/contrai-core/src/contrai_core/trick.py index 2d1bae0..d104e2b 100644 --- a/packages/contrai-core/src/contrai_core/trick.py +++ b/packages/contrai-core/src/contrai_core/trick.py @@ -1,5 +1,7 @@ -# Trick class for the contrée card game. -# This class represents a single trick in the game. +"""Trick class for the contrée card game. + +This class represents a single trick in the game. +""" from __future__ import annotations from typing import List, Tuple, Optional, TYPE_CHECKING @@ -104,31 +106,58 @@ def get_current_winner(self, trump_suit: Optional[Suit]) -> Optional[Player]: Player who is currently winning, or None if no card has been played yet. """ - if not self.plays: - return None + return current_winner(self.plays, trump_suit) - lead_suit = self.plays[0][1].suit - best_player = self.plays[0][0] - best_card = self.plays[0][1] - best_is_trump = trump_suit is not None and best_card.suit == trump_suit - for player, card in self.plays[1:]: - card_is_trump = trump_suit is not None and card.suit == trump_suit - - if card_is_trump and not best_is_trump: - # Trump beats non-trump +def current_winner( + plays: List[Tuple[Player, Card]], trump_suit: Optional[Suit] +) -> Optional[Player]: + """ + Determine who currently wins a (possibly partial) trick. + + Works on incomplete plays — useful while a trick is being played for + legality checks (e.g. *partner is currently master*) and view + rendering (live winner highlight). + + Args: + plays: The ordered (player, card) pairs played so far, in play + order. The first entry sets the led suit. + trump_suit: The trump suit to evaluate against, taken from the + round's contract. Pass ``None`` (or ``Suit.NO_TRUMP``) when no + suit is trump — every trump-related branch then reduces to the + follow-suit rule. The argument is required: there is no + construction-time trump to fall back to, so callers must state + trump explicitly rather than risk a silent no-trump evaluation. + + Returns: + Player who is currently winning, or None if no card has been + played yet. + """ + if not plays: + return None + + lead_suit = plays[0][1].suit + best_player = plays[0][0] + best_card = plays[0][1] + best_is_trump = trump_suit is not None and best_card.suit == trump_suit + + for player, card in plays[1:]: + card_is_trump = trump_suit is not None and card.suit == trump_suit + + if card_is_trump and not best_is_trump: + # Trump beats non-trump + best_player = player + best_card = card + best_is_trump = True + elif card_is_trump and best_is_trump: + # Compare trump cards (Jack > 9 > Ace > 10 > King > Queen > 8 > 7) + if card.get_order(trump_suit) > best_card.get_order(trump_suit): + best_player = player + best_card = card + elif not card_is_trump and not best_is_trump and card.suit == lead_suit: + # Compare cards of the same suit (non-trump) + if card.get_order() > best_card.get_order(): best_player = player best_card = card - best_is_trump = True - elif card_is_trump and best_is_trump: - # Compare trump cards (Jack > 9 > Ace > 10 > King > Queen > 8 > 7) - if card.get_order(trump_suit) > best_card.get_order(trump_suit): - best_player = player - best_card = card - elif not card_is_trump and not best_is_trump and card.suit == lead_suit: - # Compare cards of the same suit (non-trump) - if card.get_order() > best_card.get_order(): - best_player = player - best_card = card - - return best_player + + return best_player diff --git a/packages/contrai-core/tests/conftest.py b/packages/contrai-core/tests/conftest.py new file mode 100644 index 0000000..443f90b --- /dev/null +++ b/packages/contrai-core/tests/conftest.py @@ -0,0 +1,36 @@ +"""Shared fixtures for the ``contrai-core`` test suite. + +The play-phase tests (``test_play_legality.py`` and ``test_play_state.py``) +both need four positioned players wired into the two contrée teams, so the +fixture lives here. Plain :class:`BasePlayer` instances are used — the core +package has no game-flow player subclasses, and the play-legality rules only +read a player's ``team`` and identity. +""" + +from __future__ import annotations + +import pytest + +from contrai_core import BasePlayer, Team + + +@pytest.fixture +def players() -> dict[str, BasePlayer]: + """Four positioned players wired into N-S and E-W teams. + + Returns: + A mapping of seat letter (``"N"``/``"E"``/``"S"``/``"W"``) to the + :class:`BasePlayer` seated there. North-South and East-West are the + two teams, matching the physical seating where partners sit opposite. + """ + north = BasePlayer("N", "North") + east = BasePlayer("E", "East") + south = BasePlayer("S", "South") + west = BasePlayer("W", "West") + ns = Team("North-South", [north, south]) + ew = Team("East-West", [east, west]) + for p in (north, south): + p.team = ns + for p in (east, west): + p.team = ew + return {"N": north, "E": east, "S": south, "W": west} diff --git a/packages/contrai-core/tests/test_auction.py b/packages/contrai-core/tests/test_auction.py index 1e3cc6a..1939deb 100644 --- a/packages/contrai-core/tests/test_auction.py +++ b/packages/contrai-core/tests/test_auction.py @@ -58,26 +58,31 @@ @pytest.fixture def north(): + """North-seat player, initially without a team.""" return BasePlayer("North", "North") @pytest.fixture def west(): + """West-seat player, initially without a team.""" return BasePlayer("West", "West") @pytest.fixture def south(): + """South-seat player, initially without a team.""" return BasePlayer("South", "South") @pytest.fixture def east(): + """East-seat player, initially without a team.""" return BasePlayer("East", "East") @pytest.fixture def team_ns(north, south): + """North-South team, wired onto both seats.""" team = Team("North-South", [north, south]) north.team = team south.team = team @@ -86,6 +91,7 @@ def team_ns(north, south): @pytest.fixture def team_ew(east, west): + """East-West team, wired onto both seats.""" team = Team("East-West", [east, west]) east.team = team west.team = team diff --git a/packages/contrai-core/tests/test_bid.py b/packages/contrai-core/tests/test_bid.py index baedd57..6200c9c 100644 --- a/packages/contrai-core/tests/test_bid.py +++ b/packages/contrai-core/tests/test_bid.py @@ -38,26 +38,31 @@ @pytest.fixture def north(): + """North-seat player, initially without a team.""" return BasePlayer("North", "North") @pytest.fixture def south(): + """South-seat player, initially without a team.""" return BasePlayer("South", "South") @pytest.fixture def east(): + """East-seat player, initially without a team.""" return BasePlayer("East", "East") @pytest.fixture def west(): + """West-seat player, initially without a team.""" return BasePlayer("West", "West") @pytest.fixture def team_ns(north, south): + """North-South team, wired onto both seats.""" team = Team("North-South", [north, south]) north.team = team south.team = team diff --git a/packages/contrai-core/tests/test_card.py b/packages/contrai-core/tests/test_card.py index c2429d8..badacc6 100644 --- a/packages/contrai-core/tests/test_card.py +++ b/packages/contrai-core/tests/test_card.py @@ -1,3 +1,9 @@ +"""Tests for the ``Card`` value object. + +Covers trump vs non-trump point values, ordering strength, equality / +hashing, immutability, and the string representations. +""" + import dataclasses import pytest diff --git a/packages/contrai-core/tests/test_contract.py b/packages/contrai-core/tests/test_contract.py index af6fc35..82e185f 100644 --- a/packages/contrai-core/tests/test_contract.py +++ b/packages/contrai-core/tests/test_contract.py @@ -25,16 +25,19 @@ @pytest.fixture def north(): + """North-seat declarer-to-be, initially without a team.""" return BasePlayer("North", "North") @pytest.fixture def south(): + """South-seat partner, initially without a team.""" return BasePlayer("South", "South") @pytest.fixture def team_ns(north, south): + """North-South team, wired onto both seats.""" team = Team("North-South", [north, south]) north.team = team south.team = team @@ -43,16 +46,19 @@ def team_ns(north, south): @pytest.fixture def numeric_contract(north): + """100-in-Spades contract declared by North.""" return Contract(ContractBid(north, 100, Suit.SPADES)) @pytest.fixture def slam_contract(north): + """Slam-in-Hearts contract declared by North.""" return Contract(ContractBid(north, SlamLevel.SLAM, Suit.HEARTS)) @pytest.fixture def solo_slam_contract(north): + """Solo-Slam-in-Hearts contract declared by North.""" return Contract(ContractBid(north, SlamLevel.SOLO_SLAM, Suit.HEARTS)) diff --git a/packages/contrai-core/tests/test_deck.py b/packages/contrai-core/tests/test_deck.py index 2acc0d4..3b36cb0 100644 --- a/packages/contrai-core/tests/test_deck.py +++ b/packages/contrai-core/tests/test_deck.py @@ -1,3 +1,10 @@ +"""Tests for the ``Deck`` class. + +Covers the 32-card initial composition, shuffling and cutting, dealing +(eight unique cards per player, player-count and card-count guards), +card return via ``add_cards``, and the string representations. +""" + import copy from collections import Counter @@ -21,6 +28,8 @@ def deck(): return Deck() class DummyPlayer: + """Minimal player stand-in: just the ``hand`` list ``deal`` fills.""" + def __init__(self): self.hand = [] diff --git a/packages/contrai-core/tests/test_exceptions.py b/packages/contrai-core/tests/test_exceptions.py index 9f56582..b5a56e2 100644 --- a/packages/contrai-core/tests/test_exceptions.py +++ b/packages/contrai-core/tests/test_exceptions.py @@ -43,17 +43,21 @@ def test_domain_error_is_value_error(self, error_cls): class TestPlayRuleViolation: - def test_three_members_exist(self): + def test_members_exist(self): assert {m.name for m in PlayRuleViolation} == { "MUST_FOLLOW_SUIT", "MUST_TRUMP", "MUST_OVERTRUMP", + "OUT_OF_TURN", + "CARD_NOT_IN_HAND", } def test_str_enum_behaviour(self): assert PlayRuleViolation.MUST_TRUMP == "must_trump" assert PlayRuleViolation.MUST_FOLLOW_SUIT == "must_follow_suit" assert PlayRuleViolation.MUST_OVERTRUMP == "must_overtrump" + assert PlayRuleViolation.OUT_OF_TURN == "out_of_turn" + assert PlayRuleViolation.CARD_NOT_IN_HAND == "card_not_in_hand" class TestIllegalPlayError: diff --git a/packages/contrai-core/tests/test_play_legality.py b/packages/contrai-core/tests/test_play_legality.py new file mode 100644 index 0000000..a9b794c --- /dev/null +++ b/packages/contrai-core/tests/test_play_legality.py @@ -0,0 +1,380 @@ +"""Tests for the card-legality oracle exposed by :class:`PlayState`. + +``PlayState.legal_actions`` answers "what may this player play?" and +``PlayState.apply`` surfaces "why was that card illegal?" through the +:class:`IllegalPlayError` it raises. The rules under test come from the +contrée follow / trump obligations: + + 1. Follow suit if possible. + 2. When trump is led, over-trump if you hold a higher trump than the + highest already played; otherwise any trump. + 3. When you cannot follow suit and your partner is *not* currently + master of the trick, you must trump (and over-trump opponents if + able). + 4. Partner-master exemption: if your partner is currently winning the + trick, you may discard freely. + 5. Otherwise discard. + +Each test seeds a :class:`PlayState` directly through the bare constructor +with a hand-picked trick state, then queries ``legal_actions`` (for the +legal set) or drives ``apply`` (for the violation classification). The +shared ``players`` fixture lives in ``conftest.py``. +""" + +from __future__ import annotations + +import pytest + +from contrai_core import ( + BasePlayer, + Card, + Contract, + IllegalPlayError, + PlayRuleViolation, + PlayState, + Rank, + Suit, +) +from contrai_core.bid import ContractBid +from contrai_core.play import Play + + +def _make_state( + players_dict: dict[str, BasePlayer], + hands: dict[str, list[Card]], + contract: Contract | None, + plays: list[tuple[str, Card]], + order: tuple[str, ...] = ("N", "E", "S", "W"), +) -> PlayState: + """Build a :class:`PlayState` wired to the supplied trick state. + + Args: + players_dict: mapping of seat letter to :class:`BasePlayer` (from + the ``players`` fixture). + hands: mapping of seat letter to the remaining cards in that + player's hand. + contract: a :class:`Contract` (provides trump) or ``None``. + plays: ordered ``(seat_letter, Card)`` pairs already played in the + current trick. + order: the seating rotation as seat letters; ``order[0]`` leads + trick 0. Defaults to N/E/S/W. + + Returns: + A :class:`PlayState` whose ``plays`` and per-seat ``hands`` reflect + the arguments — built via the unvalidated bare constructor so + arbitrary mid-trick states can be injected. + """ + seating = tuple(players_dict[s] for s in order) + hand_tuples = tuple(tuple(hands.get(s, [])) for s in order) + play_tuples = tuple(Play(players_dict[s], card) for s, card in plays) + return PlayState( + contract=contract, + players=seating, + hands=hand_tuples, + plays=play_tuples, + ) + + +def _contract(player: BasePlayer, value: int, suit: Suit) -> Contract: + """Build a :class:`Contract` at ``value`` in ``suit`` for ``player``.""" + return Contract(ContractBid(player, value, suit)) + + +# --------------------------------------------------------------------------- +# Over-trump rule when trump is led +# --------------------------------------------------------------------------- + + +class TestOverTrumpWhenTrumpIsLed: + """Must beat the highest trump on the table when trump is led.""" + + def test_higher_trump_available_forces_overtrump(self, players): + """N leads ♠ 7 (trump), E plays ♠ A (current best trump, order 5). + S holds ♠ J (master, order 7) and ♠ 8 (order 1). + S must play the ♠ J — the ♠ 8 is illegal.""" + contract = _contract(players["N"], 100, Suit.SPADES) + hand = [Card(Suit.SPADES, Rank.JACK), Card(Suit.SPADES, Rank.EIGHT)] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.SPADES, Rank.SEVEN)), + ("E", Card(Suit.SPADES, Rank.ACE))], + ) + legal = state.legal_actions(players["S"]) + assert set(legal) == {Card(Suit.SPADES, Rank.JACK)} + + def test_only_lower_trumps_falls_back_to_all_trumps(self, players): + """E plays the ♠ J (the absolute master). S holds only weaker + trumps — every one is legal.""" + contract = _contract(players["N"], 100, Suit.SPADES) + hand = [Card(Suit.SPADES, Rank.EIGHT), Card(Suit.SPADES, Rank.SEVEN)] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.SPADES, Rank.SEVEN)), + ("E", Card(Suit.SPADES, Rank.JACK))], + ) + # Lead is ♠7, but the follow-suit rule already filters to ♠ — the + # over-trump branch then sees no higher trump and returns the full + # follow-suit set. + legal = state.legal_actions(players["S"]) + assert set(legal) == { + Card(Suit.SPADES, Rank.EIGHT), + Card(Suit.SPADES, Rank.SEVEN), + } + + def test_multiple_higher_trumps_returns_all_higher(self, players): + """Both ♠ J and ♠ 9 beat the ♠ A on the table; both are legal.""" + contract = _contract(players["N"], 100, Suit.SPADES) + hand = [ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.NINE), + Card(Suit.SPADES, Rank.EIGHT), + ] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.SPADES, Rank.SEVEN)), + ("E", Card(Suit.SPADES, Rank.ACE))], + ) + legal = state.legal_actions(players["S"]) + assert set(legal) == { + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.NINE), + } + + def test_no_trump_at_all_allows_free_discard(self, players): + """Trump led and S has none → can discard anything (the trump suit + doesn't compete with the led suit for the off-suit hand).""" + contract = _contract(players["N"], 100, Suit.SPADES) + hand = [Card(Suit.HEARTS, Rank.ACE), Card(Suit.DIAMONDS, Rank.KING)] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.SPADES, Rank.SEVEN)), + ("E", Card(Suit.SPADES, Rank.ACE))], + ) + legal = state.legal_actions(players["S"]) + assert set(legal) == set(hand) + + +# --------------------------------------------------------------------------- +# Sanity scenarios for non-trump-led tricks +# --------------------------------------------------------------------------- + + +class TestFollowSuitWhenNonTrumpLed: + def test_must_follow_lead_suit(self, players): + contract = _contract(players["N"], 100, Suit.SPADES) + hand = [ + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.SPADES, Rank.JACK), # trump but lead is hearts + ] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.HEARTS, Rank.KING))], + ) + legal = state.legal_actions(players["S"]) + assert set(legal) == { + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.HEARTS, Rank.ACE), + } + + def test_partner_master_free_discard(self, players): + """N (partner) led ♥A. E followed ♥7. Partner is still master. + S has no hearts, no trump obligation → free discard.""" + contract = _contract(players["N"], 100, Suit.SPADES) + hand = [ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.SEVEN), + ] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.HEARTS, Rank.ACE)), + ("E", Card(Suit.HEARTS, Rank.SEVEN))], + ) + legal = state.legal_actions(players["S"]) + assert set(legal) == set(hand) + + def test_partner_overtrumped_must_trump(self, players): + """N (partner) led ♥A. E (opponent) over-trumped with ♠7. + Partner is no longer master → S must trump (and over-trump the ♠7 + with anything higher, here ♠J).""" + contract = _contract(players["N"], 100, Suit.SPADES) + hand = [ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.SEVEN), + ] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.HEARTS, Rank.ACE)), + ("E", Card(Suit.SPADES, Rank.SEVEN))], + ) + legal = state.legal_actions(players["S"]) + assert set(legal) == {Card(Suit.SPADES, Rank.JACK)} + + def test_partner_led_then_partner_overtaken_must_trump(self, players): + """S has no hearts AND no trump higher than the opponent's + overtrump — must still play a trump (even a lower one). The + non-trump cards are now off-limits.""" + contract = _contract(players["N"], 100, Suit.SPADES) + hand = [ + Card(Suit.SPADES, Rank.SEVEN), # below opponent's ♠ J + Card(Suit.DIAMONDS, Rank.ACE), + ] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.HEARTS, Rank.ACE)), + ("E", Card(Suit.SPADES, Rank.JACK))], + ) + legal = state.legal_actions(players["S"]) + # Must trump even though we can't over-trump. + assert set(legal) == {Card(Suit.SPADES, Rank.SEVEN)} + + def test_three_card_partial_opponent_master_forces_overtrump(self, players): + """Three-card partial trick: N♥A, E♠7, S♠A. S is now master + (S♠A beats E's ♠7 in trump order). It is W's turn. W's partner is + E (not master) — the master is the opponent S → W must over-trump + S♠A. In trump order ♠A is rank 5, only ♠9 (rank 6) and ♠J (rank 7) + beat it. W has ♠9 (legal) and ♠8 (illegal).""" + contract = _contract(players["N"], 100, Suit.SPADES) + hand_w = [ + Card(Suit.SPADES, Rank.NINE), # beats ♠A + Card(Suit.SPADES, Rank.EIGHT), # below ♠A in trump order + Card(Suit.DIAMONDS, Rank.SEVEN), + ] + state = _make_state( + players, + {"W": hand_w}, + contract, + [("N", Card(Suit.HEARTS, Rank.ACE)), + ("E", Card(Suit.SPADES, Rank.SEVEN)), + ("S", Card(Suit.SPADES, Rank.ACE))], + ) + legal = state.legal_actions(players["W"]) + assert set(legal) == {Card(Suit.SPADES, Rank.NINE)} + + def test_opponent_led_and_partner_followed_must_follow_suit(self, players): + """E (opponent) led ♥K; N (partner) played ♥7 in follow. S has + hearts → must follow suit.""" + contract = _contract(players["N"], 100, Suit.SPADES) + hand = [ + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.SPADES, Rank.JACK), + Card(Suit.DIAMONDS, Rank.ACE), + ] + state = _make_state( + players, + {"S": hand}, + contract, + [("E", Card(Suit.HEARTS, Rank.KING)), + ("N", Card(Suit.HEARTS, Rank.SEVEN))], + ) + legal = state.legal_actions(players["S"]) + assert set(legal) == {Card(Suit.HEARTS, Rank.ACE)} + + +# --------------------------------------------------------------------------- +# Illegal-play classification via apply() +# --------------------------------------------------------------------------- +# +# These mirror the legality scenarios above, but drive apply() with an +# *illegal* in-hand card and assert the PlayRuleViolation the raised +# IllegalPlayError carries. The seating rotation and preceding plays are +# arranged so the acting player is genuinely ``to_act`` — apply enforces +# turn order, then classifies the follow / trump obligation that was broken. + + +class TestClassifyPlayViolation: + def test_off_suit_while_holding_lead_is_follow_violation(self, players): + """N leads ♥K, E follows ♥8; S is to act. S holds hearts but tries + the ♠J (trump) → must follow suit.""" + contract = _contract(players["N"], 100, Suit.SPADES) + illegal = Card(Suit.SPADES, Rank.JACK) + hand = [ + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.HEARTS, Rank.ACE), + illegal, # trump but lead is hearts + ] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.HEARTS, Rank.KING)), + ("E", Card(Suit.HEARTS, Rank.EIGHT))], + ) + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(players["S"], illegal)) + assert excinfo.value.reason == PlayRuleViolation.MUST_FOLLOW_SUIT + + def test_too_low_trump_when_trump_led_is_overtrump_violation(self, players): + """N leads ♠7 (trump), E plays ♠A; S is to act. S holds ♠J (master) + and ♠8; playing the ♠8 → must over-trump.""" + contract = _contract(players["N"], 100, Suit.SPADES) + illegal = Card(Suit.SPADES, Rank.EIGHT) + hand = [Card(Suit.SPADES, Rank.JACK), illegal] + state = _make_state( + players, + {"S": hand}, + contract, + [("N", Card(Suit.SPADES, Rank.SEVEN)), + ("E", Card(Suit.SPADES, Rank.ACE))], + ) + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(players["S"], illegal)) + assert excinfo.value.reason == PlayRuleViolation.MUST_OVERTRUMP + + def test_discard_while_void_and_holding_trump_is_trump_violation(self, players): + """E (opponent) leads ♥A — no trump on the table yet; S is to act. + S is void in hearts, holds ♠J (trump) but discards ♦A → must trump.""" + contract = _contract(players["N"], 100, Suit.SPADES) + illegal = Card(Suit.DIAMONDS, Rank.ACE) + hand = [Card(Suit.SPADES, Rank.JACK), illegal] + state = _make_state( + players, + {"S": hand}, + contract, + [("E", Card(Suit.HEARTS, Rank.ACE))], + order=("E", "S", "W", "N"), + ) + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(players["S"], illegal)) + assert excinfo.value.reason == PlayRuleViolation.MUST_TRUMP + + def test_under_trump_over_opponent_ruff_is_overtrump_violation(self, players): + """Three-card partial: N♥A, E♠7, S♠A. W (opponent of master S) is to + act, void in hearts, holds ♠9 (beats ♠A) and ♠8 (below it); playing + the ♠8 → must over-trump.""" + contract = _contract(players["N"], 100, Suit.SPADES) + illegal = Card(Suit.SPADES, Rank.EIGHT) + hand_w = [ + Card(Suit.SPADES, Rank.NINE), + illegal, + Card(Suit.DIAMONDS, Rank.SEVEN), + ] + state = _make_state( + players, + {"W": hand_w}, + contract, + [("N", Card(Suit.HEARTS, Rank.ACE)), + ("E", Card(Suit.SPADES, Rank.SEVEN)), + ("S", Card(Suit.SPADES, Rank.ACE))], + ) + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(players["W"], illegal)) + assert excinfo.value.reason == PlayRuleViolation.MUST_OVERTRUMP diff --git a/packages/contrai-core/tests/test_play_observation.py b/packages/contrai-core/tests/test_play_observation.py new file mode 100644 index 0000000..fb3f64d --- /dev/null +++ b/packages/contrai-core/tests/test_play_observation.py @@ -0,0 +1,249 @@ +"""Tests for :class:`PlayObservation`, the play-phase imperfect-information view. + +``PlayState.observe`` projects the full state — which holds every seat's +hand — down to what a single player is allowed to see. These tests pin: +the field set (own hand only, nothing leaking another seat's cards), that +``legal_cards`` matches :meth:`PlayState.legal_actions` by object identity, +that ``bids`` passes through untouched, the five derived properties, and +that the observation is immutable. The shared ``players`` fixture lives in +``conftest.py``. +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from contrai_core import ( + BasePlayer, + Card, + Contract, + PlayState, + Rank, + Suit, +) +from contrai_core.bid import ContractBid, PassBid +from contrai_core.play import Play, PlayObservation + +# Seat → suit assignment for a deterministic full deal: each player holds +# one whole suit, so cross-seat leakage is trivial to detect and trick +# winners are easy to reason about. +_SEAT_SUITS = { + "N": Suit.SPADES, + "E": Suit.HEARTS, + "S": Suit.DIAMONDS, + "W": Suit.CLUBS, +} +_ORDER = ("N", "E", "S", "W") + +_EXPECTED_FIELDS = { + "player", + "hand", + "contract", + "bids", + "completed_tricks", + "current_trick", + "legal_cards", +} + + +def _deal( + players_dict: dict[str, BasePlayer], trump: Suit = Suit.HEARTS +) -> tuple[ + Contract, tuple[BasePlayer, ...], tuple[tuple[Card, ...], ...], dict[str, list[Card]] +]: + """Build a valid full deal: 4 players, one whole suit each, a contract.""" + ranks = list(Rank) + by_seat = { + seat: [Card(suit, r) for r in ranks] for seat, suit in _SEAT_SUITS.items() + } + seating = tuple(players_dict[s] for s in _ORDER) + hands = tuple(tuple(by_seat[s]) for s in _ORDER) + contract = Contract(ContractBid(players_dict["N"], 100, trump)) + return contract, seating, hands, by_seat + + +def _play_first_trick(players_dict: dict[str, BasePlayer]) -> PlayState: + """Play out trick 0 legally: North leads a spade, East ruffs and wins. + + East is void in spades, so its trump heart wins the trick — a + trump-beats-lead scenario for the ``current_winner`` tests. + """ + contract, seating, hands, by_seat = _deal(players_dict) + state = PlayState.start(contract, seating, hands) + for seat in _ORDER: + state = state.apply(Play(players_dict[seat], by_seat[seat][0])) + return state + + +# --------------------------------------------------------------------------- +# Own-hand-only +# --------------------------------------------------------------------------- + + +class TestOwnHandOnly: + def test_field_set_is_exactly_the_seven_specified(self): + assert set(PlayObservation.__dataclass_fields__) == _EXPECTED_FIELDS + + def test_hand_matches_hand_of_and_leaks_no_other_seat(self, players): + contract, seating, hands, by_seat = _deal(players) + state = PlayState.start(contract, seating, hands) + + for seat in _ORDER: + player = players[seat] + obs = state.observe(player) + assert obs.hand == state.hand_of(player) + + other_cards: set[Card] = set() + for other_seat in _ORDER: + if other_seat != seat: + other_cards.update(by_seat[other_seat]) + assert not (set(obs.hand) & other_cards) + + +# --------------------------------------------------------------------------- +# legal_cards parity +# --------------------------------------------------------------------------- + + +class TestLegalCardsParity: + def test_matches_legal_actions_by_identity_in_forced_follow(self, players): + """North leads a heart; South holds two hearts and a club — South + must follow suit, so only the two hearts are legal.""" + contract = Contract(ContractBid(players["N"], 100, Suit.SPADES)) + hand = [ + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.CLUBS, Rank.KING), + ] + state = PlayState( + contract=contract, + players=tuple(players[s] for s in _ORDER), + hands=tuple(tuple(hand) if s == "S" else () for s in _ORDER), + plays=(Play(players["N"], Card(Suit.HEARTS, Rank.KING)),), + ) + + expected = state.legal_actions(players["S"]) + obs = state.observe(players["S"]) + + assert obs.legal_cards == expected + assert len(obs.legal_cards) == 2 + for offered, wanted in zip(obs.legal_cards, expected): + assert offered is wanted + + +# --------------------------------------------------------------------------- +# bids passthrough +# --------------------------------------------------------------------------- + + +class TestBidsPassthrough: + def test_default_is_empty_tuple(self, players): + contract, seating, hands, _ = _deal(players) + state = PlayState.start(contract, seating, hands) + obs = state.observe(players["N"]) + assert obs.bids == () + + def test_sequence_arrives_as_equal_tuple(self, players): + contract, seating, hands, _ = _deal(players) + state = PlayState.start(contract, seating, hands) + bids = [PassBid(players["E"]), ContractBid(players["N"], 100, Suit.HEARTS)] + + obs = state.observe(players["N"], bids=bids) + + assert obs.bids == tuple(bids) + assert isinstance(obs.bids, tuple) + + +# --------------------------------------------------------------------------- +# Derived properties +# --------------------------------------------------------------------------- + + +class TestDerivedProperties: + def test_trick_number_matches_play_state_semantics(self, players): + state = _play_first_trick(players) + obs = state.observe(state.to_act) + assert obs.trick_number == state.trick_number == 1 + + def test_trick_number_at_start_is_zero(self, players): + contract, seating, hands, _ = _deal(players) + state = PlayState.start(contract, seating, hands) + obs = state.observe(players["N"]) + assert obs.trick_number == 0 + + def test_trump_suit_reflects_contract_suit(self, players): + contract, seating, hands, _ = _deal(players, trump=Suit.CLUBS) + state = PlayState.start(contract, seating, hands) + obs = state.observe(players["N"]) + assert obs.trump_suit == Suit.CLUBS + + def test_trump_suit_no_trump_contract(self, players): + contract, seating, hands, _ = _deal(players, trump=Suit.NO_TRUMP) + state = PlayState.start(contract, seating, hands) + obs = state.observe(players["N"]) + assert obs.trump_suit == Suit.NO_TRUMP + + def test_led_suit_none_when_trick_empty(self, players): + contract, seating, hands, _ = _deal(players) + state = PlayState.start(contract, seating, hands) + obs = state.observe(players["N"]) + assert obs.led_suit is None + + def test_led_suit_is_first_card_suit(self, players): + contract, seating, hands, by_seat = _deal(players) + state = PlayState.start(contract, seating, hands) + state = state.apply(Play(players["N"], by_seat["N"][0])) + obs = state.observe(players["E"]) + assert obs.led_suit == Suit.SPADES + + def test_played_cards_is_chronological_flatten(self, players): + state = _play_first_trick(players) + # Advance one more card into trick 1 so both completed_tricks and + # current_trick are non-empty. + winner = state.to_act + winner_seat = next(s for s in _ORDER if players[s] is winner) + second_card = _deal(players)[3][winner_seat][1] + state = state.apply(Play(winner, second_card)) + + obs = state.observe(winner) + + expected = tuple(play.card for trick in obs.completed_tricks for play in trick) + expected += tuple(play.card for play in obs.current_trick) + assert obs.played_cards == expected + assert len(obs.played_cards) == 5 + + def test_current_winner_none_when_trick_empty(self, players): + contract, seating, hands, _ = _deal(players) + state = PlayState.start(contract, seating, hands) + obs = state.observe(players["N"]) + assert obs.current_winner is None + + def test_current_winner_trump_beats_lead(self, players): + """North leads a spade; East (void, holding only hearts — the + trump suit) ruffs and becomes the current master mid-trick.""" + contract, seating, hands, by_seat = _deal(players) + state = PlayState.start(contract, seating, hands) + state = state.apply(Play(players["N"], by_seat["N"][0])) + state = state.apply(Play(players["E"], by_seat["E"][0])) + + obs = state.observe(players["S"]) + + assert obs.current_winner is players["E"] + + +# --------------------------------------------------------------------------- +# Immutability +# --------------------------------------------------------------------------- + + +class TestImmutability: + def test_assigning_any_field_raises(self, players): + contract, seating, hands, _ = _deal(players) + state = PlayState.start(contract, seating, hands) + obs = state.observe(players["N"]) + + for field_name in _EXPECTED_FIELDS: + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(obs, field_name, None) diff --git a/packages/contrai-core/tests/test_play_state.py b/packages/contrai-core/tests/test_play_state.py new file mode 100644 index 0000000..b48a638 --- /dev/null +++ b/packages/contrai-core/tests/test_play_state.py @@ -0,0 +1,421 @@ +"""Tests for the :class:`PlayState` play-phase state machine. + +Covers the parts of the state machine beyond the legality oracle: seeding +validation, turn rotation (within a trick and the winner-leads-next-trick +rule across a boundary), the derived trick views, immutability and card +identity through :meth:`PlayState.apply`, terminal behaviour, the +out-of-turn / card-not-in-hand rejections, the :meth:`PlayState.with_hands` +determinization fork, and the no-trump degrade to plain follow-suit. +""" + +from __future__ import annotations + +import pytest + +from contrai_core import ( + BasePlayer, + Card, + Contract, + IllegalPlayError, + PlayRuleViolation, + PlayState, + Rank, + Suit, +) +from contrai_core.bid import ContractBid +from contrai_core.play import Play + +# Seat → suit assignment for the deterministic full deal: each player is +# dealt one whole suit, so who wins a trick is easy to reason about. +_SEAT_SUITS = { + "N": Suit.SPADES, + "E": Suit.HEARTS, + "S": Suit.DIAMONDS, + "W": Suit.CLUBS, +} +_ORDER = ("N", "E", "S", "W") + + +def _deal( + players_dict: dict[str, BasePlayer], trump: Suit = Suit.HEARTS +) -> tuple[Contract, tuple[BasePlayer, ...], tuple[tuple[Card, ...], ...], dict[str, list[Card]]]: + """Build a valid full deal: 4 players, one whole suit each, a contract. + + Args: + players_dict: the ``players`` fixture mapping. + trump: the contract's trump suit (``HEARTS`` by default, so the + heart-holder East wins every trick it can ruff). + + Returns: + ``(contract, players, hands, by_seat)`` where ``players`` and + ``hands`` are the parallel tuples :meth:`PlayState.start` expects and + ``by_seat`` keeps the per-seat card lists for building plays. + """ + ranks = list(Rank) + by_seat = { + seat: [Card(suit, r) for r in ranks] for seat, suit in _SEAT_SUITS.items() + } + players = tuple(players_dict[s] for s in _ORDER) + hands = tuple(tuple(by_seat[s]) for s in _ORDER) + contract = Contract(ContractBid(players_dict["N"], 100, trump)) + return contract, players, hands, by_seat + + +def _full_plays(players_dict: dict[str, BasePlayer]) -> tuple[Play, ...]: + """Return the 32 plays of a whole game in N/E/S/W-per-trick order.""" + ranks = list(Rank) + plays: list[Play] = [] + for i in range(8): + for seat in _ORDER: + plays.append( + Play(players_dict[seat], Card(_SEAT_SUITS[seat], ranks[i])) + ) + return tuple(plays) + + +def _play_first_trick(players_dict: dict[str, BasePlayer]) -> PlayState: + """Play out trick 0 legally and return the resulting state. + + North leads a spade; East (void in spades) ruffs with the trump heart + and wins; South and West discard. Afterwards East is to act (the winner + leads the next trick) and every seat has 7 cards left. + """ + contract, players, hands, by_seat = _deal(players_dict) + state = PlayState.start(contract, players, hands) + for seat in _ORDER: + state = state.apply(Play(players_dict[seat], by_seat[seat][0])) + return state + + +# --------------------------------------------------------------------------- +# start() seeding validation +# --------------------------------------------------------------------------- + + +class TestStartValidation: + def test_wrong_player_count_raises(self, players): + contract, seating, hands, _ = _deal(players) + with pytest.raises(ValueError): + PlayState.start(contract, seating[:3], hands[:3]) + + def test_wrong_hand_size_raises(self, players): + contract, seating, hands, _ = _deal(players) + # Drop one card from North's hand → a 7-card hand. + bad_hands = (hands[0][:-1],) + hands[1:] + with pytest.raises(ValueError): + PlayState.start(contract, seating, bad_hands) + + def test_duplicate_cards_raise(self, players): + contract, seating, hands, _ = _deal(players) + # Replace West's first club with a card North already holds — 8 cards + # per seat still, but only 31 distinct cards overall. + dup = Card(Suit.SPADES, Rank.SEVEN) + bad_west = (dup,) + hands[3][1:] + bad_hands = hands[:3] + (bad_west,) + with pytest.raises(ValueError): + PlayState.start(contract, seating, bad_hands) + + def test_valid_seed_round_trips_fields(self, players): + contract, seating, hands, _ = _deal(players) + state = PlayState.start(contract, seating, hands) + assert state.contract is contract + assert state.players == seating + assert state.hands == hands + assert state.plays == () + + +# --------------------------------------------------------------------------- +# to_act rotation +# --------------------------------------------------------------------------- + + +class TestToActRotation: + def test_rotation_within_trick_and_winner_leads_next(self, players): + contract, seating, hands, by_seat = _deal(players) + north, east, south, west = seating + state = PlayState.start(contract, seating, hands) + + assert state.to_act is north + state = state.apply(Play(north, by_seat["N"][0])) + assert state.to_act is east + state = state.apply(Play(east, by_seat["E"][0])) + assert state.to_act is south + state = state.apply(Play(south, by_seat["S"][0])) + assert state.to_act is west + state = state.apply(Play(west, by_seat["W"][0])) + # Trick 0 is complete; East ruffed and won, so East leads trick 1. + assert state.trick_winners == (east,) + assert state.to_act is east + + +# --------------------------------------------------------------------------- +# apply() immutability + card identity +# --------------------------------------------------------------------------- + + +class TestApplyImmutabilityAndIdentity: + def test_original_unchanged_and_card_removed(self, players): + contract, seating, hands, by_seat = _deal(players) + north = seating[0] + state = PlayState.start(contract, seating, hands) + played = by_seat["N"][0] + + new_state = state.apply(Play(north, played)) + + # Original is untouched. + assert state.plays == () + assert len(state.hand_of(north)) == 8 + assert played in state.hand_of(north) + # New state has the play appended and the card removed. + assert new_state.plays == (Play(north, played),) + assert len(new_state.hand_of(north)) == 7 + assert played not in new_state.hand_of(north) + + def test_card_identity_preserved(self, players): + contract, seating, hands, by_seat = _deal(players) + north = seating[0] + state = PlayState.start(contract, seating, hands) + + # legal_actions hands back the exact objects seeded in. + legal = state.legal_actions(north) + for seeded, offered in zip(by_seat["N"], legal): + assert offered is seeded + + played = by_seat["N"][0] + new_state = state.apply(Play(north, played)) + # The remaining cards in the new state are the same objects, in order. + for seeded, remaining in zip(by_seat["N"][1:], new_state.hand_of(north)): + assert remaining is seeded + + +# --------------------------------------------------------------------------- +# Terminal behaviour +# --------------------------------------------------------------------------- + + +class TestTerminal: + def test_terminal_state_blocks_further_play(self, players): + contract, seating, _, _ = _deal(players) + state = PlayState( + contract=contract, + players=seating, + hands=((), (), (), ()), + plays=_full_plays(players), + ) + assert state.is_terminal() + assert state.to_act is None + + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(seating[0], Card(Suit.SPADES, Rank.SEVEN))) + assert excinfo.value.reason == PlayRuleViolation.OUT_OF_TURN + + +# --------------------------------------------------------------------------- +# Boundary derived properties +# --------------------------------------------------------------------------- + + +class TestDerivedPropertyBoundaries: + def _state(self, players, n_plays): + contract, seating, _, _ = _deal(players) + all_plays = _full_plays(players) + # Seats keep whatever cards are not yet played (irrelevant here — the + # derived views only read ``plays``), so empty hands are fine. + return PlayState( + contract=contract, + players=seating, + hands=((), (), (), ()), + plays=all_plays[:n_plays], + ) + + def test_at_start(self, players): + state = self._state(players, 0) + assert state.trick_number == 0 + assert state.current_trick == () + assert state.completed_tricks == () + assert state.trick_winners == () + + def test_mid_trick(self, players): + state = self._state(players, 2) + assert state.trick_number == 0 + assert len(state.current_trick) == 2 + assert state.completed_tricks == () + assert state.trick_winners == () + + def test_exactly_at_trick_boundary(self, players): + state = self._state(players, 4) + assert state.trick_number == 1 + assert state.current_trick == () + assert len(state.completed_tricks) == 1 + assert len(state.completed_tricks[0]) == 4 + assert len(state.trick_winners) == 1 + + def test_terminal_boundary(self, players): + state = self._state(players, 32) + assert state.trick_number == 8 + assert state.current_trick == () + assert len(state.completed_tricks) == 8 + assert all(len(trick) == 4 for trick in state.completed_tricks) + assert len(state.trick_winners) == 8 + + +# --------------------------------------------------------------------------- +# Out-of-turn and card-not-in-hand rejections +# --------------------------------------------------------------------------- + + +class TestRejections: + def test_out_of_turn_mid_trick(self, players): + contract, seating, hands, by_seat = _deal(players) + _, east, _, _ = seating + state = PlayState.start(contract, seating, hands) + # North leads; East acting first is out of turn. + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(east, by_seat["E"][0])) + assert excinfo.value.reason == PlayRuleViolation.OUT_OF_TURN + + def test_card_not_in_hand(self, players): + contract, seating, hands, _ = _deal(players) + north = seating[0] + state = PlayState.start(contract, seating, hands) + # North holds only spades; a heart is not in hand. + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(north, Card(Suit.HEARTS, Rank.ACE))) + assert excinfo.value.reason == PlayRuleViolation.CARD_NOT_IN_HAND + + def test_already_played_card_not_in_hand(self, players): + state = _play_first_trick(players) + east = state.to_act # winner of trick 0 leads trick 1 + # East already played its ♥7 in trick 0 — it is gone from the hand. + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(east, Card(Suit.HEARTS, Rank.SEVEN))) + assert excinfo.value.reason == PlayRuleViolation.CARD_NOT_IN_HAND + + def test_errors_name_the_offending_seat(self, players): + """Every ``apply`` rejection carries a context naming the seat. + + The three raise sites (out of turn, card not held, obligation + violation) each attach ``" card play"`` so diagnostics + immediately say who misplayed, and the message is prefixed with it. + """ + contract, seating, hands, by_seat = _deal(players) + north, east = seating[0], seating[1] + state = PlayState.start(contract, seating, hands) + + # Out of turn: East acts while North is to act. + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(east, by_seat["E"][0])) + assert excinfo.value.context == "East card play" + assert str(excinfo.value).startswith("East card play: ") + + # Card not in hand: North holds only spades, tries a heart. + with pytest.raises(IllegalPlayError) as excinfo: + state.apply(Play(north, Card(Suit.HEARTS, Rank.ACE))) + assert excinfo.value.context == "North card play" + + # Obligation violation: East holds the led suit but tries to ruff. + # Bare-constructor mini-state — hearts are trump (from ``_deal``). + mini = PlayState( + contract, + seating, + ( + (Card(Suit.SPADES, Rank.ACE),), + (Card(Suit.SPADES, Rank.KING), Card(Suit.HEARTS, Rank.ACE)), + (Card(Suit.CLUBS, Rank.ACE),), + (Card(Suit.CLUBS, Rank.KING),), + ), + ) + led = mini.apply(Play(north, mini.hand_of(north)[0])) + with pytest.raises(IllegalPlayError) as excinfo: + led.apply(Play(east, led.hand_of(east)[1])) + assert excinfo.value.reason == PlayRuleViolation.MUST_FOLLOW_SUIT + assert excinfo.value.context == "East card play" + + +# --------------------------------------------------------------------------- +# with_hands determinization fork +# --------------------------------------------------------------------------- + + +class TestWithHands: + def test_valid_fork_preserves_contract_and_plays(self, players): + state = _play_first_trick(players) + # Swap North's and East's remaining hands (7 cards each, none of + # which appears in the play history) — a legal determinization. + old = state.hands + swapped = (old[1], old[0], old[2], old[3]) + forked = state.with_hands(swapped) + + assert forked.contract is state.contract + assert forked.plays == state.plays + assert forked.hands == swapped + # The source state is untouched. + assert state.hands == old + + def test_size_mismatch_raises(self, players): + state = _play_first_trick(players) + old = state.hands + # North gets a 6-card hand where 7 are expected. + bad = (old[0][:-1],) + old[1:] + with pytest.raises(ValueError): + state.with_hands(bad) + + def test_history_duplicate_raises(self, players): + state = _play_first_trick(players) + old = state.hands + # Slot a card that was already played back into North's hand. + played_card = state.plays[0].card + bad_north = (played_card,) + old[0][1:] + bad = (bad_north,) + old[1:] + with pytest.raises(ValueError): + state.with_hands(bad) + + +# --------------------------------------------------------------------------- +# NO_TRUMP contracts degrade to plain follow-suit +# --------------------------------------------------------------------------- + + +class TestNoTrumpDegrade: + def _no_trump_state(self, players, south_hand, plays): + contract = Contract(ContractBid(players["N"], 100, Suit.NO_TRUMP)) + seating = tuple(players[s] for s in _ORDER) + hands = tuple( + tuple(south_hand) if s == "S" else () for s in _ORDER + ) + play_tuples = tuple(Play(players[s], card) for s, card in plays) + return PlayState( + contract=contract, players=seating, hands=hands, plays=play_tuples + ) + + def test_void_player_discards_freely(self, players): + """Lead ♥K; South is void in hearts. Under NO_TRUMP there is no trump + obligation, so every card is a legal discard — even the spades that a + suit contract would force South to ruff with.""" + south_hand = [ + Card(Suit.SPADES, Rank.ACE), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.ACE), + ] + state = self._no_trump_state( + players, south_hand, [("N", Card(Suit.HEARTS, Rank.KING))] + ) + legal = state.legal_actions(players["S"]) + assert set(legal) == set(south_hand) + + def test_follow_suit_still_enforced(self, players): + """Lead ♥K; South holds hearts. NO_TRUMP still requires following the + led suit — only the hearts are legal.""" + south_hand = [ + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.CLUBS, Rank.ACE), + ] + state = self._no_trump_state( + players, south_hand, [("N", Card(Suit.HEARTS, Rank.KING))] + ) + legal = state.legal_actions(players["S"]) + assert set(legal) == { + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.HEARTS, Rank.ACE), + } diff --git a/packages/contrai-core/tests/test_team.py b/packages/contrai-core/tests/test_team.py index 8e99e25..cece5d8 100644 --- a/packages/contrai-core/tests/test_team.py +++ b/packages/contrai-core/tests/test_team.py @@ -1,3 +1,9 @@ +"""Tests for the ``Team`` class. + +Covers construction (incl. the two-player guard), partner lookup, +membership queries, score accumulation, and the string representations. +""" + import pytest from contrai_core import InvalidPlayerCountError, Team diff --git a/packages/contrai-core/tests/test_trick.py b/packages/contrai-core/tests/test_trick.py index 38b2ad4..1126bd6 100644 --- a/packages/contrai-core/tests/test_trick.py +++ b/packages/contrai-core/tests/test_trick.py @@ -9,25 +9,30 @@ import pytest from contrai_core import BasePlayer, Card, Rank, Suit, TrickStateError, Trick +from contrai_core.trick import current_winner @pytest.fixture def north(): + """North-seat player.""" return BasePlayer("North", "North") @pytest.fixture def east(): + """East-seat player.""" return BasePlayer("East", "East") @pytest.fixture def south(): + """South-seat player.""" return BasePlayer("South", "South") @pytest.fixture def west(): + """West-seat player.""" return BasePlayer("West", "West") @@ -251,3 +256,70 @@ def test_higher_trump_takes_over(self, north, east, south): trick.add_play(east, Card(Suit.SPADES, Rank.SEVEN)) # weak trump trick.add_play(south, Card(Suit.SPADES, Rank.JACK)) # master trump assert trick.get_current_winner(Suit.SPADES) is south + + +# --------------------------------------------------------------------------- +# current_winner — parity with Trick.get_current_winner +# +# Trick.get_current_winner delegates to the module-level current_winner +# function so the winner rule can be reused without instantiating a Trick. +# Both must agree on every scenario the method itself is tested against. +# --------------------------------------------------------------------------- + + +class TestCurrentWinnerParity: + def test_empty_plays(self): + trick = Trick() + assert current_winner(trick.plays, Suit.HEARTS) == trick.get_current_winner(Suit.HEARTS) + assert current_winner(trick.plays, Suit.HEARTS) is None + + def test_partial_trick(self, north, east): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + trick.add_play(east, Card(Suit.HEARTS, Rank.SEVEN)) + assert current_winner(trick.plays, Suit.SPADES) == trick.get_current_winner(Suit.SPADES) + assert current_winner(trick.plays, Suit.SPADES) is north + + def test_full_trick(self, north, east, south, west): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) + trick.add_play(east, Card(Suit.HEARTS, Rank.ACE)) + trick.add_play(south, Card(Suit.HEARTS, Rank.KING)) + trick.add_play(west, Card(Suit.HEARTS, Rank.JACK)) + assert current_winner(trick.plays, None) == trick.get_current_winner(None) + assert current_winner(trick.plays, None) is east + + def test_trump_beats_non_trump(self, north, east, south, west): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) + trick.add_play(south, Card(Suit.HEARTS, Rank.KING)) + trick.add_play(west, Card(Suit.HEARTS, Rank.JACK)) + assert current_winner(trick.plays, Suit.CLUBS) == trick.get_current_winner(Suit.CLUBS) + assert current_winner(trick.plays, Suit.CLUBS) is east + + def test_overtrump(self, north, east, south, west): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + trick.add_play(east, Card(Suit.SPADES, Rank.SEVEN)) + trick.add_play(south, Card(Suit.SPADES, Rank.JACK)) + trick.add_play(west, Card(Suit.SPADES, Rank.NINE)) + assert current_winner(trick.plays, Suit.SPADES) == trick.get_current_winner(Suit.SPADES) + assert current_winner(trick.plays, Suit.SPADES) is south + + def test_no_trump_led_suit_wins(self, north, east, south, west): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) + trick.add_play(east, Card(Suit.SPADES, Rank.ACE)) + trick.add_play(south, Card(Suit.DIAMONDS, Rank.ACE)) + trick.add_play(west, Card(Suit.CLUBS, Rank.ACE)) + assert current_winner(trick.plays, None) == trick.get_current_winner(None) + assert current_winner(trick.plays, None) is north + + def test_discard_does_not_win(self, north, east): + """An off-suit, non-trump discard never overtakes the lead card.""" + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) + trick.add_play(east, Card(Suit.DIAMONDS, Rank.ACE)) + assert current_winner(trick.plays, Suit.SPADES) == trick.get_current_winner(Suit.SPADES) + assert current_winner(trick.plays, Suit.SPADES) is north diff --git a/packages/contrai-engine/pyproject.toml b/packages/contrai-engine/pyproject.toml index a6d2964..0585bcd 100644 --- a/packages/contrai-engine/pyproject.toml +++ b/packages/contrai-engine/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "contrai-engine" -version = "0.1.0" +version = "0.2.0" description = "Add your description here" requires-python = ">=3.14" dependencies = [ diff --git a/packages/contrai-engine/src/contrai_engine/cli.py b/packages/contrai-engine/src/contrai_engine/cli.py index 19dad6e..ddffe00 100644 --- a/packages/contrai-engine/src/contrai_engine/cli.py +++ b/packages/contrai-engine/src/contrai_engine/cli.py @@ -16,7 +16,7 @@ # TODO: replace with a seat picker on the landing screen. For now the # layout matches the design handoff exactly: South is the human, the -# other three seats are AI (medium difficulty). +# other three seats are AI (expert — the default strategies). HUMAN_SEAT = "South" SEATS = ("North", "East", "South", "West") @@ -52,17 +52,21 @@ def main() -> None: while True: game = _build_game() view.attach(game, target_score=target) - while not game.check_game_over(target)["game_over"]: + while not game.check_game_over(target).game_over: game.manage_round(view=view) view.on_round_complete(game.current_round, game.scores) # Show a between-round recap (contract, made/failed, # round points, running totals). Always shown, including # before the end-game banner so the player can read the # final round's breakdown before the scoreboard takes - # over — the prompt adapts to the final-round case. - is_final = game.check_game_over(target)["game_over"] + # over — the prompt adapts to the final-round and + # sudden-death (tie at/above target) cases. + status = game.check_game_over(target) view.show_round_recap( - game.current_round, game.scores, is_final=is_final + game.current_round, + game.scores, + is_final=status.game_over, + is_tiebreaker=status.tied_teams is not None, ) choice = view.show_end_game(game.check_game_over(target)) if choice == "q": diff --git a/packages/contrai-engine/src/contrai_engine/model/__init__.py b/packages/contrai-engine/src/contrai_engine/model/__init__.py index de80b2e..f741cd2 100644 --- a/packages/contrai-engine/src/contrai_engine/model/__init__.py +++ b/packages/contrai-engine/src/contrai_engine/model/__init__.py @@ -1,6 +1,9 @@ -# Engine model layer: orchestration on top of contrai_core's shared types. -# Shared types (Card, Deck, Suit, Rank, Bid, Contract, Trick, Team, BasePlayer, -# exceptions) live in contrai_core and must be imported from there directly. +"""Engine model layer: orchestration on top of contrai_core's shared types. + +Shared types (Card, Deck, Suit, Rank, Bid, Contract, Trick, Team, +BasePlayer, exceptions) live in contrai_core and must be imported from +there directly. +""" from .player import Player, HumanPlayer, AiPlayer from .game import Game diff --git a/packages/contrai-engine/src/contrai_engine/model/game.py b/packages/contrai-engine/src/contrai_engine/model/game.py index 6626f06..47055cd 100644 --- a/packages/contrai-engine/src/contrai_engine/model/game.py +++ b/packages/contrai-engine/src/contrai_engine/model/game.py @@ -1,15 +1,44 @@ -# Game class for the contrée card game. -# This class manages the game state, players, teams, deck, and game logic. +"""Game class for the contrée card game. + +This class manages the game state, players, teams, deck, and game logic. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING from contrai_core.deck import Deck from contrai_core.team import Team from .player import Player from contrai_core.trick import Trick -from contrai_core.contract import Contract from .round import Round from contrai_core.exceptions import InvalidPlayerCountError import random +if TYPE_CHECKING: + from contrai_engine.view.rich_view import RichView + + +@dataclass(frozen=True) +class GameOverStatus: + """Structured verdict returned by :meth:`Game.check_game_over`. + + Attributes: + game_over: Whether a team strictly leads at or above the target score. + winner: The winning team name — always set when ``game_over`` is True, + ``None`` otherwise. + tied_teams: The teams sharing the lead at or above the target — the + sudden-death signal: the game continues with tiebreaker rounds + until one team leads. ``None`` when no such tie exists. + final_scores: Snapshot of every team's score at the moment of the check. + """ + + game_over: bool + winner: str | None + tied_teams: list[str] | None + final_scores: dict[str, int] + class Game: """ Represents a full game of contrée. @@ -103,15 +132,16 @@ def start_new_round(self): # Deal cards self.current_round.deal_cards() - def manage_round(self, view=None): + def manage_round(self, view: RichView | None = None) -> None: """ Manages a complete round: bidding, trick-taking, and scoring using Round class. - Args: - view: Optional view for human player interaction + Mutates game state in place: sets ``current_contract`` (``None`` when every + player passed) and folds the round's points into ``scores``. Returns nothing — + callers read the outcome off the ``Game``/``Round`` they passed in. - Returns: - dict: Round results with contract and scores + Args: + view: Optional view for human player interaction. """ # Start new round (deal cards, set dealer, etc.) self.start_new_round() @@ -125,18 +155,14 @@ def manage_round(self, view=None): contract = self.current_round.manage_bidding(view) self.current_contract = contract - # If no contract (all passed), handle failed contract + # If no contract (all passed), handle failed contract (redistributes cards). if not contract: - round_scores = self.current_round.handle_failed_contract() - # Notify the view that the round will be redealt. + self.current_round.handle_failed_contract() + # Notify the view that the round will be redealt. The hook is a + # pure announcement — it carries no round payload. if view is not None and hasattr(view, 'on_all_pass_redeal'): - view.on_all_pass_redeal(self.current_round) - return { - 'contract': None, - 'scores': round_scores, - 'total_scores': self.scores.copy(), - 'message': 'All players passed. Cards redistributed.' - } + view.on_all_pass_redeal() + return # Play all tricks - delegate to Round self.current_round.play_all_tricks(view) @@ -148,43 +174,52 @@ def manage_round(self, view=None): for team_name, points in round_scores.items(): self.scores[team_name] += points - return { - 'contract': contract, - 'scores': round_scores, - 'total_scores': self.scores.copy(), - 'message': 'Round completed' - } - - def check_game_over(self, target_score=1500): + def check_game_over(self, target_score: int = 1500) -> GameOverStatus: """ - Checks if any team has reached the target score to end the game. + Checks if a team strictly leads at the target score, ending the game. + + A tie at or above the target does not end the game: the teams are in + sudden death and keep playing tiebreaker rounds until one of them + leads. The tie is surfaced through ``tied_teams`` so callers (e.g. + the view) can announce the tiebreaker. Args: - target_score: Score required to win the game + target_score: Score required to win the game. Returns: - dict: Game over status and winner information + GameOverStatus: Whether the game is over, the winner (always set + when over), any teams tied at/above the target, and a + snapshot of the final scores. """ max_score = max(self.scores.values()) if max_score >= target_score: - # Find winning team(s) - winning_teams = [team.name for team in self.teams - if self.scores[team.name] == max_score] - - return { - 'game_over': True, - 'winner': winning_teams[0] if len(winning_teams) == 1 else None, - 'tied_teams': winning_teams if len(winning_teams) > 1 else None, - 'final_scores': self.scores.copy() - } - - return { - 'game_over': False, - 'winner': None, - 'tied_teams': None, - 'final_scores': self.scores.copy() - } + # Find the team(s) sharing the top score. + leading_teams = [team.name for team in self.teams + if self.scores[team.name] == max_score] + + if len(leading_teams) == 1: + return GameOverStatus( + game_over=True, + winner=leading_teams[0], + tied_teams=None, + final_scores=self.scores.copy(), + ) + + # Sudden death: level at/above the target — play another round. + return GameOverStatus( + game_over=False, + winner=None, + tied_teams=leading_teams, + final_scores=self.scores.copy(), + ) + + return GameOverStatus( + game_over=False, + winner=None, + tied_teams=None, + final_scores=self.scores.copy(), + ) def next_dealer(self): """ diff --git a/packages/contrai-engine/src/contrai_engine/model/player.py b/packages/contrai-engine/src/contrai_engine/model/player.py deleted file mode 100644 index a4bd129..0000000 --- a/packages/contrai-engine/src/contrai_engine/model/player.py +++ /dev/null @@ -1,969 +0,0 @@ -# Player, HumanPlayer, AiPlayer classes - -from abc import ABC, abstractmethod -from typing import Optional - -from contrai_core.auction import Auction -from contrai_core.bid import ( - Bid, - ContractBid, - DoubleBid, - PassBid, - RedoubleBid, - SlamLevel, -) -from contrai_core.card import Card -from contrai_core.exceptions import InvalidContractError -from contrai_core.player import BasePlayer -from contrai_core.types import CARD_SUITS, Rank, Suit -SUITS = CARD_SUITS - - -# --------------------------------------------------------------------------- -# Wire format bridge -# --------------------------------------------------------------------------- -# The AI strategy in this module still operates internally on the -# legacy "wire" representation of a bid: -# -# 'Pass' | 'Double' | 'Redouble' | (value, suit) -# -# The Auction API works on real :class:`Bid` instances. These two -# module-level helpers bridge between the two formats so the engine -# boundary can pass Bid objects while the AI's expert table keeps -# using its existing tuple-based helpers. Future AI families should -# consume :meth:`Auction.legal_actions` directly and let these go. - - -def wire_to_bid(player: BasePlayer, wire) -> Bid: - """Lift a legacy wire bid choice to a :class:`Bid` instance. - - Args: - player: The player making the bid (attached to the result). - wire: ``'Pass'``, ``'Double'``, ``'Redouble'`` or - ``(value, suit)``. Unrecognised payloads fall back to a - :class:`PassBid` so the caller can still hand the result - to :meth:`Auction.apply`, which raises - :class:`IllegalBidError` if the engine wiring is broken. - - Returns: - The matching :class:`Bid` subclass instance. - """ - - if wire == 'Pass': - return PassBid(player) - if wire == 'Double': - return DoubleBid(player) - if wire == 'Redouble': - return RedoubleBid(player) - if isinstance(wire, tuple) and len(wire) == 2: - value, suit = wire - try: - return ContractBid(player, value, suit) - except InvalidContractError: - # Bad contract value/suit — fall back to Pass. Catch the - # specific domain error rather than the ValueError umbrella - # so an unrelated ValueError from ContractBid still surfaces. - return PassBid(player) - return PassBid(player) - - -def bid_to_wire(bid: Bid): - """Project a :class:`Bid` instance back to the legacy wire format. - - Used by the AI strategy and by the Rich view's bidding-history - renderer, both of which still consume the legacy - ``'Pass'`` / ``'Double'`` / ``'Redouble'`` / ``(value, suit)`` - shape. - """ - - if isinstance(bid, PassBid): - return 'Pass' - if isinstance(bid, DoubleBid): - return 'Double' - if isinstance(bid, RedoubleBid): - return 'Redouble' - if isinstance(bid, ContractBid): - return (bid.value, bid.suit) - return 'Pass' - - -class Player(BasePlayer, ABC): - @property - def is_human(self): - """Returns True if this is a human player.""" - return isinstance(self, HumanPlayer) - - @abstractmethod - def choose_bid(self, auction: Auction) -> Optional[Bid]: - """Choose a :class:`Bid` for the current auction state. - - Args: - auction: The current :class:`Auction`. Use - ``auction.legal_actions(self)`` to enumerate legal - bids, or query ``auction.last_contract_bid`` / - ``auction.partner_bid(self)`` for the strategy - helpers. - - Returns: - A :class:`Bid` instance (validated by the engine via - :meth:`Auction.apply`), or ``None`` to defer to the view - (the contract for :class:`HumanPlayer`). - """ - - pass - - @abstractmethod - def choose_card(self, trick, contract, playable_cards): - pass - -class HumanPlayer(Player): - def choose_bid(self, auction: Auction) -> None: - """Defer to the view's :meth:`request_bid_action`. - - Returns ``None`` by design — Round's bidding loop then - consults the view to actually drive the human's input. - """ - - return None - - def choose_card(self, trick, contract, playable_cards): - # This method should be called by the controller via the view - return None # To be implemented in controller/view - -class AiPlayer(Player): - """ - AI Player with sophisticated bidding strategy based on functional specifications. - - Bidding strategy: - 1. Evaluate hand according to bidding table (80-160 points + Slam / Solo Slam) - 2. If partner hasn't bid or bid lower, make initial bid if it's hand is strong enough - 3. If partner has bid, support with incremental bidding (+10 per external ace, +10 for trump complement) - 4. If multiple bid are possible : choose best suit based on strength, belote - """ - - # Internal numeric values used in BIDDING_TABLE for the all-tricks - # bids. Sourced from the single source of truth on the core - # :class:`SlamLevel` enum so the AI's ladder arithmetic and the - # domain scoring never drift apart. - SLAM_NUMERIC = SlamLevel.SLAM.base_value - SOLO_SLAM_NUMERIC = SlamLevel.SOLO_SLAM.base_value - - # Bidding table. The ``contract`` column is stored numerically and - # matches each contract's *base value* (what the bidder commits to, - # used for auction precedence). The two all-tricks bids live at the - # bottom of the table: - # - ``SLAM_NUMERIC`` (250) — team must win all 8 tricks. - # - ``SOLO_SLAM_NUMERIC`` (500) — bidder personally must win all 8. - # Both rows are gated purely by the trick estimator (``tricks_min=8``) - # in this first pass. The numeric values match - # ``ContractBid.get_numeric_value`` / ``Contract.get_base_points`` in - # ``contrai-core``; they're translated back to the ``SlamLevel`` - # members at the bid-return boundary (see ``_make_initial_bid`` / - # ``_support_partner_bid``). - BIDDING_TABLE = [ - # (contract, trump_expected, trump_min, aces, tricks_min, belote_required) - (80, {'jack_or_nine': True, 'jack_and_nine': False}, 3, 1, 4, False), - (90, {'jack_or_nine': False, 'jack_and_nine': True}, 3, 1, 4, False), - (100, {'jack_or_nine': True, 'jack_and_nine': False}, 3, 2, 5, False), - (110, {'jack_or_nine': False, 'jack_and_nine': True}, 3, 2, 5, False), - (120, {'jack_or_nine': True, 'jack_and_nine': False}, 3, 3, 6, False), - (130, {'jack_or_nine': False, 'jack_and_nine': True}, 3, 3, 6, False), - (140, {'jack_or_nine': True, 'jack_and_nine': False}, 4, 3, 6, True), - (150, {'jack_or_nine': False, 'jack_and_nine': True}, 4, 3, 6, True), - (160, {'jack_or_nine': False, 'jack_and_nine': True, 'ace_required': True}, 5, 3, 7, True), - (SLAM_NUMERIC, {}, 0, 0, 8, False), # Slam — only the trick estimator gates it. - # TODO: tune SoloSlam gate — currently shares Slam's gate. A - # stricter rule (e.g. holds the 8 top trumps in trump-led play, - # or all aces + trump master) would make this conservative. - (SOLO_SLAM_NUMERIC, {}, 0, 0, 8, False), # Solo Slam — same gate as Slam for now. - ] - - # Suit preference order (Spades, Hearts, Diamonds, Clubs) - SUIT_PREFERENCE = SUITS - - def choose_bid(self, auction: Auction) -> Bid: - """Choose a :class:`Bid` for the current auction state. - - The expert bidding table still operates on the legacy wire - format internally; this method adapts the :class:`Auction` - boundary into wire-format inputs, delegates to - :meth:`_choose_wire`, and lifts the result back to a - :class:`Bid` for the engine to apply. The engine is - responsible for validating legality — see - :meth:`Auction.apply`. - - Args: - auction: The current :class:`Auction` state. - - Returns: - A :class:`Bid` instance the engine will validate. - """ - - # A standing Coinche (Double) freezes the auction: no further - # numeric contract bids are legal — only Pass, or a Surcoinche - # (Redouble) from the team that owns the contract (see - # ``Auction._is_contract_value_legal`` / ``contree-domain.md - # §5.3``). The expert bidding table below has no model of this - # freeze and would happily try to raise — including raising its - # *own* partner's contract — producing an illegal ContractBid. - # Resolve the frozen states here before delegating. - if auction.has_redouble: - # Already surcoinched; nothing legal remains but to pass. - return PassBid(self) - if auction.has_double: - return self._choose_under_double(auction) - - current_bids = [(b.player, bid_to_wire(b)) for b in auction.bids] - wire_choice = self._choose_wire(current_bids) - bid = wire_to_bid(self, wire_choice) - - # Safety net honouring the Auction design contract: callers must - # only propose legal bids, there is no silent force-a-Pass in - # ``Auction.apply`` (it raises ``IllegalBidError``). If the - # expert table still produced an illegal bid in some unmodeled - # edge case, fall back to the always-legal Pass rather than - # crash the whole game mid-auction. - if not auction.is_legal(bid): - return PassBid(self) - return bid - - def _choose_under_double(self, auction: Auction) -> Bid: - """Pick a bid when a Coinche (Double) has frozen the auction. - - With a Double standing, the only legal actions are :class:`PassBid` - and — for the side that owns the contract — a :class:`RedoubleBid` - (Surcoinche). Numeric raises are illegal, so the expert bidding - table must not run. We offer a Surcoinche only when we are on the - contracting team and :meth:`_should_redouble` approves; otherwise - we pass. - - Args: - auction: The current (doubled) :class:`Auction` state. - - Returns: - A :class:`RedoubleBid` when surcoinching is both legal and - strategically chosen, else a :class:`PassBid`. - """ - - contract_bid = auction.last_contract_bid - if contract_bid is not None and contract_bid.player.team is self.team: - redouble = RedoubleBid(self) - if auction.is_legal(redouble) and self._should_redouble(): - return redouble - return PassBid(self) - - def _choose_wire(self, current_bids): - """Strategy core: pick a wire-format bid for ``current_bids``. - - Args: - current_bids: List of ``(player, wire_bid)`` tuples from - the current bidding round in chronological order. - - Returns: - ``'Pass'``, ``'Double'``, ``'Redouble'``, or - ``(value, suit)``. - """ - - # Get current game state - last_bid = self._get_last_bid(current_bids) - partner_bid = self._get_partner_bid(current_bids) - - # Check if we can double or redouble - double_action = self._check_double_redouble(current_bids, last_bid) - if double_action: - return double_action - - # Evaluate our hand for each suit - suit_evaluations = self._evaluate_suits() - - # Determine bidding strategy - if partner_bid is None or (isinstance(partner_bid, tuple) and self._can_overbid_partner(partner_bid, suit_evaluations)): - # Make initial bid or overbid partner - return self._make_initial_bid(suit_evaluations, last_bid) - elif isinstance(partner_bid, tuple): - # Support partner's bid - return self._support_partner_bid(partner_bid, last_bid) - - return 'Pass' - - @classmethod - def _bid_value_numeric(cls, value): - """Coerce a contract value (numeric or :class:`SlamLevel`) to int. - - The wire format on ``current_bids`` carries the all-tricks bids - as :class:`SlamLevel` members (see the wire-format bridge in - :mod:`contrai_engine.model.player`), so the AI's ladder - arithmetic must normalise them to their auction-precedence / - base-point numeric: ``SlamLevel.SLAM`` → 250, - ``SlamLevel.SOLO_SLAM`` → 500. - """ - - if isinstance(value, SlamLevel): - return value.base_value - return value - - @staticmethod - def _get_last_bid(current_bids): - """Get the last non-pass bid.""" - - for player, bid in reversed(current_bids): - if bid != 'Pass' and isinstance(bid, tuple): - return bid - return None - - def _get_partner_bid(self, current_bids): - """Get partner's last bid.""" - - for player, bid in reversed(current_bids): - if player.team == self.team and bid != 'Pass': - return bid - return None - - def _check_double_redouble(self, current_bids, last_bid): - """Check if we should double or redouble.""" - - if not last_bid or not isinstance(last_bid, tuple): - return None - - # Find who made the last bid - last_bidder = None - for player, bid in reversed(current_bids): - if bid == last_bid: - last_bidder = player - break - - # Check for double (only if opponent team made the bid) - if last_bidder.team != self.team: - # Simple double strategy: double if we have strong hand in other suits - if self._should_double(last_bid): - return 'Double' - - # Check for redouble (only if the opposite team made the original bid it was a double) - if last_bidder.team != self.team and last_bid == 'Double': - if self._should_redouble(): - return 'Redouble' - - return None - - def _should_double(self, opponent_bid): - """Determine if we should double opponent's bid.""" - - value, suit = opponent_bid - value = self._bid_value_numeric(value) - - strength = self._estimate_tricks(suit) * 20 # Each expected trick worth 20 points - - # Double if we have significant external strength - return strength > 162 - value - - @staticmethod - def _should_redouble(): - """Determine if we should redouble after being doubled.""" - - # TODO: Implement a redouble strategy - return False - - def _evaluate_suits(self): - """Evaluate each suit for potential trump contracts.""" - - evaluations = {} - - for suit in SUITS: - evaluations[suit] = self._evaluate_suit_as_trump(suit) - - return evaluations - - def _evaluate_suit_as_trump(self, suit): - """Evaluate a specific suit as potential trump.""" - - trump_cards = self.hand.cards_of_suit(suit) - - if not trump_cards: - return {'contract': 0, 'strength': 0, 'has_belote': False} - - # Count trump strength - has_jack = any(card.rank == Rank.JACK for card in trump_cards) - has_nine = any(card.rank == Rank.NINE for card in trump_cards) - has_ace = any(card.rank == Rank.ACE for card in trump_cards) - has_king = any(card.rank == Rank.KING for card in trump_cards) - has_queen = any(card.rank == Rank.QUEEN for card in trump_cards) - - trump_count = len(trump_cards) - - # Check for belote (King + Queen of trump) - has_belote = has_king and has_queen - - # Count external aces - external_aces = sum(1 for card in self.hand - if card.suit != suit and card.rank == Rank.ACE) - - # Estimate trick-taking potential - estimated_tricks = self._estimate_tricks(suit) - - # Find the highest contract we can bid - max_contract = 0 - - for contract, trump_req, trump_min, aces_req, tricks_req, belote_req in self.BIDDING_TABLE: - # Check trump requirements - trump_ok = trump_count >= trump_min - - if trump_req.get('jack_and_nine', False): - trump_ok = trump_ok and has_jack and has_nine - elif trump_req.get('jack_or_nine', False): - trump_ok = trump_ok and (has_jack or has_nine) - - if trump_req.get('ace_required', False): - trump_ok = trump_ok and has_ace - - # Check other requirements - if (trump_ok and - external_aces >= aces_req and - estimated_tricks >= tricks_req and - (not belote_req or has_belote)): - max_contract = contract - - return { - 'contract': max_contract, - 'has_belote': has_belote, - 'trump_count': trump_count, - 'external_aces': external_aces, - 'estimated_tricks': estimated_tricks - } - - def _estimate_tricks(self, trump_suit): - """Estimate number of tricks we can take with this trump suit.""" - - tricks = 0 - - # Count our strength inside their trump suit - tricks += self._evaluate_trump_tricks(trump_suit) - - # Count our strength outside their trump suit - for card in self.hand: - if card.suit != trump_suit: - if card.rank == Rank.ACE: - tricks += 1 - if card.rank == Rank.TEN and self.hand.count_suit(card.suit) > 1: - tricks += 1 - if (card.rank == Rank.KING or card.rank == Rank.QUEEN) and self.hand.has_card(card.suit, Rank.ACE)\ - and self.hand.has_card(card.suit, Rank.TEN): - tricks += 1 - - return min(tricks, 8) # Maximum 8 tricks in a round - - @classmethod - def _can_overbid_partner(cls, partner_bid, suit_evaluations): - """Check if we can make a higher bid than our partner.""" - - partner_value, partner_suit = partner_bid - partner_value = cls._bid_value_numeric(partner_value) - - # Find our best contract - best_contract = 0 - for suit_eval in suit_evaluations.values(): - best_contract = max(best_contract, suit_eval['contract']) - - return best_contract > partner_value - - def _make_initial_bid(self, suit_evaluations, last_bid): - """Make an initial bid or overbid.""" - - # Find the best suit to bid - best_suits = [] - max_contract = 0 - - for suit, evaluation in suit_evaluations.items(): - if evaluation['contract'] > max_contract: - max_contract = evaluation['contract'] - best_suits = [suit] - elif evaluation['contract'] == max_contract and max_contract > 0: - best_suits.append(suit) - - if max_contract == 0: - return 'Pass' - - # Check if we can overbid the last bid - if last_bid: - last_value, _ = last_bid - last_value = self._bid_value_numeric(last_value) - if max_contract <= last_value: - return 'Pass' - - # Choose best suit among candidates - chosen_suit = self._choose_best_suit(best_suits, suit_evaluations) - - # Translate the internal numeric sentinels back to the wire format. - bid_value = self._numeric_to_wire(max_contract) - return bid_value, chosen_suit - - def _support_partner_bid(self, partner_bid, last_bid): - """Support partner's bid with incremental bidding.""" - - _, partner_suit = partner_bid - - # Calculate our contribution to partner's suit - contribution = 0 - - # +10 per external ace - for card in self.hand: - if card.suit != partner_suit and card.rank == Rank.ACE: - contribution += 10 - - # +10 if we have trump complement (Jack or 9) - trump_cards = self.hand.cards_of_suit(partner_suit) - has_jack = any(card.rank == Rank.JACK for card in trump_cards) - has_nine = any(card.rank == Rank.NINE for card in trump_cards) - - if has_jack or has_nine: - contribution += 10 - - # Calculate new bid value - last_value, _ = last_bid - last_value = self._bid_value_numeric(last_value) - new_value = last_value + contribution - - # Cap at SoloSlam (the top of the table); don't try to raise past it. - if new_value > self.SOLO_SLAM_NUMERIC or contribution == 0: - return 'Pass' - - bid_value = self._numeric_to_wire(new_value) - return bid_value, partner_suit - - @classmethod - def _numeric_to_wire(cls, value): - """Translate the bidding-table numeric back to the wire value. - - Numeric contracts (80–160) round-trip unchanged. The two - all-tricks numerics become their :class:`SlamLevel` members: - ``SLAM_NUMERIC`` → ``SlamLevel.SLAM``, ``SOLO_SLAM_NUMERIC`` → - ``SlamLevel.SOLO_SLAM`` — so the wire ``(value, suit)`` tuple - carries the same value a :class:`ContractBid` will hold. - """ - - if value == cls.SOLO_SLAM_NUMERIC: - return SlamLevel.SOLO_SLAM - if value == cls.SLAM_NUMERIC: - return SlamLevel.SLAM - return value - - def _choose_best_suit(self, candidate_suits, suit_evaluations): - """Choose the best suit from candidates.""" - - if len(candidate_suits) == 1: - return candidate_suits[0] - - strongest_suits = [] - - # If tied, prefer suit with belote - belote_suits = [suit for suit in candidate_suits - if suit_evaluations[suit]['has_belote']] - - if belote_suits: - if len(belote_suits) == 1: - return belote_suits[0] - - # If still tied, use preference order: Spades, Hearts, Diamonds, Clubs - for preferred_suit in self.SUIT_PREFERENCE: - return preferred_suit - - return strongest_suits[0] # Fallback - - def _evaluate_trump_tricks(self, suit): - """Evaluate potential tricks won with trump suit.""" - - trump_cards = self.hand.cards_of_suit(suit) - expected_won_tricks = 0 - - has_jack = False - has_nine = False - has_ace = False - - if len(trump_cards) > 0: - has_jack = any(card.rank == Rank.JACK for card in trump_cards) - has_nine = any(card.rank == Rank.NINE for card in trump_cards) - has_ace = any(card.rank == Rank.ACE for card in trump_cards) - - if has_jack and has_nine: - expected_won_tricks = 2 # Both Jack and 9 - elif has_jack: - expected_won_tricks = 1 # Only Jack - elif has_nine and len(trump_cards) > 1: - expected_won_tricks = 1 # Only 9 but with support - - if len(trump_cards) >= 3: - expected_won_tricks += len(trump_cards) - 3 + has_ace - - return expected_won_tricks - - def choose_card(self, trick, contract, playable_cards): - """ - Choose a card to play based on simple AI strategy. - - Args: - trick: List of (player, card) tuples, current trick with cards played by players so far - contract: Current contract (value, trump_suit, player) or None - playable_cards: List of cards the AI can legally play - - Returns: - Card: The chosen card to play - """ - - # Lazy-init card tracking. The engine never calls - # initialize_card_tracking() explicitly, so without this guard - # _is_master_card / _opponents_might_have_trump crash on the - # first non-opening trick. - if not hasattr(self, '_fallen_cards'): - self.initialize_card_tracking() - - # Determine strategy based on position in trick - # TODO: adapt the code using the game class to know the trick number - # First to play - use fallback approach since we don't have game reference - if len(trick) == 0: - # Check if this is likely the very first card by checking if we have tracking data - if not hasattr(self, '_fallen_cards') or all(len(cards) == 0 for cards in self._fallen_cards.values()): - return self._play_opening_card(contract, playable_cards) - else: - return self._play_leading_card(contract, playable_cards) - else: - # Not first to play - return self._play_following_card(trick, contract, playable_cards) - - def initialize_card_tracking(self): - """Initialize tracking of fallen cards and trump distribution. Should be called by the game.""" - - self._fallen_cards = { - Suit.SPADES: set(), - Suit.HEARTS: set(), - Suit.DIAMONDS: set(), - Suit.CLUBS: set() - } - self._players_without_trump = set() - - def update_card_tracking(self, card, player, led_suit, trump_suit): - """ - Update tracking based on a card played by any player. - Should be called by the game whenever a card is played. - - Args: - card: The card that was played - player: Player who played the card - led_suit: The suit that was led this trick - trump_suit: The current trump suit - """ - - if not hasattr(self, '_fallen_cards'): - self.initialize_card_tracking() - - # Track fallen cards - self._fallen_cards[card.suit].add(card.rank) - - # Track trump distribution - if player couldn't follow suit and didn't trump - if (led_suit == trump_suit and card.suit != trump_suit) or (led_suit != trump_suit and - (card.suit != led_suit and card.suit != trump_suit)): - # Player couldn't follow suit and didn't trump - no trump - self._players_without_trump.add(player) - - def _play_first_card(self, game, contract, playable_cards): - """Strategy when AI is first to play in the trick.""" - - # Check if this is the very first card of the round - if game.current_trick_number == 0: - return self._play_opening_card(contract, playable_cards) - - # Subsequent tricks when AI leads - return self._play_leading_card(contract, playable_cards) - - def _play_opening_card(self, contract, playable_cards): - """Play the very first card of the round.""" - - trump_suit = contract.suit if contract else None - - if contract and contract.player.team == self.team: - # Our team has the contract - play the strongest trump - trump_cards = [c for c in playable_cards if c.suit == trump_suit] - if trump_cards: - sorted_trumps = sorted(trump_cards, key=lambda c: c.get_order(trump_suit), reverse = True) - if sorted_trumps[0].rank == Rank.NINE and len(sorted_trumps) > 1: - # Avoid playing 9 first - return sorted_trumps[1] - else: - return sorted_trumps[0] - else: - # Opponents have contract - play an ace if we have one - aces = [c for c in playable_cards if c.rank == Rank.ACE] - if aces: - # Play ace from the shortest suit - return min(aces, key=lambda c: self.hand.count_suit(c.suit)) - - # Default: play the lowest value card (excluding trump unless only trumps available) - non_trump_cards = [c for c in playable_cards if c.suit != trump_suit] if trump_suit else playable_cards - - if not non_trump_cards: - # Only trump cards available, use all playable cards - cards_to_consider = playable_cards - else: - # Use non-trump cards - cards_to_consider = non_trump_cards - - # Find cards with minimum points value - min_points = min(c.get_points(trump_suit) for c in cards_to_consider) - lowest_value_cards = [c for c in cards_to_consider if c.get_points(trump_suit) == min_points] - - # If multiple cards with same lowest value, choose randomly - return lowest_value_cards[0] - - def _play_leading_card(self, contract, playable_cards): - """Play when leading subsequent tricks.""" - - trump_suit = contract.suit if contract else None - - # If the team has the contract and opponents might still have trump, play the strongest trump - if contract and contract.player.team == self.team and self._opponents_might_have_trump(trump_suit): - trump_cards = [c for c in playable_cards if c.suit == trump_suit] - if trump_cards: - return max(trump_cards, key=lambda c: c.get_order(trump_suit)) - - # TODO: exclude trump from logic if we know opponents have no trump left - # No trump left with opponents - play ace from the longest suit - aces = [c for c in playable_cards if c.rank == Rank.ACE] - if aces: - return max(aces, key=lambda c: self.hand.count_suit(c.suit)) - - # Play master card from the longest suit - master_cards = [c for c in playable_cards if self._is_master_card(c, trump_suit)] - if master_cards: - return max(master_cards, key=lambda c: self.hand.count_suit(c.suit)) - - # Default: play the lowest value card (excluding trump unless only trumps available) - non_trump_cards = [c for c in playable_cards if c.suit != trump_suit] if trump_suit else playable_cards - - if not non_trump_cards: - # Only trump cards available, use all playable cards - cards_to_consider = playable_cards - else: - # Use non-trump cards - cards_to_consider = non_trump_cards - - # Find cards with minimum points value - min_points = min(c.get_points(trump_suit) for c in cards_to_consider) - lowest_value_cards = [c for c in cards_to_consider if c.get_points(trump_suit) == min_points] - - # If multiple cards with same lowest value, choose randomly - return lowest_value_cards[0] - - def _play_following_card(self, trick, contract, playable_cards): - """Strategy when not first to play.""" - - team_winning = self._is_team_winning_trick(trick) - - if team_winning: - return self._play_when_team_winning(trick, contract, playable_cards) - else: - return self._play_when_team_losing(trick, contract, playable_cards) - - def _play_when_team_winning(self, trick, contract, playable_cards): - """Play when our team is currently winning the trick. - - Partner already secures the trick, so the goal is to add value - (high-points cards) to the pile WITHOUT wasting trumps: - - 1. Follow suit if able — pile the highest-points lead-suit card - on partner's win. - 2. Cannot follow suit → discard a NON-TRUMP card. Don't dump - trumps onto a trick the partner has already locked down. - Prefer non-master cards (preserve cards that can still win - their suit later); within the candidate set, pick the - highest-points to maximize this trick's value. - 3. Hand has nothing but trumps → forced to play one. Use the - lowest trump so we don't waste the Jack or 9. - """ - trump_suit = contract.suit if contract else None - led_suit = trick.get_led_suit() - - # 1. Follow suit if able. - same_suit_cards = [c for c in playable_cards if c.suit == led_suit] - if same_suit_cards: - return max(same_suit_cards, key=lambda c: c.get_points(trump_suit)) - - # 2. Discard a non-trump card. - non_trump_cards = [ - c for c in playable_cards if c.suit != trump_suit - ] - if non_trump_cards: - non_master_non_trump = [ - c for c in non_trump_cards - if not self._is_master_card(c, trump_suit) - ] - candidates = non_master_non_trump or non_trump_cards - return max(candidates, key=lambda c: c.get_points(trump_suit)) - - # 3. Only trumps in hand — dump the lowest one. - if playable_cards: - return min(playable_cards, key=lambda c: c.get_order(trump_suit)) - return playable_cards[0] - - def _play_when_team_losing(self, trick, contract, playable_cards): - """Play when opponents are currently winning the trick.""" - - trump_suit = contract.suit if contract else None - led_suit = trick.get_led_suit() - current_best = self._get_strongest_card_in_trick(trick, trump_suit) - - # Try to follow suit - same_suit_cards = [c for c in playable_cards if c.suit == led_suit] - if same_suit_cards: - # Try to beat the current best card - stronger_cards = [c for c in same_suit_cards - if self._is_stronger_card(c, current_best, trump_suit)] - if stronger_cards: - return max(stronger_cards, key=lambda c: c.get_points(trump_suit)) - else: - # Can't beat - play the lowest card - return min(same_suit_cards, key=lambda c: c.get_points(trump_suit)) - - # Can't follow suit - try to trump - if trump_suit and led_suit != trump_suit: - trump_cards = [c for c in playable_cards if c.suit == trump_suit] - if trump_cards: - # Trump with the lowest trump that can win - winning_trumps = [c for c in trump_cards - if self._can_trump_win(c, trick, trump_suit)] - if winning_trumps: - return min(winning_trumps, key=lambda c: c.get_order(trump_suit)) - - - # Can't follow or trump - discard lowest from the shortest suit (excluding masters) - non_master_cards = [c for c in playable_cards if not self._is_master_card(c, trump_suit)] - if non_master_cards: - return min(non_master_cards, key=lambda c: ( - self.hand.count_suit(c.suit), - c.get_points(trump_suit) - )) - - return playable_cards[0] - - def _opponents_might_have_trump(self, trump_suit): - """Check if opponents might still have trump cards.""" - - # TODO: upgrade to exclude partner if we can track their cards - # Count trump cards we've seen fall - trump_fallen = len(self._fallen_cards.get(trump_suit, set())) - trump_in_hand = self.hand.count_suit(trump_suit) - - # Total trump cards is 8, if we've seen less than 8 - trump_in_hand, opponents might have some - return trump_fallen < (8 - trump_in_hand) - - # TODO: replace trump_suit with a boolean is_trump parameter - def _is_master_card(self, card, trump_suit): - """Check if a card is currently the master (highest remaining) in its suit.""" - - # Get fallen cards in this suit - suit_fallen = self._fallen_cards.get(card.suit, set()) - - # Get all ranks higher than this card's rank - higher_ranks = self._get_higher_ranks(card.rank, card.suit, trump_suit) - - # Check if all higher cards have fallen - return all(rank in suit_fallen for rank in higher_ranks) - - @staticmethod - def _get_higher_ranks(rank, suit, trump_suit): - """Get all ranks higher than the given rank in the suit.""" - - if suit == trump_suit: - # Trump order: 7, 8, Queen, King, 10, Ace, 9, Jack - trump_order = [Rank.SEVEN, Rank.EIGHT, Rank.QUEEN, Rank.KING, Rank.TEN, Rank.ACE, Rank.NINE, Rank.JACK] - else: - # Normal order: 7, 8, 9, Jack, Queen, King, 10, Ace - trump_order = [Rank.SEVEN, Rank.EIGHT, Rank.NINE, Rank.JACK, Rank.QUEEN, Rank.KING, Rank.TEN, Rank.ACE] - - try: - rank_index = trump_order.index(rank) - return trump_order[rank_index + 1:] - except ValueError: - return [] - - def _is_team_winning_trick(self, trick, trump_suit=None): - """Check if our team is currently winning the trick.""" - - # TODO: check with trick number from game - if len(trick) < 1: - return False - - # Find partner's position - partner_position = self._get_partner_position() - - # Check if partner played the strongest card so far - strongest_position = self._get_strongest_card_position(trick, trump_suit) - return strongest_position == partner_position - - def _get_partner_position(self): - """Get partner's position.""" - - position_map = {'North': 'South', 'South': 'North', 'East': 'West', 'West': 'East'} - return position_map.get(self.position) - - def _get_strongest_card_position(self, trick, trump_suit): - """Get the position of the player who played the strongest card.""" - - if not trick: - return None - - strongest_card = self._get_strongest_card_in_trick(trick, trump_suit) - - # Find which player played the strongest card - for player, card in trick.get_plays(): - if card == strongest_card: - return player.position - - return None - - @staticmethod - def _get_strongest_card_in_trick(trick, trump_suit): - """Get the strongest card played so far in the trick.""" - - if not trick: - return None - - led_suit = trick.get_led_suit() - cards = trick.get_cards() - - # Trump cards beat non-trump (unless led suit is trump) - if led_suit != trump_suit: - trump_cards = [c for c in cards if c.suit == trump_suit] - if trump_cards: - return max(trump_cards, key=lambda c: c.get_order(trump_suit)) - - # Among cards of led suit - led_suit_cards = [c for c in cards if c.suit == led_suit] - if led_suit_cards: - return max(led_suit_cards, key=lambda c: c.get_order(trump_suit if led_suit == trump_suit else None)) - - return cards[0] - - @staticmethod - def _is_stronger_card(card, current_best, trump_suit): - """Check if card is stronger than current_best.""" - - if not current_best: - return True - - # If current best is trump and our card isn't (and trump is not led suit) - if current_best.suit == trump_suit and card.suit != trump_suit: - return False - - # If our card is trump and current best isn't - if card.suit == trump_suit and current_best.suit != trump_suit: - return True - - # Both trump or both same suit - if card.suit == current_best.suit: - return card.get_order(trump_suit if card.suit == trump_suit else None) > current_best.get_order(trump_suit if current_best.suit == trump_suit else None) - - return False - - def _can_trump_win(self, trump_card, trick, trump_suit): - """Check if playing this trump card would win the trick.""" - - current_best = self._get_strongest_card_in_trick(trick, trump_suit) - return self._is_stronger_card(trump_card, current_best, trump_suit) diff --git a/packages/contrai-engine/src/contrai_engine/model/player/__init__.py b/packages/contrai-engine/src/contrai_engine/model/player/__init__.py new file mode 100644 index 0000000..1b1ee00 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/player/__init__.py @@ -0,0 +1,25 @@ +"""Player subpackage — public API re-exports. + +Re-exports the subpackage's public names (players, strategies, and the +AI-level registry) so callers import them directly from +``contrai_engine.model.player`` without knowing the internal module +layout. +""" + +from .ai import AiPlayer +from .base import HumanPlayer, Player +from .levels import AI_LEVELS, make_ai_player +from .rule_based import RuleBasedBiddingStrategy, RuleBasedCardPlayStrategy +from .strategy import BiddingStrategy, CardPlayStrategy + +__all__ = [ + "Player", + "HumanPlayer", + "AiPlayer", + "BiddingStrategy", + "CardPlayStrategy", + "RuleBasedBiddingStrategy", + "RuleBasedCardPlayStrategy", + "AI_LEVELS", + "make_ai_player", +] diff --git a/packages/contrai-engine/src/contrai_engine/model/player/ai.py b/packages/contrai-engine/src/contrai_engine/model/player/ai.py new file mode 100644 index 0000000..b0b6b7c --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/player/ai.py @@ -0,0 +1,51 @@ +"""AiPlayer — holds pluggable strategies and delegates to them. + +``AiPlayer`` owns no strategic logic of its own. It holds a bidding +strategy and a card-play strategy behind the :mod:`.strategy` +interfaces, injected at construction, and routes the engine's calls to +them. The defaults are the expert rule-based strategies, so +``AiPlayer("Bot", "South")`` keeps producing today's bot. +""" + +from contrai_core.auction import Auction +from contrai_core.bid import Bid + +from .base import Player +from .rule_based import RuleBasedBiddingStrategy, RuleBasedCardPlayStrategy + + +class AiPlayer(Player): + """AI player delegating bidding and card play to injected strategies. + + Each strategy is supplied as a *factory* (``player -> strategy``, i.e. + the strategy class itself) so the strategy can take a back-reference + to this player while the player is still being built. Defaults + reproduce today's expert bot; pass different factories (or use + :func:`make_ai_player`) to mix and match levels. + """ + + def __init__(self, name, position, + bidding=RuleBasedBiddingStrategy, + cardplay=RuleBasedCardPlayStrategy): + """Build an AI player with injected strategies. + + Args: + name: Display name. + position: Seat position (``'North'`` / ``'South'`` / …). + bidding: A factory ``player -> BiddingStrategy``. Defaults to + :class:`RuleBasedBiddingStrategy` (the ``"expert"`` level). + cardplay: A factory ``player -> CardPlayStrategy``. Defaults to + :class:`RuleBasedCardPlayStrategy` (the ``"expert"`` level). + """ + + super().__init__(name, position) + self.bidding = bidding(self) + self.cardplay = cardplay(self) + + def choose_bid(self, auction: Auction) -> Bid: + """Delegate to the injected bidding strategy.""" + return self.bidding.choose_bid(auction) + + def choose_card(self, observation): + """Delegate to the injected card-play strategy.""" + return self.cardplay.choose_card(observation) diff --git a/packages/contrai-engine/src/contrai_engine/model/player/base.py b/packages/contrai-engine/src/contrai_engine/model/player/base.py new file mode 100644 index 0000000..f317fa8 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/player/base.py @@ -0,0 +1,97 @@ +"""Engine-side player abstractions built on :class:`contrai_core.BasePlayer`. + +Defines :class:`Player`, the abstract seat contract the engine's +:class:`Round` drives (``choose_bid`` / ``choose_card``), and +:class:`HumanPlayer`, whose choices are deferred to the view. +""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Optional + +from contrai_core.auction import Auction +from contrai_core.bid import Bid +from contrai_core.player import BasePlayer + +if TYPE_CHECKING: + from contrai_core.card import Card + from contrai_core.play import PlayObservation + + +class Player(BasePlayer, ABC): + """Abstract engine player: a :class:`BasePlayer` that can make decisions. + + Concrete subclasses implement the two decision hooks the engine + calls during a round — :meth:`choose_bid` during the auction and + :meth:`choose_card` during trick play. Returning ``None`` from + either hook signals that the decision is delegated to the view + (the human-input path). + """ + + @property + def is_human(self): + """Returns True if this is a human player.""" + return isinstance(self, HumanPlayer) + + @abstractmethod + def choose_bid(self, auction: Auction) -> Optional[Bid]: + """Choose a :class:`Bid` for the current auction state. + + Args: + auction: The current :class:`Auction`. Use + ``auction.legal_actions(self)`` to enumerate legal + bids, or query ``auction.last_contract_bid`` / + ``auction.partner_bid(self)`` for the strategy + helpers. + + Returns: + A :class:`Bid` instance (validated by the engine via + :meth:`Auction.apply`), or ``None`` to defer to the view + (the contract for :class:`HumanPlayer`). + """ + + @abstractmethod + def choose_card(self, observation: 'PlayObservation') -> Optional['Card']: + """Choose a :class:`Card` to play into the current trick. + + Args: + observation: The :class:`~contrai_core.PlayObservation` for + this seat — its own hand, the public trick history, the + contract/auction, and ``observation.legal_cards`` (the + legal subset for this turn). The returned card must be one + of the legal cards — Round raises ``IllegalPlayError`` + otherwise. + + Returns: + A :class:`Card` drawn from ``observation.legal_cards``, or + ``None`` to defer to the view (the contract for + :class:`HumanPlayer`). + """ + + +class HumanPlayer(Player): + """A human-controlled seat whose decisions come from the view. + + Both hooks return ``None`` so the engine routes the actual input + through the view's ``request_*_action`` methods. + """ + + def choose_bid(self, auction: Auction) -> None: + """Defer to the view's :meth:`request_bid_action`. + + Returns ``None`` by design — Round's bidding loop then + consults the view to actually drive the human's input. + """ + + return None + + def choose_card(self, observation: 'PlayObservation') -> None: + """Defer to the view's :meth:`request_card_action`. + + Returns ``None`` by design — Round's trick loop drives the + human's card choice through the view instead, exactly as + :meth:`choose_bid` defers bidding to ``request_bid_action``. + The override exists only to satisfy the abstract base; its + return value is never consumed for a human. + """ + + return None diff --git a/packages/contrai-engine/src/contrai_engine/model/player/levels.py b/packages/contrai-engine/src/contrai_engine/model/player/levels.py new file mode 100644 index 0000000..5757e28 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/player/levels.py @@ -0,0 +1,37 @@ +"""AI difficulty levels — registry + factory over the strategy seam. + +A thin convenience layer mapping a human-readable level name to a +(bidding, card-play) strategy pair. This is the seam for a future CLI +``--difficulty`` flag, a web-app difficulty picker, and eval-by-name +match protocols (AI roadmap §6). The raw +``AiPlayer(..., bidding=…, cardplay=…)`` form stays available for +mix-and-match (e.g. rule-based bidding + a learned card-play). +""" + +from .ai import AiPlayer +from .rule_based import RuleBasedBiddingStrategy, RuleBasedCardPlayStrategy + +AI_LEVELS = { + "expert": (RuleBasedBiddingStrategy, RuleBasedCardPlayStrategy), + # future: "mcts": (MctsBiddingStrategy, MctsCardPlayStrategy), ... +} + + +def make_ai_player(name, position, level="expert"): + """Build an :class:`AiPlayer` wired to a named difficulty level. + + Args: + name: Display name. + position: Seat position (``'North'`` / ``'South'`` / …). + level: A key of :data:`AI_LEVELS`. Defaults to ``"expert"``. + + Returns: + An :class:`AiPlayer` whose ``bidding`` / ``cardplay`` are the + strategy pair registered for ``level``. + + Raises: + KeyError: If ``level`` is not a registered level. + """ + + bidding_cls, cardplay_cls = AI_LEVELS[level] + return AiPlayer(name, position, bidding=bidding_cls, cardplay=cardplay_cls) diff --git a/packages/contrai-engine/src/contrai_engine/model/player/rule_based/__init__.py b/packages/contrai-engine/src/contrai_engine/model/player/rule_based/__init__.py new file mode 100644 index 0000000..b44c339 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/player/rule_based/__init__.py @@ -0,0 +1,17 @@ +"""Rule-based AI strategies. + +These are the *first* concrete rung of the AI ladder (AI roadmap §6), +registered as ``AI_LEVELS["expert"]`` (see :mod:`.levels`). They are the +logic that used to live inline on ``AiPlayer``; injecting them behind the +:mod:`.strategy` interfaces means future levels are new classes, not +edits to ``AiPlayer``. + +The two strategy families live in sibling modules — :mod:`.bidding` for +the auction policy, :mod:`.card_play` for trick play — and are +re-exported here so consumers keep importing from ``rule_based``. +""" + +from .bidding import RuleBasedBiddingStrategy +from .card_play import RuleBasedCardPlayStrategy + +__all__ = ["RuleBasedBiddingStrategy", "RuleBasedCardPlayStrategy"] diff --git a/packages/contrai-engine/src/contrai_engine/model/player/rule_based/bidding.py b/packages/contrai-engine/src/contrai_engine/model/player/rule_based/bidding.py new file mode 100644 index 0000000..30971e8 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/player/rule_based/bidding.py @@ -0,0 +1,560 @@ +"""Expert rule-based bidding strategy (see the package docstring).""" + +from contrai_core.auction import Auction +from contrai_core.bid import ( + Bid, + ContractBid, + DoubleBid, + PassBid, + RedoubleBid, + SlamLevel, +) +from contrai_core.types import CARD_SUITS, Rank, Suit + +from ..strategy import BiddingStrategy, PlayerStateMixin + +SUITS = CARD_SUITS + + +class RuleBasedBiddingStrategy(BiddingStrategy, PlayerStateMixin): + """Expert bidding policy driven by a bidding table. + + Bidding strategy: + 1. Evaluate hand according to bidding table (80-160 points + Slam / Solo Slam) + 2. If partner hasn't bid or bid lower, make initial bid if it's hand is strong enough + 3. If partner has bid, support once with our complement (+10 per external ace, + +10 for trump complement), capped at partner's opening bid + that complement — + the team ceiling that stops partners from alternately re-raising each other + 4. If multiple bid are possible : choose best suit based on strength, belote + """ + + # Internal numeric values used in BIDDING_TABLE for the all-tricks + # bids. Sourced from the single source of truth on the core + # :class:`SlamLevel` enum so the AI's ladder arithmetic and the + # domain scoring never drift apart. + SLAM_NUMERIC = SlamLevel.SLAM.base_value + SOLO_SLAM_NUMERIC = SlamLevel.SOLO_SLAM.base_value + + # Bidding table. The ``contract`` column is stored numerically and + # matches each contract's *base value* (what the bidder commits to, + # used for auction precedence). The two all-tricks bids live at the + # bottom of the table: + # - ``SLAM_NUMERIC`` (250) — team must win all 8 tricks. + # - ``SOLO_SLAM_NUMERIC`` (500) — bidder personally must win all 8. + # Both rows are gated purely by the trick estimator (``tricks_min=8``) + # in this first pass. The numeric values match + # ``ContractBid.get_numeric_value`` / ``Contract.get_base_points`` in + # ``contrai-core``; they're translated back to the ``SlamLevel`` + # members at the bid-return boundary (see ``_make_initial_bid`` / + # ``_support_partner_bid``). + BIDDING_TABLE = [ + # (contract, trump_expected, trump_min, aces, tricks_min, belote_required) + (80, {'jack_or_nine': True, 'jack_and_nine': False}, 3, 1, 4, False), + (90, {'jack_or_nine': False, 'jack_and_nine': True}, 3, 1, 4, False), + (100, {'jack_or_nine': True, 'jack_and_nine': False}, 3, 2, 5, False), + (110, {'jack_or_nine': False, 'jack_and_nine': True}, 3, 2, 5, False), + (120, {'jack_or_nine': True, 'jack_and_nine': False}, 3, 3, 6, False), + (130, {'jack_or_nine': False, 'jack_and_nine': True}, 3, 3, 6, False), + (140, {'jack_or_nine': True, 'jack_and_nine': False}, 4, 3, 6, True), + (150, {'jack_or_nine': False, 'jack_and_nine': True}, 4, 3, 6, True), + (160, {'jack_or_nine': False, 'jack_and_nine': True, 'ace_required': True}, 5, 3, 7, True), + (SLAM_NUMERIC, {}, 0, 0, 8, False), # Slam — only the trick estimator gates it. + # TODO: tune SoloSlam gate — currently shares Slam's gate. A + # stricter rule (e.g. holds the 8 top trumps in trump-led play, + # or all aces + trump master) would make this conservative. + (SOLO_SLAM_NUMERIC, {}, 0, 0, 8, False), # Solo Slam — same gate as Slam for now. + ] + + # Suit preference order (Spades, Hearts, Diamonds, Clubs) + SUIT_PREFERENCE = SUITS + + def choose_bid(self, auction: Auction) -> Bid: + """Choose a :class:`Bid` for the current auction state. + + The expert bidding table reads the :class:`Auction` history + directly: :meth:`_choose_open_bid` walks ``auction.bids`` and + returns a concrete :class:`Bid` (``PassBid`` / + ``ContractBid`` / ``DoubleBid``). The engine is responsible for + validating legality — see :meth:`Auction.apply`. + + Args: + auction: The current :class:`Auction` state. + + Returns: + A :class:`Bid` instance the engine will validate. + """ + + # A standing Double freezes the auction: no further + # numeric contract bids are legal — only Pass, or a Redouble + # from the team that owns the contract (see + # ``Auction._is_contract_value_legal``). The expert bidding + # table below has no model of this freeze and would happily try + # to raise — including raising its *own* partner's contract — + # producing an illegal ContractBid. Resolve the frozen states + # here before delegating. + if auction.has_redouble: + # Already redoubled; nothing legal remains but to pass. + return PassBid(self._player) + if auction.has_double: + return self._choose_under_double(auction) + + bid = self._choose_open_bid(auction) + + # Safety net honouring the Auction design contract: callers must + # only propose legal bids, there is no silent force-a-Pass in + # ``Auction.apply`` (it raises ``IllegalBidError``). If the + # expert table still produced an illegal bid in some unmodeled + # edge case, fall back to the always-legal Pass rather than + # crash the whole game mid-auction. + if not auction.is_legal(bid): + return PassBid(self._player) + return bid + + def _choose_under_double(self, auction: Auction) -> Bid: + """Pick a bid when a Double has frozen the auction. + + With a Double standing, the only legal actions are :class:`PassBid` + and — for the side that owns the contract — a :class:`RedoubleBid` + (Redouble). Numeric raises are illegal, so the expert bidding + table must not run. We offer a Redouble only when we are on the + contracting team and :meth:`_should_redouble` approves; otherwise + we pass. + + Args: + auction: The current (doubled) :class:`Auction` state. + + Returns: + A :class:`RedoubleBid` when surcoinching is both legal and + strategically chosen, else a :class:`PassBid`. + """ + + contract_bid = auction.last_contract_bid + if contract_bid is not None and contract_bid.player.team is self.team: + redouble = RedoubleBid(self._player) + if auction.is_legal(redouble) and self._should_redouble(): + return redouble + return PassBid(self._player) + + def _choose_open_bid(self, auction: Auction) -> Bid: + """Strategy core: pick a :class:`Bid` for an open (unfrozen) auction. + + Reads the chronological :class:`Bid` history off ``auction`` + directly — no wire-format projection. Called by + :meth:`choose_bid` only once the Double/Redouble freeze states + have been ruled out, so the full expert table (raise, support, + Coinche) is in play here. + + Args: + auction: The current :class:`Auction` state. + + Returns: + A :class:`PassBid`, :class:`ContractBid`, or + :class:`DoubleBid`. Legality is re-checked by the caller. + """ + + bids = auction.bids + + # Get current game state + last_bid = auction.last_contract_bid + partner_bid = self._get_partner_bid(bids) + + # Check if we can Double the opponents' standing contract + double_action = self._check_double(last_bid) + if double_action is not None: + return double_action + + # Evaluate our hand once and resolve it to the single best + # (contract, suit) pair — ties already broken (belote first, + # then the fixed preference order). + best_contract, best_suit = self._find_best_contract(self._evaluate_suits()) + + # Determine bidding strategy + if partner_bid is None or ( + isinstance(partner_bid, ContractBid) + and best_contract > partner_bid.get_numeric_value() + ): + # Make initial bid or overbid partner + return self._make_initial_bid(best_contract, best_suit, last_bid) + if isinstance(partner_bid, ContractBid): + # Support partner's bid + return self._support_partner_bid(partner_bid, last_bid, bids) + + return PassBid(self._player) + + def _get_partner_bid(self, bids): + """Return our side's most recent non-pass :class:`Bid`, or ``None``. + + Matches any bid made by a player on our team (including our own + earlier bid this round); only :class:`PassBid` is skipped. + """ + + for bid in reversed(bids): + if not isinstance(bid, PassBid) and bid.player.team is self.team: + return bid + return None + + def _check_double(self, last_bid): + """Return a :class:`DoubleBid` if we should Double, else ``None``. + + Only the Double decision lives here — the Redouble + is a defence of our *own* contract and is handled on + the frozen-auction path in :meth:`_choose_under_double`. + + Args: + last_bid: The standing :class:`ContractBid`, or ``None``. + """ + + if last_bid is None: + return None + + # Double only if the standing contract belongs to the opponents + # and we hold enough external strength to threaten it. + if last_bid.player.team is not self.team and self._should_double(last_bid): + return DoubleBid(self._player) + + return None + + def _should_double(self, opponent_bid): + """Determine if we should double opponent's bid. + + Args: + opponent_bid: The opposing :class:`ContractBid` in play. + """ + + value = opponent_bid.get_numeric_value() + suit = opponent_bid.suit + + strength = self._estimate_tricks(suit) * 20 # Each expected trick worth 20 points + + # Double if we have significant external strength + return strength > 162 - value + + @staticmethod + def _should_redouble(): + """Determine if we should redouble after being doubled.""" + + # TODO: Implement a redouble strategy + return False + + def _evaluate_suits(self): + """Evaluate each suit for potential trump contracts.""" + + evaluations = {} + + for suit in SUITS: + evaluations[suit] = self._evaluate_suit_as_trump(suit) + + return evaluations + + def _evaluate_suit_as_trump(self, suit): + """Evaluate a specific suit as potential trump.""" + + trump_cards = self.hand.cards_of_suit(suit) + + if not trump_cards: + return {'contract': 0, 'strength': 0, 'has_belote': False} + + # Held trump ranks — queried against the table's honour + # requirements (Jack / 9 / Ace) by the table scan. + trump_ranks = {card.rank for card in trump_cards} + + # Check for belote (King + Queen of trump) + has_belote = Rank.KING in trump_ranks and Rank.QUEEN in trump_ranks + + # Count external aces + external_aces = sum(1 for card in self.hand + if card.suit != suit and card.rank == Rank.ACE) + + # Estimate trick-taking potential + estimated_tricks = self._estimate_tricks(suit) + + # Find the highest contract we can bid + max_contract = self._max_table_contract( + trump_ranks, len(trump_cards), external_aces, estimated_tricks, has_belote + ) + + return { + 'contract': max_contract, + 'has_belote': has_belote, + 'trump_count': len(trump_cards), + 'external_aces': external_aces, + 'estimated_tricks': estimated_tricks + } + + def _max_table_contract( + self, trump_ranks, trump_count, external_aces, estimated_tricks, has_belote + ): + """Return the highest ``BIDDING_TABLE`` contract the hand satisfies. + + Scans the table top to bottom and keeps the last row whose gates + all pass, so the returned contract is the strongest one reachable. + + Args: + trump_ranks: Ranks held in the candidate trump suit. + trump_count: Number of cards held in that suit. + external_aces: Aces held outside that suit. + estimated_tricks: Trick estimate from :meth:`_estimate_tricks`. + has_belote: Whether the suit carries King + Queen. + + Returns: + The numeric contract value, or 0 when no row matches. + """ + + max_contract = 0 + + for contract, trump_req, trump_min, aces_req, tricks_req, belote_req in self.BIDDING_TABLE: + # Check trump requirements + trump_ok = trump_count >= trump_min + + if trump_req.get('jack_and_nine', False): + trump_ok = trump_ok and Rank.JACK in trump_ranks and Rank.NINE in trump_ranks + elif trump_req.get('jack_or_nine', False): + trump_ok = trump_ok and (Rank.JACK in trump_ranks or Rank.NINE in trump_ranks) + + if trump_req.get('ace_required', False): + trump_ok = trump_ok and Rank.ACE in trump_ranks + + # Check other requirements + if (trump_ok and + external_aces >= aces_req and + estimated_tricks >= tricks_req and + (not belote_req or has_belote)): + max_contract = contract + + return max_contract + + def _estimate_tricks(self, trump_suit): + """Estimate number of tricks we can take with this trump suit.""" + + tricks = 0 + + # Count our strength inside their trump suit + tricks += self._evaluate_trump_tricks(trump_suit) + + # Count our strength outside their trump suit + for card in self.hand: + if card.suit != trump_suit: + if card.rank == Rank.ACE: + tricks += 1 + if card.rank == Rank.TEN and self.hand.count_suit(card.suit) > 1: + tricks += 1 + if ( + card.rank in (Rank.KING, Rank.QUEEN) + and self.hand.has_card(card.suit, Rank.ACE) + and self.hand.has_card(card.suit, Rank.TEN) + ): + tricks += 1 + + return min(tricks, 8) # Maximum 8 tricks in a round + + def _evaluate_trump_tricks(self, suit): + """Evaluate potential tricks won with trump suit.""" + + trump_cards = self.hand.cards_of_suit(suit) + expected_won_tricks = 0 + + has_jack = False + has_nine = False + has_ace = False + + if len(trump_cards) > 0: + has_jack = any(card.rank == Rank.JACK for card in trump_cards) + has_nine = any(card.rank == Rank.NINE for card in trump_cards) + has_ace = any(card.rank == Rank.ACE for card in trump_cards) + + if has_jack and has_nine: + expected_won_tricks = 2 # Both Jack and 9 + elif has_jack: + expected_won_tricks = 1 # Only Jack + elif has_nine and len(trump_cards) > 1: + expected_won_tricks = 1 # Only 9 but with support + + if len(trump_cards) >= 3: + expected_won_tricks += len(trump_cards) - 3 + has_ace + + return expected_won_tricks + + def _find_best_contract(self, suit_evaluations: dict) -> tuple[int, Suit | None]: + """Resolve the suit evaluations to the single best (contract, suit). + + Folds the two questions the open-bid path used to answer + separately — "what is the highest contract I can reach?" and + "in which suit?" — into one pass. Ties on the contract value + are broken here as well: suits carrying a Belote (King + Queen + of trump) win first, then the fixed preference order + (Spades, Hearts, Diamonds, Clubs) decides among the rest. + + Args: + suit_evaluations: Per-suit evaluation dicts from + :meth:`_evaluate_suits`. + + Returns: + The highest reachable bidding-table contract and the single + suit chosen for it, or ``(0, None)`` when no suit supports + any contract. + """ + + max_contract = max( + evaluation['contract'] for evaluation in suit_evaluations.values() + ) + if max_contract == 0: + return 0, None + + # Suits tied on the best contract value. + candidates = [ + suit for suit, evaluation in suit_evaluations.items() + if evaluation['contract'] == max_contract + ] + + # Prefer belote-carrying suits; narrow the field when any exist. + belote_suits = [ + suit for suit in candidates if suit_evaluations[suit]['has_belote'] + ] + if belote_suits: + candidates = belote_suits + + # Break the remaining tie with the fixed preference order. The + # order covers every suit, so the first hit always exists. + chosen_suit = next( + suit for suit in self.SUIT_PREFERENCE if suit in candidates + ) + return max_contract, chosen_suit + + def _make_initial_bid(self, best_contract, best_suit, last_bid): + """Make an initial bid or overbid. + + Args: + best_contract: Our highest reachable bidding-table contract + (from :meth:`_find_best_contract`). + best_suit: The single suit resolved for it, or ``None``. + last_bid: The standing :class:`ContractBid`, or ``None``. + + Returns: + A :class:`ContractBid` for the chosen suit, or a + :class:`PassBid` when nothing legal improves the auction. + """ + + if best_contract == 0 or best_suit is None: + return PassBid(self._player) + + # Check if we can overbid the last bid + if last_bid is not None and best_contract <= last_bid.get_numeric_value(): + return PassBid(self._player) + + return self._contract_bid(best_contract, best_suit) + + def _team_opening_bid(self, bids, suit): + """Return our team's first :class:`ContractBid` in ``suit``, or ``None``. + + That opening bid anchors the support ceiling: the expert table + always opens at its full evaluation of the suit (there is no + slow walk-up), so everything our side may legitimately add on + top of it is the *other* seat's complement — announced once. + + Args: + bids: Chronological bid history. + suit: The trump suit whose team opening we want. + """ + + for bid in bids: + if ( + isinstance(bid, ContractBid) + and bid.suit == suit + and bid.player.team is self.team + ): + return bid + return None + + def _support_partner_bid(self, partner_bid, last_bid, bids): + """Support partner's suit up to a fixed team ceiling. + + The ceiling is partner's *opening* bid in the suit (their full + table evaluation) plus our own contribution (+10 per external + ace, +10 for the trump complement). Anchoring the raise on the + opening bid — never on the standing contract — is what breaks + the partner-support loop: a hand is static during the auction, + so re-adding the same contribution on top of a value that + already contains it would count the same cards on every lap and + ratchet the contract far past what the two hands can make. + + Two Pass conditions fall out of the same invariant: we opened + the suit ourselves (our cards are already priced into the + anchor, so there is nothing of ours left to announce), or the + standing contract already reaches the ceiling (our complement + is spent, whether by our earlier raise or an opponent overbid). + + Args: + partner_bid: Our side's most recent standing :class:`ContractBid`. + last_bid: The standing :class:`ContractBid`, or ``None``. + bids: Chronological bid history, to locate the anchor. + + Returns: + A :class:`ContractBid` raising partner's suit to the team + ceiling, or a :class:`PassBid` when we add nothing, opened + the suit ourselves, or the ceiling is already reached. + """ + + partner_suit = partner_bid.suit + + # Anchor on our team's opening bid of the suit. If *we* opened + # it, `partner_bid` is our own bid echoed back by + # `_get_partner_bid` — supporting it would double-count the + # very cards that priced it. + anchor = self._team_opening_bid(bids, partner_suit) + if anchor is None or anchor.player is self._player: + return PassBid(self._player) + + # Calculate our contribution to partner's suit + contribution = 0 + + # +10 per external ace + for card in self.hand: + if card.suit != partner_suit and card.rank == Rank.ACE: + contribution += 10 + + # +10 if we have trump complement (Jack or 9) + trump_cards = self.hand.cards_of_suit(partner_suit) + has_jack = any(card.rank == Rank.JACK for card in trump_cards) + has_nine = any(card.rank == Rank.NINE for card in trump_cards) + + if has_jack or has_nine: + contribution += 10 + + if contribution == 0: + return PassBid(self._player) + + # The team ceiling: partner's evaluation + our complement. Once + # the standing contract reaches it, our support is spent. + ceiling = anchor.get_numeric_value() + contribution + if ceiling <= last_bid.get_numeric_value(): + return PassBid(self._player) + + # An off-ladder ceiling (e.g. overshooting a partner's Slam) + # falls back to Pass inside _contract_bid. + return self._contract_bid(ceiling, partner_suit) + + def _contract_bid(self, numeric, suit) -> Bid: + """Build a :class:`ContractBid` from a bidding-table numeric + suit. + + The expert table stores the all-tricks bids as their base-value + numerics (``SLAM_NUMERIC`` = 250, ``SOLO_SLAM_NUMERIC`` = 500); + those map to the corresponding :class:`SlamLevel` members here, + so the constructed :class:`ContractBid` carries a value the + domain accepts. Numeric steps (80–180) pass through unchanged. + + A numeric that lands off the contract ladder — e.g. the support + ceiling overshooting a partner's Slam (250 + 40 = 290) — + is not a constructible contract; we fall back to a + :class:`PassBid` rather than raise. (``choose_bid``'s + ``is_legal`` net then leaves that Pass untouched.) + """ + + if numeric == self.SOLO_SLAM_NUMERIC: + value: int | SlamLevel = SlamLevel.SOLO_SLAM + elif numeric == self.SLAM_NUMERIC: + value = SlamLevel.SLAM + elif numeric in ContractBid.VALID_VALUES: + value = numeric + else: + return PassBid(self._player) + return ContractBid(self._player, value, suit) diff --git a/packages/contrai-engine/src/contrai_engine/model/player/rule_based/card_play.py b/packages/contrai-engine/src/contrai_engine/model/player/rule_based/card_play.py new file mode 100644 index 0000000..f120e7a --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/player/rule_based/card_play.py @@ -0,0 +1,709 @@ +"""Expert rule-based card-play strategy (see the package docstring).""" + +from contrai_core.card import Card +from contrai_core.play import PlayObservation +from contrai_core.player import BasePlayer +from contrai_core.trick import current_winner +from contrai_core.types import Rank, Suit + +from ..strategy import CardPlayStrategy, PlayerStateMixin + + +class RuleBasedCardPlayStrategy(CardPlayStrategy, PlayerStateMixin): + """Expert card-play policy. + + Stateless between calls: every decision is a pure function of the + frozen :class:`~contrai_core.PlayObservation` it is handed. The card + tracking the rules need — which cards have fallen and which suits + each seat is proven void in — is *derived* from the observation's + public trick history on each turn (see :meth:`_derive_tracking`), + never carried across calls or rounds. Decides which card to play + from the trick state, the contract, and what has fallen. + """ + + def choose_card(self, observation: PlayObservation) -> Card: + """Choose a card to play based on the expert card-play rules. + + Args: + observation: The frozen play-phase view for this seat — its + hand, legal cards, the contract, and the public trick + history. + + Returns: + The chosen :class:`Card`, drawn from + ``observation.legal_cards``. + """ + + # Rebuild the fallen-card map and the per-player void suits from + # the public history before deciding; the trick-reading helpers + # below consume them. + fallen, voids = self._derive_tracking(observation) + + if not observation.current_trick: + # First to play this trick. Trick 0 with nothing played yet is + # the opening lead; any later trick is a fresh lead with + # history behind it. + if observation.trick_number == 0: + return self._play_opening_card(observation) + return self._play_leading_card(observation, fallen, voids) + + # Someone has already played this trick — we are following. + return self._play_following_card(observation, fallen, voids) + + def _derive_tracking( + self, observation: PlayObservation + ) -> tuple[dict[Suit, set], dict[BasePlayer, set[Suit]]]: + """Rebuild fallen-card and per-player void tracking from history. + + Replays every play in ``(*completed_tricks, current_trick)`` + chronologically and reconstructs, for each play, exactly the + information a per-card tracker accumulates: + + - **Fallen cards**: every played card is recorded under its suit — + own plays included — so ``fallen[suit]`` plus the seat's own + holding plus the still-unseen cards always sum to 8 per suit. + - **Led-suit voids**: following suit is never optional, so *any* + card off the led suit — ruff and discard alike — proves the + seat holds none of that suit. No exemption applies here. + - **Voids in trump**: a seat that fails to follow the led suit and + does not trump has proven it holds no trump, but only when it was + *compelled* to. The compulsion is judged against the trick state + **before** the play lands — the master among the plays strictly + earlier in the same trick. A seat discarding while its own + partner is already master was free to (the partner-master + exemption), so that discard proves nothing. On a trump lead there + is no exemption: holding trump forces playing it, so any + off-trump card there is always a void (the led-suit rule above + already records it — led suit and trump are the same suit). + + The pre-play winner must be read from the plays *before* this one, + not after: a discard whose partner becomes master only through a + later play in the same trick was still compelled at decision time, + and evaluating the winner one play too late would silently hide the + void. + + Args: + observation: The play-phase view whose public history is + replayed. + + Returns: + A ``(fallen, voids)`` pair — ``fallen`` maps each card suit to + its set of fallen ranks; ``voids`` maps each player to the set + of suits that player is proven to hold no card of. + """ + + trump_suit = observation.trump_suit + fallen: dict[Suit, set] = { + Suit.SPADES: set(), + Suit.HEARTS: set(), + Suit.DIAMONDS: set(), + Suit.CLUBS: set(), + } + voids: dict[BasePlayer, set[Suit]] = {} + + for trick in (*observation.completed_tricks, observation.current_trick): + for index, (player, card) in enumerate(trick): + # Record the fallen card — happens for every play, whatever + # it proves about voids. + fallen[card.suit].add(card.rank) + + # Not following the led suit is always proof of a led-suit + # void — following is mandatory whenever possible. + led_suit = trick[0].card.suit + if card.suit != led_suit: + voids.setdefault(player, set()).add(led_suit) + + # Trump led: the led-suit rule above already recorded the + # trump void, and no further inference exists. + if led_suit == trump_suit: + continue + + # Reconstruct the pre-play master: the winner among the + # plays strictly earlier in this trick — the state the seat + # decided against. + prior = current_winner(list(trick[:index]), trump_suit) + partner_was_master = ( + prior is not None and prior.team == player.team + ) + + # Non-trump led: a discard behind a master partner is + # voluntary and proves nothing about trump. + if partner_was_master: + continue + if trump_suit is not None and card.suit not in ( + led_suit, + trump_suit, + ): + voids.setdefault(player, set()).add(trump_suit) + + return fallen, voids + + def _play_opening_card(self, observation: PlayObservation) -> Card: + """Play the very first card of the round.""" + + contract = observation.contract + playable_cards = observation.legal_cards + hand = observation.hand + trump_suit = observation.trump_suit + + if contract and contract.player.team == self.team: + # Our team has the contract - play the strongest trump + trump_cards = [c for c in playable_cards if c.suit == trump_suit] + if trump_cards: + sorted_trumps = sorted( + trump_cards, key=lambda c: c.get_order(trump_suit), reverse=True + ) + if sorted_trumps[0].rank == Rank.NINE and len(sorted_trumps) > 1: + # Avoid playing 9 first + return sorted_trumps[1] + return sorted_trumps[0] + else: + # Opponents have contract - play an ace if we have one + aces = [c for c in playable_cards if c.rank == Rank.ACE] + if aces: + # Play ace from the shortest suit + return min(aces, key=lambda c: self._count_suit(hand, c.suit)) + + # Default: play the lowest value card (excluding trump unless only trumps available) + non_trump_cards = ( + [c for c in playable_cards if c.suit != trump_suit] + if trump_suit + else playable_cards + ) + + if not non_trump_cards: + # Only trump cards available, use all playable cards + cards_to_consider = playable_cards + else: + # Use non-trump cards + cards_to_consider = non_trump_cards + + # Find cards with minimum points value + min_points = min(c.get_points(trump_suit) for c in cards_to_consider) + lowest_value_cards = [ + c for c in cards_to_consider if c.get_points(trump_suit) == min_points + ] + + # If multiple cards with same lowest value, choose randomly + return lowest_value_cards[0] + + def _play_leading_card( + self, + observation: PlayObservation, + fallen: dict[Suit, set], + voids: dict[BasePlayer, set[Suit]], + ) -> Card: + """Play when leading subsequent tricks.""" + + contract = observation.contract + playable_cards = observation.legal_cards + hand = observation.hand + trump_suit = observation.trump_suit + + # If the team has the contract and opponents might still have + # trump, play the strongest trump + if ( + contract + and contract.player.team == self.team + and self._opponents_might_have_trump(trump_suit, fallen, voids, hand) + ): + trump_cards = [c for c in playable_cards if c.suit == trump_suit] + if trump_cards: + return max(trump_cards, key=lambda c: c.get_order(trump_suit)) + + # TODO: exclude trump from logic if we know opponents have no trump left + # No trump left with opponents - play ace from the longest suit + aces = [c for c in playable_cards if c.rank == Rank.ACE] + if aces: + return max(aces, key=lambda c: self._count_suit(hand, c.suit)) + + # Play master card from the longest suit + master_cards = [c for c in playable_cards if self._is_master_card(c, trump_suit, fallen)] + if master_cards: + return max(master_cards, key=lambda c: self._count_suit(hand, c.suit)) + + # Default: play the lowest value card (excluding trump unless only trumps available) + non_trump_cards = ( + [c for c in playable_cards if c.suit != trump_suit] + if trump_suit + else playable_cards + ) + + if not non_trump_cards: + # Only trump cards available, use all playable cards + cards_to_consider = playable_cards + else: + # Use non-trump cards + cards_to_consider = non_trump_cards + + # Find cards with minimum points value + min_points = min(c.get_points(trump_suit) for c in cards_to_consider) + lowest_value_cards = [ + c for c in cards_to_consider if c.get_points(trump_suit) == min_points + ] + + # If multiple cards with same lowest value, choose randomly + return lowest_value_cards[0] + + def _play_following_card( + self, + observation: PlayObservation, + fallen: dict[Suit, set], + voids: dict[BasePlayer, set[Suit]], + ) -> Card: + """Strategy when not first to play. + + Both follow branches receive the anticipated-ruff flag — whether + an opponent still to play in this trick is expected to cut it + (see :meth:`_opponent_cut_expected`) — which turns their usual + point-piling plays into damage control. + + Args: + observation: The frozen play-phase view for this seat. + fallen: The fallen-card map from :meth:`_derive_tracking`. + voids: The per-player proven-void suits from + :meth:`_derive_tracking`. + """ + + cut_expected = self._opponent_cut_expected(observation, fallen, voids) + if self._is_team_winning_trick(observation.current_trick): + return self._play_when_team_winning(observation, fallen, cut_expected) + return self._play_when_team_losing(observation, fallen, cut_expected) + + def _play_when_team_winning( + self, + observation: PlayObservation, + fallen: dict[Suit, set], + cut_expected: bool, + ) -> Card: + """Play when our team is currently winning the trick. + + Partner already secures the trick, so the goal is to add value + (high-points cards) to the pile WITHOUT wasting trumps — unless + an opponent still to play is expected to ruff (``cut_expected``), + in which case the trick is presumed lost and every pile-on rule + flips to conceding as little as possible: + + 1. Follow suit if able — pile the highest-points lead-suit card + on partner's win, but never a master: a card partner's own + play just promoted (their Ace makes our Ten the new suit + master) can still win a later trick, so keep it and give the + next-highest instead. When the only followable card IS the + master, the play is forced and it goes anyway. Ruff expected + → concede the lowest-points card instead of piling on. + 2. Cannot follow suit → discard a NON-TRUMP card. Don't dump + trumps onto a trick the partner has already locked down. + Prefer non-master cards (preserve cards that can still win + their suit later); within the candidate set, pick the + highest-points to maximize this trick's value — or the + lowest-points when the ruff is expected to capture it. + 3. Hand has nothing but trumps → forced to play one. Use the + lowest trump so we don't waste the Jack or 9. + + Args: + observation: The frozen play-phase view for this seat. + fallen: The fallen-card map from :meth:`_derive_tracking`. + cut_expected: Whether :meth:`_opponent_cut_expected` predicts + an opponent still to play will ruff this trick. + """ + trump_suit = observation.trump_suit + led_suit = observation.led_suit + playable_cards = observation.legal_cards + + # 1. Follow suit if able, preserving the suit's current master. + same_suit_cards = [c for c in playable_cards if c.suit == led_suit] + if same_suit_cards: + if cut_expected: + # The trick is presumed lost to the ruff — concede the + # cheapest card instead of feeding the cutter. + return min(same_suit_cards, key=lambda c: c.get_points(trump_suit)) + non_master = [ + c for c in same_suit_cards + if not self._is_master_card(c, trump_suit, fallen) + ] + candidates = non_master or same_suit_cards + return max(candidates, key=lambda c: c.get_points(trump_suit)) + + # 2. Discard a non-trump card. + non_trump_cards = [ + c for c in playable_cards if c.suit != trump_suit + ] + if non_trump_cards: + non_master_non_trump = [ + c for c in non_trump_cards + if not self._is_master_card(c, trump_suit, fallen) + ] + candidates = non_master_non_trump or non_trump_cards + if cut_expected: + # Same logic as above: a discard onto a ruffed trick is + # captured too, so it turns cheap. + return min(candidates, key=lambda c: c.get_points(trump_suit)) + return max(candidates, key=lambda c: c.get_points(trump_suit)) + + # 3. Only trumps in hand — dump the lowest one. + if playable_cards: + return min(playable_cards, key=lambda c: c.get_order(trump_suit)) + return playable_cards[0] + + def _play_when_team_losing( + self, + observation: PlayObservation, + fallen: dict[Suit, set], + cut_expected: bool, + ) -> Card: + """Play when an opponent is currently winning the trick. + + The goal flips from adding value to contesting the trick: win it + when a card can, concede as cheaply as possible when none can. + The rules cascade in order: + + 1. Follow suit and beat if able — among the led-suit cards that + beat the current best, play the highest-points one: it takes + the trick AND banks the most points. When an opponent still + to play is expected to ruff (``cut_expected``), whatever we + invest is likely captured — so beat with the *smallest* + stronger card instead. That hedge keeps the loss minimal and + still pays off when we sit second: our partner plays after + the predicted cutter and may over-ruff, turning the cheap + investment into a won trick. + 2. Follow suit but cannot beat → the trick is gone; concede the + lowest-points card of the led suit rather than feed it. + 3. Cannot follow suit → ruff if it wins: play the lowest trump + that beats the current best (over-ruffing a trump already + played works the same way — the comparison is trump-aware). + No trump wins → fall through rather than waste one that + would be over-ruffed. + 4. Cannot follow or usefully ruff → discard the lowest-points + card from the shortest suit, excluding masters (a master can + still win its suit later). Nothing but masters left → the + first legal card goes. + + Args: + observation: The frozen play-phase view for this seat. + fallen: The fallen-card map from :meth:`_derive_tracking`. + cut_expected: Whether :meth:`_opponent_cut_expected` predicts + an opponent still to play will ruff this trick. + """ + + trump_suit = observation.trump_suit + led_suit = observation.led_suit + playable_cards = observation.legal_cards + plays = observation.current_trick + current_best = self._get_strongest_card_in_trick(plays, trump_suit) + + # 1./2. Try to follow suit. + same_suit_cards = [c for c in playable_cards if c.suit == led_suit] + if same_suit_cards: + # 1. Beat the current best if able. + stronger_cards = [c for c in same_suit_cards + if self._is_stronger_card(c, current_best, trump_suit)] + if stronger_cards: + if cut_expected: + # A ruff is coming — invest the smallest card that + # still beats the current best. (The predicate is + # False on trump leads, so the led suit is plain here + # and the normal order applies.) + return min(stronger_cards, key=lambda c: c.get_order(None)) + return max(stronger_cards, key=lambda c: c.get_points(trump_suit)) + # 2. Can't beat — concede the lowest card. + return min(same_suit_cards, key=lambda c: c.get_points(trump_suit)) + + # 3. Can't follow suit — ruff if it wins the trick. + if trump_suit and led_suit != trump_suit: + trump_cards = [c for c in playable_cards if c.suit == trump_suit] + if trump_cards: + winning_trumps = [c for c in trump_cards + if self._can_trump_win(c, plays, trump_suit)] + if winning_trumps: + return min(winning_trumps, key=lambda c: c.get_order(trump_suit)) + + # 4. Can't follow or usefully ruff — discard lowest from the + # shortest suit (excluding masters). + non_master_cards = [ + c for c in playable_cards if not self._is_master_card(c, trump_suit, fallen) + ] + if non_master_cards: + return min(non_master_cards, key=lambda c: ( + self._count_suit(observation.hand, c.suit), + c.get_points(trump_suit) + )) + + return playable_cards[0] + + @staticmethod + def _count_suit(hand, suit: Suit) -> int: + """Count the cards of ``suit`` in the observing seat's own hand. + + Args: + hand: The observer's hand, from ``observation.hand``. + suit: The suit to count. + + Returns: + The number of cards in ``hand`` whose suit is ``suit``. + """ + + return sum(1 for card in hand if card.suit == suit) + + def _opponents_might_have_trump( + self, + trump_suit: Suit, + fallen: dict[Suit, set], + voids: dict[BasePlayer, set[Suit]], + hand, + ) -> bool: + """Check if opponents might still have trump cards. + + Two knowledge sources, both derived from the observation's public + trick history: + + 1. **Counting** — 8 trumps exist; once every trump outside our + own hand has fallen, nobody else holds one. + 2. **Void inference** — a contrée table has exactly two + opponents. When both are known void (they were compelled to + trump but couldn't), any unseen trumps sit in partner's hand, + so pulling them helps nobody. + + Args: + trump_suit: The current trump suit. + fallen: The fallen-card map from :meth:`_derive_tracking`. + voids: The per-player proven-void suits from + :meth:`_derive_tracking`. + hand: The observing seat's own hand (``observation.hand``). + + Returns: + True if at least one opponent might still hold a trump. + """ + + # Counting: 8 trumps total; unseen = 8 - fallen - in our hand. + trump_fallen = len(fallen.get(trump_suit, set())) + trump_in_hand = self._count_suit(hand, trump_suit) + if trump_fallen >= (8 - trump_in_hand): + return False + + # Void inference: a contrée table has exactly two opponents. When both + # are known void, any unseen trumps sit in partner's hand — pulling + # them helps nobody. (`is not` — Team has no __eq__, identity is it.) + opponents_void = { + p + for p, void_suits in voids.items() + if trump_suit in void_suits and p.team is not self.team + } + return len(opponents_void) < 2 + + def _opponent_cut_expected( + self, + observation: PlayObservation, + fallen: dict[Suit, set], + voids: dict[BasePlayer, set[Suit]], + ) -> bool: + """Predict whether an opponent still to play will ruff this trick. + + Pure inference from the public history: an opponent seen unable + to follow the led suit earlier in the round cannot hold it now, + so if that opponent can still hold a trump, the expert assumption + is that the trick will be cut. All three legs must hold for some + opponent who has not played in the current trick yet: + + 1. **Led-suit void** — the opponent is proven void in the suit + led right now. + 2. **Trump plausible** — that same opponent is *not* proven void + in trump. + 3. **A trump is unseen** — at least one of the 8 trumps sits + outside our own hand and the fallen cards; with none left, + nobody can ruff anything. + + Trump leads and ``NO_TRUMP`` contracts have no ruff concept, and + a void seat that already played this trick is no longer a threat + — both come back ``False``. Being last to play also naturally + returns ``False``: no opponent is left behind us. + + Args: + observation: The frozen play-phase view for this seat. + fallen: The fallen-card map from :meth:`_derive_tracking`. + voids: The per-player proven-void suits from + :meth:`_derive_tracking`. + + Returns: + True when some opponent yet to play in the current trick is + proven void in the led suit and may still hold a trump. + """ + + trump_suit = observation.trump_suit + led_suit = observation.led_suit + if trump_suit is None or led_suit is None: + return False + if led_suit == trump_suit or trump_suit == Suit.NO_TRUMP: + return False + + # Leg 3 — counting: any unseen trump at all? + trump_fallen = len(fallen.get(trump_suit, set())) + trump_in_hand = self._count_suit(observation.hand, trump_suit) + if trump_fallen + trump_in_hand >= 8: + return False + + # Legs 1 and 2, restricted to opponents still to play. + already_played = {play.player for play in observation.current_trick} + return any( + player.team is not self.team + and player not in already_played + and led_suit in void_suits + and trump_suit not in void_suits + for player, void_suits in voids.items() + ) + + # TODO: replace trump_suit with a boolean is_trump parameter + def _is_master_card(self, card, trump_suit, fallen: dict[Suit, set]) -> bool: + """Check if a card is currently the master (highest remaining) in its suit. + + Args: + card: The candidate card. + trump_suit: The current trump suit. + fallen: The fallen-card map from :meth:`_derive_tracking`. + """ + + # Get fallen cards in this suit + suit_fallen = fallen.get(card.suit, set()) + + # Get all ranks higher than this card's rank + higher_ranks = self._get_higher_ranks(card.rank, card.suit, trump_suit) + + # Check if all higher cards have fallen + return all(rank in suit_fallen for rank in higher_ranks) + + @staticmethod + def _get_higher_ranks(rank, suit, trump_suit): + """Get all ranks higher than the given rank in the suit.""" + + if suit == trump_suit: + # Trump order: 7, 8, Queen, King, 10, Ace, 9, Jack + trump_order = [ + Rank.SEVEN, Rank.EIGHT, Rank.QUEEN, Rank.KING, + Rank.TEN, Rank.ACE, Rank.NINE, Rank.JACK, + ] + else: + # Normal order: 7, 8, 9, Jack, Queen, King, 10, Ace + trump_order = [ + Rank.SEVEN, Rank.EIGHT, Rank.NINE, Rank.JACK, + Rank.QUEEN, Rank.KING, Rank.TEN, Rank.ACE, + ] + + try: + rank_index = trump_order.index(rank) + return trump_order[rank_index + 1:] + except ValueError: + return [] + + def _is_team_winning_trick(self, plays, trump_suit=None) -> bool: + """Check if our team is currently winning the trick. + + The determination is made on the led-suit ranking — the highest + card of the led suit and who played it — with ``trump_suit`` + defaulting to ``None`` (the normal, trump-agnostic ordering). Our + team is winning when our partner holds that top led-suit card. The + team-losing branch does its own trump-aware comparison, so the + cut/over-cut reasoning lives there rather than in this gate. + + Args: + plays: The in-progress trick's plays, a ``tuple[Play, ...]``. + trump_suit: Ordering to rank by; ``None`` for the normal order. + """ + + if len(plays) < 1: + return False + + # Find partner's position + partner_position = self._get_partner_position() + + # Check if partner played the strongest card so far + strongest_position = self._get_strongest_card_position(plays, trump_suit) + return strongest_position == partner_position + + def _get_partner_position(self): + """Get partner's position.""" + + position_map = {'North': 'South', 'South': 'North', 'East': 'West', 'West': 'East'} + return position_map.get(self.position) + + def _get_strongest_card_position(self, plays, trump_suit): + """Get the position of the player who played the strongest card. + + Args: + plays: The trick's plays, a ``tuple[Play, ...]``. + trump_suit: The suit to rank by, or ``None`` for normal order. + """ + + if not plays: + return None + + strongest_card = self._get_strongest_card_in_trick(plays, trump_suit) + + # Find which player played the strongest card + for player, card in plays: + if card == strongest_card: + return player.position + + return None + + @staticmethod + def _get_strongest_card_in_trick(plays, trump_suit): + """Get the strongest card played so far in the trick. + + Args: + plays: The trick's plays, a ``tuple[Play, ...]``. ``Play`` + unpacks as ``(player, card)``. + trump_suit: The suit to rank by, or ``None`` for normal order. + """ + + if not plays: + return None + + led_suit = plays[0].card.suit + cards = [card for _, card in plays] + + # Trump cards beat non-trump (unless led suit is trump) + if led_suit != trump_suit: + trump_cards = [c for c in cards if c.suit == trump_suit] + if trump_cards: + return max(trump_cards, key=lambda c: c.get_order(trump_suit)) + + # Among cards of led suit + led_suit_cards = [c for c in cards if c.suit == led_suit] + if led_suit_cards: + order_suit = trump_suit if led_suit == trump_suit else None + return max(led_suit_cards, key=lambda c: c.get_order(order_suit)) + + return cards[0] + + @staticmethod + def _is_stronger_card(card, current_best, trump_suit): + """Check if card is stronger than current_best.""" + + if not current_best: + return True + + # If current best is trump and our card isn't (and trump is not led suit) + if current_best.suit == trump_suit and card.suit != trump_suit: + return False + + # If our card is trump and current best isn't + if card.suit == trump_suit and current_best.suit != trump_suit: + return True + + # Both trump or both same suit + if card.suit == current_best.suit: + order_suit = trump_suit if card.suit == trump_suit else None + return card.get_order(order_suit) > current_best.get_order(order_suit) + + return False + + def _can_trump_win(self, trump_card, plays, trump_suit): + """Check if playing this trump card would win the trick. + + Args: + trump_card: The trump card being considered. + plays: The in-progress trick's plays, a ``tuple[Play, ...]``. + trump_suit: The current trump suit. + """ + + current_best = self._get_strongest_card_in_trick(plays, trump_suit) + return self._is_stronger_card(trump_card, current_best, trump_suit) diff --git a/packages/contrai-engine/src/contrai_engine/model/player/strategy.py b/packages/contrai-engine/src/contrai_engine/model/player/strategy.py new file mode 100644 index 0000000..ab31cae --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/player/strategy.py @@ -0,0 +1,99 @@ +"""Pluggable AI strategy interfaces + shared player-state access. + +``AiPlayer`` no longer owns its bidding and card-play logic directly. +Instead it holds two strategy objects behind the abstract interfaces +defined here, injected at construction (see :mod:`.ai`). Today's expert +rules are the first concrete implementation (see :mod:`.rule_based`); +future AI levels (MCTS, learned policies) are new +strategy classes, never edits to ``AiPlayer``. +""" + +from abc import ABC, abstractmethod + +from contrai_core.auction import Auction +from contrai_core.bid import Bid + + +class PlayerStateMixin: + """Mix-in giving a strategy live read access to its owning player. + + A strategy needs to read the player's table state (``hand``, + ``team``, ``position``) while making decisions. It keeps a + back-reference to the player and exposes those fields as + properties, so re-assigning ``player.hand`` between rounds (or in + tests) is reflected immediately — the strategy never caches a stale + copy. This also lets the moved method bodies stay nearly verbatim: + ``self.hand`` / ``self.team`` / ``self.position`` keep working. + """ + + def __init__(self, player): + """Bind this strategy to the player it advises. + + Args: + player: The owning :class:`~contrai_engine.model.player.AiPlayer`. + """ + + self._player = player + + @property + def hand(self): + """The owning player's current hand.""" + return self._player.hand + + @property + def team(self): + """The owning player's team.""" + return self._player.team + + @property + def position(self): + """The owning player's seat position.""" + return self._player.position + + +class BiddingStrategy(ABC): + """Interface for an AI bidding policy. + + Implementations decide what :class:`Bid` to make given the current + :class:`Auction` state. The owning :class:`AiPlayer` delegates + :meth:`AiPlayer.choose_bid` straight through to :meth:`choose_bid`. + """ + + @abstractmethod + def choose_bid(self, auction: Auction) -> Bid: + """Choose a :class:`Bid` for the current auction state. + + Args: + auction: The current :class:`Auction` state. + + Returns: + A :class:`Bid` instance the engine will validate. + """ + + +class CardPlayStrategy(ABC): + """Interface for an AI card-play policy. + + Implementations choose which card to play from a single frozen + :class:`~contrai_core.PlayObservation` — the observing seat's own + hand, the public trick history, the contract/auction, and the seat's + legal plays. Any card tracking a strategy wants (fallen cards, + inferred voids) is derived from that public history; the observation + is the only input, so a strategy can never read another seat's hand. + The owning :class:`AiPlayer` delegates :meth:`AiPlayer.choose_card` + to this object. + """ + + @abstractmethod + def choose_card(self, observation): + """Choose a card to play in the current trick. + + Args: + observation: The :class:`~contrai_core.PlayObservation` for + the observing seat — its hand, legal cards, the contract, + and the public trick history. + + Returns: + The chosen :class:`Card`, drawn from + ``observation.legal_cards``. + """ diff --git a/packages/contrai-engine/src/contrai_engine/model/round.py b/packages/contrai-engine/src/contrai_engine/model/round.py deleted file mode 100644 index 22e04c7..0000000 --- a/packages/contrai-engine/src/contrai_engine/model/round.py +++ /dev/null @@ -1,793 +0,0 @@ -# Round class for the contrée card game. -# This class represents a complete round of the card game from dealing to scoring. - -import itertools -from enum import Enum -from typing import Optional, Dict, List, TYPE_CHECKING - -from contrai_core.auction import Auction -from contrai_core.bid import Bid, SlamLevel -from contrai_core.contract import Contract -from contrai_core.exceptions import IllegalPlayError, PlayRuleViolation -from contrai_core.trick import Trick -from contrai_core.types import Rank, Suit - -if TYPE_CHECKING: - from .player import Player - from contrai_core.team import Team - from contrai_core.deck import Deck - - -class UnannouncedSlam(Enum): - """Outcome tag for an *undeclared* all-tricks sweep on a numeric contract. - - Set by :meth:`Round.calculate_round_scores` after play, when the - declaring team takes all 8 tricks on an un-doubled numeric (80-180) - contract without having announced anything. The round still scores on - the numeric path — the bidder's contract value plus a flat 250 - substitute for the trick pile — *not* the Slam at-risk grid. - - This is deliberately distinct from :class:`contrai_core.SlamLevel`: that - enum is a *declared bid value*; this is a post-play classification, and - its ``GRAND_SLAM`` member is named for the undeclared sweep (it is not a - Solo Slam). Each member's value is its display label, so ``str(tag)`` - yields the text the recap panel shows. - - Members: - SLAM: The declaring *team* swept all 8 tricks (partner won at - least one). - GRAND_SLAM: The contracting *player personally* won all 8 tricks. - """ - - SLAM = "Slam" - GRAND_SLAM = "Grand Slam" - - def __str__(self) -> str: - return self.value - - -class Round: - """ - Represents a complete round of the card game from dealing to scoring. - - Manages the complete round lifecycle including bidding phase coordination, - trick sequence management, and round score calculation. - """ - - def __init__(self, players_order: List['Player'], dealer: 'Player', deck: 'Deck', round_number: int): - """ - Initialize a round with the given parameters. - - Args: - players_order: List of players in playing order for this round - dealer: The dealer for this round - deck: The deck to use for dealing cards - round_number: The current round number - """ - self.players_order = players_order - self.dealer = dealer - self.deck = deck - self.round_number = round_number - - # Round state - self.contract: Optional[Contract] = None - self.tricks: List[Trick] = [] - self.current_trick: Optional[Trick] = None - self.last_trick_winner: Optional['Player'] = None - self.team_tricks: Dict[str, List[Trick]] = {} - self.round_scores: Dict[str, int] = {} - # Single source of truth for the contract outcome, set by - # ``calculate_round_scores``. ``None`` until scored (or when the - # round was all-passed). The view reads this rather than - # re-deriving "made" from the scores — a failed declarer can - # still score a non-zero Belote bonus, so "round_score > 0" is - # not a reliable made/failed signal. - self.contract_made: Optional[bool] = None - # Unannounced-capot marker, set by ``calculate_round_scores``. - # ``None`` when the round was not an unannounced capot; otherwise - # the matching :class:`UnannouncedSlam` member — ``SLAM`` (the - # declaring *team* swept all 8 tricks) or ``GRAND_SLAM`` (the - # contracting *player personally* won them all). Only set for - # un-doubled numeric contracts — the path that swaps the - # 162-point pile for a flat 250 substitute. The view reads this to - # render the 250 and its explanatory tag. - self.unannounced_capot: Optional[UnannouncedSlam] = None - - # Belote / rebelote announcement state. ``belote_holder`` is the - # unique player holding both the K and the Q of trump at deal time - # (None when no one has both, or when the contract is NO_TRUMP / - # passed). ``belote_state`` tracks which of the two cards they - # have already played: missing → not yet announced; "belote" → - # one played; "rebelote" → both played. - self.belote_holder: Optional['Player'] = None - self.belote_state: Dict['Player', str] = {} - - # Initialize team tricks dictionary - if players_order: - teams = set(player.team for player in players_order) - self.team_tricks = {team.name: [] for team in teams} - - def deal_cards(self): - """ - Deal cards to all players in the proper order. - Dealer gets cards last. - """ - self.deck.deal(self.players_order) - - def manage_bidding(self, view=None) -> Optional[Contract]: - """Handle the complete bidding phase. - - Drives an :class:`Auction` through the standard cyclic - ``players_order``. Each iteration: - - 1. Look up the legal actions for the active player. When the - only legal action is :class:`PassBid` (partner just doubled - or redoubled, or a pass already closed the redouble window) - the engine auto-applies it without prompting the player or - the view. - 2. Otherwise consult ``player.choose_bid`` and — for the - human seat — ``view.request_bid_action`` to gather the - player's chosen :class:`Bid`. - 3. Apply the bid via :meth:`Auction.apply`. An illegal bid - raises :class:`IllegalBidError` — there is no silent - "force a Pass on illegal" fallback any more. - - Args: - view: Optional view that drives human input and pacing - hooks. - - Returns: - The established :class:`Contract`, or ``None`` if every - player passed. - """ - - auction = Auction.empty() - player_iter = itertools.cycle(self.players_order) - - while not auction.is_terminal(): - player = next(player_iter) - legal = auction.legal_actions(player) - if len(legal) == 1: - # Pass is the only legal action — skip both the AI - # strategy and the human prompt entirely. Covers the - # "partner doubled / redoubled" UX as a special case - # of the general "no real choice" rule. - bid = legal[0] - else: - bid = self._gather_bid(player, auction, view) - auction = auction.apply(bid) - # Notify the view that a bid was just registered. Used by - # interactive views to render the AI action and pause - # briefly before the next bidder. - if view is not None and hasattr(view, 'on_bid_made'): - view.on_bid_made(player, bid, list(auction.bids)) - - self.contract = auction.contract() - if self.contract is not None: - self._detect_belote_holder() - # Bookmark the contract in the event log so the start of - # play is clearly delimited. - if view is not None and hasattr(view, 'on_contract_established'): - view.on_contract_established(self) - - return self.contract - - def _gather_bid( - self, - player: 'Player', - auction: Auction, - view, - ) -> Bid: - """Ask ``player`` for a :class:`Bid`, consulting ``view`` for humans. - - Args: - player: The active bidder. - auction: The current auction state. Passed verbatim to - both ``player.choose_bid`` and (for humans) - ``view.request_bid_action``. - view: The optional view. - - Returns: - The player's chosen :class:`Bid`. - - Raises: - RuntimeError: If neither the player nor the view produced - a bid — that's an engine wiring bug (e.g. a - :class:`HumanPlayer` with no view attached). - """ - - bid: Optional[Bid] = None - if hasattr(player, 'choose_bid'): - bid = player.choose_bid(auction) - if ( - view is not None - and getattr(player, 'is_human', False) - and hasattr(view, 'request_bid_action') - ): - bid = view.request_bid_action(player, auction) - if bid is None: - raise RuntimeError( - f"No bid produced for {player.position}: " - f"choose_bid returned None and the view did not intercept." - ) - return bid - - def _is_belote_event(self, player: 'Player', card) -> bool: - """True if *player* playing *card* counts toward a belote announcement.""" - if self.belote_holder is None or self.contract is None: - return False - if player is not self.belote_holder: - return False - trump = self.contract.suit - return card.suit == trump and card.rank in (Rank.KING, Rank.QUEEN) - - def _transition_belote_state(self, player: 'Player') -> Optional[str]: - """Advance the belote_state machine and return the new state name. - - Returns ``"belote"`` if this is the first of the K+Q pair played, - ``"rebelote"`` if it's the second, or ``None`` if the player has - already fired both (defensive — shouldn't happen, since each card - is unique). - """ - current = self.belote_state.get(player) - if current is None: - self.belote_state[player] = "belote" - return "belote" - if current == "belote": - self.belote_state[player] = "rebelote" - return "rebelote" - return None - - def _detect_belote_holder(self) -> None: - """Snapshot which player (if any) holds the K + Q of trump. - - Belote/rebelote is a per-round, per-holder narrative event: - whoever holds both cards announces ``Belote`` on the first they - play and ``Rebelote`` on the second. No-trump contracts have no - belote. - """ - if self.contract is None or self.contract.suit == Suit.NO_TRUMP: - self.belote_holder = None - return - trump = self.contract.suit - for player in self.players_order: - has_king = player.hand.has_card(trump, Rank.KING) - has_queen = player.hand.has_card(trump, Rank.QUEEN) - if has_king and has_queen: - self.belote_holder = player - return - self.belote_holder = None - - def play_trick(self, view=None) -> Optional['Player']: - """ - Play a single trick and return winner. - - Args: - view: Optional view for human player interaction - - Returns: - Player: The winner of the trick or None if no winner - """ - self.current_trick = Trick() - trick_leader = self.players_order[0] if self.last_trick_winner is None else self.last_trick_winner - - # Determine the order for this trick (winner of last trick leads) - leader_idx = self.players_order.index(trick_leader) - trick_order = [] - for i in range(4): - trick_order.append(self.players_order[(leader_idx + i) % 4]) - - # Each player plays a card - for player in trick_order: - # Get the playable cards for this player - playable_cards = self._get_playable_cards(player) - - if hasattr(player, 'choose_card'): - # AI player or player with choose_card method - # Pass playable cards to help AI make legal moves - card = player.choose_card(self.current_trick, self.contract, playable_cards) - else: - # Simple fallback: play first playable card - card = playable_cards[0] if playable_cards else None - - # If view provided and player is human, use view for input - if view and hasattr(player, 'is_human') and player.is_human: - card = view.request_card_action(player, self.current_trick, self.contract, playable_cards) - - # Validate that the chosen card is legal. An illegal card is - # surfaced as a loud failure (IllegalPlayError) rather than - # silently corrected to a legal one: choose_card / - # request_card_action are contracted to return a card from - # playable_cards, so a violation here is a wiring bug we want - # to see, not paper over. - played_card = None - if card and card in playable_cards: # playable ⊆ hand, so in-hand is implied - player.hand.remove(card) - self.current_trick.add_play(player, card) - played_card = card - elif card: # truthy but illegal → loud failure - raise IllegalPlayError( - card, - self._classify_play_violation(player, card), - playable_cards, - context=f"{getattr(player, 'position', player)} card play", - ) - # card falsy → unchanged (out of scope) - - # Notify the view that a card just landed on the table. - # Lets interactive views render the AI action and pause. - if ( - played_card is not None - and view is not None - and hasattr(view, 'on_card_played') - ): - view.on_card_played(player, played_card, self.current_trick) - - # Belote / rebelote announcement. Fires only when the holder - # plays one of the K/Q of trump. Each card fires at most once. - if played_card is not None and self._is_belote_event(player, played_card): - kind = self._transition_belote_state(player) - if kind is not None and view is not None and hasattr( - view, 'on_belote_announced' - ): - view.on_belote_announced(player, kind, self) - - # Determine trick winner. Who wins is a pure rule of the trick - # given trump, so we delegate to contrai-core rather than duplicate - # the comparison here. The contract carries the authoritative trump - # suit (None only defensively, before a contract is established). - winner = self.current_trick.get_current_winner( - self.contract.suit if self.contract else None - ) - self.last_trick_winner = winner - - # Add trick to the tricks list and to winner's team - if self.current_trick: - self.tricks.append(self.current_trick) - if winner and winner.team: - self.team_tricks[winner.team.name].append(self.current_trick) - - # Add cards back to deck (last card played first, then reverse order) - if self.current_trick and hasattr(self.current_trick, 'get_plays'): - trick_cards = [card for _, card in self.current_trick.get_plays()] - trick_cards.reverse() # Last card played becomes first to be added back - self.deck.add_cards(trick_cards) - - # Notify the view that a trick just completed (optional view hook). - # Used by interactive views (e.g. RichView) to pause for "Press Enter" - # between tricks. Skipped silently when no such hook exists. - if view is not None and hasattr(view, 'on_trick_complete'): - view.on_trick_complete(self.current_trick, winner, self) - - return winner - - def play_all_tricks(self, view=None) -> Dict[str, List[Trick]]: - """ - Play all 8 tricks of the round. - - Args: - view: Optional view for human player interaction - - Returns: - Dict mapping team names to their tricks - """ - # Initialize team tricks tracking - self.last_trick_winner = None - - # Play 8 tricks - for trick_num in range(8): - winner = self.play_trick(view) - - return self.team_tricks - - def calculate_round_scores(self) -> Dict[str, int]: - """ - Calculate scores for this round. - - Three scoring shapes, all sharing the same Belote rule (see - contree-domain.md §6.5, §7): - - - **Numeric, un-doubled (M = 1).** Made → declarer scores - ``C + P_attack`` and the defense keeps its own card points; - failed → the defense scores ``160 + C`` and the declarer - scores nothing. ``P_attack`` is the declarer's card points - (which already include the *dix de der*) plus the Belote - bonus when the declarer holds it. - - **Unannounced capot (M = 1).** When the declaring team wins - *all 8 tricks* on a numeric contract without having bid a - Slam, the trick pile (152 cards + 10 *dix de der* = 162) is - replaced by a flat **250** substitute: the declarer scores - ``C + 250`` (+ Belote), the defense scores nothing, and the - contract is necessarily made. The personal-trick predicate - tags it :attr:`UnannouncedSlam.GRAND_SLAM` when the - *contracting player* won all 8, else - :attr:`UnannouncedSlam.SLAM`. Only un-doubled — a - doubled/redoubled sweep keeps the winner-takes-all shape below. - - **Numeric, doubled / redoubled (M > 1).** Winner-takes-all: - the side that wins the round takes the whole pile, the loser - scores 0. The winner scores ``160 + C × M`` whether it is the - declarer (made) or the defense (failed). See - contree-domain.md §7.2. - - **Slam / Solo Slam.** A symmetric grid that replaces the - 162-point pile with a flat substitute equal to the base: the - winning side scores ``(base + substitute) × M`` (500 / 1000 / - 2000 for Slam; 1000 / 2000 / 4000 for Solo Slam). Solo Slam - additionally requires the *contracting player personally* to - win every trick. - - Across every shape the **Belote bonus (+20)** is credited to the - team *holding* both K and Q of trump (``belote_holder`` — not - whoever captures the cards in a trick) and is always preserved, - even for the side that loses the round. - - Sets :attr:`contract_made` as the canonical made/failed signal. - - Returns: - Dict: Team scores for this round - """ - if not self.contract: - # No contract established, return zero scores - teams = set(player.team for player in self.players_order) - self.round_scores = {team.name: 0 for team in teams} - self.contract_made = None - return self.round_scores - - contract_team = self.contract.player.team - contract_value = self.contract.value - trump_suit = self.contract.suit - - team_card_points = {team_name: 0 for team_name in self.team_tricks.keys()} - team_scores = {team_name: 0 for team_name in self.team_tricks.keys()} - - # Card points per team (trump-aware). Belote is deliberately NOT - # folded in here — it is a *held-cards* bonus credited below to - # the holder's team, independent of who captured the K/Q. - for team_name, tricks in self.team_tricks.items(): - points = 0 - for trick in tricks: - if hasattr(trick, 'get_plays'): - for _player, card in trick.get_plays(): - points += card.get_points(trump_suit) - team_card_points[team_name] = points - - # Add "dix de der" (10 points for last trick). - if self.last_trick_winner and self.last_trick_winner.team: - team_card_points[self.last_trick_winner.team.name] += 10 - - # Belote (+20) belongs to the team *holding* K + Q of trump - # (contree-domain.md §6.5), not to whoever wins the trick those - # cards land in. ``belote_holder`` is the single player holding - # both at deal time (None when split, or at No-Trump). - belote_team: Optional[str] = None - if self.belote_holder is not None and self.belote_holder.team is not None: - belote_team = self.belote_holder.team.name - - def belote_bonus(team_name: str) -> int: - """Belote (+20) for ``team_name`` when it holds the pair.""" - return 20 if team_name == belote_team else 0 - - contract_team_name = contract_team.name - - # Multiplier for double/redouble (shared by both paths). - multiplier = self.contract.get_multiplier() - - # ----- Slam / Solo Slam scoring path ----- - # The 162 of trick-card points is replaced by a flat substitute - # equal to the contract base (see Contract.get_slam_card_substitute). - # The full at-risk amount is (base + substitute) × multiplier, - # giving 500 / 1000 / 2000 for Slam and 1000 / 2000 / 4000 for - # Solo Slam at normal / doubled / redoubled. The grid is symmetric: - # whichever side wins the contract scores the at-risk amount. - # See contree-domain.md §7.2. - if self.contract.is_slam_family(): - contract_team_trick_count = len(self.team_tricks[contract_team_name]) - contract_made = contract_team_trick_count == 8 - - # Solo Slam: the bidder *personally* must win all 8 tricks. - # Even if their team takes every trick collectively, the - # contract fails when the partner won any of them. - if self.contract.is_solo_slam(): - bidder_personal_tricks = self._count_player_tricks( - self.contract.player - ) - contract_made = contract_made and bidder_personal_tricks == 8 - - base = self.contract.get_base_points() - substitute = self.contract.get_slam_card_substitute() - at_risk = (base + substitute) * multiplier - if contract_made: - team_scores[contract_team_name] = at_risk - else: - for team_name in team_scores: - if team_name != contract_team_name: - team_scores[team_name] = at_risk - - # Belote (+20) layered on top — independent of who won the contract. - if belote_team is not None: - team_scores[belote_team] += 20 - - self.contract_made = contract_made - self.round_scores = team_scores - return team_scores - - # ----- Numeric contract scoring path (80-180) ----- - defender_names = [t for t in team_scores if t != contract_team_name] - - # Unannounced capot: the declaring team swept all 8 tricks on a - # numeric contract. Recognised only un-doubled — the - # doubled/redoubled path keeps its winner-takes-all 160 + C×M - # shape regardless. The trick pile (152 cards + 10 der) is - # replaced by a flat 250 substitute and the contract is - # necessarily made. GRAND_SLAM when the contracting player won all - # 8 personally (the Solo Slam predicate), else plain SLAM. - # The 250 substitute is the same flat amount a *declared* Slam is - # worth, so it reads from the SlamLevel single source of truth. - UNANNOUNCED_CAPOT_SUBSTITUTE = SlamLevel.SLAM.base_value - declarer_capot = ( - multiplier == 1 - and len(self.team_tricks[contract_team_name]) == 8 - ) - if declarer_capot: - bidder_personal_tricks = self._count_player_tricks( - self.contract.player - ) - self.unannounced_capot = ( - UnannouncedSlam.GRAND_SLAM - if bidder_personal_tricks == 8 - else UnannouncedSlam.SLAM - ) - - # The declarer's *realized* points decide made/failed: card - # points (already including the dix de der) plus the Belote - # bonus when the declarer holds it (contree-domain.md §7.1-§7.2). - # A capot is made outright — sweeping every trick can never fail. - attacker_realized = ( - team_card_points[contract_team_name] + belote_bonus(contract_team_name) - ) - contract_made = declarer_capot or attacker_realized >= contract_value - self.contract_made = contract_made - - if multiplier == 1: - # Un-doubled: the two sides share the pile. - if contract_made: - # On an unannounced capot the 162 pile (der included) is - # swapped for the flat 250 substitute; otherwise the - # declarer adds its real captured card points. - attacker_pile = ( - UNANNOUNCED_CAPOT_SUBSTITUTE - if declarer_capot - else team_card_points[contract_team_name] - ) - team_scores[contract_team_name] = ( - contract_value - + attacker_pile - + belote_bonus(contract_team_name) - ) - for name in defender_names: - team_scores[name] = team_card_points[name] + belote_bonus(name) - else: - # Failed (chuté): the defense takes the whole pile plus - # the contract; the declarer keeps only its Belote bonus. - team_scores[contract_team_name] = belote_bonus(contract_team_name) - for name in defender_names: - team_scores[name] = (160 + contract_value) + belote_bonus(name) - else: - # Doubled / redoubled: winner-takes-all. The losing side - # scores nothing but its Belote bonus (always preserved). - if contract_made: - team_scores[contract_team_name] = ( - 160 + contract_value * multiplier - + belote_bonus(contract_team_name) - ) - for name in defender_names: - team_scores[name] = belote_bonus(name) - else: - team_scores[contract_team_name] = belote_bonus(contract_team_name) - for name in defender_names: - team_scores[name] = ( - 160 + contract_value * multiplier + belote_bonus(name) - ) - - self.round_scores = team_scores - return team_scores - - def _count_player_tricks(self, player: 'Player') -> int: - """Count the number of completed tricks personally won by ``player``. - - Walks the round's trick history and asks each trick for its - winner via :meth:`contrai_core.Trick.get_current_winner`, - forcing the contract's trump suit so trump beats lead-suit - regardless of whether the trick had its ``trump_suit`` bound - at construction time. Used by the Solo Slam predicate in - :meth:`calculate_round_scores`. - - Args: - player: The player whose personal trick tally we want. - - Returns: - The number of completed tricks won outright by ``player``. - """ - if not self.tricks or self.contract is None: - return 0 - trump_suit = self.contract.suit - count = 0 - for trick in self.tricks: - winner = trick.get_current_winner(trump_suit) - if winner is player: - count += 1 - return count - - def handle_failed_contract(self) -> Dict[str, int]: - """ - Manage cards when all players pass. - - Returns: - Dict: Zero scores for all teams - """ - # Put all players' cards back in deck (8 cards per player) - for player in self.players_order: - self.deck.add_cards(player.hand) - player.hand.clear() - - # Return zero scores - teams = set(player.team for player in self.players_order) - self.round_scores = {team.name: 0 for team in teams} - return self.round_scores - - def _get_playable_cards(self, player: 'Player'): - """ - Determine which cards a player can legally play. - - Implements the rules from contree-domain.md §6.2-§6.3: - 1. Follow the led suit if you can. - 2. When trump is led, you must additionally over-trump if you - hold a higher trump than the highest already on the table - (§6.3). - 3. When you cannot follow suit and your partner is *not* - currently master of the trick, you must trump. If an - opponent has already trumped, you must over-trump if able; - otherwise play any trump. - 4. Partner-master exception: if your partner is currently - winning the trick, you may discard freely — no obligation - to trump or over-trump (§6.2 rule 4). - 5. Otherwise (no trump in your hand, or no trump suit) any - card may be discarded. - - Args: - player: The player whose playable cards we want to determine. - - Returns: - list: List of cards that can be legally played. - """ - if not player.hand: - return [] - - trump_suit = self.contract.suit if self.contract else None - if not self.current_trick or not hasattr(self.current_trick, 'get_plays'): - return player.hand.copy() - - plays = self.current_trick.get_plays() - if not plays: - # First to play in this trick — anything goes. - return player.hand.copy() - - lead_suit = plays[0][1].suit - lead_suit_cards = player.hand.cards_of_suit(lead_suit) - trump_cards = ( - player.hand.cards_of_suit(trump_suit) if trump_suit else [] - ) - - # Rule 1 — follow suit. Special-case rule 2 (over-trump when trump - # is led): the player MUST go higher than the best trump on the - # table if they hold one; only fall back to lower trumps when no - # higher trump exists. - if lead_suit_cards: - if trump_suit and lead_suit == trump_suit: - higher = self._higher_trumps_than_played(lead_suit_cards, plays, trump_suit) - return higher if higher else lead_suit_cards - return lead_suit_cards - - # Rule 4 — partner-master exemption per contree-domain.md §6.2 - # rule 4. The exemption applies only when the partner is - # *currently winning* the partial trick, not just whoever led: - # a partner who has since been over-trumped by an opponent no - # longer protects you from the trump obligation. - current_master = self.current_trick.get_current_winner(trump_suit) - if current_master is not None and current_master.team == player.team: - return player.hand.copy() - - # No trump suit, or led suit is trump (and we have none — already - # handled above when we have some): nothing to over-trump, free discard. - if not trump_suit or lead_suit == trump_suit: - return player.hand.copy() - - # Trump obligations apply. If any opponent trumped, must beat them. - highest_opponent_trump = self._highest_opponent_trump(plays, player.team, trump_suit) - if highest_opponent_trump is not None: - higher_trumps = [ - card for card in trump_cards - if card.get_order(trump_suit) > highest_opponent_trump.get_order(trump_suit) - ] - if higher_trumps: - return higher_trumps - if trump_cards: - return trump_cards - return player.hand.copy() - - # No opponent trump yet but partner is not master either → must - # trump if able. - if trump_cards: - return trump_cards - return player.hand.copy() - - def _classify_play_violation(self, player: 'Player', card) -> PlayRuleViolation: - """Classify *why* an in-hand card is illegal for ``player`` to play. - - Called only when ``card`` is genuinely illegal — held in hand but - absent from ``_get_playable_cards``'s legal set, with the current - trick already holding at least one play. The branch order mirrors - :meth:`_get_playable_cards` and **must stay in sync** with it - until the deferred ``Ruleset`` unifies the two (CLAUDE.md §10). - - Args: - player: The player whose illegal play we are explaining. - card: The illegal card they attempted to play. - - Returns: - The :class:`PlayRuleViolation` describing the broken - obligation. - """ - trump_suit = self.contract.suit if self.contract else None - plays = self.current_trick.get_plays() - lead_suit = plays[0][1].suit - lead_suit_cards = player.hand.cards_of_suit(lead_suit) - - # Rule 1/2 — held the led suit. Trump led + a too-low trump is an - # over-trump failure; anything else off-suit is a follow failure. - if lead_suit_cards: - if trump_suit and lead_suit == trump_suit and card.suit == trump_suit: - return PlayRuleViolation.MUST_OVERTRUMP - return PlayRuleViolation.MUST_FOLLOW_SUIT - - # Void in the led suit (partner-master plays are legal, so never - # reach here). An opponent already trumped and we under-trumped → - # over-trump failure; otherwise we discarded instead of trumping. - highest_opponent_trump = self._highest_opponent_trump( - plays, player.team, trump_suit - ) - if highest_opponent_trump is not None and card.suit == trump_suit: - return PlayRuleViolation.MUST_OVERTRUMP - return PlayRuleViolation.MUST_TRUMP - - @staticmethod - def _higher_trumps_than_played(trumps_in_hand, plays, trump_suit): - """Return the subset of *trumps_in_hand* that beat every trump in *plays*. - - Used by the over-trump rule when the led suit is itself trump. - Returns an empty list if no trump has been played to the trick - yet (logically impossible here, but kept defensive) or if no - trump in hand beats the current best. - """ - best_so_far = None - for _, card in plays: - if card.suit != trump_suit: - continue - if best_so_far is None or card.get_order(trump_suit) > best_so_far.get_order(trump_suit): - best_so_far = card - if best_so_far is None: - return [] - return [ - c for c in trumps_in_hand - if c.get_order(trump_suit) > best_so_far.get_order(trump_suit) - ] - - @staticmethod - def _highest_opponent_trump(plays, player_team, trump_suit): - """Return the highest trump played by an opponent of *player_team*, or None.""" - highest = None - for trick_player, card in plays: - if card.suit != trump_suit or trick_player.team == player_team: - continue - if highest is None or card.get_order(trump_suit) > highest.get_order(trump_suit): - highest = card - return highest diff --git a/packages/contrai-engine/src/contrai_engine/model/round/__init__.py b/packages/contrai-engine/src/contrai_engine/model/round/__init__.py new file mode 100644 index 0000000..107f8ad --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/round/__init__.py @@ -0,0 +1,14 @@ +"""Round subpackage — public API re-exports. + +The single ``round.py`` module was split into a ``round/`` subpackage +(the lifecycle orchestrator plus the near-pure scoring transformation it +calls). This ``__init__`` re-exports the +historical public names so external imports +(``from contrai_engine.model.round import Round, UnannouncedSlam``) keep +working byte-for-byte. +""" + +from .round import Round +from .scoring import UnannouncedSlam + +__all__ = ["Round", "UnannouncedSlam"] diff --git a/packages/contrai-engine/src/contrai_engine/model/round/round.py b/packages/contrai-engine/src/contrai_engine/model/round/round.py new file mode 100644 index 0000000..b22d346 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/round/round.py @@ -0,0 +1,454 @@ +"""Round class for the contrée card game. + +This class represents a complete round of the card game from dealing to +scoring. +""" + +import itertools +from typing import Optional, Dict, List, TYPE_CHECKING + +from contrai_core.auction import Auction +from contrai_core.bid import Bid +from contrai_core.contract import Contract +from contrai_core.play import Play, PlayState +from contrai_core.trick import Trick +from contrai_core.types import Rank, Suit + +from .scoring import UnannouncedSlam, score_round + +if TYPE_CHECKING: + from ..player import Player + from contrai_core.team import Team + from contrai_core.deck import Deck + + +class Round: + """ + Represents a complete round of the card game from dealing to scoring. + + Manages the complete round lifecycle including bidding phase coordination, + trick sequence management, and round score calculation. + """ + + def __init__(self, players_order: List[Player], dealer: Player, deck: Deck, round_number: int): + """ + Initialize a round with the given parameters. + + Args: + players_order: List of players in playing order for this round + dealer: The dealer for this round + deck: The deck to use for dealing cards + round_number: The current round number + """ + self.players_order = players_order + self.dealer = dealer + self.deck = deck + self.round_number = round_number + + # Round state + self.contract: Optional[Contract] = None + # The auction that produced ``contract``, retained by + # ``manage_bidding`` once the bidding phase closes. ``None`` until + # then. Kept so the play phase can attach the bidding history to + # the observation it hands each card-play strategy. + self.auction: Auction | None = None + # The immutable core play-phase state — authoritative for whose + # turn it is, which cards are legal, and each seat's remaining + # cards. Seeded at the start of play (by ``play_all_tricks``, or + # lazily by ``play_trick`` when driven directly); ``None`` before + # play begins. The engine mirrors it onto ``current_trick`` and the + # players' hands so the view keeps reading the classic engine + # objects. AI seats instead read the frozen ``PlayObservation`` + # projected from this state. + self.play_state: PlayState | None = None + self.tricks: List[Trick] = [] + self.current_trick: Optional[Trick] = None + self.last_trick_winner: Optional[Player] = None + self.team_tricks: Dict[str, List[Trick]] = {} + self.round_scores: Dict[str, int] = {} + # Single source of truth for the contract outcome, set by + # ``calculate_round_scores``. ``None`` until scored (or when the + # round was all-passed). The view reads this rather than + # re-deriving "made" from the scores — a failed declarer can + # still score a non-zero Belote bonus, so "round_score > 0" is + # not a reliable made/failed signal. + self.contract_made: Optional[bool] = None + # Unannounced-Slam marker, set by ``calculate_round_scores``. + # ``None`` when the round was not an unannounced Slam; otherwise + # the matching :class:`UnannouncedSlam` member — ``SLAM`` (the + # declaring *team* swept all 8 tricks) or ``GRAND_SLAM`` (the + # contracting *player personally* won them all). Only set for + # un-doubled numeric contracts — the path that swaps the + # 162-point pile for a flat 250 substitute. The view reads this to + # render the 250 and its explanatory tag. + self.unannounced_slam: Optional[UnannouncedSlam] = None + + # Belote / rebelote announcement state. ``belote_holder`` is the + # unique player holding both the K and the Q of trump at deal time + # (None when no one has both, or when the contract is NO_TRUMP / + # passed). ``belote_state`` tracks which of the two cards they + # have already played: missing → not yet announced; "belote" → + # one played; "rebelote" → both played. + self.belote_holder: Optional[Player] = None + self.belote_state: Dict[Player, str] = {} + + # Initialize team tricks dictionary + if players_order: + teams = set(player.team for player in players_order) + self.team_tricks = {team.name: [] for team in teams} + + def deal_cards(self): + """ + Deal cards to all players in the proper order. + Dealer gets cards last. + """ + self.deck.deal(self.players_order) + + def manage_bidding(self, view=None) -> Optional[Contract]: + """Handle the complete bidding phase. + + Drives an :class:`Auction` through the standard cyclic + ``players_order``. Each iteration: + + 1. Look up the legal actions for the active player. When the + only legal action is :class:`PassBid` (partner just doubled + or redoubled, or a pass already closed the redouble window) + the engine auto-applies it without prompting the player or + the view. + 2. Otherwise consult ``player.choose_bid`` and — for the + human seat — ``view.request_bid_action`` to gather the + player's chosen :class:`Bid`. + 3. Apply the bid via :meth:`Auction.apply`. An illegal bid + raises :class:`IllegalBidError` — there is no silent + "force a Pass on illegal" fallback any more. + + Args: + view: Optional view that drives human input and pacing + hooks. + + Returns: + The established :class:`Contract`, or ``None`` if every + player passed. + """ + + auction = Auction.empty() + player_iter = itertools.cycle(self.players_order) + + while not auction.is_terminal(): + player = next(player_iter) + legal = auction.legal_actions(player) + if len(legal) == 1: + # Pass is the only legal action — skip both the AI + # strategy and the human prompt entirely. Covers the + # "partner doubled / redoubled" UX as a special case + # of the general "no real choice" rule. + bid = legal[0] + else: + bid = self._gather_bid(player, auction, view) + auction = auction.apply(bid) + # Notify the view that a bid was just registered. Used by + # interactive views to render the AI action and pause + # briefly before the next bidder. + if view is not None and hasattr(view, 'on_bid_made'): + view.on_bid_made(player, bid, list(auction.bids)) + + self.auction = auction + self.contract = auction.contract() + if self.contract is not None: + self._detect_belote_holder() + # Bookmark the contract in the event log so the start of + # play is clearly delimited. + if view is not None and hasattr(view, 'on_contract_established'): + view.on_contract_established(self) + + return self.contract + + def _gather_bid( + self, + player: Player, + auction: Auction, + view, + ) -> Bid: + """Ask ``player`` for a :class:`Bid`, consulting ``view`` for humans. + + Args: + player: The active bidder. + auction: The current auction state. Passed verbatim to + both ``player.choose_bid`` and (for humans) + ``view.request_bid_action``. + view: The optional view. + + Returns: + The player's chosen :class:`Bid`. + + Raises: + RuntimeError: If neither the player nor the view produced + a bid — that's an engine wiring bug (e.g. a + :class:`HumanPlayer` with no view attached). + """ + + bid: Optional[Bid] = None + if hasattr(player, 'choose_bid'): + bid = player.choose_bid(auction) + if ( + view is not None + and getattr(player, 'is_human', False) + and hasattr(view, 'request_bid_action') + ): + bid = view.request_bid_action(player, auction) + if bid is None: + raise RuntimeError( + f"No bid produced for {player.position}: " + f"choose_bid returned None and the view did not intercept." + ) + return bid + + def _is_belote_event(self, player: Player, card) -> bool: + """True if *player* playing *card* counts toward a belote announcement.""" + if self.belote_holder is None or self.contract is None: + return False + if player is not self.belote_holder: + return False + trump = self.contract.suit + return card.suit == trump and card.rank in (Rank.KING, Rank.QUEEN) + + def _transition_belote_state(self, player: Player) -> Optional[str]: + """Advance the belote_state machine and return the new state name. + + Returns ``"belote"`` if this is the first of the K+Q pair played, + ``"rebelote"`` if it's the second, or ``None`` if the player has + already fired both (defensive — shouldn't happen, since each card + is unique). + """ + current = self.belote_state.get(player) + if current is None: + self.belote_state[player] = "belote" + return "belote" + if current == "belote": + self.belote_state[player] = "rebelote" + return "rebelote" + return None + + def _detect_belote_holder(self) -> None: + """Snapshot which player (if any) holds the K + Q of trump. + + Belote/rebelote is a per-round, per-holder narrative event: + whoever holds both cards announces ``Belote`` on the first they + play and ``Rebelote`` on the second. No-trump contracts have no + belote. + """ + if self.contract is None or self.contract.suit == Suit.NO_TRUMP: + self.belote_holder = None + return + trump = self.contract.suit + for player in self.players_order: + has_king = player.hand.has_card(trump, Rank.KING) + has_queen = player.hand.has_card(trump, Rank.QUEEN) + if has_king and has_queen: + self.belote_holder = player + return + self.belote_holder = None + + def _sync_hands(self) -> None: + """Re-mirror the players' hands from the authoritative play state. + + :attr:`play_state` is the single source of truth for each seat's + remaining cards; the players' :class:`~contrai_core.Hand` objects + are mutable mirrors kept in lock-step so the view — which still + reads ``player.hand`` — stays correct. Clearing and re-extending + the same ``Hand`` in place preserves its object identity, the card + object references, and their relative order. + """ + for player in self.players_order: + player.hand.clear() + player.hand.extend(self.play_state.hand_of(player)) + + def play_trick(self, view=None) -> None: + """ + Play a single trick. + + The trick is driven by the immutable core :class:`PlayState`: the + active player, the legal cards, and each play's effect on the hands + all come from it. The engine keeps two mutable mirrors in + lock-step — :attr:`current_trick` and each ``player.hand`` — for the + view, which still reads the classic engine objects. Each AI seat is + instead handed the frozen :class:`PlayObservation` projected from + the play state, and derives its own card tracking from that. + + Args: + view: Optional view for human player interaction + """ + # Lazy-seed guard: tests drive ``play_trick`` directly without going + # through ``play_all_tricks``. Seed from the current contract, + # seating and hands via the bare (unvalidated) constructor — + # mirroring the Auction idiom — since a directly injected mid-round + # hand need not hold the full 8 cards. + if self.play_state is None: + self.play_state = PlayState( + self.contract, + tuple(self.players_order), + tuple(tuple(player.hand) for player in self.players_order), + ) + + # The mutable mirror of the trick. Its object identity must persist + # across all four ``on_card_played`` notifications and the + # ``on_trick_complete`` call, so it is built once here and never + # rebuilt mid-trick. + self.current_trick = Trick() + + # Trump is fixed for the whole trick; resolve it once for the + # final winner call. + trump_suit = self.contract.suit if self.contract else None + + # Four plays make a trick. The active player and the legal cards + # come from the authoritative play state, never from local + # bookkeeping. + for _ in range(4): + player = self.play_state.to_act + playable_cards = list(self.play_state.legal_actions(player)) + + # Source the card. A human's input is driven by the view — + # the model can't block on terminal I/O under MVC — so we + # ask the view directly and never call choose_card, whose + # HumanPlayer override only returns None. AI players (and any + # viewless/headless player) are asked via choose_card, with a + # bare first-playable fallback for objects that lack it. + if view is not None and getattr(player, 'is_human', False): + card = view.request_card_action( + player, self.current_trick, self.contract, playable_cards + ) + elif hasattr(player, 'choose_card'): + # Hand the AI a frozen observation projected from the + # authoritative play state — its own hand, legal cards, and + # the public trick history — attaching the retained + # auction's bids (empty until the auction is set). + card = player.choose_card( + self.play_state.observe( + player, + bids=self.auction.bids if self.auction else (), + ) + ) + else: + # Simple fallback: play first playable card + card = playable_cards[0] if playable_cards else None + + # Advance the authoritative state. The core enforces turn order + # and legality itself: an out-of-turn, not-held, or + # obligation-breaking card raises IllegalPlayError rather than + # being silently corrected — so an absent/illegal card from + # choose_card / request_card_action fails loudly here. + self.play_state = self.play_state.apply(Play(player, card)) + + # Re-mirror the hands from the new state, then mirror the play + # onto the trick. Model bookkeeping stays ahead of view pacing. + self._sync_hands() + self.current_trick.add_play(player, card) + + # Notify the view that a card just landed on the table. + # Lets interactive views render the AI action and pause. + if view is not None and hasattr(view, 'on_card_played'): + view.on_card_played(player, card, self.current_trick) + + # Belote / rebelote announcement. Fires only when the holder + # plays one of the K/Q of trump. Each card fires at most once. + if self._is_belote_event(player, card): + kind = self._transition_belote_state(player) + if kind is not None and view is not None and hasattr( + view, 'on_belote_announced' + ): + view.on_belote_announced(player, kind, self) + + # Determine trick winner. Who wins is a pure rule of the trick + # given trump, so we delegate to contrai-core rather than duplicate + # the comparison here. The contract carries the authoritative trump + # suit (None only defensively, before a contract is established). + winner = self.current_trick.get_current_winner(trump_suit) + self.last_trick_winner = winner + + # Add trick to the tricks list and to winner's team + if self.current_trick: + self.tricks.append(self.current_trick) + if winner and winner.team: + self.team_tricks[winner.team.name].append(self.current_trick) + + # Add cards back to deck (last card played first, then reverse order) + if self.current_trick and hasattr(self.current_trick, 'get_plays'): + trick_cards = [card for _, card in self.current_trick.get_plays()] + trick_cards.reverse() # Last card played becomes first to be added back + self.deck.add_cards(trick_cards) + + # Notify the view that a trick just completed (optional view hook). + # Used by interactive views (e.g. RichView) to pause for "Press Enter" + # between tricks. Skipped silently when no such hook exists. + if view is not None and hasattr(view, 'on_trick_complete'): + view.on_trick_complete(self.current_trick, winner, self) + + return + + def play_all_tricks(self, view=None) -> Dict[str, List[Trick]]: + """ + Play all 8 tricks of the round. + + Args: + view: Optional view for human player interaction + + Returns: + Dict mapping team names to their tricks + """ + # Initialize team tricks tracking + self.last_trick_winner = None + + # Seed the authoritative play state from the fresh deal — a + # validated start (4 seats, 8 distinct cards each). The very Card + # objects held in the players' hands flow into it, so the view can + # keep matching playable cards by identity. + self.play_state = PlayState.start( + self.contract, + tuple(self.players_order), + tuple(tuple(player.hand) for player in self.players_order), + ) + + # Play 8 tricks + for _ in range(8): + self.play_trick(view) + + return self.team_tricks + + def calculate_round_scores(self) -> Dict[str, int]: + """ + Calculate scores for this round. + + Thin lifecycle wrapper around the pure :func:`scoring.score_round` + transformation: it runs the scoring rules over the round's final + state and publishes the three result attributes the view reads — + :attr:`round_scores`, :attr:`contract_made` (the canonical + made/failed signal), and :attr:`unannounced_slam`. The scoring + shapes (numeric, unannounced Slam, doubled winner-takes-all, + Slam / Solo Slam) and the Belote rule all live in + :mod:`scoring`. + + Returns: + Dict: Team scores for this round + """ + result = score_round(self) + self.round_scores = result.scores + self.contract_made = result.contract_made + self.unannounced_slam = result.unannounced_slam + return self.round_scores + + def handle_failed_contract(self) -> Dict[str, int]: + """ + Manage cards when all players pass. + + Returns: + Dict: Zero scores for all teams + """ + # Put all players' cards back in deck (8 cards per player) + for player in self.players_order: + self.deck.add_cards(player.hand) + player.hand.clear() + + # Return zero scores + teams = set(player.team for player in self.players_order) + self.round_scores = {team.name: 0 for team in teams} + return self.round_scores diff --git a/packages/contrai-engine/src/contrai_engine/model/round/scoring.py b/packages/contrai-engine/src/contrai_engine/model/round/scoring.py new file mode 100644 index 0000000..51bd44c --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/model/round/scoring.py @@ -0,0 +1,324 @@ +"""Round scoring — the pure transformation from a played-out round to +its team scores. + +``score_round`` reads the final round state (contract, captured tricks, +last-trick winner, belote holder) and returns a :class:`RoundScore` +result; it mutates nothing. The thin ``Round.calculate_round_scores`` +wrapper unpacks that result onto the round's public result attributes. +Keeping the maths side-effect-free here isolates ~250 lines of scoring +rules from the lifecycle orchestrator. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Optional, TYPE_CHECKING + +from contrai_core.bid import SlamLevel + +if TYPE_CHECKING: + from contrai_core.trick import Trick + from contrai_core.types import Suit + from ..player import Player + from .round import Round + + +class UnannouncedSlam(Enum): + """Outcome tag for an *undeclared* all-tricks sweep on a numeric contract. + + Set by :func:`score_round` (via :meth:`Round.calculate_round_scores`) + after play, when the declaring team takes all 8 tricks on an + un-doubled numeric (80-180) contract without having announced + anything. The round still scores on the numeric path — the bidder's + contract value plus a flat 250 substitute for the trick pile — *not* + the Slam at-risk grid. + + This is deliberately distinct from :class:`contrai_core.SlamLevel`: that + enum is a *declared bid value*; this is a post-play classification, and + its ``GRAND_SLAM`` member is named for the undeclared sweep (it is not a + Solo Slam). Each member's value is its display label, so ``str(tag)`` + yields the text the recap panel shows. + + Members: + SLAM: The declaring *team* swept all 8 tricks (partner won at + least one). + GRAND_SLAM: The contracting *player personally* won all 8 tricks. + """ + + SLAM = "Slam" + GRAND_SLAM = "Grand Slam" + + def __str__(self) -> str: + return self.value + + +@dataclass +class RoundScore: + """Result of scoring a played-out round. + + A side-effect-free bundle of the three values + :meth:`Round.calculate_round_scores` publishes onto the round: + + Attributes: + scores: Per-team round scores, keyed by team name. + contract_made: The canonical made/failed signal — ``None`` when + the round was all-passed (no contract), else a bool. + unannounced_slam: The :class:`UnannouncedSlam` tag when the + declaring team swept all 8 tricks on an un-doubled numeric + contract, else ``None``. + """ + + scores: Dict[str, int] + contract_made: Optional[bool] + unannounced_slam: Optional[UnannouncedSlam] + + +def count_player_tricks( + tricks: List['Trick'], trump_suit: Optional['Suit'], player: 'Player' +) -> int: + """Count the number of completed tricks personally won by ``player``. + + Walks the round's trick history and asks each trick for its + winner via :meth:`contrai_core.Trick.get_current_winner`, + forcing the contract's trump suit so trump beats lead-suit + regardless of whether the trick had its ``trump_suit`` bound + at construction time. Used by the Solo Slam predicate in + :func:`score_round`. + + Args: + tricks: The round's completed tricks. + trump_suit: The contract's trump suit, forced into the winner + comparison. + player: The player whose personal trick tally we want. + + Returns: + The number of completed tricks won outright by ``player``. + """ + if not tricks or trump_suit is None: + return 0 + count = 0 + for trick in tricks: + winner = trick.get_current_winner(trump_suit) + if winner is player: + count += 1 + return count + + +def score_round(round_: 'Round') -> RoundScore: + """Score a played-out round into a :class:`RoundScore`. + + Three scoring shapes, all sharing the same Belote rule: + + - **Numeric, un-doubled (M = 1).** Made → declarer scores + ``C + P_attack`` and the defense keeps its own card points; + failed → the defense scores ``160 + C`` and the declarer + scores nothing. ``P_attack`` is the declarer's card points + (which already include the last-trick bonus) plus the Belote + bonus when the declarer holds it. + - **Unannounced Slam (M = 1).** When the declaring team wins + *all 8 tricks* on a numeric contract without having bid a + Slam, the trick pile (152 cards + the 10-point last-trick + bonus = 162) is + replaced by a flat **250** substitute: the declarer scores + ``C + 250`` (+ Belote), the defense scores nothing, and the + contract is necessarily made. The personal-trick predicate + tags it :attr:`UnannouncedSlam.GRAND_SLAM` when the + *contracting player* won all 8, else + :attr:`UnannouncedSlam.SLAM`. Only un-doubled — a + doubled/redoubled sweep keeps the winner-takes-all shape below. + - **Numeric, doubled / redoubled (M > 1).** Winner-takes-all: + the side that wins the round takes the whole pile, the loser + scores 0. The winner scores ``160 + C × M`` whether it is the + declarer (made) or the defense (failed). See + contree-domain.md §7.2. + - **Slam / Solo Slam.** A symmetric grid that replaces the + 162-point pile with a flat substitute equal to the base: the + winning side scores ``(base + substitute) × M`` (500 / 1000 / + 2000 for Slam; 1000 / 2000 / 4000 for Solo Slam). Solo Slam + additionally requires the *contracting player personally* to + win every trick. + + Across every shape the **Belote bonus (+20)** is credited to the + team *holding* both K and Q of trump (``round_.belote_holder`` — not + whoever captures the cards in a trick) and is always preserved, + even for the side that loses the round. + + Args: + round_: The played-out round, read by reference (contract, + team_tricks, tricks, last_trick_winner, belote_holder, + players_order). Nothing on it is mutated. + + Returns: + A :class:`RoundScore` carrying the per-team scores, the + made/failed signal, and any unannounced-Slam tag. + """ + if not round_.contract: + # No contract established, return zero scores. + teams = set(player.team for player in round_.players_order) + return RoundScore( + scores={team.name: 0 for team in teams}, + contract_made=None, + unannounced_slam=None, + ) + + contract_team = round_.contract.player.team + contract_value = round_.contract.value + trump_suit = round_.contract.suit + + team_card_points = {team_name: 0 for team_name in round_.team_tricks.keys()} + team_scores = {team_name: 0 for team_name in round_.team_tricks.keys()} + + # Card points per team (trump-aware). Belote is deliberately NOT + # folded in here — it is a *held-cards* bonus credited below to + # the holder's team, independent of who captured the K/Q. + for team_name, tricks in round_.team_tricks.items(): + points = 0 + for trick in tricks: + if hasattr(trick, 'get_plays'): + for _player, card in trick.get_plays(): + points += card.get_points(trump_suit) + team_card_points[team_name] = points + + # Add the last-trick bonus (10 points for winning the last trick). + if round_.last_trick_winner and round_.last_trick_winner.team: + team_card_points[round_.last_trick_winner.team.name] += 10 + + # Belote (+20) belongs to the team *holding* K + Q of trump, not + # to whoever wins the trick those cards land in. ``belote_holder`` + # is the single player holding both at deal time (None when split, + # or at No-Trump). + belote_team: Optional[str] = None + if round_.belote_holder is not None and round_.belote_holder.team is not None: + belote_team = round_.belote_holder.team.name + + def belote_bonus(team_name: str) -> int: + """Belote (+20) for ``team_name`` when it holds the pair.""" + return 20 if team_name == belote_team else 0 + + contract_team_name = contract_team.name + + # Multiplier for double/redouble (shared by both paths). + multiplier = round_.contract.get_multiplier() + + # ----- Slam / Solo Slam scoring path ----- + # The 162 of trick-card points is replaced by a flat substitute + # equal to the contract base (see Contract.get_slam_card_substitute). + # The full at-risk amount is (base + substitute) × multiplier, + # giving 500 / 1000 / 2000 for Slam and 1000 / 2000 / 4000 for + # Solo Slam at normal / doubled / redoubled. The grid is symmetric: + # whichever side wins the contract scores the at-risk amount. + if round_.contract.is_slam_family(): + contract_team_trick_count = len(round_.team_tricks[contract_team_name]) + contract_made = contract_team_trick_count == 8 + + # Solo Slam: the bidder *personally* must win all 8 tricks. + # Even if their team takes every trick collectively, the + # contract fails when the partner won any of them. + if round_.contract.is_solo_slam(): + bidder_personal_tricks = count_player_tricks( + round_.tricks, trump_suit, round_.contract.player + ) + contract_made = contract_made and bidder_personal_tricks == 8 + + base = round_.contract.get_base_points() + substitute = round_.contract.get_slam_card_substitute() + at_risk = (base + substitute) * multiplier + if contract_made: + team_scores[contract_team_name] = at_risk + else: + for team_name in team_scores: + if team_name != contract_team_name: + team_scores[team_name] = at_risk + + # Belote (+20) layered on top — independent of who won the contract. + if belote_team is not None: + team_scores[belote_team] += 20 + + return RoundScore( + scores=team_scores, + contract_made=contract_made, + unannounced_slam=None, + ) + + # ----- Numeric contract scoring path (80-180) ----- + defender_names = [t for t in team_scores if t != contract_team_name] + + # Unannounced Slam: the declaring team swept all 8 tricks on a + # numeric contract. Recognised only un-doubled — the + # doubled/redoubled path keeps its winner-takes-all 160 + C×M + # shape regardless. The trick pile (152 cards + the 10-point + # last-trick bonus) is replaced by a flat 250 substitute and the + # contract is necessarily made. GRAND_SLAM when the contracting + # player won all 8 personally (the Solo Slam predicate), else plain + # SLAM. The 250 substitute is the same flat amount a *declared* Slam + # is worth, so it reads from the SlamLevel single source of truth. + UNANNOUNCED_SLAM_SUBSTITUTE = SlamLevel.SLAM.base_value + unannounced_slam: Optional[UnannouncedSlam] = None + declarer_slam = ( + multiplier == 1 + and len(round_.team_tricks[contract_team_name]) == 8 + ) + if declarer_slam: + bidder_personal_tricks = count_player_tricks( + round_.tricks, trump_suit, round_.contract.player + ) + unannounced_slam = ( + UnannouncedSlam.GRAND_SLAM + if bidder_personal_tricks == 8 + else UnannouncedSlam.SLAM + ) + + # The declarer's *realized* points decide made/failed: card + # points (already including the last-trick bonus) plus the Belote + # bonus when the declarer holds it. An unannounced Slam is made + # outright — sweeping every trick can never fail. + attacker_realized = ( + team_card_points[contract_team_name] + belote_bonus(contract_team_name) + ) + contract_made = declarer_slam or attacker_realized >= contract_value + + if multiplier == 1: + # Un-doubled: the two sides share the pile. + if contract_made: + # On an unannounced Slam the 162 pile (last-trick bonus + # included) is swapped for the flat 250 substitute; otherwise the + # declarer adds its real captured card points. + attacker_pile = ( + UNANNOUNCED_SLAM_SUBSTITUTE + if declarer_slam + else team_card_points[contract_team_name] + ) + team_scores[contract_team_name] = ( + contract_value + + attacker_pile + + belote_bonus(contract_team_name) + ) + for name in defender_names: + team_scores[name] = team_card_points[name] + belote_bonus(name) + else: + # Failed: the defense takes the whole pile plus + # the contract; the declarer keeps only its Belote bonus. + team_scores[contract_team_name] = belote_bonus(contract_team_name) + for name in defender_names: + team_scores[name] = (160 + contract_value) + belote_bonus(name) + else: + # Doubled / redoubled: winner-takes-all. The losing side + # scores nothing but its Belote bonus (always preserved). + if contract_made: + team_scores[contract_team_name] = ( + 160 + contract_value * multiplier + + belote_bonus(contract_team_name) + ) + for name in defender_names: + team_scores[name] = belote_bonus(name) + else: + team_scores[contract_team_name] = belote_bonus(contract_team_name) + for name in defender_names: + team_scores[name] = ( + 160 + contract_value * multiplier + belote_bonus(name) + ) + + return RoundScore( + scores=team_scores, + contract_made=contract_made, + unannounced_slam=unannounced_slam, + ) diff --git a/packages/contrai-engine/src/contrai_engine/view/__init__.py b/packages/contrai-engine/src/contrai_engine/view/__init__.py index 576352b..3b42da9 100644 --- a/packages/contrai-engine/src/contrai_engine/view/__init__.py +++ b/packages/contrai-engine/src/contrai_engine/view/__init__.py @@ -1,2 +1,18 @@ -# CLI View: user interactions +"""CLI View: user interactions. +The view layer is split into focused modules — design tokens +(:mod:`theme`), stateless formatters (:mod:`formatting`), input parsers +(:mod:`parsing`), auction-legality helpers (:mod:`bidding_rules`), +game-state readers (:mod:`state_helpers`), shared layout +(:mod:`layout`), and per-screen rendering (:mod:`screens`). The stateful +orchestrator :class:`~contrai_engine.view.rich_view.RichView` ties them +together and owns all terminal I/O. + +``RichView`` (and the :class:`~contrai_engine.view.rich_view.RoundSummary` +row it tracks) are re-exported here so callers can ``from +contrai_engine.view import RichView``. +""" + +from contrai_engine.view.rich_view import RichView, RoundSummary + +__all__ = ["RichView", "RoundSummary"] diff --git a/packages/contrai-engine/src/contrai_engine/view/bidding_rules.py b/packages/contrai-engine/src/contrai_engine/view/bidding_rules.py new file mode 100644 index 0000000..dfab64a --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/bidding_rules.py @@ -0,0 +1,54 @@ +"""Auction-legality messaging for the Rich terminal UI. + +Builds the specific nudge shown when a human types an illegal bid. The +authoritative legality verdict always remains :meth:`Auction.is_legal`; +this never decides anything, it only explains. The adaptive prompt hint +(which actions are legal for the next bidder) is derived directly from +:meth:`Auction.legal_actions` in +:func:`contrai_engine.view.screens.bidding._bidding_prompt_text`. +""" + +from __future__ import annotations + +from contrai_core import Auction +from contrai_core.bid import ( + Bid, + ContractBid, + DoubleBid, + RedoubleBid, + SlamLevel, +) + + +def _illegal_bid_reason(bid: Bid, auction: Auction) -> str: + """Return a human-readable reason ``bid`` is illegal in ``auction``. + + Used by the bid prompt loop to give the player a specific nudge + instead of a generic rejection. Pure string builder that mirrors the + rule checks in :class:`contrai_core.auction.Auction` for messaging + only — the authoritative legality verdict is + :meth:`Auction.is_legal`. Callers should only invoke this once the + bid is already known to be illegal. + """ + if isinstance(bid, DoubleBid): + if auction.last_contract_bid is None: + return "There's no contract to double yet." + if auction.has_double or auction.has_redouble: + return "This contract has already been doubled." + return ( + "You can only double the opposing team's contract, " + "not your own side's." + ) + if isinstance(bid, RedoubleBid): + return ( + "Redouble is only legal right after the opposing team " + "doubles your team's contract." + ) + if isinstance(bid, ContractBid): + last = auction.last_contract_bid + if last is not None and isinstance(last.value, SlamLevel): + return f"Nothing outranks a {last.value} bid — you can only pass." + if last is not None: + return f"Your bid must outrank the current contract ({last.value})." + return "That contract bid isn't legal here." + return "That bid isn't legal right now." diff --git a/packages/contrai-engine/src/contrai_engine/view/formatting.py b/packages/contrai-engine/src/contrai_engine/view/formatting.py new file mode 100644 index 0000000..2328e57 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/formatting.py @@ -0,0 +1,216 @@ +"""Stateless text/glyph/label builders for the Rich terminal UI. + +Pure ``(data) -> str | Text`` formatters with no game state and no I/O: +seat/team labels and colors, suit glyphs and colors, the compact card +label, and the shared contract / trump / legacy-bid labels. The screen +modules and ``RichView`` compose these into panels. +""" + +from __future__ import annotations + +from typing import Optional + +from contrai_core import ( + BasePlayer, + Card, + Contract, + Rank, + Suit, +) +from contrai_core.bid import ( + Bid, + ContractBid, + DoubleBid, + PassBid, + RedoubleBid, +) +from rich.text import Text + +from contrai_engine.view.theme import ( + BLUE, + DIM, + FG, + GOLD, + ORANGE, + POSITION_SHORT, + RED, + RED_DIM, + TEAM_ABBR, +) + + +def _position_short(position: str) -> str: + """Single-letter seat label: ``"North"`` -> ``"N"``.""" + return POSITION_SHORT.get(position, position[:1]) + + +def _position_color(position: str) -> str: + """Seat color by team: blue for N/S seats, orange for E/W.""" + return BLUE if position in ("North", "South") else ORANGE + + +def _team_color(team_name: str) -> str: + """Team color: blue for North-South, orange for East-West.""" + return BLUE if team_name == "North-South" else ORANGE + + +def _team_abbr(team_name: str) -> str: + """Scoreboard abbreviation: ``"North-South"`` -> ``"N-S"``.""" + return TEAM_ABBR.get(team_name, team_name) + + +def _suit_glyph(suit: Suit) -> str: + """Unicode suit glyph (``♠``/``♥``/``♦``/``♣``), falling back to the enum value.""" + return Card.SUIT_SYMBOLS.get(suit, suit.value) + + +# Short rank labels for the hand row and trick diamond. The engine's +# Rank.value strings spell "Jack"/"Queen"/"King"/"Ace" in full; the +# mockup uses single-letter abbreviations so 8 cards fit a 70-col row. +RANK_SHORT = { + Rank.SEVEN: "7", + Rank.EIGHT: "8", + Rank.NINE: "9", + Rank.TEN: "10", + Rank.JACK: "J", + Rank.QUEEN: "Q", + Rank.KING: "K", + Rank.ACE: "A", +} + + +def _rank_short(rank: Rank) -> str: + """Short rank label from ``RANK_SHORT``: ``Rank.JACK`` -> ``"J"``.""" + return RANK_SHORT.get(rank, rank.value) + + +def _suit_color(suit: Suit) -> str: + """Suit color: red for hearts/diamonds, plain foreground otherwise.""" + return RED if suit in (Suit.HEARTS, Suit.DIAMONDS) else FG + + +def _suit_color_dim(suit: Suit) -> str: + """Dimmed variant of :func:`_suit_color` for de-emphasized cards.""" + return RED_DIM if suit in (Suit.HEARTS, Suit.DIAMONDS) else DIM + + +def _format_card_compact(card: Card) -> Text: + """Render a card as ``"K♠"`` style — bold, with suit color.""" + t = Text() + t.append(_rank_short(card.rank), style="bold") + t.append(_suit_glyph(card.suit), style=f"bold {_suit_color(card.suit)}") + return t + + +def _seat_letter(player: Optional[BasePlayer]) -> Optional[Text]: + """Single-letter seat label colored by the player's team, or ``None``. + + Used to name the players behind a contract: the taker whose bid set + it, and the double / redouble caller. Each letter keeps the + seat's team color (blue for N-S, orange for E-W). + """ + if player is None or getattr(player, "position", None) is None: + return None + return Text( + _position_short(player.position), + style=f"bold {_position_color(player.position)}", + ) + + +def _format_contract_short( + contract: Contract, *, verbose: bool = False, suit_glyph: bool = False +) -> Text: + """Short label: ``"100 by E ×2 by S"``. + + Names the players, not just the team: the contract-setter follows + ``by`` as a single team-colored seat letter, and any double / redouble + shows its multiplier with the caller's seat + (``×2 by S`` / ``×4 by N``). + + Args: + contract: The materialized contract to render. + verbose: When ``True``, spell the double / redouble markers + out as ``doubled`` / ``redoubled`` instead of the compact + ``×2`` / ``×4`` glyphs. The recap panel uses this so the + after-round summary reads in full prose; the in-game panel + and event log keep the compact form. + suit_glyph: When ``True``, slot the trump glyph right after the + value (``"100 ♥ by E"``). The event-log 'Contract set' line + uses this because it carries no other trump hint; the round + panel and recap keep the default — they already show the + trump on a dedicated line. + """ + double_label = "redoubled" if verbose else "×4" + single_label = "doubled" if verbose else "×2" + t = Text() + # SlamLevel.__str__ already yields "Slam" / "Solo Slam"; numerics + # stringify to "80" … "180". + value_str = str(contract.value) + t.append(value_str, style="bold") + if suit_glyph: + t.append(" ", style=FG) + t.append( + _suit_glyph(contract.suit), + style=f"bold {_suit_color(contract.suit)}", + ) + t.append(" by ", style=DIM) + taker = _seat_letter(getattr(contract, "player", None)) + if taker is not None: + t.append_text(taker) + else: + # Defensive fallback: name the team if the player is missing. + t.append(_team_abbr(contract.team.name), + style=f"bold {_team_color(contract.team.name)}") + # Double / redouble: multiplier plus the player who called it. + if contract.redouble: + caller = _seat_letter(getattr(contract, "redouble_player", None)) + t.append(f" {double_label}", style=GOLD) + if caller is not None: + t.append(" by ", style=DIM) + t.append_text(caller) + elif contract.double: + caller = _seat_letter(getattr(contract, "double_player", None)) + t.append(f" {single_label}", style=GOLD) + if caller is not None: + t.append(" by ", style=DIM) + t.append_text(caller) + return t + + +def _format_trump_label(suit: Optional[Suit]) -> Text: + """``"♥ Hearts"`` with the glyph in suit color. + + Args: + suit: The trump suit to render, or ``None`` for an em-dash. + """ + if suit is None: + return Text("—", style=DIM) + t = Text() + t.append(_suit_glyph(suit), style=_suit_color(suit)) + t.append(" ", style=FG) + label = "No Trump" if suit == Suit.NO_TRUMP else suit.value + t.append(label, style="bold") + return t + + +def _bid_label(bid: Bid) -> Text: + """Compact bid label for the bidding history and diamond. + + Renders a :class:`~contrai_core.bid.Bid` to the short glyphs the + auction panels use: ``Pass`` for a pass, ``×2`` / ``×4`` for a + double / redouble, and ``" "`` for a numeric + or Slam-family contract. + """ + if isinstance(bid, PassBid): + return Text("Pass", style=DIM) + if isinstance(bid, DoubleBid): + return Text("×2", style=GOLD) + if isinstance(bid, RedoubleBid): + return Text("×4", style=GOLD) + if isinstance(bid, ContractBid): + t = Text() + t.append(str(bid.value), style="bold") + t.append(" ", style=FG) + t.append(_suit_glyph(bid.suit), style=_suit_color(bid.suit)) + return t + return Text(str(bid), style=DIM) diff --git a/packages/contrai-engine/src/contrai_engine/view/layout.py b/packages/contrai-engine/src/contrai_engine/view/layout.py new file mode 100644 index 0000000..aaab3e9 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/layout.py @@ -0,0 +1,120 @@ +"""Cross-screen layout helpers for the Rich terminal UI. + +Building blocks shared by every screen: the two-column grid that places +panels side by side, the Prompt panel (question + optional rejection +notice), the rolling event-log panel, and the ``Game score`` panel (the +running game total shown in every in-game frame's top-left). Pure +builders — they take the data they render as explicit parameters; +``RichView`` owns the state and does the printing. +""" + +from __future__ import annotations + +from typing import Optional + +from rich.box import ROUNDED +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from contrai_engine.view.theme import ( + BLUE, + BORDER, + BORDER_DIM, + DIM, + DOT, + FG, + ORANGE, + TITLE, + YELLOW, +) + + +def _two_column(left, right, *, left_width: int) -> Table: + """Place two panels side-by-side with a fixed-width left column. + + A ``Table.grid`` keeps the row exactly as tall as the panels (unlike + ``rich.layout.Layout``, which expands to fill the console height). + """ + grid = Table.grid(expand=False, padding=(0, 1)) + grid.add_column(width=left_width, no_wrap=True) + grid.add_column(no_wrap=True) + grid.add_row(left, right) + return grid + + +def _panel_prompt( + question: Text, + mandatory: bool, + notice: Optional[Text] = None, +) -> Panel: + """The Prompt panel: the question plus an optional rejection notice. + + ``mandatory`` styles the question bold yellow (input the player must + answer now); ``notice`` carries the previous input's rejection. + """ + body = Text() + # A rejection from the previous input sits above the question, in + # red, so the player reads *why* the last entry bounced without it + # ever leaving the frame. The panel grows one row to fit it. + if notice is not None: + body.append_text(notice) + body.append("\n") + if mandatory: + q = question.copy() + q.stylize(f"bold {YELLOW}") + body.append_text(q) + else: + body.append_text(question) + body.append("\n") + return Panel( + body, + title=Text("Prompt", style=f"bold {TITLE}"), + border_style=BORDER, + box=ROUNDED, + width=70, + height=5 if notice is not None else 4, + ) + + +def _panel_game_score(scores: dict[str, int], target_score: int) -> Panel: + """Top-left panel with both teams' running totals and the target.""" + body = Text() + ns = scores.get("North-South", 0) + ew = scores.get("East-West", 0) + body.append(f"{'N-S':<8}", style=f"bold {BLUE}") + body.append(f"{ns:>10}\n", style=FG) + body.append(f"{'E-W':<8}", style=f"bold {ORANGE}") + body.append(f"{ew:>10}\n", style=FG) + body.append("·" * 18, style=DOT) + body.append("\n") + body.append(f"{'Target':<8}", style=DIM) + body.append(f"{target_score:>10}", style=f"bold {YELLOW}") + return Panel( + body, + title=Text("Game score", style=f"bold {TITLE}"), + border_style=BORDER, + box=ROUNDED, + width=22, + height=6, + ) + + +def _panel_event_log(event_log: list[Text], log_max: int) -> Panel: + """Bottom panel showing the last ``log_max`` events.""" + body = Text() + if not event_log: + body.append("(no events yet)", style=DIM) + else: + for i, line in enumerate(event_log): + if i > 0: + body.append("\n") + body.append_text(line) + return Panel( + body, + title=Text("Log", style=f"bold {TITLE}"), + border_style=BORDER_DIM, + box=ROUNDED, + width=70, + height=log_max + 2, + ) diff --git a/packages/contrai-engine/src/contrai_engine/view/parsing.py b/packages/contrai-engine/src/contrai_engine/view/parsing.py new file mode 100644 index 0000000..6b22422 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/parsing.py @@ -0,0 +1,119 @@ +"""Human-input parsers for the Rich terminal UI. + +Turn the raw strings a player types at the prompt into engine-shaped +values: a :class:`~contrai_core.bid.Bid` attached to the acting player, +or the selected :class:`~contrai_core.card.Card`. Both return ``None`` on +unrecognized input so the prompt loops can re-ask rather than crash. +Syntactic validation only — the auction and round rules own legality. +""" + +from __future__ import annotations + +from typing import Optional + +from contrai_core import BasePlayer, Card +from contrai_core.bid import ( + Bid, + ContractBid, + DoubleBid, + PassBid, + RedoubleBid, + SlamLevel, +) + +from contrai_engine.view.theme import ( + DOUBLE_WORDS, + PASS_WORDS, + REDOUBLE_WORDS, + SUIT_ALIASES, + VALID_BID_VALUES, +) + + +def _parse_bid_input(raw: str, player: BasePlayer) -> Optional[Bid]: + """Parse a human bid string into a :class:`Bid` for ``player``. + + Construction is always safe here: numeric values are checked against + ``VALID_BID_VALUES`` and the Slam sentinels are always valid, so the + resulting :class:`ContractBid` never trips ``__post_init__``. Legality + within the auction is a separate concern the caller checks with + :meth:`Auction.is_legal`. + + Accepted forms: + pass / p -> PassBid + double / d -> DoubleBid + redouble / r -> RedoubleBid + "80 h" / "100 hearts" / "150nt" -> ContractBid(value, Suit) + "slam s" / "slams" -> ContractBid(SlamLevel.SLAM, Suit) + "solo slam h" / "soloslam h" -> ContractBid(SlamLevel.SOLO_SLAM, Suit) + """ + s = raw.strip().lower() + if not s: + return None + if s in PASS_WORDS: + return PassBid(player) + if s in DOUBLE_WORDS: + return DoubleBid(player) + if s in REDOUBLE_WORDS: + return RedoubleBid(player) + + # Try "" with optional whitespace; also accept + # the value and suit being glued together ("100h", "slams"). + parts = s.replace(",", " ").split() + + # Accept the two-word form "solo slam " by collapsing the + # first two tokens into the canonical "soloslam" token. + if len(parts) == 3 and parts[0] == "solo" and parts[1] == "slam": + parts = ["soloslam", parts[2]] + + if len(parts) == 1: + token = parts[0] + # Split alpha tail (suit) from leading value. + i = 0 + while i < len(token) and (token[i].isdigit() or token[i] == "-"): + i += 1 + if i == 0: + # All-alpha: maybe "soloslams" -> soloslam + s, or "slams" -> slam + s + if token.startswith("soloslam") and len(token) > len("soloslam"): + parts = ["soloslam", token[len("soloslam"):]] + elif token.startswith("slam") and len(token) > len("slam"): + parts = ["slam", token[len("slam"):]] + else: + return None + else: + parts = [token[:i], token[i:]] + + if len(parts) != 2: + return None + raw_value, raw_suit = parts + suit = SUIT_ALIASES.get(raw_suit) + if suit is None: + return None + + if raw_value == "slam": + return ContractBid(player, SlamLevel.SLAM, suit) + if raw_value == "soloslam": + return ContractBid(player, SlamLevel.SOLO_SLAM, suit) + try: + value = int(raw_value) + except ValueError: + return None + if value not in VALID_BID_VALUES: + return None + return ContractBid(player, value, suit) + + +def _parse_card_input( + raw: str, sorted_hand: list[Card], playable: list[Card] +) -> Optional[Card]: + """Parse a card-selection number; validate it's in playable. None on error.""" + s = raw.strip() + if not s.isdigit(): + return None + idx = int(s) - 1 + if idx < 0 or idx >= len(sorted_hand): + return None + card = sorted_hand[idx] + if card not in playable: + return None + return card diff --git a/packages/contrai-engine/src/contrai_engine/view/rich_view.py b/packages/contrai-engine/src/contrai_engine/view/rich_view.py index 1331dd5..2d94094 100644 --- a/packages/contrai-engine/src/contrai_engine/view/rich_view.py +++ b/packages/contrai-engine/src/contrai_engine/view/rich_view.py @@ -1,7 +1,6 @@ """Rich-based terminal UI for the contrée game. -Implements the five-screen design from -``ContrAI CLI/design_handoff_contrai_tui/`` (landing, bidding, +Implements the five-screen design (landing, bidding, mid-trick, trick-won, game-over). Plugs into the engine through the existing view hook points: @@ -16,9 +15,8 @@ from __future__ import annotations -import os import time -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, Optional from contrai_core.bid import ( @@ -27,7 +25,6 @@ DoubleBid, PassBid, RedoubleBid, - SlamLevel, ) from contrai_core import ( @@ -35,597 +32,78 @@ BasePlayer, Card, Contract, - Rank, - Suit, Trick, ) -from contrai_engine.model.player import wire_to_bid -from rich.align import Align -from rich.box import DOUBLE, ROUNDED, SQUARE +from contrai_engine.view.bidding_rules import _illegal_bid_reason +from contrai_engine.view.formatting import ( + _format_card_compact, + _format_contract_short, + _position_color, + _position_short, + _suit_color, + _suit_glyph, +) +from contrai_engine.view.layout import ( + _panel_event_log, + _panel_game_score, + _panel_prompt, + _two_column, +) +from contrai_engine.view.parsing import _parse_bid_input, _parse_card_input +from contrai_engine.view.screens.bidding import ( + _ai_bid_announcement, + _bid_rejection_text, + _bidding_prompt_text, + _panel_bidding_history, +) +from contrai_engine.view.screens.endgame import ( + _end_game_prompt_text, + _panel_game_over_banner, + _panel_round_summary, +) +from contrai_engine.view.screens.landing import ( + _landing_prompt_text, + _landing_subtitle, + _landing_suit_ribbon, + _landing_title, + _panel_game_setup, + _panel_players, +) +from contrai_engine.view.screens.recap import ( + _contract_made, + _panel_round_recap, +) +from contrai_engine.view.screens.trick import ( + _ai_card_announcement, + _card_prompt_text, + _panel_current_trick, + _panel_hand, + _panel_last_trick, + _panel_round, + _trick_won_prompt_text, +) +from contrai_engine.view.state_helpers import ( + _resolve_delay, + _sort_hand_for_display, +) +from contrai_engine.view.theme import ( + DEFAULT_TARGET, + DIM, + FG, + GOLD, + GREEN_FG, + RED, + TARGET_OPTIONS, + YELLOW, +) from rich.console import Console -from rich.panel import Panel -from rich.table import Table from rich.text import Text -try: - from pyfiglet import Figlet - _HAS_PYFIGLET = True -except ImportError: - _HAS_PYFIGLET = False - if TYPE_CHECKING: - from contrai_engine.model.game import Game + from contrai_engine.model.game import Game, GameOverStatus from contrai_engine.model.round import Round -# --------------------------------------------------------------------------- -# Design tokens (mapped from the handoff README's color table) -# --------------------------------------------------------------------------- - -FG = "rgb(212,212,212)" -DIM = "rgb(106,106,106)" -BORDER = "rgb(122,122,122)" -BORDER_DIM = "rgb(68,68,68)" -TITLE = "rgb(200,200,200)" -RED = "rgb(224,108,117)" -RED_DIM = "rgb(122,58,63)" -BLUE = "rgb(127,182,255)" -ORANGE = "rgb(255,180,130)" -GREEN_BG = "rgb(46,90,42)" -GREEN_FG = "rgb(207,234,192)" -GREEN_CHECK = "rgb(58,122,58)" -YELLOW = "rgb(229,192,123)" -GOLD = "rgb(240,181,74)" -GOLD_BG = "rgb(58,43,16)" -GOLD_FG = "rgb(255,213,122)" -HINT = "rgb(61,61,64)" -RULE = "rgb(42,42,42)" -DOT = "rgb(58,58,58)" - -# Valid target scores shown on the landing radio. -TARGET_OPTIONS = [ - (500, "Quick game", "~10 min"), - (1000, "Short game", "~20 min"), - (1500, "Standard", "~30 min"), - (2000, "Long game", "~45 min"), - (3000, "Marathon", "~60 min"), -] -DEFAULT_TARGET = 1500 - -# Position label mapping: full engine name -> single-letter UI label. -POSITION_SHORT = {"North": "N", "East": "E", "South": "S", "West": "W"} - -# Team -> abbreviation used in scoreboards. -TEAM_ABBR = {"North-South": "N-S", "East-West": "E-W"} - -# Bid keyword aliases for parsing. -PASS_WORDS = {"pass", "p"} -DOUBLE_WORDS = {"double", "d"} -REDOUBLE_WORDS = {"redouble", "r"} -SUIT_ALIASES = { - "s": Suit.SPADES, "spades": Suit.SPADES, "spade": Suit.SPADES, "♠": Suit.SPADES, - "h": Suit.HEARTS, "hearts": Suit.HEARTS, "heart": Suit.HEARTS, "♥": Suit.HEARTS, - "d": Suit.DIAMONDS, "diamonds": Suit.DIAMONDS, "diamond": Suit.DIAMONDS, "♦": Suit.DIAMONDS, - "c": Suit.CLUBS, "clubs": Suit.CLUBS, "club": Suit.CLUBS, "♣": Suit.CLUBS, - "nt": Suit.NO_TRUMP, "notrump": Suit.NO_TRUMP, "no-trump": Suit.NO_TRUMP, -} -# Derived from ``ContractBid.VALID_VALUES`` so the human-input parser -# stays in lockstep with the auction's canonical value ladder. The -# all-tricks ``SlamLevel`` members are handled by a separate parsing -# branch above, so only the numeric subset is needed here. -VALID_BID_VALUES = {v for v in ContractBid.VALID_VALUES if isinstance(v, int)} - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _position_short(position: str) -> str: - return POSITION_SHORT.get(position, position[:1]) - - -def _position_color(position: str) -> str: - return BLUE if position in ("North", "South") else ORANGE - - -def _team_color(team_name: str) -> str: - return BLUE if team_name == "North-South" else ORANGE - - -def _team_abbr(team_name: str) -> str: - return TEAM_ABBR.get(team_name, team_name) - - -def _suit_glyph(suit: Suit) -> str: - return Card.SUIT_SYMBOLS.get(suit, suit.value) - - -# Short rank labels for the hand row and trick diamond. The engine's -# Rank.value strings spell "Jack"/"Queen"/"King"/"Ace" in full; the -# mockup uses single-letter abbreviations so 8 cards fit a 70-col row. -RANK_SHORT = { - Rank.SEVEN: "7", - Rank.EIGHT: "8", - Rank.NINE: "9", - Rank.TEN: "10", - Rank.JACK: "J", - Rank.QUEEN: "Q", - Rank.KING: "K", - Rank.ACE: "A", -} - - -def _rank_short(rank: Rank) -> str: - return RANK_SHORT.get(rank, rank.value) - - -def _suit_color(suit: Suit) -> str: - return RED if suit in (Suit.HEARTS, Suit.DIAMONDS) else FG - - -def _suit_color_dim(suit: Suit) -> str: - return RED_DIM if suit in (Suit.HEARTS, Suit.DIAMONDS) else DIM - - -def _format_card_compact(card: Card) -> Text: - """Render a card as ``"K♠"`` style — bold, with suit color.""" - t = Text() - t.append(_rank_short(card.rank), style="bold") - t.append(_suit_glyph(card.suit), style=f"bold {_suit_color(card.suit)}") - return t - - -def _sort_hand_for_display(cards: list[Card], trump_suit: Optional[Suit]) -> list[Card]: - """Sort cards trump-first then by suit; within each suit by rank. - - Mockup convention: trump cards on the far left (in trump order), - then non-trump suits in spades/hearts/diamonds/clubs preference, - skipping suits with no cards. Within a suit, highest rank first. - """ - suit_order = [Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS] - if trump_suit and trump_suit in suit_order: - suit_order.remove(trump_suit) - suit_order.insert(0, trump_suit) - - sorted_cards: list[Card] = [] - for suit in suit_order: - in_suit = [c for c in cards if c.suit == suit] - in_suit.sort(key=lambda c: c.get_order(trump_suit), reverse=True) - sorted_cards.extend(in_suit) - return sorted_cards - - -def _current_winner( - plays: list[tuple[BasePlayer, Card]], trump_suit: Optional[Suit] -) -> Optional[BasePlayer]: - """Return the player currently winning the (possibly incomplete) trick. - - Thin wrapper around :meth:`contrai_core.trick.Trick.get_current_winner` - that accepts a raw ``plays`` list (the shape ``_render_diamond`` already - uses) instead of forcing a Trick allocation at every render. - """ - if not plays: - return None - # Synthesize a minimal Trick for the delegate. Cheap: no game logic - # depends on the wrapper instance — only the plays list is read. - proxy = Trick() - for p, c in plays: - proxy.plays.append((p, c)) - return proxy.get_current_winner(trump_suit) - - -def _explain_constraint( - player: BasePlayer, - trick: Trick, - playable: list[Card], - trump_suit: Optional[Suit], -) -> Text: - """Build the hint line under the hand explaining *why* this is playable.""" - plays = trick.get_plays() if trick else [] - if not plays: - return Text("your lead — anything goes", style=GREEN_FG) - - led_suit = plays[0][1].suit - has_led = player.hand.has_suit(led_suit) - - hint = Text("↑ playable ", style=GREEN_FG) - if has_led: - hint.append("(must follow ", style=GREEN_FG) - hint.append(_suit_glyph(led_suit), style=_suit_color(led_suit)) - hint.append(")", style=GREEN_FG) - return hint - - # No card of led suit. See if we're forced to trump. - if trump_suit and all(c.suit == trump_suit for c in playable): - # Identify the partner / opponent that led, for the message. - leader = plays[0][0] - leader_label = _position_short(leader.position) - hint.append("(must trump — ", style=GREEN_FG) - hint.append(f"{leader_label} led ", style=GREEN_FG) - hint.append(_format_card_compact(plays[0][1])) - hint.append(")", style=GREEN_FG) - return hint - - hint.append("(free discard)", style=GREEN_FG) - return hint - - -def _seat_letter(player: Optional[BasePlayer]) -> Optional[Text]: - """Single-letter seat label colored by the player's team, or ``None``. - - Used to name the players behind a contract: the taker whose bid set - it, and the Coinche / Surcoinche caller. Each letter keeps the - seat's team color (blue for N-S, orange for E-W). - """ - if player is None or getattr(player, "position", None) is None: - return None - return Text( - _position_short(player.position), - style=f"bold {_position_color(player.position)}", - ) - - -def _format_contract_short(contract: Contract, *, verbose: bool = False) -> Text: - """Short label: ``"100 by E ×2 by S"``. - - Names the players, not just the team: the contract-setter follows - ``by`` as a single team-colored seat letter, and any Coinche / - Surcoinche shows its multiplier with the caller's seat - (``×2 by S`` / ``×4 by N``). - - Args: - contract: The materialized contract to render. - verbose: When ``True``, spell the Coinche / Surcoinche markers - out as ``doubled`` / ``redoubled`` instead of the compact - ``×2`` / ``×4`` glyphs. The recap panel uses this so the - after-round summary reads in full prose; the in-game panel - and event log keep the compact form. - """ - double_label = "redoubled" if verbose else "×4" - single_label = "doubled" if verbose else "×2" - t = Text() - # SlamLevel.__str__ already yields "Slam" / "Solo Slam"; numerics - # stringify to "80" … "180". - value_str = str(contract.value) - t.append(value_str, style="bold") - t.append(" by ", style=DIM) - taker = _seat_letter(getattr(contract, "player", None)) - if taker is not None: - t.append_text(taker) - else: - # Defensive fallback: name the team if the player is missing. - t.append(_team_abbr(contract.team.name), - style=f"bold {_team_color(contract.team.name)}") - # Coinche / Surcoinche: multiplier plus the player who called it. - if contract.redouble: - caller = _seat_letter(getattr(contract, "redouble_player", None)) - t.append(f" {double_label}", style=GOLD) - if caller is not None: - t.append(" by ", style=DIM) - t.append_text(caller) - elif contract.double: - caller = _seat_letter(getattr(contract, "double_player", None)) - t.append(f" {single_label}", style=GOLD) - if caller is not None: - t.append(" by ", style=DIM) - t.append_text(caller) - return t - - -def _format_trump_label(suit: Optional[Suit], *, star: bool = True) -> Text: - """``"♥ Hearts ★"`` with red glyph and gold star. - - Args: - suit: The trump suit to render, or ``None`` for an em-dash. - star: When ``True`` (default) append the gold ``★`` flourish. - The after-round recap passes ``star=False`` so its Trump - line reads plain; the in-game Round panel keeps the star. - """ - if suit is None: - return Text("—", style=DIM) - t = Text() - t.append(_suit_glyph(suit), style=_suit_color(suit)) - t.append(" ", style=FG) - label = "No Trump" if suit == Suit.NO_TRUMP else suit.value - t.append(label, style="bold") - if star: - t.append(" ★", style=GOLD) - return t - - -def _parse_bid_input(raw: str) -> Optional[str | tuple[int | SlamLevel, Suit]]: - """Parse a human bid string. Returns engine bid representation or None. - - Accepted forms: - pass / p -> 'Pass' - double / d -> 'Double' - redouble / r -> 'Redouble' - "80 h" / "100 hearts" / "150nt" -> (value, Suit) - "slam s" / "slams" -> (SlamLevel.SLAM, Suit) - "solo slam h" / "soloslam h" -> (SlamLevel.SOLO_SLAM, Suit) - """ - s = raw.strip().lower() - if not s: - return None - if s in PASS_WORDS: - return "Pass" - if s in DOUBLE_WORDS: - return "Double" - if s in REDOUBLE_WORDS: - return "Redouble" - - # Try "" with optional whitespace; also accept - # the value and suit being glued together ("100h", "slams"). - parts = s.replace(",", " ").split() - - # Accept the two-word form "solo slam " by collapsing the - # first two tokens into the canonical "soloslam" wire form. - if len(parts) == 3 and parts[0] == "solo" and parts[1] == "slam": - parts = ["soloslam", parts[2]] - - if len(parts) == 1: - token = parts[0] - # Split alpha tail (suit) from leading value. - i = 0 - while i < len(token) and (token[i].isdigit() or token[i] == "-"): - i += 1 - if i == 0: - # All-alpha: maybe "soloslams" -> soloslam + s, or "slams" -> slam + s - if token.startswith("soloslam") and len(token) > len("soloslam"): - parts = ["soloslam", token[len("soloslam"):]] - elif token.startswith("slam") and len(token) > len("slam"): - parts = ["slam", token[len("slam"):]] - else: - return None - else: - parts = [token[:i], token[i:]] - - if len(parts) != 2: - return None - raw_value, raw_suit = parts - suit = SUIT_ALIASES.get(raw_suit) - if suit is None: - return None - - if raw_value == "slam": - return (SlamLevel.SLAM, suit) - if raw_value == "soloslam": - return (SlamLevel.SOLO_SLAM, suit) - try: - value = int(raw_value) - except ValueError: - return None - if value not in VALID_BID_VALUES: - return None - return (value, suit) - - -def _parse_card_input( - raw: str, sorted_hand: list[Card], playable: list[Card] -) -> Optional[Card]: - """Parse a card-selection number; validate it's in playable. None on error.""" - s = raw.strip() - if not s.isdigit(): - return None - idx = int(s) - 1 - if idx < 0 or idx >= len(sorted_hand): - return None - card = sorted_hand[idx] - if card not in playable: - return None - return card - - -def _belote_by_position(round_) -> dict[str, str]: - """Project ``round_.belote_state`` (player → kind) onto positions. - - Returns an empty dict when no round is active, the round has no - belote_state, or none has been triggered yet. Used to render the - persistent ★ Belote/Rebelote badge in the trick diamond. - """ - if round_ is None: - return {} - state = getattr(round_, "belote_state", None) or {} - return {player.position: kind for player, kind in state.items()} - - -def _resolve_delay(env_var: str, default: float) -> float: - """Read a float pacing value from the environment with a default. - - Pacing for AI actions is tunable so the user can dial the game - speed without code edits. Garbage values fall back to ``default`` - rather than raising — this is UI pacing, not a correctness path. - """ - raw = os.environ.get(env_var) - if raw is None: - return default - try: - value = float(raw) - except (TypeError, ValueError): - return default - return max(0.0, value) - - -def _bid_to_legacy(bid: Bid): - """Convert a Bid object to the legacy tuple/string history shape.""" - if isinstance(bid, PassBid): - return "Pass" - if isinstance(bid, DoubleBid): - return "Double" - if isinstance(bid, RedoubleBid): - return "Redouble" - if isinstance(bid, ContractBid): - return (bid.value, bid.suit) - return "Pass" - - -def _redouble_available_to(history: list, player: BasePlayer) -> bool: - """True if *player* may currently redouble — narrows the prompt hint. - - Mirrors :class:`contrai_core.bid.RedoubleBid.is_valid_after` for the - legacy-format history this view receives, without re-deriving - Contract objects. The rule: the most recent non-pass bid is a - Double, no passes have occurred since it, and the previous - ContractBid was made by *player*'s team. - """ - if not history or player is None or getattr(player, "team", None) is None: - return False - - # Walk backwards looking for the most recent Double; abort if we - # see a Pass first or a Redouble has already fired. - saw_double = False - contract_team = None - for bid_player, bid in reversed(history): - if bid == "Pass": - if not saw_double: - # Pass before any Double — Double slot is closed. - return False - # Pass after the Double we already found — also closes the window. - return False - if bid == "Redouble": - return False - if bid == "Double": - saw_double = True - continue - if isinstance(bid, tuple): - # That's the ContractBid the Double refers to. - contract_team = getattr(bid_player, "team", None) - break - - if not saw_double or contract_team is None: - return False - return contract_team == player.team - - -def _double_available_to(history: list, player: BasePlayer) -> bool: - """True if *player* may currently double — narrows the prompt hint. - - Mirrors :meth:`contrai_core.auction.Auction._is_double_legal` for - the legacy-format history this view receives. The rule: there is a - live :class:`ContractBid`, it was made by the *opposing* team, and - no Double/Redouble already stands against it. Intervening passes do - **not** close the Coinche window, so they're skipped over. - - This is messaging only — the authoritative verdict comes from - :meth:`contrai_core.auction.Auction.is_legal`. It exists so the hint - stops offering ``double`` when it would be rejected (e.g. doubling - one's own partner's contract). - """ - if not history or player is None or getattr(player, "team", None) is None: - return False - - # Walk backwards: the first non-pass bid decides. A Double/Redouble - # means the contract is already (re)doubled; a ContractBid is the - # live contract whose team we compare against. - for bid_player, bid in reversed(history): - if bid in ("Double", "Redouble"): - return False - if isinstance(bid, tuple): - contract_team = getattr(bid_player, "team", None) - if contract_team is None: - return False - return contract_team != player.team - return False - - -def _min_legal_contract_value(history: list) -> Optional[int]: - """Lowest contract value a fresh numeric bid could legally announce. - - The prompt's worked example ("e.g. '100 H'") should track the live - auction rather than always parroting the ``80`` floor: a new contract - must strictly outrank the standing one, so once someone has bid 90 the - cheapest legal raise is 100, not 80. Mirrors - :meth:`contrai_core.auction.Auction._is_contract_value_legal` for the - legacy-format history this view receives, without re-deriving - :class:`~contrai_core.auction.Auction` state. - - Args: - history: The legacy ``(player, wire_bid)`` history. Contract bids - appear as ``(value, suit)`` tuples; passes/doubles as strings. - - Returns: - The lowest legal numeric value (80–180) for a new contract bid, or - ``None`` when no numeric bid is legal anymore — either a standing - contract of 180 (where only Slam/SoloSlam outrank it) or a - ``Slam``/``SoloSlam`` that nothing outranks. Callers drop the - contract example from the hint in that case. - """ - # Contracts climb monotonically, so the most recent contract bid is - # also the highest. The first tuple seen walking backwards is it. - last_value: int | str | None = None - for _bid_player, bid in reversed(history): - if isinstance(bid, tuple): - last_value = bid[0] - break - - values = ContractBid.VALID_VALUES - if last_value is None: - # No contract on the table — the ladder opens at its floor (80). - return values[0] - if isinstance(last_value, SlamLevel): - # Nothing outranks a Slam; no numeric raise is legal. - return None - # First numeric step strictly above the standing contract. Past 180 - # only the Slam sentinels remain, so the example is dropped instead. - for value in values[values.index(last_value) + 1 :]: - if isinstance(value, int): - return value - return None - - -def _illegal_bid_reason(bid: Bid, auction: Auction) -> str: - """Return a human-readable reason ``bid`` is illegal in ``auction``. - - Used by the bid prompt loop to give the player a specific nudge - instead of a generic rejection. Pure string builder that mirrors the - rule checks in :class:`contrai_core.auction.Auction` for messaging - only — the authoritative legality verdict is - :meth:`Auction.is_legal`. Callers should only invoke this once the - bid is already known to be illegal. - """ - if isinstance(bid, DoubleBid): - if auction.last_contract_bid is None: - return "There's no contract to double yet." - if auction.has_double or auction.has_redouble: - return "This contract has already been doubled." - return ( - "You can only double the opposing team's contract, " - "not your own side's." - ) - if isinstance(bid, RedoubleBid): - return ( - "Redouble is only legal right after the opposing team " - "doubles your team's contract." - ) - if isinstance(bid, ContractBid): - last = auction.last_contract_bid - if last is not None and isinstance(last.value, SlamLevel): - return f"Nothing outranks a {last.value} bid — you can only pass." - if last is not None: - return f"Your bid must outrank the current contract ({last.value})." - return "That contract bid isn't legal here." - return "That bid isn't legal right now." - - -def _bid_legacy_label(bid: str | tuple) -> Text: - """Legacy bid label for the bidding-history line.""" - if bid == "Pass": - return Text("Pass", style=DIM) - if bid == "Double": - return Text("×2", style=GOLD) - if bid == "Redouble": - return Text("×4", style=GOLD) - if isinstance(bid, tuple): - value, suit = bid - t = Text() - t.append(str(value), style="bold") - t.append(" ", style=FG) - t.append(_suit_glyph(suit), style=_suit_color(suit)) - return t - return Text(str(bid), style=DIM) - - # --------------------------------------------------------------------------- # Round summary (UI-side history) # --------------------------------------------------------------------------- @@ -662,11 +140,12 @@ class RichView: LOG_MAX = 5 def __init__(self) -> None: + """Create an unattached view: fresh console, empty per-game state.""" self.console: Console = Console() self.target_score: int = DEFAULT_TARGET self.history: list[RoundSummary] = [] self.last_completed_trick: Optional[tuple[Trick, BasePlayer]] = None - self.game: Optional["Game"] = None + self.game: Optional[Game] = None # Rolling narrative log shown below the hand. Captures the last # ``LOG_MAX`` events (deal, bids, plays, trick winners, redeal, # belote announcements). Survives across rounds so the end of @@ -677,7 +156,7 @@ def __init__(self) -> None: # Lifecycle wiring (called by the CLI) # ------------------------------------------------------------------ - def attach(self, game: "Game", target_score: int) -> None: + def attach(self, game: Game, target_score: int) -> None: """Bind a new game session. Resets per-game state.""" self.game = game self.target_score = target_score @@ -703,9 +182,9 @@ def request_bid_action( Args: player: The human player whose turn it is. - auction: The current auction state — projected to the - legacy ``(player, wire_bid)`` shape internally for the - renderer, which still consumes that format. + auction: The current auction state — its ``bids`` feed the + renderer directly and its :meth:`Auction.legal_actions` + drive the adaptive prompt hint. Returns: A :class:`Bid` that is guaranteed legal in ``auction`` — @@ -713,9 +192,7 @@ def request_bid_action( auction rules reject, so :meth:`Auction.apply` downstream never sees an illegal human bid. """ - legacy_bids = [ - (bid.player, _bid_to_legacy(bid)) for bid in auction.bids - ] + bidding_history = list(auction.bids) # A rejection from the previous iteration. Rendered *inside* the # next frame's Prompt panel rather than ``console.print``ed after # the input — otherwise the loop's ``console.clear()`` pushes the @@ -726,23 +203,18 @@ def request_bid_action( self._render_in_game( phase="bidding", current_player=player, - bidding_history=legacy_bids, - prompt_question=self._bidding_prompt_text(legacy_bids, player), + bidding_history=bidding_history, + prompt_question=_bidding_prompt_text(auction, player), mandatory=False, notice=notice, ) raw = self.console.input( Text("> ", style=f"bold {GREEN_FG}").markup ) - parsed = _parse_bid_input(raw) - if parsed is None: - notice = Text( - "✗ Unrecognized bid. Try '80 h', 'pass', " - "'double', 'redouble'.", - style=RED, - ) + bid = _parse_bid_input(raw, player) + if bid is None: + notice = _bid_rejection_text(auction, player) continue - bid = wire_to_bid(player, parsed) # Syntactic parsing only checks the *shape* of the input; # the auction owns the rules (precedence, the Double freeze, # can't-double-your-own-side, …). Validate here so an @@ -777,7 +249,7 @@ def request_card_action( current_player=player, current_trick=trick, playable_cards=playable_cards, - prompt_question=self._card_prompt_text( + prompt_question=_card_prompt_text( playable_cards, len(sorted_hand) ), mandatory=True, @@ -808,7 +280,7 @@ def on_trick_complete( phase="trick_won", current_trick=trick, trick_winner=winner, - prompt_question=self._trick_won_prompt_text(winner), + prompt_question=_trick_won_prompt_text(winner), mandatory=False, ) try: @@ -830,19 +302,19 @@ def on_round_dealt(self, round_: "Round") -> None: line.append(f"{dealer} deals.", style=FG) self._log(line) - def on_all_pass_redeal(self, round_: "Round") -> None: + def on_all_pass_redeal(self) -> None: """Engine hook: every bid was a pass, the deal will be repeated.""" line = Text("All passed — redealing.", style=f"bold {YELLOW}") self._log(line) - def on_contract_established(self, round_: "Round") -> None: + def on_contract_established(self, round_: Round) -> None: """Engine hook: bidding ended on a contract — bookmark it in the log.""" contract = getattr(round_, "contract", None) if contract is None: return line = Text() line.append("Contract set: ", style=f"bold {GOLD}") - line.append_text(_format_contract_short(contract)) + line.append_text(_format_contract_short(contract, suit_glyph=True)) line.append(".", style=DIM) self._log(line) @@ -857,16 +329,14 @@ def on_bid_made( without a frame — this hook gives the user time to read the bidding history. """ - legacy_bid = _bid_to_legacy(bid) - self._log(self._format_bid_log(player, legacy_bid)) + self._log(self._format_bid_log(player, bid)) if getattr(player, "is_human", False): return - legacy_history = [(b.player, _bid_to_legacy(b)) for b in history] self._render_in_game( phase="bidding", current_player=None, - bidding_history=legacy_history, - prompt_question=self._ai_bid_announcement(player, legacy_bid), + bidding_history=list(history), + prompt_question=_ai_bid_announcement(player, bid), mandatory=False, ) time.sleep(_resolve_delay("CONTRAI_AI_BID_DELAY", default=1.4)) @@ -882,7 +352,7 @@ def on_card_played( phase="playing", current_player=None, current_trick=trick, - prompt_question=self._ai_card_announcement(player, card), + prompt_question=_ai_card_announcement(player, card), mandatory=False, ) time.sleep(_resolve_delay("CONTRAI_AI_CARD_DELAY", default=0.9)) @@ -916,7 +386,12 @@ def on_belote_announced( time.sleep(_resolve_delay("CONTRAI_AI_CARD_DELAY", default=0.9)) def show_round_recap( - self, round_: "Round", running_scores: dict, *, is_final: bool = False + self, + round_: "Round", + running_scores: dict, + *, + is_final: bool = False, + is_tiebreaker: bool = False, ) -> None: """Full-screen recap shown after each round; waits for Enter. @@ -926,18 +401,32 @@ def show_round_recap( that just clinched the game. When ``is_final`` is true the prompt switches to "see the final score" so the user knows the next screen is the game-over scoreboard, not another deal. + When ``is_tiebreaker`` is true (both teams level at/above the + target) the panel carries a sudden-death notice and the prompt + deals the tiebreaker round. """ self.console.clear() - self.console.print(self._panel_round_recap(round_, running_scores)) + self.console.print( + _panel_round_recap( + round_, + running_scores, + self.target_score, + tiebreaker=is_tiebreaker, + ) + ) if is_final: prompt_text = Text( "Press [Enter] to see the final score…", style=FG ) + elif is_tiebreaker: + prompt_text = Text( + "Press [Enter] to deal the tiebreaker round…", style=FG + ) else: prompt_text = Text( "Press [Enter] to deal the next round…", style=FG ) - self.console.print(self._panel_prompt(prompt_text, mandatory=False)) + self.console.print(_panel_prompt(prompt_text, mandatory=False)) try: self.console.input(Text("> ", style=f"bold {GOLD}").markup) except (EOFError, KeyboardInterrupt): @@ -955,7 +444,7 @@ def on_round_complete(self, round_: "Round", running_scores: dict) -> None: contract_team_name = None else: contract_team_name = contract.team.name - made = self._contract_made(round_) + made = _contract_made(round_) self.history.append( RoundSummary( round_number=round_.round_number, @@ -979,14 +468,14 @@ def show_landing(self, selected_target: int = DEFAULT_TARGET) -> int: """Render the landing screen and return the chosen target score.""" while True: self.console.clear() - self.console.print(self._landing_title()) - self.console.print(self._landing_subtitle()) - self.console.print(self._landing_suit_ribbon()) + self.console.print(_landing_title()) + self.console.print(_landing_subtitle()) + self.console.print(_landing_suit_ribbon()) self.console.print() - self.console.print(self._panel_game_setup(selected_target)) - self.console.print(self._panel_players()) - self.console.print(self._panel_prompt( - self._landing_prompt_text(selected_target), + self.console.print(_panel_game_setup(selected_target)) + self.console.print(_panel_players()) + self.console.print(_panel_prompt( + _landing_prompt_text(selected_target), mandatory=False, )) raw = self.console.input( @@ -1018,14 +507,14 @@ def show_landing(self, selected_target: int = DEFAULT_TARGET) -> int: continue return target - def show_end_game(self, status: dict) -> str: + def show_end_game(self, status: GameOverStatus) -> str: """Render the end-game scoreboard and return 'n'/'r'/'q'.""" while True: self.console.clear() - self.console.print(self._panel_game_over_banner(status)) - self.console.print(self._panel_round_summary()) - self.console.print(self._panel_prompt( - self._end_game_prompt_text(), + self.console.print(_panel_game_over_banner(status)) + self.console.print(_panel_round_summary(self.history)) + self.console.print(_panel_prompt( + _end_game_prompt_text(), mandatory=False, )) raw = self.console.input( @@ -1069,12 +558,16 @@ def _render_in_game( self.console.clear() round_ = self.game.current_round if self.game else None # Top row: game score + round info - top_left = self._panel_game_score() - top_right = self._panel_round(round_, phase) + scores = ( + self.game.scores if self.game + else {"North-South": 0, "East-West": 0} + ) + top_left = _panel_game_score(scores, self.target_score) + top_right = _panel_round(round_, phase) self.console.print(_two_column(top_left, top_right, left_width=24)) # Middle row: last trick + current trick - mid_left = self._panel_last_trick(round_) - mid_right = self._panel_current_trick( + mid_left = _panel_last_trick(round_, self.last_completed_trick) + mid_right = _panel_current_trick( round_, current_trick, phase, current_player, trick_winner, bidding_history=bidding_history, ) @@ -1089,7 +582,7 @@ def _render_in_game( is_human_turn = ( current_player is not None and current_player is human ) - hand_panel = self._panel_hand( + hand_panel = _panel_hand( human, current_trick, playable_cards, phase, round_, interactive=is_human_turn, ) @@ -1097,655 +590,14 @@ def _render_in_game( hand_panel = None # Bidding history for state 1, if any non-pass bids if phase == "bidding" and bidding_history: - history_panel = self._panel_bidding_history(bidding_history) + history_panel = _panel_bidding_history(bidding_history) self.console.print(history_panel) if hand_panel is not None: self.console.print(hand_panel) # Event log: a rolling narrative of the last few engine events. - self.console.print(self._panel_event_log()) + self.console.print(_panel_event_log(self.event_log, self.LOG_MAX)) self.console.print( - self._panel_prompt(prompt_question, mandatory, notice=notice) - ) - - # ------------------------------------------------------------------ - # Landing screen pieces - # ------------------------------------------------------------------ - - def _landing_title(self) -> Text: - """Centered block-ASCII CONTRAI title.""" - if _HAS_PYFIGLET: - ascii_art = Figlet(font="ansi_shadow", width=70).renderText("CONTRAI") - else: - ascii_art = "CONTRAI" - t = Text() - for line in ascii_art.splitlines(): - t.append(line.center(70), style=f"bold {YELLOW}") - t.append("\n") - return t - - def _landing_subtitle(self) -> Text: - return Text("Belote · Contrée · CLI edition".center(70), style=DIM) - - def _landing_suit_ribbon(self) -> Text: - ribbon = Text() - glyphs = [(Suit.SPADES, FG), (Suit.HEARTS, RED), - (Suit.DIAMONDS, RED), (Suit.CLUBS, FG)] - # Build " ♠ ♥ ♦ ♣ " then center it. - segments = [] - for suit, color in glyphs: - segments.append((suit, color)) - # Render with 3 spaces between glyphs. - inner = Text() - for i, (suit, color) in enumerate(segments): - if i > 0: - inner.append(" ") - inner.append(_suit_glyph(suit), style=f"bold {color}") - # Centered within 70 cols. - total = inner.cell_len - pad = max(0, (70 - total) // 2) - ribbon.append(" " * pad) - ribbon.append_text(inner) - return ribbon - - def _panel_game_setup(self, selected: int) -> Panel: - """Five radio rows for target score, highlight the selected one.""" - rows = Text() - rows.append("Target score", style=f"bold {FG}") - rows.append(" ", style=FG) - rows.append( - "(first team to reach the target wins the game)\n\n", - style=DIM, - ) - for value, label, estimate in TARGET_OPTIONS: - is_sel = value == selected - line = Text() - if is_sel: - radio = "(●)" - line.append(f" {radio} ", style=f"bold {GOLD_FG} on {GOLD_BG}") - line.append(f"{value:<4} ", style=f"bold {GOLD_FG} on {GOLD_BG}") - line.append(f"{label:<10}", style=f"{GOLD_FG} on {GOLD_BG}") - line.append(f" · {estimate}", style=f"{GOLD_FG} on {GOLD_BG}") - if value == DEFAULT_TARGET: - line.append(" ← default", style=f"bold {GOLD} on {GOLD_BG}") - # Pad to fill the panel width with the gold background. - used = line.cell_len - line.append(" " * max(0, 60 - used), style=f"on {GOLD_BG}") - else: - line.append(" ( ) ", style=DIM) - line.append(f"{value:<4} ", style=f"bold {FG}") - line.append(f"{label:<10}", style=FG) - line.append(f" · {estimate}", style=DIM) - rows.append_text(line) - rows.append("\n") - return Panel( - rows, - title=Text("Game setup", style=f"bold {TITLE}"), - border_style=BORDER, - box=ROUNDED, - width=70, - ) - - def _panel_players(self) -> Panel: - """Players block. Hardcoded for v1 — South=human, others=AI medium. - - TODO: replace with a configurable seat picker when we expose - difficulty / player config on the landing screen. - """ - seats = [ - ("N", "North", "AI · medium", BLUE, False), - ("E", "East", "AI · medium", ORANGE, False), - ("S", "You", "human", GREEN_FG, True), - ("W", "West", "AI · medium", ORANGE, False), - ] - # Two columns of two: render as a 2-row, 2-col Table. - table = Table.grid(expand=True, padding=(0, 2)) - table.add_column(ratio=1) - table.add_column(ratio=1) - rows = [] - for label, name, role, color, is_human in seats: - cell = Text() - cell.append(label, style=f"bold {color}") - cell.append(" ", style=FG) - if is_human: - cell.append(name, style=f"bold {color}") - else: - cell.append(name, style=FG) - cell.append(f" ({role})", style=DIM) - rows.append(cell) - table.add_row(rows[0], rows[1]) # N, E - table.add_row(rows[2], rows[3]) # S, W - return Panel( - table, - title=Text("Players", style=f"bold {TITLE}"), - border_style=BORDER, - box=ROUNDED, - width=70, - ) - - def _landing_prompt_text(self, selected: int) -> Text: - t = Text() - t.append( - "Target score? [500 / 1000 / 1500 / 2000 / 3000] (default ", - style=FG, - ) - t.append(str(selected), style=f"bold {GOLD}") - t.append(")", style=FG) - return t - - # ------------------------------------------------------------------ - # In-game panels - # ------------------------------------------------------------------ - - def _panel_game_score(self) -> Panel: - scores = self.game.scores if self.game else {"North-South": 0, "East-West": 0} - body = Text() - ns = scores.get("North-South", 0) - ew = scores.get("East-West", 0) - body.append(f"{'N-S':<8}", style=f"bold {BLUE}") - body.append(f"{ns:>10}\n", style=FG) - body.append(f"{'E-W':<8}", style=f"bold {ORANGE}") - body.append(f"{ew:>10}\n", style=FG) - body.append("·" * 18, style=DOT) - body.append("\n") - body.append(f"{'Target':<8}", style=DIM) - body.append(f"{self.target_score:>10}", style=f"bold {YELLOW}") - return Panel( - body, - title=Text("Game score", style=f"bold {TITLE}"), - border_style=BORDER, - box=ROUNDED, - width=22, - height=6, - ) - - def _panel_round( - self, round_: Optional["Round"], phase: str - ) -> Panel: - body = Text() - contract = round_.contract if round_ else None - trump_active = contract is not None - # Contract line - body.append("Contract: ", style=DIM) - if contract is None: - body.append("—\n", style=FG) - else: - body.append_text(_format_contract_short(contract)) - body.append("\n") - # Trump line - body.append("Trump: ", style=DIM) - body.append_text(_format_trump_label(contract.suit if contract else None)) - body.append("\n") - # Phase / trick - if phase == "bidding": - body.append("Phase: ", style=DIM) - body.append("Bidding in progress\n", style=f"bold {YELLOW}") - dealer_name = round_.dealer.position if round_ and round_.dealer else "—" - body.append("Dealer: ", style=DIM) - body.append(dealer_name, style=FG) - else: - tricks_done = len(round_.tricks) if round_ else 0 - current_idx = tricks_done + (1 if phase == "playing" else 0) - current_idx = min(current_idx, 8) - body.append("Trick: ", style=DIM) - body.append(f"{current_idx} of 8\n", style=FG) - # Round running points (cards collected by each team so far). - ns_pts, ew_pts = self._round_running_points(round_) - body.append("Round pts: ", style=DIM) - body.append("N-S ", style=f"bold {BLUE}") - body.append(str(ns_pts), style="bold") - body.append(" · ", style=DIM) - body.append("E-W ", style=f"bold {ORANGE}") - body.append(str(ew_pts), style="bold") - - border_color = YELLOW if trump_active else BORDER - title_color = YELLOW if trump_active else TITLE - round_label = ( - f"Round #{round_.round_number}" - if round_ is not None and getattr(round_, "round_number", None) - else "Round" - ) - title = Text(round_label, style=f"bold {title_color}") - if trump_active: - title.append(" ★", style=GOLD) - return Panel( - body, - title=title, - border_style=border_color, - box=ROUNDED, - width=46, - height=6, - ) - - def _round_running_points(self, round_: Optional["Round"]) -> tuple[int, int]: - if not round_ or not round_.contract: - return 0, 0 - trump = round_.contract.suit - ns, ew = 0, 0 - for team_name, tricks in round_.team_tricks.items(): - pts = 0 - for trick in tricks: - for _, card in trick.get_plays(): - pts += card.get_points(trump) - if team_name == "North-South": - ns = pts - elif team_name == "East-West": - ew = pts - return ns, ew - - def _panel_last_trick(self, round_: Optional["Round"]) -> Panel: - if not self.last_completed_trick: - body = Text("(none)", style=DIM, justify="center") - body = Align.center(body, vertical="middle") - return Panel( - body, - title=Text("Last trick", style=DIM), - border_style=BORDER_DIM, - box=ROUNDED, - width=22, - height=8, - ) - trick, winner = self.last_completed_trick - trump = round_.contract.suit if round_ and round_.contract else None - body = self._render_diamond( - trick, - trump, - pending_position=None, - winner_position=winner.position if winner else None, - dimmed=True, - width=18, - belote_by_position=_belote_by_position(round_), - ) - body.append("\n") - body.append("Won: ", style=DIM) - body.append(_position_short(winner.position), style=f"bold {GOLD}") - # Last trick number is the just-completed trick — that's the - # length of tricks (the freshly appended one we are echoing). - last_idx = len(round_.tricks) if round_ else 0 - title = Text( - f"Last trick (#{last_idx})" if last_idx else "Last trick", - style=DIM, - ) - return Panel( - body, - title=title, - border_style=BORDER_DIM, - box=ROUNDED, - width=22, - height=8, - ) - - def _panel_current_trick( - self, - round_: Optional["Round"], - trick: Optional[Trick], - phase: str, - current_player: Optional[BasePlayer], - trick_winner: Optional[BasePlayer], - bidding_history: Optional[list] = None, - ) -> Panel: - title_suffix = "" - if round_ and phase in ("playing", "trick_won"): - trick_idx = len(round_.tricks) + (0 if phase == "trick_won" else 1) - trick_idx = min(max(1, trick_idx), 8) - title_suffix = f" (#{trick_idx})" - - if phase == "bidding": - # Reuse the table slot for the auction: each seat shows the - # player's latest bid so the human can read announces off - # the diamond the same way they read cards during play. - body = self._render_bidding_diamond( - bidding_history or [], - pending_position=( - current_player.position - if current_player is not None - else None - ), - width=42, - ) - body.append("\n") - if current_player is not None and current_player.is_human: - body.append("→ Your bid", style=f"bold {YELLOW}") - elif current_player is not None: - body.append(f"→ {current_player.position} to bid", style=DIM) - return Panel( - body, - title=Text("Bidding", style=f"bold {TITLE}"), - border_style=BORDER, - box=ROUNDED, - width=46, - height=8, - ) - - if trick is None: - body = Text("(none)", style=DIM, justify="center") - body = Align.center(body, vertical="middle") - return Panel( - body, - title=Text(f"Current trick{title_suffix}", style=f"bold {TITLE}"), - border_style=BORDER, - box=ROUNDED, - width=46, - height=8, - ) - - trump = round_.contract.suit if round_ and round_.contract else None - pending_position = ( - current_player.position - if current_player is not None and phase == "playing" - else None - ) - winner_position = trick_winner.position if trick_winner else None - body = self._render_diamond( - trick, - trump, - pending_position=pending_position, - winner_position=winner_position, - dimmed=False, - width=42, - belote_by_position=_belote_by_position(round_), - ) - body.append("\n") - if phase == "trick_won" and trick_winner is not None: - body.append("Won: ", style=DIM) - body.append(_position_short(trick_winner.position), - style=f"bold {GOLD}") - elif current_player is not None and current_player.is_human: - body.append("→ Your turn", style=f"bold {YELLOW}") - elif current_player is not None: - body.append(f"→ {current_player.position}'s turn", style=DIM) - return Panel( - body, - title=Text(f"Current trick{title_suffix}", style=f"bold {TITLE}"), - border_style=BORDER, - box=ROUNDED, - width=46, - height=8, - ) - - def _render_diamond( - self, - trick: Trick, - trump: Optional[Suit], - *, - pending_position: Optional[str], - winner_position: Optional[str], - dimmed: bool, - width: int, - belote_by_position: Optional[dict[str, str]] = None, - ) -> Text: - """Render the 4-player diamond: N top, E right, S bottom, W left. - - ``belote_by_position`` maps a position string (``"North"`` etc.) - to either ``"belote"`` or ``"rebelote"`` for seats that have - announced. The badge persists for the rest of the round. - """ - belote_by_position = belote_by_position or {} - - def _belote_badge(pos: str) -> Optional[Text]: - # The seat badge always reads "★ Belote" once the holder - # has played either the K or the Q of trump. The belote / - # rebelote distinction is narrative-only and lives in the - # event log; under the seat we just signal "this player - # has the K+Q pair". - if belote_by_position.get(pos) is None: - return None - t = Text() - t.append("★ ", style=f"bold {GOLD}") - t.append("Belote", style=f"bold {GOLD}") - return t - - plays = trick.get_plays() if trick else [] - plays_by_pos: dict[str, tuple[BasePlayer, Card]] = {} - led_position: Optional[str] = None - for i, (player, card) in enumerate(plays): - plays_by_pos[player.position] = (player, card) - if i == 0: - led_position = player.position - - # Live winner (only if there's at least one play and no explicit winner). - live_winner_pos = winner_position - if live_winner_pos is None and plays: - lw = _current_winner(plays, trump) - if lw is not None: - live_winner_pos = lw.position - - def slot(pos: str) -> Text: - t = Text() - label = _position_short(pos) - pcolor = _position_color(pos) - if pos == pending_position: - t.append(f"{label} ", style=f"bold {pcolor}") - t.append("?", style=f"bold {YELLOW}") - return t - play = plays_by_pos.get(pos) - if play is None: - t.append(f"{label} ", style=f"bold {DIM if dimmed else pcolor}") - t.append("·", style=DIM) - return t - _, card = play - rank_label = _rank_short(card.rank) - is_winner = pos == live_winner_pos - if is_winner and not dimmed: - t.append(f"{label} ", style=f"bold {GOLD_FG} on {GOLD_BG}") - t.append(rank_label, style=f"bold {GOLD_FG} on {GOLD_BG}") - t.append(_suit_glyph(card.suit), - style=f"bold {GOLD_FG} on {GOLD_BG}") - t.append(" ★", style=f"bold {GOLD} on {GOLD_BG}") - elif is_winner and dimmed: - t.append(f"{label} ", style=f"bold {GOLD_FG}") - t.append(rank_label, style=f"bold {GOLD_FG}") - t.append(_suit_glyph(card.suit), style=f"bold {GOLD_FG}") - t.append(" ★", style=f"bold {GOLD}") - else: - fg_label = DIM if dimmed else pcolor - rank_style = DIM if dimmed else "bold" - suit_style = (_suit_color_dim(card.suit) if dimmed - else f"bold {_suit_color(card.suit)}") - t.append(f"{label} ", style=f"bold {fg_label}") - t.append(rank_label, style=rank_style) - t.append(_suit_glyph(card.suit), style=suit_style) - if pos == led_position and not dimmed: - t.append(" (led)", style=DIM) - return t - - # Build rows of fixed-width text. Belote badges (when any seat - # has announced) are inserted as a centered line below the seat - # that owns them. - out = Text() - # Row 1: blank - out.append("\n") - # Row 2: N centered - n = slot("North") - pad_left = max(0, (width - n.cell_len) // 2) - out.append(" " * pad_left) - out.append_text(n) - out.append("\n") - # N's belote badge (centered) - n_badge = _belote_badge("North") - if n_badge is not None: - pad = max(0, (width - n_badge.cell_len) // 2) - out.append(" " * pad) - out.append_text(n_badge) - out.append("\n") - # Row 3: W left, E right - w = slot("West") - e = slot("East") - used = w.cell_len + e.cell_len - gap = max(2, width - used) - out.append_text(w) - out.append(" " * gap) - out.append_text(e) - out.append("\n") - # W/E badges share a row (left-aligned for W, right-aligned for E). - w_badge = _belote_badge("West") - e_badge = _belote_badge("East") - if w_badge is not None or e_badge is not None: - wb_len = w_badge.cell_len if w_badge else 0 - eb_len = e_badge.cell_len if e_badge else 0 - badge_gap = max(2, width - wb_len - eb_len) - if w_badge is not None: - out.append_text(w_badge) - else: - out.append(" " * wb_len) - out.append(" " * badge_gap) - if e_badge is not None: - out.append_text(e_badge) - out.append("\n") - # Row 4: S centered - s = slot("South") - pad_left = max(0, (width - s.cell_len) // 2) - out.append(" " * pad_left) - out.append_text(s) - # S's belote badge (centered) - s_badge = _belote_badge("South") - if s_badge is not None: - out.append("\n") - pad = max(0, (width - s_badge.cell_len) // 2) - out.append(" " * pad) - out.append_text(s_badge) - return out - - def _render_bidding_diamond( - self, - bidding_history: list, - *, - pending_position: Optional[str], - width: int, - ) -> Text: - """Render the 4-seat diamond with each player's latest bid. - - Mirrors :meth:`_render_diamond` (N top, E right, S bottom, W - left) but for the auction: each seat shows that player's most - recent bid, so announces map onto the table spatially the same - way cards do during play. The seat about to bid is marked - ``?``; seats that have not bid yet show ``·``. - - ``bidding_history`` is the legacy ``(player, wire_bid)`` list the - rest of the bidding renderer already consumes, where ``wire_bid`` - is one of ``"Pass"`` / ``"Double"`` / ``"Redouble"`` / a - ``(value, suit)`` tuple. - """ - # Collapse the history to the latest bid standing at each seat; - # a later bid by the same player overwrites the earlier one. - latest_by_pos: dict[str, str | tuple] = {} - for player, bid in bidding_history: - latest_by_pos[player.position] = bid - - def slot(pos: str) -> Text: - t = Text() - label = _position_short(pos) - pcolor = _position_color(pos) - t.append(f"{label} ", style=f"bold {pcolor}") - if pos == pending_position: - t.append("?", style=f"bold {YELLOW}") - elif pos in latest_by_pos: - t.append_text(_bid_legacy_label(latest_by_pos[pos])) - else: - t.append("·", style=DIM) - return t - - # Same skeleton as _render_diamond (blank row, N, W/E, S), minus - # the belote badges — those belong to the play phase. - out = Text() - out.append("\n") - n = slot("North") - pad_left = max(0, (width - n.cell_len) // 2) - out.append(" " * pad_left) - out.append_text(n) - out.append("\n") - w = slot("West") - e = slot("East") - used = w.cell_len + e.cell_len - gap = max(2, width - used) - out.append_text(w) - out.append(" " * gap) - out.append_text(e) - out.append("\n") - s = slot("South") - pad_left = max(0, (width - s.cell_len) // 2) - out.append(" " * pad_left) - out.append_text(s) - return out - - def _panel_hand( - self, - player: BasePlayer, - trick: Optional[Trick], - playable_cards: Optional[list[Card]], - phase: str, - round_: Optional["Round"], - *, - interactive: bool = True, - ) -> Panel: - """Render the human's hand row. - - ``interactive`` is true only when the human is the actively- - acting player and the view is gathering their input. In every - other in-game frame (AI bidding, AI playing, the trick-won - pause) the panel still appears — keeping the slot stable in - the layout — but cards are rendered with neutral styling: no - green playable pills, no constraint hint, just the row plus a - size readout. - - An empty hand (after the last trick of the round) still - produces a panel; the row reads ``(no cards left)`` so the - slot doesn't pop in and out at the trick-won frame for the - eighth trick. - """ - trump_suit = round_.contract.suit if round_ and round_.contract else None - sorted_hand = _sort_hand_for_display(list(player.hand), trump_suit) - - cards_row = Text() - if not sorted_hand: - cards_row.append("(no cards left)", style=DIM) - else: - # In non-interactive frames we render every card with the - # bidding-style "yellow numbers, bold rank+suit" treatment. - # Passing a phase that isn't "playing" routes the cell - # renderer down the neutral branch. - cell_phase = phase if interactive else "neutral" - playable_set = ( - set(id(c) for c in (playable_cards or sorted_hand)) - if interactive - else set() - ) - for idx, card in enumerate(sorted_hand, start=1): - is_playable = id(card) in playable_set - cell = self._render_card_cell(idx, card, is_playable, cell_phase) - cards_row.append_text(cell) - cards_row.append(" ") - - body = Text() - body.append("\n") - pad = max(0, (66 - cards_row.cell_len) // 2) - body.append(" " * pad) - body.append_text(cards_row) - body.append("\n") - - if not sorted_hand: - # The cards row already reads "(no cards left)"; a second - # "(hand empty)" line would just be redundant. - hint = Text("", justify="center") - elif phase == "bidding": - hint = Text( - "(no card-play obligation yet — bidding phase)", - style=DIM, justify="center", - ) - elif phase == "playing" and interactive and trick is not None: - hint = _explain_constraint(player, trick, playable_cards or [], trump_suit) - hint.justify = "center" - else: - hint = Text(f"{len(sorted_hand)} cards remaining", - style=DIM, justify="center") - body.append_text(hint) - title = Text(f"Your hand ({player.position})", style=f"bold {TITLE}") - return Panel( - body, - title=title, - border_style=BORDER, - box=ROUNDED, - width=70, - height=5, + _panel_prompt(prompt_question, mandatory, notice=notice) ) def _find_human_player(self) -> Optional[BasePlayer]: @@ -1764,155 +616,6 @@ def _find_human_player(self) -> Optional[BasePlayer]: return p return None - def _render_card_cell( - self, idx: int, card: Card, is_playable: bool, phase: str - ) -> Text: - """Render a single card cell: ``[n] R♠`` with optional pill.""" - rank_label = _rank_short(card.rank) - t = Text() - if phase == "playing" and is_playable: - t.append(f"[{idx}] ", style=f"bold white on {GREEN_BG}") - t.append(rank_label, style=f"bold {GREEN_FG} on {GREEN_BG}") - t.append(_suit_glyph(card.suit), style=f"bold {GREEN_FG} on {GREEN_BG}") - elif phase == "playing" and not is_playable: - t.append(f"[{idx}] ", style=DIM) - t.append(rank_label, style=DIM) - t.append(_suit_glyph(card.suit), style=_suit_color_dim(card.suit)) - else: - t.append(f"[{idx}] ", style=f"bold {YELLOW}") - t.append(rank_label, style="bold") - t.append(_suit_glyph(card.suit), style=f"bold {_suit_color(card.suit)}") - return t - - def _panel_bidding_history(self, bids: list) -> Panel: - """One-line-per-round history of bids so far. - - Each line starts with the bidding-round number (``#1``, ``#2``, - …) and lays the four seats out in fixed-width columns so bids - line up vertically across rounds: - #1 S Pass E Pass N 80 ♥ W Pass - #2 S 100 ♥ E Pass N 130 ♥ W ×2 - """ - # Fixed column widths so cells stack in vertical lanes. The bid - # cell holds at most "S 180 ♥" (7 cells); pad to leave a gap. - round_w = 4 - cell_w = 11 - body = Text() - if not bids: - body.append("(no bids yet)", style=DIM) - else: - for i, (player, bid) in enumerate(bids): - if i % 4 == 0: - # New bidding round: break the line (except the very - # first) and emit the round-number gutter. - if i > 0: - body.append("\n") - label = f"#{i // 4 + 1}" - body.append(label, style=f"bold {DIM}") - body.append(" " * max(1, round_w - len(label)), style=FG) - cell = Text() - cell.append(_position_short(player.position), - style=f"bold {_position_color(player.position)}") - cell.append(" ", style=FG) - cell.append_text(_bid_legacy_label(bid)) - # Right-pad the cell to keep the seats in vertical lanes. - body.append_text(cell) - body.append(" " * max(1, cell_w - cell.cell_len), style=FG) - return Panel( - body, - title=Text("Bidding so far", style=f"bold {TITLE}"), - border_style=BORDER, - box=ROUNDED, - width=70, - ) - - def _panel_prompt( - self, - question: Text, - mandatory: bool, - notice: Optional[Text] = None, - ) -> Panel: - body = Text() - # A rejection from the previous input sits above the question, in - # red, so the player reads *why* the last entry bounced without it - # ever leaving the frame. The panel grows one row to fit it. - if notice is not None: - body.append_text(notice) - body.append("\n") - if mandatory: - q = question.copy() - q.stylize(f"bold {YELLOW}") - body.append_text(q) - else: - body.append_text(question) - body.append("\n") - return Panel( - body, - title=Text("Prompt", style=f"bold {TITLE}"), - border_style=BORDER, - box=ROUNDED, - width=70, - height=5 if notice is not None else 4, - ) - - # ------------------------------------------------------------------ - # Prompt text builders - # ------------------------------------------------------------------ - - def _bidding_prompt_text( - self, - history: list, - next_player: Optional[BasePlayer] = None, - ) -> Text: - t = Text() - # Find what last non-self event was — for "West passed.". - if history: - last_player, last_bid = history[-1] - label = _position_short(last_player.position) - if last_bid == "Pass": - t.append(f"{label} passed. ", style=FG) - elif last_bid == "Double": - t.append(f"{label} doubled. ", style=f"bold {GOLD}") - elif last_bid == "Redouble": - t.append(f"{label} redoubled. ", style=f"bold {GOLD}") - elif isinstance(last_bid, tuple): - value, suit = last_bid - t.append(f"{label} bid {value} ", style=FG) - t.append(_suit_glyph(suit), style=_suit_color(suit)) - t.append(". ", style=FG) - t.append("Your bid? ", style=FG) - # Adaptive example — only advertise actions that are actually - # legal for the next bidder, so the hint never invites a move - # the auction will reject (e.g. doubling one's own partner). - if next_player is not None and _redouble_available_to(history, next_player): - # Contractor just got doubled: redouble is the only - # meaningful active option besides passing. - t.append("(pass / redouble)", style=DIM) - else: - # The worked contract example tracks the auction: show the - # cheapest *legal* raise (100 once 90 stands), never the bare - # 80 floor, so the hint can't suggest a bid the auction would - # reject. Dropped entirely past 180, where only Slam remains. - options: list[str] = [] - min_value = _min_legal_contract_value(history) - if min_value is not None: - options.append(f"'{min_value} H'") - options.append("'pass'") - if next_player is not None and _double_available_to(history, next_player): - options.append("'double'") - t.append(f"(e.g. {' / '.join(options)})", style=DIM) - return t - - def _card_prompt_text( - self, playable_cards: list[Card], hand_size: int - ) -> Text: - t = Text() - t.append("Your turn. ", style=f"bold {YELLOW}") - if playable_cards and len(playable_cards) == 1: - t.append("Only one legal play. ", style=f"bold {YELLOW}") - t.append(f"Choose card [1-{hand_size}]:", style=f"bold {YELLOW}") - return t - # ------------------------------------------------------------------ # Event log # ------------------------------------------------------------------ @@ -1923,26 +626,26 @@ def _log(self, line: Text) -> None: if len(self.event_log) > self.LOG_MAX: del self.event_log[: len(self.event_log) - self.LOG_MAX] - def _format_bid_log(self, player: BasePlayer, bid) -> Text: + def _format_bid_log(self, player: BasePlayer, bid: Bid) -> Text: """Build the log line for a single bid action.""" label = _position_short(player.position) color = _position_color(player.position) t = Text() t.append(f"{label} ", style=f"bold {color}") - if bid == "Pass": + if isinstance(bid, PassBid): t.append("passed.", style=DIM) - elif bid == "Double": - t.append("doubled.", style=f"bold {GOLD}") - elif bid == "Redouble": + elif isinstance(bid, RedoubleBid): t.append("redoubled.", style=f"bold {GOLD}") - elif isinstance(bid, tuple): - value, suit = bid - t.append(f"bid {value} ", style=FG) - t.append(_suit_glyph(suit), style=_suit_color(suit)) + elif isinstance(bid, DoubleBid): + t.append("doubled.", style=f"bold {GOLD}") + elif isinstance(bid, ContractBid): + t.append(f"bid {bid.value} ", style=FG) + t.append(_suit_glyph(bid.suit), style=_suit_color(bid.suit)) t.append(".", style=FG) return t def _format_card_log(self, player: BasePlayer, card: Card) -> Text: + """Build the log line for a single card play.""" label = _position_short(player.position) color = _position_color(player.position) t = Text() @@ -1955,6 +658,7 @@ def _format_card_log(self, player: BasePlayer, card: Card) -> Text: def _format_trick_won_log( self, winner: BasePlayer, trick_points: int ) -> Text: + """Build the log line for a completed trick: winner and points.""" label = _position_short(winner.position) color = _position_color(winner.position) t = Text() @@ -1962,770 +666,3 @@ def _format_trick_won_log( t.append(f"wins trick ({trick_points} pts).", style=f"bold {GOLD}") return t - def _panel_round_recap( - self, - round_: "Round", - running_scores: dict, - ) -> Panel: - """Between-rounds recap panel — what just happened, in one read. - - Two stacked sub-tables share the N-S / E-W columns. The - **Outcome** table reports the factual play tally — tricks won, - trick points (trump-aware pile), last trick (10) and belote (20) - each side captured — closing with a Total of those points. The - **Scoring** table then summarizes how the round scored: contract - bonus / penalty, round points (the score-contributing part of the - tally — belote only on a chuté/contré round), then the round-score - total. A final Running line carries the game-level totals and the - target. - """ - body = Text() - body.append("\n") - contract = getattr(round_, "contract", None) - ns_round = round_.round_scores.get("North-South", 0) - ew_round = round_.round_scores.get("East-West", 0) - running_ns = running_scores.get("North-South", 0) - running_ew = running_scores.get("East-West", 0) - - # Contract line - body.append(" Contract: ", style=DIM) - if contract is None: - body.append("All passed — no contract", style=f"bold {YELLOW}") - body.append("\n\n") - else: - body.append_text(_format_contract_short(contract, verbose=True)) - body.append("\n") - # Trump recall — the contract label omits the suit, so spell - # it out here the same way the in-game Round panel does, but - # without the ★ flourish (the recap keeps this line plain). - body.append(" Trump: ", style=DIM) - body.append_text(_format_trump_label(contract.suit, star=False)) - body.append("\n") - # Made/failed badge - made = self._contract_made(round_) - body.append(" Result: ", style=DIM) - if made: - body.append("✓ Contract made", style=f"bold {GREEN_CHECK}") - else: - body.append("✗ Contract failed", style=f"bold {RED}") - body.append("\n\n") - - # Two stacked sub-tables sharing the same N-S / E-W columns. - # "Outcome" first — the factual play tally (tricks won, trick - # points, last trick, belote each side captured). "Scoring" next - # — contract bonus, the rolled-up round points, and round score. - breakdown = self._recap_breakdown(round_) - trump = contract.suit if contract is not None else None - all_passed = contract is None - - body.append_text(self._section_rule("Outcome")) - body.append("\n") - body.append_text( - self._format_outcome_table( - breakdown, - trump=trump, - all_passed=all_passed, - capot_label=getattr(round_, "unannounced_capot", None), - ) - ) - body.append("\n") - - body.append_text(self._section_rule("Scoring")) - body.append("\n") - body.append_text( - self._format_recap_table( - breakdown, ns_round, ew_round, all_passed=all_passed - ) - ) - body.append("\n") - - # Running game totals + target. Label padded to the shared - # 24-char column gutter so the numbers line up under N-S / E-W. - body.append(f" {'Running':<22}", style=DIM) - body.append(f"{running_ns:>6}", style=f"bold {BLUE}") - body.append(f" {running_ew:>6}", style=f"bold {ORANGE}") - body.append(f" target {self.target_score}", style=DIM) - - return Panel( - body, - title=Text( - f"Round #{getattr(round_, 'round_number', '?')} recap", - style=f"bold {GOLD}", - ), - border_style=GOLD, - box=ROUNDED, - width=70, - ) - - def _recap_breakdown(self, round_) -> dict: - """Per-team point components used by the recap panel. - - Returns a dict keyed by team name with: - contract: contract-related bonus credited to this team - (attacker base on numeric un-doubled made, - 160+C*mult to the winning side on numeric - failed *and* on numeric doubled/redoubled made - — winner-takes-all; base*mult on Slam family - for the side winning the contract; 0 otherwise). - card_points: sum of card.get_points(trump) across the - team's tricks (trump-aware) for numeric - contracts, *or* the flat substitute - ``slam_card_substitute * multiplier`` credited - to the side winning a Slam-family contract. - The ``card_points_substituted`` flag tells the - renderer which kind it is. - card_points_substituted: - True iff this round uses a Slam-family flat - substitute instead of the actual trick pile. - Drives the row label ("Tricks won (cards)" vs - "Tricks won (subst.)"). - round_points: honest play tally — the real trump-aware pile - captured plus last-trick (10) and belote (20). - Always the true captured total, independent of - how the contract converts it into score; the - Outcome sub-table renders it verbatim. - dix_de_der: 10 if the team took the last trick, else 0. - belote: 20 if the team *holds* both K and Q of trump - (``belote_holder``), else 0. - trick_count: number of tricks won. - cards_count: True when ``card_points`` contributes to the - team's round score (and should render as a - number). False → em-dash. - dix_count: True when ``dix_de_der`` contributes; False → - em-dash. (Always False for Slam family and for - any doubled/failed numeric round — the flat - winner-takes-all bonus already covers the pile.) - belote_count: True when ``belote`` contributes — i.e. iff - this team holds the pair. Belote is always - preserved, win or lose, in every scoring shape. - - Each component is the *contribution to round_score* — so - contract + card_points + dix_de_der + belote always equals - the engine's round_score for that team. - """ - contract = getattr(round_, "contract", None) - trump = contract.suit if contract else None - team_tricks = getattr(round_, "team_tricks", {}) or {} - last_trick_team = None - last_trick_winner = getattr(round_, "last_trick_winner", None) - if last_trick_winner is not None and last_trick_winner.team is not None: - last_trick_team = last_trick_winner.team.name - - belote_team = self._belote_team_in_round(round_) - - attacking_team = ( - contract.team.name if contract is not None else None - ) - contract_made = contract is not None and self._contract_made(round_) - # Unannounced-capot marker set by the engine (None or an - # UnannouncedSlam member). When present, the declaring team's 162 - # pile is shown as the flat 250 substitute with the der folded in. - unannounced_capot = getattr(round_, "unannounced_capot", None) - if contract is not None: - base = contract.get_base_points() - mult = contract.get_multiplier() - is_slam_family = contract.is_slam_family() - slam_substitute = contract.get_slam_card_substitute() - else: - base = 0 - mult = 1 - is_slam_family = False - slam_substitute = 0 - - out = {} - for team_name in ("North-South", "East-West"): - tricks = team_tricks.get(team_name, []) - raw_card_pts = sum( - card.get_points(trump) - for tr in tricks - for _, card in tr.get_plays() - ) - raw_dix = 10 if team_name == last_trick_team else 0 - raw_belote = 20 if team_name == belote_team else 0 - - is_attacker = (team_name == attacking_team) - is_winner = (is_attacker == contract_made) - contract_row = 0 - card_points_value = raw_card_pts - card_points_substituted = False - cards_count = True - dix_count = True - # Outcome-row display values. Default to the real captured - # pile / der; the unannounced-capot branch swaps the pile for - # the flat 250 substitute and folds the der in (shows 0). - display_trick_points = raw_card_pts - display_last_trick = raw_dix - # Belote (+20) is always preserved for the team holding the - # pair, win or lose — so it counts iff this team is the - # holder, in every scoring shape. - belote_count = (team_name == belote_team) - - if contract is None: - # All passed — nothing scores. - cards_count = False - dix_count = False - elif is_slam_family: - # Slam family: the 162 of trick-card points is replaced - # by a flat substitute equal to the contract base. The - # at-risk amount on each half (contract / substitute) - # scales with the multiplier and goes to the side that - # wins the contract. Belote (+20) still applies on top - # for whichever team holds it. Dix de der does NOT — the - # substitute already covers the 162. - card_points_substituted = True - dix_count = False - if is_winner: - contract_row = base * mult - card_points_value = slam_substitute * mult - cards_count = True - else: - card_points_value = 0 - cards_count = False - elif mult == 1: - # Numeric, un-doubled: the two sides share the pile. - if contract_made: - # Made → declarer adds the contract value on top of - # its card pile; both sides keep cards/der/belote. - if is_attacker: - contract_row = base - if is_attacker and unannounced_capot is not None: - # Unannounced capot: the declarer's 162 pile - # (der included) is replaced by the flat 250 - # substitute, mirroring the announced-Slam shape. - card_points_value = 250 - card_points_substituted = True - dix_count = False - display_trick_points = 250 - display_last_trick = 0 - else: - # Failed → defender takes the whole pile + contract; - # the declarer keeps only its belote. - cards_count = False - dix_count = False - if not is_attacker: - contract_row = 160 + base - else: - # Numeric, doubled / redoubled: winner-takes-all. The - # flat 160 + C×M replaces the cards/der pile for both - # sides; the loser scores only its belote. - cards_count = False - dix_count = False - if is_winner: - contract_row = 160 + base * mult - - out[team_name] = { - "contract": contract_row, - "card_points": card_points_value if cards_count else 0, - "card_points_substituted": card_points_substituted, - # Honest play tally for the Outcome sub-table: the real - # trump-aware pile this team captured plus the last-trick - # (10) and belote (20) it earned in play. Independent of - # how the contract converts these into score — so it still - # reflects real captured points in a winner-takes-all round - # where the Scoring rows are dashed out. The display values - # equal the raw ones except on an unannounced capot, where - # the pile reads 250 and the der is folded in (0). - "round_points": display_trick_points + display_last_trick + raw_belote, - # Factual components the Outcome sub-table renders one per - # row. ``trick_points`` is the real pile and ``last_trick`` - # the real der (10/0), both independent of the scoring - # formula; ``belote`` below is already factual (the holder - # keeps it in every shape). - "trick_points": display_trick_points, - "last_trick": display_last_trick, - "dix_de_der": raw_dix if dix_count else 0, - "belote": raw_belote if belote_count else 0, - "trick_count": len(tricks), - "cards_count": cards_count, - "dix_count": dix_count, - "belote_count": belote_count, - } - return out - - @staticmethod - def _section_rule(label: str, width: int = 44) -> Text: - """A dim horizontal rule with a centered section label. - - Renders e.g. ``──────── Outcome ────────`` to split the recap - panel into its Outcome / Scoring sub-tables. ``width`` is the - dash-field length (excluding the 2-space left gutter). - """ - tag = f" {label} " - fill = max(0, width - len(tag)) - left = fill // 2 - right = fill - left - rule = Text(" ") - rule.append("─" * left, style=DIM) - rule.append(tag, style=f"bold {FG}") - rule.append("─" * right, style=DIM) - return rule - - @staticmethod - def _column_divider() -> Text: - """A dim rule under the two N-S / E-W number columns only. - - Anchors a sum row (the Outcome ``Total`` or the Scoring ``Round - score``) without underlining the label gutter. Geometry matches - the shared layout: a 24-char label gutter, then two 6-wide - columns separated by two spaces. - """ - divider = Text() - divider.append(" " * 24, style=DIM) - divider.append("─" * 6, style=DIM) - divider.append(" ", style=DIM) - divider.append("─" * 6, style=DIM) - divider.append("\n") - return divider - - def _format_outcome_table( - self, - breakdown: dict, - *, - trump: Optional[Suit] = None, - all_passed: bool = False, - capot_label: Optional[str] = None, - ) -> Text: - """Render the per-team play tally — the factual results of play. - - Rows: Tricks won (count), Tricks points (trump-aware pile), Last - trick (10 to whoever won trick 8), Belote (20 to the side holding - K+Q of trump) and a closing Total. Every value is the *real* - amount each side captured in play, independent of how the contract - converts it into score — so a winner-takes-all round still surfaces - the points each side genuinely took. The Total is their per-side - sum (trick points + last trick + belote), the honest play tally; - the Scoring sub-table then reports how much of it actually scored. - - When ``all_passed`` is set (no contract was struck, so no cards - were played) every cell renders as an em-dash, so the whole panel - reads consistently. - - When ``capot_label`` is set (an :class:`UnannouncedSlam` member) - the round was an unannounced capot: the Tricks points row already - carries the flat 250 substitute, and the label is appended to its - right (e.g. ``← Grand Slam``) to explain why. - """ - ns = breakdown.get("North-South", {}) - ew = breakdown.get("East-West", {}) - - def _count_cell(value: int) -> Text: - if all_passed: - return Text(f"{'—':>6}", style=DIM) - return Text(f"{value:>6}", style="bold") - - def _bonus_cell(value: int) -> Text: - # Last trick / belote: the captured amount, em-dash when none. - if all_passed or value == 0: - return Text(f"{'—':>6}", style=DIM) - return Text(f"{value:>6}", style="bold") - - # Header row: " N-S E-W" - header = Text() - header.append(f" {'':<22}", style=DIM) - header.append(f"{'N-S':>6}", style=f"bold {BLUE}") - header.append(f" {'E-W':>6}", style=f"bold {ORANGE}") - header.append("\n") - - row_tricks = Text() - row_tricks.append(f" {'Tricks won':<22}", style=FG) - row_tricks.append_text(_count_cell(ns.get("trick_count", 0))) - row_tricks.append(" ") - row_tricks.append_text(_count_cell(ew.get("trick_count", 0))) - row_tricks.append("\n") - - row_points = Text() - row_points.append(f" {'Tricks points':<22}", style=FG) - row_points.append_text(_count_cell(ns.get("trick_points", 0))) - row_points.append(" ") - row_points.append_text(_count_cell(ew.get("trick_points", 0))) - if capot_label and not all_passed: - # Explain the flat 250 substitute sitting in this row. The - # UnannouncedSlam member stringifies to its display label. - row_points.append(f" ← {capot_label}", style=f"bold {GOLD}") - row_points.append("\n") - - # Last-trick bonus (10 points to the team that wins trick 8). - row_last = Text() - row_last.append(f" {'Last trick':<22}", style=FG) - row_last.append_text(_bonus_cell(ns.get("last_trick", 0))) - row_last.append(" ") - row_last.append_text(_bonus_cell(ew.get("last_trick", 0))) - row_last.append("\n") - - # Belote (suit glyph reflects the actual trump suit). The label - # is hand-built so the trump glyph slots into the 24-char gutter. - row_bel = Text() - row_bel.append(" Belote (K + Q ", style=FG) - if trump is not None and trump != Suit.NO_TRUMP: - row_bel.append(_suit_glyph(trump), style=_suit_color(trump)) - else: - row_bel.append("—", style=DIM) - row_bel.append(") ", style=FG) - row_bel.append_text(_bonus_cell(ns.get("belote", 0))) - row_bel.append(" ") - row_bel.append_text(_bonus_cell(ew.get("belote", 0))) - row_bel.append("\n") - - # Total — the honest play tally per side (trick points + last - # trick + belote), surfaced as ``round_points`` by the breakdown. - # ``_count_cell`` keeps a literal 0 for a side that captured - # nothing and an em-dash only when the whole round was passed. - row_total = Text() - row_total.append(f" {'Total':<22}", style=f"bold {FG}") - row_total.append_text(_count_cell(ns.get("round_points", 0))) - row_total.append(" ") - row_total.append_text(_count_cell(ew.get("round_points", 0))) - row_total.append("\n") - - out = Text() - out.append_text(header) - out.append_text(row_tricks) - # Blank line sets the trick *count* apart from the point rows that - # follow (a column rule here would wrongly read as a sub-total). - out.append("\n") - out.append_text(row_points) - out.append_text(row_last) - out.append_text(row_bel) - out.append_text(self._column_divider()) - out.append_text(row_total) - return out - - def _format_recap_table( - self, - breakdown: dict, - ns_round: int, - ew_round: int, - *, - all_passed: bool = False, - ) -> Text: - """Render the Scoring sub-table inside the recap panel. - - Rows: Contract (the bonus a team earns from the contract being - made or failed), Round points (the part of the play tally that - actually scored), then a divider and the engine-computed Round - score. - - Round points is the score-contributing roll-up, not the raw tally: - ``card_points + dix_de_der + belote`` — i.e. ``Round score − - Contract`` by the :meth:`_recap_breakdown` invariant. On a - winner-takes-all round (chuté or contré) the captured pile and - last trick stop counting, so the row collapses to just the belote - the holder keeps, or an em-dash when no belote is held. For engine - data the columns therefore reconcile: Contract + Round points = - Round score, which the divider anchors. - """ - ns = breakdown.get("North-South", {}) - ew = breakdown.get("East-West", {}) - - def _num_cell(value: int, *, show_zero: bool = True) -> Text: - t = Text() - if value == 0 and not show_zero: - t.append(f"{'—':>6}", style=DIM) - return t - t.append(f"{value:>6}", style="bold") - return t - - def _round_points_cell(side: dict) -> Text: - # The score-contributing part only: cards + der + belote, each - # already zeroed by the breakdown when it doesn't count. A - # chuté/contré round leaves belote alone, so this is belote - # (or an em-dash when the side holds none). - if all_passed: - return Text(f"{'—':>6}", style=DIM) - scored = ( - side.get("card_points", 0) - + side.get("dix_de_der", 0) - + side.get("belote", 0) - ) - return _num_cell(scored, show_zero=False) - - # Header row: " N-S E-W" - header = Text() - header.append(f" {'':<22}", style=DIM) - header.append(f"{'N-S':>6}", style=f"bold {BLUE}") - header.append(f" {'E-W':>6}", style=f"bold {ORANGE}") - header.append("\n") - - # Contract row — the bonus each team gets from the contract. - row_contract = Text() - row_contract.append(f" {'Contract':<22}", style=FG) - row_contract.append_text( - _num_cell(ns.get("contract", 0), show_zero=False) - ) - row_contract.append(" ") - row_contract.append_text( - _num_cell(ew.get("contract", 0), show_zero=False) - ) - row_contract.append("\n") - - # Round points row — the score-contributing part of the play tally - # (belote only on a chuté/contré round, em-dash when none scored). - row_points = Text() - row_points.append(f" {'Round points':<22}", style=FG) - row_points.append_text(_round_points_cell(ns)) - row_points.append(" ") - row_points.append_text(_round_points_cell(ew)) - row_points.append("\n") - - row_total = Text() - row_total.append(f" {'Round score':<22}", style=f"bold {GOLD}") - row_total.append_text(_num_cell(ns_round)) - row_total.append(" ") - row_total.append_text(_num_cell(ew_round)) - row_total.append("\n") - - out = Text() - out.append_text(header) - out.append_text(row_contract) - out.append_text(row_points) - out.append_text(self._column_divider()) - out.append_text(row_total) - return out - - @staticmethod - def _belote_team_in_round(round_) -> Optional[str]: - """Return the team *holding* both K and Q of trump this round. - - Belote belongs to whoever holds the pair (``belote_holder``), - not to whichever team captures those cards in a trick — see - contree-domain.md §6.5 and the matching rule in - :meth:`contrai_engine.model.round.Round.calculate_round_scores`. - """ - holder = getattr(round_, "belote_holder", None) - if holder is None or getattr(holder, "team", None) is None: - return None - return holder.team.name - - @staticmethod - def _contract_made(round_) -> bool: - """Canonical made/failed verdict for ``round_``. - - Reads the engine's :attr:`Round.contract_made` flag — the single - source of truth. "round_score > 0" is *not* a safe proxy: a - failed declarer can still score a non-zero Belote bonus. Falls - back to the score heuristic only for legacy/stub rounds that - predate the flag. - """ - made = getattr(round_, "contract_made", None) - if made is not None: - return bool(made) - contract = getattr(round_, "contract", None) - if contract is None: - return False - scores = getattr(round_, "round_scores", {}) or {} - return scores.get(contract.team.name, 0) > 0 - - def _panel_event_log(self) -> Panel: - """Bottom panel showing the last ``LOG_MAX`` events.""" - body = Text() - if not self.event_log: - body.append("(no events yet)", style=DIM) - else: - for i, line in enumerate(self.event_log): - if i > 0: - body.append("\n") - body.append_text(line) - return Panel( - body, - title=Text("Log", style=f"bold {TITLE}"), - border_style=BORDER_DIM, - box=ROUNDED, - width=70, - height=self.LOG_MAX + 2, - ) - - # ------------------------------------------------------------------ - # Prompt text builders (continued) - # ------------------------------------------------------------------ - - def _ai_bid_announcement( - self, player: BasePlayer, bid - ) -> Text: - """Prompt text shown during an AI's brief post-bid pause.""" - label = _position_short(player.position) - t = Text() - if bid == "Pass": - t.append(f"{label} passes.", style=DIM) - elif bid == "Double": - t.append(f"{label} doubles.", style=f"bold {GOLD}") - elif bid == "Redouble": - t.append(f"{label} redoubles.", style=f"bold {GOLD}") - elif isinstance(bid, tuple): - value, suit = bid - t.append(f"{label} bids {value} ", style=FG) - t.append(_suit_glyph(suit), style=_suit_color(suit)) - t.append(".", style=FG) - else: - t.append(f"{label} is thinking…", style=DIM) - return t - - def _ai_card_announcement( - self, player: BasePlayer, card: Card - ) -> Text: - """Prompt text shown during an AI's brief post-play pause.""" - label = _position_short(player.position) - t = Text() - t.append(f"{label} plays ", style=FG) - t.append_text(_format_card_compact(card)) - t.append(".", style=FG) - return t - - def _trick_won_prompt_text(self, winner: BasePlayer) -> Text: - t = Text() - label = _position_short(winner.position) - if winner.is_human: - t.append("You won the trick. ", style=f"bold {GOLD}") - t.append("Press [Enter] to continue…", style=FG) - else: - t.append(f"{label} won the trick. ", style=FG) - t.append("Press [Enter] to continue…", style=DIM) - return t - - def _end_game_prompt_text(self) -> Text: - t = Text() - t.append("Game over. ", style=FG) - t.append("[n]", style=f"bold {YELLOW}") - t.append(" new game · ", style=FG) - t.append("[r]", style=f"bold {YELLOW}") - t.append(" rematch · ", style=FG) - t.append("[q]", style=f"bold {YELLOW}") - t.append(" quit", style=FG) - return t - - # ------------------------------------------------------------------ - # End-game panels - # ------------------------------------------------------------------ - - def _panel_game_over_banner(self, status: dict) -> Panel: - winner_name = status.get("winner") or "—" - winner_abbr = _team_abbr(winner_name) if winner_name != "—" else "—" - final = status.get("final_scores", {}) - ns = final.get("North-South", 0) - ew = final.get("East-West", 0) - is_ns_winner = winner_name == "North-South" - - body = Text() - body.append("\n") - # Winner banner row: gold pill spanning full inner width. - banner = f"★ {winner_abbr} WINS ★" - pad = max(0, (66 - len(banner)) // 2) - body.append(" " * pad) - body.append(banner, style=f"bold {GOLD_FG} on {GOLD_BG}") - body.append("\n\n") - body.append("Final score".center(66), style=DIM) - body.append("\n") - # Score line: "1620 vs 1420" - ns_str = str(ns) - ew_str = str(ew) - score_line = Text() - if is_ns_winner: - score_line.append(ns_str, style=f"bold {GOLD}") - else: - score_line.append(ns_str, style=f"bold {BLUE}") - score_line.append(" vs ", style=DIM) - if not is_ns_winner: - score_line.append(ew_str, style=f"bold {GOLD}") - else: - score_line.append(ew_str, style=f"bold {ORANGE}") - pad2 = max(0, (66 - score_line.cell_len) // 2) - body.append(" " * pad2) - body.append_text(score_line) - body.append("\n") - # Team labels - label_line = Text() - label_line.append("N-S".rjust(len(ns_str)), style=f"bold {BLUE}") - label_line.append(" ", style=DIM) - label_line.append("E-W".ljust(len(ew_str)), style=f"bold {ORANGE}") - pad3 = max(0, (66 - label_line.cell_len) // 2) - body.append(" " * pad3) - body.append_text(label_line) - - return Panel( - body, - title=Text("Game over", style=f"bold {GOLD}"), - border_style=GOLD, - box=DOUBLE, - width=70, - ) - - def _panel_round_summary(self) -> Panel: - table = Table( - show_header=True, - header_style=f"bold {DIM}", - border_style=RULE, - box=SQUARE, - expand=True, - ) - table.add_column("#", justify="right", style=DIM, width=3) - table.add_column("Contract", justify="left") - table.add_column("Made", justify="center", width=5) - table.add_column("N-S pts", justify="right") - table.add_column("E-W pts", justify="right") - table.add_column("Running N-S / E-W", justify="right", style=DIM) - - for row in self.history: - num = str(row.round_number) - contract_cell = self._format_summary_contract(row) - made_cell = ( - Text("✓", style=f"bold {GREEN_CHECK}") - if row.contract_made - else Text("✗", style=f"bold {RED}") - ) - if row.contract is None: - made_cell = Text("—", style=DIM) - ns_cell = (Text(str(row.ns_pts), style=f"bold {BLUE}") - if row.ns_pts > 0 - else Text("·", style=DIM)) - ew_cell = (Text(str(row.ew_pts), style=f"bold {ORANGE}") - if row.ew_pts > 0 - else Text("·", style=DIM)) - running = f"{row.running_ns} / {row.running_ew}" - table.add_row(num, contract_cell, made_cell, ns_cell, ew_cell, - Text(running, style=DIM)) - - return Panel( - table, - title=Text("Round-by-round summary", style=f"bold {TITLE}"), - border_style=BORDER, - box=ROUNDED, - width=70, - ) - - def _format_summary_contract(self, row: RoundSummary) -> Text: - t = Text() - if row.contract is None: - t.append("all passed", style=DIM) - return t - team_abbr = _team_abbr(row.contract_team_name or "") - team_color = _team_color(row.contract_team_name or "") - t.append(team_abbr, style=f"bold {team_color}") - t.append(" ", style=FG) - # SlamLevel.__str__ yields "Slam" / "Solo Slam"; numerics "80"…"180". - value_str = str(row.contract.value) - t.append(value_str, style="bold") - t.append(" ", style=FG) - t.append(_suit_glyph(row.contract.suit), - style=_suit_color(row.contract.suit)) - if row.contract.redouble: - t.append(" redoubled", style=GOLD) - elif row.contract.double: - t.append(" doubled", style=GOLD) - return t - - -# --------------------------------------------------------------------------- -# Layout helper -# --------------------------------------------------------------------------- - - -def _two_column(left, right, *, left_width: int) -> Table: - """Place two panels side-by-side with a fixed-width left column. - - A ``Table.grid`` keeps the row exactly as tall as the panels (unlike - ``rich.layout.Layout``, which expands to fill the console height). - """ - grid = Table.grid(expand=False, padding=(0, 1)) - grid.add_column(width=left_width, no_wrap=True) - grid.add_column(no_wrap=True) - grid.add_row(left, right) - return grid diff --git a/packages/contrai-engine/src/contrai_engine/view/screens/__init__.py b/packages/contrai-engine/src/contrai_engine/view/screens/__init__.py new file mode 100644 index 0000000..f787102 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/screens/__init__.py @@ -0,0 +1,7 @@ +"""Per-screen rendering for the Rich terminal UI. + +One module per screen of the five-screen design (landing, bidding, +mid-trick / trick-won, round recap, game-over). Each exposes pure +``(data) -> Panel/Text`` builders that ``RichView`` composes and prints; +the screens hold no state and do no I/O. +""" diff --git a/packages/contrai-engine/src/contrai_engine/view/screens/bidding.py b/packages/contrai-engine/src/contrai_engine/view/screens/bidding.py new file mode 100644 index 0000000..8726f7a --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/screens/bidding.py @@ -0,0 +1,251 @@ +"""Bidding screen rendering for the Rich terminal UI. + +The auction view: the running bidding-history panel, the per-seat +bidding diamond (each seat shows its latest bid), the adaptive bid +prompt (only advertising actions legal for the next bidder), and the +brief AI post-bid announcement. Pure builders consuming the chronological +``list[Bid]`` history that ``RichView`` passes straight from the +:class:`~contrai_core.auction.Auction`. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Optional + +from contrai_core import Auction, BasePlayer +from contrai_core.bid import ( + Bid, + ContractBid, + DoubleBid, + PassBid, + RedoubleBid, +) +from rich.box import ROUNDED +from rich.panel import Panel +from rich.text import Text + +from contrai_engine.view.formatting import ( + _bid_label, + _position_color, + _position_short, + _suit_color, + _suit_glyph, +) +from contrai_engine.view.theme import ( + BORDER, + DIM, + FG, + GOLD, + RED, + TITLE, + YELLOW, +) + + +def _render_bidding_diamond( + bidding_history: list, + *, + pending_position: Optional[str], + width: int, +) -> Text: + """Render the 4-seat diamond with each player's latest bid. + + Mirrors :func:`contrai_engine.view.screens.trick._render_diamond` + (N top, E right, S bottom, W left) but for the auction: each seat + shows that player's most recent bid, so announces map onto the table + spatially the same way cards do during play. The seat about to bid + is marked ``?``; seats that have not bid yet show ``·``. + + ``bidding_history`` is the chronological ``list[Bid]`` the rest of + the bidding renderer consumes, straight from ``Auction.bids``. + """ + # Collapse the history to the latest bid standing at each seat; + # a later bid by the same player overwrites the earlier one. + latest_by_pos: dict[str, Bid] = {} + for bid in bidding_history: + latest_by_pos[bid.player.position] = bid + + def slot(pos: str) -> Text: + t = Text() + label = _position_short(pos) + pcolor = _position_color(pos) + t.append(f"{label} ", style=f"bold {pcolor}") + if pos == pending_position: + t.append("?", style=f"bold {YELLOW}") + elif pos in latest_by_pos: + t.append_text(_bid_label(latest_by_pos[pos])) + else: + t.append("·", style=DIM) + return t + + # Same skeleton as _render_diamond (blank row, N, W/E, S), minus + # the belote badges — those belong to the play phase. + out = Text() + out.append("\n") + n = slot("North") + pad_left = max(0, (width - n.cell_len) // 2) + out.append(" " * pad_left) + out.append_text(n) + out.append("\n") + w = slot("West") + e = slot("East") + used = w.cell_len + e.cell_len + gap = max(2, width - used) + out.append_text(w) + out.append(" " * gap) + out.append_text(e) + out.append("\n") + s = slot("South") + pad_left = max(0, (width - s.cell_len) // 2) + out.append(" " * pad_left) + out.append_text(s) + return out + + +def _panel_bidding_history(bids: list) -> Panel: + """One-line-per-round history of bids so far. + + Each line starts with the bidding-round number (``#1``, ``#2``, + …) and lays the four seats out in fixed-width columns so bids + line up vertically across rounds: + #1 S Pass E Pass N 80 ♥ W Pass + #2 S 100 ♥ E Pass N 130 ♥ W ×2 + """ + # Fixed column widths so cells stack in vertical lanes. The bid + # cell holds at most "S 180 ♥" (7 cells); pad to leave a gap. + round_w = 4 + cell_w = 11 + body = Text() + if not bids: + body.append("(no bids yet)", style=DIM) + else: + for i, bid in enumerate(bids): + if i % 4 == 0: + # New bidding round: break the line (except the very + # first) and emit the round-number gutter. + if i > 0: + body.append("\n") + label = f"#{i // 4 + 1}" + body.append(label, style=f"bold {DIM}") + body.append(" " * max(1, round_w - len(label)), style=FG) + cell = Text() + cell.append(_position_short(bid.player.position), + style=f"bold {_position_color(bid.player.position)}") + cell.append(" ", style=FG) + cell.append_text(_bid_label(bid)) + # Right-pad the cell to keep the seats in vertical lanes. + body.append_text(cell) + body.append(" " * max(1, cell_w - cell.cell_len), style=FG) + return Panel( + body, + title=Text("Bidding so far", style=f"bold {TITLE}"), + border_style=BORDER, + box=ROUNDED, + width=70, + ) + + +def _bidding_prompt_text( + auction: Auction, + next_player: Optional[BasePlayer] = None, +) -> Text: + """Adaptive bid prompt: recap the last bid, then hint legal actions. + + The action hint is derived straight from + :meth:`Auction.legal_actions` for ``next_player``, so it never + advertises a move the auction would reject (e.g. doubling one's own + partner, or a numeric raise once a Slam stands). + """ + t = Text() + # Recap the last event — for "West passed.". + bids = auction.bids + if bids: + last_bid = bids[-1] + label = _position_short(last_bid.player.position) + if isinstance(last_bid, PassBid): + t.append(f"{label} passed. ", style=FG) + elif isinstance(last_bid, RedoubleBid): + t.append(f"{label} redoubled. ", style=f"bold {GOLD}") + elif isinstance(last_bid, DoubleBid): + t.append(f"{label} doubled. ", style=f"bold {GOLD}") + elif isinstance(last_bid, ContractBid): + t.append(f"{label} bid {last_bid.value} ", style=FG) + t.append(_suit_glyph(last_bid.suit), style=_suit_color(last_bid.suit)) + t.append(". ", style=FG) + t.append("Your bid? ", style=FG) + # Adaptive example — only advertise actions that are actually legal + # for the next bidder. The enumerated legal action space is the + # single source of truth here. + legal = auction.legal_actions(next_player) if next_player is not None else () + if any(isinstance(b, RedoubleBid) for b in legal): + # Contractor just got doubled: redouble is the only + # meaningful active option besides passing. + t.append("(pass / redouble)", style=DIM) + else: + # The worked contract example tracks the auction: show the + # cheapest *legal* raise (100 once 90 stands), never the bare + # 80 floor. Dropped entirely past 180, where only Slam remains. + options: list[str] = [] + min_value = _cheapest_legal_raise(legal) + if min_value is not None: + options.append(f"'{min_value} H'") + options.append("'pass'") + if any(isinstance(b, DoubleBid) for b in legal): + options.append("'double'") + t.append(f"(e.g. {' / '.join(options)})", style=DIM) + return t + + +def _cheapest_legal_raise(legal: Iterable[Bid]) -> Optional[int]: + """Smallest numeric contract value among the given legal actions. + + Returns ``None`` when no numeric raise remains — past 180 only the + Slam family is left, and its value is a ``SlamLevel``, not an + ``int``, so it is filtered out here. + """ + return min( + ( + b.value + for b in legal + if isinstance(b, ContractBid) and isinstance(b.value, int) + ), + default=None, + ) + + +def _bid_rejection_text(auction: Auction, player: BasePlayer) -> Text: + """Notice shown when the human's bid input does not parse. + + The worked contract example tracks the auction exactly like + :func:`_bidding_prompt_text`: once 90 stands the notice suggests + '100 h', and past 180 — where only the Slam family remains — the + numeric example is dropped entirely. + """ + examples = ["'pass'", "'double'", "'redouble'"] + min_value = _cheapest_legal_raise(auction.legal_actions(player)) + if min_value is not None: + examples.insert(0, f"'{min_value} h'") + return Text( + f"✗ Unrecognized bid. Try {', '.join(examples)}.", + style=RED, + ) + + +def _ai_bid_announcement(player: BasePlayer, bid: Bid) -> Text: + """Prompt text shown during an AI's brief post-bid pause.""" + label = _position_short(player.position) + t = Text() + if isinstance(bid, PassBid): + t.append(f"{label} passes.", style=DIM) + elif isinstance(bid, RedoubleBid): + t.append(f"{label} redoubles.", style=f"bold {GOLD}") + elif isinstance(bid, DoubleBid): + t.append(f"{label} doubles.", style=f"bold {GOLD}") + elif isinstance(bid, ContractBid): + t.append(f"{label} bids {bid.value} ", style=FG) + t.append(_suit_glyph(bid.suit), style=_suit_color(bid.suit)) + t.append(".", style=FG) + else: + t.append(f"{label} is thinking…", style=DIM) + return t diff --git a/packages/contrai-engine/src/contrai_engine/view/screens/endgame.py b/packages/contrai-engine/src/contrai_engine/view/screens/endgame.py new file mode 100644 index 0000000..71bf1cf --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/screens/endgame.py @@ -0,0 +1,188 @@ +"""End-game screen rendering for the Rich terminal UI. + +The final scoreboard: the winner banner, the round-by-round summary +table (one row per :class:`~contrai_engine.view.rich_view.RoundSummary`), +the per-row contract cell, and the new-game / rematch / quit prompt. +Pure builders consuming the UI-side history ``RichView`` accumulated. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.box import DOUBLE, ROUNDED, SQUARE +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from contrai_engine.view.formatting import ( + _suit_color, + _suit_glyph, + _team_abbr, + _team_color, +) +from contrai_engine.view.theme import ( + BLUE, + BORDER, + DIM, + FG, + GOLD, + GOLD_BG, + GOLD_FG, + GREEN_CHECK, + ORANGE, + RED, + RULE, + TITLE, + YELLOW, +) + +if TYPE_CHECKING: + from contrai_engine.model.game import GameOverStatus + from contrai_engine.view.rich_view import RoundSummary + + +def _panel_game_over_banner(status: GameOverStatus) -> Panel: + """Gold double-bordered banner naming the winning team and final score. + + The winner's total is highlighted gold; the loser keeps its team + color. Team labels sit under their numbers. + """ + winner_name = status.winner or "—" + winner_abbr = _team_abbr(winner_name) if winner_name != "—" else "—" + final = status.final_scores + ns = final.get("North-South", 0) + ew = final.get("East-West", 0) + is_ns_winner = winner_name == "North-South" + + body = Text() + body.append("\n") + # Winner banner row: gold pill spanning full inner width. + banner = f"★ {winner_abbr} WINS ★" + pad = max(0, (66 - len(banner)) // 2) + body.append(" " * pad) + body.append(banner, style=f"bold {GOLD_FG} on {GOLD_BG}") + body.append("\n\n") + body.append("Final score".center(66), style=DIM) + body.append("\n") + # Score line: "1620 vs 1420" + ns_str = str(ns) + ew_str = str(ew) + score_line = Text() + if is_ns_winner: + score_line.append(ns_str, style=f"bold {GOLD}") + else: + score_line.append(ns_str, style=f"bold {BLUE}") + score_line.append(" vs ", style=DIM) + if not is_ns_winner: + score_line.append(ew_str, style=f"bold {GOLD}") + else: + score_line.append(ew_str, style=f"bold {ORANGE}") + pad2 = max(0, (66 - score_line.cell_len) // 2) + body.append(" " * pad2) + body.append_text(score_line) + body.append("\n") + # Team labels + label_line = Text() + label_line.append("N-S".rjust(len(ns_str)), style=f"bold {BLUE}") + label_line.append(" ", style=DIM) + label_line.append("E-W".ljust(len(ew_str)), style=f"bold {ORANGE}") + pad3 = max(0, (66 - label_line.cell_len) // 2) + body.append(" " * pad3) + body.append_text(label_line) + + return Panel( + body, + title=Text("Game over", style=f"bold {GOLD}"), + border_style=GOLD, + box=DOUBLE, + width=70, + ) + + +def _panel_round_summary(history: list["RoundSummary"]) -> Panel: + """Round-by-round table: one row per :class:`RoundSummary`. + + Columns: round number, contract, made/failed mark, per-team round + points, and the running game totals after that round. + """ + table = Table( + show_header=True, + header_style=f"bold {DIM}", + border_style=RULE, + box=SQUARE, + expand=True, + ) + table.add_column("#", justify="right", style=DIM, width=3) + table.add_column("Contract", justify="left") + table.add_column("Made", justify="center", width=5) + table.add_column("N-S pts", justify="right") + table.add_column("E-W pts", justify="right") + table.add_column("Running N-S / E-W", justify="right", style=DIM) + + for row in history: + num = str(row.round_number) + contract_cell = _format_summary_contract(row) + made_cell = ( + Text("✓", style=f"bold {GREEN_CHECK}") + if row.contract_made + else Text("✗", style=f"bold {RED}") + ) + if row.contract is None: + made_cell = Text("—", style=DIM) + ns_cell = (Text(str(row.ns_pts), style=f"bold {BLUE}") + if row.ns_pts > 0 + else Text("·", style=DIM)) + ew_cell = (Text(str(row.ew_pts), style=f"bold {ORANGE}") + if row.ew_pts > 0 + else Text("·", style=DIM)) + running = f"{row.running_ns} / {row.running_ew}" + table.add_row(num, contract_cell, made_cell, ns_cell, ew_cell, + Text(running, style=DIM)) + + return Panel( + table, + title=Text("Round-by-round summary", style=f"bold {TITLE}"), + border_style=BORDER, + box=ROUNDED, + width=70, + ) + + +def _format_summary_contract(row: "RoundSummary") -> Text: + """Contract cell for a summary row: team, value, suit, double marker. + + Renders a dim ``all passed`` when the round produced no contract. + """ + t = Text() + if row.contract is None: + t.append("all passed", style=DIM) + return t + team_abbr = _team_abbr(row.contract_team_name or "") + team_color = _team_color(row.contract_team_name or "") + t.append(team_abbr, style=f"bold {team_color}") + t.append(" ", style=FG) + # SlamLevel.__str__ yields "Slam" / "Solo Slam"; numerics "80"…"180". + value_str = str(row.contract.value) + t.append(value_str, style="bold") + t.append(" ", style=FG) + t.append(_suit_glyph(row.contract.suit), + style=_suit_color(row.contract.suit)) + if row.contract.redouble: + t.append(" redoubled", style=GOLD) + elif row.contract.double: + t.append(" doubled", style=GOLD) + return t + + +def _end_game_prompt_text() -> Text: + """Prompt line offering the ``[n]`` / ``[r]`` / ``[q]`` end-game choices.""" + t = Text() + t.append("Game over. ", style=FG) + t.append("[n]", style=f"bold {YELLOW}") + t.append(" new game · ", style=FG) + t.append("[r]", style=f"bold {YELLOW}") + t.append(" rematch · ", style=FG) + t.append("[q]", style=f"bold {YELLOW}") + t.append(" quit", style=FG) + return t diff --git a/packages/contrai-engine/src/contrai_engine/view/screens/landing.py b/packages/contrai-engine/src/contrai_engine/view/screens/landing.py new file mode 100644 index 0000000..936aa13 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/screens/landing.py @@ -0,0 +1,168 @@ +"""Landing screen rendering for the Rich terminal UI. + +The pre-game splash: the block-ASCII title and subtitle, the suit +ribbon, the target-score radio, the seat roster, and the target prompt. +Pure builders consuming scalars. +""" + +from __future__ import annotations + +from contrai_core import Suit +from rich.box import ROUNDED +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from contrai_engine.view.formatting import _suit_glyph +from contrai_engine.view.theme import ( + BLUE, + BORDER, + DEFAULT_TARGET, + DIM, + FG, + GOLD, + GOLD_BG, + GOLD_FG, + GREEN_FG, + ORANGE, + RED, + TARGET_OPTIONS, + TITLE, + YELLOW, +) + +try: + from pyfiglet import Figlet + _HAS_PYFIGLET = True +except ImportError: + _HAS_PYFIGLET = False + + +def _landing_title() -> Text: + """Centered block-ASCII CONTRAI title.""" + if _HAS_PYFIGLET: + ascii_art = Figlet(font="ansi_shadow", width=70).renderText("CONTRAI") + else: + ascii_art = "CONTRAI" + t = Text() + for line in ascii_art.splitlines(): + t.append(line.center(70), style=f"bold {YELLOW}") + t.append("\n") + return t + + +def _landing_subtitle() -> Text: + """Centered dim subtitle line under the block title.""" + return Text("Belote · Contrée · CLI edition".center(70), style=DIM) + + +def _landing_suit_ribbon() -> Text: + """Centered decorative ribbon of the four suit glyphs.""" + ribbon = Text() + glyphs = [(Suit.SPADES, FG), (Suit.HEARTS, RED), + (Suit.DIAMONDS, RED), (Suit.CLUBS, FG)] + # Build " ♠ ♥ ♦ ♣ " then center it. + segments = [] + for suit, color in glyphs: + segments.append((suit, color)) + # Render with 3 spaces between glyphs. + inner = Text() + for i, (suit, color) in enumerate(segments): + if i > 0: + inner.append(" ") + inner.append(_suit_glyph(suit), style=f"bold {color}") + # Centered within 70 cols. + total = inner.cell_len + pad = max(0, (70 - total) // 2) + ribbon.append(" " * pad) + ribbon.append_text(inner) + return ribbon + + +def _panel_game_setup(selected: int) -> Panel: + """Five radio rows for target score, highlight the selected one.""" + rows = Text() + rows.append("Target score", style=f"bold {FG}") + rows.append(" ", style=FG) + rows.append( + "(first team to reach the target wins the game)\n\n", + style=DIM, + ) + for value, label, estimate in TARGET_OPTIONS: + is_sel = value == selected + line = Text() + if is_sel: + radio = "(●)" + line.append(f" {radio} ", style=f"bold {GOLD_FG} on {GOLD_BG}") + line.append(f"{value:<4} ", style=f"bold {GOLD_FG} on {GOLD_BG}") + line.append(f"{label:<10}", style=f"{GOLD_FG} on {GOLD_BG}") + line.append(f" · {estimate}", style=f"{GOLD_FG} on {GOLD_BG}") + if value == DEFAULT_TARGET: + line.append(" ← default", style=f"bold {GOLD} on {GOLD_BG}") + # Pad to fill the panel width with the gold background. + used = line.cell_len + line.append(" " * max(0, 60 - used), style=f"on {GOLD_BG}") + else: + line.append(" ( ) ", style=DIM) + line.append(f"{value:<4} ", style=f"bold {FG}") + line.append(f"{label:<10}", style=FG) + line.append(f" · {estimate}", style=DIM) + rows.append_text(line) + rows.append("\n") + return Panel( + rows, + title=Text("Game setup", style=f"bold {TITLE}"), + border_style=BORDER, + box=ROUNDED, + width=70, + ) + + +def _panel_players() -> Panel: + """Players block. Hardcoded for v1 — South=human, others=AI expert. + + TODO: replace with a configurable seat picker when we expose + difficulty / player config on the landing screen. + """ + seats = [ + ("N", "North", "AI · expert", BLUE, False), + ("E", "East", "AI · expert", ORANGE, False), + ("S", "You", "human", GREEN_FG, True), + ("W", "West", "AI · expert", ORANGE, False), + ] + # Two columns of two: render as a 2-row, 2-col Table. + table = Table.grid(expand=True, padding=(0, 2)) + table.add_column(ratio=1) + table.add_column(ratio=1) + rows = [] + for label, name, role, color, is_human in seats: + cell = Text() + cell.append(label, style=f"bold {color}") + cell.append(" ", style=FG) + if is_human: + cell.append(name, style=f"bold {color}") + else: + cell.append(name, style=FG) + cell.append(f" ({role})", style=DIM) + rows.append(cell) + table.add_row(rows[0], rows[1]) # N, E + table.add_row(rows[2], rows[3]) # S, W + return Panel( + table, + title=Text("Players", style=f"bold {TITLE}"), + border_style=BORDER, + box=ROUNDED, + width=70, + ) + + +def _landing_prompt_text(selected: int) -> Text: + """Prompt line asking for the target score, naming the default.""" + t = Text() + t.append( + "Target score? [500 / 1000 / 1500 / 2000 / 3000] (default ", + style=FG, + ) + t.append(str(selected), style=f"bold {GOLD}") + t.append(")", style=FG) + return t diff --git a/packages/contrai-engine/src/contrai_engine/view/screens/recap.py b/packages/contrai-engine/src/contrai_engine/view/screens/recap.py new file mode 100644 index 0000000..e5df319 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/screens/recap.py @@ -0,0 +1,612 @@ +"""Round-recap screen rendering for the Rich terminal UI. + +The between-rounds panel: contract + made/failed, an Outcome sub-table +(the factual play tally) and a Scoring sub-table (how the round scored), +closing with the running game totals. ``_recap_breakdown`` computes the +per-team point components both sub-tables read; the rest are pure +``(data) -> Panel/Text`` builders ``RichView.show_round_recap`` drives. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from contrai_core import Suit +from rich.box import ROUNDED +from rich.panel import Panel +from rich.text import Text + +from contrai_engine.view.formatting import ( + _format_contract_short, + _format_trump_label, + _suit_color, + _suit_glyph, +) +from contrai_engine.view.theme import ( + BLUE, + DEFAULT_TARGET, + DIM, + FG, + GOLD, + GREEN_CHECK, + ORANGE, + RED, + YELLOW, +) + +if TYPE_CHECKING: + from contrai_engine.model.round import Round + + +def _panel_round_recap( + round_: Round, + running_scores: dict, + target_score: int = DEFAULT_TARGET, + *, + tiebreaker: bool = False, +) -> Panel: + """Between-rounds recap panel — what just happened, in one read. + + Two stacked sub-tables share the N-S / E-W columns. The + **Outcome** table reports the factual play tally — tricks won, + trick points (trump-aware pile), last trick (10) and belote (20) + each side captured — closing with a Total of those points. The + **Scoring** table then summarizes how the round scored: contract + bonus / penalty, round points (the score-contributing part of the + tally — belote only on a failed/doubled round), then the round-score + total. A final Running line carries the game-level totals and the + target. When ``tiebreaker`` is set (both teams level at/above the + target) a sudden-death notice closes the panel. + """ + body = Text() + body.append("\n") + contract = getattr(round_, "contract", None) + ns_round = round_.round_scores.get("North-South", 0) + ew_round = round_.round_scores.get("East-West", 0) + running_ns = running_scores.get("North-South", 0) + running_ew = running_scores.get("East-West", 0) + + # Contract line + body.append(" Contract: ", style=DIM) + if contract is None: + body.append("All passed — no contract", style=f"bold {YELLOW}") + body.append("\n\n") + else: + body.append_text(_format_contract_short(contract, verbose=True)) + body.append("\n") + # Trump recall — the contract label omits the suit, so spell + # it out here the same way the in-game Round panel does. + body.append(" Trump: ", style=DIM) + body.append_text(_format_trump_label(contract.suit)) + body.append("\n") + # Made/failed badge + made = _contract_made(round_) + body.append(" Result: ", style=DIM) + if made: + body.append("✓ Contract made", style=f"bold {GREEN_CHECK}") + else: + body.append("✗ Contract failed", style=f"bold {RED}") + body.append("\n\n") + + # Two stacked sub-tables sharing the same N-S / E-W columns. + # "Outcome" first — the factual play tally (tricks won, trick + # points, last trick, belote each side captured). "Scoring" next + # — contract bonus, the rolled-up round points, and round score. + breakdown = _recap_breakdown(round_) + trump = contract.suit if contract is not None else None + all_passed = contract is None + + body.append_text(_section_rule("Outcome")) + body.append("\n") + body.append_text( + _format_outcome_table( + breakdown, + trump=trump, + all_passed=all_passed, + slam_label=getattr(round_, "unannounced_slam", None), + ) + ) + body.append("\n") + + body.append_text(_section_rule("Scoring")) + body.append("\n") + body.append_text( + _format_recap_table( + breakdown, ns_round, ew_round, all_passed=all_passed + ) + ) + body.append("\n") + + # Running game totals + target. Label padded to the shared + # 24-char column gutter so the numbers line up under N-S / E-W. + body.append(f" {'Running':<22}", style=DIM) + body.append(f"{running_ns:>6}", style=f"bold {BLUE}") + body.append(f" {running_ew:>6}", style=f"bold {ORANGE}") + body.append(f" target {target_score}", style=DIM) + + if tiebreaker: + # Sudden death: both teams sit level at/above the target, so + # the game continues until one of them leads. + body.append("\n\n") + body.append( + " Scores level at the target — tiebreaker round follows", + style=f"bold {GOLD}", + ) + + return Panel( + body, + title=Text( + f"Round #{getattr(round_, 'round_number', '?')} recap", + style=f"bold {GOLD}", + ), + border_style=GOLD, + box=ROUNDED, + width=70, + ) + + +def _recap_breakdown(round_) -> dict: + """Per-team point components used by the recap panel. + + Returns a dict keyed by team name with: + contract: contract-related bonus credited to this team + (attacker base on numeric un-doubled made, + 160+C*mult to the winning side on numeric + failed *and* on numeric doubled/redoubled made + — winner-takes-all; base*mult on Slam family + for the side winning the contract; 0 otherwise). + card_points: sum of card.get_points(trump) across the + team's tricks (trump-aware) for numeric + contracts, *or* the flat substitute + ``slam_card_substitute * multiplier`` credited + to the side winning a Slam-family contract. + The ``card_points_substituted`` flag tells the + renderer which kind it is. + card_points_substituted: + True iff this round uses a Slam-family flat + substitute instead of the actual trick pile. + Drives the row label ("Tricks won (cards)" vs + "Tricks won (subst.)"). + round_points: honest play tally — the real trump-aware pile + captured plus last-trick (10) and belote (20). + Always the true captured total, independent of + how the contract converts it into score; the + Outcome sub-table renders it verbatim. + last_trick_bonus: + 10 if the team took the last trick, else 0. + belote: 20 if the team *holds* both K and Q of trump + (``belote_holder``), else 0. + trick_count: number of tricks won. + cards_count: True when ``card_points`` contributes to the + team's round score (and should render as a + number). False → em-dash. + last_trick_counts: + True when ``last_trick_bonus`` contributes; + False → em-dash. (Always False for Slam family + and for any doubled/failed numeric round — the + flat winner-takes-all bonus already covers the + pile.) + belote_count: True when ``belote`` contributes — i.e. iff + this team holds the pair. Belote is always + preserved, win or lose, in every scoring shape. + + Each component is the *contribution to round_score* — so + contract + card_points + last_trick_bonus + belote always equals + the engine's round_score for that team. + """ + contract = getattr(round_, "contract", None) + trump = contract.suit if contract else None + team_tricks = getattr(round_, "team_tricks", {}) or {} + last_trick_team = None + last_trick_winner = getattr(round_, "last_trick_winner", None) + if last_trick_winner is not None and last_trick_winner.team is not None: + last_trick_team = last_trick_winner.team.name + + belote_team = _belote_team_in_round(round_) + + attacking_team = ( + contract.team.name if contract is not None else None + ) + contract_made = contract is not None and _contract_made(round_) + # Unannounced-Slam marker set by the engine (None or an + # UnannouncedSlam member). When present, the declaring team's 162 + # pile is shown as the flat 250 substitute with the last-trick + # bonus folded in. + unannounced_slam = getattr(round_, "unannounced_slam", None) + if contract is not None: + base = contract.get_base_points() + mult = contract.get_multiplier() + is_slam_family = contract.is_slam_family() + slam_substitute = contract.get_slam_card_substitute() + else: + base = 0 + mult = 1 + is_slam_family = False + slam_substitute = 0 + + out = {} + for team_name in ("North-South", "East-West"): + tricks = team_tricks.get(team_name, []) + raw_card_pts = sum( + card.get_points(trump) + for tr in tricks + for _, card in tr.get_plays() + ) + raw_last_trick = 10 if team_name == last_trick_team else 0 + raw_belote = 20 if team_name == belote_team else 0 + + is_attacker = (team_name == attacking_team) + is_winner = (is_attacker == contract_made) + contract_row = 0 + card_points_value = raw_card_pts + card_points_substituted = False + cards_count = True + last_trick_counts = True + # Outcome-row display values. Default to the real captured + # pile / last-trick bonus; the unannounced-slam branch swaps + # the pile for the flat 250 substitute and folds the + # last-trick bonus in (shows 0). + display_trick_points = raw_card_pts + display_last_trick = raw_last_trick + # Belote (+20) is always preserved for the team holding the + # pair, win or lose — so it counts iff this team is the + # holder, in every scoring shape. + belote_count = (team_name == belote_team) + + if contract is None: + # All passed — nothing scores. + cards_count = False + last_trick_counts = False + elif is_slam_family: + # Slam family: the 162 of trick-card points is replaced + # by a flat substitute equal to the contract base. The + # at-risk amount on each half (contract / substitute) + # scales with the multiplier and goes to the side that + # wins the contract. Belote (+20) still applies on top + # for whichever team holds it. The last-trick bonus does + # NOT — the substitute already covers the 162. + card_points_substituted = True + last_trick_counts = False + if is_winner: + contract_row = base * mult + card_points_value = slam_substitute * mult + cards_count = True + else: + card_points_value = 0 + cards_count = False + elif mult == 1: + # Numeric, un-doubled: the two sides share the pile. + if contract_made: + # Made → declarer adds the contract value on top of + # its card pile; both sides keep cards, the last-trick + # bonus, and belote. + if is_attacker: + contract_row = base + if is_attacker and unannounced_slam is not None: + # Unannounced slam: the declarer's 162 pile + # (last-trick bonus included) is replaced by the + # flat 250 substitute, mirroring the + # announced-Slam shape. + card_points_value = 250 + card_points_substituted = True + last_trick_counts = False + display_trick_points = 250 + display_last_trick = 0 + else: + # Failed → defender takes the whole pile + contract; + # the declarer keeps only its belote. + cards_count = False + last_trick_counts = False + if not is_attacker: + contract_row = 160 + base + else: + # Numeric, doubled / redoubled: winner-takes-all. The + # flat 160 + C×M replaces the cards/last-trick pile for + # both sides; the loser scores only its belote. + cards_count = False + last_trick_counts = False + if is_winner: + contract_row = 160 + base * mult + + out[team_name] = { + "contract": contract_row, + "card_points": card_points_value if cards_count else 0, + "card_points_substituted": card_points_substituted, + # Honest play tally for the Outcome sub-table: the real + # trump-aware pile this team captured plus the last-trick + # (10) and belote (20) it earned in play. Independent of + # how the contract converts these into score — so it still + # reflects real captured points in a winner-takes-all round + # where the Scoring rows are dashed out. The display values + # equal the raw ones except on an unannounced slam, where + # the pile reads 250 and the last-trick bonus is folded + # in (0). + "round_points": display_trick_points + display_last_trick + raw_belote, + # Factual components the Outcome sub-table renders one per + # row. ``trick_points`` is the real pile and ``last_trick`` + # the real last-trick bonus (10/0), both independent of the + # scoring formula; ``belote`` below is already factual (the + # holder keeps it in every shape). + "trick_points": display_trick_points, + "last_trick": display_last_trick, + "last_trick_bonus": raw_last_trick if last_trick_counts else 0, + "belote": raw_belote if belote_count else 0, + "trick_count": len(tricks), + "cards_count": cards_count, + "last_trick_counts": last_trick_counts, + "belote_count": belote_count, + } + return out + + +def _section_rule(label: str, width: int = 44) -> Text: + """A dim horizontal rule with a centered section label. + + Renders e.g. ``──────── Outcome ────────`` to split the recap + panel into its Outcome / Scoring sub-tables. ``width`` is the + dash-field length (excluding the 2-space left gutter). + """ + tag = f" {label} " + fill = max(0, width - len(tag)) + left = fill // 2 + right = fill - left + rule = Text(" ") + rule.append("─" * left, style=DIM) + rule.append(tag, style=f"bold {FG}") + rule.append("─" * right, style=DIM) + return rule + + +def _column_divider() -> Text: + """A dim rule under the two N-S / E-W number columns only. + + Anchors a sum row (the Outcome ``Total`` or the Scoring ``Round + score``) without underlining the label gutter. Geometry matches + the shared layout: a 24-char label gutter, then two 6-wide + columns separated by two spaces. + """ + divider = Text() + divider.append(" " * 24, style=DIM) + divider.append("─" * 6, style=DIM) + divider.append(" ", style=DIM) + divider.append("─" * 6, style=DIM) + divider.append("\n") + return divider + + +def _format_outcome_table( + breakdown: dict, + *, + trump: Optional[Suit] = None, + all_passed: bool = False, + slam_label: Optional[str] = None, +) -> Text: + """Render the per-team play tally — the factual results of play. + + Rows: Tricks won (count), Tricks points (trump-aware pile), Last + trick (10 to whoever won trick 8), Belote (20 to the side holding + K+Q of trump) and a closing Total. Every value is the *real* + amount each side captured in play, independent of how the contract + converts it into score — so a winner-takes-all round still surfaces + the points each side genuinely took. The Total is their per-side + sum (trick points + last trick + belote), the honest play tally; + the Scoring sub-table then reports how much of it actually scored. + + When ``all_passed`` is set (no contract was struck, so no cards + were played) every cell renders as an em-dash, so the whole panel + reads consistently. + + When ``slam_label`` is set (an :class:`UnannouncedSlam` member) + the round was an unannounced Slam: the Tricks points row already + carries the flat 250 substitute, and the label is appended to its + right (e.g. ``← Grand Slam``) to explain why. + """ + ns = breakdown.get("North-South", {}) + ew = breakdown.get("East-West", {}) + + def _count_cell(value: int) -> Text: + if all_passed: + return Text(f"{'—':>6}", style=DIM) + return Text(f"{value:>6}", style="bold") + + def _bonus_cell(value: int) -> Text: + # Last trick / belote: the captured amount, em-dash when none. + if all_passed or value == 0: + return Text(f"{'—':>6}", style=DIM) + return Text(f"{value:>6}", style="bold") + + # Header row: " N-S E-W" + header = Text() + header.append(f" {'':<22}", style=DIM) + header.append(f"{'N-S':>6}", style=f"bold {BLUE}") + header.append(f" {'E-W':>6}", style=f"bold {ORANGE}") + header.append("\n") + + row_tricks = Text() + row_tricks.append(f" {'Tricks won':<22}", style=FG) + row_tricks.append_text(_count_cell(ns.get("trick_count", 0))) + row_tricks.append(" ") + row_tricks.append_text(_count_cell(ew.get("trick_count", 0))) + row_tricks.append("\n") + + row_points = Text() + row_points.append(f" {'Tricks points':<22}", style=FG) + row_points.append_text(_count_cell(ns.get("trick_points", 0))) + row_points.append(" ") + row_points.append_text(_count_cell(ew.get("trick_points", 0))) + if slam_label and not all_passed: + # Explain the flat 250 substitute sitting in this row. The + # UnannouncedSlam member stringifies to its display label. + row_points.append(f" ← {slam_label}", style=f"bold {GOLD}") + row_points.append("\n") + + # Last-trick bonus (10 points to the team that wins trick 8). + row_last = Text() + row_last.append(f" {'Last trick':<22}", style=FG) + row_last.append_text(_bonus_cell(ns.get("last_trick", 0))) + row_last.append(" ") + row_last.append_text(_bonus_cell(ew.get("last_trick", 0))) + row_last.append("\n") + + # Belote (suit glyph reflects the actual trump suit). The label + # is hand-built so the trump glyph slots into the 24-char gutter. + row_bel = Text() + row_bel.append(" Belote (K + Q ", style=FG) + if trump is not None and trump != Suit.NO_TRUMP: + row_bel.append(_suit_glyph(trump), style=_suit_color(trump)) + else: + row_bel.append("—", style=DIM) + row_bel.append(") ", style=FG) + row_bel.append_text(_bonus_cell(ns.get("belote", 0))) + row_bel.append(" ") + row_bel.append_text(_bonus_cell(ew.get("belote", 0))) + row_bel.append("\n") + + # Total — the honest play tally per side (trick points + last + # trick + belote), surfaced as ``round_points`` by the breakdown. + # ``_count_cell`` keeps a literal 0 for a side that captured + # nothing and an em-dash only when the whole round was passed. + row_total = Text() + row_total.append(f" {'Total':<22}", style=f"bold {FG}") + row_total.append_text(_count_cell(ns.get("round_points", 0))) + row_total.append(" ") + row_total.append_text(_count_cell(ew.get("round_points", 0))) + row_total.append("\n") + + out = Text() + out.append_text(header) + out.append_text(row_tricks) + # Column rule sets the trick *count* apart from the point rows + # that follow, mirroring the rule drawn before the Total row. + out.append_text(_column_divider()) + out.append_text(row_points) + out.append_text(row_last) + out.append_text(row_bel) + out.append_text(_column_divider()) + out.append_text(row_total) + return out + + +def _format_recap_table( + breakdown: dict, + ns_round: int, + ew_round: int, + *, + all_passed: bool = False, +) -> Text: + """Render the Scoring sub-table inside the recap panel. + + Rows: Contract (the bonus a team earns from the contract being + made or failed), Round points (the part of the play tally that + actually scored), then a divider and the engine-computed Round + score. + + Round points is the score-contributing roll-up, not the raw tally: + ``card_points + last_trick_bonus + belote`` — i.e. ``Round score − + Contract`` by the :meth:`_recap_breakdown` invariant. On a + winner-takes-all round (failed or doubled) the captured pile and + last trick stop counting, so the row collapses to just the belote + the holder keeps, or an em-dash when no belote is held. For engine + data the columns therefore reconcile: Contract + Round points = + Round score, which the divider anchors. + """ + ns = breakdown.get("North-South", {}) + ew = breakdown.get("East-West", {}) + + def _num_cell(value: int, *, show_zero: bool = True) -> Text: + t = Text() + if value == 0 and not show_zero: + t.append(f"{'—':>6}", style=DIM) + return t + t.append(f"{value:>6}", style="bold") + return t + + def _round_points_cell(side: dict) -> Text: + # The score-contributing part only: cards + last-trick bonus + + # belote, each already zeroed by the breakdown when it doesn't + # count. A failed/doubled round leaves belote alone, so this + # is belote (or an em-dash when the side holds none). + if all_passed: + return Text(f"{'—':>6}", style=DIM) + scored = ( + side.get("card_points", 0) + + side.get("last_trick_bonus", 0) + + side.get("belote", 0) + ) + return _num_cell(scored, show_zero=False) + + # Header row: " N-S E-W" + header = Text() + header.append(f" {'':<22}", style=DIM) + header.append(f"{'N-S':>6}", style=f"bold {BLUE}") + header.append(f" {'E-W':>6}", style=f"bold {ORANGE}") + header.append("\n") + + # Contract row — the bonus each team gets from the contract. + row_contract = Text() + row_contract.append(f" {'Contract':<22}", style=FG) + row_contract.append_text( + _num_cell(ns.get("contract", 0), show_zero=False) + ) + row_contract.append(" ") + row_contract.append_text( + _num_cell(ew.get("contract", 0), show_zero=False) + ) + row_contract.append("\n") + + # Round points row — the score-contributing part of the play tally + # (belote only on a failed/doubled round, em-dash when none scored). + row_points = Text() + row_points.append(f" {'Round points':<22}", style=FG) + row_points.append_text(_round_points_cell(ns)) + row_points.append(" ") + row_points.append_text(_round_points_cell(ew)) + row_points.append("\n") + + row_total = Text() + row_total.append(f" {'Round score':<22}", style=f"bold {GOLD}") + row_total.append_text(_num_cell(ns_round)) + row_total.append(" ") + row_total.append_text(_num_cell(ew_round)) + row_total.append("\n") + + out = Text() + out.append_text(header) + out.append_text(row_contract) + out.append_text(row_points) + out.append_text(_column_divider()) + out.append_text(row_total) + return out + + +def _belote_team_in_round(round_) -> Optional[str]: + """Return the team *holding* both K and Q of trump this round. + + Belote belongs to whoever holds the pair (``belote_holder``), + not to whichever team captures those cards in a trick — see the + matching rule in + :meth:`contrai_engine.model.round.Round.calculate_round_scores`. + """ + holder = getattr(round_, "belote_holder", None) + if holder is None or getattr(holder, "team", None) is None: + return None + return holder.team.name + + +def _contract_made(round_) -> bool: + """Canonical made/failed verdict for ``round_``. + + Reads the engine's :attr:`Round.contract_made` flag — the single + source of truth. "round_score > 0" is *not* a safe proxy: a + failed declarer can still score a non-zero Belote bonus. Falls + back to the score heuristic only for legacy/stub rounds that + predate the flag. + """ + made = getattr(round_, "contract_made", None) + if made is not None: + return bool(made) + contract = getattr(round_, "contract", None) + if contract is None: + return False + scores = getattr(round_, "round_scores", {}) or {} + return scores.get(contract.team.name, 0) > 0 diff --git a/packages/contrai-engine/src/contrai_engine/view/screens/trick.py b/packages/contrai-engine/src/contrai_engine/view/screens/trick.py new file mode 100644 index 0000000..18bd8b4 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/screens/trick.py @@ -0,0 +1,564 @@ +"""Mid-trick / trick-won screen rendering for the Rich terminal UI. + +The in-game table: the Round info panel, the last-trick and current-trick +panels, the 4-player card diamond (with the live winner highlight and the +belote badge), the human's hand row, and the per-play prompts. Pure +builders; ``RichView._render_in_game`` feeds them state and prints. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from contrai_core import BasePlayer, Card, Suit, Trick +from rich.align import Align +from rich.box import ROUNDED +from rich.panel import Panel +from rich.text import Text + +from contrai_engine.view.formatting import ( + _format_card_compact, + _format_contract_short, + _format_trump_label, + _position_color, + _position_short, + _rank_short, + _suit_color, + _suit_color_dim, + _suit_glyph, +) +from contrai_engine.view.screens.bidding import _render_bidding_diamond +from contrai_engine.view.state_helpers import ( + _belote_by_position, + _current_winner, + _explain_constraint, + _sort_hand_for_display, +) +from contrai_engine.view.theme import ( + BLUE, + BORDER, + BORDER_DIM, + DIM, + FG, + GOLD, + GOLD_BG, + GOLD_FG, + GREEN_BG, + GREEN_FG, + ORANGE, + TITLE, + YELLOW, +) + +if TYPE_CHECKING: + from contrai_engine.model.round import Round + + +def _panel_round(round_: Optional[Round], phase: str) -> Panel: + """Top-right Round info panel: contract, trump, and phase status. + + During bidding the third line names the dealer; during play it + shows the current trick index and both teams' running card points. + The border and title turn gold once a contract (trump) is active. + """ + body = Text() + contract = round_.contract if round_ else None + trump_active = contract is not None + # Contract line + body.append("Contract: ", style=DIM) + if contract is None: + body.append("—\n", style=FG) + else: + body.append_text(_format_contract_short(contract)) + body.append("\n") + # Trump line + body.append("Trump: ", style=DIM) + body.append_text(_format_trump_label(contract.suit if contract else None)) + body.append("\n") + # Phase / trick + if phase == "bidding": + body.append("Phase: ", style=DIM) + body.append("Bidding in progress\n", style=f"bold {YELLOW}") + dealer_name = round_.dealer.position if round_ and round_.dealer else "—" + body.append("Dealer: ", style=DIM) + body.append(dealer_name, style=FG) + else: + tricks_done = len(round_.tricks) if round_ else 0 + current_idx = tricks_done + (1 if phase == "playing" else 0) + current_idx = min(current_idx, 8) + body.append("Trick: ", style=DIM) + body.append(f"{current_idx} of 8\n", style=FG) + # Round running points (cards collected by each team so far). + ns_pts, ew_pts = _round_running_points(round_) + body.append("Round pts: ", style=DIM) + body.append("N-S ", style=f"bold {BLUE}") + body.append(str(ns_pts), style="bold") + body.append(" · ", style=DIM) + body.append("E-W ", style=f"bold {ORANGE}") + body.append(str(ew_pts), style="bold") + + border_color = YELLOW if trump_active else BORDER + title_color = YELLOW if trump_active else TITLE + round_label = ( + f"Round #{round_.round_number}" + if round_ is not None and getattr(round_, "round_number", None) + else "Round" + ) + title = Text(round_label, style=f"bold {title_color}") + if trump_active: + title.append(" ★", style=GOLD) + return Panel( + body, + title=title, + border_style=border_color, + box=ROUNDED, + width=46, + height=6, + ) + + +def _round_running_points(round_: Optional[Round]) -> tuple[int, int]: + """Trump-aware card points each team has captured so far this round. + + Returns ``(ns_points, ew_points)``; ``(0, 0)`` before a contract + exists (no trump means no point values to sum yet). + """ + if not round_ or not round_.contract: + return 0, 0 + trump = round_.contract.suit + ns, ew = 0, 0 + for team_name, tricks in round_.team_tricks.items(): + pts = 0 + for trick in tricks: + for _, card in trick.get_plays(): + pts += card.get_points(trump) + if team_name == "North-South": + ns = pts + elif team_name == "East-West": + ew = pts + return ns, ew + + +def _panel_last_trick( + round_: Optional[Round], + last_completed_trick: Optional[tuple[Trick, BasePlayer]], +) -> Panel: + """Narrow left panel echoing the previous completed trick, dimmed. + + Renders a compact card diamond with the winner highlighted, or a + ``(none)`` placeholder before the round's first trick completes. + """ + if not last_completed_trick: + body = Text("(none)", style=DIM, justify="center") + body = Align.center(body, vertical="middle") + return Panel( + body, + title=Text("Last trick", style=DIM), + border_style=BORDER_DIM, + box=ROUNDED, + width=22, + height=8, + ) + trick, winner = last_completed_trick + trump = round_.contract.suit if round_ and round_.contract else None + body = _render_diamond( + trick, + trump, + pending_position=None, + winner_position=winner.position if winner else None, + dimmed=True, + width=18, + belote_by_position=_belote_by_position(round_), + ) + body.append("\n") + body.append("Won: ", style=DIM) + body.append(_position_short(winner.position), style=f"bold {GOLD}") + # Last trick number is the just-completed trick — that's the + # length of tricks (the freshly appended one we are echoing). + last_idx = len(round_.tricks) if round_ else 0 + title = Text( + f"Last trick (#{last_idx})" if last_idx else "Last trick", + style=DIM, + ) + return Panel( + body, + title=title, + border_style=BORDER_DIM, + box=ROUNDED, + width=22, + height=8, + ) + + +def _panel_current_trick( + round_: Optional[Round], + trick: Optional[Trick], + phase: str, + current_player: Optional[BasePlayer], + trick_winner: Optional[BasePlayer], + bidding_history: Optional[list] = None, +) -> Panel: + """Main table panel: the current trick (or the auction) as a diamond. + + During bidding the slot is reused for the bidding diamond so the + auction reads spatially like play. During play it renders the + in-progress trick with the acting seat marked ``?``; at the + trick-won pause the winner is highlighted instead. A footer line + names whose turn it is (or who won). + """ + title_suffix = "" + if round_ and phase in ("playing", "trick_won"): + trick_idx = len(round_.tricks) + (0 if phase == "trick_won" else 1) + trick_idx = min(max(1, trick_idx), 8) + title_suffix = f" (#{trick_idx})" + + if phase == "bidding": + # Reuse the table slot for the auction: each seat shows the + # player's latest bid so the human can read announces off + # the diamond the same way they read cards during play. + body = _render_bidding_diamond( + bidding_history or [], + pending_position=( + current_player.position + if current_player is not None + else None + ), + width=42, + ) + body.append("\n") + if current_player is not None and current_player.is_human: + body.append("→ Your bid", style=f"bold {YELLOW}") + elif current_player is not None: + body.append(f"→ {current_player.position} to bid", style=DIM) + return Panel( + body, + title=Text("Bidding", style=f"bold {TITLE}"), + border_style=BORDER, + box=ROUNDED, + width=46, + height=8, + ) + + if trick is None: + body = Text("(none)", style=DIM, justify="center") + body = Align.center(body, vertical="middle") + return Panel( + body, + title=Text(f"Current trick{title_suffix}", style=f"bold {TITLE}"), + border_style=BORDER, + box=ROUNDED, + width=46, + height=8, + ) + + trump = round_.contract.suit if round_ and round_.contract else None + pending_position = ( + current_player.position + if current_player is not None and phase == "playing" + else None + ) + winner_position = trick_winner.position if trick_winner else None + body = _render_diamond( + trick, + trump, + pending_position=pending_position, + winner_position=winner_position, + dimmed=False, + width=42, + belote_by_position=_belote_by_position(round_), + ) + body.append("\n") + if phase == "trick_won" and trick_winner is not None: + body.append("Won: ", style=DIM) + body.append(_position_short(trick_winner.position), + style=f"bold {GOLD}") + elif current_player is not None and current_player.is_human: + body.append("→ Your turn", style=f"bold {YELLOW}") + elif current_player is not None: + body.append(f"→ {current_player.position}'s turn", style=DIM) + return Panel( + body, + title=Text(f"Current trick{title_suffix}", style=f"bold {TITLE}"), + border_style=BORDER, + box=ROUNDED, + width=46, + height=8, + ) + + +def _render_diamond( + trick: Trick, + trump: Optional[Suit], + *, + pending_position: Optional[str], + winner_position: Optional[str], + dimmed: bool, + width: int, + belote_by_position: Optional[dict[str, str]] = None, +) -> Text: + """Render the 4-player diamond: N top, E right, S bottom, W left. + + ``belote_by_position`` maps a position string (``"North"`` etc.) + to either ``"belote"`` or ``"rebelote"`` for seats that have + announced. The badge persists for the rest of the round. + """ + belote_by_position = belote_by_position or {} + + def _belote_badge(pos: str) -> Optional[Text]: + # The seat badge always reads "★ Belote" once the holder + # has played either the K or the Q of trump. The belote / + # rebelote distinction is narrative-only and lives in the + # event log; under the seat we just signal "this player + # has the K+Q pair". + if belote_by_position.get(pos) is None: + return None + t = Text() + t.append("★ ", style=f"bold {GOLD}") + t.append("Belote", style=f"bold {GOLD}") + return t + + plays = trick.get_plays() if trick else [] + plays_by_pos: dict[str, tuple[BasePlayer, Card]] = {} + led_position: Optional[str] = None + for i, (player, card) in enumerate(plays): + plays_by_pos[player.position] = (player, card) + if i == 0: + led_position = player.position + + # Live winner (only if there's at least one play and no explicit winner). + live_winner_pos = winner_position + if live_winner_pos is None and plays: + lw = _current_winner(plays, trump) + if lw is not None: + live_winner_pos = lw.position + + def slot(pos: str) -> Text: + t = Text() + label = _position_short(pos) + pcolor = _position_color(pos) + if pos == pending_position: + t.append(f"{label} ", style=f"bold {pcolor}") + t.append("?", style=f"bold {YELLOW}") + return t + play = plays_by_pos.get(pos) + if play is None: + t.append(f"{label} ", style=f"bold {DIM if dimmed else pcolor}") + t.append("·", style=DIM) + return t + _, card = play + rank_label = _rank_short(card.rank) + is_winner = pos == live_winner_pos + if is_winner and not dimmed: + t.append(f"{label} ", style=f"bold {GOLD_FG} on {GOLD_BG}") + t.append(rank_label, style=f"bold {GOLD_FG} on {GOLD_BG}") + t.append(_suit_glyph(card.suit), + style=f"bold {GOLD_FG} on {GOLD_BG}") + t.append(" ★", style=f"bold {GOLD} on {GOLD_BG}") + elif is_winner and dimmed: + t.append(f"{label} ", style=f"bold {GOLD_FG}") + t.append(rank_label, style=f"bold {GOLD_FG}") + t.append(_suit_glyph(card.suit), style=f"bold {GOLD_FG}") + t.append(" ★", style=f"bold {GOLD}") + else: + fg_label = DIM if dimmed else pcolor + rank_style = DIM if dimmed else "bold" + suit_style = (_suit_color_dim(card.suit) if dimmed + else f"bold {_suit_color(card.suit)}") + t.append(f"{label} ", style=f"bold {fg_label}") + t.append(rank_label, style=rank_style) + t.append(_suit_glyph(card.suit), style=suit_style) + if pos == led_position and not dimmed: + t.append(" (led)", style=DIM) + return t + + # Build rows of fixed-width text. Belote badges (when any seat + # has announced) are inserted as a centered line below the seat + # that owns them. + out = Text() + # Row 1: blank + out.append("\n") + # Row 2: N centered + n = slot("North") + pad_left = max(0, (width - n.cell_len) // 2) + out.append(" " * pad_left) + out.append_text(n) + out.append("\n") + # N's belote badge (centered) + n_badge = _belote_badge("North") + if n_badge is not None: + pad = max(0, (width - n_badge.cell_len) // 2) + out.append(" " * pad) + out.append_text(n_badge) + out.append("\n") + # Row 3: W left, E right + w = slot("West") + e = slot("East") + used = w.cell_len + e.cell_len + gap = max(2, width - used) + out.append_text(w) + out.append(" " * gap) + out.append_text(e) + out.append("\n") + # W/E badges share a row (left-aligned for W, right-aligned for E). + w_badge = _belote_badge("West") + e_badge = _belote_badge("East") + if w_badge is not None or e_badge is not None: + wb_len = w_badge.cell_len if w_badge else 0 + eb_len = e_badge.cell_len if e_badge else 0 + badge_gap = max(2, width - wb_len - eb_len) + if w_badge is not None: + out.append_text(w_badge) + else: + out.append(" " * wb_len) + out.append(" " * badge_gap) + if e_badge is not None: + out.append_text(e_badge) + out.append("\n") + # Row 4: S centered + s = slot("South") + pad_left = max(0, (width - s.cell_len) // 2) + out.append(" " * pad_left) + out.append_text(s) + # S's belote badge (centered) + s_badge = _belote_badge("South") + if s_badge is not None: + out.append("\n") + pad = max(0, (width - s_badge.cell_len) // 2) + out.append(" " * pad) + out.append_text(s_badge) + return out + + +def _panel_hand( + player: BasePlayer, + trick: Optional[Trick], + playable_cards: Optional[list[Card]], + phase: str, + round_: Optional[Round], + *, + interactive: bool = True, +) -> Panel: + """Render the human's hand row. + + ``interactive`` is true only when the human is the actively- + acting player and the view is gathering their input. In every + other in-game frame (AI bidding, AI playing, the trick-won + pause) the panel still appears — keeping the slot stable in + the layout — but cards are rendered with neutral styling: no + green playable pills, no constraint hint, just the row plus a + size readout. + + An empty hand (after the last trick of the round) still + produces a panel; the row reads ``(no cards left)`` so the + slot doesn't pop in and out at the trick-won frame for the + eighth trick. + """ + trump_suit = round_.contract.suit if round_ and round_.contract else None + sorted_hand = _sort_hand_for_display(list(player.hand), trump_suit) + + cards_row = Text() + if not sorted_hand: + cards_row.append("(no cards left)", style=DIM) + else: + # In non-interactive frames we render every card with the + # bidding-style "yellow numbers, bold rank+suit" treatment. + # Passing a phase that isn't "playing" routes the cell + # renderer down the neutral branch. + cell_phase = phase if interactive else "neutral" + playable_set = ( + set(id(c) for c in (playable_cards or sorted_hand)) + if interactive + else set() + ) + for idx, card in enumerate(sorted_hand, start=1): + is_playable = id(card) in playable_set + cell = _render_card_cell(idx, card, is_playable, cell_phase) + cards_row.append_text(cell) + cards_row.append(" ") + + body = Text() + body.append("\n") + pad = max(0, (66 - cards_row.cell_len) // 2) + body.append(" " * pad) + body.append_text(cards_row) + body.append("\n") + + if not sorted_hand: + # The cards row already reads "(no cards left)"; a second + # "(hand empty)" line would just be redundant. + hint = Text("", justify="center") + elif phase == "bidding": + hint = Text( + "(no card-play obligation yet — bidding phase)", + style=DIM, justify="center", + ) + elif phase == "playing" and interactive and trick is not None: + hint = _explain_constraint(player, trick, playable_cards or [], trump_suit) + hint.justify = "center" + else: + hint = Text(f"{len(sorted_hand)} cards remaining", + style=DIM, justify="center") + body.append_text(hint) + title = Text(f"Your hand ({player.position})", style=f"bold {TITLE}") + return Panel( + body, + title=title, + border_style=BORDER, + box=ROUNDED, + width=70, + height=5, + ) + + +def _render_card_cell( + idx: int, card: Card, is_playable: bool, phase: str +) -> Text: + """Render a single card cell: ``[n] R♠`` with optional pill.""" + rank_label = _rank_short(card.rank) + t = Text() + if phase == "playing" and is_playable: + t.append(f"[{idx}] ", style=f"bold white on {GREEN_BG}") + t.append(rank_label, style=f"bold {GREEN_FG} on {GREEN_BG}") + t.append(_suit_glyph(card.suit), style=f"bold {GREEN_FG} on {GREEN_BG}") + elif phase == "playing" and not is_playable: + t.append(f"[{idx}] ", style=DIM) + t.append(rank_label, style=DIM) + t.append(_suit_glyph(card.suit), style=_suit_color_dim(card.suit)) + else: + t.append(f"[{idx}] ", style=f"bold {YELLOW}") + t.append(rank_label, style="bold") + t.append(_suit_glyph(card.suit), style=f"bold {_suit_color(card.suit)}") + return t + + +def _card_prompt_text(playable_cards: list[Card], hand_size: int) -> Text: + """Prompt line asking the human to pick a card by hand index.""" + t = Text() + t.append("Your turn. ", style=f"bold {YELLOW}") + if playable_cards and len(playable_cards) == 1: + t.append("Only one legal play. ", style=f"bold {YELLOW}") + t.append(f"Choose card [1-{hand_size}]:", style=f"bold {YELLOW}") + return t + + +def _ai_card_announcement(player: BasePlayer, card: Card) -> Text: + """Prompt text shown during an AI's brief post-play pause.""" + label = _position_short(player.position) + t = Text() + t.append(f"{label} plays ", style=FG) + t.append_text(_format_card_compact(card)) + t.append(".", style=FG) + return t + + +def _trick_won_prompt_text(winner: BasePlayer) -> Text: + """Prompt line for the trick-won pause; says "You" for the human.""" + t = Text() + label = _position_short(winner.position) + if winner.is_human: + t.append("You won the trick. ", style=f"bold {GOLD}") + t.append("Press [Enter] to continue…", style=FG) + else: + t.append(f"{label} won the trick. ", style=FG) + t.append("Press [Enter] to continue…", style=DIM) + return t diff --git a/packages/contrai-engine/src/contrai_engine/view/state_helpers.py b/packages/contrai-engine/src/contrai_engine/view/state_helpers.py new file mode 100644 index 0000000..a2a3414 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/state_helpers.py @@ -0,0 +1,129 @@ +"""Small game-state readers for the Rich terminal UI. + +Pure functions that read a slice of round/trick state and answer one +question the screens need: who is currently winning the trick, what +constraint applies to the human's playable cards, how to order the hand +for display, which seats have announced belote, and the env-tunable AI +pacing delay. No I/O beyond ``os.environ`` (read-only, for pacing). +""" + +from __future__ import annotations + +import os +from typing import Optional + +from contrai_core import BasePlayer, Card, Suit, Trick +from rich.text import Text + +from contrai_engine.view.formatting import ( + _format_card_compact, + _position_short, + _suit_color, + _suit_glyph, +) +from contrai_engine.view.theme import GREEN_FG + + +def _sort_hand_for_display(cards: list[Card], trump_suit: Optional[Suit]) -> list[Card]: + """Sort cards trump-first then by suit; within each suit by rank. + + Mockup convention: trump cards on the far left (in trump order), + then non-trump suits in spades/hearts/diamonds/clubs preference, + skipping suits with no cards. Within a suit, highest rank first. + """ + suit_order = [Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS] + if trump_suit and trump_suit in suit_order: + suit_order.remove(trump_suit) + suit_order.insert(0, trump_suit) + + sorted_cards: list[Card] = [] + for suit in suit_order: + in_suit = [c for c in cards if c.suit == suit] + in_suit.sort(key=lambda c: c.get_order(trump_suit), reverse=True) + sorted_cards.extend(in_suit) + return sorted_cards + + +def _current_winner( + plays: list[tuple[BasePlayer, Card]], trump_suit: Optional[Suit] +) -> Optional[BasePlayer]: + """Return the player currently winning the (possibly incomplete) trick. + + Thin wrapper around :meth:`contrai_core.trick.Trick.get_current_winner` + that accepts a raw ``plays`` list (the shape ``_render_diamond`` already + uses) instead of forcing a Trick allocation at every render. + """ + if not plays: + return None + # Synthesize a minimal Trick for the delegate. Cheap: no game logic + # depends on the wrapper instance — only the plays list is read. + proxy = Trick() + for p, c in plays: + proxy.plays.append((p, c)) + return proxy.get_current_winner(trump_suit) + + +def _explain_constraint( + player: BasePlayer, + trick: Trick, + playable: list[Card], + trump_suit: Optional[Suit], +) -> Text: + """Build the hint line under the hand explaining *why* this is playable.""" + plays = trick.get_plays() if trick else [] + if not plays: + return Text("your lead — anything goes", style=GREEN_FG) + + led_suit = plays[0][1].suit + has_led = player.hand.has_suit(led_suit) + + hint = Text("↑ playable ", style=GREEN_FG) + if has_led: + hint.append("(must follow ", style=GREEN_FG) + hint.append(_suit_glyph(led_suit), style=_suit_color(led_suit)) + hint.append(")", style=GREEN_FG) + return hint + + # No card of led suit. See if we're forced to trump. + if trump_suit and all(c.suit == trump_suit for c in playable): + # Identify the partner / opponent that led, for the message. + leader = plays[0][0] + leader_label = _position_short(leader.position) + hint.append("(must trump — ", style=GREEN_FG) + hint.append(f"{leader_label} led ", style=GREEN_FG) + hint.append(_format_card_compact(plays[0][1])) + hint.append(")", style=GREEN_FG) + return hint + + hint.append("(free discard)", style=GREEN_FG) + return hint + + +def _belote_by_position(round_) -> dict[str, str]: + """Project ``round_.belote_state`` (player → kind) onto positions. + + Returns an empty dict when no round is active, the round has no + belote_state, or none has been triggered yet. Used to render the + persistent ★ Belote/Rebelote badge in the trick diamond. + """ + if round_ is None: + return {} + state = getattr(round_, "belote_state", None) or {} + return {player.position: kind for player, kind in state.items()} + + +def _resolve_delay(env_var: str, default: float) -> float: + """Read a float pacing value from the environment with a default. + + Pacing for AI actions is tunable so the user can dial the game + speed without code edits. Garbage values fall back to ``default`` + rather than raising — this is UI pacing, not a correctness path. + """ + raw = os.environ.get(env_var) + if raw is None: + return default + try: + value = float(raw) + except (TypeError, ValueError): + return default + return max(0.0, value) diff --git a/packages/contrai-engine/src/contrai_engine/view/theme.py b/packages/contrai-engine/src/contrai_engine/view/theme.py new file mode 100644 index 0000000..4fa3678 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/theme.py @@ -0,0 +1,70 @@ +"""Design tokens and shared constants for the Rich terminal UI. + +Holds the color palette mapped from the handoff README's color table, +plus the small lookup tables (target-score options, position/team +labels, bid keyword aliases, the valid contract-value set) that the +formatting, parsing, and screen modules all consume. Pure data — no +rendering or game logic lives here. +""" + +from __future__ import annotations + +from contrai_core import Suit +from contrai_core.bid import ContractBid + +# --------------------------------------------------------------------------- +# Design tokens (mapped from the handoff README's color table) +# --------------------------------------------------------------------------- + +FG = "rgb(212,212,212)" +DIM = "rgb(106,106,106)" +BORDER = "rgb(122,122,122)" +BORDER_DIM = "rgb(68,68,68)" +TITLE = "rgb(200,200,200)" +RED = "rgb(224,108,117)" +RED_DIM = "rgb(122,58,63)" +BLUE = "rgb(127,182,255)" +ORANGE = "rgb(255,180,130)" +GREEN_BG = "rgb(46,90,42)" +GREEN_FG = "rgb(207,234,192)" +GREEN_CHECK = "rgb(58,122,58)" +YELLOW = "rgb(229,192,123)" +GOLD = "rgb(240,181,74)" +GOLD_BG = "rgb(58,43,16)" +GOLD_FG = "rgb(255,213,122)" +HINT = "rgb(61,61,64)" +RULE = "rgb(42,42,42)" +DOT = "rgb(58,58,58)" + +# Valid target scores shown on the landing radio. +TARGET_OPTIONS = [ + (500, "Quick game", "~10 min"), + (1000, "Short game", "~20 min"), + (1500, "Standard", "~30 min"), + (2000, "Long game", "~45 min"), + (3000, "Marathon", "~60 min"), +] +DEFAULT_TARGET = 1500 + +# Position label mapping: full engine name -> single-letter UI label. +POSITION_SHORT = {"North": "N", "East": "E", "South": "S", "West": "W"} + +# Team -> abbreviation used in scoreboards. +TEAM_ABBR = {"North-South": "N-S", "East-West": "E-W"} + +# Bid keyword aliases for parsing. +PASS_WORDS = {"pass", "p"} +DOUBLE_WORDS = {"double", "d"} +REDOUBLE_WORDS = {"redouble", "r"} +SUIT_ALIASES = { + "s": Suit.SPADES, "spades": Suit.SPADES, "spade": Suit.SPADES, "♠": Suit.SPADES, + "h": Suit.HEARTS, "hearts": Suit.HEARTS, "heart": Suit.HEARTS, "♥": Suit.HEARTS, + "d": Suit.DIAMONDS, "diamonds": Suit.DIAMONDS, "diamond": Suit.DIAMONDS, "♦": Suit.DIAMONDS, + "c": Suit.CLUBS, "clubs": Suit.CLUBS, "club": Suit.CLUBS, "♣": Suit.CLUBS, + "nt": Suit.NO_TRUMP, "notrump": Suit.NO_TRUMP, "no-trump": Suit.NO_TRUMP, +} +# Derived from ``ContractBid.VALID_VALUES`` so the human-input parser +# stays in lockstep with the auction's canonical value ladder. The +# all-tricks ``SlamLevel`` members are handled by a separate parsing +# branch above, so only the numeric subset is needed here. +VALID_BID_VALUES = {v for v in ContractBid.VALID_VALUES if isinstance(v, int)} diff --git a/packages/contrai-engine/tests/test_model/conftest.py b/packages/contrai-engine/tests/test_model/conftest.py new file mode 100644 index 0000000..b674181 --- /dev/null +++ b/packages/contrai-engine/tests/test_model/conftest.py @@ -0,0 +1,35 @@ +"""Shared fixtures for the model-layer round tests. + +The ``round/`` subpackage holds the lifecycle orchestrator plus the pure +``scoring`` transformation, and the round test suite mirrors that split +into ``test_round.py`` (lifecycle / play-state loop / belote / bidding) +and ``test_round_scoring.py`` (the scoring grid). The legal-play oracle +now lives in ``contrai-core`` (``test_play_legality.py``). The four +positioned players are used across the round tests, so the fixture lives +here. Each file keeps its own scenario-builder helpers, which are +specific to the state that file exercises. +""" + +from __future__ import annotations + +import pytest + +from contrai_core.team import Team + +from contrai_engine.model.player import AiPlayer + + +@pytest.fixture +def players(): + """Four positioned players wired into N-S and E-W teams.""" + north = AiPlayer("N", "North") + east = AiPlayer("E", "East") + south = AiPlayer("S", "South") + west = AiPlayer("W", "West") + ns = Team("North-South", [north, south]) + ew = Team("East-West", [east, west]) + for p in (north, south): + p.team = ns + for p in (east, west): + p.team = ew + return {"N": north, "E": east, "S": south, "W": west} diff --git a/packages/contrai-engine/tests/test_model/test_game.py b/packages/contrai-engine/tests/test_model/test_game.py index adb799b..8a46ed6 100644 --- a/packages/contrai-engine/tests/test_model/test_game.py +++ b/packages/contrai-engine/tests/test_model/test_game.py @@ -1,16 +1,90 @@ +"""Tests for the ``Game`` orchestrator. + +Covers construction (player count/position guards, team formation, +seat sorting), anticlockwise dealer rotation, playing-order derivation, +round start (shuffle-then-cut policy, dealing), game-over detection, +and the ``manage_round`` lifecycle driven through a ``Round`` double +(completed contract, score accumulation, all-pass redeal). +""" + import pytest +from contrai_engine.model import game as game_module from contrai_engine.model.game import Game from contrai_core.deck import Deck from contrai_core.exceptions import InvalidPlayerCountError from contrai_core.card import Card -#TODO : add test for trick number in game class DummyPlayer: + """Minimal player stand-in: name, seat position, and an empty hand.""" + def __init__(self, name, position): self.name = name self.position = position self.hand = [] + +class FakeRound: + """Test double standing in for :class:`Round` in ``manage_round`` tests. + + The real ``Round`` runs a full bidding/trick/scoring lifecycle that needs + AI players with hands. ``Game.manage_round`` only orchestrates those calls, + so we swap in this double to drive the orchestration deterministically and + record which lifecycle hooks fired. + """ + + # Per-test configuration: what bidding resolves to and the scores each + # outcome reports back to the Game. + bidding_contract = None + play_scores: dict[str, int] = {} + failed_scores: dict[str, int] = {} + + def __init__(self, players_order, dealer, deck, round_number): + self.players_order = players_order + self.dealer = dealer + self.deck = deck + self.round_number = round_number + self.calls: list[str] = [] + + def deal_cards(self): + """Record the call; no cards actually move.""" + self.calls.append("deal_cards") + + def manage_bidding(self, view=None): + """Record the call and resolve to the scripted contract.""" + self.calls.append("manage_bidding") + return self.bidding_contract + + def play_all_tricks(self, view=None): + """Record the call; no tricks are actually played.""" + self.calls.append("play_all_tricks") + return {} + + def calculate_round_scores(self): + """Record the call and report the scripted completed-round scores.""" + self.calls.append("calculate_round_scores") + return dict(self.play_scores) + + def handle_failed_contract(self): + """Record the call and report the scripted all-pass scores.""" + self.calls.append("handle_failed_contract") + return dict(self.failed_scores) + + +class RecordingView: + """View double recording the lifecycle callbacks ``manage_round`` fires.""" + + def __init__(self): + self.dealt = [] + self.redeal_count = 0 + + def on_round_dealt(self, round_obj): + """Record which round object the deal callback announced.""" + self.dealt.append(round_obj) + + def on_all_pass_redeal(self): + """Count how many times the redeal callback fired.""" + self.redeal_count += 1 + @pytest.fixture def players(): """ @@ -161,10 +235,10 @@ def test_check_game_over_not_finished(game): result = game.check_game_over(target_score=1500) - assert result['game_over'] is False - assert result['winner'] is None - assert result['tied_teams'] is None - assert result['final_scores'] == {'North-South': 1200, 'East-West': 800} + assert result.game_over is False + assert result.winner is None + assert result.tied_teams is None + assert result.final_scores == {'North-South': 1200, 'East-West': 800} def test_check_game_over_winner(game): """ @@ -174,20 +248,193 @@ def test_check_game_over_winner(game): result = game.check_game_over(target_score=1500) - assert result['game_over'] is True - assert result['winner'] == 'North-South' - assert result['tied_teams'] is None - assert result['final_scores'] == {'North-South': 1600, 'East-West': 1200} + assert result.game_over is True + assert result.winner == 'North-South' + assert result.tied_teams is None + assert result.final_scores == {'North-South': 1600, 'East-West': 1200} -def test_check_game_over_tie(game): +def test_check_game_over_tie_continues_game(game): """ - Test check_game_over when teams are tied above target score. + Test that a tie at/above the target does not end the game. + + Both teams level at or above the target means sudden death: the + game continues with tiebreaker rounds until one team leads, so + ``game_over`` stays False while ``tied_teams`` flags the state. """ game.scores = {'North-South': 1600, 'East-West': 1600} result = game.check_game_over(target_score=1500) - assert result['game_over'] is True - assert result['winner'] is None - assert result['tied_teams'] == ['North-South', 'East-West'] - assert result['final_scores'] == {'North-South': 1600, 'East-West': 1600} + assert result.game_over is False + assert result.winner is None + assert result.tied_teams == ['North-South', 'East-West'] + assert result.final_scores == {'North-South': 1600, 'East-West': 1600} + + +def test_check_game_over_tie_below_target_not_flagged(game): + """ + Test that a tie below the target is not reported as a tiebreaker. + + ``tied_teams`` only signals the sudden-death state — equal scores + short of the target are just an unfinished game. + """ + game.scores = {'North-South': 1200, 'East-West': 1200} + + result = game.check_game_over(target_score=1500) + + assert result.game_over is False + assert result.winner is None + assert result.tied_teams is None + + +def test_check_game_over_tie_resolved_by_next_round(game): + """ + Test that the game ends once a tiebreaker round breaks the tie. + + After sudden death, both teams sit above the target but one now + leads — that team wins. + """ + game.scores = {'North-South': 1760, 'East-West': 1600} + + result = game.check_game_over(target_score=1500) + + assert result.game_over is True + assert result.winner == 'North-South' + assert result.tied_teams is None + + +def test_check_game_over_default_target_score(game): + """ + Test that check_game_over uses 1500 as the default target score. + """ + game.scores = {'North-South': 1500, 'East-West': 900} + + result = game.check_game_over() + + assert result.game_over is True + assert result.winner == 'North-South' + + +def test_next_dealer_picks_random_when_none(game, monkeypatch): + """ + Test that the first call to next_dealer picks a player at random. + """ + assert game.dealer is None + + # Force the "random" choice to be deterministic for the assertion. + monkeypatch.setattr(game_module.random, 'choice', lambda seq: seq[2]) + + game.next_dealer() + + assert game.dealer is game.players[2] + + +def test_set_players_order_starts_after_dealer(game): + """ + Test that the playing order begins with the player after the dealer and + proceeds anticlockwise (North, West, South, East). + """ + # Players are sorted as [North, West, South, East]; dealer is North. + game.dealer = game.players[0] + + game.set_players_order() + + positions = [player.position for player in game.players_order] + assert positions == ["West", "South", "East", "North"] + + +def test_set_players_order_wraps_around(game): + """ + Test that the playing order wraps past the end of the player list when the + dealer sits last in position order. + """ + # Dealer is East (last in [North, West, South, East]). + game.dealer = game.players[3] + + game.set_players_order() + + positions = [player.position for player in game.players_order] + assert positions == ["North", "West", "South", "East"] + + +def test_start_new_round_shuffles_first_round_then_cuts(game, monkeypatch): + """ + Test that the deck is shuffled on the first round and cut on later rounds. + """ + calls = [] + monkeypatch.setattr(game.deck, 'shuffle', lambda: calls.append('shuffle')) + monkeypatch.setattr(game.deck, 'cut', lambda: calls.append('cut')) + # Swap in the round double so dealing does not exhaust the (un-shuffled) deck. + monkeypatch.setattr(game_module, 'Round', FakeRound) + + game.start_new_round() + assert calls == ['shuffle'] + + game.start_new_round() + assert calls == ['shuffle', 'cut'] + + +def test_manage_round_completed(game, monkeypatch): + """ + Test the happy path of manage_round: a contract is won, per-round scores are + accumulated into the totals, and the deal callback fires on the view. + """ + contract = object() + FakeRound.bidding_contract = contract + FakeRound.play_scores = {'North-South': 160, 'East-West': 0} + monkeypatch.setattr(game_module, 'Round', FakeRound) + + view = RecordingView() + game.manage_round(view) + + # manage_round mutates game state in place and returns nothing: the contract + # is recorded and the round's points are folded into the running totals. + assert game.current_contract is contract + assert game.scores == {'North-South': 160, 'East-West': 0} + + # The full lifecycle ran, in order. + assert game.current_round.calls == [ + 'deal_cards', 'manage_bidding', 'play_all_tricks', 'calculate_round_scores' + ] + # The view was told a fresh round was dealt, and never asked to redeal. + assert view.dealt == [game.current_round] + assert view.redeal_count == 0 + + +def test_manage_round_accumulates_scores_across_rounds(game, monkeypatch): + """ + Test that manage_round adds each round's scores onto the running totals. + """ + FakeRound.bidding_contract = object() + FakeRound.play_scores = {'North-South': 90, 'East-West': 70} + monkeypatch.setattr(game_module, 'Round', FakeRound) + + game.manage_round() + assert game.scores == {'North-South': 90, 'East-West': 70} + + game.manage_round() + assert game.scores == {'North-South': 180, 'East-West': 140} + + +def test_manage_round_all_pass_redeals(game, monkeypatch): + """ + Test the all-pass path of manage_round: with no contract, tricks are never + played, the failed-contract branch redistributes cards, and the redeal + callback fires on the view. + """ + FakeRound.bidding_contract = None + FakeRound.failed_scores = {'North-South': 0, 'East-West': 0} + monkeypatch.setattr(game_module, 'Round', FakeRound) + + view = RecordingView() + game.manage_round(view) + + # No contract was recorded for the passed-out round. + assert game.current_contract is None + + # No trick play or scoring happened; the failed-contract branch ran instead. + assert 'play_all_tricks' not in game.current_round.calls + assert 'handle_failed_contract' in game.current_round.calls + # The view was asked to redeal, and the totals were left untouched. + assert view.redeal_count == 1 + assert game.scores == {'North-South': 0, 'East-West': 0} diff --git a/packages/contrai-engine/tests/test_model/test_levels.py b/packages/contrai-engine/tests/test_model/test_levels.py new file mode 100644 index 0000000..08ebd40 --- /dev/null +++ b/packages/contrai-engine/tests/test_model/test_levels.py @@ -0,0 +1,32 @@ +"""Unit tests for the AI level registry + factory.""" + +from contrai_engine.model.player import ( + AI_LEVELS, + AiPlayer, + RuleBasedBiddingStrategy, + RuleBasedCardPlayStrategy, + make_ai_player, +) + + +def test_expert_level_maps_to_rule_based_pair(): + """``AI_LEVELS["expert"]`` is the rule-based (bidding, card-play) pair.""" + assert AI_LEVELS["expert"] == ( + RuleBasedBiddingStrategy, + RuleBasedCardPlayStrategy, + ) + + +def test_make_ai_player_builds_expert_by_default(): + """``make_ai_player`` defaults to the expert level.""" + player = make_ai_player("Bot", "South") + assert isinstance(player, AiPlayer) + assert isinstance(player.bidding, RuleBasedBiddingStrategy) + assert isinstance(player.cardplay, RuleBasedCardPlayStrategy) + + +def test_make_ai_player_wires_strategies_to_the_player(): + """The built strategies hold a back-reference to the player.""" + player = make_ai_player("Bot", "South", level="expert") + assert player.bidding._player is player + assert player.cardplay._player is player diff --git a/packages/contrai-engine/tests/test_model/test_player.py b/packages/contrai-engine/tests/test_model/test_player.py index 2ce5919..9f24b7a 100644 --- a/packages/contrai-engine/tests/test_model/test_player.py +++ b/packages/contrai-engine/tests/test_model/test_player.py @@ -1,1159 +1,90 @@ -# Unit tests for the Player classes (Player, HumanPlayer, AiPlayer) +"""Unit tests for the Player classes (Player, HumanPlayer, AiPlayer).""" -import pytest -from contrai_engine.model.player import HumanPlayer, AiPlayer, wire_to_bid +from contrai_engine.model.player import ( + AiPlayer, + HumanPlayer, + RuleBasedBiddingStrategy, + RuleBasedCardPlayStrategy, +) from contrai_core import ( Auction, - Contract, - ContractBid, - DoubleBid, - Hand, PassBid, - RedoubleBid, - SlamLevel, ) -from contrai_core.card import Card -from contrai_core.team import Team -from contrai_core.types import Suit, Rank - - -def _contract(player, value, suit): - """Build a real Contract for the AiPlayer trick-taking tests. - - The original tests passed a ``(player, value, suit)`` tuple, but the - engine threads the actual ``Contract`` object from ``Round`` into - ``AiPlayer.choose_card``. This helper keeps the test bodies readable - while matching the production type. - """ - return Contract(ContractBid(player, value, suit)) -def _auction(bids_with_players=()): - """Build an :class:`Auction` from a list of ``(player, wire_bid)`` tuples. +class TestIsHumanProperty: + """The engine's is_human property splits human from AI players. - ``AiPlayer.choose_bid`` now takes an Auction; the existing tests - were written when it took the legacy ``[(player, wire), …]`` list. - This helper lifts each (player, wire) entry into the matching - :class:`Bid` and packs the lot into an Auction so the test bodies - can stay close to their original shape. + Identity state (name, position, hand, team) comes from + :class:`contrai_core.BasePlayer` and is covered by core's + ``test_base_player.py``; here we only assert the engine-specific + polymorphic behavior. """ - bids = tuple(wire_to_bid(p, w) for p, w in bids_with_players) - return Auction(bids) - - -class TestWireToBid: - """Test the legacy wire-format → :class:`Bid` bridge.""" - - @pytest.fixture - def player(self): - """A plain player to attach bids to.""" - return AiPlayer("Bot", "South") - - def test_keyword_wires_map_to_their_bid_types(self, player): - """``'Pass'`` / ``'Double'`` / ``'Redouble'`` lift to their classes.""" - assert isinstance(wire_to_bid(player, "Pass"), PassBid) - assert isinstance(wire_to_bid(player, "Double"), DoubleBid) - assert isinstance(wire_to_bid(player, "Redouble"), RedoubleBid) - - def test_valid_tuple_yields_contract_bid(self, player): - """A legal ``(value, suit)`` tuple builds a matching ContractBid.""" - bid = wire_to_bid(player, (80, Suit.HEARTS)) - assert isinstance(bid, ContractBid) - assert bid.value == 80 - assert bid.suit == Suit.HEARTS - - def test_invalid_contract_value_falls_back_to_pass(self, player): - """A bad contract value raises InvalidContractError, caught as a Pass. - 85 is not on ``ContractBid.VALID_VALUES``, so construction raises - :class:`InvalidContractError`. The bridge must swallow that - specific domain error and fall back to a :class:`PassBid`. - """ - assert isinstance(wire_to_bid(player, (85, Suit.HEARTS)), PassBid) + def test_human_player_is_human(self): + """HumanPlayer reports is_human True.""" + assert HumanPlayer("Alice", "North").is_human is True - def test_unknown_payload_falls_back_to_pass(self, player): - """An unrecognised wire payload falls back to a Pass.""" - assert isinstance(wire_to_bid(player, "garbage"), PassBid) + def test_ai_player_is_not_human(self): + """AiPlayer reports is_human False.""" + assert AiPlayer("Bot", "South").is_human is False -class TestPlayer: - """Test the abstract Player class""" +class TestAiPlayerStrategyInjection: + """Test that AiPlayer injects and delegates to its strategies.""" - def test_player_creation(self): - """Test creating a human player""" - player = HumanPlayer("Alice", "North") - assert player.name == "Alice" - assert player.position == "North" - assert len(player.hand) == 0 - assert player.team is None - assert player.is_human is True - - def test_ai_player_creation(self): - """Test creating an AI player""" + def test_default_strategies_are_rule_based(self): + """An AiPlayer built with defaults gets the rule-based pair.""" player = AiPlayer("Bot", "South") - assert player.name == "Bot" - assert player.position == "South" - assert len(player.hand) == 0 - assert player.team is None - assert player.is_human is False - - -class TestAiPlayerBidding: - """Test AI player bidding logic""" - - @pytest.fixture - def ai_player(self): - """Create an AI player for testing""" - player = AiPlayer("TestBot", "North") - # Create a mock team - partner = AiPlayer("Partner", "South") - team = Team("North-South", [player, partner]) - player.team = team - partner.team = team - return player - - @pytest.fixture - def ai_opponent_player(self): - """Create an opponent AI player for testing""" - opponent = AiPlayer("Opponent", "West") - opponent_partner = AiPlayer("OpponentPartner", "East") - opponent_team = Team("East-West", [opponent, opponent_partner]) - opponent.team = opponent_team - opponent_partner.team = opponent_team - - return opponent - - @pytest.fixture - def sample_cards_weak(self): - """Create a weak hand for testing""" - return Hand([ - Card(Suit.SPADES, Rank.SEVEN), - Card(Suit.SPADES, Rank.EIGHT), - Card(Suit.HEARTS, Rank.SEVEN), - Card(Suit.HEARTS, Rank.EIGHT), - Card(Suit.DIAMONDS, Rank.SEVEN), - Card(Suit.DIAMONDS, Rank.EIGHT), - Card(Suit.CLUBS, Rank.SEVEN), - Card(Suit.CLUBS, Rank.EIGHT) - ]) - - @pytest.fixture - def sample_cards_correct_hearts(self): - """Create a middle hand for testing""" - return Hand([ - Card(Suit.HEARTS, Rank.JACK), - Card(Suit.HEARTS, Rank.KING), - Card(Suit.HEARTS, Rank.SEVEN), - Card(Suit.SPADES, Rank.EIGHT), - Card(Suit.DIAMONDS, Rank.TEN), - Card(Suit.DIAMONDS, Rank.EIGHT), - Card(Suit.CLUBS, Rank.ACE), - Card(Suit.CLUBS, Rank.TEN) - ]) - - @pytest.fixture - def sample_cards_strong_spades(self): - """Create a strong spades hand for testing""" - return Hand([ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.NINE), - Card(Suit.SPADES, Rank.ACE), - Card(Suit.SPADES, Rank.KING), - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.CLUBS, Rank.ACE), - Card(Suit.CLUBS, Rank.JACK) - ]) - - @pytest.fixture - def sample_cards_belote_spades(self): - """Create a hand with belote in spades""" - return Hand([ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.ACE), - Card(Suit.SPADES, Rank.KING), - Card(Suit.SPADES, Rank.QUEEN), - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.CLUBS, Rank.ACE), - Card(Suit.CLUBS, Rank.EIGHT) - ]) - - def test_evaluate_suits_weak_hand(self, ai_player, sample_cards_weak): - """Test suit evaluation with a weak hand""" - ai_player.hand = sample_cards_weak - evaluations = ai_player._evaluate_suits() - - # All suits should have low or zero contract values - for suit, eval_data in evaluations.items(): - assert eval_data['contract'] == 0 - assert eval_data['estimated_tricks'] == 0 - assert eval_data['has_belote'] is False - - def test_evaluate_suits_correct_hand(self, ai_player, sample_cards_correct_hearts): - """Test suit evaluation with a correct hand""" - ai_player.hand = sample_cards_correct_hearts - evaluations = ai_player._evaluate_suits() - - hearts_eval = evaluations[Suit.HEARTS] - assert hearts_eval['contract'] == 80 # Should be able to bid 130 - assert hearts_eval['trump_count'] == 3 - assert hearts_eval['estimated_tricks'] == 4 - assert hearts_eval['external_aces'] == 1 - - def test_evaluate_suits_strong_spades(self, ai_player, sample_cards_strong_spades): - """Test suit evaluation with a strong spades hand""" - ai_player.hand = sample_cards_strong_spades - evaluations = ai_player._evaluate_suits() - - spades_eval = evaluations[Suit.SPADES] - assert spades_eval['contract'] == 130 # Should be able to bid 130 - assert spades_eval['trump_count'] == 4 - assert spades_eval['estimated_tricks'] == 7 - assert spades_eval['external_aces'] == 3 - - def test_evaluate_suits_belote(self, ai_player, sample_cards_belote_spades): - """Test suit evaluation with belote""" - ai_player.hand = sample_cards_belote_spades - evaluations = ai_player._evaluate_suits() - - spades_eval = evaluations[Suit.SPADES] - assert spades_eval['has_belote'] is True - assert spades_eval['contract'] == 140 - - def test_estimate_tricks(self, ai_player, sample_cards_strong_spades): - """Test trick estimation""" - ai_player.hand = sample_cards_strong_spades - tricks = ai_player._estimate_tricks(Suit.SPADES) - - # Strong spades hand with 3 external aces should estimate 7 tricks - assert tricks == 7 - - def test_evaluate_trump_tricks(self, ai_player, sample_cards_strong_spades): - """Test trump tricks evaluation""" - ai_player.hand = sample_cards_strong_spades - expected_tricks = ai_player._evaluate_trump_tricks(Suit.SPADES) - - # Strong spades hand with Jack + 9 + Ace + King should expect good trick count - # Jack + 9 = 2 tricks, plus additional tricks from trump length - assert expected_tricks == 4 - - def test_get_last_bid(self, ai_player, ai_opponent_player): - """Test getting the last contract bid""" - ai_player_partner = ai_player.team.players[1] - ai_opponent_player_partner = ai_opponent_player.team.players[1] - - current_bids = [ - (ai_opponent_player, 'Pass'), - (ai_player_partner, (80, Suit.SPADES)), - (ai_opponent_player_partner, (90, Suit.HEARTS)), - ] - - last_bid = ai_player._get_last_bid(current_bids) - assert last_bid == (90, Suit.HEARTS) - - def test_get_partner_bid(self, ai_player, ai_opponent_player): - """Test getting partner's bid""" - ai_player_partner = ai_player.team.players[1] - ai_opponent_player_partner = ai_opponent_player.team.players[1] - - current_bids = [ - (ai_opponent_player, 'Pass'), - (ai_player_partner, (80, Suit.SPADES)), - (ai_opponent_player_partner, (90, Suit.HEARTS)), - ] - - partner_bid = ai_player._get_partner_bid(current_bids) - assert partner_bid == (80, Suit.SPADES) - - def test_choose_bid_pass_weak_hand(self, ai_player, sample_cards_weak): - """Test that AI passes with weak hand""" - ai_player.hand = sample_cards_weak - bid = ai_player.choose_bid(_auction()) - assert isinstance(bid, PassBid) - - def test_choose_bid_initial_bid_strong_hand(self, ai_player, sample_cards_strong_spades): - """Test initial bid with strong hand""" - ai_player.hand = sample_cards_strong_spades - bid = ai_player.choose_bid(_auction()) - - assert isinstance(bid, ContractBid) - assert bid.value == 130 - assert bid.suit == Suit.SPADES - - def test_choose_bid_overbid_opponent(self, ai_player, ai_opponent_player, sample_cards_strong_spades): - """Test overbidding opponent""" - ai_player.hand = sample_cards_strong_spades - - auction = _auction([(ai_opponent_player, (90, Suit.HEARTS))]) - bid = ai_player.choose_bid(auction) - - assert isinstance(bid, ContractBid) - assert bid.value > 90 - assert bid.suit == Suit.SPADES - - def test_choose_bid_support_partner(self, ai_player, ai_opponent_player): - """Test supporting partner's bid""" - # Give AI player some external aces to support partner - ai_player.hand = Hand([ - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.QUEEN), - Card(Suit.CLUBS, Rank.ACE), - Card(Suit.SPADES, Rank.JACK), # Trump complement - Card(Suit.SPADES, Rank.EIGHT), - Card(Suit.HEARTS, Rank.EIGHT), - Card(Suit.DIAMONDS, Rank.EIGHT), - Card(Suit.CLUBS, Rank.EIGHT) - ]) - - # Partner bids 80 in Spades - partner = ai_player.team.players[1] - auction = _auction([ - (partner, (80, Suit.SPADES)), - (ai_opponent_player, 'Pass'), - ]) - bid = ai_player.choose_bid(auction) - - # Should support with higher bid due to 3 external aces + trump complement - assert isinstance(bid, ContractBid) - assert bid.value >= 100 # 80 + 20 (2 aces) + 10 (trump complement) - assert bid.suit == Suit.SPADES - - def test_choose_bid_cant_overbid_partner(self, ai_player, ai_opponent_player, sample_cards_weak): - """Test that AI doesn't overbid partner when it can't""" - ai_player.hand = sample_cards_weak - - # Partner bids high - partner = ai_player.team.players[1] - auction = _auction([ - (partner, (140, Suit.SPADES)), - (ai_opponent_player, 'Pass'), - ]) - bid = ai_player.choose_bid(auction) - - assert isinstance(bid, PassBid) - - # --- Bidding under a standing Coinche / Surcoinche -------------------- - # Regression coverage for the crash where the expert table, blind to a - # Double freezing the auction, returned an illegal numeric raise (even - # over its *own* partner) and ``Auction.apply`` aborted the game with - # ``IllegalBidError``. A standing Double permits only Pass, or a - # Surcoinche (Redouble) from the contracting team. - - def test_choose_bid_strong_hand_overbids_partner_without_double( - self, ai_player, ai_opponent_player, sample_cards_strong_spades - ): - """Control case: with no Double, the strong AI *does* raise partner. - - Establishes that the Pass in - :meth:`test_choose_bid_passes_when_opponent_doubled_partner` is - caused by the freeze, not by the hand being too weak to raise. - """ - ai_player.hand = sample_cards_strong_spades # max contract 130 - partner = ai_player.team.players[1] - auction = _auction([(partner, (80, Suit.SPADES))]) - bid = ai_player.choose_bid(auction) - assert isinstance(bid, ContractBid) - assert bid.value == 130 - assert bid.suit == Suit.SPADES - - def test_choose_bid_passes_when_opponent_doubled_partner( - self, ai_player, ai_opponent_player, sample_cards_strong_spades - ): - """AI must Pass — not raise — when an opponent Coinched partner. - - The exact reproduction of the reported crash: partner holds the - contract, an opponent Doubles, and the AI's hand is strong enough - that the open-auction path would raise to 130. The Double freezes - the auction, so the only non-redouble action is Pass. - """ - ai_player.hand = sample_cards_strong_spades - partner = ai_player.team.players[1] - auction = _auction([ - (partner, (80, Suit.SPADES)), - (ai_opponent_player, 'Double'), - ]) - bid = ai_player.choose_bid(auction) - assert isinstance(bid, PassBid) - - def test_choose_bid_passes_when_own_team_doubled_opponent( - self, ai_player, ai_opponent_player, sample_cards_strong_spades - ): - """AI on the *doubling* side may only Pass (no raise, no redouble). - - Here the opponents hold the contract and the AI's partner has - already Coinched it. The contracting team is the opponents, so a - Surcoinche is illegal for this seat and the strong hand must not - tempt a numeric raise either. - """ - ai_player.hand = sample_cards_strong_spades - partner = ai_player.team.players[1] - auction = _auction([ - (ai_opponent_player, (120, Suit.HEARTS)), - (partner, 'Double'), - ]) - bid = ai_player.choose_bid(auction) - assert isinstance(bid, PassBid) - - def test_choose_bid_passes_after_redouble( - self, ai_player, ai_opponent_player, sample_cards_strong_spades - ): - """Once the auction is Surcoinched, only Pass remains.""" - ai_player.hand = sample_cards_strong_spades - partner = ai_player.team.players[1] - auction = _auction([ - (partner, (110, Suit.SPADES)), - (ai_opponent_player, 'Double'), - (partner, 'Redouble'), - ]) - bid = ai_player.choose_bid(auction) - assert isinstance(bid, PassBid) - - def test_choose_bid_surcoinches_when_strategy_approves( - self, ai_player, ai_opponent_player, sample_cards_weak - ): - """Contracting team may Redouble when the strategy says so. - - ``_should_redouble`` is a stub returning ``False`` today, so we - force it ``True`` to exercise the (legal) Surcoinche path and - confirm the resulting :class:`RedoubleBid` is what the Auction - would accept. - """ - ai_player.hand = sample_cards_weak - partner = ai_player.team.players[1] - auction = _auction([ - (partner, (100, Suit.SPADES)), - (ai_opponent_player, 'Double'), - ]) - ai_player._should_redouble = lambda: True # type: ignore[method-assign] - bid = ai_player.choose_bid(auction) - assert isinstance(bid, RedoubleBid) - assert auction.is_legal(bid) - - def test_choose_bid_guard_converts_illegal_table_bid_to_pass( - self, ai_player, ai_opponent_player, sample_cards_weak - ): - """The is_legal safety net turns an illegal expert-table bid into Pass. - - Independently of the freeze handling, ``choose_bid`` must never - hand ``Auction.apply`` a bid it would reject. We force the expert - table to emit an under-cutting raise (90 over a live 140) and - assert the guard downgrades it to the always-legal Pass. - """ - ai_player.hand = sample_cards_weak - auction = _auction([(ai_opponent_player, (140, Suit.SPADES))]) - ai_player._choose_wire = lambda current_bids: (90, Suit.SPADES) # type: ignore[method-assign] - bid = ai_player.choose_bid(auction) - assert isinstance(bid, PassBid) - - # --- Slam / Solo Slam bidding ----------------------------------------- - # _estimate_tricks is capped at 8 (player.py: `min(tricks, 8)`), so a - # hand holding 5 trumps (J + 9 + A + K + Q) plus all three external aces - # triggers the Slam-family rows in BIDDING_TABLE. Both Slam (500) and - # Solo Slam (1000) share the same trick-estimator gate today - # (tricks_min=8), so the table walks both and stops on the higher one. - - @pytest.fixture - def sample_cards_slam_spades(self): - return Hand([ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.NINE), - Card(Suit.SPADES, Rank.ACE), - Card(Suit.SPADES, Rank.KING), - Card(Suit.SPADES, Rank.QUEEN), - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.CLUBS, Rank.ACE), - ]) - - def test_evaluate_suit_slam_family_qualifies( - self, ai_player, sample_cards_slam_spades - ): - """A hand estimated at 8 tricks resolves to the top Slam-family row. - - With the current (deliberately permissive) Solo Slam gate that - shares Slam's ``tricks_min=8``, the table walk lands on - ``SOLO_SLAM_NUMERIC`` (1000). The Slam row (500) is still - reachable via the AI when partner bids below that — see the - sentinel-translation tests. - """ - ai_player.hand = sample_cards_slam_spades - evaluations = ai_player._evaluate_suits() - assert evaluations[Suit.SPADES]['contract'] == ai_player.SOLO_SLAM_NUMERIC - assert evaluations[Suit.SPADES]['estimated_tricks'] == 8 - - def test_choose_bid_solo_slam_strong_hand( - self, ai_player, sample_cards_slam_spades - ): - """choose_bid lifts the Solo Slam wire choice to a ContractBid.""" - ai_player.hand = sample_cards_slam_spades - bid = ai_player.choose_bid(_auction()) - assert isinstance(bid, ContractBid) - assert bid.value is SlamLevel.SOLO_SLAM - assert bid.suit == Suit.SPADES - - def test_can_overbid_partner_handles_slam_value( - self, ai_player, sample_cards_weak - ): - """Normalising SlamLevel.SLAM → 500 in _can_overbid_partner avoids TypeError.""" - ai_player.hand = sample_cards_weak - # Should not raise; nothing in our weak hand beats Slam. - assert ai_player._can_overbid_partner( - (SlamLevel.SLAM, Suit.SPADES), ai_player._evaluate_suits() - ) is False - - def test_can_overbid_partner_handles_solo_slam_value( - self, ai_player, sample_cards_weak - ): - """Normalising SlamLevel.SOLO_SLAM → 1000 in _can_overbid_partner avoids TypeError.""" - ai_player.hand = sample_cards_weak - assert ai_player._can_overbid_partner( - (SlamLevel.SOLO_SLAM, Suit.SPADES), ai_player._evaluate_suits() - ) is False - - def test_should_double_handles_slam_value(self, ai_player, sample_cards_weak): - """_should_double must not TypeError on a SlamLevel value. - - The heuristic itself (``strength > 162 - value``) is permissive - against Slam-family bids because ``162 - 500`` (and -1000) is - negative; we only assert the boolean contract here. Tuning the - heuristic is a separate concern. - """ - ai_player.hand = sample_cards_weak - result = ai_player._should_double((SlamLevel.SLAM, Suit.SPADES)) - assert isinstance(result, bool) - result = ai_player._should_double((SlamLevel.SOLO_SLAM, Suit.SPADES)) - assert isinstance(result, bool) - - def test_choose_bid_passes_when_partner_announced_slam( - self, ai_player, ai_opponent_player, sample_cards_strong_spades - ): - """A strong-but-not-Slam AI passes cleanly when partner announces Slam.""" - ai_player.hand = sample_cards_strong_spades # estimates 7 tricks, max 130 - partner = ai_player.team.players[1] - auction = _auction([(partner, (SlamLevel.SLAM, Suit.SPADES))]) - # Must not TypeError on the 130-vs-Slam comparison. - bid = ai_player.choose_bid(auction) - assert isinstance(bid, PassBid) - - def test_choose_bid_passes_when_partner_announced_solo_slam( - self, ai_player, ai_opponent_player, sample_cards_strong_spades - ): - """A strong-but-not-Slam AI passes when partner announces Solo Slam.""" - ai_player.hand = sample_cards_strong_spades - partner = ai_player.team.players[1] - auction = _auction([(partner, (SlamLevel.SOLO_SLAM, Suit.SPADES))]) - bid = ai_player.choose_bid(auction) - assert isinstance(bid, PassBid) - - def test_choose_best_suit_preference_order(self, ai_player): - """Test suit preference order when multiple suits are equal""" - # Create hand with equal strength in multiple suits - ai_player.hand = Hand([ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.NINE), - Card(Suit.SPADES, Rank.ACE), - Card(Suit.HEARTS, Rank.JACK), - Card(Suit.HEARTS, Rank.NINE), - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.CLUBS, Rank.ACE) - ]) - - evaluations = ai_player._evaluate_suits() - - # Both Spades and Hearts should be good, but Spades should be preferred - candidate_suits = [Suit.SPADES, Suit.HEARTS] - chosen_suit = ai_player._choose_best_suit(candidate_suits, evaluations) - assert chosen_suit == Suit.SPADES - - def test_choose_best_suit_belote_preference(self, ai_player): - """Test that belote is preferred when contract values are equal""" - ai_player.hand = Hand([ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.NINE), - Card(Suit.SPADES, Rank.ACE), - Card(Suit.HEARTS, Rank.JACK), - Card(Suit.HEARTS, Rank.KING), - Card(Suit.HEARTS, Rank.QUEEN), # Belote in Hearts - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.CLUBS, Rank.ACE) - ]) - - evaluations = ai_player._evaluate_suits() - - # Hearts should be preferred due to belote - candidate_suits = [Suit.SPADES, Suit.HEARTS] - chosen_suit = ai_player._choose_best_suit(candidate_suits, evaluations) - assert chosen_suit == Suit.HEARTS - - -class TestAiPlayerDoubling: - """Test AI player doubling logic""" - - @pytest.fixture - def ai_players_with_teams(self): - """Create AI players with team setup""" - player = AiPlayer("TestBot", "North") - partner = AiPlayer("Partner", "South") - team = Team("North-South", [player, partner]) - player.team = team - partner.team = team - - # Create opponent team - opponent1 = AiPlayer("Opponent1", "West") - opponent2 = AiPlayer("Opponent2", "East") - opponent_team = Team("East-West", [opponent1, opponent2]) - opponent1.team = opponent_team - opponent2.team = opponent_team - - return player, partner, opponent1, opponent2 - - def test_should_double_with_external_strength(self, ai_players_with_teams): - """Test doubling when having external strength""" - player, _, opponent1, _ = ai_players_with_teams - - # Give player strong external cards - player.hand = Hand([ - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.HEARTS, Rank.TEN), - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.TEN), - Card(Suit.CLUBS, Rank.TEN), - Card(Suit.CLUBS, Rank.JACK), - Card(Suit.SPADES, Rank.EIGHT), - Card(Suit.SPADES, Rank.SEVEN) - ]) - - # Opponent bids in Spades - auction = _auction([(opponent1, (120, Suit.SPADES))]) - bid = player.choose_bid(auction) - - assert isinstance(bid, DoubleBid) - - def test_should_not_double_weak_external(self, ai_players_with_teams): - """Test not doubling when lacking external strength""" - player, _, opponent1, _ = ai_players_with_teams - - # Give player weak external cards - player.hand = Hand([ - Card(Suit.HEARTS, Rank.EIGHT), - Card(Suit.HEARTS, Rank.SEVEN), - Card(Suit.DIAMONDS, Rank.EIGHT), - Card(Suit.DIAMONDS, Rank.SEVEN), - Card(Suit.CLUBS, Rank.EIGHT), - Card(Suit.CLUBS, Rank.SEVEN), - Card(Suit.SPADES, Rank.ACE), - Card(Suit.SPADES, Rank.KING) - ]) - - # Opponent bids in Hearts - auction = _auction([(opponent1, (100, Suit.HEARTS))]) - bid = player.choose_bid(auction) - - assert isinstance(bid, PassBid) - -class TestAiPlayerTrickTaking: - """Test AI player trick taking strategy""" - - @pytest.fixture - def ai_player_with_tracking(self): - """Create an AI player with initialized card tracking""" - player = AiPlayer("TestBot", "North") - # Create a mock team - partner = AiPlayer("Partner", "South") - team = Team("North-South", [player, partner]) - player.team = team - partner.team = team - player.initialize_card_tracking() - return player - - @pytest.fixture - def ai_player_opponent(self): - """Create an opponent AI player and team""" - opponent1 = AiPlayer("Opponent1", "West") - opponent2 = AiPlayer("Opponent2", "East") - opponent_team = Team("East-West", [opponent1, opponent2]) - opponent1.team = opponent_team - opponent2.team = opponent_team - return opponent1 - - @pytest.fixture - def mock_trick(self): - """Create a mock trick object. - - Mirrors the subset of the real Trick API that AiPlayer consumes: - ``__len__`` (so empty-check works), ``get_led_suit`` and - ``get_cards`` (cards-only convenience for tests that don't care - about players), and ``get_plays`` (used by code paths that need - player identity — synthetic plays pair each card with ``None``, - which is sufficient because the only test exercising that path - mocks the methods that look at players). - """ - class MockTrick: - def __init__(self): - self.cards = [] - self.leader_position = 0 - self.trump_suit = None - - def __len__(self): - return len(self.cards) - - def get_cards(self): - return list(self.cards) - - def get_led_suit(self): - return self.cards[0].suit if self.cards else None - - def get_plays(self): - return [(None, card) for card in self.cards] - return MockTrick() - - @pytest.fixture - def sample_hand_mixed(self): - """Create a mixed hand for testing""" - return Hand([ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.ACE), - Card(Suit.HEARTS, Rank.KING), - Card(Suit.HEARTS, Rank.TEN), - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.EIGHT), - Card(Suit.DIAMONDS, Rank.SEVEN), - Card(Suit.CLUBS, Rank.QUEEN), - ]) - - def test_play_first_card_opening_round(self, ai_player_with_tracking, mock_trick, sample_hand_mixed): - """Test playing the very first card of the round""" - ai_player_with_tracking.hand = sample_hand_mixed - contract = _contract(ai_player_with_tracking, 80, Suit.SPADES) - - # Should play the strongest trump (Jack of Spades) - result = ai_player_with_tracking.choose_card(mock_trick, contract, sample_hand_mixed) - assert result.suit == Suit.SPADES - assert result.rank == Rank.JACK - - def test_play_first_card_opponents_contract(self, ai_player_with_tracking, ai_player_opponent, mock_trick, sample_hand_mixed): - """Test playing first card when opponents have contract""" - ai_player_with_tracking.hand = sample_hand_mixed - contract = _contract(ai_player_opponent, 100, Suit.HEARTS) - - # Should play ace from the shortest suit (Diamonds or Spades) - result = ai_player_with_tracking.choose_card(mock_trick, contract, sample_hand_mixed) - assert result.rank == Rank.ACE - assert result.suit == Suit.SPADES - - def test_play_leading_card_with_trump_remaining(self, ai_player_with_tracking, mock_trick): - """Test leading subsequent tricks when opponents might have trump""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.NINE), - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.EIGHT) - ]) - contract = _contract(ai_player_with_tracking, 100, Suit.SPADES) - - # Mark some cards as fallen to simulate non-opening trick - ai_player_with_tracking._fallen_cards[Suit.HEARTS].add(Rank.KING) - - # Mock opponents might have trump - ai_player_with_tracking._opponents_might_have_trump = lambda s: True - - result = ai_player_with_tracking.choose_card(mock_trick, contract, ai_player_with_tracking.hand) - assert result.suit == Suit.SPADES # Should play trump - - def test_play_leading_card_no_trump_remaining(self, ai_player_with_tracking, mock_trick): - """Test leading when opponents have no trump left""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.SPADES, Rank.EIGHT), - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.CLUBS, Rank.SEVEN) - ]) - contract = _contract(ai_player_with_tracking, 100, Suit.SPADES) - - # Mark some cards as fallen - ai_player_with_tracking._fallen_cards[Suit.HEARTS].add(Rank.KING) - - # Mock opponents have no trump - ai_player_with_tracking._opponents_might_have_trump = lambda s: False - - result = ai_player_with_tracking.choose_card(mock_trick, contract, ai_player_with_tracking.hand) - assert result.rank == Rank.ACE # Should play ace - - def test_does_not_trump_when_partner_master_non_trump_trick( - self, ai_player_with_tracking, mock_trick - ): - """When the AI cannot follow suit but partner is already master, - the AI must NOT waste a trump — even though a trump would add - more points to the pile. Picks a non-trump discard instead.""" - # Hearts trump. Partner led ♠ A and is currently master. - # AI hand has high-points trumps PLUS a non-trump option. - ai_player_with_tracking.hand = Hand([ - Card(Suit.HEARTS, Rank.JACK), # 20 trump points — must NOT play - Card(Suit.HEARTS, Rank.NINE), # 14 trump points — must NOT play - Card(Suit.DIAMONDS, Rank.KING), # 4 points — should play - ]) - mock_trick.cards = [Card(Suit.SPADES, Rank.ACE)] - mock_trick.trump_suit = Suit.HEARTS - ai_player_with_tracking._is_team_winning_trick = lambda t: True - - contract = _contract(ai_player_with_tracking, 100, Suit.HEARTS) - result = ai_player_with_tracking.choose_card( - mock_trick, contract, list(ai_player_with_tracking.hand) - ) - assert result.suit == Suit.DIAMONDS - assert result.rank == Rank.KING - - def test_dumps_highest_points_non_trump_non_master_when_partner_master( - self, ai_player_with_tracking, mock_trick - ): - """Within the non-trump non-master candidates, pick the highest - points to maximize this trick's value.""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.HEARTS, Rank.NINE), # trump — must NOT play - Card(Suit.DIAMONDS, Rank.TEN), # 10 points, non-master if A♦ out - Card(Suit.CLUBS, Rank.EIGHT), # 0 points, non-master - ]) - mock_trick.cards = [Card(Suit.SPADES, Rank.ACE)] - mock_trick.trump_suit = Suit.HEARTS - ai_player_with_tracking._is_team_winning_trick = lambda t: True - - contract = _contract(ai_player_with_tracking, 100, Suit.HEARTS) - result = ai_player_with_tracking.choose_card( - mock_trick, contract, list(ai_player_with_tracking.hand) - ) - # 10♦ picked: highest-points non-trump non-master. - assert result.suit == Suit.DIAMONDS - assert result.rank == Rank.TEN - - def test_falls_back_to_lowest_trump_when_only_trumps_in_hand( - self, ai_player_with_tracking, mock_trick - ): - """Edge case: AI's entire playable set is trumps. Forced to - play one — picks the lowest by trump-order so we don't dump - the Jack or 9.""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.HEARTS, Rank.JACK), # top trump (order 7) - Card(Suit.HEARTS, Rank.NINE), # 2nd-best (order 6) - Card(Suit.HEARTS, Rank.SEVEN), # lowest (order 0) - ]) - mock_trick.cards = [Card(Suit.SPADES, Rank.ACE)] - mock_trick.trump_suit = Suit.HEARTS - ai_player_with_tracking._is_team_winning_trick = lambda t: True - - contract = _contract(ai_player_with_tracking, 100, Suit.HEARTS) - result = ai_player_with_tracking.choose_card( - mock_trick, contract, list(ai_player_with_tracking.hand) - ) - assert result.suit == Suit.HEARTS - assert result.rank == Rank.SEVEN - - def test_prefers_non_master_over_master_in_discard( - self, ai_player_with_tracking, mock_trick - ): - """When the AI must discard non-trump, preserve master cards - for later wins — pick non-masters first.""" - ai_player_with_tracking.hand = Hand([ - # ♣A is master (no higher club exists). - Card(Suit.CLUBS, Rank.ACE), - # ♦K is non-master (♦A still out — not in fallen set). - Card(Suit.DIAMONDS, Rank.KING), - Card(Suit.HEARTS, Rank.NINE), # trump - ]) - mock_trick.cards = [Card(Suit.SPADES, Rank.ACE)] - mock_trick.trump_suit = Suit.HEARTS - ai_player_with_tracking._is_team_winning_trick = lambda t: True - - contract = _contract(ai_player_with_tracking, 100, Suit.HEARTS) - result = ai_player_with_tracking.choose_card( - mock_trick, contract, list(ai_player_with_tracking.hand) - ) - # ♣A preserved (master), ♥9 preserved (trump), ♦K dumped. - assert result.suit == Suit.DIAMONDS - assert result.rank == Rank.KING - - def test_does_not_overtrump_partner_who_already_cut( - self, ai_player_with_tracking, mock_trick - ): - """Partner already cut the non-trump-led trick. The AI must - NOT cover (over-trump or under-trump) — discard a non-trump.""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.HEARTS, Rank.NINE), # higher trump — would over-trump partner - Card(Suit.HEARTS, Rank.SEVEN), # lower trump — would under-trump partner - Card(Suit.DIAMONDS, Rank.EIGHT), # non-trump discard - ]) - # Spades led; partner trumped with ♥ A. - mock_trick.cards = [ - Card(Suit.SPADES, Rank.KING), - Card(Suit.HEARTS, Rank.ACE), - ] - mock_trick.trump_suit = Suit.HEARTS - ai_player_with_tracking._is_team_winning_trick = lambda t: True - - contract = _contract(ai_player_with_tracking, 100, Suit.HEARTS) - result = ai_player_with_tracking.choose_card( - mock_trick, contract, list(ai_player_with_tracking.hand) - ) - assert result.suit == Suit.DIAMONDS - assert result.rank == Rank.EIGHT - - def test_follow_suit_when_team_winning(self, ai_player_with_tracking, mock_trick): - """Test following suit when team is winning""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.HEARTS, Rank.KING), - Card(Suit.HEARTS, Rank.TEN), - Card(Suit.HEARTS, Rank.EIGHT), - Card(Suit.SPADES, Rank.ACE) - ]) - - # Set up trick where partner is winning - mock_trick.cards = [Card(Suit.HEARTS, Rank.QUEEN), Card(Suit.HEARTS, Rank.SEVEN)] - mock_trick.trump_suit = Suit.SPADES - - # Mock team is winning - ai_player_with_tracking._is_team_winning_trick = lambda t: True - - playable_cards = [Card(Suit.HEARTS, Rank.KING), Card(Suit.HEARTS, Rank.TEN), Card(Suit.HEARTS, Rank.EIGHT)] - result = ai_player_with_tracking.choose_card(mock_trick, _contract(ai_player_with_tracking, 100, Suit.SPADES), playable_cards) - - # Should play the highest point card (King or 10) - assert result.suit == Suit.HEARTS - assert result.rank == Rank.TEN - - def test_follow_suit_when_team_losing_can_beat(self, ai_player_with_tracking, mock_trick): - """Test following suit when team is losing but can beat current card""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.HEARTS, Rank.EIGHT), - Card(Suit.SPADES, Rank.JACK) - ]) - - # Set up trick where opponent is winning with King - mock_trick.cards = [Card(Suit.HEARTS, Rank.KING)] - mock_trick.trump_suit = Suit.SPADES - - # Mock team is losing - ai_player_with_tracking._is_team_winning_trick = lambda t: False - - playable_cards = [Card(Suit.HEARTS, Rank.ACE), Card(Suit.HEARTS, Rank.EIGHT)] - result = ai_player_with_tracking.choose_card(mock_trick, _contract(ai_player_with_tracking, 100, Suit.SPADES), playable_cards) - - # Should play Ace to beat King - assert result.rank == Rank.ACE - assert result.suit == Suit.HEARTS - - def test_follow_suit_when_team_losing_cannot_beat(self, ai_player_with_tracking, mock_trick): - """Test following suit when team is losing and cannot beat""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.HEARTS, Rank.JACK), - Card(Suit.HEARTS, Rank.EIGHT), - Card(Suit.SPADES, Rank.JACK) - ]) - - # Set up trick where opponent is winning with Ace - mock_trick.cards = [Card(Suit.HEARTS, Rank.ACE)] - mock_trick.trump_suit = Suit.SPADES - - # Mock team is losing - ai_player_with_tracking._is_team_winning_trick = lambda t: False - - playable_cards = [Card(Suit.HEARTS, Rank.JACK), Card(Suit.HEARTS, Rank.EIGHT)] - result = ai_player_with_tracking.choose_card(mock_trick, _contract(ai_player_with_tracking, 100, Suit.SPADES), playable_cards) - - # Should play the lowest card (8) - assert result.rank == Rank.EIGHT - assert result.suit == Suit.HEARTS - - def test_trump_when_cannot_follow_suit(self, ai_player_with_tracking, mock_trick): - """Test trumping when cannot follow suit and team is losing""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.NINE), - Card(Suit.DIAMONDS, Rank.EIGHT) - ]) - - # Set up trick with Hearts led - mock_trick.cards = [Card(Suit.HEARTS, Rank.KING)] - mock_trick.trump_suit = Suit.SPADES - - # Mock team is losing and can trump win - ai_player_with_tracking._is_team_winning_trick = lambda t: False - ai_player_with_tracking._can_trump_win = lambda card, trick, trump: card.rank == Rank.JACK - - playable_cards = [Card(Suit.SPADES, Rank.JACK), Card(Suit.SPADES, Rank.NINE), Card(Suit.DIAMONDS, Rank.EIGHT)] - result = ai_player_with_tracking.choose_card(mock_trick, _contract(ai_player_with_tracking, 100, Suit.SPADES), playable_cards) - - # Should trump with Jack (lowest winning trump) - assert result.suit == Suit.SPADES - assert result.rank == Rank.JACK - - def test_discard_when_cannot_follow_or_trump(self, ai_player_with_tracking, mock_trick): - """Test discarding when cannot follow suit or trump effectively""" - ai_player_with_tracking.hand = Hand([ - Card(Suit.DIAMONDS, Rank.SEVEN), - Card(Suit.CLUBS, Rank.QUEEN), - Card(Suit.CLUBS, Rank.JACK), - Card(Suit.CLUBS, Rank.TEN) - ]) - - # Set up trick with Hearts led and Spades trump - mock_trick.cards = [Card(Suit.HEARTS, Rank.KING)] - mock_trick.trump_suit = Suit.SPADES - - # Mock team is losing, no trump cards, all cards are not masters - ai_player_with_tracking._is_team_winning_trick = lambda t: False - ai_player_with_tracking._is_master_card = lambda card, trump: False - - playable_cards = [Card(Suit.DIAMONDS, Rank.SEVEN), Card(Suit.CLUBS, Rank.QUEEN), Card(Suit.CLUBS, Rank.JACK), Card(Suit.CLUBS, Rank.TEN)] - result = ai_player_with_tracking.choose_card(mock_trick, _contract(ai_player_with_tracking, 100, Suit.SPADES), playable_cards) - - # Should discard lowest from the shortest suit - assert result.rank == Rank.SEVEN # Lowest point card - assert result.suit == Suit.DIAMONDS # From shorter suit - - def test_card_tracking_initialization(self, ai_player_with_tracking): - """Test that card tracking is properly initialized""" - assert hasattr(ai_player_with_tracking, '_fallen_cards') - assert hasattr(ai_player_with_tracking, '_players_without_trump') - assert len(ai_player_with_tracking._fallen_cards) == 4 - for suit_cards in ai_player_with_tracking._fallen_cards.values(): - assert isinstance(suit_cards, set) - - - def test_update_card_tracking(self, ai_player_with_tracking, ai_player_opponent): - """Test updating card tracking with played cards""" - # Test the update_card_tracking method directly - card = Card(Suit.HEARTS, Rank.KING) - ai_player_with_tracking.update_card_tracking(card, ai_player_opponent, Suit.HEARTS, Suit.SPADES) - - # Players list without trump should be empty - assert ai_player_with_tracking._players_without_trump == set() - assert Rank.KING in ai_player_with_tracking._fallen_cards[Suit.HEARTS] - - # Test trump tracking - player couldn't follow suit and didn't trump - card2 = Card(Suit.DIAMONDS, Rank.EIGHT) - ai_player_with_tracking.update_card_tracking(card2, ai_player_opponent.team.players[1], Suit.HEARTS, Suit.SPADES) - - # West should be marked as having no trump (couldn't follow Hearts, didn't trump) - assert ai_player_opponent.team.players[1] in ai_player_with_tracking._players_without_trump - - # With 2 trump in hand and 2 fallen, opponents might have 4 remaining - ai_player_with_tracking.hand = Hand([Card(Suit.SPADES, Rank.JACK), Card(Suit.SPADES, Rank.NINE)]) - ai_player_with_tracking._fallen_cards[Suit.SPADES] = {Rank.KING, Rank.QUEEN} - result = ai_player_with_tracking._opponents_might_have_trump(Suit.SPADES) - assert result is True - - # Mark more trump cards as fallen - ai_player_with_tracking._fallen_cards[Suit.SPADES] = {Rank.KING, Rank.QUEEN, Rank.ACE, Rank.TEN, Rank.EIGHT, Rank.SEVEN} - - # With 2 trump in hand and 6 fallen, opponents have 0 remaining - result = ai_player_with_tracking._opponents_might_have_trump(Suit.SPADES) - assert result is False - - def test_is_master_card_detection(self, ai_player_with_tracking): - """Test detection of master cards""" - # Set up fallen cards - ai_player_with_tracking._fallen_cards[Suit.HEARTS] = {Rank.ACE, Rank.QUEEN, Rank.EIGHT} - - # Ace should be master now - ace_hearts = Card(Suit.HEARTS, Rank.TEN) - result = ai_player_with_tracking._is_master_card(ace_hearts, Suit.SPADES) - assert result is True - - # 10 should not be master (Ace still out) - ten_hearts = Card(Suit.HEARTS, Rank.KING) - result = ai_player_with_tracking._is_master_card(ten_hearts, Suit.SPADES) - assert result is False - - def test_trump_order_vs_normal_order(self, ai_player_with_tracking): - """Test that trump and normal card orders are handled correctly""" - # Normal order: 7, 8, 9, Jack, Queen, King, 10, Ace - normal_higher = ai_player_with_tracking._get_higher_ranks(Rank.NINE, Suit.HEARTS, Suit.SPADES) - assert Rank.JACK in normal_higher - assert Rank.ACE in normal_higher - - # Trump order: 7, 8, Queen, King, 10, Ace, 9, Jack - trump_higher = ai_player_with_tracking._get_higher_ranks(Rank.NINE, Suit.SPADES, Suit.SPADES) - assert Rank.JACK in trump_higher - assert Rank.ACE not in trump_higher # 9 is higher than Ace in trump - - def test_team_winning_trick_detection(self, ai_player_with_tracking, mock_trick): - """Test detection of whether team is winning current trick""" - # Set up mock trick with partner winning - mock_trick.cards = [Card(Suit.HEARTS, Rank.KING), Card(Suit.HEARTS, Rank.ACE)] - mock_trick.leader_position = 0 - - # Mock partner position and strongest card detection - ai_player_with_tracking._get_partner_position = lambda: 1 # Partner at position 1 - ai_player_with_tracking._get_strongest_card_position = lambda t, ts: 1 # Position 1 winning - - result = ai_player_with_tracking._is_team_winning_trick(mock_trick) - assert result is True - - # Change winning position to opponent - ai_player_with_tracking._get_strongest_card_position = lambda t, ts: 2 # Position 2 winning - - result = ai_player_with_tracking._is_team_winning_trick(mock_trick) - assert result is False + assert isinstance(player.bidding, RuleBasedBiddingStrategy) + assert isinstance(player.cardplay, RuleBasedCardPlayStrategy) + # Each strategy reads player state live through its back-reference. + assert player.bidding._player is player + assert player.cardplay._player is player + + def test_choose_bid_delegates_to_bidding_strategy(self): + """AiPlayer.choose_bid routes straight to the injected strategy.""" + player = AiPlayer("Bot", "South") + sentinel = PassBid(player) + calls = [] - def test_strongest_card_in_trick_with_trump(self, ai_player_with_tracking, mock_trick): - """Test finding strongest card when trump is involved""" - mock_trick.cards = [ - Card(Suit.HEARTS, Rank.ACE), # Led suit - Card(Suit.HEARTS, Rank.KING), # Following suit - Card(Suit.SPADES, Rank.EIGHT) # Trump beats all - ] + def spy(auction): + calls.append(auction) + return sentinel - result = ai_player_with_tracking._get_strongest_card_in_trick(mock_trick, Suit.SPADES) - assert result.suit == Suit.SPADES - assert result.rank == Rank.EIGHT + player.bidding.choose_bid = spy # type: ignore[method-assign] + auction = Auction() + result = player.choose_bid(auction) + assert result is sentinel + assert calls == [auction] - def test_strongest_card_in_trick_no_trump(self, ai_player_with_tracking, mock_trick): - """Test finding strongest card when no trump is played""" - mock_trick.cards = [ - Card(Suit.HEARTS, Rank.KING), # Led suit - Card(Suit.HEARTS, Rank.ACE), # Higher in led suit - Card(Suit.DIAMONDS, Rank.ACE) # Different suit, doesn't matter - ] + def test_choose_card_delegates_to_cardplay_strategy(self): + """AiPlayer.choose_card routes the observation straight to the strategy.""" + player = AiPlayer("Bot", "South") + sentinel = object() + calls = [] - result = ai_player_with_tracking._get_strongest_card_in_trick(mock_trick, Suit.SPADES) - assert result.suit == Suit.HEARTS - assert result.rank == Rank.ACE + def spy(observation): + calls.append(observation) + return sentinel - def test_can_trump_win_logic(self, ai_player_with_tracking, mock_trick): - """Test logic for determining if a trump card can win""" - mock_trick.cards = [ - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.SPADES, Rank.EIGHT) # Current trump winning - ] + player.cardplay.choose_card = spy # type: ignore[method-assign] + result = player.choose_card("observation") + assert result is sentinel + assert calls == ["observation"] - # Jack of trump should beat 8 of trump - trump_jack = Card(Suit.SPADES, Rank.JACK) - result = ai_player_with_tracking._can_trump_win(trump_jack, mock_trick, Suit.SPADES) - assert result is True + def test_custom_injected_factories_are_used(self): + """Factories passed at construction replace the defaults.""" - # 7 of trump should not beat 8 of trump - trump_seven = Card(Suit.SPADES, Rank.SEVEN) - result = ai_player_with_tracking._can_trump_win(trump_seven, mock_trick, Suit.SPADES) - assert result is False + class StubBidding(RuleBasedBiddingStrategy): + """Marker subclass to prove the factory was honored.""" - def test_is_stronger_card_comparison(self, ai_player_with_tracking): - """Test card strength comparison logic""" - # Trump vs non-trump - trump_card = Card(Suit.SPADES, Rank.SEVEN) - non_trump = Card(Suit.HEARTS, Rank.ACE) - result = ai_player_with_tracking._is_stronger_card(trump_card, non_trump, Suit.SPADES) - assert result is True + class StubCardPlay(RuleBasedCardPlayStrategy): + """Marker subclass to prove the factory was honored.""" - # Same suit comparison - higher_card = Card(Suit.HEARTS, Rank.ACE) - lower_card = Card(Suit.HEARTS, Rank.KING) - result = ai_player_with_tracking._is_stronger_card(higher_card, lower_card, Suit.SPADES) - assert result is True + player = AiPlayer("Bot", "South", bidding=StubBidding, cardplay=StubCardPlay) + assert type(player.bidding) is StubBidding + assert type(player.cardplay) is StubCardPlay + assert player.bidding._player is player + assert player.cardplay._player is player - # Trump vs trump - trump_jack = Card(Suit.SPADES, Rank.JACK) - trump_nine = Card(Suit.SPADES, Rank.NINE) - result = ai_player_with_tracking._is_stronger_card(trump_jack, trump_nine, Suit.SPADES) - assert result is True diff --git a/packages/contrai-engine/tests/test_model/test_round.py b/packages/contrai-engine/tests/test_model/test_round.py index 6f6fb4f..39a0b23 100644 --- a/packages/contrai-engine/tests/test_model/test_round.py +++ b/packages/contrai-engine/tests/test_model/test_round.py @@ -1,61 +1,61 @@ -"""Tests for ``Round._get_playable_cards`` — the legality oracle. - -The rules under test come from ``contree-domain.md`` §6.2-§6.3: - - 1. Follow suit if possible. - 2. When trump is led, over-trump if you hold a higher trump than - the highest already played; otherwise any trump. - 3. When you cannot follow suit and your partner is *not* currently - master of the trick, you must trump (and over-trump opponents - if able). - 4. Partner-master exemption: if your partner is currently winning - the trick, you may discard freely. - 5. Otherwise discard. - -These tests build a minimal ``Round`` with a hand-picked trick state -and ask for the legal-play set. They avoid the full ``manage_bidding`` -+ ``play_all_tricks`` path so the oracle's branches can be exercised -in isolation. +"""Tests for the ``Round`` lifecycle orchestrator. + +Covers the parts that stay in the orchestrator now that the trick loop is +driven by the immutable core :class:`PlayState`: + + * ``play_trick`` rejecting an illegal card with ``IllegalPlayError`` + (raised by the core state machine, no longer by the engine); + * the mirror bookkeeping — the players' hands and ``current_trick`` + kept in lock-step with the authoritative ``play_state``; + * card identity flowing unbroken from the seed to the playable set; + * auction retention and play-state seeding across the lifecycle; + * belote / rebelote detection and the announcement state machine; + * the ``manage_bidding`` auto-pass UX promise (the human is never + prompted when Pass is the only legal action). + +The legal-play oracle itself lives in ``contrai-core``'s +``test_play_legality.py`` and the scoring grid in +``test_round_scoring.py``. The shared ``players`` fixture lives in +``conftest.py``. """ from __future__ import annotations import pytest -from contrai_core import Auction, Hand -from contrai_core.bid import ContractBid, DoubleBid, PassBid, RedoubleBid, SlamLevel +from contrai_core import Hand +from contrai_core.auction import Auction +from contrai_core.bid import ContractBid, DoubleBid, PassBid from contrai_core.card import Card from contrai_core.contract import Contract -from contrai_core.exceptions import IllegalPlayError, PlayRuleViolation +from contrai_core.deck import Deck +from contrai_core.play import PlayState from contrai_core.team import Team +from contrai_core.exceptions import IllegalPlayError, PlayRuleViolation from contrai_core.trick import Trick from contrai_core.types import Rank, Suit -from contrai_engine.model.player import AiPlayer, HumanPlayer, wire_to_bid -from contrai_engine.model.round import Round, UnannouncedSlam +from contrai_engine.model.player import AiPlayer, HumanPlayer +from contrai_engine.model.round import Round # --------------------------------------------------------------------------- -# Fixtures — four positioned players + their teams +# Scenario builders. The shared ``players`` fixture lives in ``conftest.py``. # --------------------------------------------------------------------------- -@pytest.fixture -def players(): - north = AiPlayer("N", "North") - east = AiPlayer("E", "East") - south = AiPlayer("S", "South") - west = AiPlayer("W", "West") - ns = Team("North-South", [north, south]) - ew = Team("East-West", [east, west]) - for p in (north, south): - p.team = ns - for p in (east, west): - p.team = ew - return {"N": north, "E": east, "S": south, "W": west} +class _StubDeck: + """Deck stand-in for tests that drive ``play_trick`` to completion. + + ``play_trick`` returns the trick's cards to the deck at the end; + the stub just swallows them so no real ``Deck`` state is needed. + """ + + def add_cards(self, cards): + """Swallow the returned trick cards.""" -def _make_round(players_dict, hands, contract, plays): +def _make_round(players_dict, hands, contract, plays, deck=None): """Build a ``Round`` wired to the supplied state. Args: @@ -66,6 +66,9 @@ def _make_round(players_dict, hands, contract, plays): contract: a Contract object (provides trump) or None. plays: ordered list of (seat_letter, Card) tuples — the cards already played in the current trick. + deck: optional deck object; tests that run ``play_trick`` to + completion pass a ``_StubDeck`` so the end-of-trick + ``add_cards`` call has something to land on. Returns: A Round whose ``current_trick`` reflects ``plays`` and whose @@ -74,7 +77,7 @@ def _make_round(players_dict, hands, contract, plays): order = [players_dict[s] for s in ("N", "E", "S", "W")] for seat, cards in hands.items(): players_dict[seat].hand = Hand(cards) - round_ = Round(order, dealer=players_dict["N"], deck=None, round_number=1) + round_ = Round(order, dealer=players_dict["N"], deck=deck, round_number=1) round_.contract = contract round_.current_trick = Trick() for seat, card in plays: @@ -86,332 +89,395 @@ def _contract(player, value, suit): return Contract(ContractBid(player, value, suit)) -# --------------------------------------------------------------------------- -# Over-trump rule when trump is led (commit 2 target) -# --------------------------------------------------------------------------- - - -class TestOverTrumpWhenTrumpIsLed: - """contree-domain.md §6.3 — must beat the highest trump on the table.""" +class TestPlayTrickRejectsIllegalCard: + """play_trick raises IllegalPlayError instead of silently correcting + an illegal card returned by choose_card.""" - def test_higher_trump_available_forces_overtrump(self, players): - """N leads ♠ 7 (trump), E plays ♠ A (current best trump, order 5). - S holds ♠ J (master, order 7) and ♠ 8 (order 1). - S must play the ♠ J — the ♠ 8 is illegal.""" + def test_illegal_card_raises_illegal_play_error(self, players): contract = _contract(players["N"], 100, Suit.SPADES) - hand = [Card(Suit.SPADES, Rank.JACK), Card(Suit.SPADES, Rank.EIGHT)] + n_card = Card(Suit.HEARTS, Rank.KING) + e_follow = Card(Suit.HEARTS, Rank.ACE) + e_illegal = Card(Suit.SPADES, Rank.JACK) # trump, but E holds a heart round_ = _make_round( players, - {"N": [], "E": [], "S": hand, "W": []}, + {"N": [n_card], "E": [e_illegal, e_follow], "S": [], "W": []}, contract, - [("N", Card(Suit.SPADES, Rank.SEVEN)), - ("E", Card(Suit.SPADES, Rank.ACE))], + [], # play_trick starts a fresh trick itself ) - legal = round_._get_playable_cards(players["S"]) - assert set(legal) == {Card(Suit.SPADES, Rank.JACK)} - - def test_only_lower_trumps_falls_back_to_all_trumps(self, players): - """E plays the ♠ J (the absolute master). S holds only weaker - trumps — every one is legal.""" - contract = _contract(players["N"], 100, Suit.SPADES) - hand = [Card(Suit.SPADES, Rank.EIGHT), Card(Suit.SPADES, Rank.SEVEN)] - round_ = _make_round( - players, - {"N": [], "E": [], "S": hand, "W": []}, - contract, - [("N", Card(Suit.SPADES, Rank.SEVEN)), - ("E", Card(Suit.SPADES, Rank.JACK))], + # Scripted choices: N leads its only heart, E tries the illegal trump. + players["N"].choose_card = ( + lambda observation, _card=n_card: _card ) - # NOTE: lead is ♠7, but follow-suit rule already filters to ♠ — - # the over-trump branch then sees no higher trump and returns - # the full follow-suit set. - legal = round_._get_playable_cards(players["S"]) - assert set(legal) == { - Card(Suit.SPADES, Rank.EIGHT), - Card(Suit.SPADES, Rank.SEVEN), - } - - def test_multiple_higher_trumps_returns_all_higher(self, players): - """Both ♠ J and ♠ 9 beat the ♠ A on the table; both are legal.""" - contract = _contract(players["N"], 100, Suit.SPADES) - hand = [ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.NINE), - Card(Suit.SPADES, Rank.EIGHT), - ] - round_ = _make_round( - players, - {"N": [], "E": [], "S": hand, "W": []}, - contract, - [("N", Card(Suit.SPADES, Rank.SEVEN)), - ("E", Card(Suit.SPADES, Rank.ACE))], + players["E"].choose_card = ( + lambda observation, _card=e_illegal: _card ) - legal = round_._get_playable_cards(players["S"]) - assert set(legal) == { - Card(Suit.SPADES, Rank.JACK), - Card(Suit.SPADES, Rank.NINE), + + with pytest.raises(IllegalPlayError) as excinfo: + round_.play_trick() + + assert excinfo.value.card is e_illegal + assert excinfo.value.reason == PlayRuleViolation.MUST_FOLLOW_SUIT + assert set(excinfo.value.legal_cards) == {Card(Suit.HEARTS, Rank.ACE)} + + +class TestPlayTrickHumanUsesView: + """A human's card is sourced from the view, never from + ``HumanPlayer.choose_card`` (which only returns None by design).""" + + def test_human_card_comes_from_view_not_choose_card(self): + human = HumanPlayer("H", "North") + east = AiPlayer("E", "East") + south = AiPlayer("S", "South") + west = AiPlayer("W", "West") + order = [human, east, south, west] + ns = Team("North-South", [human, south]) + ew = Team("East-West", [east, west]) + for p in (human, south): + p.team = ns + for p in (east, west): + p.team = ew + + contract = _contract(human, 100, Suit.SPADES) + # One heart each so following suit is trivial; human leads. + cards = { + human: Card(Suit.HEARTS, Rank.KING), + east: Card(Suit.HEARTS, Rank.SEVEN), + south: Card(Suit.HEARTS, Rank.EIGHT), + west: Card(Suit.HEARTS, Rank.NINE), } + for player, card in cards.items(): + player.hand = Hand([card]) - def test_no_trump_at_all_allows_free_discard(self, players): - """Trump led and S has none → can discard anything (the trump - suit doesn't compete with the led suit for the off-suit hand).""" - contract = _contract(players["N"], 100, Suit.SPADES) - hand = [Card(Suit.HEARTS, Rank.ACE), Card(Suit.DIAMONDS, Rank.KING)] - round_ = _make_round( - players, - {"N": [], "E": [], "S": hand, "W": []}, - contract, - [("N", Card(Suit.SPADES, Rank.SEVEN)), - ("E", Card(Suit.SPADES, Rank.ACE))], + round_ = Round(order, dealer=human, deck=_StubDeck(), round_number=1) + round_.contract = contract + + # Spy: the human's choose_card must NOT be called on the view path. + human_calls = [] + human.choose_card = ( # type: ignore[method-assign] + lambda *args, _calls=human_calls: _calls.append(args) ) - legal = round_._get_playable_cards(players["S"]) - assert set(legal) == set(hand) + # Bots play their single legal card straight through choose_card. + for player in (east, south, west): + player.choose_card = ( # type: ignore[method-assign] + lambda observation, _card=cards[player]: _card + ) + + view_calls = [] + + class _SpyView: + def request_card_action(self, player, trick, contract, playable): + """Record who was prompted and return the scripted card.""" + view_calls.append(player) + return cards[player] + + round_.play_trick(view=_SpyView()) + + assert human_calls == [] # choose_card bypassed for the human + assert view_calls == [human] # the view drove the human's turn + assert cards[human] not in human.hand # the chosen card was played # --------------------------------------------------------------------------- -# Sanity scenarios for non-trump-led tricks (regression coverage so -# the over-trump fix doesn't drift into the wrong branch) +# PlayState-driven loop: seeding, hand mirroring, and card identity # --------------------------------------------------------------------------- -class TestFollowSuitWhenNonTrumpLed: - def test_must_follow_lead_suit(self, players): +class TestSyncHandsMirrorsPlayState: + """After each play the players' hands are re-mirrored from the + authoritative ``play_state``: same content, same order, same ``Hand`` + object identity.""" + + def test_hands_mirror_play_state_and_keep_hand_identity(self, players): contract = _contract(players["N"], 100, Suit.SPADES) - hand = [ - Card(Suit.HEARTS, Rank.SEVEN), - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.SPADES, Rank.JACK), # trump but lead is hearts - ] - round_ = _make_round( - players, - {"N": [], "E": [], "S": hand, "W": []}, - contract, - [("N", Card(Suit.HEARTS, Rank.KING))], - ) - legal = round_._get_playable_cards(players["S"]) - assert set(legal) == { - Card(Suit.HEARTS, Rank.SEVEN), - Card(Suit.HEARTS, Rank.ACE), + # Each seat holds the heart it will play plus a distinct spare, so + # the hands are non-empty after the trick and the mirror content is + # observable. + played = { + "N": Card(Suit.HEARTS, Rank.KING), + "E": Card(Suit.HEARTS, Rank.SEVEN), + "S": Card(Suit.HEARTS, Rank.EIGHT), + "W": Card(Suit.HEARTS, Rank.NINE), + } + spares = { + "N": Card(Suit.DIAMONDS, Rank.KING), + "E": Card(Suit.DIAMONDS, Rank.QUEEN), + "S": Card(Suit.DIAMONDS, Rank.JACK), + "W": Card(Suit.DIAMONDS, Rank.TEN), } + hands = {seat: [played[seat], spares[seat]] for seat in played} + round_ = _make_round(players, hands, contract, [], deck=_StubDeck()) + for seat, card in played.items(): + players[seat].choose_card = ( + lambda observation, _card=card: _card + ) - def test_partner_master_free_discard(self, players): - """N (partner) led ♥A. E followed ♥7. Partner is still master. - S has no hearts, no trump obligation → free discard.""" - contract = _contract(players["N"], 100, Suit.SPADES) - hand = [ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.CLUBS, Rank.SEVEN), - ] - round_ = _make_round( - players, - {"N": [], "E": [], "S": hand, "W": []}, - contract, - [("N", Card(Suit.HEARTS, Rank.ACE)), - ("E", Card(Suit.HEARTS, Rank.SEVEN))], - ) - legal = round_._get_playable_cards(players["S"]) - assert set(legal) == set(hand) + # Capture the Hand object identities before the trick runs. + original_hands = {seat: players[seat].hand for seat in played} - def test_partner_overtrumped_must_trump(self, players): - """N (partner) led ♥A. E (opponent) over-trumped with ♠7. - Partner is no longer master → S must trump (and over-trump - the ♠7 with anything higher, here ♠J).""" - contract = _contract(players["N"], 100, Suit.SPADES) - hand = [ - Card(Suit.SPADES, Rank.JACK), - Card(Suit.DIAMONDS, Rank.ACE), - Card(Suit.CLUBS, Rank.SEVEN), - ] - round_ = _make_round( - players, - {"N": [], "E": [], "S": hand, "W": []}, - contract, - [("N", Card(Suit.HEARTS, Rank.ACE)), - ("E", Card(Suit.SPADES, Rank.SEVEN))], - ) - legal = round_._get_playable_cards(players["S"]) - assert set(legal) == {Card(Suit.SPADES, Rank.JACK)} + round_.play_trick() - def test_partner_led_then_partner_overtaken_must_trump(self, players): - """Symmetric scenario where S has no hearts AND no trump - higher than the opponent's overtrump — must still play a - trump (even a lower one). The non-trump cards are now off-limits.""" - contract = _contract(players["N"], 100, Suit.SPADES) - hand = [ - Card(Suit.SPADES, Rank.SEVEN), # below opponent's ♠ J - Card(Suit.DIAMONDS, Rank.ACE), - ] - round_ = _make_round( - players, - {"N": [], "E": [], "S": hand, "W": []}, - contract, - [("N", Card(Suit.HEARTS, Rank.ACE)), - ("E", Card(Suit.SPADES, Rank.JACK))], - ) - legal = round_._get_playable_cards(players["S"]) - # Must trump even though we can't over-trump. - assert set(legal) == {Card(Suit.SPADES, Rank.SEVEN)} - - def test_three_card_partial_opponent_master_forces_overtrump(self, players): - """Three-card partial trick: N♥A, E♠7, S♠A. S is now master - (S♠A beats E's ♠7 in trump order). It is W's turn. W's partner - is E (not master) — the master is the opponent S → W must - over-trump S♠A. In trump order ♠A is rank 5, only ♠9 (rank 6) - and ♠J (rank 7) beat it. W has ♠9 (legal) and ♠8 (illegal).""" + for seat in played: + player = players[seat] + # The same Hand object, mutated in place — never reassigned. + assert player.hand is original_hands[seat] + authoritative = round_.play_state.hand_of(player) + # Content and order agree with the authoritative state, and the + # played heart is gone. + assert list(player.hand) == list(authoritative) + assert list(player.hand) == [spares[seat]] + + +class TestCardIdentityFlowsFromSeed: + """The playable set handed to a strategy holds the very ``Card`` + objects seeded into ``play_state`` — no copy or reconstruction — so + identity-matching call sites (the view) keep working.""" + + def test_playables_are_the_seeded_card_objects(self, players): contract = _contract(players["N"], 100, Suit.SPADES) - hand_w = [ - Card(Suit.SPADES, Rank.NINE), # beats ♠A - Card(Suit.SPADES, Rank.EIGHT), # below ♠A in trump order - Card(Suit.DIAMONDS, Rank.SEVEN), - ] - round_ = _make_round( - players, - {"N": [], "E": [], "S": [], "W": hand_w}, - contract, - [("N", Card(Suit.HEARTS, Rank.ACE)), - ("E", Card(Suit.SPADES, Rank.SEVEN)), - ("S", Card(Suit.SPADES, Rank.ACE))], - ) - legal = round_._get_playable_cards(players["W"]) - assert set(legal) == {Card(Suit.SPADES, Rank.NINE)} + # N leads, so every card in N's hand is legal — the playable set is + # N's whole hand and identity is easy to assert end-to-end. + n_cards = [Card(Suit.HEARTS, Rank.KING), Card(Suit.CLUBS, Rank.ACE)] + hands = { + "N": n_cards, + "E": [Card(Suit.HEARTS, Rank.SEVEN)], + "S": [Card(Suit.HEARTS, Rank.EIGHT)], + "W": [Card(Suit.HEARTS, Rank.NINE)], + } + round_ = _make_round(players, hands, contract, [], deck=_StubDeck()) + + captured: dict[str, list] = {} + + def n_choose(observation): + captured["playable"] = observation.legal_cards + return observation.legal_cards[0] + + players["N"].choose_card = n_choose + for seat in ("E", "S", "W"): + card = hands[seat][0] + players[seat].choose_card = ( + lambda observation, _card=card: _card + ) + + # The exact Card objects the seed will draw from N's hand. + seeded = list(players["N"].hand) + + round_.play_trick() + + # Same objects, same order — matched by identity, not equality. + assert len(captured["playable"]) == len(seeded) + assert all(a is b for a, b in zip(captured["playable"], seeded)) + + +class TestAuctionRetention: + """``manage_bidding`` retains the terminal auction on the round.""" + + def test_round_keeps_the_auction_that_set_the_contract(self, players): + w, n, e, s = players["W"], players["N"], players["E"], players["S"] + scripted = { + n: [ContractBid(n, 80, Suit.HEARTS)], + e: [PassBid(e)], + s: [PassBid(s)], + w: [PassBid(w)], + } + for ai, choices in scripted.items(): + queue = list(choices) + ai.choose_bid = lambda _auction, _p=ai, _q=queue: ( + _q.pop(0) if _q else PassBid(_p) + ) + + # A capture view anchors the identity: the auction present when the + # contract was established is the very object retained afterward. + captured: dict[str, object] = {} + + class _CaptureView: + def on_contract_established(self, round_): + """Capture the auction object live at contract time.""" + captured["auction"] = round_.auction + + round_ = _empty_round(players) # order N, E, S, W + contract = round_.manage_bidding(view=_CaptureView()) + + assert contract is not None + assert round_.auction is not None + assert round_.auction is captured["auction"] + assert round_.auction.is_terminal() + assert round_.auction.contract() == contract + - def test_opponent_led_and_partner_followed_must_follow_suit(self, players): - """E (opponent) led ♥K; N (partner) played ♥7 in follow. S has - hearts → must follow suit.""" +class TestPlayStateSeeding: + """``play_all_tricks`` seeds a *validated* start state; ``play_trick`` + lazy-seeds an *unvalidated* one when called directly.""" + + def _script_first_playable(self, order): + for player in order: + player.choose_card = ( + lambda observation: observation.legal_cards[0] + ) + + def test_play_all_tricks_validates_the_deal(self, players): + order = [players[s] for s in ("N", "E", "S", "W")] + round_ = Round(order, dealer=players["N"], deck=Deck(), round_number=1) + round_.deal_cards() # 8 distinct cards per seat + round_.contract = _contract(players["N"], 100, Suit.SPADES) + # Corrupt the deal: one seat now holds only 7 cards. A validated + # seeding (PlayState.start) must reject it. + players["N"].hand.remove(players["N"].hand[0]) + + with pytest.raises(ValueError): + round_.play_all_tricks() + + def test_play_trick_lazy_seeds_unvalidated_when_unseeded(self, players): contract = _contract(players["N"], 100, Suit.SPADES) - hand = [ - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.SPADES, Rank.JACK), - Card(Suit.DIAMONDS, Rank.ACE), - ] + # One card per seat — an invalid start deal, so a validated seeding + # would raise. Lazy-seeding uses the bare constructor and proceeds. + cards = { + "N": Card(Suit.HEARTS, Rank.KING), + "E": Card(Suit.HEARTS, Rank.SEVEN), + "S": Card(Suit.HEARTS, Rank.EIGHT), + "W": Card(Suit.HEARTS, Rank.NINE), + } round_ = _make_round( players, - {"N": [], "E": [], "S": hand, "W": []}, + {seat: [card] for seat, card in cards.items()}, contract, - [("E", Card(Suit.HEARTS, Rank.KING)), - ("N", Card(Suit.HEARTS, Rank.SEVEN))], + [], + deck=_StubDeck(), ) - legal = round_._get_playable_cards(players["S"]) - assert set(legal) == {Card(Suit.HEARTS, Rank.ACE)} + for seat, card in cards.items(): + players[seat].choose_card = ( + lambda observation, _card=card: _card + ) + + assert round_.play_state is None + + round_.play_trick() + + # The state was seeded and advanced by exactly one trick's worth of + # plays — no exception despite the sub-8-card hands. + assert round_.play_state is not None + assert len(round_.play_state.plays) == 4 + assert round_.play_state.trick_number == 1 + + +class TestPlayThroughReachesTerminal: + """A full ``play_all_tricks`` drives the ``play_state`` to a terminal + state: 8 completed tricks and every card played.""" + + def test_full_round_is_terminal_after_eight_tricks(self, players): + order = [players[s] for s in ("N", "E", "S", "W")] + round_ = Round(order, dealer=players["N"], deck=Deck(), round_number=1) + round_.deal_cards() + round_.contract = _contract(players["N"], 100, Suit.SPADES) + for player in order: + player.choose_card = ( + lambda observation: observation.legal_cards[0] + ) + + round_.play_all_tricks() + + assert round_.play_state.is_terminal() + assert len(round_.play_state.completed_tricks) == 8 + assert len(round_.tricks) == 8 + for player in order: + assert len(player.hand) == 0 # --------------------------------------------------------------------------- -# Illegal-play classifier — _classify_play_violation +# The observation handed to each AI seat # --------------------------------------------------------------------------- -# -# These mirror the legality scenarios above, but feed the classifier an -# *illegal* in-hand card and assert the PlayRuleViolation it returns. The -# classifier's branch order must stay in sync with _get_playable_cards. -class TestClassifyPlayViolation: - def test_off_suit_while_holding_lead_is_follow_violation(self, players): - """N leads ♥K. S holds hearts but tries the ♠J (trump) → must - follow suit.""" - contract = _contract(players["N"], 100, Suit.SPADES) - illegal = Card(Suit.SPADES, Rank.JACK) - hand = [ - Card(Suit.HEARTS, Rank.SEVEN), - Card(Suit.HEARTS, Rank.ACE), - illegal, # trump but lead is hearts - ] - round_ = _make_round( - players, - {"N": [], "E": [], "S": hand, "W": []}, - contract, - [("N", Card(Suit.HEARTS, Rank.KING))], - ) - assert ( - round_._classify_play_violation(players["S"], illegal) - == PlayRuleViolation.MUST_FOLLOW_SUIT - ) +class TestPlayTrickHandsObservation: + """``play_trick`` hands each AI seat a frozen ``PlayObservation`` + projected from the authoritative play state, carrying that seat's legal + cards, the public trick-so-far, and the retained auction's bids.""" - def test_too_low_trump_when_trump_led_is_overtrump_violation(self, players): - """N leads ♠7 (trump), E plays ♠A. S holds ♠J (master) and ♠8; - playing the ♠8 → must over-trump.""" + def test_observation_matches_play_state_and_auction(self, players): contract = _contract(players["N"], 100, Suit.SPADES) - illegal = Card(Suit.SPADES, Rank.EIGHT) - hand = [Card(Suit.SPADES, Rank.JACK), illegal] + cards = { + "N": Card(Suit.HEARTS, Rank.KING), + "E": Card(Suit.HEARTS, Rank.SEVEN), + "S": Card(Suit.HEARTS, Rank.EIGHT), + "W": Card(Suit.HEARTS, Rank.NINE), + } round_ = _make_round( players, - {"N": [], "E": [], "S": hand, "W": []}, + {seat: [card] for seat, card in cards.items()}, contract, - [("N", Card(Suit.SPADES, Rank.SEVEN)), - ("E", Card(Suit.SPADES, Rank.ACE))], - ) - assert ( - round_._classify_play_violation(players["S"], illegal) - == PlayRuleViolation.MUST_OVERTRUMP + [], + deck=_StubDeck(), ) - def test_discard_while_void_and_holding_trump_is_trump_violation(self, players): - """E (opponent) leads ♥A — no trump on the table yet. S is void in - hearts, holds ♠J (trump) but discards ♦A → must trump.""" - contract = _contract(players["N"], 100, Suit.SPADES) - illegal = Card(Suit.DIAMONDS, Rank.ACE) - hand = [Card(Suit.SPADES, Rank.JACK), illegal] - round_ = _make_round( - players, - {"N": [], "E": [], "S": hand, "W": []}, - contract, - [("E", Card(Suit.HEARTS, Rank.ACE))], - ) - assert ( - round_._classify_play_violation(players["S"], illegal) - == PlayRuleViolation.MUST_TRUMP - ) + # Retain a real terminal auction on the round: its bids must ride + # along on every observation. + auction = Auction.empty() + auction = auction.apply(ContractBid(players["N"], 100, Suit.SPADES)) + for seat in ("E", "S", "W"): + auction = auction.apply(PassBid(players[seat])) + round_.auction = auction - def test_under_trump_over_opponent_ruff_is_overtrump_violation(self, players): - """Three-card partial: N♥A, E♠7, S♠A. W (opponent of master S) is - void in hearts, holds ♠9 (beats ♠A) and ♠8 (below it); playing the - ♠8 → must over-trump.""" - contract = _contract(players["N"], 100, Suit.SPADES) - illegal = Card(Suit.SPADES, Rank.EIGHT) - hand_w = [Card(Suit.SPADES, Rank.NINE), illegal, Card(Suit.DIAMONDS, Rank.SEVEN)] - round_ = _make_round( - players, - {"N": [], "E": [], "S": [], "W": hand_w}, - contract, - [("N", Card(Suit.HEARTS, Rank.ACE)), - ("E", Card(Suit.SPADES, Rank.SEVEN)), - ("S", Card(Suit.SPADES, Rank.ACE))], - ) - assert ( - round_._classify_play_violation(players["W"], illegal) - == PlayRuleViolation.MUST_OVERTRUMP - ) + seen: dict[str, object] = {} + def _record(seat): + def choose(observation, _seat=seat): + seen[_seat] = observation + return observation.legal_cards[0] + return choose -class TestPlayTrickRejectsIllegalCard: - """play_trick raises IllegalPlayError instead of silently correcting - an illegal card returned by choose_card.""" + for seat in ("N", "E", "S", "W"): + players[seat].choose_card = _record(seat) - def test_illegal_card_raises_illegal_play_error(self, players): + round_.play_trick() + + # Every seat saw the retained auction's bids. + for seat in ("N", "E", "S", "W"): + assert seen[seat].bids == auction.bids + assert seen[seat].completed_tricks == () + + # The in-progress trick grows one play per seat, in play order. + assert [c for _, c in seen["N"].current_trick] == [] + assert [c for _, c in seen["E"].current_trick] == [cards["N"]] + assert [c for _, c in seen["S"].current_trick] == [ + cards["N"], cards["E"] + ] + assert [c for _, c in seen["W"].current_trick] == [ + cards["N"], cards["E"], cards["S"] + ] + + # Legal cards come straight from the play state (each seat holds one). + assert list(seen["N"].legal_cards) == [cards["N"]] + assert list(seen["E"].legal_cards) == [cards["E"]] + + def test_bids_default_to_empty_when_no_auction_retained(self, players): contract = _contract(players["N"], 100, Suit.SPADES) - n_card = Card(Suit.HEARTS, Rank.KING) - e_follow = Card(Suit.HEARTS, Rank.ACE) - e_illegal = Card(Suit.SPADES, Rank.JACK) # trump, but E holds a heart + cards = { + "N": Card(Suit.HEARTS, Rank.KING), + "E": Card(Suit.HEARTS, Rank.SEVEN), + "S": Card(Suit.HEARTS, Rank.EIGHT), + "W": Card(Suit.HEARTS, Rank.NINE), + } round_ = _make_round( players, - {"N": [n_card], "E": [e_illegal, e_follow], "S": [], "W": []}, + {seat: [card] for seat, card in cards.items()}, contract, - [], # play_trick starts a fresh trick itself - ) - # Scripted choices: N leads its only heart, E tries the illegal trump. - players["N"].choose_card = ( - lambda trick, c, playable, _card=n_card: _card - ) - players["E"].choose_card = ( - lambda trick, c, playable, _card=e_illegal: _card + [], + deck=_StubDeck(), ) + assert round_.auction is None # nothing retained - with pytest.raises(IllegalPlayError) as excinfo: - round_.play_trick() + seen: list = [] + for seat in ("N", "E", "S", "W"): + players[seat].choose_card = ( + lambda observation: ( + seen.append(observation) or observation.legal_cards[0] + ) + ) - assert excinfo.value.card is e_illegal - assert excinfo.value.reason == PlayRuleViolation.MUST_FOLLOW_SUIT - assert set(excinfo.value.legal_cards) == {Card(Suit.HEARTS, Rank.ACE)} + round_.play_trick() + + assert seen # the AI path ran + for observation in seen: + assert observation.bids == () # --------------------------------------------------------------------------- @@ -581,19 +647,20 @@ def test_human_is_not_prompted_after_partner_double(self, players): human.team = players["S"].team # same N-S team players["S"] = human - # Pre-seed each AI's choose_bid via a scripted queue. Lambdas - # consume wire-format entries and lift them through - # ``wire_to_bid`` so the returned objects match the new - # :class:`Bid`-typed signature of ``Player.choose_bid``. + # Pre-seed each AI's choose_bid via a scripted queue of concrete + # :class:`Bid` objects, matching the ``Bid``-typed signature of + # ``Player.choose_bid``. Each seat's bids are attached to that + # seat so ``Auction`` records the right player. + w, n, e = players["W"], players["N"], players["E"] scripted = { - players["W"]: [(100, Suit.HEARTS), "Pass", "Pass", "Pass"], - players["N"]: ["Double", "Pass", "Pass", "Pass"], - players["E"]: ["Pass", "Pass", "Pass", "Pass"], + w: [ContractBid(w, 100, Suit.HEARTS), PassBid(w), PassBid(w), PassBid(w)], + n: [DoubleBid(n), PassBid(n), PassBid(n), PassBid(n)], + e: [PassBid(e), PassBid(e), PassBid(e), PassBid(e)], } for ai, choices in scripted.items(): queue = list(choices) - ai.choose_bid = lambda _auction, _p=ai, _q=queue: wire_to_bid( - _p, _q.pop(0) if _q else "Pass" + ai.choose_bid = lambda _auction, _p=ai, _q=queue: ( + _q.pop(0) if _q else PassBid(_p) ) # Stub view: records request_bid_action calls. Asserting it @@ -602,6 +669,7 @@ def test_human_is_not_prompted_after_partner_double(self, players): class _View: def request_bid_action(self, player, auction): + """Record the prompt (the test asserts none ever happens).""" prompts.append((player, list(auction.bids))) return PassBid(player) @@ -622,662 +690,3 @@ def request_bid_action(self, player, auction): # And the critical assertion: S was never prompted. assert prompts == [] - -# --------------------------------------------------------------------------- -# Slam / Solo Slam scoring (calculate_round_scores) -# --------------------------------------------------------------------------- -# -# Tests below build a Round directly and stuff it with the minimal state -# the scoring path reads: -# - ``self.contract`` — drives base / multiplier / family check. -# - ``self.team_tricks`` — number of tricks per team (length used). -# - ``self.tricks`` — per-trick winners (used by Solo Slam). -# - ``self.last_trick_winner``— "dix de der" (irrelevant for Slam family). -# -# Cards inside each Trick only matter when belote / card points are -# computed; for Slam family they are not — we still seed at least one -# card per trick so :meth:`Trick.get_current_winner` has something to -# answer with. - - -def _slam_round( - players_dict, - *, - contract, - trick_winners, -): - """Build a Round with synthesised tricks. - - Args: - players_dict: the ``players`` fixture (seat → Player). - contract: a Contract bound to one of the players. - trick_winners: ordered list of seat letters — one per completed - trick. Each entry is the player who wins that trick. Cards - are filler (the suit-7), and the winner leads it so - :meth:`Trick.get_current_winner` returns them. - - Returns: - Round with ``contract``, ``tricks``, ``team_tricks``, and - ``last_trick_winner`` populated. - """ - order = [players_dict[s] for s in ("N", "E", "S", "W")] - round_ = Round(order, dealer=players_dict["N"], deck=None, round_number=1) - round_.contract = contract - - # Filler card per trick: a low non-trump card. The winner plays it - # solo so get_current_winner returns them regardless of trump. - filler = Card(Suit.CLUBS, Rank.SEVEN) - for seat in trick_winners: - trick = Trick() - trick.add_play(players_dict[seat], filler) - round_.tricks.append(trick) - winner = players_dict[seat] - if winner.team is not None: - round_.team_tricks[winner.team.name].append(trick) - - if trick_winners: - round_.last_trick_winner = players_dict[trick_winners[-1]] - return round_ - - -class TestSlamScoring: - """Symmetric grid: 500 / 1000 / 2000 to the winning side.""" - - def test_slam_made_normal_attacker_scores_500(self, players): - contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 500 - assert scores["East-West"] == 0 - - def test_slam_failed_normal_defender_scores_500(self, players): - # Attacker (N) takes only 7 tricks; W steals one → contract fails. - contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) - winners = ["N"] * 7 + ["W"] - round_ = _slam_round(players, contract=contract, trick_winners=winners) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 0 - assert scores["East-West"] == 500 - - def test_slam_made_doubled_attacker_scores_1000(self, players): - contract = Contract( - ContractBid(players["N"], SlamLevel.SLAM, Suit.SPADES), - double_player=players["E"], - ) - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 1000 - assert scores["East-West"] == 0 - - def test_slam_failed_doubled_defender_scores_1000(self, players): - contract = Contract( - ContractBid(players["N"], SlamLevel.SLAM, Suit.SPADES), - double_player=players["E"], - ) - winners = ["N"] * 6 + ["E", "W"] - round_ = _slam_round(players, contract=contract, trick_winners=winners) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 0 - assert scores["East-West"] == 1000 - - def test_slam_made_redoubled_attacker_scores_2000(self, players): - contract = Contract( - ContractBid(players["N"], SlamLevel.SLAM, Suit.SPADES), - double_player=players["E"], - redouble_player=players["N"], - ) - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 2000 - assert scores["East-West"] == 0 - - def test_slam_failed_redoubled_defender_scores_2000(self, players): - contract = Contract( - ContractBid(players["N"], SlamLevel.SLAM, Suit.SPADES), - double_player=players["E"], - redouble_player=players["N"], - ) - winners = ["N"] * 7 + ["W"] - round_ = _slam_round(players, contract=contract, trick_winners=winners) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 0 - assert scores["East-West"] == 2000 - - def test_slam_team_partner_wins_a_trick_still_makes(self, players): - """Plain Slam only cares about the TEAM winning all 8. The - partner taking some tricks is fine — that's the Solo Slam - rule, not Slam.""" - contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) - # N takes 5, partner S takes 3 → team owns all 8 → contract made. - winners = ["N"] * 5 + ["S"] * 3 - round_ = _slam_round(players, contract=contract, trick_winners=winners) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 500 - assert scores["East-West"] == 0 - - -class TestSoloSlamScoring: - """Bidder-personally rule + 1000 / 2000 / 4000 symmetric grid.""" - - def test_solo_slam_made_bidder_takes_all_8(self, players): - contract = _contract(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES) - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 1000 - assert scores["East-West"] == 0 - - def test_solo_slam_failed_when_partner_takes_a_trick(self, players): - """Key Solo Slam invariant: team owning all 8 tricks is NOT - enough — the bidder personally must win them all.""" - contract = _contract(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES) - winners = ["N"] * 7 + ["S"] # partner wins the last trick - round_ = _slam_round(players, contract=contract, trick_winners=winners) - scores = round_.calculate_round_scores() - # Team took all 8 tricks, but partner won one → Solo Slam fails. - # Defenders score the at-risk amount. - assert scores["North-South"] == 0 - assert scores["East-West"] == 1000 - - def test_solo_slam_failed_when_opponent_takes_a_trick(self, players): - contract = _contract(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES) - winners = ["N"] * 7 + ["W"] - round_ = _slam_round(players, contract=contract, trick_winners=winners) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 0 - assert scores["East-West"] == 1000 - - def test_solo_slam_made_doubled_scores_2000(self, players): - contract = Contract( - ContractBid(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES), - double_player=players["E"], - ) - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 2000 - assert scores["East-West"] == 0 - - def test_solo_slam_made_redoubled_scores_4000(self, players): - contract = Contract( - ContractBid(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES), - double_player=players["E"], - redouble_player=players["N"], - ) - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 4000 - assert scores["East-West"] == 0 - - def test_solo_slam_failed_redoubled_defender_scores_4000(self, players): - contract = Contract( - ContractBid(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES), - double_player=players["E"], - redouble_player=players["N"], - ) - winners = ["N"] * 7 + ["S"] # partner steals one → Solo Slam fails - round_ = _slam_round(players, contract=contract, trick_winners=winners) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 0 - assert scores["East-West"] == 4000 - - -class TestSlamFamilyBeloteLayering: - """Belote (+20) applies on top of the Slam grid for whichever team - *holds* the K + Q of trump, independent of who wins the contract.""" - - def test_slam_made_belote_to_attacker(self, players): - """Slam made, attacker holds belote → 500 + 20 to attacker.""" - contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - round_.belote_holder = players["N"] # N-S holds K+Q of trump - scores = round_.calculate_round_scores() - assert scores["North-South"] == 520 # 500 + 20 - assert scores["East-West"] == 0 - - def test_slam_failed_belote_to_defender(self, players): - """Slam failed, defender holds belote → 500 + 20 to defender.""" - contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) - winners = ["N"] * 7 + ["W"] - round_ = _slam_round(players, contract=contract, trick_winners=winners) - round_.belote_holder = players["W"] # E-W holds K+Q of trump - scores = round_.calculate_round_scores() - assert scores["North-South"] == 0 - assert scores["East-West"] == 520 # 500 + 20 - - def test_slam_failed_belote_to_attacker_independent_of_contract( - self, players - ): - """Belote is independent of contract outcome: attacker can hold - belote even when they lost the contract → defender scores 500, - attacker still scores +20.""" - contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) - winners = ["N"] * 7 + ["W"] - round_ = _slam_round(players, contract=contract, trick_winners=winners) - round_.belote_holder = players["N"] # attacker holds belote - scores = round_.calculate_round_scores() - # Attacker still gets +20 from belote even though the contract failed. - assert scores["North-South"] == 20 - assert scores["East-West"] == 500 - - -class TestNumericContractScoringRegression: - """Confirms numeric (80–180) contracts are *not* affected by the - Slam-family branch added during this refactor.""" - - @staticmethod - def _trick_with_card(seat_player, card): - trick = Trick() - trick.add_play(seat_player, card) - return trick - - def test_numeric_made_normal_uses_base_plus_card_points(self, players): - """80 made by N-S without double, and *not* a capot: attacker = - 80 + card points, defender = its own card points. Trump = clubs; - the bidder plays the trump Jack (20 pts) in seven tricks while - E-W steal one 0-point trick — so the plain made formula, not the - unannounced-capot substitute, is the path under test.""" - contract = _contract(players["N"], 80, Suit.CLUBS) - order = [players[s] for s in ("N", "E", "S", "W")] - round_ = Round( - order, dealer=players["N"], deck=None, round_number=1 - ) - round_.contract = contract - # Seven tricks where N plays the trump Jack solo — 20 pts each. - # (Card identity is fine — Card doesn't have unique-per-instance - # invariants we care about for scoring.) - for _ in range(7): - trick = self._trick_with_card( - players["N"], Card(Suit.CLUBS, Rank.JACK) - ) - round_.tricks.append(trick) - round_.team_tricks["North-South"].append(trick) - # E-W steal a single 0-point trick so N-S did not sweep all 8. - ew_trick = self._trick_with_card( - players["E"], Card(Suit.HEARTS, Rank.SEVEN) - ) - round_.tricks.append(ew_trick) - round_.team_tricks["East-West"].append(ew_trick) - round_.last_trick_winner = players["N"] - scores = round_.calculate_round_scores() - # Card points = 20*7 = 140; dix de der = +10 → 150 card pts. - # Contract made (150 >= 80) → attacker score = 80 + 150 = 230. - assert round_.unannounced_capot is None - assert scores["North-South"] == 230 - # E-W captured a single 0-point trick → 0 card points. - assert scores["East-West"] == 0 - - def test_numeric_failed_normal_defender_gets_160_plus_base(self, players): - """Failed 80 contract by N-S: defender gets (160 + 80) * 1 = 240.""" - contract = _contract(players["N"], 80, Suit.CLUBS) - # 0 tricks to N — contract fails immediately on points (0 < 80). - round_ = _slam_round( - players, contract=contract, trick_winners=["E"] * 8 - ) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 0 - assert scores["East-West"] == 240 - - -# --------------------------------------------------------------------------- -# Numeric scoring — belote attribution & doubled (winner-takes-all) -# --------------------------------------------------------------------------- -# -# These build a Round directly and stuff ``team_tricks`` with synthesised -# tricks. Scoring only sums ``card.get_points(trump)`` over each team's -# tricks, so the trick *shape* (how many cards, who else played) is -# irrelevant — we can pack all of a team's point-carrying cards into a -# single Trick. Trump = hearts throughout, where the trump-aware values -# are J=20, 9=14, A=11, 10=10, K=4, Q=3, 8=7=0. - - -def _numeric_round( - players_dict, - *, - contract, - team_cards, - last_trick_winner=None, - belote_holder=None, -): - """Build a numeric-contract Round with synthesised tricks. - - Args: - players_dict: the ``players`` fixture (seat → Player). - contract: a numeric Contract bound to one of the players. - team_cards: mapping team-name → list of ``(seat, Card)`` plays. - Each team's cards are packed into Tricks of up to four cards - (the Trick capacity), all credited to that team. - last_trick_winner: seat letter credited with the dix de der, or - None. - belote_holder: seat letter holding K + Q of trump, or None. - - Returns: - Round with ``contract``, ``tricks``, ``team_tricks``, - ``last_trick_winner`` and ``belote_holder`` populated. - """ - order = [players_dict[s] for s in ("N", "E", "S", "W")] - round_ = Round(order, dealer=players_dict["N"], deck=None, round_number=1) - round_.contract = contract - for team_name, plays in team_cards.items(): - # Trick holds at most four cards — chunk the team's plays so the - # synthesised pile spans as many tricks as needed. - for start in range(0, len(plays), 4): - trick = Trick() - for seat, card in plays[start:start + 4]: - trick.add_play(players_dict[seat], card) - round_.tricks.append(trick) - round_.team_tricks[team_name].append(trick) - if last_trick_winner is not None: - round_.last_trick_winner = players_dict[last_trick_winner] - if belote_holder is not None: - round_.belote_holder = players_dict[belote_holder] - return round_ - - -class TestNumericBeloteByHolder: - """Belote follows the *holder* of K + Q of trump, never the team that - merely captures those cards in a trick. This is the Problem-1 - regression: a phantom capture-based +20 used to flip a failed - contract into a spurious "made".""" - - # All eight hearts = 62 trump-aware points, including both K and Q. - _HEART_RANKS = ( - Rank.JACK, Rank.NINE, Rank.ACE, Rank.TEN, - Rank.KING, Rank.QUEEN, Rank.EIGHT, Rank.SEVEN, - ) - - def _all_hearts_for(self, seat): - return [(seat, Card(Suit.HEARTS, r)) for r in self._HEART_RANKS] - - def test_captured_kq_without_holder_does_not_make_contract(self, players): - """E-W capture all hearts (incl. K+Q, 62 pts) but no single - player *holds* the pair → no belote. Bare 62 < 80 → the contract - FAILS. Under the old capture-based rule the phantom +20 would - have lifted 62→82 and "made" the 80 contract — the bug behind - the impossible recap.""" - contract = _contract(players["E"], 80, Suit.HEARTS) - round_ = _numeric_round( - players, - contract=contract, - team_cards={ - "East-West": self._all_hearts_for("E"), - "North-South": [], - }, - last_trick_winner="N", # der to N-S, not the declarer - belote_holder=None, # pair is split — nobody holds it - ) - scores = round_.calculate_round_scores() - assert round_.contract_made is False - assert scores["East-West"] == 0 - assert scores["North-South"] == 240 # 160 + 80 - - def test_belote_credited_to_holder_even_if_opponent_captures(self, players): - """E-W capture the K+Q in their tricks, but S (N-S) *held* the - pair → the +20 belote is credited to N-S, the holder, not E-W.""" - contract = _contract(players["E"], 80, Suit.HEARTS) - round_ = _numeric_round( - players, - contract=contract, - team_cards={ - "East-West": self._all_hearts_for("E"), - "North-South": [], - }, - last_trick_winner="N", - belote_holder="S", # N-S holds the pair - ) - scores = round_.calculate_round_scores() - # Declarer E-W realized 62 < 80 → failed → 0. - assert scores["East-West"] == 0 - # Defender N-S: 160 + 80 (winner-takes-all, M=1) + 20 belote. - assert scores["North-South"] == 260 - - def test_failed_declarer_keeps_only_its_belote(self, players): - """A failed declarer keeps its belote bonus (always preserved) - and nothing else.""" - contract = _contract(players["E"], 80, Suit.HEARTS) - round_ = _numeric_round( - players, - contract=contract, - team_cards={ - "East-West": [ - ("E", Card(Suit.HEARTS, Rank.KING)), - ("E", Card(Suit.HEARTS, Rank.QUEEN)), - ], - "North-South": [], - }, - last_trick_winner="N", - belote_holder="E", # declarer holds the pair - ) - scores = round_.calculate_round_scores() - # E-W realized = 7 cards + 20 belote = 27 < 80 → failed. - assert round_.contract_made is False - assert scores["East-West"] == 20 # belote only - assert scores["North-South"] == 240 # 160 + 80 - - -class TestNumericDoubledScoring: - """Doubled / redoubled numeric contracts: winner-takes-all, the loser - scores 0 except its belote. The winner amount is 160 + C×M whether it - is the made declarer or the winning defense.""" - - @staticmethod - def _ns_big_pile(): - """76 trump-aware points for N-S — clears an 80 contract once the - dix de der is added.""" - return [ - ("N", Card(Suit.HEARTS, Rank.JACK)), # 20 - ("N", Card(Suit.HEARTS, Rank.NINE)), # 14 - ("N", Card(Suit.HEARTS, Rank.ACE)), # 11 - ("N", Card(Suit.HEARTS, Rank.TEN)), # 10 - ("S", Card(Suit.SPADES, Rank.ACE)), # 11 - ("S", Card(Suit.SPADES, Rank.TEN)), # 10 - ] - - def test_doubled_made_defender_scores_zero(self, players): - """Doubled contract made: the defending side scores 0 even though - it captured point-carrying cards (Problem 2).""" - contract = Contract( - ContractBid(players["N"], 80, Suit.HEARTS), - double_player=players["E"], - ) - round_ = _numeric_round( - players, - contract=contract, - team_cards={ - "North-South": self._ns_big_pile(), - # E-W win a fat trick — under the old rule they'd keep - # these 14 points; winner-takes-all zeroes them. - "East-West": [ - ("E", Card(Suit.DIAMONDS, Rank.TEN)), # 10 - ("E", Card(Suit.CLUBS, Rank.KING)), # 4 - ], - }, - last_trick_winner="N", # +10 der → N-S realized 86 ≥ 80 - ) - scores = round_.calculate_round_scores() - assert round_.contract_made is True - assert scores["North-South"] == 320 # 160 + 80*2 - assert scores["East-West"] == 0 - - def test_doubled_made_defender_keeps_only_belote(self, players): - """The lone exception: the losing defender keeps its belote.""" - contract = Contract( - ContractBid(players["N"], 80, Suit.HEARTS), - double_player=players["E"], - ) - round_ = _numeric_round( - players, - contract=contract, - team_cards={ - "North-South": self._ns_big_pile(), - "East-West": [("E", Card(Suit.CLUBS, Rank.KING))], - }, - last_trick_winner="N", - belote_holder="E", # E-W (defender) holds the pair - ) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 320 # 160 + 80*2 - assert scores["East-West"] == 20 # belote only - - def test_doubled_failed_winner_takes_160_plus_cm(self, players): - """Doubled contract failed: the defense takes 160 + C×M, declarer 0.""" - contract = Contract( - ContractBid(players["N"], 100, Suit.HEARTS), - double_player=players["E"], - ) - round_ = _numeric_round( - players, - contract=contract, - team_cards={ - "North-South": [("N", Card(Suit.DIAMONDS, Rank.TEN))], # 10 < 100 - "East-West": [("E", Card(Suit.HEARTS, Rank.JACK))], - }, - last_trick_winner="E", - ) - scores = round_.calculate_round_scores() - assert round_.contract_made is False - assert scores["North-South"] == 0 - assert scores["East-West"] == 360 # 160 + 100*2 - - def test_redoubled_failed_winner_takes_160_plus_c_times_four(self, players): - """Redoubled failed: the defense takes 160 + C×4 — the same shape - as a made redoubled declarer (symmetric stake).""" - contract = Contract( - ContractBid(players["N"], 100, Suit.HEARTS), - double_player=players["E"], - redouble_player=players["N"], - ) - round_ = _numeric_round( - players, - contract=contract, - team_cards={ - "North-South": [("N", Card(Suit.DIAMONDS, Rank.TEN))], - "East-West": [("E", Card(Suit.HEARTS, Rank.JACK))], - }, - last_trick_winner="E", - ) - scores = round_.calculate_round_scores() - assert scores["North-South"] == 0 - assert scores["East-West"] == 560 # 160 + 100*4 - - -# --------------------------------------------------------------------------- -# Unannounced capot scoring (calculate_round_scores) -# --------------------------------------------------------------------------- -# -# When the declaring team wins all 8 tricks on an *un-doubled* numeric -# contract without having bid a Slam, the 162-point pile (152 cards + 10 -# dix de der) is replaced by a flat 250 substitute: the declarer scores -# contract value + 250 (+ belote), the defence scores nothing, and the -# contract is necessarily made. The round is flagged UnannouncedSlam.GRAND_SLAM -# when the contracting player personally won all 8 tricks, else -# UnannouncedSlam.SLAM. A doubled/redoubled sweep keeps the winner-takes-all -# 160 + C×M shape, and a defence sweep is unaffected (declaring team only). - - -class TestUnannouncedSlamEnum: - """The UnannouncedSlam member value is its display label.""" - - def test_member_labels_via_str(self): - assert str(UnannouncedSlam.SLAM) == "Slam" - assert str(UnannouncedSlam.GRAND_SLAM) == "Grand Slam" - - -class TestUnannouncedSlamScoring: - """Un-doubled numeric sweep by the declaring team → contract + 250.""" - - def test_team_sweep_scores_contract_plus_250(self, players): - """N takes 5, partner S takes 3 → the *team* swept (but no single - player did) → UnannouncedSlam.SLAM, scored 100 + 250.""" - contract = _contract(players["N"], 100, Suit.SPADES) - winners = ["N"] * 5 + ["S"] * 3 - round_ = _slam_round(players, contract=contract, trick_winners=winners) - scores = round_.calculate_round_scores() - assert round_.unannounced_capot is UnannouncedSlam.SLAM - assert round_.contract_made is True - assert scores["North-South"] == 350 # 100 + 250 - assert scores["East-West"] == 0 - - def test_bidder_personal_sweep_is_grand_slam(self, players): - """N wins all 8 personally → UnannouncedSlam.GRAND_SLAM (same 250 substitute).""" - contract = _contract(players["N"], 100, Suit.SPADES) - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - scores = round_.calculate_round_scores() - assert round_.unannounced_capot is UnannouncedSlam.GRAND_SLAM - assert scores["North-South"] == 350 # 100 + 250 - assert scores["East-West"] == 0 - - def test_capot_forces_made_below_threshold(self, players): - """The filler tricks carry 0 card points, so a 180 contract could - never clear its threshold on cards — but sweeping every trick - makes it outright → 180 + 250 = 430.""" - contract = _contract(players["N"], 180, Suit.SPADES) - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - scores = round_.calculate_round_scores() - assert round_.contract_made is True - assert scores["North-South"] == 430 # 180 + 250 - assert scores["East-West"] == 0 - - def test_capot_layers_belote_on_top(self, players): - """Belote (+20) still credits the holder on top of contract + 250.""" - contract = _contract(players["N"], 100, Suit.SPADES) - winners = ["N"] * 5 + ["S"] * 3 - round_ = _slam_round(players, contract=contract, trick_winners=winners) - round_.belote_holder = players["N"] # N-S holds K+Q of trump - scores = round_.calculate_round_scores() - assert scores["North-South"] == 370 # 100 + 250 + 20 - assert scores["East-West"] == 0 - - def test_doubled_sweep_keeps_winner_takes_all_and_is_unflagged(self, players): - """A doubled contract swept by the declarer keeps the - winner-takes-all 160 + C×M shape — no 250 substitute, no flag.""" - contract = Contract( - ContractBid(players["N"], 100, Suit.SPADES), - double_player=players["E"], - ) - order = [players[s] for s in ("N", "E", "S", "W")] - round_ = Round(order, dealer=players["N"], deck=None, round_number=1) - round_.contract = contract - # N sweeps all 8 with the trump Jack (20 pts each → 160 card - # points, clearing the 100 threshold). Card identity is - # irrelevant to scoring, so the same Card may recur. - for _ in range(8): - trick = Trick() - trick.add_play(players["N"], Card(Suit.SPADES, Rank.JACK)) - round_.tricks.append(trick) - round_.team_tricks["North-South"].append(trick) - round_.last_trick_winner = players["N"] - scores = round_.calculate_round_scores() - assert round_.unannounced_capot is None - assert round_.contract_made is True - assert scores["North-South"] == 360 # 160 + 100*2 - assert scores["East-West"] == 0 - - def test_defense_sweep_is_not_a_capot(self, players): - """Declaring team only: when the *defence* sweeps, the declarer - simply fails (160 + C to the defence) — no 250, not flagged.""" - contract = _contract(players["E"], 100, Suit.SPADES) # E-W declares - round_ = _slam_round( - players, contract=contract, trick_winners=["N"] * 8 - ) - scores = round_.calculate_round_scores() - assert round_.unannounced_capot is None - assert round_.contract_made is False - assert scores["East-West"] == 0 - assert scores["North-South"] == 260 # 160 + 100 (normal failed) diff --git a/packages/contrai-engine/tests/test_model/test_round_lifecycle.py b/packages/contrai-engine/tests/test_model/test_round_lifecycle.py new file mode 100644 index 0000000..56e51a3 --- /dev/null +++ b/packages/contrai-engine/tests/test_model/test_round_lifecycle.py @@ -0,0 +1,459 @@ +"""End-to-end coverage of the full ``Round`` lifecycle. + +``deal_cards`` -> ``manage_bidding`` -> ``play_all_tricks`` -> +``calculate_round_scores`` (or ``handle_failed_contract`` on an all-pass) +is exercised only in fragments elsewhere (``test_round.py`` drives the +play-state loop from hand-built mid-round state; ``test_round_scoring.py`` +feeds ``score_round`` synthesised tricks directly). This file drives the +whole path from a *dealt* deck through to a scored round, so the pieces are +proven to actually fit together. + +Every scenario is a **fully stacked deck** — no shuffling, no RNG — built +backwards from the hands each seat needs to hold, via :func:`_stack_deck`. +Four :class:`AiPlayer` seats and no view, so every view-dependent branch in +``Round`` takes its ``None`` path, and the bidding/card-play come from +:class:`RuleBasedBiddingStrategy` / :class:`RuleBasedCardPlayStrategy` +(``AiPlayer``'s defaults) end to end. + +The happy-path and belote scenarios pin the resulting score with a mix of +a rule-derived invariant (the exact total ``score_round`` guarantees for a +*made*, un-doubled, non-sweep numeric contract: ``contract_value + 162``, +plus 20 when a team holds the belote) and a regression pin on the concrete +per-team split. The bidding table's outcome is derived by hand in the +docstrings below and asserted exactly; the card-by-card play is a +deterministic function of the stacked hands (see ``rule_based/card_play.py``'s +``RuleBasedCardPlayStrategy``), but tracing that decision tree by hand +across 8 tricks is impractical — the concrete numbers are pinned from an +actual (deterministic, reproducible) run and guarded by the invariant, so +a scoring-rule regression still fails loudly even if the exact split ever +drifts. +""" + +from __future__ import annotations + +from contrai_core.card import Card +from contrai_core.deck import Deck +from contrai_core.types import Rank, Suit + +from contrai_engine.model.round import Round + + +def _stack_deck(hands: dict[str, list[Card]]) -> Deck: + """Build a ``Deck`` whose ``deal()`` reproduces exactly ``hands``. + + ``Deck.deal`` hands out cards in a fixed 3-2-3 pattern per seat, each + seat's three batches read from three *disjoint* slices of + ``deck.cards`` (see ``Deck.deal``): for seat index ``i`` (0=N, 1=E, + 2=S, 3=W, matching the N/E/S/W seating order the tests below use) + the batches are ``cards[i*3:i*3+3]``, ``cards[i*2+12:i*2+14]``, and + ``cards[i*3+20:i*3+23]``. This helper inverts that layout: given the + 8-card hand each seat should end up holding (in the desired final + order), it writes each hand's cards into the matching three slots so + ``deck.deal([N, E, S, W])`` reconstructs those exact hands. + + Args: + hands: Mapping of seat letter ("N"/"E"/"S"/"W") to the 8-card + hand that seat should be dealt, in the exact order the dealt + hand should hold them. + + Returns: + A fresh ``Deck`` stacked for the inverse deal. ``deal()`` still + performs its own 32-card / 4-player validation, so a malformed + ``hands`` mapping fails there if it slips past the assertions + below. + """ + + seats = ("N", "E", "S", "W") + deck_cards: list[Card | None] = [None] * 32 + for i, seat in enumerate(seats): + hand = hands[seat] + assert len(hand) == 8, f"seat {seat} needs exactly 8 cards, got {len(hand)}" + batch1, batch2, batch3 = hand[0:3], hand[3:5], hand[5:8] + deck_cards[i * 3 : i * 3 + 3] = batch1 + deck_cards[i * 2 + 12 : i * 2 + 14] = batch2 + deck_cards[i * 3 + 20 : i * 3 + 23] = batch3 + + assert all(card is not None for card in deck_cards), "every slot must be filled" + assert len(set(deck_cards)) == 32, "a stacked deck must hold 32 distinct cards" + + deck = Deck() + deck.cards = deck_cards + return deck + + +# --------------------------------------------------------------------------- +# Happy path: one stacked deal, a numeric contract reached and made. +# --------------------------------------------------------------------------- +# +# Hand design (worked by hand against ``RuleBasedBiddingStrategy``'s +# ``BIDDING_TABLE`` in ``rule_based/bidding.py``): +# +# N: Spade J, 9, 7 (3 trumps, both J and 9 - the two best trumps); +# Hearts A, 10 and Diamonds A, 10 (2 external aces, each with a +# suit-mate so the "ten with support" bonus fires); Clubs 7 filler. +# -> trump strength = 2 (jack_and_nine) + (3-3+0) = 2. +# -> estimated tricks = 2 (trump) + 1+1 (Hearts A, 10) + 1+1 +# (Diamonds A, 10) = 6. +# Walking the table: 80/90 clear (aces>=1, tricks>=4); 100/110 clear +# (aces>=2, tricks>=5); 120/130 need aces>=3 - N only has 2 -> stop. +# Best reachable = 110 Spades. No other suit has >=3 cards, so every +# other suit evaluates to 0 and can't compete. +# S (N's partner): Spade 8, 10, A (3 more trumps, but neither the J nor +# the 9 - S's own spade evaluation never clears row 80's +# ``jack_or_nine`` gate) plus Clubs 8/9, Hearts 7, Diamonds 7/8 - no +# ace outside spades anywhere in S's hand. ``_support_partner_bid``'s +# contribution (external ace +10, trump J/9 complement +10) is +# therefore 0, so S passes instead of raising N's bid. +# E, W: hold no spades at all (N + S account for all 8 trumps between +# them) and few enough aces/tens that both +# ``_should_double`` (needs estimated trump tricks * 20 > 162-110 = +# 52, i.e. >=3 estimated tricks) and their own opening bid stay +# under threshold. Verified by the deal-order bidding assertions +# below; a stacking mistake big enough to change this would flip +# ``contract`` away from N/110/Spades and fail loudly there. +# +# N/S hold 6 of the 8 trumps (missing only the Queen and King, the two +# lowest-ranked trumps - see ``Card.TRUMP_ORDER``), so the contract is +# expected to be comfortably made; the exact card-by-card play (and hence +# the precise score split) is pinned from an actual run - see the module +# docstring. + + +class TestFullRoundLifecycleHappyPath: + """Deal -> bid -> play -> score, all the way through, contract made.""" + + HANDS = { + "N": [ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.NINE), + Card(Suit.SPADES, Rank.SEVEN), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.HEARTS, Rank.TEN), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.TEN), + Card(Suit.CLUBS, Rank.SEVEN), + ], + "E": [ + Card(Suit.SPADES, Rank.KING), + Card(Suit.HEARTS, Rank.EIGHT), + Card(Suit.HEARTS, Rank.NINE), + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.QUEEN), + Card(Suit.DIAMONDS, Rank.NINE), + Card(Suit.CLUBS, Rank.TEN), + Card(Suit.CLUBS, Rank.JACK), + ], + "S": [ + Card(Suit.SPADES, Rank.EIGHT), + Card(Suit.SPADES, Rank.TEN), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.CLUBS, Rank.EIGHT), + Card(Suit.CLUBS, Rank.NINE), + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.DIAMONDS, Rank.SEVEN), + Card(Suit.DIAMONDS, Rank.EIGHT), + ], + "W": [ + Card(Suit.SPADES, Rank.QUEEN), + Card(Suit.HEARTS, Rank.KING), + Card(Suit.DIAMONDS, Rank.JACK), + Card(Suit.DIAMONDS, Rank.QUEEN), + Card(Suit.DIAMONDS, Rank.KING), + Card(Suit.CLUBS, Rank.QUEEN), + Card(Suit.CLUBS, Rank.KING), + Card(Suit.CLUBS, Rank.ACE), + ], + } + + def test_full_lifecycle_reaches_a_made_contract(self, players): + order = [players[s] for s in ("N", "E", "S", "W")] + round_ = Round( + order, dealer=players["W"], deck=_stack_deck(self.HANDS), round_number=1 + ) + + # --- Deal --------------------------------------------------- + round_.deal_cards() + # RED guard: a mis-stacked deck fails loudly right here, before + # bidding or play ever runs on the wrong hands. + for seat, expected in self.HANDS.items(): + assert list(players[seat].hand) == expected, f"seat {seat} mis-dealt" + assert round_.deck.cards == [] # the deck hands out every card + + # --- Bidding -------------------------------------------------- + contract = round_.manage_bidding(None) + + assert contract is not None, "stacked hand failed to open the bidding" + assert contract.player is players["N"] + assert contract.suit == Suit.SPADES + assert contract.value == 110 + assert contract.double is False + # No single seat holds both King and Queen of trump in this deal + # (King -> E, Queen -> W) - the happy path is deliberately + # belote-free so its score reduces to the plain numeric formula. + assert round_.belote_holder is None + + # --- Play ------------------------------------------------------- + team_tricks = round_.play_all_tricks(None) + + assert round_.play_state.is_terminal() + assert len(round_.play_state.completed_tricks) == 8 + assert len(round_.tricks) == 8 + assert sum(len(tricks) for tricks in team_tricks.values()) == 8 + for seat in ("N", "E", "S", "W"): + assert len(players[seat].hand) == 0 + # The trick-return ritual: every played card lands back in the + # deck as each trick completes (see ``Round.play_trick``), so by + # the time all 8 tricks are done the deck is whole again. + assert len(round_.deck.cards) == 32 + assert len(set(round_.deck.cards)) == 32 + + # --- Scoring ------------------------------------------------ + scores = round_.calculate_round_scores() + + assert round_.contract_made is True + assert round_.unannounced_slam is None + # Rule-derived invariant (scoring.py): a made, un-doubled numeric + # contract with no unannounced Slam always has both scores sum to + # contract_value + 162 (152 card points + the 10-point + # last-trick bonus) - the two teams simply split the one pile, + # and no belote + # is in play here. + assert sum(scores.values()) == contract.value + 162 + assert scores["North-South"] > scores["East-West"] + # Regression pin: the concrete split observed from this exact + # stacked deal (deterministic - no RNG is reachable from either + # strategy). Re-run twice to confirm before trusting a change to + # these numbers reflects a real scoring-rule change and not a + # stacking edit. + assert scores == {"North-South": 259, "East-West": 13} + + +# --------------------------------------------------------------------------- +# Belote: King + Queen of trump concentrated in one hand. +# --------------------------------------------------------------------------- +# +# Same shape as the happy path (N opens 110 Spades, S support-passes, E/W +# pass), but N's hand now also holds the Queen and King of trump, so N/S +# hold every trump (8 of 8) and the Belote/Rebelote pair besides: +# +# N: Spade J, Q, K, 9, 7 (5 trumps, both J and 9, plus the pair) + +# Hearts A + Diamonds A + Clubs 7. +# -> trump strength = 2 (jack_and_nine) + (5-3+0) = 4. +# -> estimated tricks = 4 (trump) + 1 (Hearts A) + 1 (Diamonds A) +# = 6. +# Table walk: 80/90 clear; 100/110 clear (aces>=2, tricks>=5); +# 120/130 need aces>=3 (N has 2) -> stop; 140/150/160 additionally +# need aces>=3 too, so ``has_belote`` alone doesn't unlock them. +# Best reachable = 110 Spades, same value as the happy path but for +# a different reason (extra trump length standing in for external +# aces on the ceiling rows). +# S: Spade 8, 10, A - the 3 remaining trumps - plus Hearts 7/8, +# Diamonds 7/8, Clubs 8: no external ace, so (as in the happy path) +# the support contribution is 0 and S passes. +# E, W: no spades at all; kept below both the double threshold (needs +# estimated trump tricks * 20 > 52) and their own opening threshold, +# verified below. + + +class TestFullRoundLifecycleBelote: + """A second stacked deal: the King + Queen of trump share a hand.""" + + HANDS = { + "N": [ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.QUEEN), + Card(Suit.SPADES, Rank.KING), + Card(Suit.SPADES, Rank.NINE), + Card(Suit.SPADES, Rank.SEVEN), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.SEVEN), + ], + "E": [ + Card(Suit.HEARTS, Rank.NINE), + Card(Suit.HEARTS, Rank.TEN), + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.QUEEN), + Card(Suit.DIAMONDS, Rank.NINE), + Card(Suit.DIAMONDS, Rank.JACK), + Card(Suit.CLUBS, Rank.NINE), + Card(Suit.CLUBS, Rank.TEN), + ], + "S": [ + Card(Suit.SPADES, Rank.EIGHT), + Card(Suit.SPADES, Rank.TEN), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.HEARTS, Rank.EIGHT), + Card(Suit.DIAMONDS, Rank.SEVEN), + Card(Suit.DIAMONDS, Rank.EIGHT), + Card(Suit.CLUBS, Rank.EIGHT), + ], + "W": [ + Card(Suit.HEARTS, Rank.KING), + Card(Suit.DIAMONDS, Rank.TEN), + Card(Suit.DIAMONDS, Rank.QUEEN), + Card(Suit.DIAMONDS, Rank.KING), + Card(Suit.CLUBS, Rank.JACK), + Card(Suit.CLUBS, Rank.QUEEN), + Card(Suit.CLUBS, Rank.KING), + Card(Suit.CLUBS, Rank.ACE), + ], + } + + def test_full_lifecycle_tracks_belote_holder_and_bonus(self, players): + order = [players[s] for s in ("N", "E", "S", "W")] + round_ = Round( + order, dealer=players["W"], deck=_stack_deck(self.HANDS), round_number=1 + ) + + # --- Deal --------------------------------------------------- + round_.deal_cards() + for seat, expected in self.HANDS.items(): + assert list(players[seat].hand) == expected, f"seat {seat} mis-dealt" + + # --- Bidding -------------------------------------------------- + contract = round_.manage_bidding(None) + + assert contract is not None, "stacked hand failed to open the bidding" + assert contract.player is players["N"] + assert contract.suit == Suit.SPADES + assert contract.value == 110 + assert contract.double is False + + # N holds both King and Queen of trump at deal time - the belote + # holder is snapshotted as soon as the contract is established. + assert round_.belote_holder is players["N"] + # Nothing has been played yet, so neither leg of the + # belote/rebelote announcement has fired. + assert round_.belote_state == {} + + # --- Play ------------------------------------------------------- + round_.play_all_tricks(None) + + assert round_.play_state.is_terminal() + assert len(round_.tricks) == 8 + for seat in ("N", "E", "S", "W"): + assert len(players[seat].hand) == 0 + + # By the end of the round N has necessarily played every card, + # King and Queen of trump included, so the belote state machine + # must have advanced all the way to "rebelote" for N. + assert round_.belote_state == {players["N"]: "rebelote"} + + # --- Scoring ------------------------------------------------ + scores = round_.calculate_round_scores() + + assert round_.contract_made is True + # Rule-derived invariant: made, un-doubled, non-sweep numeric + # contract sums to contract_value + 162, *plus* the 20-point + # belote bonus layered on top of the pile split (scoring.py + # credits it to the holder's team independent of who wins the + # round or which cards capture the K/Q). + assert sum(scores.values()) == contract.value + 162 + 20 + assert scores["North-South"] > scores["East-West"] + # Regression pin: the concrete split observed from this exact + # stacked deal (deterministic - no RNG is reachable). + assert scores == {"North-South": 278, "East-West": 14} + + +# --------------------------------------------------------------------------- +# All-pass: every seat too weak to open, no contract, redeal-ready state. +# --------------------------------------------------------------------------- +# +# Every seat holds exactly two cards of each suit. ``BIDDING_TABLE``'s most +# lenient row (80) already requires ``trump_min=3``, so a suit no seat ever +# holds more than 2 of can never clear a single row - every seat's +# ``_evaluate_suit_as_trump`` returns ``contract=0`` for all four suits +# regardless of rank quality, and ``_choose_open_bid`` falls through to +# ``PassBid``. The four Aces, Jacks, Kings, and Nines are additionally +# spread one per seat per suit (no seat holds a suit's Ace together with +# its Ten, and no seat holds a suit's Jack together with its Nine) so no +# seat's estimated-tricks total can accidentally reach the ``tricks_min=8`` +# Slam/Solo-Slam gate either (whose ``trump_min=0`` is the one row this +# deal's low trump counts don't rule out on their own). + + +class TestFullRoundLifecycleAllPass: + """A third stacked deal: four hands too weak for anyone to open.""" + + HANDS = { + "N": [ + Card(Suit.SPADES, Rank.SEVEN), + Card(Suit.SPADES, Rank.TEN), + Card(Suit.HEARTS, Rank.EIGHT), + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.DIAMONDS, Rank.NINE), + Card(Suit.DIAMONDS, Rank.QUEEN), + Card(Suit.CLUBS, Rank.TEN), + Card(Suit.CLUBS, Rank.KING), + ], + "E": [ + Card(Suit.SPADES, Rank.EIGHT), + Card(Suit.SPADES, Rank.JACK), + Card(Suit.HEARTS, Rank.NINE), + Card(Suit.HEARTS, Rank.KING), + Card(Suit.DIAMONDS, Rank.SEVEN), + Card(Suit.DIAMONDS, Rank.TEN), + Card(Suit.CLUBS, Rank.JACK), + Card(Suit.CLUBS, Rank.QUEEN), + ], + "S": [ + Card(Suit.SPADES, Rank.NINE), + Card(Suit.SPADES, Rank.KING), + Card(Suit.HEARTS, Rank.TEN), + Card(Suit.HEARTS, Rank.QUEEN), + Card(Suit.DIAMONDS, Rank.EIGHT), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.SEVEN), + Card(Suit.CLUBS, Rank.NINE), + ], + "W": [ + Card(Suit.SPADES, Rank.QUEEN), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.JACK), + Card(Suit.DIAMONDS, Rank.KING), + Card(Suit.CLUBS, Rank.EIGHT), + Card(Suit.CLUBS, Rank.ACE), + ], + } + + def test_all_pass_leaves_the_round_redeal_ready(self, players): + order = [players[s] for s in ("N", "E", "S", "W")] + round_ = Round( + order, dealer=players["W"], deck=_stack_deck(self.HANDS), round_number=1 + ) + + # --- Deal --------------------------------------------------- + round_.deal_cards() + for seat, expected in self.HANDS.items(): + assert list(players[seat].hand) == expected, f"seat {seat} mis-dealt" + assert round_.deck.cards == [] + + # --- Bidding -------------------------------------------------- + contract = round_.manage_bidding(None) + + assert contract is None, "a weak stacked hand still opened the bidding" + assert round_.contract is None + assert round_.belote_holder is None + + # --- Redeal-ready state ----------------------------------------- + scores = round_.handle_failed_contract() + + # Every card returns to the deck (8 per seat x 4 seats). + assert len(round_.deck.cards) == 32 + assert len(set(round_.deck.cards)) == 32 + for seat in ("N", "E", "S", "W"): + assert len(players[seat].hand) == 0 + + # Zero scores for both teams - no contract means nothing was at + # stake, not merely "no score attribute published". + assert scores == {"North-South": 0, "East-West": 0} + assert round_.round_scores == scores + # calculate_round_scores was never called on this path - the + # made/failed and unannounced-Slam signals stay at their + # pre-round default. + assert round_.contract_made is None + assert round_.unannounced_slam is None diff --git a/packages/contrai-engine/tests/test_model/test_round_scoring.py b/packages/contrai-engine/tests/test_model/test_round_scoring.py new file mode 100644 index 0000000..5941162 --- /dev/null +++ b/packages/contrai-engine/tests/test_model/test_round_scoring.py @@ -0,0 +1,720 @@ +"""Tests for round scoring — ``Round.calculate_round_scores`` and the +underlying pure :func:`contrai_engine.model.round.scoring.score_round`. + +The scoring rules come from ``contree-domain.md`` §6.5, §7: the numeric +(80-180) share-the-pile path, the unannounced-Slam 250 substitute, the +doubled/redoubled winner-takes-all path, and the symmetric Slam / Solo +Slam grid — with the Belote (+20) bonus layered onto every shape for the +team *holding* K + Q of trump. + +These build a ``Round`` directly and stuff it with the minimal state the +scoring path reads (``contract`` / ``team_tricks`` / ``tricks`` / +``last_trick_winner`` / ``belote_holder``), then assert on the published +result attributes (``round_scores`` / ``contract_made`` / +``unannounced_slam``). The shared ``players`` fixture lives in +``conftest.py``. +""" + +from __future__ import annotations + +from contrai_core.bid import ContractBid, SlamLevel +from contrai_core.card import Card +from contrai_core.contract import Contract +from contrai_core.trick import Trick +from contrai_core.types import Rank, Suit + +from contrai_engine.model.round import Round, UnannouncedSlam +from contrai_engine.model.round.scoring import RoundScore, score_round + + +def _contract(player, value, suit): + return Contract(ContractBid(player, value, suit)) + + +# --------------------------------------------------------------------------- +# Slam / Solo Slam scoring (calculate_round_scores) +# --------------------------------------------------------------------------- +# +# Tests below build a Round directly and stuff it with the minimal state +# the scoring path reads: +# - ``self.contract`` — drives base / multiplier / family check. +# - ``self.team_tricks`` — number of tricks per team (length used). +# - ``self.tricks`` — per-trick winners (used by Solo Slam). +# - ``self.last_trick_winner``— last-trick bonus (irrelevant for Slam family). +# +# Cards inside each Trick only matter when belote / card points are +# computed; for Slam family they are not — we still seed at least one +# card per trick so :meth:`Trick.get_current_winner` has something to +# answer with. + + +def _slam_round( + players_dict, + *, + contract, + trick_winners, +): + """Build a Round with synthesised tricks. + + Args: + players_dict: the ``players`` fixture (seat → Player). + contract: a Contract bound to one of the players. + trick_winners: ordered list of seat letters — one per completed + trick. Each entry is the player who wins that trick. Cards + are filler (the suit-7), and the winner leads it so + :meth:`Trick.get_current_winner` returns them. + + Returns: + Round with ``contract``, ``tricks``, ``team_tricks``, and + ``last_trick_winner`` populated. + """ + order = [players_dict[s] for s in ("N", "E", "S", "W")] + round_ = Round(order, dealer=players_dict["N"], deck=None, round_number=1) + round_.contract = contract + + # Filler card per trick: a low non-trump card. The winner plays it + # solo so get_current_winner returns them regardless of trump. + filler = Card(Suit.CLUBS, Rank.SEVEN) + for seat in trick_winners: + trick = Trick() + trick.add_play(players_dict[seat], filler) + round_.tricks.append(trick) + winner = players_dict[seat] + if winner.team is not None: + round_.team_tricks[winner.team.name].append(trick) + + if trick_winners: + round_.last_trick_winner = players_dict[trick_winners[-1]] + return round_ + + +class TestScoreRoundResult: + """The pure ``score_round`` returns a ``RoundScore`` without mutating + the round — ``calculate_round_scores`` is the thin publishing wrapper.""" + + def test_score_round_returns_result_without_mutating(self, players): + contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + result = score_round(round_) + assert isinstance(result, RoundScore) + assert result.scores["North-South"] == 500 + assert result.contract_made is True + assert result.unannounced_slam is None + # Pure: the round's result attributes are untouched until the + # wrapper publishes them. + assert round_.round_scores == {} + assert round_.contract_made is None + + def test_wrapper_publishes_result_onto_the_round(self, players): + contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert scores is round_.round_scores + assert round_.contract_made is True + + +class TestSlamScoring: + """Symmetric grid: 500 / 1000 / 2000 to the winning side.""" + + def test_slam_made_normal_attacker_scores_500(self, players): + contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 500 + assert scores["East-West"] == 0 + + def test_slam_failed_normal_defender_scores_500(self, players): + # Attacker (N) takes only 7 tricks; W steals one → contract fails. + contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) + winners = ["N"] * 7 + ["W"] + round_ = _slam_round(players, contract=contract, trick_winners=winners) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 0 + assert scores["East-West"] == 500 + + def test_slam_made_doubled_attacker_scores_1000(self, players): + contract = Contract( + ContractBid(players["N"], SlamLevel.SLAM, Suit.SPADES), + double_player=players["E"], + ) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 1000 + assert scores["East-West"] == 0 + + def test_slam_failed_doubled_defender_scores_1000(self, players): + contract = Contract( + ContractBid(players["N"], SlamLevel.SLAM, Suit.SPADES), + double_player=players["E"], + ) + winners = ["N"] * 6 + ["E", "W"] + round_ = _slam_round(players, contract=contract, trick_winners=winners) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 0 + assert scores["East-West"] == 1000 + + def test_slam_made_redoubled_attacker_scores_2000(self, players): + contract = Contract( + ContractBid(players["N"], SlamLevel.SLAM, Suit.SPADES), + double_player=players["E"], + redouble_player=players["N"], + ) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 2000 + assert scores["East-West"] == 0 + + def test_slam_failed_redoubled_defender_scores_2000(self, players): + contract = Contract( + ContractBid(players["N"], SlamLevel.SLAM, Suit.SPADES), + double_player=players["E"], + redouble_player=players["N"], + ) + winners = ["N"] * 7 + ["W"] + round_ = _slam_round(players, contract=contract, trick_winners=winners) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 0 + assert scores["East-West"] == 2000 + + def test_slam_team_partner_wins_a_trick_still_makes(self, players): + """Plain Slam only cares about the TEAM winning all 8. The + partner taking some tricks is fine — that's the Solo Slam + rule, not Slam.""" + contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) + # N takes 5, partner S takes 3 → team owns all 8 → contract made. + winners = ["N"] * 5 + ["S"] * 3 + round_ = _slam_round(players, contract=contract, trick_winners=winners) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 500 + assert scores["East-West"] == 0 + + +class TestSoloSlamScoring: + """Bidder-personally rule + 1000 / 2000 / 4000 symmetric grid.""" + + def test_solo_slam_made_bidder_takes_all_8(self, players): + contract = _contract(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 1000 + assert scores["East-West"] == 0 + + def test_solo_slam_failed_when_partner_takes_a_trick(self, players): + """Key Solo Slam invariant: team owning all 8 tricks is NOT + enough — the bidder personally must win them all.""" + contract = _contract(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES) + winners = ["N"] * 7 + ["S"] # partner wins the last trick + round_ = _slam_round(players, contract=contract, trick_winners=winners) + scores = round_.calculate_round_scores() + # Team took all 8 tricks, but partner won one → Solo Slam fails. + # Defenders score the at-risk amount. + assert scores["North-South"] == 0 + assert scores["East-West"] == 1000 + + def test_solo_slam_failed_when_opponent_takes_a_trick(self, players): + contract = _contract(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES) + winners = ["N"] * 7 + ["W"] + round_ = _slam_round(players, contract=contract, trick_winners=winners) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 0 + assert scores["East-West"] == 1000 + + def test_solo_slam_made_doubled_scores_2000(self, players): + contract = Contract( + ContractBid(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES), + double_player=players["E"], + ) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 2000 + assert scores["East-West"] == 0 + + def test_solo_slam_made_redoubled_scores_4000(self, players): + contract = Contract( + ContractBid(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES), + double_player=players["E"], + redouble_player=players["N"], + ) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 4000 + assert scores["East-West"] == 0 + + def test_solo_slam_failed_redoubled_defender_scores_4000(self, players): + contract = Contract( + ContractBid(players["N"], SlamLevel.SOLO_SLAM, Suit.SPADES), + double_player=players["E"], + redouble_player=players["N"], + ) + winners = ["N"] * 7 + ["S"] # partner steals one → Solo Slam fails + round_ = _slam_round(players, contract=contract, trick_winners=winners) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 0 + assert scores["East-West"] == 4000 + + +class TestSlamFamilyBeloteLayering: + """Belote (+20) applies on top of the Slam grid for whichever team + *holds* the K + Q of trump, independent of who wins the contract.""" + + def test_slam_made_belote_to_attacker(self, players): + """Slam made, attacker holds belote → 500 + 20 to attacker.""" + contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + round_.belote_holder = players["N"] # N-S holds K+Q of trump + scores = round_.calculate_round_scores() + assert scores["North-South"] == 520 # 500 + 20 + assert scores["East-West"] == 0 + + def test_slam_failed_belote_to_defender(self, players): + """Slam failed, defender holds belote → 500 + 20 to defender.""" + contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) + winners = ["N"] * 7 + ["W"] + round_ = _slam_round(players, contract=contract, trick_winners=winners) + round_.belote_holder = players["W"] # E-W holds K+Q of trump + scores = round_.calculate_round_scores() + assert scores["North-South"] == 0 + assert scores["East-West"] == 520 # 500 + 20 + + def test_slam_failed_belote_to_attacker_independent_of_contract( + self, players + ): + """Belote is independent of contract outcome: attacker can hold + belote even when they lost the contract → defender scores 500, + attacker still scores +20.""" + contract = _contract(players["N"], SlamLevel.SLAM, Suit.SPADES) + winners = ["N"] * 7 + ["W"] + round_ = _slam_round(players, contract=contract, trick_winners=winners) + round_.belote_holder = players["N"] # attacker holds belote + scores = round_.calculate_round_scores() + # Attacker still gets +20 from belote even though the contract failed. + assert scores["North-South"] == 20 + assert scores["East-West"] == 500 + + +class TestNumericContractScoringRegression: + """Confirms numeric (80–180) contracts are *not* affected by the + Slam-family branch added during this refactor.""" + + @staticmethod + def _trick_with_card(seat_player, card): + trick = Trick() + trick.add_play(seat_player, card) + return trick + + def test_numeric_made_normal_uses_base_plus_card_points(self, players): + """80 made by N-S without double, and *not* a sweep: attacker = + 80 + card points, defender = its own card points. Trump = clubs; + the bidder plays the trump Jack (20 pts) in seven tricks while + E-W steal one 0-point trick — so the plain made formula, not the + unannounced-Slam substitute, is the path under test.""" + contract = _contract(players["N"], 80, Suit.CLUBS) + order = [players[s] for s in ("N", "E", "S", "W")] + round_ = Round( + order, dealer=players["N"], deck=None, round_number=1 + ) + round_.contract = contract + # Seven tricks where N plays the trump Jack solo — 20 pts each. + # (Card identity is fine — Card doesn't have unique-per-instance + # invariants we care about for scoring.) + for _ in range(7): + trick = self._trick_with_card( + players["N"], Card(Suit.CLUBS, Rank.JACK) + ) + round_.tricks.append(trick) + round_.team_tricks["North-South"].append(trick) + # E-W steal a single 0-point trick so N-S did not sweep all 8. + ew_trick = self._trick_with_card( + players["E"], Card(Suit.HEARTS, Rank.SEVEN) + ) + round_.tricks.append(ew_trick) + round_.team_tricks["East-West"].append(ew_trick) + round_.last_trick_winner = players["N"] + scores = round_.calculate_round_scores() + # Card points = 20*7 = 140; last-trick bonus = +10 → 150 card pts. + # Contract made (150 >= 80) → attacker score = 80 + 150 = 230. + assert round_.unannounced_slam is None + assert scores["North-South"] == 230 + # E-W captured a single 0-point trick → 0 card points. + assert scores["East-West"] == 0 + + def test_numeric_failed_normal_defender_gets_160_plus_base(self, players): + """Failed 80 contract by N-S: defender gets (160 + 80) * 1 = 240.""" + contract = _contract(players["N"], 80, Suit.CLUBS) + # 0 tricks to N — contract fails immediately on points (0 < 80). + round_ = _slam_round( + players, contract=contract, trick_winners=["E"] * 8 + ) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 0 + assert scores["East-West"] == 240 + + +# --------------------------------------------------------------------------- +# Numeric scoring — belote attribution & doubled (winner-takes-all) +# --------------------------------------------------------------------------- +# +# These build a Round directly and stuff ``team_tricks`` with synthesised +# tricks. Scoring only sums ``card.get_points(trump)`` over each team's +# tricks, so the trick *shape* (how many cards, who else played) is +# irrelevant — we can pack all of a team's point-carrying cards into a +# single Trick. Trump = hearts throughout, where the trump-aware values +# are J=20, 9=14, A=11, 10=10, K=4, Q=3, 8=7=0. + + +def _numeric_round( + players_dict, + *, + contract, + team_cards, + last_trick_winner=None, + belote_holder=None, +): + """Build a numeric-contract Round with synthesised tricks. + + Args: + players_dict: the ``players`` fixture (seat → Player). + contract: a numeric Contract bound to one of the players. + team_cards: mapping team-name → list of ``(seat, Card)`` plays. + Each team's cards are packed into Tricks of up to four cards + (the Trick capacity), all credited to that team. + last_trick_winner: seat letter credited with the last-trick bonus, or + None. + belote_holder: seat letter holding K + Q of trump, or None. + + Returns: + Round with ``contract``, ``tricks``, ``team_tricks``, + ``last_trick_winner`` and ``belote_holder`` populated. + """ + order = [players_dict[s] for s in ("N", "E", "S", "W")] + round_ = Round(order, dealer=players_dict["N"], deck=None, round_number=1) + round_.contract = contract + for team_name, plays in team_cards.items(): + # Trick holds at most four cards — chunk the team's plays so the + # synthesised pile spans as many tricks as needed. + for start in range(0, len(plays), 4): + trick = Trick() + for seat, card in plays[start:start + 4]: + trick.add_play(players_dict[seat], card) + round_.tricks.append(trick) + round_.team_tricks[team_name].append(trick) + if last_trick_winner is not None: + round_.last_trick_winner = players_dict[last_trick_winner] + if belote_holder is not None: + round_.belote_holder = players_dict[belote_holder] + return round_ + + +class TestNumericBeloteByHolder: + """Belote follows the *holder* of K + Q of trump, never the team that + merely captures those cards in a trick. This is the Problem-1 + regression: a phantom capture-based +20 used to flip a failed + contract into a spurious "made".""" + + # All eight hearts = 62 trump-aware points, including both K and Q. + _HEART_RANKS = ( + Rank.JACK, Rank.NINE, Rank.ACE, Rank.TEN, + Rank.KING, Rank.QUEEN, Rank.EIGHT, Rank.SEVEN, + ) + + def _all_hearts_for(self, seat): + return [(seat, Card(Suit.HEARTS, r)) for r in self._HEART_RANKS] + + def test_captured_kq_without_holder_does_not_make_contract(self, players): + """E-W capture all hearts (incl. K+Q, 62 pts) but no single + player *holds* the pair → no belote. Bare 62 < 80 → the contract + FAILS. Under the old capture-based rule the phantom +20 would + have lifted 62→82 and "made" the 80 contract — the bug behind + the impossible recap.""" + contract = _contract(players["E"], 80, Suit.HEARTS) + round_ = _numeric_round( + players, + contract=contract, + team_cards={ + "East-West": self._all_hearts_for("E"), + "North-South": [], + }, + last_trick_winner="N", # last-trick bonus to N-S, not the declarer + belote_holder=None, # pair is split — nobody holds it + ) + scores = round_.calculate_round_scores() + assert round_.contract_made is False + assert scores["East-West"] == 0 + assert scores["North-South"] == 240 # 160 + 80 + + def test_belote_credited_to_holder_even_if_opponent_captures(self, players): + """E-W capture the K+Q in their tricks, but S (N-S) *held* the + pair → the +20 belote is credited to N-S, the holder, not E-W.""" + contract = _contract(players["E"], 80, Suit.HEARTS) + round_ = _numeric_round( + players, + contract=contract, + team_cards={ + "East-West": self._all_hearts_for("E"), + "North-South": [], + }, + last_trick_winner="N", + belote_holder="S", # N-S holds the pair + ) + scores = round_.calculate_round_scores() + # Declarer E-W realized 62 < 80 → failed → 0. + assert scores["East-West"] == 0 + # Defender N-S: 160 + 80 (winner-takes-all, M=1) + 20 belote. + assert scores["North-South"] == 260 + + def test_failed_declarer_keeps_only_its_belote(self, players): + """A failed declarer keeps its belote bonus (always preserved) + and nothing else.""" + contract = _contract(players["E"], 80, Suit.HEARTS) + round_ = _numeric_round( + players, + contract=contract, + team_cards={ + "East-West": [ + ("E", Card(Suit.HEARTS, Rank.KING)), + ("E", Card(Suit.HEARTS, Rank.QUEEN)), + ], + "North-South": [], + }, + last_trick_winner="N", + belote_holder="E", # declarer holds the pair + ) + scores = round_.calculate_round_scores() + # E-W realized = 7 cards + 20 belote = 27 < 80 → failed. + assert round_.contract_made is False + assert scores["East-West"] == 20 # belote only + assert scores["North-South"] == 240 # 160 + 80 + + +class TestNumericDoubledScoring: + """Doubled / redoubled numeric contracts: winner-takes-all, the loser + scores 0 except its belote. The winner amount is 160 + C×M whether it + is the made declarer or the winning defense.""" + + @staticmethod + def _ns_big_pile(): + """76 trump-aware points for N-S — clears an 80 contract once the + last-trick bonus is added.""" + return [ + ("N", Card(Suit.HEARTS, Rank.JACK)), # 20 + ("N", Card(Suit.HEARTS, Rank.NINE)), # 14 + ("N", Card(Suit.HEARTS, Rank.ACE)), # 11 + ("N", Card(Suit.HEARTS, Rank.TEN)), # 10 + ("S", Card(Suit.SPADES, Rank.ACE)), # 11 + ("S", Card(Suit.SPADES, Rank.TEN)), # 10 + ] + + def test_doubled_made_defender_scores_zero(self, players): + """Doubled contract made: the defending side scores 0 even though + it captured point-carrying cards (Problem 2).""" + contract = Contract( + ContractBid(players["N"], 80, Suit.HEARTS), + double_player=players["E"], + ) + round_ = _numeric_round( + players, + contract=contract, + team_cards={ + "North-South": self._ns_big_pile(), + # E-W win a fat trick — under the old rule they'd keep + # these 14 points; winner-takes-all zeroes them. + "East-West": [ + ("E", Card(Suit.DIAMONDS, Rank.TEN)), # 10 + ("E", Card(Suit.CLUBS, Rank.KING)), # 4 + ], + }, + last_trick_winner="N", # +10 bonus → N-S realized 86 ≥ 80 + ) + scores = round_.calculate_round_scores() + assert round_.contract_made is True + assert scores["North-South"] == 320 # 160 + 80*2 + assert scores["East-West"] == 0 + + def test_doubled_made_defender_keeps_only_belote(self, players): + """The lone exception: the losing defender keeps its belote.""" + contract = Contract( + ContractBid(players["N"], 80, Suit.HEARTS), + double_player=players["E"], + ) + round_ = _numeric_round( + players, + contract=contract, + team_cards={ + "North-South": self._ns_big_pile(), + "East-West": [("E", Card(Suit.CLUBS, Rank.KING))], + }, + last_trick_winner="N", + belote_holder="E", # E-W (defender) holds the pair + ) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 320 # 160 + 80*2 + assert scores["East-West"] == 20 # belote only + + def test_doubled_failed_winner_takes_160_plus_cm(self, players): + """Doubled contract failed: the defense takes 160 + C×M, declarer 0.""" + contract = Contract( + ContractBid(players["N"], 100, Suit.HEARTS), + double_player=players["E"], + ) + round_ = _numeric_round( + players, + contract=contract, + team_cards={ + "North-South": [("N", Card(Suit.DIAMONDS, Rank.TEN))], # 10 < 100 + "East-West": [("E", Card(Suit.HEARTS, Rank.JACK))], + }, + last_trick_winner="E", + ) + scores = round_.calculate_round_scores() + assert round_.contract_made is False + assert scores["North-South"] == 0 + assert scores["East-West"] == 360 # 160 + 100*2 + + def test_redoubled_failed_winner_takes_160_plus_c_times_four(self, players): + """Redoubled failed: the defense takes 160 + C×4 — the same shape + as a made redoubled declarer (symmetric stake).""" + contract = Contract( + ContractBid(players["N"], 100, Suit.HEARTS), + double_player=players["E"], + redouble_player=players["N"], + ) + round_ = _numeric_round( + players, + contract=contract, + team_cards={ + "North-South": [("N", Card(Suit.DIAMONDS, Rank.TEN))], + "East-West": [("E", Card(Suit.HEARTS, Rank.JACK))], + }, + last_trick_winner="E", + ) + scores = round_.calculate_round_scores() + assert scores["North-South"] == 0 + assert scores["East-West"] == 560 # 160 + 100*4 + + +# --------------------------------------------------------------------------- +# Unannounced Slam scoring (calculate_round_scores) +# --------------------------------------------------------------------------- +# +# When the declaring team wins all 8 tricks on an *un-doubled* numeric +# contract without having bid a Slam, the 162-point pile (152 cards + 10 +# last-trick bonus) is replaced by a flat 250 substitute: the declarer scores +# contract value + 250 (+ belote), the defence scores nothing, and the +# contract is necessarily made. The round is flagged UnannouncedSlam.GRAND_SLAM +# when the contracting player personally won all 8 tricks, else +# UnannouncedSlam.SLAM. A doubled/redoubled sweep keeps the winner-takes-all +# 160 + C×M shape, and a defence sweep is unaffected (declaring team only). + + +class TestUnannouncedSlamEnum: + """The UnannouncedSlam member value is its display label.""" + + def test_member_labels_via_str(self): + assert str(UnannouncedSlam.SLAM) == "Slam" + assert str(UnannouncedSlam.GRAND_SLAM) == "Grand Slam" + + +class TestUnannouncedSlamScoring: + """Un-doubled numeric sweep by the declaring team → contract + 250.""" + + def test_team_sweep_scores_contract_plus_250(self, players): + """N takes 5, partner S takes 3 → the *team* swept (but no single + player did) → UnannouncedSlam.SLAM, scored 100 + 250.""" + contract = _contract(players["N"], 100, Suit.SPADES) + winners = ["N"] * 5 + ["S"] * 3 + round_ = _slam_round(players, contract=contract, trick_winners=winners) + scores = round_.calculate_round_scores() + assert round_.unannounced_slam is UnannouncedSlam.SLAM + assert round_.contract_made is True + assert scores["North-South"] == 350 # 100 + 250 + assert scores["East-West"] == 0 + + def test_bidder_personal_sweep_is_grand_slam(self, players): + """N wins all 8 personally → UnannouncedSlam.GRAND_SLAM (same 250 substitute).""" + contract = _contract(players["N"], 100, Suit.SPADES) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert round_.unannounced_slam is UnannouncedSlam.GRAND_SLAM + assert scores["North-South"] == 350 # 100 + 250 + assert scores["East-West"] == 0 + + def test_unannounced_slam_forces_made_below_threshold(self, players): + """The filler tricks carry 0 card points, so a 180 contract could + never clear its threshold on cards — but sweeping every trick + makes it outright → 180 + 250 = 430.""" + contract = _contract(players["N"], 180, Suit.SPADES) + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert round_.contract_made is True + assert scores["North-South"] == 430 # 180 + 250 + assert scores["East-West"] == 0 + + def test_unannounced_slam_layers_belote_on_top(self, players): + """Belote (+20) still credits the holder on top of contract + 250.""" + contract = _contract(players["N"], 100, Suit.SPADES) + winners = ["N"] * 5 + ["S"] * 3 + round_ = _slam_round(players, contract=contract, trick_winners=winners) + round_.belote_holder = players["N"] # N-S holds K+Q of trump + scores = round_.calculate_round_scores() + assert scores["North-South"] == 370 # 100 + 250 + 20 + assert scores["East-West"] == 0 + + def test_doubled_sweep_keeps_winner_takes_all_and_is_unflagged(self, players): + """A doubled contract swept by the declarer keeps the + winner-takes-all 160 + C×M shape — no 250 substitute, no flag.""" + contract = Contract( + ContractBid(players["N"], 100, Suit.SPADES), + double_player=players["E"], + ) + order = [players[s] for s in ("N", "E", "S", "W")] + round_ = Round(order, dealer=players["N"], deck=None, round_number=1) + round_.contract = contract + # N sweeps all 8 with the trump Jack (20 pts each → 160 card + # points, clearing the 100 threshold). Card identity is + # irrelevant to scoring, so the same Card may recur. + for _ in range(8): + trick = Trick() + trick.add_play(players["N"], Card(Suit.SPADES, Rank.JACK)) + round_.tricks.append(trick) + round_.team_tricks["North-South"].append(trick) + round_.last_trick_winner = players["N"] + scores = round_.calculate_round_scores() + assert round_.unannounced_slam is None + assert round_.contract_made is True + assert scores["North-South"] == 360 # 160 + 100*2 + assert scores["East-West"] == 0 + + def test_defense_sweep_is_not_an_unannounced_slam(self, players): + """Declaring team only: when the *defence* sweeps, the declarer + simply fails (160 + C to the defence) — no 250, not flagged.""" + contract = _contract(players["E"], 100, Suit.SPADES) # E-W declares + round_ = _slam_round( + players, contract=contract, trick_winners=["N"] * 8 + ) + scores = round_.calculate_round_scores() + assert round_.unannounced_slam is None + assert round_.contract_made is False + assert scores["East-West"] == 0 + assert scores["North-South"] == 260 # 160 + 100 (normal failed) diff --git a/packages/contrai-engine/tests/test_model/test_rule_based_bidding.py b/packages/contrai-engine/tests/test_model/test_rule_based_bidding.py new file mode 100644 index 0000000..ee6de36 --- /dev/null +++ b/packages/contrai-engine/tests/test_model/test_rule_based_bidding.py @@ -0,0 +1,835 @@ +"""Unit tests for the rule-based AI bidding strategy. + +These tests exercise the expert bidding table now living on +``RuleBasedBiddingStrategy``. ``AiPlayer.choose_bid`` is a public +delegator, so high-level ``choose_bid(...)`` calls stay on the player, +while private helpers and constants are reached through +``ai_player.bidding.*`` (the injected strategy object). +""" + +import itertools + +import pytest +from contrai_engine.model.player import AiPlayer +from contrai_core import ( + Auction, + ContractBid, + DoubleBid, + Hand, + PassBid, + RedoubleBid, + SlamLevel, +) +from contrai_core.card import Card +from contrai_core.team import Team +from contrai_core.types import Suit, Rank + + +def _auction(bids=()): + """Pack a sequence of :class:`Bid` objects into an :class:`Auction`. + + ``AiPlayer.choose_bid`` takes an Auction; test bodies build the + chronological :class:`Bid` history directly (``ContractBid`` / + ``PassBid`` / ``DoubleBid`` / ``RedoubleBid``) and hand it here. + """ + return Auction(tuple(bids)) + + +class TestAiPlayerBidding: + """Test AI player bidding logic""" + + @pytest.fixture + def ai_player(self): + """Create an AI player for testing""" + player = AiPlayer("TestBot", "North") + # Create a mock team + partner = AiPlayer("Partner", "South") + team = Team("North-South", [player, partner]) + player.team = team + partner.team = team + return player + + @pytest.fixture + def ai_opponent_player(self): + """Create an opponent AI player for testing""" + opponent = AiPlayer("Opponent", "West") + opponent_partner = AiPlayer("OpponentPartner", "East") + opponent_team = Team("East-West", [opponent, opponent_partner]) + opponent.team = opponent_team + opponent_partner.team = opponent_team + + return opponent + + @pytest.fixture + def sample_cards_weak(self): + """Create a weak hand for testing""" + return Hand([ + Card(Suit.SPADES, Rank.SEVEN), + Card(Suit.SPADES, Rank.EIGHT), + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.HEARTS, Rank.EIGHT), + Card(Suit.DIAMONDS, Rank.SEVEN), + Card(Suit.DIAMONDS, Rank.EIGHT), + Card(Suit.CLUBS, Rank.SEVEN), + Card(Suit.CLUBS, Rank.EIGHT) + ]) + + @pytest.fixture + def sample_cards_correct_hearts(self): + """Create a middle hand for testing""" + return Hand([ + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.KING), + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.SPADES, Rank.EIGHT), + Card(Suit.DIAMONDS, Rank.TEN), + Card(Suit.DIAMONDS, Rank.EIGHT), + Card(Suit.CLUBS, Rank.ACE), + Card(Suit.CLUBS, Rank.TEN) + ]) + + @pytest.fixture + def sample_cards_strong_spades(self): + """Create a strong spades hand for testing""" + return Hand([ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.NINE), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.SPADES, Rank.KING), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.ACE), + Card(Suit.CLUBS, Rank.JACK) + ]) + + @pytest.fixture + def sample_cards_belote_spades(self): + """Create a hand with belote in spades""" + return Hand([ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.SPADES, Rank.KING), + Card(Suit.SPADES, Rank.QUEEN), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.ACE), + Card(Suit.CLUBS, Rank.EIGHT) + ]) + + def test_evaluate_suits_weak_hand(self, ai_player, sample_cards_weak): + """Test suit evaluation with a weak hand""" + ai_player.hand = sample_cards_weak + evaluations = ai_player.bidding._evaluate_suits() + + # All suits should have low or zero contract values + for suit, eval_data in evaluations.items(): + assert eval_data['contract'] == 0 + assert eval_data['estimated_tricks'] == 0 + assert eval_data['has_belote'] is False + + def test_evaluate_suits_correct_hand(self, ai_player, sample_cards_correct_hearts): + """Test suit evaluation with a correct hand""" + ai_player.hand = sample_cards_correct_hearts + evaluations = ai_player.bidding._evaluate_suits() + + hearts_eval = evaluations[Suit.HEARTS] + assert hearts_eval['contract'] == 80 # Should be able to bid 130 + assert hearts_eval['trump_count'] == 3 + assert hearts_eval['estimated_tricks'] == 4 + assert hearts_eval['external_aces'] == 1 + + def test_evaluate_suits_strong_spades(self, ai_player, sample_cards_strong_spades): + """Test suit evaluation with a strong spades hand""" + ai_player.hand = sample_cards_strong_spades + evaluations = ai_player.bidding._evaluate_suits() + + spades_eval = evaluations[Suit.SPADES] + assert spades_eval['contract'] == 130 # Should be able to bid 130 + assert spades_eval['trump_count'] == 4 + assert spades_eval['estimated_tricks'] == 7 + assert spades_eval['external_aces'] == 3 + + def test_evaluate_suits_belote(self, ai_player, sample_cards_belote_spades): + """Test suit evaluation with belote""" + ai_player.hand = sample_cards_belote_spades + evaluations = ai_player.bidding._evaluate_suits() + + spades_eval = evaluations[Suit.SPADES] + assert spades_eval['has_belote'] is True + assert spades_eval['contract'] == 140 + + def test_estimate_tricks(self, ai_player, sample_cards_strong_spades): + """Test trick estimation""" + ai_player.hand = sample_cards_strong_spades + tricks = ai_player.bidding._estimate_tricks(Suit.SPADES) + + # Strong spades hand with 3 external aces should estimate 7 tricks + assert tricks == 7 + + def test_evaluate_trump_tricks(self, ai_player, sample_cards_strong_spades): + """Test trump tricks evaluation""" + ai_player.hand = sample_cards_strong_spades + expected_tricks = ai_player.bidding._evaluate_trump_tricks(Suit.SPADES) + + # Strong spades hand with Jack + 9 + Ace + King should expect good trick count + # Jack + 9 = 2 tricks, plus additional tricks from trump length + assert expected_tricks == 4 + + def test_get_partner_bid(self, ai_player, ai_opponent_player): + """Test getting partner's bid""" + ai_player_partner = ai_player.team.players[1] + ai_opponent_player_partner = ai_opponent_player.team.players[1] + + bids = [ + PassBid(ai_opponent_player), + ContractBid(ai_player_partner, 80, Suit.SPADES), + ContractBid(ai_opponent_player_partner, 90, Suit.HEARTS), + ] + + partner_bid = ai_player.bidding._get_partner_bid(bids) + assert isinstance(partner_bid, ContractBid) + assert partner_bid.value == 80 + assert partner_bid.suit == Suit.SPADES + + def test_choose_bid_pass_weak_hand(self, ai_player, sample_cards_weak): + """Test that AI passes with weak hand""" + ai_player.hand = sample_cards_weak + bid = ai_player.choose_bid(_auction()) + assert isinstance(bid, PassBid) + + def test_choose_bid_initial_bid_strong_hand(self, ai_player, sample_cards_strong_spades): + """Test initial bid with strong hand""" + ai_player.hand = sample_cards_strong_spades + bid = ai_player.choose_bid(_auction()) + + assert isinstance(bid, ContractBid) + assert bid.value == 130 + assert bid.suit == Suit.SPADES + + def test_choose_bid_overbid_opponent(self, ai_player, ai_opponent_player, sample_cards_strong_spades): + """Test overbidding opponent""" + ai_player.hand = sample_cards_strong_spades + + auction = _auction([ContractBid(ai_opponent_player, 90, Suit.HEARTS)]) + bid = ai_player.choose_bid(auction) + + assert isinstance(bid, ContractBid) + assert bid.value > 90 + assert bid.suit == Suit.SPADES + + def test_choose_bid_support_partner(self, ai_player, ai_opponent_player): + """Test supporting partner's bid""" + # Give AI player some external aces to support partner + ai_player.hand = Hand([ + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.QUEEN), + Card(Suit.CLUBS, Rank.ACE), + Card(Suit.SPADES, Rank.JACK), # Trump complement + Card(Suit.SPADES, Rank.EIGHT), + Card(Suit.HEARTS, Rank.EIGHT), + Card(Suit.DIAMONDS, Rank.EIGHT), + Card(Suit.CLUBS, Rank.EIGHT) + ]) + + # Partner bids 80 in Spades + partner = ai_player.team.players[1] + auction = _auction([ + ContractBid(partner, 80, Suit.SPADES), + PassBid(ai_opponent_player), + ]) + bid = ai_player.choose_bid(auction) + + # Should support with higher bid due to 3 external aces + trump complement + assert isinstance(bid, ContractBid) + assert bid.value >= 100 # 80 + 20 (2 aces) + 10 (trump complement) + assert bid.suit == Suit.SPADES + + def test_choose_bid_cant_overbid_partner(self, ai_player, ai_opponent_player, sample_cards_weak): + """Test that AI doesn't overbid partner when it can't""" + ai_player.hand = sample_cards_weak + + # Partner bids high + partner = ai_player.team.players[1] + auction = _auction([ + ContractBid(partner, 140, Suit.SPADES), + PassBid(ai_opponent_player), + ]) + bid = ai_player.choose_bid(auction) + + assert isinstance(bid, PassBid) + + # --- Bidding under a standing Coinche / Surcoinche -------------------- + # Regression coverage for the crash where the expert table, blind to a + # Double freezing the auction, returned an illegal numeric raise (even + # over its *own* partner) and ``Auction.apply`` aborted the game with + # ``IllegalBidError``. A standing Double permits only Pass, or a + # Surcoinche (Redouble) from the contracting team. + + def test_choose_bid_strong_hand_overbids_partner_without_double( + self, ai_player, ai_opponent_player, sample_cards_strong_spades + ): + """Control case: with no Double, the strong AI *does* raise partner. + + Establishes that the Pass in + :meth:`test_choose_bid_passes_when_opponent_doubled_partner` is + caused by the freeze, not by the hand being too weak to raise. + """ + ai_player.hand = sample_cards_strong_spades # max contract 130 + partner = ai_player.team.players[1] + auction = _auction([ContractBid(partner, 80, Suit.SPADES)]) + bid = ai_player.choose_bid(auction) + assert isinstance(bid, ContractBid) + assert bid.value == 130 + assert bid.suit == Suit.SPADES + + def test_choose_bid_passes_when_opponent_doubled_partner( + self, ai_player, ai_opponent_player, sample_cards_strong_spades + ): + """AI must Pass — not raise — when an opponent Coinched partner. + + The exact reproduction of the reported crash: partner holds the + contract, an opponent Doubles, and the AI's hand is strong enough + that the open-auction path would raise to 130. The Double freezes + the auction, so the only non-redouble action is Pass. + """ + ai_player.hand = sample_cards_strong_spades + partner = ai_player.team.players[1] + auction = _auction([ + ContractBid(partner, 80, Suit.SPADES), + DoubleBid(ai_opponent_player), + ]) + bid = ai_player.choose_bid(auction) + assert isinstance(bid, PassBid) + + def test_choose_bid_passes_when_own_team_doubled_opponent( + self, ai_player, ai_opponent_player, sample_cards_strong_spades + ): + """AI on the *doubling* side may only Pass (no raise, no redouble). + + Here the opponents hold the contract and the AI's partner has + already Coinched it. The contracting team is the opponents, so a + Surcoinche is illegal for this seat and the strong hand must not + tempt a numeric raise either. + """ + ai_player.hand = sample_cards_strong_spades + partner = ai_player.team.players[1] + auction = _auction([ + ContractBid(ai_opponent_player, 120, Suit.HEARTS), + DoubleBid(partner), + ]) + bid = ai_player.choose_bid(auction) + assert isinstance(bid, PassBid) + + def test_choose_bid_passes_after_redouble( + self, ai_player, ai_opponent_player, sample_cards_strong_spades + ): + """Once the auction is Surcoinched, only Pass remains.""" + ai_player.hand = sample_cards_strong_spades + partner = ai_player.team.players[1] + auction = _auction([ + ContractBid(partner, 110, Suit.SPADES), + DoubleBid(ai_opponent_player), + RedoubleBid(partner), + ]) + bid = ai_player.choose_bid(auction) + assert isinstance(bid, PassBid) + + def test_choose_bid_surcoinches_when_strategy_approves( + self, ai_player, ai_opponent_player, sample_cards_weak + ): + """Contracting team may Redouble when the strategy says so. + + ``_should_redouble`` is a stub returning ``False`` today, so we + force it ``True`` to exercise the (legal) Surcoinche path and + confirm the resulting :class:`RedoubleBid` is what the Auction + would accept. + """ + ai_player.hand = sample_cards_weak + partner = ai_player.team.players[1] + auction = _auction([ + ContractBid(partner, 100, Suit.SPADES), + DoubleBid(ai_opponent_player), + ]) + ai_player.bidding._should_redouble = lambda: True # type: ignore[method-assign] + bid = ai_player.choose_bid(auction) + assert isinstance(bid, RedoubleBid) + assert auction.is_legal(bid) + + def test_choose_bid_guard_converts_illegal_table_bid_to_pass( + self, ai_player, ai_opponent_player, sample_cards_weak + ): + """The is_legal safety net turns an illegal expert-table bid into Pass. + + Independently of the freeze handling, ``choose_bid`` must never + hand ``Auction.apply`` a bid it would reject. We force the expert + table to emit an under-cutting raise (90 over a live 140) and + assert the guard downgrades it to the always-legal Pass. + """ + ai_player.hand = sample_cards_weak + auction = _auction([ContractBid(ai_opponent_player, 140, Suit.SPADES)]) + ai_player.bidding._choose_open_bid = ( # type: ignore[method-assign] + lambda _auction: ContractBid(ai_player, 90, Suit.SPADES) + ) + bid = ai_player.choose_bid(auction) + assert isinstance(bid, PassBid) + + # --- Slam / Solo Slam bidding ----------------------------------------- + # _estimate_tricks is capped at 8 (`min(tricks, 8)`), so a hand holding + # 5 trumps (J + 9 + A + K + Q) plus all three external aces triggers + # the Slam-family rows in BIDDING_TABLE. Both Slam (500) and Solo Slam + # (1000) share the same trick-estimator gate today (tricks_min=8), so + # the table walks both and stops on the higher one. + + @pytest.fixture + def sample_cards_slam_spades(self): + """Five-trump Spades hand plus the three external aces.""" + return Hand([ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.NINE), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.SPADES, Rank.KING), + Card(Suit.SPADES, Rank.QUEEN), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.ACE), + ]) + + def test_evaluate_suit_slam_family_qualifies( + self, ai_player, sample_cards_slam_spades + ): + """A hand estimated at 8 tricks resolves to the top Slam-family row. + + With the current (deliberately permissive) Solo Slam gate that + shares Slam's ``tricks_min=8``, the table walk lands on + ``SOLO_SLAM_NUMERIC`` (1000). The Slam row (500) is still + reachable via the AI when partner bids below that — see the + sentinel-translation tests. + """ + ai_player.hand = sample_cards_slam_spades + evaluations = ai_player.bidding._evaluate_suits() + assert evaluations[Suit.SPADES]['contract'] == ai_player.bidding.SOLO_SLAM_NUMERIC + assert evaluations[Suit.SPADES]['estimated_tricks'] == 8 + + def test_choose_bid_solo_slam_strong_hand( + self, ai_player, sample_cards_slam_spades + ): + """choose_bid lifts the Solo Slam wire choice to a ContractBid.""" + ai_player.hand = sample_cards_slam_spades + bid = ai_player.choose_bid(_auction()) + assert isinstance(bid, ContractBid) + assert bid.value is SlamLevel.SOLO_SLAM + assert bid.suit == Suit.SPADES + + def test_should_double_handles_slam_value(self, ai_player, sample_cards_weak): + """_should_double must not TypeError on a SlamLevel value. + + The heuristic itself (``strength > 162 - value``) is permissive + against Slam-family bids because ``162 - 500`` (and -1000) is + negative; we only assert the boolean contract here. Tuning the + heuristic is a separate concern. + """ + ai_player.hand = sample_cards_weak + result = ai_player.bidding._should_double( + ContractBid(ai_player, SlamLevel.SLAM, Suit.SPADES) + ) + assert isinstance(result, bool) + result = ai_player.bidding._should_double( + ContractBid(ai_player, SlamLevel.SOLO_SLAM, Suit.SPADES) + ) + assert isinstance(result, bool) + + def test_choose_bid_passes_when_partner_announced_slam( + self, ai_player, ai_opponent_player, sample_cards_strong_spades + ): + """A strong-but-not-Slam AI passes cleanly when partner announces Slam.""" + ai_player.hand = sample_cards_strong_spades # estimates 7 tricks, max 130 + partner = ai_player.team.players[1] + auction = _auction([ContractBid(partner, SlamLevel.SLAM, Suit.SPADES)]) + # Must not TypeError on the 130-vs-Slam comparison. + bid = ai_player.choose_bid(auction) + assert isinstance(bid, PassBid) + + def test_choose_bid_passes_when_partner_announced_solo_slam( + self, ai_player, ai_opponent_player, sample_cards_strong_spades + ): + """A strong-but-not-Slam AI passes when partner announces Solo Slam.""" + ai_player.hand = sample_cards_strong_spades + partner = ai_player.team.players[1] + auction = _auction([ContractBid(partner, SlamLevel.SOLO_SLAM, Suit.SPADES)]) + bid = ai_player.choose_bid(auction) + assert isinstance(bid, PassBid) + + # --- Best-contract resolution ------------------------------------------ + # _find_best_contract folds the max-contract search and the suit + # tie-break (belote first, then the fixed preference order) into a + # single step, so the open-bid path handles one (contract, suit) + # pair. Tie cases build the evaluation dicts directly — real hands + # rarely produce exact contract ties on demand. + + def test_find_best_contract_weak_hand(self, ai_player, sample_cards_weak): + """No suit meets the bidding table → (0, None).""" + ai_player.hand = sample_cards_weak + evaluations = ai_player.bidding._evaluate_suits() + + assert ai_player.bidding._find_best_contract(evaluations) == (0, None) + + def test_find_best_contract_single_best_suit( + self, ai_player, sample_cards_strong_spades + ): + """A hand with one dominant suit resolves to that suit's contract.""" + ai_player.hand = sample_cards_strong_spades + evaluations = ai_player.bidding._evaluate_suits() + + assert ai_player.bidding._find_best_contract(evaluations) == (130, Suit.SPADES) + + def test_find_best_contract_tie_preference_order(self, ai_player): + """Tied suits without belote fall back to the preference order.""" + # Mirror-image Spades/Hearts holdings: both evaluate to 130. + ai_player.hand = Hand([ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.NINE), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.NINE), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.ACE) + ]) + + evaluations = ai_player.bidding._evaluate_suits() + + # Spades wins the tie: first in the preference order. + assert ai_player.bidding._find_best_contract(evaluations) == (130, Suit.SPADES) + + def test_find_best_contract_tie_belote_preference(self, ai_player): + """A belote suit wins the tie over an equal belote-less suit.""" + evaluations = { + Suit.SPADES: {'contract': 100, 'has_belote': False}, + Suit.HEARTS: {'contract': 100, 'has_belote': True}, + Suit.DIAMONDS: {'contract': 0, 'has_belote': False}, + Suit.CLUBS: {'contract': 0, 'has_belote': False}, + } + + assert ai_player.bidding._find_best_contract(evaluations) == (100, Suit.HEARTS) + + def test_find_best_contract_tie_respects_candidates(self, ai_player): + """The preference tie-break must pick among the *tied* suits only. + + Regression: the retired ``_choose_best_suit`` fallback loop + returned ``SUIT_PREFERENCE[0]`` (Spades) unconditionally — even + when Spades never met the bidding table. + """ + evaluations = { + Suit.SPADES: {'contract': 0, 'has_belote': False}, + Suit.HEARTS: {'contract': 100, 'has_belote': False}, + Suit.DIAMONDS: {'contract': 100, 'has_belote': False}, + Suit.CLUBS: {'contract': 0, 'has_belote': False}, + } + + assert ai_player.bidding._find_best_contract(evaluations) == (100, Suit.HEARTS) + + def test_find_best_contract_tie_multiple_belotes(self, ai_player): + """Several belote suits: tie-break *within* the belote holders. + + Regression: two belote suits fell through to the raw preference + order over all candidates, so a belote-less suit (here Spades) + could win the tie it should have lost. + """ + evaluations = { + Suit.SPADES: {'contract': 100, 'has_belote': False}, + Suit.HEARTS: {'contract': 100, 'has_belote': True}, + Suit.DIAMONDS: {'contract': 100, 'has_belote': True}, + Suit.CLUBS: {'contract': 0, 'has_belote': False}, + } + + assert ai_player.bidding._find_best_contract(evaluations) == (100, Suit.HEARTS) + + +class TestAiPlayerDoubling: + """Test AI player doubling logic""" + + @pytest.fixture + def ai_players_with_teams(self): + """Create AI players with team setup""" + player = AiPlayer("TestBot", "North") + partner = AiPlayer("Partner", "South") + team = Team("North-South", [player, partner]) + player.team = team + partner.team = team + + # Create opponent team + opponent1 = AiPlayer("Opponent1", "West") + opponent2 = AiPlayer("Opponent2", "East") + opponent_team = Team("East-West", [opponent1, opponent2]) + opponent1.team = opponent_team + opponent2.team = opponent_team + + return player, partner, opponent1, opponent2 + + def test_should_double_with_external_strength(self, ai_players_with_teams): + """Test doubling when having external strength""" + player, _, opponent1, _ = ai_players_with_teams + + # Give player strong external cards + player.hand = Hand([ + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.HEARTS, Rank.TEN), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.TEN), + Card(Suit.CLUBS, Rank.TEN), + Card(Suit.CLUBS, Rank.JACK), + Card(Suit.SPADES, Rank.EIGHT), + Card(Suit.SPADES, Rank.SEVEN) + ]) + + # Opponent bids in Spades + auction = _auction([ContractBid(opponent1, 120, Suit.SPADES)]) + bid = player.choose_bid(auction) + + assert isinstance(bid, DoubleBid) + + def test_should_not_double_weak_external(self, ai_players_with_teams): + """Test not doubling when lacking external strength""" + player, _, opponent1, _ = ai_players_with_teams + + # Give player weak external cards + player.hand = Hand([ + Card(Suit.HEARTS, Rank.EIGHT), + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.DIAMONDS, Rank.EIGHT), + Card(Suit.DIAMONDS, Rank.SEVEN), + Card(Suit.CLUBS, Rank.EIGHT), + Card(Suit.CLUBS, Rank.SEVEN), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.SPADES, Rank.KING) + ]) + + # Opponent bids in Hearts + auction = _auction([ContractBid(opponent1, 100, Suit.HEARTS)]) + bid = player.choose_bid(auction) + + assert isinstance(bid, PassBid) + + +class TestSupportCeiling: + """Partner-support raises are anchored to the team's opening bid. + + Regression suite for the support escalation loop: each supporting + turn used to re-add the seat's full (static) contribution on top of + the *standing* contract, so two partners alternately raised each + other — the same aces re-counted on every lap — until the value + walked off the ladder (typically at 180). Support is now capped at + a team ceiling: partner's opening bid in the suit (their full table + evaluation) plus our own contribution, announced exactly once. A + seat never supports a suit it opened itself. + """ + + @pytest.fixture + def four_ai_players(self): + """Four AI players seated N/E/S/W with N-S and E-W teams.""" + north = AiPlayer("North", "North") + east = AiPlayer("East", "East") + south = AiPlayer("South", "South") + west = AiPlayer("West", "West") + + ns_team = Team("North-South", [north, south]) + ew_team = Team("East-West", [east, west]) + north.team = ns_team + south.team = ns_team + east.team = ew_team + west.team = ew_team + + return north, east, south, west + + @pytest.fixture + def opener_80_spades(self): + """A hand the bidding table resolves to exactly 80 in Spades. + + 4 trumps with the Jack (no 9), 1 external ace, 4 estimated + tricks. Its own support contribution to Spades is +20 + (1 external ace + trump complement). + """ + return Hand([ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.SPADES, Rank.KING), + Card(Suit.SPADES, Rank.EIGHT), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.HEARTS, Rank.SEVEN), + Card(Suit.DIAMONDS, Rank.EIGHT), + Card(Suit.CLUBS, Rank.EIGHT), + ]) + + @pytest.fixture + def supporter_30_spades(self): + """A hand worth no table contract but a +30 support of Spades. + + 2 external aces (+20) and the 9 of trump (+10); disjoint from + :meth:`opener_80_spades` so the pair can be dealt together in + the full-auction test. + """ + return Hand([ + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.ACE), + Card(Suit.SPADES, Rank.NINE), + Card(Suit.SPADES, Rank.SEVEN), + Card(Suit.HEARTS, Rank.NINE), + Card(Suit.HEARTS, Rank.EIGHT), + Card(Suit.DIAMONDS, Rank.SEVEN), + Card(Suit.CLUBS, Rank.SEVEN), + ]) + + @pytest.fixture + def strong_spades_130(self): + """The 130-in-Spades hand (3 external aces, J+9 of trump).""" + return Hand([ + Card(Suit.SPADES, Rank.JACK), + Card(Suit.SPADES, Rank.NINE), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.SPADES, Rank.KING), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.ACE), + Card(Suit.CLUBS, Rank.ACE), + Card(Suit.CLUBS, Rank.JACK), + ]) + + def test_support_raises_to_partner_opening_plus_contribution( + self, four_ai_players, supporter_30_spades + ): + """The ceiling is partner's opening bid + our contribution. + + With an opponent overbid wedged between partner's 80 and our + turn, the raise must land on 80 + 30 = 110 — not on + standing-90 + 30 = 120, which would silently inflate the team + estimate. + """ + north, east, south, _ = four_ai_players + north.hand = supporter_30_spades + + auction = _auction([ + ContractBid(south, 80, Suit.SPADES), + ContractBid(east, 90, Suit.DIAMONDS), + ]) + bid = north.choose_bid(auction) + + assert isinstance(bid, ContractBid) + assert bid.value == 110 + assert bid.suit == Suit.SPADES + + def test_support_passes_when_ceiling_already_beaten( + self, four_ai_players, supporter_30_spades + ): + """No raise once the standing contract exceeds the team ceiling. + + Partner opened 80, our complement is +30, and an opponent + already stands at 120 > 110: supporting would commit the team + past its own combined estimate, so the only sane action is Pass. + """ + north, east, south, _ = four_ai_players + north.hand = supporter_30_spades + + auction = _auction([ + ContractBid(south, 80, Suit.SPADES), + ContractBid(east, 120, Suit.DIAMONDS), + ]) + bid = north.choose_bid(auction) + + assert isinstance(bid, PassBid) + + def test_opener_does_not_reraise_after_partner_support( + self, four_ai_players, strong_spades_130 + ): + """The suit opener passes on partner's support raise. + + The opening bid already carries the opener's full table + evaluation; re-adding the same aces on top of partner's raise + is the first lap of the escalation loop. + """ + north, east, south, west = four_ai_players + north.hand = strong_spades_130 + + auction = _auction([ + ContractBid(north, 130, Suit.SPADES), + PassBid(east), + ContractBid(south, 140, Suit.SPADES), + PassBid(west), + ]) + bid = north.choose_bid(auction) + + assert isinstance(bid, PassBid) + + def test_opener_does_not_support_own_bid_after_opponent_overbid( + self, four_ai_players, opener_80_spades + ): + """A seat never 'supports' its own opening bid. + + With partner silent, ``_get_partner_bid`` hands back the seat's + own contract; piling the support contribution on top would + re-count the very cards that priced the opening 80. + """ + north, east, south, west = four_ai_players + north.hand = opener_80_spades + + auction = _auction([ + ContractBid(north, 80, Suit.SPADES), + ContractBid(east, 90, Suit.HEARTS), + PassBid(south), + PassBid(west), + ]) + bid = north.choose_bid(auction) + + assert isinstance(bid, PassBid) + + def test_full_auction_settles_at_team_ceiling( + self, four_ai_players, opener_80_spades, supporter_30_spades + ): + """End-to-end: a full 4-AI auction stops at opener + complement. + + The user-visible symptom of the loop: an 80 opening with a +30 + partner complement ratcheted 80 → 110 → 130 → 160, at which + point the double heuristic (`strength > 162 - value`) armed + itself on the inflated value and an opponent Coinched — the + 80-hand team ended up committed to a doubled 160. The auction + must now settle at 110, un-doubled. + """ + north, east, south, west = four_ai_players + north.hand = opener_80_spades + south.hand = supporter_30_spades + # Weak opposing hands (disjoint from N/S): no table row is met + # and the double heuristic stays quiet at every standing value. + east.hand = Hand([ + Card(Suit.SPADES, Rank.QUEEN), + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.QUEEN), + Card(Suit.HEARTS, Rank.TEN), + Card(Suit.DIAMONDS, Rank.JACK), + Card(Suit.DIAMONDS, Rank.NINE), + Card(Suit.CLUBS, Rank.JACK), + Card(Suit.CLUBS, Rank.NINE), + ]) + west.hand = Hand([ + Card(Suit.SPADES, Rank.TEN), + Card(Suit.HEARTS, Rank.KING), + Card(Suit.DIAMONDS, Rank.QUEEN), + Card(Suit.DIAMONDS, Rank.KING), + Card(Suit.DIAMONDS, Rank.TEN), + Card(Suit.CLUBS, Rank.QUEEN), + Card(Suit.CLUBS, Rank.KING), + Card(Suit.CLUBS, Rank.TEN), + ]) + + auction = Auction() + for player in itertools.cycle([north, east, south, west]): + if auction.is_terminal(): + break + auction = auction.apply(player.choose_bid(auction)) + # A converging auction is short; a long one means the + # escalation loop is back. + assert len(auction.bids) <= 24, "auction failed to converge" + + final = auction.last_contract_bid + assert final is not None + assert final.value == 110 + assert final.suit == Suit.SPADES + # A sane 110 stays below the opponents' double threshold. + assert not auction.has_double diff --git a/packages/contrai-engine/tests/test_model/test_rule_based_cardplay.py b/packages/contrai-engine/tests/test_model/test_rule_based_cardplay.py new file mode 100644 index 0000000..4a27e2e --- /dev/null +++ b/packages/contrai-engine/tests/test_model/test_rule_based_cardplay.py @@ -0,0 +1,1164 @@ +"""Unit tests for the rule-based AI card-play strategy. + +The strategy is handed a single frozen ``PlayObservation`` and derives +its own card tracking (fallen cards, per-player proven-void suits) from +the observation's public trick history — there is no mutable per-round +state to seed. Every scenario below is therefore expressed by building a real +observation (own hand, contract, and completed / in-progress tricks made +of genuine ``Play`` records), never by poking attributes on the strategy. +""" + +import pytest + +from contrai_core import ( + Card, + Contract, + ContractBid, + Play, + PlayObservation, + PlayState, +) +from contrai_core.types import Suit, Rank + + +def _contract(player, value, suit): + """Build a real :class:`Contract` (the type the engine threads in).""" + return Contract(ContractBid(player, value, suit)) + + +def _obs( + observer, + hand, + contract, + *, + current_trick=(), + completed_tricks=(), + legal_cards=None, + bids=(), +): + """Assemble a :class:`PlayObservation` for ``observer``. + + Args: + observer: The seat the observation is from the point of view of. + hand: The observer's own remaining cards. + contract: The established :class:`Contract` (supplies trump). + current_trick: Plays made so far in the in-progress trick, a + sequence of :class:`Play`. + completed_tricks: Sequence of completed tricks, each a sequence of + four :class:`Play`. + legal_cards: The observer's legal plays; defaults to the whole hand + (the observer is leading / everything is legal). + bids: The auction history to attach. + """ + hand = tuple(hand) + return PlayObservation( + player=observer, + hand=hand, + contract=contract, + bids=tuple(bids), + completed_tricks=tuple(tuple(trick) for trick in completed_tricks), + current_trick=tuple(current_trick), + legal_cards=tuple(hand if legal_cards is None else legal_cards), + ) + + +# Shorthand card constructors keep the scenario tables readable. +def _c(suit, rank): + return Card(suit, rank) + + +# --------------------------------------------------------------------------- +# Opening and leading leads +# --------------------------------------------------------------------------- + + +class TestOpeningLead: + """The very first card of the round (empty trick, trick 0).""" + + def test_own_contract_plays_strongest_trump(self, players): + north = players["N"] + hand = [ + _c(Suit.SPADES, Rank.JACK), + _c(Suit.SPADES, Rank.ACE), + _c(Suit.HEARTS, Rank.KING), + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + obs = _obs(north, hand, _contract(north, 80, Suit.SPADES)) + result = north.cardplay.choose_card(obs) + assert (result.suit, result.rank) == (Suit.SPADES, Rank.JACK) + + def test_opponents_contract_plays_ace_from_shortest_suit(self, players): + north, east = players["N"], players["E"] + hand = [ + _c(Suit.SPADES, Rank.JACK), + _c(Suit.SPADES, Rank.ACE), + _c(Suit.HEARTS, Rank.KING), + _c(Suit.HEARTS, Rank.TEN), + _c(Suit.DIAMONDS, Rank.ACE), + _c(Suit.DIAMONDS, Rank.EIGHT), + _c(Suit.DIAMONDS, Rank.SEVEN), + _c(Suit.CLUBS, Rank.QUEEN), + ] + # Opponent East declares; aces are ♠A (2-card suit) and ♦A (3-card). + obs = _obs(north, hand, _contract(east, 100, Suit.HEARTS)) + result = north.cardplay.choose_card(obs) + assert (result.suit, result.rank) == (Suit.SPADES, Rank.ACE) + + +class TestSubsequentLead: + """Leading a later trick (empty trick, trick_number > 0).""" + + def _prior_trick(self, players): + """A clean completed trick — four followed hearts, nobody void.""" + return ( + Play(players["N"], _c(Suit.HEARTS, Rank.KING)), + Play(players["E"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.QUEEN)), + Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)), + ) + + def test_leads_strongest_trump_when_opponents_may_hold_trump(self, players): + north = players["N"] + hand = [ + _c(Suit.SPADES, Rank.JACK), + _c(Suit.SPADES, Rank.NINE), + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + # No trumps fell in the prior trick and nobody is void, so the + # opponents may still hold trump — pull it. + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + completed_tricks=[self._prior_trick(players)], + ) + result = north.cardplay.choose_card(obs) + assert (result.suit, result.rank) == (Suit.SPADES, Rank.JACK) + + def test_leads_ace_when_both_opponents_known_void(self, players): + north = players["N"] + # A prior trick where East and West were both compelled to discard + # off-suit (no trump), proving both void — the pull must stop. + both_void = ( + Play(north, _c(Suit.HEARTS, Rank.ACE)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.KING)), + Play(players["W"], _c(Suit.DIAMONDS, Rank.SEVEN)), + ) + hand = [ + _c(Suit.SPADES, Rank.JACK), + _c(Suit.SPADES, Rank.NINE), + _c(Suit.DIAMONDS, Rank.ACE), + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + completed_tricks=[both_void], + ) + result = north.cardplay.choose_card(obs) + # Not a trump — the pull stopped; the ace goes out instead. + assert (result.suit, result.rank) == (Suit.DIAMONDS, Rank.ACE) + + +# --------------------------------------------------------------------------- +# Following: team currently winning +# --------------------------------------------------------------------------- + + +class TestFollowingTeamWinning: + """Partner is the led-suit master, so the seat adds value cheaply.""" + + def _partner_master_spade_lead(self, players): + """Partner (South) leads ♠A and stands as led-suit master.""" + return ( + Play(players["S"], _c(Suit.SPADES, Rank.ACE)), + Play(players["W"], _c(Suit.SPADES, Rank.SEVEN)), + ) + + def test_does_not_waste_trump_behind_master_partner(self, players): + """Cannot follow the led suit but partner is master — discard a + non-trump rather than burn a trump, even a points-rich one.""" + north = players["N"] + hand = [ + _c(Suit.HEARTS, Rank.JACK), # trump — must NOT play + _c(Suit.HEARTS, Rank.NINE), # trump — must NOT play + _c(Suit.DIAMONDS, Rank.KING), + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.HEARTS), + current_trick=self._partner_master_spade_lead(players), + ) + result = north.cardplay.choose_card(obs) + assert (result.suit, result.rank) == (Suit.DIAMONDS, Rank.KING) + + def test_dumps_highest_points_non_trump_non_master(self, players): + north = players["N"] + hand = [ + _c(Suit.HEARTS, Rank.NINE), # trump — must NOT play + _c(Suit.DIAMONDS, Rank.TEN), # 10 points, non-master (♦A still out) + _c(Suit.CLUBS, Rank.EIGHT), # 0 points, non-master + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.HEARTS), + current_trick=self._partner_master_spade_lead(players), + ) + result = north.cardplay.choose_card(obs) + # Highest-points non-trump non-master → ♦10. + assert (result.suit, result.rank) == (Suit.DIAMONDS, Rank.TEN) + + def test_prefers_non_master_over_master_in_discard(self, players): + north = players["N"] + hand = [ + _c(Suit.CLUBS, Rank.ACE), # master (no higher club) + _c(Suit.DIAMONDS, Rank.KING), # non-master (♦A still out) + _c(Suit.HEARTS, Rank.NINE), # trump + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.HEARTS), + current_trick=self._partner_master_spade_lead(players), + ) + result = north.cardplay.choose_card(obs) + # ♣A preserved (master), ♥9 preserved (trump), ♦K dumped. + assert (result.suit, result.rank) == (Suit.DIAMONDS, Rank.KING) + + def test_falls_back_to_lowest_trump_when_only_trumps_left(self, players): + north = players["N"] + hand = [ + _c(Suit.HEARTS, Rank.JACK), # top trump + _c(Suit.HEARTS, Rank.NINE), # 2nd-best + _c(Suit.HEARTS, Rank.SEVEN), # lowest + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.HEARTS), + current_trick=self._partner_master_spade_lead(players), + ) + result = north.cardplay.choose_card(obs) + assert (result.suit, result.rank) == (Suit.HEARTS, Rank.SEVEN) + + def test_follows_suit_with_highest_points(self, players): + north = players["N"] + # Partner (South) leads ♥Q and is led-suit master; ♠ is trump. + current = ( + Play(players["S"], _c(Suit.HEARTS, Rank.QUEEN)), + Play(players["W"], _c(Suit.HEARTS, Rank.SEVEN)), + ) + hand = [ + _c(Suit.HEARTS, Rank.KING), + _c(Suit.HEARTS, Rank.TEN), + _c(Suit.HEARTS, Rank.EIGHT), + _c(Suit.SPADES, Rank.ACE), + ] + playable = [ + _c(Suit.HEARTS, Rank.KING), + _c(Suit.HEARTS, Rank.TEN), + _c(Suit.HEARTS, Rank.EIGHT), + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + legal_cards=playable, + ) + result = north.cardplay.choose_card(obs) + # ♥10 is worth the most points among the followable hearts. + assert (result.suit, result.rank) == (Suit.HEARTS, Rank.TEN) + + def test_follow_suit_keeps_new_master_gives_next_highest(self, players): + """Partner's ♠A makes the seat's ♠10 master — keep it, give ♠K.""" + north = players["N"] + hand = [ + _c(Suit.SPADES, Rank.TEN), # new master once ♠A falls — keep + _c(Suit.SPADES, Rank.KING), + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + playable = [ + _c(Suit.SPADES, Rank.TEN), + _c(Suit.SPADES, Rank.KING), + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.HEARTS), + current_trick=self._partner_master_spade_lead(players), + legal_cards=playable, + ) + result = north.cardplay.choose_card(obs) + # ♠10 preserved to win a later spade trick; ♠K piles 4 points. + assert (result.suit, result.rank) == (Suit.SPADES, Rank.KING) + + def test_follow_suit_preserves_master_even_at_zero_points(self, players): + """The master is kept even when the only alternative adds nothing.""" + north = players["N"] + hand = [ + _c(Suit.SPADES, Rank.TEN), # new master once ♠A falls — keep + _c(Suit.SPADES, Rank.SEVEN), # 0 points — given anyway + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + playable = [ + _c(Suit.SPADES, Rank.TEN), + _c(Suit.SPADES, Rank.SEVEN), + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.HEARTS), + current_trick=self._partner_master_spade_lead(players), + legal_cards=playable, + ) + result = north.cardplay.choose_card(obs) + assert (result.suit, result.rank) == (Suit.SPADES, Rank.SEVEN) + + def test_follow_suit_forced_master_when_only_suit_card(self, players): + """Sole card of the led suit is the master — forced fallback plays it.""" + north = players["N"] + hand = [ + _c(Suit.SPADES, Rank.TEN), + _c(Suit.DIAMONDS, Rank.EIGHT), + _c(Suit.CLUBS, Rank.SEVEN), + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.HEARTS), + current_trick=self._partner_master_spade_lead(players), + legal_cards=[_c(Suit.SPADES, Rank.TEN)], + ) + result = north.cardplay.choose_card(obs) + assert (result.suit, result.rank) == (Suit.SPADES, Rank.TEN) + + def test_trump_led_follow_preserves_new_trump_master(self, players): + """Partner's trump Jack makes the seat's 9 master — pile the Ace.""" + north = players["N"] + # Partner (South) leads the trump Jack; West follows low. + current = ( + Play(players["S"], _c(Suit.HEARTS, Rank.JACK)), + Play(players["W"], _c(Suit.HEARTS, Rank.SEVEN)), + ) + hand = [ + _c(Suit.HEARTS, Rank.NINE), # new trump master once ♥J falls — keep + _c(Suit.HEARTS, Rank.ACE), + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + playable = [ + _c(Suit.HEARTS, Rank.NINE), + _c(Suit.HEARTS, Rank.ACE), + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.HEARTS), + current_trick=current, + legal_cards=playable, + ) + result = north.cardplay.choose_card(obs) + # ♥9 (14 trump points) outscores ♥A (11) but is the new master — + # the Ace goes onto partner's locked trick instead. + assert (result.suit, result.rank) == (Suit.HEARTS, Rank.ACE) + + def test_follows_low_when_opponent_cut_expected(self, players): + """Partner holds the trick but East (void in the led suit, maybe + holding trump) still has to play — the trick is presumed lost, so + stop piling points on it and follow with the cheapest card.""" + north = players["N"] + # Prior trick: East discards a club behind its master partner West + # on a heart lead — proven void in ♥, trump holding still unknown. + prior = ( + Play(players["W"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["N"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.NINE)), + ) + # Partner South leads ♥K and stands master; East plays after us. + current = ( + Play(players["S"], _c(Suit.HEARTS, Rank.KING)), + Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)), + ) + hand = [ + _c(Suit.HEARTS, Rank.QUEEN), + _c(Suit.HEARTS, Rank.JACK), + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + playable = [_c(Suit.HEARTS, Rank.QUEEN), _c(Suit.HEARTS, Rank.JACK)] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + completed_tricks=[prior], + legal_cards=playable, + ) + result = north.cardplay.choose_card(obs) + # Without the anticipation rule the ♥Q (3 points) would pile on; + # with East expected to ruff, the ♥J (2 points) is conceded instead. + assert (result.suit, result.rank) == (Suit.HEARTS, Rank.JACK) + + def test_discards_low_when_opponent_cut_expected(self, players): + """Same predicted ruff, but the seat cannot follow — the discard + turns cheap instead of feeding the cutter the fattest side card.""" + north = players["N"] + prior = ( + Play(players["W"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["N"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.NINE)), + ) + current = ( + Play(players["S"], _c(Suit.HEARTS, Rank.KING)), + Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)), + ) + hand = [ + _c(Suit.DIAMONDS, Rank.TEN), # non-master (♦A still out) + _c(Suit.CLUBS, Rank.EIGHT), # non-master, 0 points + _c(Suit.SPADES, Rank.NINE), # trump — still never dumped here + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + completed_tricks=[prior], + ) + result = north.cardplay.choose_card(obs) + # Without the rule the ♦10 (10 points) would be piled on; with the + # ruff expected the worthless ♣8 goes instead. + assert (result.suit, result.rank) == (Suit.CLUBS, Rank.EIGHT) + + +# --------------------------------------------------------------------------- +# Following: team currently losing +# --------------------------------------------------------------------------- + + +class TestFollowingTeamLosing: + """An opponent holds the led-suit master — try to take the trick.""" + + def test_follows_suit_and_beats_when_able(self, players): + north = players["N"] + # West (opponent) leads ♥K; North is 2nd to act. + current = (Play(players["W"], _c(Suit.HEARTS, Rank.KING)),) + hand = [ + _c(Suit.HEARTS, Rank.ACE), + _c(Suit.HEARTS, Rank.EIGHT), + _c(Suit.SPADES, Rank.JACK), + ] + playable = [_c(Suit.HEARTS, Rank.ACE), _c(Suit.HEARTS, Rank.EIGHT)] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + legal_cards=playable, + ) + result = north.cardplay.choose_card(obs) + assert (result.suit, result.rank) == (Suit.HEARTS, Rank.ACE) + + def test_follows_suit_low_when_cannot_beat(self, players): + north = players["N"] + current = (Play(players["W"], _c(Suit.HEARTS, Rank.ACE)),) + hand = [ + _c(Suit.HEARTS, Rank.JACK), + _c(Suit.HEARTS, Rank.EIGHT), + _c(Suit.SPADES, Rank.JACK), + ] + playable = [_c(Suit.HEARTS, Rank.JACK), _c(Suit.HEARTS, Rank.EIGHT)] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + legal_cards=playable, + ) + result = north.cardplay.choose_card(obs) + # Cannot beat ♥A — throw the lowest heart by points. + assert (result.suit, result.rank) == (Suit.HEARTS, Rank.EIGHT) + + def test_trumps_with_lowest_winning_trump(self, players): + north = players["N"] + # West leads ♥K; North is void in hearts and can ruff. + current = (Play(players["W"], _c(Suit.HEARTS, Rank.KING)),) + hand = [ + _c(Suit.SPADES, Rank.JACK), + _c(Suit.SPADES, Rank.NINE), + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + ) + result = north.cardplay.choose_card(obs) + # Both trumps beat a bare heart; the lowest winning trump goes in. + assert (result.suit, result.rank) == (Suit.SPADES, Rank.NINE) + + def test_discards_lowest_short_suit_when_cannot_follow_or_trump( + self, players + ): + north = players["N"] + # West leads ♥K; ♠ trump; North holds neither hearts nor spades. + current = (Play(players["W"], _c(Suit.HEARTS, Rank.KING)),) + # ♣A would be master — a real ♦7 (shortest suit, non-master) goes. + prior = ( + Play(players["N"], _c(Suit.DIAMONDS, Rank.ACE)), + Play(players["E"], _c(Suit.DIAMONDS, Rank.KING)), + Play(players["S"], _c(Suit.DIAMONDS, Rank.TEN)), + Play(players["W"], _c(Suit.DIAMONDS, Rank.QUEEN)), + ) + hand = [ + _c(Suit.DIAMONDS, Rank.SEVEN), + _c(Suit.CLUBS, Rank.QUEEN), + _c(Suit.CLUBS, Rank.JACK), + _c(Suit.CLUBS, Rank.TEN), + ] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + completed_tricks=[prior], + ) + result = north.cardplay.choose_card(obs) + # Diamonds is the shortest suit; ♦7 is its lowest non-master card. + assert (result.suit, result.rank) == (Suit.DIAMONDS, Rank.SEVEN) + + def test_beats_with_smallest_winning_card_when_cut_expected(self, players): + """East (void in the led suit, trump holding unknown) plays after + us — invest the cheapest card that still beats the current best, + not the fattest, since a ruff would capture whatever we spend.""" + north = players["N"] + # Prior trick: East discards a club behind its master partner West + # on a heart lead — proven void in ♥ but not in trump. + prior = ( + Play(players["W"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["N"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.NINE)), + ) + # West leads ♥8 and is winning; East and partner play after us. + current = (Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)),) + hand = [ + _c(Suit.HEARTS, Rank.QUEEN), + _c(Suit.HEARTS, Rank.TEN), + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + playable = [_c(Suit.HEARTS, Rank.QUEEN), _c(Suit.HEARTS, Rank.TEN)] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + completed_tricks=[prior], + legal_cards=playable, + ) + result = north.cardplay.choose_card(obs) + # Both hearts beat the ♥8; without the rule the ♥10 (10 points) + # would go in — with the ruff expected the ♥Q hedges instead. + assert (result.suit, result.rank) == (Suit.HEARTS, Rank.QUEEN) + + def test_beats_with_highest_points_when_cutter_known_trumpless(self, players): + """The same shape stops firing once East is proven void in trump + too — no ruff can come, so the fat winner goes in as usual.""" + north = players["N"] + hearts_void = ( + Play(players["W"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["N"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.NINE)), + ) + # Trump (♠) lead on which East shows out — East proven trumpless. + trump_void = ( + Play(players["N"], _c(Suit.SPADES, Rank.ACE)), + Play(players["E"], _c(Suit.DIAMONDS, Rank.SEVEN)), + Play(players["S"], _c(Suit.SPADES, Rank.SEVEN)), + Play(players["W"], _c(Suit.SPADES, Rank.EIGHT)), + ) + current = (Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)),) + hand = [ + _c(Suit.HEARTS, Rank.QUEEN), + _c(Suit.HEARTS, Rank.TEN), + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + playable = [_c(Suit.HEARTS, Rank.QUEEN), _c(Suit.HEARTS, Rank.TEN)] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + completed_tricks=[hearts_void, trump_void], + legal_cards=playable, + ) + result = north.cardplay.choose_card(obs) + assert (result.suit, result.rank) == (Suit.HEARTS, Rank.TEN) + + +# --------------------------------------------------------------------------- +# Routing: opening vs leading vs following +# --------------------------------------------------------------------------- + + +class TestRouting: + """``choose_card`` routes purely on the trick shape, not on tracking.""" + + def _hand(self): + # Aces sit in a 1-card suit (♠) and a 2-card suit (♦): opening picks + # the shortest-suit ace, leading picks the longest-suit ace. + return [ + _c(Suit.SPADES, Rank.ACE), + _c(Suit.DIAMONDS, Rank.ACE), + _c(Suit.DIAMONDS, Rank.EIGHT), + ] + + def test_empty_trick_zero_takes_the_opening_path(self, players): + north, east = players["N"], players["E"] + obs = _obs(north, self._hand(), _contract(east, 100, Suit.HEARTS)) + assert obs.trick_number == 0 + result = north.cardplay.choose_card(obs) + # Opening → ace from the shortest suit (♠A). + assert (result.suit, result.rank) == (Suit.SPADES, Rank.ACE) + + def test_empty_trick_after_history_takes_the_leading_path(self, players): + north, east = players["N"], players["E"] + prior = ( + Play(players["N"], _c(Suit.HEARTS, Rank.KING)), + Play(players["E"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.QUEEN)), + Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)), + ) + obs = _obs( + north, + self._hand(), + _contract(east, 100, Suit.HEARTS), + completed_tricks=[prior], + ) + assert obs.trick_number == 1 + result = north.cardplay.choose_card(obs) + # Leading → ace from the longest suit (♦A). + assert (result.suit, result.rank) == (Suit.DIAMONDS, Rank.ACE) + + def test_non_empty_trick_takes_the_following_path(self, players): + north = players["N"] + # Opponent West leads ♥A; North can only follow low. + current = (Play(players["W"], _c(Suit.HEARTS, Rank.ACE)),) + hand = [_c(Suit.HEARTS, Rank.SEVEN), _c(Suit.HEARTS, Rank.EIGHT)] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + current_trick=current, + ) + result = north.cardplay.choose_card(obs) + # Following a losing trick it cannot beat → lowest heart. + assert (result.suit, result.rank) == (Suit.HEARTS, Rank.SEVEN) + + +# --------------------------------------------------------------------------- +# Parity suite: _derive_tracking rebuilds fallen cards and trump voids +# --------------------------------------------------------------------------- + + +class TestDeriveTracking: + """The replay of the public history must reconstruct exactly the + fallen-card map and per-player void-suit map a per-card tracker + would accumulate. + """ + + def test_fallen_counts_every_card_including_own_plays(self, players): + north = players["N"] + completed = ( + Play(players["N"], _c(Suit.HEARTS, Rank.KING)), + Play(players["E"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.EIGHT)), + Play(players["W"], _c(Suit.HEARTS, Rank.NINE)), + ) + current = (Play(players["N"], _c(Suit.CLUBS, Rank.ACE)),) + obs = _obs( + north, + [_c(Suit.CLUBS, Rank.ACE)], + _contract(north, 100, Suit.SPADES), + completed_tricks=[completed], + current_trick=current, + ) + fallen, voids = north.cardplay._derive_tracking(obs) + assert fallen[Suit.HEARTS] == {Rank.KING, Rank.SEVEN, Rank.EIGHT, Rank.NINE} + # North's own club is counted just like everyone else's cards. + assert fallen[Suit.CLUBS] == {Rank.ACE} + assert fallen[Suit.SPADES] == set() + assert fallen[Suit.DIAMONDS] == set() + assert voids == {} + + def test_partner_master_discard_proves_no_void(self, players): + """East discards behind its master partner West — proves nothing.""" + north = players["N"] + # West leads ♥A (master); East discards a club while West is master. + completed = ( + Play(players["W"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["N"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.EIGHT)), + ) + obs = _obs( + north, + [_c(Suit.SPADES, Rank.JACK)], + _contract(north, 100, Suit.SPADES), + completed_tricks=[completed], + ) + fallen, voids = north.cardplay._derive_tracking(obs) + assert Rank.SEVEN in fallen[Suit.CLUBS] + # Partner-master exemption: the heart void is proven, trump isn't. + assert Suit.SPADES not in voids[players["E"]] + assert Suit.HEARTS in voids[players["E"]] + + def test_trump_lead_off_suit_always_proves_void(self, players): + """On a trump lead the partner-master exemption does not apply.""" + north = players["N"] + # East leads ♠A (trump); West (East's partner) is master, yet East's + # partner-master status must NOT excuse a later off-trump card. Here + # South discards off-trump on the trump lead → South is void. + completed = ( + Play(players["E"], _c(Suit.SPADES, Rank.ACE)), + Play(players["S"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["W"], _c(Suit.SPADES, Rank.SEVEN)), + Play(players["N"], _c(Suit.SPADES, Rank.EIGHT)), + ) + obs = _obs( + north, + [_c(Suit.CLUBS, Rank.JACK)], + _contract(north, 100, Suit.SPADES), + completed_tricks=[completed], + ) + _, voids = north.cardplay._derive_tracking(obs) + assert Suit.SPADES in voids[players["S"]] + + def test_pre_play_winner_is_read_before_the_play_lands(self, players): + """The off-by-one case: a seat compelled to discard is void even + when its partner becomes master only through a LATER play.""" + north = players["N"] + # ♠ trump, ♥ led by North. + # 0 N ♥K (leads) + # 1 E ♣7 → prior master is North (opponent of East) → E void + # 2 S ♥Q (follows) + # 3 W ♠7 → West (East's partner) ruffs and becomes master + # Judged post-hoc, East's partner ends the trick master and the void + # would vanish; judged pre-play (correctly), East was compelled. + completed = ( + Play(players["N"], _c(Suit.HEARTS, Rank.KING)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.QUEEN)), + Play(players["W"], _c(Suit.SPADES, Rank.SEVEN)), + ) + obs = _obs( + north, + [_c(Suit.DIAMONDS, Rank.ACE)], + _contract(north, 100, Suit.SPADES), + completed_tricks=[completed], + ) + fallen, voids = north.cardplay._derive_tracking(obs) + assert Suit.SPADES in voids[players["E"]] + assert fallen[Suit.CLUBS] == {Rank.SEVEN} + assert fallen[Suit.SPADES] == {Rank.SEVEN} + + def test_derivation_matches_a_real_play_state_projection(self, players): + """A history-seeded observation and one projected from a bare + ``PlayState`` derive the same tracking — the two construction + routes agree.""" + north = players["N"] + order = (players["N"], players["E"], players["S"], players["W"]) + # A completed trick with a compelled off-suit discard by East. + plays = ( + Play(players["N"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.KING)), + Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)), + ) + contract = _contract(north, 100, Suit.SPADES) + # Remaining hands are irrelevant to derivation; give North a spare. + hands = ( + (_c(Suit.SPADES, Rank.JACK),), + (_c(Suit.SPADES, Rank.NINE),), + (_c(Suit.SPADES, Rank.SEVEN),), + (_c(Suit.SPADES, Rank.EIGHT),), + ) + state = PlayState(contract, order, hands, plays) + projected = state.observe(north, bids=()) + + seeded = _obs( + north, + [_c(Suit.SPADES, Rank.JACK)], + contract, + completed_tricks=[plays], + ) + + assert north.cardplay._derive_tracking(projected) == ( + north.cardplay._derive_tracking(seeded) + ) + + +# --------------------------------------------------------------------------- +# Suit-void tracking and the anticipated-ruff predicate +# --------------------------------------------------------------------------- + + +class TestSuitVoidTracking: + """Any non-follow proves a led-suit void — the voids map records, per + player, every suit that player has been seen unable to follow. The + trump entry keeps its partner-master-exemption inference; led-suit + entries need no exemption because following suit is never optional. + """ + + def test_discard_records_led_suit_void_without_trump_void(self, players): + """East discards behind its master partner: the heart void is + proven, the trump holding stays unknown (exemption applies).""" + north = players["N"] + completed = ( + Play(players["W"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["N"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.NINE)), + ) + obs = _obs( + north, + [_c(Suit.SPADES, Rank.JACK)], + _contract(north, 100, Suit.SPADES), + completed_tricks=[completed], + ) + _, voids = north.cardplay._derive_tracking(obs) + assert Suit.HEARTS in voids[players["E"]] + assert Suit.SPADES not in voids[players["E"]] + + def test_ruff_records_led_suit_void(self, players): + """A ruff is a non-follow too: West cutting a heart lead proves + the heart void (and obviously proves nothing against trump).""" + north = players["N"] + completed = ( + Play(players["N"], _c(Suit.HEARTS, Rank.KING)), + Play(players["E"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.QUEEN)), + Play(players["W"], _c(Suit.SPADES, Rank.SEVEN)), + ) + obs = _obs( + north, + [_c(Suit.DIAMONDS, Rank.ACE)], + _contract(north, 100, Suit.SPADES), + completed_tricks=[completed], + ) + _, voids = north.cardplay._derive_tracking(obs) + assert Suit.HEARTS in voids[players["W"]] + assert Suit.SPADES not in voids[players["W"]] + + +class TestOpponentCutExpected: + """``_opponent_cut_expected`` — will an opponent still to play in this + trick ruff it? True only when that opponent is proven void in the led + suit, is not proven void in trump, and a trump is still unseen. + """ + + def _cut_expected(self, players, completed, current, hand): + """Derive tracking from a built observation and ask the predicate.""" + north = players["N"] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + completed_tricks=completed, + current_trick=current, + ) + strat = north.cardplay + fallen, voids = strat._derive_tracking(obs) + return strat._opponent_cut_expected(obs, fallen, voids) + + # A prior trick proving East void in hearts but not in trump: East + # discards a club behind its master partner West. + def _east_hearts_void(self, players): + return ( + Play(players["W"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["N"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.NINE)), + ) + + def test_fires_for_suit_void_opponent_yet_to_play(self, players): + assert self._cut_expected( + players, + [self._east_hearts_void(players)], + (Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)),), + [_c(Suit.HEARTS, Rank.QUEEN)], + ) is True + + def test_silent_when_the_void_opponent_already_played(self, players): + """West is the void seat here and has already discarded into the + current trick — nobody left behind us can ruff.""" + # West discards behind its master partner East → ♥ void only. + prior = ( + Play(players["N"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["E"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["S"], _c(Suit.HEARTS, Rank.NINE)), + Play(players["W"], _c(Suit.CLUBS, Rank.SEVEN)), + ) + current = ( + Play(players["E"], _c(Suit.HEARTS, Rank.KING)), + Play(players["S"], _c(Suit.HEARTS, Rank.EIGHT)), + Play(players["W"], _c(Suit.DIAMONDS, Rank.SEVEN)), + ) + assert self._cut_expected( + players, [prior], current, [_c(Suit.HEARTS, Rank.QUEEN)] + ) is False + + def test_silent_when_the_cutter_is_proven_trumpless(self, players): + trump_void = ( + Play(players["N"], _c(Suit.SPADES, Rank.ACE)), + Play(players["E"], _c(Suit.DIAMONDS, Rank.SEVEN)), + Play(players["S"], _c(Suit.SPADES, Rank.SEVEN)), + Play(players["W"], _c(Suit.SPADES, Rank.EIGHT)), + ) + assert self._cut_expected( + players, + [self._east_hearts_void(players), trump_void], + (Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)),), + [_c(Suit.HEARTS, Rank.QUEEN)], + ) is False + + def test_silent_when_no_trump_is_unseen(self, players): + """Counting kills the prediction: four trumps fell and the seat + holds the other four, so East cannot be sitting on one.""" + spades_round = ( + Play(players["N"], _c(Suit.SPADES, Rank.ACE)), + Play(players["E"], _c(Suit.SPADES, Rank.KING)), + Play(players["S"], _c(Suit.SPADES, Rank.QUEEN)), + Play(players["W"], _c(Suit.SPADES, Rank.TEN)), + ) + hand = [ + _c(Suit.SPADES, Rank.JACK), + _c(Suit.SPADES, Rank.NINE), + _c(Suit.SPADES, Rank.EIGHT), + _c(Suit.SPADES, Rank.SEVEN), + _c(Suit.HEARTS, Rank.QUEEN), + ] + assert self._cut_expected( + players, + [self._east_hearts_void(players), spades_round], + (Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)),), + hand, + ) is False + + def test_silent_on_a_trump_lead(self, players): + """No ruff exists when trump itself is led.""" + assert self._cut_expected( + players, + [self._east_hearts_void(players)], + (Play(players["W"], _c(Suit.SPADES, Rank.EIGHT)),), + [_c(Suit.SPADES, Rank.QUEEN)], + ) is False + + def test_silent_when_only_the_partner_is_void(self, players): + """A void partner ruffing is good news, not a threat.""" + partner_void = ( + Play(players["W"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["N"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["S"], _c(Suit.CLUBS, Rank.SEVEN)), + Play(players["E"], _c(Suit.HEARTS, Rank.NINE)), + ) + assert self._cut_expected( + players, + [partner_void], + (Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)),), + [_c(Suit.HEARTS, Rank.QUEEN)], + ) is False + + +# --------------------------------------------------------------------------- +# Parity suite: the trump-pull inference reads derived voids / fallen counts +# --------------------------------------------------------------------------- + + +class TestTrumpPullInference: + """``_opponents_might_have_trump`` fed the tracking the replay derives. + + Each case seeds the inference through real completed-trick history and + asserts the pull decision, never by poking a void set onto the strategy. + """ + + def _derive(self, players, completed, hand): + north = players["N"] + obs = _obs( + north, + hand, + _contract(north, 100, Suit.SPADES), + completed_tricks=completed, + ) + return north.cardplay, north.cardplay._derive_tracking(obs), obs.hand + + def test_both_opponents_void_stops_the_pull(self, players): + north = players["N"] + both_void = ( + Play(north, _c(Suit.HEARTS, Rank.ACE)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), # East void + Play(players["S"], _c(Suit.HEARTS, Rank.KING)), + Play(players["W"], _c(Suit.DIAMONDS, Rank.SEVEN)), # West void + ) + hand = [_c(Suit.SPADES, Rank.JACK)] + strat, (fallen, voids), obs_hand = self._derive( + players, [both_void], hand + ) + assert Suit.SPADES in voids[players["E"]] + assert Suit.SPADES in voids[players["W"]] + assert strat._opponents_might_have_trump( + Suit.SPADES, fallen, voids, obs_hand + ) is False + + def test_one_opponent_void_keeps_the_pull(self, players): + north = players["N"] + one_void = ( + Play(north, _c(Suit.HEARTS, Rank.ACE)), + Play(players["E"], _c(Suit.CLUBS, Rank.SEVEN)), # East void + Play(players["S"], _c(Suit.HEARTS, Rank.KING)), + Play(players["W"], _c(Suit.HEARTS, Rank.SEVEN)), # West follows + ) + hand = [_c(Suit.SPADES, Rank.JACK)] + strat, (fallen, voids), obs_hand = self._derive( + players, [one_void], hand + ) + assert Suit.SPADES in voids[players["E"]] + assert players["W"] not in voids + assert strat._opponents_might_have_trump( + Suit.SPADES, fallen, voids, obs_hand + ) is True + + def test_partner_void_does_not_stop_the_pull(self, players): + north = players["N"] + # East leads ♥A; South (North's partner) is compelled void; the two + # opponents are not — a void partner says nothing about them. + partner_void = ( + Play(players["E"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["S"], _c(Suit.CLUBS, Rank.SEVEN)), # South void + Play(players["W"], _c(Suit.HEARTS, Rank.KING)), + Play(north, _c(Suit.HEARTS, Rank.SEVEN)), + ) + hand = [_c(Suit.SPADES, Rank.JACK)] + strat, (fallen, voids), obs_hand = self._derive( + players, [partner_void], hand + ) + assert Suit.SPADES in voids[players["S"]] + assert strat._opponents_might_have_trump( + Suit.SPADES, fallen, voids, obs_hand + ) is True + + def test_counting_alone_stops_the_pull(self, players): + north = players["N"] + # Six spades fall across two tricks; North holds the other two, so + # no unseen trump remains even without any void knowledge. + trick_a = ( + Play(players["N"], _c(Suit.SPADES, Rank.KING)), + Play(players["E"], _c(Suit.SPADES, Rank.QUEEN)), + Play(players["S"], _c(Suit.SPADES, Rank.TEN)), + Play(players["W"], _c(Suit.SPADES, Rank.EIGHT)), + ) + trick_b = ( + Play(players["N"], _c(Suit.SPADES, Rank.ACE)), + Play(players["E"], _c(Suit.SPADES, Rank.SEVEN)), + Play(players["S"], _c(Suit.HEARTS, Rank.SEVEN)), + Play(players["W"], _c(Suit.HEARTS, Rank.EIGHT)), + ) + hand = [_c(Suit.SPADES, Rank.JACK), _c(Suit.SPADES, Rank.NINE)] + strat, (fallen, voids), obs_hand = self._derive( + players, [trick_a, trick_b], hand + ) + assert len(fallen[Suit.SPADES]) == 6 + assert strat._opponents_might_have_trump( + Suit.SPADES, fallen, voids, obs_hand + ) is False + + +# --------------------------------------------------------------------------- +# Pure helper unit tests (fallen / plays threaded explicitly) +# --------------------------------------------------------------------------- + + +class TestPureHelpers: + """The trick-reading helpers now take their tracking / plays as + arguments and are exercised directly.""" + + @pytest.fixture + def strat(self, players): + """North's card-play strategy, host of the helpers under test.""" + return players["N"].cardplay + + def _fallen(self, suit=None, ranks=()): + base = {s: set() for s in (Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS)} + if suit is not None: + base[suit] = set(ranks) + return base + + def test_is_master_card_reads_the_fallen_map(self, strat): + fallen = self._fallen(Suit.HEARTS, {Rank.ACE, Rank.QUEEN, Rank.EIGHT}) + # ♥10's only higher card (♥A) has fallen → master. + assert strat._is_master_card(_c(Suit.HEARTS, Rank.TEN), Suit.SPADES, fallen) is True + # ♥K still has ♥10 out → not master. + assert strat._is_master_card(_c(Suit.HEARTS, Rank.KING), Suit.SPADES, fallen) is False + + def test_higher_ranks_respect_trump_vs_normal_order(self, strat): + normal = strat._get_higher_ranks(Rank.NINE, Suit.HEARTS, Suit.SPADES) + assert Rank.JACK in normal and Rank.ACE in normal + trump = strat._get_higher_ranks(Rank.NINE, Suit.SPADES, Suit.SPADES) + assert Rank.JACK in trump and Rank.ACE not in trump # 9 outranks A in trump + + def test_team_winning_reads_the_led_suit_master(self, strat, players): + partner_master = ( + Play(players["S"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["W"], _c(Suit.HEARTS, Rank.KING)), + ) + assert strat._is_team_winning_trick(partner_master) is True + opponent_master = ( + Play(players["W"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["S"], _c(Suit.HEARTS, Rank.KING)), + ) + assert strat._is_team_winning_trick(opponent_master) is False + + def test_strongest_card_with_trump_on_the_table(self, strat, players): + plays = ( + Play(players["N"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["E"], _c(Suit.HEARTS, Rank.KING)), + Play(players["S"], _c(Suit.SPADES, Rank.EIGHT)), + ) + best = strat._get_strongest_card_in_trick(plays, Suit.SPADES) + assert (best.suit, best.rank) == (Suit.SPADES, Rank.EIGHT) + + def test_strongest_card_without_trump(self, strat, players): + plays = ( + Play(players["N"], _c(Suit.HEARTS, Rank.KING)), + Play(players["E"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["S"], _c(Suit.DIAMONDS, Rank.ACE)), + ) + best = strat._get_strongest_card_in_trick(plays, Suit.SPADES) + assert (best.suit, best.rank) == (Suit.HEARTS, Rank.ACE) + + def test_can_trump_win_reads_the_plays(self, strat, players): + plays = ( + Play(players["N"], _c(Suit.HEARTS, Rank.ACE)), + Play(players["E"], _c(Suit.SPADES, Rank.EIGHT)), + ) + assert strat._can_trump_win(_c(Suit.SPADES, Rank.JACK), plays, Suit.SPADES) is True + assert strat._can_trump_win(_c(Suit.SPADES, Rank.SEVEN), plays, Suit.SPADES) is False + + def test_is_stronger_card_comparison(self, strat): + # Trump beats non-trump. + assert strat._is_stronger_card( + _c(Suit.SPADES, Rank.SEVEN), _c(Suit.HEARTS, Rank.ACE), Suit.SPADES + ) is True + # Higher of the same suit. + assert strat._is_stronger_card( + _c(Suit.HEARTS, Rank.ACE), _c(Suit.HEARTS, Rank.KING), Suit.SPADES + ) is True + # Higher trump beats lower trump. + assert strat._is_stronger_card( + _c(Suit.SPADES, Rank.JACK), _c(Suit.SPADES, Rank.NINE), Suit.SPADES + ) is True diff --git a/packages/contrai-engine/tests/test_view/conftest.py b/packages/contrai-engine/tests/test_view/conftest.py new file mode 100644 index 0000000..ab8d803 --- /dev/null +++ b/packages/contrai-engine/tests/test_view/conftest.py @@ -0,0 +1,29 @@ +"""Shared fixtures for the view-layer tests. + +The ``rich_view`` module was split into focused sub-modules +(``theme`` / ``formatting`` / ``parsing`` / ``bidding_rules`` / +``state_helpers`` / ``layout`` / ``screens``), and the test suite mirrors +that split into one file per module. The ``four_players`` quartet is used +across several of them, so it lives here. +""" + +from __future__ import annotations + +import pytest + +from contrai_engine.model.player import AiPlayer +from contrai_core.team import Team + + +@pytest.fixture +def four_players(): + """A North/East/South/West quartet wired into N-S and E-W teams.""" + north = AiPlayer("North", "North") + east = AiPlayer("East", "East") + south = AiPlayer("South", "South") + west = AiPlayer("West", "West") + ns = Team("North-South", [north, south]) + ew = Team("East-West", [east, west]) + north.team = south.team = ns + east.team = west.team = ew + return north, east, south, west diff --git a/packages/contrai-engine/tests/test_view/test_bidding.py b/packages/contrai-engine/tests/test_view/test_bidding.py new file mode 100644 index 0000000..941ac44 --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_bidding.py @@ -0,0 +1,195 @@ +"""Tests for the bidding screen in :mod:`contrai_engine.view.screens.bidding`. + +Covers the branchy parts: the latest-bid-per-seat collapse in the +bidding diamond, the line-break-every-four layout of the history panel, +and — most importantly — the adaptive prompt whose hints are derived +from :meth:`Auction.legal_actions` (cheapest legal raise, the +double/redouble branches, the past-180 Slam-only tail). +""" + +from __future__ import annotations + +from contrai_core import Auction, Suit +from contrai_core.bid import ContractBid, DoubleBid, PassBid + +from contrai_engine.view.screens.bidding import ( + _ai_bid_announcement, + _bid_rejection_text, + _bidding_prompt_text, + _panel_bidding_history, + _render_bidding_diamond, +) + + +class TestRenderBiddingDiamond: + """Each seat shows its *latest* bid; pending shows ?, un-bid shows ·.""" + + def test_later_bid_by_same_seat_overwrites_earlier(self, four_players): + north, east, south, west = four_players + history = [ + ContractBid(north, 80, Suit.HEARTS), + PassBid(east), + ContractBid(south, 100, Suit.HEARTS), + PassBid(west), + ContractBid(north, 110, Suit.HEARTS), + ] + text = _render_bidding_diamond( + history, pending_position=None, width=42 + ).plain + assert "N 110 ♥" in text + assert "80" not in text # overwritten by North's later raise + assert "S 100 ♥" in text + assert "W Pass" in text + + def test_pending_seat_shows_question_mark_over_its_bid( + self, four_players + ): + north, east, *_ = four_players + history = [ContractBid(north, 80, Suit.HEARTS), PassBid(east)] + text = _render_bidding_diamond( + history, pending_position="East", width=42 + ).plain + # East already passed, but as the seat about to act it reads "?". + assert "E ?" in text + assert "E Pass" not in text + + def test_seats_without_a_bid_show_a_dot(self, four_players): + north, *_ = four_players + history = [ContractBid(north, 80, Suit.HEARTS)] + text = _render_bidding_diamond( + history, pending_position="East", width=42 + ).plain + assert "S ·" in text + assert "W ·" in text + + +class TestPanelBiddingHistory: + """Chronological history: #N gutter every four bids, fixed lanes.""" + + def test_empty_history_shows_placeholder(self): + text = _panel_bidding_history([]).renderable.plain + assert "(no bids yet)" in text + + def test_breaks_line_every_four_bids_with_round_gutters( + self, four_players + ): + north, east, south, west = four_players + bids = [ + ContractBid(south, 80, Suit.HEARTS), + PassBid(west), + PassBid(north), + ContractBid(east, 90, Suit.SPADES), + ContractBid(south, 100, Suit.HEARTS), + ] + text = _panel_bidding_history(bids).renderable.plain + lines = text.splitlines() + assert len(lines) == 2 + assert lines[0].startswith("#1") + assert lines[1].startswith("#2") + # The fifth bid opens round #2 on its own line. + assert "S 100 ♥" in lines[1] + + +class TestBiddingPromptText: + """The adaptive hint only advertises actions that are legal.""" + + def test_fresh_auction_advertises_the_80_floor_and_no_double( + self, four_players + ): + *_ , south, _west = four_players + text = _bidding_prompt_text(Auction.empty(), south).plain + assert "'80 H'" in text + assert "'pass'" in text + assert "double" not in text # nothing to double yet + + def test_worked_example_tracks_the_cheapest_legal_raise( + self, four_players + ): + _north, _east, south, west = four_players + auction = Auction.empty().apply(ContractBid(south, 90, Suit.HEARTS)) + text = _bidding_prompt_text(auction, west).plain + # 90 stands: the example shows 100, never the bare 80 floor. + assert "S bid 90 ♥" in text + assert "'100 H'" in text + assert "'80 H'" not in text + assert "'double'" in text # opponent contract → double is legal + + def test_no_double_hint_against_your_own_sides_contract( + self, four_players + ): + north, _east, south, _west = four_players + auction = Auction.empty().apply(ContractBid(south, 100, Suit.HEARTS)) + text = _bidding_prompt_text(auction, north).plain + assert "'110 H'" in text + assert "double" not in text # can't double your partner + + def test_doubled_contract_offers_pass_or_redouble_only( + self, four_players + ): + north, _east, south, west = four_players + auction = ( + Auction.empty() + .apply(ContractBid(south, 100, Suit.HEARTS)) + .apply(DoubleBid(west)) + ) + text = _bidding_prompt_text(auction, north).plain + assert "W doubled." in text + assert "(pass / redouble)" in text + + def test_past_180_drops_the_numeric_example(self, four_players): + _north, east, south, _west = four_players + auction = Auction.empty().apply(ContractBid(east, 180, Suit.HEARTS)) + text = _bidding_prompt_text(auction, south).plain + # Only Slam-family raises remain; they are filtered from the + # worked example, leaving pass (and the legal double). + assert "'pass'" in text + assert "'double'" in text + assert " H'" not in text + + +class TestBidRejectionText: + """The unparseable-input notice tracks the cheapest legal raise.""" + + def test_fresh_auction_suggests_the_80_floor(self, four_players): + *_, south, _west = four_players + text = _bid_rejection_text(Auction.empty(), south).plain + assert "Unrecognized bid" in text + assert "'80 h'" in text + assert "'pass'" in text + + def test_example_tracks_the_standing_bid(self, four_players): + _north, _east, south, west = four_players + auction = Auction.empty().apply(ContractBid(south, 90, Suit.HEARTS)) + text = _bid_rejection_text(auction, west).plain + # 90 stands: the example shows 100, never the bare 80 floor. + assert "'100 h'" in text + assert "'80 h'" not in text + + def test_past_180_drops_the_numeric_example(self, four_players): + _north, east, south, _west = four_players + auction = Auction.empty().apply(ContractBid(east, 180, Suit.HEARTS)) + text = _bid_rejection_text(auction, south).plain + # Only Slam-family raises remain; the notice falls back to the + # word actions alone. + assert " h'" not in text + assert "'pass'" in text + assert "'double'" in text + + +class TestAiBidAnnouncement: + """The post-bid pause line for each bid kind.""" + + def test_pass(self, four_players): + north, *_ = four_players + assert _ai_bid_announcement(north, PassBid(north)).plain == "N passes." + + def test_contract(self, four_players): + _north, east, *_ = four_players + text = _ai_bid_announcement( + east, ContractBid(east, 100, Suit.HEARTS) + ).plain + assert text == "E bids 100 ♥." + + def test_double(self, four_players): + *_, west = four_players + assert _ai_bid_announcement(west, DoubleBid(west)).plain == "W doubles." diff --git a/packages/contrai-engine/tests/test_view/test_bidding_rules.py b/packages/contrai-engine/tests/test_view/test_bidding_rules.py new file mode 100644 index 0000000..19e535b --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_bidding_rules.py @@ -0,0 +1,56 @@ +"""Tests for the illegal-bid nudge in +:mod:`contrai_engine.view.bidding_rules`. + +The single remaining helper, :func:`_illegal_bid_reason`, is a +messaging-only mirror of the auction rules: it builds the specific +explanation shown when a human types an illegal bid. The authoritative +verdict is :meth:`Auction.is_legal`, and the adaptive prompt hint is now +derived directly from :meth:`Auction.legal_actions` (covered by +``test_rich_view``'s ``TestBiddingPromptHint``). +""" + +from __future__ import annotations + +from contrai_core import Auction, Suit +from contrai_core.bid import ContractBid, DoubleBid, PassBid +from contrai_engine.view.bidding_rules import _illegal_bid_reason + + +class TestIllegalBidReason: + """The specific nudge shown when a human types an illegal bid.""" + + def _auction(self, bids): + auction = Auction.empty() + for bid in bids: + auction = auction.apply(bid) + return auction + + def test_double_own_partner(self, four_players): + north, east, south, west = four_players + auction = self._auction( + [PassBid(east), ContractBid(north, 90, Suit.SPADES), PassBid(west)] + ) + reason = _illegal_bid_reason(DoubleBid(south), auction) + assert "own side" in reason + + def test_double_with_no_contract(self, four_players): + north, east, _south, _west = four_players + auction = self._auction([PassBid(east)]) + reason = _illegal_bid_reason(DoubleBid(north), auction) + assert "no contract" in reason.lower() + + def test_double_already_doubled(self, four_players): + north, east, south, _west = four_players + auction = self._auction( + [ContractBid(east, 90, Suit.SPADES), DoubleBid(south)] + ) + reason = _illegal_bid_reason(DoubleBid(north), auction) + assert "already" in reason.lower() + + def test_contract_must_outrank(self, four_players): + _north, east, south, _west = four_players + auction = self._auction([ContractBid(east, 100, Suit.SPADES)]) + reason = _illegal_bid_reason( + ContractBid(south, 80, Suit.HEARTS), auction + ) + assert "outrank" in reason and "100" in reason diff --git a/packages/contrai-engine/tests/test_view/test_endgame.py b/packages/contrai-engine/tests/test_view/test_endgame.py new file mode 100644 index 0000000..79051d7 --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_endgame.py @@ -0,0 +1,54 @@ +"""Tests for the end-game screen in :mod:`contrai_engine.view.screens.endgame`. + +The round-by-round summary contract cell must read in English +vocabulary exclusively — no French ``coinché`` / ``surcoinché`` leakage. +""" + +from __future__ import annotations + +from contrai_core import Suit +from contrai_engine.view.rich_view import RoundSummary +from contrai_engine.view.screens.endgame import _format_summary_contract + + +class TestFormatSummaryContract: + """The end-game summary contract cell must use English vocabulary + exclusively — no French ``coinché`` / ``surcoinché`` leakage.""" + + class _StubContract: + def __init__(self, value, suit, *, double=False, redouble=False): + self.value = value + self.suit = suit + self.double = double + self.redouble = redouble + + @staticmethod + def _row(contract, team_name="North-South"): + return RoundSummary( + round_number=1, + contract=contract, + contract_team_name=team_name, + contract_made=True, + ns_pts=100, + ew_pts=0, + running_ns=100, + running_ew=0, + ) + + def test_doubled_contract_reads_english(self): + row = self._row(self._StubContract(100, Suit.HEARTS, double=True)) + text = _format_summary_contract(row).plain + assert "doubled" in text + assert "coinché" not in text + + def test_redoubled_contract_reads_english(self): + row = self._row(self._StubContract(100, Suit.HEARTS, redouble=True)) + text = _format_summary_contract(row).plain + assert "redoubled" in text + assert "surcoinché" not in text + + def test_plain_contract_has_no_double_marker(self): + row = self._row(self._StubContract(100, Suit.HEARTS)) + text = _format_summary_contract(row).plain + assert "doubled" not in text + assert "redoubled" not in text diff --git a/packages/contrai-engine/tests/test_view/test_formatting.py b/packages/contrai-engine/tests/test_view/test_formatting.py new file mode 100644 index 0000000..68bbce6 --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_formatting.py @@ -0,0 +1,114 @@ +"""Tests for the stateless formatters in :mod:`contrai_engine.view.formatting`. + +Covers the shared labels with real branching: the contract label +(taker seat + double caller, compact vs verbose, optional suit glyph) +and the trump label (glyph/label rendering). +""" + +from __future__ import annotations + +from contrai_core import Suit +from contrai_core.bid import ContractBid, SlamLevel +from contrai_core.contract import Contract +from contrai_engine.view.formatting import ( + _format_contract_short, + _format_trump_label, +) + + +class TestFormatContractShort: + """The shared contract label: value + taker seat + double caller. + + Used by the in-game round panel, the after-round recap, and the + event-log 'Contract set' line — all three render through this. + """ + + def test_plain_contract_names_taker_seat(self, four_players): + _north, east, *_ = four_players + contract = Contract(ContractBid(east, 100, Suit.HEARTS)) + text = _format_contract_short(contract).plain + assert "100 by E" in text + # No multiplier marker on an un-doubled contract. + assert "×2" not in text and "×4" not in text + + def test_doubled_contract_names_double_caller(self, four_players): + north, east, _south, west = four_players + contract = Contract( + ContractBid(north, 110, Suit.SPADES), + double_player=east, + ) + text = _format_contract_short(contract).plain + assert "110 by N" in text + assert "×2 by E" in text + + def test_redoubled_contract_names_redouble_caller(self, four_players): + north, east, _south, west = four_players + contract = Contract( + ContractBid(north, 120, Suit.CLUBS), + double_player=east, + redouble_player=north, + ) + text = _format_contract_short(contract).plain + assert "120 by N" in text + # Redouble takes precedence over the double marker. + assert "×4 by N" in text + assert "×2" not in text + + def test_suit_glyph_opt_in(self, four_players): + """suit_glyph=True slots the trump glyph between value and taker.""" + north, *_ = four_players + contract = Contract(ContractBid(north, 110, Suit.SPADES)) + assert "110 ♠ by N" in _format_contract_short( + contract, suit_glyph=True + ).plain + # Default stays glyph-free: the round panel and recap already + # carry the trump on their own line. + assert "♠" not in _format_contract_short(contract).plain + + def test_slam_value_label(self, four_players): + _north, east, *_ = four_players + contract = Contract(ContractBid(east, SlamLevel.SLAM, Suit.HEARTS)) + text = _format_contract_short(contract).plain + assert "Slam by E" in text + + def test_verbose_spells_out_doubled(self, four_players): + """verbose=True replaces the ×2 glyph with the word 'doubled'.""" + north, east, *_ = four_players + contract = Contract( + ContractBid(north, 110, Suit.SPADES), + double_player=east, + ) + text = _format_contract_short(contract, verbose=True).plain + assert "doubled by E" in text + assert "×2" not in text + + def test_verbose_spells_out_redoubled(self, four_players): + """verbose=True replaces the ×4 glyph with the word 'redoubled'.""" + north, east, _south, _west = four_players + contract = Contract( + ContractBid(north, 120, Suit.CLUBS), + double_player=east, + redouble_player=north, + ) + text = _format_contract_short(contract, verbose=True).plain + assert "redoubled by N" in text + assert "×4" not in text + # Redouble takes precedence: only one marker, not two. + assert text.count("doubled") == 1 + + +class TestFormatTrumpLabel: + """`_format_trump_label` glyph/label rendering.""" + + def test_suit_label_has_no_star(self): + text = _format_trump_label(Suit.HEARTS).plain + assert "♥ Hearts" in text + assert "★" not in text + + def test_no_trump_label(self): + text = _format_trump_label(Suit.NO_TRUMP).plain + assert "No Trump" in text + assert "★" not in text + + def test_none_suit_is_em_dash(self): + assert _format_trump_label(None).plain == "—" diff --git a/packages/contrai-engine/tests/test_view/test_layout.py b/packages/contrai-engine/tests/test_view/test_layout.py new file mode 100644 index 0000000..adae56e --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_layout.py @@ -0,0 +1,74 @@ +"""Tests for the cross-screen layout helpers in +:mod:`contrai_engine.view.layout`. + +The Prompt panel folds an optional rejection notice above the question +and grows a row to fit it — that branching is what's worth locking down. +""" + +from __future__ import annotations + +from rich.text import Text + +from contrai_engine.view.layout import ( + _panel_event_log, + _panel_game_score, + _panel_prompt, +) +from contrai_engine.view.theme import RED + + +class TestPanelPromptNotice: + """The rejection line is rendered inside the Prompt panel itself.""" + + def test_notice_appears_above_question(self): + notice = Text("✗ doubling your own side", style=RED) + panel = _panel_prompt(Text("Your bid?"), False, notice=notice) + text = panel.renderable.plain + # Both the reason and the question share the one panel, reason + # first — so the player never has to scroll to see why input + # bounced. + assert "own side" in text + assert "Your bid?" in text + assert text.index("own side") < text.index("Your bid?") + # Grows a row to fit the extra line. + assert panel.height == 5 + + def test_no_notice_keeps_compact_height(self): + panel = _panel_prompt(Text("Your bid?"), False) + assert "own side" not in panel.renderable.plain + assert panel.height == 4 + + +class TestPanelGameScore: + """The in-game top-left panel: both team totals and the target.""" + + def test_renders_scores_and_target(self): + panel = _panel_game_score( + {"North-South": 120, "East-West": 250}, target_score=1500 + ) + text = panel.renderable.plain + assert panel.title.plain == "Game score" + assert "N-S" in text and "120" in text + assert "E-W" in text and "250" in text + assert "Target" in text and "1500" in text + + def test_missing_teams_default_to_zero(self): + # A fresh game hands over an empty dict before any round scores. + panel = _panel_game_score({}, target_score=1000) + text = panel.renderable.plain + assert text.count("0") >= 2 + assert "1000" in text + + +class TestPanelEventLog: + """The rolling event-log panel: placeholder when empty, lines when not.""" + + def test_renders_lines(self): + panel = _panel_event_log([Text("alpha"), Text("beta")], log_max=5) + assert "alpha" in panel.renderable.plain + assert "beta" in panel.renderable.plain + assert panel.title.plain == "Log" + + def test_empty_placeholder(self): + panel = _panel_event_log([], log_max=5) + assert "(no events yet)" in panel.renderable.plain diff --git a/packages/contrai-engine/tests/test_view/test_parsing.py b/packages/contrai-engine/tests/test_view/test_parsing.py new file mode 100644 index 0000000..14a1bd0 --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_parsing.py @@ -0,0 +1,198 @@ +"""Tests for the human-input parsers in :mod:`contrai_engine.view.parsing`. + +Humans type bids and card numbers at the prompt; these two parsers turn +that text into engine-shaped values (or ``None`` so the loops re-ask). +""" + +from __future__ import annotations + +import pytest + +from contrai_core import Card, Rank, Suit +from contrai_core.bid import ( + ContractBid, + DoubleBid, + PassBid, + RedoubleBid, + SlamLevel, +) +from contrai_engine.model.player import AiPlayer +from contrai_engine.view.parsing import _parse_bid_input, _parse_card_input + + +# ====================================================================== +# _parse_bid_input +# ====================================================================== + + +class TestParseBidInput: + """Bid-string parser. Returns a :class:`Bid` or ``None`` on error. + + ``Bid`` equality is type + payload (the player is excluded), so the + parsed bid compares equal to the expected variant regardless of which + player instance it is attached to. + """ + + @pytest.fixture + def player(self): + """A player to attach the parsed bid to.""" + return AiPlayer("Bot", "South") + + @pytest.mark.parametrize("raw", ["pass", "PASS", "Pass", "p", " pass "]) + def test_pass_variants(self, raw, player): + assert _parse_bid_input(raw, player) == PassBid(player) + + @pytest.mark.parametrize( + "raw", ["double", "d", "Double", "DOUBLE", " double "] + ) + def test_double_variants(self, raw, player): + assert _parse_bid_input(raw, player) == DoubleBid(player) + + @pytest.mark.parametrize( + "raw", ["redouble", "r", "Redouble", "REDOUBLE", " redouble "] + ) + def test_redouble_variants(self, raw, player): + assert _parse_bid_input(raw, player) == RedoubleBid(player) + + @pytest.mark.parametrize( + "raw", + ["coinche", "surcoinche", "contrée", "contree", + "surcontrée", "surcontree", "passe"], + ) + def test_rejects_french_aliases(self, raw, player): + """The CLI uses the English vocabulary exclusively. The parser + used to accept the French aliases ``coinche`` / ``surcoinche`` / + ``contrée`` / ``surcontrée`` / ``passe``; those have been + retired.""" + assert _parse_bid_input(raw, player) is None + + @pytest.mark.parametrize( + "raw,value,suit", + [ + ("80 h", 80, Suit.HEARTS), + ("100 hearts", 100, Suit.HEARTS), + ("100 heart", 100, Suit.HEARTS), + ("90 s", 90, Suit.SPADES), + ("110 spades", 110, Suit.SPADES), + ("120 d", 120, Suit.DIAMONDS), + ("130 diamond", 130, Suit.DIAMONDS), + ("140 c", 140, Suit.CLUBS), + ("150 clubs", 150, Suit.CLUBS), + ("160 nt", 160, Suit.NO_TRUMP), + ("160 notrump", 160, Suit.NO_TRUMP), + ("80 ♥", 80, Suit.HEARTS), + ("80 ♠", 80, Suit.SPADES), + ], + ) + def test_contract_bid_separated(self, raw, value, suit, player): + assert _parse_bid_input(raw, player) == ContractBid(player, value, suit) + + @pytest.mark.parametrize( + "raw,value,suit", + [ + ("100h", 100, Suit.HEARTS), + ("80s", 80, Suit.SPADES), + ("130c", 130, Suit.CLUBS), + ], + ) + def test_contract_bid_glued(self, raw, value, suit, player): + """Value and suit may be glued together with no separator.""" + assert _parse_bid_input(raw, player) == ContractBid(player, value, suit) + + @pytest.mark.parametrize( + "raw,suit", + [ + ("slam s", Suit.SPADES), + ("slam h", Suit.HEARTS), + ("slam d", Suit.DIAMONDS), + ("slam c", Suit.CLUBS), + ("slams", Suit.SPADES), # glued + ("SLAM H", Suit.HEARTS), # case-insensitive + ], + ) + def test_slam(self, raw, suit, player): + assert _parse_bid_input(raw, player) == ContractBid( + player, SlamLevel.SLAM, suit + ) + + @pytest.mark.parametrize( + "raw,suit", + [ + ("soloslam s", Suit.SPADES), + ("solo slam h", Suit.HEARTS), # two-word form + ("solo slam d", Suit.DIAMONDS), + ("soloslam c", Suit.CLUBS), + ("soloslams", Suit.SPADES), # glued + ("SOLO SLAM H", Suit.HEARTS), # case-insensitive + ], + ) + def test_solo_slam(self, raw, suit, player): + assert _parse_bid_input(raw, player) == ContractBid( + player, SlamLevel.SOLO_SLAM, suit + ) + + def test_capital_letters_in_value_suit(self, player): + assert _parse_bid_input("100 H", player) == ContractBid( + player, 100, Suit.HEARTS + ) + + @pytest.mark.parametrize( + "raw", + [ + "", # empty + " ", # whitespace only + "xyz", # garbage + "80", # value but no suit + "h", # suit but no value + "80 q", # invalid suit letter + "70 h", # value below the 80 floor + "85 h", # value not on the 10-step ladder + "190 h", # value above the 180 ceiling + "abc h", # non-numeric value + "80 h s", # too many tokens + "capot s", # legacy name no longer accepted + "160 sa", # French sans-atout alias no longer accepted + ], + ) + def test_rejects_garbage(self, raw, player): + assert _parse_bid_input(raw, player) is None + + +# ====================================================================== +# _parse_card_input +# ====================================================================== + + +class TestParseCardInput: + """Card-number parser. Validates that the picked card is playable.""" + + @pytest.fixture + def hand(self): + """Four-card display hand the parser indexes into.""" + return [ + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.DIAMONDS, Rank.QUEEN), + ] + + def test_valid_choice_in_playable(self, hand): + playable = hand[:2] # only hearts are playable + assert _parse_card_input("1", hand, playable) is hand[0] + assert _parse_card_input("2", hand, playable) is hand[1] + + def test_choice_not_in_playable(self, hand): + """User picks a number that maps to a non-playable card.""" + playable = hand[:2] + assert _parse_card_input("3", hand, playable) is None # A♠ not playable + + def test_choice_out_of_range(self, hand): + assert _parse_card_input("0", hand, hand) is None + assert _parse_card_input("5", hand, hand) is None + + @pytest.mark.parametrize("raw", ["", "abc", "1.5", "-1", " 1a"]) + def test_non_digit(self, hand, raw): + assert _parse_card_input(raw, hand, hand) is None + + def test_whitespace_trimmed(self, hand): + assert _parse_card_input(" 1 ", hand, hand) is hand[0] diff --git a/packages/contrai-engine/tests/test_view/test_recap.py b/packages/contrai-engine/tests/test_view/test_recap.py new file mode 100644 index 0000000..11c2ecd --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_recap.py @@ -0,0 +1,1009 @@ +"""Tests for the round-recap screen in :mod:`contrai_engine.view.screens.recap`. + +Covers the per-team ``_recap_breakdown`` (the point-component invariant +that drives both sub-tables) and the rendered recap panel, plus the +``RichView.show_round_recap`` orchestration that prints it. +""" + +from __future__ import annotations + +import re + +import pytest + +from contrai_core import Card, Rank, Suit, Trick +from contrai_core.bid import SlamLevel +from contrai_engine.model.round import UnannouncedSlam +from contrai_engine.view.rich_view import RichView +from contrai_engine.view.screens.recap import ( + _panel_round_recap, + _recap_breakdown, +) + + +class TestRoundRecapPanel: + """Between-rounds recap: contract, made/failed, totals, belote.""" + + class _StubContract: + def __init__(self, value, suit, team_name, double=False, redouble=False): + self.value = value + self.suit = suit + class _T: pass + self.team = _T() + self.team.name = team_name + self.double = double + self.redouble = redouble + + def is_slam_family(self) -> bool: + """Mirror ``Contract.is_slam_family`` on the stubbed value.""" + return isinstance(self.value, SlamLevel) + + def is_slam(self) -> bool: + """Mirror ``Contract.is_slam`` on the stubbed value.""" + return self.value is SlamLevel.SLAM + + def is_solo_slam(self) -> bool: + """Mirror ``Contract.is_solo_slam`` on the stubbed value.""" + return self.value is SlamLevel.SOLO_SLAM + + def get_base_points(self) -> int: + """Mirror ``Contract.get_base_points`` on the stubbed value.""" + if isinstance(self.value, SlamLevel): + return self.value.base_value + return self.value + + def get_slam_card_substitute(self) -> int: + """Mirror ``Contract.get_slam_card_substitute``.""" + if isinstance(self.value, SlamLevel): + return self.value.base_value + return 0 + + def get_multiplier(self) -> int: + """Mirror ``Contract.get_multiplier`` from the double flags.""" + if self.redouble: + return 4 + if self.double: + return 2 + return 1 + + class _StubRound: + def __init__(self, *, round_number, contract, round_scores, + team_tricks=None, belote_holder=None, + contract_made=None): + self.round_number = round_number + self.contract = contract + self.round_scores = round_scores + self.team_tricks = team_tricks or {} + # Belote holder (player object exposing ``.team.name``) and + # the engine's canonical made/failed flag. ``contract_made`` + # left None lets ``RichView._contract_made`` fall back to the + # score heuristic, matching pre-flag behaviour for the simple + # cases these stubs cover. + self.belote_holder = belote_holder + self.contract_made = contract_made + + def test_recap_made_contract_shows_check(self): + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + ) + panel = _panel_round_recap(round_, {"North-South": 500, "East-West": 0}) + text = panel.renderable.plain + assert "Round #3 recap" in panel.title.plain + assert "Contract made" in text + assert "162" in text # round score, no leading "+" + assert "+162" not in text + assert "500" in text # running NS total + + def test_recap_shows_trump_recall_line(self): + """The recap spells out the contract trump on its own line.""" + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + ) + panel = _panel_round_recap(round_, {"North-South": 500, "East-West": 0}) + text = panel.renderable.plain + assert "Trump:" in text + assert "♥ Hearts" in text + + def test_recap_trump_line_omits_star(self): + """The recap's Trump line drops the ★ flourish (it stays plain). + + The star is reserved for the in-game Round panel; nothing else in + the recap renders a ★, so its absence is asserted panel-wide. + """ + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + ) + panel = _panel_round_recap(round_, {"North-South": 500, "East-West": 0}) + text = panel.renderable.plain + assert "♥ Hearts" in text + assert "★" not in text + + def test_recap_outcome_holds_tally_scoring_holds_round_points( + self, four_players + ): + """Section placement after the refactor: the factual play tally + (Tricks points / Last trick / Belote / Total) sits under Outcome, + while the rolled-up Round points sits under Scoring. On this + normal-made round the Scoring Round points equals trick points + + last trick + belote per side.""" + view = RichView() + north, east, *_ = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + # N-S takes one trick worth A♥ (trump ace = 11), wins the last + # trick (+10) and holds the belote pair (+20) → 41 round points. + ns_trick = Trick() + ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) + round_ = self._StubRound( + round_number=2, + contract=contract, + round_scores={"North-South": 141, "East-West": 0}, + team_tricks={"North-South": [ns_trick], "East-West": []}, + belote_holder=north, + ) + round_.last_trick_winner = north + breakdown = _recap_breakdown(round_) + ns = breakdown["North-South"] + # Round points is the sum of the three factual Outcome rows. + assert ( + ns["round_points"] + == ns["trick_points"] + ns["last_trick"] + ns["belote"] + == 41 + ) + text = _panel_round_recap( + round_, {"North-South": 141, "East-West": 0} + ).renderable.plain + outcome, scoring = text.split("Scoring") + # Tally rows (and their Total) live above the Scoring rule, the + # rolled-up Round points below it. + for row in ("Tricks points", "Last trick", "Belote", "Total"): + assert row in outcome + assert row not in scoring + assert "Round points" in scoring + assert "Round points" not in outcome + + def test_recap_failed_contract_shows_cross(self): + view = RichView() + contract = self._StubContract(120, Suit.SPADES, "East-West") + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 280, "East-West": 0}, + ) + panel = _panel_round_recap(round_, {"North-South": 280, "East-West": 0}) + text = panel.renderable.plain + assert "Contract failed" in text + + def test_recap_uses_verbose_doubled_marker(self): + """The recap spells out 'doubled' rather than the ×2 glyph.""" + view = RichView() + contract = self._StubContract( + 110, Suit.SPADES, "North-South", double=True + ) + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 0, "East-West": 320}, + ) + panel = _panel_round_recap(round_, {"North-South": 0, "East-West": 320}) + text = panel.renderable.plain + assert "doubled" in text + assert "×2" not in text + + def test_recap_all_passed(self): + view = RichView() + round_ = self._StubRound( + round_number=5, + contract=None, + round_scores={"North-South": 0, "East-West": 0}, + ) + panel = _panel_round_recap(round_, {"North-South": 0, "East-West": 0}) + text = panel.renderable.plain + assert "All passed" in text + # No made/failed line for an all-passed round. + assert "made" not in text + assert "failed" not in text + # Outcome table harmonizes with Scoring: em-dashes, not zeros, on + # the Tricks won, Total and Round points rows when nothing was played. + for line in text.splitlines(): + if ( + "Tricks won" in line + or "Total" in line + or "Round points" in line + ): + assert "0" not in line + assert "—" in line + + def test_recap_includes_belote_when_holder_holds_kq_of_trump( + self, four_players + ): + view = RichView() + north, *_ = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + # Belote follows the *holder* of K+Q of trump, not who captures + # them in a trick — so the recap reads ``belote_holder``. + round_ = self._StubRound( + round_number=2, + contract=contract, + round_scores={"North-South": 200, "East-West": 0}, + team_tricks={"North-South": [], "East-West": []}, + belote_holder=north, + ) + panel = _panel_round_recap(round_, {"North-South": 200, "East-West": 0}) + text = panel.renderable.plain + # The Belote row carries the holder's 20, with no leading "+". + # (The "+" in the "K + Q" label is not a sign — guard the value.) + belote_line = next( + line for line in text.splitlines() if "Belote" in line + ) + assert "20" in belote_line + assert "+20" not in belote_line + + def test_recap_shows_card_points_sum_per_team(self, four_players): + """Card-points row shows the trump-aware sum across each team's + tricks (plus the trick count).""" + view = RichView() + north, east, south, west = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + # N-S took J♥ (20) + 9♥ (14) + A♠ (11) = 45 across two tricks. + ns_trick1 = Trick() + ns_trick1.add_play(north, Card(Suit.HEARTS, Rank.JACK)) + ns_trick1.add_play(east, Card(Suit.HEARTS, Rank.SEVEN)) + ns_trick2 = Trick() + ns_trick2.add_play(south, Card(Suit.SPADES, Rank.ACE)) + ns_trick2.add_play(west, Card(Suit.HEARTS, Rank.NINE)) + # E-W took two low tricks worth 0 + 0 = 0. + ew_trick = Trick() + ew_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 145, "East-West": 0}, + team_tricks={ + "North-South": [ns_trick1, ns_trick2], + "East-West": [ew_trick], + }, + ) + panel = _panel_round_recap( + round_, {"North-South": 145, "East-West": 0} + ) + text = panel.renderable.plain + # Trump-aware card points: + # ns_trick1: J♥(20) + 7♥(0) = 20 + # ns_trick2: A♠(11) + 9♥(14) = 25 + # N-S total = 45 — shown in the Outcome "Tricks points" row. + assert "45" in text + assert "Outcome" in text + assert "Tricks won" in text + assert "Tricks points" in text + # The rolled-up tally lives in the Scoring sub-table now. + assert "Round points" in text + + def test_recap_round_points_sum_pile_last_trick_and_belote( + self, four_players + ): + """Outcome ``round_points`` = trump-aware pile + last trick (10) + + belote (20), the honest play tally per team.""" + view = RichView() + north, east, *_ = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + # N-S takes one trick worth A♥ (trump ace = 11). + ns_trick = Trick() + ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) + round_ = self._StubRound( + round_number=2, + contract=contract, + round_scores={"North-South": 141, "East-West": 0}, + team_tricks={"North-South": [ns_trick], "East-West": []}, + belote_holder=north, + ) + round_.last_trick_winner = north + breakdown = _recap_breakdown(round_) + # 11 (A♥) + 10 (last trick) + 20 (belote) = 41. + assert breakdown["North-South"]["round_points"] == 41 + assert breakdown["East-West"]["round_points"] == 0 + + def test_recap_round_points_survive_winner_takes_all_round( + self, four_players + ): + """In a doubled/failed round the Scoring card row is dashed, but + ``round_points`` still reports the real pile each side captured.""" + view = RichView() + north, east, *_ = four_players + # Doubled contract by N-S that fails — E-W scores winner-takes-all. + contract = self._StubContract( + 100, Suit.HEARTS, "North-South", double=True + ) + ew_trick = Trick() + ew_trick.add_play(east, Card(Suit.HEARTS, Rank.JACK)) # trump J = 20 + round_ = self._StubRound( + round_number=2, + contract=contract, + round_scores={"North-South": 0, "East-West": 320}, + team_tricks={"North-South": [], "East-West": [ew_trick]}, + contract_made=False, + ) + breakdown = _recap_breakdown(round_) + ew = breakdown["East-West"] + # Scoring zeroes the card row (winner-takes-all formula)... + assert ew["cards_count"] is False + # ...but the real captured pile still shows in round_points. + assert ew["round_points"] == 20 + + def test_recap_outcome_total_sums_the_tally(self, four_players): + """The Outcome table closes with a Total row equal to the per-side + honest tally — trick points + last trick + belote.""" + view = RichView() + north, east, *_ = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + # N-S: A♥ (11) + last trick (10) + belote (20) = 41. + ns_trick = Trick() + ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) + round_ = self._StubRound( + round_number=2, + contract=contract, + round_scores={"North-South": 141, "East-West": 0}, + team_tricks={"North-South": [ns_trick], "East-West": []}, + belote_holder=north, + ) + round_.last_trick_winner = north + text = _panel_round_recap( + round_, {"North-South": 141, "East-West": 0} + ).renderable.plain + outcome = text.split("Scoring")[0] + total_line = next( + line for line in outcome.splitlines() if "Total" in line + ) + # 11 + 10 + 20 = 41, with no leading "+". + assert "41" in total_line + assert "+" not in total_line + + def test_recap_scoring_round_points_belote_only_when_doubled( + self, four_players + ): + """On a failed/doubled round the captured pile stops scoring, so the + Scoring 'Round points' row collapses to the belote the holder keeps + — while the Outcome 'Total' still reports the full captured tally.""" + view = RichView() + north, east, *_ = four_players + # N-S declares doubled, fails; N-S still captured A♥ (11) and holds + # the belote (20). E-W took the last trick. + contract = self._StubContract( + 100, Suit.HEARTS, "North-South", double=True + ) + ns_trick = Trick() + ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 20, "East-West": 360}, + team_tricks={"North-South": [ns_trick], "East-West": []}, + belote_holder=north, + contract_made=False, + ) + round_.last_trick_winner = east # last trick goes to E-W, not N-S + text = _panel_round_recap( + round_, {"North-South": 20, "East-West": 360} + ).renderable.plain + outcome, scoring = text.split("Scoring") + # Outcome Total = 11 (A♥) + 0 (no last-trick bonus) + 20 (belote) = 31. + total_line = next( + line for line in outcome.splitlines() if "Total" in line + ) + assert "31" in total_line + # Scoring Round points = belote only (20), not the 31 captured. + rp_line = next( + line for line in scoring.splitlines() if "Round points" in line + ) + assert "20" in rp_line + assert "31" not in rp_line + + def test_recap_scoring_round_points_dashed_when_chute_no_belote( + self, four_players + ): + """A failed contract with no belote held → the Scoring 'Round + points' row dashes out entirely (nothing of the pile scores).""" + view = RichView() + north, east, *_ = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + ew_trick = Trick() + ew_trick.add_play(east, Card(Suit.HEARTS, Rank.JACK)) # E-W captures + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 0, "East-West": 260}, + team_tricks={"North-South": [], "East-West": [ew_trick]}, + contract_made=False, + ) + text = _panel_round_recap( + round_, {"North-South": 0, "East-West": 260} + ).renderable.plain + scoring = text.split("Scoring")[1] + rp_line = next( + line for line in scoring.splitlines() if "Round points" in line + ) + # No belote anywhere → both sides dash on the scoring roll-up. + assert "—" in rp_line + assert "20" not in rp_line + + def test_recap_no_plus_signs_in_made_round(self, four_players): + """Regression guard: no leading '+' survives anywhere in the recap + after the sign cleanup, on a normal made round with bonuses.""" + view = RichView() + north, east, *_ = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + ns_trick = Trick() + ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) + round_ = self._StubRound( + round_number=2, + contract=contract, + round_scores={"North-South": 141, "East-West": 0}, + team_tricks={"North-South": [ns_trick], "East-West": []}, + belote_holder=north, + ) + round_.last_trick_winner = north + text = _panel_round_recap( + round_, {"North-South": 141, "East-West": 0} + ).renderable.plain + # No signed numbers remain (a "+" before a digit). The literal "+" + # in the "Belote (K + Q ♥)" label is not a sign and is allowed. + assert re.search(r"\+\d", text) is None + + def test_recap_unannounced_slam_substitutes_250_and_folds_last_trick( + self, four_players + ): + """Unannounced Slam: the Outcome 'Tricks points' row reads 250 + (the flat substitute), 'Last trick' is folded in (0), and the + contract + substitute still sum to the round score.""" + view = RichView() + north, *_ = four_players + contract = self._StubContract(100, Suit.SPADES, "North-South") + # N-S swept all 8 tricks. Filler cards — the engine's 250 + # substitute, not the raw pile, is what the recap must show. + ns_tricks = [] + for _ in range(8): + tr = Trick() + tr.add_play(north, Card(Suit.CLUBS, Rank.SEVEN)) + ns_tricks.append(tr) + round_ = self._StubRound( + round_number=6, + contract=contract, + round_scores={"North-South": 350, "East-West": 0}, + team_tricks={"North-South": ns_tricks, "East-West": []}, + contract_made=True, + ) + round_.unannounced_slam = UnannouncedSlam.GRAND_SLAM # north swept personally + round_.last_trick_winner = north # bonus would be +10 — must fold in + breakdown = _recap_breakdown(round_) + ns = breakdown["North-South"] + assert ns["trick_points"] == 250 + assert ns["last_trick"] == 0 + assert ns["card_points"] == 250 + assert ns["card_points_substituted"] is True + assert ns["contract"] == 100 + assert ns["round_points"] == 250 + # Invariant preserved: contract + card_points + last-trick + # bonus + belote == score. + assert ( + ns["contract"] + + ns["card_points"] + + ns["last_trick_bonus"] + + ns["belote"] + == 350 + ) + text = _panel_round_recap( + round_, {"North-South": 350, "East-West": 0} + ).renderable.plain + assert "250" in text + # The last-trick bonus is folded into the substitute — no + # stray +10 in the row. + outcome = text.split("Scoring")[0] + last_trick_line = next( + line for line in outcome.splitlines() if "Last trick" in line + ) + assert "+10" not in last_trick_line + + @pytest.mark.parametrize( + "marker, expected_tag", + [ + (UnannouncedSlam.SLAM, "Slam"), + (UnannouncedSlam.GRAND_SLAM, "Grand Slam"), + ], + ) + def test_recap_unannounced_slam_tags_the_trick_points_row( + self, four_players, marker, expected_tag + ): + """The unannounced-Slam marker surfaces its label on the Trick + points row to explain the 250 substitute.""" + view = RichView() + north, *_ = four_players + contract = self._StubContract(90, Suit.HEARTS, "North-South") + ns_tricks = [] + for _ in range(8): + tr = Trick() + tr.add_play(north, Card(Suit.CLUBS, Rank.SEVEN)) + ns_tricks.append(tr) + round_ = self._StubRound( + round_number=7, + contract=contract, + round_scores={"North-South": 340, "East-West": 0}, + team_tricks={"North-South": ns_tricks, "East-West": []}, + contract_made=True, + ) + round_.unannounced_slam = marker + text = _panel_round_recap( + round_, {"North-South": 340, "East-West": 0} + ).renderable.plain + assert expected_tag in text + assert "250" in text + + def test_recap_shows_last_trick_bonus_for_last_trick_winner(self, four_players): + view = RichView() + north, *_ = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + last_trick = Trick() + last_trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 110, "East-West": 0}, + team_tricks={"North-South": [last_trick], "East-West": []}, + ) + round_.last_trick_winner = north + panel = _panel_round_recap( + round_, {"North-South": 110, "East-West": 0} + ) + text = panel.renderable.plain + # The Last trick row carries the bonus's 10, with no leading "+". + last_trick_line = next( + line for line in text.splitlines() if "Last trick" in line + ) + assert "10" in last_trick_line + assert "+10" not in last_trick_line + + def test_recap_contract_row_shows_contract_value_when_made_normal( + self, four_players + ): + """100 ♥ made by N-S → 'Contract' row shows +100 on N-S column, + em-dash on E-W.""" + view = RichView() + north, *_ = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=2, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + assert breakdown["North-South"]["contract"] == 100 + assert breakdown["East-West"]["contract"] == 0 + # Cards / last trick / belote DO contribute on a normal-made + # contract. + assert breakdown["North-South"]["cards_count"] is True + assert breakdown["East-West"]["cards_count"] is True + + def test_recap_contract_row_uses_slam_base_when_made(self, four_players): + """A made Slam normal: the contract row carries the base (250) + and the card-points row carries the flat substitute (250), + summing to the engine's 500. The last-trick bonus does not + contribute; the row label flips to "(subst.)".""" + view = RichView() + contract = self._StubContract(SlamLevel.SLAM, Suit.SPADES, "East-West") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 0, "East-West": 500}, + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + # Slam normal: base = 250, substitute = 250, mult = 1. + assert breakdown["East-West"]["contract"] == 250 + assert breakdown["East-West"]["card_points"] == 250 + assert breakdown["East-West"]["card_points_substituted"] is True + assert breakdown["East-West"]["cards_count"] is True + # The last-trick bonus is no longer counted on Slam family rounds. + assert breakdown["East-West"]["last_trick_counts"] is False + assert breakdown["East-West"]["last_trick_bonus"] == 0 + # Losing side: zeros everywhere except belote (not tested here). + assert breakdown["North-South"]["contract"] == 0 + assert breakdown["North-South"]["card_points"] == 0 + assert breakdown["North-South"]["card_points_substituted"] is True + + def test_recap_contract_row_uses_slam_grid_when_failed(self, four_players): + """Failed Slam: defender wins the at-risk amount split into + contract (250) + substituted card points (250).""" + view = RichView() + contract = self._StubContract(SlamLevel.SLAM, Suit.SPADES, "East-West") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 500, "East-West": 0}, + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + assert breakdown["North-South"]["contract"] == 250 + assert breakdown["North-South"]["card_points"] == 250 + assert breakdown["North-South"]["card_points_substituted"] is True + assert breakdown["North-South"]["cards_count"] is True + assert breakdown["East-West"]["contract"] == 0 + assert breakdown["East-West"]["card_points"] == 0 + assert breakdown["East-West"]["cards_count"] is False + + def test_recap_contract_row_uses_solo_slam_grid_when_made(self, four_players): + """Made Solo Slam normal: contract = 500, substitute = 500, + sum = 1000.""" + view = RichView() + contract = self._StubContract(SlamLevel.SOLO_SLAM, Suit.SPADES, "East-West") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 0, "East-West": 1000}, + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + assert breakdown["East-West"]["contract"] == 500 + assert breakdown["East-West"]["card_points"] == 500 + assert breakdown["East-West"]["card_points_substituted"] is True + assert breakdown["North-South"]["contract"] == 0 + assert breakdown["North-South"]["card_points"] == 0 + + def test_recap_contract_row_uses_solo_slam_doubled_grid(self, four_players): + """Doubled Solo Slam made: both halves scale with the multiplier. + Contract = 500 * 2 = 1000; substitute = 500 * 2 = 1000; sum = 2000.""" + view = RichView() + contract = self._StubContract( + SlamLevel.SOLO_SLAM, Suit.SPADES, "East-West", double=True + ) + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 0, "East-West": 2000}, + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + assert breakdown["East-West"]["contract"] == 1000 + assert breakdown["East-West"]["card_points"] == 1000 + assert breakdown["East-West"]["card_points_substituted"] is True + + def test_recap_contract_row_includes_full_bonus_when_doubled_made( + self, four_players + ): + """When the engine substitutes the flat 160+base*mult bonus + (doubled or redoubled made), the 'Contract' row carries the + full amount and the cards/last-trick/belote rows are zeroed for the + attacker so the breakdown sums to round_score.""" + view = RichView() + contract = self._StubContract( + 100, Suit.HEARTS, "North-South", double=True + ) + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 360, "East-West": 0}, + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + # 160 + 100*2 = 360 + assert breakdown["North-South"]["contract"] == 360 + # Attacker's cards/last-trick/belote are ignored by the engine + # — the recap reflects that so the addition matches round_score. + assert breakdown["North-South"]["cards_count"] is False + assert breakdown["North-South"]["card_points"] == 0 + assert breakdown["North-South"]["last_trick_bonus"] == 0 + assert breakdown["North-South"]["belote"] == 0 + + def test_recap_contract_row_includes_full_bonus_when_redoubled_made( + self, four_players + ): + view = RichView() + contract = self._StubContract( + 100, Suit.HEARTS, "North-South", redouble=True + ) + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 560, "East-West": 0}, # 160 + 100*4 + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + # 160 + 100*4 = 560 + assert breakdown["North-South"]["contract"] == 560 + assert breakdown["North-South"]["cards_count"] is False + + def test_recap_contract_row_shows_defender_bonus_when_failed( + self, four_players + ): + """100 ♥ failed by N-S → E-W gets (160 + 100) * 1 = 260 in + their 'Contract' row; their cards/last-trick/belote are zeroed.""" + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 0, "East-West": 260}, + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + assert breakdown["East-West"]["contract"] == 260 + assert breakdown["North-South"]["contract"] == 0 + # Defender's cards/last-trick/belote don't contribute on a + # failed contract — the engine pays them a flat bonus instead. + assert breakdown["East-West"]["cards_count"] is False + # Attacker gets 0 on a failed contract; their + # cards/last-trick/belote also don't contribute (round_score is 0). + assert breakdown["North-South"]["cards_count"] is False + + def test_recap_contract_row_failed_doubled_winner_takes_160_plus_cm( + self, four_players + ): + """Failed 100 ♥ ×2 by N-S → E-W wins 160 + 100*2 = 360 (same + stake as a doubled made declarer — winner-takes-all).""" + view = RichView() + contract = self._StubContract( + 100, Suit.HEARTS, "North-South", double=True + ) + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 0, "East-West": 360}, + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + assert breakdown["East-West"]["contract"] == 360 + # Loser scores nothing (no belote here). + assert breakdown["North-South"]["contract"] == 0 + assert breakdown["North-South"]["cards_count"] is False + + def test_recap_doubled_made_defender_scores_zero(self, four_players): + """Doubled contract made → the losing defender's breakdown is all + zeros (winner-takes-all). Mirrors the engine's Problem-2 fix.""" + view = RichView() + contract = self._StubContract( + 100, Suit.HEARTS, "North-South", double=True + ) + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 360, "East-West": 0}, + team_tricks={"North-South": [], "East-West": []}, + ) + breakdown = _recap_breakdown(round_) + ew = breakdown["East-West"] + assert ew["contract"] == 0 + assert ew["cards_count"] is False + assert ew["card_points"] == 0 + assert ew["last_trick_bonus"] == 0 + assert ew["belote"] == 0 + + def test_recap_loser_keeps_belote_when_doubled(self, four_players): + """The one thing a losing side keeps is its belote — the recap + shows +20 for the holder even when it lost a doubled round.""" + view = RichView() + _north, east, _south, _west = four_players + contract = self._StubContract( + 100, Suit.HEARTS, "North-South", double=True + ) + round_ = self._StubRound( + round_number=4, + contract=contract, + round_scores={"North-South": 360, "East-West": 20}, + team_tricks={"North-South": [], "East-West": []}, + belote_holder=east, # losing defender holds the pair + ) + breakdown = _recap_breakdown(round_) + ew = breakdown["East-West"] + assert ew["belote_count"] is True + assert ew["belote"] == 20 + assert ew["contract"] == 0 + assert ew["cards_count"] is False + # The four components still sum to the engine's round score. + assert ( + ew["contract"] + + ew["card_points"] + + ew["last_trick_bonus"] + + ew["belote"] + == 20 + ) + + def test_recap_panel_renders_contract_row(self, four_players): + """End-to-end: the rendered panel contains a 'Contract' row.""" + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + team_tricks={"North-South": [], "East-West": []}, + ) + panel = _panel_round_recap( + round_, {"North-South": 500, "East-West": 0} + ) + text = panel.renderable.plain + assert "Contract " in text # row label + assert "100" in text # attacker contract bonus, no leading "+" + assert "+100" not in text + + def test_recap_breakdown_sums_to_round_score_normal_made( + self, four_players + ): + """Invariant: for any team, the four component rows must sum + to the engine's round_score. This is the test for the normal + (un-doubled) made case.""" + view = RichView() + north, east, south, west = four_players + contract = self._StubContract(100, Suit.HEARTS, "North-South") + # N-S took two tricks; sum of card.get_points(♥) = + # J♥(20)+7♥(0)+9♥(14)+8♥(0) = 34 + # A♠(11)+7♠(0)+K♠(4)+8♠(0) = 15 + # Total card points: 49. + ns_trick1 = Trick() + for p, c in [ + (north, Card(Suit.HEARTS, Rank.JACK)), + (east, Card(Suit.HEARTS, Rank.SEVEN)), + (south, Card(Suit.HEARTS, Rank.NINE)), + (west, Card(Suit.HEARTS, Rank.EIGHT)), + ]: + ns_trick1.add_play(p, c) + ns_trick2 = Trick() + for p, c in [ + (north, Card(Suit.SPADES, Rank.ACE)), + (east, Card(Suit.SPADES, Rank.SEVEN)), + (south, Card(Suit.SPADES, Rank.KING)), + (west, Card(Suit.SPADES, Rank.EIGHT)), + ]: + ns_trick2.add_play(p, c) + # Engine score = 100 (contract) + 49 (cards) + 10 (last trick) + # = 159. + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 159, "East-West": 0}, + team_tricks={ + "North-South": [ns_trick1, ns_trick2], + "East-West": [], + }, + ) + round_.last_trick_winner = north + breakdown = _recap_breakdown(round_) + ns = breakdown["North-South"] + sum_ns = ( + ns["contract"] + + ns["card_points"] + + ns["last_trick_bonus"] + + ns["belote"] + ) + assert sum_ns == 159 + + def test_recap_table_uses_trump_glyph_in_belote_label(self, four_players): + """The Belote row label reflects the actual trump suit.""" + view = RichView() + north, *_ = four_players + contract = self._StubContract(100, Suit.SPADES, "North-South") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 200, "East-West": 0}, + team_tricks={"North-South": [], "East-West": []}, + ) + panel = _panel_round_recap( + round_, {"North-South": 200, "East-West": 0} + ) + text = panel.renderable.plain + # Spade glyph in the Belote row, not the hearts glyph. + assert "Belote (K + Q ♠)" in text + + def _capture_recap_prompt(self, view, round_, scores, **kwargs) -> str: + """Run ``show_round_recap`` and return the printed plain text. + + Panels carry a ``renderable`` Text whose ``.plain`` is the + prompt copy we want to assert on. Walk every printed argument + and concatenate any plain-text payloads we can extract. + """ + view.console.input = lambda *_a, **_kw: "" + view.console.clear = lambda *_a, **_kw: None + captured: list[str] = [] + def _record(*args, **_kw): + for a in args: + if hasattr(a, "plain"): + captured.append(a.plain) + elif hasattr(a, "renderable") and hasattr(a.renderable, "plain"): + captured.append(a.renderable.plain) + else: + captured.append(str(a)) + view.console.print = _record + view.show_round_recap(round_, scores, **kwargs) + return "\n".join(captured) + + def test_show_round_recap_default_prompt(self): + """Without ``is_final`` the prompt invites the next deal.""" + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + ) + output = self._capture_recap_prompt( + view, round_, {"North-South": 162, "East-West": 0} + ) + assert "deal the next round" in output + assert "final score" not in output + + def test_recap_panel_shows_tiebreaker_notice(self): + """With ``tiebreaker=True`` the panel announces sudden death.""" + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=12, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + ) + panel = _panel_round_recap( + round_, {"North-South": 1600, "East-West": 1600}, + tiebreaker=True, + ) + text = panel.renderable.plain + assert "tiebreaker round" in text + + def test_recap_panel_omits_tiebreaker_notice_by_default(self): + """A normal round recap carries no tiebreaker copy.""" + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=3, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + ) + panel = _panel_round_recap(round_, {"North-South": 500, "East-West": 0}) + text = panel.renderable.plain + assert "tiebreaker" not in text.lower() + + def test_show_round_recap_tiebreaker_prompt(self): + """With ``is_tiebreaker=True`` the prompt deals the tiebreaker.""" + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=12, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + ) + output = self._capture_recap_prompt( + view, round_, {"North-South": 1600, "East-West": 1600}, + is_tiebreaker=True, + ) + assert "deal the tiebreaker round" in output + assert "deal the next round" not in output + assert "final score" not in output + + def test_show_round_recap_final_prompt(self): + """With ``is_final=True`` the prompt points at the final score.""" + view = RichView() + contract = self._StubContract(100, Suit.HEARTS, "North-South") + round_ = self._StubRound( + round_number=10, + contract=contract, + round_scores={"North-South": 162, "East-West": 0}, + ) + output = self._capture_recap_prompt( + view, round_, {"North-South": 1620, "East-West": 1300}, + is_final=True, + ) + assert "final score" in output + assert "deal the next round" not in output diff --git a/packages/contrai-engine/tests/test_view/test_rich_view.py b/packages/contrai-engine/tests/test_view/test_rich_view.py index 682bb17..ea18029 100644 --- a/packages/contrai-engine/tests/test_view/test_rich_view.py +++ b/packages/contrai-engine/tests/test_view/test_rich_view.py @@ -1,190 +1,36 @@ -"""Tests for the pure helpers in :mod:`contrai_engine.view.rich_view`. +"""Tests for the :class:`~contrai_engine.view.rich_view.RichView` class. -We cover the four functions that have real branching logic and would -break silently if the engine APIs around them shifted: +The pure helpers that used to live in ``rich_view`` now have their own +modules and test files (``test_formatting`` / ``test_parsing`` / +``test_bidding_rules`` / ``test_state_helpers``). What remains here is the +stateful, ``self``-coupled behaviour: the engine hooks (bid/card pacing, +event log, belote announcements), the input loops, and the in-game / +recap panel builders driven off ``RichView`` state. -- ``_parse_bid_input`` — humans type bids, this is the parser. -- ``_parse_card_input`` — humans type card numbers, this validates them. -- ``_sort_hand_for_display`` — trump-first ordering for the hand row. -- ``_current_winner`` — live trick-winner highlight. -- ``_explain_constraint`` — the green "↑ playable …" hint line. - -Rendering helpers (``Panel``/``Table`` builders) are not unit-tested — the -smoke-test pass on ``uv run contrai`` validates them end-to-end. +The deeper ``Panel``/``Table`` rendering is smoke-validated by the +``uv run contrai`` pass; these tests assert titles and key text only. """ from __future__ import annotations -import re - import pytest from rich.text import Text from contrai_core import Auction, Card, Rank, Suit, Trick -from contrai_engine.model.player import AiPlayer -from contrai_engine.model.round import UnannouncedSlam -from contrai_core.team import Team -from contrai_core.bid import ContractBid, DoubleBid, PassBid, RedoubleBid, SlamLevel -from contrai_core.contract import Contract -from contrai_engine.view.rich_view import ( - RED, - RichView, - RoundSummary, - _bid_to_legacy, - _current_winner, - _double_available_to, - _explain_constraint, - _format_contract_short, - _format_trump_label, - _illegal_bid_reason, - _min_legal_contract_value, - _parse_bid_input, - _parse_card_input, - _redouble_available_to, - _resolve_delay, - _sort_hand_for_display, +from contrai_core.bid import ContractBid, DoubleBid, PassBid +from contrai_engine.view.rich_view import RichView +from contrai_engine.view.screens.bidding import ( + _bidding_prompt_text, + _panel_bidding_history, + _render_bidding_diamond, +) +from contrai_engine.view.screens.trick import ( + _panel_current_trick, + _panel_hand, + _panel_last_trick, + _panel_round, + _render_diamond, ) - - -# ---------------------------------------------------------------------- -# fixtures -# ---------------------------------------------------------------------- - - -@pytest.fixture -def four_players(): - """A North/East/South/West quartet wired into N-S and E-W teams.""" - north = AiPlayer("North", "North") - east = AiPlayer("East", "East") - south = AiPlayer("South", "South") - west = AiPlayer("West", "West") - ns = Team("North-South", [north, south]) - ew = Team("East-West", [east, west]) - north.team = south.team = ns - east.team = west.team = ew - return north, east, south, west - - -# ====================================================================== -# _parse_bid_input -# ====================================================================== - - -class TestParseBidInput: - """Bid-string parser. Returns engine-shaped bid or ``None`` on error.""" - - @pytest.mark.parametrize("raw", ["pass", "PASS", "Pass", "p", " pass "]) - def test_pass_variants(self, raw): - assert _parse_bid_input(raw) == "Pass" - - @pytest.mark.parametrize( - "raw", ["double", "d", "Double", "DOUBLE", " double "] - ) - def test_double_variants(self, raw): - assert _parse_bid_input(raw) == "Double" - - @pytest.mark.parametrize( - "raw", ["redouble", "r", "Redouble", "REDOUBLE", " redouble "] - ) - def test_redouble_variants(self, raw): - assert _parse_bid_input(raw) == "Redouble" - - @pytest.mark.parametrize( - "raw", - ["coinche", "surcoinche", "contrée", "contree", - "surcontrée", "surcontree", "passe"], - ) - def test_rejects_french_aliases(self, raw): - """The CLI uses the English vocabulary exclusively. The parser - used to accept the French aliases ``coinche`` / ``surcoinche`` / - ``contrée`` / ``surcontrée`` / ``passe``; those have been - retired.""" - assert _parse_bid_input(raw) is None - - @pytest.mark.parametrize( - "raw,value,suit", - [ - ("80 h", 80, Suit.HEARTS), - ("100 hearts", 100, Suit.HEARTS), - ("100 heart", 100, Suit.HEARTS), - ("90 s", 90, Suit.SPADES), - ("110 spades", 110, Suit.SPADES), - ("120 d", 120, Suit.DIAMONDS), - ("130 diamond", 130, Suit.DIAMONDS), - ("140 c", 140, Suit.CLUBS), - ("150 clubs", 150, Suit.CLUBS), - ("160 nt", 160, Suit.NO_TRUMP), - ("160 notrump", 160, Suit.NO_TRUMP), - ("80 ♥", 80, Suit.HEARTS), - ("80 ♠", 80, Suit.SPADES), - ], - ) - def test_contract_bid_separated(self, raw, value, suit): - assert _parse_bid_input(raw) == (value, suit) - - @pytest.mark.parametrize( - "raw,value,suit", - [ - ("100h", 100, Suit.HEARTS), - ("80s", 80, Suit.SPADES), - ("130c", 130, Suit.CLUBS), - ], - ) - def test_contract_bid_glued(self, raw, value, suit): - """Value and suit may be glued together with no separator.""" - assert _parse_bid_input(raw) == (value, suit) - - @pytest.mark.parametrize( - "raw,suit", - [ - ("slam s", Suit.SPADES), - ("slam h", Suit.HEARTS), - ("slam d", Suit.DIAMONDS), - ("slam c", Suit.CLUBS), - ("slams", Suit.SPADES), # glued - ("SLAM H", Suit.HEARTS), # case-insensitive - ], - ) - def test_slam(self, raw, suit): - assert _parse_bid_input(raw) == (SlamLevel.SLAM, suit) - - @pytest.mark.parametrize( - "raw,suit", - [ - ("soloslam s", Suit.SPADES), - ("solo slam h", Suit.HEARTS), # two-word form - ("solo slam d", Suit.DIAMONDS), - ("soloslam c", Suit.CLUBS), - ("soloslams", Suit.SPADES), # glued - ("SOLO SLAM H", Suit.HEARTS), # case-insensitive - ], - ) - def test_solo_slam(self, raw, suit): - assert _parse_bid_input(raw) == (SlamLevel.SOLO_SLAM, suit) - - def test_capital_letters_in_value_suit(self): - assert _parse_bid_input("100 H") == (100, Suit.HEARTS) - - @pytest.mark.parametrize( - "raw", - [ - "", # empty - " ", # whitespace only - "xyz", # garbage - "80", # value but no suit - "h", # suit but no value - "80 q", # invalid suit letter - "70 h", # value below the 80 floor - "85 h", # value not on the 10-step ladder - "190 h", # value above the 180 ceiling - "abc h", # non-numeric value - "80 h s", # too many tokens - "capot s", # legacy name no longer accepted - "160 sa", # French sans-atout alias no longer accepted - ], - ) - def test_rejects_garbage(self, raw): - assert _parse_bid_input(raw) is None # ====================================================================== @@ -192,81 +38,22 @@ def test_rejects_garbage(self, raw): # ====================================================================== -class TestRedoubleAvailability: - """Validates the helper that drives the '(pass / redouble)' hint.""" - - def test_empty_history_no_redouble(self, four_players): - north, *_ = four_players - assert _redouble_available_to([], north) is False - - def test_after_contract_only_no_redouble(self, four_players): - """A bare contract bid hasn't been doubled yet.""" - north, _east, _south, _west = four_players - history = [(north, (100, Suit.HEARTS))] - assert _redouble_available_to(history, north) is False - - def test_contractor_can_redouble_after_opponent_doubles( - self, four_players - ): - """N bid 100♥, E doubled. N (contractor) is up — must offer - redouble.""" - north, east, south, _west = four_players - history = [ - (north, (100, Suit.HEARTS)), - (east, "Double"), - ] - assert _redouble_available_to(history, north) is True - # Contractor's partner (South) is also on the contracting team. - assert _redouble_available_to(history, south) is True - - def test_opponent_cannot_redouble(self, four_players): - """An opponent of the contractor cannot redouble even when a - Double is on the table.""" - north, east, _south, west = four_players - history = [ - (north, (100, Suit.HEARTS)), - (east, "Double"), - ] - # West is on East's team → not the contracting team. - assert _redouble_available_to(history, west) is False - - def test_pass_after_double_closes_window(self, four_players): - """Once any player has passed after the Double, the redouble - window has closed.""" - north, east, south, _west = four_players - history = [ - (north, (100, Suit.HEARTS)), - (east, "Double"), - (south, "Pass"), - ] - # North is the only contracting-team member who hasn't acted — - # but their PARTNER (S) already passed. By bidding-loop rules - # the redouble window is closed once a pass intervenes. - assert _redouble_available_to(history, north) is False - - def test_already_redoubled_no_more(self, four_players): - north, east, south, _west = four_players - history = [ - (north, (100, Suit.HEARTS)), - (east, "Double"), - (south, "Redouble"), - ] - assert _redouble_available_to(history, north) is False - - class TestBiddingPromptHint: - """End-to-end test that the prompt text adapts to the bid history.""" + """End-to-end test that the prompt text adapts to the auction state. - def _prompt(self, history, next_player): - view = RichView() - return view._bidding_prompt_text(history, next_player).plain + The hint is derived from :meth:`Auction.legal_actions`, so each case + builds a real :class:`Auction` from :class:`Bid` objects. + """ + + def _prompt(self, auction, next_player): + return _bidding_prompt_text(auction, next_player).plain def test_no_double_hint_before_any_contract(self, four_players): """With nothing but a Pass on the table there's no contract to double, so the hint offers only bidding and passing.""" north, _east, _south, _west = four_players - history = [(north, "Pass")] - text = self._prompt(history, north) + auction = Auction((PassBid(north),)) + text = self._prompt(auction, north) assert "double" not in text assert "redouble" not in text assert "80 H" in text and "pass" in text @@ -275,11 +62,11 @@ def test_redouble_hint_when_contractor_was_doubled( self, four_players ): north, east, _south, _west = four_players - history = [ - (north, (100, Suit.HEARTS)), - (east, "Double"), - ] - text = self._prompt(history, north) + auction = Auction(( + ContractBid(north, 100, Suit.HEARTS), + DoubleBid(east), + )) + text = self._prompt(auction, north) assert "redouble" in text # The default '80 H' example shouldn't appear in the redouble # variant since the only meaningful play is pass/redouble. @@ -290,15 +77,14 @@ def test_no_double_hint_when_own_partner_holds_contract( ): """The reported bug: N (South's partner) holds the contract, so the hint must NOT advertise 'double' to South.""" - north, east, _south, west = four_players - history = [ - (east, "Pass"), - (north, (90, Suit.SPADES)), - (west, "Pass"), - ] + north, east, south, west = four_players + auction = Auction(( + PassBid(east), + ContractBid(north, 90, Suit.SPADES), + PassBid(west), + )) # South is North's partner — doubling own side is illegal. - _, _, south, _ = four_players - text = self._prompt(history, south) + text = self._prompt(auction, south) assert "double" not in text # Bidding higher and passing are still on the table — and the # example tracks the 90♠ contract, so it offers 100, not 80. @@ -308,19 +94,19 @@ def test_no_double_hint_when_own_partner_holds_contract( def test_double_hint_when_opponent_holds_contract(self, four_players): """East (an opponent of South) holds the contract → offer double.""" _north, east, south, _west = four_players - history = [(east, (90, Suit.SPADES))] - text = self._prompt(history, south) + auction = Auction((ContractBid(east, 90, Suit.SPADES),)) + text = self._prompt(auction, south) assert "double" in text def test_example_tracks_highest_contract(self, four_players): """The reported request: with 90♦ standing, the worked example must propose at least 100, never the bare 80 floor.""" north, east, south, _west = four_players - history = [ - (east, (80, Suit.HEARTS)), - (south, (90, Suit.DIAMONDS)), - ] - text = self._prompt(history, north) + auction = Auction(( + ContractBid(east, 80, Suit.HEARTS), + ContractBid(south, 90, Suit.DIAMONDS), + )) + text = self._prompt(auction, north) assert "100 H" in text assert "80 H" not in text and "90 H" not in text @@ -328,140 +114,13 @@ def test_example_dropped_when_only_slam_outranks(self, four_players): """At 180 only Slam/SoloSlam are legal raises, so the numeric example is dropped rather than suggesting an illegal bid.""" north, east, _south, _west = four_players - history = [(east, (180, Suit.HEARTS))] - text = self._prompt(history, north) + auction = Auction((ContractBid(east, 180, Suit.HEARTS),)) + text = self._prompt(auction, north) # No numeric contract example, but passing/doubling remain. assert "180 H" not in text assert "pass" in text and "double" in text -class TestDoubleAvailability: - """Validates the helper that gates the 'double' hint.""" - - def test_empty_history_no_double(self, four_players): - north, *_ = four_players - assert _double_available_to([], north) is False - - def test_only_passes_no_double(self, four_players): - north, east, south, _west = four_players - history = [(east, "Pass"), (south, "Pass")] - assert _double_available_to(history, north) is False - - def test_opponent_contract_is_doublable(self, four_players): - """South may double East's standing contract.""" - _north, east, south, _west = four_players - history = [(east, (90, Suit.SPADES))] - assert _double_available_to(history, south) is True - - def test_own_side_contract_not_doublable(self, four_players): - """South may NOT double North's (partner's) contract.""" - north, _east, south, _west = four_players - history = [(north, (90, Suit.SPADES))] - assert _double_available_to(history, south) is False - - def test_passes_do_not_close_double_window(self, four_players): - """Intervening passes keep the Coinche window open.""" - _north, east, south, west = four_players - history = [(east, (90, Suit.SPADES)), (west, "Pass")] - assert _double_available_to(history, south) is True - - def test_already_doubled_not_doublable_again(self, four_players): - north, east, south, _west = four_players - history = [(east, (90, Suit.SPADES)), (south, "Double")] - # North is on the contracting side's opponents... but a Double - # already stands, so no further Double is legal regardless. - assert _double_available_to(history, north) is False - - -class TestMinLegalContractValue: - """The dynamic floor that drives the prompt's worked example.""" - - def test_empty_history_opens_at_floor(self, four_players): - """Nothing bid yet → the ladder opens at 80.""" - assert _min_legal_contract_value([]) == 80 - - def test_only_passes_still_floor(self, four_players): - """Passes don't raise the floor — still 80.""" - north, east, _south, _west = four_players - history = [(north, "Pass"), (east, "Pass")] - assert _min_legal_contract_value(history) == 80 - - def test_next_step_above_standing_contract(self, four_players): - """90 standing → cheapest legal raise is 100.""" - north, _east, _south, _west = four_players - history = [(north, (90, Suit.DIAMONDS))] - assert _min_legal_contract_value(history) == 100 - - def test_uses_highest_not_latest_shape(self, four_players): - """The most recent contract is the highest (monotonic), so the - floor sits one step above it.""" - north, east, _south, _west = four_players - history = [ - (east, (80, Suit.HEARTS)), - (north, (120, Suit.CLUBS)), - ] - assert _min_legal_contract_value(history) == 130 - - def test_double_does_not_reset_floor(self, four_players): - """A trailing Double leaves the standing contract intact, so the - floor is still computed from the last numeric bid.""" - north, east, _south, _west = four_players - history = [(north, (110, Suit.SPADES)), (east, "Double")] - assert _min_legal_contract_value(history) == 120 - - def test_180_leaves_no_numeric_raise(self, four_players): - """Past 180 only the Slam sentinels remain → None.""" - north, _east, _south, _west = four_players - history = [(north, (180, Suit.HEARTS))] - assert _min_legal_contract_value(history) is None - - def test_slam_outranked_by_nothing(self, four_players): - """A standing Slam blocks every further contract bid → None.""" - north, _east, _south, _west = four_players - history = [(north, (SlamLevel.SLAM, Suit.HEARTS))] - assert _min_legal_contract_value(history) is None - - -class TestIllegalBidReason: - """The specific nudge shown when a human types an illegal bid.""" - - def _auction(self, bids): - auction = Auction.empty() - for bid in bids: - auction = auction.apply(bid) - return auction - - def test_double_own_partner(self, four_players): - north, east, south, west = four_players - auction = self._auction( - [PassBid(east), ContractBid(north, 90, Suit.SPADES), PassBid(west)] - ) - reason = _illegal_bid_reason(DoubleBid(south), auction) - assert "own side" in reason - - def test_double_with_no_contract(self, four_players): - north, east, _south, _west = four_players - auction = self._auction([PassBid(east)]) - reason = _illegal_bid_reason(DoubleBid(north), auction) - assert "no contract" in reason.lower() - - def test_double_already_doubled(self, four_players): - north, east, south, _west = four_players - auction = self._auction( - [ContractBid(east, 90, Suit.SPADES), DoubleBid(south)] - ) - reason = _illegal_bid_reason(DoubleBid(north), auction) - assert "already" in reason.lower() - - def test_contract_must_outrank(self, four_players): - _north, east, south, _west = four_players - auction = self._auction([ContractBid(east, 100, Suit.SPADES)]) - reason = _illegal_bid_reason( - ContractBid(south, 80, Suit.HEARTS), auction - ) - assert "outrank" in reason and "100" in reason - - class TestRequestBidActionLegality: """Regression: an illegal human bid must re-prompt, never crash. @@ -530,30 +189,6 @@ def test_legal_double_against_opponent_is_accepted(self, four_players): assert notices == [None] -class TestPanelPromptNotice: - """The rejection line is rendered inside the Prompt panel itself.""" - - def test_notice_appears_above_question(self): - view = RichView() - notice = Text("✗ doubling your own side", style=RED) - panel = view._panel_prompt(Text("Your bid?"), False, notice=notice) - text = panel.renderable.plain - # Both the reason and the question share the one panel, reason - # first — so the player never has to scroll to see why input - # bounced. - assert "own side" in text - assert "Your bid?" in text - assert text.index("own side") < text.index("Your bid?") - # Grows a row to fit the extra line. - assert panel.height == 5 - - def test_no_notice_keeps_compact_height(self): - view = RichView() - panel = view._panel_prompt(Text("Your bid?"), False) - assert "own side" not in panel.renderable.plain - assert panel.height == 4 - - # ====================================================================== # _panel_round — round number in the title # ====================================================================== @@ -563,32 +198,30 @@ class TestPanelBiddingHistorySeparator: """Bidding rounds break onto separate lines.""" def test_single_line_within_first_round(self, four_players): - view = RichView() north, east, south, west = four_players bids = [ - (south, "Pass"), - (east, "Pass"), - (north, (80, Suit.HEARTS)), - (west, "Pass"), + PassBid(south), + PassBid(east), + ContractBid(north, 80, Suit.HEARTS), + PassBid(west), ] - text = view._panel_bidding_history(bids).renderable.plain + text = _panel_bidding_history(bids).renderable.plain assert "\n" not in text def test_newline_between_rounds(self, four_players): - view = RichView() north, east, south, west = four_players bids = [ - (south, "Pass"), - (east, "Pass"), - (north, (80, Suit.HEARTS)), - (west, "Pass"), + PassBid(south), + PassBid(east), + ContractBid(north, 80, Suit.HEARTS), + PassBid(west), # round 2 begins: - (south, (100, Suit.HEARTS)), - (east, "Pass"), - (north, (130, Suit.HEARTS)), - (west, "Double"), + ContractBid(south, 100, Suit.HEARTS), + PassBid(east), + ContractBid(north, 130, Suit.HEARTS), + DoubleBid(west), ] - text = view._panel_bidding_history(bids).renderable.plain + text = _panel_bidding_history(bids).renderable.plain # Exactly one line break between round 1 and round 2. assert text.count("\n") == 1 # Each line opens with its round-number gutter. @@ -601,19 +234,18 @@ def test_newline_between_rounds(self, four_players): def test_seats_align_vertically_across_rounds(self, four_players): """Each seat sits in the same column on every round's line.""" - view = RichView() north, east, south, west = four_players bids = [ - (south, "Pass"), - (east, "Pass"), - (north, (80, Suit.HEARTS)), - (west, "Pass"), - (south, (100, Suit.HEARTS)), - (east, "Pass"), - (north, (130, Suit.HEARTS)), - (west, "Double"), + PassBid(south), + PassBid(east), + ContractBid(north, 80, Suit.HEARTS), + PassBid(west), + ContractBid(south, 100, Suit.HEARTS), + PassBid(east), + ContractBid(north, 130, Suit.HEARTS), + DoubleBid(west), ] - text = view._panel_bidding_history(bids).renderable.plain + text = _panel_bidding_history(bids).renderable.plain line1, line2 = text.split("\n", 1) # The seat letters start at identical offsets on both lines, so # the bids stack in vertical lanes despite differing bid widths. @@ -621,109 +253,6 @@ def test_seats_align_vertically_across_rounds(self, four_players): assert line1.index(f"{letter} ") == line2.index(f"{letter} ") -class TestResolveDelay: - """Env-var pacing resolver — used by the AI hooks.""" - - def test_default_when_unset(self, monkeypatch): - monkeypatch.delenv("CONTRAI_AI_TEST", raising=False) - assert _resolve_delay("CONTRAI_AI_TEST", default=0.7) == 0.7 - - def test_reads_float_from_env(self, monkeypatch): - monkeypatch.setenv("CONTRAI_AI_TEST", "0.25") - assert _resolve_delay("CONTRAI_AI_TEST", default=0.7) == 0.25 - - def test_garbage_falls_back_to_default(self, monkeypatch): - monkeypatch.setenv("CONTRAI_AI_TEST", "fast") - assert _resolve_delay("CONTRAI_AI_TEST", default=0.7) == 0.7 - - def test_negative_clamped_to_zero(self, monkeypatch): - monkeypatch.setenv("CONTRAI_AI_TEST", "-2.0") - assert _resolve_delay("CONTRAI_AI_TEST", default=0.7) == 0.0 - - -class TestBidToLegacy: - def test_pass(self): - assert _bid_to_legacy(PassBid(player=None)) == "Pass" - - def test_double(self): - assert _bid_to_legacy(DoubleBid(player=None)) == "Double" - - def test_contract(self, four_players): - north, *_ = four_players - bid = ContractBid(north, 100, Suit.HEARTS) - assert _bid_to_legacy(bid) == (100, Suit.HEARTS) - - -class TestFormatContractShort: - """The shared contract label: value + taker seat + Coinche caller. - - Used by the in-game round panel, the after-round recap, and the - event-log 'Contract set' line — all three render through this. - """ - - def test_plain_contract_names_taker_seat(self, four_players): - _north, east, *_ = four_players - contract = Contract(ContractBid(east, 100, Suit.HEARTS)) - text = _format_contract_short(contract).plain - assert "100 by E" in text - # No multiplier marker on an un-doubled contract. - assert "×2" not in text and "×4" not in text - - def test_doubled_contract_names_coincheur(self, four_players): - north, east, _south, west = four_players - contract = Contract( - ContractBid(north, 110, Suit.SPADES), - double_player=east, - ) - text = _format_contract_short(contract).plain - assert "110 by N" in text - assert "×2 by E" in text - - def test_redoubled_contract_names_surcoincheur(self, four_players): - north, east, _south, west = four_players - contract = Contract( - ContractBid(north, 120, Suit.CLUBS), - double_player=east, - redouble_player=north, - ) - text = _format_contract_short(contract).plain - assert "120 by N" in text - # Redouble takes precedence over the double marker. - assert "×4 by N" in text - assert "×2" not in text - - def test_slam_value_label(self, four_players): - _north, east, *_ = four_players - contract = Contract(ContractBid(east, SlamLevel.SLAM, Suit.HEARTS)) - text = _format_contract_short(contract).plain - assert "Slam by E" in text - - def test_verbose_spells_out_doubled(self, four_players): - """verbose=True replaces the ×2 glyph with the word 'doubled'.""" - north, east, *_ = four_players - contract = Contract( - ContractBid(north, 110, Suit.SPADES), - double_player=east, - ) - text = _format_contract_short(contract, verbose=True).plain - assert "doubled by E" in text - assert "×2" not in text - - def test_verbose_spells_out_redoubled(self, four_players): - """verbose=True replaces the ×4 glyph with the word 'redoubled'.""" - north, east, _south, _west = four_players - contract = Contract( - ContractBid(north, 120, Suit.CLUBS), - double_player=east, - redouble_player=north, - ) - text = _format_contract_short(contract, verbose=True).plain - assert "redoubled by N" in text - assert "×4" not in text - # Redouble takes precedence: only one marker, not two. - assert text.count("doubled") == 1 - - class TestOnBidMadePacing: """on_bid_made renders + sleeps for AI players, skips humans.""" @@ -878,7 +407,7 @@ class _StubRound: def test_on_all_pass_redeal_logs(self, monkeypatch): view = self._make_view(monkeypatch) - view.on_all_pass_redeal(round_=None) + view.on_all_pass_redeal() assert any("redealing" in line.plain for line in view.event_log) def test_on_contract_established_logs(self, monkeypatch, four_players): @@ -901,8 +430,9 @@ class _StubRound: view.on_contract_established(_StubRound()) line = view.event_log[-1].plain assert "Contract set:" in line - # The contract short label embeds value + the taker's seat letter. - assert "100" in line + # The contract short label embeds value + trump glyph + the + # taker's seat letter. + assert "100 ♥" in line assert "by N" in line def test_on_contract_established_includes_double_multiplier( @@ -926,7 +456,7 @@ class _StubRound: view.on_contract_established(_StubRound()) line = view.event_log[-1].plain - # Multiplier plus the coincheur's seat letter. + # Multiplier plus the double caller's seat letter. assert "×2 by W" in line # Taker is still named. assert "by E" in line @@ -942,20 +472,6 @@ class _StubRound: view.on_contract_established(_StubRound()) assert view.event_log == [] - def test_panel_event_log_renders_lines(self, monkeypatch): - view = self._make_view(monkeypatch) - view._log(Text("alpha")) - view._log(Text("beta")) - panel = view._panel_event_log() - assert "alpha" in panel.renderable.plain - assert "beta" in panel.renderable.plain - assert panel.title.plain == "Log" - - def test_panel_event_log_empty_placeholder(self, monkeypatch): - view = self._make_view(monkeypatch) - panel = view._panel_event_log() - assert "(no events yet)" in panel.renderable.plain - def test_attach_resets_log(self, monkeypatch, four_players): view = self._make_view(monkeypatch) view._log(Text("from previous game")) @@ -1030,7 +546,7 @@ def test_diamond_renders_belote_badge_for_announcer( north, *_ = four_players trick = Trick() # Empty trick is fine — the badge is keyed off belote_by_position. - diamond = view._render_diamond( + diamond = _render_diamond( trick, Suit.HEARTS, pending_position=None, @@ -1052,7 +568,7 @@ def test_diamond_badge_is_belote_regardless_of_kind( seat badge still reads '★ Belote' — the rebelote distinction lives only in the event log, not under the seat.""" view = self._make_view(monkeypatch) - diamond = view._render_diamond( + diamond = _render_diamond( Trick(), Suit.HEARTS, pending_position=None, @@ -1066,7 +582,7 @@ def test_diamond_badge_is_belote_regardless_of_kind( def test_diamond_no_badge_when_state_empty(self, monkeypatch): view = self._make_view(monkeypatch) - diamond = view._render_diamond( + diamond = _render_diamond( Trick(), Suit.HEARTS, pending_position=None, @@ -1092,14 +608,13 @@ def __init__(self): self.belote_state = {} def test_each_seat_shows_its_latest_bid(self, four_players): - view = RichView() north, east, south, west = four_players history = [ - (south, "Pass"), - (west, (80, Suit.HEARTS)), - (north, "Pass"), + PassBid(south), + ContractBid(west, 80, Suit.HEARTS), + PassBid(north), ] - diamond = view._render_bidding_diamond( + diamond = _render_bidding_diamond( history, pending_position=None, width=42 ) text = diamond.plain @@ -1108,10 +623,9 @@ def test_each_seat_shows_its_latest_bid(self, four_players): assert "Pass" in text def test_pending_seat_marked_with_question(self, four_players): - view = RichView() north, east, south, west = four_players - diamond = view._render_bidding_diamond( - [(west, (80, Suit.HEARTS))], + diamond = _render_bidding_diamond( + [ContractBid(west, 80, Suit.HEARTS)], pending_position="North", width=42, ) @@ -1120,8 +634,7 @@ def test_pending_seat_marked_with_question(self, four_players): assert "80 ♥" in diamond.plain def test_seat_without_bid_shows_dot(self, four_players): - view = RichView() - diamond = view._render_bidding_diamond( + diamond = _render_bidding_diamond( [], pending_position=None, width=42 ) # Empty auction: every seat is a placeholder dot, no "?". @@ -1130,16 +643,15 @@ def test_seat_without_bid_shows_dot(self, four_players): def test_latest_bid_overwrites_earlier(self, four_players): """A second bid by the same seat replaces the first in the diamond.""" - view = RichView() north, east, south, west = four_players history = [ - (west, (80, Suit.HEARTS)), - (north, (90, Suit.SPADES)), - (east, "Pass"), - (south, "Pass"), - (west, (100, Suit.HEARTS)), + ContractBid(west, 80, Suit.HEARTS), + ContractBid(north, 90, Suit.SPADES), + PassBid(east), + PassBid(south), + ContractBid(west, 100, Suit.HEARTS), ] - text = view._render_bidding_diamond( + text = _render_bidding_diamond( history, pending_position=None, width=42 ).plain assert "100 ♥" in text @@ -1147,15 +659,14 @@ def test_latest_bid_overwrites_earlier(self, four_players): def test_panel_current_trick_bidding_renders_diamond(self, four_players): """During bidding the Current-trick slot becomes the auction diamond.""" - view = RichView() north, east, south, west = four_players - panel = view._panel_current_trick( + panel = _panel_current_trick( self._StubRound(), trick=None, phase="bidding", current_player=south, trick_winner=None, - bidding_history=[(west, (80, Suit.HEARTS))], + bidding_history=[ContractBid(west, 80, Suit.HEARTS)], ) assert panel.title.plain == "Bidding" body = panel.renderable.plain @@ -1164,954 +675,6 @@ def test_panel_current_trick_bidding_renders_diamond(self, four_players): assert "S ?" in body -class TestRoundRecapPanel: - """Between-rounds recap: contract, made/failed, totals, belote.""" - - class _StubContract: - def __init__(self, value, suit, team_name, double=False, redouble=False): - self.value = value - self.suit = suit - class _T: pass - self.team = _T() - self.team.name = team_name - self.double = double - self.redouble = redouble - - def is_slam_family(self) -> bool: - return isinstance(self.value, SlamLevel) - - def is_slam(self) -> bool: - return self.value is SlamLevel.SLAM - - def is_solo_slam(self) -> bool: - return self.value is SlamLevel.SOLO_SLAM - - def get_base_points(self) -> int: - if isinstance(self.value, SlamLevel): - return self.value.base_value - return self.value - - def get_slam_card_substitute(self) -> int: - if isinstance(self.value, SlamLevel): - return self.value.base_value - return 0 - - def get_multiplier(self) -> int: - if self.redouble: - return 4 - if self.double: - return 2 - return 1 - - class _StubRound: - def __init__(self, *, round_number, contract, round_scores, - team_tricks=None, belote_holder=None, - contract_made=None): - self.round_number = round_number - self.contract = contract - self.round_scores = round_scores - self.team_tricks = team_tricks or {} - # Belote holder (player object exposing ``.team.name``) and - # the engine's canonical made/failed flag. ``contract_made`` - # left None lets ``RichView._contract_made`` fall back to the - # score heuristic, matching pre-flag behaviour for the simple - # cases these stubs cover. - self.belote_holder = belote_holder - self.contract_made = contract_made - - def test_recap_made_contract_shows_check(self): - view = RichView() - contract = self._StubContract(100, Suit.HEARTS, "North-South") - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 162, "East-West": 0}, - ) - panel = view._panel_round_recap(round_, {"North-South": 500, "East-West": 0}) - text = panel.renderable.plain - assert "Round #3 recap" in panel.title.plain - assert "Contract made" in text - assert "162" in text # round score, no leading "+" - assert "+162" not in text - assert "500" in text # running NS total - - def test_recap_shows_trump_recall_line(self): - """The recap spells out the contract trump on its own line.""" - view = RichView() - contract = self._StubContract(100, Suit.HEARTS, "North-South") - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 162, "East-West": 0}, - ) - panel = view._panel_round_recap(round_, {"North-South": 500, "East-West": 0}) - text = panel.renderable.plain - assert "Trump:" in text - assert "♥ Hearts" in text - - def test_recap_trump_line_omits_star(self): - """The recap's Trump line drops the ★ flourish (it stays plain). - - The star is reserved for the in-game Round panel; nothing else in - the recap renders a ★, so its absence is asserted panel-wide. - """ - view = RichView() - contract = self._StubContract(100, Suit.HEARTS, "North-South") - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 162, "East-West": 0}, - ) - panel = view._panel_round_recap(round_, {"North-South": 500, "East-West": 0}) - text = panel.renderable.plain - assert "♥ Hearts" in text - assert "★" not in text - - def test_recap_outcome_holds_tally_scoring_holds_round_points( - self, four_players - ): - """Section placement after the refactor: the factual play tally - (Tricks points / Last trick / Belote / Total) sits under Outcome, - while the rolled-up Round points sits under Scoring. On this - normal-made round the Scoring Round points equals trick points + - last trick + belote per side.""" - view = RichView() - north, east, *_ = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - # N-S takes one trick worth A♥ (trump ace = 11), wins the last - # trick (+10) and holds the belote pair (+20) → 41 round points. - ns_trick = Trick() - ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) - ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) - round_ = self._StubRound( - round_number=2, - contract=contract, - round_scores={"North-South": 141, "East-West": 0}, - team_tricks={"North-South": [ns_trick], "East-West": []}, - belote_holder=north, - ) - round_.last_trick_winner = north - breakdown = view._recap_breakdown(round_) - ns = breakdown["North-South"] - # Round points is the sum of the three factual Outcome rows. - assert ( - ns["round_points"] - == ns["trick_points"] + ns["last_trick"] + ns["belote"] - == 41 - ) - text = view._panel_round_recap( - round_, {"North-South": 141, "East-West": 0} - ).renderable.plain - outcome, scoring = text.split("Scoring") - # Tally rows (and their Total) live above the Scoring rule, the - # rolled-up Round points below it. - for row in ("Tricks points", "Last trick", "Belote", "Total"): - assert row in outcome - assert row not in scoring - assert "Round points" in scoring - assert "Round points" not in outcome - - def test_recap_failed_contract_shows_cross(self): - view = RichView() - contract = self._StubContract(120, Suit.SPADES, "East-West") - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 280, "East-West": 0}, - ) - panel = view._panel_round_recap(round_, {"North-South": 280, "East-West": 0}) - text = panel.renderable.plain - assert "Contract failed" in text - - def test_recap_uses_verbose_doubled_marker(self): - """The recap spells out 'doubled' rather than the ×2 glyph.""" - view = RichView() - contract = self._StubContract( - 110, Suit.SPADES, "North-South", double=True - ) - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 0, "East-West": 320}, - ) - panel = view._panel_round_recap(round_, {"North-South": 0, "East-West": 320}) - text = panel.renderable.plain - assert "doubled" in text - assert "×2" not in text - - def test_recap_all_passed(self): - view = RichView() - round_ = self._StubRound( - round_number=5, - contract=None, - round_scores={"North-South": 0, "East-West": 0}, - ) - panel = view._panel_round_recap(round_, {"North-South": 0, "East-West": 0}) - text = panel.renderable.plain - assert "All passed" in text - # No made/failed line for an all-passed round. - assert "made" not in text - assert "failed" not in text - # Outcome table harmonizes with Scoring: em-dashes, not zeros, on - # the Tricks won, Total and Round points rows when nothing was played. - for line in text.splitlines(): - if ( - "Tricks won" in line - or "Total" in line - or "Round points" in line - ): - assert "0" not in line - assert "—" in line - - def test_recap_includes_belote_when_holder_holds_kq_of_trump( - self, four_players - ): - view = RichView() - north, *_ = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - # Belote follows the *holder* of K+Q of trump, not who captures - # them in a trick — so the recap reads ``belote_holder``. - round_ = self._StubRound( - round_number=2, - contract=contract, - round_scores={"North-South": 200, "East-West": 0}, - team_tricks={"North-South": [], "East-West": []}, - belote_holder=north, - ) - panel = view._panel_round_recap(round_, {"North-South": 200, "East-West": 0}) - text = panel.renderable.plain - # The Belote row carries the holder's 20, with no leading "+". - # (The "+" in the "K + Q" label is not a sign — guard the value.) - belote_line = next( - line for line in text.splitlines() if "Belote" in line - ) - assert "20" in belote_line - assert "+20" not in belote_line - - def test_recap_shows_card_points_sum_per_team(self, four_players): - """Card-points row shows the trump-aware sum across each team's - tricks (plus the trick count).""" - view = RichView() - north, east, south, west = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - # N-S took J♥ (20) + 9♥ (14) + A♠ (11) = 45 across two tricks. - ns_trick1 = Trick() - ns_trick1.add_play(north, Card(Suit.HEARTS, Rank.JACK)) - ns_trick1.add_play(east, Card(Suit.HEARTS, Rank.SEVEN)) - ns_trick2 = Trick() - ns_trick2.add_play(south, Card(Suit.SPADES, Rank.ACE)) - ns_trick2.add_play(west, Card(Suit.HEARTS, Rank.NINE)) - # E-W took two low tricks worth 0 + 0 = 0. - ew_trick = Trick() - ew_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 145, "East-West": 0}, - team_tricks={ - "North-South": [ns_trick1, ns_trick2], - "East-West": [ew_trick], - }, - ) - panel = view._panel_round_recap( - round_, {"North-South": 145, "East-West": 0} - ) - text = panel.renderable.plain - # Trump-aware card points: - # ns_trick1: J♥(20) + 7♥(0) = 20 - # ns_trick2: A♠(11) + 9♥(14) = 25 - # N-S total = 45 — shown in the Outcome "Tricks points" row. - assert "45" in text - assert "Outcome" in text - assert "Tricks won" in text - assert "Tricks points" in text - # The rolled-up tally lives in the Scoring sub-table now. - assert "Round points" in text - - def test_recap_round_points_sum_pile_last_trick_and_belote( - self, four_players - ): - """Outcome ``round_points`` = trump-aware pile + last trick (10) - + belote (20), the honest play tally per team.""" - view = RichView() - north, east, *_ = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - # N-S takes one trick worth A♥ (trump ace = 11). - ns_trick = Trick() - ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) - ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) - round_ = self._StubRound( - round_number=2, - contract=contract, - round_scores={"North-South": 141, "East-West": 0}, - team_tricks={"North-South": [ns_trick], "East-West": []}, - belote_holder=north, - ) - round_.last_trick_winner = north - breakdown = view._recap_breakdown(round_) - # 11 (A♥) + 10 (last trick) + 20 (belote) = 41. - assert breakdown["North-South"]["round_points"] == 41 - assert breakdown["East-West"]["round_points"] == 0 - - def test_recap_round_points_survive_winner_takes_all_round( - self, four_players - ): - """In a doubled/failed round the Scoring card row is dashed, but - ``round_points`` still reports the real pile each side captured.""" - view = RichView() - north, east, *_ = four_players - # Doubled contract by N-S that fails — E-W scores winner-takes-all. - contract = self._StubContract( - 100, Suit.HEARTS, "North-South", double=True - ) - ew_trick = Trick() - ew_trick.add_play(east, Card(Suit.HEARTS, Rank.JACK)) # trump J = 20 - round_ = self._StubRound( - round_number=2, - contract=contract, - round_scores={"North-South": 0, "East-West": 320}, - team_tricks={"North-South": [], "East-West": [ew_trick]}, - contract_made=False, - ) - breakdown = view._recap_breakdown(round_) - ew = breakdown["East-West"] - # Scoring zeroes the card row (winner-takes-all formula)... - assert ew["cards_count"] is False - # ...but the real captured pile still shows in round_points. - assert ew["round_points"] == 20 - - def test_recap_outcome_total_sums_the_tally(self, four_players): - """The Outcome table closes with a Total row equal to the per-side - honest tally — trick points + last trick + belote.""" - view = RichView() - north, east, *_ = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - # N-S: A♥ (11) + last trick (10) + belote (20) = 41. - ns_trick = Trick() - ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) - ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) - round_ = self._StubRound( - round_number=2, - contract=contract, - round_scores={"North-South": 141, "East-West": 0}, - team_tricks={"North-South": [ns_trick], "East-West": []}, - belote_holder=north, - ) - round_.last_trick_winner = north - text = view._panel_round_recap( - round_, {"North-South": 141, "East-West": 0} - ).renderable.plain - outcome = text.split("Scoring")[0] - total_line = next( - line for line in outcome.splitlines() if "Total" in line - ) - # 11 + 10 + 20 = 41, with no leading "+". - assert "41" in total_line - assert "+" not in total_line - - def test_recap_scoring_round_points_belote_only_when_contre( - self, four_players - ): - """On a chuté/contré round the captured pile stops scoring, so the - Scoring 'Round points' row collapses to the belote the holder keeps - — while the Outcome 'Total' still reports the full captured tally.""" - view = RichView() - north, east, *_ = four_players - # N-S declares doubled, fails; N-S still captured A♥ (11) and holds - # the belote (20). E-W took the last trick. - contract = self._StubContract( - 100, Suit.HEARTS, "North-South", double=True - ) - ns_trick = Trick() - ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) - ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 20, "East-West": 360}, - team_tricks={"North-South": [ns_trick], "East-West": []}, - belote_holder=north, - contract_made=False, - ) - round_.last_trick_winner = east # der goes to E-W, not N-S - text = view._panel_round_recap( - round_, {"North-South": 20, "East-West": 360} - ).renderable.plain - outcome, scoring = text.split("Scoring") - # Outcome Total = 11 (A♥) + 0 (no der) + 20 (belote) = 31. - total_line = next( - line for line in outcome.splitlines() if "Total" in line - ) - assert "31" in total_line - # Scoring Round points = belote only (20), not the 31 captured. - rp_line = next( - line for line in scoring.splitlines() if "Round points" in line - ) - assert "20" in rp_line - assert "31" not in rp_line - - def test_recap_scoring_round_points_dashed_when_chute_no_belote( - self, four_players - ): - """A failed contract with no belote held → the Scoring 'Round - points' row dashes out entirely (nothing of the pile scores).""" - view = RichView() - north, east, *_ = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - ew_trick = Trick() - ew_trick.add_play(east, Card(Suit.HEARTS, Rank.JACK)) # E-W captures - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 0, "East-West": 260}, - team_tricks={"North-South": [], "East-West": [ew_trick]}, - contract_made=False, - ) - text = view._panel_round_recap( - round_, {"North-South": 0, "East-West": 260} - ).renderable.plain - scoring = text.split("Scoring")[1] - rp_line = next( - line for line in scoring.splitlines() if "Round points" in line - ) - # No belote anywhere → both sides dash on the scoring roll-up. - assert "—" in rp_line - assert "20" not in rp_line - - def test_recap_no_plus_signs_in_made_round(self, four_players): - """Regression guard: no leading '+' survives anywhere in the recap - after the sign cleanup, on a normal made round with bonuses.""" - view = RichView() - north, east, *_ = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - ns_trick = Trick() - ns_trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) - ns_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) - round_ = self._StubRound( - round_number=2, - contract=contract, - round_scores={"North-South": 141, "East-West": 0}, - team_tricks={"North-South": [ns_trick], "East-West": []}, - belote_holder=north, - ) - round_.last_trick_winner = north - text = view._panel_round_recap( - round_, {"North-South": 141, "East-West": 0} - ).renderable.plain - # No signed numbers remain (a "+" before a digit). The literal "+" - # in the "Belote (K + Q ♥)" label is not a sign and is allowed. - assert re.search(r"\+\d", text) is None - - def test_recap_unannounced_capot_substitutes_250_and_folds_der( - self, four_players - ): - """Unannounced capot: the Outcome 'Tricks points' row reads 250 - (the flat substitute), 'Last trick' is folded in (0), and the - contract + substitute still sum to the round score.""" - view = RichView() - north, *_ = four_players - contract = self._StubContract(100, Suit.SPADES, "North-South") - # N-S swept all 8 tricks. Filler cards — the engine's 250 - # substitute, not the raw pile, is what the recap must show. - ns_tricks = [] - for _ in range(8): - tr = Trick() - tr.add_play(north, Card(Suit.CLUBS, Rank.SEVEN)) - ns_tricks.append(tr) - round_ = self._StubRound( - round_number=6, - contract=contract, - round_scores={"North-South": 350, "East-West": 0}, - team_tricks={"North-South": ns_tricks, "East-West": []}, - contract_made=True, - ) - round_.unannounced_capot = UnannouncedSlam.GRAND_SLAM # north swept personally - round_.last_trick_winner = north # der would be +10 — must fold in - breakdown = view._recap_breakdown(round_) - ns = breakdown["North-South"] - assert ns["trick_points"] == 250 - assert ns["last_trick"] == 0 - assert ns["card_points"] == 250 - assert ns["card_points_substituted"] is True - assert ns["contract"] == 100 - assert ns["round_points"] == 250 - # Invariant preserved: contract + card_points + dix + belote == score. - assert ( - ns["contract"] + ns["card_points"] + ns["dix_de_der"] + ns["belote"] - == 350 - ) - text = view._panel_round_recap( - round_, {"North-South": 350, "East-West": 0} - ).renderable.plain - assert "250" in text - # The der is folded into the substitute — no stray +10 in the row. - outcome = text.split("Scoring")[0] - last_trick_line = next( - line for line in outcome.splitlines() if "Last trick" in line - ) - assert "+10" not in last_trick_line - - @pytest.mark.parametrize( - "marker, expected_tag", - [ - (UnannouncedSlam.SLAM, "Slam"), - (UnannouncedSlam.GRAND_SLAM, "Grand Slam"), - ], - ) - def test_recap_capot_tags_the_trick_points_row( - self, four_players, marker, expected_tag - ): - """The unannounced-capot marker surfaces its label on the Trick - points row to explain the 250 substitute.""" - view = RichView() - north, *_ = four_players - contract = self._StubContract(90, Suit.HEARTS, "North-South") - ns_tricks = [] - for _ in range(8): - tr = Trick() - tr.add_play(north, Card(Suit.CLUBS, Rank.SEVEN)) - ns_tricks.append(tr) - round_ = self._StubRound( - round_number=7, - contract=contract, - round_scores={"North-South": 340, "East-West": 0}, - team_tricks={"North-South": ns_tricks, "East-West": []}, - contract_made=True, - ) - round_.unannounced_capot = marker - text = view._panel_round_recap( - round_, {"North-South": 340, "East-West": 0} - ).renderable.plain - assert expected_tag in text - assert "250" in text - - def test_recap_shows_dix_de_der_for_last_trick_winner(self, four_players): - view = RichView() - north, *_ = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - last_trick = Trick() - last_trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 110, "East-West": 0}, - team_tricks={"North-South": [last_trick], "East-West": []}, - ) - round_.last_trick_winner = north - panel = view._panel_round_recap( - round_, {"North-South": 110, "East-West": 0} - ) - text = panel.renderable.plain - # The Last trick row carries the der's 10, with no leading "+". - last_trick_line = next( - line for line in text.splitlines() if "Last trick" in line - ) - assert "10" in last_trick_line - assert "+10" not in last_trick_line - - def test_recap_contract_row_shows_contract_value_when_made_normal( - self, four_players - ): - """100 ♥ made by N-S → 'Contract' row shows +100 on N-S column, - em-dash on E-W.""" - view = RichView() - north, *_ = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - round_ = self._StubRound( - round_number=2, - contract=contract, - round_scores={"North-South": 162, "East-West": 0}, - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - assert breakdown["North-South"]["contract"] == 100 - assert breakdown["East-West"]["contract"] == 0 - # Cards / dix / belote DO contribute on a normal-made contract. - assert breakdown["North-South"]["cards_count"] is True - assert breakdown["East-West"]["cards_count"] is True - - def test_recap_contract_row_uses_slam_base_when_made(self, four_players): - """A made Slam normal: the contract row carries the base (250) - and the card-points row carries the flat substitute (250), - summing to the engine's 500. Dix de der does not contribute; - the row label flips to "(subst.)".""" - view = RichView() - contract = self._StubContract(SlamLevel.SLAM, Suit.SPADES, "East-West") - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 0, "East-West": 500}, - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - # Slam normal: base = 250, substitute = 250, mult = 1. - assert breakdown["East-West"]["contract"] == 250 - assert breakdown["East-West"]["card_points"] == 250 - assert breakdown["East-West"]["card_points_substituted"] is True - assert breakdown["East-West"]["cards_count"] is True - # Dix de der is no longer counted on Slam family rounds. - assert breakdown["East-West"]["dix_count"] is False - assert breakdown["East-West"]["dix_de_der"] == 0 - # Losing side: zeros everywhere except belote (not tested here). - assert breakdown["North-South"]["contract"] == 0 - assert breakdown["North-South"]["card_points"] == 0 - assert breakdown["North-South"]["card_points_substituted"] is True - - def test_recap_contract_row_uses_slam_grid_when_failed(self, four_players): - """Failed Slam: defender wins the at-risk amount split into - contract (250) + substituted card points (250).""" - view = RichView() - contract = self._StubContract(SlamLevel.SLAM, Suit.SPADES, "East-West") - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 500, "East-West": 0}, - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - assert breakdown["North-South"]["contract"] == 250 - assert breakdown["North-South"]["card_points"] == 250 - assert breakdown["North-South"]["card_points_substituted"] is True - assert breakdown["North-South"]["cards_count"] is True - assert breakdown["East-West"]["contract"] == 0 - assert breakdown["East-West"]["card_points"] == 0 - assert breakdown["East-West"]["cards_count"] is False - - def test_recap_contract_row_uses_solo_slam_grid_when_made(self, four_players): - """Made Solo Slam normal: contract = 500, substitute = 500, - sum = 1000.""" - view = RichView() - contract = self._StubContract(SlamLevel.SOLO_SLAM, Suit.SPADES, "East-West") - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 0, "East-West": 1000}, - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - assert breakdown["East-West"]["contract"] == 500 - assert breakdown["East-West"]["card_points"] == 500 - assert breakdown["East-West"]["card_points_substituted"] is True - assert breakdown["North-South"]["contract"] == 0 - assert breakdown["North-South"]["card_points"] == 0 - - def test_recap_contract_row_uses_solo_slam_doubled_grid(self, four_players): - """Doubled Solo Slam made: both halves scale with the multiplier. - Contract = 500 * 2 = 1000; substitute = 500 * 2 = 1000; sum = 2000.""" - view = RichView() - contract = self._StubContract( - SlamLevel.SOLO_SLAM, Suit.SPADES, "East-West", double=True - ) - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 0, "East-West": 2000}, - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - assert breakdown["East-West"]["contract"] == 1000 - assert breakdown["East-West"]["card_points"] == 1000 - assert breakdown["East-West"]["card_points_substituted"] is True - - def test_recap_contract_row_includes_full_bonus_when_doubled_made( - self, four_players - ): - """When the engine substitutes the flat 160+base*mult bonus - (doubled or redoubled made), the 'Contract' row carries the - full amount and the cards/dix/belote rows are zeroed for the - attacker so the breakdown sums to round_score.""" - view = RichView() - contract = self._StubContract( - 100, Suit.HEARTS, "North-South", double=True - ) - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 360, "East-West": 0}, - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - # 160 + 100*2 = 360 - assert breakdown["North-South"]["contract"] == 360 - # Attacker's cards/dix/belote are ignored by the engine — the - # recap reflects that so the addition matches round_score. - assert breakdown["North-South"]["cards_count"] is False - assert breakdown["North-South"]["card_points"] == 0 - assert breakdown["North-South"]["dix_de_der"] == 0 - assert breakdown["North-South"]["belote"] == 0 - - def test_recap_contract_row_includes_full_bonus_when_redoubled_made( - self, four_players - ): - view = RichView() - contract = self._StubContract( - 100, Suit.HEARTS, "North-South", redouble=True - ) - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 560, "East-West": 0}, # 160 + 100*4 - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - # 160 + 100*4 = 560 - assert breakdown["North-South"]["contract"] == 560 - assert breakdown["North-South"]["cards_count"] is False - - def test_recap_contract_row_shows_defender_bonus_when_failed( - self, four_players - ): - """100 ♥ failed by N-S → E-W gets (160 + 100) * 1 = 260 in - their 'Contract' row; their cards/dix/belote are zeroed.""" - view = RichView() - contract = self._StubContract(100, Suit.HEARTS, "North-South") - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 0, "East-West": 260}, - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - assert breakdown["East-West"]["contract"] == 260 - assert breakdown["North-South"]["contract"] == 0 - # Defender's cards/dix/belote don't contribute on a failed - # contract — the engine pays them a flat bonus instead. - assert breakdown["East-West"]["cards_count"] is False - # Attacker gets 0 on a failed contract; their cards/dix/belote - # also don't contribute (round_score is 0). - assert breakdown["North-South"]["cards_count"] is False - - def test_recap_contract_row_failed_doubled_winner_takes_160_plus_cm( - self, four_players - ): - """Failed 100 ♥ ×2 by N-S → E-W wins 160 + 100*2 = 360 (same - stake as a doubled made declarer — winner-takes-all).""" - view = RichView() - contract = self._StubContract( - 100, Suit.HEARTS, "North-South", double=True - ) - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 0, "East-West": 360}, - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - assert breakdown["East-West"]["contract"] == 360 - # Loser scores nothing (no belote here). - assert breakdown["North-South"]["contract"] == 0 - assert breakdown["North-South"]["cards_count"] is False - - def test_recap_doubled_made_defender_scores_zero(self, four_players): - """Doubled contract made → the losing defender's breakdown is all - zeros (winner-takes-all). Mirrors the engine's Problem-2 fix.""" - view = RichView() - contract = self._StubContract( - 100, Suit.HEARTS, "North-South", double=True - ) - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 360, "East-West": 0}, - team_tricks={"North-South": [], "East-West": []}, - ) - breakdown = view._recap_breakdown(round_) - ew = breakdown["East-West"] - assert ew["contract"] == 0 - assert ew["cards_count"] is False - assert ew["card_points"] == 0 - assert ew["dix_de_der"] == 0 - assert ew["belote"] == 0 - - def test_recap_loser_keeps_belote_when_doubled(self, four_players): - """The one thing a losing side keeps is its belote — the recap - shows +20 for the holder even when it lost a doubled round.""" - view = RichView() - _north, east, _south, _west = four_players - contract = self._StubContract( - 100, Suit.HEARTS, "North-South", double=True - ) - round_ = self._StubRound( - round_number=4, - contract=contract, - round_scores={"North-South": 360, "East-West": 20}, - team_tricks={"North-South": [], "East-West": []}, - belote_holder=east, # losing defender holds the pair - ) - breakdown = view._recap_breakdown(round_) - ew = breakdown["East-West"] - assert ew["belote_count"] is True - assert ew["belote"] == 20 - assert ew["contract"] == 0 - assert ew["cards_count"] is False - # The four components still sum to the engine's round score. - assert ( - ew["contract"] + ew["card_points"] + ew["dix_de_der"] + ew["belote"] - == 20 - ) - - def test_recap_panel_renders_contract_row(self, four_players): - """End-to-end: the rendered panel contains a 'Contract' row.""" - view = RichView() - contract = self._StubContract(100, Suit.HEARTS, "North-South") - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 162, "East-West": 0}, - team_tricks={"North-South": [], "East-West": []}, - ) - panel = view._panel_round_recap( - round_, {"North-South": 500, "East-West": 0} - ) - text = panel.renderable.plain - assert "Contract " in text # row label - assert "100" in text # attacker contract bonus, no leading "+" - assert "+100" not in text - - def test_recap_breakdown_sums_to_round_score_normal_made( - self, four_players - ): - """Invariant: for any team, the four component rows must sum - to the engine's round_score. This is the test for the normal - (un-doubled) made case.""" - view = RichView() - north, east, south, west = four_players - contract = self._StubContract(100, Suit.HEARTS, "North-South") - # N-S took two tricks; sum of card.get_points(♥) = - # J♥(20)+7♥(0)+9♥(14)+8♥(0) = 34 - # A♠(11)+7♠(0)+K♠(4)+8♠(0) = 15 - # Total card points: 49. - ns_trick1 = Trick() - for p, c in [ - (north, Card(Suit.HEARTS, Rank.JACK)), - (east, Card(Suit.HEARTS, Rank.SEVEN)), - (south, Card(Suit.HEARTS, Rank.NINE)), - (west, Card(Suit.HEARTS, Rank.EIGHT)), - ]: - ns_trick1.add_play(p, c) - ns_trick2 = Trick() - for p, c in [ - (north, Card(Suit.SPADES, Rank.ACE)), - (east, Card(Suit.SPADES, Rank.SEVEN)), - (south, Card(Suit.SPADES, Rank.KING)), - (west, Card(Suit.SPADES, Rank.EIGHT)), - ]: - ns_trick2.add_play(p, c) - # Engine score = 100 (contract) + 49 (cards) + 10 (dix) = 159. - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 159, "East-West": 0}, - team_tricks={ - "North-South": [ns_trick1, ns_trick2], - "East-West": [], - }, - ) - round_.last_trick_winner = north - breakdown = view._recap_breakdown(round_) - ns = breakdown["North-South"] - sum_ns = ( - ns["contract"] - + ns["card_points"] - + ns["dix_de_der"] - + ns["belote"] - ) - assert sum_ns == 159 - - def test_recap_table_uses_trump_glyph_in_belote_label(self, four_players): - """The Belote row label reflects the actual trump suit.""" - view = RichView() - north, *_ = four_players - contract = self._StubContract(100, Suit.SPADES, "North-South") - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 200, "East-West": 0}, - team_tricks={"North-South": [], "East-West": []}, - ) - panel = view._panel_round_recap( - round_, {"North-South": 200, "East-West": 0} - ) - text = panel.renderable.plain - # Spade glyph in the Belote row, not the hearts glyph. - assert "Belote (K + Q ♠)" in text - - def _capture_recap_prompt(self, view, round_, scores, **kwargs) -> str: - """Run ``show_round_recap`` and return the printed plain text. - - Panels carry a ``renderable`` Text whose ``.plain`` is the - prompt copy we want to assert on. Walk every printed argument - and concatenate any plain-text payloads we can extract. - """ - view.console.input = lambda *_a, **_kw: "" - view.console.clear = lambda *_a, **_kw: None - captured: list[str] = [] - def _record(*args, **_kw): - for a in args: - if hasattr(a, "plain"): - captured.append(a.plain) - elif hasattr(a, "renderable") and hasattr(a.renderable, "plain"): - captured.append(a.renderable.plain) - else: - captured.append(str(a)) - view.console.print = _record - view.show_round_recap(round_, scores, **kwargs) - return "\n".join(captured) - - def test_show_round_recap_default_prompt(self): - """Without ``is_final`` the prompt invites the next deal.""" - view = RichView() - contract = self._StubContract(100, Suit.HEARTS, "North-South") - round_ = self._StubRound( - round_number=3, - contract=contract, - round_scores={"North-South": 162, "East-West": 0}, - ) - output = self._capture_recap_prompt( - view, round_, {"North-South": 162, "East-West": 0} - ) - assert "deal the next round" in output - assert "final score" not in output - - def test_show_round_recap_final_prompt(self): - """With ``is_final=True`` the prompt points at the final score.""" - view = RichView() - contract = self._StubContract(100, Suit.HEARTS, "North-South") - round_ = self._StubRound( - round_number=10, - contract=contract, - round_scores={"North-South": 162, "East-West": 0}, - ) - output = self._capture_recap_prompt( - view, round_, {"North-South": 1620, "East-West": 1300}, - is_final=True, - ) - assert "final score" in output - assert "deal the next round" not in output - - -class TestFormatTrumpLabel: - """`_format_trump_label` glyph/label plus the optional ★ flourish.""" - - def test_default_includes_star(self): - text = _format_trump_label(Suit.HEARTS).plain - assert "♥ Hearts" in text - assert "★" in text - - def test_star_false_omits_star(self): - text = _format_trump_label(Suit.HEARTS, star=False).plain - assert "♥ Hearts" in text - assert "★" not in text - - def test_no_trump_label(self): - text = _format_trump_label(Suit.NO_TRUMP, star=False).plain - assert "No Trump" in text - assert "★" not in text - - def test_none_suit_is_em_dash(self): - assert _format_trump_label(None).plain == "—" - - class TestPanelRoundTitle: """The Round panel's title shows the active round number.""" @@ -2127,12 +690,12 @@ def __init__(self, round_number): def test_title_contains_round_number(self): view = RichView() - panel = view._panel_round(self._StubRound(7), phase="bidding") + panel = _panel_round(self._StubRound(7), phase="bidding") assert "Round #7" in panel.title.plain def test_title_defaults_when_round_is_none(self): view = RichView() - panel = view._panel_round(None, phase="bidding") + panel = _panel_round(None, phase="bidding") assert panel.title.plain.startswith("Round") # No # marker when there is no round to talk about. assert "#" not in panel.title.plain @@ -2153,7 +716,7 @@ def __init__(self, tricks_done): def test_current_trick_title_uses_hash_format(self): view = RichView() # 4 tricks done, currently playing trick #5. - panel = view._panel_current_trick( + panel = _panel_current_trick( self._StubRound(tricks_done=4), trick=Trick(), phase="playing", @@ -2172,262 +735,17 @@ def test_last_trick_title_uses_hash_format(self, monkeypatch, four_players): # Stub a completed trick. view.last_completed_trick = (trick, north) round_ = self._StubRound(tricks_done=7) - panel = view._panel_last_trick(round_) + panel = _panel_last_trick(round_, view.last_completed_trick) assert "Last trick (#7)" in panel.title.plain def test_last_trick_title_bare_when_no_round(self): view = RichView() # No last_completed_trick set → '(none)' panel with the bare # "Last trick" title. - panel = view._panel_last_trick(None) + panel = _panel_last_trick(None, view.last_completed_trick) assert panel.title.plain == "Last trick" -# ====================================================================== -# _parse_card_input -# ====================================================================== - - -class TestParseCardInput: - """Card-number parser. Validates that the picked card is playable.""" - - @pytest.fixture - def hand(self): - return [ - Card(Suit.HEARTS, Rank.JACK), - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.SPADES, Rank.ACE), - Card(Suit.DIAMONDS, Rank.QUEEN), - ] - - def test_valid_choice_in_playable(self, hand): - playable = hand[:2] # only hearts are playable - assert _parse_card_input("1", hand, playable) is hand[0] - assert _parse_card_input("2", hand, playable) is hand[1] - - def test_choice_not_in_playable(self, hand): - """User picks a number that maps to a non-playable card.""" - playable = hand[:2] - assert _parse_card_input("3", hand, playable) is None # A♠ not playable - - def test_choice_out_of_range(self, hand): - assert _parse_card_input("0", hand, hand) is None - assert _parse_card_input("5", hand, hand) is None - - @pytest.mark.parametrize("raw", ["", "abc", "1.5", "-1", " 1a"]) - def test_non_digit(self, hand, raw): - assert _parse_card_input(raw, hand, hand) is None - - def test_whitespace_trimmed(self, hand): - assert _parse_card_input(" 1 ", hand, hand) is hand[0] - - -# ====================================================================== -# _sort_hand_for_display -# ====================================================================== - - -class TestSortHandForDisplay: - """Display-order sort: trump-first, then suit-by-suit, rank desc.""" - - def test_no_trump_default_order(self): - cards = [ - Card(Suit.CLUBS, Rank.SEVEN), - Card(Suit.HEARTS, Rank.QUEEN), - Card(Suit.SPADES, Rank.JACK), - Card(Suit.DIAMONDS, Rank.ACE), - ] - result = _sort_hand_for_display(cards, trump_suit=None) - # Default suit order: S, H, D, C - assert [c.suit for c in result] == [ - Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS, - ] - - def test_trump_goes_first(self): - cards = [ - Card(Suit.SPADES, Rank.ACE), - Card(Suit.HEARTS, Rank.JACK), - Card(Suit.DIAMONDS, Rank.KING), - Card(Suit.CLUBS, Rank.NINE), - ] - result = _sort_hand_for_display(cards, trump_suit=Suit.HEARTS) - assert result[0].suit == Suit.HEARTS - # Non-trump suits keep S, D, C order with hearts removed. - assert [c.suit for c in result[1:]] == [ - Suit.SPADES, Suit.DIAMONDS, Suit.CLUBS, - ] - - def test_within_suit_rank_desc_no_trump(self): - """Within a non-trump suit, highest rank first (normal order).""" - cards = [ - Card(Suit.SPADES, Rank.SEVEN), - Card(Suit.SPADES, Rank.ACE), - Card(Suit.SPADES, Rank.JACK), - ] - result = _sort_hand_for_display(cards, trump_suit=Suit.HEARTS) - assert [c.rank for c in result] == [Rank.ACE, Rank.JACK, Rank.SEVEN] - - def test_within_trump_suit_uses_trump_order(self): - """Inside the trump suit, the Jack out-ranks the Ace (trump order).""" - cards = [ - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.HEARTS, Rank.JACK), - Card(Suit.HEARTS, Rank.NINE), - Card(Suit.HEARTS, Rank.SEVEN), - ] - result = _sort_hand_for_display(cards, trump_suit=Suit.HEARTS) - # Trump order: 7, 8, Q, K, 10, A, 9, J — so J on top, then 9, then A. - assert [c.rank for c in result] == [ - Rank.JACK, Rank.NINE, Rank.ACE, Rank.SEVEN, - ] - - def test_empty_suit_skipped(self): - cards = [ - Card(Suit.SPADES, Rank.ACE), - Card(Suit.DIAMONDS, Rank.KING), - ] - result = _sort_hand_for_display(cards, trump_suit=None) - assert len(result) == 2 - assert {c.suit for c in result} == {Suit.SPADES, Suit.DIAMONDS} - - def test_empty_hand_returns_empty(self): - assert _sort_hand_for_display([], trump_suit=None) == [] - assert _sort_hand_for_display([], trump_suit=Suit.SPADES) == [] - - -# ====================================================================== -# _current_winner -# ====================================================================== - - -class TestCurrentWinner: - """Live trick-winner computation for the diamond gold-pill highlight.""" - - def test_empty_plays_returns_none(self): - assert _current_winner([], trump_suit=Suit.HEARTS) is None - assert _current_winner([], trump_suit=None) is None - - def test_single_play_wins(self, four_players): - north, _, _, _ = four_players - plays = [(north, Card(Suit.SPADES, Rank.SEVEN))] - assert _current_winner(plays, trump_suit=Suit.HEARTS) is north - - def test_highest_of_led_suit_wins_no_trump_played(self, four_players): - north, east, south, west = four_players - plays = [ - (west, Card(Suit.SPADES, Rank.KING)), - (north, Card(Suit.SPADES, Rank.TEN)), - (east, Card(Suit.SPADES, Rank.ACE)), # ace wins - ] - assert _current_winner(plays, trump_suit=Suit.HEARTS) is east - - def test_off_suit_non_trump_cannot_win(self, four_players): - """Discarding off-suit (no trump) doesn't take the trick.""" - north, east, south, west = four_players - plays = [ - (west, Card(Suit.SPADES, Rank.SEVEN)), - (north, Card(Suit.DIAMONDS, Rank.ACE)), # off suit, no trump - ] - assert _current_winner(plays, trump_suit=Suit.HEARTS) is west - - def test_trump_beats_non_trump(self, four_players): - north, east, south, west = four_players - plays = [ - (west, Card(Suit.SPADES, Rank.ACE)), - (north, Card(Suit.HEARTS, Rank.SEVEN)), # weakest trump still wins - ] - assert _current_winner(plays, trump_suit=Suit.HEARTS) is north - - def test_highest_trump_wins(self, four_players): - north, east, south, west = four_players - plays = [ - (west, Card(Suit.SPADES, Rank.KING)), # led - (north, Card(Suit.HEARTS, Rank.NINE)), # trump - (east, Card(Suit.HEARTS, Rank.JACK)), # jack is top trump - (south, Card(Suit.HEARTS, Rank.ACE)), # ace below jack/9 - ] - assert _current_winner(plays, trump_suit=Suit.HEARTS) is east - - def test_no_trump_contract_uses_led_suit(self, four_players): - """``trump_suit=None`` (or NoTrump) means highest led-suit card wins.""" - north, east, south, west = four_players - plays = [ - (west, Card(Suit.SPADES, Rank.KING)), - (north, Card(Suit.SPADES, Rank.ACE)), - (east, Card(Suit.HEARTS, Rank.JACK)), # off suit, can't win - ] - assert _current_winner(plays, trump_suit=None) is north - - -# ====================================================================== -# _explain_constraint -# ====================================================================== - - -class TestExplainConstraint: - """Human-readable hint under the hand row.""" - - def _make_trick(self, *plays): - t = Trick() - for player, card in plays: - t.add_play(player, card) - return t - - def test_empty_trick_is_your_lead(self, four_players): - _, _, south, _ = four_players - south.hand.clear() - south.hand.append(Card(Suit.SPADES, Rank.ACE)) - empty = Trick() - result = _explain_constraint(south, empty, list(south.hand), Suit.HEARTS) - assert "your lead" in result.plain.lower() - - def test_must_follow_led_suit(self, four_players): - north, _, south, west = four_players - # West led ♠K, South has ♠s in hand → must follow. - south.hand.clear() - south.hand.extend([ - Card(Suit.SPADES, Rank.SEVEN), - Card(Suit.SPADES, Rank.JACK), - Card(Suit.HEARTS, Rank.ACE), - ]) - trick = self._make_trick((west, Card(Suit.SPADES, Rank.KING))) - playable = south.hand.cards_of_suit(Suit.SPADES) - result = _explain_constraint(south, trick, playable, Suit.HEARTS) - assert "must follow" in result.plain - assert "♠" in result.plain - - def test_must_trump_when_partner_not_winning(self, four_players): - north, east, south, west = four_players - # West led ♣K, South has no clubs, has hearts (trump) → must trump. - south.hand.clear() - south.hand.extend([ - Card(Suit.HEARTS, Rank.JACK), - Card(Suit.HEARTS, Rank.ACE), - Card(Suit.DIAMONDS, Rank.QUEEN), - ]) - trick = self._make_trick((west, Card(Suit.CLUBS, Rank.KING))) - playable = south.hand.cards_of_suit(Suit.HEARTS) # only trumps legal - result = _explain_constraint(south, trick, playable, Suit.HEARTS) - assert "must trump" in result.plain - # The leader's position label should appear in the hint. - assert "W" in result.plain - - def test_free_discard_when_no_led_suit_no_trump_obligation(self, four_players): - """No led-suit in hand, playable includes non-trump → free discard.""" - north, _, south, west = four_players - south.hand.clear() - south.hand.extend([ - Card(Suit.DIAMONDS, Rank.QUEEN), - Card(Suit.DIAMONDS, Rank.TEN), - ]) - trick = self._make_trick((west, Card(Suit.CLUBS, Rank.KING))) - # Playable list includes non-trump (Round logic decides — when partner - # leads, the engine returns the full hand). Here we simulate "free". - playable = list(south.hand) - result = _explain_constraint(south, trick, playable, Suit.HEARTS) - assert "free discard" in result.plain - - # ====================================================================== # Hand panel — always visible while a human is seated # ====================================================================== @@ -2474,7 +792,7 @@ def test_panel_hand_empty_hand_renders_placeholder(self): rather than disappearing. No redundant second empty-state line.""" view, human = self._build_view_with_human() human.hand.clear() - panel = view._panel_hand( + panel = _panel_hand( human, trick=None, playable_cards=None, phase="trick_won", round_=None, interactive=False, ) @@ -2497,7 +815,7 @@ def test_panel_hand_non_interactive_omits_constraint_hint(self): trick = _Trick() trick.add_play(human, Card(Suit.CLUBS, Rank.KING)) - panel = view._panel_hand( + panel = _panel_hand( human, trick=trick, playable_cards=[human.hand[1]], phase="playing", round_=None, interactive=False, ) @@ -2525,7 +843,7 @@ def test_panel_hand_interactive_still_shows_constraint_hint(self): # the human's hearts are trumps and emits the "must trump" hint. contract_stub = type("_C", (), {"suit": Suit.HEARTS})() round_stub = type("_R", (), {"contract": contract_stub})() - panel = view._panel_hand( + panel = _panel_hand( human, trick=trick, playable_cards=list(human.hand), phase="playing", round_=round_stub, interactive=True, ) @@ -2636,54 +954,3 @@ class _StubGame: ) combined = "\n".join(captured) assert "Your hand" not in combined - - -# ====================================================================== -# _format_summary_contract — end-game round-by-round table cell -# ====================================================================== - - -class TestFormatSummaryContract: - """The end-game summary contract cell must use English vocabulary - exclusively — no French ``coinché`` / ``surcoinché`` leakage.""" - - class _StubContract: - def __init__(self, value, suit, *, double=False, redouble=False): - self.value = value - self.suit = suit - self.double = double - self.redouble = redouble - - @staticmethod - def _row(contract, team_name="North-South"): - return RoundSummary( - round_number=1, - contract=contract, - contract_team_name=team_name, - contract_made=True, - ns_pts=100, - ew_pts=0, - running_ns=100, - running_ew=0, - ) - - def test_doubled_contract_reads_english(self): - view = RichView() - row = self._row(self._StubContract(100, Suit.HEARTS, double=True)) - text = view._format_summary_contract(row).plain - assert "doubled" in text - assert "coinché" not in text - - def test_redoubled_contract_reads_english(self): - view = RichView() - row = self._row(self._StubContract(100, Suit.HEARTS, redouble=True)) - text = view._format_summary_contract(row).plain - assert "redoubled" in text - assert "surcoinché" not in text - - def test_plain_contract_has_no_double_marker(self): - view = RichView() - row = self._row(self._StubContract(100, Suit.HEARTS)) - text = view._format_summary_contract(row).plain - assert "doubled" not in text - assert "redoubled" not in text diff --git a/packages/contrai-engine/tests/test_view/test_state_helpers.py b/packages/contrai-engine/tests/test_view/test_state_helpers.py new file mode 100644 index 0000000..0b842ea --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_state_helpers.py @@ -0,0 +1,244 @@ +"""Tests for the game-state readers in :mod:`contrai_engine.view.state_helpers`. + +These read a slice of round/trick state: the display-order hand sort, the +live trick-winner highlight, the green "↑ playable …" constraint hint, and +the env-tunable AI pacing delay. +""" + +from __future__ import annotations + +import pytest + +from contrai_core import Card, Rank, Suit, Trick +from contrai_engine.view.state_helpers import ( + _current_winner, + _explain_constraint, + _resolve_delay, + _sort_hand_for_display, +) + + +class TestResolveDelay: + """Env-var pacing resolver — used by the AI hooks.""" + + def test_default_when_unset(self, monkeypatch): + monkeypatch.delenv("CONTRAI_AI_TEST", raising=False) + assert _resolve_delay("CONTRAI_AI_TEST", default=0.7) == 0.7 + + def test_reads_float_from_env(self, monkeypatch): + monkeypatch.setenv("CONTRAI_AI_TEST", "0.25") + assert _resolve_delay("CONTRAI_AI_TEST", default=0.7) == 0.25 + + def test_garbage_falls_back_to_default(self, monkeypatch): + monkeypatch.setenv("CONTRAI_AI_TEST", "fast") + assert _resolve_delay("CONTRAI_AI_TEST", default=0.7) == 0.7 + + def test_negative_clamped_to_zero(self, monkeypatch): + monkeypatch.setenv("CONTRAI_AI_TEST", "-2.0") + assert _resolve_delay("CONTRAI_AI_TEST", default=0.7) == 0.0 + + +# ====================================================================== +# _sort_hand_for_display +# ====================================================================== + + +class TestSortHandForDisplay: + """Display-order sort: trump-first, then suit-by-suit, rank desc.""" + + def test_no_trump_default_order(self): + cards = [ + Card(Suit.CLUBS, Rank.SEVEN), + Card(Suit.HEARTS, Rank.QUEEN), + Card(Suit.SPADES, Rank.JACK), + Card(Suit.DIAMONDS, Rank.ACE), + ] + result = _sort_hand_for_display(cards, trump_suit=None) + # Default suit order: S, H, D, C + assert [c.suit for c in result] == [ + Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS, + ] + + def test_trump_goes_first(self): + cards = [ + Card(Suit.SPADES, Rank.ACE), + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.DIAMONDS, Rank.KING), + Card(Suit.CLUBS, Rank.NINE), + ] + result = _sort_hand_for_display(cards, trump_suit=Suit.HEARTS) + assert result[0].suit == Suit.HEARTS + # Non-trump suits keep S, D, C order with hearts removed. + assert [c.suit for c in result[1:]] == [ + Suit.SPADES, Suit.DIAMONDS, Suit.CLUBS, + ] + + def test_within_suit_rank_desc_no_trump(self): + """Within a non-trump suit, highest rank first (normal order).""" + cards = [ + Card(Suit.SPADES, Rank.SEVEN), + Card(Suit.SPADES, Rank.ACE), + Card(Suit.SPADES, Rank.JACK), + ] + result = _sort_hand_for_display(cards, trump_suit=Suit.HEARTS) + assert [c.rank for c in result] == [Rank.ACE, Rank.JACK, Rank.SEVEN] + + def test_within_trump_suit_uses_trump_order(self): + """Inside the trump suit, the Jack out-ranks the Ace (trump order).""" + cards = [ + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.NINE), + Card(Suit.HEARTS, Rank.SEVEN), + ] + result = _sort_hand_for_display(cards, trump_suit=Suit.HEARTS) + # Trump order: 7, 8, Q, K, 10, A, 9, J — so J on top, then 9, then A. + assert [c.rank for c in result] == [ + Rank.JACK, Rank.NINE, Rank.ACE, Rank.SEVEN, + ] + + def test_empty_suit_skipped(self): + cards = [ + Card(Suit.SPADES, Rank.ACE), + Card(Suit.DIAMONDS, Rank.KING), + ] + result = _sort_hand_for_display(cards, trump_suit=None) + assert len(result) == 2 + assert {c.suit for c in result} == {Suit.SPADES, Suit.DIAMONDS} + + def test_empty_hand_returns_empty(self): + assert _sort_hand_for_display([], trump_suit=None) == [] + assert _sort_hand_for_display([], trump_suit=Suit.SPADES) == [] + + +# ====================================================================== +# _current_winner +# ====================================================================== + + +class TestCurrentWinner: + """Live trick-winner computation for the diamond gold-pill highlight.""" + + def test_empty_plays_returns_none(self): + assert _current_winner([], trump_suit=Suit.HEARTS) is None + assert _current_winner([], trump_suit=None) is None + + def test_single_play_wins(self, four_players): + north, _, _, _ = four_players + plays = [(north, Card(Suit.SPADES, Rank.SEVEN))] + assert _current_winner(plays, trump_suit=Suit.HEARTS) is north + + def test_highest_of_led_suit_wins_no_trump_played(self, four_players): + north, east, south, west = four_players + plays = [ + (west, Card(Suit.SPADES, Rank.KING)), + (north, Card(Suit.SPADES, Rank.TEN)), + (east, Card(Suit.SPADES, Rank.ACE)), # ace wins + ] + assert _current_winner(plays, trump_suit=Suit.HEARTS) is east + + def test_off_suit_non_trump_cannot_win(self, four_players): + """Discarding off-suit (no trump) doesn't take the trick.""" + north, east, south, west = four_players + plays = [ + (west, Card(Suit.SPADES, Rank.SEVEN)), + (north, Card(Suit.DIAMONDS, Rank.ACE)), # off suit, no trump + ] + assert _current_winner(plays, trump_suit=Suit.HEARTS) is west + + def test_trump_beats_non_trump(self, four_players): + north, east, south, west = four_players + plays = [ + (west, Card(Suit.SPADES, Rank.ACE)), + (north, Card(Suit.HEARTS, Rank.SEVEN)), # weakest trump still wins + ] + assert _current_winner(plays, trump_suit=Suit.HEARTS) is north + + def test_highest_trump_wins(self, four_players): + north, east, south, west = four_players + plays = [ + (west, Card(Suit.SPADES, Rank.KING)), # led + (north, Card(Suit.HEARTS, Rank.NINE)), # trump + (east, Card(Suit.HEARTS, Rank.JACK)), # jack is top trump + (south, Card(Suit.HEARTS, Rank.ACE)), # ace below jack/9 + ] + assert _current_winner(plays, trump_suit=Suit.HEARTS) is east + + def test_no_trump_contract_uses_led_suit(self, four_players): + """``trump_suit=None`` (or NoTrump) means highest led-suit card wins.""" + north, east, south, west = four_players + plays = [ + (west, Card(Suit.SPADES, Rank.KING)), + (north, Card(Suit.SPADES, Rank.ACE)), + (east, Card(Suit.HEARTS, Rank.JACK)), # off suit, can't win + ] + assert _current_winner(plays, trump_suit=None) is north + + +# ====================================================================== +# _explain_constraint +# ====================================================================== + + +class TestExplainConstraint: + """Human-readable hint under the hand row.""" + + def _make_trick(self, *plays): + t = Trick() + for player, card in plays: + t.add_play(player, card) + return t + + def test_empty_trick_is_your_lead(self, four_players): + _, _, south, _ = four_players + south.hand.clear() + south.hand.append(Card(Suit.SPADES, Rank.ACE)) + empty = Trick() + result = _explain_constraint(south, empty, list(south.hand), Suit.HEARTS) + assert "your lead" in result.plain.lower() + + def test_must_follow_led_suit(self, four_players): + north, _, south, west = four_players + # West led ♠K, South has ♠s in hand → must follow. + south.hand.clear() + south.hand.extend([ + Card(Suit.SPADES, Rank.SEVEN), + Card(Suit.SPADES, Rank.JACK), + Card(Suit.HEARTS, Rank.ACE), + ]) + trick = self._make_trick((west, Card(Suit.SPADES, Rank.KING))) + playable = south.hand.cards_of_suit(Suit.SPADES) + result = _explain_constraint(south, trick, playable, Suit.HEARTS) + assert "must follow" in result.plain + assert "♠" in result.plain + + def test_must_trump_when_partner_not_winning(self, four_players): + north, east, south, west = four_players + # West led ♣K, South has no clubs, has hearts (trump) → must trump. + south.hand.clear() + south.hand.extend([ + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.DIAMONDS, Rank.QUEEN), + ]) + trick = self._make_trick((west, Card(Suit.CLUBS, Rank.KING))) + playable = south.hand.cards_of_suit(Suit.HEARTS) # only trumps legal + result = _explain_constraint(south, trick, playable, Suit.HEARTS) + assert "must trump" in result.plain + # The leader's position label should appear in the hint. + assert "W" in result.plain + + def test_free_discard_when_no_led_suit_no_trump_obligation(self, four_players): + """No led-suit in hand, playable includes non-trump → free discard.""" + north, _, south, west = four_players + south.hand.clear() + south.hand.extend([ + Card(Suit.DIAMONDS, Rank.QUEEN), + Card(Suit.DIAMONDS, Rank.TEN), + ]) + trick = self._make_trick((west, Card(Suit.CLUBS, Rank.KING))) + # Playable list includes non-trump (Round logic decides — when partner + # leads, the engine returns the full hand). Here we simulate "free". + playable = list(south.hand) + result = _explain_constraint(south, trick, playable, Suit.HEARTS) + assert "free discard" in result.plain diff --git a/packages/contrai-engine/tests/test_view/test_trick.py b/packages/contrai-engine/tests/test_view/test_trick.py new file mode 100644 index 0000000..8092260 --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_trick.py @@ -0,0 +1,372 @@ +"""Tests for the in-game table in :mod:`contrai_engine.view.screens.trick`. + +Covers the logic hiding inside the renderers: the running-points sum, +the trick-index clamping in the Round / Current-trick panels, the +diamond's winner highlight / pending seat / led marker / belote badge, +the hand panel's interactive vs neutral modes and constraint hints, the +playable-pill styling, and the per-play prompt texts. +""" + +from __future__ import annotations + +from contrai_core import Card, Rank, Suit, Trick +from contrai_engine.model.player import HumanPlayer +from contrai_engine.view.screens.trick import ( + _ai_card_announcement, + _card_prompt_text, + _panel_current_trick, + _panel_hand, + _panel_last_trick, + _panel_round, + _render_card_cell, + _render_diamond, + _round_running_points, + _trick_won_prompt_text, +) +from contrai_engine.view.theme import GREEN_BG + + +class _StubContract: + def __init__(self, value, suit, team_name="North-South", player=None): + self.value = value + self.suit = suit + class _T: + pass + self.team = _T() + self.team.name = team_name + self.player = player + self.double = False + self.redouble = False + + +class _StubRound: + def __init__(self, *, contract=None, tricks=None, dealer=None, + round_number=1, team_tricks=None): + self.contract = contract + self.tricks = tricks if tricks is not None else [] + self.dealer = dealer + self.round_number = round_number + self.team_tricks = team_tricks or {} + + +class TestRoundRunningPoints: + """Trump-aware sum of each team's captured pile so far.""" + + def test_no_round_or_contract_yields_zeros(self): + assert _round_running_points(None) == (0, 0) + assert _round_running_points(_StubRound(contract=None)) == (0, 0) + + def test_sums_trump_aware_points_per_team(self, four_players): + north, east, *_ = four_players + contract = _StubContract(100, Suit.HEARTS, player=north) + # N-S captured J♥ (trump jack = 20) + A♠ (11) = 31. + ns_trick = Trick() + ns_trick.add_play(north, Card(Suit.HEARTS, Rank.JACK)) + ns_trick.add_play(east, Card(Suit.SPADES, Rank.ACE)) + # E-W captured a pointless 7♣. + ew_trick = Trick() + ew_trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) + round_ = _StubRound( + contract=contract, + team_tricks={ + "North-South": [ns_trick], + "East-West": [ew_trick], + }, + ) + assert _round_running_points(round_) == (31, 0) + + +class TestPanelRound: + """The Round info panel: phase line, trick index, clamping.""" + + def test_bidding_phase_shows_dealer(self, four_players): + *_, west = four_players + round_ = _StubRound(dealer=west, round_number=2) + panel = _panel_round(round_, phase="bidding") + text = panel.renderable.plain + assert "Bidding in progress" in text + assert "West" in text + assert "Round #2" in panel.title.plain + + def test_no_contract_renders_trump_dash(self): + text = _panel_round(_StubRound(), phase="bidding").renderable.plain + assert "Trump:" in text + assert "—" in text + + def test_playing_phase_counts_the_in_flight_trick(self, four_players): + north, *_ = four_players + contract = _StubContract(100, Suit.HEARTS, player=north) + round_ = _StubRound(contract=contract, tricks=[Trick(), Trick()]) + text = _panel_round(round_, phase="playing").renderable.plain + # Two done + the one in flight = trick 3 of 8. + assert "3 of 8" in text + + def test_trick_index_clamps_at_eight(self, four_players): + north, *_ = four_players + contract = _StubContract(100, Suit.HEARTS, player=north) + round_ = _StubRound(contract=contract, tricks=[Trick() for _ in range(8)]) + text = _panel_round(round_, phase="playing").renderable.plain + assert "8 of 8" in text + assert "9 of 8" not in text + + +class TestRenderDiamond: + """Winner star, pending ?, led marker, dots, belote badge.""" + + def test_live_winner_is_the_trump_cutter(self, four_players): + north, east, *_ = four_players + trick = Trick() + trick.add_play(north, Card(Suit.SPADES, Rank.ACE)) + trick.add_play(east, Card(Suit.HEARTS, Rank.SEVEN)) # trump cut + text = _render_diamond( + trick, Suit.HEARTS, + pending_position="South", winner_position=None, + dimmed=False, width=42, + ).plain + assert "E 7♥ ★" in text # the low trump beats the off-suit ace + assert "N A♠ (led)" in text + assert "S ?" in text + assert "W ·" in text + + def test_explicit_winner_position_overrides_live_computation( + self, four_players + ): + north, east, *_ = four_players + trick = Trick() + trick.add_play(north, Card(Suit.SPADES, Rank.ACE)) + trick.add_play(east, Card(Suit.HEARTS, Rank.SEVEN)) + text = _render_diamond( + trick, Suit.HEARTS, + pending_position=None, winner_position="North", + dimmed=True, width=18, + ).plain + assert "N A♠ ★" in text + assert "E 7♥ ★" not in text + + def test_dimmed_rendering_drops_the_led_marker(self, four_players): + north, *_ = four_players + trick = Trick() + trick.add_play(north, Card(Suit.SPADES, Rank.ACE)) + text = _render_diamond( + trick, Suit.HEARTS, + pending_position=None, winner_position="North", + dimmed=True, width=18, + ).plain + assert "(led)" not in text + + def test_belote_badge_renders_under_the_announcing_seat( + self, four_players + ): + text = _render_diamond( + Trick(), Suit.HEARTS, + pending_position="North", winner_position=None, + dimmed=False, width=42, + belote_by_position={"South": "belote"}, + ).plain + assert "★ Belote" in text + + def test_no_badge_without_announcements(self, four_players): + text = _render_diamond( + Trick(), Suit.HEARTS, + pending_position="North", winner_position=None, + dimmed=False, width=42, + ).plain + assert "Belote" not in text + + +class TestPanelCurrentTrick: + """Phase routing and the trick-number suffix in the title.""" + + def test_bidding_phase_flags_the_human_turn(self, four_players): + human = HumanPlayer("You", "South") + panel = _panel_current_trick( + None, None, "bidding", human, None, bidding_history=[] + ) + assert "→ Your bid" in panel.renderable.plain + assert "Bidding" in panel.title.plain + + def test_bidding_phase_names_the_ai_turn(self, four_players): + north, *_ = four_players + panel = _panel_current_trick( + None, None, "bidding", north, None, bidding_history=[] + ) + assert "→ North to bid" in panel.renderable.plain + + def test_playing_without_a_trick_shows_none(self): + panel = _panel_current_trick(None, None, "playing", None, None) + # The placeholder Text is centered inside an Align wrapper. + assert "(none)" in panel.renderable.renderable.plain + + def test_title_counts_the_in_flight_trick_while_playing( + self, four_players + ): + north, *_ = four_players + contract = _StubContract(100, Suit.HEARTS, player=north) + round_ = _StubRound(contract=contract, tricks=[Trick() for _ in range(3)]) + trick = Trick() + trick.add_play(north, Card(Suit.SPADES, Rank.ACE)) + panel = _panel_current_trick(round_, trick, "playing", None, None) + assert "(#4)" in panel.title.plain + + def test_title_keeps_the_finished_trick_number_when_won( + self, four_players + ): + north, *_ = four_players + contract = _StubContract(100, Suit.HEARTS, player=north) + round_ = _StubRound(contract=contract, tricks=[Trick() for _ in range(3)]) + trick = Trick() + trick.add_play(north, Card(Suit.SPADES, Rank.ACE)) + panel = _panel_current_trick(round_, trick, "trick_won", None, north) + assert "(#3)" in panel.title.plain + assert "Won: N" in panel.renderable.plain + + +class TestPanelLastTrick: + """The dimmed echo of the previous trick.""" + + def test_placeholder_before_any_trick_completes(self): + panel = _panel_last_trick(None, None) + # The placeholder Text is centered inside an Align wrapper. + assert "(none)" in panel.renderable.renderable.plain + assert panel.title.plain == "Last trick" + + def test_shows_winner_and_trick_number(self, four_players): + north, east, *_ = four_players + contract = _StubContract(100, Suit.HEARTS, player=north) + trick = Trick() + trick.add_play(north, Card(Suit.SPADES, Rank.ACE)) + trick.add_play(east, Card(Suit.SPADES, Rank.SEVEN)) + round_ = _StubRound(contract=contract, tricks=[trick, Trick()]) + panel = _panel_last_trick(round_, (trick, north)) + assert "Won: N" in panel.renderable.plain + assert "Last trick (#2)" in panel.title.plain + + +class TestPanelHand: + """Interactive vs neutral modes, hints, and the empty-hand slot.""" + + @staticmethod + def _stock_hand(player): + cards = [ + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.HEARTS, Rank.KING), + Card(Suit.SPADES, Rank.QUEEN), + ] + player.hand.extend(cards) + return cards + + def test_leading_hand_hints_anything_goes(self, four_players): + _n, _e, south, _w = four_players + cards = self._stock_hand(south) + panel = _panel_hand( + south, Trick(), cards, "playing", None, interactive=True + ) + assert "your lead — anything goes" in panel.renderable.plain + + def test_following_hand_hints_must_follow_led_suit(self, four_players): + north, _e, south, _w = four_players + cards = self._stock_hand(south) + contract = _StubContract(100, Suit.HEARTS, player=north) + round_ = _StubRound(contract=contract) + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) + playable = [c for c in cards if c.suit == Suit.HEARTS] + panel = _panel_hand( + south, trick, playable, "playing", round_, interactive=True + ) + assert "must follow ♥" in panel.renderable.plain + + def test_void_in_led_suit_hints_must_trump(self, four_players): + north, _e, south, _w = four_players + # South holds only trump (hearts) — void in the led spades. + cards = [ + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.HEARTS, Rank.SEVEN), + ] + south.hand.extend(cards) + contract = _StubContract(100, Suit.HEARTS, player=north) + round_ = _StubRound(contract=contract) + trick = Trick() + trick.add_play(north, Card(Suit.SPADES, Rank.KING)) + panel = _panel_hand( + south, trick, cards, "playing", round_, interactive=True + ) + text = panel.renderable.plain + assert "must trump" in text + assert "N led K♠" in text + + def test_neutral_frame_reads_cards_remaining(self, four_players): + _n, _e, south, _w = four_players + self._stock_hand(south) + panel = _panel_hand( + south, None, None, "playing", None, interactive=False + ) + text = panel.renderable.plain + assert "3 cards remaining" in text + assert "playable" not in text + + def test_bidding_phase_hints_no_obligation(self, four_players): + _n, _e, south, _w = four_players + self._stock_hand(south) + panel = _panel_hand( + south, None, None, "bidding", None, interactive=True + ) + assert "no card-play obligation yet" in panel.renderable.plain + + def test_empty_hand_keeps_the_slot_with_a_placeholder( + self, four_players + ): + _n, _e, south, _w = four_players + panel = _panel_hand( + south, None, None, "trick_won", None, interactive=False + ) + assert "(no cards left)" in panel.renderable.plain + + +class TestRenderCardCell: + """Cell text plus the green pill on playable cards.""" + + def test_cell_reads_index_rank_and_glyph(self): + cell = _render_card_cell(3, Card(Suit.SPADES, Rank.KING), True, "playing") + assert cell.plain == "[3] K♠" + + def test_playable_cell_carries_the_green_pill(self): + cell = _render_card_cell(1, Card(Suit.SPADES, Rank.KING), True, "playing") + assert any(GREEN_BG in str(span.style) for span in cell.spans) + + def test_unplayable_cell_has_no_green_pill(self): + cell = _render_card_cell(1, Card(Suit.SPADES, Rank.KING), False, "playing") + assert not any(GREEN_BG in str(span.style) for span in cell.spans) + + +class TestPromptTexts: + """The per-play prompt and announcement lines.""" + + def test_card_prompt_flags_a_forced_play(self): + text = _card_prompt_text([Card(Suit.HEARTS, Rank.ACE)], 5).plain + assert "Only one legal play." in text + assert "[1-5]" in text + + def test_card_prompt_stays_quiet_with_choices(self): + playable = [ + Card(Suit.HEARTS, Rank.ACE), + Card(Suit.HEARTS, Rank.KING), + ] + text = _card_prompt_text(playable, 8).plain + assert "Only one legal play." not in text + assert "[1-8]" in text + + def test_ai_card_announcement(self, four_players): + north, *_ = four_players + text = _ai_card_announcement(north, Card(Suit.SPADES, Rank.ACE)).plain + assert text == "N plays A♠." + + def test_trick_won_prompt_congratulates_the_human(self): + human = HumanPlayer("You", "South") + text = _trick_won_prompt_text(human).plain + assert "You won the trick." in text + + def test_trick_won_prompt_names_the_ai_winner(self, four_players): + north, *_ = four_players + text = _trick_won_prompt_text(north).plain + assert "N won the trick." in text diff --git a/packages/contrai-scraper/main.py b/packages/contrai-scraper/main.py index df36b78..7164378 100644 --- a/packages/contrai-scraper/main.py +++ b/packages/contrai-scraper/main.py @@ -1,3 +1,10 @@ +"""Playwright spectator-mode scraper for app.belote-rebelote.fr (v1). + +Logs in, navigates to a Contrée tournament table in spectator mode, +identifies the four seated players, and polls for new rounds. Bidding +and card-play observation are still ``# FUTURE LOGIC`` placeholders. +""" + import asyncio import sys import re diff --git a/packages/contrai-scraper/pyproject.toml b/packages/contrai-scraper/pyproject.toml index 8f54be4..3442d64 100644 --- a/packages/contrai-scraper/pyproject.toml +++ b/packages/contrai-scraper/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "contrai-scraper" -version = "0.1.0" +version = "0.2.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.14" diff --git a/packages/contrai-scraper/run.py b/packages/contrai-scraper/run.py index 95b19ba..bf14e60 100644 --- a/packages/contrai-scraper/run.py +++ b/packages/contrai-scraper/run.py @@ -1,3 +1,5 @@ +"""Early Playwright login experiment against belote.com (Google auth flow).""" + from playwright.async_api import async_playwright # Configuration diff --git a/pyproject.toml b/pyproject.toml index 5c60cc2..b2b812d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,12 @@ contrai-engine = { workspace = true } contrai-analyzer = { workspace = true } contrai-scraper = { workspace = true } +[tool.pylint.basic] +# Docstrings are required on public API (see CLAUDE.md), but a pytest test's +# name *is* its documentation — exempt ``test_*`` functions and ``Test*`` +# classes alongside the default private-helper exemption (``_*``). +no-docstring-rgx = "^_|^test_|^Test" + [tool.pytest.ini_options] # Both contrai-core and contrai-engine ship a top-level ``tests`` package # (each with its own ``__init__.py``). Under pytest's default ``prepend`` diff --git a/uv.lock b/uv.lock index 95403f3..b84ce84 100644 --- a/uv.lock +++ b/uv.lock @@ -349,7 +349,7 @@ wheels = [ [[package]] name = "contrai-analyzer" -version = "0.1.0" +version = "0.2.0" source = { editable = "packages/contrai-analyzer" } dependencies = [ { name = "altair" }, @@ -376,7 +376,7 @@ requires-dist = [ [[package]] name = "contrai-core" -version = "0.1.0" +version = "0.2.0" source = { editable = "packages/contrai-core" } [package.optional-dependencies] @@ -390,7 +390,7 @@ provides-extras = ["test"] [[package]] name = "contrai-engine" -version = "0.1.0" +version = "0.2.0" source = { editable = "packages/contrai-engine" } dependencies = [ { name = "contrai-core" }, @@ -414,7 +414,7 @@ provides-extras = ["test"] [[package]] name = "contrai-scraper" -version = "0.1.0" +version = "0.2.0" source = { editable = "packages/contrai-scraper" } dependencies = [ { name = "jupyter" },