t22000t's picture
Initial commit: optcg-deck-builder Gradio Space
16eaadc
"""Auto-build a 50-card OPTCG deck around a chosen Leader.
Algorithm
---------
1. Score every color-legal candidate via `recommend_synergy`
(cosine_similarity to leader + family bonus). Other Leader cards
and the chosen leader itself are excluded by the synergy step.
2. Walk a *target cost curve* (chosen by `style`) bucket by bucket.
For each bucket, take top-synergy cards in that cost slot, assigning
up to `max_copies` per card, until the bucket is filled.
3. If a cost bucket has insufficient candidates (rare in real corpora,
common in narrow synthetic fixtures), the deficit spills into a
final backfill pass that consumes the highest-synergy remaining
cards regardless of cost. Backfill respects the per-card copy cap.
The result is always exactly 50 cards. Color legality and the copy cap
are hard invariants enforced at every step.
Cost curve presets
------------------
- aggro: weighted to 1-3 cost - flood the early board.
- midrange: 3-6 cost dominant - the safe default.
- control: 4-8 cost weighted - bigger threats, less early presence.
Each preset is a dict[int, int] summing to exactly 50.
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from spaceutil.synergy import recommend_synergy
DEFAULT_DECK_SIZE = 50
DEFAULT_MAX_COPIES = 4
# Each preset must sum to exactly DEFAULT_DECK_SIZE. The cost-8 bucket
# captures everything 8+ (8, 9, 10, ...).
COST_CURVES: dict[str, dict[int, int]] = {
"aggro": {1: 4, 2: 12, 3: 12, 4: 8, 5: 6, 6: 4, 7: 2, 8: 2},
"midrange": {1: 0, 2: 6, 3: 10, 4: 10, 5: 8, 6: 8, 7: 4, 8: 4},
"control": {1: 0, 2: 4, 3: 8, 4: 8, 5: 8, 6: 8, 7: 6, 8: 8},
}
@dataclass(frozen=True)
class DeckCard:
card_id: str
name: str
quantity: int
cost: int | None
card_type: str
colors: list[str]
family: list[str]
rarity: str
set_code: str
synergy_score: float
family_match: bool
@dataclass(frozen=True)
class Deck:
leader: dict[str, Any]
cards: list[DeckCard]
style: str
target_curve: dict[int, int] = field(default_factory=dict)
@property
def total_quantity(self) -> int:
return sum(c.quantity for c in self.cards)
@property
def total_cost(self) -> int:
return sum((c.cost or 0) * c.quantity for c in self.cards)
@property
def avg_cost(self) -> float:
# Average is over the cards that actually have a cost (Stages
# without cost are excluded from the denominator). For typical
# OPTCG decks ~all cards have cost, so this matches intuition.
priced = [c for c in self.cards if c.cost is not None]
total_qty = sum(c.quantity for c in priced)
if total_qty == 0:
return 0.0
return sum((c.cost or 0) * c.quantity for c in priced) / total_qty
@property
def cost_distribution(self) -> dict[int, int]:
dist: dict[int, int] = {}
for c in self.cards:
if c.cost is None:
continue
bucket = min(int(c.cost), 8)
dist[bucket] = dist.get(bucket, 0) + c.quantity
return dist
@property
def type_distribution(self) -> dict[str, int]:
dist: dict[str, int] = {}
for c in self.cards:
dist[c.card_type] = dist.get(c.card_type, 0) + c.quantity
return dist
@property
def color_distribution(self) -> dict[str, int]:
dist: dict[str, int] = {}
for c in self.cards:
for color in c.colors or ["?"]:
dist[color] = dist.get(color, 0) + c.quantity
return dist
@property
def family_match_count(self) -> int:
return sum(c.quantity for c in self.cards if c.family_match)
def build_deck(
leader_idx: int,
cards: list[dict[str, Any]],
matrix: np.ndarray,
style: str = "midrange",
max_copies: int = DEFAULT_MAX_COPIES,
deck_size: int = DEFAULT_DECK_SIZE,
) -> Deck:
if style not in COST_CURVES:
raise ValueError(
f"Unknown style {style!r}. Available: {sorted(COST_CURVES)}"
)
leader = cards[leader_idx]
if leader.get("card_type") != "Leader":
raise ValueError(
f"Card at index {leader_idx} ({leader.get('id')!r}) is not a Leader"
)
# Pull every color-legal candidate (synergy-ranked, leaders/self excluded).
all_hits = recommend_synergy(leader_idx, cards, matrix, k=len(cards))
# Group by cost bucket; cost None goes into a separate "no-cost" pile
# and is only used during backfill (most OPTCG cards have a cost).
by_bucket: dict[int, list] = defaultdict(list)
no_cost: list = []
for hit in all_hits:
if hit.cost is None:
no_cost.append(hit)
else:
by_bucket[min(int(hit.cost), 8)].append(hit)
target = COST_CURVES[style]
deck: list[DeckCard] = []
copies_used: dict[str, int] = defaultdict(int)
total = 0
# Pass 1: fill each bucket from its top-synergy candidates.
for cost in sorted(target.keys()):
want = min(target[cost], deck_size - total)
taken = 0
for hit in by_bucket.get(cost, []):
if taken >= want:
break
available = max_copies - copies_used[hit.card_id]
if available <= 0:
continue
qty = min(available, want - taken)
deck.append(_to_deck_card(hit, qty, cards))
copies_used[hit.card_id] += qty
taken += qty
total += qty
# Pass 2: backfill any remainder from the highest-synergy candidates
# not yet at their copy cap, regardless of cost. This is what makes
# the size invariant hold even when the target curve is unfillable
# at exact cost slots.
if total < deck_size:
for hit in all_hits:
if total >= deck_size:
break
available = max_copies - copies_used[hit.card_id]
if available <= 0:
continue
qty = min(available, deck_size - total)
# If we've already added this card, bump its quantity rather
# than appending a duplicate row.
existing = next((dc for dc in deck if dc.card_id == hit.card_id), None)
if existing is None:
deck.append(_to_deck_card(hit, qty, cards))
else:
deck[deck.index(existing)] = _bump_quantity(existing, qty)
copies_used[hit.card_id] += qty
total += qty
# Sort the final deck by cost (asc), then synergy (desc) for nice display.
deck.sort(key=lambda dc: (dc.cost if dc.cost is not None else 99, -dc.synergy_score))
return Deck(
leader=leader,
cards=deck,
style=style,
target_curve=dict(target),
)
def _to_deck_card(hit, qty: int, cards: list[dict[str, Any]]) -> DeckCard:
# `hit` carries most fields; we look up family/rarity from the source
# card by id since SynergyHit doesn't carry them.
full = next((c for c in cards if c.get("id") == hit.card_id), None) or {}
return DeckCard(
card_id=hit.card_id,
name=hit.name,
quantity=qty,
cost=hit.cost,
card_type=hit.card_type,
colors=hit.colors,
family=list(full.get("family") or []),
rarity=str(full.get("rarity") or ""),
set_code=hit.set_code,
synergy_score=hit.total_score,
family_match=hit.family_match,
)
def _bump_quantity(dc: DeckCard, extra: int) -> DeckCard:
return DeckCard(
card_id=dc.card_id,
name=dc.name,
quantity=dc.quantity + extra,
cost=dc.cost,
card_type=dc.card_type,
colors=dc.colors,
family=dc.family,
rarity=dc.rarity,
set_code=dc.set_code,
synergy_score=dc.synergy_score,
family_match=dc.family_match,
)
def deck_to_text(deck: Deck) -> str:
"""Plain-text deck list. Format mirrors common OPTCG-Sim conventions:
one card per line, `<qty>x <ID> <Name>`, plus a summary header.
"""
lines: list[str] = []
leader = deck.leader
lines.append("# OPTCG deck")
lines.append(f"# Style: {deck.style}")
lines.append(f"# Total: {deck.total_quantity} cards")
lines.append(f"# Avg cost: {deck.avg_cost:.2f}")
lines.append("")
lines.append("## Leader")
lines.append(f"1x {leader.get('id', '?')} {leader.get('name', '?')}")
lines.append("")
lines.append("## Main deck")
for dc in deck.cards:
lines.append(f"{dc.quantity}x {dc.card_id} {dc.name}")
return "\n".join(lines)