fix(parser): UNSUPPORTED cluster: SECRET-BALLOT vote seam — secret votes need a hidden-commit tally #4685
Conversation
…tes need a hidden-commit tally Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-unsupported-cluster-secret-ballot
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
Parse changes introduced by this PR · 6 card(s), 4 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
[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.
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
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
CR references
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