chore: release v0.2.0 - core PlayState, expert AI card play, TUI review#4
Conversation
Annotate Game.manage_round's return value with a new RoundResult TypedDict and type its `view` parameter as RichView | None. Add `from __future__ import annotations` plus a TYPE_CHECKING import so the View can be referenced unquoted without the Model importing it at runtime (preserves MVC layering).
Annotate Game.check_game_over's return value with a new GameOverStatus TypedDict and type its `target_score` parameter as int, matching the treatment given to manage_round.
Add tests for the previously untested round orchestrator and the players-order helper: the completed-round and all-pass redeal paths, cross-round score accumulation, view deal/redeal callbacks, the shuffle-vs-cut branch, anticlockwise ordering after the dealer, and the random first-dealer branch. A FakeRound double isolates manage_round from the full bidding/trick lifecycle.
The CLI discarded manage_round's return value and read game.current_contract and game.scores directly; the returned RoundResult merely snapshotted that live state and baked a presentation-only 'message' string into the Model. Remove the TypedDict and let manage_round return None, mutating Game/Round in place. Tests now assert on game state rather than the dict; diagrams and engine docs updated.
GameOverStatus genuinely flows Model to View (the CLI reads .game_over and forwards the whole verdict to show_end_game / the game-over banner). Replacing the TypedDict with a frozen dataclass gives attribute access and immutability; consumers switch from status['game_over'] to status.game_over and the banner helper drops its defensive dict .get() defaults now that the fields are guaranteed. Tests and the class diagram updated to match.
Replace the French terms (dix de der, der, chute, contre) with their English equivalents across the recap view, the scoring model, and their test suites. Rename the recap-breakdown keys dix_de_der -> last_trick_bonus and dix_count -> last_trick_counts, and the affected test names, to match.
Align with the English-only vocabulary rule (Slam, not capot): the Round/RoundScore attribute, the scoring locals and 250-substitute constant, and the recap's slam_label parameter, with tests, the engine doc page, and the three affected diagrams (re-rendered) following. Also translate the last French term left in those docs - dix-de-der becomes the last-trick bonus in index.md, flow_scoring.mmd, and class_engine.puml.
Add short Google-style docstrings to the remaining undocumented panel builders, prompt builders, and formatting helpers in view/ (trick, endgame, landing, layout, formatting, rich_view).
The "Unrecognized bid" notice showed a fixed '80 h' example even when the auction had moved past 80. It now derives the example from Auction.legal_actions like the adaptive prompt does — '100 h' once 90 stands — and drops the numeric example when only Slam raises remain.
- Round.play_trick now returns None: the winner was already published on last_trick_winner and no caller consumed the return value. - Drop the unused trick_num/winner locals in play_all_tricks. - The on_all_pass_redeal view hook is now a pure announcement: its unused round_ parameter is gone, and Game's call site and the test view doubles follow suit. - Unquote TYPE_CHECKING-only forward references (Player, Deck, Round); deferred annotation evaluation makes the quotes redundant. - Trim stale section pointers from scoring's rule comments. - Sync the engine class/round/trick diagrams (sources + PNGs) with the play_trick and on_all_pass_redeal signature changes.
…ndings Turn the over-limit rule_based module (1054 lines, pylint C0302) into a rule_based/ package - bidding.py and card_play.py, re-exported from __init__.py so existing imports are unchanged. Along the way fix every other pylint finding: trailing whitespace, over-long lines, no-else-return, consider-using-in, the unused voids argument on _play_following_card, and too-many-locals in _evaluate_suit_as_trump (table scan extracted to _max_table_contract). Only the deliberate TODO markers (W0511) remain.
When the team already holds the trick and the seat follows suit, the expert AI now plays the highest-points non-master card, keeping a card its partner's own play just promoted (their Ace makes our Ten the new suit master) to win a later trick. Trump-led tricks get the same guard; when the seat's only card of the led suit is that master, the forced play goes through unchanged. The stacked-deck lifecycle pin moves to the new deterministic playout: the preserved trump ten now cashes late and flips the last trick, so the declarers close at 278/14 instead of 261/31 on the same deal.
Track per-suit voids for every seat (any failure to follow proves the led-suit void; the trump inference keeps its partner-master exemption) and predict a ruff when an opponent still to play is void in the led suit and may hold trump. Behind a winning partner the AI then concedes its cheapest card instead of piling points; on a losing trick it beats with the smallest winning card instead of the highest-points one.
A game could end with no winner when both teams finished a round level at or above the target score. Such a tie is now sudden death: check_game_over keeps the game running (tied_teams flags the state), the round recap announces the tiebreaker round, and play continues until one team strictly leads - so the game-over banner always names a winner.
…star
The event-log "Contract set" line carried no trump hint; it now slots
the suit glyph after the value ("Contract set: 110 ♠ by N."). The Round
panel's Trump line loses its ★ flourish, so the star flag on
_format_trump_label is gone entirely (the recap already opted out).
The summarize pass reflowed the Fixed section and lost the AI-seat-label bullet in the process; restore it. Add the never-logged bullet for illegal-play errors naming the acting seat, and drop a stray double space left by the same reflow.
Close out the changelog — the Unreleased section becomes 0.2.0, dated 2026-07-25, with a fresh empty Unreleased above it and updated compare links — and bump the four workspace packages to 0.2.0 in lockstep (pyproject.toml files and uv.lock).
PR Summary by Qodov0.2.0: core PlayState, expert AI card-play rules, TUI review pass
AI Description
Diagram
High-Level Assessment
Files changed (80)
|
Code Review by Qodo
1. Dealer advances on redeal
|
| # 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 |
There was a problem hiding this comment.
1. Dealer advances on redeal 🐞 Bug ≡ Correctness
When all players pass, Game.manage_round() returns after collecting cards; the CLI loop then calls manage_round() again, which calls start_new_round() and advances the dealer via next_dealer(), violating the documented rule that an all-pass redeal uses the same dealer.
Agent Prompt
## Issue description
After an all-pass auction, the rules state the cards are collected and redealt **with the same dealer**. Currently `Game.manage_round()` returns on all-pass, and the CLI loop calls `manage_round()` again; the next call unconditionally runs `start_new_round()` which unconditionally calls `next_dealer()`, so the dealer advances before the redeal.
## Issue Context
- The CLI drives rounds in a loop and does not special-case all-pass.
- `start_new_round()` always advances the dealer.
## Fix Focus Areas
- packages/contrai-engine/src/contrai_engine/model/game.py[109-165]
- packages/contrai-engine/src/contrai_engine/cli.py[49-70]
## Suggested fix approach
- Add an internal flag on `Game` like `_pending_redeal_same_dealer: bool`.
- In `manage_round()`, when `contract` is falsy (all-pass), set the flag and return (or redeal inline).
- In `start_new_round()`, only call `next_dealer()` when the flag is not set; clear the flag once consumed.
- Ensure `players_order` is recomputed appropriately for the dealer who is re-dealing (same as current dealer).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| 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, | ||
| ) |
There was a problem hiding this comment.
2. With_hands allows duplicates 🐞 Bug ≡ Correctness
PlayState.with_hands() rejects already-played cards but does not enforce uniqueness across the replacement hands, allowing the same card to exist in multiple hands and be played twice in a determinized fork.
Agent Prompt
## Issue description
`PlayState.with_hands()` validates per-seat card counts and prevents replacement cards that already appear in `self.plays`, but it does not prevent duplicates across the provided replacement hands (or within a hand). This can create an impossible play state (a single card owned by multiple seats).
## Issue Context
`PlayState.start()` enforces that all 32 dealt cards are distinct across hands; determinization forks should preserve the same invariant to avoid invalid downstream simulations.
## Fix Focus Areas
- packages/contrai-core/src/contrai_core/play.py[113-160]
- packages/contrai-core/src/contrai_core/play.py[409-456]
## Suggested fix approach
- In `with_hands()`, flatten all replacement cards into one list.
- Reject duplicates by checking `len(set(all_replacement_cards)) == len(all_replacement_cards)`.
- Keep the existing check that no replacement card appears in `self.plays` (can be folded into the same set-based validation).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
What
The v0.2.0 milestone (84 commits): the core gains a real play-phase model, the expert AI a sound card-play brain, and the whole TUI a review pass — closed out with the changelog and lockstep version bumps to 0.2.0.
PlayState, an immutable trick-by-trick state machine owning follow/trump legality, turn order, and the typedOUT_OF_TURN/CARD_NOT_IN_HANDviolations;PlayObservation, the imperfect-information projection a single seat is allowed to see — the input surface for AI card-play strategies.PlayState, and card-play strategies receive a frozenPlayObservationfrom which they derive card tracking (fallen cards, per-seat voids). On that base the expert AI stops pulling trumps once both opponents are void, preserves master cards behind a winning partner, and anticipates opponent ruffs.Bid/Auctionobjects (legacy wire-format bridge removed); partner-support escalation capped at the team ceiling; suit tie-break and bid-rejection-hint fixes.Why
This closes the "review the TUI, harden the core" phase: the play phase is modeled in
contrai-corewhere future search-based AIs can fork it, strategies are sealed behind an observation boundary (groundwork for the AI ladder), and the CLI is presentable end to end.Release plan
Rebase-merge (the branch is atomic Conventional Commits), then tag
v0.2.0onmainand publish the GitHub release with the changelog section.