stevenkhan commited on
Commit
dbb2ad3
·
verified ·
1 Parent(s): 3af60ea

Upload clashcr/models/card_resolver.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. clashcr/models/card_resolver.py +327 -0
clashcr/models/card_resolver.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Card resolver: convert visual evidence into card events.
2
+
3
+ Rules:
4
+ - Bats observed as grouped flying units -> Bats.
5
+ - Three Musketeers observed as three Musketeers spawning together -> Three Musketeers.
6
+ - Elixir Pump building spawn -> Elixir Collector.
7
+ - Tornado vortex effect -> Tornado.
8
+ - Vines root/tangle effect -> Vines.
9
+ - Elixir Golem split behavior -> Elixir Golem.
10
+ - Hero visual variant -> corresponding Hero card, not base card.
11
+ - Evolution variant -> evolution-aware event.
12
+ - If evidence is ambiguous, output UNKNOWN instead of guessing.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+ from typing import Dict, List, Optional, Set
19
+
20
+ from clashcr.utils.card_registry import CardRegistry
21
+ from clashcr.models.evidence_model import EvidenceBundle, UnitEvidence, SpellEvidence
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class ResolvedEvent:
28
+ card_key: str # 'UNKNOWN' if ambiguous
29
+ confidence: float
30
+ evidence: EvidenceBundle
31
+ reasoning: str
32
+ is_ambiguous: bool = False
33
+ ambiguity_reason: str = ""
34
+
35
+
36
+ class CardResolver:
37
+ """Maps unit/spell evidence to card plays."""
38
+
39
+ # Multi-unit card mappings: unit_name -> possible cards
40
+ UNIT_TO_CARDS: Dict[str, List[str]] = {
41
+ "skeleton": ["skeletons", "skeleton-army", "graveyard", "tombstone"],
42
+ "skeleton-evolution": ["skeletons-evolution"],
43
+ "bat": ["bats"],
44
+ "bat-evolution": ["bats-evolution"],
45
+ "goblin": ["goblins", "goblin-gang", "goblin-barrel", "goblin-drill"],
46
+ "spear-goblin": ["spear-goblins", "goblin-gang"],
47
+ "barbarian": ["barbarians", "barbarian-hut", "battle-ram", "elite-barbarians"],
48
+ "barbarian-evolution": ["barbarians-evolution"],
49
+ "hog": ["hog-rider"],
50
+ "hog-rider": ["hog-rider"],
51
+ "musketeer": ["musketeer", "three-musketeers"],
52
+ "mini-pekka": ["mini-pekka"],
53
+ "pekka": ["pekka"],
54
+ "knight": ["knight"],
55
+ "knight-evolution": ["knight-evolution"],
56
+ "archer": ["archers"],
57
+ "archer-evolution": ["archers-evolution"],
58
+ "minion": ["minions", "minion-horde"],
59
+ "mega-minion": ["mega-minion"],
60
+ "elite-barbarian": ["elite-barbarians"],
61
+ "royal-recruit": ["royal-recruits"],
62
+ "royal-recruit-evolution": ["royal-recruits-evolution"],
63
+ "wall-breaker": ["wall-breakers"],
64
+ "wall-breaker-evolution": ["wall-breakers-evolution"],
65
+ "bomber": ["bomber"],
66
+ "bomber-evolution": ["bomber-evolution"],
67
+ "battle-ram": ["battle-ram"],
68
+ "battle-ram-evolution": ["battle-ram-evolution"],
69
+ "ice-spirit": ["ice-spirit"],
70
+ "ice-spirit-evolution": ["ice-spirit-evolution"],
71
+ "fire-spirit": ["fire-spirit"],
72
+ "electro-spirit": ["electro-spirit"],
73
+ "heal-spirit": ["heal-spirit"],
74
+ "valkyrie": ["valkyrie"],
75
+ "valkyrie-evolution": ["valkyrie-evolution"],
76
+ "wizard": ["wizard"],
77
+ "witch": ["witch"],
78
+ "baby-dragon": ["baby-dragon"],
79
+ "inferno-dragon": ["inferno-dragon"],
80
+ "electro-dragon": ["electro-dragon"],
81
+ "skeleton-dragon": ["skeleton-dragons"],
82
+ "prince": ["prince"],
83
+ "dark-prince": ["dark-prince"],
84
+ "bandit": ["bandit"],
85
+ "royal-ghost": ["royal-ghost"],
86
+ "fisherman": ["fisherman"],
87
+ "hunter": ["hunter"],
88
+ "executioner": ["executioner"],
89
+ "magic-archer": ["magic-archer"],
90
+ "lumberjack": ["lumberjack"],
91
+ "miner": ["miner"],
92
+ "princess": ["princess"],
93
+ "ice-wizard": ["ice-wizard"],
94
+ "electro-wizard": ["electro-wizard"],
95
+ "night-witch": ["night-witch"],
96
+ "mother-witch": ["mother-witch"],
97
+ "golden-knight": ["golden-knight"],
98
+ "skeleton-king": ["skeleton-king"],
99
+ "archer-queen": ["archer-queen"],
100
+ "monk": ["monk"],
101
+ "mighty-miner": ["mighty-miner"],
102
+ "little-prince": ["little-prince"],
103
+ "royal-guardian": ["royal-guardian"],
104
+ "phoenix-big": ["phoenix"],
105
+ "phoenix-egg": ["phoenix"],
106
+ "phoenix-small": ["phoenix"],
107
+ "golem": ["golem"],
108
+ "golemite": ["golem"],
109
+ "lava-hound": ["lava-hound"],
110
+ "lava-pup": ["lava-hound"],
111
+ "giant": ["giant"],
112
+ "giant-skeleton": ["giant-skeleton"],
113
+ "goblin-giant": ["goblin-giant"],
114
+ "royal-giant": ["royal-giant"],
115
+ "royal-giant-evolution": ["royal-giant-evolution"],
116
+ "electro-giant": ["electro-giant"],
117
+ "mega-knight": ["mega-knight"],
118
+ "sparky": ["sparky"],
119
+ "balloon": ["balloon"],
120
+ "ram-rider": ["ram-rider"],
121
+ "battle-healer": ["battle-healer"],
122
+ "cannon": ["cannon"],
123
+ "tesla": ["tesla"],
124
+ "tesla-evolution": ["tesla-evolution"],
125
+ "inferno-tower": ["inferno-tower"],
126
+ "bomb-tower": ["bomb-tower"],
127
+ "mortar": ["mortar"],
128
+ "mortar-evolution": ["mortar-evolution"],
129
+ "x-bow": ["x-bow"],
130
+ "elixir-collector": ["elixir-collector"],
131
+ "furnace": ["furnace"],
132
+ "goblin-hut": ["goblin-hut"],
133
+ "barbarian-hut": ["barbarian-hut"],
134
+ "tombstone": ["tombstone"],
135
+ "goblin-cage": ["goblin-cage"],
136
+ "goblin-brawler": ["goblin-cage"],
137
+ "dart-goblin": ["dart-goblin"],
138
+ "flying-machine": ["flying-machine"],
139
+ "zappy": ["zappies"],
140
+ "rascal-boy": ["rascals"],
141
+ "rascal-girl": ["rascals"],
142
+ "royal-hog": ["royal-hogs"],
143
+ "cannon-cart": ["cannon-cart"],
144
+ "skeleton-barrel": ["skeleton-barrel"],
145
+ "goblin-barrel": ["goblin-barrel"],
146
+ "goblin-drill": ["goblin-drill"],
147
+ "clone": ["clone"],
148
+ "mirror": ["mirror"],
149
+ "graveyard": ["graveyard"],
150
+ "elixir-golem-big": ["elixir-golem"],
151
+ "elixir-golem-mid": ["elixir-golem"],
152
+ "elixir-golem-small": ["elixir-golem"],
153
+ "dirt": ["miner"],
154
+ # Spells as YOLO classes (if model detects them)
155
+ "fireball": ["fireball"],
156
+ "zap": ["zap"],
157
+ "zap-evolution": ["zap-evolution"],
158
+ "arrows": ["arrows"],
159
+ "poison": ["poison"],
160
+ "freeze": ["freeze"],
161
+ "rage": ["rage"],
162
+ "tornado": ["tornado"],
163
+ "earthquake": ["earthquake"],
164
+ "the-log": ["the-log"],
165
+ "barbarian-barrel": ["barbarian-barrel"],
166
+ "giant-snowball": ["giant-snowball"],
167
+ "lightning": ["lightning"],
168
+ "rocket": ["rocket"],
169
+ "royal-delivery": ["royal-delivery"],
170
+ }
171
+
172
+ # Cards that spawn multiple identical units
173
+ MULTI_UNIT_CARDS = {
174
+ "skeletons": {"unit": "skeleton", "count": 4},
175
+ "skeletons-evolution": {"unit": "skeleton-evolution", "count": 4},
176
+ "skeleton-army": {"unit": "skeleton", "count": 15},
177
+ "goblins": {"unit": "goblin", "count": 3},
178
+ "goblin-gang": {"unit": ["goblin", "spear-goblin"], "count": [3, 3]},
179
+ "minions": {"unit": "minion", "count": 3},
180
+ "minion-horde": {"unit": "minion", "count": 6},
181
+ "barbarians": {"unit": "barbarian", "count": 5},
182
+ "barbarians-evolution": {"unit": "barbarian-evolution", "count": 5},
183
+ "elite-barbarians": {"unit": "elite-barbarian", "count": 2},
184
+ "bats": {"unit": "bat", "count": 5},
185
+ "bats-evolution": {"unit": "bat-evolution", "count": 5},
186
+ "three-musketeers": {"unit": "musketeer", "count": 3},
187
+ "royal-recruits": {"unit": "royal-recruit", "count": 6},
188
+ "royal-recruits-evolution": {"unit": "royal-recruit-evolution", "count": 6},
189
+ "wall-breakers": {"unit": "wall-breaker", "count": 2},
190
+ "wall-breakers-evolution": {"unit": "wall-breaker-evolution", "count": 2},
191
+ "royal-hogs": {"unit": "royal-hog", "count": 5},
192
+ "rascals": {"unit": ["rascal-boy", "rascal-girl"], "count": [1, 2]},
193
+ }
194
+
195
+ def __init__(self, registry: Optional[CardRegistry] = None):
196
+ self.registry = registry or CardRegistry()
197
+ if not self.registry.cards:
198
+ self.registry.load()
199
+ self._recent_resolutions: List[ResolvedEvent] = []
200
+
201
+ def resolve(self, evidence: EvidenceBundle) -> ResolvedEvent:
202
+ """Convert an EvidenceBundle into a ResolvedEvent."""
203
+ units = evidence.units
204
+ spells = evidence.spells
205
+
206
+ # No evidence -> UNKNOWN
207
+ if not units and not spells:
208
+ return ResolvedEvent(
209
+ card_key="UNKNOWN",
210
+ confidence=0.0,
211
+ evidence=evidence,
212
+ reasoning="no_units_or_spells",
213
+ is_ambiguous=True,
214
+ ambiguity_reason="empty_evidence",
215
+ )
216
+
217
+ # Spell-only evidence
218
+ if not units and spells:
219
+ if len(spells) == 1:
220
+ sp = spells[0]
221
+ return ResolvedEvent(
222
+ card_key=sp.spell_name,
223
+ confidence=sp.confidence * 0.7,
224
+ evidence=evidence,
225
+ reasoning=f"single_spell_heuristic:{sp.spell_name}",
226
+ )
227
+ else:
228
+ names = [s.spell_name for s in spells]
229
+ return ResolvedEvent(
230
+ card_key="UNKNOWN",
231
+ confidence=0.3,
232
+ evidence=evidence,
233
+ reasoning=f"multiple_spells:{names}",
234
+ is_ambiguous=True,
235
+ ambiguity_reason=f"multiple_spell_effects:{names}",
236
+ )
237
+
238
+ # Building detection
239
+ buildings = [u for u in units if u.is_building]
240
+ if buildings and not any(not u.is_building for u in units):
241
+ if len(buildings) == 1:
242
+ b = buildings[0]
243
+ cards = self.UNIT_TO_CARDS.get(b.unit_name, [b.unit_name])
244
+ return ResolvedEvent(
245
+ card_key=cards[0],
246
+ confidence=b.confidence * 0.8,
247
+ evidence=evidence,
248
+ reasoning=f"single_building:{b.unit_name}",
249
+ )
250
+
251
+ # Multi-unit inference
252
+ unit_counts: Dict[str, int] = {}
253
+ for u in units:
254
+ unit_counts[u.unit_name] = unit_counts.get(u.unit_name, 0) + 1
255
+
256
+ for card_key, spec in self.MULTI_UNIT_CARDS.items():
257
+ expected_unit = spec["unit"]
258
+ expected_count = spec["count"]
259
+ if isinstance(expected_unit, list):
260
+ # Mixed units (e.g., Goblin Gang)
261
+ match = all(
262
+ unit_counts.get(u, 0) >= c
263
+ for u, c in zip(expected_unit, expected_count)
264
+ )
265
+ if match:
266
+ return ResolvedEvent(
267
+ card_key=card_key,
268
+ confidence=0.75,
269
+ evidence=evidence,
270
+ reasoning=f"multi_unit_match:{card_key}",
271
+ )
272
+ else:
273
+ if unit_counts.get(expected_unit, 0) >= expected_count:
274
+ return ResolvedEvent(
275
+ card_key=card_key,
276
+ confidence=0.75,
277
+ evidence=evidence,
278
+ reasoning=f"multi_unit_match:{card_key}",
279
+ )
280
+
281
+ # Single-unit fallback
282
+ if len(units) == 1:
283
+ u = units[0]
284
+ cards = self.UNIT_TO_CARDS.get(u.unit_name, [u.unit_name])
285
+ if len(cards) == 1:
286
+ return ResolvedEvent(
287
+ card_key=cards[0],
288
+ confidence=u.confidence * 0.8,
289
+ evidence=evidence,
290
+ reasoning=f"single_unit:{u.unit_name}",
291
+ )
292
+ else:
293
+ return ResolvedEvent(
294
+ card_key="UNKNOWN",
295
+ confidence=u.confidence * 0.5,
296
+ evidence=evidence,
297
+ reasoning=f"ambiguous_unit:{u.unit_name}->{'/'.join(cards)}",
298
+ is_ambiguous=True,
299
+ ambiguity_reason=f"unit_maps_to_multiple_cards:{cards}",
300
+ )
301
+
302
+ # Multiple different units -> ambiguous
303
+ all_names = [u.unit_name for u in units]
304
+ return ResolvedEvent(
305
+ card_key="UNKNOWN",
306
+ confidence=0.3,
307
+ evidence=evidence,
308
+ reasoning=f"mixed_units:{all_names}",
309
+ is_ambiguous=True,
310
+ ambiguity_reason=f"multiple_different_units:{all_names}",
311
+ )
312
+
313
+ def deduplicate(self, event: ResolvedEvent, cooldown_seconds: float = 2.0) -> bool:
314
+ """Return True if this event is a duplicate of a recent one."""
315
+ import time
316
+ now = time.monotonic()
317
+ for recent in self._recent_resolutions:
318
+ if recent.card_key == event.card_key and \
319
+ (now - recent.evidence.timestamp) < cooldown_seconds:
320
+ return True
321
+ self._recent_resolutions.append(event)
322
+ # Prune old
323
+ self._recent_resolutions = [
324
+ r for r in self._recent_resolutions
325
+ if (now - r.evidence.timestamp) < cooldown_seconds * 3
326
+ ]
327
+ return False