From 6f2f0093ab36e2330a21da7a701abf66fdda18f2 Mon Sep 17 00:00:00 2001 From: wondercreatemaster Date: Tue, 28 Jul 2026 02:36:00 +0300 Subject: [PATCH] fix(draft): put suggested nonbasic lands on main_deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drafted duals were stuffed into `lands`, which the limited deckbuilder only renders for addable basics — so they stayed visible in the pool, got added again, and submit double-counted them as ExceedsPoolCount. Also surface per-card ValidationFailed details in the error Display and submit UI. Fixes #6562 --- .../components/draft/LimitedDeckBuilder.tsx | 25 +++++++++- crates/draft-core/src/types.rs | 34 +++++++++++++- crates/draft-wasm/src/suggest.rs | 46 +++++++++++++------ 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/client/src/components/draft/LimitedDeckBuilder.tsx b/client/src/components/draft/LimitedDeckBuilder.tsx index 0c61e3cf8f..63eac0398e 100644 --- a/client/src/components/draft/LimitedDeckBuilder.tsx +++ b/client/src/components/draft/LimitedDeckBuilder.tsx @@ -220,6 +220,8 @@ export function LimitedDeckBuilder({ const [hoveredCard, setHoveredCard] = useState(null); + const [submitError, setSubmitError] = useState(null); + const pool = useMemo(() => view?.pool ?? [], [view?.pool]); const remainingPool = useMemo( @@ -361,7 +363,20 @@ export function LimitedDeckBuilder({ + {submitError ? ( +

+ {submitError} +

+ ) : null} diff --git a/crates/draft-core/src/types.rs b/crates/draft-core/src/types.rs index 2f12a3de97..4ffb9432d4 100644 --- a/crates/draft-core/src/types.rs +++ b/crates/draft-core/src/types.rs @@ -415,7 +415,14 @@ pub enum DraftError { CardNotInPack { card_instance_id: String }, #[error("seat {seat} has no pending pack")] NoPendingPack { seat: u8 }, - #[error("deck validation failed")] + #[error( + "deck validation failed: {}", + errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("; ") + )] ValidationFailed { errors: Vec }, #[error("pairing not found: {match_id}")] PairingNotFound { match_id: String }, @@ -716,4 +723,29 @@ mod tests { let config: DraftConfig = serde_json::from_str(json).unwrap(); assert_eq!(config.spectator_visibility, SpectatorVisibility::Public); } + + #[test] + fn validation_failed_display_includes_per_card_errors() { + let err = DraftError::ValidationFailed { + errors: vec![ + LimitedDeckError::ExceedsPoolCount { + name: "Hell's Kitchen".to_string(), + requested: 2, + available: 1, + }, + LimitedDeckError::NotInPool { + name: "Ghost Card".to_string(), + }, + ], + }; + let message = err.to_string(); + assert!( + message.contains("Hell's Kitchen") && message.contains("Ghost Card"), + "Display must name offending cards, got {message}" + ); + assert!( + message.contains("used 2 times") || message.contains("not in the drafted pool"), + "Display must include reasons, got {message}" + ); + } } diff --git a/crates/draft-wasm/src/suggest.rs b/crates/draft-wasm/src/suggest.rs index 60af339815..c99ca7688f 100644 --- a/crates/draft-wasm/src/suggest.rs +++ b/crates/draft-wasm/src/suggest.rs @@ -88,10 +88,17 @@ pub fn suggest_deck( } } - // `main_deck` holds the non-land spells only; `lands` carries the land - // distribution separately. Consumers (the deckbuilder store, `get_bot_deck`) - // concatenate the two — appending lands here as well would double-count them - // (e.g. 23 spells + 17 lands in `main_deck`, then +17 lands again = 57). + // `main_deck` holds drafted cards (spells + nonbasic fixing lands); `lands` + // carries only the always-addable basic fill. Consumers (the deckbuilder + // store, `get_bot_deck`) concatenate the two — never put basics into + // `main_deck` or they'd double-count (e.g. 23 spells + 17 lands in + // `main_deck`, then +17 lands again = 57). + // + // Drafted nonbasics must live in `main_deck`, not `lands`: the limited + // deckbuilder UI only exposes `lands` for `addable_cards` (basics), and + // `computeRemainingPool` only subtracts `mainDeck`. Putting duals in + // `lands` left them visible in the pool so players could add them again, + // then submit expanded both maps → ExceedsPoolCount (#6562). let spell_names: Vec = spells.iter().map(|c| c.name.clone()).collect(); let land_total = min_deck_size.saturating_sub(spell_names.len()) as u8; @@ -109,15 +116,16 @@ pub fn suggest_deck( }; let nonbasic_count: u8 = nonbasic_lands.values().copied().sum(); let basics_total = land_total.saturating_sub(nonbasic_count); - let mut lands = suggest_addable_cards(&spell_names, pool, basics_total, addable_cards); + let lands = suggest_addable_cards(&spell_names, pool, basics_total, addable_cards); + + let mut main_deck = spell_names; for (name, count) in nonbasic_lands { - *lands.entry(name).or_insert(0) += count; + for _ in 0..count { + main_deck.push(name.clone()); + } } - SuggestedDeck { - main_deck: spell_names, - lands, - } + SuggestedDeck { main_deck, lands } } /// On-color drafted nonbasic fixing lands as a `name -> copy-count` map, capped at @@ -480,10 +488,20 @@ mod tests { &DeckAddableCards::standard_basics(), ); assert!( - deck.lands.contains_key("On Color Dual"), - "on-color (W/U) fixing land should be admitted to the manabase, got {:?}", + deck.main_deck.iter().any(|n| n == "On Color Dual"), + "on-color (W/U) fixing land should be admitted onto main_deck, got {:?}", + deck.main_deck + ); + assert!( + !deck.lands.contains_key("On Color Dual"), + "drafted nonbasics must not go in lands (basics-only map), got {:?}", deck.lands ); + assert!( + !deck.main_deck.iter().any(|n| n == "Off Color Dual"), + "off-color (B/R) fixing land must not be admitted, got {:?}", + deck.main_deck + ); assert!( !deck.lands.contains_key("Off Color Dual"), "off-color (B/R) fixing land must not be admitted, got {:?}", @@ -506,7 +524,8 @@ mod tests { assert_eq!( deck.main_deck.len() as u32 + land_count, 8, - "spells + lands must equal min_deck_size; lands = {:?}", + "main_deck + basics must equal min_deck_size; main={:?} lands={:?}", + deck.main_deck, deck.lands ); } @@ -522,6 +541,7 @@ mod tests { 8, &DeckAddableCards::standard_basics(), ); + assert!(!deck.main_deck.iter().any(|n| n == "On Color Dual")); assert!(!deck.lands.contains_key("On Color Dual")); } }