Files
climperor/pc/recommend.py
T
vosonandCursor 9c5aa5b610 Reorganize repository into pc web shared monorepo
Separate the local recognition, web publishing, and shared data paths while preserving direct script execution and existing site content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:29:08 +08:00

330 lines
9.7 KiB
Python

"""Draft suggestions from qualitative hero relations + lineup archetypes.
Mark available heroes with 克 / 搭 / 补:
克 — relation counters, push/global answers, punish enemy gaps
搭 — synergy with locked allies
补 — fill ally tag gaps
Role-queue filters by position tags; otherwise all heroes are candidates.
Also returns a short analysis string and per-mark reasons (no AI).
Data: shared/data/relations.json + draft_archetypes rules.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from draft_archetypes import (
answer_for_candidate,
collect_reasons,
detect_archetypes,
detect_gaps,
format_analysis,
tag_profile,
)
from shared.grid import hero_table
from shared.hero_tags import tags_for_hero
from shared.relations import DEFAULT_RELATIONS, indexes, load_relations
DEFAULT_ROLE_TAGS = {
"1": ["Carry"],
"2": ["Carry", "Nuker", "Escape"],
"3": ["Initiator", "Durable", "Carry"],
"4": ["Support"],
"5": ["Support"],
}
# Soft boosts when candidate tags address a prominent enemy profile face.
# Never creates marks alone.
_PROFILE_BOOSTS: dict[str, tuple[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}
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]:
if self_team == "radiant":
slots = range(6, 11)
elif self_team == "dire":
slots = range(1, 6)
else:
return []
return [confirmed[s] for s in slots if confirmed.get(s)]
def ally_keys(confirmed: dict[int, str], self_team: str | None, self_slot: int | None = None) -> list[str]:
"""Teammates already locked (excludes your own slot)."""
if self_team == "radiant":
slots = range(1, 6)
elif self_team == "dire":
slots = range(6, 11)
else:
return []
out = []
for s in slots:
if self_slot is not None and s == self_slot:
continue
if confirmed.get(s):
out.append(confirmed[s])
return out
def candidates_for_position(
position: int | None,
*,
roles_by_key: dict[str, list[str]],
role_tags: dict | None = None,
) -> list[str]:
"""Role-queue filter. position=None means all heroes (non-role queue)."""
if position is None:
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 []
out = []
for key, tags in roles_by_key.items():
if wanted.intersection(tags):
out.append(key)
return out
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 | None = 0,
role_tags: dict | None = None,
min_enemies: int = 1,
min_heroes_for_gaps: int = 2,
archetypes_enabled: bool = True,
**_ignored,
) -> dict:
"""Return 克/搭/补 marks plus lineup analysis.
Requires at least ``min_enemies`` locked enemies. ``top_n`` None/<=0 means
no truncation. ``position`` None = non-role queue (all heroes).
"""
allies = list(allies or [])
enemies = list(enemies or [])
if len(enemies) < max(1, int(min_enemies)):
return _empty_result()
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}
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 = []
for ek in enemies:
for edge in counters_of.get(cand) or []:
if edge["key"] == ek:
beats.append({"enemy": ek, "reason": edge.get("reason") or ""})
for edge in countered_by.get(cand) or []:
if edge["key"] == ek:
beaten_by.append({"enemy": ek, "reason": edge.get("reason") or ""})
for ak in allies:
for edge in synergies_of.get(cand) or []:
if edge["key"] == ak:
with_allies.append({"ally": ak, "reason": edge.get("reason") or ""})
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)
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,
**detail,
})
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__ = [
"DEFAULT_RELATIONS",
"DEFAULT_ROLE_TAGS",
"ally_keys",
"candidates_for_position",
"enemy_keys",
"enemy_profile",
"load_relations",
"suggest_marks",
"suggest_top",
]