"""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, `x `, 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)