From 87711f10d861fdb0b72f9f59d3df1d8783be81ce Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 27 Jul 2026 12:58:18 -0700 Subject: [PATCH] feat(manabrew-compat): render generic object subsets as cards, not labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/manabrew-compat/src/lib.rs | 461 ++++++++++++++++++++++++++++-- 1 file changed, 436 insertions(+), 25 deletions(-) diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index 6d2ab1279d..314ee7f821 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -29,7 +29,7 @@ use engine::types::game_state::{ use engine::types::interaction::{ InteractionChoice, InteractionOpportunity, InteractionOpportunityResponse, InteractionPresentationSurface, InteractionResponse, InteractionResponseSpec, - InteractionSubmission, SelectionConstraint, ViewerInteraction, + InteractionRoleCode, InteractionSubmission, SelectionConstraint, ViewerInteraction, }; use engine::types::mana::{ManaColor as EngineManaColor, ManaCost, ManaCostShard, ManaType}; use engine::types::phase::Phase; @@ -2350,7 +2350,7 @@ fn build_prompt_input( WaitingFor::CombatTaxPayment { .. } => { unsupported_prompt(waiting_for, "local.pay-combat-cost-unsupported") } - _ => interaction_prompt(prepared), + _ => interaction_prompt(prepared, card_lookup), } } @@ -2376,7 +2376,14 @@ fn unsupported_prompt(waiting_for: &WaitingFor, code: &'static str) -> Result /// per-variant judgement. The schema-valued specs (sequences, numbers, amount /// assignments, relations) carry an unbounded response space that no single /// prompt family expresses; they still fail closed under a declared code. -fn interaction_prompt(prepared: &PreparedManabrewSnapshot) -> Result { +/// +/// One projection shape leaves the labelled-option family: an unordered subset +/// over a list of objects is a card selection, and [`card_selection_objects`] +/// routes it to `ChooseCards` so the client renders the cards themselves. +fn interaction_prompt( + prepared: &PreparedManabrewSnapshot, + card_lookup: &impl CardTextLookup, +) -> Result { let waiting_for = &prepared.state.waiting_for; // One opportunity per interaction slot this viewer may submit for, and a // viewer can be the authorized submitter for more than one semantic owner — @@ -2459,6 +2466,19 @@ fn interaction_prompt(prepared: &PreparedManabrewSnapshot) -> Result .map_err(|_| illegal("numberDecision the engine refused to materialize")) } +/// The objects a projected response is a selection *of*, when it is one. +/// +/// `Some` identifies the decision as a non-targeting card selection — the +/// `ChooseCards` shape — and carries the object behind each candidate, in the +/// order the engine offered them. +/// +/// Both halves of the classification are read from the projection, 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, which cannot express a slot order, so a target +/// choice never arrives in this shape. +/// - **Cards.** Every candidate must be exactly one object and nothing else, per +/// [`candidate_object`]. A candidate carrying a player, an extra discriminator, +/// or a concealed object (whose object surface the engine withholds) fails the +/// whole list back to the labelled-option family rather than rendering a +/// partial or misleading card list. +fn card_selection_objects(response: &InteractionOpportunityResponse) -> Option> { + card_selection_candidates(response)? + .iter() + .map(candidate_object) + .collect() +} + +/// The candidate list of a card selection, unresolved. +/// +/// The half of [`card_selection_objects`] that identifies the *schema*. The +/// response path needs the choices themselves (it answers by choice id, not by +/// object), so the two halves are separated rather than duplicated. +fn card_selection_candidates( + response: &InteractionOpportunityResponse, +) -> Option<&[InteractionChoice]> { + match response { + InteractionOpportunityResponse::Schema { + spec: InteractionResponseSpec::Select { .. }, + candidates, + } => Some(candidates), + _ => None, + } +} + +/// The one object a projected choice denotes, when the choice *is* that object. +/// +/// `Summary` surfaces are skipped because they carry a classification code, not +/// an identity. Everything else must amount to a single `Object` in the +/// `Candidate` role: any second identity surface means the choice denotes an +/// object *plus* something the card list cannot show, and the caller must not +/// treat it as a card. +fn candidate_object(choice: &InteractionChoice) -> Option { + let mut identities = choice + .surfaces + .iter() + .filter(|surface| !matches!(surface, InteractionPresentationSurface::Summary { .. })); + let InteractionPresentationSurface::Object { + role: InteractionRoleCode::Candidate, + reference, + .. + } = identities.next()? + else { + return None; + }; + if identities.next().is_some() { + return None; + } + // The engine writes the raw `ObjectId` here; the wire's `card-` prefix is + // this crate's encoding and is applied on the way out. + reference.parse().ok().map(ObjectId) +} + +/// Answer a generically-projected card prompt. +/// +/// Split from [`interaction_selection_action`] because `ChooseCards` answers by +/// card id, not by position: the ids are resolved back through the very +/// [`candidate_object`] surface the prompt rendered them from, so a card the +/// prompt did not offer cannot be smuggled in by index arithmetic. +/// +/// The submitted response is `Select`, matching the schema +/// [`card_selection_objects`] required — the engine rejects a `Choose` or +/// `Sequence` against a `Select` schema as malformed. +fn interaction_cards_action( + state: &GameState, + actor: PlayerId, + chosen_card_ids: &[String], +) -> Result { + let illegal = |kind: &'static str| AdapterError::IllegalResponseForPrompt { + response_kind: kind, + }; + let opportunity = sole_open_opportunity(state, actor)?; + let Some(candidates) = card_selection_candidates(&opportunity.response) else { + return Err(illegal( + "chooseCardsDecision against an interaction that is not a card selection", + )); + }; + // Bounds are not rechecked. The engine owns them and rejects a violating + // submission; a second check here would be a drifting authority. + let choice_ids = chosen_card_ids + .iter() + .map(|card_id| { + let object_id = parse_object_id(card_id)?; + candidates + .iter() + .find(|candidate| candidate_object(candidate) == Some(object_id)) + .map(|candidate| candidate.id.clone()) + .ok_or_else(|| illegal("chooseCardsDecision naming an unoffered card")) + }) + .collect::>>()?; + resolve_interaction_response( + state, + actor, + &InteractionSubmission { + interaction_id: opportunity.interaction_id, + response: InteractionResponse::Select { choice_ids }, + }, + ) + .map_err(|_| illegal("chooseCardsDecision the engine refused to materialize")) +} + /// Label one projected choice, from the strings the engine already put on it. /// /// Every naming surface is joined rather than taking the first, because choices @@ -2979,11 +3117,21 @@ pub fn translate_response( }), } } - // CR 701.8a: the chosen cards are the ones discarded. PromptOutput::ChooseCards(ChooseCardsOutput::ChooseCardsDecision { chosen_card_ids }) => { - Ok(GameAction::SelectCards { - cards: parse_object_ids(&chosen_card_ids)?, - }) + match &state.waiting_for { + // CR 701.9b: an effect that causes a discard lets the affected + // player choose which cards, so the chosen cards are exactly the + // discarded ones. The one bespoke producer of this family, whose + // answer is already the card list the engine's action wants. + WaitingFor::DiscardChoice { .. } => Ok(GameAction::SelectCards { + cards: parse_object_ids(&chosen_card_ids)?, + }), + // Every other card selection reaches the client through the + // projection, and its answering action is the engine's to name — + // `SelectCards` is one of several the `Select` schema + // materializes into. + _ => interaction_cards_action(state, context.deciding_player, &chosen_card_ids), + } } // CR 603.3b: `ReorderItem::id` is the trigger's index in the prompt's // list (see the `OrderTriggers` prompt arm), so the answer parses back @@ -4424,7 +4572,13 @@ fn output_family_matches_waiting( | WaitingFor::ExertChoice { .. } | WaitingFor::UnlessPayment { .. } ), - PromptOutput::ChooseCards(_) => matches!(waiting_for, WaitingFor::DiscardChoice { .. }), + // Reachable both bespoke (discard, CR 701.9b) and generically, so the + // bespoke match stays primary and the open-prompt check carries the rest + // rather than a list that would rot as the engine reclassifies states. + PromptOutput::ChooseCards(_) => { + matches!(waiting_for, WaitingFor::DiscardChoice { .. }) + || open_prompt_is_generic_cards(state, viewer) + } PromptOutput::Reorder(_) => matches!(waiting_for, WaitingFor::OrderTriggers { .. }), // Modeled on the wire, but this adapter emits no prompt that accepts // them, so no `WaitingFor` can legally receive one. @@ -4442,13 +4596,19 @@ fn output_family_matches_waiting( /// Rebuilding cannot drift from the builder because it *is* the builder. One /// extra prompt build per answer is proportionate: this runs once per decision. /// -/// The lookup yields no card text on purpose. Family selection never depends on -/// it — the arms that need text belong to other families and fail with -/// `MissingCardText`, which is no family at all and so gates `false`, exactly as -/// it should. +/// The lookup yields *empty* card text on purpose. Which family a state builds +/// into never depends on the text, only on whether text can be had at all — so +/// supplying an empty string keeps every state answerable here while leaking +/// nothing. Yielding `None` instead would make `MissingCardText` swallow the +/// card-bearing families (`ChooseCards`, mulligan, scry, reorder) into "no +/// family", silently gating a legal answer to a card prompt as illegal. fn open_prompt(state: &GameState, viewer: PlayerId) -> Option { let prepared = prepare_snapshot(state, viewer, "").ok()?; - build_prompt_input(&prepared, &(|_: &GameObject| -> Option { None })).ok() + build_prompt_input( + &prepared, + &(|_: &GameObject| -> Option { Some(String::new()) }), + ) + .ok() } fn open_prompt_is_generic_selection(state: &GameState, viewer: PlayerId) -> bool { @@ -4458,6 +4618,13 @@ fn open_prompt_is_generic_selection(state: &GameState, viewer: PlayerId) -> bool ) } +fn open_prompt_is_generic_cards(state: &GameState, viewer: PlayerId) -> bool { + matches!( + open_prompt(state, viewer), + Some(PromptInput::ChooseCards(_)) + ) +} + fn open_prompt_is_generic_number(state: &GameState, viewer: PlayerId) -> bool { matches!( open_prompt(state, viewer), @@ -4971,8 +5138,9 @@ mod tests { use engine::types::ability::{Effect, ResolvedAbility, TargetFilter}; use engine::types::counter::CounterType; use engine::types::game_state::{ - MulliganDecisionEntry, MulliganDecisionPhase, PayableResource, PendingCast, - PendingMulliganAction, TargetSelectionProgress, TargetSelectionSlot, + MulliganDecisionEntry, MulliganDecisionPhase, OutsideGameChoiceEntry, + OutsideGameChoiceSource, PayableResource, PendingCast, PendingMulliganAction, + TargetSelectionProgress, TargetSelectionSlot, }; use engine::types::identifiers::CardId; use engine::types::interaction::InteractionSessionId; @@ -5935,9 +6103,13 @@ mod tests { /// `DiscardToHandSize` (CR 514.1) is the clearest case — discard exactly /// `count` of the cards in hand — so it pins the two things that distinguish /// this from the `ExactChoices` path: the count bounds reach the prompt as - /// `min_total`/`max_total` instead of the hardcoded 1/1, and the answer must + /// the family's own min/max instead of the hardcoded 1/1, and the answer must /// go back as `InteractionResponse::Select`, since the engine rejects a /// `Choose` against a `Select` schema as malformed. + /// + /// The family is `ChooseCards`, not `ChooseFromSelection`: an unordered + /// subset over a list of objects is a card selection, and the client is owed + /// the cards rather than three opaque labels. #[test] fn a_select_schema_carries_its_count_bounds_and_answers_as_a_subset() { let mut state = GameState::new_two_player(7); @@ -5964,28 +6136,267 @@ mod tests { let prepared = prepare_snapshot_with_prompt_id(&state, PlayerId(0), "game-a", 42).unwrap(); let prompt = build_prompt_input(&prepared, &lookup) .expect("a Select schema is served by the projection"); - let PromptInput::ChooseFromSelection(input) = prompt else { - panic!("a subset choice over candidates is ChooseFromSelection, got {prompt:?}"); + let PromptInput::ChooseCards(input) = prompt else { + panic!("a subset choice over objects is ChooseCards, got {prompt:?}"); }; assert_eq!( - (input.min_total, input.max_total), + (input.min, input.max), (2, 2), "the engine's count bounds must survive, not the one-of path's 1/1" ); - assert_eq!(input.options.len(), 3, "every hand card is a candidate"); + assert_eq!( + input + .cards + .iter() + .map(|card| card.identity.name.as_str()) + .collect::>(), + vec!["Discard A", "Discard B", "Discard C"], + "every hand card is a candidate, named — not a labelled option" + ); let action = translate_response( 42, - PromptOutput::ChooseFromSelection(ChooseFromSelectionOutput::SelectionDecision { - chosen_indices: vec![0, 1], + PromptOutput::ChooseCards(ChooseCardsOutput::ChooseCardsDecision { + chosen_card_ids: vec![input.cards[0].id.clone(), input.cards[1].id.clone()], }), &prepared.prompt_context(), &state, ) .expect("a two-card subset resolves back through the engine"); - assert!( - matches!(action, GameAction::SelectCards { .. }), - "a discard subset answers with SelectCards, got {action:?}" + assert_eq!( + action, + GameAction::SelectCards { + cards: vec![cards[0], cards[1]], + }, + "a discard subset answers with the engine-materialized SelectCards" + ); + } + + /// The card family is wired at all three sites, not just at the prompt. + /// + /// `ChooseRingBearer` (CR 701.54a) is chosen over a discard because its + /// answering action is *not* `SelectCards`: the bespoke discard arm, which + /// this family already had, would have answered it with the wrong action + /// entirely. So a green here means prompt construction, response + /// translation, and the gate all reached the generic path. + /// + /// The gate is exercised by construction: `translate_response` runs + /// `output_family_matches_waiting` first, and `ChooseRingBearer` is not in + /// the bespoke `matches!`, so without `open_prompt_is_generic_cards` this + /// legal answer is rejected as `IllegalResponseForPrompt` before any + /// translation runs. + #[test] + fn a_card_selection_prompts_as_cards_and_answers_the_engines_own_action() { + let mut state = GameState::new_two_player(7); + let candidates = ["Frodo Baggins", "Samwise Gamgee"] + .into_iter() + .map(|name| { + create_object( + &mut state, + CardId(1), + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ) + }) + .collect::>(); + state.waiting_for = WaitingFor::ChooseRingBearer { + player: PlayerId(0), + candidates: candidates.clone(), + }; + bind_interaction_authority(&mut state, InteractionSessionId("ring-bearer".to_string())) + .expect("valid interaction authority binding"); + + let prepared = prepare_snapshot_with_prompt_id(&state, PlayerId(0), "game-a", 42).unwrap(); + let prompt = build_prompt_input(&prepared, &lookup) + .expect("a Select schema over objects is served by the projection"); + let PromptInput::ChooseCards(input) = prompt else { + panic!("a non-targeting selection over objects is ChooseCards, got {prompt:?}"); + }; + assert_eq!( + (input.min, input.max), + (1, 1), + "CR 701.54a: the Ring tempts you, so choose one creature you control" + ); + assert_eq!( + input + .cards + .iter() + .map(|card| (card.id.clone(), card.identity.name.clone())) + .collect::>(), + vec![ + (encode_object_id(candidates[0]), "Frodo Baggins".to_string()), + ( + encode_object_id(candidates[1]), + "Samwise Gamgee".to_string() + ), + ], + "the candidates reach the client as cards, keyed by the wire id the answer echoes" + ); + + let action = translate_response( + 42, + PromptOutput::ChooseCards(ChooseCardsOutput::ChooseCardsDecision { + chosen_card_ids: vec![encode_object_id(candidates[1])], + }), + &prepared.prompt_context(), + &state, + ) + .expect("the echoed card id resolves back through the engine"); + assert_eq!( + action, + GameAction::ChooseRingBearer { + target: candidates[1], + }, + "the engine names the action; the bespoke discard arm would have said SelectCards" + ); + } + + /// An unoffered card is refused, and the refusal is not vacuous. + /// + /// The positive leg proves the fixture reaches the generic card path at all + /// — without it, a rejection could equally mean the prompt never became a + /// `ChooseCards` in the first place. + #[test] + fn a_card_answer_naming_an_unoffered_card_is_refused() { + let mut state = GameState::new_two_player(7); + let offered = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Frodo Baggins".to_string(), + Zone::Battlefield, + ); + let bystander = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Gollum".to_string(), + Zone::Battlefield, + ); + state.waiting_for = WaitingFor::ChooseRingBearer { + player: PlayerId(0), + candidates: vec![offered], + }; + bind_interaction_authority(&mut state, InteractionSessionId("ring-guard".to_string())) + .expect("valid interaction authority binding"); + let prepared = prepare_snapshot_with_prompt_id(&state, PlayerId(0), "game-a", 42).unwrap(); + + // Reach-guard: the offered card really does answer this prompt. + assert!(translate_response( + 42, + PromptOutput::ChooseCards(ChooseCardsOutput::ChooseCardsDecision { + chosen_card_ids: vec![encode_object_id(offered)], + }), + &prepared.prompt_context(), + &state, + ) + .is_ok()); + + assert!(matches!( + translate_response( + 42, + PromptOutput::ChooseCards(ChooseCardsOutput::ChooseCardsDecision { + chosen_card_ids: vec![encode_object_id(bystander)], + }), + &prepared.prompt_context(), + &state, + ), + Err(AdapterError::IllegalResponseForPrompt { .. }) + )); + } + + /// An ordered sequence over objects is **not** reclassified as cards. + /// + /// The schema leg of the classifier, isolated: `ProliferateChoice` (CR + /// 701.29a) projects every eligible permanent through the same + /// `Object`/`Candidate` surface a card selection uses, so the candidates + /// alone would pass. Only the schema keeps it out — widening + /// [`card_selection_candidates`] to accept `Sequence` turns this red. + /// + /// The bounds and candidate count are the reach-guard: a state that failed + /// to build at all would produce nothing to count. + #[test] + fn an_ordered_sequence_over_objects_is_not_a_card_selection() { + let mut state = GameState::new_two_player(7); + let permanent = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Counter Bearer".to_string(), + Zone::Battlefield, + ); + state.waiting_for = WaitingFor::ProliferateChoice { + player: PlayerId(0), + eligible: vec![TargetRef::Object(permanent)], + }; + bind_interaction_authority(&mut state, InteractionSessionId("proliferate".to_string())) + .expect("valid interaction authority binding"); + + let prepared = prepare_snapshot_with_prompt_id(&state, PlayerId(0), "game-a", 42).unwrap(); + let prompt = build_prompt_input(&prepared, &lookup) + .expect("a Sequence schema is served by the projection"); + let PromptInput::ChooseFromSelection(input) = prompt else { + panic!("an ordered sequence stays in the labelled family, got {prompt:?}"); + }; + assert_eq!( + (input.min_total, input.max_total, input.options.len()), + (0, 1, 1), + "the sequence bounds and the candidate survive" + ); + } + + /// A subset whose candidates are not plain candidates is **not** cards. + /// + /// The surface leg, isolated: `OutsideGameChoice` (CR 400.11a / CR 406.3) is + /// a `Select` schema — the very schema the card classifier keys on — but its + /// candidates are projected in the `FaceUpExile` role, because a card + /// outside the game is not interchangeable with one the client can render + /// from the battlefield snapshot. Dropping the role test in + /// [`candidate_object`] turns this red. + #[test] + fn a_subset_whose_candidates_are_not_plain_objects_is_not_a_card_selection() { + let mut state = GameState::new_two_player(7); + let source_id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Learn Source".to_string(), + Zone::Battlefield, + ); + let exiled = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Lesson Card".to_string(), + Zone::Exile, + ); + state.waiting_for = WaitingFor::OutsideGameChoice { + player: PlayerId(0), + source_id, + choices: vec![OutsideGameChoiceEntry { + source: OutsideGameChoiceSource::FaceUpExile { object_id: exiled }, + count: 1, + name: "Lesson Card".to_string(), + }], + count: 1, + reveal: false, + up_to: true, + destination: Zone::Hand, + }; + bind_interaction_authority(&mut state, InteractionSessionId("outside-game".to_string())) + .expect("valid interaction authority binding"); + + let prepared = prepare_snapshot_with_prompt_id(&state, PlayerId(0), "game-a", 42).unwrap(); + let prompt = build_prompt_input(&prepared, &lookup) + .expect("a Select schema is served by the projection"); + let PromptInput::ChooseFromSelection(input) = prompt else { + panic!("a non-candidate role stays in the labelled family, got {prompt:?}"); + }; + assert_eq!( + (input.min_total, input.max_total, input.options.len()), + (0, 1, 1), + "the selection bounds and the candidate survive" ); }