Skip to content

engine: collapse the Round trick mirror onto the core play state #8

Description

@valmathieu

Rewritten 2026-08-06. Originally filed as "unify the three trick-winner computation call
paths"
. Two of those three have since been closed by unrelated work, and what remains is both
narrower and differently shaped than the original description. Line numbers as of develop @
abd8bf0.

Already resolved

The view's proxy Trick is gone3a27225 refactor(engine): route view state readers through TrumpRules. view/state_helpers.py:50-60 is now a thin wrapper over
contrai_core.trick.current_winner(plays, trump_suit): it takes a raw plays list and synthesizes
nothing. No Trick() is constructed anywhere in the engine's view layer.

Scoring no longer reads the mirror32ee4b4 refactor(engine): score rounds from the authoritative play state. model/round/scoring.py:162-188 takes completed_tricks and
trick_winners straight off round_.play_state; team_tricks / last_trick_winner feed no score.

The AI never read the mirror.rule_based/card_play.py works off
PlayObservation.current_trick / .completed_tricks — core Play records throughout.

So the drift risk this issue was filed for — two authorities disagreeing about who won a trick, one
of them feeding the scoreboard — no longer exists. The remaining duplication sits entirely on the
display path.

What's actually left

Round maintains a mutable mirror of the play phase — current_trick, tricks, team_tricks,
last_trick_winner (round.py:73-76) — kept in lock-step with play_state purely so the view can
go on reading classic engine objects.

1. The winner is still recomputed on the mirror.round.py:382:

winner=self.current_trick.get_current_winner(trump_suit)

self.play_state.trick_winners[-1] is the same value, already derived from the authoritative state.

2. The view re-derives card points from the mirror — in two places. Both
view/screens/trick.py:146-152 and view/screens/recap.py:229-235 walk team_tricks and sum
rules.points(card) per side. That is the same accumulation scoring.py:179-184 performs off
play_state. Two implementations of the point-pile rule over two representations of the same
tricks — a sharper duplication than the winner rule ever was, since the view's copy is what the
player actually reads on screen.

3. Round.tricks is a list nobody inspects. Every read is a len(...)trick.py:96,
trick.py:191, trick.py:224, and the debug log at round.py:404. It is a counter wearing a
List[Trick] costume; len(play_state.completed_tricks) answers all four.

4. play_all_tricks' return value is dead. It returns self.team_tricks (round.py:450) and
Game discards it (game.py:235). Only tests read it.

The genuine remaining consumers of mirror Trick objects are the three view hooks
(request_card_action, on_card_played, on_trick_complete) and the deck-recycling loop at
round.py:410-413.

Proposal

Two separable steps.

Step 1 — read the winner from the state. Swap round.py:382 for
self.play_state.trick_winners[-1], replace Round.tricks with a length taken off play_state,
and drop play_all_tricks' unused return. Pure refactor; the round-lifecycle, scoring and view
suites cover it as they stand.

Step 2 — collapse the mirror. Give the view a read model projected from play_state: per-side
card points and trick counts derived once, in one place, shared with calculate_round_scores
instead of re-summed in two screens. team_tricks and last_trick_winner then disappear, and
current_trick survives only in whatever shape the three view hooks actually need.

Step 2 touches Round, both screens, and a good slice of tests/test_view/ — the fake rounds there
hand-build tricks / team_tricks (test_recap.py, test_trick.py, test_rich_view.py). It is a
design job rather than a mechanical one: worth its own branch and a <FEATURE>_DESIGN.md per §4.2.

Notes

  • No behaviour change in either step; existing suites must stay green.
  • Step 1 is best folded into the Step 2 branch as a warm-up commit rather than run as a branch of
    its own — on its own it leaves the mirror standing and buys little.
  • Not urgent. With scoring already off the mirror, the worst a divergence can now produce is a wrong
    recap panel, not a wrong score.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions