"""Rule-based draft lineup archetypes and gap analysis (no AI). Detects push / global enemy shapes, enemy & ally tag gaps, answer heroes, and short Chinese analysis / reason strings for recommend marks. """ from __future__ import annotations from collections import Counter from typing import Iterable from hero_tags import TAG_ORDER # Strong push cores: one hit can flag push even before 2+ Pusher tags. PUSH_CORE = frozenset({ "lycan", "furion", "broodmother", "chen", "enchantress", "visage", "beastmaster", "naga_siren", "lone_druid", "undying", }) GLOBAL_SET = frozenset({ "furion", "spectre", "wisp", "abyssal_underlord", "zuus", "ancient_apparition", "spirit_breaker", "storm_spirit", "rattletrap", }) HARD_GLOBAL = frozenset({ "furion", "spectre", "wisp", }) # Archetype -> answer hero keys (marked 克 with reason 对推进 / 对全球流). ARCHETYPE_ANSWERS: dict[str, tuple[str, ...]] = { "push": ( "medusa", "terrorblade", "naga_siren", "jakiro", "gyrocopter", "dragon_knight", "shredder", ), "global": ( "storm_spirit", "anti_mage", "riki", "bounty_hunter", "queenofpain", "ember_spirit", ), } ARCHETYPE_REASON = { "push": "对推进", "global": "对全球流", } ARCHETYPE_LABEL = { "push": "偏推进", "global": "全球流", } # Gaps we report (user-facing). 输出 is proxied by 核心. GAP_TAGS = ("控制", "爆发", "核心", "先手") GAP_DISPLAY = { "控制": "控制", "爆发": "爆发", "核心": "输出", "先手": "先手", } # Enemy gap -> candidate tags that punish it (marked 克). ENEMY_GAP_PUNISH: dict[str, tuple[str, ...]] = { "控制": ("控制", "先手"), "爆发": ("耐久", "核心"), "核心": ("爆发", "控制"), "先手": ("先手", "爆发"), } MAX_REASONS = 3 MAX_REASON_LEN = 12 def tag_profile(keys: Iterable[str], tags_by_key: dict[str, list[str]]) -> dict[str, int]: counts: Counter[str] = Counter() for key in keys: for tag in tags_by_key.get(key) or []: if tag in TAG_ORDER: counts[tag] += 1 return {t: counts[t] for t in TAG_ORDER if counts.get(t)} def detect_archetypes( enemies: list[str], tags_by_key: dict[str, list[str]], *, push_tag_min: int = 2, ) -> list[str]: """Return ordered archetype ids present in the enemy lineup.""" out: list[str] = [] push_n = sum(1 for e in enemies if "推进" in (tags_by_key.get(e) or [])) if push_n >= push_tag_min or any(e in PUSH_CORE for e in enemies): out.append("push") global_hits = [e for e in enemies if e in GLOBAL_SET] if len(global_hits) >= 2 or any(e in HARD_GLOBAL for e in enemies): out.append("global") return out def detect_gaps( profile: dict[str, int], *, hero_count: int, min_heroes: int = 2, ) -> list[str]: """Return missing GAP_TAGS when enough heroes are locked.""" if hero_count < max(1, int(min_heroes)): return [] missing = [] for tag in GAP_TAGS: if int(profile.get(tag) or 0) <= 0: missing.append(tag) return missing def format_analysis( *, archetypes: list[str], enemy_gaps: list[str], ally_gaps: list[str], ally_count: int, ) -> str: """One short Chinese lineup summary (may be empty).""" enemy_bits: list[str] = [] for arch in archetypes: lab = ARCHETYPE_LABEL.get(arch) if lab and lab not in enemy_bits: enemy_bits.append(lab) for gap in enemy_gaps: disp = GAP_DISPLAY.get(gap, gap) bit = f"缺{disp}" if bit not in enemy_bits: enemy_bits.append(bit) ally_bits: list[str] = [] if ally_count <= 0: if enemy_bits: ally_bits.append("缺口尚不明") else: for gap in ally_gaps: disp = GAP_DISPLAY.get(gap, gap) bit = f"缺{disp}" if bit not in ally_bits: ally_bits.append(bit) parts: list[str] = [] if enemy_bits: parts.append("敌:" + "·".join(enemy_bits)) if ally_bits: parts.append("我:" + "·".join(ally_bits)) text = " | ".join(parts) if len(text) > 40: text = text[:39] + "…" return text def _trim_reason(s: str) -> str: s = (s or "").strip() if len(s) <= MAX_REASON_LEN: return s return s[: MAX_REASON_LEN - 1] + "…" def collect_reasons( *, names: dict[str, str], beats: list[dict], with_allies: list[dict], archetype_hits: list[str], punish_gaps: list[str], fill_gaps: list[str], ) -> list[str]: """Build up to MAX_REASONS short reason phrases for one candidate.""" reasons: list[str] = [] def add(phrase: str) -> None: p = _trim_reason(phrase) if p and p not in reasons and len(reasons) < MAX_REASONS: reasons.append(p) # Prefer one signal per mark type (克 / 补 / 搭) before extras. for edge in beats[:1]: add(f"克{names.get(edge['enemy'], edge['enemy'])}") for gap in fill_gaps[:1]: add(f"补{GAP_DISPLAY.get(gap, gap)}") for edge in with_allies[:1]: add(f"搭{names.get(edge['ally'], edge['ally'])}") for arch in archetype_hits: add(ARCHETYPE_REASON.get(arch, arch)) for gap in punish_gaps: add(f"打缺{GAP_DISPLAY.get(gap, gap)}") for edge in beats[1:]: add(f"克{names.get(edge['enemy'], edge['enemy'])}") for gap in fill_gaps[1:]: add(f"补{GAP_DISPLAY.get(gap, gap)}") for edge in with_allies[1:]: add(f"搭{names.get(edge['ally'], edge['ally'])}") return reasons def answer_for_candidate( key: str, cand_tags: list[str], *, archetypes: list[str], enemy_gaps: list[str], ally_gaps: list[str], ) -> tuple[list[str], list[str], list[str]]: """Return (archetype_hits, punish_gaps, fill_gaps) that apply to this hero.""" tag_set = set(cand_tags or []) arch_hits = [a for a in archetypes if key in ARCHETYPE_ANSWERS.get(a, ())] punish = [] for gap in enemy_gaps: wanted = ENEMY_GAP_PUNISH.get(gap) or () if tag_set.intersection(wanted): punish.append(gap) fill = [g for g in ally_gaps if g in tag_set] # 核心 gap displays as 输出; candidate must have 核心 tag to fill. return arch_hits, punish, fill __all__ = [ "ARCHETYPE_ANSWERS", "ARCHETYPE_LABEL", "ARCHETYPE_REASON", "ENEMY_GAP_PUNISH", "GAP_DISPLAY", "GAP_TAGS", "answer_for_candidate", "collect_reasons", "detect_archetypes", "detect_gaps", "format_analysis", "tag_profile", ]