Skip to content

fix(parser): UNSUPPORTED cluster: SECRET-BALLOT vote seam — secret votes need a hidden-commit tally #4685

Open
ntindle wants to merge 2 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-83-unsupported-cluster-secret-ballot
Open

fix(parser): UNSUPPORTED cluster: SECRET-BALLOT vote seam — secret votes need a hidden-commit tally #4685
ntindle wants to merge 2 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-83-unsupported-cluster-secret-ballot

Conversation

@ntindle

@ntindle ntindle commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

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

Root cause: UNSUPPORTED cluster: SECRET-BALLOT vote seam — secret votes need a hidden-commit tally (public VoteCast events are wrong for secret ballots)

Cards corrected

  • Truth or Consequences
  • Prime Minister's Cabinet Room
  • Council's Judgment
  • Capital Punishment
  • Council Guardian

Fix

Implemented all four work-streams of the approved cluster-83 voting plan in /Users/ntindle/code/random/magic/phase-main-base. All 5 cards now parse with ZERO Unimplemented/Unknown (verified end-to-end via oracle-gen against real card-data).

WS-A (PR1): Refactored VoteTally::Threshold{tie_breaker_index} into the parameterized VoteTally::TopVotes{tie: TieResolution} (TieResolution::{Breaker(u8), AllTied}) — no new sibling. resolve_tally dispatch became an exhaustive match; resolve_threshold_tally became resolve_top_votes_tally(tie, ...). Council Guardian: parse_all_tied_vote_clause builds per-color protection grants via the shared parse_keyword_from_oracle building block, pinned to Duration::Permanent per CR 611.2a (the plan's CR 611.2b was wrong; verified 611.2b @docs:2902 is the "for as long as" case). A parser test asserts duration==Permanent as the EOT-regression guard.

WS-B (PR1): parse_conjoined_suffix_clauses parses Capital Punishment's "Each opponent [verb-A] for each vote and [verb-B] for each vote" by distributing the shared subject text and binding each conjunct to QuantityRef::VoteCount. Factored parse_for_each_vote_suffix + bind_vote_count_aggregate out of the single-suffix helper. Result: per_choice_effect[death]=Sacrifice{VoteCount{0}, Opponent}, [taxes]=Discard{VoteCount{1}, Opponent}; the old nested-Sacrifice Fixed{1} artifact is gone.

WS-C (PR2): Added VoteSubject::{Named, Objects{candidate_filter, outcome_template}} + GameAction::SubmitVoteCandidate{candidate_index} + WaitingFor::VoteChoice.{candidate_objects, outcome_template}. Extracted the inline ballot tally/advance block into the single authority append_vote_ballot_and_advance(VoteRoundState). resolve() enumerates battlefield candidates (FilterContext::from_source_with_controller + matches_target_filter); resolve_top_votes_tally's AllTied arm guards on outcome_template BEFORE indexing per_choice_effect and injects the winning ObjectId as TargetRef::Object so a tie exiles exactly the tied winners (not a rescan). Wired the AI object-vote candidate branch, and the frontend (hand-maintained types.ts + index-based VoteChoiceModal dispatch). Council's Judgment + PMCR chaos body parse to Objects/AllTied.

WS-D (PR3): Added VoteVisibility::{Open, Secret} + WaitingFor field. parse_each_player_votes_clause returns a ParsedVoteOpener struct and recognizes the "each player secretly votes for … , then those votes are revealed" opener (choice list terminates at the reveal marker, no period). append_vote_ballot_and_advance suppresses per-ballot VoteCast under Secret; filter_state_for_viewer scrubs running tallies/ballots to zero for every viewer until the single VoteResolved reveal. The accepted local-AI raw-state limitation (D6) is documented inline and in the test.

Verification (this worktree is NOT under Tilt — ran cargo directly): cargo fmt clean; cargo clippy -p engine --all-targets clean (fixed vec_box via mirror-shape allow, type_complexity via VoteChoiceSlot alias); full cargo test -p engine = 0 failures; server-core tests pass (228+13+5); frontend type-check + eslint on both changed files clean. New tests: 6 parser tests (5 cards + conjoined building block) + 5 resolver/runtime tests (object-vote SubmitVoteCandidate round-trip proving single-target injection, all-tied resolves-all/zero-votes-no-op, secret VoteCast suppression + tally scrub, object-vote validation). oracle-gen confirms all 5 cards: 0 Unimplemented, 0 Unknown, correct Vote shapes.

Files changed

  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/actions.rs
  • crates/engine/src/game/effects/vote.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/src/game/visibility.rs
  • crates/engine/src/game/printed_cards.rs
  • crates/engine/src/parser/oracle_vote.rs
  • crates/engine/src/ai_support/candidates.rs
  • crates/engine/tests/integration/master_of_ceremonies.rs
  • crates/phase-ai/src/search.rs
  • crates/server-core/src/game_action_payload_guard.rs
  • client/src/adapter/types.ts
  • client/src/components/modal/VoteChoiceModal.tsx

CR references

  • CR 611.2a (docs:2900) — Council Guardian's protection grant has no stated duration → lasts until end of game (Duration::Permanent); replaces the plan's wrong CR 611.2b
  • CR 701.38a (docs:3612) — vote procedure (starting player + turn order); most-votes/tie selection annotated as card-defined, not this rule
  • CR 701.38b (docs:3614) — listed choices may be objects; annotates VoteSubject::Objects + 'you don't control' candidate enumeration + single-object exile injection
  • CR 701.38d (docs:3618) — multiple votes happen together; unchanged PerVote fan-out
  • CR 608.2c (docs:2789) — instructions in written order, later text refers back; annotates SourceChosenPlayer retarget, object-vote target injection, and empty-object-pool no-op (corrected from a CR 800.4g misattribution)
  • CR 702.16a (docs:4031) — protection from a color; annotates Council Guardian per-color keyword grants
  • CR 800.4g (docs:6395) — verified for existing EachOpponent voter-queue exclusion usage

Verification

  • cargo fmt --all — pass (no changes; nothing to commit)
  • check-parser-combinators.sh <upstream/main merge-base> — pass (exit 0)
  • cargo clippy -p engine --all-targets -- -D warnings — pass (zero warnings; fix's changed files clippy-clean)
  • cargo test -p engine — pass (all tests passed, 0 failed)
  • oracle-gen data --filter (5 affected cards) — pass (all 5 parse to typed Vote effects, no Unimplemented/Unknown)
    Cards confirmed re-parsed correctly: truth or consequences, prime minister's cabinet room, council's judgment, capital punishment, council guardian

🤖 Generated with Claude Code

ntindle and others added 2 commits June 30, 2026 20:55
…tes need a hidden-commit tally

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ntindle ntindle requested a review from matthewevans as a code owner July 1, 2026 02:12
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 6 card(s), 4 signature(s) (baseline: main 8da56cc159bc)

5 card(s) · ability/Vote · added: Vote

Examples: Capital Punishment, Council Guardian, Council's Judgment (+2 more)

4 card(s) · ability/vote · removed: vote

Examples: Capital Punishment, Council Guardian, Council's Judgment (+1 more)

2 card(s) · ability/secretly · removed: secretly

Examples: Elrond of the White Council, Truth or Consequences

1 card(s) · ability/Choose · added: Choose (choice=opponent, persist=yes)

Examples: Truth or Consequences

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.

[MED] Object-pool vote candidates are indexed with u8, so large boards make candidates unreachable or aliased. Evidence: crates/engine/src/types/actions.rs:310 defines SubmitVoteCandidate { candidate_index: u8 }, crates/engine/src/types/game_state.rs:4070 / :6985 store vote ballots as (PlayerId, u8), and crates/engine/src/ai_support/candidates.rs:1293-:1298 casts every object candidate index with i as u8. Object-pool votes enumerate arbitrary battlefield objects, not a fixed small Oracle choice list. Why it matters: Council's Judgment / Prime Minister's Cabinet Room can legally see more than 255 candidate permanents/creatures in token-heavy games, making later candidates impossible to vote for and causing AI votes after 255 to wrap to earlier objects. Suggested fix: widen vote candidate/ballot indices to a non-wrapping serialized integer (u32/usize as appropriate) and add a runtime test with >255 object candidates proving the last candidate can be selected and wins.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants