diff --git a/coach/services/deck_generator.py b/coach/services/deck_generator.py index 9773646..ae8f866 100644 --- a/coach/services/deck_generator.py +++ b/coach/services/deck_generator.py @@ -253,6 +253,9 @@ class DeckGeneratorV3: # Step 5: Check ownership and build enriched card list cards_with_status = self._check_ownership(raw_deck.get("cards", [])) + # Step 5b: Fill basic lands to hit exactly 100 cards + cards_with_status = self._fill_basic_lands(cards_with_status, color_identity) + # Step 6: Compute stats stats = self._compute_stats(cards_with_status) @@ -319,6 +322,57 @@ class DeckGeneratorV3: return enriched + # ── Step 5b: Basic Land Fill ────────────────────────────── + _COLOR_TO_BASIC = { + 'W': 'Plains', 'U': 'Island', 'B': 'Swamp', + 'R': 'Mountain', 'G': 'Forest', + } + _BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'} + + def _fill_basic_lands( + self, + cards: List[DeckCardWithStatus], + color_identity: List[str], + target_total: int = 100, + ) -> List[DeckCardWithStatus]: + """ + Strip LLM-provided basics, then add the right number evenly + across the commander's colors to reach exactly target_total. + """ + non_basics = [c for c in cards if c.name not in self._BASIC_NAMES] + current_total = sum(c.count for c in non_basics) + basics_needed = max(0, target_total - current_total) + + if basics_needed == 0: + return non_basics + + ci = [c.upper() for c in color_identity] + basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC] + if not basic_names: + basic_names = ['Wastes'] + + per_color = basics_needed // len(basic_names) + remainder = basics_needed % len(basic_names) + + for i, bname in enumerate(basic_names): + qty = per_color + (1 if i < remainder else 0) + if qty > 0: + non_basics.append(DeckCardWithStatus( + name=bname, + count=qty, + category='Land', + role_tags=[], + reason='Basic land for mana fixing', + estimated_price_usd=0.10, + owned=True, + owned_qty=qty, + status='owned', + )) + + logger.info("Basic land fill: %d basics added across %d color(s)", + basics_needed, len(basic_names)) + return non_basics + # ── Step 6: Smart Substitution ─────────────────────────── def run_substitution( self, diff --git a/lab_api.py b/lab_api.py index 36a0c2d..7afa7dd 100644 --- a/lab_api.py +++ b/lab_api.py @@ -6318,6 +6318,73 @@ def _postprocess_deck_cards(cards: list[dict], color_identity: list[str] | None return processed +# Color-to-basic-land mapping for deterministic fill +_COLOR_TO_BASIC = { + 'W': 'Plains', + 'U': 'Island', + 'B': 'Swamp', + 'R': 'Mountain', + 'G': 'Forest', +} +BASIC_LAND_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'} + + +def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None, target_total: int = 100) -> list[dict]: + """ + Deterministically fill basic lands so the deck hits exactly target_total cards. + + The LLM may list basics with wrong counts, duplicates, or omit them. + This function: + 1. Strips all basic-land entries the LLM provided + 2. Counts remaining cards (non-basics) and their total count + 3. Computes how many basic land slots are needed + 4. Distributes basics evenly across the commander's colors + """ + # Separate basics from non-basics + non_basics = [] + for card in cards: + name = card.get('name', '').strip() + if name not in BASIC_LAND_NAMES: + non_basics.append(card) + + # Count total non-basic cards + non_basic_total = sum(c.get('count', 1) for c in non_basics) + + # How many basic land copies we need + basics_needed = max(0, target_total - non_basic_total) + + if basics_needed == 0: + return non_basics + + # Determine which basics to use from color identity + ci = [c.upper() for c in (color_identity or [])] + basic_names = [_COLOR_TO_BASIC[c] for c in ci if c in _COLOR_TO_BASIC] + if not basic_names: + # Colorless commander — use Wastes + basic_names = ['Wastes'] + + # Distribute evenly, then add remainders one-by-one + per_color = basics_needed // len(basic_names) + remainder = basics_needed % len(basic_names) + + for i, bname in enumerate(basic_names): + qty = per_color + (1 if i < remainder else 0) + if qty > 0: + non_basics.append({ + 'name': bname, + 'count': qty, + 'role': 'land', + 'category': 'Land', + 'role_tags': [], + 'reason': 'Basic land for mana fixing', + 'estimated_price_usd': 0.10, + 'from_collection': False, + 'is_basic': True, + }) + + return non_basics + + # ── Research Endpoint ───────────────────────────────────────── @app.post('/api/deck-research') @@ -6567,6 +6634,8 @@ Build the deck as JSON. Prioritize collection cards when they fit the strategy." # Post-process cards if 'cards' in result and isinstance(result['cards'], list): result['cards'] = _postprocess_deck_cards(result['cards'], color_identity) + # Fill basic lands deterministically to hit exactly 100 + result['cards'] = _fill_basic_lands(result['cards'], color_identity, target_total=100) # Compute real totals real_total = sum(c.get('estimated_price_usd', 0) * c.get('count', 1) for c in result.get('cards', [])) diff --git a/ui/deckgenerator.css b/ui/deckgenerator.css index 73e5964..80f33dc 100644 --- a/ui/deckgenerator.css +++ b/ui/deckgenerator.css @@ -1152,6 +1152,19 @@ font-size: 0.85rem; } +.dg-card-count { + display: inline-block; + background: var(--accent, #6c63ff); + color: #fff; + border-radius: 4px; + padding: 1px 5px; + font-size: 0.7rem; + font-weight: 700; + margin-right: 2px; + min-width: 20px; + text-align: center; +} + .dg-card-row-roles { display: flex; gap: 3px; diff --git a/ui/deckgenerator.js b/ui/deckgenerator.js index f12f7cc..bd22790 100644 --- a/ui/deckgenerator.js +++ b/ui/deckgenerator.js @@ -406,7 +406,7 @@ const DeckGenerator = (() => { html += '
' + '
' + '' + icon + ' ' + escHtml(type) + '' - + '' + groupCards.length + '' + + '' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '' + '
' + '
'; @@ -462,7 +462,7 @@ const DeckGenerator = (() => { return '
' + '
' - + '' + escHtml(card.name) + '' + + '' + ((card.count || 1) > 1 ? '' + card.count + 'x ' : '') + escHtml(card.name) + '' + '' + roleTags + '' + statusBadge + price