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>
This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
"""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 sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import time
|
||||
|
||||
import mss
|
||||
|
||||
from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_frame
|
||||
from shared.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_marks
|
||||
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", 0))
|
||||
self.recommend_min_enemies = int(rec.get("min_enemies", 1))
|
||||
self.recommend_min_heroes_for_gaps = int(rec.get("min_heroes_for_gaps", 2))
|
||||
self.recommend_archetypes = bool(rec.get("archetypes", True))
|
||||
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
|
||||
self._last_enemy_profile: dict = {}
|
||||
self._last_rec_meta: dict = {}
|
||||
|
||||
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],
|
||||
analysis: str = "",
|
||||
) -> None:
|
||||
if self.overlay is None:
|
||||
return
|
||||
try:
|
||||
marks = {p["key"]: list(p.get("labels") or []) for p in picks if p.get("key")}
|
||||
self.overlay.set_grid_marks(cells or {}, marks)
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis(analysis or "")
|
||||
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({}, {})
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis("")
|
||||
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 shared/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({}, {})
|
||||
if hasattr(self.overlay, "set_analysis"):
|
||||
self.overlay.set_analysis("")
|
||||
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 and not self.recommend_archetypes:
|
||||
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_enemy_profile = {}
|
||||
self._last_rec_meta = {}
|
||||
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 len(enemies) < self.recommend_min_enemies:
|
||||
info["recommendations"] = []
|
||||
self._last_enemy_profile = {}
|
||||
self._last_rec_meta = {}
|
||||
self._push_rec_overlay(info.get("cells"), [], "")
|
||||
return
|
||||
result = suggest_marks(
|
||||
position=position,
|
||||
enemies=enemies,
|
||||
allies=allies,
|
||||
exclude=exclude,
|
||||
relations=self.relations,
|
||||
top_n=self.recommend_top_n,
|
||||
role_tags=self.recommend_role_tags,
|
||||
min_enemies=self.recommend_min_enemies,
|
||||
min_heroes_for_gaps=self.recommend_min_heroes_for_gaps,
|
||||
archetypes_enabled=self.recommend_archetypes,
|
||||
)
|
||||
picks = result.get("marks") or []
|
||||
profile = result.get("enemy_profile") or {}
|
||||
analysis = result.get("analysis") or ""
|
||||
info["recommendations"] = picks
|
||||
self._last_enemy_profile = profile
|
||||
self._last_rec_meta = {
|
||||
"analysis": analysis,
|
||||
"enemy_archetypes": list(result.get("enemy_archetypes") or []),
|
||||
"enemy_gaps": list(result.get("enemy_gaps") or []),
|
||||
"ally_gaps": list(result.get("ally_gaps") or []),
|
||||
"ally_profile": dict(result.get("ally_profile") or {}),
|
||||
}
|
||||
self._push_rec_overlay(info.get("cells"), picks, analysis)
|
||||
if picks or analysis:
|
||||
bits = []
|
||||
for p in picks[:12]:
|
||||
labs = "".join(p.get("labels") or [])
|
||||
why = "、".join(p.get("reasons") or [])
|
||||
extra = f":{why}" if why else ""
|
||||
bits.append(f"{p['name_loc']}[{labs}]{extra}")
|
||||
pos_s = f"pos{position}" if position is not None else "all"
|
||||
ally_n = [self.hero_names.get(a, a) for a in allies]
|
||||
enemy_n = [self.hero_names.get(e, e) for e in enemies]
|
||||
analysis_s = analysis or "-"
|
||||
self.log(
|
||||
f"[rec] {pos_s} {analysis_s} | with {ally_n} vs {enemy_n}: "
|
||||
f"{len(picks)} marks — {', '.join(bits)}"
|
||||
)
|
||||
|
||||
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,
|
||||
"enemy_profile": dict(self._last_enemy_profile or {}),
|
||||
"ally_profile": dict((self._last_rec_meta or {}).get("ally_profile") or {}),
|
||||
"enemy_archetypes": list((self._last_rec_meta or {}).get("enemy_archetypes") or []),
|
||||
"enemy_gaps": list((self._last_rec_meta or {}).get("enemy_gaps") or []),
|
||||
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
||||
"analysis": (self._last_rec_meta or {}).get("analysis") or "",
|
||||
"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 {}
|
||||
analysis = rec.get("analysis") or ""
|
||||
if analysis:
|
||||
lines.append(f"draft : {analysis}")
|
||||
picks = rec.get("picks") or []
|
||||
if picks:
|
||||
bits = []
|
||||
for p in picks[:15]:
|
||||
labs = "".join(p.get("labels") or []) or "?"
|
||||
why = "、".join(p.get("reasons") or [])
|
||||
name = p.get("name_loc") or loc(p["key"], names)
|
||||
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
||||
lines.append(f"rec : {len(picks)} — {', '.join(bits)}")
|
||||
return lines
|
||||
|
||||
|
||||
__all__ = ["DraftSession", "DRAFT_STATES", "HERO_SELECTION", "STRATEGY_TIME", "describe", "loc", "ROLES"]
|
||||
Reference in New Issue
Block a user