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
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

584 changes: 583 additions & 1 deletion crates/engine/src/game/zone_pipeline.rs

Large diffs are not rendered by default.

92 changes: 89 additions & 3 deletions crates/engine/src/game/zones.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1216,10 +1216,48 @@ pub fn move_to_zone(
let static_dependency_after =
crate::game::layers::static_layer_dependency_for_zone_transition(state, from, to);

// CR 611.3a + CR 400.3: Hand size affects continuous effects gated on the
// controller's hand (Carnage Interpreter, issue #3991) and hand-zone
// effects (Miracle in hand). Re-evaluate layers on any hand entry/exit.
// pod-lab loop-3 Q5: a plain Battlefield entry that doesn't originate
// from Hand or Exile, and isn't itself the source of a live
// zone-membership-dependent static (static_dependency_before/after),
// can take the cheaper `mark_layers_entered` path instead of forcing a
// full re-evaluation of every object's characteristics. This does NOT
// skip re-verification: `prepare_incremental_flush` (layers.rs) re-runs
// its own full Axis-1/Axis-2 safety analysis fresh from live state at
// flush time regardless of which mark got set here, and escalates to a
// full pass itself whenever that analysis can't prove the entering
// object is safe (a sourced continuous effect, a CDA, counters,
// attachments, or a population-perturbing static). This call only
// proposes the cheap mark when the mutation site itself has nothing
// else forcing a full re-evaluation; it is not the safety net.
//
// Hand and Exile are excluded UNCONDITIONALLY here, not merely folded
// into static_dependency_before/after, because both have a proven blind
// spot in that check:
// - CR 611.3a + CR 400.3: hand size affects continuous effects gated
// on the controller's hand (Carnage Interpreter, issue #3991), and
// `layers.rs`'s `quantity_ref_reads_zone` classifier maps
// `QuantityRef::HandSize` to a hardcoded `false` — a live
// HandSize-gated static is not detected as a zone dependency at all.
// - CR 613.1: characteristics set by "for each card exiled with/by
// [this]"-style statics (`QuantityRef::CardsExiledBySource`,
// `ExiledCardPower`, `TrackedSetSize`, `FilteredTrackedSetSize`,
// `TrackedSetAggregate` — e.g. Unlicensed Hearse, Veteran Survivor,
// Sutured Ghoul) have the identical blind spot: the same classifier
// maps all of them to `false`, and the count is live-filtered on
// `obj.zone == Zone::Exile` (see `linked_exile_for_context` /
// `players.rs`), so it changes the instant a linked card leaves
// Exile for the Battlefield. Neither axis has a Axis-2 analog in
// `prepare_incremental_flush` (which is exclusively board-population
// framed), so there is no flush-time safety net for either — the
// unconditional mark at this mutation site is these statics' ONLY
// protection, exactly as it is today.
if to == Zone::Battlefield
&& from != Zone::Hand
&& from != Zone::Exile
&& !(static_dependency_before || static_dependency_after)
{
crate::game::layers::mark_layers_entered(state, object_id);
} else if to == Zone::Battlefield
|| from == Zone::Battlefield
|| to == Zone::Hand
|| from == Zone::Hand
Expand Down Expand Up @@ -1310,6 +1348,17 @@ pub(crate) fn restore_after_rollback(
events: &mut Vec<GameEvent>,
) {
move_to_zone(state, object_id, to, events);
// CR 601.2 + CR 733.1: reversing an incomplete action needs full
// reconciliation regardless of which mark move_to_zone's own
// axis-gated internal logic picked — an undone action is rare
// (not gameplay-hot) and can leave board state in a shape the
// entry-only incremental-flush safety classifier was never designed to
// reason about, so there is no perf case for trusting it here. This is
// conservatively at-or-above today's marking, not byte-for-byte
// identical to it: some rollback transitions `move_to_zone` marks
// nothing for today (e.g. Stack->Library) become `Full` here, which is
// strictly safe, never a behavior change a test could observe as wrong.
crate::game::layers::mark_layers_full(state);
}

/// CR 603.10a: Record that every member of `group` left the battlefield in the
Expand Down Expand Up @@ -4069,4 +4118,41 @@ mod tests {
"SBA zone movement must still publish the unattach event for triggers"
);
}

/// pod-lab loop-3 Q5, row 5: `restore_after_rollback` targeting the
/// battlefield must still force a full layers re-evaluation
/// unconditionally — CR 601.2 + CR 733.1, reversing an incomplete action
/// is rare (not gameplay-hot) and can leave board state in a shape the
/// entry-only incremental-flush safety classifier was never designed to
/// reason about, so there is no perf case for trusting `move_to_zone`'s
/// own (now axis-gated) internal decision here. Today's only production
/// caller targets Graveyard, not Battlefield, so this exercises the
/// function's general contract directly rather than replaying an
/// existing call site.
#[test]
fn restore_after_rollback_to_battlefield_marks_full() {
let mut state = setup();
let id = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Rolled Back Spell".to_string(),
Zone::Stack,
);
state.layers_dirty = crate::types::game_state::LayersDirty::Clean;

let mut events = Vec::new();
restore_after_rollback(&mut state, id, Zone::Battlefield, &mut events);

assert_eq!(state.objects[&id].zone, Zone::Battlefield);
assert!(
matches!(
state.layers_dirty,
crate::types::game_state::LayersDirty::Full
),
"restore_after_rollback targeting the battlefield must \
unconditionally force a full re-evaluation, got {:?}",
state.layers_dirty
);
}
}
10 changes: 10 additions & 0 deletions crates/phase-ai/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
web-time = "1"
rayon = { version = "1", optional = true }

# Native-binary throughput lever (pod-lab loop-3 Q5). Target-gated, not a
# plain [dependencies] entry: `engine-wasm`/`draft-wasm` both depend on this
# crate's lib for their wasm32 builds, which are explicitly size-tuned
# (`opt-level = 'z'`, the #6313 25 MiB pages-deploy guard) -- an ungated
# entry would pull mimalloc's C sources into those builds too. `[[bin]]`
# targets are never part of that wasm-bindgen graph, so no further per-bin
# cfg is needed once the dependency itself is scoped here.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
mimalloc = "0.1"

[features]
tune = ["rayon"]
# Scenario-backed benchmark binaries are opt-in: build them with
Expand Down
45 changes: 23 additions & 22 deletions crates/phase-ai/baselines/perf-baseline.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema_version": 3,
"git_sha": "a496f13efa1d",
"card_data_hash": "976e3bffb3fdb726460f4beb902125c329e82428",
"schema_version": 4,
"git_sha": "64b65e58e249",
"card_data_hash": "e2db8a6d4711e34097b307c454032b29fbce8d4f",
"base_seed": 2654435769,
"action_cap": 3000,
"sample_count": 5,
Expand All @@ -11,34 +11,35 @@
"enchantress-mirror"
],
"counters": {
"attackable_player_sweeps": 1476,
"auto_tap_source_cache_builds": 1426,
"attackable_player_sweeps": 830,
"auto_tap_source_cache_builds": 31,
"cached_auto_tap_source_rejects": 0,
"cached_auto_tap_source_reuses": 1316,
"cached_auto_tap_source_reuses": 0,
"combat_shadow_block_scans": 0,
"crew_eligibility_scans": 10655,
"crew_eligibility_scans": 7337,
"granted_ability_provider_scans": 0,
"layers_escalated": 35,
"layers_full_eval": 5147,
"layers_incremental": 307,
"legal_actions_spell_cost_sweeps": 1426,
"legend_rule_mode_gate_scans": 15588,
"mana_aura_trigger_scans": 22942,
"mana_display_sweeps": 385,
"mana_display_swept_objects": 5128,
"priority_cast_probe_builds": 1426,
"layers_escalated": 93,
"layers_full_eval": 3495,
"layers_incremental": 491,
"legal_actions_spell_cost_sweeps": 31,
"legend_rule_mode_gate_scans": 10274,
"mana_aura_trigger_scans": 14286,
"mana_display_sweeps": 270,
"mana_display_swept_objects": 2718,
"priority_cast_probe_builds": 31,
"restriction_static_exact_scans": 0,
"restriction_static_mode_gate_scans": 48012,
"sba_battlefield_snapshot_builds": 15322,
"sba_empty_battlefield_short_circuits": 40,
"restriction_static_mode_gate_scans": 46421,
"sba_battlefield_snapshot_builds": 10205,
"sba_empty_battlefield_short_circuits": 57,
"spell_keyword_grant_scans": 0,
"stack_batch_candidates": 0,
"stack_batch_observer_refusals": 0,
"stack_batch_plans": 0,
"stack_batched_entries": 0,
"stack_inert_noop_batches": 0,
"stack_inert_noop_entries": 0,
"state_clone_for_legality": 13683,
"static_full_scans": 0
"state_clone_for_legality": 6489,
"static_full_scans": 15
},
"wall_clock_ms": 12131
"wall_clock_ms": 196164
}
6 changes: 6 additions & 0 deletions crates/phase-ai/src/bin/ai_bench_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
//!
//! Usage: `cargo run --release --bin ai-bench-state -- <path> [--difficulty medium] [--iters N] [--assert-under-ms N]`

// pod-lab loop-3 Q5: native-binary throughput lever, gated in Cargo.toml so
// wasm32 builds of this crate's lib (pulled in by engine-wasm/draft-wasm)
// never see it.
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

use std::fs;
use std::time::Instant;

Expand Down
Loading
Loading