-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrecommender.py
More file actions
262 lines (215 loc) · 8.03 KB
/
recommender.py
File metadata and controls
262 lines (215 loc) · 8.03 KB
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
# src/tft_analyzer/recommender.py
from typing import Dict, List, Any
def get_trait_breakpoints(trait_name: str, trait_data: Dict[str, Any]) -> List[int]:
"""Returns a sorted list of minUnits for a trait."""
if trait_name not in trait_data:
return []
effects = trait_data[trait_name].get("effects", [])
if not effects:
return []
return sorted([int(e.get("minUnits", 0)) for e in effects])
def evaluate_comp(
comp: List[str],
champion_data: Dict[str, Dict[str, Any]],
trait_data: Dict[str, Any],
units_gone: Dict[str, Dict[str, int]],
level_odds: Dict[int, float]
) -> float:
"""
Calculates a heuristic score for a composition based on:
1. Active Trait Breakpoints (High Value)
2. Proximity to next Breakpoint (Medium Value)
3. Champion Individual Value (Cost, Odds, Contention)
"""
if not comp:
return 0.0
# --- 1. Trait Score ---
trait_counts = {}
for name in comp:
if name not in champion_data:
continue
for trait in champion_data[name].get("traits", []):
trait_counts[trait] = trait_counts.get(trait, 0) + 1
trait_score = 0.0
for trait, count in trait_counts.items():
breakpoints = get_trait_breakpoints(trait, trait_data)
if not breakpoints:
continue
# Find active breakpoint and next target
active_min = 0
next_min = breakpoints[0]
for idx, bp in enumerate(breakpoints):
if count >= bp:
active_min = bp
if idx + 1 < len(breakpoints):
next_min = breakpoints[idx+1]
else:
next_min = None # Already at max breakpoint
else:
next_min = bp
break
# Scoring Weights
# Active traits are worth a lot (synergy power)
trait_score += active_min * 15.0
# Proximity score: encourage the algorithm to "finish" a trait
if next_min is not None and next_min > 0 and count < next_min:
trait_score += (count / next_min) * 5.0
# --- 2. Champion Value (EV) ---
champ_score = 0.0
for name in comp:
if name not in champion_data:
continue
cost = champion_data[name].get("cost", 1)
tier_str = f"{cost}-cost"
# Calculate contention penalty
gone = units_gone.get(tier_str, {}).get(name, 0)
# Probability of finding this unit tier
prob = level_odds.get(cost, 0)
# Heuristic Value Formula: (Cost^1.5 * Odds) / (1 + Contention)
# We value high-cost units more, but only if odds are decent.
val = (pow(cost, 1.5) * prob) / (1.0 + (gone * 0.2))
champ_score += val * 10.0
return trait_score + champ_score
def _jaccard(a: List[str], b: List[str]) -> float:
"""Jaccard similarity between two champion lists."""
sa, sb = set(a), set(b)
if not sa and not sb:
return 1.0
return len(sa & sb) / len(sa | sb)
def _get_trait_pairs(
champion_data: Dict[str, Dict[str, Any]],
available: List[str],
locked: List[str],
) -> List[List[str]]:
"""
Build seed pairs: for each trait with 3+ available champs,
pick the two highest-cost available champs sharing that trait.
Returns list of [champ1, champ2] pairs (excluding locked champs).
"""
# Group available champs by trait
trait_champs: Dict[str, List[str]] = {}
for name in available:
if name in locked or name not in champion_data:
continue
for trait in champion_data[name].get("traits", []):
trait_champs.setdefault(trait, []).append(name)
pairs = []
for trait, champs in trait_champs.items():
if len(champs) < 2:
continue
# Sort by cost descending to pick the strongest pair
champs.sort(key=lambda n: champion_data[n].get("cost", 1), reverse=True)
pairs.append(champs[:2])
return pairs
def _run_greedy(
seed: List[str],
available: List[str],
champion_data: Dict[str, Dict[str, Any]],
trait_data: Dict[str, Any],
units_gone: Dict[str, Dict[str, int]],
level_odds: Dict[int, float],
level: int,
) -> List[str]:
"""Run greedy fill from a seed comp up to the given level."""
working = list(seed)
while len(working) < level:
best_candidate = None
best_candidate_score = -float('inf')
for name in available:
if name in working:
continue
temp_comp = working + [name]
score = evaluate_comp(temp_comp, champion_data, trait_data, units_gone, level_odds)
if score > best_candidate_score:
best_candidate_score = score
best_candidate = name
if best_candidate:
working.append(best_candidate)
else:
break
return working
def recommend_comp(
champions_by_tier: Dict[str, List[str]],
champion_data: Dict[str, Dict[str, Any]],
trait_data: Dict[str, Any],
level: int,
units_gone: Dict[str, Dict[str, int]],
level_odds: Dict[int, float],
locked_champions: List[str] = None,
top_n: int = 1,
) -> List[Dict[str, Any]]:
"""
Recommends team compositions using a multi-seed greedy algorithm.
Returns a list of scored comp dicts sorted by score descending:
[{"champions": [...], "score": float, "source": "greedy"}, ...]
When top_n=1, returns a single-element list (backward compatible shape).
"""
if locked_champions is None:
locked_champions = []
# 1. Filter available champions based on current shop odds
available = []
for tier_str, names in champions_by_tier.items():
tier_cost = int(tier_str.split('-')[0])
if level_odds.get(tier_cost, 0) > 0:
available.extend(names)
for name in locked_champions:
if name not in available:
available.append(name)
if not available:
return []
# 2. Build seeds
scored_champs = []
for name in available:
if name not in champion_data:
continue
if name in locked_champions:
continue
cost = champion_data[name].get("cost", 1)
prob = level_odds.get(cost, 0)
score = cost * prob
scored_champs.append((score, name))
scored_champs.sort(key=lambda x: x[0], reverse=True)
base = list(locked_champions)
seeds: List[List[str]] = [list(base)] # pure greedy seed
# Top-carry seeds
if len(base) < level:
for _, name in scored_champs[:5]:
if name not in base:
seeds.append(list(base) + [name])
# Trait-pair seeds (for diversity)
if top_n > 1:
for pair in _get_trait_pairs(champion_data, available, locked_champions):
seed = list(base) + [n for n in pair if n not in base]
if len(seed) <= level:
seeds.append(seed)
# 3. Run greedy for each seed, collect all results
all_comps: List[Dict[str, Any]] = []
for seed in seeds:
comp = _run_greedy(seed, available, champion_data, trait_data, units_gone, level_odds, level)
raw_score = evaluate_comp(comp, champion_data, trait_data, units_gone, level_odds)
all_comps.append({"champions": comp, "raw_score": raw_score})
if not all_comps:
return []
# 4. Normalize scores to 0-100
max_score = max(c["raw_score"] for c in all_comps)
if max_score <= 0:
max_score = 1.0
for c in all_comps:
c["score"] = round((c["raw_score"] / max_score) * 100, 1)
c["source"] = "greedy"
del c["raw_score"]
# 5. Sort by score descending
all_comps.sort(key=lambda c: c["score"], reverse=True)
# 6. Deduplicate (Jaccard > 0.8 = too similar)
unique: List[Dict[str, Any]] = []
for comp in all_comps:
is_dup = False
for existing in unique:
if _jaccard(comp["champions"], existing["champions"]) > 0.8:
is_dup = True
break
if not is_dup:
unique.append(comp)
if len(unique) >= top_n:
break
return unique