feat(manabrew-compat): render generic object subsets as cards, not labels - #6702
Merged
Conversation
…bels
The generic path served every candidate list as `ChooseFromSelection` — a list
of text labels. For a subset chosen over objects that is a card selection, and
`ChooseCards` renders the cards themselves. The bounds are the same ones the
labelled family already carried, so nothing the engine computed is re-derived to
get here; only the family changes.
Both halves of the classification are read from projection data, never from a
`WaitingFor` list:
* **Not targeting.** CR 601.2c announces targets one slot at a time, and the
engine projects that ordered fill as the `Sequence` schema. `Select` is the
unordered subset schema and cannot express a slot order, so a target choice
never arrives in this shape. Verified over the whole enum rather than
assumed: all 28 `Select`-modelled states are non-targeting. `StationTarget`
is the one whose name says otherwise and it is a naming trap — CR 702.184a
makes the creature a cost ("Tap another untapped creature you control:"),
not a target.
* **Cards.** Every candidate must be exactly one `Object` in the `Candidate`
role and nothing else. A candidate carrying a player, a second
discriminator, or a concealed object fails the *whole list* back to the
labelled family rather than rendering a partial or misleading card list.
Requiring the `Candidate` role rather than any `Object` is what keeps
`OutsideGameChoice` out: its candidates are cards outside the game (CR 400.11a)
mixed with entries that have no `ObjectId` at all, which the client cannot
render from a board snapshot.
The answer path resolves card ids back through the same surface the prompt
rendered them from, so a card the prompt did not offer cannot be smuggled in by
index arithmetic. It submits `Select`, matching the schema the classifier
required — the engine rejects a `Choose` or `Sequence` against a `Select` schema
as malformed. Bounds are deliberately not rechecked adapter-side, for the reason
the rest of this path does not: the engine owns them and a second check here
would be a drifting authority.
The gate's stub lookup changes from yielding `None` to yielding empty text, and
this is load-bearing rather than cosmetic. `build_card_dto` fails with
`MissingCardText` whenever an object's identity is visible but the lookup is
empty-handed, so under the old stub the gate could never build a `ChooseCards`
and would have rejected every legal answer to one — the precise failure the
crate's guide warns about when a family is wired at two sites instead of three.
Family selection never depends on the text itself, only on whether text can be
had, so empty text keeps every state classifiable while leaking nothing. The
widening cannot loosen the two existing gates: the only bespoke `ChooseNumber`
site is `ChooseXValue` and the only bespoke `ChooseFromSelection` sites are
`ModeChoice`/`AbilityModeChoice`, none of which touches card text, so no state
that previously failed with `MissingCardText` can now build into either family.
Scope, measured rather than estimated: of the 28 `Select`-modelled states, 8
already have bespoke arms and are untouched (discard stays `ChooseCards`,
mulligan stays `Mulligan`, scry stays `Scry`). The 20 that reach the generic
path were `local.prompt-unsupported` before the projection work landed earlier
today, so against released behaviour this is additive rather than a change of
family. Aggregate-constrained members of that set still fail closed:
`ChooseCardsInput` carries only min/max and cannot express a summed-power bound.
Two CR annotations are corrected along the way. The pre-existing
`CR 701.8a: the chosen cards are the ones discarded` was wrong — 701.8a is
destroying a permanent; the discarding player's choice is CR 701.9b. Fixed
because this change edits that line.
Board targeting is deliberately NOT routed. The projection carries no datum
separating a CR 115 target choice from a non-targeting choice over objects:
eight states share `HumanResponseModel::TargetSequence`, and they genuinely mix
both kinds — `TargetSelection` and `MultiTargetSelection` target, while
`ProliferateChoice` (CR 701.27) and `TimeTravelChoice` do not. The only
discriminator, `TargetSequenceAction`, is private to the engine module and
never projected; that model's opportunity carries a bare `Summary` surface with
no `InteractionIntentCode` at all. Routing it today would require the hardcoded
`WaitingFor` list this design exists to remove, so it stays on the bespoke gate.
Unblocking it is an engine change: project the targeting bit on `TargetSequence`
opportunities.
80 -> 84 tests, all passing, `cargo clippy -p manabrew-compat --all-targets`
exit 0 with zero warnings, both in an isolated `CARGO_TARGET_DIR` since no Tilt
resource runs this crate's tests. Each new test was verified to discriminate by
reverting the seam it covers and observing it go red.
matthewevans
enabled auto-merge
July 27, 2026 19:58
Contributor
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughGeneric interaction projections now support card-selection prompts. Responses validate submitted cards against offered candidates, while discard choices retain their existing path. Tests cover bounds, classification, round trips, and invalid-card rejection. ChangesGeneric card-selection support
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The generic path served every candidate list as
ChooseFromSelection— a listof text labels. For a subset chosen over objects that is a card selection, and
ChooseCardsrenders the cards themselves. The bounds are the same ones thelabelled family already carried, so nothing the engine computed is re-derived to
get here; only the family changes.
Both halves of the classification are read from projection data, never from a
WaitingForlist:engine projects that ordered fill as the
Sequenceschema.Selectis theunordered subset schema and cannot express a slot order, so a target choice
never arrives in this shape. Verified over the whole enum rather than
assumed: all 28
Select-modelled states are non-targeting.StationTargetis the one whose name says otherwise and it is a naming trap — CR 702.184a
makes the creature a cost ("Tap another untapped creature you control:"),
not a target.
Objectin theCandidaterole and nothing else. A candidate carrying a player, a second
discriminator, or a concealed object fails the whole list back to the
labelled family rather than rendering a partial or misleading card list.
Requiring the
Candidaterole rather than anyObjectis what keepsOutsideGameChoiceout: its candidates are cards outside the game (CR 400.11a)mixed with entries that have no
ObjectIdat all, which the client cannotrender from a board snapshot.
The answer path resolves card ids back through the same surface the prompt
rendered them from, so a card the prompt did not offer cannot be smuggled in by
index arithmetic. It submits
Select, matching the schema the classifierrequired — the engine rejects a
ChooseorSequenceagainst aSelectschemaas malformed. Bounds are deliberately not rechecked adapter-side, for the reason
the rest of this path does not: the engine owns them and a second check here
would be a drifting authority.
The gate's stub lookup changes from yielding
Noneto yielding empty text, andthis is load-bearing rather than cosmetic.
build_card_dtofails withMissingCardTextwhenever an object's identity is visible but the lookup isempty-handed, so under the old stub the gate could never build a
ChooseCardsand would have rejected every legal answer to one — the precise failure the
crate's guide warns about when a family is wired at two sites instead of three.
Family selection never depends on the text itself, only on whether text can be
had, so empty text keeps every state classifiable while leaking nothing. The
widening cannot loosen the two existing gates: the only bespoke
ChooseNumbersite is
ChooseXValueand the only bespokeChooseFromSelectionsites areModeChoice/AbilityModeChoice, none of which touches card text, so no statethat previously failed with
MissingCardTextcan now build into either family.Scope, measured rather than estimated: of the 28
Select-modelled states, 8already have bespoke arms and are untouched (discard stays
ChooseCards,mulligan stays
Mulligan, scry staysScry). The 20 that reach the genericpath were
local.prompt-unsupportedbefore the projection work landed earliertoday, so against released behaviour this is additive rather than a change of
family. Aggregate-constrained members of that set still fail closed:
ChooseCardsInputcarries only min/max and cannot express a summed-power bound.Two CR annotations are corrected along the way. The pre-existing
CR 701.8a: the chosen cards are the ones discardedwas wrong — 701.8a isdestroying a permanent; the discarding player's choice is CR 701.9b. Fixed
because this change edits that line.
Board targeting is deliberately NOT routed. The projection carries no datum
separating a CR 115 target choice from a non-targeting choice over objects:
eight states share
HumanResponseModel::TargetSequence, and they genuinely mixboth kinds —
TargetSelectionandMultiTargetSelectiontarget, whileProliferateChoice(CR 701.27) andTimeTravelChoicedo not. The onlydiscriminator,
TargetSequenceAction, is private to the engine module andnever projected; that model's opportunity carries a bare
Summarysurface withno
InteractionIntentCodeat all. Routing it today would require the hardcodedWaitingForlist this design exists to remove, so it stays on the bespoke gate.Unblocking it is an engine change: project the targeting bit on
TargetSequenceopportunities.
80 -> 84 tests, all passing,
cargo clippy -p manabrew-compat --all-targetsexit 0 with zero warnings, both in an isolated
CARGO_TARGET_DIRsince no Tiltresource runs this crate's tests. Each new test was verified to discriminate by
reverting the seam it covers and observing it go red.
Summary by CodeRabbit
New Features
Bug Fixes