rivalsdata-api 1.2.2__tar.gz → 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/PKG-INFO +30 -3
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/README.md +29 -2
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/docs/API.md +37 -3
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/docs/PROJECT_CONTEXT.md +18 -4
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/pyproject.toml +1 -1
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/src/rivalsdata/__init__.py +1 -1
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/src/rivalsdata/mcp_server.py +18 -4
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/src/rivalsdata/models.py +11 -1
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/src/rivalsdata/resources.py +54 -6
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/tests/test_class_stats.py +31 -2
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/.github/workflows/publish-pypi.yml +0 -0
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/.gitignore +0 -0
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/CONTRIBUTING.md +0 -0
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/LICENSE +0 -0
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/src/rivalsdata/client.py +0 -0
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/src/rivalsdata/exceptions.py +0 -0
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/src/rivalsdata/hero_ids.py +0 -0
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/tests/test_hero_names.py +0 -0
- {rivalsdata_api-1.2.2 → rivalsdata_api-1.3.0}/tests/test_typed_responses.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: rivalsdata-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Python client and MCP server for public Marvel Rivals stats from RivalsData
|
|
5
5
|
Project-URL: Homepage, https://github.com/GS-Rionnag/rivalsdata-api
|
|
6
6
|
Project-URL: Repository, https://github.com/GS-Rionnag/rivalsdata-api
|
|
@@ -114,10 +114,37 @@ For MCP, use `get_player_stats(uid_or_name="GS-", category="classes", season=20)
|
|
|
114
114
|
for one season, or `season="all"` for combined all-seasons stats. Both return
|
|
115
115
|
the same class response structure.
|
|
116
116
|
|
|
117
|
+
Detailed hero stats require a mode and match the website's selected tab:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
competitive = player.stats.heroes(mode="competitive", season="all")
|
|
121
|
+
quickplay = player.stats.heroes(mode="quickplay", season=20)
|
|
122
|
+
print(competitive[0].competitive.games)
|
|
123
|
+
print(competitive[0].rank) # Hero leaderboard position, or None if unavailable
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Only heroes with data for the chosen mode are returned, with that mode's nested
|
|
127
|
+
stats and a `mode` label; the other mode is omitted. Rows are sorted by the
|
|
128
|
+
selected mode's games played descending, with ties retaining the JSON order.
|
|
129
|
+
The source returns both modes in one response; filtering and sorting happen
|
|
130
|
+
in this package, as they do on the website. Existing calls to
|
|
131
|
+
`player.stats.heroes()` must now supply `mode`. MCP also requires `mode` when
|
|
132
|
+
`get_player_stats` uses `category="heroes"`; other categories do not require it.
|
|
133
|
+
The separate summary method `player.heroes.fetch()` keeps its existing behavior.
|
|
134
|
+
|
|
135
|
+
Hero stats include the source's top-level `rank`, matching the **#N** displayed
|
|
136
|
+
in the left-hand hero card. It is preserved for either mode and all-seasons
|
|
137
|
+
requests when supplied by the source; it is not recalculated as a quickplay or
|
|
138
|
+
all-seasons leaderboard position. Missing ranks are returned as `None`.
|
|
139
|
+
|
|
117
140
|
Win rates are `total wins / (total wins + total losses)`, rounded to an integer
|
|
118
141
|
percent. They are weighted by hero records, rather than averaging hero win
|
|
119
|
-
rates.
|
|
120
|
-
|
|
142
|
+
rates. The response's `metadata` identifies the source, formula, and requested
|
|
143
|
+
season scope. Upstream hero-switch attribution is unknown; hero records may
|
|
144
|
+
overlap within a match, so these totals cannot establish distinct match counts
|
|
145
|
+
or the player's overall match win rate. All-seasons coverage is limited to
|
|
146
|
+
records returned by the source; complete lifetime coverage is unverified.
|
|
147
|
+
Excluded rows also produce a metadata warning. Empty modes
|
|
121
148
|
have a `None` win rate. Role mappings were observed on RivalsData on
|
|
122
149
|
2026-09-30, including Deadpool's separate role IDs; generic Deadpool and unknown
|
|
123
150
|
IDs are excluded rather than assigned a guessed class.
|
|
@@ -78,10 +78,37 @@ For MCP, use `get_player_stats(uid_or_name="GS-", category="classes", season=20)
|
|
|
78
78
|
for one season, or `season="all"` for combined all-seasons stats. Both return
|
|
79
79
|
the same class response structure.
|
|
80
80
|
|
|
81
|
+
Detailed hero stats require a mode and match the website's selected tab:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
competitive = player.stats.heroes(mode="competitive", season="all")
|
|
85
|
+
quickplay = player.stats.heroes(mode="quickplay", season=20)
|
|
86
|
+
print(competitive[0].competitive.games)
|
|
87
|
+
print(competitive[0].rank) # Hero leaderboard position, or None if unavailable
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Only heroes with data for the chosen mode are returned, with that mode's nested
|
|
91
|
+
stats and a `mode` label; the other mode is omitted. Rows are sorted by the
|
|
92
|
+
selected mode's games played descending, with ties retaining the JSON order.
|
|
93
|
+
The source returns both modes in one response; filtering and sorting happen
|
|
94
|
+
in this package, as they do on the website. Existing calls to
|
|
95
|
+
`player.stats.heroes()` must now supply `mode`. MCP also requires `mode` when
|
|
96
|
+
`get_player_stats` uses `category="heroes"`; other categories do not require it.
|
|
97
|
+
The separate summary method `player.heroes.fetch()` keeps its existing behavior.
|
|
98
|
+
|
|
99
|
+
Hero stats include the source's top-level `rank`, matching the **#N** displayed
|
|
100
|
+
in the left-hand hero card. It is preserved for either mode and all-seasons
|
|
101
|
+
requests when supplied by the source; it is not recalculated as a quickplay or
|
|
102
|
+
all-seasons leaderboard position. Missing ranks are returned as `None`.
|
|
103
|
+
|
|
81
104
|
Win rates are `total wins / (total wins + total losses)`, rounded to an integer
|
|
82
105
|
percent. They are weighted by hero records, rather than averaging hero win
|
|
83
|
-
rates.
|
|
84
|
-
|
|
106
|
+
rates. The response's `metadata` identifies the source, formula, and requested
|
|
107
|
+
season scope. Upstream hero-switch attribution is unknown; hero records may
|
|
108
|
+
overlap within a match, so these totals cannot establish distinct match counts
|
|
109
|
+
or the player's overall match win rate. All-seasons coverage is limited to
|
|
110
|
+
records returned by the source; complete lifetime coverage is unverified.
|
|
111
|
+
Excluded rows also produce a metadata warning. Empty modes
|
|
85
112
|
have a `None` win rate. Role mappings were observed on RivalsData on
|
|
86
113
|
2026-09-30, including Deadpool's separate role IDs; generic Deadpool and unknown
|
|
87
114
|
IDs are excluded rather than assigned a guessed class.
|
|
@@ -21,7 +21,7 @@ and `.raw` so upstream additions are not discarded.
|
|
|
21
21
|
| Player match history | `POST /player/matches/cached` (or `/player/matches`) | `uid`, `cursor`, optional `season`; cached endpoint also accepts `mode`, `hero`, `teammate` | Cached response keys observed: `matches`, `next_cursor`, `source`; match rows include `assists`, `deaths`, `game_mode_id`, `game_play_mode_id`, `hero_id`, `is_mvp`, `is_svp`, `is_win`, `kills`, `map_id`, `match_uid`, `os`, `placement`, `platform`, `rank_level`, `rank_score`, `score_change`, `season`, `team_score`, `timestamp`, `winner_camp`. A sample profile marked its history private; a private profile can return no visible match rows. |
|
|
22
22
|
| Player teammates | `POST /player/teammates` | `uid`, optional `season`, `mode` | Array rows: `games`, `icon`, `losses`, `name`, `teammate_uid`, `wins`. |
|
|
23
23
|
| Player proficiency | `POST /player/proficiency` | `uid` | Object keyed by account id (sample: `11001_{uid}`); each account has `hero_proficiency_infos` keyed by hero id, with `proficiency_level` and `proficiency_point`. |
|
|
24
|
-
| Player hero stats | `POST /player/stats/heroes` | `uid`, optional `season` |
|
|
24
|
+
| Player hero stats | `POST /player/stats/heroes` | `uid`, optional `season`; wrapper requires `mode` (not sent upstream) | Source rows: `competitive`, `hero_id`, `quickplay`, `rank`. Each mode includes games, wins/losses, KDA, MVP/SVP counts, accuracy, and `per_10`/`per_game` combat averages. Wrapper selects one mode, sorts by its games, and includes `rank` (or `None`). |
|
|
25
25
|
| Player map stats | `POST /player/stats/maps` | `uid`, optional `season` | Array rows: `map`, `competitive`, `quickplay`; each mode has games, wins, losses, winrate. |
|
|
26
26
|
| Player ban stats | `POST /player/stats/bans` | `uid`, optional `season` | Array rows: `hero_id`, `matches`, `wins`, `losses`, `winrate`. |
|
|
27
27
|
| Player punishments | `POST /player/punishments` | `uid` | Object keys: `chat`, `login`, `rank`; non-null entries include `expire`, `name`, `reason`, `time`, `uid`. |
|
|
@@ -65,12 +65,46 @@ omitting the season keeps the endpoint default. `player.stats.heroes` and MCP
|
|
|
65
65
|
`get_player_stats` support the same selector. The method also sums
|
|
66
66
|
MVP/SVP counts when every included row supplies them. Win rate is calculated
|
|
67
67
|
from summed wins and losses. Unknown classes and incomplete win/loss rows appear
|
|
68
|
-
in `excluded`.
|
|
69
|
-
|
|
68
|
+
in `excluded`. The response includes `metadata` with `source`, `counts_basis`,
|
|
69
|
+
`win_rate_formula`, `unique_matches_verified`, `hero_switch_attribution`,
|
|
70
|
+
`season`, `season_scope`, and `warnings`. Numeric seasons have scope `season`,
|
|
71
|
+
`"all"` and `-1` normalize to season/scope `"all"`, and an omitted season has
|
|
72
|
+
scope `endpoint_default` with season `None`. Hero-switch attribution is unknown,
|
|
73
|
+
so summed hero records cannot establish unique match counts or a player's
|
|
74
|
+
overall match win rate. Complete lifetime coverage for all-seasons data is
|
|
75
|
+
unverified. Warnings flag those limitations and any excluded records.
|
|
70
76
|
|
|
71
77
|
The roster at `/stats` on 2026-09-30 identifies Deadpool variants as 10571
|
|
72
78
|
(tank), 10572 (DPS), and 10573 (support), Daredevil as 1055, and Angela as 1056.
|
|
73
79
|
|
|
80
|
+
### Detailed hero mode selection and ordering
|
|
81
|
+
|
|
82
|
+
`player.stats.heroes(mode="competitive", season="all")` requires either
|
|
83
|
+
`"competitive"` or `"quickplay"`. Missing mode raises `TypeError`; unsupported
|
|
84
|
+
values raise `ValueError`. The upstream `/player/stats/heroes` request still
|
|
85
|
+
contains only UID and optional season, because the source returns both modes
|
|
86
|
+
together. The wrapper filters heroes without the selected mode's data, omits
|
|
87
|
+
the other mode's key, adds `mode`, and sorts by selected-mode `games` descending.
|
|
88
|
+
Equal game counts retain source order. Access stats through the selected nested
|
|
89
|
+
key, such as `row.competitive` or `row.quickplay`.
|
|
90
|
+
|
|
91
|
+
`row.rank` is the source-supplied hero leaderboard position from the top-level
|
|
92
|
+
JSON `rank` field, or `None` when unavailable. On 2026-10-01, GS-'s left-hand
|
|
93
|
+
Loki card showed #408; both `/player/heroes` and `/player/stats/heroes` supplied
|
|
94
|
+
`rank: 408` for hero 1016. The detailed Stats tab did not display that field.
|
|
95
|
+
Mode filtering preserves it. All-seasons requests may also return a rank, but
|
|
96
|
+
the wrapper does not infer its leaderboard period or recalculate it per mode.
|
|
97
|
+
|
|
98
|
+
Camoufox inspection of GS-'s All Seasons page on 2026-10-01 found the JSON sorted
|
|
99
|
+
by combined competitive + quickplay games. The Competitive tab's 40 rows and
|
|
100
|
+
Quickplay tab's 39 rows each exactly matched sorting by that mode's games.
|
|
101
|
+
Switching tabs sent no additional hero-stats request.
|
|
102
|
+
|
|
103
|
+
MCP `get_player_stats(category="heroes", mode=..., season=...)` requires mode
|
|
104
|
+
for heroes and rejects missing or invalid values before making a request.
|
|
105
|
+
Other categories retain their behavior; class stats still aggregate both modes
|
|
106
|
+
from the complete source response. `player.heroes.fetch()` is unchanged.
|
|
107
|
+
|
|
74
108
|
### Character playtime investigation (2026-09-30)
|
|
75
109
|
|
|
76
110
|
Using Camoufox (the package's optional browser dependency), the public GS-
|
|
@@ -15,7 +15,7 @@ as public, stable methods. Keep requests respectful and conservative.
|
|
|
15
15
|
## Current package
|
|
16
16
|
|
|
17
17
|
- Distribution: `rivalsdata-api`; import: `rivalsdata`.
|
|
18
|
-
- Version: `1.
|
|
18
|
+
- Version: `1.3.0`.
|
|
19
19
|
- Python `>=3.10`, Hatchling build, `src/` layout.
|
|
20
20
|
- Runtime HTTP dependency: `curl-cffi`; optional browser fallback: Camoufox.
|
|
21
21
|
- Public entry point: `RivalsDataClient`.
|
|
@@ -65,12 +65,26 @@ and DPS. Supply a numeric season ID for one season or `season="all"` for
|
|
|
65
65
|
combined all-seasons stats; the latter sends `season=-1` to the upstream API.
|
|
66
66
|
The detailed `player.stats.heroes` method and MCP stats tool accept the same
|
|
67
67
|
selector. Omitting the season keeps the endpoint default.
|
|
68
|
-
|
|
68
|
+
Detailed `player.stats.heroes` now requires `mode="competitive"` or
|
|
69
|
+
`mode="quickplay"`; it filters to that mode, omits the opposite mode key, and
|
|
70
|
+
sorts by selected-mode games descending (stable ties), matching the website.
|
|
71
|
+
MCP requires mode for category heroes; other categories do not require it.
|
|
72
|
+
Class aggregation uses a private full-response fetch so both class modes stay
|
|
73
|
+
available. Hero summaries (`player.heroes.fetch`) remain unchanged.
|
|
74
|
+
Detailed hero rows always expose `rank` (None if absent), preserving the
|
|
75
|
+
source's top-level hero leaderboard position shown as #N in the left card.
|
|
76
|
+
It is not recalculated for quickplay or all seasons. GS-'s Loki was #408 in
|
|
77
|
+
both the card and both hero endpoint responses on 2026-10-01.
|
|
78
|
+
Class calculation sums games, wins, losses, and available MVP/SVP counts separately
|
|
69
79
|
for competitive and quickplay, with win rates calculated from summed wins and
|
|
70
80
|
losses. The response has typed `classes` rows and an `excluded` list for unknown
|
|
71
81
|
roles or incomplete win/loss data. MCP exposes it through `get_player_stats`
|
|
72
|
-
with `category="classes"`.
|
|
73
|
-
|
|
82
|
+
with `category="classes"`. Response `metadata` describes the source, formula,
|
|
83
|
+
season scope, and limitations. Upstream hero-switch attribution and distinct
|
|
84
|
+
match counts are unverified; do not derive player overall win rate from class
|
|
85
|
+
totals. All-seasons coverage is limited to returned records, with complete
|
|
86
|
+
lifetime coverage unverified. Excluded rows produce a metadata warning.
|
|
87
|
+
Role IDs and the corrected Angela/Daredevil IDs were
|
|
74
88
|
verified against RivalsData's roster on 2026-09-30; Deadpool has separate role
|
|
75
89
|
variants (10571/10572/10573), while generic 1057 remains unclassified.
|
|
76
90
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rivalsdata-api"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.0"
|
|
8
8
|
description = "Python client and MCP server for public Marvel Rivals stats from RivalsData"
|
|
9
9
|
keywords = ["marvel-rivals", "rivalsdata", "game-stats", "api-client", "mcp"]
|
|
10
10
|
readme = "README.md"
|
|
@@ -415,22 +415,36 @@ def get_player_matches(
|
|
|
415
415
|
def get_player_stats(
|
|
416
416
|
uid_or_name: str, category: str = "heroes",
|
|
417
417
|
season: int | Literal["all"] | None = None,
|
|
418
|
+
mode: Literal["competitive", "quickplay"] | None = None,
|
|
418
419
|
) -> Any:
|
|
419
420
|
"""Get player stats: heroes, maps, bans, or calculated classes.
|
|
420
421
|
|
|
422
|
+
Heroes REQUIRE mode="competitive" or mode="quickplay". Only that mode's
|
|
423
|
+
hero records are returned, sorted by its games played descending to match
|
|
424
|
+
the website. Mode is not used for the other categories.
|
|
425
|
+
Hero rows include rank: the source's hero leaderboard position, also shown
|
|
426
|
+
as #N in the left-hand profile card, or null when the source has no rank.
|
|
421
427
|
Classes sum hero wins/losses by tank/support/dps and game mode; these are
|
|
422
|
-
hero
|
|
428
|
+
summed hero records, which may overlap within a match. The response metadata
|
|
429
|
+
explains the calculation and scope; class totals cannot establish a player's
|
|
430
|
+
overall match win rate. Upstream hero-switch attribution is unknown.
|
|
423
431
|
Supply a season ID or "all" for combined all-seasons totals. Omitting the
|
|
424
432
|
season uses the endpoint default.
|
|
425
433
|
"""
|
|
426
434
|
methods = {"heroes": "heroes", "maps": "maps", "bans": "bans", "classes": "classes"}
|
|
427
435
|
if category not in methods:
|
|
428
436
|
raise ValueError("category must be one of: heroes, maps, bans, classes")
|
|
437
|
+
if category == "heroes" and mode not in ("competitive", "quickplay"):
|
|
438
|
+
raise ValueError("heroes require mode=competitive or mode=quickplay")
|
|
429
439
|
def fetch(client: RivalsDataClient, value: str, category: str,
|
|
430
|
-
season: int | Literal["all"] | None
|
|
440
|
+
season: int | Literal["all"] | None,
|
|
441
|
+
mode: Literal["competitive", "quickplay"] | None) -> Any:
|
|
431
442
|
season_id = -1 if season == "all" else season
|
|
432
|
-
|
|
433
|
-
|
|
443
|
+
filters: dict[str, Any] = {"season": season_id}
|
|
444
|
+
if category == "heroes":
|
|
445
|
+
filters["mode"] = mode
|
|
446
|
+
return getattr(client.get_player(value).stats, methods[category])(**filters)
|
|
447
|
+
return _call(fetch, uid_or_name, category, season, mode)
|
|
434
448
|
|
|
435
449
|
|
|
436
450
|
@mcp.tool()
|
|
@@ -388,12 +388,21 @@ class HeroModeStats(StatRecord):
|
|
|
388
388
|
|
|
389
389
|
|
|
390
390
|
class HeroStatsRecord(Character):
|
|
391
|
+
"""Detailed hero stats including the source's leaderboard position.
|
|
392
|
+
|
|
393
|
+
``rank`` is the number shown as #N in the profile's left-hand hero card.
|
|
394
|
+
It is shared source metadata, not a rank calculated for the selected mode.
|
|
395
|
+
None means the source did not supply a position.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
mode: str | None
|
|
391
399
|
competitive: HeroModeStats | None
|
|
392
400
|
quickplay: HeroModeStats | None
|
|
393
401
|
rank: int | str | None
|
|
394
402
|
|
|
395
403
|
def __init__(self, data: Mapping[str, Any] | None = None, **values: Any) -> None:
|
|
396
404
|
super().__init__(data, **values)
|
|
405
|
+
self._data.setdefault("rank", None)
|
|
397
406
|
for key in ("competitive", "quickplay"):
|
|
398
407
|
if isinstance(self._data.get(key), Mapping):
|
|
399
408
|
self._data[key] = HeroModeStats(self._data[key])
|
|
@@ -428,10 +437,11 @@ class ClassStatsRecord(DataModel):
|
|
|
428
437
|
|
|
429
438
|
|
|
430
439
|
class ClassStatsResponse(DataModel):
|
|
431
|
-
"""Derived class totals
|
|
440
|
+
"""Derived class totals, excluded rows, and calculation metadata."""
|
|
432
441
|
|
|
433
442
|
classes: list[ClassStatsRecord]
|
|
434
443
|
excluded: list[DataModel]
|
|
444
|
+
metadata: DataModel
|
|
435
445
|
|
|
436
446
|
def __init__(self, data: Mapping[str, Any] | None = None, **values: Any) -> None:
|
|
437
447
|
super().__init__(data, **values)
|
|
@@ -206,13 +206,32 @@ class PlayerStats(PlayerResource):
|
|
|
206
206
|
"""Detailed per-player statistics tabs."""
|
|
207
207
|
|
|
208
208
|
def heroes(
|
|
209
|
-
self, *,
|
|
209
|
+
self, *, mode: Literal["competitive", "quickplay"],
|
|
210
|
+
season: int | Literal["all"] | None = None,
|
|
210
211
|
) -> list[HeroStatsRecord]:
|
|
211
|
-
"""Fetch
|
|
212
|
+
"""Fetch heroes for a required mode, in the website's most-played order.
|
|
212
213
|
|
|
214
|
+
The source supplies both modes together. This method keeps only heroes
|
|
215
|
+
with the selected mode's data, removes the other mode, and sorts by
|
|
216
|
+
selected-mode games descending. Ties retain the source order.
|
|
217
|
+
Each row includes ``rank`` (the source's hero leaderboard position,
|
|
218
|
+
also shown in the left-hand profile card), or None when unavailable.
|
|
213
219
|
``season="all"`` sends the API's all-seasons selector (-1). Omitting
|
|
214
220
|
``season`` preserves the endpoint default.
|
|
215
221
|
"""
|
|
222
|
+
if mode not in ("competitive", "quickplay"):
|
|
223
|
+
raise ValueError("mode must be competitive or quickplay")
|
|
224
|
+
heroes = [hero for hero in self._hero_records(season=season)
|
|
225
|
+
if hero.get(mode) is not None]
|
|
226
|
+
heroes.sort(key=lambda hero: hero[mode].get("games", 0) or 0, reverse=True)
|
|
227
|
+
other_mode = "quickplay" if mode == "competitive" else "competitive"
|
|
228
|
+
return [HeroStatsRecord({key: value for key, value in hero.raw.items()
|
|
229
|
+
if key != other_mode}, mode=mode) for hero in heroes]
|
|
230
|
+
|
|
231
|
+
def _hero_records(
|
|
232
|
+
self, *, season: int | Literal["all"] | None = None
|
|
233
|
+
) -> list[HeroStatsRecord]:
|
|
234
|
+
"""Fetch both upstream modes for class aggregation and hero filtering."""
|
|
216
235
|
season_id = -1 if season == "all" else season
|
|
217
236
|
payload = {"season": season_id} if season_id is not None else {}
|
|
218
237
|
return _many(self._post("/player/stats/heroes", **payload), HeroStatsRecord)
|
|
@@ -225,8 +244,10 @@ class PlayerStats(PlayerResource):
|
|
|
225
244
|
Supply a season ID or ``season="all"`` for combined all-seasons totals.
|
|
226
245
|
Omitting ``season`` preserves the endpoint default.
|
|
227
246
|
Win rate uses total wins / (wins + losses), not the average of hero
|
|
228
|
-
percentages. Counts
|
|
229
|
-
|
|
247
|
+
percentages. Counts are summed upstream hero records. The upstream
|
|
248
|
+
attribution rule for hero switches is unknown, so distinct match
|
|
249
|
+
counts cannot be established from these rows. ``metadata`` describes
|
|
250
|
+
the calculation and requested season scope.
|
|
230
251
|
Unknown roles and incomplete win/loss rows are listed in ``excluded``.
|
|
231
252
|
"""
|
|
232
253
|
groups = {
|
|
@@ -236,7 +257,7 @@ class PlayerStats(PlayerResource):
|
|
|
236
257
|
("dps", "Duelist"))
|
|
237
258
|
}
|
|
238
259
|
excluded = []
|
|
239
|
-
for hero in self.
|
|
260
|
+
for hero in self._hero_records(season=season):
|
|
240
261
|
identifier = hero.get("hero_id")
|
|
241
262
|
name = hero_class(identifier)
|
|
242
263
|
if name is None:
|
|
@@ -271,7 +292,34 @@ class PlayerStats(PlayerResource):
|
|
|
271
292
|
total = totals["wins"] + totals["losses"]
|
|
272
293
|
totals["win_rate"] = round(totals["wins"] * 100 / total) if total else None
|
|
273
294
|
group[mode] = totals
|
|
274
|
-
|
|
295
|
+
all_seasons = season in ("all", -1)
|
|
296
|
+
warnings = [
|
|
297
|
+
("Hero records may overlap within a match. Class totals must not "
|
|
298
|
+
"be used to calculate the player's overall match win rate.")
|
|
299
|
+
]
|
|
300
|
+
if all_seasons:
|
|
301
|
+
warnings.append(
|
|
302
|
+
"All-seasons results cover the records returned by the source; "
|
|
303
|
+
"complete lifetime coverage is not verified."
|
|
304
|
+
)
|
|
305
|
+
if excluded:
|
|
306
|
+
warnings.append(
|
|
307
|
+
"Some hero or mode records were excluded; see excluded for details."
|
|
308
|
+
)
|
|
309
|
+
metadata = {
|
|
310
|
+
"source": "/player/stats/heroes",
|
|
311
|
+
"counts_basis": "summed_hero_records",
|
|
312
|
+
"win_rate_formula": "wins / (wins + losses) * 100",
|
|
313
|
+
"unique_matches_verified": False,
|
|
314
|
+
"hero_switch_attribution": "unknown",
|
|
315
|
+
"season": "all" if all_seasons else season,
|
|
316
|
+
"season_scope": "all" if all_seasons else (
|
|
317
|
+
"endpoint_default" if season is None else "season"
|
|
318
|
+
),
|
|
319
|
+
"warnings": warnings,
|
|
320
|
+
}
|
|
321
|
+
return ClassStatsResponse({"classes": list(groups.values()), "excluded": excluded,
|
|
322
|
+
"metadata": metadata})
|
|
275
323
|
|
|
276
324
|
def maps(self, *, season: int | None = None) -> list[MapRecord]:
|
|
277
325
|
payload = {"season": season} if season is not None else {}
|
|
@@ -7,7 +7,7 @@ from rivalsdata import (
|
|
|
7
7
|
hero_class,
|
|
8
8
|
hero_name,
|
|
9
9
|
)
|
|
10
|
-
from rivalsdata.resources import PlayerStats
|
|
10
|
+
from rivalsdata.resources import PlayerHeroes, PlayerStats
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class HeroClient:
|
|
@@ -42,6 +42,7 @@ def test_weighted_class_totals_modes_and_exclusions():
|
|
|
42
42
|
assert groups["dps"].quickplay.win_rate is None
|
|
43
43
|
assert len(result.excluded) == 3
|
|
44
44
|
assert result.to_dict()["classes"][0]["competitive"]["win_rate"] == 83
|
|
45
|
+
assert any("excluded" in warning for warning in result.metadata.warnings)
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
def test_roles_use_observed_ids_and_do_not_guess_generic_deadpool():
|
|
@@ -77,13 +78,39 @@ def test_class_season_selector_uses_the_selected_hero_data(season, payload, wins
|
|
|
77
78
|
result = PlayerStats(client, 123).classes(season=season)
|
|
78
79
|
assert client.request == ("/player/stats/heroes", payload)
|
|
79
80
|
assert result.classes[1].competitive.wins == wins
|
|
81
|
+
metadata = result.to_dict()["metadata"]
|
|
82
|
+
assert metadata["counts_basis"] == "summed_hero_records"
|
|
83
|
+
assert metadata["unique_matches_verified"] is False
|
|
84
|
+
assert metadata["hero_switch_attribution"] == "unknown"
|
|
85
|
+
assert metadata["season"] == ("all" if season in ("all", -1) else season)
|
|
86
|
+
assert metadata["season_scope"] == (
|
|
87
|
+
"all" if season in ("all", -1) else
|
|
88
|
+
"endpoint_default" if season is None else "season"
|
|
89
|
+
)
|
|
90
|
+
assert len(metadata["warnings"]) == (2 if season in ("all", -1) else 1)
|
|
80
91
|
|
|
81
92
|
|
|
82
93
|
def test_detailed_hero_stats_accept_the_same_all_seasons_selector():
|
|
83
94
|
client = SeasonClient()
|
|
84
|
-
rows = PlayerStats(client, 123).heroes(season="all")
|
|
95
|
+
rows = PlayerStats(client, 123).heroes(mode="competitive", season="all")
|
|
85
96
|
assert client.request == ("/player/stats/heroes", {"uid": 123, "season": -1})
|
|
86
97
|
assert rows[0].competitive.wins == 12
|
|
98
|
+
assert rows[0].mode == "competitive"
|
|
99
|
+
assert "quickplay" not in rows[0]
|
|
100
|
+
with pytest.raises(TypeError):
|
|
101
|
+
PlayerStats(client, 123).heroes(season="all")
|
|
102
|
+
with pytest.raises(ValueError):
|
|
103
|
+
PlayerStats(client, 123).heroes(mode="invalid", season="all")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.parametrize("season,season_id", [(None, None), (20, 20), ("all", -1)])
|
|
107
|
+
def test_hero_summary_season_selector(season, season_id):
|
|
108
|
+
client = SeasonClient()
|
|
109
|
+
PlayerHeroes(client, 123).fetch(season=season)
|
|
110
|
+
expected = {"uid": 123}
|
|
111
|
+
if season_id is not None:
|
|
112
|
+
expected["season"] = season_id
|
|
113
|
+
assert client.request == ("/player/heroes", expected)
|
|
87
114
|
|
|
88
115
|
|
|
89
116
|
def test_mcp_stats_exposes_all_seasons_and_forwards_the_selector(monkeypatch):
|
|
@@ -97,6 +124,8 @@ def test_mcp_stats_exposes_all_seasons_and_forwards_the_selector(monkeypatch):
|
|
|
97
124
|
result = server.get_player_stats("123", category="classes", season="all")
|
|
98
125
|
assert client.request == ("/player/stats/heroes", {"uid": 123, "season": -1})
|
|
99
126
|
assert result.classes[1].competitive.wins == 12
|
|
127
|
+
assert result.metadata.season == "all"
|
|
128
|
+
assert result.metadata.season_scope == "all"
|
|
100
129
|
tools = asyncio.run(server.mcp.list_tools())
|
|
101
130
|
schema = next(tool.inputSchema for tool in tools if tool.name == "get_player_stats")
|
|
102
131
|
assert {"const": "all", "type": "string"} in schema["properties"]["season"]["anyOf"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|