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" ); }