Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
13 changes: 13 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
Duration, Effect, GuessSubject, ModalChoice, MultiTargetSpec, ObjectScope, PlayerFilter,
PlayerScope, QuantityExpr, QuantityRef, RepeatContinuation, ReplacementDefinition,
ResolvedAbility, StaticCondition, StaticDefinition, TargetFilter, TriggerCondition,
TriggerDefinition, TurnJournalKind, TypeFilter, TypedFilter, ZoneRef,

Check failure on line 109 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Card data (generate, validate, coverage)

unresolved import `crate::types::ability::TurnJournalKind`

Check failure on line 109 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / WASM compile check

unresolved import `crate::types::ability::TurnJournalKind`

Check failure on line 109 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 2/4)

unresolved import `crate::types::ability::TurnJournalKind`

Check failure on line 109 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 4/4)

unresolved import `crate::types::ability::TurnJournalKind`

Check failure on line 109 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 3/4)

unresolved import `crate::types::ability::TurnJournalKind`

Check failure on line 109 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 1/4)

unresolved import `crate::types::ability::TurnJournalKind`

Check failure on line 109 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

unresolved import `crate::types::ability::TurnJournalKind`
};
use crate::types::game_state::TargetSelectionConstraint;
use crate::types::zones::Zone;
Expand Down Expand Up @@ -3756,7 +3756,7 @@
fn characteristic_source_read_bounded(source: &CardTypeSetSource) -> RwProfile {
let mut profile = RwProfile::empty();
let complete = source
.try_for_each_member(crate::types::ability::UNION_DEPTH_BUDGET, &mut |leaf| {

Check failure on line 3759 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Card data (generate, validate, coverage)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 3759 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / WASM compile check

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 3759 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 2/4)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 3759 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 4/4)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 3759 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 3/4)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 3759 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 1/4)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 3759 in crates/engine/src/game/ability_rw.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`
profile.merge(characteristic_source_read(leaf))
});
if complete {
Expand Down Expand Up @@ -3915,6 +3915,7 @@
copy_count_status: _,
forward_result: _,
distribution: _,
distribute: _, // announcement unit, no state read/write axis
chosen_x: _,
cost_paid_object: _,
noted_mana_payment: _, // concrete captured payment snapshot, no read/write effect
Expand Down Expand Up @@ -7072,6 +7073,18 @@
fn ra(effect: Effect) -> ResolvedAbility {
ResolvedAbility::new(effect, vec![], ObjectId(1), PlayerId(0))
}

#[test]
fn unassigned_distribution_unit_is_rw_inert() {
let base = ra(Effect::NoOp);
let mut divided = base.clone();
divided.distribute = Some(crate::types::game_state::DistributionUnit::Damage);

assert_eq!(
format!("{:?}", ability_rw_profile(&base)),
format!("{:?}", ability_rw_profile(&divided))
);
}
fn cond(mut a: ResolvedAbility, c: AbilityCondition) -> ResolvedAbility {
a.condition = Some(c);
a
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ fn resolved_ability_axes(a: &ResolvedAbility, mode: ScanMode) -> Axes {
copy_count_status: _, // status tag
forward_result: _, // bool
distribution: _, // concrete pre-assigned (TargetRef, u32) portions
distribute: _, // announcement unit tag/string, no resolution-time dynamic read
chosen_x: _, // concrete cast-time X
cost_paid_object: _, // concrete captured-object snapshot
cost_paid_object_ids: _, // concrete captured-object ids (issue #4948)
Expand Down
132 changes: 130 additions & 2 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@
resolved.description = def.description.clone();
resolved.forward_result = def.forward_result;
resolved.unless_pay = def.unless_pay.clone();
// CR 601.2d + CR 603.3d: Preserve the unassigned division unit until the
// ordinary stack-announcement authority assigns concrete portions.
resolved.distribute = def.distribute.clone();
resolved.player_scope = def.player_scope.clone();
// CR 101.4 + CR 800.4: Propagate the turn-order override for `player_scope`
// iteration. The iteration driver in `effects/mod.rs` reads this and calls
Expand All @@ -169,7 +172,7 @@
// keyword list collapses after the first false gate.
resolved.sibling_condition = def.sibling_condition;
// CR 700.2b + CR 603.3c: Carry the reflexive modal choice + per-mode abilities
// through so try_begin_reflexive_target_selection can route a gated modal
// through so try_materialize_reflexive_trigger can route a gated modal
// trigger (Caesar) to AbilityModeChoice instead of resolving the modes
// unconditionally.
resolved.modal = def.modal.clone();
Expand All @@ -193,7 +196,8 @@
/// Fields from `sub`: effect, duration, sub_ability, else_ability,
/// player_scope, optional, optional_for, optional_targeting, multi_target,
/// target_constraints, target_choice_timing, description, repeat_for,
/// min_x_value, forward_result, unless_pay, distribution, target_selection_mode.
/// min_x_value, forward_result, unless_pay, distribution, distribute,
/// target_selection_mode.
///
/// Fields preserved from `parent`: controller, source_id, kind, context,
/// original_controller, scoped_player, chosen_x, cost_paid_object,
Expand Down Expand Up @@ -246,6 +250,7 @@
overridden.forward_result = sub.forward_result;
overridden.unless_pay = sub.unless_pay.clone();
overridden.distribution = sub.distribution.clone();
overridden.distribute = sub.distribute.clone();
overridden.target_selection_mode = sub.target_selection_mode;
overridden.target_chooser = sub.target_chooser.clone();
// CR 608.2b + CR 601.2c: a swapped-in effect with its own declared target
Expand Down Expand Up @@ -901,6 +906,35 @@
unavailable
}

/// CR 700.2a / CR 700.2e: every player the modal's `chooser` admits, in APNAP
/// order.
///
/// `PlayerFilter::Controller` — every standard modal and the `you choose —`
/// alias — is the controller alone, without consulting
/// `effects::matches_player_scope`. Any other filter (CR 700.2e, "an opponent
/// chooses …") is resolved through that canonical authority over APNAP order.
///
/// Spell announcement wants only the first admitted player, which is what
/// `casting::resolve_modal_chooser` takes; trigger construction needs the whole
/// set, because more than one non-controller candidate makes the controller's
/// CR 700.2e chooser selection a real choice rather than a derivation.
pub(crate) fn modal_chooser_candidates(
state: &GameState,
modal: &ModalChoice,
controller: PlayerId,
source_id: ObjectId,
) -> Vec<PlayerId> {
if modal.chooser == PlayerFilter::Controller {
return vec![controller];
}
players::apnap_order(state)
.into_iter()
.filter(|&p| {
super::effects::matches_player_scope(state, p, &modal.chooser, controller, source_id)
})
.collect()
}

/// CR 700.2a-b: Mode indices a modal spell cannot choose — repeat constraints
/// plus modes whose targeting requirements have no legal assignment.
pub fn spell_modal_unavailable_modes(
Expand Down Expand Up @@ -4289,7 +4323,7 @@
// earlier hit. Truncation needs no conservative branch here — this returns a
// slot to wire, and inventing one would be worse than finding none.
let mut found: Option<TargetFilter> = None;
source.try_for_each_member(crate::types::ability::UNION_DEPTH_BUDGET, &mut |leaf| {

Check failure on line 4326 in crates/engine/src/game/ability_utils.rs

View workflow job for this annotation

GitHub Actions / Card data (generate, validate, coverage)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 4326 in crates/engine/src/game/ability_utils.rs

View workflow job for this annotation

GitHub Actions / WASM compile check

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 4326 in crates/engine/src/game/ability_utils.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 2/4)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 4326 in crates/engine/src/game/ability_utils.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 4/4)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 4326 in crates/engine/src/game/ability_utils.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 3/4)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 4326 in crates/engine/src/game/ability_utils.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 1/4)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`

Check failure on line 4326 in crates/engine/src/game/ability_utils.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

cannot find value `UNION_DEPTH_BUDGET` in module `crate::types::ability`
if found.is_some() {
return;
}
Expand Down Expand Up @@ -7755,6 +7789,75 @@
use super::*;
use crate::game::zones::create_object;

/// CR 700.2a / CR 700.2e: `modal_chooser_candidates` is the one authority
/// both spell announcement and trigger construction read.
///
/// Announcement is single-valued and takes `.first()`, so this row proves
/// the head of the returned order is byte-identical to the historic
/// `resolve_modal_chooser` result on both branches, and that the tail — the
/// part only trigger construction consumes — really is the complete
/// admitted set rather than that same single value. A regression that
/// truncates the extraction back to one candidate fails the three-player
/// length assertion while leaving both head assertions green.
#[test]
fn modal_chooser_candidates_are_the_complete_admitted_set_in_apnap_order() {
let mut state = GameState::new(crate::types::format::FormatConfig::free_for_all(), 3, 42);
state.active_player = PlayerId(0);
let source = create_object(
&mut state,
crate::types::identifiers::CardId(1),
PlayerId(0),
"Modal chooser source".to_string(),
Zone::Battlefield,
);

let mut modal = ModalChoice {
min_choices: 1,
max_choices: 1,
mode_count: 2,
..Default::default()
};

// CR 700.2a: the controller branch never consults `matches_player_scope`
// and never admits anyone else, in any seat count.
modal.chooser = PlayerFilter::Controller;
assert_eq!(
modal_chooser_candidates(&state, &modal, PlayerId(1), source),
vec![PlayerId(1)],
"the controller branch is the controller alone"
);

// CR 700.2e: "an opponent chooses —" with two opponents is a real
// choice, and APNAP order decides which one announcement would take.
modal.chooser = PlayerFilter::Opponent;
let candidates = modal_chooser_candidates(&state, &modal, PlayerId(0), source);
assert_eq!(
candidates,
vec![PlayerId(1), PlayerId(2)],
"every opponent is admitted, in APNAP order"
);
assert_eq!(
candidates.first().copied(),
Some(PlayerId(1)),
"announcement's single-valued head is the first APNAP opponent"
);

// Two-player: the same authority collapses to the unambiguous opponent.
let mut two = GameState::new_two_player(42);
two.active_player = PlayerId(0);
let two_source = create_object(
&mut two,
crate::types::identifiers::CardId(1),
PlayerId(0),
"Modal chooser source".to_string(),
Zone::Battlefield,
);
assert_eq!(
modal_chooser_candidates(&two, &modal, PlayerId(0), two_source),
vec![PlayerId(1)]
);
}

/// Matrix rows 5 + 6 — the slot/spec mirror must agree in COUNT **and**
/// ORDER, and the context-ref skip must agree between the two sites.
///
Expand Down Expand Up @@ -9132,6 +9235,7 @@
sub.player_scope = Some(crate::types::ability::PlayerFilter::Opponent);
sub.optional = true;
sub.description = Some("override description".to_string());
sub.distribute = Some(crate::types::game_state::DistributionUnit::Damage);

let swapped = apply_instead_swap(&parent, &sub);

Expand All @@ -9147,6 +9251,11 @@
);
assert!(swapped.optional, "swap must preserve sub.optional");
assert_eq!(swapped.description.as_deref(), Some("override description"));
assert_eq!(
swapped.distribute,
Some(crate::types::game_state::DistributionUnit::Damage),
"swap must preserve the sub-ability's unassigned distribution unit"
);
// Identity / runtime-context fields come from parent.
assert_eq!(
swapped.controller,
Expand Down Expand Up @@ -9260,6 +9369,25 @@
);
}

#[test]
fn build_resolved_from_def_preserves_unassigned_distribution_unit() {
let mut def = AbilityDefinition::new(
AbilityKind::Database,
Effect::DealDamage {
amount: QuantityExpr::Fixed { value: 4 },
target: TargetFilter::Any,
damage_source: None,
excess: None,
},
);
def.distribute = Some(crate::types::game_state::DistributionUnit::Damage);

let resolved = build_resolved_from_def(&def, ObjectId(1), PlayerId(0));

assert_eq!(resolved.distribute, def.distribute);
assert!(resolved.distribution.is_none());
}

#[test]
fn build_resolved_from_def_preserves_unless_pay_modifier() {
let modifier = UnlessPayModifier {
Expand Down
22 changes: 8 additions & 14 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13352,26 +13352,20 @@ fn continue_with_prepared(
/// `effects::matches_player_scope` authority filtered over APNAP order. In the
/// 2-player engine this is unambiguous. Falls back to the controller if no
/// player matches (defensive — cannot happen in a live 2-player game).
///
/// Spell announcement is single-valued by construction: it opens exactly one
/// `WaitingFor::ModeChoice`, so it takes the first admitted candidate from the
/// shared `ability_utils::modal_chooser_candidates` authority. Trigger
/// construction consumes that same authority's full set.
fn resolve_modal_chooser(
state: &GameState,
modal: &crate::types::ability::ModalChoice,
controller: PlayerId,
source_id: ObjectId,
) -> PlayerId {
if modal.chooser == crate::types::ability::PlayerFilter::Controller {
return controller;
}
crate::game::players::apnap_order(state)
.into_iter()
.find(|&p| {
crate::game::effects::matches_player_scope(
state,
p,
&modal.chooser,
controller,
source_id,
)
})
super::ability_utils::modal_chooser_candidates(state, modal, controller, source_id)
.first()
.copied()
.unwrap_or(controller)
}

Expand Down
Loading
Loading