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:
KoalaTrapLord committed 2026-03-12 17:44:30 +00:00
1 parent 7f7ad62087
commit f09098d017
4 files changed
+138 -2

No files matched your search

+54
View File
@@ -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
View File
@@ -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', []))
+13
View File
@@ -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
View File
@@ -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