diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index f92089a8a5..c1bc1327f5 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -27,7 +27,7 @@ use crate::types::card_type::{CardType, CoreType, Supertype}; use crate::types::counter::{CounterMatch, CounterType}; use crate::types::format::DeckCopyLimit; use crate::types::keywords::{ - BloodthirstValue, BuybackCost, CyclingCost, EchoCost, Keyword, PartnerType, + BloodthirstValue, BuybackCost, CyclingCost, EchoCost, GiftKind, Keyword, PartnerType, }; use crate::types::mana::{ManaColor, ManaCost, ManaCostShard}; use crate::types::phase::Phase; @@ -1420,10 +1420,10 @@ pub(crate) fn bargain_additional_cost() -> AdditionalCost { /// when the player promises the gift. Conditional branches ("if the gift was promised" / /// "wasn't promised") are handled by the parser via `strip_additional_cost_conditional`. /// -/// Gift delivery (opponent receives the gift) is injected as a `GiftDelivery` effect -/// wrapping the first spell ability. The delivery checks `additional_cost_paid` at -/// resolution time — if the gift wasn't promised, it's a no-op and the spell resolves -/// normally. If promised, the opponent receives the gift before the spell's other effects. +/// Gift delivery (opponent receives the gift) is injected as a `GiftDelivery` effect. +/// Instants and sorceries wrap the first spell ability so delivery resolves before other +/// spell effects (CR 702.174j). Permanent gifts synthesize an ETB trigger instead +/// (CR 702.174b: "when it enters, they …"). pub fn synthesize_gift(face: &mut CardFace) { if face.additional_cost.is_some() { return; @@ -1447,16 +1447,61 @@ pub fn synthesize_gift(face: &mut CardFace) { repeatability: crate::types::ability::AdditionalCostRepeatability::Once, }); - // Inject GiftDelivery as a wrapper around the first spell ability. - // The delivery effect is a no-op when the gift wasn't promised, so the - // chain always flows through to the spell's normal effects. - if let Some(first_ability) = face.abilities.first_mut() { - let original = std::mem::replace( - first_ability, - AbilityDefinition::new(AbilityKind::Spell, Effect::GiftDelivery { kind: gift_kind }), - ); - first_ability.sub_ability = Some(Box::new(original)); + let is_instant_or_sorcery = face + .card_type + .core_types + .iter() + .any(|t| matches!(t, CoreType::Instant | CoreType::Sorcery)); + + if is_instant_or_sorcery { + // CR 702.174j: Inject GiftDelivery as a wrapper around the first spell ability. + // The delivery effect is a no-op when the gift wasn't promised, so the + // chain always flows through to the spell's normal effects. + if let Some(first_ability) = face.abilities.first_mut() { + let original = std::mem::replace( + first_ability, + AbilityDefinition::new( + AbilityKind::Spell, + Effect::GiftDelivery { kind: gift_kind }, + ), + ); + first_ability.sub_ability = Some(Box::new(original)); + } + } else { + synthesize_gift_etb_delivery_trigger(face, gift_kind); + } +} + +/// CR 702.174b: Permanent gifts deliver on ETB via a ChangesZone trigger rather +/// than wrapping a printed spell ability (Kitnap, Octomancer, Starforged Sword). +fn synthesize_gift_etb_delivery_trigger(face: &mut CardFace, kind: GiftKind) { + use crate::types::zones::Zone; + + let already_has_trigger = face.triggers.iter().any(|t| { + matches!(t.mode, TriggerMode::ChangesZone) + && t.destination == Some(Zone::Battlefield) + && matches!(t.valid_card, Some(TargetFilter::SelfRef)) + && matches!( + t.execute.as_deref().map(|a| &*a.effect), + Some(Effect::GiftDelivery { kind: existing }) if existing == &kind + ) + }); + if already_has_trigger { + return; } + + face.triggers.insert( + 0, + TriggerDefinition::new(TriggerMode::ChangesZone) + .destination(Zone::Battlefield) + .valid_card(TargetFilter::SelfRef) + .trigger_zones(vec![Zone::Battlefield]) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::GiftDelivery { kind }, + )) + .description("Gift delivery".to_string()), + ); } /// CR 719.2: Synthesize the intrinsic Case auto-solve trigger. diff --git a/crates/engine/src/game/effects/gift_delivery.rs b/crates/engine/src/game/effects/gift_delivery.rs index d3f70c5992..9551db8140 100644 --- a/crates/engine/src/game/effects/gift_delivery.rs +++ b/crates/engine/src/game/effects/gift_delivery.rs @@ -79,6 +79,28 @@ pub fn resolve( obj.tapped = true; } } + GiftKind::CreatureToken(spec) => { + let obj_id = create_gift_token( + state, + events, + opponent, + &spec.name, + ability.source_id, + |ct| { + ct.core_types.push(CoreType::Creature); + ct.subtypes.extend(spec.subtypes.iter().cloned()); + }, + ); + if let Some(obj) = state.objects.get_mut(&obj_id) { + obj.color = spec.colors.clone(); + obj.base_color = spec.colors.clone(); + obj.power = Some(spec.power); + obj.toughness = Some(spec.toughness); + obj.base_power = Some(spec.power); + obj.base_toughness = Some(spec.toughness); + obj.tapped = spec.tapped; + } + } } events.push(GameEvent::EffectResolved { @@ -277,4 +299,36 @@ mod tests { let token = token.unwrap(); assert!(token.card_types.subtypes.contains(&"Food".to_string())); } + + #[test] + fn gift_creature_token_creates_octopus_for_opponent() { + use crate::types::keywords::GiftCreatureToken; + + let mut state = GameState::new_two_player(42); + let mut events = Vec::new(); + + let ability = make_gift_ability( + GiftKind::CreatureToken(GiftCreatureToken { + name: "Octopus".to_string(), + power: 8, + toughness: 8, + colors: vec![ManaColor::Blue], + subtypes: vec!["Octopus".to_string()], + tapped: false, + }), + true, + ); + resolve(&mut state, &ability, &mut events).unwrap(); + + let token = state + .objects + .values() + .find(|o| o.card_id == CardId(0) && o.owner == PlayerId(1)); + assert!(token.is_some(), "Octopus token should exist for opponent"); + let token = token.unwrap(); + assert_eq!(token.power, Some(8)); + assert_eq!(token.toughness, Some(8)); + assert!(token.color.contains(&ManaColor::Blue)); + assert!(token.card_types.subtypes.contains(&"Octopus".to_string())); + } } diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 0d9a98cbdf..2531cb893f 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -71,8 +71,9 @@ use super::oracle_ir::relation::{DocumentRelationIr, LinkedChoiceKind, LinkedRet use super::oracle_ir::replacement::ReplacementIr; pub use super::oracle_keyword::keyword_display_name; use super::oracle_keyword::{ - is_keyword_cost_line, is_kicker_family_line, parse_kicker_additional_cost_line, - parse_router_keyword_fragment, parse_router_keyword_line, parse_router_keyword_list, + gift_keyword_router_line, is_keyword_cost_line, is_kicker_family_line, + parse_kicker_additional_cost_line, parse_router_keyword_fragment, parse_router_keyword_line, + parse_router_keyword_list, }; use super::oracle_level::parse_level_blocks; use super::oracle_modal::{ @@ -4010,7 +4011,13 @@ pub(crate) fn parse_oracle_ir( // and becomes an honest, exact-unit `Effect::Unimplemented`. let is_ability_cost_static = is_ability_activate_cost_static(&lower); if !is_ability_cost_static { - if let Some(extracted) = parse_router_keyword_list(&line, mtgjson_keyword_names) { + // CR 702.174: "Gift an Octopus" delivery lives in the parenthetical + // reminder; the loop strips reminders before this slot, so gift lines + // must route through the raw Oracle line. + let keyword_router_line = gift_keyword_router_line(raw_line, &line, &lower); + if let Some(extracted) = + parse_router_keyword_list(keyword_router_line, mtgjson_keyword_names) + { if let Some(cost) = parse_kicker_additional_cost_line(&line, &lower) { merge_kicker_additional_cost(&mut result.additional_cost, cost); additional_cost_line.get_or_insert(item_line); @@ -5435,7 +5442,8 @@ pub(crate) fn parse_oracle_ir( // strictly parse — "Cycling {2} if you control an artifact" — falls // through to spell-effect parsing and becomes an honest, exact-unit // `Effect::Unimplemented` rather than vanishing. - if let Some(routed) = parse_router_keyword_line(&line) { + let keyword_router_line = gift_keyword_router_line(raw_line, &line, &lower); + if let Some(routed) = parse_router_keyword_line(keyword_router_line) { if let Some(keyword) = routed.keyword { emitter.keyword_at(item_line, keyword); } @@ -5798,7 +5806,8 @@ pub(crate) fn parse_oracle_ir( // and NO `Unimplemented` — a silent swallow that rendered as full // support. A strict parse is now the only licence to advance; anything // else falls through to priority 14a/15 and stays honestly red. - if let Some(routed) = parse_router_keyword_line(&line) { + let keyword_router_line = gift_keyword_router_line(raw_line, &line, &lower); + if let Some(routed) = parse_router_keyword_line(keyword_router_line) { if let Some(keyword) = routed.keyword { emitter.keyword_at(item_line, keyword); } diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index d0d6762e61..bea31d6ca4 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -21,7 +21,8 @@ use crate::types::ability::{ }; use crate::types::keywords::{ normalize_bands_with_other_quality, BloodthirstValue, BuybackCost, CyclingCost, DisguiseCost, - EmbalmCost, EscapeCost, EternalizeCost, FlashbackCost, Keyword, WardCost, + EmbalmCost, EscapeCost, EternalizeCost, FlashbackCost, GiftCreatureToken, GiftKind, Keyword, + WardCost, }; use crate::types::mana::{ManaCost, ManaCostShard}; use crate::types::zones::Zone; @@ -1642,18 +1643,16 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { } // Gift keyword: "gift a card", "gift a treasure", "gift a food", "gift a tapped fish" - if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("gift a ").parse(text) { - use crate::types::keywords::GiftKind; - let kind = match rest.trim() { - "card" => GiftKind::Card, - "treasure" => GiftKind::Treasure, - "food" => GiftKind::Food, - "tapped fish" => GiftKind::TappedFish, - _ => return None, - }; + if let Some(kind) = parse_gift_a_declaration(text) { return Some((Keyword::Gift(kind), "")); } + // CR 702.174: "gift an " declarations (Octomancer) resolve delivery from + // the parenthetical reminder on the full keyword line via `parse_gift_keyword_line`. + if tag::<_, _, OracleError<'_>>("gift an ").parse(text).is_ok() { + return None; + } + // CR 702.49d: Commander ninjutsu — multi-word keyword name (like "level up"). if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("commander ninjutsu ").parse(text) { let cost_str = rest.trim(); @@ -2088,6 +2087,163 @@ fn apply_keyword_line_modifiers( Some(keyword) } +/// Extract the first balanced parenthetical span from a Gift keyword line. +fn gift_reminder_text(full_line: &str) -> Option<&str> { + let start = full_line.find('(')?; + let mut depth = 0usize; + for (offset, ch) in full_line[start..].char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(&full_line[start + 1..start + offset]); + } + } + _ => {} + } + } + None +} + +fn capitalize_token_name(word: &str) -> String { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } +} + +/// CR 702.174: Parse a parameterized creature-token gift delivery clause from +/// reminder text ("they create an 8/8 blue Octopus creature token"). +fn parse_gift_creature_token_delivery(reminder_lower: &str) -> Option { + let trimmed = reminder_lower.trim().trim_end_matches('.'); + let (rest, (_, _, tapped, power, _, toughness, color, subtype, _)) = ( + tag::<_, _, OracleError<'_>>("they create "), + alt(( + tag::<_, _, OracleError<'_>>("a "), + tag::<_, _, OracleError<'_>>("an "), + )), + opt(tag::<_, _, OracleError<'_>>("tapped ")), + nom_primitives::parse_number, + tag::<_, _, OracleError<'_>>("/"), + nom_primitives::parse_number, + opt(preceded(space1, nom_primitives::parse_color)), + preceded(space0, alpha1), + tag::<_, _, OracleError<'_>>(" creature token"), + ) + .parse(trimmed) + .ok()?; + if !rest.trim().is_empty() { + return None; + } + let subtype_name = capitalize_token_name(subtype); + Some(GiftKind::CreatureToken(GiftCreatureToken { + name: subtype_name.clone(), + power: power as i32, + toughness: toughness as i32, + colors: color.into_iter().collect(), + subtypes: vec![subtype_name], + tapped: tapped.is_some(), + })) +} + +/// CR 702.174: Resolve the opponent's gift payload from a Gift reminder line. +fn parse_gift_delivery_from_reminder(reminder: &str) -> Option { + let lower = reminder.to_lowercase(); + let trimmed = lower.trim().trim_end_matches('.'); + let delivery = [ + "they draw a card", + "they create a treasure token", + "they create a food token", + "they create a tapped 1/1 blue fish creature token", + "they create an ", + "they create a ", + ] + .iter() + .find_map(|needle| trimmed.find(needle).map(|idx| &trimmed[idx..]))?; + + if tag::<_, _, OracleError<'_>>("they draw a card") + .parse(delivery) + .is_ok() + { + return Some(GiftKind::Card); + } + if tag::<_, _, OracleError<'_>>("they create a treasure token") + .parse(delivery) + .is_ok() + { + return Some(GiftKind::Treasure); + } + if tag::<_, _, OracleError<'_>>("they create a food token") + .parse(delivery) + .is_ok() + { + return Some(GiftKind::Food); + } + if tag::<_, _, OracleError<'_>>("they create a tapped 1/1 blue fish creature token") + .parse(delivery) + .is_ok() + { + return Some(GiftKind::TappedFish); + } + parse_gift_creature_token_delivery(delivery) +} + +fn parse_gift_a_declaration(text: &str) -> Option { + let (rest, kind) = preceded( + tag::<_, _, OracleError<'_>>("gift a "), + alt(( + value(GiftKind::Card, tag("card")), + value(GiftKind::Treasure, tag("treasure")), + value(GiftKind::Food, tag("food")), + value(GiftKind::TappedFish, tag("tapped fish")), + )), + ) + .parse(text.trim()) + .ok()?; + if !rest.trim().is_empty() { + return None; + } + Some(kind) +} + +/// Whether a lowercase keyword-router line opens with the Gift keyword prefix. +pub(crate) fn is_gift_keyword_router_line(lower: &str) -> bool { + tag::<_, _, OracleError<'_>>("gift ") + .parse(lower.trim()) + .is_ok() +} + +/// CR 702.174: Gift lines carry delivery in parenthetical reminder text; route +/// through the raw Oracle line when the stripped line is a Gift declaration. +pub(crate) fn gift_keyword_router_line<'a>( + raw_line: &'a str, + stripped_line: &'a str, + lower: &str, +) -> &'a str { + if is_gift_keyword_router_line(lower) { + raw_line + } else { + stripped_line + } +} + +fn parse_gift_keyword_declaration(declaration_lower: &str) -> Option { + parse_gift_a_declaration(declaration_lower) +} + +/// CR 702.174: Parse a whole Gift keyword line including reminder-driven delivery +/// for non-standard gifts ("Gift an Octopus (… they create an 8/8 blue Octopus …)"). +pub(crate) fn parse_gift_keyword_line(full_line: &str) -> Option { + let declaration = strip_reminder_text(full_line); + if let Some(kind) = parse_gift_keyword_declaration(&declaration.to_lowercase()) { + return Some(kind); + } + let reminder = gift_reminder_text(full_line)?; + parse_gift_delivery_from_reminder(reminder) +} + /// The SINGLE router-facing keyword-line parser. Returns `Some` only when the /// ENTIRE line is a keyword declaration plus a permitted tail (`P/R/M`). /// @@ -2122,6 +2278,18 @@ pub(crate) fn parse_router_keyword_line(line: &str) -> Option (true, true) => PermittedKeywordRemainder::TerminalPunctuationAndReminderText, }; + // CR 702.174: Gift lines must parse completely (including reminder-driven + // delivery for "Gift an Octopus") before the generic keyword core runs. + if is_gift_keyword_router_line(&lower) { + return parse_gift_keyword_line(trimmed).map(|kind| RoutedKeywordLine { + keyword: Some(Keyword::Gift(kind)), + tail: KeywordLineTail { + modifiers: Vec::new(), + permitted_remainder: permitted_remainder_of(false), + }, + }); + } + // 2b. MTGJSON-authoritative declarations: fully accounted for, nothing to emit. if is_partner_declaration_line(&lower) { return Some(RoutedKeywordLine { @@ -2803,7 +2971,7 @@ pub(crate) fn is_keyword_cost_line(lower: &str) -> bool { mod tests { use super::*; use crate::types::ability::{AbilityCost, SacrificeCost}; - use crate::types::mana::ManaCost; + use crate::types::mana::{ManaColor, ManaCost}; #[test] fn parse_granted_keyword_fragment_cascade() { @@ -3357,6 +3525,31 @@ mod tests { assert!(is_keyword_cost_line("gift a card")); assert!(is_keyword_cost_line("gift a treasure")); assert!(is_keyword_cost_line("gift a tapped fish")); + assert!(is_keyword_cost_line("gift an octopus")); + } + + #[test] + fn parse_gift_keyword_line_octomancer_octopus_token() { + let line = "Gift an Octopus (You may promise an opponent a gift as you cast this spell. If you do, when it enters, they create an 8/8 blue Octopus creature token.)"; + let kind = parse_gift_keyword_line(line).expect("Octomancer gift line"); + assert_eq!( + kind, + GiftKind::CreatureToken(GiftCreatureToken { + name: "Octopus".to_string(), + power: 8, + toughness: 8, + colors: vec![ManaColor::Blue], + subtypes: vec!["Octopus".to_string()], + tapped: false, + }) + ); + assert!(parse_router_keyword_line(line).is_some()); + } + + #[test] + fn parse_gift_keyword_line_permanent_draw_reminder() { + let line = "Gift a card (You may promise an opponent a gift as you cast this spell. If you do, when it enters, they draw a card.)"; + assert_eq!(parse_gift_keyword_line(line), Some(GiftKind::Card)); } #[test] diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 8f35964aa0..5ad0bb9838 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -457,6 +457,18 @@ pub enum CompanionCondition { PermanentsHaveActivatedAbilities, } +/// CR 702.174: Parameterized creature token promised by a Gift keyword whose +/// delivery is spelled out in the reminder text (Octomancer: 8/8 blue Octopus). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GiftCreatureToken { + pub name: String, + pub power: i32, + pub toughness: i32, + pub colors: Vec, + pub subtypes: Vec, + pub tapped: bool, +} + /// The type of gift promised by the Gift keyword. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] @@ -469,6 +481,8 @@ pub enum GiftKind { Food, /// Opponent creates a tapped 1/1 blue Fish creature token. TappedFish, + /// Opponent creates a parameterized creature token (P/T, colors, subtypes). + CreatureToken(GiftCreatureToken), } /// CR 702.11d: What a hexproof-from keyword protects against. diff --git a/crates/engine/tests/integration/issue_5975_octomancer_gift.rs b/crates/engine/tests/integration/issue_5975_octomancer_gift.rs new file mode 100644 index 0000000000..f425cf84ce --- /dev/null +++ b/crates/engine/tests/integration/issue_5975_octomancer_gift.rs @@ -0,0 +1,164 @@ +//! Integration regression for GitHub issue #5975 — Octomancer Gift an Octopus. +//! +//! Oracle: `Gift an Octopus (… If you do, when it enters, they create an 8/8 +//! blue Octopus creature token.)` +//! +//! Parser coverage lives in `oracle_keyword.rs`; this test drives the production +//! cast → ETB gift-delivery trigger path and verifies the opponent receives the +//! promised Octopus token at runtime. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::Effect; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::keywords::{GiftKind, Keyword}; +use engine::types::mana::{ManaColor, ManaCost}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const OCTOMANCER_ORACLE: &str = "Gift an Octopus (You may promise an opponent a gift as you cast this spell. If you do, when it enters, they create an 8/8 blue Octopus creature token.)\nAt the beginning of each end step, create a token that's a copy of target creature token that entered the battlefield this turn."; + +fn drive_octomancer_cast( + runner: &mut GameRunner, + spell: engine::types::ObjectId, + promise_gift: bool, +) { + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast Octomancer"); + + for _ in 0..64 { + match &runner.state().waiting_for { + WaitingFor::ManaPayment { .. } => { + runner.act(GameAction::PassPriority).expect("pay mana"); + } + WaitingFor::OptionalCostChoice { .. } => { + runner + .act(GameAction::DecideOptionalCost { pay: promise_gift }) + .expect("decide Gift an Octopus"); + } + WaitingFor::OrderTriggers { .. } | WaitingFor::Priority { .. } + if !runner.state().stack.is_empty() => + { + runner + .act(GameAction::PassPriority) + .expect("advance Octomancer resolution"); + } + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => return, + other => panic!("unexpected Octomancer cast prompt: {other:?}"), + } + } + + panic!("Octomancer cast did not finish within 64 actions"); +} + +fn opponent_octopus_token(runner: &GameRunner) -> Option { + runner.state().objects.iter().find_map(|(&id, obj)| { + (obj.owner == P1 + && obj.card_id == CardId(0) + && obj.zone == Zone::Battlefield + && obj.card_types.core_types.contains(&CoreType::Creature) + && obj.card_types.subtypes.iter().any(|s| s == "Octopus")) + .then_some(id) + }) +} + +#[test] +fn octomancer_oracle_parses_gift_creature_token_and_etb_delivery_trigger() { + let mut scenario = GameScenario::new(); + let octomancer = { + let mut builder = + scenario.add_creature_to_hand_from_oracle(P0, "Octomancer", 3, 3, OCTOMANCER_ORACLE); + builder.with_mana_cost(ManaCost::zero()); + builder.id() + }; + let runner = scenario.build(); + let obj = &runner.state().objects[&octomancer]; + assert!( + obj.keywords + .iter() + .any(|k| matches!(k, Keyword::Gift(GiftKind::CreatureToken(_)))), + "Octomancer must parse Gift an Octopus, got keywords: {:?}", + obj.keywords + ); + assert!( + obj.additional_cost.is_some(), + "synthesize_gift must wire optional additional cost" + ); + assert!( + obj.base_trigger_definitions.iter().any(|t| { + matches!( + t.execute.as_deref(), + Some(a) if matches!(*a.effect, Effect::GiftDelivery { kind: GiftKind::CreatureToken(_) }) + ) + }), + "permanent gift must synthesize ETB GiftDelivery trigger, got triggers: {:?}", + obj + .base_trigger_definitions + .iter() + .map(|t| (&t.mode, t.execute.as_ref().map(|a| &a.effect))) + .collect::>() + ); +} + +#[test] +fn octomancer_promised_gift_delivers_octopus_token_on_etb() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let octomancer = { + let mut builder = + scenario.add_creature_to_hand_from_oracle(P0, "Octomancer", 3, 3, OCTOMANCER_ORACLE); + builder.with_mana_cost(ManaCost::zero()); + builder.id() + }; + + let mut runner = scenario.build(); + drive_octomancer_cast(&mut runner, octomancer, true); + + assert_eq!( + runner.state().objects[&octomancer].zone, + Zone::Battlefield, + "Octomancer must enter the battlefield after resolving" + ); + + let token_id = + opponent_octopus_token(&runner).expect("opponent must receive Octopus gift token"); + let token = &runner.state().objects[&token_id]; + assert_eq!(token.power, Some(8)); + assert_eq!(token.toughness, Some(8)); + assert!( + token.color.contains(&ManaColor::Blue), + "Octopus gift token must be blue, got {:?}", + token.color + ); +} + +#[test] +fn octomancer_unpromised_gift_does_not_create_octopus_token() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let octomancer = { + let mut builder = + scenario.add_creature_to_hand_from_oracle(P0, "Octomancer", 3, 3, OCTOMANCER_ORACLE); + builder.with_mana_cost(ManaCost::zero()); + builder.id() + }; + + let mut runner = scenario.build(); + drive_octomancer_cast(&mut runner, octomancer, false); + + assert!( + opponent_octopus_token(&runner).is_none(), + "declining Gift an Octopus must not create the Octopus token" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index e5a36ce514..5d7f988ad7 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -535,6 +535,7 @@ mod issue_5820_susan_foreman; mod issue_5821_psychic_paper_attach_choice; mod issue_583_vivi_ornitier_mana_source; mod issue_5972_tracked_set_token_cleanup; +mod issue_5975_octomancer_gift; mod issue_5977_hunger_tide_chapter_iv; mod issue_5978_roar_chapter_ii_creature_mana; mod issue_5983_sothera_dies_edict; diff --git a/crates/phase-ai/src/policies/downside_awareness.rs b/crates/phase-ai/src/policies/downside_awareness.rs index 90ec8f4c54..14c128961a 100644 --- a/crates/phase-ai/src/policies/downside_awareness.rs +++ b/crates/phase-ai/src/policies/downside_awareness.rs @@ -34,6 +34,12 @@ impl DownsideAwarenessPolicy { GiftKind::Treasure => ctx.penalties().gift_treasure_penalty, GiftKind::Food => ctx.penalties().gift_food_penalty, GiftKind::TappedFish => ctx.penalties().gift_fish_penalty, + GiftKind::CreatureToken(spec) => { + let stat_product = (spec.power.max(0) * spec.toughness.max(0)) as f64; + // Scale from the tapped-Fish baseline (1×1); cap so doubled + // penalty stays within the policy's critical band. + ctx.penalties().gift_fish_penalty * stat_product.min(6.0) + } }; } }