Spaces:
Sleeping
Sleeping
| """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}, | |
| } | |
| 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 | |
| class Deck: | |
| leader: dict[str, Any] | |
| cards: list[DeckCard] | |
| style: str | |
| target_curve: dict[int, int] = field(default_factory=dict) | |
| def total_quantity(self) -> int: | |
| return sum(c.quantity for c in self.cards) | |
| def total_cost(self) -> int: | |
| return sum((c.cost or 0) * c.quantity for c in self.cards) | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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) | |