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:
+142
-15
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user