"""Draft suggestions from qualitative hero relations + lineup archetypes. Mark available heroes with 克 / 搭 / 补: 克 — relation counters, push/global answers, punish enemy gaps 搭 — synergy with locked allies 补 — fill ally tag gaps Role-queue filters by position tags; otherwise all heroes are candidates. Also returns a short analysis string and per-mark reasons (no AI). Data: data/relations.json + draft_archetypes rules. """ from __future__ import annotations from draft_archetypes import ( answer_for_candidate, collect_reasons, detect_archetypes, detect_gaps, format_analysis, tag_profile, ) from grid import hero_table from hero_tags import tags_for_hero 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"], } # Soft boosts when candidate tags address a prominent enemy profile face. # Never creates marks alone. _PROFILE_BOOSTS: dict[str, tuple[str, ...]] = { "爆发": ("耐久", "逃生"), "推进": ("控制", "先手"), "先手": ("逃生", "控制"), "控制": ("逃生", "耐久"), "核心": ("控制", "先手"), "辅助": ("核心", "先手"), } _SOFT_BOOST = 0.25 _ARCH_BOOST = 0.5 _GAP_BOOST = 0.35 def _maps() -> tuple[dict[str, str], dict[str, list[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} tags = { h["key"]: list(h.get("tags") or []) or tags_for_hero(h["key"], h.get("roles")) for h in table } return names, roles, tags 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]: """Role-queue filter. position=None means all heroes (non-role queue).""" if position is None: return list(roles_by_key.keys()) tags_map = role_tags or DEFAULT_ROLE_TAGS 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 enemy_profile(enemies: list[str], tags_by_key: dict[str, list[str]] | None = None) -> dict[str, int]: """Count Chinese draft tags across locked enemies.""" if tags_by_key is None: _, _, tags_by_key = _maps() return tag_profile(enemies, tags_by_key) def _profile_soft_boost(cand_tags: list[str], profile: dict[str, int]) -> float: if not profile or not cand_tags: return 0.0 cand = set(cand_tags) boost = 0.0 for face, n in sorted(profile.items(), key=lambda kv: (-kv[1], kv[0])): if n <= 0: continue wanted = _PROFILE_BOOSTS.get(face) if not wanted: continue if cand.intersection(wanted): boost += _SOFT_BOOST * n return boost def _empty_result() -> dict: return { "enemy_profile": {}, "ally_profile": {}, "enemy_archetypes": [], "enemy_gaps": [], "ally_gaps": [], "analysis": "", "marks": [], } def suggest_marks( *, position: int | None, enemies: list[str], allies: list[str] | None = None, exclude: set[str] | list[str], relations: dict | None = None, top_n: int | None = 0, role_tags: dict | None = None, min_enemies: int = 1, min_heroes_for_gaps: int = 2, archetypes_enabled: bool = True, **_ignored, ) -> dict: """Return 克/搭/补 marks plus lineup analysis. Requires at least ``min_enemies`` locked enemies. ``top_n`` None/<=0 means no truncation. ``position`` None = non-role queue (all heroes). """ allies = list(allies or []) enemies = list(enemies or []) if len(enemies) < max(1, int(min_enemies)): return _empty_result() rel = relations if relations is not None else load_relations() has_rel = bool(rel.get("counters") or rel.get("synergies")) if not has_rel and not archetypes_enabled: return _empty_result() names, roles_by_key, tags_by_key = _maps() counters_of, countered_by, synergies_of = indexes(rel) if has_rel else ({}, {}, {}) exclude_set = {e for e in exclude if e} e_profile = tag_profile(enemies, tags_by_key) a_profile = tag_profile(allies, tags_by_key) archetypes: list[str] = [] enemy_gaps: list[str] = [] ally_gaps: list[str] = [] if archetypes_enabled: archetypes = detect_archetypes(enemies, tags_by_key) enemy_gaps = detect_gaps( e_profile, hero_count=len(enemies), min_heroes=min_heroes_for_gaps ) ally_gaps = detect_gaps( a_profile, hero_count=len(allies), min_heroes=min_heroes_for_gaps ) analysis = format_analysis( archetypes=archetypes, enemy_gaps=enemy_gaps, ally_gaps=ally_gaps, ally_count=len(allies), ) scored: list[tuple[float, 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 cand_tags = tags_by_key.get(cand) or [] 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 ""}) arch_hits, punish_gaps, fill_gaps = answer_for_candidate( cand, cand_tags, archetypes=archetypes, enemy_gaps=enemy_gaps, ally_gaps=ally_gaps, ) labels: list[str] = [] if beats or arch_hits or punish_gaps: labels.append("克") if with_allies: labels.append("搭") if fill_gaps: labels.append("补") if not labels: continue reasons = collect_reasons( names=names, beats=beats, with_allies=with_allies, archetype_hits=arch_hits, punish_gaps=punish_gaps, fill_gaps=fill_gaps, ) soft = _profile_soft_boost(cand_tags, e_profile) score = ( float(len(beats) - len(beaten_by) + len(with_allies)) + soft + _ARCH_BOOST * len(arch_hits) + _GAP_BOOST * (len(punish_gaps) + len(fill_gaps)) ) scored.append((score, cand, { "labels": labels, "beats": beats, "beaten_by": beaten_by, "with": with_allies, "reasons": reasons, "score": round(score, 3), })) scored.sort(key=lambda t: (t[0], t[1]), reverse=True) limit = None if top_n is None or int(top_n) <= 0 else int(top_n) sliced = scored if limit is None else scored[:limit] marks = [] for rank, (_score, key, detail) in enumerate(sliced, start=1): marks.append({ "key": key, "name_loc": names.get(key, key), "rank": rank, **detail, }) return { "enemy_profile": e_profile, "ally_profile": a_profile, "enemy_archetypes": archetypes, "enemy_gaps": enemy_gaps, "ally_gaps": ally_gaps, "analysis": analysis, "marks": marks, } 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 | None = 0, role_tags: dict | None = None, min_enemies: int = 1, min_heroes_for_gaps: int = 2, archetypes_enabled: bool = True, **_ignored, ) -> list[dict]: """Compatibility wrapper: return mark list from ``suggest_marks``.""" return suggest_marks( position=position, enemies=enemies, allies=allies, exclude=exclude, relations=relations, top_n=top_n, role_tags=role_tags, min_enemies=min_enemies, min_heroes_for_gaps=min_heroes_for_gaps, archetypes_enabled=archetypes_enabled, **_ignored, )["marks"] __all__ = [ "DEFAULT_RELATIONS", "DEFAULT_ROLE_TAGS", "ally_keys", "candidates_for_position", "enemy_keys", "enemy_profile", "load_relations", "suggest_marks", "suggest_top", ]