From c747955e5ffb830c52a9679e4ea6b86035279ab0 Mon Sep 17 00:00:00 2001 From: boskodev790 <233739930+boskodev790@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:03:18 -0400 Subject: [PATCH] #6337 Make draft color selection deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit color_preference (bot_ai) and find_best_colors (suggest) ranked colors from a HashMap and sorted by count/score with no secondary tie-break, so tied colors followed per-process-randomized HashMap iteration order — breaking seeded-draft reproducibility and making "Suggest deck" return a different pair on repeated runs. Add a color-name tie-break (matching distribute_lands) so ties resolve alphabetically and deterministically. Closes #6337 Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/draft-wasm/src/bot_ai.rs | 43 +++++++++++++++++++++++++++++++- crates/draft-wasm/src/suggest.rs | 28 ++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/draft-wasm/src/bot_ai.rs b/crates/draft-wasm/src/bot_ai.rs index 346d69e981..f616aa96a2 100644 --- a/crates/draft-wasm/src/bot_ai.rs +++ b/crates/draft-wasm/src/bot_ai.rs @@ -197,7 +197,10 @@ fn color_preference(prior_picks: &[DraftCardInstance]) -> Vec { } let mut sorted: Vec<(&&str, &u32)> = counts.iter().collect(); - sorted.sort_by(|a, b| b.1.cmp(a.1)); + // Tie-break by color name so equal pip counts resolve deterministically — + // HashMap iteration order is randomized per process, which would otherwise + // make a seeded draft replay differently run to run (matches distribute_lands). + sorted.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); // Take top 2 colors sorted @@ -240,3 +243,41 @@ fn curve_bonus(cmc: u8, pick_number: u8) -> i8 { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn instance(name: &str, colors: &[&str]) -> DraftCardInstance { + DraftCardInstance { + instance_id: format!("id-{name}"), + name: name.to_string(), + set_code: "TST".to_string(), + collector_number: "1".to_string(), + rarity: "common".to_string(), + colors: colors.iter().map(|s| s.to_string()).collect(), + cmc: 2, + type_line: "Creature".to_string(), + } + } + + // Five colors tied at count 1. Without a deterministic tie-break the chosen top-2 + // depends on HashMap iteration order, which is seeded per map — so a seeded draft + // replays differently run to run. Building a fresh map on each call and asserting a + // stable alphabetical result (B < G < R < U < W → top 2 = ["B", "G"]) exercises many + // seeds; without the tie-break at least one iteration diverges. + #[test] + fn color_preference_breaks_ties_deterministically() { + let picks = [ + instance("a", &["W"]), + instance("b", &["U"]), + instance("c", &["B"]), + instance("d", &["R"]), + instance("e", &["G"]), + ]; + let expected = vec!["B".to_string(), "G".to_string()]; + for _ in 0..64 { + assert_eq!(color_preference(&picks), expected); + } + } +} diff --git a/crates/draft-wasm/src/suggest.rs b/crates/draft-wasm/src/suggest.rs index 60af339815..43cddf7ec9 100644 --- a/crates/draft-wasm/src/suggest.rs +++ b/crates/draft-wasm/src/suggest.rs @@ -211,7 +211,14 @@ fn find_best_colors<'a>( } let mut sorted: Vec<(&&str, &f64)> = color_scores.iter().collect(); - sorted.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal)); + // Tie-break by color name so equal scores resolve deterministically — + // HashMap iteration order is randomized per process, which would otherwise + // make "Suggest deck" return a different pair run to run (matches distribute_lands). + sorted.sort_by(|a, b| { + b.1.partial_cmp(a.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(b.0)) + }); sorted.iter().take(2).map(|(color, _)| **color).collect() } @@ -524,4 +531,23 @@ mod tests { ); assert!(!deck.lands.contains_key("On Color Dual")); } + + // Five colors tied at equal score (no card DB → every card scores the same). Without a + // deterministic tie-break the top-2 pick follows HashMap iteration order, which is seeded + // per map, so a seeded draft is non-deterministic. Building a fresh map on each call and + // asserting a stable alphabetical result (B < G < R < U < W → ["B", "G"]) exercises many + // seeds; without the tie-break at least one iteration diverges. + #[test] + fn find_best_colors_breaks_ties_deterministically() { + let pool = [ + instance("a", &["W"], 2, "Creature"), + instance("b", &["U"], 2, "Creature"), + instance("c", &["B"], 2, "Creature"), + instance("d", &["R"], 2, "Creature"), + instance("e", &["G"], 2, "Creature"), + ]; + for _ in 0..64 { + assert_eq!(find_best_colors(&pool, None), vec!["B", "G"]); + } + } }