stevenkhan commited on
Commit
60c0181
·
verified ·
1 Parent(s): 9266a5a

Upload clashcr/utils/card_registry.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. clashcr/utils/card_registry.py +285 -0
clashcr/utils/card_registry.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Card registry synced from RoyaleAPI and Official Clash Royale API.
2
+
3
+ Sources:
4
+ - RoyaleAPI static data: https://royaleapi.github.io/cr-api-data/json/cards.json
5
+ - Official API: https://api.clashroyale.com/v1/cards (requires token)
6
+ - RoyaleAPI new-card feed: https://royaleapi.com/blog/tags/new-card?lang=en
7
+
8
+ Usage:
9
+ registry = CardRegistry()
10
+ registry.sync()
11
+ print(registry.cards)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import time
18
+ from dataclasses import dataclass, field, asdict
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Dict, List, Optional, Set
22
+
23
+ import requests
24
+ import yaml
25
+
26
+
27
+ ROYALEAPI_CARDS_URL = "https://royaleapi.github.io/cr-api-data/json/cards.json"
28
+ OFFICIAL_CARDS_URL = "https://api.clashroyale.com/v1/cards"
29
+ ROYALEAPI_NEW_CARD_FEED = "https://royaleapi.com/blog/tags/new-card?lang=en"
30
+
31
+
32
+ @dataclass
33
+ class Card:
34
+ id: str
35
+ name: str
36
+ sc_key: str
37
+ elixir_cost: int
38
+ card_type: str # Troop, Spell, Building
39
+ rarity: str
40
+ is_evolution: bool = False
41
+ is_hero: bool = False
42
+ is_tower_troop: bool = False
43
+ is_champion: bool = False
44
+ icon_url: Optional[str] = None
45
+ image_url: Optional[str] = None
46
+ evolution_parent: Optional[str] = None # base card name if this is an evolution
47
+ hero_parent: Optional[str] = None # base card name if this is a hero variant
48
+
49
+
50
+ @dataclass
51
+ class SyncMetadata:
52
+ timestamp: str
53
+ royaleapi_url: str
54
+ official_api_url: str
55
+ new_card_feed_url: str
56
+ card_count: int
57
+ source_card_count_royaleapi: int
58
+ source_card_count_official: int
59
+ missing_from_official: List[str] = field(default_factory=list)
60
+ missing_from_royaleapi: List[str] = field(default_factory=list)
61
+ warnings: List[str] = field(default_factory=list)
62
+
63
+
64
+ class CardRegistry:
65
+ """Maintains an up-to-date card registry with sync capabilities."""
66
+
67
+ def __init__(self, cache_path: Optional[str] = None):
68
+ self.cache_path = Path(cache_path) if cache_path else Path.home() / ".clashcr" / "cards_registry.json"
69
+ self.cards: Dict[str, Card] = {}
70
+ self.metadata: Optional[SyncMetadata] = None
71
+ self._by_sc_key: Dict[str, Card] = {}
72
+ self._by_name_lower: Dict[str, Card] = {}
73
+
74
+ def _fetch_royaleapi(self) -> List[dict]:
75
+ resp = requests.get(ROYALEAPI_CARDS_URL, timeout=30)
76
+ resp.raise_for_status()
77
+ data = resp.json()
78
+ if isinstance(data, dict) and "items" in data:
79
+ return data["items"]
80
+ return data
81
+
82
+ def _fetch_official(self, api_token: Optional[str] = None) -> List[dict]:
83
+ token = api_token or os.environ.get("CLASH_ROYALE_API_TOKEN")
84
+ if not token:
85
+ return []
86
+ headers = {"Authorization": f"Bearer {token}"}
87
+ resp = requests.get(OFFICIAL_CARDS_URL, headers=headers, timeout=30)
88
+ resp.raise_for_status()
89
+ data = resp.json()
90
+ return data.get("items", [])
91
+
92
+ def _normalize_name(self, name: str) -> str:
93
+ return name.lower().replace(" ", "-").replace(".", "").replace("'", "")
94
+
95
+ def _classify_card(self, raw: dict) -> Card:
96
+ name = raw.get("name", "")
97
+ sc_key = raw.get("sc_key", "")
98
+ rarity = raw.get("rarity", "")
99
+ card_type = raw.get("type", "")
100
+ elixir = raw.get("elixir", -1)
101
+ # Handle string elixir
102
+ if isinstance(elixir, str):
103
+ try:
104
+ elixir = int(elixir)
105
+ except ValueError:
106
+ elixir = -1
107
+
108
+ is_champion = rarity.lower() == "champion"
109
+ is_hero = "hero" in name.lower() or "hero" in sc_key.lower()
110
+ is_evolution = raw.get("is_evolution", False) or "-evolution" in sc_key.lower() or "evolution" in name.lower()
111
+ is_tower_troop = "tower" in card_type.lower() or raw.get("is_tower_troop", False)
112
+
113
+ # Infer parent for evolutions / heroes
114
+ evolution_parent = None
115
+ hero_parent = None
116
+ if is_evolution and not is_hero:
117
+ # Strip evolution suffixes to guess parent
118
+ base = name.lower().replace(" evolution", "").replace("-evolution", "").replace(" evolved ", " ")
119
+ evolution_parent = base.strip()
120
+ if is_hero:
121
+ base = name.lower().replace("hero ", "").replace("hero-", "").strip()
122
+ hero_parent = base
123
+
124
+ return Card(
125
+ id=str(raw.get("id", "")),
126
+ name=name,
127
+ sc_key=sc_key,
128
+ elixir_cost=elixir,
129
+ card_type=card_type,
130
+ rarity=rarity,
131
+ is_evolution=is_evolution,
132
+ is_hero=is_hero,
133
+ is_tower_troop=is_tower_troop,
134
+ is_champion=is_champion,
135
+ icon_url=raw.get("icon_url") or raw.get("iconUrls", {}).get("medium"),
136
+ image_url=raw.get("image_url") or raw.get("iconUrls", {}).get("medium"),
137
+ evolution_parent=evolution_parent,
138
+ hero_parent=hero_parent,
139
+ )
140
+
141
+ def sync(self, api_token: Optional[str] = None, fail_on_stale: bool = True) -> SyncMetadata:
142
+ """Sync cards from RoyaleAPI and Official API.
143
+
144
+ Args:
145
+ api_token: Official Clash Royale API bearer token.
146
+ fail_on_stale: If True, raise RuntimeError if registry appears stale.
147
+ """
148
+ warnings: List[str] = []
149
+ royaleapi_cards = self._fetch_royaleapi()
150
+ official_cards = self._fetch_official(api_token)
151
+
152
+ # Merge: prefer official API data when available, fallback to RoyaleAPI
153
+ by_id: Dict[str, dict] = {}
154
+ for c in royaleapi_cards:
155
+ cid = str(c.get("id", c.get("name", "")))
156
+ by_id[cid] = c
157
+
158
+ official_by_id: Dict[str, dict] = {}
159
+ for c in official_cards:
160
+ cid = str(c.get("id", c.get("name", "")))
161
+ official_by_id[cid] = c
162
+ by_id[cid] = c # official overrides
163
+
164
+ # Build Card objects
165
+ self.cards = {}
166
+ for cid, raw in by_id.items():
167
+ card = self._classify_card(raw)
168
+ self.cards[cid] = card
169
+ self._by_sc_key[card.sc_key.lower()] = card
170
+ self._by_name_lower[self._normalize_name(card.name)] = card
171
+
172
+ # Detect missing cards
173
+ missing_from_official = [c.get("name", cid) for cid, c in by_id.items() if cid not in official_by_id]
174
+ missing_from_royaleapi = [c.get("name", cid) for cid, c in official_by_id.items() if cid not in by_id]
175
+
176
+ # Staleness checks
177
+ expected_min_cards = 120 # As of 2024 baseline
178
+ if len(self.cards) < expected_min_cards:
179
+ msg = f"Card registry suspiciously small: {len(self.cards)} cards (expected >= {expected_min_cards})"
180
+ if fail_on_stale:
181
+ raise RuntimeError(msg)
182
+ warnings.append(msg)
183
+
184
+ # Check for known 2024-2025 cards that should exist
185
+ known_new_cards = [
186
+ "dagger-duchess", "little-prince", "royal-guardian",
187
+ "vines", "spirit-empress", "rune-giant", "berserker",
188
+ "boss-bandit", "goblinstein", "royal-chef",
189
+ ]
190
+ for cn in known_new_cards:
191
+ if cn not in self._by_name_lower:
192
+ warnings.append(f"Known new card '{cn}' missing from registry")
193
+
194
+ self.metadata = SyncMetadata(
195
+ timestamp=datetime.now(timezone.utc).isoformat(),
196
+ royaleapi_url=ROYALEAPI_CARDS_URL,
197
+ official_api_url=OFFICIAL_CARDS_URL,
198
+ new_card_feed_url=ROYALEAPI_NEW_CARD_FEED,
199
+ card_count=len(self.cards),
200
+ source_card_count_royaleapi=len(royaleapi_cards),
201
+ source_card_count_official=len(official_cards),
202
+ missing_from_official=missing_from_official,
203
+ missing_from_royaleapi=missing_from_royaleapi,
204
+ warnings=warnings,
205
+ )
206
+
207
+ self.save()
208
+ return self.metadata
209
+
210
+ def save(self) -> None:
211
+ self.cache_path.parent.mkdir(parents=True, exist_ok=True)
212
+ payload = {
213
+ "metadata": asdict(self.metadata) if self.metadata else {},
214
+ "cards": {cid: asdict(c) for cid, c in self.cards.items()},
215
+ }
216
+ with open(self.cache_path, "w", encoding="utf-8") as f:
217
+ json.dump(payload, f, indent=2, ensure_ascii=False)
218
+
219
+ def load(self) -> bool:
220
+ if not self.cache_path.exists():
221
+ return False
222
+ with open(self.cache_path, "r", encoding="utf-8") as f:
223
+ payload = json.load(f)
224
+ meta = payload.get("metadata", {})
225
+ self.metadata = SyncMetadata(**meta) if meta else None
226
+ self.cards = {}
227
+ for cid, cdict in payload.get("cards", {}).items():
228
+ self.cards[cid] = Card(**cdict)
229
+ self._rebuild_indices()
230
+ return True
231
+
232
+ def _rebuild_indices(self) -> None:
233
+ self._by_sc_key = {}
234
+ self._by_name_lower = {}
235
+ for card in self.cards.values():
236
+ self._by_sc_key[card.sc_key.lower()] = card
237
+ self._by_name_lower[self._normalize_name(card.name)] = card
238
+
239
+ def get_by_name(self, name: str) -> Optional[Card]:
240
+ return self._by_name_lower.get(self._normalize_name(name))
241
+
242
+ def get_by_sc_key(self, sc_key: str) -> Optional[Card]:
243
+ return self._by_sc_key.get(sc_key.lower())
244
+
245
+ def get_evolutions(self) -> List[Card]:
246
+ return [c for c in self.cards.values() if c.is_evolution]
247
+
248
+ def get_heroes(self) -> List[Card]:
249
+ return [c for c in self.cards.values() if c.is_hero]
250
+
251
+ def get_champions(self) -> List[Card]:
252
+ return [c for c in self.cards.values() if c.is_champion]
253
+
254
+ def get_tower_troops(self) -> List[Card]:
255
+ return [c for c in self.cards.values() if c.is_tower_troop]
256
+
257
+ def get_cost(self, name: str) -> int:
258
+ card = self.get_by_name(name)
259
+ return card.elixir_cost if card else -1
260
+
261
+ def list_names(self) -> List[str]:
262
+ return sorted([c.name for c in self.cards.values()])
263
+
264
+ def to_yaml(self, path: str) -> None:
265
+ data = {
266
+ "metadata": asdict(self.metadata) if self.metadata else {},
267
+ "cards": [asdict(c) for c in self.cards.values()],
268
+ }
269
+ with open(path, "w", encoding="utf-8") as f:
270
+ yaml.dump(data, f, sort_keys=False, allow_unicode=True)
271
+
272
+
273
+ if __name__ == "__main__":
274
+ reg = CardRegistry()
275
+ try:
276
+ meta = reg.sync(fail_on_stale=False)
277
+ print(f"Synced {meta.card_count} cards at {meta.timestamp}")
278
+ for w in meta.warnings:
279
+ print(f"WARNING: {w}")
280
+ except Exception as e:
281
+ print(f"Sync failed: {e}")
282
+ if reg.load():
283
+ print(f"Loaded cached {len(reg.cards)} cards")
284
+ else:
285
+ print("No cache available")