Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 59 additions & 14 deletions crates/engine/src/database/synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions crates/engine/src/game/effects/gift_delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment on lines +82 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Missing mandatory CR annotation. Evidence: crates/engine/src/game/effects/gift_delivery.rs:82-103. Why it matters: Every rules-touching line of engine code must carry a CR annotation per rule R6. Suggested fix: Add a CR 702.174 comment before resolving the creature token gift.

        GiftKind::CreatureToken(spec) => {
            // CR 702.174: Deliver the promised creature token gift to the opponent.
            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;
            }
        }
References
  1. R6. CR annotations are mandatory and verified. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

}

events.push(GameEvent::EffectResolved {
Expand Down Expand Up @@ -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()));
}
}
19 changes: 14 additions & 5 deletions crates/engine/src/parser/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading