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/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 7215487f66..4d814c0378 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -56,7 +56,13 @@ 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. 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 } /// CR 702.51a / 702.66a / 702.126a: During `ManaPayment`, every structurally @@ -5061,6 +5067,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/casting.rs b/crates/engine/src/game/casting.rs index d184dc072c..347067a511 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,7 +867,7 @@ 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 = + let exile_permission_ids: BTreeSet = exile_objects_castable_by_permission(state, player) .iter() .map(|(obj_id, _source_id, _freq)| *obj_id) diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index e56c4a14fd..28b1a47ff2 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/layers.rs b/crates/engine/src/game/layers.rs index 7164bdbece..2b91c6eb55 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, } @@ -2258,7 +2258,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)?; @@ -2329,7 +2329,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 { @@ -2384,7 +2384,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| { @@ -2453,7 +2453,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| { @@ -2617,8 +2617,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 @@ -2635,7 +2635,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), @@ -3716,7 +3716,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)); @@ -3771,7 +3771,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<&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(); @@ -4394,7 +4397,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, ) { @@ -4410,7 +4413,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/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/game/stack.rs b/crates/engine/src/game/stack.rs index bdf7c36bf4..2395c9a7a5 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -7889,7 +7889,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:?}"), }; @@ -8662,7 +8662,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/ability.rs b/crates/engine/src/types/ability.rs index f4b69d652c..4992fb7cca 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -4556,7 +4556,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. @@ -19085,7 +19085,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..22af09f8e7 --- /dev/null +++ b/crates/engine/src/types/action_stable_order.rs @@ -0,0 +1,1498 @@ +// @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 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 { + 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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::ChooseAssistPlayer { player: a0 } => { + let GameAction::ChooseAssistPlayer { player: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::CommitAssistPayment { generic: a0 } => { + let GameAction::CommitAssistPayment { generic: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::MulliganDecision { choice: a0 } => { + let GameAction::MulliganDecision { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, 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::UntapLandForMana { object_id: a0 } => { + let GameAction::UntapLandForMana { object_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, 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::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::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, + } = 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, + } = 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, + } = 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, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + 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, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + 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, + } = 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::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, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::SubmitSpellbookDraft { card: a0 } => { + let GameAction::SubmitSpellbookDraft { card: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, 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::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::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"); + }; + 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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::DecideOptionalEffect { accept: a0 } => { + let GameAction::DecideOptionalEffect { accept: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::RespondToSpliceOffer { card: a0 } => { + let GameAction::RespondToSpliceOffer { card: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::DecideOptionalEffectAndRemember { choice: a0 } => { + let GameAction::DecideOptionalEffectAndRemember { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + 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, + } = 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, + } = 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, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::HarmonizeTap { creature_id: a0 } => { + let GameAction::HarmonizeTap { creature_id: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, 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::CompanionToHand => { + let GameAction::CompanionToHand = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal + } + GameAction::DiscoverChoice { choice: a0 } => { + let GameAction::DiscoverChoice { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + { + cmp_val(a0, b0) + } + } + GameAction::GraveyardPaidCastChoice { choice: a0 } => { + let GameAction::GraveyardPaidCastChoice { choice: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, 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::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::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::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, + } = 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::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, + } = 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) + } + 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) + } + } + } +} + +/// 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, + } +} + +/// 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 { + 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, + } = 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, + } = 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::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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = 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, + } = b + else { + unreachable!("cmp_debug_action_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + } +} + +/// 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 889ee9b71d..0d28b2966c 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 { @@ -782,7 +789,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 { @@ -796,7 +803,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. @@ -1288,6 +1295,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 12b4ee468e..f1341926ca 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, @@ -714,26 +714,12 @@ pub enum YieldScope { /// latched at the moment the yield is registered. /// /// `ThisObject` binds a concrete object incarnation: a matching stack entry must -/// carry the same `source_id` and `source_incarnation`. Here `incarnation` is an -/// `Option`, so an `incarnation` of `None` matches a trigger whose -/// `source_incarnation` is *also* `None` — synthetic/delayed game-rule triggers -/// that never latched an incarnation can now be yielded (Option == Option -/// compare). `AllCopies` binds a `CardId`: any trigger whose `source_card_id` -/// equals it matches, regardless of which object (or whether the object still -/// exists, CR 704.5d). -/// -/// Both variants carry an optional `trigger_description`, the per-trigger -/// discriminator the stack entry already exposes -/// (`StackEntryKind::TriggeredAbility.description`). A `trigger_description` of -/// `None` is a **wildcard** that matches ANY entry description (the coarse, -/// source-level yield used by legacy persisted yields and pre-upgrade saves), -/// while `Some(desc)` gives per-trigger precision so one source's distinct -/// triggers can be yielded independently. -/// -// serde: legacy bare-u64 incarnation loads as Some (serde maps only null→None), -// so old persisted `{"incarnation":26}` still deserializes and matches; an -// absent `trigger_description` defaults to None (the wildcard). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// carry the same `source_id` and `source_incarnation`. A `source_incarnation` +/// of `None` therefore never matches (unlike the epoch guard's "None = always +/// 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, PartialOrd, Ord, Serialize, Deserialize)] pub enum YieldTarget { ThisObject { source_id: ObjectId, @@ -1407,14 +1393,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, @@ -1426,7 +1412,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, @@ -1955,7 +1941,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] @@ -2286,7 +2272,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), @@ -2410,7 +2396,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, } @@ -2433,7 +2419,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); } @@ -2564,7 +2550,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). @@ -2838,7 +2824,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, @@ -4935,7 +4921,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, @@ -5464,7 +5450,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 @@ -5483,7 +5469,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 839cadf9bd..d875a27d35 100644 --- a/crates/engine/src/types/identifiers.rs +++ b/crates/engine/src/types/identifiers.rs @@ -1,10 +1,10 @@ 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); -#[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/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 84d0eb0612..cc9ee02767 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 1436df04df..65b6929af1 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/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 diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 28f395d05b..70792a9651 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(|a, b| a.0.cmp_stable(&b.0)); let chosen = if scored.len() == 1 { Some(scored[0].0.clone()) } else { @@ -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 } @@ -1836,9 +1835,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(|a, b| a.candidate.action.cmp_stable(&b.candidate.action)); let actions: Vec = gated .iter() @@ -1916,10 +1914,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); @@ -1996,9 +1991,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(|a, b| a.0.cmp_stable(&b.0)); out } else { // Heuristic-only scoring @@ -2015,17 +2008,11 @@ 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(|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, @@ -2869,10 +2856,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(|| a.0.cmp_stable(&b.0)) + }) .map(|s| s.0.clone()); } @@ -2926,6 +2918,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, @@ -5362,7 +5427,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 } 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