v0.2.0: relations preview, item shop, abilities, overlay recommend, GSI enhancements

- Add relations/item/abilities preview (serve_relations.py + web/relations/)
- Add fetch scripts: hero_items, item_shop, items_meta, hero_abilities,
  ability_videos, patches, stratz, matchups, portraits
- Add overlay.py (role tags + Top-3 cyan marks), recommend.py
- Add http_utils.py, loc_format.py, hero_tags.py, item_fears.py
- GSI: full payload JSONL dump, foreground window detection
- Drop real template library; CDN-only matching
- Update docs: CHANGELOG 0.2.0, DESIGN config table, AGENTS module table
- .gitignore: exclude large regenerable assets (icons/portraits/videos)
This commit is contained in:
voson
2026-07-27 11:56:51 +08:00
parent e567a5cdfc
commit a91789b72f
76 changed files with 109860 additions and 996 deletions
+142 -15
View File
@@ -38,6 +38,7 @@ from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_fram
from grid import bans, hero_table, read_grid
from modes import detect_mode, load_mode_templates
from recognize import recognize_image
from recommend import ally_keys, enemy_keys, load_relations, suggest_top
from roles import ROLES, detect_roles, load_role_templates
HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION"
@@ -55,10 +56,11 @@ def pick_round(per_team_max: int) -> int:
class DraftSession:
def __init__(self, cfg: dict, library, log=print):
def __init__(self, cfg: dict, library, log=print, overlay=None):
self.cfg = cfg
self.library = library
self.log = log
self.overlay = overlay
g = cfg.get("gsi", {})
self.poll_interval = g.get("poll_interval", 1.0)
self.confirm_polls = g.get("confirm_polls", 2)
@@ -80,11 +82,48 @@ class DraftSession:
self.mode_templates = load_mode_templates()
self.hero_names = {h["key"]: h["name_loc"] for h in hero_table()}
self.frame_dir = raw_dir_for_match(None)
rec = cfg.get("recommend") or {}
self.recommend_enabled = bool(rec.get("enabled", True))
self.recommend_top_n = int(rec.get("top_n", 3))
self.recommend_role_tags = rec.get("role_tags")
self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None
self._rec_warned = False
self._last_rec_sig: tuple | None = None
def _push_overlay(self, confirmed: dict[int, str]) -> None:
if self.overlay is None:
return
try:
self.overlay.set_roster(confirmed)
except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay update failed: {e}")
def _push_rec_overlay(self, cells: dict | None, picks: list[dict]) -> None:
if self.overlay is None:
return
try:
marks = {p["key"]: p["rank"] for p in picks}
self.overlay.set_grid_marks(cells or {}, marks)
except Exception as e: # noqa: BLE001
self.log(f"[draft] rec overlay update failed: {e}")
def run(self, match_id: str, state_fn, gsi_fn=None) -> dict:
"""Poll until the draft is over. state_fn returns the live GSI state."""
self.frame_dir = raw_dir_for_match(match_id)
self.log(f"[draft] frames -> {self.frame_dir}")
if self.overlay is not None:
try:
self.overlay.set_roster({})
self.overlay.set_grid_marks({}, {})
self.overlay.show()
except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay show failed: {e}")
if self.recommend_enabled and not self._rec_warned:
rel = self.relations or {}
if not rel.get("counters") and not rel.get("synergies"):
self._rec_warned = True
self.log("[rec] relations empty — edit data/relations.json "
"or run import_relations_xlsx.py")
started = time.monotonic()
pending: dict[int, tuple[str, int]] = {}
confirmed: dict[int, str] = {}
@@ -96,7 +135,9 @@ class DraftSession:
"self_team": None,
"roles": {},
"unavailable": None,
"cells": None,
"mode": None,
"recommendations": [],
}
polls = 0
last_frame = None
@@ -142,6 +183,7 @@ class DraftSession:
frame = grab_frame(sct)
last_frame = frame
polls += 1
self._sync_self_from_gsi(info, gsi_fn)
# Keep reading in strategy until the roster is full - the last
# pick is often revealed on the same tick selection ends, and
@@ -179,12 +221,16 @@ class DraftSession:
rev["t"] = round(elapsed, 1)
revisions.append(rev)
self._log_revision(rev)
if added or revised:
self._push_overlay(confirmed)
if added:
event = self._event(added, confirmed, state, elapsed)
if self.keep_frames:
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
timeline.append(event)
self._log_event(event, info)
if state == HERO_SELECTION:
self._refresh_recommendations(confirmed, info, gsi_fn)
n = len(confirmed)
if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones:
@@ -204,6 +250,14 @@ class DraftSession:
if best and not vision_done:
self._finalize_vision(best, confirmed, scores)
self._push_overlay(confirmed)
self._refresh_recommendations(confirmed, info, gsi_fn, force=True)
if self.overlay is not None:
try:
self.overlay.set_grid_marks({}, {})
self.overlay.hide()
except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay hide failed: {e}")
keep = best["frame"] if best is not None else last_frame
return self._summary(match_id, timeline, confirmed, info, polls, started, keep,
gsi_fn, revisions, best)
@@ -216,6 +270,7 @@ class DraftSession:
self.log(f"[draft] best lineup frame: {best['recognized']}/10 "
f"at t={best['t']:.1f}s ({best['state']})")
filled = self._backfill(confirmed, scores, best)
self._push_overlay(confirmed)
if filled:
self.log(f"[draft] backfilled {filled} slots from best frame")
if self.keep_frames:
@@ -267,35 +322,93 @@ class DraftSession:
return cand
return best
def _sync_self_from_gsi(self, info: dict, gsi_fn) -> None:
"""Own top-bar slot comes only from GSI team_slot."""
if not gsi_fn:
return
gsi = gsi_fn() or {}
slot = gsi_slot(gsi)
if slot is not None:
info["self_slot"] = slot
if gsi.get("team") in ("radiant", "dire"):
info["self_team"] = gsi["team"]
def _absorb_roles(self, frame, info: dict) -> None:
"""Roles and your own slot never change, so stop looking once found."""
if info["self_slot"] is not None and info["roles"]:
"""Lane labels never change mid-draft; stop scanning once found."""
if info["roles"]:
return
found = detect_roles(frame, self.cfg, self.role_templates)
if found["self_slot"] is not None and info["self_slot"] is None:
info["self_slot"] = found["self_slot"]
if found["roles"] and not info["roles"]:
if found["roles"]:
info["roles"] = found["roles"]
info["self_team"] = found["self_team"] or info["self_team"]
if found["self_team"] and not info["self_team"]:
info["self_team"] = found["self_team"]
def _absorb_grid(self, frame, info: dict) -> None:
"""Read the ban list off the hero grid, once.
"""Read bans (once) and cell rects (whenever the lattice is readable).
The set of banned heroes is fixed before the first pick, so the
earliest readable frame is also the cleanest: nothing has been taken
yet, so everything greyed out is a ban. Frames where a hover tooltip
covers the grid fail the layout check and are simply skipped.
earliest readable frame is also the cleanest. Cell geometry is
refreshed so recommend badges stay aligned while the grid is up.
"""
if info["unavailable"] is not None:
return
res = read_grid(frame, self.cfg)
if not res["ok"]:
return
if res.get("cells"):
info["cells"] = res["cells"]
if info["unavailable"] is not None:
return
info["unavailable"] = res["unavailable"]
names = ", ".join(self.hero_names.get(k, k) for k in res["unavailable"])
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
f"(contrast margin {res['margin']}) - {names}")
def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
if not self.recommend_enabled:
return
if not self.relations:
return
gsi = (gsi_fn() or {}) if gsi_fn else {}
self_slot = gsi_slot(gsi) or info.get("self_slot")
self_team = gsi.get("team") or info.get("self_team")
role = info["roles"].get(self_slot) if self_slot else None
position = role["position"] if role else None
# Stop suggesting once you have locked a hero.
if self_slot and confirmed.get(self_slot):
if info.get("recommendations") or self._last_rec_sig is not None:
info["recommendations"] = []
self._last_rec_sig = ("locked",)
self._push_rec_overlay(info.get("cells"), [])
return
enemies = enemy_keys(confirmed, self_team)
allies = ally_keys(confirmed, self_team, self_slot)
exclude = set(confirmed.values())
if info.get("unavailable"):
exclude.update(info["unavailable"])
sig = (position, self_team, tuple(enemies), tuple(allies), tuple(sorted(exclude)))
if not force and sig == self._last_rec_sig:
return
self._last_rec_sig = sig
if position is None or (not enemies and not allies):
info["recommendations"] = []
self._push_rec_overlay(info.get("cells"), [])
return
picks = suggest_top(
position=position,
enemies=enemies,
allies=allies,
exclude=exclude,
relations=self.relations,
top_n=self.recommend_top_n,
role_tags=self.recommend_role_tags,
)
info["recommendations"] = picks
self._push_rec_overlay(info.get("cells"), picks)
if picks:
names = ", ".join(f"{p['rank']}.{p['name_loc']}({p['score']:+d})" for p in picks)
ally_n = [self.hero_names.get(a, a) for a in allies]
enemy_n = [self.hero_names.get(e, e) for e in enemies]
self.log(f"[rec] pos{position} with {ally_n} vs {enemy_n}: {names}")
def _absorb_picks(self, result: dict, pending: dict, confirmed: dict,
scores: dict, *, allow_revise: bool = True,
) -> tuple[list[dict], list[dict]]:
@@ -381,8 +494,6 @@ class DraftSession:
revisions=None, best=None) -> dict:
gsi = gsi_fn() if gsi_fn else {}
self_slot = gsi_slot(gsi) or info["self_slot"]
if self_slot and info["self_slot"] and self_slot != info["self_slot"]:
self.log(f"[draft] self slot {info['self_slot']} -> {self_slot} (GSI team_slot)")
# GSI knows your own hero with certainty once it is locked. Prefer it.
gsi_hero = gsi.get("hero")
@@ -395,6 +506,9 @@ class DraftSession:
)
role = info["roles"].get(self_slot) if self_slot else None
team = gsi.get("team") or info["self_team"]
enemies = enemy_keys(confirmed, team)
allies = ally_keys(confirmed, team, self_slot)
summary = {
"match_id": match_id,
"captured_at": time.strftime("%Y-%m-%d %H:%M:%S"),
@@ -409,6 +523,8 @@ class DraftSession:
"role_label": role["label"] if role else None,
"position": role["position"] if role else None,
"gsi_name": gsi.get("name"),
"accountid": gsi.get("accountid"),
"steamid": gsi.get("steamid"),
},
"team_roles": {
str(s): {"position": r["position"], "label": r["label"], "hero": confirmed.get(s)}
@@ -421,6 +537,12 @@ class DraftSession:
"recognized": len(confirmed),
"revisions": revisions or [],
"timeline": timeline,
"recommendations": {
"position": role["position"] if role else None,
"enemies": enemies,
"allies": allies,
"picks": info.get("recommendations") or [],
},
}
if info["unavailable"] is not None:
banned = bans({"unavailable": info["unavailable"]}, list(confirmed.values()))
@@ -490,6 +612,11 @@ def describe(summary: dict) -> list[str]:
lines.append(f"rounds : {len(summary['timeline'])} reveal events over {summary['duration_s']}s")
if summary.get("bans_loc"):
lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}")
rec = summary.get("recommendations") or {}
picks = rec.get("picks") or []
if picks:
bits = ", ".join(f"{p['rank']}.{p.get('name_loc') or loc(p['key'], names)}" for p in picks)
lines.append(f"rec : {bits}")
return lines