File size: 8,696 Bytes
16eaadc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
"""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)