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 gone — 3a27225 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 mirror — 32ee4b4 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.
Already resolved
The view's proxy
Trickis gone —3a27225 refactor(engine): route view state readers through TrumpRules.view/state_helpers.py:50-60is now a thin wrapper overcontrai_core.trick.current_winner(plays, trump_suit): it takes a raw plays list and synthesizesnothing. No
Trick()is constructed anywhere in the engine's view layer.Scoring no longer reads the mirror —
32ee4b4 refactor(engine): score rounds from the authoritative play state.model/round/scoring.py:162-188takescompleted_tricksandtrick_winnersstraight offround_.play_state;team_tricks/last_trick_winnerfeed no score.The AI never read the mirror.
rule_based/card_play.pyworks offPlayObservation.current_trick/.completed_tricks— corePlayrecords 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
Roundmaintains a mutable mirror of the play phase —current_trick,tricks,team_tricks,last_trick_winner(round.py:73-76) — kept in lock-step withplay_statepurely so the view cango on reading classic engine objects.
1. The winner is still recomputed on the mirror.
round.py:382: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-152andview/screens/recap.py:229-235walkteam_tricksand sumrules.points(card)per side. That is the same accumulationscoring.py:179-184performs offplay_state. Two implementations of the point-pile rule over two representations of the sametricks — a sharper duplication than the winner rule ever was, since the view's copy is what the
player actually reads on screen.
3.
Round.tricksis a list nobody inspects. Every read is alen(...)—trick.py:96,trick.py:191,trick.py:224, and the debug log atround.py:404. It is a counter wearing aList[Trick]costume;len(play_state.completed_tricks)answers all four.4.
play_all_tricks' return value is dead. It returnsself.team_tricks(round.py:450) andGamediscards it (game.py:235). Only tests read it.The genuine remaining consumers of mirror
Trickobjects are the three view hooks(
request_card_action,on_card_played,on_trick_complete) and the deck-recycling loop atround.py:410-413.Proposal
Two separable steps.
Step 1 — read the winner from the state. Swap
round.py:382forself.play_state.trick_winners[-1], replaceRound.trickswith a length taken offplay_state,and drop
play_all_tricks' unused return. Pure refactor; the round-lifecycle, scoring and viewsuites cover it as they stand.
Step 2 — collapse the mirror. Give the view a read model projected from
play_state: per-sidecard points and trick counts derived once, in one place, shared with
calculate_round_scoresinstead of re-summed in two screens.
team_tricksandlast_trick_winnerthen disappear, andcurrent_tricksurvives only in whatever shape the three view hooks actually need.Step 2 touches
Round, both screens, and a good slice oftests/test_view/— the fake rounds therehand-build
tricks/team_tricks(test_recap.py,test_trick.py,test_rich_view.py). It is adesign job rather than a mechanical one: worth its own branch and a
<FEATURE>_DESIGN.mdper §4.2.Notes
its own — on its own it leaves the mirror standing and buys little.
recap panel, not a wrong score.