doubletap 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
doubletap/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
doubletap/analysis.py ADDED
@@ -0,0 +1,222 @@
1
+ """Deck-level analysis: functional card roles (the "how does this deck win"
2
+ breakdown), mana curve and color balance, and market prices (budget
3
+ constraints). Heuristics run on Scryfall oracle text — approximate by design,
4
+ good enough to spot structural gaps. The gap list this implements is
5
+ documented in docs/gameplay-blindspots.md."""
6
+
7
+ import json
8
+ import re
9
+ import sqlite3
10
+ from collections import Counter
11
+ from dataclasses import dataclass, field
12
+
13
+ # --- Market prices -----------------------------------------------------------
14
+
15
+
16
+ def card_price(card: dict) -> float | None:
17
+ """Cheapest available USD finish from Scryfall, None when unpriced
18
+ (digital-only or brand-new cards)."""
19
+ prices = card.get("prices") or {}
20
+ values = [
21
+ float(p)
22
+ for p in (prices.get("usd"), prices.get("usd_foil"), prices.get("usd_etched"))
23
+ if p
24
+ ]
25
+ return min(values) if values else None
26
+
27
+
28
+ # --- Functional roles --------------------------------------------------------
29
+
30
+ # Each pattern runs case-insensitively against the card's combined oracle text.
31
+ _ROLE_PATTERNS = {
32
+ "ramp": re.compile(
33
+ r"add \{|search your library for (?:a|up to \w+) (?:basic )?land", re.I
34
+ ),
35
+ "draw": re.compile(r"draws? (?:a|two|three|four|x) cards?", re.I),
36
+ "removal": re.compile(
37
+ r"destroy target|exile target|counter target"
38
+ r"|deals? \d+ damage to (?:any target|target creature|target planeswalker)"
39
+ r"|target creature gets? [-—]\d+/[-—]\d+",
40
+ re.I,
41
+ ),
42
+ "board_wipe": re.compile(
43
+ r"destroy all|exile all|deals? \d+ damage to each creature"
44
+ r"|all creatures get [-—]\d+/[-—]\d+",
45
+ re.I,
46
+ ),
47
+ "wincon": re.compile(r"you win the game|each opponent loses the game", re.I),
48
+ "mill": re.compile(
49
+ r"\bmills?\b"
50
+ r"|puts? the top .{0,30} cards? of (?:their|that player's|each player's)"
51
+ r" library into (?:their|its owner's) graveyard",
52
+ re.I,
53
+ ),
54
+ }
55
+
56
+ # "search your library for <what>" is a tutor unless <what> is a land (that's
57
+ # ramp / mana fixing, already counted)
58
+ _TUTOR_RE = re.compile(r"search your library for ([^.\n]*)", re.I)
59
+
60
+ EVASION_KEYWORDS = frozenset(
61
+ {"Flying", "Trample", "Menace", "Fear", "Intimidate", "Shadow", "Skulk"}
62
+ )
63
+ POISON_KEYWORDS = frozenset({"Infect", "Toxic"})
64
+
65
+ BIG_THREAT_POWER = 5
66
+
67
+ # Community consensus quotas for a functional Commander deck. Not rules —
68
+ # a starting point for spotting gaps.
69
+ COMMANDER_TARGETS = {
70
+ "lands": 36,
71
+ "ramp": 10,
72
+ "draw": 10,
73
+ "removal": 10,
74
+ "board_wipe": 3,
75
+ }
76
+
77
+
78
+ def _oracle_text(card: dict) -> str:
79
+ if "oracle_text" in card:
80
+ return card["oracle_text"]
81
+ return "\n".join(f.get("oracle_text", "") for f in card.get("card_faces", []))
82
+
83
+
84
+ def _mana_cost(card: dict) -> str:
85
+ if card.get("mana_cost"):
86
+ return card["mana_cost"]
87
+ faces = card.get("card_faces") or []
88
+ return faces[0].get("mana_cost", "") if faces else ""
89
+
90
+
91
+ def _power(card: dict) -> int:
92
+ for source in (card, *card.get("card_faces", [])):
93
+ raw = source.get("power")
94
+ if raw and raw.isdigit():
95
+ return int(raw)
96
+ return 0
97
+
98
+
99
+ def _is_land(card: dict) -> bool:
100
+ return "Land" in card["type_line"].split(" // ")[0]
101
+
102
+
103
+ def is_instant_speed(card: dict) -> bool:
104
+ """Castable on other players' turns: an instant, or anything with flash."""
105
+ return "Instant" in card["type_line"].split("//")[0] or "Flash" in card.get(
106
+ "keywords", []
107
+ )
108
+
109
+
110
+ def classify(card: dict) -> set[str]:
111
+ """Which functional roles a card fills. A card can fill several
112
+ (e.g. a creature that ramps); lands are counted separately."""
113
+ if _is_land(card):
114
+ return {"land"}
115
+ text = _oracle_text(card)
116
+ keywords = set(card.get("keywords", []))
117
+ roles = {role for role, pat in _ROLE_PATTERNS.items() if pat.search(text)}
118
+
119
+ tutor_match = _TUTOR_RE.search(text)
120
+ if tutor_match and "land" not in tutor_match.group(1).casefold():
121
+ roles.add("tutor")
122
+
123
+ is_creature = "Creature" in card["type_line"]
124
+ # big creatures are a path to winning through combat even without
125
+ # explicit wincon text
126
+ if is_creature and _power(card) >= BIG_THREAT_POWER:
127
+ roles.add("threat")
128
+ if is_creature and (
129
+ keywords & EVASION_KEYWORDS or "can't be blocked" in text.casefold()
130
+ ):
131
+ roles.add("evasive")
132
+ if keywords & POISON_KEYWORDS:
133
+ roles.add("poison")
134
+ # removal you can cast on opponents' turns is worth more than sorceries
135
+ if "removal" in roles and is_instant_speed(card):
136
+ roles.add("removal_instant")
137
+ return roles
138
+
139
+
140
+ # --- Curve and color balance ---------------------------------------------------
141
+
142
+ CURVE_TOP_BUCKET = 7 # mana values 7+ share one histogram bucket
143
+
144
+
145
+ @dataclass
146
+ class DeckReport:
147
+ by_role: dict[str, list[tuple[str, int]]] = field(default_factory=dict)
148
+ total_price: float = 0.0
149
+ unpriced: int = 0
150
+ curve: Counter = field(default_factory=Counter) # mv bucket -> nonland count
151
+ avg_mv: float = 0.0
152
+ early_plays: int = 0 # nonland cards with mv <= 2
153
+ pips: Counter = field(default_factory=Counter) # color -> symbols in costs
154
+ sources: Counter = field(default_factory=Counter) # color -> lands making it
155
+
156
+
157
+ def deck_report(conn: sqlite3.Connection, entries: dict[str, int]) -> DeckReport:
158
+ """Full structural report for a deck (oracle_id -> qty): roles, price,
159
+ mana curve, and colored-cost vs land-color balance."""
160
+ report = DeckReport()
161
+ total_nonland = 0
162
+ total_mv = 0.0
163
+ for oid, qty in entries.items():
164
+ row = conn.execute(
165
+ "SELECT name, json FROM cards WHERE oracle_id = ?", (oid,)
166
+ ).fetchone()
167
+ if row is None:
168
+ continue
169
+ name, raw = row
170
+ card = json.loads(raw)
171
+ for role in classify(card):
172
+ report.by_role.setdefault(role, []).append((name, qty))
173
+
174
+ price = card_price(card)
175
+ if price is None:
176
+ report.unpriced += qty
177
+ else:
178
+ report.total_price += price * qty
179
+
180
+ if _is_land(card):
181
+ for color in card.get("produced_mana") or []:
182
+ if color in "WUBRG":
183
+ report.sources[color] += qty
184
+ else:
185
+ mv = card.get("cmc") or 0
186
+ report.curve[min(int(mv), CURVE_TOP_BUCKET)] += qty
187
+ total_nonland += qty
188
+ total_mv += mv * qty
189
+ if mv <= 2:
190
+ report.early_plays += qty
191
+ for symbol in re.findall(r"\{([^}]+)\}", _mana_cost(card)):
192
+ for color in "WUBRG":
193
+ if color in symbol:
194
+ report.pips[color] += qty
195
+ if total_nonland:
196
+ report.avg_mv = total_mv / total_nonland
197
+ return report
198
+
199
+
200
+ def short_colors(report: DeckReport) -> list[str]:
201
+ """Colors whose share of land sources falls well below their share of
202
+ mana symbols — hands with those spells won't be castable on time."""
203
+ pip_total = sum(report.pips.values())
204
+ source_total = sum(report.sources.values())
205
+ if not pip_total or not source_total:
206
+ return []
207
+ short = []
208
+ for color, n in report.pips.items():
209
+ need = n / pip_total
210
+ have = report.sources.get(color, 0) / source_total
211
+ if need > 0.1 and have < need * 0.6:
212
+ short.append(color)
213
+ return short
214
+
215
+
216
+ def analyze_deck(
217
+ conn: sqlite3.Connection, entries: dict[str, int]
218
+ ) -> tuple[dict[str, list[tuple[str, int]]], float, int]:
219
+ """Classify every card in a deck (oracle_id -> qty). Returns
220
+ (role -> [(name, qty)], total_price_usd, n_unpriced)."""
221
+ report = deck_report(conn, entries)
222
+ return report.by_role, report.total_price, report.unpriced
doubletap/archidekt.py ADDED
@@ -0,0 +1,330 @@
1
+ import gzip
2
+ import json
3
+ import random
4
+ import sqlite3
5
+ import time
6
+ from collections import Counter
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+ from .db import data_home
13
+ from .decks import Deck
14
+ from .formats import get_format, validate
15
+
16
+ SEARCH_URL = "https://archidekt.com/api/decks/v3/"
17
+ DECK_URL = "https://archidekt.com/api/decks/{deck_id}/"
18
+ USER_AGENT = "DoubleTap/0.1 (joshua.magana@gmail.com)"
19
+
20
+ # verified empirically 2026-07-04: deckFormat=2 returns Modern decks,
21
+ # deckFormat=3 returns Commander decks (7 is Custom — do not trust folklore ids)
22
+ FORMAT_IDS = {"commander": 3, "modern": 2}
23
+
24
+ _DROPPED_CATEGORIES = {
25
+ "sideboard",
26
+ "maybeboard",
27
+ "considering",
28
+ "wishlist",
29
+ "tokens",
30
+ # the companion sits outside the starting deck; v1 corpus ignores it
31
+ "companion",
32
+ }
33
+ MAX_UNRESOLVED_FRACTION = 0.02
34
+
35
+
36
+ class RateLimiter:
37
+ def __init__(
38
+ self,
39
+ interval: float = 1.0,
40
+ jitter: float = 0.3,
41
+ sleep=time.sleep,
42
+ clock=time.monotonic,
43
+ ):
44
+ self.interval = interval
45
+ self.jitter = jitter
46
+ self.sleep = sleep
47
+ self.clock = clock
48
+ self._last: float | None = None
49
+
50
+ def wait(self) -> None:
51
+ if self._last is not None:
52
+ delay = (
53
+ self.interval
54
+ + random.uniform(0, self.jitter)
55
+ - (self.clock() - self._last)
56
+ )
57
+ if delay > 0:
58
+ self.sleep(delay)
59
+ self._last = self.clock()
60
+
61
+
62
+ def make_client() -> httpx.Client:
63
+ return httpx.Client(
64
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
65
+ timeout=60,
66
+ follow_redirects=True,
67
+ )
68
+
69
+
70
+ def get_json(
71
+ client: httpx.Client,
72
+ limiter: RateLimiter,
73
+ url: str,
74
+ params: dict | None = None,
75
+ max_retries: int = 5,
76
+ ):
77
+ """GET with rate limiting and exponential backoff on 429/5xx and transport
78
+ failures (dropped connections, timeouts); hard stop after retries."""
79
+ for attempt in range(max_retries):
80
+ limiter.wait()
81
+ try:
82
+ resp = client.get(url, params=params)
83
+ except httpx.TransportError:
84
+ limiter.sleep(2**attempt)
85
+ continue
86
+ if resp.status_code == 429 or resp.status_code >= 500:
87
+ limiter.sleep(2**attempt)
88
+ continue
89
+ resp.raise_for_status()
90
+ return resp.json()
91
+ raise RuntimeError(
92
+ f"giving up on {url} after {max_retries} attempts (rate limited?)"
93
+ )
94
+
95
+
96
+ def discover(
97
+ conn: sqlite3.Connection,
98
+ client: httpx.Client,
99
+ limiter: RateLimiter,
100
+ format_name: str,
101
+ max_decks: int,
102
+ order_by: str = "-viewCount",
103
+ ) -> int:
104
+ """Queue deck ids from the search API. Idempotent.
105
+
106
+ Pages are requested explicitly: the API's `next` link goes null around
107
+ page 100 (~6k decks) but direct page access keeps returning results far
108
+ deeper (verified to page 700), so `next` cannot be trusted for large
109
+ crawls. Stops on the first empty page.
110
+
111
+ The max_decks bound counts entries *seen*, not newly inserted — a resumed
112
+ crawl re-walks the same pages, and counting inserts would keep it paging
113
+ until it found max_decks decks it had never seen. max_decks=0 skips
114
+ discovery entirely (fetch the existing queue only)."""
115
+ fmt_id = FORMAT_IDS[format_name]
116
+ queued = 0
117
+ seen = 0
118
+ page = 1
119
+ while seen < max_decks:
120
+ data = get_json(
121
+ client,
122
+ limiter,
123
+ SEARCH_URL,
124
+ params={"deckFormat": fmt_id, "orderBy": order_by, "page": page},
125
+ )
126
+ results = data.get("results", [])
127
+ if not results:
128
+ break
129
+ for entry in results:
130
+ cur = conn.execute(
131
+ "INSERT OR IGNORE INTO decks (deck_id, source, format, url, status) VALUES (?, 'archidekt', ?, ?, 'queued')",
132
+ (
133
+ entry["id"],
134
+ format_name,
135
+ f"https://archidekt.com/decks/{entry['id']}/",
136
+ ),
137
+ )
138
+ queued += cur.rowcount
139
+ seen += 1
140
+ if seen >= max_decks:
141
+ break
142
+ page += 1
143
+ conn.commit()
144
+ return queued
145
+
146
+
147
+ def trim_deck(raw: dict, format_name: str) -> dict:
148
+ """Reduce a deck API response to the fields the corpus needs."""
149
+ cards = []
150
+ for entry in raw.get("cards", []):
151
+ oracle = entry["card"].get("oracleCard") or {}
152
+ cards.append(
153
+ {
154
+ "qty": entry.get("quantity", 1),
155
+ "oracle_id": oracle.get("uid"),
156
+ "name": oracle.get("name"),
157
+ "categories": entry.get("categories") or [],
158
+ }
159
+ )
160
+ return {
161
+ "id": raw["id"],
162
+ "name": raw.get("name"),
163
+ "format": format_name,
164
+ "cards": cards,
165
+ }
166
+
167
+
168
+ def shard_path(format_name: str) -> Path:
169
+ path = data_home() / "corpus" / "raw" / f"{format_name}.jsonl.gz"
170
+ path.parent.mkdir(parents=True, exist_ok=True)
171
+ return path
172
+
173
+
174
+ def fetch_queued(
175
+ conn: sqlite3.Connection,
176
+ client: httpx.Client,
177
+ limiter: RateLimiter,
178
+ format_name: str,
179
+ progress=None,
180
+ ) -> int:
181
+ """Fetch queued decks, appending trimmed records to the raw shard. Resumable:
182
+ already-fetched decks are never requested again. Decks the API refuses with
183
+ a 4xx (deleted/private) are marked 'gone' and skipped — one dead deck must
184
+ not kill a large crawl."""
185
+ rows = conn.execute(
186
+ "SELECT deck_id FROM decks WHERE source = 'archidekt' AND format = ? AND status = 'queued'",
187
+ (format_name,),
188
+ ).fetchall()
189
+ fetched = 0
190
+ path = shard_path(format_name)
191
+ for (deck_id,) in rows:
192
+ try:
193
+ raw = get_json(client, limiter, DECK_URL.format(deck_id=deck_id))
194
+ except httpx.HTTPStatusError as e:
195
+ if 400 <= e.response.status_code < 500:
196
+ conn.execute(
197
+ "UPDATE decks SET status = 'gone', fetched_at = datetime('now') WHERE deck_id = ?",
198
+ (deck_id,),
199
+ )
200
+ conn.commit()
201
+ continue
202
+ raise
203
+ with gzip.open(path, "at", encoding="utf-8") as f:
204
+ f.write(json.dumps(trim_deck(raw, format_name), ensure_ascii=False) + "\n")
205
+ conn.execute(
206
+ "UPDATE decks SET status = 'fetched', fetched_at = datetime('now') WHERE deck_id = ?",
207
+ (deck_id,),
208
+ )
209
+ conn.commit()
210
+ fetched += 1
211
+ if progress:
212
+ progress(fetched, len(rows))
213
+ return fetched
214
+
215
+
216
+ def parse_trimmed(conn: sqlite3.Connection, trimmed: dict) -> tuple[Deck | None, str]:
217
+ """Build a validated Deck from a trimmed record; (None, reason) on rejection."""
218
+ fmt = get_format(trimmed["format"])
219
+ deck = Deck(format=fmt.name)
220
+ commanders = []
221
+ unresolved = 0
222
+ total = 0
223
+ known = set()
224
+ for card in trimmed["cards"]:
225
+ categories = {c.casefold() for c in card["categories"]}
226
+ if categories & _DROPPED_CATEGORIES:
227
+ continue
228
+ total += card["qty"]
229
+ oid = card["oracle_id"]
230
+ if oid not in known:
231
+ exists = conn.execute(
232
+ "SELECT 1 FROM cards WHERE oracle_id = ?", (oid,)
233
+ ).fetchone()
234
+ if not exists:
235
+ unresolved += card["qty"]
236
+ continue
237
+ known.add(oid)
238
+ if "commander" in categories:
239
+ commanders.append(oid)
240
+ else:
241
+ deck.entries[oid] += card["qty"]
242
+ if total == 0:
243
+ return None, "empty"
244
+ if unresolved / total > MAX_UNRESOLVED_FRACTION:
245
+ return None, "unresolved_cards"
246
+ if fmt.requires_commander:
247
+ if len(commanders) == 1:
248
+ deck.commander = commanders[0]
249
+ elif len(commanders) == 2:
250
+ # Accept partner commanders: both must carry a Partner keyword
251
+ def _has_partner(oid):
252
+ row = conn.execute(
253
+ "SELECT json FROM cards WHERE oracle_id = ?", (oid,)
254
+ ).fetchone()
255
+ if not row:
256
+ return False
257
+ card = json.loads(row[0])
258
+ return any(k.startswith("Partner") for k in card.get("keywords", []))
259
+
260
+ if _has_partner(commanders[0]) and _has_partner(commanders[1]):
261
+ deck.commander = commanders[0]
262
+ deck.partner = commanders[1]
263
+ else:
264
+ return None, "commander_count"
265
+ else:
266
+ return None, "commander_count"
267
+ elif commanders:
268
+ return None, "unexpected_commander"
269
+ if validate(conn, deck):
270
+ return None, "invalid"
271
+ return deck, "ok"
272
+
273
+
274
+ def parse_shards(conn: sqlite3.Connection, format_name: str) -> Counter:
275
+ """(Re)parse the raw shard into decks/deck_cards. Only fetched/parsed/rejected
276
+ rows are touched, so this can rebuild tables without re-crawling."""
277
+ outcomes = Counter()
278
+ path = shard_path(format_name)
279
+ if not path.exists():
280
+ return outcomes
281
+ with gzip.open(path, "rt", encoding="utf-8") as f:
282
+ for line in f:
283
+ trimmed = json.loads(line)
284
+ deck, reason = parse_trimmed(conn, trimmed)
285
+ outcomes[reason] += 1
286
+ deck_id = trimmed["id"]
287
+ conn.execute("DELETE FROM deck_cards WHERE deck_id = ?", (deck_id,))
288
+ if deck is None:
289
+ conn.execute(
290
+ "UPDATE decks SET status = 'rejected' WHERE deck_id = ?", (deck_id,)
291
+ )
292
+ continue
293
+ conn.execute(
294
+ "UPDATE decks SET status = 'parsed', commander_oracle_id = ?, partner_oracle_id = ? WHERE deck_id = ?",
295
+ (deck.commander, deck.partner, deck_id),
296
+ )
297
+ conn.executemany(
298
+ "INSERT OR REPLACE INTO deck_cards (deck_id, oracle_id, qty) VALUES (?, ?, ?)",
299
+ [(deck_id, oid, qty) for oid, qty in deck.entries.items()],
300
+ )
301
+ for cmd_oid in (deck.commander, deck.partner):
302
+ if cmd_oid:
303
+ conn.execute(
304
+ "INSERT OR REPLACE INTO deck_cards (deck_id, oracle_id, qty) VALUES (?, ?, 1)",
305
+ (deck_id, cmd_oid),
306
+ )
307
+ conn.commit()
308
+ return outcomes
309
+
310
+
311
+ def crawl(
312
+ conn: sqlite3.Connection,
313
+ format_name: str,
314
+ max_decks: int,
315
+ client: httpx.Client | None = None,
316
+ limiter: RateLimiter | None = None,
317
+ progress=None,
318
+ order_by: str = "-viewCount",
319
+ ) -> Counter:
320
+ get_format(format_name) # fail fast on unknown formats
321
+ own_client = client is None
322
+ client = client or make_client()
323
+ limiter = limiter or RateLimiter()
324
+ try:
325
+ discover(conn, client, limiter, format_name, max_decks, order_by=order_by)
326
+ fetch_queued(conn, client, limiter, format_name, progress=progress)
327
+ return parse_shards(conn, format_name)
328
+ finally:
329
+ if own_client:
330
+ client.close()