diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1376d75 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,57 @@ +name: tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Cancel stale runs when you push new commits to the same PR/branch. +# Saves CI minutes and gives you a faster signal on the latest code. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: pytest (${{ matrix.package }}) + runs-on: ubuntu-latest + + strategy: + # Don't abort the whole matrix if one package fails; + # you want to see which packages broke independently. + fail-fast: false + matrix: + package: + - contrai-core + - contrai-engine + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + # Invalidate the cache when the lockfile changes. + cache-dependency-glob: "uv.lock" + + - name: Set up Python + # No arg = uv reads requires-python / .python-version + # and installs the matching interpreter (3.14 for ContrAI). + run: uv python install + + - name: Sync workspace + # --all-packages installs every workspace member, including + # dev dependencies. Needed because contrai-engine depends on + # contrai-core in editable mode within the workspace. + run: uv sync --all-packages --all-extras + + - name: Run pytest + # --package scopes the run to one workspace member, so we get + # a clean per-package status check in the PR UI. + run: uv run --package ${{ matrix.package }} pytest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 64a3143..5ceecdd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ build/ dist/ wheels/ +# MkDocs build output. +site/ + # ─── Virtual environments ───────────────────────────────────────────────── .venv/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bfe23cd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,269 @@ +# Contributing to ContrAI + +ContrAI is a personal research project on AI for the French card game +*Contrée*. It's a learning vehicle as much as an engineering project, so the +conventions here lean toward "make it easy for future-Valentin (or any +collaborator) to pick up the thread six months later" rather than ceremony for +its own sake. + +If you've forked or cloned this repo to play around, build on top of it, or +contribute back: welcome. This file is the handbook. + +## What's in the repo + +ContrAI is a [uv](https://docs.astral.sh/uv/) workspace with four packages: + +- **`contrai-core`** — Shared domain types (cards, suits, contracts, players). + No game logic, no UI. Other packages depend on this. +- **`contrai-engine`** — The Contrée game engine. CLI, MVC architecture, + rule-based AI players, pytest-tested. +- **`contrai-analyzer`** — A Streamlit dashboard for hand analysis using + hypergeometric probabilities. Deliberately independent of `contrai-core` + (different abstractions for a different question — don't try to unify them). +- **`contrai-scraper`** — A Playwright-based spectator scraper for an online + Contrée site, persisting observed games to SQLite. + +Specs and the LaTeX report live in a **separate** repo, `contrai-docs`. Don't +propose changes to `Specs_fonctionnelles.md` or `Specs_logicielles.md` in PRs +here — those live there. + +## Getting started + +Requirements: + +- Python 3.14 (uv will install it for you) +- [uv](https://docs.astral.sh/uv/getting-started/installation/) + +Clone and set up: + +```bash +git clone https://github.com//contrai.git +cd contrai +uv sync --all-packages --all-extras +``` + +Run the tests: + +```bash +uv run --package contrai-core pytest +uv run --package contrai-engine pytest +``` + +Run the CLI engine: + +```bash +uv run --package contrai-engine python -m contrai_engine +``` + +Run the analyzer dashboard: + +```bash +uv run --package contrai-analyzer streamlit run src/contrai_analyzer/app.py +``` + +(Adjust the entrypoint paths once the actual modules are in place.) + +## Branching + +ContrAI follows **GitHub Flow**: `main` is always working, every non-trivial +change lives on a short-lived feature branch, and changes land via pull +requests. + +Branch names mirror the Conventional Commit type and the scope of the work: + +``` +/- +``` + +Examples: + +``` +feat/engine-bidding-double +fix/core-suit-equality +refactor/engine-extract-trick-class +test/core-deck-shuffle-edge-cases +ci/add-ruff-check +``` + +Types and scopes come from the Conventional Commits convention (see below). + +Don't commit directly to `main`. The only exception is the very first +scaffolding commit on an empty repo; after that, everything goes through a +branch. + +## Commits + +Every commit follows +[Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): + +[optional body explaining what and why] +``` + +Allowed types: + +- `feat` — A new feature +- `fix` — A bug fix +- `refactor` — Code change that neither fixes a bug nor adds a feature +- `docs` — Documentation only +- `test` — Adding or fixing tests +- `chore` — Build, tooling, maintenance +- `perf` — Performance improvement +- `style` — Formatting only (no code change) +- `build` — Build system or dependency changes +- `ci` — CI configuration + +Scopes usually map to packages: `core`, `engine`, `analyzer`, `scraper`. In +the `contrai-docs` repo, scopes are `specs` or `report`. + +**Atomic commits.** One logical change per commit. The rule of thumb: if +you'd want to revert two things together, they belong in one commit; if you'd +ever want to revert one without the other, split them. Adding a feature with +its tests is one logical change. Refactoring AND adding a feature is two. + +**No AI co-authorship trailers.** Commits are attributed to the human author +only, regardless of whether an AI assistant helped draft the change. + +## Pull requests + +Open a PR for anything beyond a one-line fix or typo. Yes, even when you're +the only person on the project — the PR is your **self-review ritual**. + +What a self-review catches: + +- Forgotten `print()` or `breakpoint()` +- Stale docstrings +- Commented-out code that should have been deleted +- Inconsistent naming +- Missing tests +- Files you forgot to add to the staging area + +Process: + +1. Push your branch to GitHub. +2. Open a PR with a description that explains *what* and *why* (the *how* is + in the diff). +3. Use **draft PRs** for work-in-progress you want CI to run on without + claiming it's ready. +4. Review your own diff in the GitHub UI before merging. +5. Wait for CI to pass. +6. Merge. + +PR titles should match the eventual commit subject — Conventional Commits +format. + +## Merging + +The default merge strategy is **rebase-merge**: each commit on the feature +branch is replayed onto `main`. This keeps the history linear and preserves +the atomic commits you carefully crafted. + +Use **squash-merge** when the branch has messy intermediate commits (`wip`, +`fix typo`, `actually fix it`) or when the whole PR is one logical change and +the individual commits aren't worth keeping. + +Avoid **merge commits**. They add noise without information for a project +this size. + +## Tests + +`contrai-core` and `contrai-engine` use pytest. Tests are mandatory for: + +- New Model-layer code in the engine (non-negotiable per project rules) +- New types or invariants in `contrai-core` +- Bug fixes — write a test that fails before the fix and passes after; it's + the cheapest insurance against regression + +`contrai-analyzer` and `contrai-scraper` don't currently have test suites. +When they grow them, this section gets updated and they join the CI matrix. + +CI runs pytest on `core` and `engine` on every PR. The merge button is +blocked until tests pass. + +Run locally before pushing: + +```bash +uv run --package contrai-core pytest +uv run --package contrai-engine pytest +``` + +## Code style + +- **Type hints everywhere.** Function signatures, class attributes, return + types. `ruff`, `mypy`, or `pyright` may join CI later. +- **Google-style docstrings** on every public class, method, and function. +- **Didactic comments are welcome.** This is a learning project — explain + non-trivial logic, especially anything probability-, combinatorics-, or + ML-related. Comments that explain *why* (not *what*) are gold. +- **English in code.** Identifiers, comments, docstrings — all English. + Reports and specs live in the `contrai-docs` repo and may be bilingual. + +## Architecture rules + +The engine uses **MVC**: Model (game state, rules), View (CLI), Controller +(orchestration). Don't bypass it silently. If a feature seems to require +crossing the boundaries — for instance, a Gym-style env wrapper for RL +training accessing internal state — raise it in an issue or PR description +first. MVC is explicitly on the table for re-discussion when ML training +arrives; until then, respect it. + +The **analyzer** has its own conventions and abstractions (e.g., `SuitSlot`). +Don't try to unify them with engine abstractions like `Suit` — they answer +different questions, and the deliberate separation is the point. + +## Dependencies + +Don't add a dependency to any `pyproject.toml` without flagging it first +(open an issue or mention it in the PR description before adding). Every +dependency is a long-term commitment — preferable to reach for the stdlib or +a small focused library before pulling in a heavy one. + +When you do add a dependency: `uv add --package ` and commit the +updated `pyproject.toml` and `uv.lock` together. + +## Diagrams + +- **PlantUML** for sequence and class diagrams. Source files end in `.puml`. +- **Mermaid** for everything else (component, state, flowchart, ER, + deployment, mindmap, etc.). Source files end in `.mmd`. +- **Use color.** Distinguish MVC layers, package boundaries, actors, + hot/cold paths. Plain black-and-white renders are dispreferred. + +Rendering commands: + +```bash +plantuml -tpng diagram.puml # → diagram.png +mmdc -i diagram.mmd -o diagram.png # → diagram.png +``` + +Commit the source (`.puml` or `.mmd`) and the rendered `.png` **together in +the same commit**. The PNG is what readers see on GitHub and in the report; +the source is what gets edited. + +## Releases and versioning + +ContrAI is pre-1.0, so versions are `0.x.y`. Breaking changes are allowed in +minor bumps. All four packages move in **lockstep** — same version, same +release — until something external (the planned multiplayer web server, a +published artifact, a friend's fork pinning a specific version) forces +independence. + +Releases are **milestone tags**: + +```bash +git tag -a v0.1.0 -m "First playable CLI engine" +git push --tags +``` + +GitHub Releases (with notes) for the bigger milestones — these double as +report material later. There's no fixed release cadence; tag when something +worth tagging happens. + +## Questions + +Open an issue. Tag it with the package involved, or `meta` for cross-cutting +questions. + +# diff --git a/ContrAI CLI/design_handoff_contrai_tui/README.md b/ContrAI CLI/design_handoff_contrai_tui/README.md new file mode 100644 index 0000000..e78783d --- /dev/null +++ b/ContrAI CLI/design_handoff_contrai_tui/README.md @@ -0,0 +1,309 @@ +# Handoff: ContrAI Terminal UI + +## Overview + +ContrAI is a Python CLI implementation of **Belote contrée**, a French four-player trick-taking card game. This handoff covers the full terminal UI for the game, rendered with the [Rich](https://rich.readthedocs.io/) library: a landing/setup screen, three in-game states covering the bidding and trick-play loop, and a final scoreboard screen. + +## About the Design Files + +The files in `mockups/` are **design references created in HTML/SVG** — they show the intended look and behavior of the terminal UI but are **not production code to copy**. The task is to **recreate these designs in the existing Python codebase** using the Rich library's panel/table/text primitives, hooked into your existing game-state objects (Round, Trick, Player, Hand, etc.). + +`mockups/index.html` is a single-page viewer that shows all five frames stacked vertically — open it in any browser for the canonical reference. + +## Fidelity + +**High-fidelity.** Every panel title, border style, color, and prompt string in the mockups is intentional. Match them exactly unless a Rich constraint forces a substitute. The mockups use: + +- Monospace terminal aesthetic, dark background (`#1e1e1e`) +- Rich-style panels: single `┌─ … ─┐` lines for normal panels, double `╔═ … ═╗` for the end-game banner +- Box titles in the top border, e.g. `┌─ Your hand (South) ─┐` +- Suit glyphs `♠ ♣` in light gray, `♥ ♦` in red +- Diamond seating layout (N top, E right, S bottom, W left) for trick panels + +## Screens / Views + +### 0. Landing — Game Setup (`mockups/00-landing.svg`) + +**Purpose**: First screen shown after `contrai` is launched. The user picks a target score and starts the game. + +**Layout** (terminal grid, 70 columns wide): + +- Rows 1–6: Block-ASCII **CONTRAI** title, centered. Use the [pyfiglet](https://pypi.org/project/pyfiglet/) `ANSI Shadow` font OR a hand-rolled `▀ ▄ █` block (the mockup uses `█ ╗ ╔ ╝ ╚` Unicode box block glyphs). Color: `#e5c07b` (warm yellow), bold. +- Row 7: Subtitle "Belote · Contrée · CLI edition" in dim gray (`#6a6a6a`), centered. +- Row 8: Suit ribbon `♠ ♥ ♦ ♣`, centered. Red suits `#e06c75`, black suits `#d4d4d4`. +- Rows 10–19: **Game setup** panel (Rich `Panel`, title="Game setup", border `#7a7a7a`). Contains: + - Row 11: "Target score" (bold light gray) + helper text "(first team to reach the target wins the game)" in dim + - Rows 13–17: Five radio-style options, one per row: + - `( )` empty radio or `(●)` filled radio for the selected one + - Value (right-padded to 4 chars): `500`, `1000`, `1500`, `2000`, `3000` + - Label: `Quick game`, `Short game`, `Standard`, `Long game`, `Marathon` + - Separator `·` + - Estimate: `~10 min`, `~20 min`, `~30 min`, `~45 min`, `~60 min` + - The selected row (`1500 Standard`) has: + - Gold background pill `#3a2b10` spanning the full row + - Foreground text in `#ffd57a` (gold), bold value, "← default" suffix in `#f0b54a` +- Rows 20–23: **Players** panel (Rich `Panel`, title="Players"). Two columns of two: + - `N North (AI · medium)` — N in blue `#7fb6ff` bold, name in default gray, role in dim + - `E East (AI · medium)` — E in orange `#ffb482` + - `S You · human` — S and "You" in green `#cfeac0` bold (this is the human player) + - `W West (AI · medium)` — W in orange +- Row 23–24: Prompt line, no border (or in its own Panel — keep consistent with in-game prompt panel): + - `> ` in green bold + - `Target score? [500 / 1000 / 1500 / 2000 / 3000] (default 1500): █` — the default value `1500` is highlighted in gold, cursor block `█` at the end + +**Interaction**: User types a number and presses Enter, or just Enter to accept default 1500. Validate: must be one of the five values (or accept any positive int divisible by 10 if you want to be liberal — match your existing pattern). Then transition to game. + +### 1. In-Game — Bidding Phase (`mockups/01-bidding.svg`) + +**Purpose**: Cards have just been dealt; players are bidding. South's turn to bid (West just passed). + +**Layout** (70-column grid, 5 panels): + +``` +┌─ Game score ───────┐ ┌─ Round ──────────────────────────────────────┐ +│ N-S 850 │ │ Contract: — │ +│ E-W 1320 │ │ Trump: — │ +│ ·················· │ │ Phase: Bidding in progress │ +│ Target 1500 │ │ Dealer: East │ +└────────────────────┘ └──────────────────────────────────────────────┘ + +┌─ Last trick ─────┐ ┌─ Current trick ──────────────────────────────┐ +│ (none) │ │ (bidding…) │ +│ │ │ │ +└──────────────────┘ └──────────────────────────────────────────────┘ + +┌─ Your hand (South) ─────────────────────────────────────────────────┐ +│ [1] A♠ [2] K♠ [3] J♥ [4] A♥ [5] 9♥ [6] Q♦ [7] 10♦ [8] 8♦ │ +│ (no card-play obligation yet — bidding phase) │ +└─────────────────────────────────────────────────────────────────────┘ + +┌─ Prompt ────────────────────────────────────────────────────────────┐ +│ West passed. Your bid? (e.g. '80 H' / 'pass' / 'coinche') │ +│ > █ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Component details**: + +- **Game score panel**: Width 22 cols. `N-S` label in blue bold + right-aligned score. `E-W` label in orange bold + right-aligned score. Dotted separator `·` row in `#3a3a3a`. `Target` in dim + value in yellow `#e5c07b`. +- **Round panel** (state 1, bidding only): Width 46 cols. Plain gray border. Fields `Contract:`, `Trump:`, `Phase:`, `Dealer:` — labels in dim gray, values in default. "Bidding in progress" value is bold yellow to call attention. +- **Last trick / Current trick**: When no tricks played, show placeholder text `(none)` / `(bidding…)` in dim, centered vertically in the panel. Panel height 8 rows. +- **Hand panel**: All 8 cards visible. Each card cell is 8 chars wide: ` [n] RR S` (compact — fits 8 in 70 cols). Numbers 1–8 in yellow `#e5c07b` bold for selection, brackets in border gray. Cards sorted: spades, hearts, diamonds (no clubs in this hand). No card is highlighted as playable. +- **Prompt panel**: 4 rows tall. Question on row 1 (default gray, since not the user's mandatory turn). `> ` in green bold + cursor block `█` on row 2. + +### 2. In-Game — Mid-Trick, Must Trump (`mockups/02-trick-in-progress.svg`) + +**Purpose**: Trick 4 of 8. West led ♣K, North played ♣10, East played ♣A, **South to play**. South has no clubs → must trump. The two ♥ cards are highlighted as the only legal plays. + +**Differences from state 1**: + +- **Round panel border becomes yellow** `#e5c07b` (trump active). Title in yellow. Title suffix `★` in gold `#f0b54a`. + + - `Contract: 100 by E-W` — "100" bold default, "by" dim, "E-W" orange bold. **Do not repeat the trump suit here** — it lives in the Trump row. + - `Trump: ♥ Hearts ★` — heart glyph red, "Hearts" bold default, ★ gold + - `Trick: 4 of 8` + - `Round pts: N-S 38 · E-W 52` — N-S in blue, E-W in orange + +- **Last trick panel**: Now uses the **dim/echo** treatment. Border `#444`, title "Last trick 3" in dim. Width compressed to 22 cols (half the width of current trick). Diamond rendered with muted colors — this is intentional secondary information. South's ♠A is the winner, shown with gold pill `★`. Status line: `Won: South` in gold. + +- **Current trick panel**: Width 46 cols (the focal point). Title `Current trick`, suffix `trick 4` in dim. Diamond layout: + + ``` + N ♣10 + W ♣K (led) [E ♣A ★] ← E is currently winning the trick + S ? ← S to play, yellow "?" + ``` + + - **N at top center, E at right (anchored to inner right edge), S at bottom center, W at left (anchored to inner left)** + - Faint `╱╲` diamond outline in `#3d3d40` between positions + - Each player rendered as `LABEL CARD`. Label in blue (N/S) or orange (E/W). + - **Live leader** (E here — highest card under the led suit, no trump played) gets the gold pill background `#3a2b10`, label/card text in `#ffd57a`, trailing `★` in `#f0b54a`. + - **South's slot** shows `?` in yellow bold (pending). + - **`(led)` annotation** in dim gray follows the leader's card. + - Status line at bottom of panel: `→ Your turn` in yellow. + +- **Hand panel**: 5 cards (sorted trump-first): `♥J ♥A | ♠A | ♦Q ♦10`. Cell width 10 (more breathing room with fewer cards). + + - The two playable cards (`♥J`, `♥A`) get a **green background** `#2e5a2a` spanning the cell, foreground `#cfeac0`, number in white bold. + - The three non-playable cards (`♠A`, `♦Q`, `♦10`) are dimmed: text in `#6a6a6a`, red suits in `#7a3a3f`. + - Hint line under the row: `↑ playable (must trump — partner E led ♣A)` in green `#cfeac0`, centered. + +- **Prompt panel**: First line `Your turn. Must trump. Choose card [1-5]:` in **yellow bold** (mandatory action). + +### 3. In-Game — Trick Won, Transition (`mockups/03-trick-won.svg`) + +**Purpose**: Trick 4 just finished. South played ♥J (jack of trump) and won. + +**Differences from state 2**: + +- Round panel `Round pts:` updates to `N-S 58 · E-W 52` (South's team gained the trick). +- **Current trick panel**: All four cards revealed in the diamond. S now shows `♥J` with the gold winner pill + `★`. Status line: `Won: South` in gold. +- **Last trick panel**: Unchanged from state 2 (still trick 3 — only rotates after next play). +- **Hand panel**: 4 cards remaining (`♥A`, `♠A`, `♦Q`, `♦10`). All neutral (not South's turn yet visually). Cell width 11. Hint: `4 cards remaining` in dim. +- **Prompt panel**: `South leads next trick. Press [Enter] to continue…` in default gray (waiting, not mandatory). + +### 4. End Game — Scoreboard (`mockups/04-game-over.svg`) + +**Purpose**: A team has reached the target score. Show the winner and full round-by-round breakdown. + +**Layout**: + +- **Rows 0–6: "Game over" banner panel** — full width, **double-line border** `╔═ … ═╗` in gold `#f0b54a`, title `Game over` in gold bold. + - Row 2: `★ N-S WINS ★` centered, with gold pill background `#3a2b10` spanning the full inner row, foreground `#ffd57a` bold. + - Row 4: "Final score" label in dim, centered. + - Row 5: `1620 vs 1420` centered. `1620` (winner) in gold bold, `vs` in dim, `1420` in orange bold. + - Row 6: Team labels `N-S` (blue) and `E-W` (orange) directly under their numbers. +- **Rows 8–20: Round-by-round summary panel** — Rich `Table` inside a Panel titled "Round-by-round summary". Columns: + - `#` (right-align, dim) — round number 1..10 + - `Contract` (left, default) — e.g. `N-S 100 ♠`, `E-W 110 ♦`, `N-S 130 ♥ + bel`, `E-W 100 ♣ contrée` (color the suit glyph) + - `Made` (center) — `✓` in `#3a7a3a` if made, `✗` in red `#e06c75` if down + - `N-S pts` (right) — bold blue if N-S won the round, dim `·` if 0 + - `E-W pts` (right) — bold orange if E-W won the round, dim `·` if 0 + - `Running N-S / E-W` (right, dim) — cumulative game score after this round, e.g. `358 / 284` + - Header row in dim bold, separator row `─` in `#2a2a2a` below the header. +- **Rows 21–24: Prompt panel** — `Game over. [n] new game · [r] rematch · [q] quit` — bracketed keys in yellow bold. `> █` on line 2. + +**Interaction**: `n` → back to landing screen (state 0). `r` → new game, same target & player config. `q` → exit cleanly. + +## Interactions & Behavior + +### Navigation flow + +``` +landing (0) ──Enter──▶ bidding (1) ──bid accepted──▶ play loop ──┐ + │ + ┌──────────────────────────────────────────┘ + ▼ + play trick (2 → 3 → 2 → 3 …) ──hand exhausted──▶ next round + │ + ┌──────────────────────────────────────────┘ + ▼ + round score updates ──either team hits target──▶ end game (4) + │ + ┌───────────────────────────────────────────────┘ + ▼ + [n] → landing · [r] → bidding (same setup) · [q] → exit +``` + +### Live trick-winner tracking (state 2) + +After each card is played within a trick, recompute who is currently winning: + +- If any trump has been played: highest trump wins +- Else: highest card of the led suit wins + +Apply the **gold pill highlight (`#3a2b10` bg, `#ffd57a` fg, `★` suffix)** to that player's slot in the Current Trick panel. As more cards are played, the highlight may move. When all 4 are in, the winner is final and the status line flips from `→ Your turn` (or whoever's turn) to `Won: `. + +### Playable-card highlighting (state 2) + +Compute legal plays per Coinche rules: + +- Must follow suit if possible +- If can't follow and partner is not currently winning: must trump (and over-trump if a trump has been played) +- If can't follow and partner *is* winning: free to discard +- "Pisser" — if no trump in hand and can't follow: free to discard + +Cards that pass these rules → **green pill** (`#2e5a2a` bg, `#cfeac0` fg, white number). +Cards that don't → **dimmed** (`#6a6a6a` fg, `#7a3a3f` for red suits). + +The hint line below the hand explains *why* (e.g. "must trump — partner E led ♣A", "must follow ♠", "free discard"). + +### Animations + +The CLI doesn't really animate, but consider: + +- Re-render the full layout on each state change (Rich's `Live` context is the right pattern) +- A short `time.sleep(0.5)` between trick completion (state 3) and clearing for next trick gives the user time to read the result + +## State Management + +You likely already have these — just map them to render input: + +- `Game`: target_score, n_s_score, e_w_score, current_round, history (list of completed rounds), is_over +- `Round`: contract (bid value, suit, taker, doubled_state), trump_suit, current_trick_idx, tricks_played, ns_round_pts, ew_round_pts +- `Trick`: led_suit, plays (ordered list of {player, card}), winner (computed) +- `Player`: name, position (N/E/S/W), is_human, hand (list of Cards) +- `Card`: rank, suit; helpers `is_trump(round.trump)`, `value(is_trump)` + +Render functions receive these objects and emit Rich `Panel`s / `Table`s / `Text` instances. + +## Design Tokens + +### Colors + +| Token | Hex | Use | +| ------------- | --------- | ----------------------------------------------- | +| `bg` | `#1e1e1e` | Terminal background (Rich auto, just don't set) | +| `fg` | `#d4d4d4` | Default text | +| `dim` | `#6a6a6a` | Labels, secondary text, dimmed cards | +| `border` | `#7a7a7a` | Default panel borders | +| `border_dim` | `#444444` | Last-trick panel border | +| `title` | `#c8c8c8` | Panel titles | +| `red` | `#e06c75` | ♥ ♦ suits, error | +| `red_dim` | `#7a3a3f` | ♥ ♦ suits in dimmed cards | +| `blue` | `#7fb6ff` | N-S team color, N/S player labels | +| `orange` | `#ffb482` | E-W team color, E/W player labels | +| `green_bg` | `#2e5a2a` | Playable-card background pill | +| `green_fg` | `#cfeac0` | Playable-card foreground, "You" / prompt arrow | +| `green_check` | `#3a7a3a` | ✓ "made" marker | +| `yellow` | `#e5c07b` | Trump emphasis, mandatory prompt, hotkeys | +| `gold` | `#f0b54a` | ★ markers, winner banner border, default arrow | +| `gold_bg` | `#3a2b10` | Winner pill background, selected-radio bg | +| `gold_fg` | `#ffd57a` | Winner pill foreground, gold text | +| `hint` | `#3d3d40` | Faint diamond outline `╱╲` | +| `rule` | `#2a2a2a` | Table separator under header | +| `dot` | `#3a3a3a` | Dotted divider row in Game score panel | + +### Box characters + +- Single line: `┌ ┐ └ ┘ ─ │ ├ ┤ ┬ ┴ ┼` +- Double line (banner): `╔ ╗ ╚ ╝ ═ ║` +- Diamond outline: `╱ ╲` +- Suits: `♠ ♥ ♦ ♣` +- Misc: `★ █ ✓ ✗ ● ◄ →` + +### Typography + +The CLI inherits whatever monospace font the user's terminal uses. Don't try to override. The mockups render in **Menlo / Monaco / Courier New** for preview but in production Rich just outputs ANSI escapes. + +Bold is applied to: panel titles, player labels (N/E/S/W), card ranks and suits in trick panels, mandatory prompt text, hotkey letters in end-game prompt, ✓/✗ markers, winning-card highlight, ★, █ cursor. + +### Spacing / Layout + +- Total terminal width target: **70 columns** (works in any terminal ≥ 80 cols). +- Top row: `Game score` (22 cols) + 2-col gap + `Round` (46 cols) +- Middle row: `Last trick` (22 cols) + 2-col gap + `Current trick` (46 cols) +- Hand panel: full 70 cols, height 5 (border + 1 card row + 1 hint row + border + 1 spare) +- Prompt panel: full 70 cols, height 4 (border + question row + input row + border) +- Panel heights: top = 6, middle = 8, hand = 5, prompt = 4 → total content = ~24 rows, fits a 24-row terminal. + +## Implementation Notes for Rich + +- Use `rich.layout.Layout` for the 2×2 panel grid in-game. Use `Layout.split_row()` for the top and middle rows. +- For the diamond seating, render a single `rich.text.Text` with explicit spaces and newlines (don't try to use a Table — manual positioning is cleaner here). Use `Text.append()` with style strings per segment. +- The Round panel border switches between `style="white"` (default) and `style="bold yellow"` (trump active) — Rich's `Panel(border_style=...)` is your friend. +- The playable-card green pill is `Text(" [1] J♥ ", style="white on rgb(46,90,42)")` — Rich does ANSI bg colors fine. +- The end-game **double-line border** — Rich's `Panel` supports `box=box.DOUBLE` (or use `box.DOUBLE_EDGE`). The gold color is `border_style="rgb(240,181,74)"`. +- The round-by-round summary is a perfect fit for `rich.table.Table(show_header=True, header_style="bold dim")` with per-cell `Text(..., style=...)`. + +## Assets + +No image assets — everything is text + ANSI colors. The mockups are SVG-as-terminal renderings (a generator script produced them); they exist only for visual reference and don't need to be shipped with the app. + +## Files in this bundle + +- `README.md` — this file +- `mockups/index.html` — single-page viewer showing all 5 frames stacked +- `mockups/00-landing.svg` — game setup screen +- `mockups/01-bidding.svg` — in-game, bidding phase +- `mockups/02-trick-in-progress.svg` — in-game, mid-trick, South must trump +- `mockups/03-trick-won.svg` — in-game, trick just completed +- `mockups/04-game-over.svg` — end-game scoreboard + +## How to use this with Claude Code + +In your repo, run Claude Code and reference the files explicitly. Example prompt: + +> Here is a design handoff in `design_handoff_contrai_tui/`. Read `README.md` and the SVGs in `mockups/` to understand the intended UI. Then implement these five screens in the existing codebase using the Rich library, wiring them up to the existing `Game` / `Round` / `Trick` / `Player` objects. Start by sketching the Layout structure for the in-game view, then build the landing screen, then the end-game screen. Keep rendering logic in a new module `contrai/ui.py` and don't touch game-logic code. + +Iterate from there — Claude Code can compare its output against the SVGs side-by-side using `mockups/index.html`. diff --git a/ContrAI CLI/design_handoff_contrai_tui/mockups/00-landing.svg b/ContrAI CLI/design_handoff_contrai_tui/mockups/00-landing.svg new file mode 100644 index 0000000..10446cf --- /dev/null +++ b/ContrAI CLI/design_handoff_contrai_tui/mockups/00-landing.svg @@ -0,0 +1 @@ +contrai — coinche ██████╗ ██████╗ ███╗ ██╗████████╗██████╗ █████╗ ██╗ ██╔════╝██╔═══██╗████╗ ██║╚══██╔══╝██╔══██╗██╔══██╗██║ ██║ ██║ ██║██╔██╗ ██║ ██║ ██████╔╝███████║██║ ██║ ██║ ██║██║╚██╗██║ ██║ ██╔══██╗██╔══██║██║ ╚██████╗╚██████╔╝██║ ╚████║ ██║ ██║ ██║██║ ██║██║ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ Coinche · Belote contrée · CLI edition ┌─ Game setup ───────────────────────────────────────────────────┐ Target score (first team to reach the target wins the game) ( ) 500 Quick game · ~10 min ( ) 1000 Short game · ~20 min () 1500 Standard · ~30 min ← default ( ) 2000 Long game · ~45 min ( ) 3000 Marathon · ~60 min └────────────────────────────────────────────────────────────────┘ ┌─ Players ──────────────────────────────────────────────────────┐ N North (AI · medium) E East (AI · medium) S You · human W West (AI · medium) > Target score? [500 / 1000 / 1500 / 2000 / 3000] (default 1500):█ \ No newline at end of file diff --git a/ContrAI CLI/design_handoff_contrai_tui/mockups/01-bidding.svg b/ContrAI CLI/design_handoff_contrai_tui/mockups/01-bidding.svg new file mode 100644 index 0000000..30c5eca --- /dev/null +++ b/ContrAI CLI/design_handoff_contrai_tui/mockups/01-bidding.svg @@ -0,0 +1 @@ +contrai — coinche┌─ Game score ───────┐ ┌─ Round ────────────────────────────────────┐ N-S 850 Contract: E-W 1320 Trump: ·················· Phase: Bidding in progress Target 1500 Dealer: East └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Last trick ───────┐ ┌─ Current trick ────────────────────────────┐ (none) (bidding…) └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Your hand (South) ────────────────────────────────────────────────┐ [1] A♠ [2] K♠ [3] J [4] A [5] 9 [6] Q [7] 10 [8] 8 (no card-play obligation yet — bidding phase) └────────────────────────────────────────────────────────────────────┘┌─ Prompt ───────────────────────────────────────────────────────────┐ West passed. Your bid? (e.g. '80 H' / 'pass' / 'coinche') > └────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/ContrAI CLI/design_handoff_contrai_tui/mockups/02-trick-in-progress.svg b/ContrAI CLI/design_handoff_contrai_tui/mockups/02-trick-in-progress.svg new file mode 100644 index 0000000..c6c99e6 --- /dev/null +++ b/ContrAI CLI/design_handoff_contrai_tui/mockups/02-trick-in-progress.svg @@ -0,0 +1 @@ +contrai — coinche┌─ Game score ───────┐ ┌─ Round ──────────────────────────────────┐ N-S 850 Contract: 100 by E-W E-W 1320 Trump: Hearts ·················· Trick: 4 of 8 Target 1500 Round pts: N-S 38 · E-W 52 └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Last trick 3 ─────┐ ┌─ Current trick trick 4 ────────────────────┐ N ♠7 N ♣10 ╱╱╱╱ ╲╲╲╲ ╱╱╱╱ ╲╲╲╲ W ♠8 E ♠10 W ♣K (led) E ♣A ╲╲╲╲ ╱╱╱╱ ╲╲╲╲ ╱╱╱╱ S ♠A S ? Won: South → Your turn └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Your hand (South) ────────────────────────────────────────────────┐ [1] J [2] A [3] A♠ [4] Q [5] 10 ↑ playable (must trump — partner E led ♣A) └────────────────────────────────────────────────────────────────────┘┌─ Prompt ───────────────────────────────────────────────────────────┐ Your turn. Must trump. Choose card [1-5]: > └────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/ContrAI CLI/design_handoff_contrai_tui/mockups/03-trick-won.svg b/ContrAI CLI/design_handoff_contrai_tui/mockups/03-trick-won.svg new file mode 100644 index 0000000..ca83d1f --- /dev/null +++ b/ContrAI CLI/design_handoff_contrai_tui/mockups/03-trick-won.svg @@ -0,0 +1 @@ +contrai — coinche┌─ Game score ───────┐ ┌─ Round ──────────────────────────────────┐ N-S 850 Contract: 100 by E-W E-W 1320 Trump: Hearts ·················· Trick: 4 of 8 Target 1500 Round pts: N-S 58 · E-W 52 └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Last trick 3 ─────┐ ┌─ Current trick trick 4 ────────────────────┐ N ♠7 N ♣10 ╱╱╱╱ ╲╲╲╲ ╱╱╱╱ ╲╲╲╲ W ♠8 E ♠10 W ♣K (led) E ♣A ╲╲╲╲ ╱╱╱╱ ╲╲╲╲ ╱╱╱╱ S ♠A S ♥J Won: South Won: South └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Your hand (South) ────────────────────────────────────────────────┐ [1] A [2] A♠ [3] Q [4] 10 4 cards remaining └────────────────────────────────────────────────────────────────────┘┌─ Prompt ───────────────────────────────────────────────────────────┐ South leads next trick. Press [Enter] to continue… > └────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/ContrAI CLI/design_handoff_contrai_tui/mockups/04-game-over.svg b/ContrAI CLI/design_handoff_contrai_tui/mockups/04-game-over.svg new file mode 100644 index 0000000..1299fce --- /dev/null +++ b/ContrAI CLI/design_handoff_contrai_tui/mockups/04-game-over.svg @@ -0,0 +1 @@ +contrai — coinche╔═ Game over ════════════════════════════════════════════════════════╗ ★ N-S WINS ★ Final score 1620 vs 1420 N-S E-W ┌─ Round-by-round summary ───────────────────────────────────────────┐ # Contract Made N-S pts E-W pts Running N-S / E-W ────────────────────────────────────────────────────────────────── 1 N-S 100 ♠ 100 · 100 / 0 2 E-W 90 · 162 100 / 162 3 N-S 110 220 · 320 / 162 4 E-W 100 38 122 358 / 284 5 N-S 130 + bel 260 · 618 / 284 6 E-W 100 ♣ contrée · 320 618 / 604 7 N-S 120 ♠ 252 · 870 / 604 8 E-W 110 250 · 1120 / 604 9 N-S 130 + bel 280 60 1400 / 664 └─10N-S 80 ♣──────────────── 220────── 756────────1620 / 1420──────┘┌─ Prompt ───────────────────────────────────────────────────────────┐ Game over. [n] new game · [r] rematch · [q] quit > └────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/ContrAI CLI/design_handoff_contrai_tui/mockups/index.html b/ContrAI CLI/design_handoff_contrai_tui/mockups/index.html new file mode 100644 index 0000000..06f2783 --- /dev/null +++ b/ContrAI CLI/design_handoff_contrai_tui/mockups/index.html @@ -0,0 +1,84 @@ + + + + +ContrAI — Terminal mockup + + + +
+
+

contrai — terminal UI mockup

+

Five frames covering the full game arc: landing splash, three in-game states, and the end-game scoreboard. Same Rich-style terminal vocabulary throughout.

+
+ +
+
+ State 0 + Landing · target score selection + Game setup +
+
contrai — coinche ██████╗ ██████╗ ███╗ ██╗████████╗██████╗ █████╗ ██╗ ██╔════╝██╔═══██╗████╗ ██║╚══██╔══╝██╔══██╗██╔══██╗██║ ██║ ██║ ██║██╔██╗ ██║ ██║ ██████╔╝███████║██║ ██║ ██║ ██║██║╚██╗██║ ██║ ██╔══██╗██╔══██║██║ ╚██████╗╚██████╔╝██║ ╚████║ ██║ ██║ ██║██║ ██║██║ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ Coinche · Belote contrée · CLI edition ┌─ Game setup ───────────────────────────────────────────────────┐ Target score (first team to reach the target wins the game) ( ) 500 Quick game · ~10 min ( ) 1000 Short game · ~20 min () 1500 Standard · ~30 min ← default ( ) 2000 Long game · ~45 min ( ) 3000 Marathon · ~60 min └────────────────────────────────────────────────────────────────┘ ┌─ Players ──────────────────────────────────────────────────────┐ N North (AI · medium) E East (AI · medium) S You · human W West (AI · medium) > Target score? [500 / 1000 / 1500 / 2000 / 3000] (default 1500):█
+
+ +
+
+ State 1 + Just dealt · bidding phase + West passed → South to bid +
+
contrai — coinche┌─ Game score ───────┐ ┌─ Round ────────────────────────────────────┐ N-S 850 Contract: E-W 1320 Trump: ·················· Phase: Bidding in progress Target 1500 Dealer: East └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Last trick ───────┐ ┌─ Current trick ────────────────────────────┐ (none) (bidding…) └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Your hand (South) ────────────────────────────────────────────────┐ [1] A♠ [2] K♠ [3] J [4] A [5] 9 [6] Q [7] 10 [8] 8 (no card-play obligation yet — bidding phase) └────────────────────────────────────────────────────────────────────┘┌─ Prompt ───────────────────────────────────────────────────────────┐ West passed. Your bid? (e.g. '80 H' / 'pass' / 'coinche') > └────────────────────────────────────────────────────────────────────┘
+
+ +
+
+ State 2 + Trick 4 in progress · South to play + No clubs → must trump +
+
contrai — coinche┌─ Game score ───────┐ ┌─ Round ──────────────────────────────────┐ N-S 850 Contract: 100 by E-W E-W 1320 Trump: Hearts ·················· Trick: 4 of 8 Target 1500 Round pts: N-S 38 · E-W 52 └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Last trick 3 ─────┐ ┌─ Current trick trick 4 ────────────────────┐ N ♠7 N ♣10 ╱╱╱╱ ╲╲╲╲ ╱╱╱╱ ╲╲╲╲ W ♠8 E ♠10 W ♣K (led) E ♣A ╲╲╲╲ ╱╱╱╱ ╲╲╲╲ ╱╱╱╱ S ♠A S ? Won: South → Your turn └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Your hand (South) ────────────────────────────────────────────────┐ [1] J [2] A [3] A♠ [4] Q [5] 10 ↑ playable (must trump — partner E led ♣A) └────────────────────────────────────────────────────────────────────┘┌─ Prompt ───────────────────────────────────────────────────────────┐ Your turn. Must trump. Choose card [1-5]: > └────────────────────────────────────────────────────────────────────┘
+
+ +
+
+ State 3 + Trick 4 won · transition to lead + South leads trick 5 next +
+
contrai — coinche┌─ Game score ───────┐ ┌─ Round ──────────────────────────────────┐ N-S 850 Contract: 100 by E-W E-W 1320 Trump: Hearts ·················· Trick: 4 of 8 Target 1500 Round pts: N-S 58 · E-W 52 └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Last trick 3 ─────┐ ┌─ Current trick trick 4 ────────────────────┐ N ♠7 N ♣10 ╱╱╱╱ ╲╲╲╲ ╱╱╱╱ ╲╲╲╲ W ♠8 E ♠10 W ♣K (led) E ♣A ╲╲╲╲ ╱╱╱╱ ╲╲╲╲ ╱╱╱╱ S ♠A S ♥J Won: South Won: South └────────────────────┘ └────────────────────────────────────────────┘ ┌─ Your hand (South) ────────────────────────────────────────────────┐ [1] A [2] A♠ [3] Q [4] 10 4 cards remaining └────────────────────────────────────────────────────────────────────┘┌─ Prompt ───────────────────────────────────────────────────────────┐ South leads next trick. Press [Enter] to continue… > └────────────────────────────────────────────────────────────────────┘
+
+ +
+
+ State 4 + Game over · N-S wins + Final 1620 vs 1420 +
+
contrai — coinche╔═ Game over ════════════════════════════════════════════════════════╗ ★ N-S WINS ★ Final score 1620 vs 1420 N-S E-W ┌─ Round-by-round summary ───────────────────────────────────────────┐ # Contract Made N-S pts E-W pts Running N-S / E-W ────────────────────────────────────────────────────────────────── 1 N-S 100 ♠ 100 · 100 / 0 2 E-W 90 · 162 100 / 162 3 N-S 110 220 · 320 / 162 4 E-W 100 38 122 358 / 284 5 N-S 130 + bel 260 · 618 / 284 6 E-W 100 ♣ contrée · 320 618 / 604 7 N-S 120 ♠ 252 · 870 / 604 8 E-W 110 250 · 1120 / 604 9 N-S 130 + bel 280 60 1400 / 664 └─10N-S 80 ♣──────────────── 220────── 756────────1620 / 1420──────┘┌─ Prompt ───────────────────────────────────────────────────────────┐ Game over. [n] new game · [r] rematch · [q] quit > └────────────────────────────────────────────────────────────────────┘
+
+ +
+ Files: state-0-landing.svg · state-1-bidding.svg · state-2-must-trump.svg · state-3-trick-won.svg · state-4-end-game.svg +
+
+ + \ No newline at end of file diff --git a/README.md b/README.md index 9f04d67..6024a0f 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,36 @@ AI research project studying the French card game *Coinche* (a.k.a. *Contrée*). A [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/) with four packages under `packages/`: -| Package | Description | -|---|---| -| `contrai-core` | Shared domain model — `Card`, `Deck`, `Hand`, `Bid`, `Contract`, `Trick`, `Round`. *Populated in phase 2.* | -| `contrai-engine` | Game engine: model layer, AI players, CLI controller. | -| `contrai-analyzer` | Streamlit dashboard for hand-strength analysis. | -| `contrai-scraper` | Playwright spectator-mode scraper for online games. | +| Package | Description | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `contrai-core` | Shared domain model — `Suit`/`Rank` enums, `Card`, `Deck`, `Hand`, `Team`, `BasePlayer`, the `Bid` hierarchy (`PassBid`/`ContractBid`/`DoubleBid`/`RedoubleBid`), `Contract`, `Trick`, and exceptions. | +| `contrai-engine` | Game engine on top of `contrai-core`: `Player`/`HumanPlayer`/`AiPlayer`, `Game` / `Round` orchestration, CLI controller and view. | +| `contrai-analyzer` | Streamlit dashboard for opening-hand strength analysis. Independent of `contrai-core` by design (uses its own `SuitSlot` abstraction). | +| `contrai-scraper` | Playwright spectator-mode scraper for online Coinche games. | ## Setup Requires **Python 3.14**. Dependency management via [uv](https://docs.astral.sh/uv/). +The workspace `pyproject.toml` is virtual (no top-level project), so after `uv sync` the workspace members must be editable-installed explicitly: + ```bash -uv sync # install all workspace deps +uv sync +uv pip install -e packages/contrai-core -e packages/contrai-engine -e packages/contrai-analyzer -e packages/contrai-scraper + uv run --package contrai-engine main.py # run the engine CLI uv run --package contrai-analyzer streamlit run main.py ``` ## Documentation -See [`docs/`](docs/) for architecture overview, per-package documentation, and PlantUML diagrams. +See [`docs/`](docs/) for the architecture overview, per-package documentation, and diagram sources (PlantUML for sequence/class diagrams, Mermaid for everything else). + +## Docs site + +The narrative docs and per-package API reference are published with [MkDocs Material](https://squidfunk.github.io/mkdocs-material/). + +```bash +uv run mkdocs serve # live preview at http://127.0.0.1:8000 +uv run mkdocs build # render the static site into site/ (gitignored) +``` diff --git a/contree-domain.md b/contree-domain.md new file mode 100644 index 0000000..fd2cdee --- /dev/null +++ b/contree-domain.md @@ -0,0 +1,512 @@ +# Contrée — Domain Knowledge + +> **Scope.** This document captures the *game* of Contrée independent of any +> software implementation. It is the canonical reference for rules, +> terminology, and community conventions used across the ContrAI project. For +> *what the engine does*, see `Specs_logicielles.md` and `Specs_fonctionelles.md`; +> for *how the AI reasons*, see the AI-family docs alongside their +> implementations. + +--- + +## 1. Overview + +Contrée is a French trick-taking card game for four players in two fixed +partnerships. It is a member of the Jass family (Klaverjassen → Belote → +Contrée) and inherits its bidding mechanism from Bridge: each round, the two +teams bid against each other for the right to choose the trump suit and to +commit to a points contract. + +A game consists of multiple rounds (**manches**); each round runs through four +phases: + +1. **Deal** (*distribution*) +2. **Bidding** (*enchères*) +3. **Card play** (*le jeu de la carte*) — 8 tricks +4. **Scoring** (*comptage*) + +The first team to reach a target score (commonly 1500 or 2000 points) wins. + +--- + +## 2. Setup + +- **Players.** Exactly 4. +- **Teams.** Two fixed pairs, partners seated opposite each other. By + convention we call them **North–South** and **East–West** (N–S vs E–W), as + in Bridge. +- **Deck.** 32 cards: 7, 8, 9, 10, Jack, Queen, King, Ace in each of four suits + (♠ ♥ ♦ ♣). +- **Turn order.** Anticlockwise. The player to the **right** of the current + actor plays next. +- **Dealer rotation.** Anticlockwise — each new round, the dealer is the player + to the right of the previous dealer. + +--- + +## 3. Cards: hierarchy and point values + +The same physical card can be worth different numbers of points depending on +whether it is currently trump or not, and the ranking within a suit also +changes. This is the single trickiest rule for newcomers, and it is the source +of most edge cases in the engine. + +### 3.1. Trump suit (strongest first) + +| Card | Jack | 9 | Ace | 10 | King | Queen | 8 | 7 | +| ---------- | ---- | --- | --- | --- | ---- | ----- | --- | --- | +| **Points** | 20 | 14 | 11 | 10 | 4 | 3 | 0 | 0 | + +The Jack (*Valet*) and the 9 are the master cards at trump. Mnemonic: +**V 9 A 10 R D 8 7**. + +### 3.2. Non-trump suits (strongest first) + +| Card | Ace | 10 | King | Queen | Jack | 9 | 8 | 7 | +| ---------- | --- | --- | ---- | ----- | ---- | --- | --- | --- | +| **Points** | 11 | 10 | 4 | 3 | 2 | 0 | 0 | 0 | + +Standard order outside trump: **A 10 R D V 9 8 7**. + +### 3.3. Totals + +- 152 points live in the cards themselves. +- An extra 10 points (the **dix de der**) go to whichever team wins the last + trick. +- Per round, **162 points** are distributed across the two teams. +- The Belote bonus (see §6.5) adds 20 points to one team's total if applicable, + bringing the per-round ceiling to 182. + +There is no hierarchy *between* the three non-trump suits — they are all equal, +beaten only by the trump suit. + +--- + +## 4. Phase 1 — Deal + +1. For the very first round of a game, the dealer is chosen at random. +2. For subsequent rounds: + - The dealer is the player to the right of the previous dealer. + - The deck is **not** reshuffled between rounds by default. The collected + pile is simply *cut* by the player to the dealer's left and then dealt. + (Players may agree before the game to reshuffle every time.) +3. Dealing pattern: groups of **3-2-3** cards to each player, anticlockwise. + Variants like 2-3-3 or 3-3-2 are also acceptable as long as everyone agrees + in advance. +4. After dealing, each player has 8 cards. No card is turned up; all 32 are + distributed. + +--- + +## 5. Phase 2 — Bidding + +This is the strategic core of the game and what separates Contrée from +classical Belote. + +### 5.1. Order + +The first player to speak is the one to the **right of the dealer**. Bidding +proceeds anticlockwise. + +### 5.2. Possible actions on your turn + +- **Make a bid.** Announce a *value* and a *suit*, e.g. `90 ♥`. The value is + the number of points your team commits to taking with that suit as trump. + + - Minimum opening bid: **80**. + - Increments: **10 points**. + - Maximum numeric bid: **180**. + - Each new bid must be strictly higher than the current one. + + > The 170 and 180 steps are only feasible with **Belote** in hand (K + Q of + > trump add 20 points), since the cards alone cap at 162 + 10 *dix de der* = + > + > 172. The auction does **not** enforce that constraint at bid time — + > announcing 170 / 180 without Belote is legal but commits the bidder to a contract they cannot make on cards alone, which will *chuter* at scoring. + +- **Bid Slam** (*Capot*). A special bid declaring your team will take **all 8 + tricks**. Contract base value **250** points. Slam outranks any numeric + bid: once declared, no further contract bid is legal (numeric, Slam, or + Solo Slam). *Contre* and *surcontre* remain available against a Slam. + +- **Bid Solo Slam** (*Capot général*). A stronger all-tricks bid declaring + that the **bidder personally** will win every one of the 8 tricks — their + partner may play normally but is forbidden from winning any trick. Contract + base value **500** points. Solo Slam outranks any numeric bid, but it + **cannot be announced after a Slam** — once a Slam is on the table, the + auction is closed to further contract bids (asymmetric block). *Contre* + and *surcontre* remain available. + +- **Pass** (*passer*). A player who passes may re-enter the bidding later, as + long as the auction has not yet ended. + +- **Contrer** (double) — see §5.3. + +- **Surcontrer** (redouble) — see §5.3. + +### 5.3. Doubling + +- **Contre**: an opponent of the current bidder may call *contre* instead of + passing or bidding. This **freezes** the auction at the current contract + and **doubles** the contract's point value (both for success and for + failure). +- **Surcontre**: the bidder's team may respond to a contre with a + *surcontre*, which **quadruples** the contract's point value. Either player + on the bidding team may do this. +- *Contre* can only be called on the most recent numeric bid (it cannot be + used to reopen a finished auction). +- **Intervening passes do not close the Coinche / Surcoinche window.** Both + *contre* (by an opposing player) and *surcontre* (by the bidding team) + remain legal up until the auction terminates on three consecutive passes + per §5.4 — players who passed earlier may re-enter and call *contre* or + *surcontre*, consistent with the general re-entry rule in §5.2. + +### 5.4. End of bidding + +The auction ends when three consecutive players pass after the last bid (or +fewer if the bid has been contred / surcontred and the appropriate replies +given). + +- The team holding the final bid becomes the **declarer** / *attaque* / + *preneur*. +- The other team is the **defense** / *défense*. +- The suit of the final bid is the **trump** for this round. +- If everyone passes without anyone bidding, the round is annulled, cards are + collected and redealt (with the same dealer). + +--- + +## 6. Phase 3 — Card play + +The first card of the round (the *entame*) is played by the player to the +**right of the dealer**, regardless of which team won the contract. + +### 6.1. The trick + +Each trick has 4 cards, one per player, played anticlockwise. The winner of a +trick leads the next one. There are 8 tricks per round. + +### 6.2. Card-play obligations (in order) + +The legal-move rules of Contrée are stricter than most card games. Given the +suit led, a player must obey the following, in order: + +1. **Follow suit.** If you have any card in the led suit, you must play one. +2. **Trump if you cannot follow.** If you have no card in the led suit, you + must play a trump — *unless* exception 4 applies. +3. **Overtrump if a trump has been played to this trick.** If trumps have + already been played and you must trump, you must play a trump *higher* than + the highest trump already on the table, if you have one. Otherwise play any + trump. +4. **Partner exception.** If your partner is currently winning the trick + (their card is the strongest played so far), you are *not* obligated to + trump or to overtrump. You may discard freely. +5. **Discard.** If you have neither the led suit nor a trump (and no obligation + forces a trump), you may play any card. + +### 6.3. Special case: trump is led + +When trump is led, the follow-suit rule (1) applies as usual. In addition, +every player who can must play a trump *higher* than the highest already on +the table, if they hold one. If they cannot beat it, they must still play a +trump. + +### 6.4. Winning a trick + +- If the trick contains any trumps, the highest trump wins. +- Otherwise, the highest card *in the led suit* wins. Cards of other non-trump + suits cannot win. + +### 6.5. Belote / Rebelote + +If a player holds **both** the King and the Queen of trump, they may declare +this for a 20-point bonus to their team. The declaration is verbal: + +- Say "**Belote**" when playing the first of the two cards. +- Say "**Rebelote**" when playing the second. + +Notes: + +- The bonus is awarded to the team regardless of which of the two cards is + played first. +- It counts toward the contract total (so it can save a borderline contract). +- It is **kept even if the contract fails**. This is non-obvious and worth + testing carefully in the engine. + +--- + +## 7. Phase 4 — Scoring + +### 7.1. Counting + +At the end of the 8 tricks: + +1. Each team sums the point values of the cards in the tricks it has won + (using the *current* trump values — see §3). +2. The team that won the last trick adds the **dix de der** (10 points). +3. Belote bonus (20) is added if applicable. + +The total across both teams (excluding Belote) is always **162**. + +### 7.2. Contract outcome + +Let: + +- `C` = numeric contract value (one of 80, 90, …, 180) +- `P_attack` = points realized by the declaring team (cards + der + Belote if + applicable) +- `M` = multiplier: 1 (no contre), 2 (contre), 4 (surcontre) + +#### Numeric contracts (80–180) + +**Un-doubled** (`M = 1`) — the two sides *share* the pile: + +- **Made** (`P_attack ≥ C`): **declarer** scores `C + P_attack`; **defense** + scores its own card points (its share of the 162 + the *dix de der* if it + took the last trick). + Worked example: contract `90 ♥`, declarer realizes 102 → declarer 192, + defense 60. +- **Failed** (`P_attack < C`, *chuté*): **declarer** scores 0; **defense** + scores `160 + C`. + Worked example: contract `100 ♠`, failed → defense 260, declarer 0. + +**Doubled / redoubled** (`M > 1`) — **winner-takes-all**, exactly like the +Slam grid below: + +- The **winning side** (declarer if the contract is made, defense if it is + failed) scores `160 + C × M`. The stake is the *same* whichever side wins. +- The **losing side scores 0** — the defense never keeps its own card points + once it has doubled. + Worked example: contract `100 ♥ ×2` made → declarer 360, defense 0; the same + contract failed → defense 360, declarer 0. + +> The 162-point pile is treated as a flat **160** in the winner-takes-all and +> *chuté* formulas — the engine's rounding convention. + +**Belote (+20)** is the standing exception to "the loser scores 0": it is +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. + +#### Slam and Solo Slam + +Slam-family contracts keep the same shape as numeric contracts — the at-risk +amount is **contract + trick-points × multiplier** — but the trick pile +(normally up to 162) is *replaced* by a flat **substitute** equal to the +contract base. So the at-risk amount is: + +> `(contract + substitute) × multiplier` + +with `substitute = contract` for both Slam and Solo Slam. + +| Bid | Contract (`C`) | Substitute (replaces 162) | At-risk per `M` | +| --------- | -------------- | ------------------------- | ----------------- | +| Slam | 250 | 250 | `(250 + 250) × M` | +| Solo Slam | 500 | 500 | `(500 + 500) × M` | + +Both halves are multiplied by `M` (1 for normal, 2 for *contre*, 4 for +*surcontre*), giving: + +| Contract | Normal | Doubled | Redoubled | +| --------- | ------ | ------- | --------- | +| Slam | 500 | 1000 | 2000 | +| Solo Slam | 1000 | 2000 | 4000 | + +The grid is **symmetric**: whichever side wins the contract scores the +at-risk amount (declarer if made, defense if failed). The other side scores +zero (modulo Belote — see below). + +**Slam** (*Capot*) is **made** when the declaring team wins **all 8 tricks**. +Anything less is a failure → defense scores the at-risk amount. + +**Solo Slam** (*Capot général*) is **made** only when the **declaring player +personally** wins every one of the 8 tricks. The team winning all 8 together +is **not** enough — if the partner wins any trick, the Solo Slam fails and +defense scores the at-risk amount. + +**Belote (+20)** still applies on top of the Slam grid: it goes to whichever +team holds the K + Q of trump, independent of which side wins the contract. + +**Dix de der** does **not** apply on a Slam-family round — the substitute +already covers the full trick pile. + +### 7.3. Double/ Redouble multiplier + +The multiplier `M` from §7.2 applies whether the contract is made or failed. +Doubling cuts both ways — it punishes overbidding *and* rewards a successful +defense. + +--- + +## 8. End of game + +- 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. + +--- + +## 9. Variants + +These are common community variants. The base ContrAI engine does **not** +implement them; they are listed here for future reference and to clarify +terminology. + +- **Sans atout** (*no trump*): a contract played with no trump suit. Card + values shift (e.g. the Ace ranks highest in every suit) and the contract + value is typically scaled. +- **Tout atout** (*all trump*): every suit acts as trump simultaneously. Card + values become the trump values in every suit; total card points change + accordingly. +- **Corsica deal**: 4-4 dealing pattern instead of 3-2-3. +- **Générale**: a regional synonym (or close cousin) of *Capot général* — + a contract declaring the bidder *alone* will take all 8 tricks. ContrAI + models this as **Solo Slam** in the canonical engine (see §5.2). +- **Annonces**: extra bonuses declared at the start of the first trick for + card combinations held in hand (*tierce*, *cinquante*, *cent*, *carré*…). + Inherited from classical Belote. **Out of scope for ContrAI** — this is + what distinguishes contrée (without annonces) from coinche. + +--- + +## 10. Terminology — FR ↔ EN + +For the bilingual report and for keeping Claude consistent across languages. + +| French | English | Notes | +| ----------------------- | ----------------------------- | ----------------------------------------------------------------------- | +| Atout | Trump | | +| Annonce | Bid / announcement | Context: a bidding announcement (the only meaning used in this project) | +| Belote | Belote | The K+Q-of-trump bonus | +| Capot | Slam | Taking all 8 tricks (the *team* wins them all) | +| Capot général | Solo Slam | Bidder *personally* takes all 8 tricks (cannot follow a Slam) | +| Chute / Chuter | Failure / to fail | Used when the declarer does not make the contract | +| Contrat | Contract | The bid value | +| Contre / Contrer | Double / to double | | +| Coupe / Couper | Trump (n.) / to trump (v.) | *Couper* = play a trump on a non-trump-led trick | +| Défausse / Se défausser | Discard / to discard | | +| Défense | Defense | The non-declaring team | +| Der / Dix de der | Last trick / last-trick bonus | 10 points | +| Donneur | Dealer | | +| Entame / Entamer | Lead / to lead | First card of a trick | +| Fournir | To follow suit | | +| Levée | Trick | Synonym of *pli* | +| Main | Hand | The 8 cards a player holds | +| Manche | Round / hand | One complete deal + bidding + 8 tricks + scoring | +| Maître / Maîtresse | Master | A card guaranteed to win (in its suit, given what has fallen) | +| Monter | To raise / to overtrump | *Monter à l'atout* = play a higher trump | +| Partie | Game | Multiple rounds, ending when a team reaches the target score | +| Passer | To pass | | +| Pli | Trick | Synonym of *levée* | +| Preneur / Prenante | Declarer / declaring team | The team that won the contract | +| Rebelote | Rebelote | Second of the Belote pair | +| Sans atout | No trump | Variant | +| Surcontre / Surcontrer | Redouble / to redouble | | +| Surcouper | To overtrump | | +| Tout atout | All trump | Variant | +| Valet | Jack | Top trump card | + +--- + +## 11. Bidding convention — the 80-to-160 table + +This is the community convention currently encoded in the engine's rule-based +AI. It is a **convention**, not a rule of the game: other tables exist and +players adapt. + +The table tells you, given your hand, what is the highest opening contract you +can reasonably announce. Read each row as: *"If your hand contains at least +the listed pieces, you can open at this level."* + +> The auction itself allows numeric bids up to **180** (see §5.2), but this +> opening-bid convention conservatively caps at 160 — 170 and 180 are +> Belote-only steps and the table here doesn't try to characterise hands +> strong enough to open there. + +| Opening | Required trumps | Min trumps | Aces | Non-bare tens | Min tricks | Belote | +| ------- | --------------- | ---------- | ---- | ------------- | ---------- | ------ | +| 80 | J ⊕ 9 (one of) | 3 | 1 | | 4 | | +| 90 | J ∧ 9 (both) | 3 | 1 | | 4 | | +| 100 | J ⊕ 9 | 3 | 2 | | 5 | | +| 110 | J ∧ 9 | 3 | 2 | | 5 | | +| 120 | J ⊕ 9 | 3 | 3 | | 6 | | +| 130 | J ∧ 9 | 3 | 3 | | 6 | | +| 140 | J ⊕ 9 | 4 | 3 | 1 | 6 | ✅ | +| 150 | J ∧ 9 | 4 | 3 | 1 | 6 | ✅ | +| 160 | J ∧ 9 ∧ A | 5 | 3 | 2 | 7 | ✅ | + +Where: + +- `J ⊕ 9` means *Jack XOR 9 of trump* (at least one, possibly both). +- `J ∧ 9` means *Jack AND 9 of trump*. +- "Min trumps" is the total trump count *including* J and 9. +- "Aces" counts aces *outside* the trump suit (external aces). +- "Non-bare tens" means tens of non-trump suits that are protected (not + singletons). +- "Belote" ✅ means holding K+Q of the proposed trump is required. + +### 11.1. Choosing the suit + +If the hand qualifies at the same level for multiple suits, the AI chooses: + +1. The suit with the strongest expected take (most aces / tens that fit). +2. Tie-break on **Belote** (favor the suit where you hold K+Q of trump). +3. Final tie-break (preference order): **♠ Spades > ♥ Hearts > ♦ Diamonds > ♣ Clubs**. + +### 11.2. Bidding over partner + +If your partner has already bid and you can add value, raise their contract +rather than start a new one in another suit: + +- **+10** for each *external* ace you hold. +- **+10** if you hold the missing complement of trump (the J or 9 that partner + may be missing) in the suit they announced. + +If you cannot raise and cannot open in another suit, **pass**. + +### 11.3. When to contre / surcontre + +*To be expanded as the AI strategy evolves. For now: the rule-based AI +contres when its expected defensive points clearly exceed the contract +threshold; details live alongside the AI implementation.* + +--- + +## 12. Quick reference — round flow + +``` +[Deal] → 8 cards each, 3-2-3 anticlockwise + ↓ +[Bidding] → starting right of dealer + actions: bid (80–180, slam, or solo slam), contre, surcontre, pass + ends: 3 consecutive passes after the last bid + ↓ +[Card play] → 8 tricks, anticlockwise, lead = right of dealer + obey: follow → trump → overtrump (except partner-master) → discard + optional: announce Belote/Rebelote on K/Q of trump + ↓ +[Scoring] → sum cards + dix de der (+ belote if applicable) + apply contract success/failure + multiplier + ↓ +[Check] → if any team ≥ target (1500/2000): end game + else next round, dealer rotates right +``` + +--- + +## 13. Open points + +Things deliberately left out or unresolved here, to revisit: + +- **Annonces** (tierce, cinquante, carré, etc.) are out of scope for ContrAI. + This is the explicit boundary of the project: Contrée *without* annonces. +- The *sans atout* and *tout atout* variants are out of scope for v1 of the + engine. +- The bidding table in §11 is one convention among several. The project's + next AI families (supervised → RL) will likely *not* use this table at all; + it remains here as the baseline rule-based behavior and as a sanity check + against learned policies. +- The LaTeX report (`ContrAI.tex`) currently has the turn-direction wrong: + it says *gauche du donneur* (left of dealer) in several places where the + specs and the standard rules say *droite du donneur* (anticlockwise rotation, + right of dealer plays next). Fix-up pending a separate proposal — this doc + uses the correct version. diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f58d1e0..0000000 --- a/docs/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# ContrAI documentation - -Navigation: - -- [Architecture overview](architecture.md) — packages, dependencies, dataflow -- **Packages** — per-package design notes - - [contrai-core](packages/core.md) - - [contrai-engine](packages/engine.md) - - [contrai-analyzer](packages/analyzer.md) - - [contrai-scraper](packages/scraper/README.md) -- **AI strategies** — design and tradeoffs by AI family - - [Rule-based](ai/rule_based.md) - - [Supervised learning](ai/supervised.md) - - [Reinforcement learning](ai/rl.md) -- [Diagrams](diagrams/README.md) — PlantUML sources and rendering diff --git a/docs/ai-ladder/index.md b/docs/ai-ladder/index.md new file mode 100644 index 0000000..f22ed61 --- /dev/null +++ b/docs/ai-ladder/index.md @@ -0,0 +1,3 @@ +# AI Ladder + + diff --git a/docs/ai/rl.md b/docs/ai-ladder/rl.md similarity index 100% rename from docs/ai/rl.md rename to docs/ai-ladder/rl.md diff --git a/docs/ai/rule_based.md b/docs/ai-ladder/rule_based.md similarity index 100% rename from docs/ai/rule_based.md rename to docs/ai-ladder/rule_based.md diff --git a/docs/ai/supervised.md b/docs/ai-ladder/supervised.md similarity index 100% rename from docs/ai/supervised.md rename to docs/ai-ladder/supervised.md diff --git a/docs/analyzer/api.md b/docs/analyzer/api.md new file mode 100644 index 0000000..24ac2fa --- /dev/null +++ b/docs/analyzer/api.md @@ -0,0 +1,11 @@ +# contrai-analyzer — API reference + +`contrai-analyzer` is a Streamlit application rather than a library: there is no +unified `contrai_analyzer` namespace to document. The internal modules +(`bidding`, `engine`, `models`) live under `packages/contrai-analyzer/src/` and +are subject to change. + +A proper API reference will be generated here once the analyzer exposes a +stable importable package. + + diff --git a/docs/packages/analyzer.md b/docs/analyzer/index.md similarity index 58% rename from docs/packages/analyzer.md rename to docs/analyzer/index.md index ad79731..8c52ac2 100644 --- a/docs/packages/analyzer.md +++ b/docs/analyzer/index.md @@ -11,6 +11,11 @@ Streamlit dashboard for hand-strength analysis (hypergeometric distribution + bi **Strict UI/logic split.** All math and game logic in `src/`; `main.py` is pure UI glue. -Package-specific conventions live in `packages/contrai-analyzer/CLAUDE.md` (gitignored). +## Class structure + +```plantuml format="svg" source="class_analyzer.puml" +``` + +The probability + bidding stack is deliberately decoupled from `contrai-core` — `SuitSlot` (TRUMP / BLUE / GREEN / PURPLE) is a suit-agnostic abstraction for the combinatorial math, not a duplicate of core's `Suit` enum. See [Diagrams](../diagrams/) for the colour convention. > TODO: bidding truth-table reference; probability formulas. diff --git a/docs/architecture.md b/docs/architecture.md index fa0a075..3dbb50a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,26 +1,52 @@ # Architecture -> Overview of how the four ContrAI packages fit together. To be filled in. +Overview of how the four ContrAI packages fit together. ## Workspace layout The repository is a [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/) with four members under `packages/`: -- `contrai-core` — shared domain model (populated in phase 2) -- `contrai-engine` — game engine, AI players, CLI -- `contrai-analyzer` — Streamlit dashboard for hand analysis -- `contrai-scraper` — Playwright spectator-mode scraper +- **`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-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. -## Dependency direction (target) +## Package map + +```plantuml format="svg" source="class_workspace.puml" +``` + +Headline types per package plus cross-package dependency direction. The engine `<>` core's `BasePlayer`; the scraper's dashed `<>` arrow to core marks the planned materialization of observed games into `Card` / `Bid` / `Trick` / … instances; the analyzer has no arrow into core by design. The dashed note attached to the engine flags the planned multiplayer web server, which isn't in this repo yet. See [Diagrams](diagrams/) for the colour convention. + +## Shared types + +`contrai-core`'s public API (everything re-exported from `contrai_core/__init__.py`): + +``` +Suit, Rank, CARD_SUITS, +Card, Deck, Hand, +Team, BasePlayer, +Bid, PassBid, ContractBid, DoubleBid, RedoubleBid, +Auction, +Contract, Trick, +ContraiError, +InvalidPlayerCountError, InvalidCardCountError, +IllegalBidError, IllegalPlayError, PlayRuleViolation, +TrickStateError, InvalidContractError +``` + +Consumers import these directly (`from contrai_core import Card, Suit, …`); the engine no longer re-exports them. + +## Dependency direction ``` contrai-core ↑ - ├── contrai-engine - ├── contrai-analyzer - └── contrai-scraper + ├── contrai-engine (direct dependency) + ├── contrai-scraper (planned — will materialize observed games into core types) + └── contrai-analyzer (independent by design — does NOT depend on core) ``` -`contrai-engine`, `contrai-analyzer`, and `contrai-scraper` all depend on `contrai-core` for shared types (`Card`, `Deck`, `Hand`, `Bid`, `Contract`, …). They do not depend on each other. +`contrai-engine`, `contrai-analyzer`, and `contrai-scraper` do not depend on each other. -> TODO: dataflow diagrams (live in [diagrams/](diagrams/)). +> TODO: dataflow diagrams (live in [`diagrams/`](diagrams/index.md) — PlantUML `.puml` for sequence/class, Mermaid `.mmd` for everything else). diff --git a/docs/assets/flavicon.png b/docs/assets/flavicon.png new file mode 100644 index 0000000..ccdc51d Binary files /dev/null and b/docs/assets/flavicon.png differ diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 0000000..3b43b75 --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/core/api.md b/docs/core/api.md new file mode 100644 index 0000000..fc4256a --- /dev/null +++ b/docs/core/api.md @@ -0,0 +1,3 @@ +# contrai-core — API reference + +::: contrai_core diff --git a/docs/core/index.md b/docs/core/index.md new file mode 100644 index 0000000..09c2b20 --- /dev/null +++ b/docs/core/index.md @@ -0,0 +1,52 @@ +# contrai-core + +Shared domain model for the ContrAI workspace — pure data + invariants, no orchestration. + +## Module map + +Source lives at `packages/contrai-core/src/contrai_core/`: + +| Module | Contents | +| --------------- | --------------------------------------------------------------------------------------- | +| `types.py` | `Suit`, `Rank` enums and the `CARD_SUITS` tuple | +| `card.py` | `Card` | +| `deck.py` | `Deck` | +| `hand.py` | `Hand` (list-compatible API including `copy()` + query helpers) | +| `team.py` | `Team` | +| `player.py` | `BasePlayer` (engine `Player` extends it) | +| `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` | +| `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. + +## Class structure + +```plantuml format="svg" source="class_core.puml" +``` + +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`. + +## Consumers + +- **`contrai-engine`** — direct dependency. Imports core types with `from contrai_core import …` and adds `Player` / `HumanPlayer` / `AiPlayer` / `Game` / `Round` on top. +- **`contrai-scraper`** — planned consumer. Observed games will be materialized into `Card` / `Bid` / `Trick` / … instances before being persisted to SQLite. +- **`contrai-analyzer`** — **does not** depend on core. The analyzer's `SuitSlot` (TRUMP/BLUE/GREEN/PURPLE) is a suit-agnostic abstraction for probability math, intentionally separate from `Suit`. See the [analyzer overview](../analyzer/index.md). + +## Conventions + +- Type hints everywhere, including private helpers. +- Google-style docstrings on every public class/method/function. +- Didactic comments are welcome — this is a learning project. +- Every Model-layer addition ships with `pytest` tests under `packages/contrai-core/tests/`. + +## 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_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). diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md deleted file mode 100644 index 075782c..0000000 --- a/docs/diagrams/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Diagrams - -Architecture, sequence, class, and state diagrams illustrating ContrAI components. - -**Format:** [PlantUML](https://plantuml.com/) source, versioned alongside markdown. - -## Rendering - -- **VS Code:** install the *PlantUML* extension (`jebbs.plantuml`) -- **Web:** paste source into [planttext.com](https://www.planttext.com/) or [plantuml.com/uml](https://www.plantuml.com/plantuml/uml/) -- **CLI:** `plantuml -tpng diagram.puml` - -## Conventions - -- One `.puml` per diagram; descriptive filename (`engine_class.puml`, `scraper_sequence.puml`, …) -- Reference spec IDs (e.g. `SF-09`) where applicable -- Keep diagrams minimal and traceable to spec or package documentation - -> TODO: initial diagrams — architecture overview, engine MVC class, scraper sequence. diff --git a/docs/diagrams/class_analyzer.png b/docs/diagrams/class_analyzer.png new file mode 100644 index 0000000..78bf04a Binary files /dev/null and b/docs/diagrams/class_analyzer.png differ diff --git a/docs/diagrams/class_analyzer.puml b/docs/diagrams/class_analyzer.puml new file mode 100644 index 0000000..8fdfb13 --- /dev/null +++ b/docs/diagrams/class_analyzer.puml @@ -0,0 +1,154 @@ +@startuml class_analyzer +title contrai-analyzer — class structure (current state) + +' --- Palette: analyzer green --- +skinparam shadowing false +skinparam class { + BackgroundColor #E8F5E9 + BorderColor #3F8C3D + HeaderBackgroundColor #7AC178 + FontColor #1B5E20 + AttributeFontColor #1B5E20 + StereotypeFontColor #2E7D32 +} +skinparam enum { + BackgroundColor #E8F5E9 + BorderColor #3F8C3D + HeaderBackgroundColor #7AC178 + FontColor #1B5E20 + StereotypeFontColor #2E7D32 +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 + FontColor #5F4B00 +} +skinparam ArrowColor #3F8C3D + +' ========================================================= +' Domain primitives — models/ +' ========================================================= +package "src.models" as models { + + enum SuitSlot <> { + TRUMP = "trump" + BLUE = "blue" + GREEN = "green" + PURPLE = "purple" + -- + + emoji : str <> + + label : str <> + + color : str <> + } + + enum Rank <> { + SEVEN, EIGHT, NINE, TEN + JACK, QUEEN, KING, ACE + -- + + point_value(is_trump: bool) : int + } + + class Card <> { + + rank : Rank + + suit : SuitSlot + -- + + id : str <> + + point_value : int <> + + __str__() : str + } + + class Hand { + + cards : list + -- + + has_card(rank: Rank, suit: SuitSlot) : bool + + count_suit(suit: SuitSlot) : int + + count_rank(rank: Rank) : int + + get_suit_cards(suit: SuitSlot) : list + + my_points() : int + } + + class Deck { + + cards : list + -- + + get_all_cards() : list + } +} + +' ========================================================= +' Probability core — engine/ +' ========================================================= +package "src.engine" as engine { + + class ProbabilityEngine { + + hand : Hand + + total_unknown_cards : int = 24 + + cards_per_player : int = 8 + -- + .. hypergeometric core .. + + hypergeometric_prob(N, K, n, k) : float + .. partner — generic .. + + prob_partner_has_specific_card() : float + + prob_partner_has_at_least_one_of(num_target_cards: int) : float + .. partner — trump support .. + + prob_partner_has_at_least_n_trumps(n: int) : float + + prob_partner_has_trump_ace() : float + .. partner — non-trump aces .. + + prob_partner_has_ace(slot: SuitSlot) : float + .. opponent threats .. + + prob_opponent_has_ace(slot: SuitSlot) : float + + prob_opponent_has_both_j_and_9() : float + + prob_opponent_can_bid_slot(slot: SuitSlot) : float + + prob_opponent_threat_third_ace(slot: SuitSlot) : float + .. distribution .. + + expected_points_by_slot(player: "partner"|"opponents") : dict + } +} + +' ========================================================= +' Bidding truth-table — bidding/ +' ========================================================= +package "src.bidding" as bidding { + + class BidSuggestion <> { + + value : int + + suit : SuitSlot + + reasoning : str + } + + class BiddingEvaluator { + + hand : Hand + - _engine : ProbabilityEngine + -- + + evaluate() : list + + opponent_bidding_risk() : tuple + - _evaluate_suit(trump_suit: SuitSlot) : BidSuggestion? + } +} + +' ========================================================= +' Relationships +' ========================================================= +Card --> SuitSlot : suit +Card --> Rank : rank +Hand "1" *-- "8" Card : cards +Deck "1" *-- "32" Card : cards +ProbabilityEngine "1" o-- "1" Hand : hand +BiddingEvaluator "1" o-- "1" Hand : hand +BiddingEvaluator "1" *-- "1" ProbabilityEngine : _engine +BiddingEvaluator ..> BidSuggestion : <> + +' ========================================================= +' Banner note — deliberate independence +' ========================================================= +note as N_independence + **Deliberately independent of `contrai-core`.** + Zero imports from `contrai_core` across all files. + `SuitSlot` (TRUMP / BLUE / GREEN / PURPLE) is a + **suit-agnostic** probability abstraction — what matters + for combinatorial math is whether a slot is trump, + not which French suit it represents. +end note + +N_independence .. SuitSlot + +@enduml diff --git a/docs/diagrams/class_core.png b/docs/diagrams/class_core.png new file mode 100644 index 0000000..2d7aa6c Binary files /dev/null and b/docs/diagrams/class_core.png differ diff --git a/docs/diagrams/class_core.puml b/docs/diagrams/class_core.puml new file mode 100644 index 0000000..863198b --- /dev/null +++ b/docs/diagrams/class_core.puml @@ -0,0 +1,408 @@ +@startuml class_core +title contrai-core — domain model (current state) + +' --- Palette: core blue --- +skinparam shadowing false +skinparam class { + BackgroundColor #E1F0FF + BorderColor #3D6FA5 + HeaderBackgroundColor #7AAEE3 + FontColor #0D3B66 + AttributeFontColor #0D3B66 + StereotypeFontColor #1A5490 +} +skinparam enum { + BackgroundColor #E1F0FF + BorderColor #3D6FA5 + HeaderBackgroundColor #7AAEE3 + FontColor #0D3B66 + StereotypeFontColor #1A5490 +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 + FontColor #5F4B00 +} +skinparam ArrowColor #3D6FA5 + +' ========================================================= +' Enums + module-level constants (types.py) +' ========================================================= +package "types.py" as p_types { + enum Suit <> { + SPADES = "Spades" + HEARTS = "Hearts" + DIAMONDS = "Diamonds" + CLUBS = "Clubs" + NO_TRUMP = "NoTrump" + ALL_TRUMP = "AllTrump" + } + + enum Rank <> { + SEVEN, EIGHT, NINE, TEN + JACK, QUEEN, KING, ACE + } + + note bottom of Suit + Module constant: + **CARD_SUITS** : tuple[Suit, Suit, Suit, Suit] + = (SPADES, HEARTS, DIAMONDS, CLUBS) + —— the 4 real suits; NO_TRUMP and + ALL_TRUMP are contract-only suits + (sans atout / tout atout variants — + see contree-domain.md §9, §13). + ALL_TRUMP is currently inert: it is + reserved in the enum but no engine + logic produces or consumes it. + end note +} + +' ========================================================= +' Card & deck (card.py, deck.py) +' ========================================================= +package "card.py / deck.py" as p_card { + class Card <> { + + suit : Suit + + rank : Rank + -- + + get_points(trump_suit: Suit? = None) : int + + get_order(trump_suit: Suit? = None) : int + } + + note bottom of Card + Immutable value object: equality and + hashing are by ``(suit, rank)``, so + distinct instances of the same physical + card are equal and hashable. No ``__lt__`` — + strength is parametric via + ``get_order(trump_suit)``. + end note + + class Deck { + + cards : list + -- + + shuffle() : None + + cut() : None + + deal(players: list) : None + + is_empty() : bool + + add_cards(cards: list) : None + } +} + +' ========================================================= +' Hand (hand.py) +' ========================================================= +package "hand.py" as p_hand { + class Hand { + + cards : list + -- + .. list-compatible API .. + + append(card: Card) : None + + extend(cards: Iterable) : None + + remove(card: Card) : None + + clear() : None + + __contains__(card) : bool + + __iter__() : Iterator + + __len__() : int + + __getitem__(idx) : Card | list + .. query helpers .. + + count_suit(suit: Suit) : int + + count_rank(rank: Rank) : int + + has_card(suit: Suit, rank: Rank) : bool + + cards_of_suit(suit: Suit) : list + + is_complete() : bool + } +} + +' ========================================================= +' Team & BasePlayer (team.py, player.py) +' ========================================================= +package "team.py / player.py" as p_team { + class Team { + + name : str + + players : list + + total_score : int + -- + + __init__(name: str, players: list) + + add_points(points: int) : None + + get_partner(player: BasePlayer) : BasePlayer? + + contains_player(player: BasePlayer) : bool + } + + class BasePlayer { + + name : str + + position : str + + hand : Hand + + team : Team? + -- + + __init__(name: str, position: str) + } +} + +' ========================================================= +' Bid hierarchy (bid.py) — frozen dataclass sum type +' ========================================================= +package "bid.py" as p_bid { + class Bid <> { + + player : BasePlayer ' field(compare=False) + } + + class PassBid <> { + + __str__() : str ' "Pass" + } + + enum SlamLevel { + SLAM ' (250, "Slam") + SOLO_SLAM ' (500, "Solo Slam") + -- + + base_value : int ' single source of truth for 250 / 500 + + label : str + + __str__() : str ' the label + } + + class ContractBid <> { + + {static} VALID_VALUES = [80..180, SlamLevel.SLAM, SlamLevel.SOLO_SLAM] + + {static} VALID_SUITS = list(Suit) + + value : int | SlamLevel + + suit : Suit + -- + + __post_init__() : None ' validates value + suit + + get_numeric_value() : int ' SlamLevel → base_value + + __gt__(other) : bool ' strict numeric ordering + + __str__() : str + } + + class DoubleBid <> { + + __str__() : str ' "Double" + } + + class RedoubleBid <> { + + __str__() : str ' "Redouble" + } + + note bottom of Bid + Pure value carriers — no auction-state + behaviour. ``player`` is excluded from + ``__eq__`` and ``__hash__`` (matching + bid equality on *what was announced*, + not who announced it). All four + variants are ``@dataclass(frozen=True, + slots=True)``. + end note +} + +' ========================================================= +' Auction (auction.py) — bidding-state rule oracle +' ========================================================= +package "auction.py" as p_auction { + class Auction <> { + + bids : tuple = () + -- + + {static} empty() : Auction + + is_legal(bid: Bid) : bool + + legal_actions(player: BasePlayer) : tuple + + apply(bid: Bid) : Auction ' raises IllegalBidError + + is_terminal() : bool + + contract() : Contract? + -- + + last_contract_bid : ContractBid? <> + + has_double : bool <> + + has_redouble : bool <> + + double_player : BasePlayer? <> + + redouble_player : BasePlayer? <> + + consecutive_passes : int <> + + partner_bid(player) : Bid? + } + + note bottom of Auction + Immutable bidding-phase state. + Owns the chronological bid history + and the contrée rules (see + ``contree-domain.md §5.2`` and §5.3). + ``is_legal`` / ``legal_actions`` + replace ``Bid.is_valid_after`` and + ``BidValidator``; ``apply`` raises + :class:`IllegalBidError` rather than + silently downgrading an illegal bid + to a Pass. + The shape (``legal_actions`` + + ``apply``) mirrors MCTS / RL game-state + interfaces, so the auction phase is + ready to drop into a future + imperfect-information state object. + end note +} + +' ========================================================= +' Contract & Trick (contract.py, trick.py) +' ========================================================= +package "contract.py / trick.py" as p_contract { + class Contract { + + contract_bid : ContractBid + + player : BasePlayer + + team : Team + + value : int | SlamLevel + + suit : Suit + + double : bool + + redouble : bool + + double_player : BasePlayer? ' who called Coinche + + redouble_player : BasePlayer? ' who called Surcoinche + -- + + __init__(contract_bid, double=False, redouble=False, double_player=None, redouble_player=None) + + get_multiplier() : int ' 1, 2, or 4 + + is_slam() : bool + + is_solo_slam() : bool + + is_slam_family() : bool + + get_base_points() : int ' Slam → 250, SoloSlam → 500 + + get_slam_card_substitute() : int ' Slam → 250, SoloSlam → 500, else 0 + } + + class Trick { + + plays : list<(BasePlayer, Card)> + -- + + __init__() + + add_play(player: BasePlayer, card: Card) : None + + get_cards() : list + + get_led_suit() : Suit? + + get_plays() : list<(BasePlayer, Card)> + + get_current_winner(trump_suit: Suit?) : BasePlayer? + + is_complete() : bool + + __len__() : int + } + + note bottom of Trick + A dumb container of plays — it does **not** + store trump. `get_current_winner(trump_suit)` + takes trump as a **required** argument + (like `Card.get_order`/`get_points`) and + works on **partial** tricks. The engine + builds `Trick()` bare and passes the + contract's suit for trick-winner + determination, the partner-master legality + check, and the view's live winner highlight. + end note +} + +' ========================================================= +' Exceptions (exceptions.py) +' ========================================================= +package "exceptions.py" as p_exc { + class "Exception" as Exception <> #DDDDDD + class "ValueError" as ValueError <> #DDDDDD + + class ContraiError <> + + enum PlayRuleViolation <> { + MUST_FOLLOW_SUIT + MUST_TRUMP + MUST_OVERTRUMP + } + + class InvalidPlayerCountError { + + expected_count : int + + actual_count : int + + context : str + } + + class InvalidCardCountError { + + expected_count : int + + actual_count : int + + context : str + } + + class IllegalBidError { + + bid : Bid + + bids : tuple + + context : str + } + + class IllegalPlayError { + + card : Card + + reason : PlayRuleViolation + + legal_cards : tuple + + context : str + } + + class TrickStateError { + + context : str + } + + class InvalidContractError { + + context : str + } + + ' Every domain error dual-inherits (ContraiError, ValueError); + ' ValueError stays in the MRO so legacy except-ValueError keeps working. + ContraiError --|> Exception + InvalidPlayerCountError --|> ContraiError + InvalidPlayerCountError --|> ValueError + InvalidCardCountError --|> ContraiError + InvalidCardCountError --|> ValueError + IllegalBidError --|> ContraiError + IllegalBidError --|> ValueError + IllegalPlayError --|> ContraiError + IllegalPlayError --|> ValueError + TrickStateError --|> ContraiError + TrickStateError --|> ValueError + InvalidContractError --|> ContraiError + InvalidContractError --|> ValueError + IllegalPlayError --> PlayRuleViolation : reason +} + +' ========================================================= +' Relationships +' ========================================================= + +' Inheritance — Bid hierarchy +Bid <|-- PassBid +Bid <|-- ContractBid +Bid <|-- DoubleBid +Bid <|-- RedoubleBid + +' Card composes enums (by-reference) +Card --> Suit +Card --> Rank + +' Hand owns Cards +Hand "1" *-- "0..8" Card : cards + +' Deck owns the full 32-card set +Deck "1" *-- "32" Card : cards + +' Player owns its Hand, references Team +BasePlayer "1" *-- "1" Hand : hand +BasePlayer "1" o-- "0..1" Team : team + +' Team aggregates exactly 2 players +Team "1" o-- "2" BasePlayer : players + +' Bid references BasePlayer +Bid "1" o-- "1" BasePlayer : player +ContractBid --> Suit : suit +ContractBid ..> SlamLevel : value (all-tricks bids) + +' Auction composes a tuple of Bids and materialises a Contract +Auction "1" *-- "0..N" Bid : bids +Auction ..> Contract : <> +Auction ..> IllegalBidError : <> + +' Contract references ContractBid + BasePlayer + Team +Contract "1" o-- "1" ContractBid : contract_bid +Contract "1" o-- "1" BasePlayer : player +Contract "1" o-- "1" Team : team +Contract --> Suit : suit + +' Trick composes (player, card) plays; trump arrives via get_current_winner +Trick "1" *-- "0..4" Card : (via plays) +Trick "1" o-- "0..4" BasePlayer : (via plays) +Trick ..> Suit : get_current_winner(trump_suit) + +' Operations raising exceptions +Team ..> InvalidPlayerCountError : <> +Deck ..> InvalidPlayerCountError : <> +Deck ..> InvalidCardCountError : <> +ContractBid ..> InvalidContractError : <> +Contract ..> InvalidContractError : <> +Trick ..> TrickStateError : <> + +@enduml diff --git a/docs/diagrams/class_engine.png b/docs/diagrams/class_engine.png new file mode 100644 index 0000000..594b154 Binary files /dev/null and b/docs/diagrams/class_engine.png differ diff --git a/docs/diagrams/class_engine.puml b/docs/diagrams/class_engine.puml new file mode 100644 index 0000000..d7a3743 --- /dev/null +++ b/docs/diagrams/class_engine.puml @@ -0,0 +1,389 @@ +@startuml class_engine +title contrai-engine — model + MVC (current state, honest portrayal) + +' --- Palette: engine orange (model), grey (stubs), core blue (boundary) --- +skinparam shadowing false +skinparam class { + BackgroundColor #FFEFD9 + BorderColor #B26A28 + HeaderBackgroundColor #E89A4F + FontColor #5C3A14 + AttributeFontColor #5C3A14 + StereotypeFontColor #8A4F1C +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 + FontColor #5F4B00 +} +skinparam ArrowColor #B26A28 + +' ========================================================= +' Boundary element from contrai-core (blue palette) +' ========================================================= +class BasePlayer <> #E1F0FF { + + name : str + + position : str + + hand : Hand + + team : Team? +} + +' ========================================================= +' model/player.py — Player hierarchy +' ========================================================= +package "model/player.py" as p_player { + abstract class Player <> { + + is_human : bool <> + -- + + {abstract} choose_bid(auction: Auction) : Bid? + + {abstract} choose_card(trick, contract, playable_cards) : Card + } + + class HumanPlayer { + + choose_bid(auction) : None <> + + choose_card(trick, contract, playable_cards) : None <> + } + + class AiPlayer { + + {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 + } + + class "wire_to_bid / bid_to_wire" as p_bridge <> #FFEFD9 { + wire_to_bid(player, wire) : Bid + bid_to_wire(bid : Bid) : str | tuple + } + + 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) + 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). + end note + + note bottom of HumanPlayer + ``choose_bid`` / ``choose_card`` return + **None** by design. Human input is + serviced by **RichView** through the + ``view.request_*_action`` hooks Round + 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 +' ========================================================= +package "model/game.py" as p_game { + class Game { + + teams : list + + players : list + + deck : Deck + + dealer : Player? + + players_order : list + + current_contract : Contract? + + current_round : Round? + + round_number : int + + scores : dict + -- + + __init__(players: list) + + start_new_round() : None + + manage_round(view=None) : dict + + check_game_over(target_score=1500) : dict + + next_dealer() : None + + set_players_order() : None + } +} + +package "model/round.py" as p_round { + class Round { + + players_order : list + + dealer : Player + + deck : Deck + + round_number : int + + contract : Contract? + + tricks : list + + current_trick : Trick? + + last_trick_winner : Player? + + team_tricks : dict> + + round_scores : dict + + 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_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? + } + + note right of Round + ``manage_bidding`` drives an + :class:`contrai_core.Auction` through + the cyclic ``players_order``. Each + turn: query ``legal_actions(player)``; + if the only legal action is Pass + (partner just doubled / redoubled), + auto-apply it without prompting; + otherwise gather a Bid from the + player and the view. ``auction.apply`` + raises :class:`IllegalBidError` on an + illegal bid — no silent force-Pass. + ---- + Trick-winner determination delegates to + ``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. + ---- + During play Round fires (each ``hasattr``-guarded): + • ``view.on_bid_made(player, bid, history)`` + • ``view.on_contract_established(self)`` + (once, immediately after the final + Contract is built) + • ``view.on_card_played(player, card, trick)`` + • ``view.on_belote_announced(player, kind, self)`` + • ``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 + 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? + end note +} + +' ========================================================= +' view/ — live Rich terminal UI +' ========================================================= +package "view/rich_view.py" as p_view { + class RoundSummary <> { + + round_number : int + + contract : Contract? + + contract_team_name : str? + + contract_made : bool + + ns_pts : int + + ew_pts : int + + running_ns : int + + running_ew : int + } + + class RichView { + + {static} LOG_MAX : int = 5 + + console : rich.Console + + target_score : int + + history : list + + last_completed_trick : (Trick, Player)? + + game : Game? + + event_log : list + -- + + attach(game, target_score) : None + + reset_for_rematch() : None + -- + ' Engine hooks + + 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_bid_made(player, bid, history) : None + + on_contract_established(round_) : None + + on_card_played(player, card, trick) : None + + on_belote_announced(player, kind, round_) : None + + on_trick_complete(trick, winner, round_) : None + + on_round_complete(round_, running_scores) : None + -- + ' CLI flow screens + + show_landing(selected_target=1500) : int + + show_round_recap(round_, running_scores, *, is_final=False) : None + + show_end_game(status) : str ('n' | 'r' | 'q') + } + + 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`, + `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`. + ---- + 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. + ---- + 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`. + end note +} + +' ========================================================= +' cli.py — `contrai` console-script entry point +' ========================================================= +package "cli.py" as p_cli { + class "main()" as Cli <> #FFEFD9 { + + main() : None + -- + - _build_game() : Game + -- + Drives landing → game loop → + end-game; routes `n`/`r`/`q`. + } + + note right of Cli + Hardcoded seating: South = + `HumanPlayer`, N/E/W = `AiPlayer` + (medium). TODO marker for a + future seat picker on the landing + screen. + end note +} + +' ========================================================= +' Relationships +' ========================================================= + +' Player hierarchy +BasePlayer <|-- Player +Player <|-- HumanPlayer +Player <|-- AiPlayer + +' Game composes / aggregates engine + core types +Game "1" *-- "2" "Team" : teams +Game "1" *-- "4" Player : players +Game "1" *-- "1" "Deck" : deck +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 "1" *-- "0..8" "Trick" : tricks +Round "1" o-- "0..1" "Contract" : contract +Round "1" o-- "1" "Deck" : deck +Round "1" o-- "4" Player : players_order + +' MVC wiring +GameController ..> Game : <> +Round ..> RichView : <> +Cli ..> Game : <> +Cli ..> RichView : <> +RichView "1" *-- "0..N" RoundSummary : history + +@enduml diff --git a/docs/diagrams/class_workspace.png b/docs/diagrams/class_workspace.png new file mode 100644 index 0000000..17c77eb Binary files /dev/null and b/docs/diagrams/class_workspace.png differ diff --git a/docs/diagrams/class_workspace.puml b/docs/diagrams/class_workspace.puml new file mode 100644 index 0000000..07e010e --- /dev/null +++ b/docs/diagrams/class_workspace.puml @@ -0,0 +1,128 @@ +@startuml class_workspace +title ContrAI workspace — package overview (headline types + dependency direction) + +' --- All four palettes appear at once; styling is set per-class. --- +skinparam shadowing false +skinparam class { + FontColor #1A1A1A + AttributeFontColor #1A1A1A + StereotypeFontColor #1A1A1A +} +skinparam packageStyle rectangle +skinparam packageFontColor #1A1A1A +skinparam packageBorderColor #555555 +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 + FontColor #5F4B00 +} +skinparam ArrowColor #555555 +skinparam ArrowFontColor #1A1A1A + +' ========================================================= +' contrai-core — blue +' ========================================================= +package "contrai-core\n(shared domain model)" as P_core { + class "Card" as C_card #E1F0FF + class "Hand" as C_hand #E1F0FF + class "Deck" as C_deck #E1F0FF + class "Trick" as C_trick #E1F0FF + class "Bid" as C_bid #E1F0FF + class "Contract" as C_contract #E1F0FF + class "BasePlayer" as C_player #E1F0FF + class "Team" as C_team #E1F0FF + + C_hand --> C_card + C_deck --> C_card + C_player --> C_hand + C_player --> C_team + C_trick --> C_card + C_contract --> C_bid + C_bid --> C_player +} + +' ========================================================= +' contrai-engine — orange +' ========================================================= +package "contrai-engine\n(MVC + game loop)" as P_engine { + class "Player" as E_player #FFEFD9 + class "HumanPlayer" as E_human #FFEFD9 + class "AiPlayer" as E_ai #FFEFD9 + 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 + + E_player <|-- E_human + 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 : <> +} + +' ========================================================= +' contrai-analyzer — green +' ========================================================= +package "contrai-analyzer\n(Streamlit hand strength)" as P_analyzer { + class "SuitSlot" as A_slot #E8F5E9 + class "Hand" as A_hand #E8F5E9 + class "ProbabilityEngine" as A_engine #E8F5E9 + class "BiddingEvaluator" as A_bidding #E8F5E9 + + A_engine --> A_hand + A_bidding --> A_engine + A_hand --> A_slot +} + +' ========================================================= +' contrai-scraper — purple +' ========================================================= +package "contrai-scraper\n(Playwright spectator)" as P_scraper { + class "main.py" as S_main <> #EDE7F6 { + + main() + + observe_game(page) + + get_players(page) + + get_current_round(page) + + wait_for_new_round(page, current) + + is_game_scrapeable(players) + } +} + +' ========================================================= +' Cross-package dependencies +' ========================================================= + +' Engine extends + uses core +E_player --|> C_player : <> +P_engine ..> P_core : <>\n(Card, Suit, Rank,\n Bid, Contract, Trick,\n Team, Deck) + +' Analyzer deliberately does NOT depend on core +note as N_analyzer_indep + **Deliberately independent.** + `contrai-analyzer` does not import anything + from `contrai_core`. Its `SuitSlot` is a + suit-agnostic abstraction for combinatorial + math, not a duplicate of core's `Suit`. +end note +P_analyzer .. N_analyzer_indep + +' Scraper: planned consumer of core (future) +P_scraper ..> P_core : <>\nmaterialize observed\ngames into core types + +' Web server: planned future component (not in repo yet) +note as N_future + **Planned (not in repo today)** + A multiplayer web server (likely FastAPI + + WebSockets) will expose trained AI models + over the network. It will consume + `contrai-engine` and the trained models + from the AI ladder. +end note +P_engine .. N_future + +@enduml diff --git a/docs/diagrams/index.md b/docs/diagrams/index.md new file mode 100644 index 0000000..19dd4eb --- /dev/null +++ b/docs/diagrams/index.md @@ -0,0 +1,67 @@ +# Diagrams + +Architecture, sequence, class, state, and flow diagrams illustrating ContrAI components. + +Per-package diagrams live next to the package they describe; this page is the conventions hub and catalogue. + +## Two-tool policy + +- **[PlantUML](https://plantuml.com/)** — *only* for **sequence** and **class** diagrams. Sources are `.puml` files. +- **[Mermaid](https://mermaid.js.org/)** — for **everything else** (component, state, flowchart, ER, Gantt, mindmap, deployment, …). Sources are `.mmd` files. + +## Colour convention + +Colour encodes **which package owns the element**, reused consistently across every diagram. Light backgrounds keep things printable/report-friendly. + +| Package | Header fill | Body fill | Border | +|----------------------|-------------|-------------|-----------| +| `contrai-core` | `#7AAEE3` | `#E1F0FF` | `#3D6FA5` | +| `contrai-engine` | `#E89A4F` | `#FFEFD9` | `#B26A28` | +| `contrai-analyzer` | `#7AC178` | `#E8F5E9` | `#3F8C3D` | +| `contrai-scraper` | `#9B7FCC` | `#EDE7F6` | `#5E4495` | +| 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. + +## Rendering + +MkDocs renders both PlantUML and Mermaid **inline at site-build time**: + +- PlantUML via the [`plantuml-markdown`](https://pypi.org/project/plantuml-markdown/) extension (`format: svg`, `base_dir: docs/diagrams`). Requires the `plantuml` CLI on PATH (Java jar). +- Mermaid via the [`mkdocs-mermaid2-plugin`](https://pypi.org/project/mkdocs-mermaid2-plugin/) (no CLI dependency). + +So `mkdocs serve` / `mkdocs build` is enough — no pre-rendering step. + +A **rendered PNG is committed alongside each `.puml`** in `docs/diagrams/` so the diagrams are previewable offline (in a file browser, an IDE, slides, the LaTeX report) without spinning up `mkdocs serve`. The MkDocs site itself does not read those PNGs — it re-renders from the `.puml` source — so the canonical source of truth is still the `.puml` file. Re-render the PNG whenever the source changes and commit both together in the same atomic commit: + +```bash +plantuml -tpng docs/diagrams/file.puml # → docs/diagrams/file.png +mmdc -i docs/diagrams/file.mmd -o docs/diagrams/file.png +``` + +VS Code: install the *PlantUML* (`jebbs.plantuml`) and *Markdown Preview Mermaid Support* extensions for in-editor previews. + +## Conventions + +- **Source location:** all `.puml` / `.mmd` sources live in `docs/diagrams/`, even when the rendered diagram is embedded on a per-package page. The `plantuml_markdown` extension's `base_dir` lets per-package pages embed by bare filename (e.g. `source="class_analyzer.puml"`). +- **Embed location:** per-package diagrams are embedded on that package's overview page (`docs/{core,engine,analyzer,scraper}/index.md`); workspace-spanning diagrams go on `docs/architecture.md`. This catalogue page links to each. +- **Naming:** kind-prefixed filenames — `class_*.puml`, `seq_*.puml`, `comp_*.mmd`, `state_*.mmd`, … +- **Honest portrayal:** mark unimplemented elements with `<>` / `<>` stereotypes plus the grey/dashed styling above. The diagram should describe what the code *is*, not what we wish it were. +- **Traceability:** reference spec IDs (e.g. `SF-09`) where applicable. + +## Catalogue + +Each row links to the canonical `.puml` source, the rendered `.png` preview, and the topical page where the diagram is embedded. + +| Diagram | Kind | Scope | Source | PNG preview | Embedded on | +|------------------------|----------|----------------------|---------------------------------------|--------------------------------------|------------------------------------------------------------| +| `class_core.puml` | Class | contrai-core | [source](class_core.puml) | [png](class_core.png) | [Core overview](../core/#class-structure) | +| `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_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) | +| `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 new file mode 100644 index 0000000..753021b Binary files /dev/null and b/docs/diagrams/seq_bidding.png differ diff --git a/docs/diagrams/seq_bidding.puml b/docs/diagrams/seq_bidding.puml new file mode 100644 index 0000000..c49695d --- /dev/null +++ b/docs/diagrams/seq_bidding.puml @@ -0,0 +1,191 @@ +@startuml seq_bidding +title contrai-engine — bidding cycle zoom (Round.manage_bidding) + +' --- Palette: engine orange, grey for stub paths, 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 +} + +participant "Round" as R <> +participant "Player" as P <> +participant "Auction" as A <> #E1F0FF +participant "Contract" as CT <> #E1F0FF +participant "RichView" as V <> + +[-> R : manage_bidding(view) +activate R + +R -> A : Auction.empty() +A --> R : auction +R -> R : player_iter = itertools.cycle(\n players_order\n) + +loop **while not auction.is_terminal()** + + R -> R : player = next(player_iter) + + R -> A : legal_actions(player) + A --> R : tuple + note right of A + Enumerates every legal Bid for + ``player``: always a PassBid first, + then every ContractBid that beats + the current contract without + crossing a Double/Redouble freeze, + plus the DoubleBid / RedoubleBid + candidates if their team-and-timing + rules pass. + end note + + alt len(legal) == 1 (only Pass is legal) + R -> R : bid = legal[0] ' PassBid + note right of R + Partner has just doubled or + redoubled. Pass is the only + meaningful action — skip both + the AI prompt and the human + view prompt entirely. + + Intervening passes do NOT + collapse the action set: the + Coinche / Surcoinche window + stays open until the auction + terminates on three consecutive + passes (contree-domain.md + §5.3 / §5.4). + end note + else normal turn + R -> R : bid = _gather_bid(\n player, auction, view\n) + + 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``. + + HumanPlayer.choose_bid + returns None by design. + end note + P --> R : Bid | None + end + + 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, + 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. + end note + V -->> R : Bid + end + end + + R -> A : auction.apply(bid) + A -> A : is_legal(bid) ? + note right of A + ``apply`` raises + :class:`IllegalBidError` if the + bid is illegal — *no* silent + downgrade to a Pass any more. A + raised IllegalBidError is an + engine-wiring bug (e.g. an AI + strategy returning a bid that + doesn't match the auction state). + end note + A --> R : auction' (new instance with bid appended) + + R -->> V : view.on_bid_made(player, bid,\n list(auction.bids)) + note right of V + RichView logs the bid in the + rolling event log. For AI bidders + it re-renders the bidding screen + with the new action visible and + sleeps ``CONTRAI_AI_BID_DELAY`` + (1.4 s default). Humans skip the + pause — ``request_bid_action`` + already drove their frame. + end note + +end + +== Build the Contract == + +R -> A : auction.contract() +A --> R : Contract | None +note right of A + ``Auction.contract()`` walks the + bids backwards for the last + ``ContractBid`` and folds in + ``has_double`` / ``has_redouble`` + to build the final Contract. +end note + +alt no ContractBid (all-pass wipe) + R -> R : self.contract = None +else + R -> R : self.contract = contract + R -> R : _detect_belote_holder()\n(scan hands for K + Q of trump;\n NO_TRUMP contracts → no holder) + R -->> V : view.on_contract_established(self) + note right of V + RichView appends a + "Contract set: 100 ♥ by N-S ×2" + bookmark to the event log so + the start of play is visible. + end note +end + +[<-- R : return self.contract (Contract | None) +deactivate R + +note over R, V + **Wire format vs Bid objects.** 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 + ``'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. +end note + +@enduml diff --git a/docs/diagrams/seq_round.png b/docs/diagrams/seq_round.png new file mode 100644 index 0000000..289571f Binary files /dev/null and b/docs/diagrams/seq_round.png differ diff --git a/docs/diagrams/seq_round.puml b/docs/diagrams/seq_round.puml new file mode 100644 index 0000000..a93dccc --- /dev/null +++ b/docs/diagrams/seq_round.puml @@ -0,0 +1,144 @@ +@startuml seq_round +title contrai-engine — round lifecycle overview (Game.manage_round) + +' --- 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 "caller\n(cli.py | tests)" as C +participant "Game" as G <> +participant "Round" as R <> +participant "Deck" as D <> #E1F0FF +participant "Player\n(per turn)" as P <> +participant "RichView" as V <> + +C -> G : manage_round(view) +note right of G + `view` is typically a **RichView** + (passed by `cli.py`); tests and + headless callers can pass `None`. + Every view hook below is gated on + `hasattr(view, …)`. +end note +activate G + +== Setup — start_new_round() == + +G -> G : current_contract = None +G -> G : next_dealer()\n(random first time, else rotate +1) +alt round_number == 0 + G -> D : shuffle() +else subsequent rounds + G -> D : cut() +end +G -> G : set_players_order()\n(starts after dealer, anticlockwise) +G -> G : round_number += 1 +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 + +G -->> V : on_round_dealt(current_round) +note right of V + RichView appends + "Round N: X deals." + to the rolling event log. +end note + +== Bidding phase == + +G -> R : manage_bidding(view) +ref over R, P : **seq_bidding** — bid loop, validation, termination +R -> R : _detect_belote_holder()\n(if a contract was made) +R --> G : Contract | None +G -> G : current_contract = contract + +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 +end + +== Trick-taking — 8 tricks == + +G -> R : play_all_tricks(view) +loop trick_num in 0..7 + ref over R, P : **seq_trick** — playable cards, choose, winner +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 --> G : round_scores: dict +deactivate R + +loop team_name, points in round_scores + G -> G : scores[team_name] += points +end + +G --> C : { contract, scores, total_scores,\n message: "Round completed" } +deactivate G + +== Between rounds — cli.py == + +C -->> V : on_round_complete(round_, scores) +note right of V + Append a RoundSummary to + `history` for the end-game + scoreboard table. +end note +C -->> V : show_round_recap(round_, scores,\n is_final=check_game_over(target)) +note right of V + Full-screen recap panel: + contract, made/failed, per-team + round points, running totals, + belote advisory. Blocks on Enter. + When `is_final` is true the prompt + switches to "see the final score…" + so the next screen is the EndGame + banner, not another deal. +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: + 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 + the user to take stock — on every round, including the final one + before the game-over banner appears. +end note + +@enduml diff --git a/docs/diagrams/seq_scraper.png b/docs/diagrams/seq_scraper.png new file mode 100644 index 0000000..53ef0b1 Binary files /dev/null and b/docs/diagrams/seq_scraper.png differ diff --git a/docs/diagrams/seq_scraper.puml b/docs/diagrams/seq_scraper.puml new file mode 100644 index 0000000..f78eb7f --- /dev/null +++ b/docs/diagrams/seq_scraper.puml @@ -0,0 +1,145 @@ +@startuml seq_scraper +title contrai-scraper — spectator observation flow (main.py, current state) + +' --- Palette: scraper purple, with grey "future" components --- +skinparam shadowing false +skinparam sequence { + ArrowColor #5E4495 + LifeLineBorderColor #9B7FCC + LifeLineBackgroundColor #EDE7F6 + ParticipantBorderColor #5E4495 + ParticipantBackgroundColor #EDE7F6 + ParticipantFontColor #311B92 + ActorBorderColor #5E4495 + ActorBackgroundColor #EDE7F6 + ActorFontColor #311B92 + GroupBorderColor #5E4495 + GroupBackgroundColor #F3E5F5 + GroupHeaderFontColor #311B92 + BoxBorderColor #5E4495 + BoxBackgroundColor #F3E5F5 + DividerBorderColor #5E4495 + DividerBackgroundColor #D1C4E9 + DividerFontColor #311B92 +} +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 + FontColor #5F4B00 +} + +actor "User" as U +participant "main.py" as M <> +participant "async_playwright" as P <> +participant "Browser / page\n(Chromium)" as B +participant "DOM\nspectator site" as D +participant "SQLite" as S <> #DDDDDD + +U -> M : python main.py +activate M + +M -> P : async_playwright().start() +M -> P : chromium.launch(headless=False, slow_mo=500) +P -> B : <> page +M -> B : page.goto(target_url) +B -> D : navigate + +== Phase 1 — Login flow == + +M -> M : page.wait_for_timeout(5000) +M -> B : tutorial_btn.is_visible() ?\n(`gui.quick-start.launch.no`) +alt tutorial visible + M -> B : click `gui.quick-start.launch.no` +end + +M -> B : click 'button:has-text("Email")' +note right of M + Fallback selector if the + primary `text("Email")` fails: + `button[data-icon="email"]` +end note +M -> B : fill `input[placeholder=\n"Adresse électronique"]` ← EMAIL +M -> B : click `gui.users.email-wizard.continue` +M -> B : wait_for_selector(`#verificationCode`) +M -> B : fill `#verificationCode` ← FIXED_CODE +M -> B : click `#validateBtn` +B --> D : authenticate +M -> M : page.wait_for_timeout(10000) + +== Phase 2 — Mode navigation == + +M -> B : click `gui.actions.mode.online` +M -> B : click `gui.actions.online.observe` +M -> B : click `gui.versions.contree` + +== Phase 3 — Table discovery == + +M -> B : `.table-list-item`.first.click() (timeout 3s) +note right of M + If auto-join times out, + user has 5s to click a + table manually. +end note + +loop up to 20 attempts (until tournament found) + M -> M : page.wait_for_timeout(2000) + M -> B : `#tournamentMatchInfo`.is_visible() ? + alt tournament match found + M -> M : break loop + else standard table + M -> B : click `gui.online.tables.other-table` + M -> M : page.wait_for_timeout(3000) + end +end + +== Phase 4 — observe_game(page) == + +M -> B : get_players(page) +loop pos ∈ {nord, sud, est, ouest} + M -> D : inner_text(`#{pos} div[data-role='badge']`) + D --> M : player name +end + +M -> M : is_game_scrapeable(players) +note right of M + **<>** — currently + returns **True** always. + Will check a DB whether + these players are already + being scraped. +end note + +M -> B : last_known_round = get_current_round(page) +B -> D : inner_text(`#tour label[\ndata-i18n="gui.scores.tour"]`) +D --> M : "TOUR N" → regex \d+ → int + +loop forever (1s poll) + M -> B : current_round = get_current_round(page) + alt round changed (and ≠ -1) + M -> M : print "🎬 ROUND N STARTED - RECORDING" + + ' --- FUTURE LOGIC block (main.py:105-108) --- + group <> FUTURE LOGIC — main.py:105-108 + M -->> B : observe_bidding(page) + M -->> B : observe_gameplay(page) + M -->> S : persist round / bids / plays + end + + M -> M : last_known_round = current_round + end + M -> M : page.wait_for_timeout(1000) +end + +== Shutdown == + +M -> B : browser.close() +deactivate M + +note over M, S + **What is NOT wired up yet** + • `observe_bidding(page)` and `observe_gameplay(page)` — commented placeholders + • Any persistence path — no SQLite (or other) writes + • DB-based de-duplication of already-scraped players +end note + +@enduml diff --git a/docs/diagrams/seq_trick.png b/docs/diagrams/seq_trick.png new file mode 100644 index 0000000..71d7ace Binary files /dev/null and b/docs/diagrams/seq_trick.png differ diff --git a/docs/diagrams/seq_trick.puml b/docs/diagrams/seq_trick.puml new file mode 100644 index 0000000..af6bcc7 --- /dev/null +++ b/docs/diagrams/seq_trick.puml @@ -0,0 +1,177 @@ +@startuml seq_trick +title contrai-engine — single trick zoom (Round.play_trick) + +' --- Palette: engine orange, grey for stub paths, 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 +} + +participant "Round" as R <> +participant "Trick" as T <> #E1F0FF +participant "Player" as P <> +participant "Hand" as H <> #E1F0FF +participant "Deck" as D <> #E1F0FF +participant "RichView" as V <> + +[-> R : play_trick(view) +activate R + +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". +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) + + R -> R : playable_cards = _get_playable_cards(player) + + 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 + + alt view ≠ None AND player.is_human + R -->> V : view.request_card_action(player,\n current_trick, contract, playable_cards) + note right of V + RichView renders the + mid-trick screen, parses + the human's card-number + input, and returns the + chosen Card. + end note + V -->> R : Card + 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) + end + + R -->> V : view.on_card_played(player, played_card,\n current_trick) + note right of V + RichView logs "X plays Y" in the + event log. For AI players it + re-renders the playing screen and + sleeps `CONTRAI_AI_CARD_DELAY` + (0.9s default). Humans skip the + pause — their own request_card_action + already drove the frame. + end note + + alt player is belote_holder\nAND card is K-or-Q of trump + R -> R : _transition_belote_state(player)\n→ "belote" (first) or "rebelote" (second) + R -->> V : view.on_belote_announced(player, kind, self) + note right of V + Log line: "X announces Belote" + (first play) or "Rebelote" (second). + The seat badge always reads + "★ Belote" regardless of kind — + rendered by `_render_diamond` from + `round_.belote_state`. Sleeps + for `CONTRAI_AI_CARD_DELAY`. + end note + end + +end + +== Winner + bookkeeping == + +R -> T : winner = current_trick.get_current_winner(contract.suit) +note right of T + Trump comes from `self.contract.suit`. + Trump beats non-trump; within the same suit, + highest `get_order()` wins. The rule lives on + `Trick.get_current_winner(trump_suit)` in contrai-core. +end note + +R -> R : last_trick_winner = winner +R -> R : tricks.append(current_trick) +R -> R : team_tricks[winner.team.name].append(\n current_trick\n) + +R -> T : current_trick.get_plays() +T --> R : list<(Player, Card)> +R -> R : trick_cards = [card for _, card in plays]\ntrick_cards.reverse() (last played → first back in) +R -> D : deck.add_cards(trick_cards) + +== View callback (gated on hasattr) == + +alt view ≠ None AND hasattr(view, 'on_trick_complete') + R -->> V : view.on_trick_complete(\n current_trick, winner, self\n) + note right of V + RichView renders the + **trick-won** screen with + all four cards revealed + and the winner highlighted, + then blocks on "Press + [Enter] to continue…". + end note + V -->> R : (returns) +end + +[<-- R : return winner (Player) +deactivate R + +note over R, V + **Legality (SF-09 / SF-10) — `_get_playable_cards`:** + + 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. + • 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. + 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. + • no opponent trump yet → must trump if any; else discard. +end note + +@enduml diff --git a/docs/diagrams/state_cli_screens.mmd b/docs/diagrams/state_cli_screens.mmd new file mode 100644 index 0000000..b12ddfb --- /dev/null +++ b/docs/diagrams/state_cli_screens.mmd @@ -0,0 +1,69 @@ +--- +title: contrai CLI — screen flow (RichView) +--- +stateDiagram-v2 + direction TB + + [*] --> Landing : uv run contrai + + Landing : 0. Landing
target-score picker
(500 / 1000 / 1500 / 2000 / 3000) + 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) + EndGame : 5. End game
show_end_game(status)
[n] new · [r] rematch · [q] quit + + Landing --> Bidding : Enter + Bidding --> Bidding : AI bids,
still bidding
(view.on_bid_made paces) + Bidding --> RoundRecap : all 4 pass
(no contract → recap → redeal) + Bidding --> MidTrick : contract set,
play_all_tricks starts + MidTrick --> MidTrick : AI plays,
same trick
(view.on_card_played paces) + 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 --> EndGame : Enter,
check_game_over true + + EndGame --> Landing : [n] new game
(fresh target prompt) + EndGame --> Bidding : [r] rematch
(same target) + EndGame --> [*] : [q] quit + + note right of MidTrick + Hand row: green pill for legal plays, + dim for illegal. Hint line below + explains the constraint + ("must follow ♠" / "must trump — W led K♣" + / "free discard"). Rolling 'Log' panel + (last 5 events) sits below the hand. + end note + + note right of TrickWon + Round.play_trick calls + view.on_trick_complete(...) only if + hasattr(view, "on_trick_complete"). + Non-Rich callers are unaffected. + end note + + note right of RoundRecap + Shown after every round — including the + one that just crossed the target. The + 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"). + end note + + classDef screen0 fill:#FFEFD9,stroke:#B26A28,stroke-width:2px,color:#5C3A14; + classDef screen1 fill:#FFEFD9,stroke:#B26A28,stroke-width:2px,color:#5C3A14; + classDef screen2 fill:#FFEFD9,stroke:#B26A28,stroke-width:2px,color:#5C3A14; + classDef screen3 fill:#FFF3E0,stroke:#E89A4F,stroke-width:2px,color:#5C3A14; + classDef screen4 fill:#FFF3E0,stroke:#E89A4F,stroke-width:2px,color:#5C3A14; + classDef screen5 fill:#FFD9B3,stroke:#B26A28,stroke-width:2px,color:#5C3A14; + + class Landing screen0 + class Bidding screen1 + class MidTrick screen2 + class TrickWon screen3 + class RoundRecap screen4 + class EndGame screen5 diff --git a/docs/diagrams/state_cli_screens.png b/docs/diagrams/state_cli_screens.png new file mode 100644 index 0000000..275a3b3 Binary files /dev/null and b/docs/diagrams/state_cli_screens.png differ diff --git a/docs/engine/api.md b/docs/engine/api.md new file mode 100644 index 0000000..d280978 --- /dev/null +++ b/docs/engine/api.md @@ -0,0 +1,3 @@ +# contrai-engine — API reference + +::: contrai_engine diff --git a/docs/engine/index.md b/docs/engine/index.md new file mode 100644 index 0000000..4d421b5 --- /dev/null +++ b/docs/engine/index.md @@ -0,0 +1,101 @@ +# contrai-engine + +Game engine for Coinche / Contrée. MVC architecture, 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`) + - `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) +- `cli.py` — `contrai` console-script entry point: landing → game-loop → end-game +- `tests/` — pytest suite (`test_model/`, `test_view/`) + +Everything else (`Card`, `Deck`, `Hand`, `Suit`, `Rank`, `Bid`, `Contract`, `Trick`, `Team`, exceptions) is imported directly from `contrai_core`. There are no back-compat re-exports under the engine namespace anymore. + +## Class structure + +```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. + +## 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. + +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. + +## CLI + +`uv run contrai` (or `python -m contrai_engine.cli`) launches a six-screen Rich-based terminal UI driven by `RichView` and wired in `cli.py`: + +| 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). | +| **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. | + +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. + +Per-round summaries shown on the end-game scoreboard are tracked **view-side** (`RichView.history: list[RoundSummary]`), so `Game` itself stays free of UI state. + +**Contract label.** A single helper (`_format_contract_short`) renders the contract everywhere it appears — the in-game Round panel, the round recap, and the event-log *contract-set* line. It reads `VALUE by ×2/×4 by `, e.g. `110 by E ×2 by S`: the taker (`contract.player`) and any Coinche/Surcoinche caller are shown as single-letter seats colored by team (blue N-S, orange E-W). The caller identities ride on `Contract.double_player` / `redouble_player`, which `Auction.contract()` lifts off the bid history when it materialises the contract; the multiplier still renders if the caller is unknown. The recap passes `verbose=True`, which spells the markers out in full prose (`110 by N doubled by E`, `120 by N redoubled by N`) since the after-round summary has the room and reads better than the compact glyph. + +**Pacing.** AI bids and card plays each fire a view hook that re-renders the state with the new action visible, then sleeps for a tunable interval before the next player acts. Defaults: `1.4 s` between bids, `0.9 s` between card plays and after a belote announcement. Override via env vars (any positive float; garbage falls back to the default, negatives clamp to zero): + +```bash +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. + +**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. + +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`. + +```mermaid format="svg" source="state_cli_screens.mmd" +``` + +The screen flow above is rendered from [`state_cli_screens.mmd`](../diagrams/state_cli_screens.mmd) — the canonical source — and shows every transition the view drives, including the `on_trick_complete` callback edge and the new between-rounds recap. + +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). + +## 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 two zoom diagrams below break out the dense parts. + +??? note "Bidding cycle zoom — `Round.manage_bidding`" + + ```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. + +??? 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. + +## 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. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..45b4682 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,3 @@ +# ContrAI + + diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js new file mode 100644 index 0000000..daf3bc4 --- /dev/null +++ b/docs/javascripts/mathjax.js @@ -0,0 +1,19 @@ +window.MathJax = { + tex: { + inlineMath: [["\\(", "\\)"]], + displayMath: [["\\[", "\\]"]], + processEscapes: true, + processEnvironments: true + }, + options: { + ignoreHtmlClass: ".*|", + processHtmlClass: "arithmatex" + } +}; + +document$.subscribe(() => { + MathJax.startup.output.clearCache(); + MathJax.typesetClear(); + MathJax.texReset(); + MathJax.typesetPromise(); +}); diff --git a/docs/packages/core.md b/docs/packages/core.md deleted file mode 100644 index 6368365..0000000 --- a/docs/packages/core.md +++ /dev/null @@ -1,7 +0,0 @@ -# contrai-core - -Shared domain model for the ContrAI monorepo. - -**Status:** empty — populated in phase 2. Will contain the model classes currently in `contrai-engine` (`Card`, `Deck`, `Hand`, `Bid`, `Contract`, `Trick`, `Round`, `Team`, `Player`, exceptions). - -Consumed by `contrai-engine`, `contrai-analyzer`, and `contrai-scraper`. diff --git a/docs/packages/engine.md b/docs/packages/engine.md deleted file mode 100644 index 461810d..0000000 --- a/docs/packages/engine.md +++ /dev/null @@ -1,16 +0,0 @@ -# contrai-engine - -Game engine for Coinche / Contrée. MVC architecture. - -## Layout - -- `src/contrai_engine/model/` — `Card`, `Deck`, `Hand`, `Bid`, `Contract`, `Trick`, `Round`, `Game`, `Player`, `Team`, `exceptions` -- `src/contrai_engine/controller/` — `GameController` (stub) -- `src/contrai_engine/view/` — `CliView` (stub) -- `tests/` — pytest suite for the model layer - -## AI players - -`AiPlayer` implements the expert bidding table (80–160, Capot) and card-play strategy from the functional specs (SF-09, SF-10). - -> TODO: per-class one-liners; MVC sequence diagram. diff --git a/docs/packages/scraper/README.md b/docs/packages/scraper/README.md deleted file mode 100644 index 2faeed3..0000000 --- a/docs/packages/scraper/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# contrai-scraper - -Playwright spectator-mode scraper for online Coinche games. - -**Target:** `https://app.belote-rebelote.fr/` (auth required). - -**Stack:** Playwright async, Python 3.14, uv. Storage: SQLite (default, schema TBD). - -## Current flow (v1) - -login → Online → Spectator → Contree → Tournament → identify players via `#nord/#sud/#est/#ouest` → poll `#tour` for new rounds. - -## Pending - -- Bidding observation -- Card-play observation -- Game persistence (schema design) -- Multi-table orchestration -- Rate-limiting / ToS considerations - -## Screenshots - -Reference DOM captures of the target site in [`screenshots/`](screenshots/). diff --git a/docs/scraper/api.md b/docs/scraper/api.md new file mode 100644 index 0000000..40754c0 --- /dev/null +++ b/docs/scraper/api.md @@ -0,0 +1,10 @@ +# contrai-scraper — API reference + +`contrai-scraper` currently ships as a pair of entry-point scripts (`main.py`, +`run.py`) under `packages/contrai-scraper/` and does not expose an importable +Python package yet. + +A proper API reference will be generated here once the scraper grows a stable +`contrai_scraper` package under `src/`. + + diff --git a/docs/scraper/index.md b/docs/scraper/index.md new file mode 100644 index 0000000..769b6fb --- /dev/null +++ b/docs/scraper/index.md @@ -0,0 +1,29 @@ +# contrai-scraper + +Playwright spectator-mode scraper for online Coinche games (auth required). + +**Stack:** Playwright async, Python 3.14, uv. Storage: SQLite (default, schema TBD). + +## Current flow (v1) + +login → Online → Spectator → Contree → Tournament → identify players via `#nord/#sud/#est/#ouest` → poll `#tour` for new rounds. + +```plantuml format="svg" source="seq_scraper.puml" +``` + +`FUTURE LOGIC` placeholders (bidding observation, gameplay observation, SQLite persistence, DB-based de-duplication of already-scraped players) appear as dashed `<>` arrows on the diagram and map to the comment block at `main.py:105-108`. + +## Pending + +- Bidding observation +- Card-play observation +- Game persistence (schema design) +- Multi-table orchestration +- Rate-limiting / ToS considerations + +## Screenshots + +Reference DOM captures of the target site live under `screenshots/`: + +- ![Lobby (final view)](screenshots/lobby_final.png) +- ![Target table (spectator)](screenshots/success_target_table.png) diff --git a/docs/packages/scraper/screenshots/lobby_final.png b/docs/scraper/screenshots/lobby_final.png similarity index 100% rename from docs/packages/scraper/screenshots/lobby_final.png rename to docs/scraper/screenshots/lobby_final.png diff --git a/docs/packages/scraper/screenshots/success_target_table.png b/docs/scraper/screenshots/success_target_table.png similarity index 100% rename from docs/packages/scraper/screenshots/success_target_table.png rename to docs/scraper/screenshots/success_target_table.png diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..a963ae4 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,43 @@ +/* ContrAI — card-table palette */ + +:root { + --md-primary-fg-color: #1A1A1A; + --md-primary-fg-color--light: #2E2E2E; + --md-primary-fg-color--dark: #000000; + --md-default-bg-color: #FAFAFA; + --md-accent-fg-color: #B22234; + --md-accent-fg-color--transparent: rgba(178, 34, 52, 0.10); + --md-typeset-a-color: #B22234; + --contrai-green: #0F5132; + --contrai-green-soft: rgba(15, 81, 50, 0.08); +} + +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #0F0F0F; + --md-primary-fg-color--light: #1A1A1A; + --md-primary-fg-color--dark: #000000; + --md-default-bg-color: #121212; + --md-accent-fg-color: #D63E50; + --md-accent-fg-color--transparent: rgba(214, 62, 80, 0.15); + --md-typeset-a-color: #D63E50; + --contrai-green: #2D8C66; + --contrai-green-soft: rgba(45, 140, 102, 0.12); +} + +.md-typeset pre > code { + border-left: 3px solid var(--contrai-green); +} + +.md-typeset blockquote { + border-left: 3px solid var(--contrai-green); + color: var(--md-default-fg-color--light); +} + +.md-typeset table:not([class]) th { + background-color: var(--contrai-green-soft); + border-bottom: 2px solid var(--contrai-green); +} + +::selection { + background-color: var(--md-accent-fg-color--transparent); +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..55f4d3b --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,118 @@ +site_name: ContrAI +site_description: AI for the French card game Contrée +site_author: Valentin Mathieu +# site_url: # intentionally empty until a hosting decision is made + +docs_dir: docs + +theme: + name: material + palette: + - scheme: default + primary: black + accent: red + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: black + accent: red + toggle: + icon: material/brightness-4 + name: Switch to light mode + font: + text: Inter + code: JetBrains Mono + features: + # NOTE: navigation.instant is intentionally omitted; mkdocs-static-i18n's + # language switcher is incompatible with instant SPA-style navigation + # (would always land on the homepage of the other locale instead of the + # equivalent page). Re-enable only if i18n is dropped. + - navigation.tracking + - navigation.sections + - navigation.top + - toc.follow + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + logo: assets/logo.svg + favicon: assets/flavicon.png + +extra_css: + - stylesheets/extra.css + +extra_javascript: + - javascripts/mathjax.js + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - tables + - footnotes + - toc: + permalink: true + - pymdownx.arithmatex: + generic: true + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:mermaid2.fence_mermaid_custom + - pymdownx.tabbed: + alternate_style: true + - pymdownx.details + - plantuml_markdown: + format: svg + base_dir: docs/diagrams + +plugins: + - search + - mermaid2 + - mkdocstrings: + handlers: + python: + options: + docstring_style: google + show_root_heading: true + show_source: false + members_order: source + - i18n: + docs_structure: suffix + languages: + - locale: en + default: true + name: English + build: true + - locale: fr + name: Français + build: true + +nav: + - Home: index.md + - Architecture: architecture.md + - Engine: + - Overview: engine/index.md + - API reference: engine/api.md + - Core: + - Overview: core/index.md + - API reference: core/api.md + - Analyzer: + - Overview: analyzer/index.md + - API reference: analyzer/api.md + - Scraper: + - Overview: scraper/index.md + - API reference: scraper/api.md + - AI Ladder: + - Overview: ai-ladder/index.md + - Rule-based: ai-ladder/rule_based.md + - Supervised learning: ai-ladder/supervised.md + - Reinforcement learning: ai-ladder/rl.md + - Diagrams: diagrams/index.md diff --git a/packages/contrai-analyzer/main.py b/packages/contrai-analyzer/main.py index 7aa1c5b..dfc7398 100644 --- a/packages/contrai-analyzer/main.py +++ b/packages/contrai-analyzer/main.py @@ -1,11 +1,11 @@ """ -Main entry point for the La Contrée Probability Dashboard. +Main entry point for the contrée Probability Dashboard. Tabbed Streamlit application providing suit-agnostic hand analysis: Tab 1 — Hand Input + Bidding suggestion Tab 2 — Partner support probabilities Tab 3 — Opponent threat analysis - Tab 4 — Contrée point distribution chart + Tab 4 — contrée point distribution chart """ import streamlit as st @@ -60,7 +60,7 @@ def _slot_header(slot: SuitSlot) -> str: # --------------------------------------------------------------------------- def main() -> None: - """Initialize and render the Contrée dashboard.""" + """Initialize and render the contrée dashboard.""" st.set_page_config( page_title="La Contrée · Dashboard", diff --git a/packages/contrai-analyzer/src/bidding/__init__.py b/packages/contrai-analyzer/src/bidding/__init__.py index 6a93f5b..14d2b96 100644 --- a/packages/contrai-analyzer/src/bidding/__init__.py +++ b/packages/contrai-analyzer/src/bidding/__init__.py @@ -1 +1 @@ -"""Bidding decision logic initialization for La Contrée.""" +"""Bidding decision logic initialization for contrée.""" diff --git a/packages/contrai-analyzer/src/bidding/evaluator.py b/packages/contrai-analyzer/src/bidding/evaluator.py index 5182c14..8afcb10 100644 --- a/packages/contrai-analyzer/src/bidding/evaluator.py +++ b/packages/contrai-analyzer/src/bidding/evaluator.py @@ -1,7 +1,7 @@ """ Bidding Decision Matrix Evaluator. -This module evaluates a player's hand against a truth table of Contrée bidding +This module evaluates a player's hand against a truth table of contrée bidding rules to suggest the optimal opening bid for the trump slot, and estimates the probability that an opponent can open in each non-trump slot. """ diff --git a/packages/contrai-analyzer/src/engine/__init__.py b/packages/contrai-analyzer/src/engine/__init__.py index dbed736..3bd4b01 100644 --- a/packages/contrai-analyzer/src/engine/__init__.py +++ b/packages/contrai-analyzer/src/engine/__init__.py @@ -1 +1 @@ -"""Probability engine initialization for La Contrée.""" +"""Probability engine initialization for contrée.""" diff --git a/packages/contrai-analyzer/src/engine/probability_engine.py b/packages/contrai-analyzer/src/engine/probability_engine.py index 7c8ae63..bb28f7d 100644 --- a/packages/contrai-analyzer/src/engine/probability_engine.py +++ b/packages/contrai-analyzer/src/engine/probability_engine.py @@ -15,7 +15,7 @@ class ProbabilityEngine: """ - Calculates probabilities for La Contrée using hypergeometric distributions. + Calculates probabilities for contrée using hypergeometric distributions. Attributes: total_unknown_cards (int): Always 24 (32 − 8 in hand). @@ -324,7 +324,7 @@ def expected_points_by_slot( self, player: Literal["partner", "opponents"] ) -> dict[SuitSlot, float]: """ - Expected Contrée points per slot for either the partner or both opponents combined. + Expected contrée points per slot for either the partner or both opponents combined. Each unknown card contributes its point value weighted by the fraction of unknown cards that go to the player(s): diff --git a/packages/contrai-analyzer/src/models/__init__.py b/packages/contrai-analyzer/src/models/__init__.py index b82bf2c..7c34efa 100644 --- a/packages/contrai-analyzer/src/models/__init__.py +++ b/packages/contrai-analyzer/src/models/__init__.py @@ -1 +1 @@ -"""Models initialization for La Contrée.""" +"""Models initialization for contrée.""" diff --git a/packages/contrai-analyzer/src/models/deck.py b/packages/contrai-analyzer/src/models/deck.py index 2998013..25ca4ac 100644 --- a/packages/contrai-analyzer/src/models/deck.py +++ b/packages/contrai-analyzer/src/models/deck.py @@ -1,5 +1,5 @@ """ -Deck and Card representations for La Contrée. +Deck and Card representations for contrée. This module defines the basic Object-Oriented representations of cards, suit slots (abstract — suit identity is irrelevant for probability analysis), @@ -12,7 +12,7 @@ class SuitSlot(Enum): """ - Abstract suit slots for La Contrée. + Abstract suit slots for contrée. Suit identity (Hearts vs Clubs) does not affect probability calculations — only whether a slot is trump or non-trump matters. The three non-trump @@ -44,7 +44,7 @@ def color(self) -> str: class Rank(Enum): - """Enumeration of the 8 card ranks in a Contrée deck.""" + """Enumeration of the 8 card ranks in a contrée deck.""" SEVEN = "7" EIGHT = "8" @@ -57,9 +57,9 @@ class Rank(Enum): def point_value(self, is_trump: bool) -> int: """ - Return the Contrée point value for this rank. + Return the contrée point value for this rank. - In Contrée the Jack and Nine of trump are worth far more than in other + In contrée the Jack and Nine of trump are worth far more than in other suits — they are the two highest trumps. All other suits share the standard Belote scale. @@ -116,12 +116,12 @@ def id(self) -> str: @property def point_value(self) -> int: - """Contrée point value of this card given its slot.""" + """The contrée point value of this card given its slot.""" return self.rank.point_value(is_trump=self.suit == SuitSlot.TRUMP) class Deck: - """Represents a standard 32-card deck for La Contrée.""" + """Represents a standard 32-card deck for contrée.""" def __init__(self) -> None: """Initialize the deck with all 32 cards (8 ranks × 4 suit slots).""" diff --git a/packages/contrai-analyzer/src/models/hand.py b/packages/contrai-analyzer/src/models/hand.py index b914c33..3ef67ca 100644 --- a/packages/contrai-analyzer/src/models/hand.py +++ b/packages/contrai-analyzer/src/models/hand.py @@ -78,7 +78,7 @@ def get_suit_cards(self, suit: SuitSlot) -> list[Card]: def my_points(self) -> int: """ - Calculate the total Contrée point value of this hand. + Calculate the total contrée point value of this hand. Uses the trump scale for cards in SuitSlot.TRUMP and the non-trump scale for all other slots. diff --git a/packages/contrai-core/src/contrai_core/__init__.py b/packages/contrai-core/src/contrai_core/__init__.py index cb3b082..daf00c7 100644 --- a/packages/contrai-core/src/contrai_core/__init__.py +++ b/packages/contrai-core/src/contrai_core/__init__.py @@ -10,10 +10,20 @@ from .hand import Hand from .team import Team from .player import BasePlayer -from .bid import Bid, PassBid, ContractBid, DoubleBid, RedoubleBid, BidValidator +from .bid import Bid, PassBid, ContractBid, DoubleBid, RedoubleBid, SlamLevel +from .auction import Auction from .contract import Contract from .trick import Trick -from .exceptions import InvalidPlayerCountError, InvalidCardCountError +from .exceptions import ( + ContraiError, + InvalidPlayerCountError, + InvalidCardCountError, + IllegalBidError, + PlayRuleViolation, + IllegalPlayError, + TrickStateError, + InvalidContractError, +) __all__ = [ "Suit", @@ -29,9 +39,16 @@ "ContractBid", "DoubleBid", "RedoubleBid", - "BidValidator", + "SlamLevel", + "Auction", "Contract", "Trick", + "ContraiError", "InvalidPlayerCountError", "InvalidCardCountError", + "IllegalBidError", + "PlayRuleViolation", + "IllegalPlayError", + "TrickStateError", + "InvalidContractError", ] diff --git a/packages/contrai-core/src/contrai_core/auction.py b/packages/contrai-core/src/contrai_core/auction.py new file mode 100644 index 0000000..5513c76 --- /dev/null +++ b/packages/contrai-core/src/contrai_core/auction.py @@ -0,0 +1,429 @@ +"""Auction: immutable bidding-phase state and rule oracle. + +The :class:`Auction` owns the chronological :class:`Bid` history and +knows the rules of contrée bidding. It exposes the questions callers +actually need to answer: + +- :meth:`Auction.is_legal` / :meth:`Auction.legal_actions` so callers + can avoid proposing illegal bids in the first place — there is no + silent "force a Pass on an illegal bid" fallback in this design. +- :meth:`Auction.apply` to produce a new ``Auction`` with the bid + appended; raises :class:`IllegalBidError` when the bid is illegal. +- :meth:`Auction.is_terminal` and :meth:`Auction.contract` to detect + bidding completion and materialise the winning :class:`Contract`. + +This split is deliberate. Bid variants (see :mod:`contrai_core.bid`) +are dumb value carriers; all knowledge about *what is legal when* +lives here, alongside the auction state itself. This is the same +shape that MCTS / RL game-state interfaces use (``legal_actions`` +plus ``apply``), so the bidding phase is ready to drop into a future +imperfect-information game-state object. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Optional + +from .bid import Bid, ContractBid, DoubleBid, PassBid, RedoubleBid, SlamLevel +from .contract import Contract +from .exceptions import IllegalBidError + +if TYPE_CHECKING: + from .player import BasePlayer + + +@dataclass(frozen=True, slots=True) +class Auction: + """Immutable bidding-phase state for one round of contrée. + + ``Auction`` is the canonical view of bidding-so-far: it owns the + chronological tuple of :class:`Bid` objects, knows the contrée + bidding rules, and answers questions about what is legal now, + whether the auction has concluded, and what :class:`Contract` (if + any) the bids produced. + + The class is intentionally immutable: :meth:`apply` returns a new + instance instead of mutating ``self``. Frozen + slots keeps copies + cheap and equality cleanly derived from the bid history. + + Attributes: + bids: The chronological tuple of bids made so far. Defaults to + the empty tuple — an :class:`Auction` with no bids is a + fresh, valid auction state at the start of a round. + """ + + bids: tuple[Bid, ...] = field(default=()) + + # ------------------------------------------------------------------ + # Construction helpers + # ------------------------------------------------------------------ + + @classmethod + def empty(cls) -> Auction: + """Return a fresh auction with no bids yet.""" + + return cls() + + # ------------------------------------------------------------------ + # Legality queries + # ------------------------------------------------------------------ + + def is_legal(self, bid: Bid) -> bool: + """Return whether ``bid`` is a legal next action in this auction. + + Args: + bid: The candidate :class:`Bid` (player + type + payload). + Any concrete ``Bid`` subclass is accepted. + + Returns: + ``True`` if ``bid`` may legally be played as the next + action; ``False`` otherwise. Unknown ``Bid`` subclasses + return ``False`` defensively. + """ + + if isinstance(bid, PassBid): + return True + if isinstance(bid, ContractBid): + return self._is_contract_legal(bid) + if isinstance(bid, DoubleBid): + return self._is_double_legal(bid) + if isinstance(bid, RedoubleBid): + return self._is_redouble_legal(bid) + return False + + def legal_actions(self, player: BasePlayer) -> tuple[Bid, ...]: + """Enumerate every legal bid ``player`` could make right now. + + Suitable for handing to an MCTS / RL action enumerator or for + filtering a UI's option list down to only the choices the + engine will accept. + + Args: + player: The player whose turn it is. + + Returns: + A tuple of legal :class:`Bid` instances. Always non-empty + — :class:`PassBid` is unconditionally legal. The first + entry is always a :class:`PassBid` so single-action calls + can deterministically pick ``actions[0]``. + """ + + actions: list[Bid] = [PassBid(player)] + + # Contract legality is suit-agnostic and monotonic in + # :attr:`ContractBid.VALID_VALUES` order: once a value clears, + # every later one does too. Probe until the first hit, then fan + # the rest over every suit. See ``TestLegalActionsMonotonicity``. + found_legal = False + for value in ContractBid.VALID_VALUES: + if not found_legal: + if not self._is_contract_value_legal(value): + continue + found_legal = True + for suit in ContractBid.VALID_SUITS: + actions.append(ContractBid(player, value, suit)) + + double_candidate = DoubleBid(player) + if self._is_double_legal(double_candidate): + actions.append(double_candidate) + + redouble_candidate = RedoubleBid(player) + if self._is_redouble_legal(redouble_candidate): + actions.append(redouble_candidate) + + return tuple(actions) + + # ------------------------------------------------------------------ + # Apply + # ------------------------------------------------------------------ + + def apply(self, bid: Bid) -> Auction: + """Return a new auction with ``bid`` appended. + + Args: + bid: The bid to apply. Must be legal in the current state. + + Raises: + IllegalBidError: If ``bid`` is not a legal next action. + + Returns: + A new :class:`Auction` whose ``bids`` ends with ``bid``. + The receiver is left unchanged (frozen dataclass). + """ + + if not self.is_legal(bid): + raise IllegalBidError(bid, self.bids) + return Auction(self.bids + (bid,)) + + # ------------------------------------------------------------------ + # Termination + # ------------------------------------------------------------------ + + def is_terminal(self) -> bool: + """Return whether the auction has concluded. + + The auction ends on either of: + + 1. Four consecutive passes from the very first bid — the + first-round all-pass wipe. + 2. Three consecutive passes after at least one non-pass bid. + The winning contract is the last non-pass numeric bid. + """ + + if not self.bids: + return False + # All-pass wipe — exactly four players, every bid a pass. + if all(isinstance(b, PassBid) for b in self.bids): + return len(self.bids) >= 4 + # Three passes after a non-pass. + return self.consecutive_passes >= 3 + + def contract(self) -> Optional[Contract]: + """Return the :class:`Contract` produced by this auction. + + Returns: + A :class:`Contract` built from the last :class:`ContractBid` + with the doubling / redoubling players recorded (which is what + marks it doubled / redoubled), or ``None`` if the auction + concluded without any numeric bid. + """ + + cb = self.last_contract_bid + if cb is None: + return None + return Contract( + cb, + double_player=self.double_player, + redouble_player=self.redouble_player, + ) + + # ------------------------------------------------------------------ + # State queries + # ------------------------------------------------------------------ + + @property + def last_contract_bid(self) -> Optional[ContractBid]: + """The most recent :class:`ContractBid` in the history, or ``None``.""" + + for bid in reversed(self.bids): + if isinstance(bid, ContractBid): + return bid + return None + + @property + def has_double(self) -> bool: + """Whether a :class:`DoubleBid` stands against the current contract. + + Walking backwards from the end of the history, this is ``True`` + iff a :class:`DoubleBid` appears before any :class:`ContractBid`. + """ + + for bid in reversed(self.bids): + if isinstance(bid, ContractBid): + return False + if isinstance(bid, DoubleBid): + return True + return False + + @property + def has_redouble(self) -> bool: + """Whether a :class:`RedoubleBid` stands against the current double. + + ``True`` iff a :class:`RedoubleBid` was played after the most + recent :class:`DoubleBid`. + """ + + for bid in reversed(self.bids): + if isinstance(bid, DoubleBid): + return False + if isinstance(bid, RedoubleBid): + return True + return False + + @property + def double_player(self) -> Optional[BasePlayer]: + """The player whose standing :class:`DoubleBid` doubled the contract. + + Mirrors :attr:`has_double`: walking backwards, this is the + :class:`DoubleBid`'s player iff that Double appears before any + :class:`ContractBid`. ``None`` when no Double currently stands. + """ + + for bid in reversed(self.bids): + if isinstance(bid, ContractBid): + return None + if isinstance(bid, DoubleBid): + return bid.player + return None + + @property + def redouble_player(self) -> Optional[BasePlayer]: + """The player whose standing :class:`RedoubleBid` redoubled. + + Mirrors :attr:`has_redouble`: the :class:`RedoubleBid`'s player + iff a Redouble was played after the most recent + :class:`DoubleBid`. ``None`` when no Redouble currently stands. + """ + + for bid in reversed(self.bids): + if isinstance(bid, DoubleBid): + return None + if isinstance(bid, RedoubleBid): + return bid.player + return None + + @property + def consecutive_passes(self) -> int: + """Count of consecutive :class:`PassBid` instances at the tail.""" + + count = 0 + for bid in reversed(self.bids): + if isinstance(bid, PassBid): + count += 1 + else: + break + return count + + def partner_bid(self, player: BasePlayer) -> Optional[Bid]: + """The most recent non-pass bid made by ``player``'s partner. + + Useful for AI strategies that condition on a partner's last + action (raise, support, mirror suit choice, etc.). + + Args: + player: The player whose partner we want to inspect. + + Returns: + The partner's most recent non-pass :class:`Bid`, or + ``None`` if ``player`` has no team, no partner has bid, + or the partner has only passed. + """ + + for bid in reversed(self.bids): + if isinstance(bid, PassBid): + continue + if bid.player is not player and bid.player.team is player.team: + return bid + return None + + # ------------------------------------------------------------------ + # Rule helpers (private) + # ------------------------------------------------------------------ + + def _is_contract_legal(self, bid: ContractBid) -> bool: + """A new :class:`ContractBid` must strictly outrank the prior + numeric contract, and the auction must not be frozen by a + :class:`DoubleBid` or :class:`RedoubleBid`. + + A *double* freezes the auction at the current contract — no + more numeric bids are legal until the auction completes. + + Contract legality is a function of the bid's *value* alone — + the suit never matters to precedence or to the freeze rule. + This method is a thin wrapper around + :meth:`_is_contract_value_legal`; suit-agnostic callers (notably + :meth:`legal_actions`) should use that helper directly to avoid + re-asking the same question for each suit. + """ + + return self._is_contract_value_legal(bid.value) + + def _is_contract_value_legal(self, value: int | SlamLevel) -> bool: + """Whether a contract at ``value`` would be legal regardless of suit. + + Factored out of :meth:`_is_contract_legal` so :meth:`legal_actions` + can enumerate suits without re-running an identical legality + probe six times per value. The split also documents the rule: + contract legality in contrée depends on value precedence and the + Double/Redouble freeze state, never on the suit announced. + + Args: + value: A numeric step (80, 90, …, 180) or a + :class:`SlamLevel` member. No validation is performed + here — ``ContractBid.__post_init__`` enforces the + domain of valid values. + + Returns: + ``True`` if a :class:`ContractBid` at ``value`` (with any + suit) would be a legal next action, ``False`` otherwise. + """ + + last_contract_bid = None + for prev in reversed(self.bids): + if isinstance(prev, ContractBid): + last_contract_bid = prev + break + if isinstance(prev, (DoubleBid, RedoubleBid)): + # Auction is frozen; new numeric bids cannot reopen it. + return False + if last_contract_bid is None: + return True + # Once a Slam or SoloSlam has been announced, no further contract + # bid is legal. This is asymmetric for the Slam → SoloSlam + # progression: Slam (500) blocks SoloSlam (1000) even though the + # latter outranks it numerically, per the user-confirmed rule. + if isinstance(last_contract_bid.value, SlamLevel): + return False + # Slam and SoloSlam outrank every numeric contract (80–180). + if isinstance(value, SlamLevel): + return True + return value > last_contract_bid.value + + def _is_double_legal(self, bid: DoubleBid) -> bool: + """A :class:`DoubleBid` requires a live :class:`ContractBid` + by the opposing team and no prior :class:`DoubleBid` / + :class:`RedoubleBid`. + + Intervening passes since the contract bid do **not** close the + Coinche window — opposing players may come back and Coinche at + any point before the auction ends. The auction's natural + terminator (three consecutive passes after a non-pass bid) + closes the window on its own. + """ + + if not self.bids: + return False + last_contract_bid = None + for prev in reversed(self.bids): + if isinstance(prev, ContractBid): + last_contract_bid = prev + break + elif isinstance(prev, (DoubleBid, RedoubleBid)): + return False + if last_contract_bid is None: + return False + if last_contract_bid.player.team is bid.player.team: + return False + return True + + def _is_redouble_legal(self, bid: RedoubleBid) -> bool: + """A :class:`RedoubleBid` requires a live :class:`DoubleBid` + against the bidder's team and no prior :class:`RedoubleBid`. + + Symmetrically with :meth:`_is_double_legal`, intervening passes + between the Double and the Redouble do **not** close the + Surcoinche window — the contracting team may come back and + Surcoinche at any point before the auction's three-consecutive- + passes terminator fires. + """ + + if not self.bids: + return False + contract_player = None + has_double = False + has_redouble = False + for prev in reversed(self.bids): + if isinstance(prev, RedoubleBid): + has_redouble = True + break + elif isinstance(prev, DoubleBid): + has_double = True + elif isinstance(prev, ContractBid): + contract_player = prev.player + break + if not has_double or has_redouble or contract_player is None: + return False + if contract_player.team is not bid.player.team: + return False + return True diff --git a/packages/contrai-core/src/contrai_core/bid.py b/packages/contrai-core/src/contrai_core/bid.py index 7b8c758..d7e4c93 100644 --- a/packages/contrai-core/src/contrai_core/bid.py +++ b/packages/contrai-core/src/contrai_core/bid.py @@ -1,375 +1,208 @@ -# Bid classes for the "contree" card game. -# This module contains the bid system with polymorphic bid types. - -from abc import ABC, abstractmethod -from typing import Optional, TYPE_CHECKING - +"""Bid hierarchy — pure value carriers for the bidding phase. + +Each :class:`Bid` is a frozen dataclass attached to the player who made +it. The four concrete variants are: + +- :class:`PassBid` — the player declines to act. +- :class:`ContractBid` — a numeric contract or *Slam* / *Solo Slam* + announcement with an associated trump suit. +- :class:`DoubleBid` — *contre*. +- :class:`RedoubleBid` — *surcontre*. + +Knowledge about which bids are *legal at which auction state* used to +live on ``Bid.is_valid_after`` and the ``BidValidator`` utility class. +That logic now lives on :class:`contrai_core.Auction`, which owns the +chronological history and the rules in one place. Bids themselves are +intentionally dumb data carriers — they answer "what was announced", +not "is it legal now". + +The variants are deliberately a sum type: any concrete ``Bid`` is one +of the four classes above, every subclass adds at most a couple of +payload fields, and there is no behaviour to override. This is the +shape pattern-matching consumers (Auction's rule helpers, the engine's +bid-to-wire bridge, future MCTS / RL agents) actually want. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, ClassVar + +from .exceptions import InvalidContractError from .types import Suit if TYPE_CHECKING: - from .player import BasePlayer as Player - -class Bid(ABC): + from .player import BasePlayer + + +class SlamLevel(Enum): + """The two all-tricks contracts, ranked above every numeric bid. + + A Slam-family contract's *identity* is the kind of declaration — not + the number of points it is worth. Each member therefore owns its + :attr:`base_value` as data: this is the single source of truth for + the 250 / 500 that used to be re-derived from string sentinels all + over the codebase. The base value drives auction precedence (both + members outrank the 180 numeric ceiling) and doubles as the + slam-family scoring substitute — see + :meth:`ContractBid.get_numeric_value`, + :meth:`contrai_core.Contract.get_base_points`, and + :meth:`contrai_core.Contract.get_slam_card_substitute`. + + This is a plain :class:`~enum.Enum`, not an :class:`~enum.IntEnum`: + keeping the type distinct from ``int`` is the whole point — it stops + a Slam's value from being silently mistaken for card points in + scoring arithmetic. + + Attributes: + base_value: Points the contract commits to (250 / 500). + label: Human-facing name used for display (``str(level)``). """ - Abstract base class for all bid types in contree. - Provides common interface for bid validation, comparison, and precedence rules. - """ + SLAM = (250, "Slam") # contracting team must win all 8 tricks + SOLO_SLAM = (500, "Solo Slam") # bidder personally must win all 8 - def __init__(self, player: 'Player'): - """ - Initialize a bid with the player who made it. + def __init__(self, base_value: int, label: str) -> None: + self.base_value = base_value + self.label = label - Args: - player: The player making this bid - """ - self.player = player + def __str__(self) -> str: + return self.label - @abstractmethod - def is_valid_after(self, previous_bids: list) -> bool: - """ - Check if this bid is valid given the previous bids. - Args: - previous_bids: List of previous Bid objects +@dataclass(frozen=True, slots=True) +class Bid: + """Common base class for all bid variants. - Returns: - True if this bid is valid, False otherwise - """ - pass + Holds the player who made the bid. Concrete subclasses add their + own payload fields (a numeric value + suit for :class:`ContractBid`, + nothing for the other three). - @abstractmethod - def can_be_doubled(self) -> bool: - """ - Check if this bid can be doubled by opponents. + Equality on bids is *type + payload*, not player identity. Two + ``PassBid`` instances from different players still compare equal — + a bid identifies *what was announced*, not *who announced it*. The + ``player`` field is therefore excluded from the auto-generated + ``__eq__`` / ``__hash__`` via :func:`dataclasses.field`. - Returns: - True if this bid can be doubled, False otherwise - """ - pass + Attributes: + player: The player who made the bid. + """ - @abstractmethod - def __str__(self) -> str: - """String representation of the bid.""" - pass + player: "BasePlayer" = field(compare=False) - @abstractmethod - def __eq__(self, other) -> bool: - """Equality comparison between bids.""" - pass +@dataclass(frozen=True, slots=True) class PassBid(Bid): - """Represents a pass bid.""" - - def is_valid_after(self, previous_bids: list) -> bool: - """Pass is always valid.""" - return True + """The player declines to bid this turn. - def can_be_doubled(self) -> bool: - """Pass cannot be doubled.""" - return False + Always a legal action in any :class:`contrai_core.Auction` state. + """ def __str__(self) -> str: return "Pass" - def __eq__(self, other) -> bool: - return isinstance(other, PassBid) +@dataclass(frozen=True, slots=True) class ContractBid(Bid): - """Represents a contract bid with value and trump suit.""" + """A numeric contract or *Slam* / *Solo Slam* announcement. + + Validated at construction via ``__post_init__``: the value must be + one of the table-defined steps and the suit must be a known + :class:`Suit`. + + The two all-tricks contracts are the :class:`SlamLevel` enum members: + + - :attr:`SlamLevel.SLAM` — the contracting team must win all 8 + tricks. Outranks every numeric bid (80–180). + - :attr:`SlamLevel.SOLO_SLAM` — the contracting **player + personally** must win all 8 tricks (their partner may not win + any). Outranks Slam in raw numeric value, but is asymmetrically + blocked once a Slam has been announced (see + :class:`contrai_core.Auction`). + + Attributes: + value: A numeric step (80, 90, 100, …, 180), or a + :class:`SlamLevel` member for the all-tricks contracts. + suit: The trump suit — any :class:`Suit`, including + ``Suit.NO_TRUMP``. + """ - # Valid contract values in contree - VALID_VALUES = [80, 90, 100, 110, 120, 130, 140, 150, 160, 'Capot'] - VALID_SUITS = list(Suit) + VALID_VALUES: ClassVar[list] = [ + 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, + SlamLevel.SLAM, SlamLevel.SOLO_SLAM, + ] + VALID_SUITS: ClassVar[list] = list(Suit) - def __init__(self, player: 'Player', value: int or str, suit: str): - """ - Initialize a contract bid. + value: int | SlamLevel + suit: Suit - Args: - player: The player making this bid - value: Contract value (80-160 or 'Capot') - suit: Trump suit + def __post_init__(self) -> None: + """Reject unknown values / suits at construction time. Raises: - ValueError: If value or suit is invalid - """ - super().__init__(player) + InvalidContractError: If ``value`` is not on + :attr:`VALID_VALUES` or ``suit`` is not a :class:`Suit` + member. + """ + + if self.value not in self.VALID_VALUES: + raise InvalidContractError( + f"Invalid contract value: {self.value}. " + f"Must be one of {self.VALID_VALUES}" + ) + if self.suit not in self.VALID_SUITS: + raise InvalidContractError( + f"Invalid trump suit: {self.suit}. " + f"Must be one of {self.VALID_SUITS}" + ) - if value not in self.VALID_VALUES: - raise ValueError(f"Invalid contract value: {value}. Must be one of {self.VALID_VALUES}") - - if suit not in self.VALID_SUITS: - raise ValueError(f"Invalid trump suit: {suit}. Must be one of {self.VALID_SUITS}") - - self.value = value - self.suit = suit - - def is_valid_after(self, previous_bids: list) -> bool: - """ - Contract bid must be higher than the last contract bid. + def get_numeric_value(self) -> int: + """Numeric value for comparison purposes. - Args: - previous_bids: List of previous Bid objects + :class:`SlamLevel` members resolve to their + :attr:`~SlamLevel.base_value` — i.e. the amount the bidder + commits to, used both for auction precedence and as one of the + two halves of the Slam-family scoring formula. + ``SlamLevel.SLAM`` → 250, ``SlamLevel.SOLO_SLAM`` → 500. (Both + still outrank the numeric ceiling of 180.) - Returns: - True if this bid is higher than previous contract bids + The final at-risk amount on a Slam-family round is + ``(base + substitute) × multiplier`` where ``substitute`` + equals the base — see :meth:`contrai_core.Contract.get_base_points` + and :meth:`contrai_core.Contract.get_slam_card_substitute`. """ - # Find the last contract bid - last_contract = None - for bid in reversed(previous_bids): - if isinstance(bid, ContractBid): - last_contract = bid - break - if last_contract is None: - return True # First contract bid is always valid + if isinstance(self.value, SlamLevel): + return self.value.base_value + return self.value - # Capot is always higher than any numeric bid - if self.value == 'Capot': - return True - - if last_contract.value == 'Capot': - return False # Cannot bid higher than Capot - - # Compare numeric values - return self.value > last_contract.value - - def can_be_doubled(self) -> bool: - """Contract bids can be doubled.""" - return True - - def get_numeric_value(self) -> int: - """ - Get the numeric value for comparison purposes. + def __gt__(self, other) -> bool: + """Strict numeric ordering against another :class:`ContractBid`. - Returns: - Numeric value (Capot = 250 for comparison) + Comparisons against any other type return ``False`` — the + bidding flow only orders contract bids against contract bids. """ - return 250 if self.value == 'Capot' else self.value - - def __str__(self) -> str: - return f"{self.value} {self.suit}" - - def __eq__(self, other) -> bool: - return (isinstance(other, ContractBid) and - self.value == other.value and - self.suit == other.suit) - def __gt__(self, other) -> bool: - """Greater than comparison for contract bids.""" if not isinstance(other, ContractBid): return False return self.get_numeric_value() > other.get_numeric_value() -class DoubleBid(Bid): - """Represents a double bid.""" - - def is_valid_after(self, previous_bids: list) -> bool: - """ - Double is valid if: - 1. There's a contract bid that hasn't been doubled yet - 2. The doubling player is not from the contracting team - 3. No passes have occurred since the last contract bid - - Args: - previous_bids: List of previous Bid objects - - Returns: - True if double is valid, False otherwise - """ - if not previous_bids: - return False - - # Find the last contract bid and check if there have been doubles/redoubles - last_contract = None - has_double = False - passes_since_contract = 0 - - for bid in reversed(previous_bids): - if isinstance(bid, ContractBid): - last_contract = bid - break - elif isinstance(bid, DoubleBid): - has_double = True - elif isinstance(bid, RedoubleBid): - return False # Cannot double after redouble - elif isinstance(bid, PassBid): - passes_since_contract += 1 - - if last_contract is None or has_double: - return False - - # Cannot double if from the same team as the contractor - if last_contract.player.team == self.player.team: - return False - - # Cannot double if there have been passes since the contract - if passes_since_contract > 0: - return False + def __str__(self) -> str: + return f"{self.value} {self.suit}" - return True - def can_be_doubled(self) -> bool: - """Double cannot be doubled (but can be redoubled).""" - return False +@dataclass(frozen=True, slots=True) +class DoubleBid(Bid): + """A *contre* — doubles the contract's stake (×2).""" def __str__(self) -> str: return "Double" - def __eq__(self, other) -> bool: - return isinstance(other, DoubleBid) +@dataclass(frozen=True, slots=True) class RedoubleBid(Bid): - """Represents a redouble bid.""" - - def is_valid_after(self, previous_bids: list) -> bool: - """ - Redouble is valid if: - 1. There's a double bid that hasn't been redoubled yet - 2. The redoubling player is from the contracting team - 3. No passes have occurred since the double - - Args: - previous_bids: List of previous Bid objects - - Returns: - True if redouble is valid, False otherwise - """ - if not previous_bids: - return False - - # Find the contract and double bids - contract_player = None - has_double = False - has_redouble = False - passes_since_double = 0 - - for bid in reversed(previous_bids): - if isinstance(bid, RedoubleBid): - has_redouble = True - break - elif isinstance(bid, DoubleBid): - has_double = True - break - elif isinstance(bid, PassBid): - passes_since_double += 1 - elif isinstance(bid, ContractBid): - contract_player = bid.player - - if not has_double or has_redouble or contract_player is None: - return False - - # Can only redouble if from the same team as the contractor - if contract_player.team != self.player.team: - return False - - # Cannot redouble if there have been passes since the double - if passes_since_double > 0: - return False - - return True - - def can_be_doubled(self) -> bool: - """Redouble cannot be doubled.""" - return False + """A *surcontre* — quadruples the contract's stake (×4).""" def __str__(self) -> str: return "Redouble" - - def __eq__(self, other) -> bool: - return isinstance(other, RedoubleBid) - -class BidValidator: - """ - Utility class for validating bids and managing bid sequences. - """ - - @staticmethod - def is_bid_valid(bid: Bid, previous_bids: list) -> bool: - """ - Validate if a bid is valid given the previous bids. - - Args: - bid: The bid to validate - previous_bids: List of previous Bid objects - - Returns: - True if the bid is valid, False otherwise - """ - return bid.is_valid_after(previous_bids) - - @staticmethod - def get_last_contract(bids: list) -> Optional[ContractBid]: - """ - Get the last contract bid from a list of bids. - - Args: - bids: List of Bid objects - - Returns: - Last ContractBid or None if no contract exists - """ - for bid in reversed(bids): - if isinstance(bid, ContractBid): - return bid - return None - - @staticmethod - def has_double(bids: list) -> bool: - """ - Check if there's a double in the bid sequence after the last contract. - - Args: - bids: List of Bid objects - - Returns: - True if there's an active double, False otherwise - """ - contract_found = False - for bid in reversed(bids): - if isinstance(bid, ContractBid): - contract_found = True - break - elif isinstance(bid, DoubleBid): - return True - return False - - @staticmethod - def has_redouble(bids: list) -> bool: - """ - Check if there's a redouble in the bid sequence after the last double. - - Args: - bids: List of Bid objects - - Returns: - True if there's an active redouble, False otherwise - """ - double_found = False - for bid in reversed(bids): - if isinstance(bid, DoubleBid): - double_found = True - break - elif isinstance(bid, RedoubleBid): - return True - return False - - @staticmethod - def count_passes_after_last_action(bids: list) -> int: - """ - Count the number of passes since the last non-pass bid. - - Args: - bids: List of Bid objects - - Returns: - Number of consecutive passes at the end of the sequence - """ - count = 0 - for bid in reversed(bids): - if isinstance(bid, PassBid): - count += 1 - else: - break - return count diff --git a/packages/contrai-core/src/contrai_core/card.py b/packages/contrai-core/src/contrai_core/card.py index 5a87dbc..4102aad 100644 --- a/packages/contrai-core/src/contrai_core/card.py +++ b/packages/contrai-core/src/contrai_core/card.py @@ -2,23 +2,30 @@ from __future__ import annotations +from dataclasses import dataclass + from .types import Suit, Rank +@dataclass(frozen=True, slots=True, repr=False) class Card: """ - Represents a playing card for the game of Contree. + Represents a playing card for the game of contrée. Each card has a suit and a rank, and provides methods to get its point value and order, depending on whether it is a trump card or not. + ``Card`` is an **immutable value object**: equality and hashing are by + ``(suit, rank)``, so two distinct instances of the same physical card + compare equal and hash alike, and cards can live in ``set``/``dict`` by + value (mirroring the :class:`~contrai_core.bid.Bid` precedent). There is + deliberately **no** ``__lt__`` — a card's *strength* is context-dependent + (it depends on the trump suit) and is obtained via :meth:`get_order`, not + by comparing cards directly. + Attributes: suit (Suit): The suit of the card. rank (Rank): The rank of the card. - points_normal (int): The point value of the card in a non-trump suit. - points_trump (int): The point value of the card in the trump suit. - order_normal (int): The order of the card in a non-trump suit. - order_trump (int): The order of the card in the trump suit. Methods: __str__(): Returns a string representation of the card with suit symbol. @@ -27,6 +34,9 @@ class Card: get_order(trump_suit=None): Returns the order of the card, considering trump. """ + suit: Suit + rank: Rank + # Normal points (non-trump), keyed by Rank NORMAL_POINTS = { Rank.SEVEN: 0, @@ -78,26 +88,18 @@ class Card: Suit.CLUBS: "♣", } - def __init__(self, suit: Suit, rank: Rank): - self.suit = suit - self.rank = rank - self.points_normal = Card.NORMAL_POINTS[rank] - self.points_trump = Card.TRUMP_POINTS[rank] - self.order_normal = Card.NORMAL_ORDER[rank] - self.order_trump = Card.TRUMP_ORDER[rank] - def __str__(self) -> str: - return f"{self.rank.value}{Card.SUIT_SYMBOLS[self.suit]}" + return f"{self.rank.value} {Card.SUIT_SYMBOLS[self.suit]}" def __repr__(self) -> str: return f"Card({self.suit!r}, {self.rank!r})" def get_points(self, trump_suit: Suit | None = None) -> int: if trump_suit and self.suit == trump_suit: - return self.points_trump - return self.points_normal + return Card.TRUMP_POINTS[self.rank] + return Card.NORMAL_POINTS[self.rank] def get_order(self, trump_suit: Suit | None = None) -> int: if trump_suit and self.suit == trump_suit: - return self.order_trump - return self.order_normal + return Card.TRUMP_ORDER[self.rank] + return Card.NORMAL_ORDER[self.rank] diff --git a/packages/contrai-core/src/contrai_core/contract.py b/packages/contrai-core/src/contrai_core/contract.py index 4bc2864..8abce0c 100644 --- a/packages/contrai-core/src/contrai_core/contract.py +++ b/packages/contrai-core/src/contrai_core/contract.py @@ -1,12 +1,14 @@ -# Contract class for the "contree" card game. +# 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 +from .bid import ContractBid, SlamLevel +from .exceptions import InvalidContractError + if TYPE_CHECKING: - from .player import Player - from contrai_core.team import Team - from .bid import ContractBid + from .player import BasePlayer as Player class Contract: """ @@ -16,41 +18,59 @@ class Contract: and handles double/redouble states with score calculations. """ - def __init__(self, contract_bid: 'ContractBid', double: bool = False, redouble: bool = False): + def __init__(self, contract_bid: ContractBid, + double_player: Optional[Player] = None, + redouble_player: Optional[Player] = None): """ Initialize a contract from a ContractBid. + The doubled / redoubled state is *derived* from whether a caller + is recorded (see :attr:`double` / :attr:`redouble`) — there is no + separate boolean flag to keep in sync, so an "anonymous double" + (doubled with no known doubler) is unrepresentable by design. + Args: contract_bid: The winning ContractBid that established this contract - double: Whether contract has been doubled - redouble: Whether contract has been redoubled - """ + double_player: The player who doubled (coincheur), if any. + Its presence is what marks the contract as doubled. + redouble_player: The player who redoubled (surcoincheur), if any. + Its presence is what marks the contract as redoubled. + + Raises: + InvalidContractError: If a ``redouble_player`` is given + without a ``double_player`` — a surcoinche can only stand + on top of a coinche. + """ + if redouble_player is not None and double_player is None: + raise InvalidContractError( + "A contract cannot be redoubled without first being " + "doubled: redouble_player was given but double_player is None." + ) self.contract_bid = contract_bid self.player = contract_bid.player self.team = contract_bid.player.team self.value = contract_bid.value self.suit = contract_bid.suit - self.double = double - self.redouble = redouble + self.double_player = double_player + self.redouble_player = redouble_player - @classmethod - def from_legacy(cls, player: 'Player', value: int or str, suit: str, - double: bool = False, redouble: bool = False): - """ - Create a Contract from legacy parameters (for backwards compatibility). + @property + def double(self) -> bool: + """Whether the contract has been doubled (coinche). - Args: - player: Player who made the winning bid - value: Contract value (points to make) - suit: Trump suit for the contract - double: Whether contract has been doubled - redouble: Whether contract has been redoubled + Derived from :attr:`double_player`: a contract is doubled iff a + doubling player is recorded. """ - # Import here to avoid circular imports - from .bid import ContractBid + return self.double_player is not None - contract_bid = ContractBid(player, value, suit) - return cls(contract_bid, double, redouble) + @property + def redouble(self) -> bool: + """Whether the contract has been redoubled (surcoinche). + + Derived from :attr:`redouble_player`. The constructor guarantees + a redouble can only exist on top of a double. + """ + return self.redouble_player is not None def get_multiplier(self) -> int: """ @@ -65,59 +85,74 @@ def get_multiplier(self) -> int: return 2 return 1 - def is_made(self, team_points: int) -> bool: + def is_slam(self) -> bool: """ - Check if the contract was successfully made. - - Args: - team_points: Points scored by the contracting team + Check if this is a Slam contract (team must win all 8 tricks). Returns: - True if contract was made, False otherwise + True if contract value is ``SlamLevel.SLAM``, False otherwise. """ - if self.value == 'Capot': - # For Capot, team must win all tricks (all 162 points) - return team_points >= 162 - else: - return team_points >= self.value + return self.value is SlamLevel.SLAM - def get_attacking_team(self) -> 'Team': + def is_solo_slam(self) -> bool: """ - Get the team that must make the contract. + Check if this is a Solo Slam contract. + + In a Solo Slam the bidder *personally* must win every one of + the 8 tricks — their partner is forbidden from winning any. Returns: - The contracting team + True if contract value is ``SlamLevel.SOLO_SLAM``, False + otherwise. """ - return self.team + return self.value is SlamLevel.SOLO_SLAM + + def is_slam_family(self) -> bool: + """Whether this contract is a Slam or Solo Slam.""" + return isinstance(self.value, SlamLevel) - def get_defending_team(self) -> 'Team': + def get_base_points(self) -> int: """ - Get the team defending against the contract. + Get the base point value of the contract — what the bidder + committed to and what shows up in the auction's precedence + ordering. Returns: - The opposing team - """ - # This requires access to game teams, but we can get it from player's game context - # For now, return None - this should be handled at game level - return None + 250 for Slam, 500 for Solo Slam, the numeric value + otherwise. - def is_capot(self) -> bool: + Note: + For Slam-family contracts this is only *half* of the + at-risk amount — the actual card pile (normally up to + 162) is replaced by a flat substitute equal to the base. + See :meth:`get_slam_card_substitute`. The full at-risk + amount is ``(base + substitute) × multiplier`` and is + awarded to whichever side wins the contract (attacker + if made, defender if failed). """ - Check if this is a Capot contract. + return self.contract_bid.get_numeric_value() - Returns: - True if contract value is 'Capot', False otherwise + def get_slam_card_substitute(self) -> int: """ - return self.value == 'Capot' + Return the flat amount that replaces the 162 of trick-card + points on a Slam-family round. - def get_base_points(self) -> int: - """ - Get the base point value of the contract. + For Slam the substitute is 250; for Solo Slam it is 500. + For numeric (80-180) contracts there is no substitute — + teams actually count the cards they took — and this method + returns 0. + + The Slam-family at-risk amount is + ``(get_base_points() + get_slam_card_substitute()) × get_multiplier()``, + i.e. ``500 / 1000 / 2000`` for Slam at normal / doubled / + redoubled and ``1000 / 2000 / 4000`` for Solo Slam. Returns: - Base points for the contract (250 for Capot, actual value otherwise) + 250 for Slam, 500 for Solo Slam, 0 otherwise. """ - return 250 if self.value == 'Capot' else self.value + if isinstance(self.value, SlamLevel): + return self.value.base_value + return 0 def __str__(self) -> str: """String representation of the contract.""" diff --git a/packages/contrai-core/src/contrai_core/deck.py b/packages/contrai-core/src/contrai_core/deck.py index 45cd6e2..0917ee6 100644 --- a/packages/contrai-core/src/contrai_core/deck.py +++ b/packages/contrai-core/src/contrai_core/deck.py @@ -1,8 +1,10 @@ -# Deck class for managing a deck of cards in the "contree" game. +# Deck class for managing a deck of cards in the contrée game. + +import random from .card import Card +from .exceptions import InvalidCardCountError, InvalidPlayerCountError from .types import CARD_SUITS, Rank -from contrai_core.exceptions import InvalidPlayerCountError, InvalidCardCountError class Deck: def __init__(self): @@ -26,7 +28,6 @@ def shuffle(self): """ Shuffles the deck of cards in place. """ - import random random.shuffle(self.cards) def cut(self): @@ -34,7 +35,6 @@ def cut(self): Cuts the deck at a random position (excluding the first and last 3 cards). Modifies the order of the cards in the deck. """ - import random cut_index = random.randint(3, len(self.cards) - 4) self.cards = self.cards[cut_index:] + self.cards[:cut_index] @@ -60,7 +60,7 @@ def deal(self, players: list): self.cards = [] - def is_empty(self): + def is_empty(self) -> bool: """ Check if the deck is empty (contains no cards). diff --git a/packages/contrai-core/src/contrai_core/exceptions.py b/packages/contrai-core/src/contrai_core/exceptions.py index e100202..e08c93c 100644 --- a/packages/contrai-core/src/contrai_core/exceptions.py +++ b/packages/contrai-core/src/contrai_core/exceptions.py @@ -1,6 +1,40 @@ -# Custom exceptions for the Contrée game. +"""Custom exceptions for the contrée domain. -class InvalidPlayerCountError(ValueError): +Every domain-rule violation raised by ``contrai-core`` (and the engine +layered on top of it) subclasses :class:`ContraiError`, so a single +``except ContraiError`` catches the whole family. Each concrete error +*also* subclasses :class:`ValueError`, preserving the historical +contract that these used to be plain ``ValueError`` s — existing +``except ValueError`` handlers and ``pytest.raises(ValueError)`` checks +keep working unchanged. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # annotations only → no runtime/circular import + from collections.abc import Iterable + + from .bid import Bid + from .card import Card + + +class ContraiError(Exception): + """Base class for every ContrAI domain error. + + Concrete domain errors inherit from ``(ContraiError, ValueError)``. + ``ContraiError`` itself deliberately defines **no** ``__init__`` so a + subclass's ``super().__init__(message)`` resolves through the MRO to + :meth:`ValueError.__init__`, storing the message the way callers + expect. The dual inheritance lets new code catch the whole family + with one ``except ContraiError`` while legacy ``except ValueError`` + handlers keep working. + """ + + +class InvalidPlayerCountError(ContraiError, ValueError): """ Raised when an operation requires a specific number of players but receives a different count. @@ -8,14 +42,16 @@ class InvalidPlayerCountError(ValueError): (for dealing cards, forming teams, etc.) or exactly 2 players (for forming a team). """ - def __init__(self, expected_count, actual_count, context=""): + def __init__( + self, expected_count: int, actual_count: int, context: str = "" + ) -> None: """ Initialize the InvalidPlayerCountError. Args: - expected_count (int): The expected number of players - actual_count (int): The actual number of players received - context (str, optional): Additional context about where the error occurred + expected_count: The expected number of players. + actual_count: The actual number of players received. + context: Additional context about where the error occurred. """ if context: message = f"{context}: Expected {expected_count} players, got {actual_count}" @@ -27,21 +63,23 @@ def __init__(self, expected_count, actual_count, context=""): self.actual_count = actual_count self.context = context -class InvalidCardCountError(ValueError): +class InvalidCardCountError(ContraiError, ValueError): """ Raised when an operation requires a specific number of cards but receives a different count. This exception is typically raised in contexts where the cards have to be dealt. """ - def __init__(self, expected_count, actual_count, context=""): + def __init__( + self, expected_count: int, actual_count: int, context: str = "" + ) -> None: """ Initialize the InvalidCardCountError. Args: - expected_count (int): The expected number of cards - actual_count (int): The actual number of cards received - context (str, optional): Additional context about where the error occurred + expected_count: The expected number of cards. + actual_count: The actual number of cards received. + context: Additional context about where the error occurred. """ if context: message = f"{context}: Expected {expected_count} cards, got {actual_count}" @@ -52,3 +90,138 @@ def __init__(self, expected_count, actual_count, context=""): self.expected_count = expected_count self.actual_count = actual_count self.context = context + + +class IllegalBidError(ContraiError, ValueError): + """Raised when a bid is applied to an :class:`Auction` in which it is illegal. + + The auction state machine surfaces illegal bids as a loud failure + rather than silently downgrading them to a Pass. The offending bid + and the prior bid history are attached so callers can render + diagnostics — engine wiring bugs around bidding should be obvious + rather than swallowed. + """ + + def __init__( + self, bid: Bid, bids: Iterable[Bid], context: str = "" + ) -> None: + """Initialize the IllegalBidError. + + Args: + bid: The bid that was rejected. + bids: The chronological iterable of prior bids the bid was + applied against. Coerced to a tuple for diagnostics. + context: Optional free-form context (e.g. originating call + site or player position) appended to the message. + """ + bids_tuple = tuple(bids) + base = ( + f"Illegal bid {bid!r} for auction with " + f"{len(bids_tuple)} prior bid(s)" + ) + message = f"{context}: {base}" if context else base + super().__init__(message) + self.bid = bid + self.bids = bids_tuple + self.context = context + + +class PlayRuleViolation(StrEnum): + """Why a card play is illegal — one member per obligation branch. + + Each value maps to one of the obligation branches the engine's + card-play legality oracle enforces. ``StrEnum`` keeps the members + string-comparable for clean logging / JSON serialization by future + RL / scraper / server consumers. + """ + + MUST_FOLLOW_SUIT = "must_follow_suit" + """Held a card of the led suit but played off-suit. Covers the + trump-led "must follow trump" case, since the led suit *is* trump + there.""" + + MUST_TRUMP = "must_trump" + """Void in the led suit, not protected by a partner-master, held + trump but discarded a non-trump instead.""" + + MUST_OVERTRUMP = "must_overtrump" + """Held a higher trump than required (trump led, or over an + opponent's ruff) but played a lower trump.""" + + +class IllegalPlayError(ContraiError, ValueError): + """Raised when a card play violates a follow / trump obligation. + + Mirrors :class:`IllegalBidError`: an illegal card is surfaced as a + loud failure rather than silently corrected to a legal one. The + offending card, the machine-readable :class:`PlayRuleViolation` + reason, and the set of legal alternatives are attached so callers + can render diagnostics and explainable rationales. + """ + + def __init__( + self, + card: Card, + reason: PlayRuleViolation, + legal_cards: Iterable[Card], + context: str = "", + ) -> None: + """Initialize the IllegalPlayError. + + Args: + card: The card whose play was rejected. + reason: The :class:`PlayRuleViolation` classifying *why* the + play was illegal. + legal_cards: The cards that would have been legal. Coerced to + a tuple for diagnostics. + context: Optional free-form context (e.g. player position or + call site) appended to the message. + """ + legal = tuple(legal_cards) + base = ( + f"Illegal play {card!r}: {reason.value} " + f"({len(legal)} legal alternative(s))" + ) + super().__init__(f"{context}: {base}" if context else base) + self.card = card + self.reason = reason + self.legal_cards = legal + self.context = context + + +class TrickStateError(ContraiError, ValueError): + """Raised on an illegal mutation of a :class:`Trick`'s state. + + Currently surfaces the single case of adding a card to an + already-complete (four-card) trick — an engine sequencing bug rather + than a player choice. + """ + + def __init__(self, message: str, context: str = "") -> None: + """Initialize the TrickStateError. + + Args: + message: Human-readable description of the illegal mutation. + context: Optional free-form context appended to the message. + """ + super().__init__(f"{context}: {message}" if context else message) + self.context = context + + +class InvalidContractError(ContraiError, ValueError): + """Raised when contract / contract-bid data is internally inconsistent. + + Unifies the two construction-time checks: an unknown contract value + or trump suit on a :class:`ContractBid`, and a redouble recorded + without an underlying double on a :class:`Contract`. + """ + + def __init__(self, message: str, context: str = "") -> None: + """Initialize the InvalidContractError. + + Args: + message: Human-readable description of the inconsistency. + context: Optional free-form context appended to the message. + """ + super().__init__(f"{context}: {message}" if context else message) + self.context = context diff --git a/packages/contrai-core/src/contrai_core/hand.py b/packages/contrai-core/src/contrai_core/hand.py index 1407dd3..f50d502 100644 --- a/packages/contrai-core/src/contrai_core/hand.py +++ b/packages/contrai-core/src/contrai_core/hand.py @@ -75,6 +75,16 @@ def clear(self) -> None: """Remove every card from the hand, leaving it empty.""" self.cards.clear() + 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 + working. Returns a ``list[Card]`` — not another ``Hand`` — to + match the "list of cards" callers expect. + """ + return list(self.cards) + def __contains__(self, card: object) -> bool: """Return ``True`` iff ``card`` is currently in the hand.""" return card in self.cards @@ -87,7 +97,7 @@ def __len__(self) -> int: """Return the number of cards currently in the hand.""" return len(self.cards) - def __getitem__(self, idx): + def __getitem__(self, idx: int | slice): """Index into the hand by integer or slice. Args: @@ -124,9 +134,31 @@ def count_rank(self, rank: Rank) -> int: """ return sum(1 for card in self.cards if card.rank == rank) + def has_suit(self, suit: Suit) -> bool: + """Return ``True`` iff the hand holds at least one card of ``suit``. + + Short-circuits on the first match, so it is cheaper than + ``bool(cards_of_suit(suit))`` when only presence — not the cards + themselves — is needed (e.g. lead-suit detection). + + Args: + suit: The suit to look for. + + Returns: + ``True`` if any card in the hand has ``.suit == suit``, + ``False`` otherwise. + """ + return any(card.suit == suit for card in self.cards) + def has_card(self, suit: Suit, rank: Rank) -> bool: """Return ``True`` iff a specific card is in the hand. + Delegates to membership (``Card(suit, rank) in self``). Since + :class:`Card` is a frozen value object comparing by + ``(suit, rank)``, ``__contains__`` is the single source of truth + for "do I hold this card" — there is no parallel field-by-field + scan to drift out of sync. + Args: suit: The suit to look up. rank: The rank to look up. @@ -135,7 +167,7 @@ def has_card(self, suit: Suit, rank: Rank) -> bool: ``True`` if a card matching both ``suit`` and ``rank`` is present, ``False`` otherwise. """ - return any(c.suit == suit and c.rank == rank for c in self.cards) + return Card(suit, rank) in self def cards_of_suit(self, suit: Suit) -> list[Card]: """Return the cards of a given suit as a new list. @@ -153,13 +185,14 @@ def cards_of_suit(self, suit: Suit) -> list[Card]: def is_complete(self) -> bool: """Return ``True`` iff the hand contains exactly 8 unique cards. - A full Contree hand. Not enforced anywhere by the class itself; + A full contrée hand. Not enforced anywhere by the class itself; this is a convenience for tests and downstream invariant checks. - Uniqueness is judged by ``(suit, rank)`` pairs. + Uniqueness is plain :class:`Card` value-equality (cards compare by + ``(suit, rank)``), so a ``set`` dedupes duplicates directly. """ if len(self.cards) != 8: return False - return len({(c.suit, c.rank) for c in self.cards}) == 8 + return len(set(self.cards)) == 8 def __repr__(self) -> str: """Return a debug representation listing every card.""" diff --git a/packages/contrai-core/src/contrai_core/team.py b/packages/contrai-core/src/contrai_core/team.py index 2aa0410..b4b81d1 100644 --- a/packages/contrai-core/src/contrai_core/team.py +++ b/packages/contrai-core/src/contrai_core/team.py @@ -1,12 +1,13 @@ -# Team class for "contree" game, representing a team of two players. +# Team class for the contrée game, representing a team of two players. from __future__ import annotations -from contrai_core.exceptions import InvalidPlayerCountError + +from .exceptions import InvalidPlayerCountError from .player import BasePlayer class Team: """ - Represents a team of two players in "contree" game. + Represents a team of two players in the contrée game. Attributes: name (str): The name of the team (e.g., "North-South", "East-West"). diff --git a/packages/contrai-core/src/contrai_core/trick.py b/packages/contrai-core/src/contrai_core/trick.py index b6847dd..2d1bae0 100644 --- a/packages/contrai-core/src/contrai_core/trick.py +++ b/packages/contrai-core/src/contrai_core/trick.py @@ -1,9 +1,15 @@ -# Trick class for the "contree" card game. +# Trick class for the contrée card game. # This class represents a single trick in the game. -from typing import List, Tuple, Optional -from .card import Card -from .player import BasePlayer as Player +from __future__ import annotations +from typing import List, Tuple, Optional, TYPE_CHECKING + +from .exceptions import TrickStateError + +if TYPE_CHECKING: + from .card import Card + from .player import BasePlayer as Player + from .types import Suit class Trick: """ @@ -13,15 +19,16 @@ class Trick: with methods to determine the winner based on trump rules. """ - def __init__(self, trump_suit: Optional[str] = None): - """ - Initialize a new trick. + def __init__(self) -> None: + """Initialize a new, empty trick. - Args: - trump_suit: The trump suit for this trick, if any + A trick is a dumb container of plays; it does not own the trump + suit. Trump is round-level state living on the ``Contract`` and is + passed to :meth:`get_current_winner` at call time — mirroring how + :meth:`contrai_core.Card.get_order` / ``get_points`` take trump as + a parameter rather than storing it. """ self.plays: List[Tuple[Player, Card]] = [] - self.trump_suit = trump_suit def add_play(self, player: Player, card: Card) -> None: """ @@ -32,10 +39,10 @@ def add_play(self, player: Player, card: Card) -> None: card: The card being played Raises: - ValueError: If trick is already complete (4 cards) + TrickStateError: If trick is already complete (4 cards) """ if self.is_complete(): - raise ValueError("Cannot add card to complete trick") + raise TrickStateError("Cannot add a card to a complete trick") self.plays.append((player, card)) @@ -76,32 +83,37 @@ def is_complete(self) -> bool: """ return len(self.plays) == 4 - def size(self) -> int: + def get_current_winner(self, trump_suit: Optional[Suit]) -> Optional[Player]: """ - Get number of cards played so far. + Return the player currently winning this (possibly partial) trick. - Returns: - Number of cards played (0-4) - """ - return len(self.plays) + Works on incomplete tricks — useful while a trick is being played + for legality checks (e.g. *partner is currently master*) and view + rendering (live winner highlight). - def get_winner(self) -> Optional[Player]: - """ - Determine the winner of this trick. + Args: + 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 won the trick, or None if trick is empty + Player who is currently winning, or None if no card has been + played yet. """ if not self.plays: return None - lead_suit = self.get_led_suit() + lead_suit = self.plays[0][1].suit best_player = self.plays[0][0] best_card = self.plays[0][1] - best_is_trump = self.trump_suit and best_card.suit == self.trump_suit + best_is_trump = trump_suit is not None and best_card.suit == trump_suit for player, card in self.plays[1:]: - card_is_trump = self.trump_suit and card.suit == self.trump_suit + 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 @@ -109,8 +121,8 @@ def get_winner(self) -> Optional[Player]: best_card = card best_is_trump = True elif card_is_trump and best_is_trump: - # Compare trump cards - if card.get_order(self.trump_suit) > best_card.get_order(self.trump_suit): + # 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: @@ -120,7 +132,3 @@ def get_winner(self) -> Optional[Player]: best_card = card return best_player - - def is_empty(self) -> bool: - """Check if the trick is empty.""" - return len(self.plays) == 0 diff --git a/packages/contrai-core/src/contrai_core/types.py b/packages/contrai-core/src/contrai_core/types.py index fcc0cfb..0b0f95d 100644 --- a/packages/contrai-core/src/contrai_core/types.py +++ b/packages/contrai-core/src/contrai_core/types.py @@ -1,15 +1,16 @@ """Enum types for card suits and ranks. -Shared across all ContrAI packages. Enum values preserve the engine's display +Shared across all ContrAI packages. Enum values are the human-readable display strings (``Rank.JACK.value == "Jack"``, ``Suit.SPADES.value == "Spades"``) so -``str(card)`` output is unchanged. +``str(card)`` renders as e.g. ``"Jack ♠"`` directly from ``rank.value`` plus the +suit glyph — no separate display map needed. """ from enum import Enum class Suit(Enum): - """Card suits in Contree. + """Card suits in contrée. ``NO_TRUMP`` is a contract trump option only — no physical card has it. Use :data:`CARD_SUITS` (or compare against ``Suit.NO_TRUMP``) when @@ -21,6 +22,7 @@ class Suit(Enum): DIAMONDS = "Diamonds" CLUBS = "Clubs" NO_TRUMP = "NoTrump" + ALL_TRUMP = "AllTrump" #: The four card-bearing suits (excludes ``Suit.NO_TRUMP``). @@ -28,7 +30,7 @@ class Suit(Enum): class Rank(Enum): - """The eight card ranks in a Contree deck (32-card subset: 7..Ace).""" + """The eight card ranks in a contrée deck (32-card subset: 7..Ace).""" SEVEN = "7" EIGHT = "8" diff --git a/packages/contrai-core/tests/test_auction.py b/packages/contrai-core/tests/test_auction.py new file mode 100644 index 0000000..1e3cc6a --- /dev/null +++ b/packages/contrai-core/tests/test_auction.py @@ -0,0 +1,1199 @@ +"""Tests for the :class:`Auction` rule oracle and state machine. + +The auction owns the chronological bid history and answers the +"what is legal now?" / "is bidding over?" / "what contract did this +produce?" questions previously scattered across ``Bid.is_valid_after`` +and ``BidValidator``. These tests cover: + +- :meth:`Auction.is_legal` for each :class:`Bid` subtype, including + the Slam / Solo Slam precedence rules and the + Double-freezes-the-auction rule. +- :meth:`Auction.legal_actions` enumeration shape and the "only Pass + is legal" cases (partner just doubled / redoubled) that drive the + engine's auto-pass shortcut. +- :meth:`Auction.apply` (happy path + ``IllegalBidError`` on illegal + bids — *no* silent downgrade to Pass). +- :meth:`Auction.is_terminal` and :meth:`Auction.contract` covering + both end conditions. +- The state-query properties: ``last_contract_bid``, ``has_double``, + ``has_redouble``, ``consecutive_passes``, and ``partner_bid``. + +Every auction sequence in this module respects the engine's +anticlockwise speaking cycle ``N → W → S → E`` (each player speaks at +their turn, starting from any of the four seats). When the rule under +test would otherwise put a player out of turn, the sequence is +extended with the appropriate intervening :class:`PassBid`\\ s, or the +"checked" player is reassigned to the next legitimate speaker — the +rule oracle is pure but the histories we feed it must be reachable +from real play. + +Fixture parameters are listed in cycle order starting from N +(``north, west, south, east`` for any subset, in that order), with +``four_players`` appended when team identity is needed (Double / +Redouble legality, ``partner_bid``). +""" + +import pytest + +from contrai_core import ( + Auction, + BasePlayer, + ContractBid, + DoubleBid, + IllegalBidError, + PassBid, + RedoubleBid, + SlamLevel, + Suit, + Team, +) + + +# --------------------------------------------------------------------------- +# Fixtures: four real players + their N-S / E-W teams. +# Auction rules compare team identity for Double / Redouble legality, so +# we want real Team/BasePlayer instances rather than mocks. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def north(): + return BasePlayer("North", "North") + + +@pytest.fixture +def west(): + return BasePlayer("West", "West") + + +@pytest.fixture +def south(): + return BasePlayer("South", "South") + + +@pytest.fixture +def east(): + return BasePlayer("East", "East") + + +@pytest.fixture +def team_ns(north, south): + team = Team("North-South", [north, south]) + north.team = team + south.team = team + return team + + +@pytest.fixture +def team_ew(east, west): + team = Team("East-West", [east, west]) + east.team = team + west.team = team + return team + + +@pytest.fixture +def four_players(team_ns, team_ew, north, west, south, east): + """Force all four-team fixtures so every player has a team assigned. + + The return value is rarely unpacked — most tests pull the seat + fixtures they need directly and depend on ``four_players`` only + for the side effect of wiring teams onto every ``BasePlayer``. + """ + return north, west, south, east + + +# --------------------------------------------------------------------------- +# Construction & basic shape +# --------------------------------------------------------------------------- + + +class TestConstruction: + """Auction is a frozen dataclass with a tuple of bids.""" + + def test_empty_default(self): + auction = Auction() + assert auction.bids == () + assert auction.consecutive_passes == 0 + assert auction.last_contract_bid is None + + def test_empty_classmethod(self): + assert Auction.empty() == Auction() + + def test_is_frozen(self, north): + auction = Auction((PassBid(north),)) + # Frozen dataclass forbids attribute reassignment. + with pytest.raises(Exception): + auction.bids = () + + def test_equality_by_bids(self, north): + a = Auction((PassBid(north),)) + b = Auction((PassBid(north),)) + assert a == b + # Different sequences differ. + assert a != Auction(()) + + +# --------------------------------------------------------------------------- +# PassBid is always legal +# --------------------------------------------------------------------------- + + +class TestPassLegality: + """Pass is always legal regardless of history.""" + + def test_pass_legal_on_empty(self, north): + assert Auction().is_legal(PassBid(north)) is True + + def test_pass_legal_after_contract(self, north, east): + # East starts; the cycle N→W→S→E puts N next after E. + auction = Auction((ContractBid(east, 90, Suit.HEARTS),)) + assert auction.is_legal(PassBid(north)) is True + + def test_pass_legal_after_double(self, north, west, south, east): + # East starts with a contract, N (next in cycle) doubles, W passes; + # S is the natural next speaker. + auction = Auction( + ( + ContractBid(east, 100, Suit.HEARTS), + DoubleBid(north), + PassBid(west), + ), + ) + assert auction.is_legal(PassBid(south)) is True + + def test_pass_legal_at_terminal(self, north, west, south, east): + # Three passes after a non-pass — auction is terminal, but the + # legality oracle still answers True for Pass (terminality is + # a separate concern). N starts, W/S/E pass; N is up again. + auction = Auction( + ( + ContractBid(north, 80, Suit.SPADES), + PassBid(west), + PassBid(south), + PassBid(east), + ), + ) + assert auction.is_legal(PassBid(north)) is True + + +# --------------------------------------------------------------------------- +# ContractBid precedence +# --------------------------------------------------------------------------- + + +class TestContractBidPrecedence: + """Precedence + auction-freeze rules.""" + + def test_first_contract_always_legal(self, north): + assert Auction().is_legal(ContractBid(north, 80, Suit.SPADES)) is True + + def test_higher_numeric_legal(self, north, east): + auction = Auction((ContractBid(east, 90, Suit.HEARTS),)) + assert auction.is_legal(ContractBid(north, 100, Suit.SPADES)) is True + + def test_lower_numeric_illegal(self, north, east): + auction = Auction((ContractBid(east, 110, Suit.HEARTS),)) + assert auction.is_legal(ContractBid(north, 100, Suit.SPADES)) is False + + def test_equal_numeric_illegal(self, north, east): + auction = Auction((ContractBid(east, 100, Suit.HEARTS),)) + assert auction.is_legal(ContractBid(north, 100, Suit.SPADES)) is False + + def test_slam_over_any_numeric_legal(self, north, west, east): + for value in (80, 90, 100, 130, 160): + auction = Auction( + (ContractBid(east, value, Suit.HEARTS), PassBid(north)), + ) + assert ( + auction.is_legal(ContractBid(west, SlamLevel.SLAM, Suit.SPADES)) + is True + ) + + def test_solo_slam_over_any_numeric_legal( + self, north, west, south, east + ): + for value in (80, 90, 100, 130, 160): + auction = Auction( + ( + ContractBid(east, value, Suit.HEARTS), + PassBid(north), + PassBid(west), + ), + ) + assert ( + auction.is_legal(ContractBid(south, SlamLevel.SOLO_SLAM, Suit.SPADES)) + is True + ) + + def test_numeric_over_slam_illegal(self, north, east): + auction = Auction((ContractBid(east, SlamLevel.SLAM, Suit.HEARTS),)) + assert auction.is_legal(ContractBid(north, 160, Suit.SPADES)) is False + + def test_numeric_over_solo_slam_illegal(self, north, east): + auction = Auction((ContractBid(east, SlamLevel.SOLO_SLAM, Suit.HEARTS),)) + assert auction.is_legal(ContractBid(north, 160, Suit.SPADES)) is False + + def test_slam_over_slam_illegal(self, north, east): + auction = Auction((ContractBid(east, SlamLevel.SLAM, Suit.HEARTS),)) + assert ( + auction.is_legal(ContractBid(north, SlamLevel.SLAM, Suit.SPADES)) is False + ) + + def test_solo_slam_after_slam_illegal(self, north, east): + """Asymmetric block: SoloSlam (1000) outranks Slam (500), but + once a Slam is on the table the auction is closed to further + contract bids — including the otherwise-higher SoloSlam.""" + auction = Auction((ContractBid(east, SlamLevel.SLAM, Suit.HEARTS),)) + assert ( + auction.is_legal(ContractBid(north, SlamLevel.SOLO_SLAM, Suit.SPADES)) + is False + ) + + def test_slam_after_solo_slam_illegal(self, north, east): + auction = Auction((ContractBid(east, SlamLevel.SOLO_SLAM, Suit.HEARTS),)) + assert ( + auction.is_legal(ContractBid(north, SlamLevel.SLAM, Suit.SPADES)) is False + ) + + def test_solo_slam_over_solo_slam_illegal(self, north, east): + auction = Auction((ContractBid(east, SlamLevel.SOLO_SLAM, Suit.HEARTS),)) + assert ( + auction.is_legal(ContractBid(north, SlamLevel.SOLO_SLAM, Suit.SPADES)) + is False + ) + + def test_passes_do_not_change_precedence(self, north, west, east): + # E starts with 100; N passes — W is the natural next speaker. + auction = Auction( + (ContractBid(east, 100, Suit.HEARTS), PassBid(north)), + ) + assert auction.is_legal(ContractBid(west, 110, Suit.HEARTS)) is True + assert auction.is_legal(ContractBid(west, 100, Suit.HEARTS)) is False + + def test_double_freezes_auction( + self, north, west, south, east, four_players + ): + # E starts, intervening passes from N and W, then S doubles — + # E is the natural next speaker after the double. + auction = Auction( + ( + ContractBid(east, 100, Suit.HEARTS), + PassBid(north), + PassBid(west), + DoubleBid(south), + ), + ) + # No new numeric bid can reopen a frozen auction. + assert auction.is_legal(ContractBid(east, 110, Suit.HEARTS)) is False + # Even Slam / SoloSlam can't. + assert ( + auction.is_legal(ContractBid(east, SlamLevel.SLAM, Suit.SPADES)) + is False + ) + assert ( + auction.is_legal(ContractBid(east, SlamLevel.SOLO_SLAM, Suit.SPADES)) + is False + ) + + def test_redouble_also_freezes_auction( + self, north, west, south, east, four_players + ): + # E contracts, N/W pass, S doubles, E redoubles — N is next. + auction = Auction( + ( + ContractBid(east, 100, Suit.HEARTS), + PassBid(north), + PassBid(west), + DoubleBid(south), + RedoubleBid(east), + ), + ) + assert auction.is_legal(ContractBid(north, 130, Suit.SPADES)) is False + + def test_passes_after_double_still_freeze_auction( + self, north, west, south, east, four_players + ): + # After E contracts, N/W pass, S doubles, then E passes — N is + # the next speaker and the auction is still frozen. + auction = Auction( + ( + ContractBid(east, 100, Suit.HEARTS), + PassBid(north), + PassBid(west), + DoubleBid(south), + PassBid(east), + ), + ) + assert auction.is_legal(ContractBid(north, 110, Suit.HEARTS)) is False + + +# --------------------------------------------------------------------------- +# Contract legality is suit-independent +# --------------------------------------------------------------------------- + + +class TestContractLegalitySuitIndependence: + """Contract legality is a function of value only — never of suit. + + The rule helper has been split into a suit-agnostic + :meth:`Auction._is_contract_value_legal` precisely so callers (most + importantly :meth:`Auction.legal_actions`) don't probe the same + question six times per value. These tests pin that invariant: the + answer for any given ``value`` must be identical across every + :class:`Suit`. + """ + + @pytest.mark.parametrize("value", ContractBid.VALID_VALUES) + def test_value_legal_iff_every_suit_legal_on_empty(self, north, value): + auction = Auction() + value_answer = auction._is_contract_value_legal(value) + for suit in ContractBid.VALID_SUITS: + assert ( + auction._is_contract_legal(ContractBid(north, value, suit)) + is value_answer + ) + + @pytest.mark.parametrize("value", ContractBid.VALID_VALUES) + def test_value_legal_iff_every_suit_legal_after_contract( + self, north, east, value + ): + auction = Auction((ContractBid(east, 100, Suit.HEARTS),)) + value_answer = auction._is_contract_value_legal(value) + for suit in ContractBid.VALID_SUITS: + assert ( + auction._is_contract_legal(ContractBid(north, value, suit)) + is value_answer + ) + + def test_value_legal_false_when_frozen_by_double( + self, north, west, south, east, four_players + ): + auction = Auction( + ( + ContractBid(east, 100, Suit.HEARTS), + PassBid(north), + PassBid(west), + DoubleBid(south), + ), + ) + # Freeze blocks every value, including the Slam family. + for value in ContractBid.VALID_VALUES: + assert auction._is_contract_value_legal(value) is False + + def test_value_legal_false_when_slam_announced(self, east): + auction = Auction((ContractBid(east, SlamLevel.SLAM, Suit.HEARTS),)) + # Slam closes the auction to every contract value (including SoloSlam). + for value in ContractBid.VALID_VALUES: + assert auction._is_contract_value_legal(value) is False + + +# --------------------------------------------------------------------------- +# Monotonicity invariant — load-bearing for legal_actions's short-circuit +# --------------------------------------------------------------------------- + + +class TestLegalActionsMonotonicity: + """``Auction._is_contract_value_legal`` is monotonic in + :attr:`ContractBid.VALID_VALUES` iteration order. + + Concretely: once some value in the list clears as legal, every + subsequent value in the list must also clear. This is what lets + :meth:`Auction.legal_actions` stop probing the moment it finds the + first legal value and fan the remainder of the list out across + every suit without further checks. If a future rule ever blocks a + *specific* high value (a hypothetical "you cannot bid 170 after a + 160-doubled contract", etc.), this invariant breaks and the + short-circuit must be re-thought. + """ + + @pytest.fixture + def histories( + self, north, west, south, east, four_players, + ) -> list[Auction]: + """Auction histories covering every shape the rule helper sees: + empty, after a low numeric, after the numeric ceiling, after a + Slam, after a SoloSlam, and after a freeze by Double.""" + cb_low = ContractBid(east, 100, Suit.HEARTS) + cb_ceiling = ContractBid(east, 160, Suit.HEARTS) + cb_slam = ContractBid(east, SlamLevel.SLAM, Suit.HEARTS) + cb_solo = ContractBid(east, SlamLevel.SOLO_SLAM, Suit.HEARTS) + return [ + Auction(), + Auction((cb_low,)), + Auction((cb_ceiling,)), + Auction((cb_slam,)), + Auction((cb_solo,)), + Auction( + (cb_low, PassBid(north), PassBid(west), DoubleBid(south)), + ), + ] + + def test_value_legality_is_monotonic_in_iteration_order(self, histories): + for auction in histories: + seen_legal = False + for value in ContractBid.VALID_VALUES: + is_legal = auction._is_contract_value_legal(value) + if seen_legal: + assert is_legal, ( + f"Monotonicity broken for {auction.bids!r}: " + f"value={value!r} is illegal after an earlier " + f"value in VALID_VALUES was legal. " + f"legal_actions's short-circuit assumes monotonicity." + ) + elif is_legal: + seen_legal = True + + def test_short_circuit_matches_per_value_probe( + self, north, west, south, east, four_players, + ): + """The short-circuited :meth:`Auction.legal_actions` produces the + same set of :class:`ContractBid` actions as a full per-value probe + would. Drives the equivalence the optimisation relies on across + every history shape exercised by :attr:`histories`.""" + scenarios = [ + Auction(), + Auction((ContractBid(east, 100, Suit.HEARTS),)), + Auction((ContractBid(east, 160, Suit.HEARTS),)), + Auction((ContractBid(east, SlamLevel.SLAM, Suit.HEARTS),)), + Auction((ContractBid(east, SlamLevel.SOLO_SLAM, Suit.HEARTS),)), + Auction( + ( + ContractBid(east, 100, Suit.HEARTS), + PassBid(north), + PassBid(west), + DoubleBid(south), + ), + ), + ] + for auction in scenarios: + from_short_circuit = { + a for a in auction.legal_actions(north) + if isinstance(a, ContractBid) + } + from_full_probe = { + ContractBid(north, value, suit) + for value in ContractBid.VALID_VALUES + for suit in ContractBid.VALID_SUITS + if auction._is_contract_value_legal(value) + } + assert from_short_circuit == from_full_probe, ( + f"Short-circuit diverged from per-value probe for " + f"{auction.bids!r}" + ) + + +# --------------------------------------------------------------------------- +# DoubleBid legality +# --------------------------------------------------------------------------- + + +class TestDoubleLegality: + def test_illegal_on_empty(self, east, four_players): + assert Auction().is_legal(DoubleBid(east)) is False + + def test_legal_against_opponent_contract(self, north, east, four_players): + # E starts; N (next in cycle, opposing team) may immediately + # double the contract. + auction = Auction((ContractBid(east, 100, Suit.SPADES),)) + assert auction.is_legal(DoubleBid(north)) is True + + def test_illegal_against_own_contract( + self, north, west, south, four_players + ): + # N contracts, W passes; S (N's partner) cannot double their + # own team's contract. + auction = Auction( + (ContractBid(north, 100, Suit.SPADES), PassBid(west)), + ) + assert auction.is_legal(DoubleBid(south)) is False + + def test_illegal_if_already_doubled( + self, north, west, south, east, four_players + ): + # N contracts, W (opposing) doubles, S passes — E is up but + # cannot re-double an already-doubled contract. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + DoubleBid(west), + PassBid(south), + ), + ) + assert auction.is_legal(DoubleBid(east)) is False + + def test_illegal_after_redouble( + self, north, west, south, east, four_players + ): + # N contracts, W/S pass, E doubles, N redoubles — W is next + # and cannot re-double after a redouble. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + RedoubleBid(north), + ), + ) + assert auction.is_legal(DoubleBid(west)) is False + + def test_legal_after_passes_since_contract( + self, north, west, south, east, four_players + ): + """Passes between the contract bid and the Coinche do not close + the window — opposing players may still come back and Double + until the auction terminates on 3 consecutive passes. + """ + # N contracts; W and S pass; E (opposing team) may still + # double from the next legitimate seat. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + ), + ) + assert auction.is_legal(DoubleBid(east)) is True + + def test_legal_against_opponent_slam(self, north, west, four_players): + """Slam closes the auction to numeric / Slam-family bids but + coinche must remain available — opponents can still Double.""" + # N announces Slam; W (next in cycle, opposing team) doubles. + auction = Auction((ContractBid(north, SlamLevel.SLAM, Suit.SPADES),)) + assert auction.is_legal(DoubleBid(west)) is True + + def test_legal_against_opponent_solo_slam( + self, north, west, four_players + ): + auction = Auction((ContractBid(north, SlamLevel.SOLO_SLAM, Suit.SPADES),)) + assert auction.is_legal(DoubleBid(west)) is True + + +# --------------------------------------------------------------------------- +# RedoubleBid legality +# --------------------------------------------------------------------------- + + +class TestRedoubleLegality: + def test_illegal_on_empty(self, north, four_players): + assert Auction().is_legal(RedoubleBid(north)) is False + + def test_illegal_without_prior_double(self, north, west, four_players): + # N contracts; W is next. Without a prior Double, nobody may + # redouble — including W (opposing team). + auction = Auction((ContractBid(north, 100, Suit.SPADES),)) + assert auction.is_legal(RedoubleBid(west)) is False + + def test_legal_for_contracting_team( + self, north, west, south, east, four_players + ): + # N contracts; W/S pass; E doubles. N is the natural next + # speaker and may redouble; partner S may too (rule oracle is + # pure, so we exercise both contracting-team seats). + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + ), + ) + assert auction.is_legal(RedoubleBid(north)) is True + assert auction.is_legal(RedoubleBid(south)) is True + + def test_illegal_for_opposing_team( + self, north, west, south, east, four_players + ): + # Same shape — but W (doubling team) cannot redouble. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + ), + ) + assert auction.is_legal(RedoubleBid(west)) is False + + def test_illegal_if_already_redoubled( + self, north, west, south, east, four_players + ): + # N contracts, W/S pass, E doubles, N redoubles. Both the + # contracting seat (S, via rule oracle) and the natural next + # speaker (W, opposing team) are blocked from redoubling + # again, but the "already redoubled" rule is the clean test + # against the contracting partner. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + RedoubleBid(north), + ), + ) + assert auction.is_legal(RedoubleBid(south)) is False + + def test_legal_after_passes_since_double( + self, north, west, south, east, four_players + ): + """Symmetric with the Double window: intervening passes after + the Coinche do not close the Surcoinche window. The contracting + team may still come back and Redouble until the auction's + 3-consecutive-passes terminator fires. + """ + # N contracts, W/S pass, E doubles, N passes, W passes — S + # (contracting team) is the next legitimate speaker and may + # still redouble. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + PassBid(north), + PassBid(west), + ), + ) + assert auction.is_legal(RedoubleBid(south)) is True + + +# --------------------------------------------------------------------------- +# legal_actions enumeration +# --------------------------------------------------------------------------- + + +class TestLegalActions: + """Shape of the enumerated legal-actions set.""" + + def test_empty_auction_includes_pass_and_all_contracts(self, north): + actions = Auction().legal_actions(north) + # Always starts with the Pass action. + assert isinstance(actions[0], PassBid) + # 13 values × 6 suits = 78 ContractBids legal at start, plus the Pass. + contracts = [a for a in actions if isinstance(a, ContractBid)] + assert len(contracts) == 13 * 6 + # No Double / Redouble before there's a contract to challenge. + assert not any(isinstance(a, DoubleBid) for a in actions) + assert not any(isinstance(a, RedoubleBid) for a in actions) + + def test_includes_double_after_opponent_contract( + self, north, west, south, east, four_players + ): + # N contracts; W/S pass; E (opposing team) is next and gets + # both Double and the legal numeric raises in its action set. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + ), + ) + actions = auction.legal_actions(east) + assert any(isinstance(a, DoubleBid) for a in actions) + contract_raises = [ + a for a in actions + if isinstance(a, ContractBid) + and not isinstance(a.value, SlamLevel) + and a.value > 100 + ] + assert contract_raises # at least one higher-value contract exists + + def test_includes_redouble_after_being_doubled( + self, north, west, south, east, four_players + ): + # N contracts, W/S pass, E doubles — N is next and gets + # Redouble in its actions; partner S does too (pure oracle). + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + ), + ) + actions = auction.legal_actions(north) + assert any(isinstance(a, RedoubleBid) for a in actions) + partner_actions = auction.legal_actions(south) + assert any(isinstance(a, RedoubleBid) for a in partner_actions) + + def test_only_pass_when_partner_doubled( + self, north, west, south, east, four_players + ): + """E (opponent) contracts; N (S's partner) doubles; W passes; + S is the natural next speaker and the only legal action is + Pass — drives the engine's auto-pass shortcut.""" + auction = Auction( + ( + ContractBid(east, 100, Suit.HEARTS), + DoubleBid(north), + PassBid(west), + ), + ) + actions = auction.legal_actions(south) + assert len(actions) == 1 + assert isinstance(actions[0], PassBid) + + def test_only_pass_when_partner_redoubled( + self, north, west, south, east, four_players + ): + """N contracts, W doubles, S (N's partner) redoubles, E passes + — N is up next and the only legal action is Pass.""" + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + DoubleBid(west), + RedoubleBid(south), + PassBid(east), + ), + ) + actions = auction.legal_actions(north) + assert len(actions) == 1 + assert isinstance(actions[0], PassBid) + + def test_only_pass_when_partner_doubled_even_after_pass( + self, north, west, south, east, four_players + ): + """The partner of a doubler still has only Pass available + regardless of how many passes follow the Double: they cannot + re-Double (their team already did), the auction is frozen so + no contract is legal, and they're on the wrong team to + Redouble.""" + # E contracts, N doubles, W passes — S (N's partner) is next. + auction = Auction( + ( + ContractBid(east, 100, Suit.HEARTS), + DoubleBid(north), + PassBid(west), + ), + ) + actions = auction.legal_actions(south) + assert len(actions) == 1 + assert isinstance(actions[0], PassBid) + + def test_redouble_still_available_after_intervening_pass( + self, north, west, south, east, four_players + ): + """The contracting team may come back and Redouble even after + passes intervene since the Double — only the auction's three- + consecutive-passes terminator closes the window. + """ + # E contracts, N doubles, W passes, S passes — E (contracting + # team) is up after the two passes and Redouble must remain + # on the table. + auction = Auction( + ( + ContractBid(east, 100, Suit.HEARTS), + DoubleBid(north), + PassBid(west), + PassBid(south), + ), + ) + actions = auction.legal_actions(east) + assert any(isinstance(a, RedoubleBid) for a in actions) + + def test_double_still_available_after_intervening_passes( + self, north, west, south, east, four_players, + ): + """Symmetric to the Redouble case: opposing players may come + back and Coinche after intervening passes since the contract + bid. + """ + # N contracts, W passes, S passes — E (opposing team) is up + # and Double must still be on the table. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + ), + ) + actions = auction.legal_actions(east) + assert any(isinstance(a, DoubleBid) for a in actions) + + def test_no_auto_pass_when_opponent_doubles_contracting_team( + self, north, west, south, east, four_players + ): + """Opponent (E) doubled the contract. N (contractor, NS team) + is up next and must have BOTH Pass and Redouble — the engine + must not auto-pass when the contracting team has the option + to redouble. + + (Tests the contractor seat; partner S sits at the same + contracting team and the pure rule oracle gives her the same + Redouble option, exercised in + :meth:`test_includes_redouble_after_being_doubled`.) + """ + auction = Auction( + ( + ContractBid(north, 100, Suit.HEARTS), + PassBid(west), + PassBid(south), + DoubleBid(east), + ), + ) + actions = auction.legal_actions(north) + assert len(actions) > 1 + assert any(isinstance(a, RedoubleBid) for a in actions) + + +# --------------------------------------------------------------------------- +# apply — happy path + illegal raises +# --------------------------------------------------------------------------- + + +class TestApply: + def test_apply_returns_new_auction(self, north): + auction = Auction() + new = auction.apply(PassBid(north)) + assert new is not auction + assert auction.bids == () + assert new.bids == (PassBid(north),) + + def test_apply_chains(self, north, east): + # E opens with a pass; N (next in cycle) contracts. + auction = ( + Auction() + .apply(PassBid(east)) + .apply(ContractBid(north, 80, Suit.SPADES)) + ) + assert len(auction.bids) == 2 + assert auction.last_contract_bid == ContractBid(north, 80, Suit.SPADES) + + def test_apply_illegal_raises(self, north, east): + auction = Auction((ContractBid(east, 100, Suit.HEARTS),)) + illegal = ContractBid(north, 90, Suit.SPADES) # lower than 100 + with pytest.raises(IllegalBidError) as excinfo: + auction.apply(illegal) + # The exception carries the offending bid and prior history. + assert excinfo.value.bid is illegal + assert excinfo.value.bids == auction.bids + + def test_apply_double_against_own_team_raises( + self, north, west, south, four_players + ): + # N contracts, W passes — S (N's partner) cannot double their + # own team's contract. + auction = Auction( + (ContractBid(north, 100, Suit.SPADES), PassBid(west)), + ) + with pytest.raises(IllegalBidError): + auction.apply(DoubleBid(south)) + + +# --------------------------------------------------------------------------- +# Termination +# --------------------------------------------------------------------------- + + +class TestIsTerminal: + def test_empty_not_terminal(self): + assert Auction().is_terminal() is False + + def test_three_passes_after_contract_terminal( + self, north, west, south, east + ): + auction = Auction( + ( + ContractBid(north, 80, Suit.SPADES), + PassBid(west), + PassBid(south), + PassBid(east), + ), + ) + assert auction.is_terminal() is True + + def test_two_passes_after_contract_not_terminal( + self, north, west, south + ): + auction = Auction( + ( + ContractBid(north, 80, Suit.SPADES), + PassBid(west), + PassBid(south), + ), + ) + assert auction.is_terminal() is False + + def test_four_passes_terminal_all_pass_wipe( + self, north, west, south, east + ): + auction = Auction( + ( + PassBid(north), + PassBid(west), + PassBid(south), + PassBid(east), + ), + ) + assert auction.is_terminal() is True + + def test_three_passes_no_contract_not_terminal( + self, north, west, south + ): + auction = Auction( + ( + PassBid(north), + PassBid(west), + PassBid(south), + ), + ) + # Need the full all-pass wipe (4 passes) before annulling. + assert auction.is_terminal() is False + + def test_three_passes_after_double_terminal( + self, north, west, south, east, four_players + ): + # N contracts, W doubles immediately (next in cycle, opposing + # team); then 3 consecutive passes from S, E, N terminate. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + DoubleBid(west), + PassBid(south), + PassBid(east), + PassBid(north), + ), + ) + assert auction.is_terminal() is True + + +class TestContractMaterialisation: + def test_no_contract_when_all_pass(self, north, west, south, east): + auction = Auction( + ( + PassBid(north), + PassBid(west), + PassBid(south), + PassBid(east), + ), + ) + assert auction.contract() is None + + def test_simple_contract( + self, north, west, south, east, four_players + ): + auction = Auction( + ( + ContractBid(north, 80, Suit.SPADES), + PassBid(west), + PassBid(south), + PassBid(east), + ), + ) + contract = auction.contract() + assert contract is not None + assert contract.value == 80 + assert contract.suit == Suit.SPADES + assert contract.player is north + assert contract.double is False + assert contract.redouble is False + + def test_doubled_contract( + self, north, west, south, east, four_players + ): + # N contracts, W (next in cycle, opposing team) doubles; S/E/N + # pass to terminate. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + DoubleBid(west), + PassBid(south), + PassBid(east), + PassBid(north), + ), + ) + contract = auction.contract() + assert contract.double is True + assert contract.redouble is False + # The materialised contract carries the coincheur for the UI. + assert contract.double_player is west + assert contract.redouble_player is None + + def test_redoubled_contract( + self, north, west, south, east, four_players + ): + # N contracts, W doubles, S (N's partner) redoubles, E/N/W + # pass to terminate. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + DoubleBid(west), + RedoubleBid(south), + PassBid(east), + PassBid(north), + PassBid(west), + ), + ) + contract = auction.contract() + assert contract.double is True + assert contract.redouble is True + assert contract.double_player is west + assert contract.redouble_player is south + + +# --------------------------------------------------------------------------- +# State-query properties +# --------------------------------------------------------------------------- + + +class TestStateProperties: + def test_last_contract_bid_none_when_empty(self): + assert Auction().last_contract_bid is None + + def test_last_contract_bid_none_when_only_passes(self, north): + auction = Auction((PassBid(north),)) + assert auction.last_contract_bid is None + + def test_last_contract_bid_returns_most_recent( + self, north, west, south, east + ): + # N opens at 80; W passes; S passes; E raises to 90 — the + # state query should return E's bid. + first = ContractBid(north, 80, Suit.SPADES) + second = ContractBid(east, 90, Suit.HEARTS) + auction = Auction( + (first, PassBid(west), PassBid(south), second), + ) + assert auction.last_contract_bid is second + + def test_has_double_true_after_double( + self, north, west, south, east, four_players + ): + # N contracts, W/S pass, E (opposing) doubles. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + ), + ) + assert auction.has_double is True + + def test_has_double_false_when_no_double(self, north, four_players): + auction = Auction((ContractBid(north, 100, Suit.SPADES),)) + assert auction.has_double is False + + def test_has_redouble_true_after_redouble( + self, north, west, south, east, four_players + ): + # N contracts, W/S pass, E doubles, N redoubles. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + RedoubleBid(north), + ), + ) + assert auction.has_redouble is True + + def test_has_redouble_false_when_only_double( + self, north, west, south, east, four_players + ): + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + ), + ) + assert auction.has_redouble is False + + def test_double_player_is_the_doubler( + self, north, west, south, east, four_players + ): + # N contracts, W/S pass, E (opposing) doubles → E is the doubler. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + ), + ) + assert auction.double_player is east + + def test_double_player_none_when_no_double(self, north, four_players): + auction = Auction((ContractBid(north, 100, Suit.SPADES),)) + assert auction.double_player is None + + def test_redouble_player_is_the_redoubler( + self, north, west, south, east, four_players + ): + # N contracts, E doubles, N redoubles → N is the redoubler while + # E remains the doubler. + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + RedoubleBid(north), + ), + ) + assert auction.redouble_player is north + assert auction.double_player is east + + def test_redouble_player_none_when_only_double( + self, north, west, south, east, four_players + ): + auction = Auction( + ( + ContractBid(north, 100, Suit.SPADES), + PassBid(west), + PassBid(south), + DoubleBid(east), + ), + ) + assert auction.redouble_player is None + + def test_consecutive_passes_counts_from_tail( + self, north, west, south + ): + # N starts at 100; subsequent passes come from W, then S + # (cycle order). Trailing non-pass resets the counter. + cb = ContractBid(north, 100, Suit.SPADES) + assert Auction().consecutive_passes == 0 + assert Auction((cb,)).consecutive_passes == 0 + assert Auction((cb, PassBid(west))).consecutive_passes == 1 + assert ( + Auction( + (cb, PassBid(west), PassBid(south)), + ).consecutive_passes + == 2 + ) + # Trailing non-pass resets — S raises after W's pass. + assert ( + Auction( + (cb, PassBid(west), ContractBid(south, 110, Suit.HEARTS)), + ).consecutive_passes + == 0 + ) + + def test_partner_bid_returns_partner_last_non_pass( + self, north, west, south, east, four_players + ): + # E opens at 80; N raises to 90; W passes; S passes — S looks + # up the partner's last non-pass, which is N's 90 ♥. + auction = Auction( + ( + ContractBid(east, 80, Suit.SPADES), + ContractBid(north, 90, Suit.HEARTS), + PassBid(west), + PassBid(south), + ), + ) + assert auction.partner_bid(south) == ContractBid( + north, 90, Suit.HEARTS + ) + + def test_partner_bid_none_when_only_passes( + self, north, south, four_players + ): + auction = Auction((PassBid(north),)) + assert auction.partner_bid(south) is None diff --git a/packages/contrai-core/tests/test_base_player.py b/packages/contrai-core/tests/test_base_player.py index 87188d2..cc1f4e9 100644 --- a/packages/contrai-core/tests/test_base_player.py +++ b/packages/contrai-core/tests/test_base_player.py @@ -1,13 +1,12 @@ """Tests for BasePlayer data class.""" -from contrai_core import Hand -from contrai_core.player import BasePlayer +from contrai_core import BasePlayer, Hand, Team def test_base_player_initialization(): """A BasePlayer is created with name and position; hand and team start empty.""" - player = BasePlayer("Alice", "North") - assert player.name == "Alice" + player = BasePlayer("Corentin", "North") + assert player.name == "Corentin" assert player.position == "North" assert isinstance(player.hand, Hand) assert len(player.hand) == 0 @@ -16,7 +15,7 @@ def test_base_player_initialization(): def test_base_player_hand_is_mutable(): """The hand attribute can be appended to and cleared in place.""" - player = BasePlayer("Bob", "South") + player = BasePlayer("Samuel", "South") player.hand.append("placeholder_card") assert len(player.hand) == 1 player.hand.clear() @@ -25,9 +24,11 @@ def test_base_player_hand_is_mutable(): def test_base_player_team_settable(): """The team attribute can be assigned after init.""" - player = BasePlayer("Carol", "East") - player.team = "team_obj" # type: ignore[assignment] - assert player.team == "team_obj" + player = BasePlayer("Nabil", "East") + partner = BasePlayer("Alexandre", "West") + team = Team("EW", [player, partner]) + player.team = team + assert player.team is team def test_two_players_have_independent_hands(): @@ -36,3 +37,14 @@ def test_two_players_have_independent_hands(): p2 = BasePlayer("P2", "South") p1.hand.append("card_for_p1") assert len(p2.hand) == 0 + + +def test_all_table_positions_construct(): + """The four documented positions are all valid construction arguments. + + AiPlayer._get_partner_position relies on exactly these four strings, + so any drift here would silently break partner lookup. + """ + for position in ("North", "South", "East", "West"): + player = BasePlayer("Hugo", position) + assert player.position == position diff --git a/packages/contrai-core/tests/test_bid.py b/packages/contrai-core/tests/test_bid.py new file mode 100644 index 0000000..baedd57 --- /dev/null +++ b/packages/contrai-core/tests/test_bid.py @@ -0,0 +1,301 @@ +"""Tests for the :class:`Bid` value-carrier hierarchy. + +Bids are now frozen dataclasses with no auction-state behaviour — +:meth:`Bid.is_valid_after` and ``BidValidator`` moved to +:class:`contrai_core.Auction` (covered in ``test_auction.py``). What +remains here is the data contract of each variant: + +- Construction validation (``ContractBid`` rejects unknown value / suit). +- Equality / hashing semantics (player excluded from comparison, + variant types still distinct). +- :meth:`ContractBid.get_numeric_value` and the strict ``__gt__`` + ordering used inside the AI's bidding helpers. +- ``__str__`` for the rendering layer. +""" + +import pytest + +from contrai_core import ( + BasePlayer, + ContractBid, + DoubleBid, + InvalidContractError, + PassBid, + RedoubleBid, + SlamLevel, + Suit, + Team, +) + + +# --------------------------------------------------------------------------- +# Fixtures — four positioned players + their teams. Some equality tests +# rely on two seats from the same team being constructible, so we keep +# the team-wired fixtures even though Bid equality itself is now +# player-agnostic. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def north(): + return BasePlayer("North", "North") + + +@pytest.fixture +def south(): + return BasePlayer("South", "South") + + +@pytest.fixture +def east(): + return BasePlayer("East", "East") + + +@pytest.fixture +def west(): + return BasePlayer("West", "West") + + +@pytest.fixture +def team_ns(north, south): + team = Team("North-South", [north, south]) + north.team = team + south.team = team + return team + + +# --------------------------------------------------------------------------- +# PassBid +# --------------------------------------------------------------------------- + + +class TestPassBid: + def test_str(self, north): + assert str(PassBid(north)) == "Pass" + + def test_equality_ignores_player(self, north, south): + # Player is field(compare=False); two PassBids compare equal + # regardless of who made them. + assert PassBid(north) == PassBid(south) + + def test_distinct_from_other_variants(self, north): + assert PassBid(north) != ContractBid(north, 80, Suit.SPADES) + assert PassBid(north) != DoubleBid(north) + assert PassBid(north) != RedoubleBid(north) + + def test_player_stored(self, north): + assert PassBid(north).player is north + + +# --------------------------------------------------------------------------- +# ContractBid: construction validation +# --------------------------------------------------------------------------- + + +class TestContractBidConstruction: + """Frozen dataclass validates value + suit in __post_init__.""" + + @pytest.mark.parametrize( + "value", + [80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, + SlamLevel.SLAM, SlamLevel.SOLO_SLAM], + ) + def test_valid_values(self, north, value): + bid = ContractBid(north, value, Suit.SPADES) + assert bid.value == value + assert bid.suit == Suit.SPADES + + @pytest.mark.parametrize("suit", list(Suit)) + def test_valid_suits(self, north, suit): + # NO_TRUMP and ALL_TRUMP are in VALID_SUITS today (list(Suit)). + bid = ContractBid(north, 80, suit) + assert bid.suit == suit + + @pytest.mark.parametrize( + "bad_value", + # The old string sentinels "Slam" / "SoloSlam" are no longer + # valid — only the SlamLevel members are. + [70, 85, 190, 0, -10, "slam", "SLAM", "Capot", "solo", "Solo Slam", + "80", "Slam", "SoloSlam"], + ) + def test_invalid_value_raises(self, north, bad_value): + with pytest.raises(InvalidContractError, match="Invalid contract value"): + ContractBid(north, bad_value, Suit.SPADES) + + def test_invalid_suit_raises(self, north): + with pytest.raises(InvalidContractError, match="Invalid trump suit"): + ContractBid(north, 80, "Spades") # raw string is not a Suit enum + + def test_player_is_stored(self, north): + bid = ContractBid(north, 100, Suit.HEARTS) + assert bid.player is north + + +# --------------------------------------------------------------------------- +# ContractBid: ordering / numeric value +# --------------------------------------------------------------------------- + + +class TestContractBidComparison: + """Numeric value extraction and __gt__.""" + + def test_get_numeric_value_for_numeric(self, north): + assert ContractBid(north, 80, Suit.SPADES).get_numeric_value() == 80 + assert ContractBid(north, 160, Suit.SPADES).get_numeric_value() == 160 + + def test_get_numeric_value_for_slam(self, north): + # 250 = the contract base value (what the bidder commits to); + # it is one half of the Slam at-risk amount, the other being + # the flat card-pile substitute. Outranks the 160 numeric ceiling. + assert ( + ContractBid(north, SlamLevel.SLAM, Suit.SPADES).get_numeric_value() + == 250 + ) + + def test_get_numeric_value_for_solo_slam(self, north): + # 500 = the Solo Slam contract base value; outranks Slam (250). + assert ( + ContractBid(north, SlamLevel.SOLO_SLAM, Suit.SPADES).get_numeric_value() + == 500 + ) + + def test_gt_numeric(self, north): + a = ContractBid(north, 100, Suit.SPADES) + b = ContractBid(north, 90, Suit.HEARTS) + assert a > b + assert not (b > a) + + def test_gt_slam_over_max_numeric(self, north): + slam = ContractBid(north, SlamLevel.SLAM, Suit.SPADES) + max_numeric = ContractBid(north, 160, Suit.HEARTS) + assert slam > max_numeric + assert not (max_numeric > slam) + + def test_gt_solo_slam_over_slam(self, north): + solo = ContractBid(north, SlamLevel.SOLO_SLAM, Suit.SPADES) + slam = ContractBid(north, SlamLevel.SLAM, Suit.HEARTS) + assert solo > slam + assert not (slam > solo) + + def test_gt_with_non_contract_bid_returns_false(self, north): + assert (ContractBid(north, 100, Suit.SPADES) > PassBid(north)) is False + + +# --------------------------------------------------------------------------- +# ContractBid: __str__ + equality semantics +# --------------------------------------------------------------------------- + + +class TestContractBidDunders: + def test_str(self, north): + bid = ContractBid(north, 100, Suit.SPADES) + assert str(bid) == f"100 {Suit.SPADES}" + + def test_str_slam(self, north): + bid = ContractBid(north, SlamLevel.SLAM, Suit.SPADES) + assert str(bid) == f"Slam {Suit.SPADES}" + + def test_str_solo_slam(self, north): + # SlamLevel.__str__ uses the human label "Solo Slam" (spaced). + bid = ContractBid(north, SlamLevel.SOLO_SLAM, Suit.SPADES) + assert str(bid) == f"Solo Slam {Suit.SPADES}" + + def test_equality_ignores_player(self, north, south): + # Player is excluded from comparison; two ContractBids with + # the same value + suit but different players still compare equal. + a = ContractBid(north, 100, Suit.SPADES) + b = ContractBid(south, 100, Suit.SPADES) + assert a == b + + def test_equality_by_value_and_suit(self, north): + a = ContractBid(north, 100, Suit.SPADES) + c = ContractBid(north, 110, Suit.SPADES) + d = ContractBid(north, 100, Suit.HEARTS) + assert a != c + assert a != d + + def test_distinct_from_other_variants(self, north): + a = ContractBid(north, 100, Suit.SPADES) + assert a != PassBid(north) + assert a != DoubleBid(north) + + +# --------------------------------------------------------------------------- +# DoubleBid / RedoubleBid — value-carrier behaviour +# --------------------------------------------------------------------------- + + +class TestDoubleBid: + def test_str(self, east): + assert str(DoubleBid(east)) == "Double" + + def test_equality_ignores_player(self, north, east): + assert DoubleBid(east) == DoubleBid(north) + + def test_distinct_from_other_variants(self, east): + assert DoubleBid(east) != PassBid(east) + assert DoubleBid(east) != RedoubleBid(east) + + +class TestRedoubleBid: + def test_str(self, north): + assert str(RedoubleBid(north)) == "Redouble" + + def test_equality_ignores_player(self, north, south): + assert RedoubleBid(north) == RedoubleBid(south) + + def test_distinct_from_other_variants(self, north): + assert RedoubleBid(north) != PassBid(north) + assert RedoubleBid(north) != DoubleBid(north) + + +# --------------------------------------------------------------------------- +# Immutability — frozen dataclass forbids field reassignment +# --------------------------------------------------------------------------- + + +class TestImmutability: + """Frozen dataclasses raise on any attribute reassignment.""" + + def test_pass_bid_is_frozen(self, north, south): + bid = PassBid(north) + with pytest.raises(Exception): + bid.player = south + + def test_contract_bid_is_frozen(self, north): + bid = ContractBid(north, 80, Suit.SPADES) + with pytest.raises(Exception): + bid.value = 100 + with pytest.raises(Exception): + bid.suit = Suit.HEARTS + + +# --------------------------------------------------------------------------- +# SlamLevel — single source of truth for the all-tricks contracts +# --------------------------------------------------------------------------- + + +class TestSlamLevel: + """The enum owns the 250 / 500 base values and the display labels.""" + + def test_base_values(self): + assert SlamLevel.SLAM.base_value == 250 + assert SlamLevel.SOLO_SLAM.base_value == 500 + + def test_labels_via_str(self): + assert str(SlamLevel.SLAM) == "Slam" + assert str(SlamLevel.SOLO_SLAM) == "Solo Slam" + + def test_valid_values_ends_with_slam_members(self): + # The slam members live last so Auction.legal_actions' monotonic + # iteration (numeric ascending, then the all-tricks bids) holds. + assert ContractBid.VALID_VALUES[-2:] == [ + SlamLevel.SLAM, + SlamLevel.SOLO_SLAM, + ] + + def test_not_an_int(self): + # Plain Enum (not IntEnum): a Slam's value must never compare + # equal to its numeric points, so scoring can't confuse them. + assert SlamLevel.SLAM != 250 + assert not isinstance(SlamLevel.SLAM, int) diff --git a/packages/contrai-core/tests/test_card.py b/packages/contrai-core/tests/test_card.py index 8c56158..c2429d8 100644 --- a/packages/contrai-core/tests/test_card.py +++ b/packages/contrai-core/tests/test_card.py @@ -1,6 +1,8 @@ +import dataclasses + import pytest -from contrai_core.card import Card -from contrai_core.types import Suit, Rank + +from contrai_core import Card, Rank, Suit @pytest.fixture @@ -227,3 +229,98 @@ def test_trump_vs_normal_order_difference(self): # Queen: 4 normal, 2 trump (becomes lower) assert queen.get_order(Suit.HEARTS) == 4 assert queen.get_order(Suit.SPADES) == 2 + + +class TestCardStringRepresentations: + """Test __str__ and __repr__ output formats.""" + + def test_str_uses_suit_symbol(self, sample_cards): + # Jack of Spades → "Jack ♠" + assert str(sample_cards['spade_jack']) == "Jack ♠" + assert str(sample_cards['heart_ace']) == "Ace ♥" + assert str(sample_cards['diamond_9']) == "9 ♦" + assert str(sample_cards['club_king']) == "King ♣" + + def test_str_uses_rank_display_value(self): + # Rank.TEN.value is "10" — make sure str doesn't show "TEN". + assert str(Card(Suit.HEARTS, Rank.TEN)) == "10 ♥" + assert str(Card(Suit.SPADES, Rank.SEVEN)) == "7 ♠" + + def test_repr_is_debuggable(self, sample_cards): + card = sample_cards['spade_jack'] + # __repr__ uses enum repr — assert the key identifying bits are + # present rather than pinning the full enum repr format. + text = repr(card) + assert "Card(" in text + assert "SPADES" in text + assert "JACK" in text + + def test_suit_symbol_table_covers_all_physical_suits(self): + for suit in (Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS): + assert suit in Card.SUIT_SYMBOLS + + +class TestCardValueSemantics: + """Card is an immutable value object: equality/hash by ``(suit, rank)``. + + Distinct instances of the same physical card must be interchangeable — + this is what lets a Card survive a deep-copy / SQLite reload / replay and + still compare equal to "the same" card, and live in a ``set``/``dict`` by + value. There is no ``__lt__``: strength is parametric via + :meth:`Card.get_order`, not direct comparison. + """ + + def test_same_suit_and_rank_are_equal(self): + # Two independently constructed 7♠ are the same value. + assert Card(Suit.SPADES, Rank.SEVEN) == Card(Suit.SPADES, Rank.SEVEN) + + def test_equal_cards_have_equal_hash(self): + a = Card(Suit.HEARTS, Rank.ACE) + b = Card(Suit.HEARTS, Rank.ACE) + assert hash(a) == hash(b) + + def test_different_rank_is_unequal(self): + assert Card(Suit.SPADES, Rank.SEVEN) != Card(Suit.SPADES, Rank.EIGHT) + + def test_different_suit_is_unequal(self): + assert Card(Suit.SPADES, Rank.SEVEN) != Card(Suit.HEARTS, Rank.SEVEN) + + def test_unequal_to_non_card(self): + # No false positive against the raw (suit, rank) tuple it wraps. + card = Card(Suit.SPADES, Rank.JACK) + assert card != (Suit.SPADES, Rank.JACK) + assert card != "Jack ♠" + assert card is not None + assert (card == 42) is False + + def test_membership_uses_value_equality(self): + # A freshly built card is "in" a set containing an equal instance. + assert Card(Suit.DIAMONDS, Rank.KING) in {Card(Suit.DIAMONDS, Rank.KING)} + + def test_set_dedupes_by_value(self): + cards = [ + Card(Suit.SPADES, Rank.SEVEN), + Card(Suit.SPADES, Rank.SEVEN), # duplicate value + Card(Suit.HEARTS, Rank.SEVEN), + ] + assert set(cards) == { + Card(Suit.SPADES, Rank.SEVEN), + Card(Suit.HEARTS, Rank.SEVEN), + } + + def test_usable_as_dict_key(self): + prices = {Card(Suit.CLUBS, Rank.ACE): 11} + # Lookup by an equal-but-distinct instance hits the same bucket. + assert prices[Card(Suit.CLUBS, Rank.ACE)] == 11 + + def test_is_immutable(self): + card = Card(Suit.SPADES, Rank.JACK) + with pytest.raises(dataclasses.FrozenInstanceError): + card.suit = Suit.HEARTS + with pytest.raises(dataclasses.FrozenInstanceError): + card.rank = Rank.ACE + + def test_no_ordering_defined(self): + # Strength is parametric (get_order), so Card defines no __lt__. + with pytest.raises(TypeError): + sorted([Card(Suit.SPADES, Rank.SEVEN), Card(Suit.HEARTS, Rank.ACE)]) diff --git a/packages/contrai-core/tests/test_contract.py b/packages/contrai-core/tests/test_contract.py new file mode 100644 index 0000000..af6fc35 --- /dev/null +++ b/packages/contrai-core/tests/test_contract.py @@ -0,0 +1,263 @@ +"""Tests for the Contract class. + +Covers contract construction (direct + legacy), multiplier semantics, +Slam / Solo Slam vs numeric base-points logic, and equality. + +Note: whether a contract was *made* is decided in +``Round.calculate_round_scores`` — it requires trick counts (and, for +Solo Slam, per-player trick counts) that ``Contract`` does not see, so +``Contract`` deliberately exposes no ``is_made`` predicate. +""" + +import pytest + +from contrai_core import ( + BasePlayer, + Contract, + ContractBid, + InvalidContractError, + PassBid, + SlamLevel, + Suit, + Team, +) + + +@pytest.fixture +def north(): + return BasePlayer("North", "North") + + +@pytest.fixture +def south(): + return BasePlayer("South", "South") + + +@pytest.fixture +def team_ns(north, south): + team = Team("North-South", [north, south]) + north.team = team + south.team = team + return team + + +@pytest.fixture +def numeric_contract(north): + return Contract(ContractBid(north, 100, Suit.SPADES)) + + +@pytest.fixture +def slam_contract(north): + return Contract(ContractBid(north, SlamLevel.SLAM, Suit.HEARTS)) + + +@pytest.fixture +def solo_slam_contract(north): + return Contract(ContractBid(north, SlamLevel.SOLO_SLAM, Suit.HEARTS)) + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestContractConstruction: + def test_wraps_contract_bid(self, north, team_ns): + bid = ContractBid(north, 110, Suit.DIAMONDS) + contract = Contract(bid) + assert contract.contract_bid is bid + assert contract.player is north + assert contract.team is team_ns + assert contract.value == 110 + assert contract.suit is Suit.DIAMONDS + assert contract.double is False + assert contract.redouble is False + + def test_construction_with_double(self, north, south): + # The doubled state is derived from the recorded doubler. + contract = Contract( + ContractBid(north, 80, Suit.CLUBS), double_player=south + ) + assert contract.double is True + assert contract.redouble is False + + def test_construction_with_redouble(self, north, south): + contract = Contract( + ContractBid(north, 80, Suit.CLUBS), + double_player=south, + redouble_player=north, + ) + assert contract.double is True + assert contract.redouble is True + + def test_double_redouble_players_default_to_none(self, north): + contract = Contract(ContractBid(north, 80, Suit.CLUBS)) + assert contract.double_player is None + assert contract.redouble_player is None + + def test_construction_records_double_and_redouble_players( + self, north, south + ): + contract = Contract( + ContractBid(north, 100, Suit.HEARTS), + double_player=south, + redouble_player=north, + ) + assert contract.double_player is south + assert contract.redouble_player is north + + def test_redouble_without_double_is_rejected(self, north, south): + # A surcoinche can only stand on top of a coinche, so the + # constructor refuses a redoubler with no doubler underneath it. + with pytest.raises(InvalidContractError): + Contract( + ContractBid(north, 80, Suit.CLUBS), redouble_player=south + ) + + +# --------------------------------------------------------------------------- +# Multiplier +# --------------------------------------------------------------------------- + + +class TestContractMultiplier: + def test_normal_multiplier(self, north): + contract = Contract(ContractBid(north, 80, Suit.SPADES)) + assert contract.get_multiplier() == 1 + + def test_double_multiplier(self, north, south): + contract = Contract( + ContractBid(north, 80, Suit.SPADES), double_player=south + ) + assert contract.get_multiplier() == 2 + + def test_redouble_multiplier(self, north, south): + contract = Contract( + ContractBid(north, 80, Suit.SPADES), + double_player=south, + redouble_player=north, + ) + assert contract.get_multiplier() == 4 + + +# --------------------------------------------------------------------------- +# Slam helpers and team accessors +# --------------------------------------------------------------------------- + + +class TestContractSlamHelpers: + def test_is_slam_true(self, slam_contract): + assert slam_contract.is_slam() is True + + def test_is_slam_false_for_numeric(self, numeric_contract): + assert numeric_contract.is_slam() is False + + def test_is_slam_false_for_solo_slam(self, solo_slam_contract): + # is_slam is the narrow Slam-only predicate. + assert solo_slam_contract.is_slam() is False + + def test_is_solo_slam_true(self, solo_slam_contract): + assert solo_slam_contract.is_solo_slam() is True + + def test_is_solo_slam_false_for_slam(self, slam_contract): + assert slam_contract.is_solo_slam() is False + + def test_is_solo_slam_false_for_numeric(self, numeric_contract): + assert numeric_contract.is_solo_slam() is False + + def test_is_slam_family_true_for_slam(self, slam_contract): + assert slam_contract.is_slam_family() is True + + def test_is_slam_family_true_for_solo_slam(self, solo_slam_contract): + assert solo_slam_contract.is_slam_family() is True + + def test_is_slam_family_false_for_numeric(self, numeric_contract): + assert numeric_contract.is_slam_family() is False + + def test_get_base_points_numeric(self, numeric_contract): + assert numeric_contract.get_base_points() == 100 + + def test_get_base_points_slam(self, slam_contract): + # 250 = the contract base (auction precedence + half of the + # at-risk amount). The other half is the flat card-pile + # substitute returned by get_slam_card_substitute(). + assert slam_contract.get_base_points() == 250 + + def test_get_base_points_solo_slam(self, solo_slam_contract): + assert solo_slam_contract.get_base_points() == 500 + + def test_get_slam_card_substitute_numeric_is_zero(self, numeric_contract): + # Numeric contracts use the actual 162 of card points — no + # substitute applies. + assert numeric_contract.get_slam_card_substitute() == 0 + + def test_get_slam_card_substitute_slam(self, slam_contract): + # The 162 trick-pile is replaced by a flat 250 for Slam. + assert slam_contract.get_slam_card_substitute() == 250 + + def test_get_slam_card_substitute_solo_slam(self, solo_slam_contract): + # Solo Slam: substitute is 500. + assert solo_slam_contract.get_slam_card_substitute() == 500 + + def test_at_risk_total_slam_normal(self, slam_contract): + # The full at-risk amount for a Slam at normal multiplier: + # (base + substitute) × 1 = 250 + 250 = 500. + amount = ( + slam_contract.get_base_points() + + slam_contract.get_slam_card_substitute() + ) * slam_contract.get_multiplier() + assert amount == 500 + + def test_at_risk_total_solo_slam_doubled(self, north, south): + # Solo Slam doubled: (500 + 500) × 2 = 2000. + contract = Contract( + ContractBid(north, SlamLevel.SOLO_SLAM, Suit.HEARTS), double_player=south + ) + amount = ( + contract.get_base_points() + + contract.get_slam_card_substitute() + ) * contract.get_multiplier() + assert amount == 2000 + + +# --------------------------------------------------------------------------- +# Dunders +# --------------------------------------------------------------------------- + + +class TestContractDunders: + def test_str_normal(self, north): + contract = Contract(ContractBid(north, 100, Suit.SPADES)) + assert "100" in str(contract) + assert str(Suit.SPADES) in str(contract) + assert north.name in str(contract) + assert "Doubled" not in str(contract) + assert "Redoubled" not in str(contract) + + def test_str_doubled(self, north, south): + contract = Contract( + ContractBid(north, 100, Suit.SPADES), double_player=south + ) + assert "Doubled" in str(contract) + + def test_str_redoubled(self, north, south): + contract = Contract( + ContractBid(north, 100, Suit.SPADES), + double_player=south, + redouble_player=north, + ) + assert "Redoubled" in str(contract) + + def test_equality_same_bid_and_flags(self, north): + a = Contract(ContractBid(north, 100, Suit.SPADES)) + b = Contract(ContractBid(north, 100, Suit.SPADES)) + assert a == b + + def test_inequality_different_flags(self, north, south): + a = Contract(ContractBid(north, 100, Suit.SPADES)) + b = Contract(ContractBid(north, 100, Suit.SPADES), double_player=south) + assert a != b + + def test_inequality_against_non_contract(self, numeric_contract, north): + assert numeric_contract != PassBid(north) + assert numeric_contract != "100 Spades" diff --git a/packages/contrai-core/tests/test_deck.py b/packages/contrai-core/tests/test_deck.py index 819e8a0..2acc0d4 100644 --- a/packages/contrai-core/tests/test_deck.py +++ b/packages/contrai-core/tests/test_deck.py @@ -1,9 +1,17 @@ -import pytest import copy -from contrai_core.deck import Deck -from contrai_core.card import Card -from contrai_core.types import Suit, Rank, CARD_SUITS -from contrai_core.exceptions import InvalidPlayerCountError, InvalidCardCountError +from collections import Counter + +import pytest + +from contrai_core import ( + CARD_SUITS, + Card, + Deck, + InvalidCardCountError, + InvalidPlayerCountError, + Rank, + Suit, +) @pytest.fixture def deck(): @@ -26,21 +34,14 @@ def test_deck_initialization(deck): def test_deck_has_all_card_combinations(): """ Test that the deck contains all expected card combinations. + + ``Card`` is a value object (equality by ``(suit, rank)``), so the deck's + cards can be compared directly against the full 32-card set built from + the suit × rank product — no ``str()`` projection needed. """ deck = Deck() - expected_cards = set() - suit_symbols = { - Suit.SPADES: '♠', - Suit.HEARTS: '♥', - Suit.DIAMONDS: '♦', - Suit.CLUBS: '♣' - } - for suit in CARD_SUITS: - for rank in Rank: - expected_cards.add(f"{rank.value}{suit_symbols[suit]}") - - actual_cards = {str(card) for card in deck.cards} - assert actual_cards == expected_cards + expected_cards = {Card(suit, rank) for suit in CARD_SUITS for rank in Rank} + assert set(deck.cards) == expected_cards def test_shuffle_changes_order(deck): """ @@ -53,7 +54,7 @@ def test_shuffle_changes_order(deck): assert deck.cards != original_order # Ensure all cards are still present assert len(deck.cards) == 32 - assert sorted(str(card) for card in deck.cards) == sorted(str(card) for card in original_order) + assert Counter(deck.cards) == Counter(original_order) def test_cut_changes_order(deck): """ @@ -64,7 +65,7 @@ def test_cut_changes_order(deck): # The order should be different after cut assert original_order != deck.cards # The deck should still have the same cards (no loss or duplication) - assert sorted(str(card) for card in original_order) == sorted(str(card) for card in deck.cards) + assert Counter(original_order) == Counter(deck.cards) def test_deal_gives_each_player_8_unique_cards(deck): """ @@ -79,8 +80,8 @@ def test_deal_gives_each_player_8_unique_cards(deck): assert len(player.hand) == 8 # Ensure all cards are unique and no card is missing - all_dealt_cards = [str(card) for player in players for card in player.hand] - assert sorted(all_dealt_cards) == sorted([str(card) for card in deck_copy.cards]) + all_dealt_cards = [card for player in players for card in player.hand] + assert Counter(all_dealt_cards) == Counter(deck_copy.cards) # Deck should be empty after dealing assert deck.is_empty() diff --git a/packages/contrai-core/tests/test_exceptions.py b/packages/contrai-core/tests/test_exceptions.py new file mode 100644 index 0000000..9f56582 --- /dev/null +++ b/packages/contrai-core/tests/test_exceptions.py @@ -0,0 +1,176 @@ +"""Tests for the custom exception classes.""" + +import pytest + +from contrai_core import ( + Card, + ContraiError, + IllegalBidError, + IllegalPlayError, + InvalidCardCountError, + InvalidContractError, + InvalidPlayerCountError, + PlayRuleViolation, + Rank, + Suit, + TrickStateError, +) + + +ALL_DOMAIN_ERRORS = [ + InvalidPlayerCountError, + InvalidCardCountError, + IllegalBidError, + IllegalPlayError, + TrickStateError, + InvalidContractError, +] + + +class TestContraiError: + def test_base_is_exception_subclass(self): + assert issubclass(ContraiError, Exception) + + @pytest.mark.parametrize("error_cls", ALL_DOMAIN_ERRORS) + def test_domain_error_is_contrai_error(self, error_cls): + assert issubclass(error_cls, ContraiError) + + @pytest.mark.parametrize("error_cls", ALL_DOMAIN_ERRORS) + def test_domain_error_is_value_error(self, error_cls): + # ValueError stays in the MRO so legacy ``except ValueError`` and + # ``pytest.raises(ValueError)`` call sites keep working. + assert issubclass(error_cls, ValueError) + + +class TestPlayRuleViolation: + def test_three_members_exist(self): + assert {m.name for m in PlayRuleViolation} == { + "MUST_FOLLOW_SUIT", + "MUST_TRUMP", + "MUST_OVERTRUMP", + } + + 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" + + +class TestIllegalPlayError: + def test_is_contrai_and_value_error(self): + assert issubclass(IllegalPlayError, ContraiError) + assert issubclass(IllegalPlayError, ValueError) + + def test_attributes_are_stored(self): + card = Card(Suit.SPADES, Rank.EIGHT) + legal = [Card(Suit.SPADES, Rank.JACK), Card(Suit.SPADES, Rank.NINE)] + err = IllegalPlayError( + card, PlayRuleViolation.MUST_OVERTRUMP, legal, context="South card play" + ) + assert err.card is card + assert err.reason is PlayRuleViolation.MUST_OVERTRUMP + # legal_cards is coerced to a tuple for diagnostics. + assert err.legal_cards == tuple(legal) + assert isinstance(err.legal_cards, tuple) + assert err.context == "South card play" + + def test_message_with_context(self): + card = Card(Suit.SPADES, Rank.EIGHT) + err = IllegalPlayError( + card, PlayRuleViolation.MUST_TRUMP, [], context="South card play" + ) + message = str(err) + assert message.startswith("South card play: ") + assert "must_trump" in message + assert "0 legal alternative(s)" in message + + def test_message_without_context(self): + card = Card(Suit.HEARTS, Rank.KING) + err = IllegalPlayError( + card, PlayRuleViolation.MUST_FOLLOW_SUIT, [Card(Suit.HEARTS, Rank.ACE)] + ) + message = str(err) + assert not message.startswith(":") + assert "must_follow_suit" in message + assert "1 legal alternative(s)" in message + + def test_reason_round_trips(self): + err = IllegalPlayError( + Card(Suit.CLUBS, Rank.SEVEN), PlayRuleViolation.MUST_TRUMP, [] + ) + assert err.reason == PlayRuleViolation.MUST_TRUMP + assert err.reason == "must_trump" + + +class TestTrickStateError: + def test_is_contrai_and_value_error(self): + assert issubclass(TrickStateError, ContraiError) + assert issubclass(TrickStateError, ValueError) + + def test_message_without_context(self): + err = TrickStateError("Cannot add a card to a complete trick") + assert str(err) == "Cannot add a card to a complete trick" + assert err.context == "" + + def test_message_with_context(self): + err = TrickStateError("Cannot add a card to a complete trick", context="Trick.add_play") + assert str(err) == "Trick.add_play: Cannot add a card to a complete trick" + assert err.context == "Trick.add_play" + + +class TestInvalidContractError: + def test_is_contrai_and_value_error(self): + assert issubclass(InvalidContractError, ContraiError) + assert issubclass(InvalidContractError, ValueError) + + def test_message_without_context(self): + err = InvalidContractError("Invalid contract value: 70") + assert str(err) == "Invalid contract value: 70" + assert err.context == "" + + def test_message_with_context(self): + err = InvalidContractError("Invalid trump suit: Spades", context="ContractBid") + assert str(err) == "ContractBid: Invalid trump suit: Spades" + assert err.context == "ContractBid" + + +class TestInvalidPlayerCountError: + def test_is_value_error_subclass(self): + assert issubclass(InvalidPlayerCountError, ValueError) + + def test_attributes_are_stored(self): + err = InvalidPlayerCountError(expected_count=4, actual_count=3, context="Deck.deal") + assert err.expected_count == 4 + assert err.actual_count == 3 + assert err.context == "Deck.deal" + + def test_message_with_context(self): + err = InvalidPlayerCountError(4, 3, "Deck.deal") + assert str(err) == "Deck.deal: Expected 4 players, got 3" + + def test_message_without_context(self): + err = InvalidPlayerCountError(4, 3) + assert str(err) == "Expected 4 players, got 3" + + def test_can_be_raised_and_caught_as_value_error(self): + with pytest.raises(ValueError, match="Expected 2 players, got 0"): + raise InvalidPlayerCountError(2, 0) + + +class TestInvalidCardCountError: + def test_is_value_error_subclass(self): + assert issubclass(InvalidCardCountError, ValueError) + + def test_attributes_are_stored(self): + err = InvalidCardCountError(expected_count=32, actual_count=20, context="Deck.deal") + assert err.expected_count == 32 + assert err.actual_count == 20 + assert err.context == "Deck.deal" + + def test_message_with_context(self): + err = InvalidCardCountError(32, 20, "Deck.deal") + assert str(err) == "Deck.deal: Expected 32 cards, got 20" + + def test_message_without_context(self): + err = InvalidCardCountError(32, 20) + assert str(err) == "Expected 32 cards, got 20" diff --git a/packages/contrai-core/tests/test_hand.py b/packages/contrai-core/tests/test_hand.py index 4b403d5..b979880 100644 --- a/packages/contrai-core/tests/test_hand.py +++ b/packages/contrai-core/tests/test_hand.py @@ -188,6 +188,23 @@ def test_has_card_hit_and_miss(sample_cards): assert h.has_card(Suit.SPADES, Rank.JACK) is False # right suit, wrong rank +def test_has_suit_present_and_absent(sample_cards): + h = Hand(sample_cards) + assert h.has_suit(Suit.SPADES) is True + assert h.has_suit(Suit.HEARTS) is True + assert h.has_suit(Suit.DIAMONDS) is False + assert h.has_suit(Suit.CLUBS) is False + + +def test_has_card_delegates_to_membership(sample_cards): + """``has_card`` agrees with ``Card(...) in hand`` for a hit and a miss.""" + h = Hand(sample_cards) + hit = Card(Suit.SPADES, Rank.ACE) + miss = Card(Suit.CLUBS, Rank.SEVEN) + assert h.has_card(hit.suit, hit.rank) == (hit in h) is True + assert h.has_card(miss.suit, miss.rank) == (miss in h) is False + + def test_cards_of_suit_returns_matching_cards_in_order(sample_cards): h = Hand(sample_cards) spades = h.cards_of_suit(Suit.SPADES) @@ -250,6 +267,28 @@ def test_is_complete_false_when_8_cards_but_duplicate(): assert Hand(cards).is_complete() is False +# ---------------------------------------------------------------------- +# copy +# ---------------------------------------------------------------------- + + +def test_copy_returns_list_of_same_cards(sample_cards): + h = Hand(sample_cards) + snapshot = h.copy() + assert isinstance(snapshot, list) + assert snapshot == list(sample_cards) + + +def test_copy_is_independent_of_hand(sample_cards): + """Mutating the copy must not affect the hand or vice versa.""" + h = Hand(sample_cards) + snapshot = h.copy() + snapshot.pop() + assert len(h) == len(sample_cards) + h.append(Card(Suit.CLUBS, Rank.NINE)) + assert len(snapshot) == len(sample_cards) - 1 + + # ---------------------------------------------------------------------- # repr # ---------------------------------------------------------------------- diff --git a/packages/contrai-core/tests/test_team.py b/packages/contrai-core/tests/test_team.py index 5276a10..8e99e25 100644 --- a/packages/contrai-core/tests/test_team.py +++ b/packages/contrai-core/tests/test_team.py @@ -1,6 +1,6 @@ import pytest -from contrai_core.team import Team -from contrai_core.exceptions import InvalidPlayerCountError + +from contrai_core import InvalidPlayerCountError, Team class DummyPlayer: """Dummy player class for testing purposes.""" diff --git a/packages/contrai-core/tests/test_trick.py b/packages/contrai-core/tests/test_trick.py new file mode 100644 index 0000000..38b2ad4 --- /dev/null +++ b/packages/contrai-core/tests/test_trick.py @@ -0,0 +1,253 @@ +"""Tests for the Trick class. + +Covers add_play (incl. completeness guard), get_plays copy semantics, +get_led_suit, the __len__/is_complete invariants, and get_current_winner +across the lead-suit, trump-beats-non-trump, and trump-over-trump +scenarios. +""" + +import pytest + +from contrai_core import BasePlayer, Card, Rank, Suit, TrickStateError, Trick + + +@pytest.fixture +def north(): + return BasePlayer("North", "North") + + +@pytest.fixture +def east(): + return BasePlayer("East", "East") + + +@pytest.fixture +def south(): + return BasePlayer("South", "South") + + +@pytest.fixture +def west(): + return BasePlayer("West", "West") + + +# --------------------------------------------------------------------------- +# Construction & basic queries +# --------------------------------------------------------------------------- + + +class TestTrickConstruction: + def test_default_construction(self): + trick = Trick() + assert trick.plays == [] + assert trick.is_complete() is False + assert len(trick) == 0 + + def test_trick_does_not_own_trump(self): + # Trump is round-level state on the Contract, never stored on a + # Trick. It is passed to get_current_winner at call time instead. + trick = Trick() + assert not hasattr(trick, "trump_suit") + + +# --------------------------------------------------------------------------- +# add_play and completion guards +# --------------------------------------------------------------------------- + + +class TestTrickAddPlay: + def test_add_single_play(self, north): + trick = Trick() + card = Card(Suit.SPADES, Rank.ACE) + trick.add_play(north, card) + assert len(trick) == 1 + assert trick.plays == [(north, card)] + + def test_add_four_plays_completes_trick(self, north, east, south, west): + trick = Trick() + for player, rank in [ + (north, Rank.ACE), + (east, Rank.KING), + (south, Rank.QUEEN), + (west, Rank.JACK), + ]: + trick.add_play(player, Card(Suit.SPADES, rank)) + assert trick.is_complete() is True + assert len(trick) == 4 + + def test_add_play_raises_when_complete(self, north, east, south, west): + trick = Trick() + for player, rank in [ + (north, Rank.ACE), + (east, Rank.KING), + (south, Rank.QUEEN), + (west, Rank.JACK), + ]: + trick.add_play(player, Card(Suit.SPADES, rank)) + with pytest.raises(TrickStateError, match="complete trick"): + trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) + + +# --------------------------------------------------------------------------- +# get_plays / get_cards / get_led_suit +# --------------------------------------------------------------------------- + + +class TestTrickAccessors: + def test_get_plays_returns_copy(self, north): + trick = Trick() + card = Card(Suit.HEARTS, Rank.SEVEN) + trick.add_play(north, card) + plays = trick.get_plays() + plays.clear() + # Mutating the returned list must not affect the trick. + assert len(trick) == 1 + + def test_get_cards_returns_only_cards(self, north, east): + trick = Trick() + c1 = Card(Suit.HEARTS, Rank.SEVEN) + c2 = Card(Suit.HEARTS, Rank.KING) + trick.add_play(north, c1) + trick.add_play(east, c2) + assert trick.get_cards() == [c1, c2] + + def test_get_led_suit_empty(self): + assert Trick().get_led_suit() is None + + def test_get_led_suit_returns_first_card_suit(self, north, east): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) + # Subsequent cards shouldn't change the lead. + trick.add_play(east, Card(Suit.SPADES, Rank.ACE)) + assert trick.get_led_suit() is Suit.HEARTS + + +# --------------------------------------------------------------------------- +# get_current_winner — full-trick scenarios +# +# Trump is always passed explicitly at call time; the engine builds +# ``Trick()`` without binding a trump and the contract carries the +# authoritative suit. +# --------------------------------------------------------------------------- + + +class TestTrickWinnerNoTrump: + def test_empty_trick_no_winner(self): + assert Trick().get_current_winner(None) is None + + def test_highest_in_lead_suit_wins(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)) # best + trick.add_play(south, Card(Suit.HEARTS, Rank.KING)) + trick.add_play(west, Card(Suit.HEARTS, Rank.JACK)) + assert trick.get_current_winner(None) is east + + def test_off_suit_cards_cannot_win(self, north, east, south, west): + """Cards not in lead suit (and not trump) never win — only the + lead-suit cards compete.""" + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) # leads + trick.add_play(east, Card(Suit.SPADES, Rank.ACE)) # off-suit, ignored + trick.add_play(south, Card(Suit.DIAMONDS, Rank.ACE)) # off-suit, ignored + trick.add_play(west, Card(Suit.CLUBS, Rank.ACE)) # off-suit, ignored + assert trick.get_current_winner(None) is north + + +class TestTrickWinnerWithTrump: + def test_trump_beats_non_trump(self, north, east, south, west): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) # leads + trick.add_play(east, Card(Suit.CLUBS, Rank.SEVEN)) # weakest trump + trick.add_play(south, Card(Suit.HEARTS, Rank.KING)) # follows lead + trick.add_play(west, Card(Suit.HEARTS, Rank.JACK)) # follows lead + # The seven of clubs is the only trump and wins despite being the + # weakest physical card on the table. + assert trick.get_current_winner(Suit.CLUBS) is east + + def test_higher_trump_beats_lower_trump(self, north, east, south, west): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) # leads, non-trump + trick.add_play(east, Card(Suit.SPADES, Rank.SEVEN)) # weak trump + trick.add_play(south, Card(Suit.SPADES, Rank.JACK)) # master trump + trick.add_play(west, Card(Suit.SPADES, Rank.NINE)) # second-best trump + # Trump order: Jack > 9 > Ace > 10 > King > Queen > 8 > 7. + assert trick.get_current_winner(Suit.SPADES) is south + + def test_trump_lead_highest_trump_wins(self, north, east, south, west): + trick = Trick() + trick.add_play(north, Card(Suit.SPADES, Rank.SEVEN)) # leads trump + trick.add_play(east, Card(Suit.SPADES, Rank.ACE)) + trick.add_play(south, Card(Suit.SPADES, Rank.JACK)) # winner + trick.add_play(west, Card(Suit.SPADES, Rank.NINE)) + assert trick.get_current_winner(Suit.SPADES) is south + + def test_first_card_wins_if_no_one_else_follows_or_trumps( + self, north, east, south, west + ): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.SEVEN)) # leads, low + trick.add_play(east, Card(Suit.DIAMONDS, Rank.ACE)) # off-suit, no trump + trick.add_play(south, Card(Suit.CLUBS, Rank.ACE)) # off-suit, no trump + trick.add_play(west, Card(Suit.DIAMONDS, Rank.KING)) # off-suit, no trump + assert trick.get_current_winner(Suit.SPADES) is north + + +# --------------------------------------------------------------------------- +# get_current_winner — partial tricks (winner mid-play, before completion) +# --------------------------------------------------------------------------- + + +class TestTrickCurrentWinner: + def test_empty_returns_none(self): + assert Trick().get_current_winner(Suit.HEARTS) is None + + def test_partial_trick_partner_still_master(self, north, east): + """Two cards in: lead Ace still beats follow-suit seven.""" + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + trick.add_play(east, Card(Suit.HEARTS, Rank.SEVEN)) + assert trick.get_current_winner(Suit.SPADES) is north + + def test_partial_trick_opponent_overtrumps_partner( + self, north, east + ): + """Partner (N) led the Ace of hearts; an opponent (E) trumped low + with the seven of spades. E is now master even though N's card + outranks it absolutely.""" + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + trick.add_play(east, Card(Suit.SPADES, Rank.SEVEN)) + assert trick.get_current_winner(Suit.SPADES) is east + + def test_winner_governed_entirely_by_passed_trump(self, north, east): + """Trump is decided solely by the call-time argument — the trick + stores none. SPADES passed in makes the seven of spades the only + trump, so it wins despite the hearts Ace outranking it absolutely.""" + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + trick.add_play(east, Card(Suit.SPADES, Rank.SEVEN)) + assert trick.get_current_winner(Suit.SPADES) is east + + def test_no_trump_argument_falls_back_to_lead_suit(self, north, east): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + trick.add_play(east, Card(Suit.SPADES, Rank.SEVEN)) + # No trump: spade can't beat lead-suit ace. + assert trick.get_current_winner(None) is north + + def test_no_trump_enum_treated_as_non_trump(self, north, east): + """``Suit.NO_TRUMP`` is what the engine passes for a no-trump + contract; no card carries that suit, so play reduces to the + follow-suit rule exactly as passing ``None`` does.""" + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) + trick.add_play(east, Card(Suit.SPADES, Rank.SEVEN)) + assert trick.get_current_winner(Suit.NO_TRUMP) is north + + def test_higher_trump_takes_over(self, north, east, south): + trick = Trick() + trick.add_play(north, Card(Suit.HEARTS, Rank.ACE)) # lead + 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 diff --git a/packages/contrai-core/tests/test_types.py b/packages/contrai-core/tests/test_types.py new file mode 100644 index 0000000..8f59668 --- /dev/null +++ b/packages/contrai-core/tests/test_types.py @@ -0,0 +1,51 @@ +"""Tests for the Suit / Rank enums and CARD_SUITS tuple.""" + +from contrai_core import CARD_SUITS, Rank, Suit + + +class TestSuit: + def test_expected_members(self): + names = {s.name for s in Suit} + assert names == {"SPADES", "HEARTS", "DIAMONDS", "CLUBS", "NO_TRUMP", "ALL_TRUMP"} + + def test_values_preserve_display_strings(self): + assert Suit.SPADES.value == "Spades" + assert Suit.HEARTS.value == "Hearts" + assert Suit.DIAMONDS.value == "Diamonds" + assert Suit.CLUBS.value == "Clubs" + assert Suit.NO_TRUMP.value == "NoTrump" + assert Suit.ALL_TRUMP.value == "AllTrump" + + +class TestRank: + def test_expected_members(self): + names = {r.name for r in Rank} + assert names == { + "SEVEN", + "EIGHT", + "NINE", + "TEN", + "JACK", + "QUEEN", + "KING", + "ACE", + } + + def test_values_preserve_display_strings(self): + # str(card) relies on these values — see card.py:90. + assert Rank.SEVEN.value == "7" + assert Rank.TEN.value == "10" + assert Rank.JACK.value == "Jack" + assert Rank.ACE.value == "Ace" + + +class TestCardSuits: + def test_excludes_no_trump(self): + assert Suit.NO_TRUMP not in CARD_SUITS + + def test_order_matches_documented_preference(self): + # Spades > Hearts > Diamonds > Clubs. + assert CARD_SUITS == (Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS) + + def test_length_is_four(self): + assert len(CARD_SUITS) == 4 diff --git a/packages/contrai-engine/pyproject.toml b/packages/contrai-engine/pyproject.toml index 6d7efe0..a6d2964 100644 --- a/packages/contrai-engine/pyproject.toml +++ b/packages/contrai-engine/pyproject.toml @@ -5,8 +5,13 @@ description = "Add your description here" requires-python = ">=3.14" dependencies = [ "contrai-core", + "rich>=13", + "pyfiglet>=1.0", ] [project.optional-dependencies] test = [ "pytest>=8.4" -] \ No newline at end of file +] + +[project.scripts] +contrai = "contrai_engine.cli:main" diff --git a/packages/contrai-engine/src/contrai_engine/__init__.py b/packages/contrai-engine/src/contrai_engine/__init__.py index 6413254..a3299b2 100644 --- a/packages/contrai-engine/src/contrai_engine/__init__.py +++ b/packages/contrai-engine/src/contrai_engine/__init__.py @@ -1,2 +1,2 @@ -# Main package for the Contrée game +# Main package for the contrée game diff --git a/packages/contrai-engine/src/contrai_engine/cli.py b/packages/contrai-engine/src/contrai_engine/cli.py new file mode 100644 index 0000000..19dad6e --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/cli.py @@ -0,0 +1,78 @@ +"""``contrai`` CLI entry point. + +Drives the landing → game loop → end-game flow, wiring a +:class:`RichView` into ``Game.manage_round``. Pure orchestration — +all rendering lives in :mod:`contrai_engine.view.rich_view`. +""" + +from __future__ import annotations + +import sys + +from contrai_engine.model.game import Game +from contrai_engine.model.player import AiPlayer, HumanPlayer +from contrai_engine.view.rich_view import RichView + + +# 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). +HUMAN_SEAT = "South" +SEATS = ("North", "East", "South", "West") + + +def _build_game() -> Game: + """Instantiate a fresh Game with one HumanPlayer (South) + 3 AiPlayers.""" + players = [] + for seat in SEATS: + if seat == HUMAN_SEAT: + players.append(HumanPlayer("You", position=seat)) + else: + players.append(AiPlayer(seat, position=seat)) + return Game(players) + + +def main() -> None: + """Entry point registered as the ``contrai`` console script.""" + # Force UTF-8 stdout/stderr so suit glyphs (♠♥♦♣) render under + # cmd.exe and other code-page-1252 contexts. Modern Windows + # Terminal handles UTF-8 natively but the legacy console path + # crashes on encode without this. + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + try: + reconfigure(encoding="utf-8") + except Exception: + pass + + view = RichView() + target = view.show_landing() + try: + while True: + game = _build_game() + view.attach(game, target_score=target) + 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"] + view.show_round_recap( + game.current_round, game.scores, is_final=is_final + ) + choice = view.show_end_game(game.check_game_over(target)) + if choice == "q": + break + if choice == "n": + target = view.show_landing(selected_target=target) + # 'r' → rematch: same target, fresh game in the next loop tick. + except (KeyboardInterrupt, EOFError): + view.console.print("\nGoodbye.") + + +if __name__ == "__main__": + main() diff --git a/packages/contrai-engine/src/contrai_engine/model/game.py b/packages/contrai-engine/src/contrai_engine/model/game.py index 8b1070b..6626f06 100644 --- a/packages/contrai-engine/src/contrai_engine/model/game.py +++ b/packages/contrai-engine/src/contrai_engine/model/game.py @@ -1,4 +1,4 @@ -# Game class for the "contree" card game. +# Game class for the contrée card game. # This class manages the game state, players, teams, deck, and game logic. from contrai_core.deck import Deck @@ -12,7 +12,7 @@ class Game: """ - Represents a full game of "contree". + Represents a full game of contrée. Attributes: teams (list[Team]): The two teams playing the game. @@ -116,6 +116,11 @@ def manage_round(self, view=None): # Start new round (deal cards, set dealer, etc.) self.start_new_round() + # Notify the view that a fresh round has been dealt. Used by + # interactive views to log the deal in the rolling event log. + if view is not None and hasattr(view, 'on_round_dealt'): + view.on_round_dealt(self.current_round) + # Bidding phase - delegate to Round contract = self.current_round.manage_bidding(view) self.current_contract = contract @@ -123,6 +128,9 @@ def manage_round(self, view=None): # If no contract (all passed), handle failed contract if not contract: round_scores = self.current_round.handle_failed_contract() + # Notify the view that the round will be redealt. + 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, diff --git a/packages/contrai-engine/src/contrai_engine/model/player.py b/packages/contrai-engine/src/contrai_engine/model/player.py index f873e04..a4bd129 100644 --- a/packages/contrai-engine/src/contrai_engine/model/player.py +++ b/packages/contrai-engine/src/contrai_engine/model/player.py @@ -1,11 +1,92 @@ # Player, HumanPlayer, AiPlayer classes from abc import ABC, abstractmethod -from contrai_core.player import BasePlayer +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.types import Suit, Rank, CARD_SUITS +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): @@ -13,7 +94,22 @@ def is_human(self): return isinstance(self, HumanPlayer) @abstractmethod - def choose_bid(self, current_bids): + 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 @@ -21,10 +117,14 @@ def choose_card(self, trick, contract, playable_cards): pass class HumanPlayer(Player): - def choose_bid(self, current_bids): - # This method should be called by the controller via the view - # Example: return ('Pass') or (value, suit) or 'Double' or 'Redouble' - return None # To be implemented in controller/view + 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 @@ -35,13 +135,31 @@ 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 + Capot) + 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 """ - # Bidding table + # 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), @@ -53,20 +171,97 @@ class AiPlayer(Player): (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, current_bids): + 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. """ - Choose a bid based on simple AI strategy. + + # 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: - current_bids: List of (player, bid) tuples from the current bidding round + auction: The current (doubled) :class:`Auction` state. Returns: - str or tuple: 'Pass', 'Double', 'Redouble', or (value, suit) + 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 @@ -91,6 +286,22 @@ def choose_bid(self, current_bids): 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.""" @@ -138,6 +349,7 @@ 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 @@ -164,7 +376,7 @@ def _evaluate_suits(self): def _evaluate_suit_as_trump(self, suit): """Evaluate a specific suit as potential trump.""" - trump_cards = [card for card in self.hand if card.suit == suit] + trump_cards = self.hand.cards_of_suit(suit) if not trump_cards: return {'contract': 0, 'strength': 0, 'has_belote': False} @@ -231,19 +443,20 @@ def _estimate_tricks(self, trump_suit): if card.suit != trump_suit: if card.rank == Rank.ACE: tricks += 1 - if card.rank == Rank.TEN and self._count_cards_in_suit(card.suit) > 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._suit_has_rank(card.suit, Rank.ACE)\ - and self._suit_has_rank(card.suit, Rank.TEN): + 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 - @staticmethod - def _can_overbid_partner(partner_bid, suit_evaluations): + @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 @@ -272,13 +485,16 @@ def _make_initial_bid(self, suit_evaluations, last_bid): # 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) - return max_contract, chosen_suit + # 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.""" @@ -294,7 +510,7 @@ def _support_partner_bid(self, partner_bid, last_bid): contribution += 10 # +10 if we have trump complement (Jack or 9) - trump_cards = [card for card in self.hand if card.suit == partner_suit] + 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) @@ -303,13 +519,32 @@ def _support_partner_bid(self, partner_bid, last_bid): # Calculate new bid value last_value, _ = last_bid + last_value = self._bid_value_numeric(last_value) new_value = last_value + contribution - # Don't bid beyond 160 - if new_value > 160 or contribution == 0: + # 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' - return new_value, partner_suit + 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.""" @@ -336,7 +571,7 @@ def _choose_best_suit(self, candidate_suits, suit_evaluations): def _evaluate_trump_tricks(self, suit): """Evaluate potential tricks won with trump suit.""" - trump_cards = [card for card in self.hand if card.suit == suit] + trump_cards = self.hand.cards_of_suit(suit) expected_won_tricks = 0 has_jack = False @@ -373,6 +608,13 @@ def choose_card(self, trick, contract, playable_cards): 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 @@ -434,9 +676,9 @@ def _play_first_card(self, game, contract, playable_cards): def _play_opening_card(self, contract, playable_cards): """Play the very first card of the round.""" - trump_suit = contract[2] if contract else None + trump_suit = contract.suit if contract else None - if contract[0].team == self.team: + 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: @@ -451,7 +693,7 @@ def _play_opening_card(self, contract, playable_cards): 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_cards_in_suit(c.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 @@ -473,10 +715,10 @@ def _play_opening_card(self, contract, playable_cards): def _play_leading_card(self, contract, playable_cards): """Play when leading subsequent tricks.""" - trump_suit = contract[2] if contract else None + 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[0].team == self.team and self._opponents_might_have_trump(trump_suit): + 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)) @@ -485,12 +727,12 @@ def _play_leading_card(self, contract, playable_cards): # 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_cards_in_suit(c.suit)) + 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._count_cards_in_suit(c.suit)) + 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 @@ -520,28 +762,51 @@ def _play_following_card(self, trick, contract, playable_cards): 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.""" - - trump_suit = contract[2] if contract else None - led_suit = trick[1][0].suit + """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() - # Try to follow suit with the highest point card + # 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)) - # Can't follow suit - play the highest point card (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 max(non_master_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[2] if contract else None - led_suit = trick[1][0].suit + 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 @@ -571,38 +836,19 @@ def _play_when_team_losing(self, trick, contract, playable_cards): 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._count_cards_in_suit(c.suit), + self.hand.count_suit(c.suit), c.get_points(trump_suit) )) return playable_cards[0] - def _count_cards_in_suit(self, suit): - """Count how many cards we have in the given suit.""" - - return sum(1 for card in self.hand if card.suit == suit) - - def _suit_has_rank(self, suit, rank): - """ - Check if the player has a specific rank in a given suit. - - Args: - suit: The suit to check (Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS) - rank: The rank to look for (Rank.SEVEN, Rank.EIGHT, Rank.NINE, Rank.TEN, Rank.JACK, Rank.QUEEN, Rank.KING, Rank.ACE) - - Returns: - bool: True if the player has the specified rank in the specified suit - """ - - return any(card.suit == suit and card.rank == rank for card in self.hand) - 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 = sum(1 for card in self.hand if card.suit == trump_suit) + 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) @@ -666,7 +912,7 @@ def _get_strongest_card_position(self, trick, trump_suit): strongest_card = self._get_strongest_card_in_trick(trick, trump_suit) # Find which player played the strongest card - for player, card in trick: + for player, card in trick.get_plays(): if card == strongest_card: return player.position @@ -679,20 +925,21 @@ def _get_strongest_card_in_trick(trick, trump_suit): if not trick: return None - led_suit = trick[1][0].suit + 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 trick.cards if c.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 trick.cards if c.suit == 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 trick.cards[0] + return cards[0] @staticmethod def _is_stronger_card(card, current_best, trump_suit): diff --git a/packages/contrai-engine/src/contrai_engine/model/round.py b/packages/contrai-engine/src/contrai_engine/model/round.py index 9db5e1e..22e04c7 100644 --- a/packages/contrai-engine/src/contrai_engine/model/round.py +++ b/packages/contrai-engine/src/contrai_engine/model/round.py @@ -1,17 +1,51 @@ -# Round class for the "contree" card game. +# 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.trick import Trick + +from contrai_core.auction import Auction +from contrai_core.bid import Bid, SlamLevel from contrai_core.contract import Contract -from contrai_core.bid import Bid, PassBid, ContractBid, DoubleBid, RedoubleBid, BidValidator -from contrai_core.types import Rank +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. @@ -42,6 +76,31 @@ def __init__(self, players_order: List['Player'], dealer: 'Player', deck: 'Deck' 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: @@ -56,83 +115,149 @@ def deal_cards(self): self.deck.deal(self.players_order) def manage_bidding(self, view=None) -> Optional[Contract]: - """ - Handle complete bidding phase. + """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 for human player interaction + view: Optional view that drives human input and pacing + hooks. Returns: - Contract: The established contract or None if all players passed + The established :class:`Contract`, or ``None`` if every + player passed. """ - bid_objects = [] # List of Bid objects - passes_count = 0 - - while True: - for player in self.players_order: - # Get bid choice from player (returns string or tuple) - if hasattr(player, 'choose_bid'): - # Convert bid objects to legacy format for compatibility - legacy_bids = [(bid.player, self._bid_to_legacy_format(bid)) for bid in bid_objects] - bid_choice = player.choose_bid(legacy_bids) - else: - bid_choice = 'Pass' - - # If view is provided and player is human, use view for input - if view and hasattr(player, 'is_human') and player.is_human: - # Convert bid objects to legacy format for view compatibility - legacy_bids = [(bid.player, self._bid_to_legacy_format(bid)) for bid in bid_objects] - bid_choice = view.request_bid_action(player, legacy_bids) - - # Create appropriate Bid object from player's choice - bid_obj = self._create_bid_from_choice(player, bid_choice) - - # Validate the bid - if BidValidator.is_bid_valid(bid_obj, bid_objects): - bid_objects.append(bid_obj) - - # Reset pass count for non-pass bids - if not isinstance(bid_obj, PassBid): - passes_count = 0 - else: - passes_count += 1 - else: - # Invalid bid - force a pass - bid_objects.append(PassBid(player)) - passes_count += 1 - - # Check for end conditions - # End if 3 passes after a valid contract/double/redouble - if passes_count >= 3 and len(bid_objects) > 3: - # Check if there's any non-pass bid - has_non_pass = any(not isinstance(bid, PassBid) for bid in bid_objects) - if has_non_pass: - break - - # Break outer loop if bidding should end - if passes_count >= 3 and len(bid_objects) > 3: - has_non_pass = any(not isinstance(bid, PassBid) for bid in bid_objects) - if has_non_pass: - break - - # If all players passed in first round - if len(bid_objects) >= 4 and all(isinstance(bid, PassBid) for bid in bid_objects[-4:]): - break - - # Create Contract from final bid sequence - contract_bid = BidValidator.get_last_contract(bid_objects) - - if contract_bid: - # Check for double and redouble - has_double = BidValidator.has_double(bid_objects) - has_redouble = BidValidator.has_redouble(bid_objects) - - self.contract = Contract(contract_bid, double=has_double, redouble=has_redouble) - else: - self.contract = None + + 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. @@ -169,18 +294,51 @@ def play_trick(self, view=None) -> Optional['Player']: 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 - if card and card in playable_cards and card in player.hand: + # 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) - elif card and playable_cards: - # Card chosen is not legal - fallback to first playable card - fallback_card = playable_cards[0] - player.hand.remove(fallback_card) - self.current_trick.add_play(player, fallback_card) - - # Determine trick winner - winner = self._determine_trick_winner(self.current_trick) + 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 @@ -195,6 +353,12 @@ def play_trick(self, view=None) -> Optional['Player']: 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]]: @@ -220,6 +384,44 @@ 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 """ @@ -227,95 +429,194 @@ def calculate_round_scores(self) -> Dict[str, int]: # 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 - is_doubled = self.contract.double - is_redoubled = self.contract.redouble 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()} - # Count card points for each team and check for belote (King + Queen of trump) - belote_teams = set() # Teams that have belote + # 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 - trump_cards_played = [] - for trick in tricks: - # Handle Trick objects if hasattr(trick, 'get_plays'): - plays = trick.get_plays() - for player, card in plays: + for _player, card in trick.get_plays(): points += card.get_points(trump_suit) - # Track trump cards played by this team - if trump_suit and card.suit == trump_suit: - trump_cards_played.append(card.rank) - - # Check for belote (King and Queen of trump suit in same round) - if trump_suit and Rank.KING in trump_cards_played and Rank.QUEEN in trump_cards_played: - points += 20 # Belote bonus - belote_teams.add(team_name) - team_card_points[team_name] = points - # Add "dix de der" (10 points for last trick) + # Add "dix de der" (10 points for last trick). if self.last_trick_winner and self.last_trick_winner.team: - last_trick_team = self.last_trick_winner.team.name - team_card_points[last_trick_team] += 10 + 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 - contract_team_points = team_card_points[contract_team_name] - # Check if contract is made - if contract_value == 'Capot': - # For Capot, team must win all tricks (all 162 points) - contract_made = contract_team_points >= 162 - else: - contract_made = contract_team_points >= contract_value - - # Calculate multiplier for double/redouble - multiplier = 1 - if is_redoubled: - multiplier = 4 - elif is_doubled: - multiplier = 2 - - # Calculate final scores - if contract_made: - # Contract successful - if is_doubled or is_redoubled: - # When contract is made with double/redouble, attacking team gets - # the same points that defending team would have gotten if contract failed - base_value = 250 if contract_value == 'Capot' else contract_value - team_scores[contract_team_name] = 160 + base_value * multiplier - - # Defending team gets their actual points (no multiplier) - for team_name, points in team_card_points.items(): - if team_name != contract_team_name: - team_scores[team_name] = points + # 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: - # Normal contract made without double/redouble - base_value = 250 if contract_value == 'Capot' else contract_value - team_scores[contract_team_name] = base_value + contract_team_points - # Opposing team gets their points - for team_name, points in team_card_points.items(): + for team_name in team_scores: if team_name != contract_team_name: - team_scores[team_name] = points + 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: - # Contract failed - team_scores[contract_team_name] = 0 # Contract team gets 0 - # Opposing team gets all points + contract value - base_value = 250 if contract_value == 'Capot' else contract_value - for team_name in team_scores: - if team_name != contract_team_name: - team_scores[team_name] = (160 + base_value) * multiplier + # 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. @@ -333,184 +634,160 @@ def handle_failed_contract(self) -> Dict[str, int]: self.round_scores = {team.name: 0 for team in teams} return self.round_scores - def _create_bid_from_choice(self, player: 'Player', choice) -> Bid: - """ - Create a Bid object from a player's choice. - - Args: - player: The player making the bid - choice: The bid choice (string or tuple) - - Returns: - Appropriate Bid object - """ - if choice == 'Pass': - return PassBid(player) - elif choice == 'Double': - return DoubleBid(player) - elif choice == 'Redouble': - return RedoubleBid(player) - elif isinstance(choice, tuple) and len(choice) == 2: - # Contract bid: (value, suit) - value, suit = choice - try: - return ContractBid(player, value, suit) - except ValueError: - # Invalid contract parameters - return pass - return PassBid(player) - else: - # Unknown bid format - return pass - return PassBid(player) - - def _bid_to_legacy_format(self, bid: Bid): - """ - Convert a Bid object to legacy format for compatibility. - - Args: - bid: Bid object to convert - - Returns: - Legacy format bid representation - """ - if isinstance(bid, PassBid): - return 'Pass' - elif isinstance(bid, DoubleBid): - return 'Double' - elif isinstance(bid, RedoubleBid): - return 'Redouble' - elif isinstance(bid, ContractBid): - return (bid.value, bid.suit) - else: - return 'Pass' - def _get_playable_cards(self, player: 'Player'): """ - Determine which cards a player can legally play based on the current trick and contract rules. + 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 + player: The player whose playable cards we want to determine. Returns: - list: List of cards that can be legally played - - Rules: - 1. Must follow suit if possible - 2. If can't follow suit and partner is not leading, must trump if possible - 3. If opponent already trumped, must play higher trump if possible - 4. If can't follow suit or trump, can play any card (discard) - 5. If partner is leading the trick, no obligation to trump when can't follow suit + list: List of cards that can be legally played. """ if not player.hand: return [] - # If no cards played yet in trick, any card is playable 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 # First card played in trick - - # Cards of the lead suit in player's hand - lead_suit_cards = [card for card in player.hand if card.suit == lead_suit] + 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 [] + ) - # If player has cards of the lead suit, must play one + # 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 - # Player doesn't have lead suit, check if partner is leading - trick_leader = plays[0][0] - player_team = player.team - partner_is_leading = trick_leader.team == player_team - - # If partner is leading, no obligation to trump - can play any card - if partner_is_leading: + # 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() - # Partner is not leading, check trump obligations + # 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: - # No trump suit or lead suit is trump, can play any card return player.hand.copy() - # Check if opponent has already played trump - trump_cards = [card for card in player.hand if card.suit == trump_suit] - - # Get highest trump played so far by opponents - highest_opponent_trump = None - player_team = player.team - - for trick_player, card in plays: - if (card.suit == trump_suit and - trick_player.team != player_team): - if (highest_opponent_trump is None or - card.get_order(trump_suit) > highest_opponent_trump.get_order(trump_suit)): - highest_opponent_trump = card - - if highest_opponent_trump: - # Opponent has trumped, must play higher trump if possible - higher_trumps = [card for card in trump_cards - if card.get_order(trump_suit) > highest_opponent_trump.get_order(trump_suit)] + # 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 - elif trump_cards: - # Must trump even if can't go higher - return trump_cards - else: - # No trump cards, can discard any card - return player.hand.copy() - else: - # No opponent trump yet if trump_cards: - # Must trump if has trump cards return trump_cards - else: - # No trump cards, can discard any card - return player.hand.copy() + return player.hand.copy() - def _determine_trick_winner(self, trick: Trick) -> Optional['Player']: - """ - Determines the winner of a trick based on the cards played. + # 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: - trick: a Trick object containing the plays + player: The player whose illegal play we are explaining. + card: The illegal card they attempted to play. Returns: - Player: The winner of the trick or None + The :class:`PlayRuleViolation` describing the broken + obligation. """ trump_suit = self.contract.suit if self.contract else None - if not trick or not hasattr(trick, 'get_plays'): - return None + plays = self.current_trick.get_plays() + lead_suit = plays[0][1].suit + lead_suit_cards = player.hand.cards_of_suit(lead_suit) - plays = trick.get_plays() - if not plays: - return None - - lead_suit = plays[0][1].suit # Suit of the first card played - - best_player = plays[0][0] - best_card = plays[0][1] - best_is_trump = trump_suit and best_card.suit == trump_suit - - for player, card in plays[1:]: - card_is_trump = trump_suit 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 - 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 + # 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/view/cli_view.py b/packages/contrai-engine/src/contrai_engine/view/cli_view.py deleted file mode 100644 index c1438a1..0000000 --- a/packages/contrai-engine/src/contrai_engine/view/cli_view.py +++ /dev/null @@ -1,2 +0,0 @@ -# CliView class: CLI display and input - diff --git a/packages/contrai-engine/src/contrai_engine/view/rich_view.py b/packages/contrai-engine/src/contrai_engine/view/rich_view.py new file mode 100644 index 0000000..1331dd5 --- /dev/null +++ b/packages/contrai-engine/src/contrai_engine/view/rich_view.py @@ -0,0 +1,2731 @@ +"""Rich-based terminal UI for the contrée game. + +Implements the five-screen design from +``ContrAI CLI/design_handoff_contrai_tui/`` (landing, bidding, +mid-trick, trick-won, game-over). Plugs into the engine through the +existing view hook points: + +- ``Round.manage_bidding(view)`` calls ``view.request_bid_action(...)`` +- ``Round.play_trick(view)`` calls ``view.request_card_action(...)`` +- After each trick, ``Round.play_trick`` calls + ``view.on_trick_complete(...)`` (added for this view). + +The view owns all rendering and human input. Per-round summaries used +by the end-game scoreboard are tracked here, not in ``Game``. +""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Optional + +from contrai_core.bid import ( + Bid, + ContractBid, + DoubleBid, + PassBid, + RedoubleBid, + SlamLevel, +) + +from contrai_core import ( + Auction, + 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 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.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) +# --------------------------------------------------------------------------- + + +@dataclass +class RoundSummary: + """One row of the end-game round-by-round table.""" + + round_number: int + contract: Optional[Contract] + contract_team_name: Optional[str] + contract_made: bool + ns_pts: int + ew_pts: int + running_ns: int + running_ew: int + + +# --------------------------------------------------------------------------- +# RichView +# --------------------------------------------------------------------------- + + +class RichView: + """Rich-based terminal UI. + + Stateful: holds the live ``console``, the per-round history used by + the end-game scoreboard, the previous trick (for the "Last trick" + panel), and a reference to the active ``Game`` so render helpers + can reach team scores without each call passing them. + """ + + LOG_MAX = 5 + + def __init__(self) -> None: + 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 + # 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 + # round N and the start of round N+1 share continuity. + self.event_log: list[Text] = [] + + # ------------------------------------------------------------------ + # Lifecycle wiring (called by the CLI) + # ------------------------------------------------------------------ + + 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 + self.history = [] + self.last_completed_trick = None + self.event_log = [] + + def reset_for_rematch(self) -> None: + """Drop per-game state but keep the console and target.""" + self.game = None + self.history = [] + self.last_completed_trick = None + self.event_log = [] + + # ------------------------------------------------------------------ + # Engine hooks + # ------------------------------------------------------------------ + + def request_bid_action( + self, player: BasePlayer, auction: Auction + ) -> Bid: + """Prompt the human for a bid. Loops until input parses. + + 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. + + Returns: + A :class:`Bid` that is guaranteed legal in ``auction`` — + the loop re-prompts on both unparseable input and bids the + 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 + ] + # 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 + # standalone message up into scrollback, where it's invisible + # until the player scrolls back. + notice: Optional[Text] = None + while True: + self._render_in_game( + phase="bidding", + current_player=player, + bidding_history=legacy_bids, + prompt_question=self._bidding_prompt_text(legacy_bids, 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, + ) + 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 + # illegal-but-parseable bid re-prompts instead of escaping to + # Auction.apply, where it would raise IllegalBidError and + # crash the CLI. + if not auction.is_legal(bid): + notice = Text( + f"✗ {_illegal_bid_reason(bid, auction)}", + style=RED, + ) + continue + return bid + + def request_card_action( + self, + player: BasePlayer, + trick: Trick, + contract: Contract, + playable_cards: list[Card], + ) -> Card: + """Prompt the human for a card. Loops until input parses.""" + trump_suit = contract.suit if contract else None + # See ``request_bid_action``: the rejection rides inside the next + # frame's Prompt panel so the ``console.clear()`` on re-render + # can't bury it in scrollback. + notice: Optional[Text] = None + while True: + sorted_hand = _sort_hand_for_display(list(player.hand), trump_suit) + self._render_in_game( + phase="playing", + current_player=player, + current_trick=trick, + playable_cards=playable_cards, + prompt_question=self._card_prompt_text( + playable_cards, len(sorted_hand) + ), + mandatory=True, + notice=notice, + ) + raw = self.console.input( + Text("> ", style=f"bold {YELLOW}").markup + ) + card = _parse_card_input(raw, sorted_hand, playable_cards) + if card is None: + notice = Text( + f"✗ Pick a number between 1 and {len(sorted_hand)} " + "matching a green-highlighted card.", + style=RED, + ) + continue + return card + + def on_trick_complete( + self, trick: Trick, winner: BasePlayer, round_: "Round" + ) -> None: + """Record the winner in the log, render the trick-won state, wait for Enter.""" + trump = round_.contract.suit if round_ and round_.contract else None + trick_points = sum(card.get_points(trump) for _, card in trick.get_plays()) + self._log(self._format_trick_won_log(winner, trick_points)) + # State 3: full trick shown, winner highlighted, Press Enter. + self._render_in_game( + phase="trick_won", + current_trick=trick, + trick_winner=winner, + prompt_question=self._trick_won_prompt_text(winner), + mandatory=False, + ) + try: + self.console.input(Text("> ", style=f"bold {GOLD}").markup) + except (EOFError, KeyboardInterrupt): + pass + # Rotate: this is now the "last trick" for the next panel. + self.last_completed_trick = (trick, winner) + + def on_round_dealt(self, round_: "Round") -> None: + """Engine hook: cards have just been dealt for a new round.""" + dealer = ( + _position_short(round_.dealer.position) + if round_ and round_.dealer + else "—" + ) + line = Text() + line.append(f"Round #{round_.round_number}: ", style=f"bold {YELLOW}") + line.append(f"{dealer} deals.", style=FG) + self._log(line) + + def on_all_pass_redeal(self, round_: "Round") -> 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: + """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(".", style=DIM) + self._log(line) + + def on_bid_made( + self, player: BasePlayer, bid: Bid, history: list + ) -> None: + """Record the bid in the event log; render+pause for AI players. + + Humans already drove the render through ``request_bid_action``; + the engine calls this hook after their input has been recorded, + so we skip the redundant frame for them. AI bids otherwise pass + 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)) + 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), + mandatory=False, + ) + time.sleep(_resolve_delay("CONTRAI_AI_BID_DELAY", default=1.4)) + + def on_card_played( + self, player: BasePlayer, card: Card, trick: Trick + ) -> None: + """Record the card in the event log; render+pause for AI players.""" + self._log(self._format_card_log(player, card)) + if getattr(player, "is_human", False): + return + self._render_in_game( + phase="playing", + current_player=None, + current_trick=trick, + prompt_question=self._ai_card_announcement(player, card), + mandatory=False, + ) + time.sleep(_resolve_delay("CONTRAI_AI_CARD_DELAY", default=0.9)) + + def on_belote_announced( + self, player: BasePlayer, kind: str, round_: "Round" + ) -> None: + """Belote / rebelote announcement: log + brief pause. + + The persistent ★ badge under the player's seat is rendered by + ``_render_diamond`` from ``round_.belote_state``, so this hook + only needs to record the moment and pace it visibly. The pause + uses the card delay so it fits the per-play rhythm.""" + trump = round_.contract.suit if round_ and round_.contract else None + line = Text() + label = _position_short(player.position) + color = _position_color(player.position) + line.append(f"{label} ", style=f"bold {color}") + line.append("announces ", style=FG) + line.append( + "Belote" if kind == "belote" else "Rebelote", + style=f"bold {GOLD}", + ) + if trump is not None: + line.append(" (", style=DIM) + line.append(_suit_glyph(trump), style=_suit_color(trump)) + line.append(").", style=DIM) + else: + line.append(".", style=DIM) + self._log(line) + 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 + ) -> None: + """Full-screen recap shown after each round; waits for Enter. + + Follows the trick-won UX pattern: clear, print the recap panel, + block on input. Called from the CLI loop after + ``on_round_complete`` for *every* round — including the one + 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. + """ + self.console.clear() + self.console.print(self._panel_round_recap(round_, running_scores)) + if is_final: + prompt_text = Text( + "Press [Enter] to see the final score…", 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)) + try: + self.console.input(Text("> ", style=f"bold {GOLD}").markup) + except (EOFError, KeyboardInterrupt): + pass + + def on_round_complete(self, round_: "Round", running_scores: dict) -> None: + """Append a row to the end-game history.""" + contract = round_.contract + ns_pts = round_.round_scores.get("North-South", 0) + ew_pts = round_.round_scores.get("East-West", 0) + running_ns = running_scores.get("North-South", 0) + running_ew = running_scores.get("East-West", 0) + if contract is None: + made = False + contract_team_name = None + else: + contract_team_name = contract.team.name + made = self._contract_made(round_) + self.history.append( + RoundSummary( + round_number=round_.round_number, + contract=contract, + contract_team_name=contract_team_name, + contract_made=made, + ns_pts=ns_pts, + ew_pts=ew_pts, + running_ns=running_ns, + running_ew=running_ew, + ) + ) + # Reset last-trick for the next round. + self.last_completed_trick = None + + # ------------------------------------------------------------------ + # CLI flow screens + # ------------------------------------------------------------------ + + 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() + 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), + mandatory=False, + )) + raw = self.console.input( + Text("> ", style=f"bold {GREEN_FG}").markup + ).strip() + if not raw: + return selected_target + try: + target = int(raw) + except ValueError: + self.console.print( + Text( + f" ✗ Pick one of " + f"{', '.join(str(v) for v, _, _ in TARGET_OPTIONS)}.", + style=RED, + ) + ) + self.console.input(Text(" Press Enter…", style=DIM).markup) + continue + if target not in {v for v, _, _ in TARGET_OPTIONS}: + self.console.print( + Text( + f" ✗ {target} is not on the list. Pick one of " + f"{', '.join(str(v) for v, _, _ in TARGET_OPTIONS)}.", + style=RED, + ) + ) + self.console.input(Text(" Press Enter…", style=DIM).markup) + continue + return target + + def show_end_game(self, status: dict) -> 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(), + mandatory=False, + )) + raw = self.console.input( + Text("> ", style=f"bold {GREEN_FG}").markup + ).strip().lower() + if raw in ("n", "new"): + return "n" + if raw in ("r", "rematch"): + return "r" + if raw in ("q", "quit", "exit"): + return "q" + self.console.print( + Text(" ✗ Pick [n] new game, [r] rematch, or [q] quit.", + style=RED) + ) + self.console.input(Text(" Press Enter…", style=DIM).markup) + + # ------------------------------------------------------------------ + # Top-level in-game render + # ------------------------------------------------------------------ + + def _render_in_game( + self, + *, + phase: str, + current_player: Optional[BasePlayer] = None, + current_trick: Optional[Trick] = None, + playable_cards: Optional[list[Card]] = None, + bidding_history: Optional[list] = None, + trick_winner: Optional[BasePlayer] = None, + prompt_question: Text = Text(""), + mandatory: bool = False, + notice: Optional[Text] = None, + ) -> None: + """Clear the screen and print all in-game panels stacked. + + ``notice`` is an optional rejection/error line (e.g. an illegal + bid or out-of-range card index) rendered inside the Prompt panel + so it survives the ``console.clear()`` that opens every frame. + """ + 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) + 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( + round_, current_trick, phase, current_player, trick_winner, + bidding_history=bidding_history, + ) + self.console.print(_two_column(mid_left, mid_right, left_width=24)) + # Hand panel — always rendered when a human is seated, so the + # slot stays put across AI bid frames, AI play frames, and the + # trick-won pause. ``interactive`` is true only when the human + # is the actively-acting player; otherwise the row is shown in + # neutral styling (no green playable pills, no constraint hint). + human = self._find_human_player() + if human is not None: + is_human_turn = ( + current_player is not None and current_player is human + ) + hand_panel = self._panel_hand( + human, current_trick, playable_cards, phase, round_, + interactive=is_human_turn, + ) + else: + 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) + 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( + 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, + ) + + def _find_human_player(self) -> Optional[BasePlayer]: + """Return the human player at the table, or ``None`` if absent. + + Used by the in-game render to decide whether to draw the hand + panel. We look up the human from the attached game rather than + the per-frame ``current_player`` so the panel stays visible + across frames where the engine has no human in focus (AI + actions, trick-won pauses). + """ + if self.game is None: + return None + for p in self.game.players: + if getattr(p, "is_human", False): + 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 + # ------------------------------------------------------------------ + + def _log(self, line: Text) -> None: + """Append a styled line and trim to ``LOG_MAX``.""" + self.event_log.append(line) + 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: + """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": + t.append("passed.", style=DIM) + elif bid == "Double": + t.append("doubled.", style=f"bold {GOLD}") + elif bid == "Redouble": + 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)) + t.append(".", style=FG) + return t + + def _format_card_log(self, player: BasePlayer, card: Card) -> Text: + label = _position_short(player.position) + color = _position_color(player.position) + t = Text() + t.append(f"{label} ", style=f"bold {color}") + t.append("plays ", style=FG) + t.append_text(_format_card_compact(card)) + t.append(".", style=FG) + return t + + def _format_trick_won_log( + self, winner: BasePlayer, trick_points: int + ) -> Text: + label = _position_short(winner.position) + color = _position_color(winner.position) + t = Text() + t.append(f"{label} ", style=f"bold {color}") + 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/tests/test_model/test_player.py b/packages/contrai-engine/tests/test_model/test_player.py index 8fc61b5..2ce5919 100644 --- a/packages/contrai-engine/tests/test_model/test_player.py +++ b/packages/contrai-engine/tests/test_model/test_player.py @@ -1,13 +1,81 @@ # Unit tests for the Player classes (Player, HumanPlayer, AiPlayer) import pytest -from contrai_engine.model.player import HumanPlayer, AiPlayer -from contrai_core import Hand +from contrai_engine.model.player import HumanPlayer, AiPlayer, wire_to_bid +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. + + ``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. + """ + 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_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) + + class TestPlayer: """Test the abstract Player class""" @@ -201,30 +269,28 @@ def test_get_partner_bid(self, ai_player, ai_opponent_player): 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([]) - assert bid == 'Pass' + 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([]) + bid = ai_player.choose_bid(_auction()) - assert isinstance(bid, tuple) - value, suit = bid - assert value == 130 - assert suit == Suit.SPADES + 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 - current_bids = [(ai_opponent_player, (90, Suit.HEARTS))] - bid = ai_player.choose_bid(current_bids) + auction = _auction([(ai_opponent_player, (90, Suit.HEARTS))]) + bid = ai_player.choose_bid(auction) - assert isinstance(bid, tuple) - value, suit = bid - assert value > 90 - assert suit == Suit.SPADES + 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""" @@ -242,14 +308,16 @@ def test_choose_bid_support_partner(self, ai_player, ai_opponent_player): # Partner bids 80 in Spades partner = ai_player.team.players[1] - current_bids = [(partner, (80, Suit.SPADES)), (ai_opponent_player,'Pass')] - bid = ai_player.choose_bid(current_bids) + 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, tuple) - value, suit = bid - assert value >= 100 # 80 + 20 (2 aces) + 10 (trump complement) - assert suit == Suit.SPADES + 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""" @@ -257,10 +325,226 @@ def test_choose_bid_cant_overbid_partner(self, ai_player, ai_opponent_player, sa # Partner bids high partner = ai_player.team.players[1] - current_bids = [(partner, (140, Suit.SPADES)), (ai_opponent_player,'Pass')] - bid = ai_player.choose_bid(current_bids) + 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), + ]) - assert bid == 'Pass' + 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""" @@ -342,10 +626,10 @@ def test_should_double_with_external_strength(self, ai_players_with_teams): ]) # Opponent bids in Spades - current_bids = [(opponent1, (120, Suit.SPADES))] - bid = player.choose_bid(current_bids) + auction = _auction([(opponent1, (120, Suit.SPADES))]) + bid = player.choose_bid(auction) - assert bid == 'Double' + assert isinstance(bid, DoubleBid) def test_should_not_double_weak_external(self, ai_players_with_teams): """Test not doubling when lacking external strength""" @@ -364,10 +648,10 @@ def test_should_not_double_weak_external(self, ai_players_with_teams): ]) # Opponent bids in Hearts - current_bids = [(opponent1, (100, Suit.HEARTS))] - bid = player.choose_bid(current_bids) + auction = _auction([(opponent1, (100, Suit.HEARTS))]) + bid = player.choose_bid(auction) - assert bid == 'Pass' + assert isinstance(bid, PassBid) class TestAiPlayerTrickTaking: """Test AI player trick taking strategy""" @@ -396,12 +680,33 @@ def ai_player_opponent(self): @pytest.fixture def mock_trick(self): - """Create a mock trick object""" + """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 @@ -421,7 +726,7 @@ def sample_hand_mixed(self): 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 = (ai_player_with_tracking, 80, Suit.SPADES) + 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) @@ -431,7 +736,7 @@ def test_play_first_card_opening_round(self, ai_player_with_tracking, mock_trick 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 = (ai_player_opponent, 100, Suit.HEARTS) + 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) @@ -446,7 +751,7 @@ def test_play_leading_card_with_trump_remaining(self, ai_player_with_tracking, m Card(Suit.HEARTS, Rank.ACE), Card(Suit.DIAMONDS, Rank.EIGHT) ]) - contract = (ai_player_with_tracking, 100, Suit.SPADES) + 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) @@ -465,7 +770,7 @@ def test_play_leading_card_no_trump_remaining(self, ai_player_with_tracking, moc Card(Suit.DIAMONDS, Rank.ACE), Card(Suit.CLUBS, Rank.SEVEN) ]) - contract = (ai_player_with_tracking, 100, Suit.SPADES) + 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) @@ -476,6 +781,123 @@ def test_play_leading_card_no_trump_remaining(self, ai_player_with_tracking, moc 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([ @@ -493,7 +915,7 @@ def test_follow_suit_when_team_winning(self, ai_player_with_tracking, mock_trick 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, (ai_player_with_tracking, 100, Suit.SPADES), playable_cards) + 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 @@ -515,7 +937,7 @@ def test_follow_suit_when_team_losing_can_beat(self, ai_player_with_tracking, mo 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, (ai_player_with_tracking, 100, Suit.SPADES), playable_cards) + 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 @@ -537,7 +959,7 @@ def test_follow_suit_when_team_losing_cannot_beat(self, ai_player_with_tracking, 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, (ai_player_with_tracking, 100, Suit.SPADES), playable_cards) + 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 @@ -560,7 +982,7 @@ def test_trump_when_cannot_follow_suit(self, ai_player_with_tracking, mock_trick 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, (ai_player_with_tracking, 100, Suit.SPADES), playable_cards) + 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 @@ -584,7 +1006,7 @@ def test_discard_when_cannot_follow_or_trump(self, ai_player_with_tracking, mock 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, (ai_player_with_tracking, 100, Suit.SPADES), playable_cards) + 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 @@ -664,13 +1086,13 @@ def test_team_winning_trick_detection(self, ai_player_with_tracking, mock_trick) # 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: 1 # Position 1 winning + 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: 2 # Position 2 winning + 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 diff --git a/packages/contrai-engine/tests/test_model/test_round.py b/packages/contrai-engine/tests/test_model/test_round.py new file mode 100644 index 0000000..6f6fb4f --- /dev/null +++ b/packages/contrai-engine/tests/test_model/test_round.py @@ -0,0 +1,1283 @@ +"""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. +""" + +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.card import Card +from contrai_core.contract import Contract +from contrai_core.exceptions import IllegalPlayError, PlayRuleViolation +from contrai_core.team import Team +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 + + +# --------------------------------------------------------------------------- +# Fixtures — four positioned players + their teams +# --------------------------------------------------------------------------- + + +@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} + + +def _make_round(players_dict, hands, contract, plays): + """Build a ``Round`` wired to the supplied state. + + Args: + players_dict: mapping of seat letter → Player (from the + ``players`` fixture). + hands: mapping of seat letter → list of Cards in that player's + hand. + contract: a Contract object (provides trump) or None. + plays: ordered list of (seat_letter, Card) tuples — the cards + already played in the current trick. + + Returns: + A Round whose ``current_trick`` reflects ``plays`` and whose + ``players_order`` is the four players in N/E/S/W order. + """ + 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_.contract = contract + round_.current_trick = Trick() + for seat, card in plays: + round_.current_trick.add_play(players_dict[seat], card) + return round_ + + +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.""" + + 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)] + round_ = _make_round( + players, + {"N": [], "E": [], "S": hand, "W": []}, + contract, + [("N", Card(Suit.SPADES, Rank.SEVEN)), + ("E", Card(Suit.SPADES, Rank.ACE))], + ) + 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))], + ) + # 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))], + ) + legal = round_._get_playable_cards(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)] + round_ = _make_round( + players, + {"N": [], "E": [], "S": hand, "W": []}, + contract, + [("N", Card(Suit.SPADES, Rank.SEVEN)), + ("E", Card(Suit.SPADES, Rank.ACE))], + ) + legal = round_._get_playable_cards(players["S"]) + assert set(legal) == set(hand) + + +# --------------------------------------------------------------------------- +# Sanity scenarios for non-trump-led tricks (regression coverage so +# the over-trump fix doesn't drift into the wrong branch) +# --------------------------------------------------------------------------- + + +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 + ] + 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), + } + + 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) + + 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)} + + 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).""" + 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)} + + 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), + ] + round_ = _make_round( + players, + {"N": [], "E": [], "S": hand, "W": []}, + contract, + [("E", Card(Suit.HEARTS, Rank.KING)), + ("N", Card(Suit.HEARTS, Rank.SEVEN))], + ) + legal = round_._get_playable_cards(players["S"]) + assert set(legal) == {Card(Suit.HEARTS, Rank.ACE)} + + +# --------------------------------------------------------------------------- +# Illegal-play classifier — _classify_play_violation +# --------------------------------------------------------------------------- +# +# 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 + ) + + 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.""" + contract = _contract(players["N"], 100, Suit.SPADES) + illegal = Card(Suit.SPADES, Rank.EIGHT) + hand = [Card(Suit.SPADES, Rank.JACK), illegal] + round_ = _make_round( + players, + {"N": [], "E": [], "S": hand, "W": []}, + contract, + [("N", Card(Suit.SPADES, Rank.SEVEN)), + ("E", Card(Suit.SPADES, Rank.ACE))], + ) + assert ( + round_._classify_play_violation(players["S"], illegal) + == 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 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 + ) + + 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 + ) + + +class TestPlayTrickRejectsIllegalCard: + """play_trick raises IllegalPlayError instead of silently correcting + an illegal card returned by choose_card.""" + + def test_illegal_card_raises_illegal_play_error(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 + round_ = _make_round( + players, + {"N": [n_card], "E": [e_illegal, e_follow], "S": [], "W": []}, + 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 + ) + + 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)} + + +# --------------------------------------------------------------------------- +# Belote / rebelote tracking +# --------------------------------------------------------------------------- + + +class TestBeloteHolderDetection: + """``_detect_belote_holder`` finds the player holding K+Q of trump.""" + + def test_sets_belote_holder_when_pair_present(self, players): + contract = _contract(players["N"], 100, Suit.HEARTS) + round_ = _make_round( + players, + { + "N": [], + "E": [], + "S": [ + Card(Suit.HEARTS, Rank.KING), + Card(Suit.HEARTS, Rank.QUEEN), + ], + "W": [], + }, + contract, + [], + ) + round_._detect_belote_holder() + assert round_.belote_holder is players["S"] + + def test_no_holder_when_pair_split(self, players): + contract = _contract(players["N"], 100, Suit.HEARTS) + round_ = _make_round( + players, + { + "N": [Card(Suit.HEARTS, Rank.KING)], + "E": [], + "S": [Card(Suit.HEARTS, Rank.QUEEN)], + "W": [], + }, + contract, + [], + ) + round_._detect_belote_holder() + assert round_.belote_holder is None + + def test_no_holder_at_no_trump(self, players): + contract = _contract(players["N"], 100, Suit.NO_TRUMP) + round_ = _make_round( + players, + { + "N": [], + "E": [], + "S": [ + Card(Suit.HEARTS, Rank.KING), + Card(Suit.HEARTS, Rank.QUEEN), + Card(Suit.SPADES, Rank.KING), + Card(Suit.SPADES, Rank.QUEEN), + ], + "W": [], + }, + contract, + [], + ) + round_._detect_belote_holder() + assert round_.belote_holder is None + + +class TestBeloteTransition: + """State machine for belote → rebelote announcements.""" + + def _setup(self, players): + contract = _contract(players["N"], 100, Suit.HEARTS) + # South holds both K♥ and Q♥ plus filler. + round_ = _make_round( + players, + { + "N": [], + "E": [], + "S": [ + Card(Suit.HEARTS, Rank.KING), + Card(Suit.HEARTS, Rank.QUEEN), + Card(Suit.SPADES, Rank.SEVEN), + ], + "W": [], + }, + contract, + [], + ) + round_.belote_holder = players["S"] + return round_ + + def test_first_play_returns_belote(self, players): + round_ = self._setup(players) + card = Card(Suit.HEARTS, Rank.KING) + assert round_._is_belote_event(players["S"], card) is True + kind = round_._transition_belote_state(players["S"]) + assert kind == "belote" + assert round_.belote_state == {players["S"]: "belote"} + + def test_second_play_returns_rebelote(self, players): + round_ = self._setup(players) + round_._transition_belote_state(players["S"]) # first → belote + kind = round_._transition_belote_state(players["S"]) + assert kind == "rebelote" + assert round_.belote_state == {players["S"]: "rebelote"} + + def test_non_kq_trump_not_an_event(self, players): + round_ = self._setup(players) + # Seven of trump is not part of the pair. + assert ( + round_._is_belote_event( + players["S"], Card(Suit.HEARTS, Rank.SEVEN) + ) + is False + ) + + def test_non_holder_not_an_event(self, players): + round_ = self._setup(players) + # N plays K♥ — but N is not the belote holder. + assert ( + round_._is_belote_event(players["N"], Card(Suit.HEARTS, Rank.KING)) + is False + ) + + +# --------------------------------------------------------------------------- +# Auto-pass when partner has doubled / redoubled (end-to-end) +# --------------------------------------------------------------------------- +# +# The unit-level "only Pass is legal" cases moved to +# ``packages/contrai-core/tests/test_auction.py`` (see +# ``TestLegalActions``) when the auction logic moved to +# :class:`contrai_core.Auction`. The remaining test here is the +# integration story: even when an auto-pass case applies for the human +# seat, Round must never call ``view.request_bid_action`` — that is the +# UX promise the player sees as "I am not asked to confirm Pass". + + +def _empty_round(players_dict): + """A Round with no contract / no trick — enough for bidding helpers.""" + order = [players_dict[s] for s in ("N", "E", "S", "W")] + return Round(order, dealer=players_dict["N"], deck=None, round_number=1) + + +class TestManageBiddingAutoPasses: + """End-to-end: the manage_bidding loop never asks the view when + the player should be auto-passed.""" + + def test_human_is_not_prompted_after_partner_double(self, players): + """Stub view that records request_bid_action calls. Pre-script + a bidding sequence that lands the human (S) right after their + partner (N) doubled the opponents' bid. + + Sequence (cyclic order W → N → E → S): + 1. W: 100 ♥ + 2. N (S's partner): Double ← DoubleBid is valid only + immediately after the + ContractBid, so the + doubler MUST be next in + cycle after the contractor. + 3. E (W's partner, contracting team): pass + 4. S (HUMAN): AUTO-PASS — partner doubled + 5. W: pass (now passes_count = 3 → bidding ends) + """ + # Make S a HumanPlayer so the view path is exercised. + human = HumanPlayer("You", "South") + 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``. + scripted = { + players["W"]: [(100, Suit.HEARTS), "Pass", "Pass", "Pass"], + players["N"]: ["Double", "Pass", "Pass", "Pass"], + players["E"]: ["Pass", "Pass", "Pass", "Pass"], + } + 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" + ) + + # Stub view: records request_bid_action calls. Asserting it + # is NEVER called is the whole point of the test. + prompts = [] + + class _View: + def request_bid_action(self, player, auction): + prompts.append((player, list(auction.bids))) + return PassBid(player) + + round_ = _empty_round(players) + # Cycle order: W → N → E → S (dealer is S, so the next player + # after the dealer leads). + round_.players_order = [ + players["W"], players["N"], players["E"], players["S"], + ] + + contract = round_.manage_bidding(view=_View()) + + # W contracted 100 ♥; N (S's partner) doubled. + assert contract is not None + assert contract.value == 100 + assert contract.suit == Suit.HEARTS + assert contract.double is True + # 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_view/__init__.py b/packages/contrai-engine/tests/test_view/__init__.py new file mode 100644 index 0000000..b123d72 --- /dev/null +++ b/packages/contrai-engine/tests/test_view/__init__.py @@ -0,0 +1 @@ +# View-layer tests diff --git a/packages/contrai-engine/tests/test_view/test_rich_view.py b/packages/contrai-engine/tests/test_view/test_rich_view.py new file mode 100644 index 0000000..682bb17 --- /dev/null +++ b/packages/contrai-engine/tests/test_view/test_rich_view.py @@ -0,0 +1,2689 @@ +"""Tests for the pure helpers in :mod:`contrai_engine.view.rich_view`. + +We cover the four functions that have real branching logic and would +break silently if the engine APIs around them shifted: + +- ``_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. +""" + +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, +) + + +# ---------------------------------------------------------------------- +# 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 + + +# ====================================================================== +# _redouble_available_to + adaptive bid prompt +# ====================================================================== + + +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.""" + + def _prompt(self, history, next_player): + view = RichView() + return view._bidding_prompt_text(history, 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) + assert "double" not in text + assert "redouble" not in text + assert "80 H" in text and "pass" in text + + 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) + assert "redouble" in text + # The default '80 H' example shouldn't appear in the redouble + # variant since the only meaningful play is pass/redouble. + assert "80 H" not in text + + def test_no_double_hint_when_own_partner_holds_contract( + self, four_players + ): + """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"), + ] + # South is North's partner — doubling own side is illegal. + _, _, south, _ = four_players + text = self._prompt(history, 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. + assert "100 H" in text and "pass" in text + assert "80 H" not in text + + 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) + 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) + assert "100 H" in text + assert "80 H" not in text and "90 H" not in text + + 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) + # 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. + + Reproduces the reported traceback — South types 'double' against + their partner North's 90♠ contract. Before the fix this escaped to + ``Auction.apply`` and raised ``IllegalBidError``; now the view + rejects it inline and loops for fresh input. + """ + + def _drive(self, four_players, raws): + """Run request_bid_action feeding *raws* as successive inputs. + + Returns ``(view, notices, inputs)``. Rendering and console I/O + are stubbed so the loop runs headless. ``notices`` collects the + ``notice`` Text handed to each ``_render_in_game`` frame — the + rejection now rides inside the frame rather than a standalone + ``console.print`` (which a re-render's ``console.clear()`` would + bury in scrollback). + """ + view = RichView() + inputs = iter(raws) + notices: list[str] = [] + + def fake_render(**kwargs): + note = kwargs.get("notice") + notices.append(getattr(note, "plain", None) if note else None) + + view._render_in_game = fake_render + view.console.input = lambda *a, **k: next(inputs) + return view, notices, inputs + + def test_double_own_partner_reprompts_then_passes(self, four_players): + north, east, south, west = four_players + auction = Auction.empty() + for bid in ( + PassBid(east), + ContractBid(north, 90, Suit.SPADES), + PassBid(west), + ): + auction = auction.apply(bid) + + view, notices, _ = self._drive(four_players, ["double", "pass"]) + result = view.request_bid_action(south, auction) + + # The illegal Double was rejected inline (no exception), and the + # loop accepted the follow-up Pass. + assert isinstance(result, PassBid) + # The rejection rode inside the re-prompt frame (notice arg), not + # a standalone print: the first frame had no notice, the retry + # frame carried the "own side" reason. + assert notices[0] is None + assert any(n and "own side" in n for n in notices) + # And whatever it returns is genuinely legal — the property the + # crash violated. + assert auction.is_legal(result) + + def test_legal_double_against_opponent_is_accepted(self, four_players): + _north, east, south, _west = four_players + auction = Auction.empty().apply(ContractBid(east, 90, Suit.SPADES)) + + view, notices, _ = self._drive(four_players, ["double"]) + result = view.request_bid_action(south, auction) + + assert isinstance(result, DoubleBid) + # Accepted on the first frame — no rejection notice was ever set. + 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 +# ====================================================================== + + +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"), + ] + text = view._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"), + # round 2 begins: + (south, (100, Suit.HEARTS)), + (east, "Pass"), + (north, (130, Suit.HEARTS)), + (west, "Double"), + ] + text = view._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. + before, after = text.split("\n", 1) + assert before.startswith("#1") + assert after.startswith("#2") + # Round 1 holds the first four bids; round 2 the next four. + assert "W Pass" in before + assert "S 100" in after + + 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"), + ] + text = view._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. + for letter in ("S", "E", "N", "W"): + 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.""" + + def test_ai_bid_calls_sleep_with_env_delay( + self, monkeypatch, four_players + ): + from contrai_engine.view import rich_view + + north, *_ = four_players + sleep_calls = [] + monkeypatch.setattr(rich_view.time, "sleep", + lambda s: sleep_calls.append(s)) + monkeypatch.setenv("CONTRAI_AI_BID_DELAY", "0.01") + + view = RichView() + bid = ContractBid(north, 100, Suit.HEARTS) + view.on_bid_made(north, bid, [bid]) + + assert sleep_calls == [0.01] + + def test_human_bid_does_not_sleep( + self, monkeypatch, four_players + ): + from contrai_engine.view import rich_view + from contrai_engine.model.player import HumanPlayer + + sleep_calls = [] + monkeypatch.setattr(rich_view.time, "sleep", + lambda s: sleep_calls.append(s)) + + human = HumanPlayer("You", "South") + human.team = four_players[0].team # any team + view = RichView() + bid = PassBid(human) + view.on_bid_made(human, bid, [bid]) + + assert sleep_calls == [] + + +class TestOnCardPlayedPacing: + def test_ai_card_calls_sleep(self, monkeypatch, four_players): + from contrai_engine.view import rich_view + + north, *_ = four_players + sleep_calls = [] + monkeypatch.setattr(rich_view.time, "sleep", + lambda s: sleep_calls.append(s)) + monkeypatch.setenv("CONTRAI_AI_CARD_DELAY", "0.01") + + trick = Trick() + view = RichView() + view.on_card_played(north, Card(Suit.HEARTS, Rank.ACE), trick) + + assert sleep_calls == [0.01] + + def test_human_card_does_not_sleep(self, monkeypatch, four_players): + from contrai_engine.view import rich_view + from contrai_engine.model.player import HumanPlayer + + sleep_calls = [] + monkeypatch.setattr(rich_view.time, "sleep", + lambda s: sleep_calls.append(s)) + + human = HumanPlayer("You", "South") + human.team = four_players[0].team + view = RichView() + view.on_card_played(human, Card(Suit.HEARTS, Rank.ACE), Trick()) + assert sleep_calls == [] + + +class TestEventLog: + """Rolling narrative log shown below the hand panel.""" + + def _make_view(self, monkeypatch): + """RichView with sleep patched out — we don't want real pauses.""" + from contrai_engine.view import rich_view + + monkeypatch.setattr(rich_view.time, "sleep", lambda _: None) + return RichView() + + def test_log_appends_and_trims(self, monkeypatch): + view = self._make_view(monkeypatch) + for i in range(view.LOG_MAX + 3): + view._log(Text(f"line {i}")) + assert len(view.event_log) == view.LOG_MAX + # Earliest entries are dropped first. + assert view.event_log[0].plain == f"line {3}" + assert view.event_log[-1].plain == f"line {view.LOG_MAX + 2}" + + def test_on_bid_made_logs_styled_entry(self, monkeypatch, four_players): + view = self._make_view(monkeypatch) + north, *_ = four_players + bid = ContractBid(north, 100, Suit.HEARTS) + view.on_bid_made(north, bid, [bid]) + assert any("bid 100" in line.plain for line in view.event_log) + assert any("♥" in line.plain for line in view.event_log) + + def test_on_bid_made_logs_pass(self, monkeypatch, four_players): + view = self._make_view(monkeypatch) + north, *_ = four_players + view.on_bid_made(north, PassBid(north), [PassBid(north)]) + assert any(line.plain.endswith("passed.") for line in view.event_log) + + def test_on_card_played_logs(self, monkeypatch, four_players): + view = self._make_view(monkeypatch) + north, *_ = four_players + view.on_card_played(north, Card(Suit.HEARTS, Rank.JACK), Trick()) + # Card log: "N plays J♥." + assert any("plays" in line.plain for line in view.event_log) + assert any("J♥" in line.plain for line in view.event_log) + + def test_on_trick_complete_logs_winner_with_points( + self, monkeypatch, four_players + ): + view = self._make_view(monkeypatch) + north, east, south, west = four_players + + class _StubRound: + def __init__(self, contract): + self.contract = contract + self.tricks = [] + self.team_tricks = {} + + class _StubContract: + suit = Suit.HEARTS + + trick = Trick() + # Build a real-ish trick. With Hearts trump, J♥(20)+A♥(11)+K♥(4)+Q♥(3)=38. + trick.add_play(north, Card(Suit.HEARTS, Rank.JACK)) + 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.QUEEN)) + # Avoid blocking on console.input — patch it. + view.console.input = lambda *_a, **_kw: "" + view.on_trick_complete(trick, north, _StubRound(_StubContract())) + + win_line = view.event_log[-1].plain + assert "wins trick" in win_line + assert "38" in win_line + + def test_on_round_dealt_logs(self, monkeypatch, four_players): + view = self._make_view(monkeypatch) + north, *_ = four_players + + class _StubRound: + round_number = 5 + dealer = north + + view.on_round_dealt(_StubRound()) + assert any("Round #5" in line.plain for line in view.event_log) + assert any("deals" in line.plain for line in view.event_log) + + def test_on_all_pass_redeal_logs(self, monkeypatch): + view = self._make_view(monkeypatch) + view.on_all_pass_redeal(round_=None) + assert any("redealing" in line.plain for line in view.event_log) + + def test_on_contract_established_logs(self, monkeypatch, four_players): + view = self._make_view(monkeypatch) + north, *_ = four_players + + class _StubContract: + value = 100 + suit = Suit.HEARTS + double = False + redouble = False + double_player = None + redouble_player = None + player = north + team = north.team + + class _StubRound: + contract = _StubContract() + + 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 + assert "by N" in line + + def test_on_contract_established_includes_double_multiplier( + self, monkeypatch, four_players + ): + view = self._make_view(monkeypatch) + _north, east, _south, west = four_players + + class _StubContract: + value = 120 + suit = Suit.SPADES + double = True + redouble = False + double_player = west + redouble_player = None + player = east + team = east.team + + class _StubRound: + contract = _StubContract() + + view.on_contract_established(_StubRound()) + line = view.event_log[-1].plain + # Multiplier plus the coincheur's seat letter. + assert "×2 by W" in line + # Taker is still named. + assert "by E" in line + + def test_on_contract_established_no_op_when_no_contract( + self, monkeypatch + ): + view = self._make_view(monkeypatch) + + class _StubRound: + contract = None + + 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")) + # Attach without a real Game (just enough for the method to work). + class _StubGame: + def __init__(self): + self.current_round = None + self.scores = {"North-South": 0, "East-West": 0} + + view.attach(_StubGame(), target_score=1500) + assert view.event_log == [] + + +class TestBeloteAnnouncement: + """Belote announcement hook + diamond badge.""" + + def _make_view(self, monkeypatch): + from contrai_engine.view import rich_view + + monkeypatch.setattr(rich_view.time, "sleep", lambda _: None) + return RichView() + + class _StubContract: + def __init__(self, suit): + self.suit = suit + class _T: pass + self.team = _T() + self.team.name = "North-South" + + class _StubRound: + def __init__(self, contract, belote_state): + self.contract = contract + self.belote_state = belote_state + self.tricks = [] + self.team_tricks = {} + + def test_on_belote_announced_logs_belote(self, monkeypatch, four_players): + view = self._make_view(monkeypatch) + north, *_ = four_players + round_ = self._StubRound(self._StubContract(Suit.HEARTS), {north: "belote"}) + view.on_belote_announced(north, "belote", round_) + line = view.event_log[-1].plain + assert "Belote" in line + assert "Rebelote" not in line + + def test_on_belote_announced_logs_rebelote(self, monkeypatch, four_players): + view = self._make_view(monkeypatch) + north, *_ = four_players + round_ = self._StubRound(self._StubContract(Suit.HEARTS), + {north: "rebelote"}) + view.on_belote_announced(north, "rebelote", round_) + assert "Rebelote" in view.event_log[-1].plain + + def test_on_belote_announced_sleeps(self, monkeypatch, four_players): + """Announcement uses the AI card delay so it lands visibly.""" + from contrai_engine.view import rich_view + + sleep_calls = [] + monkeypatch.setattr(rich_view.time, "sleep", + lambda s: sleep_calls.append(s)) + monkeypatch.setenv("CONTRAI_AI_CARD_DELAY", "0.01") + north, *_ = four_players + view = RichView() + round_ = self._StubRound(self._StubContract(Suit.HEARTS), {}) + view.on_belote_announced(north, "belote", round_) + assert sleep_calls == [0.01] + + def test_diamond_renders_belote_badge_for_announcer( + self, monkeypatch, four_players + ): + view = self._make_view(monkeypatch) + north, *_ = four_players + trick = Trick() + # Empty trick is fine — the badge is keyed off belote_by_position. + diamond = view._render_diamond( + trick, + Suit.HEARTS, + pending_position=None, + winner_position=None, + dimmed=False, + width=42, + belote_by_position={"North": "belote"}, + ) + text = diamond.plain + assert "★ Belote" in text + # The badge sits below the N slot, so the badge appears AFTER + # "N · " in linear text order. + assert text.index("N") < text.index("★ Belote") + + def test_diamond_badge_is_belote_regardless_of_kind( + self, monkeypatch, four_players + ): + """After the second K-or-Q of trump (kind='rebelote'), the + 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( + Trick(), + Suit.HEARTS, + pending_position=None, + winner_position=None, + dimmed=False, + width=42, + belote_by_position={"South": "rebelote"}, + ) + assert "★ Belote" in diamond.plain + assert "Rebelote" not in diamond.plain + + def test_diamond_no_badge_when_state_empty(self, monkeypatch): + view = self._make_view(monkeypatch) + diamond = view._render_diamond( + Trick(), + Suit.HEARTS, + pending_position=None, + winner_position=None, + dimmed=False, + width=42, + belote_by_position=None, + ) + assert "Belote" not in diamond.plain + assert "Rebelote" not in diamond.plain + + +class TestBiddingDiamond: + """The auction reuses the table diamond: each seat shows its latest bid.""" + + class _StubRound: + def __init__(self): + self.round_number = 1 + self.contract = None + self.dealer = None + self.tricks = [] + self.team_tricks = {} + 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"), + ] + diamond = view._render_bidding_diamond( + history, pending_position=None, width=42 + ) + text = diamond.plain + # West's bid renders as "80 ♥"; South and North passed. + assert "80 ♥" in text + 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))], + pending_position="North", + width=42, + ) + # North is on the move → "N ?"; West shows its standing bid. + assert "N ?" in diamond.plain + assert "80 ♥" in diamond.plain + + def test_seat_without_bid_shows_dot(self, four_players): + view = RichView() + diamond = view._render_bidding_diamond( + [], pending_position=None, width=42 + ) + # Empty auction: every seat is a placeholder dot, no "?". + assert "·" in diamond.plain + assert "?" not in diamond.plain + + 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)), + ] + text = view._render_bidding_diamond( + history, pending_position=None, width=42 + ).plain + assert "100 ♥" in text + assert "80 ♥" not in text + + 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( + self._StubRound(), + trick=None, + phase="bidding", + current_player=south, + trick_winner=None, + bidding_history=[(west, (80, Suit.HEARTS))], + ) + assert panel.title.plain == "Bidding" + body = panel.renderable.plain + assert "80 ♥" in body + # South is the human about to bid → seat marked, prompt line shown. + 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.""" + + class _StubRound: + # Minimal stand-in. _panel_round only reads round_number, + # contract, dealer, tricks during this phase path. + def __init__(self, round_number): + self.round_number = round_number + self.contract = None + self.dealer = None + self.tricks = [] + self.team_tricks = {} + + def test_title_contains_round_number(self): + view = RichView() + panel = view._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") + assert panel.title.plain.startswith("Round") + # No # marker when there is no round to talk about. + assert "#" not in panel.title.plain + + +class TestTrickPanelTitles: + """Trick panel titles use the (#N) format.""" + + class _StubRound: + def __init__(self, tricks_done): + self.round_number = 1 + self.contract = None + self.dealer = None + self.tricks = [object()] * tricks_done + self.team_tricks = {} + self.belote_state = {} + + def test_current_trick_title_uses_hash_format(self): + view = RichView() + # 4 tricks done, currently playing trick #5. + panel = view._panel_current_trick( + self._StubRound(tricks_done=4), + trick=Trick(), + phase="playing", + current_player=None, + trick_winner=None, + ) + assert "Current trick (#5)" in panel.title.plain + + def test_last_trick_title_uses_hash_format(self, monkeypatch, four_players): + from contrai_engine.view import rich_view + + monkeypatch.setattr(rich_view.time, "sleep", lambda _: None) + view = RichView() + north, *_ = four_players + trick = Trick() + # Stub a completed trick. + view.last_completed_trick = (trick, north) + round_ = self._StubRound(tricks_done=7) + panel = view._panel_last_trick(round_) + 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) + 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 +# ====================================================================== + + +class TestPanelHandPersistence: + """The hand slot stays visible across non-interactive frames. + + Bug: previously the panel was only rendered when ``current_player`` + was the human, so it vanished during AI bidding/play frames and the + trick-won pause. Tests below lock the new "always render when a + human is seated" contract, plus the empty-hand and non-interactive + styling branches. + """ + + def _build_view_with_human(self): + """RichView wired to a minimal Game-like stub holding one human.""" + from contrai_engine.model.player import HumanPlayer + + human = HumanPlayer("You", "South") + human.team = None # not exercised by these tests + + class _StubGame: + def __init__(self, human): + self.players = [human] + self.current_round = None + self.scores = {"North-South": 0, "East-West": 0} + + view = RichView() + view.attach(_StubGame(human), target_score=1500) + return view, human + + def test_find_human_returns_human_player(self): + view, human = self._build_view_with_human() + assert view._find_human_player() is human + + def test_find_human_returns_none_when_no_game(self): + view = RichView() + assert view._find_human_player() is None + + def test_panel_hand_empty_hand_renders_placeholder(self): + """After the 8th trick the hand is empty — the cards row shows a + single '(no cards left)' line so the slot stays in the layout + rather than disappearing. No redundant second empty-state line.""" + view, human = self._build_view_with_human() + human.hand.clear() + panel = view._panel_hand( + human, trick=None, playable_cards=None, + phase="trick_won", round_=None, interactive=False, + ) + text = panel.renderable.plain + assert "(no cards left)" in text + assert "(hand empty)" not in text + + def test_panel_hand_non_interactive_omits_constraint_hint(self): + """During AI/trick-won frames the hand renders neutrally — no + green playable pills, no '↑ playable …' constraint hint.""" + view, human = self._build_view_with_human() + human.hand.clear() + human.hand.extend([ + Card(Suit.SPADES, Rank.ACE), + Card(Suit.HEARTS, Rank.JACK), + ]) + # Pretend hearts is trump and clubs were led — interactive mode + # would emit "must trump"; non-interactive mode must not. + from contrai_core.trick import Trick as _Trick + + trick = _Trick() + trick.add_play(human, Card(Suit.CLUBS, Rank.KING)) + panel = view._panel_hand( + human, trick=trick, playable_cards=[human.hand[1]], + phase="playing", round_=None, interactive=False, + ) + text = panel.renderable.plain + assert "must trump" not in text + assert "↑ playable" not in text + # Size readout takes the hint slot. + assert "2 cards remaining" in text + + def test_panel_hand_interactive_still_shows_constraint_hint(self): + """The interactive path is unchanged — the constraint hint + still appears when the human is the acting player.""" + view, human = self._build_view_with_human() + human.hand.clear() + human.hand.extend([ + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.ACE), + ]) + from contrai_core.trick import Trick as _Trick + + west_stub = type("_W", (), {"position": "West", "team": None})() + trick = _Trick() + trick.add_play(west_stub, Card(Suit.CLUBS, Rank.KING)) + # Stub a round with hearts trump so the explain helper knows + # 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( + human, trick=trick, playable_cards=list(human.hand), + phase="playing", round_=round_stub, interactive=True, + ) + text = panel.renderable.plain + assert "must trump" in text + + +class TestRenderInGameHandSlot: + """End-to-end: the hand slot persists across in-game frames.""" + + def _make_view(self, monkeypatch): + from contrai_engine.view import rich_view + from contrai_engine.model.player import HumanPlayer + + monkeypatch.setattr(rich_view.time, "sleep", lambda _: None) + human = HumanPlayer("You", "South") + human.team = None + human.hand.clear() + human.hand.extend([ + Card(Suit.SPADES, Rank.ACE), + Card(Suit.HEARTS, Rank.JACK), + Card(Suit.HEARTS, Rank.ACE), + ]) + + class _StubGame: + def __init__(self, human): + self.players = [human] + self.current_round = None + self.scores = {"North-South": 0, "East-West": 0} + + view = RichView() + view.attach(_StubGame(human), target_score=1500) + return view, human + + @staticmethod + def _capture_render(view) -> list[str]: + """Intercept console output and return the plain-text body of + every panel/text printed during the next ``_render_in_game``. + + Walks Panel titles as well as renderables so assertions can + target the ``Your hand (South)`` title line. + """ + captured: list[str] = [] + + def _record(*args, **_kw): + for a in args: + title = getattr(a, "title", None) + if title is not None and hasattr(title, "plain"): + captured.append(title.plain) + if hasattr(a, "plain"): + captured.append(a.plain) + elif hasattr(a, "renderable") and hasattr(a.renderable, "plain"): + captured.append(a.renderable.plain) + + view.console.clear = lambda *_a, **_kw: None + view.console.print = _record + return captured + + def test_hand_visible_during_ai_bidding_frame(self, monkeypatch): + """No current_player (AI just bid) — the hand must still render.""" + view, human = self._make_view(monkeypatch) + captured = self._capture_render(view) + view._render_in_game( + phase="bidding", + current_player=None, + bidding_history=[], + prompt_question=Text(""), + mandatory=False, + ) + combined = "\n".join(captured) + assert "Your hand (South)" in combined + + def test_hand_visible_during_trick_won_frame(self, monkeypatch): + """Trick-won frame uses current_player=None — hand must persist.""" + view, human = self._make_view(monkeypatch) + captured = self._capture_render(view) + view._render_in_game( + phase="trick_won", + current_player=None, + current_trick=None, + trick_winner=None, + prompt_question=Text(""), + mandatory=False, + ) + combined = "\n".join(captured) + assert "Your hand (South)" in combined + + def test_hand_omitted_when_no_human_seated(self, monkeypatch): + """All-AI table (no human) — hand panel is correctly suppressed.""" + from contrai_engine.view import rich_view + + monkeypatch.setattr(rich_view.time, "sleep", lambda _: None) + view = RichView() + + class _StubGame: + players = [] + current_round = None + scores = {"North-South": 0, "East-West": 0} + + view.attach(_StubGame(), target_score=1500) + captured = self._capture_render(view) + view._render_in_game( + phase="bidding", + current_player=None, + bidding_history=[], + prompt_question=Text(""), + mandatory=False, + ) + 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-scraper/pyproject.toml b/packages/contrai-scraper/pyproject.toml index cb26bc0..8f54be4 100644 --- a/packages/contrai-scraper/pyproject.toml +++ b/packages/contrai-scraper/pyproject.toml @@ -6,7 +6,13 @@ readme = "README.md" requires-python = ">=3.14" dependencies = [ "jupyter>=1.1.1", - "lab>=8.6", "nest-asyncio>=1.6.0", "playwright>=1.57.0", ] + +# contrai-scraper currently ships only entry-point scripts (main.py, run.py) at +# the package root and exposes no importable Python module. Declaring an empty +# module list keeps setuptools' flat-layout discovery from choking on the two +# top-level .py files. Revisit once the scraper grows a real package under src/. +[tool.setuptools] +py-modules = [] diff --git a/pyproject.toml b/pyproject.toml index 42af534..5c60cc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,42 @@ -# Virtual uv workspace — members live under packages/. -# Each member declares its own [project] table and dependencies. +[project] +name = "contrai-workspace" +version = "0.0.0" +description = "AI for the French card game Contrée" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "contrai-core[test]", + "contrai-engine[test]", + "contrai-analyzer", + "contrai-scraper", +] + +[dependency-groups] +docs = [ + "mkdocs-material", + "mkdocstrings[python]", + "mkdocs-mermaid2-plugin", + "plantuml-markdown", + "mkdocs-static-i18n", +] + +[tool.uv] +default-groups = ["docs"] + [tool.uv.workspace] members = ["packages/*"] [tool.uv.sources] contrai-core = { workspace = true } +contrai-engine = { workspace = true } +contrai-analyzer = { workspace = true } +contrai-scraper = { workspace = true } + +[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`` +# import mode the two collide on ``sys.modules['tests']``, so a +# whole-workspace ``uv run pytest`` aborts collecting the second one with +# "No module named 'tests.test_view'". ``importlib`` mode imports each test +# module under a path-derived name, so the per-package suites compose. +addopts = "--import-mode=importlib" diff --git a/uv.lock b/uv.lock index c63af51..95403f3 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,7 @@ members = [ "contrai-core", "contrai-engine", "contrai-scraper", + "contrai-workspace", ] [[package]] @@ -139,6 +140,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -336,7 +350,7 @@ wheels = [ [[package]] name = "contrai-analyzer" version = "0.1.0" -source = { virtual = "packages/contrai-analyzer" } +source = { editable = "packages/contrai-analyzer" } dependencies = [ { name = "altair" }, { name = "jupyter" }, @@ -377,9 +391,11 @@ provides-extras = ["test"] [[package]] name = "contrai-engine" version = "0.1.0" -source = { virtual = "packages/contrai-engine" } +source = { editable = "packages/contrai-engine" } dependencies = [ { name = "contrai-core" }, + { name = "pyfiglet" }, + { name = "rich" }, ] [package.optional-dependencies] @@ -390,14 +406,16 @@ test = [ [package.metadata] requires-dist = [ { name = "contrai-core", editable = "packages/contrai-core" }, + { name = "pyfiglet", specifier = ">=1.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4" }, + { name = "rich", specifier = ">=13" }, ] provides-extras = ["test"] [[package]] name = "contrai-scraper" version = "0.1.0" -source = { virtual = "packages/contrai-scraper" } +source = { editable = "packages/contrai-scraper" } dependencies = [ { name = "jupyter" }, { name = "nest-asyncio" }, @@ -411,6 +429,43 @@ requires-dist = [ { name = "playwright", specifier = ">=1.57.0" }, ] +[[package]] +name = "contrai-workspace" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "contrai-analyzer" }, + { name = "contrai-core", extra = ["test"] }, + { name = "contrai-engine", extra = ["test"] }, + { name = "contrai-scraper" }, +] + +[package.dev-dependencies] +docs = [ + { name = "mkdocs-material" }, + { name = "mkdocs-mermaid2-plugin" }, + { name = "mkdocs-static-i18n" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "plantuml-markdown" }, +] + +[package.metadata] +requires-dist = [ + { name = "contrai-analyzer", editable = "packages/contrai-analyzer" }, + { name = "contrai-core", extras = ["test"], editable = "packages/contrai-core" }, + { name = "contrai-engine", extras = ["test"], editable = "packages/contrai-engine" }, + { name = "contrai-scraper", editable = "packages/contrai-scraper" }, +] + +[package.metadata.requires-dev] +docs = [ + { name = "mkdocs-material" }, + { name = "mkdocs-mermaid2-plugin" }, + { name = "mkdocs-static-i18n" }, + { name = "mkdocstrings", extras = ["python"] }, + { name = "plantuml-markdown" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -451,6 +506,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "editorconfig" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -503,6 +567,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -554,6 +630,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -743,6 +828,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsbeautifier" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "editorconfig" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" }, +] + [[package]] name = "json5" version = "0.14.0" @@ -1046,6 +1144,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1121,6 +1240,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + [[package]] name = "mistune" version = "3.2.1" @@ -1130,6 +1267,154 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, ] +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocs-mermaid2-plugin" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "jsbeautifier" }, + { name = "mkdocs" }, + { name = "pymdown-extensions" }, + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/6d/308f443a558b6a97ce55782658174c0d07c414405cfc0a44d36ad37e36f9/mkdocs_mermaid2_plugin-1.2.3.tar.gz", hash = "sha256:fb6f901d53e5191e93db78f93f219cad926ccc4d51e176271ca5161b6cc5368c", size = 16220, upload-time = "2025-10-17T19:38:53.047Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/4b/6fd6dd632019b7f522f1b1f794ab6115cd79890330986614be56fd18f0eb/mkdocs_mermaid2_plugin-1.2.3-py3-none-any.whl", hash = "sha256:33f60c582be623ed53829a96e19284fc7f1b74a1dbae78d4d2e47fe00c3e190d", size = 17299, upload-time = "2025-10-17T19:38:51.874Z" }, +] + +[[package]] +name = "mkdocs-static-i18n" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/f9/51e2ffda9c7210bc35a24f3717b08c052cd4b728dfa87f901c00d8005259/mkdocs_static_i18n-1.3.1.tar.gz", hash = "sha256:a6125ea7db6cc1a900d76a967f262535af09831160a93c56d7f0d522a79b5faf", size = 1371325, upload-time = "2026-02-20T10:42:41.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/0b/43ff4afb6b438d47718b1959a22075ed95d8460d8c47381878b37a40de63/mkdocs_static_i18n-1.3.1-py3-none-any.whl", hash = "sha256:4036e24795a150c9c4d4b001ed24a43aec01335f76188dbe5a5d8fb4a27eba65", size = 21853, upload-time = "2026-02-20T10:42:40.551Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, +] + [[package]] name = "narwhals" version = "2.21.0" @@ -1278,6 +1563,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "pandas" version = "2.3.3" @@ -1323,6 +1617,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -1368,6 +1671,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] +[[package]] +name = "plantuml-markdown" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/41/807c38a6c703ee9b94be4120102247691aeeb4aefea034eaf687ef515594/plantuml_markdown-3.11.2.tar.gz", hash = "sha256:700a6ebf5c265da488aafa3c376f27e23d604dc4a151ff811fc3aec156ed51a3", size = 32319, upload-time = "2026-04-18T06:09:53.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/ca/e0afead2f0466b5af237cf01beaa04b6c7d9479b24ead4ad10cc3c7a4209/plantuml_markdown-3.11.2-py3-none-any.whl", hash = "sha256:45eb97fed669cc23c90593ae1d42122809bfc3693970a6edb0004c0dc20cde27", size = 20158, upload-time = "2026-04-18T06:09:51.581Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -1550,6 +1862,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, ] +[[package]] +name = "pyfiglet" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/e3/0a86276ad2c383ce08d76110a8eec2fe22e7051c4b8ba3fa163a0b08c428/pyfiglet-1.0.4.tar.gz", hash = "sha256:db9c9940ed1bf3048deff534ed52ff2dafbbc2cd7610b17bb5eca1df6d4278ef", size = 1560615, upload-time = "2025-08-15T18:32:47.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/5c/fe9f95abd5eaedfa69f31e450f7e2768bef121dbdf25bcddee2cd3087a16/pyfiglet-1.0.4-py3-none-any.whl", hash = "sha256:65b57b7a8e1dff8a67dc8e940a117238661d5e14c3e49121032bd404d9b2b39f", size = 1806118, upload-time = "2025-08-15T18:32:45.556Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1559,6 +1880,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1661,6 +1995,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "pyzmq" version = "27.1.0" @@ -1753,6 +2099,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0"