Deck generator: deterministic basic land fill + frontend count display
- Added _fill_basic_lands() post-processing for V1 and V3 generators - Strips LLM-provided basics, then distributes evenly across commander colors - Always hits exactly 100 cards regardless of LLM output - Frontend group counts now sum card.count instead of entry count - Cards with count > 1 show '5x Forest' style prefix - Colorless commanders get Wastes
This commit is contained in:
1 parent
7f7ad62087
commit
f09098d017
4 files changed
+138
-2
No files matched your search
@@ -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,
|
||||
|
||||
+69
@@ -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', []))
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -406,7 +406,7 @@ const DeckGenerator = (() => {
|
||||
html += '<div class="dg-card-group">'
|
||||
+ '<div class="dg-group-header" data-type="' + type + '">'
|
||||
+ '<span class="dg-group-title">' + icon + ' ' + escHtml(type) + '</span>'
|
||||
+ '<span class="dg-group-count">' + groupCards.length + '</span>'
|
||||
+ '<span class="dg-group-count">' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="dg-group-body" data-group="' + type + '">';
|
||||
|
||||
@@ -462,7 +462,7 @@ const DeckGenerator = (() => {
|
||||
|
||||
return '<div class="dg-card-row dg-card-status-' + card.status + '">'
|
||||
+ '<div class="dg-card-row-main">'
|
||||
+ '<span class="dg-card-row-name">' + escHtml(card.name) + '</span>'
|
||||
+ '<span class="dg-card-row-name">' + ((card.count || 1) > 1 ? '<span class="dg-card-count">' + card.count + 'x</span> ' : '') + escHtml(card.name) + '</span>'
|
||||
+ '<span class="dg-card-row-roles">' + roleTags + '</span>'
|
||||
+ statusBadge
|
||||
+ price
|
||||
|
||||
Reference in new issue
Block a user