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
25 changes: 20 additions & 5 deletions crates/engine/src/ai_support/candidates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3027,16 +3027,31 @@ pub fn candidate_actions_broad_with_probe(
)
})
.collect(),
// CR 732.2a: an accepted object-growth loop collapses into a finite count
// the controller names. `max` is the engine's 1000-wide loop bound; the AI
// never wants a huge pile, so offer only the default N=1 β€” this bounds
// search regardless of the display cap. Must precede the general arm below.
// CR 732.2a: an accepted object-growth loop collapses into a finite count the
// controller names. Offer only N=1 rather than the whole `min..=max` range: the AI
// never wants a huge pile, and one candidate bounds search regardless of how wide
// the prompt is. Must precede the general arm below.
//
// CR 732.2c: `max` is NOT a fixed 1000 β€” it is the count the table accepted
// (`pending_materialization_count`), so it can legitimately be 0 (a shortcut
// accepted at `Fixed(0)`). Clamp, or the generator's sole candidate is rejected by
// the reducer's `amount > max` guard and the AI has no legal action at this prompt.
//
// Unreachable for the AI *today* and deliberately kept correct anyway: the AI's own
// `WaitingFor::LoopShortcut` arm below only ever proposes `IterationCount::
// UntilLethal`, which routes to `apply_until_lethal_shortcut` and never reaches
// `materialize_fixed_shortcut` β€” the only path that registers a stash. So no
// AI-declared shortcut currently produces this prompt; a human-declared one in a
// mixed game, or a future bounded AI offer, does.
WaitingFor::PayAmountChoice {
player,
resource: PayableResource::LoopCollapse { .. },
max,
..
} => vec![candidate(
GameAction::SubmitPayAmount { amount: 1 },
GameAction::SubmitPayAmount {
amount: (*max).min(1),
},
TacticalClass::Selection,
Some(*player),
)],
Expand Down
79 changes: 65 additions & 14 deletions crates/engine/src/analysis/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3311,6 +3311,65 @@ fn fire_time_conditions_read_projected_resource_scoped(
false
}

/// CR 113.6 (CR 113.6k): every trigger definition that FUNCTIONS in its source's current zone.
/// The shared board walk for the axis firewalls β€” `board_has_event_observer` and
/// [`board_has_functioning_etb_trigger`] both ask "which event does it react to?" of the same
/// set, so the zone gate has one authority.
fn functioning_board_trigger_defs(
state: &GameState,
) -> impl Iterator<Item = &crate::types::ability::TriggerDefinition> {
state.objects.values().flat_map(move |obj| {
crate::game::functioning_abilities::active_trigger_definitions(state, obj)
.map(|active| active.definition)
.filter(move |def| {
crate::game::triggers::trigger_definition_functions_in_zone(def, obj.zone)
})
})
}

/// CR 603.6a: does ANY functioning board trigger fire on a battlefield entry?
///
/// The route firewall for a batched collapse that MINTS TOKENS: each minted token is a real
/// CR 603.6a entry, so every board ETB trigger fires for real on top of whatever the batched
/// arithmetic already applied. Measured on the Sprout Swarm 4p dump: the batched
/// `[Tokens, Life { per_cycle_delta: 1 }]` pair took P0 from 546 to 596 at the collapse, and
/// draining the 50 real token-ETB triggers paid the SAME life again, ending at 646. Routing to
/// the concrete replay makes the real ETB triggers the ONLY source, which is what the board does.
///
/// SHAPE-AGNOSTIC by construction. An earlier form asked whether the trigger's effect chain was
/// an `Effect::GainLife`, which is under-approximate: life reaches `apply_life_gain` from four
/// resolvers (`effects/life.rs`, `effects/double.rs`, `effects/exchange_life.rs`, and
/// `effects/deal_damage.rs`'s CR 702.15b lifelink leg), so a Terror-of-the-Peaks-shaped board β€”
/// an ETB damage trigger on a permanent with lifelink β€” grows a genuinely ETB-sourced life axis
/// that no `Effect`-shape test can see. Asking only "is there a functioning ETB trigger" cannot
/// miss a life source.
///
/// This predicate and the effect-shape test it replaced are INCOMPARABLE, not nested β€” dropping the
/// `Effect::GainLife` conjunct is strictly LOOSER on effect shape (any ETB trigger counts, not just
/// a life-gaining one). What narrows the CALLER is a different axis: it pairs this with
/// `token_profile.is_some()`, so only a collapse that MINTS the entries can route here and a
/// token-less loop never does. Looser on shape, narrower on axis; neither side contains the other.
///
/// Distinct from [`life_growth_is_observed`], which asks whether a LUMP gain would miscount an
/// observer. Here the batched arithmetic is right and the double-apply comes from the collapse
/// itself. Deliberately NOT folded into `life_growth_is_observed`: that predicate also gates the
/// offer firewall, where this shape is not an observation. Deliberately NOT a
/// registration-cancelling suppressor either β€” the axis can be MIXED-cause (an ETB rider plus a
/// drain), and the batched `Life` registration is per-player, so dropping it would under-apply
/// the non-ETB half and silence the wrong beneficiary.
///
/// A sound OVER-approximation in the same idiom as its siblings: a true result routes to the
/// discrete N-cycle driver, which is always correct (only slower).
pub(crate) fn board_has_functioning_etb_trigger(state: &GameState) -> bool {
use crate::types::triggers::TriggerEventKey;
functioning_board_trigger_defs(state).any(|def| {
crate::game::trigger_index::keys_from_trigger_def(def)
.0
.iter()
.any(|key| matches!(key, TriggerEventKey::EnterBattlefield(_)))
})
}

/// CR 732.2a / CR 603.4 / CR 614.1: does any battlefield/command-FUNCTIONING trigger fire on
/// `trig_key`, or any active battlefield/command replacement replace `repl_event`? The shared
/// per-event observer scan for the axis-specific firewalls, classifying triggers via the same
Expand All @@ -3320,20 +3379,12 @@ fn board_has_event_observer(
trig_key: crate::types::triggers::TriggerEventKey,
repl_event: ReplacementEvent,
) -> bool {
for obj in state.objects.values() {
for active in crate::game::functioning_abilities::active_trigger_definitions(state, obj) {
let def = active.definition;
// CR 603.4 / CR 113.6: only a trigger that FUNCTIONS in its source's current zone.
if !crate::game::triggers::trigger_definition_functions_in_zone(def, obj.zone) {
continue;
}
if crate::game::trigger_index::keys_from_trigger_def(def)
.0
.contains(&trig_key)
{
return true;
}
}
if functioning_board_trigger_defs(state).any(|def| {
crate::game::trigger_index::keys_from_trigger_def(def)
.0
.contains(&trig_key)
}) {
return true;
}
for (_, obj, def) in crate::game::functioning_abilities::active_replacements(state) {
// CR 614.1 / CR 113.6: `active_replacements` is all-zones; a life/counter-event
Expand Down
88 changes: 80 additions & 8 deletions crates/engine/src/game/derived_views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -793,13 +793,64 @@ pub fn derive_views(state: &GameState, viewer: Option<PlayerId>) -> DerivedViews
views.turn_order = turn_order;
views.viewer_turn_number = viewer_turn_number;

// CR 732.2c: once every player accepted the shortcut it IS taken, at the finite N the
// proposal named β€” so an axis with a scheduled collapse is already BOUNDED and must not
// render `∞` anywhere beside the finite totals it is growing. ONE authority
// (`GameState::scheduled_collapse_axes`), THREE consumers below: the per-axis resource
// badge rows, the ∞ object pile, and the ∞ counter pills. The gate is computed here,
// once per controller, precisely so no surface can re-derive it and drift β€” a HUD that
// hides the resource badge while a card group still shows ∞ is internally inconsistent.
//
// Filter the PROJECTION, never the store: `unbounded_resources` +
// `unbounded_loop_enablers` stay in CR 104.4b / CR 110.1 lockstep until the CR 500.5
// boundary applies the growth, which is what keeps `zones::apply_zone_exit_cleanup`'s
// defuse armed in the meantime.
//
// FAIL-CLOSED: only axes a registered materialization really collapses are hidden, so an
// unregistered ∞ axis (a mana engine registers none) still renders.
//
// CLASS RULE for the hide-set: hide only axes whose growth is still DEFERRED; never hide an
// axis that is ALREADY MATERIALIZED and spendable right now. `Tokens` / `Counters` / `Life`
// are deferred by construction β€” the growth is not on the board until the boundary applies
// it β€” so an `∞` for them is exactly the lie this gate kills. A `DriveSequence` is the one
// item that does NOT name a deferral: its `collapsed_axes` is `proposal.unbounded`, i.e.
// EVERY axis of the whole loop, and a `Mana(_)` among them is live *now* β€”
// `mana_payment::refill_infinite_mana` tops that controller's pool back to
// `INFINITE_MANA_PER_TYPE` off the STORE (which this projection deliberately never touches)
// after every action. Hiding it would show no `∞` beside a pool that keeps refilling: the
// same internally-inconsistent HUD as an `∞ Life` badge on a finite life total, inverted.
// CR 500.5: `turns::drain_pending_phase_transition_progress` clears the mana axis when the
// step/phase ends, and THAT is what legitimately ends the badge β€” not this projection.
//
// `Mana(_)` is today's only already-materialized axis: census of production readers of
// `GameState::unbounded_resources` (`refill_infinite_mana`, the CR 500.5 clear in `turns`,
// and this projection) shows it is the only axis any reader turns back into a spendable
// resource. Widen this `retain` only for an axis that gains the same property.
let scheduled_collapse: BTreeMap<PlayerId, BTreeSet<ResourceAxis>> = state
.pending_unbounded_materialization
.iter()
.map(|(&controller, items)| {
let mut axes = state.scheduled_collapse_axes(items);
axes.retain(|a| !matches!(a, ResourceAxis::Mana(_)));
(controller, axes)
})
.collect();
let collapse_scheduled = |controller: PlayerId, axis: &ResourceAxis| -> bool {
scheduled_collapse
.get(&controller)
.is_some_and(|axes| axes.contains(axis))
};

// CR 732.2a: project every unbounded-resource loop into per-(player, axis)
// `∞` HUD rows. Runs in every format (placed BEFORE the Commander
// short-circuit below) and stays empty (field omitted) when no loop is
// active β€” the dominant case. The engine owns attribution
// (`attribution_player`); the frontend only formats each axis to a family.
for (&controller, axes) in &state.unbounded_resources {
for &axis in axes {
if collapse_scheduled(controller, &axis) {
continue;
}
views.unbounded_resources.push(UnboundedResourceView {
player: attribution_player(axis, controller),
axis,
Expand All @@ -811,7 +862,15 @@ pub fn derive_views(state: &GameState, viewer: Option<PlayerId>) -> DerivedViews
// winning controller's tapped fodder-class members β€” dropping any that have since
// left the battlefield (stale member). Public board state (no viewer filtering);
// the frontend renders `∞` on any group whose members are all pile members.
for ids in state.unbounded_loop_pile.values() {
//
// CR 732.2c: same gate, same authority as the badge rows above. The pile IS the
// `TokensCreated` axis β€” `clear_collapsed_materializations` drops it on exactly that
// axis collapsing β€” so once that axis has a scheduled finite mint the group must stop
// rendering ∞ in lockstep with its resource badge.
for (&controller, ids) in &state.unbounded_loop_pile {
if collapse_scheduled(controller, &ResourceAxis::TokensCreated) {
continue;
}
for id in ids {
if state.battlefield.contains(id) {
views.unbounded_pile.push(*id);
Expand All @@ -825,15 +884,28 @@ pub fn derive_views(state: &GameState, viewer: Option<PlayerId>) -> DerivedViews
// the battlefield (stale member). Display-only per-object channel mirroring
// `unbounded_pile`; the frontend renders `∞` (not `Γ—N`) on any counter pill whose
// type is in this set. Runs in every format (BEFORE the Commander short-circuit).
for targets in state.unbounded_counter_targets.values() {
//
// CR 732.2c: same gate, same authority. A pill's axis is derived by the SHARED
// `(object, counter) -> ResourceAxis` mapping the collapse itself uses
// (`collapsed_counter_axis`), so a pill can never disagree with the badge it mirrors.
// The class lookup is LIVE by design here: this loop only emits pills for bearers still
// on the battlefield, so the object is present and its class is current.
for (&controller, targets) in &state.unbounded_counter_targets {
for (id, ct) in targets {
if state.battlefield.contains(id) {
views
.unbounded_counters
.entry(*id)
.or_default()
.push(ct.clone());
if !state.battlefield.contains(id) {
continue;
}
if collapse_scheduled(
controller,
&crate::types::game_state::collapsed_counter_axis(state, *id, ct),
) {
continue;
}
views
.unbounded_counters
.entry(*id)
.or_default()
.push(ct.clone());
}
}

Expand Down
18 changes: 13 additions & 5 deletions crates/engine/src/game/effects/incubate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ pub fn resolve(
// token (escalates to a full pass if it sources effects, carries
// counters, etc.).
crate::game::layers::mark_layers_entered(state, obj_id);
crate::game::restrictions::record_battlefield_entry(state, obj_id);
// CR 403.3 battlefield-entry bookkeeping is done by `record_zone_change` below β€”
// recording it here too would double-count `battlefield_entries_this_turn`.
crate::game::restrictions::record_token_created(state, obj_id);

// CR 603.6a: The Incubator token enters the battlefield as a zone change
Expand All @@ -109,14 +110,21 @@ pub fn resolve(
// triggers (issue #4238). Mirrors
// `token.rs::apply_create_token_after_replacement_with_created_ids` and
// `conjure.rs`'s identical fix for the same bug class.
let zone_change_record = state
//
// CR 400.7 + CR 603.2c: route the record through `restrictions::record_zone_change` β€” the
// single authority that assigns this turn's zone-change index β€” and write the assigned index
// back onto the emitted record. `snapshot_for_zone_change` leaves it at its `0` placeholder,
// and the batched zone-change replay guard (`triggers.rs`) dedups on
// `(definition_ref, turn_zone_change_index)` read off the EVENT, so an unrouted record aliases
// this Incubator onto occurrence `0` and a `batched: true` ETB trigger that already fired for
// another entry this turn is swallowed. Same shape as `merge.rs` and `token.rs`.
let mut zone_change_record = state
.objects
.get(&obj_id)
.expect("incubator token was just created")
.snapshot_for_zone_change(obj_id, None, Zone::Battlefield);
state
.zone_changes_this_turn
.push_back(zone_change_record.clone());
zone_change_record.turn_zone_change_index =
crate::game::restrictions::record_zone_change(state, zone_change_record.clone());
events.push(GameEvent::ZoneChanged {
object_id: obj_id,
from: None,
Expand Down
Loading
Loading