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:
voson
2026-07-29 14:29:08 +08:00
co-authored by Cursor
parent 96a9312194
commit 9c5aa5b610
280 changed files with 1451 additions and 450 deletions
+454
View File
@@ -0,0 +1,454 @@
"""Build hero → feared items map from items_meta + hero_abilities.
Usage:
python item_fears.py
python item_fears.py --top 8
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.hero_tags import ILLUSION_KEYS
from shared.http_utils import http_json
from shared.paths import DATA
ITEMS_META = DATA / "items_meta.json"
HERO_ABILITIES = DATA / "hero_abilities.json"
HERO_FEAR_OVERRIDES = DATA / "hero_fear_overrides.json"
ITEM_COUNTER_STATS = DATA / "item_counter_stats.json"
OUT = DATA / "hero_item_fears.json"
ABILITIES_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
)
# Prefer iconic counter items when scores tie
ITEM_PRIORITY = {
"lotus_orb": 12,
"diffusal_blade": 10,
"disperser": 9,
"nullifier": 9,
"silver_edge": 8,
"monkey_king_bar": 8,
"bfury": 8,
"abyssal_blade": 8,
"mjollnir": 7,
"maelstrom": 6,
"radiance": 7,
"gungir": 6,
"spirit_vessel": 7,
"black_king_bar": 5,
"sphere": 5,
"butterfly": 6,
"ethereal_blade": 6,
"ghost": 4,
"gem": 4,
"dust": 3,
"orchid": 5,
"bloodthorn": 6,
"sheepstick": 6,
}
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def is_unit_target_enemy(od: dict) -> bool:
beh = od.get("behavior")
if isinstance(beh, str):
unit = "Unit Target" in beh
elif isinstance(beh, list):
unit = any("Unit Target" in str(x) for x in beh)
else:
return False
if not unit:
return False
team = od.get("target_team")
if isinstance(team, list):
team_s = " ".join(str(x) for x in team)
else:
team_s = str(team or "")
return "Enemy" in team_s or team_s == ""
def load_odota_unit_target_keys() -> set[str]:
"""Ability keys that are enemy unit-target (Lotus / Linken relevant)."""
try:
raw = http_json(ABILITIES_URL, timeout=90)
except Exception as e: # noqa: BLE001
print(f"warn: could not load OpenDota abilities.json ({e})", flush=True)
return set()
if not isinstance(raw, dict):
return set()
out: set[str] = set()
for key, row in raw.items():
if not isinstance(row, dict) or str(key).startswith("special_bonus"):
continue
if is_unit_target_enemy(row):
out.add(str(key))
return out
def index_items_by_key(items: dict) -> dict[str, dict]:
out: dict[str, dict] = {}
for row in items.values():
if not isinstance(row, dict) or not row.get("key"):
continue
key = row["key"]
out[key] = {
"key": key,
"name_loc": row.get("name_loc") or row.get("dname") or key,
"tags": [t for t in (row.get("tags") or []) if isinstance(t, str)],
"cost": int(row.get("cost") or 0),
}
return out
def enrich_override_items(items_by_key: dict[str, dict], overrides: dict[str, dict]) -> None:
"""Ensure override-only item keys have name_loc (from OpenDota items.json)."""
needed = set()
for ov in overrides.values():
for row in ov.get("add") or []:
if isinstance(row, dict) and row.get("item"):
needed.add(str(row["item"]))
missing = [k for k in needed if k not in items_by_key]
if not missing:
return
try:
raw = http_json(
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json",
timeout=90,
)
except Exception as e: # noqa: BLE001
print(f"warn: could not enrich override items ({e})", flush=True)
return
if not isinstance(raw, dict):
return
for key in missing:
row = raw.get(key)
if not isinstance(row, dict):
items_by_key[key] = {
"key": key,
"name_loc": key,
"tags": [],
"cost": 0,
}
continue
items_by_key[key] = {
"key": key,
"name_loc": str(row.get("dname") or key),
"tags": [],
"cost": int(row.get("cost") or 0),
}
def index_items_by_tag(items_by_key: dict[str, dict]) -> dict[str, list[dict]]:
by_tag: dict[str, list[dict]] = {}
for entry in items_by_key.values():
for t in entry["tags"]:
by_tag.setdefault(t, []).append(entry)
return by_tag
def hero_summary(
hero_key: str,
abilities_db: dict,
hero_tags: list[str],
unit_target_keys: set[str],
) -> dict:
cell = (abilities_db.get("by_hero") or {}).get(hero_key) or {}
summary = dict(cell.get("summary") or {})
if hero_key in ILLUSION_KEYS or "幻象" in (hero_tags or []):
summary["has_illusion"] = True
# Unit-target enemy skills → Lotus / Linken
has_unit = False
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict):
continue
key = ab.get("key") or ""
if ab.get("is_innate"):
continue
if key in unit_target_keys:
has_unit = True
break
summary["has_unit_target"] = has_unit
return summary
def needed_tags(summary: dict) -> list[tuple[str, str]]:
"""Return (item_tag, reason) pairs this hero fears."""
out: list[tuple[str, str]] = []
n_disp = int(summary.get("dispellable_buff_count") or 0)
if n_disp > 0:
out.append(("basic_dispel", "可驱散技能增益"))
out.append(("strong_dispel", "可驱散技能增益"))
if summary.get("has_strong_only_buff"):
out.append(("strong_dispel", "需强驱散才能驱散的增益"))
# Few shop items apply strong dispel offensively; still surface Linken/BKB.
out.append(("spell_block", "法术格挡关键技能"))
out.append(("magic_immune", "魔免削弱技能"))
if summary.get("has_illusion"):
out.append(("illusion_clear", "克制幻象"))
if summary.get("has_evasion"):
out.append(("true_strike", "无视闪避"))
if summary.get("has_invis"):
out.append(("invis_detect", "显影"))
out.append(("invis_break", "破隐"))
if summary.get("has_passive_breakable"):
out.append(("break", "破坏关键被动"))
if summary.get("mana_dependent"):
out.append(("mana_burn", "烧蓝克制"))
if summary.get("has_unit_target"):
out.append(("spell_reflect", "反射点目标技能"))
out.append(("spell_block", "格挡点目标技能"))
if summary.get("disable_heavy"):
out.append(("magic_immune", "魔免抵消控制"))
out.append(("spell_block", "法术格挡"))
elif summary.get("magic_nuke"):
out.append(("magic_immune", "魔免削弱魔法输出"))
return out
def score_item(
item: dict,
matched_tags: list[str],
reasons: list[str],
bonus: int = 0,
) -> tuple[int, dict]:
pri = ITEM_PRIORITY.get(item["key"], 0)
score = len(matched_tags) * 10 + pri + bonus
if item["cost"] >= 2000:
score += 2
return score, {
"item": item["key"],
"name_loc": item["name_loc"],
"tags": matched_tags,
"reason": "".join(dict.fromkeys(reasons)),
"_score": score,
}
def load_hero_overrides() -> dict[str, dict]:
if not HERO_FEAR_OVERRIDES.is_file():
return {}
try:
raw = load_json(HERO_FEAR_OVERRIDES)
except (OSError, json.JSONDecodeError):
return {}
heroes = raw.get("heroes") or {}
return {str(k): v for k, v in heroes.items() if isinstance(v, dict)}
def load_counter_stats() -> tuple[dict, dict[str, dict[str, dict]]]:
"""Load optional adjusted OpenDota evidence indexed by hero and item."""
if not ITEM_COUNTER_STATS.is_file():
return {}, {}
try:
raw = load_json(ITEM_COUNTER_STATS)
except (OSError, json.JSONDecodeError):
return {}, {}
indexed: dict[str, dict[str, dict]] = {}
for hero_key, rows in (raw.get("by_hero") or {}).items():
if not isinstance(rows, list):
continue
indexed[str(hero_key)] = {
str(row["item"]): row
for row in rows
if isinstance(row, dict) and row.get("item")
}
return dict(raw.get("meta") or {}), indexed
def evidence_adjustment(evidence: dict | None) -> int:
"""Return a deliberately small corroboration bonus/penalty."""
if not evidence:
return 0
games = int(evidence.get("games") or 0)
if games < 100:
return 0
buy_lift_pp = float(evidence.get("buy_lift") or 0) * 100
win_delta_pp = float(evidence.get("win_delta") or 0) * 100
# Mixed signs are inconclusive. Requiring agreement avoids promoting generic
# expensive winner items based on conditional win rate alone.
if buy_lift_pp * win_delta_pp <= 0:
return 0
raw = 0.6 * buy_lift_pp + 0.4 * win_delta_pp
reliability = min(1.0, games / 500)
return round(max(-6.0, min(6.0, raw)) * reliability)
def fears_for_hero(
hero_key: str,
summary: dict,
by_tag: dict[str, list[dict]],
items_by_key: dict[str, dict],
overrides: dict[str, dict],
counter_stats: dict[str, dict[str, dict]],
top_n: int,
) -> list[dict]:
need = needed_tags(summary)
acc: dict[str, dict] = {}
for tag, reason in need:
for item in by_tag.get(tag) or []:
key = item["key"]
if key not in acc:
acc[key] = {"item": item, "matched": [], "reasons": [], "bonus": 0}
if tag not in acc[key]["matched"]:
acc[key]["matched"].append(tag)
if reason not in acc[key]["reasons"]:
acc[key]["reasons"].append(reason)
ov = overrides.get(hero_key) or {}
for row in ov.get("add") or []:
if not isinstance(row, dict):
continue
key = row.get("item")
if not key or key not in items_by_key:
# Allow overrides for items not tagged (butterfly, ghost, …)
if not key:
continue
meta = items_by_key.get(key)
if meta is None:
# Synthesize from key alone if missing from finished catalog
meta = {
"key": key,
"name_loc": key,
"tags": list(row.get("tags") or []),
"cost": 0,
}
items_by_key[key] = meta
item = items_by_key[key]
if key not in acc:
acc[key] = {"item": item, "matched": [], "reasons": [], "bonus": 20}
else:
acc[key]["bonus"] = max(int(acc[key].get("bonus") or 0), 20)
for t in row.get("tags") or []:
if isinstance(t, str) and t not in acc[key]["matched"]:
acc[key]["matched"].append(t)
reason = (row.get("reason") or "").strip()
if reason and reason not in acc[key]["reasons"]:
acc[key]["reasons"].insert(0, reason)
# Refresh name_loc if we only had key
if item.get("name_loc") == key and row.get("name_loc"):
item["name_loc"] = row["name_loc"]
for key in ov.get("remove") or []:
if isinstance(key, str):
acc.pop(key, None)
ranked = []
for row in acc.values():
evidence = (counter_stats.get(hero_key) or {}).get(row["item"]["key"])
sc, entry = score_item(
row["item"],
row["matched"],
row["reasons"],
bonus=int(row.get("bonus") or 0) + evidence_adjustment(evidence),
)
if evidence:
entry["stats"] = {
"games": int(evidence.get("games") or 0),
"purchase_rate": float(evidence.get("buy_rate") or 0),
"win_rate": float(evidence.get("win_rate") or 0),
"purchase_lift": float(evidence.get("buy_lift") or 0),
"win_delta": float(evidence.get("win_delta") or 0),
}
ranked.append((sc, entry))
ranked.sort(key=lambda t: (-t[0], t[1]["item"]))
out = []
for _, entry in ranked[:top_n]:
entry.pop("_score", None)
out.append(entry)
return out
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--top", type=int, default=8)
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
if not ITEMS_META.is_file():
raise SystemExit(f"missing {ITEMS_META}; run: python fetch_items_meta.py")
if not HERO_ABILITIES.is_file():
raise SystemExit(f"missing {HERO_ABILITIES}; run: python fetch_hero_abilities.py")
items_meta = load_json(ITEMS_META)
abilities_db = load_json(HERO_ABILITIES)
counter_meta, counter_stats = load_counter_stats()
items = items_meta.get("items") or {}
items_by_key = index_items_by_key(items)
overrides = load_hero_overrides()
enrich_override_items(items_by_key, overrides)
by_tag = index_items_by_tag(items_by_key)
print("loading OpenDota abilities for unit-target detection...", flush=True)
unit_target_keys = load_odota_unit_target_keys()
print(f" {len(unit_target_keys)} unit-target enemy abilities", flush=True)
heroes = hero_table()
by_hero: dict[str, list] = {}
for h in heroes:
key = h["key"]
tags = list(h.get("tags") or [])
summary = hero_summary(key, abilities_db, tags, unit_target_keys)
by_hero[key] = fears_for_hero(
key, summary, by_tag, items_by_key, overrides, counter_stats, args.top
)
items_out = {
k: {
"key": v["key"],
"name_loc": v["name_loc"],
"tags": v["tags"],
}
for k, v in items_by_key.items()
}
payload = {
"meta": {
"source": "rules+valve+opendota",
"attribution": "derived from items_meta.json + hero_abilities.json",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"top_n": args.top,
"heroes": len(by_hero),
"overrides": str(HERO_FEAR_OVERRIDES.relative_to(DATA.parent)).replace("\\", "/"),
"counter_stats": (
{
"path": str(ITEM_COUNTER_STATS.relative_to(DATA.parent)).replace("\\", "/"),
"source": counter_meta.get("source"),
"fetched_at": counter_meta.get("fetched_at"),
"method": counter_meta.get("method"),
"caveat": counter_meta.get("caveat"),
}
if counter_meta
else None
),
},
"by_hero": by_hero,
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
nonempty = sum(1 for v in by_hero.values() if v)
jugg = [x["item"] for x in by_hero.get("juggernaut") or []]
print(f"done: {nonempty}/{len(by_hero)} heroes have feared items -> {args.out}", flush=True)
print(f" juggernaut: {jugg}", flush=True)
if __name__ == "__main__":
main()