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
189 changes: 183 additions & 6 deletions crates/engine/src/game/attractions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use crate::types::zones::Zone;

use super::effects::roll_die;
use super::game_object::GameObject;
use super::zones;
use crate::types::ability::EffectError;

/// CR 717.1: Default lit numbers when card data omits variant lights (1 and 6 are always lit).
Expand Down Expand Up @@ -43,7 +42,7 @@ pub fn open_attractions(
count: u32,
events: &mut Vec<GameEvent>,
) -> Result<(), EffectError> {
for _ in 0..count {
for opened in 0..count {
let Some(object_id) = state
.players
.iter_mut()
Expand All @@ -54,16 +53,78 @@ pub fn open_attractions(
// as many as possible and ignore the impossible remainder.
break;
};
zones::move_to_zone(state, object_id, Zone::Battlefield, events);
if let Some(obj) = state.objects.get_mut(&object_id) {
obj.in_attraction_deck = false;
// CR 614.1c: route the Attraction's battlefield entry through the
// zone-change pipeline so the delivery tail applies enters-with-counters
// statics (e.g. an artifact-scoped "enters with an additional counter"
// static) — the raw `move_to_zone` skipped that tail, so an opened
// Attraction never received them. CR 400.7 attributes the entry to the
// opened object itself (the pre-pipeline raw move recorded no source).
//
// CR 616.1: a battlefield-entry pause IS reachable here — two co-played
// external enter-tapped `Moved` effects (the Kismet / Frozen Aether
// class parses as ChangeZone Moved defs) both write the entry event's
// tap field, a material same-field collision that surfaces an ordering
// prompt. On the pause, the paused Attraction's open bookkeeping and
// the REMAINING opens of this instruction are deferred onto a
// `BatchCompletion::AttractionOpenRemainder` so the replacement-choice
// resume runs them — the old bail `break` left `in_attraction_deck`
// set, never emitted `AttractionOpened`, and dropped the remaining
// opens.
match super::zone_pipeline::move_object(
state,
super::zone_pipeline::ZoneMoveRequest::effect(object_id, Zone::Battlefield, object_id),
events,
) {
super::zone_pipeline::ZoneMoveResult::Done => {}
super::zone_pipeline::ZoneMoveResult::NeedsChoice(_)
| super::zone_pipeline::ZoneMoveResult::NeedsAuraAttachmentChoice => {
super::zone_pipeline::defer_completion_on_pause(
state,
crate::types::game_state::BatchCompletion::AttractionOpenRemainder {
player,
object_id,
remaining: count - opened - 1,
},
);
return Ok(());
}
}
finish_attraction_open(state, player, object_id, events);
}
Ok(())
}

/// CR 701.51b + CR 701.51c: Per-Attraction open bookkeeping, run exactly once
/// after the Attraction's battlefield entry delivers — inline on the
/// synchronous path, or from `BatchCompletion::AttractionOpenRemainder` when
/// the entry parked on a CR 616.1 replacement-ordering choice and resumed.
/// Clears the supplementary-deck membership flag and emits `AttractionOpened`
/// (the "whenever a player opens an Attraction" trigger event, which fires only
/// when the card actually entered the battlefield — CR 701.51c).
pub(crate) fn finish_attraction_open(
state: &mut GameState,
player: PlayerId,
object_id: ObjectId,
events: &mut Vec<GameEvent>,
) {
if let Some(obj) = state.objects.get_mut(&object_id) {
obj.in_attraction_deck = false;
}
// CR 701.51c: the "opens an Attraction" trigger fires only when the card
// actually entered the battlefield — "If an effect prevents that Attraction
// from entering the battlefield or replaces entering the battlefield with
// another event, that ability doesn't trigger." `ZoneMoveResult::Done`
// also covers prevented/redirected deliveries, so gate on arrival.
if state
.objects
.get(&object_id)
.is_some_and(|obj| obj.zone == Zone::Battlefield)
{
events.push(GameEvent::AttractionOpened {
player_id: player,
object_id,
});
}
Ok(())
}

/// CR 701.52a: Roll a d6 and visit each controlled Attraction whose lights include the result.
Expand Down Expand Up @@ -154,3 +215,119 @@ pub fn resolve_roll_to_visit(
});
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::game::engine::apply_as_current;
use crate::game::zones::create_object;
use crate::types::ability::{
AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, TargetFilter,
};
use crate::types::actions::GameAction;
use crate::types::game_state::WaitingFor;
use crate::types::identifiers::CardId;
use crate::types::replacements::ReplacementEvent;

/// CR 701.51 + CR 616.1 discriminating test (fail-first): an Attraction
/// whose battlefield entry parks on a replacement-ordering prompt (two
/// co-played external enter-tapped `Moved` effects — the Kismet / Frozen
/// Aether class parses as ChangeZone Moved defs and collides on the entry's
/// tap field) must, after the prompt is answered, still receive its open
/// bookkeeping (`in_attraction_deck` cleared, `AttractionOpened` emitted)
/// AND the remaining opens of the same instruction must still happen. The
/// old bail `break` skipped the bookkeeping on the paused Attraction and
/// silently dropped every remaining open.
#[test]
fn paused_attraction_open_resumes_bookkeeping_and_remaining_opens() {
let mut state = GameState::new_two_player(42);
let player = PlayerId(0);

// Two Attractions in the supplementary deck (command zone).
let mut attractions = Vec::new();
for i in 0..2u64 {
let id = create_object(
&mut state,
CardId(100 + i),
player,
format!("Attraction {i}"),
Zone::Command,
);
state.objects.get_mut(&id).unwrap().in_attraction_deck = true;
state.players[0].attraction_deck.push_back(id);
attractions.push(id);
}

// Two external enter-tapped Moved replacements (Kismet / Frozen Aether
// class) — both write the entry event's tap field, so CR 616.1 prompts.
for (offset, name) in [(0u64, "Kismet"), (1, "Frozen Aether")] {
let oid = ObjectId(9000 + offset);
let mut src = GameObject::new(
oid,
CardId(900 + offset),
PlayerId(1),
name.to_string(),
Zone::Battlefield,
);
src.replacement_definitions = vec![ReplacementDefinition::new(ReplacementEvent::Moved)
.execute(AbilityDefinition::new(
AbilityKind::Spell,
Effect::Tap {
target: TargetFilter::SelfRef,
},
))
.destination_zone(Zone::Battlefield)
.description(name.to_string())]
.into();
state.objects.insert(oid, src);
state.battlefield.push_back(oid);
}

let mut events = Vec::new();
open_attractions(&mut state, player, 2, &mut events).expect("open attractions");

// CR 616.1: the first open parked on the enter-tapped collision.
let WaitingFor::ReplacementChoice {
player: chooser, ..
} = state.waiting_for.clone()
else {
panic!(
"expected parked ReplacementChoice for the enter-tapped collision, got {:?}",
state.waiting_for
);
};
state.priority_player = chooser;
apply_as_current(&mut state, GameAction::ChooseReplacement { index: 0 })
.expect("resume first open");

// The first Attraction's open bookkeeping ran on resume.
let first = &state.objects[&attractions[0]];
assert_eq!(first.zone, Zone::Battlefield, "first Attraction delivered");
assert!(
!first.in_attraction_deck,
"open bookkeeping must run on the resumed Attraction (old bail left the flag set)"
);

// The remaining open ran — and re-parked on its own entry prompt.
let WaitingFor::ReplacementChoice {
player: chooser2, ..
} = state.waiting_for.clone()
else {
panic!(
"remaining open must run after the pause and re-park, got {:?} (old bail dropped it)",
state.waiting_for
);
};
state.priority_player = chooser2;
apply_as_current(&mut state, GameAction::ChooseReplacement { index: 0 })
.expect("resume second open");

let second = &state.objects[&attractions[1]];
assert_eq!(
second.zone,
Zone::Battlefield,
"remaining open must deliver after the pause (old bail dropped it)"
);
assert!(!second.in_attraction_deck);
}
}
32 changes: 27 additions & 5 deletions crates/engine/src/game/cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
//! encode is an independent link.

use super::triggers::{PendingTrigger, PendingTriggerContext};
use super::zone_pipeline::{self, ZoneMoveRequest, ZoneMoveResult};
use crate::types::ability::{Effect, ResolvedAbility, TargetFilter, TargetRef};
use crate::types::card_type::CoreType;
use crate::types::events::GameEvent;
Expand Down Expand Up @@ -130,19 +131,40 @@ pub fn begin_encode_choice(state: &mut GameState, card_id: ObjectId, controller:
/// card on that creature (exile + link); `None` — or a creature that is no
/// longer a legal host — declines, routing the card to its owner's graveyard
/// (CR 608.2n). The chosen creature is re-validated against the current board.
pub fn handle_encode_choice(
///
/// Returns the [`ZoneMoveResult`] of the decline move so the caller knows
/// whether a CR 616.1 replacement-ordering choice parked a prompt (the declined
/// card hit a graveyard→exile redirect) — the encode-accept path never pauses
/// (exile is not a `Moved` redirect destination) and reports `Done`.
pub(crate) fn handle_encode_choice(
state: &mut GameState,
card_id: ObjectId,
creature: Option<ObjectId>,
events: &mut Vec<GameEvent>,
) {
) -> ZoneMoveResult {
let controller = state.objects.get(&card_id).map(|o| o.controller);
let chosen = creature
.filter(|id| controller.is_some_and(|c| legal_encode_creatures(state, c).contains(id)));
match chosen {
Some(creature_id) => finish_encode(state, card_id, creature_id, events),
// CR 608.2n: a declined cipher card is put into its owner's graveyard.
None => super::zones::move_to_zone(state, card_id, Zone::Graveyard, events),
Some(creature_id) => {
finish_encode(state, card_id, creature_id, events);
ZoneMoveResult::Done
}
// CR 608.2n + CR 614.6: a declined cipher card is the resolving spell's
// card being put into its owner's graveyard — route it through the
// zone-change pipeline so a `Moved` graveyard→exile redirect (Rest in
// Peace / Leyline of the Void) fires on it. The raw `move_to_zone` never
// proposed the inner ZoneChange, silently dropping those redirects. The
// spell's card moves itself on resolution, so the cause is
// `SpellResolutionDefault` (no external source). A CR 616.1 ordering
// choice (two simultaneous redirects) is parked centrally by
// `move_object`; the caller surfaces the parked prompt instead of
// returning to priority.
None => zone_pipeline::move_object(
state,
ZoneMoveRequest::spell_resolution_default(card_id, Zone::Graveyard),
events,
),
}
}

Expand Down
Loading
Loading