v0.5.59: item counter evidence for fears, catch up Web features to site version.
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>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user