"""Draft suggestions from qualitative hero relations. Score candidates for the player's role-queue position using: +1 per enemy the candidate counters -1 per enemy that counters the candidate +1 per already-locked ally synergy Data: data/relations.json (not OpenDota winrates). """ from __future__ import annotations from pathlib import Path from common import ROOT from grid import hero_table from relations import DEFAULT_RELATIONS, indexes, load_relations DEFAULT_ROLE_TAGS = { "1": ["Carry"], "2": ["Carry", "Nuker", "Escape"], "3": ["Initiator", "Durable", "Carry"], "4": ["Support"], "5": ["Support"], } def _maps() -> tuple[dict[str, str], dict[str, list[str]]]: table = hero_table() names = {h["key"]: h["name_loc"] for h in table} roles = {h["key"]: list(h.get("roles") or []) for h in table} return names, roles def enemy_keys(confirmed: dict[int, str], self_team: str | None) -> list[str]: if self_team == "radiant": slots = range(6, 11) elif self_team == "dire": slots = range(1, 6) else: return [] return [confirmed[s] for s in slots if confirmed.get(s)] def ally_keys(confirmed: dict[int, str], self_team: str | None, self_slot: int | None = None) -> list[str]: """Teammates already locked (excludes your own slot).""" if self_team == "radiant": slots = range(1, 6) elif self_team == "dire": slots = range(6, 11) else: return [] out = [] for s in slots: if self_slot is not None and s == self_slot: continue if confirmed.get(s): out.append(confirmed[s]) return out def candidates_for_position( position: int | None, *, roles_by_key: dict[str, list[str]], role_tags: dict | None = None, ) -> list[str]: tags_map = role_tags or DEFAULT_ROLE_TAGS if position is None: return [] wanted = set(tags_map.get(str(position)) or tags_map.get(position) or []) if not wanted: return [] out = [] for key, tags in roles_by_key.items(): if wanted.intersection(tags): out.append(key) return out def suggest_top( *, position: int | None, enemies: list[str], allies: list[str] | None = None, exclude: set[str] | list[str], relations: dict | None = None, top_n: int = 3, role_tags: dict | None = None, **_ignored, ) -> list[dict]: """Return Top-N picks from qualitative counters / synergies. Extra kwargs (matchups, synergies, min_games) accepted and ignored for backward compatibility with older call sites. """ if position is None: return [] allies = list(allies or []) enemies = list(enemies or []) if not enemies and not allies: return [] rel = relations if relations is not None else load_relations() if not rel.get("counters") and not rel.get("synergies"): return [] names, roles_by_key = _maps() counters_of, countered_by, synergies_of = indexes(rel) exclude_set = {e for e in exclude if e} scored: list[tuple[int, str, dict]] = [] for cand in candidates_for_position(position, roles_by_key=roles_by_key, role_tags=role_tags): if cand in exclude_set: continue beats = [] beaten_by = [] with_allies = [] for ek in enemies: for edge in counters_of.get(cand) or []: if edge["key"] == ek: beats.append({"enemy": ek, "reason": edge.get("reason") or ""}) for edge in countered_by.get(cand) or []: if edge["key"] == ek: beaten_by.append({"enemy": ek, "reason": edge.get("reason") or ""}) for ak in allies: for edge in synergies_of.get(cand) or []: if edge["key"] == ak: with_allies.append({"ally": ak, "reason": edge.get("reason") or ""}) score = len(beats) - len(beaten_by) + len(with_allies) if score == 0 and not beats and not beaten_by and not with_allies: continue scored.append((score, cand, { "beats": beats, "beaten_by": beaten_by, "with": with_allies, })) scored.sort(key=lambda t: (t[0], t[1]), reverse=True) out = [] for rank, (score, key, detail) in enumerate(scored[: max(0, top_n)], start=1): out.append({ "key": key, "name_loc": names.get(key, key), "rank": rank, "score": score, **detail, }) return out __all__ = [ "DEFAULT_RELATIONS", "DEFAULT_ROLE_TAGS", "ally_keys", "candidates_for_position", "enemy_keys", "load_relations", "suggest_top", ]