Skip to content

feat(engine): Rich terminal UI + core domain hardening#1

Merged
valmathieu merged 145 commits into
mainfrom
feat/engine-rich-tui
Jun 20, 2026
Merged

feat(engine): Rich terminal UI + core domain hardening#1
valmathieu merged 145 commits into
mainfrom
feat/engine-rich-tui

Conversation

@valmathieu

Copy link
Copy Markdown
Owner

Summary

First playable release cut (v0.1.0): a Rich-based terminal UI for Contrée,
plus substantial hardening of the core domain model and the bidding/scoring
engine.

Highlights

CLI / View (contrai-engine)

  • New Rich-based terminal UI — the contrai CLI (Round panel, hand panel,
    auction diamond, persistent event log, round-recap screens).
  • Round recap split into factual Outcome and rolled-up Scoring tables.
  • Belote/rebelote announcements during play; per-seat bidding history.

Bidding (contrai-core)

  • Bidding driven by a dedicated Auction state object; Bid variants
    frozen as dataclasses (no more BidValidator).
  • Numeric ladder extended to 170/180; Coinche/Surcoinche caller tracked on
    the contract; correct freeze semantics after Double/Redouble.

Scoring & domain model

  • Symmetric Slam-family scoring, Solo Slam,
    SlamLevel, unannounced-slam handling.
  • Card is now a frozen value object with (suit, rank) equality.
  • Hand query API (has_suit / has_card) adopted across engine + AI.
  • Domain exception hierarchy: ContraiError base, IllegalPlayError,
    InvalidContractError.

AI play fixes

  • Force over-trump when trump is led; partner-master trump exemption;
    auto-pass when partner has doubled; no trump waste when partner is master.

Testing

  • uv run pytest green across contrai-core + contrai-engine.
  • Rich rendering covered by uv run contrai smoke run per convention.

valmathieu added 30 commits May 12, 2026 22:42
Captures the contrai-analyzer probability + bidding stack as it stands
today: SuitSlot/Rank enums, frozen Card dataclass, Hand/Deck,
ProbabilityEngine (with method groups), BiddingEvaluator, BidSuggestion.

Annotated as deliberately independent of contrai-core per CLAUDE.md
§2 item 4 — SuitSlot is a suit-agnostic abstraction for the
combinatorial math, not a duplicate of core's Suit enum.
Captures the Playwright spectator flow currently implemented in
packages/contrai-scraper/main.py: launch → login → mode navigation
(Online → Spectator → Contrée) → table discovery via
#tournamentMatchInfo → identify players via #nord/#sud/#est/#ouest →
poll #tour every 1s for round changes.

FUTURE LOGIC (observe_bidding, observe_gameplay, SQLite persistence,
DB de-duplication of already-scraped players) shown as dashed
<<future>> arrows + grouped block referencing main.py:105-108, plus a
greyed-out SQLite participant per CLAUDE.md §2 item 5 / §7.
Replaces docs/diagrams/README.md with docs/diagrams/index.md (the
filename MkDocs nav already references at mkdocs.yml:117) and rewrites
the content to:

- Drop the manual 'plantuml -tpng / mmdc' rendering workflow from the
  Rendering section. MkDocs renders both PlantUML (via the
  plantuml_markdown extension, format: svg) and Mermaid (via
  mkdocs-mermaid2-plugin) inline at site-build time; the manual CLI
  invocations are now documented only as an optional standalone PNG
  fallback for slides, the LaTeX report, or offline preview, and the
  rendered PNGs are no longer committed.

- Embed the two Phase 1 diagrams (class_analyzer.puml, seq_scraper.puml)
  via the plantuml-markdown 'source=' directive so .puml stays the
  canonical source of truth.

- Document the per-package colour convention (core blue, engine orange,
  analyzer green, scraper purple) reused across diagrams.

- Replace the previous baseline-diagrams TODO with a roadmap pointer
  to the deferred Phase 2 set (class_core, class_engine, class_workspace,
  seq_round, seq_bidding, seq_trick).

Note: requires 'base_dir: docs/diagrams' on the plantuml_markdown
extension in mkdocs.yml for 'source=' lookups to resolve; that line
sits in the still-untracked mkdocs.yml and is left for separate
commit.
Initial MkDocs configuration for the ContrAI docs site:

- Material theme (light/dark toggle, Inter + JetBrains Mono fonts).
- mkdocs-static-i18n with en (default) and fr locales — note: the
  navigation.instant feature is intentionally omitted because it is
  incompatible with i18n's language switcher.
- mkdocstrings (Python handler, Google docstring style) for API
  reference pages.
- mkdocs-mermaid2-plugin for inline Mermaid diagrams.
- plantuml_markdown extension (format: svg) for inline PlantUML
  diagrams.
- pymdownx superfences/highlight/tabbed/details/arithmatex, MathJax
  for LaTeX-style math.

Nav covers Home, Architecture, the four packages (Engine, Core,
Analyzer, Scraper) each with an Overview + API reference page, an
AI Ladder section, and a Diagrams page.
Without base_dir set, the plantuml_markdown extension defaults to
resolving 'source=' paths relative to CWD (the project root for
'mkdocs build' / 'mkdocs serve'), which makes embeds like
```plantuml source="class_analyzer.puml"``` fail with
'Cannot find external diagram source: class_analyzer.puml'.

Setting base_dir to docs/diagrams lets the markdown reference each
workspace-wide diagram by its bare filename, matching the canonical
storage location for workspace-wide diagrams per CLAUDE.md §5.
Package-local diagrams (under packages/<pkg>/) will need a path that
walks back out — fine for the rare case.
Phase 1's smoke test embedded both diagrams here as a gallery. On
reflection per-package placement is better for discoverability (the
diagram lives next to the docs that describe its code) and matches
CLAUDE.md §5 (package-local diagrams sit next to the doc that
references them).

Replaces the gallery with a conventions hub:

- Two-tool policy summary (PlantUML for class/sequence, Mermaid for
  everything else).
- Colour-convention table — per-package palette plus stub/future
  styling (light backgrounds, kept printable/report-friendly).
- Rendering instructions reflecting the MkDocs build-time pipeline
  (plantuml_markdown + mkdocs-mermaid2-plugin), with the manual CLI
  workflow documented as an optional standalone-PNG fallback only.
- Conventions: .puml/.mmd sources stay in docs/diagrams/ (single
  canonical location, base_dir resolves there from any topical
  page); embeds live on the package overview / architecture page;
  kind-prefixed filenames; honest portrayal via <<stub>>/<<future>>
  stereotypes.
- Catalogue table — diagram → kind → scope → source → embed location
  → status, listing both shipped Phase 1 diagrams and the deferred
  Phase 2 set.
Add a 'Class structure' section embedding class_analyzer.puml from
docs/diagrams/ (via plantuml_markdown 'source=' with base_dir set
to docs/diagrams). Includes a one-line caption pointing readers at
the deliberate independence from contrai-core (SuitSlot is a
suit-agnostic abstraction for combinatorial math, not a duplicate
of core's Suit enum) and a cross-link to the diagrams conventions
hub.

Note: this commit also folds in the staged rename of
docs/packages/analyzer.md → docs/analyzer/index.md (staged before
this session as part of the broader docs/ reorg). git commit --only
on the destination path picks up the rename alongside the content
modification.
Add seq_scraper.puml from docs/diagrams/ under 'Current flow (v1)'
via plantuml_markdown 'source=' (base_dir: docs/diagrams). Adds a
short caption mapping the diagram's dashed <<future>> arrows to the
FUTURE LOGIC comment block at main.py:105-108 (observe_bidding,
observe_gameplay, SQLite persistence, DB de-duplication of
already-scraped players).

Note: this commit also folds in the staged rename of
docs/packages/scraper/README.md → docs/scraper/index.md (staged
before this session as part of the broader docs/ reorg). git commit
--only on the destination path picks up the rename alongside the
content modification. The associated screenshots/ rename (also
staged) stays out — different pathspec.
Full contrai-core domain model: Suit / Rank enums (incl. NO_TRUMP)
with the CARD_SUITS module constant note, Card (with the four
pre-computed points_normal/trump and order_normal/trump attributes),
Deck, Hand (list-compatible API + query helper groups), Team,
BasePlayer, the full Bid hierarchy (PassBid / ContractBid / DoubleBid
/ RedoubleBid) with BidValidator marked as <<utility>>, Contract,
Trick, plus InvalidPlayerCountError / InvalidCardCountError
inheriting from a stdlib ValueError boundary element.

Honest portrayal:
- Contract.get_defending_team() flagged as 'TODO: currently None'
  per the implementation comment.
- ContractBid VALID_VALUES = [80..160, 'Capot'] shown verbatim
  (with the Capot → 250 numeric mapping noted).

.png committed alongside .puml so the diagram is browsable offline
without spinning up 'mkdocs serve'. Re-render whenever the .puml
changes (plantuml -tpng docs/diagrams/class_core.puml).
Add a 'Class structure' section between the Module map and Consumers,
embedding class_core.puml from docs/diagrams/ via plantuml_markdown
'source=' (base_dir: docs/diagrams). One-line caption flags the
known Contract.get_defending_team() TODO so readers don't get the
wrong idea from the diagram alone.

Note: this commit also folds in the staged rename of
docs/packages/core.md → docs/core/index.md (staged before this
session as part of the broader docs/ reorg).
Engine model layer plus a deliberately honest portrayal of the
still-partial MVC:

- Player hierarchy: abstract Player (engine) extends BasePlayer
  (drawn as a contrai-core boundary element in core blue, stereotype
  <<from contrai-core>>) ← HumanPlayer (stubbed: choose_bid /
  choose_card return None) and AiPlayer (full bidding + card-play
  strategy). AiPlayer's ~25 private strategy helpers collapsed into
  a <<strategy>> note. BIDDING_TABLE noted as 9 levels 80-160 with
  no Capot row (correcting a prior misread of the table).

- Game / Round shown with their public + private API surfaces.
  Annotated open question: Round._determine_trick_winner duplicates
  Trick.get_winner from contrai-core.

- GameController and CliView rendered in stub grey palette with
  <<stub>> stereotypes. Notes flag the reality: GameController
  references undefined 'pygame' and isn't wired to Game/Round at
  all; cli_view.py is an empty file, so Round's view.request_*
  branches are guarded but unreachable.

- MVC arrows (Controller→Game, Round→CliView) drawn dashed with
  <<would drive>> / <<would consult>> stereotypes to signal they're
  the intended wiring, not the current state.

.png committed alongside .puml for offline preview.
High-level Game.manage_round flow with ref blocks pointing at the
two zoom diagrams (seq_bidding, seq_trick) for detail.

Captured today's behavior, not the aspirational MVC:

- Setup phase: next_dealer is random on round 0, rotate +1
  thereafter; shuffle on round 0 / cut on subsequent; deal puts the
  dealer last.
- Bidding phase delegates to manage_bidding which returns Contract
  or None.
- Failed-contract path: all players passed → handle_failed_contract
  puts every hand back into the deck and returns zero scores.
- Trick-taking: play_all_tricks loops play_trick 8×.
- Scoring: card_points summed per team with belote +20 and
  dix-de-der +10, compared to contract.value (Capot ≥ 162 else ≥
  value), double/redouble multiplier applied.

Orange engine palette with the Deck participant rendered in core
blue as a <<from contrai-core>> boundary element.

.png committed alongside .puml for offline preview.
Zoom on Round.manage_bidding — the bid loop, validation via
BidValidator, the legacy-format conversion bridge, and the multi-
condition termination.

Honest portrayal of the human-input branch: view.request_bid_action
is shown dashed with <<not implemented>> annotation because CliView
is empty, so the (view ≠ None AND player.is_human) branch in Round
is reachable in principle but never fires today.

Termination conditions captured precisely:

- Inner-loop break when passes_count >= 3 AND len(bid_objects) > 3
  AND at least one non-pass bid exists.
- Outer-loop break: same condition, OR the first-round wipe (last
  4 bids all PassBid).

Contract construction: get_last_contract → has_double / has_redouble
checks → new Contract(...) | None.

A note at the bottom documents the legacy wire format ('Pass' /
'Double' / 'Redouble' / (value, suit)) and the two converters that
keep Player.choose_bid happy with tuples while internal state uses
proper Bid objects.

.png committed alongside .puml for offline preview.
Zoom on Round.play_trick — leader determination → 4-player loop
with legality enforcement → winner + bookkeeping.

Captures two subtle behaviours that surprise readers of the code:

- Trick() is built without a trump_suit argument. Round therefore
  determines the winner via its own _determine_trick_winner using
  self.contract.suit, bypassing Trick.get_winner() that lives on
  contrai-core. Flagged as a note on the diagram.
- After choose_card returns an illegal card (not in playable_cards
  or not in hand), Round silently falls back to playable_cards[0]
  rather than re-asking or raising.

Bookkeeping captured precisely: last_trick_winner update, append
to tricks + team_tricks, and the reverse-order deck.add_cards call
(last card played returns first to the deck).

Legality rules (SF-09 / SF-10) inlined as a long note: follow suit
if possible, partner-led short-circuit, must-trump / must-overtrump
when partner is not leading, discard fallbacks.

view.request_card_action shown dashed as <<not implemented>> —
CliView empty, same situation as seq_bidding.

.png committed alongside .puml for offline preview.
Add three new sections to the engine overview page:

- 'Class structure': embeds class_engine.puml with a caption that
  spells out which Player subclasses are real vs stubbed and what
  the grey GameController / CliView boxes actually mean today.
- 'Round lifecycle': embeds seq_round.puml as the headline overview.
- Two collapsible details blocks (pymdownx.details) under the round
  overview: 'Bidding cycle zoom' embeds seq_bidding.puml,
  'Single trick zoom' embeds seq_trick.puml. Collapsed by default
  so the page reads as a summary first; readers drill in only if
  they want detail.

Also small copy fixes in adjacent prose:

- AI players section: clarify the BIDDING_TABLE is 80-160 only with
  no Capot row (matching the diagram and the actual code), and point
  at the diagram's collapsed <<strategy>> helpers note.
- Open work: replace the TODO line ('MVC class diagram + round-flow
  sequence diagram') with a forward-reference to the <<stub>> boxes
  now visible on the class diagram.

Note: this commit also folds in the staged rename of
docs/packages/engine.md → docs/engine/index.md (staged before this
session as part of the broader docs/ reorg).
Bird's-eye view of the four packages with all four palettes
appearing at once (core blue, engine orange, analyzer green,
scraper purple) plus the grey stub palette for GameController /
CliView.

Each package shows 4-8 headline types; the engine pulls only its
own elements + a <<extends>> arrow into core's BasePlayer.

Cross-package dependency direction is the headline:

- engine <<uses>> core: solid dashed-dependency arrow with the
  imported types listed (Card, Suit, Rank, Bid, Contract, Trick,
  Team, Deck).
- engine.Player <<extends>> core.BasePlayer: solid inheritance.
- scraper <<future>> core: dashed, captures the planned
  materialization of observed games into core types (CLAUDE.md §2
  item 5).
- analyzer ↛ core: deliberately NO arrow, with a banner note
  explaining why SuitSlot is suit-agnostic (CLAUDE.md §2 item 4).

A second banner note on the engine package describes the planned
multiplayer web server (FastAPI + WebSockets) that doesn't live in
this repo yet but will consume engine + AI ladder models.

Scraper rendered as a single <<module>> box for main.py since the
package is procedural (no classes today).

.png committed alongside .puml for offline preview.
Add a 'Package map' section between Workspace layout and Shared
types, embedding class_workspace.puml. Caption summarises the
cross-package edges visible on the diagram (engine extends core,
scraper future-arrow into core, analyzer deliberately disconnected,
multiplayer-web-server planned note) so the page reads coherently
even before the reader expands the SVG.

The existing ASCII dependency-direction block stays for fast scan
below; the diagram is the rich version above.
Phase 2 is shipped — every diagram now has a committed PNG render
alongside its .puml source so contributors can preview diagrams
in a file browser / IDE / slides / the LaTeX report without
running 'mkdocs serve'. Backfill the two Phase 1 PNGs that were
missing (class_analyzer.png, seq_scraper.png) so the policy is
uniform across all 8 diagrams.

docs/diagrams/index.md changes:

- Rendering section: rewrite the 'These renders are optional and
  not committed' line. New policy is that PNGs are committed
  alongside each .puml and must be re-rendered + committed in the
  same atomic commit whenever the source changes. MkDocs still
  re-renders from .puml (the canonical source); the PNG exists
  only for offline preview.
- Catalogue table: replace the Phase-2-planned rows with completed
  Phase 2 entries linking to each diagram's source, PNG, and
  embed location. The eight diagrams now cover every package
  overview + the architecture page; the engine page hosts four
  diagrams (class + 3 sequences) with the zooms collapsed under
  pymdownx.details blocks.

This closes the Phase 2 work captured in the plan file.
Flatten the docs/ tree so MkDocs nav can reference each section by a
clean top-level directory:

- docs/ai/{rl,rule_based,supervised}.md → docs/ai-ladder/*.md
- docs/packages/analyzer.md → docs/analyzer/index.md
- docs/packages/core.md → docs/core/index.md
- docs/packages/engine.md → docs/engine/index.md
- docs/packages/scraper/README.md → docs/scraper/index.md (+ screenshots)
- delete the old docs/README.md (replaced by docs/index.md as MkDocs
  homepage, committed separately)

This mirrors the nav layout in mkdocs.yml (Home / Architecture /
Engine / Core / Analyzer / Scraper / AI Ladder / Diagrams) and lets
each section have its own subdirectory with sibling files (api.md,
screenshots/, etc.) without the docs/packages/ extra level of
indirection.
Complete the docs site setup referenced by mkdocs.yml:

- docs/index.md: homepage (TODO placeholder for now).
- docs/ai-ladder/index.md: AI ladder section index (TODO placeholder).
- docs/{core,engine}/api.md: mkdocstrings directives that auto-generate
  the API reference from each package's Google-style docstrings.
- docs/{analyzer,scraper}/api.md: explanatory stubs — analyzer is a
  Streamlit app (not a library) and scraper currently ships only
  entry-point scripts (no importable package), so neither exposes a
  stable namespace for mkdocstrings yet. The stubs document why and
  point to the trigger condition for replacing them with directives.
- docs/assets/: logo.svg and the (currently committed as) flavicon.png
  referenced by mkdocs.yml's theme block.
- docs/javascripts/mathjax.js: MathJax config referenced by mkdocs.yml.
- docs/stylesheets/extra.css: theme overrides referenced by mkdocs.yml.
- README.md: add a Docs site section explaining 'uv run mkdocs serve /
  build' so contributors discover the site.
- .gitignore: ignore site/ (MkDocs build output).
Canonical reference for the rules, terminology, and community
conventions of Contrée, independent of any software implementation.
Per CLAUDE.md §0 this is the document to consult whenever a question
touches game semantics (legal moves, bidding table, Belote bonus,
FR↔EN terminology, etc.) before reading or writing engine code.

Lives at the workspace root rather than under docs/ because it's
referenced from CLAUDE.md and from outside the MkDocs site as well.
Run pytest against contrai-core and contrai-engine on every push to
main and every PR targeting main. Matrix strategy with fail-fast
disabled so a break in one package doesn't mask a break in another.

Sets up uv with lockfile-keyed caching, installs the workspace
Python via 'uv python install' (no version arg, lets uv pick from
requires-python / .python-version), and syncs every workspace member
with --all-packages --all-extras so contrai-engine's editable
dependency on contrai-core resolves cleanly. Each matrix job then
runs 'uv run --package <name> pytest' for a clean per-package status
check in the PR UI.

Concurrency group cancels superseded runs on the same ref to save
CI minutes.
Convert the workspace from purely-virtual into a proper [project]
with a docs dependency group, fixing the CLAUDE.md §10 'uv sync
ergonomics' item:

- Add a top-level [project] table (name: contrai-workspace,
  version: 0.0.0, requires-python: >=3.14) that depends on all four
  workspace members. A fresh 'uv sync' now installs every member in
  editable mode without the manual 'uv pip install -e packages/...'
  follow-up the README used to recommend.
- Add [dependency-groups] 'docs' with mkdocs-material, mkdocstrings
  [python], mkdocs-mermaid2-plugin, plantuml-markdown, and
  mkdocs-static-i18n — the deps mkdocs.yml requires.
- Set [tool.uv] default-groups = ['docs'] so 'uv sync' / 'uv run'
  pick up the docs deps without needing --group docs every time.
- Register engine/analyzer/scraper under [tool.uv.sources] alongside
  the pre-existing contrai-core entry, so workspace resolution
  picks them up by name.

uv.lock regenerated to capture the docs dep group transitive
closure (~318 lines).
contrai-scraper currently ships only entry-point scripts (main.py,
run.py) at the package root with no importable Python module under
src/. Setuptools' flat-layout discovery trips on the two top-level
.py files when no [tool.setuptools] section tells it which modules
to package.

Declare an empty py-modules list to suppress the discovery error
without falsely claiming any importable surface. Comment in the
file explains the situation and notes to revisit once the scraper
grows a real src/contrai_scraper/ package.
valmathieu added 24 commits June 4, 2026 19:14
Trump is round-level state carried by the Contract, not a property of an
individual trick. Drop the unused Trick.trump_suit field (never set — the
engine always built Trick() bare) and make get_current_winner take
trump_suit as a required argument, mirroring Card.get_order/get_points.
Removes the silent no-trump evaluation an omitted argument used to cause
and fixes the stale `str` type hint to `Suit`.
Round._determine_trick_winner was a byte-for-byte copy of the comparison
already on Trick.get_current_winner. Delete it and have play_trick
delegate to core, passing the contract's trump suit — the same call
_count_player_tricks and _get_playable_cards already use. Behaviour
unchanged; full engine suite stays green.
Reflect that Trick no longer stores trump and that the engine delegates
trick-winner determination to Trick.get_current_winner: update the core
and engine package narratives, the core/engine class diagrams, and the
single-trick sequence diagram (PNGs re-rendered).
…lity

Card used default identity equality, so distinct instances of the same
physical card compared unequal and Card was unhashable by value. This
breaks the moment a Card is deep-copied or reloaded (scraper/ML replay).

Convert Card to @DataClass(frozen=True, slots=True), matching the Bid
precedent: equality/hash are now over (suit, rank). No __lt__ is added —
strength stays parametric via get_order(trump_suit). Drop the cached
points_*/order_* attributes (read only inside get_points/get_order) so
the methods index the class dicts directly.
…tests

Card now compares by value, so the str()-multiset assertions and the
_ids((suit, rank)) tuple projection are obsolete: deck tests use
collections.Counter / set comparisons and round tests compare set(legal)
against {Card(...)} literals directly.
contrai-core and contrai-engine each ship a top-level tests package (both
with their own __init__.py). Under pytest's default prepend import mode the
two collide on sys.modules['tests'], so a root `uv run pytest` aborted while
collecting the second package with "No module named 'tests.test_view'".

Set --import-mode=importlib in the root [tool.pytest.ini_options]; pytest
imports each test module under a path-derived name, so the per-package
suites compose into one green workspace run (per-package runs are unaffected).
has_suit answers "do I hold any card of this suit" with a short-circuiting
scan — cheaper than bool(cards_of_suit(...)) and the primitive the engine's
lead-detection needs.

has_card now delegates to `Card(suit, rank) in self`. Card became a frozen
value object comparing by (suit, rank), so membership is the single source
of truth for "do I hold this card" — no parallel field-by-field scan to drift.
Delete the untyped private helpers _count_cards_in_suit and _suit_has_rank;
they were character-for-character copies of Hand.count_suit / Hand.has_card —
exactly the ad-hoc re-implementation the Hand class exists to prevent. Repoint
every caller (and the inline suit comprehensions) to count_suit / has_card /
cards_of_suit. Behaviour is unchanged. Advances the Hand-adoption item in
CLAUDE.md §10.
…racking

Swap the lead-suit / trump-suit comprehensions in _get_playable_cards and
_classify_play_violation for Hand.cards_of_suit, and the belote King+Queen
scans for Hand.has_card. Pure expression swaps — the mirrored branch order
between the two legality methods is untouched.
…eError

The bad-contract fallback in wire_to_bid relied on the ValueError
umbrella to catch the InvalidContractError raised by ContractBid for an
unknown value/suit. Catch the specific domain error instead so an
unrelated ValueError from ContractBid surfaces as a loud failure rather
than being silently downgraded to a Pass. Add TestWireToBid covering the
keyword wires, the valid-tuple path, and both fallback branches.
When the declaring team wins all 8 tricks on an un-doubled numeric
contract (an unannounced slam / grand slam), replace the 162-point
trick pile with a flat 250 substitute: the declarer scores its
contract value + 250 (+ belote), the defense scores nothing, and the
dix de der is folded into the substitute. Taking every trick forces
the contract made. Doubled/redoubled contracts keep their existing
winner-takes-all 160 + C*M shape, and a defence sweep is unaffected.

The round recap shows 250 in the Outcome "Trick points" row, an
em-dash in "Last trick", and a "Slam" / "Grand Slam" tag to the right
of the row explaining the substitute (Grand Slam when the bidder
personally won all eight tricks).
The all-tricks contracts were string sentinels ("Slam"/"SoloSlam") inside a
ContractBid.value: int | str union, and the points they are worth (250/500)
were re-derived from those strings in three separate methods. That left 250/500
a magic number with no single owner and the value stringly-typed.

Introduce SlamLevel(Enum) whose members own their base value as data
(SLAM = 250, SOLO_SLAM = 500): the single source of truth for the points the
contract commits to, which also serves as the slam-family scoring substitute.
A plain Enum (not IntEnum) keeps the type distinct from int, so a Slam's value
can never be silently mistaken for card points in scoring arithmetic.

- ContractBid.value is now int | SlamLevel; VALID_VALUES ends with the two
  members (order preserved so Auction.legal_actions stays monotonic);
  get_numeric_value resolves via base_value.
- Contract.is_slam / is_solo_slam / is_slam_family switch to identity /
  isinstance; get_base_points delegates to get_numeric_value;
  get_slam_card_substitute reads base_value. No 250/500 literal remains in core.
- Auction legality uses isinstance(value, SlamLevel); the asymmetric
  Slam -> SoloSlam block rule is unchanged.
- SlamLevel is exported from the package root.
- Tests migrated to the enum; the old string sentinels are now asserted
  invalid, and TestSlamLevel covers base values, labels, and VALID_VALUES order.
…bstitute

Follows the core SlamLevel migration. The engine no longer threads "Slam" /
"SoloSlam" strings around; the AI still works in numerics internally and now
converts to/from SlamLevel members at the bid boundary.

- player.py: SLAM_NUMERIC / SOLO_SLAM_NUMERIC are sourced from
  SlamLevel.{SLAM,SOLO_SLAM}.base_value and used directly in BIDDING_TABLE; the
  wire bridge (_bid_value_numeric / _numeric_to_wire) converts between the table
  numerics and SlamLevel members rather than strings.
- round.py: UNANNOUNCED_CAPOT_SUBSTITUTE now reads SlamLevel.SLAM.base_value, so
  the undeclared-sweep substitute and a declared Slam's base share one source.
- rich_view.py: the human-bid parser returns SlamLevel members; the contract
  display branches collapse to str(contract.value) (the enum's label); the
  "nothing outranks a Slam" legality messages use isinstance.
- Engine tests migrated to SlamLevel, including the recap _StubContract stub.

No behavioural change: a numeric bid's value stays a plain int, and the slam
at-risk grid (500/1000/2000 and 1000/2000/4000) is unchanged.
Reflect the typed SlamLevel enum in the core class diagram: add the enum (with
base_value / label), change ContractBid.value and Contract.value to
int | SlamLevel, update VALID_VALUES and the get_numeric_value note, and add the
ContractBid ..> SlamLevel dependency. PNG re-rendered in the same commit.
…announcedSlam enum

The undeclared all-tricks sweep was tracked as a stringly-typed Round attribute
(None / "slam" / "grand slam") and the View title-cased that sentinel to render
the explanatory recap tag. Replace it with a small UnannouncedSlam(Enum) whose
member value is the display label, so the tag is type-safe and the View no
longer re-derives the label.

This is deliberately separate from SlamLevel: an unannounced slam is a post-play
*outcome* on a numeric contract (scored on the numeric path with a flat 250
substitute), not a declared bid — different name ("Grand Slam" vs "Solo Slam"),
different scoring, and a meaningful None state. The Round.unannounced_capot
attribute and the UNANNOUNCED_CAPOT_SUBSTITUTE constant keep the domain term.

- round.py: add UnannouncedSlam{SLAM, GRAND_SLAM}; unannounced_capot is now
  Optional[UnannouncedSlam], set to the matching member after a sweep.
- rich_view.py: the recap tag renders str(capot_label) directly (the member
  value is already "Slam" / "Grand Slam") instead of .title()-casing a string.
- Tests migrated to the enum, plus a focused check on the member labels.

No behavioural change: the rendered tag and the 250 substitute are unchanged.
… points in recap

Round recap Outcome table gains a Total row (trick points + last trick +
belote) and renames "Trick points" to "Tricks points". Leading "+" signs
are dropped from the Outcome bonus cells and every Scoring number. The
Scoring "Round points" row now shows the score-contributing part only
(round score minus contract), so a chuté or contré round dashes out the
captured pile and keeps just the belote.
Add a blank line after the Tricks won row so the trick count reads apart
from the point rows (Tricks points / Last trick / Belote / Total) that
follow. A column rule there would wrongly imply a sub-total, so a blank
line is used instead.
@valmathieu valmathieu added this to the v0.1.0 milestone Jun 20, 2026
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

feat(engine): Rich terminal UI + core domain hardening (v0.1.0)
✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Description

• Adds a full Rich-based TUI (RichView) plus a contrai CLI entry point for playable v0.1.0.
• Replaces ad-hoc bid validation with an immutable Auction state machine and frozen Bid
 dataclasses.
• Hardens core scoring/play rules (Slam/Solo Slam, 170/180 ladder, unannounced capot, belote) with
 loud domain exceptions.
• Ships comprehensive test coverage for the new bidding/scoring/view layers and adds CI + MkDocs
 site config.
Diagram

graph TD
    CLI["contrai CLI"] --> RichView["RichView"] --> Round["Round"] --> Auction["Auction"] --> Bid["Bid types"]
    CLI --> Game["Game"] --> Round --> Contract["Contract"]
    Round --> Trick["Trick"] --> Hand["Hand/Card"]
    Auction --> Exceptions["Domain errors"]
    subgraph Legend
      direction LR
      _ui(["UI / View"]) ~~~ _eng["Engine"] ~~~ _core[("Core")]
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Migrate AI bidding to `Auction.legal_actions` end-to-end
  • ➕ Eliminates the legacy wire-format bridge (wire_to_bid/bid_to_wire)
  • ➕ Makes AI strategy type-safe with real Bid objects
  • ➕ Avoids special-case numeric coercion for SlamLevel
  • ➖ Requires rewriting the existing tuple-based expert bidding table
  • ➖ Bigger scope than a v0.1.0 playable cut
2. Keep legality on Bid subclasses (pre-Auction design)
  • ➕ Each bid variant appears self-contained
  • ➖ Hard to enumerate legal actions (MCTS/RL unfriendly)
  • ➖ Legality depends on full history; logic gets duplicated or needs a validator helper anyway
  • ➖ Already replaced by a cleaner single-source-of-truth Auction

Recommendation: The immutable Auction state machine is the right architecture for correctness and future MCTS/RL integration; centralizing legality + history avoids drift across validators and UI/AI layers. The only meaningful follow-up alternative is to remove the temporary AI wire-format bridge by teaching the AI to operate directly on Auction.legal_actions.

Files changed (81) +15185 / -1018

Enhancement (7) +3476 / -22
auction.pyAdd immutable Auction bidding state machine +429/-0

Add immutable Auction bidding state machine

• New 'Auction' frozen dataclass owns bid history and all legality rules. Provides 'legal_actions', 'apply' (raises 'IllegalBidError'), 'is_terminal', and 'contract()' building a 'Contract' with recorded doubler/redoubler players.

packages/contrai-core/src/contrai_core/auction.py

exceptions.pyIntroduce ContraiError hierarchy and play-rule violations +184/-11

Introduce ContraiError hierarchy and play-rule violations

• Adds 'ContraiError' base (also ValueError for backward-compat). Introduces 'IllegalBidError', 'IllegalPlayError' with 'PlayRuleViolation' (must follow suit/trump/overtrump), 'TrickStateError', and 'InvalidContractError' for stronger domain signaling.

packages/contrai-core/src/contrai_core/exceptions.py

hand.pyAdd Hand query helpers and compatibility copy() +38/-5

Add Hand query helpers and compatibility copy()

• Adds 'has_suit' and updates 'has_card' to rely on Card value equality. Adds 'copy()' returning a list for legacy callers and tightens completeness checks via 'set(self.cards)'.

packages/contrai-core/src/contrai_core/hand.py

types.pyExtend Suit enum with ALL_TRUMP +6/-4

Extend Suit enum with ALL_TRUMP

• Adds 'Suit.ALL_TRUMP' for future contract support and clarifies enum display semantics in docs.

packages/contrai-core/src/contrai_core/types.py

cli.pyAdd 'contrai' CLI entry point and game loop orchestration +78/-0

Add 'contrai' CLI entry point and game loop orchestration

• New CLI that builds a default game (South human, others AI), attaches 'RichView', runs rounds with recap screens, and supports rematch/new-target/quit flow. Forces UTF-8 output for suit glyphs on Windows.

packages/contrai-engine/src/contrai_engine/cli.py

game.pyAdd view hooks for round lifecycle events +10/-2

Add view hooks for round lifecycle events

• Adds optional view callbacks for round dealt and all-pass redeal so RichView can log/present round transitions.

packages/contrai-engine/src/contrai_engine/model/game.py

rich_view.pyImplement RichView: full terminal UI with event log, auction diamond, recaps +2731/-0

Implement RichView: full terminal UI with event log, auction diamond, recaps

• Adds a complete Rich-based UI implementing landing, bidding, mid-trick, trick-won, and game-over screens. Supports human input parsing (suit aliases/glyphs), per-seat bidding history, belote announcements, persistent event log, and round recap split into Outcome vs Scoring tables via view hooks from the engine.

packages/contrai-engine/src/contrai_engine/view/rich_view.py

Refactor (18) +1234 / -816
main.pyMinor analyzer entrypoint adjustments +3/-3

Minor analyzer entrypoint adjustments

• Small edits (likely import/format alignment) to keep analyzer runnable with updated docs/structure.

packages/contrai-analyzer/main.py

__init__.pyMinor analyzer bidding package export tweak +1/-1

Minor analyzer bidding package export tweak

• Adjusts exports/imports to match updated module organization.

packages/contrai-analyzer/src/bidding/init.py

evaluator.pyMinor analyzer evaluator import tweak +1/-1

Minor analyzer evaluator import tweak

• Small import/name alignment change for current structure.

packages/contrai-analyzer/src/bidding/evaluator.py

__init__.pyMinor analyzer engine package export tweak +1/-1

Minor analyzer engine package export tweak

• Adjusts exports/imports to match updated module organization.

packages/contrai-analyzer/src/engine/init.py

probability_engine.pyMinor analyzer ProbabilityEngine tweaks +2/-2

Minor analyzer ProbabilityEngine tweaks

• Small adjustments (likely typing/import organization) to keep module consistent with docs/diagrams.

packages/contrai-analyzer/src/engine/probability_engine.py

__init__.pyMinor analyzer models package export tweak +1/-1

Minor analyzer models package export tweak

• Adjusts exports/imports to match updated module organization.

packages/contrai-analyzer/src/models/init.py

deck.pyUpdate analyzer Deck module (minor refactor) +7/-7

Update analyzer Deck module (minor refactor)

• Small updates to keep the analyzer deck model consistent with the updated workspace docs/diagrams.

packages/contrai-analyzer/src/models/deck.py

hand.pyMinor analyzer Hand module tweak +1/-1

Minor analyzer Hand module tweak

• Small import/type alignment change.

packages/contrai-analyzer/src/models/hand.py

__init__.pyExport Auction/SlamLevel and new domain exceptions +20/-3

Export Auction/SlamLevel and new domain exceptions

• Re-exports the new 'Auction' and 'SlamLevel', removes 'BidValidator', and exposes the full 'ContraiError' exception hierarchy from the package root.

packages/contrai-core/src/contrai_core/init.py

bid.pyConvert bids to frozen dataclasses; add SlamLevel; extend ladder +156/-323

Convert bids to frozen dataclasses; add SlamLevel; extend ladder

• Replaces ABC bids + BidValidator with a frozen-dataclass sum type. Adds 'SlamLevel' enum (Slam/Solo Slam), extends numeric ladder to 170/180, and makes bid equality depend on type+payload (player excluded).

packages/contrai-core/src/contrai_core/bid.py

card.pyMake Card a frozen value object +20/-18

Make Card a frozen value object

• Converts 'Card' to a frozen dataclass with value equality by (suit, rank). Removes precomputed instance fields for points/order and uses class-level maps directly.

packages/contrai-core/src/contrai_core/card.py

contract.pyTrack double/redouble caller players; add Slam-family scoring helpers +92/-57

Track double/redouble caller players; add Slam-family scoring helpers

• Replaces boolean double/redouble flags with 'double_player'/'redouble_player' and validates redouble requires double. Adds helpers for Slam/Solo Slam base points and substitutes.

packages/contrai-core/src/contrai_core/contract.py

deck.pySmall deck adjustments +5/-5

Small deck adjustments

• Minor edits (likely typing/consistency) to align with Card/Hand value-object semantics.

packages/contrai-core/src/contrai_core/deck.py

team.pyMinor team updates +4/-3

Minor team updates

• Small edits for consistency with updated types/exceptions.

packages/contrai-core/src/contrai_core/team.py

trick.pyMake trick winner computation explicit in trump argument +39/-31

Make trick winner computation explicit in trump argument

• Removes stored trump from 'Trick'; winner logic is now 'get_current_winner(trump_suit)' and works mid-trick. Raises 'TrickStateError' for illegal mutation instead of generic ValueError.

packages/contrai-core/src/contrai_core/trick.py

player.pyAdapt Player/AiPlayer bidding to Auction + add wire-bridge helpers +317/-70

Adapt Player/AiPlayer bidding to Auction + add wire-bridge helpers

• Changes 'choose_bid' signature to accept 'Auction'. Adds 'wire_to_bid'/'bid_to_wire' helpers for legacy AI internals, extends AI table for Slam/Solo Slam, and implements frozen-auction behavior under Double/Redouble (auto-pass / optional surcoinche).

packages/contrai-engine/src/contrai_engine/model/player.py

round.pyRefactor Round bidding/scoring for Auction + Slam family + belote; add strict play legality +564/-287

Refactor Round bidding/scoring for Auction + Slam family + belote; add strict play legality

• Replaces BidValidator-driven bidding with Auction-driven legality and 'apply'. Implements symmetric Slam/Solo Slam scoring, unannounced capot handling, and canonical 'contract_made' state. Adds belote/rebelote tracking based on who holds K+Q of trump and emits view hooks. Illegal card plays now raise 'IllegalPlayError' instead of silently falling back.

packages/contrai-engine/src/contrai_engine/model/round.py

cli_view.pyRemove unused CLI view stub +0/-2

Remove unused CLI view stub

• Deletes the placeholder 'CliView' file; RichView becomes the primary view implementation.

packages/contrai-engine/src/contrai_engine/view/cli_view.py

Documentation (36) +3387 / -100
CONTRIBUTING.mdAdd full contributing guide +269/-0

Add full contributing guide

• Adds workspace setup, dev conventions, testing commands, and project rules (e.g., immutability and no silent fallbacks).

CONTRIBUTING.md

README.mdAdd Rich TUI design handoff spec +309/-0

Add Rich TUI design handoff spec

• Documents the five-screen TUI layout, color tokens, component anatomy (auction diamond, hand panel, event log), and interaction guidelines used by 'RichView'.

ContrAI CLI/design_handoff_contrai_tui/README.md

index.htmlAdd TUI mockup HTML +84/-0

Add TUI mockup HTML

• Provides static mockups supporting the Rich TUI handoff and screen layout decisions.

ContrAI CLI/design_handoff_contrai_tui/mockups/index.html

README.mdUpdate root README for v0.1.0/playable engine + docs +21/-8

Update root README for v0.1.0/playable engine + docs

• Refreshes top-level project messaging and references to the now-complete core/engine and documentation structure.

README.md

contree-domain.mdAdd comprehensive contrée domain rules reference +512/-0

Add comprehensive contrée domain rules reference

• Adds a detailed domain spec covering bidding, play obligations, and scoring (including Coinche/Surcoinche, Slam/Solo Slam, and belote rules).

contree-domain.md

README.mdRemove docs README (migrated to MkDocs pages) +0/-15

Remove docs README (migrated to MkDocs pages)

• Deletes the old docs README content in favor of MkDocs-driven pages.

docs/README.md

index.mdAdd AI ladder overview page +3/-0

Add AI ladder overview page

• Introduces a stub/overview for the AI ladder section in the MkDocs site nav.

docs/ai-ladder/index.md

rl.mdNo-op placeholder for RL page +0/-0

No-op placeholder for RL page

• Included for MkDocs navigation completeness; no content changes in diff.

docs/ai-ladder/rl.md

rule_based.mdNo-op placeholder for rule-based page +0/-0

No-op placeholder for rule-based page

• Included for MkDocs navigation completeness; no content changes in diff.

docs/ai-ladder/rule_based.md

supervised.mdNo-op placeholder for supervised page +0/-0

No-op placeholder for supervised page

• Included for MkDocs navigation completeness; no content changes in diff.

docs/ai-ladder/supervised.md

api.mdAdd analyzer API reference page +11/-0

Add analyzer API reference page

• Adds MkDocs page wiring for analyzer API docs (mkdocstrings).

docs/analyzer/api.md

index.mdUpdate analyzer overview page +6/-1

Update analyzer overview page

• Minor updates to analyzer docs to align with current package layout.

docs/analyzer/index.md

architecture.mdRefresh workspace architecture overview +37/-11

Refresh workspace architecture overview

• Updates architecture documentation to match current workspace composition and package responsibilities.

docs/architecture.md

api.mdAdd core API reference page +3/-0

Add core API reference page

• Adds MkDocs page wiring for contrai-core API docs (mkdocstrings).

docs/core/api.md

index.mdAdd core overview page +52/-0

Add core overview page

• Documents the current contrai-core API and design decisions (Auction, bids, value objects, exceptions).

docs/core/index.md

README.mdRemove old diagrams README (replaced by MkDocs page) +0/-19

Remove old diagrams README (replaced by MkDocs page)

• Deletes obsolete diagrams README in favor of 'docs/diagrams/index.md'.

docs/diagrams/README.md

class_analyzer.pumlAdd analyzer class diagram (PlantUML) +154/-0

Add analyzer class diagram (PlantUML)

• Adds a PlantUML class diagram describing the analyzer probability + bidding stack and its intentional independence from contrai-core.

docs/diagrams/class_analyzer.puml

class_core.pumlAdd core class diagram (PlantUML) +408/-0

Add core class diagram (PlantUML)

• Adds a PlantUML class diagram for contrai-core types (Card/Hand/Deck/Trick/Bids/Auction/Contract/exceptions).

docs/diagrams/class_core.puml

class_engine.pumlAdd engine class diagram (PlantUML) +389/-0

Add engine class diagram (PlantUML)

• Adds a PlantUML class diagram for engine model + view integration (Game/Round/Players/RichView).

docs/diagrams/class_engine.puml

class_workspace.pumlAdd workspace class diagram (PlantUML) +128/-0

Add workspace class diagram (PlantUML)

• Adds a high-level diagram of workspace packages and relationships.

docs/diagrams/class_workspace.puml

index.mdAdd MkDocs diagrams index with embedded Mermaid/PlantUML +67/-0

Add MkDocs diagrams index with embedded Mermaid/PlantUML

• Replaces manual rendering instructions with MkDocs-embedded diagram workflow via plugins/extensions.

docs/diagrams/index.md

seq_bidding.pumlAdd bidding sequence diagram (PlantUML) +191/-0

Add bidding sequence diagram (PlantUML)

• Documents the Auction-driven bidding flow and key interactions among players, round, and view.

docs/diagrams/seq_bidding.puml

seq_round.pumlAdd round sequence diagram (PlantUML) +144/-0

Add round sequence diagram (PlantUML)

• Documents the deal → bidding → trick loop → scoring lifecycle and view hook points.

docs/diagrams/seq_round.puml

seq_scraper.pumlAdd scraper observation sequence diagram (PlantUML) +145/-0

Add scraper observation sequence diagram (PlantUML)

• Documents current Playwright spectator flow and annotates future work with dashed interactions.

docs/diagrams/seq_scraper.puml

seq_trick.pumlAdd trick sequence diagram (PlantUML) +177/-0

Add trick sequence diagram (PlantUML)

• Documents a trick play, legality checks, belote announcements, and winner determination.

docs/diagrams/seq_trick.puml

state_cli_screens.mmdAdd CLI screen state diagram (Mermaid) +69/-0

Add CLI screen state diagram (Mermaid)

• Adds a Mermaid state diagram describing the Rich TUI screen transitions.

docs/diagrams/state_cli_screens.mmd

api.mdAdd engine API reference page +3/-0

Add engine API reference page

• Adds MkDocs page wiring for contrai-engine API docs (mkdocstrings).

docs/engine/api.md

index.mdAdd engine overview page +101/-0

Add engine overview page

• Documents engine responsibilities, CLI entry point, and RichView integration details.

docs/engine/index.md

index.mdAdd MkDocs home page +3/-0

Add MkDocs home page

• Adds the MkDocs landing page content referenced by 'mkdocs.yml'.

docs/index.md

mathjax.jsAdd MathJax configuration script +19/-0

Add MathJax configuration script

• Configures MathJax for MkDocs rendering of formulas in docs.

docs/javascripts/mathjax.js

core.mdRemove old package page (migrated) +0/-7

Remove old package page (migrated)

• Deletes legacy docs page content superseded by MkDocs nav pages.

docs/packages/core.md

engine.mdRemove old package page (migrated) +0/-16

Remove old package page (migrated)

• Deletes legacy docs page content superseded by MkDocs nav pages.

docs/packages/engine.md

README.mdRemove old scraper README page (migrated) +0/-23

Remove old scraper README page (migrated)

• Deletes legacy docs page content superseded by MkDocs nav pages.

docs/packages/scraper/README.md

api.mdAdd scraper API reference page +10/-0

Add scraper API reference page

• Adds MkDocs page wiring for scraper API docs (mkdocstrings).

docs/scraper/api.md

index.mdAdd scraper overview page +29/-0

Add scraper overview page

• Documents the scraper package purpose and current observation flow.

docs/scraper/index.md

extra.cssAdd MkDocs site CSS overrides +43/-0

Add MkDocs site CSS overrides

• Adds custom styles for Material theme and diagram/layout polish.

docs/stylesheets/extra.css

Other (20) +7088 / -80
test.ymlAdd GitHub Actions pytest workflow (uv matrix) +57/-0

Add GitHub Actions pytest workflow (uv matrix)

• Introduces a CI workflow that installs uv, syncs the workspace, and runs 'uv run --package ... pytest' for 'contrai-core' and 'contrai-engine' with concurrency cancellation.

.github/workflows/test.yml

mkdocs.ymlAdd MkDocs Material config with Mermaid + PlantUML + i18n +118/-0

Add MkDocs Material config with Mermaid + PlantUML + i18n

• Introduces a full MkDocs configuration: Material theme, mkdocstrings, mermaid2 plugin, plantuml_markdown, static-i18n, MathJax, and complete nav structure.

mkdocs.yml

test_auction.pyAdd comprehensive Auction tests +1199/-0

Add comprehensive Auction tests

• Adds extensive tests covering legality, freeze semantics, termination rules, contract materialization, and monotonicity of legal action enumeration.

packages/contrai-core/tests/test_auction.py

test_base_player.pyUpdate base player tests for new APIs +20/-8

Update base player tests for new APIs

• Adjusts tests to reflect Auction/Bid refactors and updated domain behavior.

packages/contrai-core/tests/test_base_player.py

test_bid.pyAdd bid hierarchy tests (including SlamLevel) +301/-0

Add bid hierarchy tests (including SlamLevel)

• New tests for contract value validation, ordering, equality semantics, and string rendering across bid variants.

packages/contrai-core/tests/test_bid.py

test_card.pyUpdate Card tests for frozen value semantics +99/-2

Update Card tests for frozen value semantics

• Extends tests to validate hashing/equality and behavior after removing mutable/precomputed fields.

packages/contrai-core/tests/test_card.py

test_contract.pyAdd Contract tests for caller-tracked double/redouble and Slam helpers +263/-0

Add Contract tests for caller-tracked double/redouble and Slam helpers

• New tests for constructor validation, multiplier derivation, and Slam-family helper functions.

packages/contrai-core/tests/test_contract.py

test_deck.pyUpdate Deck tests for Card/Hand changes +23/-22

Update Deck tests for Card/Hand changes

• Adjusts deck tests to match updated Card equality and hand behaviors.

packages/contrai-core/tests/test_deck.py

test_exceptions.pyAdd tests for ContraiError hierarchy +176/-0

Add tests for ContraiError hierarchy

• Validates dual inheritance (ContraiError + ValueError) and attached diagnostic fields on new exceptions.

packages/contrai-core/tests/test_exceptions.py

test_hand.pyAdd tests for new Hand query API +39/-0

Add tests for new Hand query API

• Adds coverage for 'has_suit', value-based 'has_card', and compatibility 'copy()' behavior.

packages/contrai-core/tests/test_hand.py

test_team.pyMinor team test adjustments +2/-2

Minor team test adjustments

• Keeps team tests aligned with updated types/behaviors.

packages/contrai-core/tests/test_team.py

test_trick.pyAdd tests for get_current_winner(trump) behavior +253/-0

Add tests for get_current_winner(trump) behavior

• Covers winner computation across trump/no-trump contexts and trick state error behavior.

packages/contrai-core/tests/test_trick.py

test_types.pyAdd tests for updated Suit/Rank enums +51/-0

Add tests for updated Suit/Rank enums

• Adds coverage for new Suit variants and display/value expectations.

packages/contrai-core/tests/test_types.py

pyproject.tomlAdd Rich/pyfiglet deps and register 'contrai' console script +6/-1

Add Rich/pyfiglet deps and register 'contrai' console script

• Adds runtime dependencies ('rich', 'pyfiglet') and a '[project.scripts]' entry pointing to 'contrai_engine.cli:main'.

packages/contrai-engine/pyproject.toml

test_player.pyExpand engine Player tests for Auction-based bidding +464/-42

Expand engine Player tests for Auction-based bidding

• Adds coverage for the bid wire bridge and AI behavior around Double/Redouble and Slam/Solo Slam paths.

packages/contrai-engine/tests/test_model/test_player.py

test_round.pyAdd large Round tests for Auction/scoring/belote/legality +1283/-0

Add large Round tests for Auction/scoring/belote/legality

• Adds extensive tests for Auction-driven bidding, scoring variants (numeric, doubled, Slam-family, unannounced capot), belote handling, and strict illegal-play failures.

packages/contrai-engine/tests/test_model/test_round.py

__init__.pyIntroduce test_view package init +1/-0

Introduce test_view package init

• Adds '__init__.py' to make the view tests a proper package under the new pytest import mode.

packages/contrai-engine/tests/test_view/init.py

test_rich_view.pyAdd RichView rendering + interaction tests +2689/-0

Add RichView rendering + interaction tests

• Adds a comprehensive RichView test suite validating rendering outputs, screen flows, and input parsing using Rich console capture patterns.

packages/contrai-engine/tests/test_view/test_rich_view.py

pyproject.tomlDrop unused scraper dependency +7/-1

Drop unused scraper dependency

• Removes an unused dependency from the scraper package configuration.

packages/contrai-scraper/pyproject.toml

pyproject.tomlTurn workspace root into a project; add docs deps and pytest import-mode +37/-2

Turn workspace root into a project; add docs deps and pytest import-mode

• Adds a root '[project]' with workspace member dependencies, a 'docs' dependency group, uv workspace sources, and pytest '--import-mode=importlib' to avoid 'tests' package collisions across packages.

pyproject.toml

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. ALL_TRUMP rules missing 🐞 Bug ≡ Correctness
Description
Suit.ALL_TRUMP is now a legal contract suit, but trump-aware logic only triggers when card.suit ==
trump_suit, so ALL_TRUMP effectively behaves like no-trump. This yields incorrect trick winners and
scoring whenever an ALL_TRUMP contract is bid.
Code

packages/contrai-core/src/contrai_core/types.py[25]

+    ALL_TRUMP = "AllTrump"
Evidence
The PR adds Suit.ALL_TRUMP and immediately makes it a valid ContractBid suit, but neither card
scoring nor trick evaluation has any branch that can treat ALL_TRUMP as trump; both rely on strict
suit equality with the trump_suit parameter, which will never match ALL_TRUMP for any physical card.

packages/contrai-core/src/contrai_core/types.py[12-26]
packages/contrai-core/src/contrai_core/bid.py[131-156]
packages/contrai-core/src/contrai_core/card.py[97-105]
packages/contrai-core/src/contrai_core/trick.py[86-134]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Suit.ALL_TRUMP` was introduced and is now accepted by `ContractBid`, but the rest of the engine interprets "trump" as a single suit (`card.suit == trump_suit`). As a result, selecting `ALL_TRUMP` produces wrong trick evaluation and point calculation.

## Issue Context
- `ContractBid` currently accepts `ALL_TRUMP` because `VALID_SUITS = list(Suit)`.
- `Card.get_points/get_order` and `Trick.get_current_winner` only consider a card trump if its suit equals the contract’s `trump_suit`, which can never be true for `Suit.ALL_TRUMP`.

## Fix Focus Areas
Choose one:
1) Fully implement `ALL_TRUMP` semantics end-to-end (card points/order, trick winner resolution, and any play-obligation rules impacted), and add tests.
2) If `ALL_TRUMP` isn’t supported yet, remove it from legal bidding (e.g., exclude it from `ContractBid.VALID_SUITS`) until semantics are implemented.

- packages/contrai-core/src/contrai_core/types.py[12-26]
- packages/contrai-core/src/contrai_core/bid.py[131-156]
- packages/contrai-core/src/contrai_core/card.py[97-105]
- packages/contrai-core/src/contrai_core/trick.py[86-134]
- packages/contrai-engine/src/contrai_engine/model/round.py[637-722]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Unverified bid ownership 🐞 Bug ☼ Reliability
Description
Round.manage_bidding applies a Bid returned by the player/view without verifying that bid.player is
the active bidder. If an upstream component returns a bid attributed to the wrong player,
Auction/Contract will accept it and the declarer/doubling caller can be silently mis-attributed.
Code

packages/contrai-engine/src/contrai_engine/model/round.py[R157-158]

+                bid = self._gather_bid(player, auction, view)
+            auction = auction.apply(bid)
Evidence
The bidding loop applies whatever Bid object it receives, and Auction/Contract use the embedded
bid.player to determine the winning contract’s declarer and doubling callers. Without validating
bid.player == active player, a mismatched bid attribution will be accepted and will alter contract
ownership.

packages/contrai-engine/src/contrai_engine/model/round.py[144-163]
packages/contrai-core/src/contrai_core/auction.py[72-94]
packages/contrai-core/src/contrai_core/auction.py[182-200]
packages/contrai-core/src/contrai_core/contract.py[49-55]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Round.manage_bidding` cycles through active players but does not assert that the produced `Bid` is attributed to that same active player. Since `Auction.apply()` and `Auction.contract()` derive the contract declarer (and double/redouble callers) from the `Bid.player` stored inside bids, a mismatched `bid.player` can corrupt the round outcome.

## Issue Context
This is primarily a hardening/robustness problem: it prevents silent corruption if a view/controller bug or AI bug accidentally constructs a `Bid` for the wrong player.

## Fix Focus Areas
- Add a validation right before `auction.apply(bid)` (e.g., `if bid.player is not player: raise RuntimeError(...)`).
- Alternatively, reconstruct the bid with the correct player (safer for UX but can hide upstream bugs; raising is usually better).

- packages/contrai-engine/src/contrai_engine/model/round.py[144-164]
- packages/contrai-core/src/contrai_core/auction.py[72-94]
- packages/contrai-core/src/contrai_core/auction.py[182-200]
- packages/contrai-core/src/contrai_core/contract.py[49-55]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread packages/contrai-core/src/contrai_core/types.py
@valmathieu valmathieu merged commit 3957ee8 into main Jun 20, 2026
2 checks passed
@valmathieu valmathieu deleted the feat/engine-rich-tui branch June 20, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant