Skip to content

fix(parser): UNSUPPORTED cluster: Player voting — secret/open votes with per-label tallies (Effect::#4402

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-36-unsupported-cluster-player-voting
Jun 28, 2026
Merged

fix(parser): UNSUPPORTED cluster: Player voting — secret/open votes with per-label tallies (Effect::#4402
matthewevans merged 3 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-36-unsupported-cluster-player-voting

Conversation

@ntindle

@ntindle ntindle commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a parser misparse affecting 1 card(s) in the Doctor Who Commander precons.

Root cause: UNSUPPORTED cluster: Player voting — secret/open votes with per-label tallies (Effect::Vote runtime exists; wire parser)

Cards corrected

  • Truth or Consequences

Fix

Implemented cluster-36 (Truth or Consequences — secret-council vote) exactly per the approved, re-grounded plan. The card was a single Effect::Unimplemented on upstream/main; it now parses to a fully-supported, rules-correct typed tree with ZERO Unimplemented: Choose{ChoiceType::Opponent{None}, persist:true, selection:Random} → sub_ability Vote{choices:["truth","consequences"], tally_mode:PerVote} whose per-choice slots are Draw{count: VoteCount{0}, target: Controller} (truth) and DealDamage{amount: Multiply{3, VoteCount{1}}, target: SourceChosenPlayer} (consequences). Verified end-to-end via oracle-gen and a new e2e resolution test.

NO new enum variants, NO types/ change, NO engine resolver change, NO deal_damage change — every primitive pre-existed (the plan's empirical re-grounding was correct; the prior BLOCKER findings were against a stale tree). The work is purely parser dispatch onto existing handlers, exactly matching the heterogeneous-cluster "dispatch onto existing handlers" scope_note.

Three reusable building blocks added (not card special-cases): (1) a "each player secretly votes for " secret-ballot opener + nom take_until strip of the no-op "...then those votes are revealed" reveal tail; (2) ~-in-choice-list recovery via str::replace (idempotent — expands the card-name/choice-list pun back to the option list); (3) parse_vote_for_each_suffix_clause — the trailing-suffix sibling of the existing prefix parse_aggregate_tally_clause, covering " for each vote" with an optional hoisted random "[then ]choose a(n) opponent/player at random" setup and a typed TriggeringPlayer→SourceChosenPlayer retarget. (4) imperative.rs bare-plural "draw cards"→Draw{Fixed(1)} head (only fires after the dynamic tail is stripped; strict-failure preserved for unmodeled tails). All detection/dispatch is nom combinators; all data is typed enums (no raw bools).

Threaded card_name: &str through parse_vote_block (+ the 3 production dispatch sites in oracle.rs/oracle_trigger.rs/oracle_saga.rs, renaming saga's _card_namecard_name) and all ~21 test call sites.

Verification (run directly — this worktree is not under Tilt): cargo fmt clean; cargo clippy -p engine --all-targets clean (no warnings); full cargo test -p engine passes with zero failures (48 targeted vote/imperative tests green, including the new e2e); ./scripts/check-parser-combinators.sh flags NOTHING in my files (its EXIT=1 is entirely pre-existing committed code from other agents — its baseline is the old merge-base ff799f8); the instructions' parser diff gate passes clean; full gen-card-data.sh regenerated 35366 cards with exit 0 and Truth or Consequences shows unimpl_count:0.

Risk #1 from the plan (does random Choose park on a WaitingFor?) is RESOLVED both by code inspection (resolve_ability_chain resolves Effect::Choose{selection:Random} inline via choose::resolve_random_in_chain — Strax precedent, never parks) and by the new passing e2e test, which drives the full Choose→Vote chain in a 2-player game, submits both ballots, and asserts the random Choose did not park, the truth tally drove the controller's draw (+1 card), and 3×consequences-tally damage landed on the persisted chosen opponent (−3 life) and not the controller.

Tests added (building-block level): oracle_vote.rs — full-card secret vote + recovery, secret-opener unit, suffix-aggregate general (no setup), suffix-aggregate with random-opponent setup + retarget; imperative.rs — bare "draw cards"/"draw card"/"draw cards."→Fixed(1) and a negative guard ("draw cards from the top of their library"→None); vote.rs — the e2e resolution test.

All 6 CR annotations grep-verified against docs/MagicCompRules.txt (CR 102.2 opponent, CR 120.1 damage, CR 121.1 draw, CR 608.2c resolution/read-whole-text, CR 608.2d effect-choice/random-override, CR 701.38 Vote). I deliberately did NOT cite CR 701.9b for "random" as the plan's prose loosely suggested — 701.9b is discard-specific; CR 608.2d is the authorizing rule for resolution-time random selection.

Files changed

  • crates/engine/src/parser/oracle_vote.rs
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle.rs
  • crates/engine/src/parser/oracle_trigger.rs
  • crates/engine/src/parser/oracle_saga.rs
  • crates/engine/src/game/effects/vote.rs

CR references

  • CR 102.2
  • CR 120.1
  • CR 121.1
  • CR 608.2c
  • CR 608.2d
  • CR 701.38

Verification

  • cargo fmt --all — pass
  • check-parser-combinators.sh (scoped to upstream/main merge-base 53289c31d) — pass
  • cargo clippy -p engine --all-targets -- -D warnings — pass
  • cargo test -p engine — pass
  • oracle-gen data --filter "truth or consequences" — pass
    Cards confirmed re-parsed correctly: Truth or Consequences

🤖 Generated with Claude Code

ntindle and others added 2 commits June 26, 2026 03:47
…ith per-label tallies (Effect::Vote)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR
@ntindle ntindle requested a review from matthewevans as a code owner June 26, 2026 09:00

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for parsing and resolving secret votes and trailing-suffix aggregate vote clauses, specifically handling the card 'Truth or Consequences' and its card-name/choice-list self-reference. It adds the ability to hoist random opponent selection setups to wrap votes, retargets player-directed effects to the chosen opponent, and handles bare plural draw card residues. Feedback on the changes notes that the helper function retarget_that_player_to_chosen is missing a mandatory CR <number>: <description> annotation in its documentation, violating style guide rule R6.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +819 to +826
/// Retarget a `"that player"` anaphor (`TargetFilter::TriggeringPlayer`) to the
/// persisted chosen player (`TargetFilter::SourceChosenPlayer`) on a
/// player-directed effect. Used after a random "choose an opponent" setup is
/// hoisted to wrap the Vote: the damage clause's recipient is the chosen
/// opponent, recorded as `ChosenAttribute::Player` and resolved by
/// `deal_damage::player_context_target`. Only `Effect::DealDamage` carries this
/// anaphor in the suffix-vote class today; extend with new arms as new shapes
/// ship.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The rules-touching helper function retarget_that_player_to_chosen is missing a mandatory CR <number>: <description> annotation in its documentation comment, violating style guide rule R6.

Suggested change
/// Retarget a `"that player"` anaphor (`TargetFilter::TriggeringPlayer`) to the
/// persisted chosen player (`TargetFilter::SourceChosenPlayer`) on a
/// player-directed effect. Used after a random "choose an opponent" setup is
/// hoisted to wrap the Vote: the damage clause's recipient is the chosen
/// opponent, recorded as `ChosenAttribute::Player` and resolved by
/// `deal_damage::player_context_target`. Only `Effect::DealDamage` carries this
/// anaphor in the suffix-vote class today; extend with new arms as new shapes
/// ship.
/// CR 608.2c: Retarget a "that player" anaphor (TargetFilter::TriggeringPlayer) to the
/// persisted chosen player (TargetFilter::SourceChosenPlayer) on a
/// player-directed effect. Used after a random "choose an opponent" setup is
/// hoisted to wrap the Vote: the damage clause's recipient is the chosen
/// opponent, recorded as ChosenAttribute::Player and resolved by
/// deal_damage::player_context_target. Only Effect::DealDamage carries this
/// anaphor in the suffix-vote class today; extend with new arms as new shapes
/// ship.
References
  1. Every rules-touching line of engine code must carry a comment of the form CR <number>: <description> (regex CR \d{3}(\.\d+[a-z]?)?). (link)

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 161 card(s), 136 signature(s) (baseline: main 53289c31d987)

15 card(s) · ability/ChangeZone · removed: ChangeZone (target=parent target, to=exile)

Examples: Denying Wind, Earwig Squad, Emergent Ultimatum (+12 more)

14 card(s) · static/CantBeActivated(all_players) · field affects: selfenchanted by self creature

Examples: Arachnus Web, Arrest, Demotion (+11 more)

12 card(s) · static/CantAttackOrBlock · field affects: selfenchanted by self creature

Examples: Arachnus Web, Arrest, Desert's Hold (+9 more)

12 card(s) · ability/ChangeZone · field from: battlefield

Examples: Aang, Airbending Master, Aang, Swift Savior, Aang, the Last Airbender (+9 more)

11 card(s) · ability/Unsupported unless clause · removed: Unsupported unless clause

Examples: Argentum Masticore, Cut the Tethers, Great Desert Hellion (+8 more)

10 card(s) · static/CantBeActivated(all_players) · field affects: selfenchanted by self permanent

Examples: Bound in Gold, Detention Vortex, Faith's Fetters (+7 more)

10 card(s) · ability/Shuffle · added: Shuffle (target=controller)

Examples: Endless Horizons, Foresight, Kaho, Minamo Historian (+7 more)

9 card(s) · static/CantAttackOrBlock · field affects: selfenchanted by self permanent

Examples: Detention Vortex, Faith's Fetters, Nahiri's Binding (+6 more)

9 card(s) · ability/Draw · field target: playeropponent

Examples: Fall of the First Civilization, Farsight Adept, Flumph (+6 more)

7 card(s) · ability/unknown · removed: unknown

Examples: Aloy, Savior of Meridian, Anrakyr the Traveller, Martha Jones (+4 more)

6 card(s) · ability/Shuffle · added: Shuffle (target=any)

Examples: Denying Wind, Jester's Cap, Neverending Torment (+3 more)

5 card(s) · ability/ChangeZone · removed: ChangeZone (target=triggering source, to=exile)

Examples: Endless Horizons, Kaho, Minamo Historian, Omenpath Journey (+2 more)

5 card(s) · keyword/Flashback · added: Flashback

Examples: Visions of Dominance, Visions of Dread, Visions of Duplicity (+2 more)

5 card(s) · static/ReduceCost · added: ReduceCost (affects=self, conditional=casting as Flashback)

Examples: Visions of Dominance, Visions of Dread, Visions of Duplicity (+2 more)

5 card(s) · static/ReduceCost · removed: ReduceCost (affects=card)

Examples: Visions of Dominance, Visions of Dread, Visions of Duplicity (+2 more)

4 card(s) · ability/CreateDelayedTrigger · added: CreateDelayedTrigger (when=at next cleanup)

Examples: Bounty of the Hunt, Cunning, Thawing Glaciers (+1 more)

4 card(s) · ability/Dig · field keep_count: 14294967295

Examples: Accumulate Wisdom, Desperate Research, See the Truth (+1 more)

4 card(s) · ability/Dig · field up_to: true

Examples: Accumulate Wisdom, Desperate Research, See the Truth (+1 more)

3 card(s) · ability/CastFromZone · added: CastFromZone (target=any target)

Examples: A-Earthquake Dragon, Cavern-Hoard Dragon, Hearth Elemental

3 card(s) · ability/Discard · added: Discard (count=1, target=controller)

Examples: Read the Runes, Tragic Lesson, Urza's Tome

3 card(s) · static/ReduceCost · removed: ReduceCost (affects=self)

Examples: A-Earthquake Dragon, Cavern-Hoard Dragon, Hearth Elemental

3 card(s) · ability/when · removed: when

Examples: Nyssa of Traken, Ravenous Rotbelly, Three Dog, Galaxy News DJ

2 card(s) · static/CantBlock · field affects: selfenchanted by self creature

Examples: Demotion, Gelid Shackles

2 card(s) · cost/CastingOption:AlternativeCost · added: CastingOption:AlternativeCost

Examples: Arrow Volley Trap, Lethargy Trap

2 card(s) · trigger/ChangesZone · added: ChangesZone (active in=battlefield, optional=yes, to=battlefield, watches=self)

Examples: Magitek Scythe, Vislor Turlough

… 111 more signature(s) (116 card-changes) — see parse-diff.json
  • 2 card(s) · trigger/ChangesZone · added: ChangesZone (active in=battlefield, to=battlefield, watches=self)
  • 2 card(s) · keyword/Enchant · added: Enchant
  • 2 card(s) · ability/PayCost · removed: PayCost
  • 2 card(s) · ability/Sacrifice · added: Sacrifice (target=self)
  • 2 card(s) · ability/phase · removed: phase
  • 1 card(s) · trigger/Attacks · added: Attacks (active in=battlefield, optional=yes, watches=self)
  • 1 card(s) · trigger/Attacks · added: Attacks (active in=battlefield, watches=self)
  • 1 card(s) · trigger/BecomesTarget · added: BecomesTarget (active in=battlefield, constraint=once per turn, valid source=ability you control on stack, watches=in battlefield permanent)
  • 1 card(s) · trigger/BecomesTarget · field watches: controller or another you control permanentanother you control permanent
  • 1 card(s) · ability/Bounce · added: Bounce (target=parent target)
  • 1 card(s) · ability/Bounce · added: Bounce (target=parent target, targets=0-1)
  • 1 card(s) · ability/Bounce · removed: Bounce (target=self)
  • 1 card(s) · static/CantAttack · field affects: selfenchanted by self permanent
  • 1 card(s) · static/CantAttack · field affects: selfequipped by self creature
  • 1 card(s) · static/CantBeActivated(all_players) · field affects: selfequipped by self creature
  • 1 card(s) · ability/CantBeBlocked · added: CantBeBlocked (affects=self, conditional=when you do, duration=until end of turn, grants=CantBeBlocked)
  • 1 card(s) · ability/CantBeBlocked · added: CantBeBlocked (affects=self, duration=until end of turn, grants=CantBeBlocked, targeting=optional (up to))
  • 1 card(s) · ability/CantBeBlocked, grant trigger · added: CantBeBlocked, grant trigger (affects=commander you, duration=until end of turn, grants=CantBeBlocked, grants=grant trigger)
  • 1 card(s) · ability/CantBeBlockedExceptBy:Quality(Or { filters: [Typed(TypedFilter { type_filters: … · added: CantBeBlockedExceptBy:Quality(Or { filters: [Typed(TypedFilter { type_filters: [Creature], controller: None, properties: [WithKeyword { value: Flying }] }), Ty…
  • 1 card(s) · ability/CantBlock, add subtype Coward · added: CantBlock, add subtype ~ (affects=parent target, duration=until end of turn, grants=CantBlock, grants=add subtype ~, target=creature)
  • 1 card(s) · static/CantPlayLand · field conditional: # of land ≥ 10
  • 1 card(s) · ability/CastFromZone · added: CastFromZone (conditional=life total (you) < starting life total, target=any target)
  • 1 card(s) · ability/ChangeZone · field target: anyred in graveyard you control creature or white in graveyard you control creature or black in graveyard you control crea…
  • 1 card(s) · ability/ChangeZone · removed: ChangeZone (target=last created, to=exile)
  • 1 card(s) · trigger/ChaosEnsues · added: ChaosEnsues (active in=battlefield, command zone, valid target=player, watches=self)
  • 1 card(s) · ability/ChooseFromZone · added: ChooseFromZone (count=1, zone=exile)
  • 1 card(s) · static/Continuous · field conditional: unrecognized# of Reveler ≥ 5
  • 1 card(s) · static/Continuous · field mods: power +1, toughness +0, grant FirstStrikeadd dynamic power, grant FirstStrike
  • 1 card(s) · static/Continuous · field mods: CantAttack, CantBlock
  • 1 card(s) · ability/CopyTokenOf · added: CopyTokenOf (conditional=Aura changed zones this way, copies=parent target)
  • 1 card(s) · ability/Counter · added: Counter (target=spell on stack + instant or spell on stack + sorcery)
  • 1 card(s) · ability/Counter · added: Counter (target=spell on stack + opponent)
  • 1 card(s) · ability/Counter · removed: Counter (target=triggering source)
  • 1 card(s) · ability/DealDamage · added: DealDamage (amount=3, target=controller)
  • 1 card(s) · ability/DealDamage · added: DealDamage (amount=4, kind=activated, target=creature or planeswalker, timing=sorcery speed)
  • 1 card(s) · ability/DealDamage · added: DealDamage (amount=5, target=attacking creature, targets=1-5)
  • 1 card(s) · ability/DealDamage · added: DealDamage (amount=5, target=controller)
  • 1 card(s) · ability/DealDamage · field conditional: creature changed zones this way
  • 1 card(s) · ability/DealDamage · field conditional: instead if (target is white or blue)
  • 1 card(s) · ability/Destroy · added: Destroy (kind=activated, target=land)
  • 1 card(s) · ability/Destroy · added: Destroy (target=non-basic land)
  • 1 card(s) · ability/Destroy · field conditional: target is black or red
  • 1 card(s) · ability/Discard · field conditional: target is green or white
  • 1 card(s) · ability/Discard · field count: cards movedevent amount
  • 1 card(s) · ability/Draw · field conditional: target is blue or black
  • 1 card(s) · ability/Draw · field count: cards movedevent amount
  • 1 card(s) · keyword/Echo · removed: Echo
  • 1 card(s) · ability/Incubate · added: Incubate
  • 1 card(s) · ability/Modal · field modal: choose 1-1 of 3choose 0-3 of 3
  • 1 card(s) · static/MustAttack · field conditional: unrecognized# of Reveler ≥ 5
  • 1 card(s) · static/MustBeBlocked · removed: MustBeBlocked (affects=self)
  • 1 card(s) · trigger/PayEcho · removed: PayEcho (condition=echo due, phase=upkeep, valid target=controller)
  • 1 card(s) · trigger/Phase · added: Phase (active in=battlefield, constraint=only during your turn, optional=yes, phase=end step)
  • 1 card(s) · ability/PhaseOut · field target: any targetanother you control creature
  • 1 card(s) · ability/PhaseOut · field targets: 0+
  • 1 card(s) · ability/Pump · field target: anyred you control creature or green you control creature or white you control creature
  • 1 card(s) · ability/Pump · field target: any targetself
  • 1 card(s) · ability/PumpAll · added: PumpAll (duration=until end of turn, filter=attacking creature, p/t=-3/+0)
  • 1 card(s) · ability/PutAtLibraryPosition · field conditional: instead if (target is red or green)
  • 1 card(s) · ability/PutCounter · field counter: 1 additional +1/+11 P1P1
  • 1 card(s) · ability/PutCounter · field counter: Toughness { scope: Source } additional +1/+1Toughness { scope: Source } P1P1
  • 1 card(s) · ability/PutCounter · field counter: TrackedSetSize ingredientEventContextAmount ingredient
  • 1 card(s) · ability/PutCounter · field target: anygreen creature or white creature or blue creature
  • 1 card(s) · static/ReduceCost · removed: ReduceCost (affects=self, conditional=life total (you) < starting life total)
  • 1 card(s) · static/ReduceCost · removed: ReduceCost (affects=you control card)
  • 1 card(s) · ability/RemoveCounter · removed: RemoveCounter (counter=1 P1P1, target=parent target)
  • 1 card(s) · ability/RevealUntil · field until: creature or any targetcreature
  • 1 card(s) · ability/RevealUntil · field until: creature or any target or any targetcreature or any target
  • 1 card(s) · ability/RevealUntil · field until: mv 3 card non-land or any targetmv 3+ card non-land
  • 1 card(s) · ability/RevealUntil · field until: mv 3 card non-land or any targetmv 3- card non-land
  • 1 card(s) · ability/RevealUntil · field until: non-land or self or planeswalker or any targetnon-land or self or planeswalker
  • 1 card(s) · ability/RevealUntil · field until: non-legendary or mv 3 card non-land or any targetnon-legendary or mv 3- card non-land
  • 1 card(s) · ability/RevealUntil · field until: shares card type with reference card or any target or any targetshares card type with reference card or any target
  • 1 card(s) · ability/Sacrifice · added: Sacrifice (conditional=Zombie changed zones this way, target=you control creature)
  • 1 card(s) · ability/Sacrifice · removed: Sacrifice (target=self)
  • 1 card(s) · ability/Shuffle · added: Shuffle (target=triggering player)
  • 1 card(s) · ability/SkipNextStep · added: SkipNextStep (player=opponent, scope=all of next turn, step=CombatPhase)
  • 1 card(s) · ability/SkipNextStep · added: SkipNextStep (player=player, scope=all of next turn, step=CombatPhase)
  • 1 card(s) · trigger/SpellCast · added: SpellCast (active in=graveyard, valid target=controller)
  • 1 card(s) · ability/Tap · added: Tap (conditional=artifact changed zones this way, target=creature, targets=0-event amount)
  • 1 card(s) · ability/Token · added: Token (conditional=instead if (cast variant was paid), token=referenced object's toughness× +2/+2 White Alien (Creature Alien))
  • 1 card(s) · ability/Token · added: Token (token=event amount× +1/+1 Myr (Artifact Creature Myr))
  • 1 card(s) · ability/Token · field conditional: target is blue or black
  • 1 card(s) · ability/Token · field token: X× +2/+2 Black Horror (Creature Horror) tappedcreature cards in your graveyard× +2/+2 Black Horror (Creature Horror) tapped
  • 1 card(s) · ability/Transform · added: Transform (kind=activated, target=self, timing=sorcery speed)
  • 1 card(s) · ability/Transform · removed: Transform (kind=activated, target=self)
  • 1 card(s) · ability/Unsupported unless clause · added: Unsupported unless clause
  • 1 card(s) · ability/Unsupported unless clause · removed: Unsupported unless clause (kind=activated)
  • 1 card(s) · ability/Unsupported unless clause · removed: Unsupported unless clause (kind=activated, timing=sorcery speed)
  • 1 card(s) · ability/Unsupported unless clause · removed: Unsupported unless clause (targets=0-1)
  • 1 card(s) · ability/Vote · added: Vote
  • 1 card(s) · trigger/Whenever a player or permanent becomes the target of an ability you control · removed: Whenever a player or permanent becomes the target of an ability you control (active in=battlefield, constraint=once per turn)
  • 1 card(s) · trigger/YouAttack · added: YouAttack (active in=battlefield, watches=you control artifact creature)
  • 1 card(s) · ability/can't · removed: can't (duration=until end of turn)
  • 1 card(s) · ability/empty · added: empty
  • 1 card(s) · ability/gain · removed: gain (duration=until end of turn, kind=activated)
  • 1 card(s) · ability/grant Escape · added: grant Escape (affects=parent target, duration=until end of turn, grants=grant Escape, kind=activated, target=legendary in graveyard you control creature)
  • 1 card(s) · ability/grant Escape · added: grant Escape (affects=parent target, duration=until end of turn, grants=grant Escape, target=in graveyard you control creature artifact or in graveyard mv 3- y…
  • 1 card(s) · ability/grant trigger · removed: grant trigger (affects=commander you, duration=until end of turn, grants=grant trigger)
  • 1 card(s) · ability/have · removed: have (duration=until end of turn)
  • 1 card(s) · ability/instead · removed: instead
  • 1 card(s) · ability/it's · removed: it's
  • 1 card(s) · ability/set base power dynamic, set base toughness dynamic · added: set base power dynamic, set base toughness dynamic (affects=you control creature, duration=until end of turn, grants=set base power dynamic, grants=set base to…
  • 1 card(s) · ability/set base power dynamic, set base toughness dynamic · removed: set base power dynamic, set base toughness dynamic (affects=you control creature, duration=until end of turn, grants=set base power dynamic, grants=set base to…
  • 1 card(s) · ability/static_structure · removed: static_structure
  • 1 card(s) · ability/the · added: the
  • 1 card(s) · ability/unless · removed: unless
  • 1 card(s) · ability/vote · removed: vote
  • 1 card(s) · ability/~ · removed: ~ (conditional=when you do, duration=until end of turn)
  • 1 card(s) · ability/~ · removed: ~ (duration=until end of turn, targeting=optional (up to))
  • 1 card(s) · ability/~ · removed: ~ (kind=activated)

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed current head 46f384709e759f53febe1bae97dff4cb5e69d4ee via the GitHub API diff, PR comments/reviews, parse-diff output, local worktree, and local CR/card-text lookup.

The architectural direction is close for the open-vote aggregate suffix and Truth-or-Consequences tally pieces, but this cannot be approved as-is.

[HIGH] Secret votes are modeled as ordinary public votes. Evidence: parse_each_player_votes_clause maps each player secretly votes for onto the same VoterScope::AllPlayers / normal Effect::Vote path at crates/engine/src/parser/oracle_vote.rs:432-439, with the comment saying secrecy is presentation-only. The existing WaitingFor::VoteChoice state carries live tallies and ballots at crates/engine/src/types/game_state.rs:3910-3920, and every ChooseOption immediately increments new_tallies, appends (player, choice_index), and emits GameEvent::VoteCast { choice } at crates/engine/src/game/engine_resolution_choices.rs:1231-1243; the next VoteChoice then stores those updated public values at lines 1248-1255. Why it matters: Truth or Consequences and Elrond both instruct players to vote secretly and then reveal the votes. This implementation exposes each prior vote before all players have voted, so it does not preserve the Oracle instruction even though exact-head CI is green. Suggested fix: add a typed secret/open vote axis or separate waiting state so secret ballots are collected without public tallies/choice events until the reveal/completion step, or leave the secret-vote cards unsupported until that engine seam exists.

[MEDIUM] Gemini's CR-annotation finding is valid. retarget_that_player_to_chosen performs the CR 608.2c/CR 120.1 player retargeting rule, but the helper's own doc/comment block at crates/engine/src/parser/oracle_vote.rs:821-827 has no CR <number>: <description> annotation. The surrounding call site has rule context, but this rule-implementing helper should carry the required local annotation before landing.

No approval, label, or enqueue from this sweep.

HIGH: Drop secret-ballot parsing from parse_each_player_votes_clause.
The existing WaitingFor::VoteChoice state immediately increments public
tallies and emits VoteCast events after each ballot, which would expose
each player's choice before the reveal step mandated by the Oracle text
of "each player secretly votes for … then those votes are revealed."
Per @matthewevans review: leave secret-vote cards (Truth or Consequences,
Elrond of the White Council, Orchard Elemental) as Effect::Unimplemented
until a proper hidden-ballot engine seam is added.

Removed satellite code introduced alongside the secret-vote arm:
  - take_until(", then those votes are revealed") strip in clause parser
  - card_name expansion (seg.replace('~', card_name)) for T/C name pun
  - card_name parameter on parse_vote_block / parse_each_player_votes_clause
  - Renamed oracle_saga.rs param back to _card_name (was unused)
  - Updated all callers in oracle.rs, oracle_trigger.rs, oracle_saga.rs,
    game/effects/vote.rs, and all tests

KEPT: parse_vote_for_each_suffix_clause, pre_vote_choose hoisting, and
retarget_that_player_to_chosen — these are architecturally valid building
blocks for public-vote cards with suffix aggregate clauses.

MEDIUM: Add mandatory CR 608.2c annotation to retarget_that_player_to_chosen
per @gemini-code-assist and @matthewevans. CR 608.2c covers "later text on
the card may modify the meaning of earlier text" — the "that player" anaphor
in the damage clause refers back to the player chosen by the preceding
"choose an opponent at random" instruction.

Tests updated:
  - parser/oracle_vote.rs: replaced T/C secret-vote assertion tests with
    tests that assert None (secret votes intentionally unsupported)
  - game/effects/vote.rs: updated T/C runtime test to use a public-vote
    opener, exercising the same Choose→Vote→SourceChosenPlayer machinery

Verification:
  - cargo fmt --all: clean
  - cargo clippy -p engine --all-targets -- -D warnings: clean
  - cargo test -p engine: all pass
  - ./scripts/check-parser-combinators.sh: exit 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR
@matthewevans

Copy link
Copy Markdown
Member

Current head a405362ca9df7dc73f4d29686264418d975e9af1 was re-reviewed after the substantive follow-up.\n\nPrior findings:\n- ADDRESSED: secret-ballot cards no longer parse as public votes. The each player secretly votes for opener now falls through unsupported, with parser tests pinning Truth or Consequences-style text as unsupported until a hidden-ballot engine seam exists.\n- ADDRESSED: retarget_that_player_to_chosen now carries a local CR 608.2c annotation; I verified CR 608.2c in the local Comprehensive Rules text.\n\nThe retained public-vote suffix machinery is also covered at parser and runtime levels: the suffix for each <choice> vote parser binds VoteCount, hoists the random player choice, and the runtime test drives Choose -> Vote -> draw/damage through resolve_ability_chain.\n\nHold: exact-head CI is still in progress and GitHub reports the branch BEHIND, so no approval, label, or enqueue from this sweep yet.

@matthewevans matthewevans self-assigned this Jun 28, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved current head a405362 after the follow-up review and repush guard. Prior blockers are addressed: secret-ballot wording now remains unsupported instead of being treated as public voting, the that-player retarget helper has the verified CR 608.2c annotation, and the retained public-vote suffix path has parser plus runtime Choose -> Vote -> draw/damage coverage. Exact-head CI is green and GitHub reports CLEAN.

@matthewevans matthewevans added bug Bug fix status:ready-to-merge Maintainer-reviewed and ready to merge labels Jun 28, 2026
@matthewevans matthewevans added this pull request to the merge queue Jun 28, 2026
Merged via the queue into phase-rs:main with commit 28dd5f1 Jun 28, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix status:ready-to-merge Maintainer-reviewed and ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants