Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions crates/engine/src/analysis/sim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ mod tests {
player_id: PlayerId(0),
action: PlayerActionKind::Proliferate,
look_count: None,
scry_bottom_count: None,
},
];

Expand Down Expand Up @@ -408,6 +409,7 @@ mod tests {
player_id: PlayerId(0),
action: PlayerActionKind::Scry,
look_count: None,
scry_bottom_count: None,
}],
);
assert!(acc.generic_triggers.is_empty());
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/database/synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5813,6 +5813,7 @@ fn build_ingest_trigger() -> TriggerDefinition {
let exile = Effect::ExileTop {
player: TargetFilter::TriggeringPlayer,
count: QuantityExpr::Fixed { value: 1 },
position: crate::types::ability::LibraryPosition::Top,
face_down: false,
};
let execute = AbilityDefinition::new(AbilityKind::Spell, exile).description(
Expand Down Expand Up @@ -5844,6 +5845,7 @@ fn is_ingest_trigger(t: &TriggerDefinition) -> bool {
Some(Effect::ExileTop {
player: TargetFilter::TriggeringPlayer,
count: QuantityExpr::Fixed { value: 1 },
position: crate::types::ability::LibraryPosition::Top,
face_down: false,
})
)
Expand Down Expand Up @@ -24672,6 +24674,7 @@ mod ingest_gravestorm_synthesis_tests {
let Effect::ExileTop {
player,
count,
position: _,
face_down,
} = effect
else {
Expand Down
6 changes: 5 additions & 1 deletion crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2055,6 +2055,7 @@ fn legacy_quantity_ref(x: &QuantityRef) -> bool {
| QuantityRef::StartingLifeTotal
| QuantityRef::TriggeringDiscoverValue
| QuantityRef::TriggeringScryLookCount
| QuantityRef::TriggeringScryBottomCount
| QuantityRef::GraveyardSize { .. }
| QuantityRef::ObjectCount { .. }
| QuantityRef::ObjectCountDistinct { .. }
Expand Down Expand Up @@ -4542,6 +4543,7 @@ fn rw_effect(
Effect::ExileTop {
player: _,
count,
position: _,
face_down: _,
} => {
let mut p = RwProfile::empty();
Expand Down Expand Up @@ -5743,7 +5745,9 @@ fn rw_quantity_ref(x: &QuantityRef) -> RwProfile {
// global scalar. Event-live like the EventContextSourceModesChosen /
// TimesCostPaidThisResolution twins, and like them NOT a frozen D5
// carrier (the legacy-12 set is closed), so no `legacy_batch_prompt`.
QuantityRef::TriggeringScryLookCount => reads_event_live(),
QuantityRef::TriggeringScryLookCount | QuantityRef::TriggeringScryBottomCount => {
reads_event_live()
}
QuantityRef::GraveyardSize { .. } => reads_zone_membership(),
QuantityRef::ObjectCount { filter }
| QuantityRef::ObjectCountDistinct { filter, .. }
Expand Down
18 changes: 17 additions & 1 deletion crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,7 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes {
Effect::ExileTop {
player,
count,
position: _,
face_down: _,
} => {
let mut acc = Axes::NONE;
Expand Down Expand Up @@ -1824,7 +1825,7 @@ fn scan_quantity_ref(x: &QuantityRef, mode: ScanMode) -> Axes {
// (`state.current_trigger_event` — the scry's own `PlayerPerformedAction`
// carrying its effective look count) → event axis true, mirroring
// `QuantityRef::EventContextAmount` below.
QuantityRef::TriggeringScryLookCount => Axes {
QuantityRef::TriggeringScryLookCount | QuantityRef::TriggeringScryBottomCount => Axes {
event: true,
sibling: false,
projected: false,
Expand Down Expand Up @@ -8201,4 +8202,19 @@ mod tests {
QuantityRef::TriggeringScryLookCount
)));
}

/// CR 701.22a + CR 701.22d + CR 603.2c: the completed-scry bottom count is
/// carried by the current trigger event, never a sibling or projected
/// resource. Assert the scanner axes directly so the shared match arm cannot
/// accidentally classify it more broadly.
#[test]
fn triggering_scry_bottom_count_has_only_the_event_axis() {
let axes = scan_quantity_ref(
&QuantityRef::TriggeringScryBottomCount,
ScanMode::Conservative,
);
assert!(axes.event);
assert!(!axes.sibling);
assert!(!axes.projected);
}
}
8 changes: 8 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,9 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String {
QuantityRef::TriggeringScryLookCount => {
"the number of cards looked at while scrying this way".into()
}
QuantityRef::TriggeringScryBottomCount => {
"the number of cards put on the bottom while scrying this way".into()
}
QuantityRef::Speed { player } => {
format!("speed ({})", fmt_player_scope(player))
}
Expand Down Expand Up @@ -2296,10 +2299,14 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> {
Effect::ExileTop {
player,
count,
position,
face_down,
} => {
d.push(("player".into(), fmt_target(player)));
d.push(("count".into(), fmt_quantity(count)));
if !matches!(position, crate::types::ability::LibraryPosition::Top) {
d.push(("position".into(), format!("{position:?}")));
}
if *face_down {
d.push(("face_down".into(), "true".into()));
}
Expand Down Expand Up @@ -7438,6 +7445,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) {
QuantityRef::StartingLifeTotal => ("StartingLifeTotal", Unhandled),
QuantityRef::TriggeringDiscoverValue => ("TriggeringDiscoverValue", Handled),
QuantityRef::TriggeringScryLookCount => ("TriggeringScryLookCount", Handled),
QuantityRef::TriggeringScryBottomCount => ("TriggeringScryBottomCount", Handled),
QuantityRef::Speed { .. } => ("Speed", Handled),
QuantityRef::ObjectCount { .. } => ("ObjectCount", Handled),
QuantityRef::ObjectCountDistinct { .. } => ("ObjectCountDistinct", Handled),
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/dungeon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ pub fn room_effects(
Effect::ExileTop {
player: TargetFilter::Controller,
count: fixed(2),
position: crate::types::ability::LibraryPosition::Top,
face_down: false,
},
source_id,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/collect_evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ fn complete_cost_payment(
player_id: player,
action: PlayerActionKind::CollectEvidence,
look_count: None,
scry_bottom_count: None,
});

match resume {
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/effects/counters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ pub(crate) fn drain_pending_counter_additions(state: &mut GameState, events: &mu
player_id: action.player_id,
action: action.action,
look_count: None,
scry_bottom_count: None,
});
}
}
Expand Down Expand Up @@ -474,6 +475,7 @@ fn apply_pending_counter_post_action(
player_id,
action,
look_count: None,
scry_bottom_count: None,
});
true
}
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/deal_damage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4110,6 +4110,7 @@ mod tests {
count: QuantityExpr::Ref {
qty: QuantityRef::EventContextAmount,
},
position: crate::types::ability::LibraryPosition::Top,
face_down: false,
},
))
Expand Down
91 changes: 83 additions & 8 deletions crates/engine/src/game/effects/exile_top.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::game::quantity::resolve_quantity_with_targets;
use crate::game::zone_pipeline::{self, ZoneMoveRequest};
use crate::types::ability::{Effect, EffectError, EffectKind, ResolvedAbility};
use crate::types::ability::{Effect, EffectError, EffectKind, LibraryPosition, ResolvedAbility};
use crate::types::events::GameEvent;
use crate::types::game_state::GameState;
use crate::types::zones::Zone;
Expand All @@ -10,10 +10,11 @@ pub fn resolve(
ability: &ResolvedAbility,
events: &mut Vec<GameEvent>,
) -> Result<(), EffectError> {
let (count, player_filter, face_down) = match &ability.effect {
let (count, player_filter, position, face_down) = match &ability.effect {
Effect::ExileTop {
count,
player,
position,
face_down,
} => (
// Use resolve_quantity_with_targets so that TargetZoneCardCount (and
Expand All @@ -24,6 +25,7 @@ pub fn resolve(
// instead of nothing. Mirrors the guard in `draw.rs` / `discard.rs`.
resolve_quantity_with_targets(state, count, ability).max(0) as usize,
player.clone(),
position,
*face_down,
),
_ => return Err(EffectError::MissingParam("ExileTop count".to_string())),
Expand All @@ -43,12 +45,20 @@ pub fn resolve(
.find(|p| p.id == target_player)
.ok_or(EffectError::PlayerNotFound)?;
let count = count.min(player.library.len());
let top_cards: Vec<_> = player
.library
.iter()
.take(count)
.copied()
.collect::<Vec<_>>();
let top_cards: Vec<_> = match position {
// CR 401.2 + CR 701.13a: top/bottom are the two library edges an
// exile instruction may name. Bottom iteration is bottommost-first,
// preserving selected-pile order through the zone pipeline.
LibraryPosition::Top => player.library.iter().take(count).copied().collect(),
LibraryPosition::Bottom => player.library.iter().rev().take(count).copied().collect(),
LibraryPosition::NthFromTop { .. }
| LibraryPosition::BeneathTop { .. }
| LibraryPosition::RandomWithinTop { .. } => {
return Err(EffectError::MissingParam(
"ExileTop requires top or bottom library position".to_string(),
))
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let track_exiled_by_source =
crate::game::exile_links::should_track_exiled_by_source(state, ability.source_id, ability);

Expand Down Expand Up @@ -125,12 +135,20 @@ mod tests {
use crate::types::player::PlayerId;

fn make_exile_top_ability(count: u32) -> ResolvedAbility {
make_exile_top_ability_at_position(count, LibraryPosition::Top)
}

fn make_exile_top_ability_at_position(
count: u32,
position: LibraryPosition,
) -> ResolvedAbility {
ResolvedAbility::new(
Effect::ExileTop {
player: TargetFilter::Controller,
count: QuantityExpr::Fixed {
value: count as i32,
},
position,
face_down: false,
},
vec![],
Expand Down Expand Up @@ -205,6 +223,7 @@ mod tests {
Effect::ExileTop {
player: TargetFilter::Controller,
count: QuantityExpr::Fixed { value: 1 },
position: LibraryPosition::Top,
face_down: false,
},
vec![],
Expand Down Expand Up @@ -256,6 +275,7 @@ mod tests {
Effect::ExileTop {
player: TargetFilter::TriggeringPlayer,
count: QuantityExpr::Fixed { value: 1 },
position: LibraryPosition::Top,
face_down: false,
},
vec![],
Expand Down Expand Up @@ -319,6 +339,56 @@ mod tests {
);
}

/// CR 401.2 + CR 701.13a: Bottom-of-library ExileTop selects from the
/// library's opposite edge; the untouched top cards retain their exact
/// top-to-bottom order.
#[test]
fn exile_top_bottom_position_exiles_bottom_cards_and_preserves_top_order() {
let mut state = GameState::new_two_player(42);
let first = create_object(
&mut state,
CardId(1),
PlayerId(0),
"First".to_string(),
Zone::Library,
);
let second = create_object(
&mut state,
CardId(2),
PlayerId(0),
"Second".to_string(),
Zone::Library,
);
let third = create_object(
&mut state,
CardId(3),
PlayerId(0),
"Third".to_string(),
Zone::Library,
);
let fourth = create_object(
&mut state,
CardId(4),
PlayerId(0),
"Fourth".to_string(),
Zone::Library,
);
let ability = make_exile_top_ability_at_position(2, LibraryPosition::Bottom);

let mut events = Vec::new();
resolve(&mut state, &ability, &mut events).unwrap();

assert_eq!(
state.players[0].library.iter().copied().collect::<Vec<_>>(),
vec![first, second],
"the original top two cards remain in order"
);
assert_eq!(state.objects[&first].zone, Zone::Library);
assert_eq!(state.objects[&second].zone, Zone::Library);
assert_eq!(state.objects[&third].zone, Zone::Exile);
assert_eq!(state.objects[&fourth].zone, Zone::Exile);
}

#[test]
fn exile_top_controller_filter_does_not_inherit_parent_player_target() {
// CR 115.1 regression: a chained ExileTop with `player: Controller`
Expand All @@ -344,6 +414,7 @@ mod tests {
Effect::ExileTop {
player: TargetFilter::Controller,
count: QuantityExpr::Fixed { value: 1 },
position: LibraryPosition::Top,
face_down: false,
},
vec![TargetRef::Player(PlayerId(1))], // inherited parent target
Expand Down Expand Up @@ -468,6 +539,7 @@ mod tests {
},
},
},
position: LibraryPosition::Top,
face_down: false,
},
vec![],
Expand Down Expand Up @@ -633,6 +705,7 @@ mod tests {
Effect::ExileTop {
player: TargetFilter::Controller,
count: QuantityExpr::Fixed { value: 1 },
position: LibraryPosition::Top,
face_down: true,
},
vec![],
Expand Down Expand Up @@ -757,6 +830,7 @@ mod tests {
Effect::ExileTop {
player: TargetFilter::Controller,
count: QuantityExpr::Fixed { value: 1 },
position: LibraryPosition::Top,
face_down: false,
},
vec![],
Expand Down Expand Up @@ -840,6 +914,7 @@ mod tests {
Effect::ExileTop {
player: TargetFilter::Controller,
count,
position: LibraryPosition::Top,
face_down: false,
},
vec![],
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/investigate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub fn resolve(
player_id: ability.controller,
action: PlayerActionKind::Investigate,
look_count: None,
scry_bottom_count: None,
});

Ok(())
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/life.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ fn complete_pending_life_total_assignment(
player_id: action.player_id,
action: action.action,
look_count: None,
scry_bottom_count: None,
});
}
}
Expand Down
Loading
Loading