From 853fe4411440b8d0b4da3cf40b676e98573eb4cf Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Tue, 7 Jul 2026 16:49:39 +0000 Subject: [PATCH 1/7] fix(engine, ai): deterministic ordering for AI decision trajectories (#4878) --- crates/engine/src/ai_support/mod.rs | 7 ++++++- crates/engine/src/game/casting.rs | 11 +++++----- crates/engine/src/game/layers.rs | 26 ++++++++++++------------ crates/engine/src/game/stack.rs | 4 ++-- crates/engine/src/types/game_state.rs | 4 ++-- crates/engine/src/types/identifiers.rs | 4 +++- crates/phase-ai/src/search.rs | 28 +++++++++++++------------- 7 files changed, 45 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 1f40862f81..920d1852f0 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -52,7 +52,12 @@ pub fn validated_candidate_actions_with_probe( probe: Option<&crate::game::casting::PriorityCastProbe>, ) -> Vec { let pipeline = FilterPipeline::default_pipeline(); - pipeline.apply_with_probe(state, candidate_actions_with_probe(state, probe), probe) + let mut actions = + pipeline.apply_with_probe(state, candidate_actions_with_probe(state, probe), probe); + // Issue #4878: candidate enumeration must not depend on HashSet/HashMap + // iteration order leaking into AI tie-breaking downstream. + actions.sort_by_cached_key(|c| format!("{:?}", c.action)); + actions } /// CR 702.51a / 702.66a / 702.126a: During `ManaPayment`, every structurally diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 2a4a49f74e..18e8123372 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -28,7 +28,7 @@ use crate::types::statics::{ }; use crate::types::zones::{ExileCostSourceZone, Zone}; -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; use super::ability_utils::{ ability_target_legality_needs_chosen_x, assign_targets_in_chain, auto_select_targets, @@ -867,11 +867,10 @@ pub fn spell_objects_available_to_cast(state: &GameState, player: PlayerId) -> V // CR 117.1c: per-turn frequency is enforced inside the helper, not by // active-player gating, so the same logic covers the rare case of an // `Unlimited` printing on either player's turn. - let exile_permission_ids: HashSet = - exile_objects_castable_by_permission(state, player) - .iter() - .map(|(obj_id, _source_id, _freq)| *obj_id) - .collect(); + let exile_permission_ids: BTreeSet = exile_objects_castable_by_permission(state, player) + .iter() + .map(|(obj_id, _source_id, _freq)| *obj_id) + .collect(); objects.extend(exile_permission_ids); // CR 401.5 + CR 118.9 + CR 601.2a: Top card of library castable via a diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 74e10de501..ab5139d447 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::Arc; use crate::database::synthesis::KeywordTriggerInstaller; @@ -73,7 +73,7 @@ impl LayerZoneObjectCache { } struct PreparedIncrementalFlush { - recipient_ids: HashSet, + recipient_ids: BTreeSet, active_effects: Vec, } @@ -2255,7 +2255,7 @@ pub fn flush_layers(state: &mut GameState) { fn prepare_incremental_flush( state: &mut GameState, - entered_ids: &HashSet, + entered_ids: &BTreeSet, ) -> Option { for &id in entered_ids { let obj = state.objects.get(&id)?; @@ -2326,7 +2326,7 @@ fn prepare_incremental_flush( #[cfg(test)] pub(crate) fn incremental_flush_must_escalate( state: &GameState, - entered_ids: &HashSet, + entered_ids: &BTreeSet, ) -> bool { // Axis 1 — per-entered preconditions. for &id in entered_ids { @@ -2381,7 +2381,7 @@ pub(crate) fn incremental_flush_must_escalate( fn active_effects_force_incremental_escalation( state: &GameState, - entered_ids: &HashSet, + entered_ids: &BTreeSet, active_effects: &[ActiveContinuousEffect], ) -> bool { active_effects.iter().any(|e| { @@ -2450,7 +2450,7 @@ fn active_effects_force_incremental_escalation( /// (invariant 5), so the cached BEFORE truth aligns with the consulted def. fn any_active_static_condition_perturbed_by_entry( state: &GameState, - entered_ids: &HashSet, + entered_ids: &BTreeSet, ) -> bool { let mut found = false; for_each_static_effect_source(state, |state, obj| { @@ -2614,8 +2614,8 @@ fn entered_object_blocks_incremental( /// continuous static applies to the enchanted permanent, not itself). fn incremental_recipient_ids( state: &GameState, - entered_ids: &HashSet, -) -> HashSet { + entered_ids: &BTreeSet, +) -> BTreeSet { let mut recipients = entered_ids.clone(); for &id in entered_ids { if let Some(host) = state @@ -2632,7 +2632,7 @@ fn incremental_recipient_ids( fn effect_is_restricted_to_incremental_recipients( effect: &ActiveContinuousEffect, - recipient_ids: &HashSet, + recipient_ids: &BTreeSet, ) -> bool { match &effect.affected_filter { TargetFilter::SelfRef => recipient_ids.contains(&effect.source_id), @@ -3684,7 +3684,7 @@ fn apply_combat_assignment_rule_effects(state: &mut GameState) { /// object-affecting continuous effects. fn apply_combat_assignment_rule_effects_filtered( state: &mut GameState, - restrict_to: Option<&HashSet>, + restrict_to: Option<&BTreeSet>, ) { let mut effects = collect_active_combat_assignment_rule_effects(state); effects.sort_by_key(|effect| (effect.timestamp, effect.controller.0, effect.source_id.0)); @@ -3739,7 +3739,7 @@ fn apply_combat_assignment_rule_effects_filtered( /// affected objects. This is run AFTER all keyword grants/removals are applied, /// so the denial wins regardless of grant timestamp — the rules-correct "can't /// have" outcome (a concurrent anthem can't restore a denied keyword). -fn apply_cant_have_keyword_denials(state: &mut GameState, restrict_to: Option<&HashSet>) { +fn apply_cant_have_keyword_denials(state: &mut GameState, restrict_to: Option<&BTreeSet>) { // Collect (affected object, denied keyword) pairs under an immutable borrow, // then strip — avoids a borrow conflict with the per-object mutation. let mut denials: Vec<(ObjectId, Keyword)> = Vec::new(); @@ -4359,7 +4359,7 @@ fn apply_continuous_effect( fn apply_continuous_effect_to( state: &mut GameState, effect: &ActiveContinuousEffect, - restrict_to: &HashSet, + restrict_to: &BTreeSet, abilities_suppressed: &mut HashSet, zone_cache: &mut LayerZoneObjectCache, ) { @@ -4375,7 +4375,7 @@ fn apply_continuous_effect_to( fn apply_continuous_effect_filtered( state: &mut GameState, effect: &ActiveContinuousEffect, - restrict_to: Option<&HashSet>, + restrict_to: Option<&BTreeSet>, abilities_suppressed: &mut HashSet, zone_cache: &mut LayerZoneObjectCache, ) { diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index d0320f5dcc..432c2eecbb 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -7866,7 +7866,7 @@ mod tests { let mut normal = setup_board(); flush_layers(&mut normal); add_entry(&mut normal); - let entered_ids: std::collections::HashSet = match &normal.layers_dirty { + let entered_ids: std::collections::BTreeSet = match &normal.layers_dirty { crate::types::game_state::LayersDirty::EnteredObjects(ids) => ids.clone(), other => panic!("expected EnteredObjects dirty state, got {other:?}"), }; @@ -8639,7 +8639,7 @@ mod tests { flush_layers(&mut state); // A green entry perturbs the < 7 gate (would flip 6 → 7). add_green_devotion_entry(&mut state, 322); - let entered_ids: std::collections::HashSet = match &state.layers_dirty { + let entered_ids: std::collections::BTreeSet = match &state.layers_dirty { crate::types::game_state::LayersDirty::EnteredObjects(ids) => ids.clone(), other => panic!("expected EnteredObjects, got {other:?}"), }; diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index f15e9c7a04..2b8f4c7642 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -2380,7 +2380,7 @@ pub enum LayersDirty { /// Only these objects entered the battlefield since the last flush and no /// other layer-affecting mutation occurred. Candidate for the incremental /// fast path. - EnteredObjects(HashSet), + EnteredObjects(BTreeSet), /// A full battlefield re-evaluation is required. Full, } @@ -2403,7 +2403,7 @@ impl LayersDirty { pub fn mark_entered(&mut self, id: ObjectId) { match self { Self::Full => {} - Self::Clean => *self = Self::EnteredObjects(HashSet::from([id])), + Self::Clean => *self = Self::EnteredObjects(BTreeSet::from([id])), Self::EnteredObjects(s) => { s.insert(id); } diff --git a/crates/engine/src/types/identifiers.rs b/crates/engine/src/types/identifiers.rs index 839cadf9bd..86dafd51a8 100644 --- a/crates/engine/src/types/identifiers.rs +++ b/crates/engine/src/types/identifiers.rs @@ -4,7 +4,9 @@ use serde::{Deserialize, Serialize}; #[serde(transparent)] pub struct CardId(pub u64); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] #[serde(transparent)] pub struct ObjectId(pub u64); diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 97a12d7102..9a03db0f9d 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -227,9 +227,9 @@ pub fn choose_action_with_session( // so the game never deadlocks waiting for the AI. return fallback_action(state); } - if config.execution_mode.is_measurement() { - scored.sort_by_cached_key(|(action, _)| action_order_key(action)); - } + // Issue #4878: total order before softmax so equal scores never depend on + // HashSet/HashMap allocation order. + scored.sort_by_cached_key(|(action, _)| action_order_key(action)); let chosen = if scored.len() == 1 { Some(scored[0].0.clone()) } else { @@ -1827,9 +1827,8 @@ fn score_candidates_core( _ => true, }) .collect(); - if config.execution_mode.is_measurement() { - gated.sort_by_cached_key(|g| action_order_key(&g.candidate.action)); - } + // Issue #4878: deterministic candidate order before scoring / search. + gated.sort_by_cached_key(|g| action_order_key(&g.candidate.action)); let actions: Vec = gated .iter() @@ -1987,9 +1986,7 @@ fn score_candidates_core( } let mut out = best_scored; - if config.execution_mode.is_measurement() { - out.sort_by_cached_key(|(action, _)| action_order_key(action)); - } + out.sort_by_cached_key(|(action, _)| action_order_key(action)); out } else { // Heuristic-only scoring @@ -2006,9 +2003,7 @@ fn score_candidates_core( (candidate.candidate.action, score) }) .collect(); - if config.execution_mode.is_measurement() { - out.sort_by_cached_key(|(action, _)| action_order_key(action)); - } + out.sort_by_cached_key(|(action, _)| action_order_key(action)); out } } @@ -2860,10 +2855,15 @@ pub fn softmax_select_pairs( let total: f64 = weights.iter().sum(); if total <= 0.0 || !total.is_finite() { - // Fallback: pick the highest-scored action + // Fallback: pick the highest-scored action (tie-break by action key — + // issue #4878). return scored .iter() - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) + .max_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| action_order_key(&a.0).cmp(&action_order_key(&b.0))) + }) .map(|s| s.0.clone()); } From f8756faa1840ce2eaf1c6e96709f026eb33d0e16 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Tue, 7 Jul 2026 17:00:11 +0000 Subject: [PATCH 2/7] fix: ci formatting failure --- crates/engine/src/game/casting.rs | 9 +++++---- crates/engine/src/game/layers.rs | 5 ++++- crates/engine/src/types/identifiers.rs | 4 +--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 18e8123372..bdc647a013 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -867,10 +867,11 @@ pub fn spell_objects_available_to_cast(state: &GameState, player: PlayerId) -> V // CR 117.1c: per-turn frequency is enforced inside the helper, not by // active-player gating, so the same logic covers the rare case of an // `Unlimited` printing on either player's turn. - let exile_permission_ids: BTreeSet = exile_objects_castable_by_permission(state, player) - .iter() - .map(|(obj_id, _source_id, _freq)| *obj_id) - .collect(); + let exile_permission_ids: BTreeSet = + exile_objects_castable_by_permission(state, player) + .iter() + .map(|(obj_id, _source_id, _freq)| *obj_id) + .collect(); objects.extend(exile_permission_ids); // CR 401.5 + CR 118.9 + CR 601.2a: Top card of library castable via a diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index ab5139d447..be0085ec0d 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3739,7 +3739,10 @@ fn apply_combat_assignment_rule_effects_filtered( /// affected objects. This is run AFTER all keyword grants/removals are applied, /// so the denial wins regardless of grant timestamp — the rules-correct "can't /// have" outcome (a concurrent anthem can't restore a denied keyword). -fn apply_cant_have_keyword_denials(state: &mut GameState, restrict_to: Option<&BTreeSet>) { +fn apply_cant_have_keyword_denials( + state: &mut GameState, + restrict_to: Option<&BTreeSet>, +) { // Collect (affected object, denied keyword) pairs under an immutable borrow, // then strip — avoids a borrow conflict with the per-object mutation. let mut denials: Vec<(ObjectId, Keyword)> = Vec::new(); diff --git a/crates/engine/src/types/identifiers.rs b/crates/engine/src/types/identifiers.rs index 86dafd51a8..63f6214014 100644 --- a/crates/engine/src/types/identifiers.rs +++ b/crates/engine/src/types/identifiers.rs @@ -4,9 +4,7 @@ use serde::{Deserialize, Serialize}; #[serde(transparent)] pub struct CardId(pub u64); -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ObjectId(pub u64); From 8c2f3f66cec086bbb852eb8cdcf7a3fdebb67294 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Tue, 7 Jul 2026 19:08:57 +0000 Subject: [PATCH 3/7] fix(engine, ai): replace Debug sort keys with typed cmp_stable ordering (#4878) --- crates/engine/src/ai_support/mod.rs | 65 +- crates/engine/src/game/combat.rs | 2 +- crates/engine/src/game/game_object.rs | 4 +- crates/engine/src/game/merge.rs | 4 +- crates/engine/src/types/ability.rs | 4 +- .../engine/src/types/action_stable_order.rs | 1045 +++++++++++++++++ crates/engine/src/types/actions.rs | 33 +- crates/engine/src/types/card_type.rs | 4 +- crates/engine/src/types/counter.rs | 2 +- crates/engine/src/types/game_state.rs | 26 +- crates/engine/src/types/identifiers.rs | 2 +- crates/engine/src/types/keywords.rs | 2 +- crates/engine/src/types/mana.rs | 4 +- crates/engine/src/types/match_config.rs | 2 +- crates/engine/src/types/mod.rs | 1 + crates/engine/src/types/phase.rs | 10 +- crates/engine/src/types/player.rs | 2 +- crates/engine/src/types/zones.rs | 2 +- crates/phase-ai/src/search.rs | 94 +- 19 files changed, 1252 insertions(+), 56 deletions(-) create mode 100644 crates/engine/src/types/action_stable_order.rs diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 920d1852f0..9efefabdc2 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -55,8 +55,9 @@ pub fn validated_candidate_actions_with_probe( let mut actions = pipeline.apply_with_probe(state, candidate_actions_with_probe(state, probe), probe); // Issue #4878: candidate enumeration must not depend on HashSet/HashMap - // iteration order leaking into AI tie-breaking downstream. - actions.sort_by_cached_key(|c| format!("{:?}", c.action)); + // iteration order leaking into AI tie-breaking downstream. Ordered via the + // allocation-free `GameAction::cmp_stable` total order (not `Debug` strings). + actions.sort_by(|a, b| a.action.cmp_stable(&b.action)); actions } @@ -4237,6 +4238,66 @@ mod tests { ); } + /// Issue #4878: `validated_candidate_actions` must return candidates in the + /// canonical `GameAction::cmp_stable` order, independent of the upstream + /// enumeration order (which walks `state.objects`, an `im::HashMap`, in + /// hash order — not sorted for this id set). Reverting the + /// `sort_by(cmp_stable)` guard returns the candidates in that hash order, + /// which differs from the canonical order below, flipping this assertion. + #[test] + fn validated_candidates_are_cmp_stable_sorted() { + let mut state = setup_priority(); + // CR 305.2 + CR 505: land drop available in the precombat main phase so + // each land in hand survives the simulation filter as a `PlayLand`. + state.phase = Phase::PreCombatMain; + state.lands_played_this_turn = 0; + for _ in 0..10 { + let id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Forest".to_string(), + Zone::Hand, + ); + state + .objects + .get_mut(&id) + .unwrap() + .card_types + .core_types + .push(CoreType::Land); + } + + let actions: Vec = validated_candidate_actions(&state) + .iter() + .map(|c| c.action.clone()) + .collect(); + + // Reach guard: the scenario must actually offer several distinct actions + // (multiple `PlayLand` + `PassPriority`), otherwise a single-element list + // would be trivially "sorted" and the assertion vacuous. + assert!( + actions.len() >= 3, + "expected several candidate actions to canonicalize, got {}", + actions.len() + ); + + // Canonical order: identical to an independent `cmp_stable` sort. + let mut expected = actions.clone(); + expected.sort_by(|a, b| a.cmp_stable(b)); + assert_eq!( + actions, expected, + "validated_candidate_actions must emit cmp_stable-canonical order" + ); + + // Determinism: a second call over the same state yields the same order. + let again: Vec = validated_candidate_actions(&state) + .iter() + .map(|c| c.action.clone()) + .collect(); + assert_eq!(actions, again, "candidate order must be deterministic"); + } + /// CR 117.3d: a matching priority yield for the top-of-stack trigger makes /// `auto_pass_recommended` return `true`, overriding the meaningful-action /// hold that would otherwise keep the window open. Reverting the yield short- diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index a4a6105309..34b1cca411 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -90,7 +90,7 @@ pub enum TrampleKind { } /// Represents who a creature is attacking: a player, planeswalker, or battle (CR 506.3). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum AttackTarget { Player(PlayerId), diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 8c702f94fb..2b359ddf17 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -219,7 +219,7 @@ pub struct CaseState { /// truth and lets exhaustive `match` arms force every consumer to handle both /// variants. Equipment-only call sites use `as_object()` with a CR-cited /// `expect` to assert the rules invariant. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum AttachTarget { /// CR 301.5 / CR 303.4f: attached to a permanent. @@ -260,7 +260,7 @@ impl From for AttachTarget { /// CR 709.5c: Which half, or door, of a shared-type-line split permanent is /// being locked or unlocked. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum RoomDoor { Left, Right, diff --git a/crates/engine/src/game/merge.rs b/crates/engine/src/game/merge.rs index d5e306c0ee..f8217043a9 100644 --- a/crates/engine/src/game/merge.rs +++ b/crates/engine/src/game/merge.rs @@ -65,7 +65,9 @@ use crate::types::zones::Zone; /// Serializes as the plain variant string ("Top" / "Bottom") so the frontend /// `GameAction::ChooseMutateMergeSide` payload is `{ side: "Top" | "Bottom" }`, /// parallel to the sibling `ChooseTopOrBottom { top: bool }`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] pub enum MergeSide { /// The mutating spell is placed on TOP of the target creature — the spell's /// card/token supplies the copiable characteristics. diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index bf48d6d1f1..01cace3b6c 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -4534,7 +4534,7 @@ pub enum TapStateChange { /// inexpressible boolean combination. Parameterizes the lock/unlock axis — both /// live within CR rule section 709.5 — into one effect variant instead of two /// sibling effects. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type")] pub enum DoorLockOp { /// CR 709.5f: choose a locked half and give it the unlocked designation. @@ -19013,7 +19013,7 @@ pub enum ContinuousModification { // --------------------------------------------------------------------------- /// Unified target reference for creatures and players. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum TargetRef { Object(ObjectId), Player(PlayerId), diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs new file mode 100644 index 0000000000..b4363e042d --- /dev/null +++ b/crates/engine/src/types/action_stable_order.rs @@ -0,0 +1,1045 @@ +// @generated-style: maintained by action enum structure; update when GameAction grows. +// +// Issue #4878: allocation-free total order for deterministic AI / legal-action +// sorting. Every payload field type used below derives `Ord`, so payload +// comparison reduces to `cmp_val` (a thin `Ord::cmp` wrapper) chained with +// `then_with`. The single exception is `GameAction::Debug`, whose payload +// (`DebugAction`) transitively contains non-`Ord` types (`Keyword`, +// `TokenCharacteristics`); it is a cold path (debug actions are never in +// `legal_actions()`) handled by the exhaustive `cmp_debug_action`. No `Debug` +// string formatting is used for ordering. +use std::cmp::Ordering; + +use super::ability::LibraryPosition; +use super::actions::{DebugAction, DebugTokenRequest, GameAction, GameActionKind}; + +/// Total, allocation-free order over `GameAction`: variant discriminant first +/// (`GameActionKind`, declaration order), then payload fields. +pub fn cmp_game_actions(a: &GameAction, b: &GameAction) -> Ordering { + GameActionKind::from(a) + .cmp(&GameActionKind::from(b)) + .then_with(|| cmp_payload(a, b)) +} + +/// Thin `Ord::cmp` wrapper. Works uniformly for scalars, `Option`, `Vec`, +/// and tuples because each such type is `Ord` when its elements are. +fn cmp_val(a: &T, b: &T) -> Ordering { + a.cmp(b) +} + +/// Compares payloads of two actions. `cmp_game_actions` only reaches this after +/// the discriminants compared `Equal`, so in practice `a` and `b` are always the +/// same variant; the trailing `_` arm is unreachable but required for +/// exhaustiveness and returns `Equal` (a safe total-order identity). +fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { + match (a, b) { + (GameAction::PassPriority, GameAction::PassPriority) => Ordering::Equal, + ( + GameAction::PlayLand { + object_id: a0, + card_id: a1, + }, + GameAction::PlayLand { + object_id: b0, + card_id: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::CastSpell { + object_id: a0, + card_id: a1, + targets: a2, + payment_mode: a3, + }, + GameAction::CastSpell { + object_id: b0, + card_id: b1, + targets: b2, + payment_mode: b3, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)), + ( + GameAction::Foretell { + object_id: a0, + card_id: a1, + }, + GameAction::Foretell { + object_id: b0, + card_id: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::ActivateAbility { + source_id: a0, + ability_index: a1, + }, + GameAction::ActivateAbility { + source_id: b0, + ability_index: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::DeclareAttackers { + attacks: a0, + bands: a1, + }, + GameAction::DeclareAttackers { + attacks: b0, + bands: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::DeclareBlockers { assignments: a0 }, + GameAction::DeclareBlockers { assignments: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseUntap { + object_id: a0, + untap: a1, + }, + GameAction::ChooseUntap { + object_id: b0, + untap: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + (GameAction::ChooseExert { exert: a0 }, GameAction::ChooseExert { exert: b0 }) => { + cmp_val(a0, b0) + } + (GameAction::ChooseEnlist { target: a0 }, GameAction::ChooseEnlist { target: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::ChooseClashOpponent { opponent: a0 }, + GameAction::ChooseClashOpponent { opponent: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseAssistPlayer { player: a0 }, + GameAction::ChooseAssistPlayer { player: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::CommitAssistPayment { generic: a0 }, + GameAction::CommitAssistPayment { generic: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::MulliganDecision { choice: a0 }, + GameAction::MulliganDecision { choice: b0 }, + ) => cmp_val(a0, b0), + (GameAction::ReorderHand { order: a0 }, GameAction::ReorderHand { order: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::TapLandForMana { object_id: a0 }, + GameAction::TapLandForMana { object_id: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::UntapLandForMana { object_id: a0 }, + GameAction::UntapLandForMana { object_id: b0 }, + ) => cmp_val(a0, b0), + (GameAction::SpendPoolMana { pip_id: a0 }, GameAction::SpendPoolMana { pip_id: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::UnspendPoolMana { pip_id: a0 }, + GameAction::UnspendPoolMana { pip_id: b0 }, + ) => cmp_val(a0, b0), + (GameAction::SelectCards { cards: a0 }, GameAction::SelectCards { cards: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::ChooseRemoveCounterCostDistribution { distribution: a0 }, + GameAction::ChooseRemoveCounterCostDistribution { distribution: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::SelectCoinFlips { keep_indices: a0 }, + GameAction::SelectCoinFlips { keep_indices: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseOutsideGameCards { selections: a0 }, + GameAction::ChooseOutsideGameCards { selections: b0 }, + ) => cmp_val(a0, b0), + (GameAction::SelectTargets { targets: a0 }, GameAction::SelectTargets { targets: b0 }) => { + cmp_val(a0, b0) + } + (GameAction::ChooseTarget { target: a0 }, GameAction::ChooseTarget { target: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::ChooseReplacement { index: a0 }, + GameAction::ChooseReplacement { index: b0 }, + ) => cmp_val(a0, b0), + (GameAction::OrderTriggers { order: a0 }, GameAction::OrderTriggers { order: b0 }) => { + cmp_val(a0, b0) + } + (GameAction::CancelCast, GameAction::CancelCast) => Ordering::Equal, + ( + GameAction::Equip { + equipment_id: a0, + target_id: a1, + }, + GameAction::Equip { + equipment_id: b0, + target_id: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::CrewVehicle { + vehicle_id: a0, + creature_ids: a1, + }, + GameAction::CrewVehicle { + vehicle_id: b0, + creature_ids: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::ActivateStation { + spacecraft_id: a0, + creature_id: a1, + }, + GameAction::ActivateStation { + spacecraft_id: b0, + creature_id: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::SaddleMount { + mount_id: a0, + creature_ids: a1, + }, + GameAction::SaddleMount { + mount_id: b0, + creature_ids: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + (GameAction::Transform { object_id: a0 }, GameAction::Transform { object_id: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::PlayFaceDown { + object_id: a0, + card_id: a1, + }, + GameAction::PlayFaceDown { + object_id: b0, + card_id: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + (GameAction::TurnFaceUp { object_id: a0 }, GameAction::TurnFaceUp { object_id: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::SubmitSideboard { + main: a0, + sideboard: a1, + }, + GameAction::SubmitSideboard { + main: b0, + sideboard: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::ChoosePlayDraw { play_first: a0 }, + GameAction::ChoosePlayDraw { play_first: b0 }, + ) => cmp_val(a0, b0), + (GameAction::ChooseOption { choice: a0 }, GameAction::ChooseOption { choice: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::SubmitVoteCandidate { + candidate_index: a0, + }, + GameAction::SubmitVoteCandidate { + candidate_index: b0, + }, + ) => cmp_val(a0, b0), + ( + GameAction::SubmitSpellbookDraft { card: a0 }, + GameAction::SubmitSpellbookDraft { card: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::SubmitPilePartition { pile_a: a0 }, + GameAction::SubmitPilePartition { pile_a: b0 }, + ) => cmp_val(a0, b0), + (GameAction::ChoosePile { pile: a0 }, GameAction::ChoosePile { pile: b0 }) => { + cmp_val(a0, b0) + } + (GameAction::ChooseBranch { index: a0 }, GameAction::ChooseBranch { index: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::ChooseDamageSource { source: a0 }, + GameAction::ChooseDamageSource { source: b0 }, + ) => cmp_val(a0, b0), + (GameAction::SelectModes { indices: a0 }, GameAction::SelectModes { indices: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::DecideOptionalCost { pay: a0 }, + GameAction::DecideOptionalCost { pay: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseAdventureFace { creature: a0 }, + GameAction::ChooseAdventureFace { creature: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseModalFace { back_face: a0 }, + GameAction::ChooseModalFace { back_face: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseAlternativeCast { choice: a0 }, + GameAction::ChooseAlternativeCast { choice: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseCastingVariant { index: a0 }, + GameAction::ChooseCastingVariant { index: b0 }, + ) => cmp_val(a0, b0), + (GameAction::KeepAllCopyTargets, GameAction::KeepAllCopyTargets) => Ordering::Equal, + ( + GameAction::ChoosePermanentTypeSlot { slot: a0 }, + GameAction::ChoosePermanentTypeSlot { slot: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ActivateNinjutsu { + ninjutsu_object_id: a0, + creature_to_return: a1, + }, + GameAction::ActivateNinjutsu { + ninjutsu_object_id: b0, + creature_to_return: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::CastSpellAsSneak { + hand_object: a0, + card_id: a1, + creature_to_return: a2, + payment_mode: a3, + }, + GameAction::CastSpellAsSneak { + hand_object: b0, + card_id: b1, + creature_to_return: b2, + payment_mode: b3, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)), + ( + GameAction::CastSpellAsWebSlinging { + hand_object: a0, + card_id: a1, + creature_to_return: a2, + payment_mode: a3, + }, + GameAction::CastSpellAsWebSlinging { + hand_object: b0, + card_id: b1, + creature_to_return: b2, + payment_mode: b3, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)), + ( + GameAction::CastSpellForFree { + object_id: a0, + card_id: a1, + source_id: a2, + payment_mode: a3, + }, + GameAction::CastSpellForFree { + object_id: b0, + card_id: b1, + source_id: b2, + payment_mode: b3, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)), + ( + GameAction::CastSpellAsMiracle { + object_id: a0, + card_id: a1, + payment_mode: a2, + }, + GameAction::CastSpellAsMiracle { + object_id: b0, + card_id: b1, + payment_mode: b2, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)), + ( + GameAction::CastSpellAsMadness { + object_id: a0, + card_id: a1, + payment_mode: a2, + }, + GameAction::CastSpellAsMadness { + object_id: b0, + card_id: b1, + payment_mode: b2, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)), + ( + GameAction::DecideOptionalEffect { accept: a0 }, + GameAction::DecideOptionalEffect { accept: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::RespondToSpliceOffer { card: a0 }, + GameAction::RespondToSpliceOffer { card: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::DecideOptionalEffectAndRemember { choice: a0 }, + GameAction::DecideOptionalEffectAndRemember { choice: b0 }, + ) => cmp_val(a0, b0), + (GameAction::PayUnlessCost { pay: a0 }, GameAction::PayUnlessCost { pay: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::ChooseUnlessCostBranch { choice: a0 }, + GameAction::ChooseUnlessCostBranch { choice: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseActivationCostBranch { index: a0 }, + GameAction::ChooseActivationCostBranch { index: b0 }, + ) => cmp_val(a0, b0), + (GameAction::PayCombatTax { accept: a0 }, GameAction::PayCombatTax { accept: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::ChooseRingBearer { target: a0 }, + GameAction::ChooseRingBearer { target: b0 }, + ) => cmp_val(a0, b0), + (GameAction::ChoosePair { partner: a0 }, GameAction::ChoosePair { partner: b0 }) => { + cmp_val(a0, b0) + } + (GameAction::ChooseDungeon { dungeon: a0 }, GameAction::ChooseDungeon { dungeon: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::ChooseDungeonRoom { room_index: a0 }, + GameAction::ChooseDungeonRoom { room_index: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::UnlockRoomDoor { + object_id: a0, + door: a1, + }, + GameAction::UnlockRoomDoor { + object_id: b0, + door: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + (GameAction::RollPlanarDie, GameAction::RollPlanarDie) => Ordering::Equal, + ( + GameAction::ChooseRoomDoor { + object_id: a0, + op: a1, + door: a2, + }, + GameAction::ChooseRoomDoor { + object_id: b0, + op: b1, + door: b2, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)), + ( + GameAction::TapForConvoke { + object_id: a0, + mana_type: a1, + }, + GameAction::TapForConvoke { + object_id: b0, + mana_type: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::HarmonizeTap { creature_id: a0 }, + GameAction::HarmonizeTap { creature_id: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::DeclareCompanion { card_index: a0 }, + GameAction::DeclareCompanion { card_index: b0 }, + ) => cmp_val(a0, b0), + (GameAction::CompanionToHand, GameAction::CompanionToHand) => Ordering::Equal, + (GameAction::DiscoverChoice { choice: a0 }, GameAction::DiscoverChoice { choice: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::GraveyardPaidCastChoice { choice: a0 }, + GameAction::GraveyardPaidCastChoice { choice: b0 }, + ) => cmp_val(a0, b0), + (GameAction::CascadeChoice { choice: a0 }, GameAction::CascadeChoice { choice: b0 }) => { + cmp_val(a0, b0) + } + (GameAction::RippleChoice { choice: a0 }, GameAction::RippleChoice { choice: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::FreeCastWindowChoice { selection: a0 }, + GameAction::FreeCastWindowChoice { selection: b0 }, + ) => cmp_val(a0, b0), + (GameAction::ChooseTopOrBottom { top: a0 }, GameAction::ChooseTopOrBottom { top: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::ChooseMutateMergeSide { side: a0 }, + GameAction::ChooseMutateMergeSide { side: b0 }, + ) => cmp_val(a0, b0), + (GameAction::CipherEncode { creature: a0 }, GameAction::CipherEncode { creature: b0 }) => { + cmp_val(a0, b0) + } + (GameAction::ChooseLegend { keep: a0 }, GameAction::ChooseLegend { keep: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::ChooseBattleProtector { protector: a0 }, + GameAction::ChooseBattleProtector { protector: b0 }, + ) => cmp_val(a0, b0), + (GameAction::SetAutoPass { mode: a0 }, GameAction::SetAutoPass { mode: b0 }) => { + cmp_val(a0, b0) + } + (GameAction::CancelAutoPass, GameAction::CancelAutoPass) => Ordering::Equal, + (GameAction::SetPhaseStops { stops: a0 }, GameAction::SetPhaseStops { stops: b0 }) => { + cmp_val(a0, b0) + } + (GameAction::SetPriorityYield { op: a0 }, GameAction::SetPriorityYield { op: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::AssignCombatDamage { + mode: a0, + assignments: a1, + trample_damage: a2, + controller_damage: a3, + }, + GameAction::AssignCombatDamage { + mode: b0, + assignments: b1, + trample_damage: b2, + controller_damage: b3, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)), + ( + GameAction::AssignBlockerDamage { assignments: a0 }, + GameAction::AssignBlockerDamage { assignments: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::DistributeAmong { distribution: a0 }, + GameAction::DistributeAmong { distribution: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseCounterMoveDistribution { selections: a0 }, + GameAction::ChooseCounterMoveDistribution { selections: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseCountersToRemove { selections: a0 }, + GameAction::ChooseCountersToRemove { selections: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::SubmitPayAmount { amount: a0 }, + GameAction::SubmitPayAmount { amount: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::RetargetSpell { new_targets: a0 }, + GameAction::RetargetSpell { new_targets: b0 }, + ) => cmp_val(a0, b0), + (GameAction::LearnDecision { choice: a0 }, GameAction::LearnDecision { choice: b0 }) => { + cmp_val(a0, b0) + } + ( + GameAction::SelectCategoryPermanents { choices: a0 }, + GameAction::SelectCategoryPermanents { choices: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseKeptCreatures { kept: a0 }, + GameAction::ChooseKeptCreatures { kept: b0 }, + ) => cmp_val(a0, b0), + (GameAction::ChooseX { value: a0 }, GameAction::ChooseX { value: b0 }) => cmp_val(a0, b0), + ( + GameAction::SubmitPhyrexianChoices { choices: a0 }, + GameAction::SubmitPhyrexianChoices { choices: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseManaColor { + choice: a0, + count: a1, + }, + GameAction::ChooseManaColor { + choice: b0, + count: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + GameAction::PayManaAbilityMana { payment: a0 }, + GameAction::PayManaAbilityMana { payment: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::CastPreparedCopy { source: a0 }, + GameAction::CastPreparedCopy { source: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::ChooseSpecializeColor { color: a0 }, + GameAction::ChooseSpecializeColor { color: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::CastParadigmCopy { source: a0 }, + GameAction::CastParadigmCopy { source: b0 }, + ) => cmp_val(a0, b0), + (GameAction::PassParadigmOffer, GameAction::PassParadigmOffer) => Ordering::Equal, + (GameAction::Debug(a0), GameAction::Debug(b0)) => cmp_debug_action(a0, b0), + ( + GameAction::GrantDebugPermission { player_id: a0 }, + GameAction::GrantDebugPermission { player_id: b0 }, + ) => cmp_val(a0, b0), + ( + GameAction::RevokeDebugPermission { player_id: a0 }, + GameAction::RevokeDebugPermission { player_id: b0 }, + ) => cmp_val(a0, b0), + (GameAction::Concede { player_id: a0 }, GameAction::Concede { player_id: b0 }) => { + cmp_val(a0, b0) + } + // Unreachable: `cmp_game_actions` only calls this after the discriminants + // compared `Equal`, which implies the same variant. `Equal` is the safe + // total-order identity for the (never-taken) mismatched-variant case. + _ => Ordering::Equal, + } +} + +/// Cold-path order over `DebugAction`. Debug actions never appear in +/// `legal_actions()` / AI candidate scoring, so this only needs to be total and +/// deterministic. It orders by variant (declaration order via +/// [`debug_action_rank`]) then by the `Ord`-comparable payload fields; `Keyword` +/// (not `Ord`) is compared through its `KeywordKind` discriminant, and +/// `TokenCharacteristics` through its scalar/`Ord` fields. +pub fn cmp_debug_action(a: &DebugAction, b: &DebugAction) -> Ordering { + debug_action_rank(a) + .cmp(&debug_action_rank(b)) + .then_with(|| cmp_debug_action_payload(a, b)) +} + +/// Declaration-order rank for a `DebugAction` variant. Exhaustive: adding a new +/// variant is a compile error here, forcing the ordering to be extended. +fn debug_action_rank(a: &DebugAction) -> u16 { + match a { + DebugAction::MoveToZone { .. } => 0, + DebugAction::CreateCard { .. } => 1, + DebugAction::RemoveObject { .. } => 2, + DebugAction::Sacrifice { .. } => 3, + DebugAction::DrawCards { .. } => 4, + DebugAction::Mill { .. } => 5, + DebugAction::Reveal { .. } => 6, + DebugAction::ShuffleLibrary { .. } => 7, + DebugAction::Proliferate { .. } => 8, + DebugAction::SetBasePowerToughness { .. } => 9, + DebugAction::ModifyCounters { .. } => 10, + DebugAction::SetTapped { .. } => 11, + DebugAction::SetPrepared { .. } => 12, + DebugAction::SetController { .. } => 13, + DebugAction::SetSummoningSickness { .. } => 14, + DebugAction::SetFaceState { .. } => 15, + DebugAction::Attach { .. } => 16, + DebugAction::Detach { .. } => 17, + DebugAction::GrantKeyword { .. } => 18, + DebugAction::RemoveKeyword { .. } => 19, + DebugAction::SetLife { .. } => 20, + DebugAction::ModifyPlayerCounters { .. } => 21, + DebugAction::ModifyEnergy { .. } => 22, + DebugAction::AddMana { .. } => 23, + DebugAction::SetInfiniteMana { .. } => 24, + DebugAction::SetPhase { .. } => 25, + DebugAction::RunStateBasedActions => 26, + DebugAction::CreateToken { .. } => 27, + DebugAction::CreateTokenCopy { .. } => 28, + } +} + +fn cmp_debug_action_payload(a: &DebugAction, b: &DebugAction) -> Ordering { + match (a, b) { + ( + DebugAction::MoveToZone { + object_id: a0, + to_zone: a1, + library_position: a2, + simulate: a3, + }, + DebugAction::MoveToZone { + object_id: b0, + to_zone: b1, + library_position: b2, + simulate: b3, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_opt_library_position(a2, b2)) + .then_with(|| cmp_val(a3, b3)), + ( + DebugAction::CreateCard { + card_name: a0, + owner: a1, + zone: a2, + attach_to: a3, + run_etb: a4, + }, + DebugAction::CreateCard { + card_name: b0, + owner: b1, + zone: b2, + attach_to: b3, + run_etb: b4, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)) + .then_with(|| cmp_val(a4, b4)), + ( + DebugAction::RemoveObject { object_id: a0 }, + DebugAction::RemoveObject { object_id: b0 }, + ) => cmp_val(a0, b0), + (DebugAction::Sacrifice { object_id: a0 }, DebugAction::Sacrifice { object_id: b0 }) => { + cmp_val(a0, b0) + } + ( + DebugAction::DrawCards { + player_id: a0, + count: a1, + }, + DebugAction::DrawCards { + player_id: b0, + count: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::Mill { + player_id: a0, + count: a1, + }, + DebugAction::Mill { + player_id: b0, + count: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::Reveal { + player_id: a0, + count: a1, + }, + DebugAction::Reveal { + player_id: b0, + count: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::ShuffleLibrary { player_id: a0 }, + DebugAction::ShuffleLibrary { player_id: b0 }, + ) => cmp_val(a0, b0), + ( + DebugAction::Proliferate { player_id: a0 }, + DebugAction::Proliferate { player_id: b0 }, + ) => cmp_val(a0, b0), + ( + DebugAction::SetBasePowerToughness { + object_id: a0, + power: a1, + toughness: a2, + }, + DebugAction::SetBasePowerToughness { + object_id: b0, + power: b1, + toughness: b2, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)), + ( + DebugAction::ModifyCounters { + object_id: a0, + counter_type: a1, + delta: a2, + }, + DebugAction::ModifyCounters { + object_id: b0, + counter_type: b1, + delta: b2, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)), + ( + DebugAction::SetTapped { + object_id: a0, + tapped: a1, + }, + DebugAction::SetTapped { + object_id: b0, + tapped: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::SetPrepared { + object_id: a0, + prepared: a1, + }, + DebugAction::SetPrepared { + object_id: b0, + prepared: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::SetController { + object_id: a0, + controller: a1, + }, + DebugAction::SetController { + object_id: b0, + controller: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::SetSummoningSickness { + object_id: a0, + sick: a1, + }, + DebugAction::SetSummoningSickness { + object_id: b0, + sick: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::SetFaceState { + object_id: a0, + face_down: a1, + transformed: a2, + flipped: a3, + }, + DebugAction::SetFaceState { + object_id: b0, + face_down: b1, + transformed: b2, + flipped: b3, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)), + ( + DebugAction::Attach { + object_id: a0, + target: a1, + }, + DebugAction::Attach { + object_id: b0, + target: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + (DebugAction::Detach { object_id: a0 }, DebugAction::Detach { object_id: b0 }) => { + cmp_val(a0, b0) + } + ( + DebugAction::GrantKeyword { + object_id: a0, + keyword: a1, + }, + DebugAction::GrantKeyword { + object_id: b0, + keyword: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(&a1.kind(), &b1.kind())), + ( + DebugAction::RemoveKeyword { + object_id: a0, + keyword: a1, + }, + DebugAction::RemoveKeyword { + object_id: b0, + keyword: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(&a1.kind(), &b1.kind())), + ( + DebugAction::SetLife { + player_id: a0, + life: a1, + }, + DebugAction::SetLife { + player_id: b0, + life: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::ModifyPlayerCounters { + player_id: a0, + counter_kind: a1, + delta: a2, + }, + DebugAction::ModifyPlayerCounters { + player_id: b0, + counter_kind: b1, + delta: b2, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)), + ( + DebugAction::ModifyEnergy { + player_id: a0, + delta: a1, + }, + DebugAction::ModifyEnergy { + player_id: b0, + delta: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::AddMana { + player_id: a0, + mana: a1, + }, + DebugAction::AddMana { + player_id: b0, + mana: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::SetInfiniteMana { + player_id: a0, + enabled: a1, + }, + DebugAction::SetInfiniteMana { + player_id: b0, + enabled: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::SetPhase { + phase: a0, + active_player: a1, + }, + DebugAction::SetPhase { + phase: b0, + active_player: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + (DebugAction::RunStateBasedActions, DebugAction::RunStateBasedActions) => Ordering::Equal, + ( + DebugAction::CreateToken { + request: a0, + run_etb: a1, + }, + DebugAction::CreateToken { + request: b0, + run_etb: b1, + }, + ) => cmp_debug_token_request(a0, b0).then_with(|| cmp_val(a1, b1)), + ( + DebugAction::CreateTokenCopy { + source_id: a0, + owner: a1, + }, + DebugAction::CreateTokenCopy { + source_id: b0, + owner: b1, + }, + ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), + // Unreachable: reached only after `debug_action_rank` compared `Equal`, + // which implies the same variant. + _ => Ordering::Equal, + } +} + +/// Cold-path order over `Option`. `LibraryPosition` cannot +/// derive `Ord` because `BeneathTop { depth }` carries a non-`Ord` +/// `QuantityExpr`; two `BeneathTop` values therefore tie-break as `Equal` +/// (sufficient for this cold debug path). +fn cmp_opt_library_position(a: &Option, b: &Option) -> Ordering { + match (a, b) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (Some(a), Some(b)) => cmp_library_position(a, b), + } +} + +fn cmp_library_position(a: &LibraryPosition, b: &LibraryPosition) -> Ordering { + fn rank(p: &LibraryPosition) -> u8 { + match p { + LibraryPosition::Top => 0, + LibraryPosition::Bottom => 1, + LibraryPosition::NthFromTop { .. } => 2, + LibraryPosition::BeneathTop { .. } => 3, + } + } + rank(a).cmp(&rank(b)).then_with(|| match (a, b) { + (LibraryPosition::NthFromTop { n: a0 }, LibraryPosition::NthFromTop { n: b0 }) => { + cmp_val(a0, b0) + } + // `BeneathTop` carries a non-`Ord` `QuantityExpr`; equal-rank fallback. + _ => Ordering::Equal, + }) +} + +/// Cold-path order over `DebugTokenRequest`. `Preset` sorts before `Custom`; +/// within each, orders by owner then the `Ord`-comparable fields. The +/// non-`Ord` `TokenCharacteristics` is compared only through its +/// `display_name` (sufficient for a deterministic total order on this cold path). +fn cmp_debug_token_request(a: &DebugTokenRequest, b: &DebugTokenRequest) -> Ordering { + fn rank(r: &DebugTokenRequest) -> u8 { + match r { + DebugTokenRequest::Preset { .. } => 0, + DebugTokenRequest::Custom { .. } => 1, + } + } + rank(a).cmp(&rank(b)).then_with(|| match (a, b) { + ( + DebugTokenRequest::Preset { + preset_id: a0, + owner: a1, + power_override: a2, + toughness_override: a3, + enter_with_counters: a4, + }, + DebugTokenRequest::Preset { + preset_id: b0, + owner: b1, + power_override: b2, + toughness_override: b3, + enter_with_counters: b4, + }, + ) => cmp_val(a1, b1) + .then_with(|| cmp_val(a0, b0)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)) + .then_with(|| cmp_val(a4, b4)), + ( + DebugTokenRequest::Custom { + owner: a0, + characteristics: a1, + enter_with_counters: a2, + }, + DebugTokenRequest::Custom { + owner: b0, + characteristics: b1, + enter_with_counters: b2, + }, + ) => cmp_val(a0, b0) + .then_with(|| cmp_val(&a1.display_name, &b1.display_name)) + .then_with(|| cmp_val(a2, b2)), + // Unreachable: reached only after `rank` compared `Equal`. + _ => Ordering::Equal, + }) +} diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index 75f7d20dbb..69646f6df0 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -21,7 +21,7 @@ use crate::game::game_object::AttachTarget; /// Bool flags are not composable — this enum can grow new branches (e.g., /// "Cast face-down", "Put into hand" already exists for Discover) without /// changing call sites that already exhaustively match. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type")] pub enum CastChoice { /// CR 701.57a + CR 702.85a: Cast the offered card without paying its mana @@ -47,7 +47,7 @@ pub enum CastChoice { /// Only available when `object_id` references a card named "Serum Powder" in /// the actor's hand (CR 103.5b and Serum Powder Oracle text). The player /// remains pending and may keep, mulligan, or use another Serum Powder next. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum MulliganChoice { Keep, @@ -68,7 +68,7 @@ pub enum MulliganChoice { /// state, not on this action — the decision is structurally identical across /// keywords; only post-payment semantics diverge (per CR 702.74a Evoke, /// CR 702.96a Overload, CR 702.103a Bestow, and the custom Warp keyword). -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AlternativeCastDecision { /// Pay the spell's printed mana cost. Resolution proceeds normally. @@ -86,7 +86,7 @@ pub enum AlternativeCastDecision { /// `Pay { index }` selects the sub-cost by its position in /// `WaitingFor::UnlessPaymentChooseCost::costs` and routes back into the /// standard single-cost `handle_unless_payment` path. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum UnlessCostBranch { Decline, @@ -96,7 +96,7 @@ pub enum UnlessCostBranch { /// CR 400.11 + CR 406.3: One discriminated selection committed for an /// outside-game choice. The two source pools (sideboard and face-up exile) are /// expressed as parallel variants so the action wire format is uniform. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum OutsideGameSelection { /// CR 400.11a: A copy from the player's sideboard, identified by its slot. @@ -105,8 +105,15 @@ pub enum OutsideGameSelection { FaceUpExile { object_id: ObjectId }, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, strum::IntoStaticStr)] +#[derive( + Debug, Clone, PartialEq, Serialize, Deserialize, strum::IntoStaticStr, strum::EnumDiscriminants, +)] #[serde(tag = "type", content = "data")] +// Issue #4878: `GameActionKind` is the allocation-free discriminant used by +// `GameAction::cmp_stable` to order actions by variant before comparing +// payloads, so deterministic AI/legal-action sorting never depends on +// `HashSet`/`HashMap` iteration order (previously ordered via `Debug` strings). +#[strum_discriminants(name(GameActionKind), derive(PartialOrd, Ord))] pub enum GameAction { PassPriority, PlayLand { @@ -777,7 +784,7 @@ pub enum GameAction { /// reading the identity latched on that source's trigger (CR 400.7), so the /// frontend never constructs an incarnation or card id. `Remove` echoes a /// stored `YieldTarget` verbatim; `ClearAll` drops every yield for the actor. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum PriorityYieldOp { Add { @@ -791,7 +798,7 @@ pub enum PriorityYieldOp { } /// CR 701.48a: Learn choice — rummage a specific card, or skip entirely. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum LearnOption { /// Discard the specified card, then draw one. @@ -1283,6 +1290,16 @@ impl GameAction { self.into() } + /// Issue #4878: allocation-free total order over `GameAction`, used for + /// deterministic AI candidate / legal-action sorting. Orders by the + /// `GameActionKind` discriminant first, then by payload fields, so equal + /// scores never depend on `HashSet`/`HashMap` allocation-order iteration. + /// Replaces the previous `format!("{:?}", action)` sort keys — no `Debug` + /// formatting is used for ordering. + pub fn cmp_stable(&self, other: &Self) -> std::cmp::Ordering { + super::action_stable_order::cmp_game_actions(self, other) + } + /// CR 605.3a: Whether this action is a mana ability activation. /// /// Mana abilities are excluded from the flat `legal_actions()` result diff --git a/crates/engine/src/types/card_type.rs b/crates/engine/src/types/card_type.rs index ca66978a18..941d6e585a 100644 --- a/crates/engine/src/types/card_type.rs +++ b/crates/engine/src/types/card_type.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// CR 205.4: Supertypes — Legendary, Basic, Snow, World, Ongoing, plus /// supplemental set-specific supertypes such as Host. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum Supertype { Legendary, Basic, @@ -45,7 +45,7 @@ impl fmt::Display for Supertype { } /// CR 205.2a: Card types — the seven main types plus additional types. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum CoreType { /// CR 301: Artifacts — permanents cast at sorcery speed, with subtypes Equipment, Vehicle, etc. Artifact, diff --git a/crates/engine/src/types/counter.rs b/crates/engine/src/types/counter.rs index 6cbd505f12..b2acc23dfa 100644 --- a/crates/engine/src/types/counter.rs +++ b/crates/engine/src/types/counter.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; /// Counter types serialize as flat strings so they can be used as JSON map keys /// in `HashMap`. Without this, `Generic("quest")` would serialize /// as `{"Generic":"quest"}` which serde_json rejects as a map key. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum CounterType { Plus1Plus1, Minus1Minus1, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 2b8f4c7642..16dc715d56 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -669,7 +669,7 @@ pub struct BattlefieldEntryRecord { pub controller: PlayerId, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AutoMayChoice { Accept, @@ -704,7 +704,7 @@ pub struct MayTriggerAutoChoiceRecord { /// `AllCopies` yields for every trigger from any object sharing the source's /// card identity, so it keeps matching after a token source ceases to exist /// (CR 704.5d) and matches newly created copies. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum YieldScope { ThisObject, AllCopies, @@ -719,7 +719,7 @@ pub enum YieldScope { /// current" convention) — synthetic game-rule triggers cannot be yielded this /// way. `AllCopies` binds a `CardId`: any trigger whose `source_card_id` equals /// it matches, regardless of which object (or whether the object still exists). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum YieldTarget { ThisObject { source_id: ObjectId, @@ -1389,14 +1389,14 @@ pub struct PendingVoteBallotIteration { pub controller: PlayerId, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct CounterMoveChoice { pub destination_id: ObjectId, pub counter_type: CounterType, pub count: u32, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct CounterCostChoice { pub object_id: ObjectId, pub counter_type: CounterType, @@ -1408,7 +1408,7 @@ pub struct CounterCostChoice { /// Unlike [`CounterCostChoice`], there is no `object_id`: the removal source is /// the single object fixed by the effect (the ability's target or `SelfRef`), /// so the client only chooses which counter types and how many of each to shed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct CounterRemoveChoice { pub counter_type: CounterType, pub count: u32, @@ -1925,7 +1925,7 @@ fn default_copy_retarget_effect_kind() -> EffectKind { /// CR 601.2g-h: Whether the engine may auto-pay an unambiguous spell mana cost /// or must pause after announcement so the player can activate mana abilities /// manually before committing payment. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] #[serde(tag = "type")] pub enum CastPaymentMode { #[default] @@ -2256,7 +2256,7 @@ pub enum ManaChoicePrompt { /// CR 608.2d + CR 605.3b: Player's answer to a `ManaChoicePrompt`, carried by /// `GameAction::ChooseManaColor`. Shape mirrors the prompt variant. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum ManaChoice { SingleColor(ManaType), @@ -2534,7 +2534,7 @@ pub enum ShardOptions { } /// CR 107.4f + CR 601.2f: The caster's resolved choice for one Phyrexian shard. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ShardChoice { /// Pay one mana of the shard's color (or either component color for hybrid-Phyrexian). @@ -2808,7 +2808,7 @@ impl VoteActor { /// self-documenting domain and the parser/AI cannot accidentally swap /// pile semantics. Pile A is the partitioner's chosen subset; pile B is /// `eligible \ pile_a`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type")] pub enum PileSide { A, @@ -4886,7 +4886,7 @@ pub struct CopyTargetSlot { /// CR 510.1c: Optional combat-damage assignment mode for attackers with text like /// "you may have this creature assign its combat damage as though it weren't blocked." -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] pub enum CombatDamageAssignmentMode { #[default] Normal, @@ -5413,7 +5413,7 @@ impl WaitingFor { /// /// The session owner is compared against the active player (CR 102.1) at each /// turn start (CR 500.1) to decide whether the session survives the boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] pub enum TurnBoundary { /// Clears at the next turn start (i.e. the end of the session owner's /// current turn), regardless of whose turn begins. Legacy behavior; the @@ -5432,7 +5432,7 @@ pub enum TurnBoundary { /// `GameAction::SetPhaseStops`. Keeping them out of the request preserves a /// single source of truth and lets the preference change mid-session without /// requiring a new auto-pass request. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AutoPassRequest { UntilStackEmpty, diff --git a/crates/engine/src/types/identifiers.rs b/crates/engine/src/types/identifiers.rs index 63f6214014..d875a27d35 100644 --- a/crates/engine/src/types/identifiers.rs +++ b/crates/engine/src/types/identifiers.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct CardId(pub u64); diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index e381af58d2..86c228f1c0 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -136,7 +136,7 @@ pub enum EscapeCost { /// Discriminant-level keyword identity used when the Oracle text refers to a keyword class /// without caring about its parameter payload. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum KeywordKind { Flying, FirstStrike, diff --git a/crates/engine/src/types/mana.rs b/crates/engine/src/types/mana.rs index 2bd25d3373..2b9f0ce06a 100644 --- a/crates/engine/src/types/mana.rs +++ b/crates/engine/src/types/mana.rs @@ -9,7 +9,7 @@ use super::keywords::{Keyword, KeywordKind}; use super::player::PlayerId; use super::zones::Zone; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum ManaColor { White, Blue, @@ -899,7 +899,7 @@ pub mod snow_compat { /// player can direct (pin) which specific unit pays a pending cost. The sentinel /// `ManaPipId(0)` marks an unstamped unit (convoke markers, detached preview /// pools) and never matches a real pin. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ManaPipId(pub u64); #[derive(Debug, Clone, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/types/match_config.rs b/crates/engine/src/types/match_config.rs index c2b8ec85fe..f19adf7853 100644 --- a/crates/engine/src/types/match_config.rs +++ b/crates/engine/src/types/match_config.rs @@ -44,7 +44,7 @@ pub struct MatchScore { pub draws: u8, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct DeckCardCount { pub name: String, pub count: u32, diff --git a/crates/engine/src/types/mod.rs b/crates/engine/src/types/mod.rs index 75772a0364..adc4dd486c 100644 --- a/crates/engine/src/types/mod.rs +++ b/crates/engine/src/types/mod.rs @@ -1,4 +1,5 @@ pub mod ability; +pub mod action_stable_order; pub mod actions; pub mod attribution; pub mod card; diff --git a/crates/engine/src/types/phase.rs b/crates/engine/src/types/phase.rs index 9b63e2857b..7753c1a4ec 100644 --- a/crates/engine/src/types/phase.rs +++ b/crates/engine/src/types/phase.rs @@ -6,7 +6,9 @@ use serde::{Deserialize, Serialize}; /// A turn consists of five phases: beginning, precombat main, combat, /// postcombat main, and ending. The beginning, combat, and ending phases /// are further broken down into steps. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] pub enum Phase { // --- Beginning phase (CR 501.1): untap, upkeep, draw --- /// CR 502: Untap step. No player receives priority (CR 502.4). @@ -79,7 +81,9 @@ impl Phase { /// turns a stop fires, by comparing the stop's owner against the active player. /// /// CR 102.1: The active player is the player whose turn it is. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, +)] pub enum PhaseStopScope { /// Fire on every turn (legacy behavior; the migration default). #[default] @@ -95,7 +99,7 @@ pub enum PhaseStopScope { /// Backward compatibility: older persisted/serialized stops were a bare `Phase` /// string. `#[serde(from = "PhaseStopCompat")]` accepts both the legacy bare /// string (→ `AllTurns`) and the new `{ phase, scope }` object form. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(from = "PhaseStopCompat")] pub struct PhaseStop { pub phase: Phase, diff --git a/crates/engine/src/types/player.rs b/crates/engine/src/types/player.rs index f219803038..299a6d6f33 100644 --- a/crates/engine/src/types/player.rs +++ b/crates/engine/src/types/player.rs @@ -51,7 +51,7 @@ impl PlayerStatus { /// CR 122.1b: Named player counter types tracked by the engine. /// Poison counters route to the dedicated `poison_counters` field due to SBA rules (CR 704.5c). /// Energy counters are excluded — they use the dedicated `energy` field and `GainEnergy` effect. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum PlayerCounterKind { Poison, Experience, diff --git a/crates/engine/src/types/zones.rs b/crates/engine/src/types/zones.rs index 372bab60ab..692dbe7467 100644 --- a/crates/engine/src/types/zones.rs +++ b/crates/engine/src/types/zones.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; /// CR 400.1: The seven game zones — library, hand, battlefield, graveyard, stack, exile, and command. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum Zone { /// CR 401: The library — a player's draw pile, face-down, order matters. Library, diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 9a03db0f9d..71a64feb40 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -229,7 +229,7 @@ pub fn choose_action_with_session( } // Issue #4878: total order before softmax so equal scores never depend on // HashSet/HashMap allocation order. - scored.sort_by_cached_key(|(action, _)| action_order_key(action)); + scored.sort_by(|a, b| a.0.cmp_stable(&b.0)); let chosen = if scored.len() == 1 { Some(scored[0].0.clone()) } else { @@ -1828,7 +1828,7 @@ fn score_candidates_core( }) .collect(); // Issue #4878: deterministic candidate order before scoring / search. - gated.sort_by_cached_key(|g| action_order_key(&g.candidate.action)); + gated.sort_by(|a, b| a.candidate.action.cmp_stable(&b.candidate.action)); let actions: Vec = gated .iter() @@ -1906,10 +1906,7 @@ fn score_candidates_core( b.score .partial_cmp(&a.score) .unwrap_or(Ordering::Equal) - .then_with(|| { - action_order_key(&a.candidate.action) - .cmp(&action_order_key(&b.candidate.action)) - }) + .then_with(|| a.candidate.action.cmp_stable(&b.candidate.action)) }); ranked.truncate(branching); @@ -1986,7 +1983,7 @@ fn score_candidates_core( } let mut out = best_scored; - out.sort_by_cached_key(|(action, _)| action_order_key(action)); + out.sort_by(|a, b| a.0.cmp_stable(&b.0)); out } else { // Heuristic-only scoring @@ -2003,15 +2000,11 @@ fn score_candidates_core( (candidate.candidate.action, score) }) .collect(); - out.sort_by_cached_key(|(action, _)| action_order_key(action)); + out.sort_by(|a, b| a.0.cmp_stable(&b.0)); out } } -fn action_order_key(action: &GameAction) -> String { - format!("{action:?}") -} - /// Build AI context from the player's deck pool, or a neutral default if unavailable. fn build_ai_context_with_session( state: &GameState, @@ -2862,7 +2855,7 @@ pub fn softmax_select_pairs( .max_by(|a, b| { a.1.partial_cmp(&b.1) .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| action_order_key(&a.0).cmp(&action_order_key(&b.0))) + .then_with(|| a.0.cmp_stable(&b.0)) }) .map(|s| s.0.clone()); } @@ -2917,6 +2910,79 @@ mod tests { state } + /// Issue #4878: the degenerate-weight fallback in `softmax_select_pairs` + /// must break score ties with `GameAction::cmp_stable`, not fall back to the + /// input-list order. Here every score is `-inf` (weights become `NaN`, so + /// the fallback branch runs). `PassPriority` (discriminant 0) sorts before + /// `PlayLand` (discriminant 1), so the `cmp_stable`-maximum is the `PlayLand` + /// listed FIRST. Removing the `then_with(cmp_stable)` tie-break makes + /// `max_by` return the last equally-maximal element (`PassPriority`) instead, + /// flipping this assertion. + #[test] + fn softmax_fallback_tiebreak_is_cmp_stable_deterministic() { + let scored = vec![ + ( + GameAction::PlayLand { + object_id: ObjectId(5), + card_id: CardId(1), + }, + f64::NEG_INFINITY, + ), + (GameAction::PassPriority, f64::NEG_INFINITY), + ]; + // Reach guard: `PlayLand` must outrank `PassPriority` under cmp_stable so + // the expected pick is the first (non-last) element, distinguishing the + // tie-break from `max_by`'s last-on-ties behavior. + assert_eq!( + scored[0].0.cmp_stable(&scored[1].0), + std::cmp::Ordering::Greater, + "precondition: PlayLand > PassPriority under cmp_stable" + ); + + let mut rng = SmallRng::seed_from_u64(0); + let chosen = softmax_select_pairs(&scored, 1.0, &mut rng) + .expect("non-empty scored list must select an action"); + assert_eq!( + chosen, scored[0].0, + "degenerate-weight fallback must pick the cmp_stable-max action" + ); + } + + /// Issue #4878: the candidate sort was previously gated behind measurement + /// mode. A *normal* (non-measurement) config must still emit candidates in + /// the canonical `cmp_stable` order. Reverting the always-on + /// `out.sort_by(cmp_stable)` returns candidates in score / enumeration order, + /// which is not `cmp_stable`-sorted for this set, flipping the assertion. + #[test] + fn score_candidates_non_measurement_order_is_cmp_stable_canonical() { + let mut state = make_state(); + add_mana(&mut state, PlayerId(0), ManaType::Colorless, 6); + add_spell_to_hand(&mut state, PlayerId(0), "SpellA", 1); + add_spell_to_hand(&mut state, PlayerId(0), "SpellB", 2); + add_spell_to_hand(&mut state, PlayerId(0), "SpellC", 3); + // Normal config: NOT measurement mode (the guard this test protects only + // ever sorted under measurement before #4878). + let config = create_config(AiDifficulty::Hard, Platform::Native); + let session = AiSession::arc_from_game(&state); + + let scored = score_candidates_with_session(&state, PlayerId(0), &config, &session); + let actions: Vec = scored.iter().map(|(a, _)| a.clone()).collect(); + // Reach guard: several distinct candidates (3 castable spells + Pass) + // so the order is non-trivial. + assert!( + actions.len() >= 3, + "expected several scored candidates, got {}", + actions.len() + ); + + let mut expected = actions.clone(); + expected.sort_by(|a, b| a.cmp_stable(b)); + assert_eq!( + actions, expected, + "non-measurement scoring must emit cmp_stable-canonical order" + ); + } + fn add_creature( state: &mut GameState, owner: PlayerId, @@ -5353,7 +5419,7 @@ mod tests { } fn sorted_by_action(mut scored: Vec<(GameAction, f64)>) -> Vec<(GameAction, f64)> { - scored.sort_by_cached_key(|(action, _)| action_order_key(action)); + scored.sort_by(|a, b| a.0.cmp_stable(&b.0)); scored } From cfb7513a112dce73be3595951b93ac67adfe7ab6 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Tue, 7 Jul 2026 19:49:31 +0000 Subject: [PATCH 4/7] fix(engine): make cmp_payload exhaustive over GameAction variants --- .../engine/src/types/action_stable_order.rs | 1861 ++++++++++------- 1 file changed, 1154 insertions(+), 707 deletions(-) diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index b4363e042d..9f13964b02 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -27,597 +27,955 @@ fn cmp_val(a: &T, b: &T) -> Ordering { a.cmp(b) } -/// Compares payloads of two actions. `cmp_game_actions` only reaches this after -/// the discriminants compared `Equal`, so in practice `a` and `b` are always the -/// same variant; the trailing `_` arm is unreachable but required for -/// exhaustiveness and returns `Equal` (a safe total-order identity). +/// Compares payloads of two actions with the same discriminant. The outer match +/// on `a` is exhaustive over `GameAction` — a new variant is a compile error +/// until its payload comparison is wired. Mismatched variants `unreachable!`. +/// `cmp_game_actions` only calls this after discriminants compared `Equal`. fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { - match (a, b) { - (GameAction::PassPriority, GameAction::PassPriority) => Ordering::Equal, - ( - GameAction::PlayLand { - object_id: a0, - card_id: a1, - }, - GameAction::PlayLand { + match a { + GameAction::PassPriority => { + let GameAction::PassPriority = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal + } + GameAction::PlayLand { + object_id: a0, + card_id: a1, + } => { + let GameAction::PlayLand { object_id: b0, card_id: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::CastSpell { - object_id: a0, - card_id: a1, - targets: a2, - payment_mode: a3, - }, - GameAction::CastSpell { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::CastSpell { + object_id: a0, + card_id: a1, + targets: a2, + payment_mode: a3, + } => { + let GameAction::CastSpell { object_id: b0, card_id: b1, targets: b2, payment_mode: b3, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)) - .then_with(|| cmp_val(a3, b3)), - ( - GameAction::Foretell { - object_id: a0, - card_id: a1, - }, - GameAction::Foretell { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)) + } + GameAction::Foretell { + object_id: a0, + card_id: a1, + } => { + let GameAction::Foretell { object_id: b0, card_id: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::ActivateAbility { - source_id: a0, - ability_index: a1, - }, - GameAction::ActivateAbility { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::ActivateAbility { + source_id: a0, + ability_index: a1, + } => { + let GameAction::ActivateAbility { source_id: b0, ability_index: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::DeclareAttackers { - attacks: a0, - bands: a1, - }, - GameAction::DeclareAttackers { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::DeclareAttackers { + attacks: a0, + bands: a1, + } => { + let GameAction::DeclareAttackers { attacks: b0, bands: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::DeclareBlockers { assignments: a0 }, - GameAction::DeclareBlockers { assignments: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseUntap { - object_id: a0, - untap: a1, - }, - GameAction::ChooseUntap { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::DeclareBlockers { assignments: a0 } => { + let GameAction::DeclareBlockers { assignments: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseUntap { + object_id: a0, + untap: a1, + } => { + let GameAction::ChooseUntap { object_id: b0, untap: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - (GameAction::ChooseExert { exert: a0 }, GameAction::ChooseExert { exert: b0 }) => { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::ChooseExert { exert: a0 } => { + let GameAction::ChooseExert { exert: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseEnlist { target: a0 } => { + let GameAction::ChooseEnlist { target: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseClashOpponent { opponent: a0 } => { + let GameAction::ChooseClashOpponent { opponent: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - (GameAction::ChooseEnlist { target: a0 }, GameAction::ChooseEnlist { target: b0 }) => { + GameAction::ChooseAssistPlayer { player: a0 } => { + let GameAction::ChooseAssistPlayer { player: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::ChooseClashOpponent { opponent: a0 }, - GameAction::ChooseClashOpponent { opponent: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseAssistPlayer { player: a0 }, - GameAction::ChooseAssistPlayer { player: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::CommitAssistPayment { generic: a0 }, - GameAction::CommitAssistPayment { generic: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::MulliganDecision { choice: a0 }, - GameAction::MulliganDecision { choice: b0 }, - ) => cmp_val(a0, b0), - (GameAction::ReorderHand { order: a0 }, GameAction::ReorderHand { order: b0 }) => { + GameAction::CommitAssistPayment { generic: a0 } => { + let GameAction::CommitAssistPayment { generic: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::TapLandForMana { object_id: a0 }, - GameAction::TapLandForMana { object_id: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::UntapLandForMana { object_id: a0 }, - GameAction::UntapLandForMana { object_id: b0 }, - ) => cmp_val(a0, b0), - (GameAction::SpendPoolMana { pip_id: a0 }, GameAction::SpendPoolMana { pip_id: b0 }) => { + GameAction::MulliganDecision { choice: a0 } => { + let GameAction::MulliganDecision { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::UnspendPoolMana { pip_id: a0 }, - GameAction::UnspendPoolMana { pip_id: b0 }, - ) => cmp_val(a0, b0), - (GameAction::SelectCards { cards: a0 }, GameAction::SelectCards { cards: b0 }) => { + GameAction::ReorderHand { order: a0 } => { + let GameAction::ReorderHand { order: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::TapLandForMana { object_id: a0 } => { + let GameAction::TapLandForMana { object_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::ChooseRemoveCounterCostDistribution { distribution: a0 }, - GameAction::ChooseRemoveCounterCostDistribution { distribution: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::SelectCoinFlips { keep_indices: a0 }, - GameAction::SelectCoinFlips { keep_indices: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseOutsideGameCards { selections: a0 }, - GameAction::ChooseOutsideGameCards { selections: b0 }, - ) => cmp_val(a0, b0), - (GameAction::SelectTargets { targets: a0 }, GameAction::SelectTargets { targets: b0 }) => { + GameAction::UntapLandForMana { object_id: a0 } => { + let GameAction::UntapLandForMana { object_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - (GameAction::ChooseTarget { target: a0 }, GameAction::ChooseTarget { target: b0 }) => { + GameAction::SpendPoolMana { pip_id: a0 } => { + let GameAction::SpendPoolMana { pip_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::UnspendPoolMana { pip_id: a0 } => { + let GameAction::UnspendPoolMana { pip_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::ChooseReplacement { index: a0 }, - GameAction::ChooseReplacement { index: b0 }, - ) => cmp_val(a0, b0), - (GameAction::OrderTriggers { order: a0 }, GameAction::OrderTriggers { order: b0 }) => { + GameAction::SelectCards { cards: a0 } => { + let GameAction::SelectCards { cards: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseRemoveCounterCostDistribution { distribution: a0 } => { + let GameAction::ChooseRemoveCounterCostDistribution { distribution: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - (GameAction::CancelCast, GameAction::CancelCast) => Ordering::Equal, - ( - GameAction::Equip { - equipment_id: a0, - target_id: a1, - }, - GameAction::Equip { + GameAction::SelectCoinFlips { keep_indices: a0 } => { + let GameAction::SelectCoinFlips { keep_indices: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseOutsideGameCards { selections: a0 } => { + let GameAction::ChooseOutsideGameCards { selections: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::SelectTargets { targets: a0 } => { + let GameAction::SelectTargets { targets: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseTarget { target: a0 } => { + let GameAction::ChooseTarget { target: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseReplacement { index: a0 } => { + let GameAction::ChooseReplacement { index: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::OrderTriggers { order: a0 } => { + let GameAction::OrderTriggers { order: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::CancelCast => { + let GameAction::CancelCast = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal + } + GameAction::Equip { + equipment_id: a0, + target_id: a1, + } => { + let GameAction::Equip { equipment_id: b0, target_id: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::CrewVehicle { - vehicle_id: a0, - creature_ids: a1, - }, - GameAction::CrewVehicle { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::CrewVehicle { + vehicle_id: a0, + creature_ids: a1, + } => { + let GameAction::CrewVehicle { vehicle_id: b0, creature_ids: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::ActivateStation { - spacecraft_id: a0, - creature_id: a1, - }, - GameAction::ActivateStation { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::ActivateStation { + spacecraft_id: a0, + creature_id: a1, + } => { + let GameAction::ActivateStation { spacecraft_id: b0, creature_id: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::SaddleMount { - mount_id: a0, - creature_ids: a1, - }, - GameAction::SaddleMount { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::SaddleMount { + mount_id: a0, + creature_ids: a1, + } => { + let GameAction::SaddleMount { mount_id: b0, creature_ids: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - (GameAction::Transform { object_id: a0 }, GameAction::Transform { object_id: b0 }) => { - cmp_val(a0, b0) + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) } - ( - GameAction::PlayFaceDown { - object_id: a0, - card_id: a1, - }, - GameAction::PlayFaceDown { + GameAction::Transform { object_id: a0 } => { + let GameAction::Transform { object_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::PlayFaceDown { + object_id: a0, + card_id: a1, + } => { + let GameAction::PlayFaceDown { object_id: b0, card_id: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - (GameAction::TurnFaceUp { object_id: a0 }, GameAction::TurnFaceUp { object_id: b0 }) => { - cmp_val(a0, b0) + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) } - ( - GameAction::SubmitSideboard { - main: a0, - sideboard: a1, - }, - GameAction::SubmitSideboard { + GameAction::TurnFaceUp { object_id: a0 } => { + let GameAction::TurnFaceUp { object_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::SubmitSideboard { + main: a0, + sideboard: a1, + } => { + let GameAction::SubmitSideboard { main: b0, sideboard: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::ChoosePlayDraw { play_first: a0 }, - GameAction::ChoosePlayDraw { play_first: b0 }, - ) => cmp_val(a0, b0), - (GameAction::ChooseOption { choice: a0 }, GameAction::ChooseOption { choice: b0 }) => { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::ChoosePlayDraw { play_first: a0 } => { + let GameAction::ChoosePlayDraw { play_first: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::SubmitVoteCandidate { - candidate_index: a0, - }, - GameAction::SubmitVoteCandidate { + GameAction::ChooseOption { choice: a0 } => { + let GameAction::ChooseOption { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::SubmitVoteCandidate { + candidate_index: a0, + } => { + let GameAction::SubmitVoteCandidate { candidate_index: b0, - }, - ) => cmp_val(a0, b0), - ( - GameAction::SubmitSpellbookDraft { card: a0 }, - GameAction::SubmitSpellbookDraft { card: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::SubmitPilePartition { pile_a: a0 }, - GameAction::SubmitPilePartition { pile_a: b0 }, - ) => cmp_val(a0, b0), - (GameAction::ChoosePile { pile: a0 }, GameAction::ChoosePile { pile: b0 }) => { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - (GameAction::ChooseBranch { index: a0 }, GameAction::ChooseBranch { index: b0 }) => { + GameAction::SubmitSpellbookDraft { card: a0 } => { + let GameAction::SubmitSpellbookDraft { card: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::ChooseDamageSource { source: a0 }, - GameAction::ChooseDamageSource { source: b0 }, - ) => cmp_val(a0, b0), - (GameAction::SelectModes { indices: a0 }, GameAction::SelectModes { indices: b0 }) => { + GameAction::SubmitPilePartition { pile_a: a0 } => { + let GameAction::SubmitPilePartition { pile_a: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::DecideOptionalCost { pay: a0 }, - GameAction::DecideOptionalCost { pay: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseAdventureFace { creature: a0 }, - GameAction::ChooseAdventureFace { creature: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseModalFace { back_face: a0 }, - GameAction::ChooseModalFace { back_face: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseAlternativeCast { choice: a0 }, - GameAction::ChooseAlternativeCast { choice: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseCastingVariant { index: a0 }, - GameAction::ChooseCastingVariant { index: b0 }, - ) => cmp_val(a0, b0), - (GameAction::KeepAllCopyTargets, GameAction::KeepAllCopyTargets) => Ordering::Equal, - ( - GameAction::ChoosePermanentTypeSlot { slot: a0 }, - GameAction::ChoosePermanentTypeSlot { slot: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ActivateNinjutsu { - ninjutsu_object_id: a0, - creature_to_return: a1, - }, - GameAction::ActivateNinjutsu { + GameAction::ChoosePile { pile: a0 } => { + let GameAction::ChoosePile { pile: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseBranch { index: a0 } => { + let GameAction::ChooseBranch { index: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseDamageSource { source: a0 } => { + let GameAction::ChooseDamageSource { source: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::SelectModes { indices: a0 } => { + let GameAction::SelectModes { indices: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::DecideOptionalCost { pay: a0 } => { + let GameAction::DecideOptionalCost { pay: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseAdventureFace { creature: a0 } => { + let GameAction::ChooseAdventureFace { creature: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseModalFace { back_face: a0 } => { + let GameAction::ChooseModalFace { back_face: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseAlternativeCast { choice: a0 } => { + let GameAction::ChooseAlternativeCast { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseCastingVariant { index: a0 } => { + let GameAction::ChooseCastingVariant { index: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::KeepAllCopyTargets => { + let GameAction::KeepAllCopyTargets = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal + } + GameAction::ChoosePermanentTypeSlot { slot: a0 } => { + let GameAction::ChoosePermanentTypeSlot { slot: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ActivateNinjutsu { + ninjutsu_object_id: a0, + creature_to_return: a1, + } => { + let GameAction::ActivateNinjutsu { ninjutsu_object_id: b0, creature_to_return: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::CastSpellAsSneak { - hand_object: a0, - card_id: a1, - creature_to_return: a2, - payment_mode: a3, - }, - GameAction::CastSpellAsSneak { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::CastSpellAsSneak { + hand_object: a0, + card_id: a1, + creature_to_return: a2, + payment_mode: a3, + } => { + let GameAction::CastSpellAsSneak { hand_object: b0, card_id: b1, creature_to_return: b2, payment_mode: b3, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)) - .then_with(|| cmp_val(a3, b3)), - ( - GameAction::CastSpellAsWebSlinging { - hand_object: a0, - card_id: a1, - creature_to_return: a2, - payment_mode: a3, - }, - GameAction::CastSpellAsWebSlinging { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)) + } + GameAction::CastSpellAsWebSlinging { + hand_object: a0, + card_id: a1, + creature_to_return: a2, + payment_mode: a3, + } => { + let GameAction::CastSpellAsWebSlinging { hand_object: b0, card_id: b1, creature_to_return: b2, payment_mode: b3, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)) - .then_with(|| cmp_val(a3, b3)), - ( - GameAction::CastSpellForFree { - object_id: a0, - card_id: a1, - source_id: a2, - payment_mode: a3, - }, - GameAction::CastSpellForFree { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)) + } + GameAction::CastSpellForFree { + object_id: a0, + card_id: a1, + source_id: a2, + payment_mode: a3, + } => { + let GameAction::CastSpellForFree { object_id: b0, card_id: b1, source_id: b2, payment_mode: b3, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)) - .then_with(|| cmp_val(a3, b3)), - ( - GameAction::CastSpellAsMiracle { - object_id: a0, - card_id: a1, - payment_mode: a2, - }, - GameAction::CastSpellAsMiracle { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)) + } + GameAction::CastSpellAsMiracle { + object_id: a0, + card_id: a1, + payment_mode: a2, + } => { + let GameAction::CastSpellAsMiracle { object_id: b0, card_id: b1, payment_mode: b2, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)), - ( - GameAction::CastSpellAsMadness { - object_id: a0, - card_id: a1, - payment_mode: a2, - }, - GameAction::CastSpellAsMadness { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + } + GameAction::CastSpellAsMadness { + object_id: a0, + card_id: a1, + payment_mode: a2, + } => { + let GameAction::CastSpellAsMadness { object_id: b0, card_id: b1, payment_mode: b2, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)), - ( - GameAction::DecideOptionalEffect { accept: a0 }, - GameAction::DecideOptionalEffect { accept: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::RespondToSpliceOffer { card: a0 }, - GameAction::RespondToSpliceOffer { card: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::DecideOptionalEffectAndRemember { choice: a0 }, - GameAction::DecideOptionalEffectAndRemember { choice: b0 }, - ) => cmp_val(a0, b0), - (GameAction::PayUnlessCost { pay: a0 }, GameAction::PayUnlessCost { pay: b0 }) => { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) } - ( - GameAction::ChooseUnlessCostBranch { choice: a0 }, - GameAction::ChooseUnlessCostBranch { choice: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseActivationCostBranch { index: a0 }, - GameAction::ChooseActivationCostBranch { index: b0 }, - ) => cmp_val(a0, b0), - (GameAction::PayCombatTax { accept: a0 }, GameAction::PayCombatTax { accept: b0 }) => { + GameAction::DecideOptionalEffect { accept: a0 } => { + let GameAction::DecideOptionalEffect { accept: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::ChooseRingBearer { target: a0 }, - GameAction::ChooseRingBearer { target: b0 }, - ) => cmp_val(a0, b0), - (GameAction::ChoosePair { partner: a0 }, GameAction::ChoosePair { partner: b0 }) => { + GameAction::RespondToSpliceOffer { card: a0 } => { + let GameAction::RespondToSpliceOffer { card: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - (GameAction::ChooseDungeon { dungeon: a0 }, GameAction::ChooseDungeon { dungeon: b0 }) => { + GameAction::DecideOptionalEffectAndRemember { choice: a0 } => { + let GameAction::DecideOptionalEffectAndRemember { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::ChooseDungeonRoom { room_index: a0 }, - GameAction::ChooseDungeonRoom { room_index: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::UnlockRoomDoor { - object_id: a0, - door: a1, - }, - GameAction::UnlockRoomDoor { + GameAction::PayUnlessCost { pay: a0 } => { + let GameAction::PayUnlessCost { pay: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseUnlessCostBranch { choice: a0 } => { + let GameAction::ChooseUnlessCostBranch { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseActivationCostBranch { index: a0 } => { + let GameAction::ChooseActivationCostBranch { index: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::PayCombatTax { accept: a0 } => { + let GameAction::PayCombatTax { accept: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseRingBearer { target: a0 } => { + let GameAction::ChooseRingBearer { target: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChoosePair { partner: a0 } => { + let GameAction::ChoosePair { partner: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseDungeon { dungeon: a0 } => { + let GameAction::ChooseDungeon { dungeon: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseDungeonRoom { room_index: a0 } => { + let GameAction::ChooseDungeonRoom { room_index: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::UnlockRoomDoor { + object_id: a0, + door: a1, + } => { + let GameAction::UnlockRoomDoor { object_id: b0, door: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - (GameAction::RollPlanarDie, GameAction::RollPlanarDie) => Ordering::Equal, - ( - GameAction::ChooseRoomDoor { - object_id: a0, - op: a1, - door: a2, - }, - GameAction::ChooseRoomDoor { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::RollPlanarDie => { + let GameAction::RollPlanarDie = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal + } + GameAction::ChooseRoomDoor { + object_id: a0, + op: a1, + door: a2, + } => { + let GameAction::ChooseRoomDoor { object_id: b0, op: b1, door: b2, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)), - ( - GameAction::TapForConvoke { - object_id: a0, - mana_type: a1, - }, - GameAction::TapForConvoke { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + } + GameAction::TapForConvoke { + object_id: a0, + mana_type: a1, + } => { + let GameAction::TapForConvoke { object_id: b0, mana_type: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::HarmonizeTap { creature_id: a0 }, - GameAction::HarmonizeTap { creature_id: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::DeclareCompanion { card_index: a0 }, - GameAction::DeclareCompanion { card_index: b0 }, - ) => cmp_val(a0, b0), - (GameAction::CompanionToHand, GameAction::CompanionToHand) => Ordering::Equal, - (GameAction::DiscoverChoice { choice: a0 }, GameAction::DiscoverChoice { choice: b0 }) => { - cmp_val(a0, b0) + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) } - ( - GameAction::GraveyardPaidCastChoice { choice: a0 }, - GameAction::GraveyardPaidCastChoice { choice: b0 }, - ) => cmp_val(a0, b0), - (GameAction::CascadeChoice { choice: a0 }, GameAction::CascadeChoice { choice: b0 }) => { + GameAction::HarmonizeTap { creature_id: a0 } => { + let GameAction::HarmonizeTap { creature_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - (GameAction::RippleChoice { choice: a0 }, GameAction::RippleChoice { choice: b0 }) => { + GameAction::DeclareCompanion { card_index: a0 } => { + let GameAction::DeclareCompanion { card_index: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::FreeCastWindowChoice { selection: a0 }, - GameAction::FreeCastWindowChoice { selection: b0 }, - ) => cmp_val(a0, b0), - (GameAction::ChooseTopOrBottom { top: a0 }, GameAction::ChooseTopOrBottom { top: b0 }) => { - cmp_val(a0, b0) + GameAction::CompanionToHand => { + let GameAction::CompanionToHand = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal } - ( - GameAction::ChooseMutateMergeSide { side: a0 }, - GameAction::ChooseMutateMergeSide { side: b0 }, - ) => cmp_val(a0, b0), - (GameAction::CipherEncode { creature: a0 }, GameAction::CipherEncode { creature: b0 }) => { - cmp_val(a0, b0) + GameAction::DiscoverChoice { choice: a0 } => { + let GameAction::DiscoverChoice { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } } - (GameAction::ChooseLegend { keep: a0 }, GameAction::ChooseLegend { keep: b0 }) => { + GameAction::GraveyardPaidCastChoice { choice: a0 } => { + let GameAction::GraveyardPaidCastChoice { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::ChooseBattleProtector { protector: a0 }, - GameAction::ChooseBattleProtector { protector: b0 }, - ) => cmp_val(a0, b0), - (GameAction::SetAutoPass { mode: a0 }, GameAction::SetAutoPass { mode: b0 }) => { + GameAction::CascadeChoice { choice: a0 } => { + let GameAction::CascadeChoice { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::RippleChoice { choice: a0 } => { + let GameAction::RippleChoice { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::FreeCastWindowChoice { selection: a0 } => { + let GameAction::FreeCastWindowChoice { selection: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - (GameAction::CancelAutoPass, GameAction::CancelAutoPass) => Ordering::Equal, - (GameAction::SetPhaseStops { stops: a0 }, GameAction::SetPhaseStops { stops: b0 }) => { + GameAction::ChooseTopOrBottom { top: a0 } => { + let GameAction::ChooseTopOrBottom { top: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseMutateMergeSide { side: a0 } => { + let GameAction::ChooseMutateMergeSide { side: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - (GameAction::SetPriorityYield { op: a0 }, GameAction::SetPriorityYield { op: b0 }) => { + GameAction::CipherEncode { creature: a0 } => { + let GameAction::CipherEncode { creature: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseLegend { keep: a0 } => { + let GameAction::ChooseLegend { keep: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::ChooseBattleProtector { protector: a0 } => { + let GameAction::ChooseBattleProtector { protector: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - ( - GameAction::AssignCombatDamage { - mode: a0, - assignments: a1, - trample_damage: a2, - controller_damage: a3, - }, - GameAction::AssignCombatDamage { + GameAction::SetAutoPass { mode: a0 } => { + let GameAction::SetAutoPass { mode: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::CancelAutoPass => { + let GameAction::CancelAutoPass = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal + } + GameAction::SetPhaseStops { stops: a0 } => { + let GameAction::SetPhaseStops { stops: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::SetPriorityYield { op: a0 } => { + let GameAction::SetPriorityYield { op: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::AssignCombatDamage { + mode: a0, + assignments: a1, + trample_damage: a2, + controller_damage: a3, + } => { + let GameAction::AssignCombatDamage { mode: b0, assignments: b1, trample_damage: b2, controller_damage: b3, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)) - .then_with(|| cmp_val(a3, b3)), - ( - GameAction::AssignBlockerDamage { assignments: a0 }, - GameAction::AssignBlockerDamage { assignments: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::DistributeAmong { distribution: a0 }, - GameAction::DistributeAmong { distribution: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseCounterMoveDistribution { selections: a0 }, - GameAction::ChooseCounterMoveDistribution { selections: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseCountersToRemove { selections: a0 }, - GameAction::ChooseCountersToRemove { selections: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::SubmitPayAmount { amount: a0 }, - GameAction::SubmitPayAmount { amount: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::RetargetSpell { new_targets: a0 }, - GameAction::RetargetSpell { new_targets: b0 }, - ) => cmp_val(a0, b0), - (GameAction::LearnDecision { choice: a0 }, GameAction::LearnDecision { choice: b0 }) => { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)) } - ( - GameAction::SelectCategoryPermanents { choices: a0 }, - GameAction::SelectCategoryPermanents { choices: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseKeptCreatures { kept: a0 }, - GameAction::ChooseKeptCreatures { kept: b0 }, - ) => cmp_val(a0, b0), - (GameAction::ChooseX { value: a0 }, GameAction::ChooseX { value: b0 }) => cmp_val(a0, b0), - ( - GameAction::SubmitPhyrexianChoices { choices: a0 }, - GameAction::SubmitPhyrexianChoices { choices: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseManaColor { - choice: a0, - count: a1, - }, - GameAction::ChooseManaColor { + GameAction::AssignBlockerDamage { assignments: a0 } => { + let GameAction::AssignBlockerDamage { assignments: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::DistributeAmong { distribution: a0 } => { + let GameAction::DistributeAmong { distribution: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseCounterMoveDistribution { selections: a0 } => { + let GameAction::ChooseCounterMoveDistribution { selections: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseCountersToRemove { selections: a0 } => { + let GameAction::ChooseCountersToRemove { selections: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::SubmitPayAmount { amount: a0 } => { + let GameAction::SubmitPayAmount { amount: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::RetargetSpell { new_targets: a0 } => { + let GameAction::RetargetSpell { new_targets: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::LearnDecision { choice: a0 } => { + let GameAction::LearnDecision { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::SelectCategoryPermanents { choices: a0 } => { + let GameAction::SelectCategoryPermanents { choices: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseKeptCreatures { kept: a0 } => { + let GameAction::ChooseKeptCreatures { kept: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseX { value: a0 } => { + let GameAction::ChooseX { value: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::SubmitPhyrexianChoices { choices: a0 } => { + let GameAction::SubmitPhyrexianChoices { choices: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseManaColor { + choice: a0, + count: a1, + } => { + let GameAction::ChooseManaColor { choice: b0, count: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - GameAction::PayManaAbilityMana { payment: a0 }, - GameAction::PayManaAbilityMana { payment: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::CastPreparedCopy { source: a0 }, - GameAction::CastPreparedCopy { source: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::ChooseSpecializeColor { color: a0 }, - GameAction::ChooseSpecializeColor { color: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::CastParadigmCopy { source: a0 }, - GameAction::CastParadigmCopy { source: b0 }, - ) => cmp_val(a0, b0), - (GameAction::PassParadigmOffer, GameAction::PassParadigmOffer) => Ordering::Equal, - (GameAction::Debug(a0), GameAction::Debug(b0)) => cmp_debug_action(a0, b0), - ( - GameAction::GrantDebugPermission { player_id: a0 }, - GameAction::GrantDebugPermission { player_id: b0 }, - ) => cmp_val(a0, b0), - ( - GameAction::RevokeDebugPermission { player_id: a0 }, - GameAction::RevokeDebugPermission { player_id: b0 }, - ) => cmp_val(a0, b0), - (GameAction::Concede { player_id: a0 }, GameAction::Concede { player_id: b0 }) => { + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::PayManaAbilityMana { payment: a0 } => { + let GameAction::PayManaAbilityMana { payment: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; cmp_val(a0, b0) } - // Unreachable: `cmp_game_actions` only calls this after the discriminants - // compared `Equal`, which implies the same variant. `Equal` is the safe - // total-order identity for the (never-taken) mismatched-variant case. - _ => Ordering::Equal, + GameAction::CastPreparedCopy { source: a0 } => { + let GameAction::CastPreparedCopy { source: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::ChooseSpecializeColor { color: a0 } => { + let GameAction::ChooseSpecializeColor { color: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::CastParadigmCopy { source: a0 } => { + let GameAction::CastParadigmCopy { source: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::PassParadigmOffer => { + let GameAction::PassParadigmOffer = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal + } + GameAction::Debug(a0) => { + let GameAction::Debug(b0) = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_debug_action(a0, b0) + } + GameAction::GrantDebugPermission { player_id: a0 } => { + let GameAction::GrantDebugPermission { player_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::RevokeDebugPermission { player_id: a0 } => { + let GameAction::RevokeDebugPermission { player_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::Concede { player_id: a0 } => { + let GameAction::Concede { player_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } } } @@ -669,296 +1027,385 @@ fn debug_action_rank(a: &DebugAction) -> u16 { } } +/// Compares payloads of two debug actions with the same discriminant. +/// Exhaustive on `DebugAction`; mismatched variants `unreachable!`. fn cmp_debug_action_payload(a: &DebugAction, b: &DebugAction) -> Ordering { - match (a, b) { - ( - DebugAction::MoveToZone { - object_id: a0, - to_zone: a1, - library_position: a2, - simulate: a3, - }, - DebugAction::MoveToZone { + match a { + DebugAction::MoveToZone { + object_id: a0, + to_zone: a1, + library_position: a2, + simulate: a3, + } => { + let DebugAction::MoveToZone { object_id: b0, to_zone: b1, library_position: b2, simulate: b3, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_opt_library_position(a2, b2)) - .then_with(|| cmp_val(a3, b3)), - ( - DebugAction::CreateCard { - card_name: a0, - owner: a1, - zone: a2, - attach_to: a3, - run_etb: a4, - }, - DebugAction::CreateCard { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_opt_library_position(a2, b2)) + .then_with(|| cmp_val(a3, b3)) + } + DebugAction::CreateCard { + card_name: a0, + owner: a1, + zone: a2, + attach_to: a3, + run_etb: a4, + } => { + let DebugAction::CreateCard { card_name: b0, owner: b1, zone: b2, attach_to: b3, run_etb: b4, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)) - .then_with(|| cmp_val(a3, b3)) - .then_with(|| cmp_val(a4, b4)), - ( - DebugAction::RemoveObject { object_id: a0 }, - DebugAction::RemoveObject { object_id: b0 }, - ) => cmp_val(a0, b0), - (DebugAction::Sacrifice { object_id: a0 }, DebugAction::Sacrifice { object_id: b0 }) => { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)) + .then_with(|| cmp_val(a4, b4)) } - ( - DebugAction::DrawCards { - player_id: a0, - count: a1, - }, - DebugAction::DrawCards { + DebugAction::RemoveObject { object_id: a0 } => { + let DebugAction::RemoveObject { object_id: b0 } = b else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + DebugAction::Sacrifice { object_id: a0 } => { + let DebugAction::Sacrifice { object_id: b0 } = b else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + DebugAction::DrawCards { + player_id: a0, + count: a1, + } => { + let DebugAction::DrawCards { player_id: b0, count: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::Mill { - player_id: a0, - count: a1, - }, - DebugAction::Mill { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::Mill { + player_id: a0, + count: a1, + } => { + let DebugAction::Mill { player_id: b0, count: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::Reveal { - player_id: a0, - count: a1, - }, - DebugAction::Reveal { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::Reveal { + player_id: a0, + count: a1, + } => { + let DebugAction::Reveal { player_id: b0, count: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::ShuffleLibrary { player_id: a0 }, - DebugAction::ShuffleLibrary { player_id: b0 }, - ) => cmp_val(a0, b0), - ( - DebugAction::Proliferate { player_id: a0 }, - DebugAction::Proliferate { player_id: b0 }, - ) => cmp_val(a0, b0), - ( - DebugAction::SetBasePowerToughness { - object_id: a0, - power: a1, - toughness: a2, - }, - DebugAction::SetBasePowerToughness { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::ShuffleLibrary { player_id: a0 } => { + let DebugAction::ShuffleLibrary { player_id: b0 } = b else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + DebugAction::Proliferate { player_id: a0 } => { + let DebugAction::Proliferate { player_id: b0 } = b else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + DebugAction::SetBasePowerToughness { + object_id: a0, + power: a1, + toughness: a2, + } => { + let DebugAction::SetBasePowerToughness { object_id: b0, power: b1, toughness: b2, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)), - ( - DebugAction::ModifyCounters { - object_id: a0, - counter_type: a1, - delta: a2, - }, - DebugAction::ModifyCounters { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + } + DebugAction::ModifyCounters { + object_id: a0, + counter_type: a1, + delta: a2, + } => { + let DebugAction::ModifyCounters { object_id: b0, counter_type: b1, delta: b2, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)), - ( - DebugAction::SetTapped { - object_id: a0, - tapped: a1, - }, - DebugAction::SetTapped { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + } + DebugAction::SetTapped { + object_id: a0, + tapped: a1, + } => { + let DebugAction::SetTapped { object_id: b0, tapped: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::SetPrepared { - object_id: a0, - prepared: a1, - }, - DebugAction::SetPrepared { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::SetPrepared { + object_id: a0, + prepared: a1, + } => { + let DebugAction::SetPrepared { object_id: b0, prepared: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::SetController { - object_id: a0, - controller: a1, - }, - DebugAction::SetController { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::SetController { + object_id: a0, + controller: a1, + } => { + let DebugAction::SetController { object_id: b0, controller: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::SetSummoningSickness { - object_id: a0, - sick: a1, - }, - DebugAction::SetSummoningSickness { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::SetSummoningSickness { + object_id: a0, + sick: a1, + } => { + let DebugAction::SetSummoningSickness { object_id: b0, sick: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::SetFaceState { - object_id: a0, - face_down: a1, - transformed: a2, - flipped: a3, - }, - DebugAction::SetFaceState { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::SetFaceState { + object_id: a0, + face_down: a1, + transformed: a2, + flipped: a3, + } => { + let DebugAction::SetFaceState { object_id: b0, face_down: b1, transformed: b2, flipped: b3, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)) - .then_with(|| cmp_val(a3, b3)), - ( - DebugAction::Attach { - object_id: a0, - target: a1, - }, - DebugAction::Attach { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + .then_with(|| cmp_val(a3, b3)) + } + DebugAction::Attach { + object_id: a0, + target: a1, + } => { + let DebugAction::Attach { object_id: b0, target: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - (DebugAction::Detach { object_id: a0 }, DebugAction::Detach { object_id: b0 }) => { - cmp_val(a0, b0) + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) } - ( - DebugAction::GrantKeyword { - object_id: a0, - keyword: a1, - }, - DebugAction::GrantKeyword { + DebugAction::Detach { object_id: a0 } => { + let DebugAction::Detach { object_id: b0 } = b else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + DebugAction::GrantKeyword { + object_id: a0, + keyword: a1, + } => { + let DebugAction::GrantKeyword { object_id: b0, keyword: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(&a1.kind(), &b1.kind())), - ( - DebugAction::RemoveKeyword { - object_id: a0, - keyword: a1, - }, - DebugAction::RemoveKeyword { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(&a1.kind(), &b1.kind())) + } + DebugAction::RemoveKeyword { + object_id: a0, + keyword: a1, + } => { + let DebugAction::RemoveKeyword { object_id: b0, keyword: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(&a1.kind(), &b1.kind())), - ( - DebugAction::SetLife { - player_id: a0, - life: a1, - }, - DebugAction::SetLife { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(&a1.kind(), &b1.kind())) + } + DebugAction::SetLife { + player_id: a0, + life: a1, + } => { + let DebugAction::SetLife { player_id: b0, life: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::ModifyPlayerCounters { - player_id: a0, - counter_kind: a1, - delta: a2, - }, - DebugAction::ModifyPlayerCounters { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::ModifyPlayerCounters { + player_id: a0, + counter_kind: a1, + delta: a2, + } => { + let DebugAction::ModifyPlayerCounters { player_id: b0, counter_kind: b1, delta: b2, - }, - ) => cmp_val(a0, b0) - .then_with(|| cmp_val(a1, b1)) - .then_with(|| cmp_val(a2, b2)), - ( - DebugAction::ModifyEnergy { - player_id: a0, - delta: a1, - }, - DebugAction::ModifyEnergy { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + .then_with(|| cmp_val(a1, b1)) + .then_with(|| cmp_val(a2, b2)) + } + DebugAction::ModifyEnergy { + player_id: a0, + delta: a1, + } => { + let DebugAction::ModifyEnergy { player_id: b0, delta: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::AddMana { - player_id: a0, - mana: a1, - }, - DebugAction::AddMana { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::AddMana { + player_id: a0, + mana: a1, + } => { + let DebugAction::AddMana { player_id: b0, mana: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::SetInfiniteMana { - player_id: a0, - enabled: a1, - }, - DebugAction::SetInfiniteMana { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::SetInfiniteMana { + player_id: a0, + enabled: a1, + } => { + let DebugAction::SetInfiniteMana { player_id: b0, enabled: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::SetPhase { - phase: a0, - active_player: a1, - }, - DebugAction::SetPhase { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::SetPhase { + phase: a0, + active_player: a1, + } => { + let DebugAction::SetPhase { phase: b0, active_player: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - (DebugAction::RunStateBasedActions, DebugAction::RunStateBasedActions) => Ordering::Equal, - ( - DebugAction::CreateToken { - request: a0, - run_etb: a1, - }, - DebugAction::CreateToken { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::RunStateBasedActions => { + let DebugAction::RunStateBasedActions = b else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + Ordering::Equal + } + DebugAction::CreateToken { + request: a0, + run_etb: a1, + } => { + let DebugAction::CreateToken { request: b0, run_etb: b1, - }, - ) => cmp_debug_token_request(a0, b0).then_with(|| cmp_val(a1, b1)), - ( - DebugAction::CreateTokenCopy { - source_id: a0, - owner: a1, - }, - DebugAction::CreateTokenCopy { + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_debug_token_request(a0, b0).then_with(|| cmp_val(a1, b1)) + } + DebugAction::CreateTokenCopy { + source_id: a0, + owner: a1, + } => { + let DebugAction::CreateTokenCopy { source_id: b0, owner: b1, - }, - ) => cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)), - // Unreachable: reached only after `debug_action_rank` compared `Equal`, - // which implies the same variant. - _ => Ordering::Equal, + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } } } From ee343ab6181efc5263abda4f832facb5b5a1969a Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Tue, 7 Jul 2026 20:14:59 +0000 Subject: [PATCH 5/7] fix(ci): gate parse-diff on parser-source hash, not full engine tree --- .github/workflows/ci.yml | 14 +++++++++++--- scripts/engine-source-hash.sh | 16 +++++++++++----- scripts/parser-source-hash.sh | 29 +++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) create mode 100755 scripts/parser-source-hash.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 874bfee61f..bc79a088a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,9 +250,10 @@ jobs: # PR's parse changes, with no second coverage build and no cross-PR # contamination — even when the PR is behind main, or has main merged # into it (both sides move in lockstep because the base is read off the - # merge commit, not the frozen webhook base.sha). Path filter: identical - # engine-source hash => no parse - # change is possible => skip before the bin build. --features cli keeps + # merge commit, not the frozen webhook base.sha). Skip gate: identical + # parser-source hash => no Oracle/parser code changed => parse-diff + # cannot reflect real PR parse work (runtime/AI-only diffs are noise). + # Baseline fetch still keys off engine-source-hash (R2 object names). # the same [tool, cli] engine fingerprint as the steps above (lib cache # hit; only the new bin links). id: parsediff @@ -289,6 +290,13 @@ jobs: git fetch --no-tags --depth=1 origin "$BASE_SHA" BASE_HASH="$(./scripts/engine-source-hash.sh "$BASE_SHA")" HEAD_HASH="$(./scripts/engine-source-hash.sh HEAD)" + BASE_PARSER_HASH="$(./scripts/parser-source-hash.sh "$BASE_SHA")" + HEAD_PARSER_HASH="$(./scripts/parser-source-hash.sh HEAD)" + if [ "$BASE_PARSER_HASH" = "$HEAD_PARSER_HASH" ]; then + echo "Parser surface unchanged vs base ($BASE_PARSER_HASH) — no parse change possible; skipping." + echo "produced=false" >> "$GITHUB_OUTPUT" + exit 0 + fi if [ "$BASE_HASH" = "$HEAD_HASH" ]; then echo "Engine source unchanged vs base ($BASE_HASH) — no parse change possible; skipping." echo "produced=false" >> "$GITHUB_OUTPUT" diff --git a/scripts/engine-source-hash.sh b/scripts/engine-source-hash.sh index e10c583385..b933b6e2d1 100755 --- a/scripts/engine-source-hash.sh +++ b/scripts/engine-source-hash.sh @@ -2,21 +2,27 @@ # # Print the engine-source content hash for a given commit. # -# This is the single authority for the parse-diff baseline key. The same hash -# is computed on BOTH sides of the coverage-parse-diff flow: +# This is the single authority for the cardgen cache key and immutable +# parse-baseline object names (coverage-data-.json on R2). The same hash +# is computed on BOTH sides of the coverage-parse-diff baseline fetch: # # - publish (main-push CI): hash HEAD, upload coverage-data-.json to R2. # - fetch (PR CI): hash the merge commit's base (main) parent, fetch # coverage-data-.json. # +# Whether a PR can change parse trees at all is gated separately by +# scripts/parser-source-hash.sh (parser/ + database/ only). A PR that touches +# only runtime game logic or AI ordering may bust this hash (cardgen rebuild) +# while leaving the parser hash unchanged — parse-diff is skipped in that case. +# # Because the head coverage is built from the merge commit and the baseline is # keyed by that merge commit's own base parent's hash, `head - baseline` cancels # to exactly the PR's parse changes — even if the PR is stale or has main merged # into it (see .github/workflows/ci.yml "Parse-detail diff"). # -# The fingerprint covers exactly the inputs that determine parse output and -# mirrors the cardgen-cache key (ci.yml): the engine source tree, the engine -# crate manifest, and the lockfile (dep pins like nom affect parsing). +# The fingerprint covers the full engine tree for cardgen cache busting and +# baseline object naming. Parser-only change detection lives in +# scripts/parser-source-hash.sh. # # Implemented with `git ls-tree -r` rather than `hashFiles`/working-tree hashing # so it is computable for ANY commit straight from history — no checkout — which diff --git a/scripts/parser-source-hash.sh b/scripts/parser-source-hash.sh new file mode 100755 index 0000000000..551cfe078e --- /dev/null +++ b/scripts/parser-source-hash.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Print the parser-surface content hash for a given commit. +# +# Used by CI to decide whether a PR can introduce parse-tree changes in +# coverage-data.json. Only paths that actually interpret Oracle text or +# synthesize card ability data participate — NOT runtime game logic, AI +# ordering, or serde-only type derives elsewhere in the engine crate. +# +# Contrast with scripts/engine-source-hash.sh, which fingerprints the full +# engine tree for cardgen cache keys and immutable baseline object names. +# +# Usage: scripts/parser-source-hash.sh +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: parser-source-hash.sh " >&2 + exit 2 +fi + +sha="$1" + +git ls-tree -r "$sha" -- \ + crates/engine/src/parser \ + crates/engine/src/database \ + crates/engine/Cargo.toml \ + Cargo.lock \ + | sha256sum \ + | cut -c1-16 From 733a3eea67df6fdb96c5bf59764ccde33e93e0cd Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Wed, 8 Jul 2026 12:00:56 +0000 Subject: [PATCH 6/7] fix(engine, ai): wire SubmitLifeRedistribution into cmp_stable ordering --- crates/engine/src/types/action_stable_order.rs | 6 ++++++ crates/phase-ai/src/search.rs | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index 9f13964b02..22af09f8e7 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -413,6 +413,12 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { cmp_val(a0, b0) } } + GameAction::SubmitLifeRedistribution { option_index: a0 } => { + let GameAction::SubmitLifeRedistribution { option_index: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } GameAction::ChooseDamageSource { source: a0 } => { let GameAction::ChooseDamageSource { source: b0 } = b else { unreachable!("cmp_payload: same-variant invariant"); diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 26fd462c00..70792a9651 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1708,9 +1708,8 @@ pub fn score_candidates_with_session( merge_into(&mut acc, &mut positions, &mut counts, scored); } let mut out = finalize_mean(acc, counts, k as usize); - if config.execution_mode.is_measurement() { - out.sort_by_cached_key(|(action, _)| action_order_key(action)); - } + // Issue #4878: canonical order after K-sample merge (measurement + play). + out.sort_by(|a, b| a.0.cmp_stable(&b.0)); out } From 0d0127795fe1ea035265b1bc049fbf3430185c18 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Wed, 8 Jul 2026 13:05:07 +0000 Subject: [PATCH 7/7] chore(ai): refresh perf-gate baseline for deterministic ordering shift Deterministic candidate ordering and the main merge's card-data snapshot shift AI decision trajectories. Update median-of-5 counter baselines for mana_display_swept_objects and restriction_static_mode_gate_scans (plus coupled counters) so the decision-cost perf gate passes at current HEAD. Co-authored-by: Cursor --- crates/phase-ai/baselines/perf-baseline.json | 40 ++++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/phase-ai/baselines/perf-baseline.json b/crates/phase-ai/baselines/perf-baseline.json index e0d2edf46b..d4367f19b1 100644 --- a/crates/phase-ai/baselines/perf-baseline.json +++ b/crates/phase-ai/baselines/perf-baseline.json @@ -1,7 +1,7 @@ { "schema_version": 3, - "git_sha": "8d479ebc13d2", - "card_data_hash": "863056e7cd77b33532075380684122a4cd85848e", + "git_sha": "733a3eea67df", + "card_data_hash": "a16614bd07a77fddf8a8619bcd31712a7b723f8d", "base_seed": 2654435769, "action_cap": 3000, "sample_count": 5, @@ -11,25 +11,25 @@ "enchantress-mirror" ], "counters": { - "attackable_player_sweeps": 555, - "auto_tap_source_cache_builds": 516, + "attackable_player_sweeps": 535, + "auto_tap_source_cache_builds": 169, "cached_auto_tap_source_rejects": 0, - "cached_auto_tap_source_reuses": 509, + "cached_auto_tap_source_reuses": 16, "combat_shadow_block_scans": 0, - "crew_eligibility_scans": 10397, + "crew_eligibility_scans": 10470, "granted_ability_provider_scans": 0, - "layers_escalated": 19, - "layers_full_eval": 6102, - "layers_incremental": 246, - "legal_actions_spell_cost_sweeps": 516, - "legend_rule_mode_gate_scans": 14225, - "mana_aura_trigger_scans": 10497, - "mana_display_sweeps": 345, - "mana_display_swept_objects": 5996, - "priority_cast_probe_builds": 516, + "layers_escalated": 37, + "layers_full_eval": 6432, + "layers_incremental": 238, + "legal_actions_spell_cost_sweeps": 169, + "legend_rule_mode_gate_scans": 13564, + "mana_aura_trigger_scans": 9888, + "mana_display_sweeps": 369, + "mana_display_swept_objects": 6421, + "priority_cast_probe_builds": 169, "restriction_static_exact_scans": 0, - "restriction_static_mode_gate_scans": 25694, - "sba_battlefield_snapshot_builds": 14288, + "restriction_static_mode_gate_scans": 29437, + "sba_battlefield_snapshot_builds": 13596, "sba_empty_battlefield_short_circuits": 69, "stack_batch_candidates": 0, "stack_batch_observer_refusals": 0, @@ -37,8 +37,8 @@ "stack_batched_entries": 0, "stack_inert_noop_batches": 0, "stack_inert_noop_entries": 0, - "state_clone_for_legality": 4419, - "static_full_scans": 0 + "state_clone_for_legality": 4413, + "static_full_scans": 2 }, - "wall_clock_ms": 15708 + "wall_clock_ms": 98210 } \ No newline at end of file