Skip to content

feat(manabrew-compat): render generic object subsets as cards, not labels - #6702

Merged
matthewevans merged 1 commit into
mainfrom
ship/manabrew-cards-family
Jul 27, 2026
Merged

feat(manabrew-compat): render generic object subsets as cards, not labels#6702
matthewevans merged 1 commit into
mainfrom
ship/manabrew-cards-family

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 27, 2026

Copy link
Copy Markdown
Member

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.

Summary by CodeRabbit

  • New Features

    • Added support for card-selection prompts in generic interactions.
    • Preserved minimum and maximum selection limits for card choices.
    • Validated submitted cards against those offered in the prompt.
  • Bug Fixes

    • Prevented invalid or unoffered card selections from being accepted.
    • Improved classification between card and sequence selections.
    • Corrected handling of selection bounds and discard choices.

…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
matthewevans enabled auto-merge July 27, 2026 19:58
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 38678387-3053-47c8-ae7d-78d8688b9dec

📥 Commits

Reviewing files that changed from the base of the PR and between 6accf59 and 87711f1.

📒 Files selected for processing (1)
  • crates/manabrew-compat/src/lib.rs

📝 Walkthrough

Walkthrough

Generic 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.

Changes

Generic card-selection support

Layer / File(s) Summary
Projected card prompt construction
crates/manabrew-compat/src/lib.rs
Generic interaction projections produce ChooseCards prompts with materialized cards and projected min/max bounds when candidates match the card-selection shape.
Card response routing and validation
crates/manabrew-compat/src/lib.rs
Generic ChooseCards responses resolve submitted ids against offered candidates, while DiscardChoice retains direct SelectCards handling and family validation recognizes generic card prompts.
Card-selection behavior tests
crates/manabrew-compat/src/lib.rs
Tests cover Select bounds, card round trips, unoffered-card rejection, ordered sequences, and non-card candidate classification.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • phase-rs/phase#6685: Updates prompt-family routing for discard choices and ChooseCards responses.
  • phase-rs/phase#6700: Adds the interaction-projection path that this change extends for generic card selections.

Suggested reviewers: lgray, claytonlin1110, mike-thedude

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: generic object subsets are rendered as cards instead of labels.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/manabrew-cards-family

Comment @coderabbitai help to get the list of available commands.

@matthewevans
matthewevans added this pull request to the merge queue Jul 27, 2026
Merged via the queue into main with commit ec42e88 Jul 27, 2026
15 checks passed
@matthewevans
matthewevans deleted the ship/manabrew-cards-family branch July 27, 2026 20:25
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.

1 participant