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:
voson
2026-07-29 02:11:49 +08:00
co-authored by Cursor
parent 37769580f5
commit 3ec8007077
72 changed files with 20669 additions and 2105 deletions
+260
View File
@@ -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",
]