Ship OpenDota counter-stats reordering for feared items, finalize SITE_VERSION/docs for rankings/streamers/trends/matches/mechanics and draft archetypes, and ignore regenerable Web data caches. Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
6.7 KiB
Python
261 lines
6.7 KiB
Python
"""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",
|
|
]
|