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:
+201
-36
@@ -1,19 +1,28 @@
|
||||
"""Draft suggestions from qualitative hero relations.
|
||||
"""Draft suggestions from qualitative hero relations + lineup archetypes.
|
||||
|
||||
Score candidates for the player's role-queue position using:
|
||||
+1 per enemy the candidate counters
|
||||
-1 per enemy that counters the candidate
|
||||
+1 per already-locked ally synergy
|
||||
Mark available heroes with 克 / 搭 / 补:
|
||||
克 — relation counters, push/global answers, punish enemy gaps
|
||||
搭 — synergy with locked allies
|
||||
补 — fill ally tag gaps
|
||||
|
||||
Data: data/relations.json (not OpenDota winrates).
|
||||
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 pathlib import Path
|
||||
|
||||
from common import ROOT
|
||||
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 = {
|
||||
@@ -24,12 +33,31 @@ DEFAULT_ROLE_TAGS = {
|
||||
"5": ["Support"],
|
||||
}
|
||||
|
||||
# Soft boosts when candidate tags address a prominent enemy profile face.
|
||||
# Never creates marks alone.
|
||||
_PROFILE_BOOSTS: dict[str, tuple[str, ...]] = {
|
||||
"爆发": ("耐久", "逃生"),
|
||||
"推进": ("控制", "先手"),
|
||||
"先手": ("逃生", "控制"),
|
||||
"控制": ("逃生", "耐久"),
|
||||
"核心": ("控制", "先手"),
|
||||
"辅助": ("核心", "先手"),
|
||||
}
|
||||
|
||||
def _maps() -> tuple[dict[str, str], dict[str, list[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}
|
||||
return names, roles
|
||||
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]:
|
||||
@@ -65,9 +93,10 @@ def candidates_for_position(
|
||||
roles_by_key: dict[str, list[str]],
|
||||
role_tags: dict | None = None,
|
||||
) -> list[str]:
|
||||
tags_map = role_tags or DEFAULT_ROLE_TAGS
|
||||
"""Role-queue filter. position=None means all heroes (non-role queue)."""
|
||||
if position is None:
|
||||
return []
|
||||
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 []
|
||||
@@ -78,40 +107,101 @@ def candidates_for_position(
|
||||
return out
|
||||
|
||||
|
||||
def suggest_top(
|
||||
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 = 3,
|
||||
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]:
|
||||
"""Return Top-N picks from qualitative counters / synergies.
|
||||
) -> dict:
|
||||
"""Return 克/搭/补 marks plus lineup analysis.
|
||||
|
||||
Extra kwargs (matchups, synergies, min_games) accepted and ignored for
|
||||
backward compatibility with older call sites.
|
||||
Requires at least ``min_enemies`` locked enemies. ``top_n`` None/<=0 means
|
||||
no truncation. ``position`` None = non-role queue (all heroes).
|
||||
"""
|
||||
if position is None:
|
||||
return []
|
||||
allies = list(allies or [])
|
||||
enemies = list(enemies or [])
|
||||
if not enemies and not allies:
|
||||
return []
|
||||
rel = relations if relations is not None else load_relations()
|
||||
if not rel.get("counters") and not rel.get("synergies"):
|
||||
return []
|
||||
if len(enemies) < max(1, int(min_enemies)):
|
||||
return _empty_result()
|
||||
|
||||
names, roles_by_key = _maps()
|
||||
counters_of, countered_by, synergies_of = indexes(rel)
|
||||
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}
|
||||
|
||||
scored: list[tuple[int, str, dict]] = []
|
||||
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 = []
|
||||
@@ -126,26 +216,99 @@ def suggest_top(
|
||||
for edge in synergies_of.get(cand) or []:
|
||||
if edge["key"] == ak:
|
||||
with_allies.append({"ally": ak, "reason": edge.get("reason") or ""})
|
||||
score = len(beats) - len(beaten_by) + len(with_allies)
|
||||
if score == 0 and not beats and not beaten_by and not with_allies:
|
||||
|
||||
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)
|
||||
out = []
|
||||
for rank, (score, key, detail) in enumerate(scored[: max(0, top_n)], start=1):
|
||||
out.append({
|
||||
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,
|
||||
"score": score,
|
||||
**detail,
|
||||
})
|
||||
return out
|
||||
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__ = [
|
||||
@@ -154,6 +317,8 @@ __all__ = [
|
||||
"ally_keys",
|
||||
"candidates_for_position",
|
||||
"enemy_keys",
|
||||
"enemy_profile",
|
||||
"load_relations",
|
||||
"suggest_marks",
|
||||
"suggest_top",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user