- 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)
624 lines
27 KiB
Python
624 lines
27 KiB
Python
"""Follow a whole draft instead of taking one snapshot at the end.
|
|
|
|
Ranked All Pick reveals picks in waves rather than one at a time (official
|
|
rules: two rounds of 2 picks per team at 25s, then a final round of 1 at 20s,
|
|
with each round's picks hidden until the round ends). A single grab at
|
|
strategy time therefore loses the order completely, which is exactly the
|
|
information you need to reason about what to counter-pick.
|
|
|
|
This polls the screen for as long as GSI says we are still drafting and
|
|
appends a timeline event whenever the confirmed set of picks changes. A pick
|
|
only becomes confirmed after the same hero lands in the same slot on
|
|
`confirm_polls` consecutive frames, because the top bar animates portraits in
|
|
and a single frame catches half-faded artwork.
|
|
|
|
While the hero grid is still up it also reads the ban list off it (see
|
|
grid.py), which the top bar never shows.
|
|
|
|
Top-bar portraits use the default icon until everyone has picked; skins
|
|
land only after the draft is complete. Vision therefore runs through both
|
|
HERO_SELECTION and early STRATEGY_TIME until all ten slots are filled - the
|
|
last reveal often lands right as strategy begins, and a player who already
|
|
locked may be staring at the strategy UI while others are still picking.
|
|
|
|
Skinned portraits are not templated (too many variants). Instead:
|
|
- during STRATEGY_TIME only empty slots may be filled; confirmed picks are
|
|
never revised (skin art must not overwrite a settled default face);
|
|
- the saved best lineup frame prefers HERO_SELECTION when recognition
|
|
counts tie, so draft_best_* stays on default faces when possible.
|
|
|
|
Once ten heroes are confirmed, vision stops and only GSI is waited on for self.
|
|
"""
|
|
|
|
import time
|
|
|
|
import mss
|
|
|
|
from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_frame
|
|
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"
|
|
STRATEGY_TIME = "DOTA_GAMERULES_STATE_STRATEGY_TIME"
|
|
DRAFT_STATES = (HERO_SELECTION, STRATEGY_TIME)
|
|
|
|
|
|
def pick_round(per_team_max: int) -> int:
|
|
"""Which of the three All Pick rounds a given pick count belongs to."""
|
|
if per_team_max <= 2:
|
|
return 1
|
|
if per_team_max <= 4:
|
|
return 2
|
|
return 3
|
|
|
|
|
|
class DraftSession:
|
|
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)
|
|
self.timeout = g.get("session_timeout", 300)
|
|
# how much better a later reading must score before it may overwrite
|
|
# an already confirmed pick
|
|
self.revise_gain = g.get("revise_gain", 0.15)
|
|
self.target = g.get("target_slots", 10)
|
|
self.keep_frames = g.get("keep_event_frames", True)
|
|
# >0 saves a frame every N seconds while the hero grid is up
|
|
self.dump_every = g.get("dump_selection_every", 0)
|
|
# after selection ends, keep reading strategy frames until 10/10 or
|
|
# this many polls - catches the last reveal without hanging forever
|
|
self.strategy_tail = g.get("strategy_tail_polls", 8)
|
|
self.gsi_wait = g.get("strategy_gsi_wait", 3.0)
|
|
# skip grab/recognize when Dota is not the foreground window
|
|
self.require_foreground = bool(g.get("require_foreground", True))
|
|
self.role_templates = load_role_templates()
|
|
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] = {}
|
|
scores: dict[int, float] = {}
|
|
revisions: list[dict] = []
|
|
timeline: list[dict] = []
|
|
info = {
|
|
"self_slot": None,
|
|
"self_team": None,
|
|
"roles": {},
|
|
"unavailable": None,
|
|
"cells": None,
|
|
"mode": None,
|
|
"recommendations": [],
|
|
}
|
|
polls = 0
|
|
last_frame = None
|
|
best: dict | None = None
|
|
next_dump = 0.0
|
|
saved_milestones: set[int] = set()
|
|
vision_done = False
|
|
strategy_polls = 0
|
|
freeze_at = 0.0
|
|
skipped_fg = False
|
|
|
|
with mss.MSS() as sct:
|
|
while True:
|
|
state = state_fn()
|
|
if state not in DRAFT_STATES:
|
|
self.log(f"[draft] session ended (state={state})")
|
|
break
|
|
if time.monotonic() - started > self.timeout:
|
|
self.log(f"[draft] session timed out after {self.timeout}s")
|
|
break
|
|
|
|
elapsed = time.monotonic() - started
|
|
|
|
# Vision finished: only wait on GSI / short timeout (no grab).
|
|
if vision_done:
|
|
gsi_hero = (gsi_fn() or {}).get("hero") if gsi_fn else None
|
|
if gsi_hero or (elapsed - freeze_at) >= self.gsi_wait:
|
|
break
|
|
time.sleep(self.poll_interval)
|
|
continue
|
|
|
|
# Desktop / other apps must not burn strategy_tail or confirm streaks.
|
|
if self.require_foreground and not is_dota_foreground():
|
|
if not skipped_fg:
|
|
self.log("[draft] Dota 2 not foreground - skipping frames")
|
|
skipped_fg = True
|
|
time.sleep(self.poll_interval)
|
|
continue
|
|
if skipped_fg:
|
|
self.log("[draft] Dota 2 foreground again - resuming vision")
|
|
skipped_fg = False
|
|
|
|
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
|
|
# a player who already locked may only see strategy UI.
|
|
do_vision = (
|
|
state == HERO_SELECTION
|
|
or (state == STRATEGY_TIME and strategy_polls < self.strategy_tail)
|
|
)
|
|
if state == STRATEGY_TIME:
|
|
strategy_polls += 1
|
|
|
|
if do_vision:
|
|
if self.dump_every and state == HERO_SELECTION and elapsed >= next_dump:
|
|
next_dump = elapsed + self.dump_every
|
|
self.log(f"[draft] grid frame: {save_frame(frame, self.frame_dir, prefix='select')}")
|
|
|
|
if info["mode"] is None:
|
|
found = detect_mode(frame, self.cfg, self.mode_templates)
|
|
if found:
|
|
info["mode"] = found
|
|
self.log(f"[draft] mode: {found['label']} ({found['score']:.2f})")
|
|
|
|
result = recognize_image(frame, self.cfg, self.library)
|
|
self._absorb_roles(frame, info)
|
|
if state == HERO_SELECTION:
|
|
self._absorb_grid(frame, info)
|
|
|
|
best = self._remember_best(best, frame, result, state, elapsed)
|
|
# Strategy frames may show skins; only fill empty slots there.
|
|
added, revised = self._absorb_picks(
|
|
result, pending, confirmed, scores,
|
|
allow_revise=(state == HERO_SELECTION),
|
|
)
|
|
for rev in revised:
|
|
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:
|
|
saved_milestones.add(n)
|
|
path = save_frame(frame, self.frame_dir, prefix=f"draft_n{n}")
|
|
self.log(f"[draft] milestone {n}/10: {path}")
|
|
|
|
if n >= self.target or (
|
|
state == STRATEGY_TIME and strategy_polls >= self.strategy_tail
|
|
):
|
|
self._finalize_vision(best, confirmed, scores)
|
|
vision_done = True
|
|
freeze_at = elapsed
|
|
self.log("[draft] vision done - waiting on GSI for self hero")
|
|
|
|
time.sleep(self.poll_interval)
|
|
|
|
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)
|
|
|
|
def _finalize_vision(self, best: dict | None, confirmed: dict, scores: dict) -> None:
|
|
if not best:
|
|
return
|
|
if best.get("path"):
|
|
return
|
|
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:
|
|
best["path"] = save_frame(best["frame"], self.frame_dir, prefix="draft_best")
|
|
self.log(f"[draft] saved best: {best['path']}")
|
|
|
|
def _backfill(self, confirmed: dict, scores: dict, best: dict) -> int:
|
|
"""Copy threshold-passed heroes from the best frame into empty slots."""
|
|
n = 0
|
|
for i, hero in enumerate(best.get("heroes") or [], 1):
|
|
if hero and i not in confirmed:
|
|
confirmed[i] = hero
|
|
scores[i] = 0.0
|
|
n += 1
|
|
return n
|
|
|
|
def _remember_best(self, best: dict | None, frame, result: dict, state: str, t: float) -> dict:
|
|
"""Track the clearest top-bar reading seen so far.
|
|
|
|
Prefer more recognized slots first (so a late last-pick still wins).
|
|
On a tie, prefer HERO_SELECTION over STRATEGY_TIME so skinned strategy
|
|
portraits do not replace a cleaner default-face frame. Score sum is
|
|
the final tie-breaker within the same state preference.
|
|
"""
|
|
n = int(result.get("recognized") or 0)
|
|
if n == 0:
|
|
return best
|
|
score_sum = sum(float(r.get("score") or 0) for r in result["slots"] if r.get("hero"))
|
|
selection = state == HERO_SELECTION
|
|
cand = {
|
|
"recognized": n,
|
|
"score_sum": score_sum,
|
|
"selection": selection,
|
|
"t": t,
|
|
"state": state.replace("DOTA_GAMERULES_STATE_", ""),
|
|
"frame": frame.copy(),
|
|
"heroes": [r.get("hero") for r in result["slots"]],
|
|
}
|
|
if best is None:
|
|
return cand
|
|
if n > best["recognized"]:
|
|
return cand
|
|
if n < best["recognized"]:
|
|
return best
|
|
# same recognized count: prefer selection-phase default faces
|
|
if selection and not best.get("selection", False):
|
|
return cand
|
|
if selection == best.get("selection", False) and score_sum > best["score_sum"]:
|
|
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:
|
|
"""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["roles"]:
|
|
info["roles"] = found["roles"]
|
|
if found["self_team"] and not info["self_team"]:
|
|
info["self_team"] = found["self_team"]
|
|
|
|
def _absorb_grid(self, frame, info: dict) -> None:
|
|
"""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. Cell geometry is
|
|
refreshed so recommend badges stay aligned while the grid is up.
|
|
"""
|
|
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]]:
|
|
"""Promote picks seen on enough consecutive frames.
|
|
|
|
Returns (new picks, revisions). During HERO_SELECTION a slot stays
|
|
open to revision because the frame that first reveals a portrait is
|
|
the worst one to judge it on: the art is still fading in and the
|
|
ranked title bar covers the lower face. Once the portrait settles it
|
|
scores far higher, and a clearly better reading may overwrite.
|
|
|
|
During STRATEGY_TIME set allow_revise=False: only empty slots may be
|
|
filled. Skinned portraits must not replace a confirmed default face.
|
|
"""
|
|
added, revised = [], []
|
|
for r in result["slots"]:
|
|
slot, hero, score = r["slot"], r["hero"], r["score"]
|
|
if hero is None:
|
|
continue
|
|
|
|
if slot in confirmed:
|
|
if hero == confirmed[slot]:
|
|
scores[slot] = max(scores.get(slot, 0.0), score)
|
|
pending.pop(slot, None)
|
|
continue
|
|
if not allow_revise:
|
|
continue
|
|
if score < scores.get(slot, 0.0) + self.revise_gain:
|
|
continue
|
|
prev_hero, streak = pending.get(slot, (None, 0))
|
|
streak = streak + 1 if hero == prev_hero else 1
|
|
pending[slot] = (hero, streak)
|
|
if streak >= self.confirm_polls:
|
|
revised.append({"slot": slot, "team": team_of(slot),
|
|
"hero": hero, "was": confirmed[slot],
|
|
"score": score, "was_score": scores.get(slot, 0.0)})
|
|
confirmed[slot] = hero
|
|
scores[slot] = score
|
|
pending.pop(slot, None)
|
|
continue
|
|
|
|
prev_hero, streak = pending.get(slot, (None, 0))
|
|
streak = streak + 1 if hero == prev_hero else 1
|
|
pending[slot] = (hero, streak)
|
|
if streak >= self.confirm_polls:
|
|
confirmed[slot] = hero
|
|
scores[slot] = score
|
|
pending.pop(slot, None)
|
|
added.append({"slot": slot, "team": team_of(slot), "hero": hero})
|
|
return added, revised
|
|
|
|
def _event(self, added: list[dict], confirmed: dict, state: str, elapsed: float) -> dict:
|
|
radiant = sorted(s for s in confirmed if s <= 5)
|
|
dire = sorted(s for s in confirmed if s > 5)
|
|
return {
|
|
"t": round(elapsed, 1),
|
|
"state": state.replace("DOTA_GAMERULES_STATE_", ""),
|
|
"round": pick_round(max(len(radiant), len(dire))),
|
|
"added": added,
|
|
"radiant": [confirmed[s] for s in radiant],
|
|
"dire": [confirmed[s] for s in dire],
|
|
"count": len(confirmed),
|
|
}
|
|
|
|
def _log_event(self, event: dict, info: dict) -> None:
|
|
for a in event["added"]:
|
|
mine = " <- you" if a["slot"] == info["self_slot"] else ""
|
|
role = info["roles"].get(a["slot"])
|
|
tag = f" [{role['label']}]" if role else ""
|
|
self.log(
|
|
f"[draft] +{event['t']:6.1f}s round{event['round']} "
|
|
f"{a['team']:7s} slot{a['slot']:<2d} {loc(a['hero'], self.hero_names)}{tag}{mine}"
|
|
)
|
|
|
|
def _log_revision(self, rev: dict) -> None:
|
|
self.log(
|
|
f"[draft] ~{rev['t']:6.1f}s slot{rev['slot']:<2d} "
|
|
f"{loc(rev['was'], self.hero_names)} -> {loc(rev['hero'], self.hero_names)} "
|
|
f"(score {rev['was_score']:.2f} -> {rev['score']:.2f})"
|
|
)
|
|
|
|
def _summary(self, match_id, timeline, confirmed, info, polls, started, frame, gsi_fn,
|
|
revisions=None, best=None) -> dict:
|
|
gsi = gsi_fn() if gsi_fn else {}
|
|
self_slot = gsi_slot(gsi) or info["self_slot"]
|
|
|
|
# GSI knows your own hero with certainty once it is locked. Prefer it.
|
|
gsi_hero = gsi.get("hero")
|
|
if self_slot and gsi_hero and confirmed.get(self_slot) != gsi_hero:
|
|
prev = confirmed.get(self_slot)
|
|
confirmed[self_slot] = gsi_hero
|
|
self.log(
|
|
f"[draft] self hero {loc(prev, self.hero_names)} -> "
|
|
f"{loc(gsi_hero, self.hero_names)} (GSI)"
|
|
)
|
|
|
|
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"),
|
|
"duration_s": round(time.monotonic() - started, 1),
|
|
"polls": polls,
|
|
"mode": info.get("mode"),
|
|
"self": {
|
|
"slot": self_slot,
|
|
"team": gsi.get("team") or info["self_team"] or (team_of(self_slot) if self_slot else None),
|
|
"hero": (confirmed.get(self_slot) if self_slot else None) or gsi_hero,
|
|
"role": role["role"] if role else None,
|
|
"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)}
|
|
for s, r in sorted(info["roles"].items())
|
|
},
|
|
"final": {
|
|
"radiant": [confirmed.get(s) for s in range(1, 6)],
|
|
"dire": [confirmed.get(s) for s in range(6, 11)],
|
|
},
|
|
"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()))
|
|
summary["bans"] = banned
|
|
summary["bans_loc"] = [self.hero_names.get(k, k) for k in banned]
|
|
if best:
|
|
summary["best_lineup"] = {
|
|
"recognized": best["recognized"],
|
|
"t": round(best["t"], 1),
|
|
"state": best["state"],
|
|
"heroes": best["heroes"],
|
|
"frame": best.get("path"),
|
|
}
|
|
if frame is not None and self.keep_frames:
|
|
# `frame` is already the best readable lineup when one was found
|
|
summary["last_frame"] = best.get("path") if best and best.get("path") else \
|
|
save_frame(frame, self.frame_dir, prefix="draft")
|
|
return summary
|
|
|
|
|
|
def team_of(slot: int) -> str:
|
|
return "radiant" if slot <= 5 else "dire"
|
|
|
|
|
|
def gsi_slot(gsi: dict) -> int | None:
|
|
"""Top-bar slot from GSI's own team_slot, which beats any pixel heuristic.
|
|
|
|
The bar is ordered by team slot, radiant on the left. GSI leaves the
|
|
player block out until a match is loaded, hence the None path.
|
|
"""
|
|
team_slot = gsi.get("team_slot")
|
|
team = gsi.get("team")
|
|
if team_slot is None or team not in ("radiant", "dire"):
|
|
return None
|
|
return int(team_slot) + (1 if team == "radiant" else 6)
|
|
|
|
|
|
def loc(key: str | None, names: dict[str, str] | None = None) -> str:
|
|
"""English hero key -> in-client Chinese name, for human-facing output."""
|
|
if not key:
|
|
return "?"
|
|
if names is None:
|
|
names = {h["key"]: h["name_loc"] for h in hero_table()}
|
|
return names.get(key, key)
|
|
|
|
|
|
def describe(summary: dict) -> list[str]:
|
|
"""Human-readable recap printed when a session ends."""
|
|
names = {h["key"]: h["name_loc"] for h in hero_table()}
|
|
me = summary["self"]
|
|
lines = []
|
|
mode = summary.get("mode")
|
|
if mode:
|
|
lines.append(f"mode : {mode.get('label') or mode.get('key')}")
|
|
for side in ("radiant", "dire"):
|
|
heroes = [loc(h, names) for h in summary["final"][side]]
|
|
lines.append(f"{side:7s}: {', '.join(heroes)}")
|
|
if me["slot"]:
|
|
pos = f"position {me['position']} ({me['role_label']})" if me["position"] else "position unknown"
|
|
lines.append(f"you : slot {me['slot']} {me['team']} {loc(me['hero'], names)} - {pos}")
|
|
if summary["team_roles"]:
|
|
order = ", ".join(
|
|
f"{v['position']}:{loc(v['hero'], names)}"
|
|
for v in sorted(summary["team_roles"].values(), key=lambda v: v["position"])
|
|
)
|
|
lines.append(f"lanes : {order}")
|
|
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
|
|
|
|
|
|
__all__ = ["DraftSession", "DRAFT_STATES", "HERO_SELECTION", "STRATEGY_TIME", "describe", "loc", "ROLES"]
|