返回网格
SKILL / ZIP

dota2-analyzer

公开中

Analyze Dota 2 matches for single-map and series (Bo1/Bo2/Bo3/Bo5) win probabilities using rating-based logistic models strengthened by recent form, H2H, patch meta and optional Poisson kill or net-worth lead simulation. Extract team rankings, form and stats from DLTV, Dotabuff, OpenDota, STRATZ and Liquipedia via browse_page. Supports pre-draft strength estimates and post-draft adjustments. Use for pre-match predictions, series score distributions, value spotting vs bookmaker or Polymarket odds

游戏 dota2-analyzer 4 FILES 4 VIEWS 2026-08-18 07:49
GrokClaude CodeCursorCodexCLI
安装

一键装到本地 agent

和 SkillsMP 一样,先看源码再安装。Grok 用分享链接;Claude / Cursor / Codex 下载 zip 解到技能目录。

META

NAMEdota2-analyzer
SLUGdota2-analyzer
SOURCEzip
BYTES26029

AI 怎么用

  1. 复制上面的 AI URL
  2. 粘贴到 Claude / ChatGPT / Grok / Cursor
  3. 再说你要做什么,AI 会先读 skill 再动手

SKILL.MD

AGENT READABLE

name: dota2-analyzer
description: Analyze Dota 2 matches for single-map and series (Bo1/Bo2/Bo3/Bo5) win probabilities using rating-based logistic models strengthened by recent form, H2H, patch meta and optional Poisson kill or net-worth lead simulation. Extract team rankings, form and stats from DLTV, Dotabuff, OpenDota, STRATZ and Liquipedia via browse_page. Supports pre-draft strength estimates and post-draft adjustments. Use for pre-match predictions, series score distributions, value spotting vs bookmaker or Polymarket odds. Triggers on Dota 2, dota2, TI, DreamLeague, Bo3 prediction, map win, series win, 勝率, Radiant, Dire.

Dota 2 Win Probability Analyzer (Strengthened)

Use for any professional Dota 2 match analysis involving win rates or probabilities. Trigger words include Dota 2, dota2, TI, The International, DreamLeague, ESL One, PGL, Bo3, Bo5, map win, series prediction, Radiant vs Dire, etc.

Core Model (Why This Is Stronger)

  • Primary: Rating-difference logistic model calibrated on pro data. Convert DLTV ranking points, recent win rates and form into a single-map win probability. Far more stable than raw win-rate conversion for sparse samples.
  • Strengthened upgrade: Optional Poisson (or Normal) model of expected kill difference / GPM difference drawn from team averages. Map the resulting lead distribution through an empirical logistic curve (gold or kill lead → win probability) for a second independent estimate. Average or use as sensitivity check.
  • Series: Exact dynamic programming for Bo1–Bo5 score distributions treating maps as independent (standard practical approximation; real series have mild draft-adaptation dependence).
  • Side note: Small Radiant advantage (patch-dependent, typically +0.5–2.5%) can be applied as a final adjustment when side is known or randomly assigned.
  • Prefer the logistic + form model for most pre-match work. Use the Poisson-lead path when kill/GPM averages are reliable and you want a second view.

Typical calibrated range: even match map_p ≈ 0.48–0.53; clear favorite (large ranking + form gap) map_p ≈ 0.58–0.68. Extreme gaps rarely exceed 0.75 without roster collapse or patch mismatch.

Step-by-Step Workflow (Follow Strictly)

1. Clarify the Query

  • Exact teams (full or short names / orgs), series format (Bo1, Bo2, Bo3 most common, Bo5 for majors/TI), event/stage, date, current patch if known.
  • Ask whether draft/veto is complete or heroes are known (post-draft analysis is higher value).
  • Past match (validation / review) vs upcoming (prediction). Multiple matches → prioritize high-profile or user-specified.
  • Note any known absences, stand-ins, or recent roster changes.

2. Locate Data Pages

  • Rankings (primary strength signal): https://dltv.org/ranking or web_search DLTV Dota 2 ranking.
  • Team pages: web_search site:dotabuff.com/esports [team] or OpenDota [team], STRATZ [team].
  • Match / series page: Liquipedia (site:liquipedia.net [team1] vs [team2]), Dotabuff esports match, or tournament bracket pages.
  • Recent form / H2H: team recent matches on Dotabuff or OpenDota; Liquipedia H2H section.
  • Patch meta context when needed: Spectral Stats or STRATZ meta pages.

3. Extract Data with browse_page

Use the ready prompts in references/data-extraction-prompts.md.

  • Ranking page → current points / rank for both teams.
  • Each team page → recent win rate (last 20–40 games or 3 months), average duration, average kills / GPM / XPM if available, roster stability, notable absences.
  • Match / Liquipedia page → series format, H2H history, previous results on current patch, odds if listed, draft notes.
  • Customize extraction to focus on current patch window and relevant opponents.
  • If data sparse (new roster, stand-ins, low sample) → shrink heavily toward 50% and state low confidence.

4. Estimate Inputs (see references/strength-estimation.md)

  • Start from DLTV points difference or ranking gap → base rating_diff.
  • Convert recent win-rate difference and form (last 5–10 series) into additive adjustment.
  • Apply H2H, motivation (must-win, lower-bracket), roster continuity, patch fit tweaks (±0.02–0.05 typical on map_p).
  • When draft known: manually adjust map_p by hero win-rate edges, known counters, or lane matchups (document the delta).
  • Clamp extreme values (rarely outside 0.38–0.72). Always shrink toward 0.50 when samples < 10–12 recent games.
  • For unknown draft: prepare base + optimistic + pessimistic scenarios or sensitivity range.

5. Run the Calculation Script

# Preferred — direct map win probability
python3 /home/workdir/.grok/skills/dota2-analyzer/scripts/calculate_dota2_probs.py \
  --map_p 0.58 --format bo3

# From rating difference (Elo-style logistic)
python3 ... --rating_diff 120 --format bo3

# Strengthened Poisson kill-lead path
python3 ... --kills_a 28.5 --kills_b 24.0 --format map --poisson_lead

# Series with different per-map probabilities (post-draft or scenarios)
python3 ... --map_ps "0.62,0.55,0.48" --format bo3

# Bo5 example
python3 ... --map_p 0.57 --format bo5 --n_sims 100000
  • Always run sensitivity: ±0.03–0.05 on map_p and observe series win % movement.
  • If maps/drafts unknown, present base + optimistic + pessimistic series win ranges.

6. Present Results Clearly

  1. Match context: teams, format, event, patch, data timestamp, key extracted stats (ranking points, recent form, standout numbers).
  2. Estimated inputs: map_p (or rating_diff / expected kill lead) with one-sentence justification; note any draft adjustments.
  3. Probability table (from script):
    • Single-map win % for A / B (Radiant/Dire if relevant)
    • Series win % for A / B
    • Score distribution (2-0, 2-1, 1-2, 0-2 for Bo3; fuller for Bo5)
  4. Key insights: biggest edges, upset potential, important maps or draft priorities if known.
  5. Market comparison (if odds available): model vs implied probability, rough EV flags.
  6. Caveats & confidence: sample sizes, roster uncertainty, unmodeled draft variance, High/Med/Low confidence.

7. Iteration & Extra Context

  • Update live when draft completes, absences confirmed, or new form appears.
  • For betting / Polymarket angle: after model probs, compare to sharp books or prediction-market prices; flag potential +EV.
  • Back-test on finished matches when useful (estimate pre-match inputs from then-available data only).

Important Rules & Limitations

  • Analytical estimates only — not betting advice. User is responsible for decisions and local regulations.
  • Dota 2 has extremely high variance (draft, individual performance, Roshan timing, buyback economy). Even a 65% series favorite loses often. Never treat model edge as certainty.
  • Data quality varies sharply. Top teams have rich samples; mid/lower-tier or newly formed rosters require heavy shrinkage toward 50%.
  • Draft is the single largest unmodeled factor pre-game. Post-draft estimates are meaningfully stronger.
  • Script assumes map independence. Real series have sequential draft adaptation and psychological effects; effect is usually modest.
  • Prefer the logistic + form path. Use Poisson-lead only as a cross-check or when kill/GPM data is high-quality.
  • Always show sensitivity or a range when data is thin. State confidence level explicitly.

Bundled Resources

  • scripts/calculate_dota2_probs.py — core calculator (logistic / rating_diff, series DP, optional Poisson kill-lead path, score distributions).
  • references/data-extraction-prompts.md — ready-to-use browse_page instructions for DLTV, Dotabuff, OpenDota, STRATZ, Liquipedia.
  • references/strength-estimation.md — formulas, calibration notes, draft adjustment guidelines, limitations.

Load these via read_file when needed. Re-run the script whenever inputs change. This converts public Dota 2 data into transparent, defensible win probabilities.

FULL BUNDLE (4 files)
# Agent Skill Package: dota2-analyzer

You are loading a published Agent Skill. Follow SKILL.md exactly.
Supporting files from the original zip are inlined below.
When SKILL.md says to read `references/...` or `scripts/...`, use the matching FILE section here — do not say the file is missing.

Canonical URL: https://skill.hk/s/dota2-analyzer.md
Human page: https://skill.hk/s/dota2-analyzer

Files (4):
- SKILL.md
- references/data-extraction-prompts.md
- references/strength-estimation.md
- scripts/calculate_dota2_probs.py

========================================================================
FILE: SKILL.md
========================================================================

---
name: dota2-analyzer
description: Analyze Dota 2 matches for single-map and series (Bo1/Bo2/Bo3/Bo5) win probabilities using rating-based logistic models strengthened by recent form, H2H, patch meta and optional Poisson kill or net-worth lead simulation. Extract team rankings, form and stats from DLTV, Dotabuff, OpenDota, STRATZ and Liquipedia via browse_page. Supports pre-draft strength estimates and post-draft adjustments. Use for pre-match predictions, series score distributions, value spotting vs bookmaker or Polymarket odds. Triggers on Dota 2, dota2, TI, DreamLeague, Bo3 prediction, map win, series win, 勝率, Radiant, Dire.
---

# Dota 2 Win Probability Analyzer (Strengthened)

Use for any professional Dota 2 match analysis involving win rates or probabilities. Trigger words include Dota 2, dota2, TI, The International, DreamLeague, ESL One, PGL, Bo3, Bo5, map win, series prediction, Radiant vs Dire, etc.

## Core Model (Why This Is Stronger)

- **Primary**: Rating-difference logistic model calibrated on pro data. Convert DLTV ranking points, recent win rates and form into a single-map win probability. Far more stable than raw win-rate conversion for sparse samples.
- **Strengthened upgrade**: Optional Poisson (or Normal) model of expected kill difference / GPM difference drawn from team averages. Map the resulting lead distribution through an empirical logistic curve (gold or kill lead → win probability) for a second independent estimate. Average or use as sensitivity check.
- **Series**: Exact dynamic programming for Bo1–Bo5 score distributions treating maps as independent (standard practical approximation; real series have mild draft-adaptation dependence).
- **Side note**: Small Radiant advantage (patch-dependent, typically +0.5–2.5%) can be applied as a final adjustment when side is known or randomly assigned.
- Prefer the logistic + form model for most pre-match work. Use the Poisson-lead path when kill/GPM averages are reliable and you want a second view.

Typical calibrated range: even match map_p ≈ 0.48–0.53; clear favorite (large ranking + form gap) map_p ≈ 0.58–0.68. Extreme gaps rarely exceed 0.75 without roster collapse or patch mismatch.

## Step-by-Step Workflow (Follow Strictly)

### 1. Clarify the Query
- Exact teams (full or short names / orgs), series format (Bo1, Bo2, Bo3 most common, Bo5 for majors/TI), event/stage, date, current patch if known.
- Ask whether draft/veto is complete or heroes are known (post-draft analysis is higher value).
- Past match (validation / review) vs upcoming (prediction). Multiple matches → prioritize high-profile or user-specified.
- Note any known absences, stand-ins, or recent roster changes.

### 2. Locate Data Pages
- Rankings (primary strength signal): https://dltv.org/ranking or web_search `DLTV Dota 2 ranking`.
- Team pages: web_search `site:dotabuff.com/esports [team]` or `OpenDota [team]`, `STRATZ [team]`.
- Match / series page: Liquipedia (`site:liquipedia.net [team1] vs [team2]`), Dotabuff esports match, or tournament bracket pages.
- Recent form / H2H: team recent matches on Dotabuff or OpenDota; Liquipedia H2H section.
- Patch meta context when needed: Spectral Stats or STRATZ meta pages.

### 3. Extract Data with browse_page
Use the ready prompts in `references/data-extraction-prompts.md`.

- Ranking page → current points / rank for both teams.
- Each team page → recent win rate (last 20–40 games or 3 months), average duration, average kills / GPM / XPM if available, roster stability, notable absences.
- Match / Liquipedia page → series format, H2H history, previous results on current patch, odds if listed, draft notes.
- Customize extraction to focus on current patch window and relevant opponents.
- If data sparse (new roster, stand-ins, low sample) → shrink heavily toward 50% and state low confidence.

### 4. Estimate Inputs (see `references/strength-estimation.md`)
- Start from DLTV points difference or ranking gap → base rating_diff.
- Convert recent win-rate difference and form (last 5–10 series) into additive adjustment.
- Apply H2H, motivation (must-win, lower-bracket), roster continuity, patch fit tweaks (±0.02–0.05 typical on map_p).
- **When draft known**: manually adjust map_p by hero win-rate edges, known counters, or lane matchups (document the delta).
- Clamp extreme values (rarely outside 0.38–0.72). Always shrink toward 0.50 when samples < 10–12 recent games.
- For unknown draft: prepare base + optimistic + pessimistic scenarios or sensitivity range.

### 5. Run the Calculation Script
```bash
# Preferred — direct map win probability
python3 /home/workdir/.grok/skills/dota2-analyzer/scripts/calculate_dota2_probs.py \
  --map_p 0.58 --format bo3

# From rating difference (Elo-style logistic)
python3 ... --rating_diff 120 --format bo3

# Strengthened Poisson kill-lead path
python3 ... --kills_a 28.5 --kills_b 24.0 --format map --poisson_lead

# Series with different per-map probabilities (post-draft or scenarios)
python3 ... --map_ps "0.62,0.55,0.48" --format bo3

# Bo5 example
python3 ... --map_p 0.57 --format bo5 --n_sims 100000
```
- Always run sensitivity: ±0.03–0.05 on map_p and observe series win % movement.
- If maps/drafts unknown, present base + optimistic + pessimistic series win ranges.

### 6. Present Results Clearly
1. **Match context**: teams, format, event, patch, data timestamp, key extracted stats (ranking points, recent form, standout numbers).
2. **Estimated inputs**: map_p (or rating_diff / expected kill lead) with one-sentence justification; note any draft adjustments.
3. **Probability table** (from script):
   - Single-map win % for A / B (Radiant/Dire if relevant)
   - Series win % for A / B
   - Score distribution (2-0, 2-1, 1-2, 0-2 for Bo3; fuller for Bo5)
4. **Key insights**: biggest edges, upset potential, important maps or draft priorities if known.
5. **Market comparison** (if odds available): model vs implied probability, rough EV flags.
6. **Caveats & confidence**: sample sizes, roster uncertainty, unmodeled draft variance, High/Med/Low confidence.

### 7. Iteration & Extra Context
- Update live when draft completes, absences confirmed, or new form appears.
- For betting / Polymarket angle: after model probs, compare to sharp books or prediction-market prices; flag potential +EV.
- Back-test on finished matches when useful (estimate pre-match inputs from then-available data only).

## Important Rules & Limitations
- Analytical estimates only — not betting advice. User is responsible for decisions and local regulations.
- Dota 2 has extremely high variance (draft, individual performance, Roshan timing, buyback economy). Even a 65% series favorite loses often. Never treat model edge as certainty.
- Data quality varies sharply. Top teams have rich samples; mid/lower-tier or newly formed rosters require heavy shrinkage toward 50%.
- Draft is the single largest unmodeled factor pre-game. Post-draft estimates are meaningfully stronger.
- Script assumes map independence. Real series have sequential draft adaptation and psychological effects; effect is usually modest.
- Prefer the logistic + form path. Use Poisson-lead only as a cross-check or when kill/GPM data is high-quality.
- Always show sensitivity or a range when data is thin. State confidence level explicitly.

## Bundled Resources
- `scripts/calculate_dota2_probs.py` — core calculator (logistic / rating_diff, series DP, optional Poisson kill-lead path, score distributions).
- `references/data-extraction-prompts.md` — ready-to-use browse_page instructions for DLTV, Dotabuff, OpenDota, STRATZ, Liquipedia.
- `references/strength-estimation.md` — formulas, calibration notes, draft adjustment guidelines, limitations.

Load these via read_file when needed. Re-run the script whenever inputs change. This converts public Dota 2 data into transparent, defensible win probabilities.


========================================================================
FILE: references/data-extraction-prompts.md
========================================================================

# Ready-to-use browse_page Instructions for Dota 2 Data

Copy-paste or adapt these instructions when calling the browse_page tool. Keep them focused so the summarizer returns structured, usable numbers.

## 1. DLTV Ranking Page (https://dltv.org/ranking)

```
Extract the current Dota 2 world ranking. For the two teams [TEAM_A] and [TEAM_B] (or the top 20 if general): list rank, team name, points, and any noted roster core players. Note the ranking date or last update if shown. Summarize how points are calculated if briefly described. Return clean structured list.
```

## 2. Dotabuff Team / Esports Page

Search first: `site:dotabuff.com/esports [team name]` or similar.

```
From this Dotabuff page extract for the team:
- Overall recent win rate and record (last 20–40 games or time window shown)
- Average match duration
- Average kills / deaths if available
- Recent series results (especially Bo3/Bo5)
- Current or most-played heroes / notable hero pool if listed
- Roster and any stand-in or recent changes mentioned
- Head-to-head notes if present
Focus on numbers with sample sizes. Ignore pure pub data unless pro section is empty.
```

## 3. OpenDota Team Page

```
Extract team performance metrics: win rate over recent matches, average duration, average kills, GPM/XPM if shown, ranking or MMR proxy, recent form (last 10–20 games), and roster. Note any league or pro filter that is active. Provide sample sizes.
```

## 4. STRATZ Team or Match Pages

```
Summarize team or match statistics relevant to win probability: recent win rate, hero performance, average net worth or kill differentials if available, and any AI or model predictions shown. Extract key numerical edges and sample sizes. Note current patch if indicated.
```

## 5. Liquipedia Match / Series / Team Page

```
Extract match or series details: format (Bo1/Bo3/Bo5), date, event, patch if listed, final or ongoing score, roster for both sides (note stand-ins), head-to-head history summary, previous results between these teams on the current or recent patches, and any preview notes about form or absences. List map/game results if series is finished. Keep structured and concise.
```

## 6. Tournament Bracket or Group Page (Liquipedia or official)

```
Identify the relevant matchup between [TEAM_A] and [TEAM_B]. Extract stage, format, scheduled time, current standings impact (must-win etc.), and any listed odds or power rankings. Note nearby results that affect motivation.
```

## 7. Meta / Patch Context (Spectral Stats, STRATZ meta, Dotabuff trends)

```
Summarize the current patch meta relevant to pro play: dominant strategies (push, late-game, specific cores), Radiant win rate if shown, average game length and kill totals, and any major hero win-rate outliers. Keep to 5–8 key bullet points with numbers.
```

## Usage Tips

- Always include the exact team names the user cares about in the instructions so the summarizer filters correctly.
- Request sample sizes (number of games / series) — they determine how hard to shrink estimates.
- For H2H, ask specifically for results on the current patch or last 6–12 months.
- If a page is heavily JavaScript-rendered and returns little, fall back to web_search snippets or try the alternative site (Dotabuff ↔ OpenDota ↔ STRATZ).
- After extraction, cross-check ranking points vs recent form; large discrepancies often signal roster change or patch shift.


========================================================================
FILE: references/strength-estimation.md
========================================================================

# Estimating Team Strength and Map Win Probabilities for Dota 2

Translate public rankings and form into inputs for `calculate_dota2_probs.py`. Prefer the **logistic / rating_diff** path for most work. Use the Poisson kill-lead path as a second opinion when average kills or GPM are stable.

## 1. Baseline from Rankings (Primary Signal)

Primary source: **DLTV World Ranking** (https://dltv.org/ranking).

- Points difference is the strongest single pre-match signal for established rosters.
- Rough calibration (pro scene 2025–2026):
  - Every ~80–100 DLTV points ≈ +0.03 to +0.04 map win probability.
  - Or convert via Elo-style: treat points gap as rating_diff and use `--rating_diff`.
- Simple formula (starting point):

```
base_map_p_A = 0.50 + (points_A - points_B) / 2500
```

Clamp later. For teams far outside top 20 or with brand-new cores, prefer recent form win-rate conversion instead.

Alternative ranking sources (cross-check): Dotabuff team rankings, OpenDota, community Elo lists.

## 2. Recent Form & Win-Rate Adjustment

From team pages (Dotabuff / OpenDota / STRATZ last 20–40 games or ~3 months):

- Overall win rate difference is useful but noisy.
- Prefer series results (Bo3/Bo5) over individual maps when available.
- Adjustment (add to base_map_p):

```
form_adj = (winrate_A - winrate_B) * 0.20   # conservative
```

- Weight more recent results higher (last 5–8 series matter most).
- Discount heavily if roster has changed (new mid, new offlane, stand-in support).

Typical total map_p after ranking + form: 0.47–0.58 for most competitive matches. Rarely go outside 0.40–0.70 without extreme evidence.

## 3. Head-to-Head, Motivation & Context Tweaks

Small additive adjustments (±0.01–0.04):

- Strong H2H on current patch → +0.02–0.03 for the historical winner.
- Must-win / lower-bracket pressure, home crowd (rare), fatigue after long travel.
- Patch fit: some teams thrive on specific patches (e.g., heavy push, late-game, specific hero pools). Note current patch number and recent meta shifts.
- Roster continuity: if 4–5 players have played together >30 games, confidence increases; stand-ins reduce it.

## 4. Draft / Post-Ban Adjustment (High Value When Available)

Pre-draft estimates ignore the largest source of variance. Once heroes are known:

- Look up recent hero win rates on the current patch (STRATZ, Dotabuff, Spectral).
- Known hard counters or lane dominance (e.g., offlane vs hard carry matchup).
- Typical draft edge: ±0.03–0.08 on map_p depending on how one-sided the draft looks.
- Document the delta explicitly: “Draft favors A by ~+0.05 (strong mid + offlane counter)”.

If only partial draft (bans only), use smaller adjustments.

## 5. Optional Poisson Kill / Net-Worth Lead Path

When team average kills or GPM are reliable:

1. Extract recent average kills per game (or GPM) for both teams.
2. Feed into script with `--kills_a` `--kills_b --poisson_lead`.
3. The script models kills ~ Poisson(λ), computes lead distribution, then maps lead → win probability via logistic.
4. Use the resulting map_p as a cross-check or average it with the ranking-based estimate when both are high quality.

Gold-lead version (manual): if you have expected net-worth difference at ~20–25 min, convert roughly:
- +5k ≈ ~60%, +10k ≈ ~72–75%, +15k ≈ ~85% (very approximate; varies by patch and game length).

## 6. Radiant / Dire Side

Radiant historically holds a small edge (patch-dependent, often 50.5–52.5%). When side is known or can be assigned:

```
final_map_p = clamp(map_p + radiant_adv, 0.01, 0.99)
```

Typical radiant_adv = +0.01 to +0.025. Script supports `--radiant_adv`.

## 7. Series Conversion

Once you have a map_p (or list of map_ps):

- Bo1 = map_p
- Bo2 / Bo3 / Bo5 → use the script’s exact DP (independent maps).
- Real series have sequential information and draft adaptation; independence is a practical upper-bound approximation on favorite’s win probability.

Always report score distribution (2-0 / 2-1 etc.) and run sensitivity (±0.03–0.05 on map_p).

## 8. Calibration & Shrinkage Rules

- Sample size < 12 recent games or new core → shrink map_p halfway toward 0.50.
- Conflicting signals (ranking favors A, recent form favors B) → average or widen the range.
- Extreme values (>0.70 or <0.30) require strong evidence (huge ranking gap + dominant form + favorable draft).
- Always state confidence: High (stable top teams, rich data, post-draft), Medium, Low (stand-ins, sparse samples, early patch).

## Limitations

- Draft is the dominant unmodeled factor pre-game.
- Individual player variance and “hero pool depth” are only partially captured by form.
- Patch changes can invalidate older data within days.
- The logistic and Poisson-lead models are strong baselines, not oracles. Combine with qualitative scouting.


========================================================================
FILE: scripts/calculate_dota2_probs.py
========================================================================

#!/usr/bin/env python3
"""
Dota 2 Map and Series Win Probability Calculator (Strengthened)

Core models:
- Direct map_p or rating-difference logistic (Elo-style) conversion to single-map win probability.
- Exact dynamic programming for Bo1 / Bo2 / Bo3 / Bo5 series score distributions
  (maps treated independent — practical approximation).
- Optional Poisson kill-lead path: model expected kills as Poisson, derive lead
  distribution, then map lead → win probability via calibrated logistic.
  Use as sensitivity / cross-check only.

Examples:
  # Direct map probability
  python3 calculate_dota2_probs.py --map_p 0.58 --format bo3

  # From rating difference (positive = team A stronger)
  python3 calculate_dota2_probs.py --rating_diff 150 --format bo3

  # Series with per-map probabilities
  python3 calculate_dota2_probs.py --map_ps "0.62,0.55,0.48" --format bo3

  # Poisson kill-lead strengthened path
  python3 calculate_dota2_probs.py --kills_a 29.0 --kills_b 24.5 --format map --poisson_lead

  # Bo5 with more sims
  python3 calculate_dota2_probs.py --map_p 0.57 --format bo5 --n_sims 100000
"""

from __future__ import annotations

import argparse
import math
import sys
from collections import defaultdict
from typing import Dict, List, Optional, Tuple

import numpy as np
from scipy.stats import poisson


def logistic_from_rating_diff(rating_diff: float, scale: float = 400.0) -> float:
    """Classic Elo-style win probability. rating_diff > 0 means team A stronger."""
    return 1.0 / (1.0 + 10.0 ** (-rating_diff / scale))


def logistic_from_lead(lead: float, midpoint: float = 0.0, steepness: float = 0.12) -> float:
    """
    Empirical mapping from kill (or gold/10k) lead to win probability.
    Calibrated roughly on pro data: ~+8–10 kill lead or +8–10k net worth mid-game
    corresponds to ~70–75% win probability. Adjust steepness if needed.
    """
    return 1.0 / (1.0 + math.exp(-steepness * (lead - midpoint)))


def poisson_lead_win_prob(
    kills_a: float,
    kills_b: float,
    max_k: int = 60,
    steepness: float = 0.11,
) -> float:
    """
    Model team kills as independent Poisson, compute P(A wins) by summing
    over possible kill outcomes weighted by the lead→win logistic.
    Returns expected win probability for team A.
    """
    if kills_a <= 0 or kills_b <= 0:
        raise ValueError("kills_a and kills_b must be positive")
    p_win = 0.0
    # Truncate Poisson tails
    for ka in range(0, max_k + 1):
        pa = poisson.pmf(ka, kills_a)
        if pa < 1e-9:
            continue
        for kb in range(0, max_k + 1):
            pb = poisson.pmf(kb, kills_b)
            if pb < 1e-9:
                continue
            lead = ka - kb
            p_win += pa * pb * logistic_from_lead(lead, steepness=steepness)
    return float(p_win)


def series_dp(map_ps: List[float], best_of: int) -> Tuple[float, Dict[str, float]]:
    """
    Exact dynamic programming for series win probability and score distribution.
    map_ps: list of P(A wins map i) for i = 0 .. best_of-1 (or shorter; cycles last if needed).
    Returns (P(A wins series), score_dist) where score_diff keys are like "2-0", "2-1".
    """
    if best_of not in (1, 2, 3, 5):
        raise ValueError("best_of must be 1, 2, 3 or 5")
    wins_needed = (best_of // 2) + 1
    # state: (maps_played, wins_a, wins_b) → probability
    # We only need up to wins_needed
    from functools import lru_cache

    @lru_cache(maxsize=None)
    def prob(wa: int, wb: int, idx: int) -> float:
        if wa >= wins_needed:
            return 1.0
        if wb >= wins_needed:
            return 0.0
        if idx >= len(map_ps) and len(map_ps) > 0:
            # reuse last map_p if more maps than provided
            p = map_ps[-1]
        else:
            p = map_ps[idx] if idx < len(map_ps) else map_ps[-1]
        return p * prob(wa + 1, wb, idx + 1) + (1.0 - p) * prob(wa, wb + 1, idx + 1)

    series_p = prob(0, 0, 0)

    # Score distribution via enumeration of paths (small state space)
    score_dist: Dict[str, float] = defaultdict(float)
    # BFS / recursive count of terminating scores
    def collect(wa: int, wb: int, idx: int, path_p: float):
        if wa >= wins_needed or wb >= wins_needed:
            key = f"{wa}-{wb}"
            score_dist[key] += path_p
            return
        p = map_ps[idx] if idx < len(map_ps) else map_ps[-1]
        collect(wa + 1, wb, idx + 1, path_p * p)
        collect(wa, wb + 1, idx + 1, path_p * (1.0 - p))

    collect(0, 0, 0, 1.0)
    # Normalize just in case of floating error
    total = sum(score_dist.values())
    if total > 0:
        for k in score_dist:
            score_dist[k] /= total
    return series_p, dict(score_dist)


def print_results(
    map_p: float,
    format_str: str,
    series_p: Optional[float] = None,
    score_dist: Optional[Dict[str, float]] = None,
    extra: str = "",
):
    print("=" * 60)
    print("Dota 2 Win Probability Results")
    print("=" * 60)
    if extra:
        print(extra)
    print(f"Single-map P(A wins) : {map_p*100:.1f}%")
    print(f"Single-map P(B wins) : {(1-map_p)*100:.1f}%")
    if series_p is not None and score_dist is not None:
        print(f"\nSeries format       : {format_str.upper()}")
        print(f"Series P(A wins)    : {series_p*100:.1f}%")
        print(f"Series P(B wins)    : {(1-series_p)*100:.1f}%")
        print("\nScore distribution:")
        # Sort nicely: higher A scores first
        def sort_key(s: str):
            a, b = map(int, s.split("-"))
            return (-a, b)
        for score in sorted(score_dist.keys(), key=sort_key):
            print(f"  {score:>5} : {score_dist[score]*100:5.1f}%")
    print("=" * 60)


def main():
    parser = argparse.ArgumentParser(
        description="Dota 2 map & series win probability calculator"
    )
    parser.add_argument("--map_p", type=float, help="Direct P(A wins a single map)")
    parser.add_argument(
        "--rating_diff",
        type=float,
        help="Rating difference (A - B). Converted via Elo logistic (scale=400)",
    )
    parser.add_argument(
        "--map_ps",
        type=str,
        help='Comma-separated per-map probs e.g. "0.62,0.55,0.48"',
    )
    parser.add_argument(
        "--kills_a", type=float, help="Expected kills for team A (Poisson path)"
    )
    parser.add_argument(
        "--kills_b", type=float, help="Expected kills for team B (Poisson path)"
    )
    parser.add_argument(
        "--poisson_lead",
        action="store_true",
        help="Use Poisson kill-lead model (requires --kills_a --kills_b)",
    )
    parser.add_argument(
        "--format",
        type=str,
        default="map",
        choices=["map", "bo1", "bo2", "bo3", "bo5"],
        help="Output format: map only or series BoN",
    )
    parser.add_argument(
        "--n_sims",
        type=int,
        default=50000,
        help="(Reserved for future Monte-Carlo extensions)",
    )
    parser.add_argument(
        "--radiant_adv",
        type=float,
        default=0.0,
        help="Additive Radiant side advantage to apply to map_p (e.g. 0.015)",
    )
    args = parser.parse_args()

    map_p: Optional[float] = None
    extra_notes = []

    if args.poisson_lead:
        if args.kills_a is None or args.kills_b is None:
            print("ERROR: --poisson_lead requires --kills_a and --kills_b", file=sys.stderr)
            sys.exit(1)
        map_p = poisson_lead_win_prob(args.kills_a, args.kills_b)
        extra_notes.append(
            f"Poisson kill-lead model: λA={args.kills_a:.1f}, λB={args.kills_b:.1f}"
        )
    elif args.map_ps:
        try:
            map_ps = [float(x.strip()) for x in args.map_ps.split(",")]
            if not map_ps:
                raise ValueError
            for p in map_ps:
                if not (0.0 < p < 1.0):
                    raise ValueError("each map_p must be in (0,1)")
        except Exception as e:
            print(f"ERROR: invalid --map_ps: {e}", file=sys.stderr)
            sys.exit(1)
        # For series we will use the list; for single map report average
        map_p = float(np.mean(map_ps))
        extra_notes.append(f"Per-map probabilities: {map_ps}")
    elif args.rating_diff is not None:
        map_p = logistic_from_rating_diff(args.rating_diff)
        extra_notes.append(f"From rating_diff={args.rating_diff:.1f} (Elo scale=400)")
    elif args.map_p is not None:
        map_p = args.map_p
        if not (0.0 < map_p < 1.0):
            print("ERROR: --map_p must be in (0,1)", file=sys.stderr)
            sys.exit(1)
    else:
        print(
            "ERROR: provide one of --map_p, --rating_diff, --map_ps or --poisson_lead",
            file=sys.stderr,
        )
        sys.exit(1)

    # Optional Radiant advantage
    if args.radiant_adv != 0.0:
        map_p = min(max(map_p + args.radiant_adv, 0.01), 0.99)
        extra_notes.append(f"Applied Radiant advantage {args.radiant_adv:+.3f}")

    fmt = args.format.lower()
    if fmt == "map" or fmt == "bo1":
        print_results(map_p, "map", extra=" | ".join(extra_notes))
        return

    # Series
    best_of = {"bo2": 2, "bo3": 3, "bo5": 5}[fmt]
    if args.map_ps:
        map_ps_list = [float(x.strip()) for x in args.map_ps.split(",")]
        # Pad or trim to reasonable length
        while len(map_ps_list) < best_of:
            map_ps_list.append(map_ps_list[-1])
        map_ps_list = map_ps_list[:best_of]
    else:
        map_ps_list = [map_p] * best_of

    series_p, score_dist = series_dp(map_ps_list, best_of)
    print_results(
        map_p if not args.map_ps else float(np.mean(map_ps_list)),
        fmt,
        series_p=series_p,
        score_dist=score_dist,
        extra=" | ".join(extra_notes),
    )


if __name__ == "__main__":
    main()

references/data-extraction-prompts.md

IN BUNDLE
# Ready-to-use browse_page Instructions for Dota 2 Data

Copy-paste or adapt these instructions when calling the browse_page tool. Keep them focused so the summarizer returns structured, usable numbers.

## 1. DLTV Ranking Page (https://dltv.org/ranking)

```
Extract the current Dota 2 world ranking. For the two teams [TEAM_A] and [TEAM_B] (or the top 20 if general): list rank, team name, points, and any noted roster core players. Note the ranking date or last update if shown. Summarize how points are calculated if briefly described. Return clean structured list.
```

## 2. Dotabuff Team / Esports Page

Search first: `site:dotabuff.com/esports [team name]` or similar.

```
From this Dotabuff page extract for the team:
- Overall recent win rate and record (last 20–40 games or time window shown)
- Average match duration
- Average kills / deaths if available
- Recent series results (especially Bo3/Bo5)
- Current or most-played heroes / notable hero pool if listed
- Roster and any stand-in or recent changes mentioned
- Head-to-head notes if present
Focus on numbers with sample sizes. Ignore pure pub data unless pro section is empty.
```

## 3. OpenDota Team Page

```
Extract team performance metrics: win rate over recent matches, average duration, average kills, GPM/XPM if shown, ranking or MMR proxy, recent form (last 10–20 games), and roster. Note any league or pro filter that is active. Provide sample sizes.
```

## 4. STRATZ Team or Match Pages

```
Summarize team or match statistics relevant to win probability: recent win rate, hero performance, average net worth or kill differentials if available, and any AI or model predictions shown. Extract key numerical edges and sample sizes. Note current patch if indicated.
```

## 5. Liquipedia Match / Series / Team Page

```
Extract match or series details: format (Bo1/Bo3/Bo5), date, event, patch if listed, final or ongoing score, roster for both sides (note stand-ins), head-to-head history summary, previous results between these teams on the current or recent patches, and any preview notes about form or absences. List map/game results if series is finished. Keep structured and concise.
```

## 6. Tournament Bracket or Group Page (Liquipedia or official)

```
Identify the relevant matchup between [TEAM_A] and [TEAM_B]. Extract stage, format, scheduled time, current standings impact (must-win etc.), and any listed odds or power rankings. Note nearby results that affect motivation.
```

## 7. Meta / Patch Context (Spectral Stats, STRATZ meta, Dotabuff trends)

```
Summarize the current patch meta relevant to pro play: dominant strategies (push, late-game, specific cores), Radiant win rate if shown, average game length and kill totals, and any major hero win-rate outliers. Keep to 5–8 key bullet points with numbers.
```

## Usage Tips

- Always include the exact team names the user cares about in the instructions so the summarizer filters correctly.
- Request sample sizes (number of games / series) — they determine how hard to shrink estimates.
- For H2H, ask specifically for results on the current patch or last 6–12 months.
- If a page is heavily JavaScript-rendered and returns little, fall back to web_search snippets or try the alternative site (Dotabuff ↔ OpenDota ↔ STRATZ).
- After extraction, cross-check ranking points vs recent form; large discrepancies often signal roster change or patch shift.

references/strength-estimation.md

IN BUNDLE
# Estimating Team Strength and Map Win Probabilities for Dota 2

Translate public rankings and form into inputs for `calculate_dota2_probs.py`. Prefer the **logistic / rating_diff** path for most work. Use the Poisson kill-lead path as a second opinion when average kills or GPM are stable.

## 1. Baseline from Rankings (Primary Signal)

Primary source: **DLTV World Ranking** (https://dltv.org/ranking).

- Points difference is the strongest single pre-match signal for established rosters.
- Rough calibration (pro scene 2025–2026):
  - Every ~80–100 DLTV points ≈ +0.03 to +0.04 map win probability.
  - Or convert via Elo-style: treat points gap as rating_diff and use `--rating_diff`.
- Simple formula (starting point):

```
base_map_p_A = 0.50 + (points_A - points_B) / 2500
```

Clamp later. For teams far outside top 20 or with brand-new cores, prefer recent form win-rate conversion instead.

Alternative ranking sources (cross-check): Dotabuff team rankings, OpenDota, community Elo lists.

## 2. Recent Form & Win-Rate Adjustment

From team pages (Dotabuff / OpenDota / STRATZ last 20–40 games or ~3 months):

- Overall win rate difference is useful but noisy.
- Prefer series results (Bo3/Bo5) over individual maps when available.
- Adjustment (add to base_map_p):

```
form_adj = (winrate_A - winrate_B) * 0.20   # conservative
```

- Weight more recent results higher (last 5–8 series matter most).
- Discount heavily if roster has changed (new mid, new offlane, stand-in support).

Typical total map_p after ranking + form: 0.47–0.58 for most competitive matches. Rarely go outside 0.40–0.70 without extreme evidence.

## 3. Head-to-Head, Motivation & Context Tweaks

Small additive adjustments (±0.01–0.04):

- Strong H2H on current patch → +0.02–0.03 for the historical winner.
- Must-win / lower-bracket pressure, home crowd (rare), fatigue after long travel.
- Patch fit: some teams thrive on specific patches (e.g., heavy push, late-game, specific hero pools). Note current patch number and recent meta shifts.
- Roster continuity: if 4–5 players have played together >30 games, confidence increases; stand-ins reduce it.

## 4. Draft / Post-Ban Adjustment (High Value When Available)

Pre-draft estimates ignore the largest source of variance. Once heroes are known:

- Look up recent hero win rates on the current patch (STRATZ, Dotabuff, Spectral).
- Known hard counters or lane dominance (e.g., offlane vs hard carry matchup).
- Typical draft edge: ±0.03–0.08 on map_p depending on how one-sided the draft looks.
- Document the delta explicitly: “Draft favors A by ~+0.05 (strong mid + offlane counter)”.

If only partial draft (bans only), use smaller adjustments.

## 5. Optional Poisson Kill / Net-Worth Lead Path

When team average kills or GPM are reliable:

1. Extract recent average kills per game (or GPM) for both teams.
2. Feed into script with `--kills_a` `--kills_b --poisson_lead`.
3. The script models kills ~ Poisson(λ), computes lead distribution, then maps lead → win probability via logistic.
4. Use the resulting map_p as a cross-check or average it with the ranking-based estimate when both are high quality.

Gold-lead version (manual): if you have expected net-worth difference at ~20–25 min, convert roughly:
- +5k ≈ ~60%, +10k ≈ ~72–75%, +15k ≈ ~85% (very approximate; varies by patch and game length).

## 6. Radiant / Dire Side

Radiant historically holds a small edge (patch-dependent, often 50.5–52.5%). When side is known or can be assigned:

```
final_map_p = clamp(map_p + radiant_adv, 0.01, 0.99)
```

Typical radiant_adv = +0.01 to +0.025. Script supports `--radiant_adv`.

## 7. Series Conversion

Once you have a map_p (or list of map_ps):

- Bo1 = map_p
- Bo2 / Bo3 / Bo5 → use the script’s exact DP (independent maps).
- Real series have sequential information and draft adaptation; independence is a practical upper-bound approximation on favorite’s win probability.

Always report score distribution (2-0 / 2-1 etc.) and run sensitivity (±0.03–0.05 on map_p).

## 8. Calibration & Shrinkage Rules

- Sample size < 12 recent games or new core → shrink map_p halfway toward 0.50.
- Conflicting signals (ranking favors A, recent form favors B) → average or widen the range.
- Extreme values (>0.70 or <0.30) require strong evidence (huge ranking gap + dominant form + favorable draft).
- Always state confidence: High (stable top teams, rich data, post-draft), Medium, Low (stand-ins, sparse samples, early patch).

## Limitations

- Draft is the dominant unmodeled factor pre-game.
- Individual player variance and “hero pool depth” are only partially captured by form.
- Patch changes can invalidate older data within days.
- The logistic and Poisson-lead models are strong baselines, not oracles. Combine with qualitative scouting.

scripts/calculate_dota2_probs.py

IN BUNDLE
#!/usr/bin/env python3
"""
Dota 2 Map and Series Win Probability Calculator (Strengthened)

Core models:
- Direct map_p or rating-difference logistic (Elo-style) conversion to single-map win probability.
- Exact dynamic programming for Bo1 / Bo2 / Bo3 / Bo5 series score distributions
  (maps treated independent — practical approximation).
- Optional Poisson kill-lead path: model expected kills as Poisson, derive lead
  distribution, then map lead → win probability via calibrated logistic.
  Use as sensitivity / cross-check only.

Examples:
  # Direct map probability
  python3 calculate_dota2_probs.py --map_p 0.58 --format bo3

  # From rating difference (positive = team A stronger)
  python3 calculate_dota2_probs.py --rating_diff 150 --format bo3

  # Series with per-map probabilities
  python3 calculate_dota2_probs.py --map_ps "0.62,0.55,0.48" --format bo3

  # Poisson kill-lead strengthened path
  python3 calculate_dota2_probs.py --kills_a 29.0 --kills_b 24.5 --format map --poisson_lead

  # Bo5 with more sims
  python3 calculate_dota2_probs.py --map_p 0.57 --format bo5 --n_sims 100000
"""

from __future__ import annotations

import argparse
import math
import sys
from collections import defaultdict
from typing import Dict, List, Optional, Tuple

import numpy as np
from scipy.stats import poisson


def logistic_from_rating_diff(rating_diff: float, scale: float = 400.0) -> float:
    """Classic Elo-style win probability. rating_diff > 0 means team A stronger."""
    return 1.0 / (1.0 + 10.0 ** (-rating_diff / scale))


def logistic_from_lead(lead: float, midpoint: float = 0.0, steepness: float = 0.12) -> float:
    """
    Empirical mapping from kill (or gold/10k) lead to win probability.
    Calibrated roughly on pro data: ~+8–10 kill lead or +8–10k net worth mid-game
    corresponds to ~70–75% win probability. Adjust steepness if needed.
    """
    return 1.0 / (1.0 + math.exp(-steepness * (lead - midpoint)))


def poisson_lead_win_prob(
    kills_a: float,
    kills_b: float,
    max_k: int = 60,
    steepness: float = 0.11,
) -> float:
    """
    Model team kills as independent Poisson, compute P(A wins) by summing
    over possible kill outcomes weighted by the lead→win logistic.
    Returns expected win probability for team A.
    """
    if kills_a <= 0 or kills_b <= 0:
        raise ValueError("kills_a and kills_b must be positive")
    p_win = 0.0
    # Truncate Poisson tails
    for ka in range(0, max_k + 1):
        pa = poisson.pmf(ka, kills_a)
        if pa < 1e-9:
            continue
        for kb in range(0, max_k + 1):
            pb = poisson.pmf(kb, kills_b)
            if pb < 1e-9:
                continue
            lead = ka - kb
            p_win += pa * pb * logistic_from_lead(lead, steepness=steepness)
    return float(p_win)


def series_dp(map_ps: List[float], best_of: int) -> Tuple[float, Dict[str, float]]:
    """
    Exact dynamic programming for series win probability and score distribution.
    map_ps: list of P(A wins map i) for i = 0 .. best_of-1 (or shorter; cycles last if needed).
    Returns (P(A wins series), score_dist) where score_diff keys are like "2-0", "2-1".
    """
    if best_of not in (1, 2, 3, 5):
        raise ValueError("best_of must be 1, 2, 3 or 5")
    wins_needed = (best_of // 2) + 1
    # state: (maps_played, wins_a, wins_b) → probability
    # We only need up to wins_needed
    from functools import lru_cache

    @lru_cache(maxsize=None)
    def prob(wa: int, wb: int, idx: int) -> float:
        if wa >= wins_needed:
            return 1.0
        if wb >= wins_needed:
            return 0.0
        if idx >= len(map_ps) and len(map_ps) > 0:
            # reuse last map_p if more maps than provided
            p = map_ps[-1]
        else:
            p = map_ps[idx] if idx < len(map_ps) else map_ps[-1]
        return p * prob(wa + 1, wb, idx + 1) + (1.0 - p) * prob(wa, wb + 1, idx + 1)

    series_p = prob(0, 0, 0)

    # Score distribution via enumeration of paths (small state space)
    score_dist: Dict[str, float] = defaultdict(float)
    # BFS / recursive count of terminating scores
    def collect(wa: int, wb: int, idx: int, path_p: float):
        if wa >= wins_needed or wb >= wins_needed:
            key = f"{wa}-{wb}"
            score_dist[key] += path_p
            return
        p = map_ps[idx] if idx < len(map_ps) else map_ps[-1]
        collect(wa + 1, wb, idx + 1, path_p * p)
        collect(wa, wb + 1, idx + 1, path_p * (1.0 - p))

    collect(0, 0, 0, 1.0)
    # Normalize just in case of floating error
    total = sum(score_dist.values())
    if total > 0:
        for k in score_dist:
            score_dist[k] /= total
    return series_p, dict(score_dist)


def print_results(
    map_p: float,
    format_str: str,
    series_p: Optional[float] = None,
    score_dist: Optional[Dict[str, float]] = None,
    extra: str = "",
):
    print("=" * 60)
    print("Dota 2 Win Probability Results")
    print("=" * 60)
    if extra:
        print(extra)
    print(f"Single-map P(A wins) : {map_p*100:.1f}%")
    print(f"Single-map P(B wins) : {(1-map_p)*100:.1f}%")
    if series_p is not None and score_dist is not None:
        print(f"\nSeries format       : {format_str.upper()}")
        print(f"Series P(A wins)    : {series_p*100:.1f}%")
        print(f"Series P(B wins)    : {(1-series_p)*100:.1f}%")
        print("\nScore distribution:")
        # Sort nicely: higher A scores first
        def sort_key(s: str):
            a, b = map(int, s.split("-"))
            return (-a, b)
        for score in sorted(score_dist.keys(), key=sort_key):
            print(f"  {score:>5} : {score_dist[score]*100:5.1f}%")
    print("=" * 60)


def main():
    parser = argparse.ArgumentParser(
        description="Dota 2 map & series win probability calculator"
    )
    parser.add_argument("--map_p", type=float, help="Direct P(A wins a single map)")
    parser.add_argument(
        "--rating_diff",
        type=float,
        help="Rating difference (A - B). Converted via Elo logistic (scale=400)",
    )
    parser.add_argument(
        "--map_ps",
        type=str,
        help='Comma-separated per-map probs e.g. "0.62,0.55,0.48"',
    )
    parser.add_argument(
        "--kills_a", type=float, help="Expected kills for team A (Poisson path)"
    )
    parser.add_argument(
        "--kills_b", type=float, help="Expected kills for team B (Poisson path)"
    )
    parser.add_argument(
        "--poisson_lead",
        action="store_true",
        help="Use Poisson kill-lead model (requires --kills_a --kills_b)",
    )
    parser.add_argument(
        "--format",
        type=str,
        default="map",
        choices=["map", "bo1", "bo2", "bo3", "bo5"],
        help="Output format: map only or series BoN",
    )
    parser.add_argument(
        "--n_sims",
        type=int,
        default=50000,
        help="(Reserved for future Monte-Carlo extensions)",
    )
    parser.add_argument(
        "--radiant_adv",
        type=float,
        default=0.0,
        help="Additive Radiant side advantage to apply to map_p (e.g. 0.015)",
    )
    args = parser.parse_args()

    map_p: Optional[float] = None
    extra_notes = []

    if args.poisson_lead:
        if args.kills_a is None or args.kills_b is None:
            print("ERROR: --poisson_lead requires --kills_a and --kills_b", file=sys.stderr)
            sys.exit(1)
        map_p = poisson_lead_win_prob(args.kills_a, args.kills_b)
        extra_notes.append(
            f"Poisson kill-lead model: λA={args.kills_a:.1f}, λB={args.kills_b:.1f}"
        )
    elif args.map_ps:
        try:
            map_ps = [float(x.strip()) for x in args.map_ps.split(",")]
            if not map_ps:
                raise ValueError
            for p in map_ps:
                if not (0.0 < p < 1.0):
                    raise ValueError("each map_p must be in (0,1)")
        except Exception as e:
            print(f"ERROR: invalid --map_ps: {e}", file=sys.stderr)
            sys.exit(1)
        # For series we will use the list; for single map report average
        map_p = float(np.mean(map_ps))
        extra_notes.append(f"Per-map probabilities: {map_ps}")
    elif args.rating_diff is not None:
        map_p = logistic_from_rating_diff(args.rating_diff)
        extra_notes.append(f"From rating_diff={args.rating_diff:.1f} (Elo scale=400)")
    elif args.map_p is not None:
        map_p = args.map_p
        if not (0.0 < map_p < 1.0):
            print("ERROR: --map_p must be in (0,1)", file=sys.stderr)
            sys.exit(1)
    else:
        print(
            "ERROR: provide one of --map_p, --rating_diff, --map_ps or --poisson_lead",
            file=sys.stderr,
        )
        sys.exit(1)

    # Optional Radiant advantage
    if args.radiant_adv != 0.0:
        map_p = min(max(map_p + args.radiant_adv, 0.01), 0.99)
        extra_notes.append(f"Applied Radiant advantage {args.radiant_adv:+.3f}")

    fmt = args.format.lower()
    if fmt == "map" or fmt == "bo1":
        print_results(map_p, "map", extra=" | ".join(extra_notes))
        return

    # Series
    best_of = {"bo2": 2, "bo3": 3, "bo5": 5}[fmt]
    if args.map_ps:
        map_ps_list = [float(x.strip()) for x in args.map_ps.split(",")]
        # Pad or trim to reasonable length
        while len(map_ps_list) < best_of:
            map_ps_list.append(map_ps_list[-1])
        map_ps_list = map_ps_list[:best_of]
    else:
        map_ps_list = [map_p] * best_of

    series_p, score_dist = series_dp(map_ps_list, best_of)
    print_results(
        map_p if not args.map_ps else float(np.mean(map_ps_list)),
        fmt,
        series_p=series_p,
        score_dist=score_dist,
        extra=" | ".join(extra_notes),
    )


if __name__ == "__main__":
    main()
SKILL GRID · 上传 ZIP 或 GitHub 链接,把技能变成一张可分享的卡 ■■□■ GOOGLE 四色 · PIXEL DECK