Skip to content
Merged
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
6 changes: 3 additions & 3 deletions crates/engine/src/game/effects/add_target_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -954,7 +954,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(50),
controller: PlayerId(1),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let proposed = ProposedEvent::CreateToken {
owner: PlayerId(1),
Expand Down Expand Up @@ -1048,7 +1048,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(70),
controller: PlayerId(1),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let proposed = ProposedEvent::CreateToken {
owner: PlayerId(1),
Expand Down Expand Up @@ -1132,7 +1132,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(60),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let proposed = ProposedEvent::CreateToken {
owner: PlayerId(0),
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/game/effects/amass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ fn create_army_token(
enter_with_counters: vec![],
tapped: false,
enters_attacking: false,
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
sacrifice_at: None,
source_id: ability.source_id,
controller: ability.controller,
Expand Down Expand Up @@ -383,7 +383,7 @@ mod tests {
enter_with_counters: Vec::new(),
tapped: false,
enters_attacking: false,
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
sacrifice_at: None,
source_id: ObjectId(0),
controller: P0,
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/game/effects/attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ pub(crate) enum AttachmentAuthority<'a> {
/// Player hosts are Auras-only (see [`attach_to_player`]); a copy exception that
/// adds or removes the `Aura` subtype (CR 205.1a) changes the answer, so this
/// reads the authority rather than the stored object.
fn authority_is_aura(
pub(crate) fn authority_is_aura(
state: &GameState,
attachment_id: ObjectId,
authority: AttachmentAuthority<'_>,
Expand Down
166 changes: 127 additions & 39 deletions crates/engine/src/game/effects/token.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/engine/src/game/engine_debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ pub fn apply_debug_action(
sacrifice_at: None,
source_id: ObjectId(0),
controller: owner,
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let proposed = ProposedEvent::CreateToken {
owner,
Expand Down
12 changes: 6 additions & 6 deletions crates/engine/src/game/engine_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4458,7 +4458,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(1),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};

let battlefield_before = state.battlefield.clone();
Expand Down Expand Up @@ -4583,7 +4583,7 @@ mod tests {
sacrifice_at: None,
source_id: replacement_source,
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let battlefield_before = state.battlefield.clone();

Expand Down Expand Up @@ -4741,7 +4741,7 @@ mod tests {
sacrifice_at: None,
source_id: replacement_source,
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let battlefield_before = state.battlefield.clone();

Expand Down Expand Up @@ -4916,7 +4916,7 @@ mod tests {
sacrifice_at: None,
source_id: replacement_source,
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let battlefield_before = state.battlefield.clone();

Expand Down Expand Up @@ -5070,7 +5070,7 @@ mod tests {
sacrifice_at: None,
source_id: jinnie_source,
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let applied = state
.post_replacement_token_choice_applied
Expand Down Expand Up @@ -5207,7 +5207,7 @@ mod tests {
sacrifice_at: None,
source_id: jinnie_source,
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let battlefield_before = state.battlefield.clone();

Expand Down
20 changes: 10 additions & 10 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14430,7 +14430,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(999),
controller: owner_controller,
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
}
}

Expand Down Expand Up @@ -17708,7 +17708,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(0),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let repl = ReplacementDefinition::new(ReplacementEvent::CreateToken)
.token_owner_scope(ControllerRef::You)
Expand All @@ -17735,7 +17735,7 @@ mod tests {
sacrifice_at: None,
source_id: chatterfang,
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
let proposed = ProposedEvent::CreateToken {
owner: PlayerId(0),
Expand Down Expand Up @@ -17807,7 +17807,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(0),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
}
}

Expand Down Expand Up @@ -17941,7 +17941,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(0),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};

let proposed = ProposedEvent::CreateToken {
Expand Down Expand Up @@ -18269,7 +18269,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(0),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
}
}

Expand Down Expand Up @@ -18496,7 +18496,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(0),
controller: PlayerId(1),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};

let proposed = ProposedEvent::CreateToken {
Expand Down Expand Up @@ -19024,7 +19024,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(0),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
}
}

Expand Down Expand Up @@ -19142,7 +19142,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(0),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
}
}

Expand Down Expand Up @@ -19322,7 +19322,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(0),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
}),
copy: None,
enter_tapped: EtbTapState::Unspecified,
Expand Down
6 changes: 4 additions & 2 deletions crates/engine/src/game/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9007,7 +9007,7 @@ mod tests {
sacrifice_at: None,
source_id: ObjectId(1),
controller: PlayerId(0),
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
};
// Bare spec passes.
assert!(super::super::effects::token::spec_emits_only_etb_pair(
Expand All @@ -9033,7 +9033,9 @@ mod tests {
));

let mut attached = base.clone();
attached.attach_to = Some(crate::game::game_object::AttachTarget::Object(ObjectId(2)));
attached.attach_to = crate::types::proposed_event::TokenHostRequest::Bound(
crate::game::game_object::AttachTarget::Object(ObjectId(2)),
);
assert!(!super::super::effects::token::spec_emits_only_etb_pair(
&attached
));
Expand Down
85 changes: 78 additions & 7 deletions crates/engine/src/parser/oracle_effect/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,21 +453,51 @@ fn tracked_set_count_is_type_restricted(qty: &QuantityRef) -> bool {
.any(|type_filter| !matches!(type_filter, TypeFilter::Card))
}

/// CR 303.4: The printed surfaces that bind a created token to a host inside the
/// same create-token instruction — "an Aura enters the battlefield attached to
/// an object or player". `" attached to "` states the relation and
/// `" and attach it to "` states the action; the resulting permanent is
/// identical, so both feed one `attach_to` field rather than two code paths.
///
/// Scanned at word boundaries with a single `alt`, so the connector that occurs
/// FIRST in the text wins regardless of which spelling it is — testing each
/// spelling over the whole string separately would let a later "attached to"
/// beat an earlier "and attach it to".
fn first_token_attachment_connector(lower: &str) -> Option<&'static str> {
nom_primitives::scan_at_word_boundaries(lower, |input| {
alt((
value(
" and attach it to ",
tag::<_, _, OracleError<'_>>("and attach it to "),
),
value(" attached to ", tag("attached to ")),
))
.parse(input)
})
}

fn parse_token_description_with_context(
text: &str,
ctx: &ParseContext,
) -> Option<TokenDescription> {
let text = text.trim().trim_end_matches('.');
let lower = text.to_lowercase();

// CR 303.7: Strip "attached to [target]" suffix and capture the attachment target.
// CR 303.4: Strip the attachment clause and capture its target. Oracle
// prints the same relation two ways in a create-token instruction — as a
// STATE ("create a Cursed Role token attached to target creature") and as an
// ACTION ("create a Questing Role token and attach it to target creature").
// Both mean the token enters attached, in the same instruction, so both bind
// the same `attach_to` field; only the printed surface differs. Keying on the
// state form alone dropped the attachment entirely for the action form, and
// CR 303.4i then says a hostless Aura token is not created at all (#7302).
let tp = TextPair::new(text, &lower);
let (text, attach_to) = if let Some((before, after)) = tp.split_around(" attached to ") {
let (target, _) = parse_target(after.original);
(before.original, Some(target))
} else {
(text, None)
};
let (text, attach_to) = first_token_attachment_connector(&lower)
.and_then(|connector| tp.split_around(connector))
.map_or((text, None), |(before, after)| {
let (target, _) = parse_target(after.original);
(before.original, Some(target))
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// CR 508.4 + CR 506.3a: Strip inline "that's tapped and attacking" /
// "that is tapped and attacking" / "thats tapped and attacking" /
Expand Down Expand Up @@ -3491,3 +3521,44 @@ fn copy_token_non_saga_token_you_control_issue_3294() {
assert!(tf.properties.contains(&FilterProp::Token));
assert_eq!(tf.controller, Some(ControllerRef::You));
}

#[cfg(test)]
mod token_attachment_connector_tests {
use super::*;

/// CR 303.4 + CR 303.4i: Oracle prints one relation two ways inside a
/// create-token instruction — as a STATE ("…token attached to target
/// creature") and as an ACTION ("…token and attach it to target creature").
/// Both must bind `attach_to`; the action surface used to drop it, leaving a
/// hostless Aura token that CR 303.4i says is not created at all
/// (Questing Cosplayer, #7302).
///
/// Table-driven over both surfaces plus the counter-direction: a token line
/// with no attachment clause must keep `attach_to` at `None`.
#[test]
fn both_printed_attachment_surfaces_bind_the_host() {
let cases: &[(&str, bool)] = &[
(
"create a Questing Role token and attach it to target creature",
true,
),
(
"create a Cursed Role token attached to target creature",
true,
),
("create a 1/1 white Soldier creature token", false),
];
for (text, expects_host) in cases {
let effect = try_parse_token(&text.to_lowercase(), text, &mut ParseContext::default())
.unwrap_or_else(|| panic!("{text:?} must parse as a token line"));
let Effect::Token { attach_to, .. } = effect else {
panic!("{text:?} must lower to Effect::Token");
};
assert_eq!(
attach_to.is_some(),
*expects_host,
"{text:?} host binding mismatch, got {attach_to:?}"
);
}
}
}
2 changes: 1 addition & 1 deletion crates/engine/src/parser/oracle_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9605,7 +9605,7 @@ fn token_description_to_spec(
controller: crate::types::player::PlayerId(0),
// Replacement-created tokens ("instead, create a token") are not the
// "attached to" Aura/Role class; that path flows through `Effect::Token`.
attach_to: None,
attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested,
})
}

Expand Down
Loading
Loading