rivalsdata-api 1.1.1__tar.gz → 1.2.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.1.1 → rivalsdata_api-1.2.0}/PKG-INFO +32 -1
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/README.md +31 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/docs/API.md +35 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/docs/PROJECT_CONTEXT.md +20 -5
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/pyproject.toml +1 -1
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/src/rivalsdata/__init__.py +10 -2
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/src/rivalsdata/hero_ids.py +29 -2
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/src/rivalsdata/mcp_server.py +7 -3
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/src/rivalsdata/models.py +41 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/src/rivalsdata/resources.py +54 -0
- rivalsdata_api-1.2.0/tests/test_class_stats.py +57 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/.github/workflows/publish-pypi.yml +0 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/.gitignore +0 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/CONTRIBUTING.md +0 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/LICENSE +0 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/src/rivalsdata/client.py +0 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/src/rivalsdata/exceptions.py +0 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.0}/tests/test_hero_names.py +0 -0
- {rivalsdata_api-1.1.1 → rivalsdata_api-1.2.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.2.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
|
|
@@ -85,6 +85,37 @@ with RivalsDataClient() as rd:
|
|
|
85
85
|
print(hero_season[0].win_rate) # integer percent when wins/losses are present
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
Calculated player class statistics are available through
|
|
89
|
+
`player.stats.classes(season=20)` and the MCP `get_player_stats` tool with
|
|
90
|
+
`category="classes"`. Each row contains `player_class` (`tank`, `support`,
|
|
91
|
+
`dps`), the official role, hero IDs, and separate `competitive` and `quickplay`
|
|
92
|
+
totals for games, wins, losses, and available MVP/SVP counts.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
with RivalsDataClient() as rd:
|
|
96
|
+
stats = rd.get_player("GS-").stats.classes(season=20)
|
|
97
|
+
for row in stats.classes:
|
|
98
|
+
print(row.player_class, row.competitive.win_rate)
|
|
99
|
+
print(stats.excluded) # Unknown roles or incomplete win/loss records
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Win rates are `total wins / (total wins + total losses)`, rounded to an integer
|
|
103
|
+
percent. They are weighted by hero records, rather than averaging hero win
|
|
104
|
+
rates. Switching heroes can make one match contribute to multiple records;
|
|
105
|
+
these totals describe hero participation, not distinct matches. Empty modes
|
|
106
|
+
have a `None` win rate. Role mappings were observed on RivalsData on
|
|
107
|
+
2026-09-30, including Deadpool's separate role IDs; generic Deadpool and unknown
|
|
108
|
+
IDs are excluded rather than assigned a guessed class.
|
|
109
|
+
|
|
110
|
+
Character playtime was checked with Camoufox on 2026-09-30. Player hero stats
|
|
111
|
+
did not expose cumulative hours, including in All Seasons. Match details do
|
|
112
|
+
provide seconds in `match.teams[].players[].heroes[].play_time`; the site shows
|
|
113
|
+
these as minutes and seconds when hovering a hero portrait. Sum the relevant
|
|
114
|
+
player's entries across distinct retrieved matches and divide by 3600 to get
|
|
115
|
+
character hours for those matches. Incomplete history prevents treating this as
|
|
116
|
+
a lifetime total. See [the playtime investigation](docs/API.md#character-playtime-investigation-2026-09-30)
|
|
117
|
+
for the observed fields and example.
|
|
118
|
+
|
|
88
119
|
## MCP server (ChatGPT and Claude)
|
|
89
120
|
|
|
90
121
|
Install the MCP extra and the package:
|
|
@@ -49,6 +49,37 @@ with RivalsDataClient() as rd:
|
|
|
49
49
|
print(hero_season[0].win_rate) # integer percent when wins/losses are present
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
Calculated player class statistics are available through
|
|
53
|
+
`player.stats.classes(season=20)` and the MCP `get_player_stats` tool with
|
|
54
|
+
`category="classes"`. Each row contains `player_class` (`tank`, `support`,
|
|
55
|
+
`dps`), the official role, hero IDs, and separate `competitive` and `quickplay`
|
|
56
|
+
totals for games, wins, losses, and available MVP/SVP counts.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
with RivalsDataClient() as rd:
|
|
60
|
+
stats = rd.get_player("GS-").stats.classes(season=20)
|
|
61
|
+
for row in stats.classes:
|
|
62
|
+
print(row.player_class, row.competitive.win_rate)
|
|
63
|
+
print(stats.excluded) # Unknown roles or incomplete win/loss records
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Win rates are `total wins / (total wins + total losses)`, rounded to an integer
|
|
67
|
+
percent. They are weighted by hero records, rather than averaging hero win
|
|
68
|
+
rates. Switching heroes can make one match contribute to multiple records;
|
|
69
|
+
these totals describe hero participation, not distinct matches. Empty modes
|
|
70
|
+
have a `None` win rate. Role mappings were observed on RivalsData on
|
|
71
|
+
2026-09-30, including Deadpool's separate role IDs; generic Deadpool and unknown
|
|
72
|
+
IDs are excluded rather than assigned a guessed class.
|
|
73
|
+
|
|
74
|
+
Character playtime was checked with Camoufox on 2026-09-30. Player hero stats
|
|
75
|
+
did not expose cumulative hours, including in All Seasons. Match details do
|
|
76
|
+
provide seconds in `match.teams[].players[].heroes[].play_time`; the site shows
|
|
77
|
+
these as minutes and seconds when hovering a hero portrait. Sum the relevant
|
|
78
|
+
player's entries across distinct retrieved matches and divide by 3600 to get
|
|
79
|
+
character hours for those matches. Incomplete history prevents treating this as
|
|
80
|
+
a lifetime total. See [the playtime investigation](docs/API.md#character-playtime-investigation-2026-09-30)
|
|
81
|
+
for the observed fields and example.
|
|
82
|
+
|
|
52
83
|
## MCP server (ChatGPT and Claude)
|
|
53
84
|
|
|
54
85
|
Install the MCP extra and the package:
|
|
@@ -50,6 +50,41 @@ none of these, it returns `None`.
|
|
|
50
50
|
|
|
51
51
|
## Observed UI and routes
|
|
52
52
|
|
|
53
|
+
`player.stats.classes(season=...)` derives tank (Vanguard), support (Strategist),
|
|
54
|
+
and DPS (Duelist) totals from `/player/stats/heroes`; it does not call a class
|
|
55
|
+
endpoint. It sums games/wins/losses separately for competitive and quickplay,
|
|
56
|
+
and MVP/SVP counts when every included row supplies them. Win rate is calculated
|
|
57
|
+
from summed wins and losses. Unknown classes and incomplete win/loss rows appear
|
|
58
|
+
in `excluded`. Hero switching can count a single match in multiple hero records,
|
|
59
|
+
so class totals are participation counts rather than distinct matches.
|
|
60
|
+
|
|
61
|
+
The roster at `/stats` on 2026-09-30 identifies Deadpool variants as 10571
|
|
62
|
+
(tank), 10572 (DPS), and 10573 (support), Daredevil as 1055, and Angela as 1056.
|
|
63
|
+
|
|
64
|
+
### Character playtime investigation (2026-09-30)
|
|
65
|
+
|
|
66
|
+
Using Camoufox (the package's optional browser dependency), the public GS-
|
|
67
|
+
profile (`1970288503`) was inspected in Stats > Heroes for the current season
|
|
68
|
+
and All Seasons. Expanded hero cards showed games, wins/losses, win rate, KDA,
|
|
69
|
+
accuracy, MVP/SVP counts, combat averages, and team-ups. Neither the UI nor the
|
|
70
|
+
captured `/player/heroes` and `/player/stats/heroes` responses supplied cumulative
|
|
71
|
+
hero playtime. The all-seasons stats request returned 49 hero rows, including
|
|
72
|
+
Deadpool's distinct role variants.
|
|
73
|
+
|
|
74
|
+
Match details do expose per-character time in seconds at
|
|
75
|
+
`teams[].players[].heroes[].play_time`. For match
|
|
76
|
+
`5518155_1790655030_1272083_11001_11`, the match duration was 584 seconds and GS-'s
|
|
77
|
+
Loki entry contained `play_time=583.8668914120644`. Hovering the hero portrait
|
|
78
|
+
displayed `Loki` and `9:43`. The observed frontend formatter takes
|
|
79
|
+
`floor(play_time / 60)` minutes and `floor(play_time % 60)` seconds.
|
|
80
|
+
|
|
81
|
+
Hours for a character over a supplied set of matches can be calculated by
|
|
82
|
+
summing that player's matching hero entries and dividing by 3600. Include all
|
|
83
|
+
matching entries (a hero can have multiple usage segments), and deduplicate
|
|
84
|
+
match IDs. This produces time for the retrieved matches, not a guaranteed
|
|
85
|
+
lifetime or season total: history and detail availability may be incomplete.
|
|
86
|
+
No automatic bulk match fetch was added as part of class statistics.
|
|
87
|
+
|
|
53
88
|
| UI route | What the UI exposes |
|
|
54
89
|
| --- | --- |
|
|
55
90
|
| `/` | Search, top leaderboard, favorites, top heroes, and top team-ups. |
|
|
@@ -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.2.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`.
|
|
@@ -45,7 +45,7 @@ copy of the full response.
|
|
|
45
45
|
Lazy subresources include `player.heroes.fetch(...)`, `player.matches.fetch(...)`,
|
|
46
46
|
`player.teammates.fetch(...)`, `player.crosshairs.fetch()`,
|
|
47
47
|
`player.proficiency.fetch()`, `player.punishments.fetch()`,
|
|
48
|
-
`player.name_history.fetch()`, and `player.stats.heroes/maps/bans(...)`.
|
|
48
|
+
`player.name_history.fetch()`, and `player.stats.heroes/maps/bans/classes(...)`.
|
|
49
49
|
Client-wide resources include `client.leaderboards`, `client.heroes`,
|
|
50
50
|
`client.team_ups`, `client.insights`, `client.factions`, and `client.matches`.
|
|
51
51
|
`client.profiles` and `client.favorites` have typed read methods. The profile
|
|
@@ -54,6 +54,21 @@ Favorites requires numeric UIDs and returns player summary rows.
|
|
|
54
54
|
Rows offer `.win_rate` and `.winrate` integer-percent access when data supports
|
|
55
55
|
it; all original data remains in mapping access.
|
|
56
56
|
|
|
57
|
+
`player.stats.classes(season=...)` groups observed hero IDs into tank, support,
|
|
58
|
+
and DPS. It sums games, wins, losses, and available MVP/SVP counts separately
|
|
59
|
+
for competitive and quickplay, with win rates calculated from summed wins and
|
|
60
|
+
losses. The response has typed `classes` rows and an `excluded` list for unknown
|
|
61
|
+
roles or incomplete win/loss data. MCP exposes it through `get_player_stats`
|
|
62
|
+
with `category="classes"`. Hero switching means totals count participation,
|
|
63
|
+
not unique matches. Role IDs and the corrected Angela/Daredevil IDs were
|
|
64
|
+
verified against RivalsData's roster on 2026-09-30; Deadpool has separate role
|
|
65
|
+
variants (10571/10572/10573), while generic 1057 remains unclassified.
|
|
66
|
+
|
|
67
|
+
Camoufox inspection found no cumulative hero hours in current-season or
|
|
68
|
+
all-seasons player stats. Match hero usage does include `play_time` in seconds.
|
|
69
|
+
See `docs/API.md` for the recorded investigation; no playtime aggregation
|
|
70
|
+
method was added.
|
|
71
|
+
|
|
57
72
|
## Observed API details
|
|
58
73
|
|
|
59
74
|
The major UI areas inspected are home, player profile/tabs, global leaderboard,
|
|
@@ -103,7 +118,7 @@ Use a virtual environment. Install editable dependencies with
|
|
|
103
118
|
fallback work. The global host Python has unrelated package conflicts; don't
|
|
104
119
|
change global dependencies to resolve those.
|
|
105
120
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
Mocked tests in `tests/` cover hero lookups, typed responses, and calculated
|
|
122
|
+
class stats. Keep network-dependent checks opt-in. Run pytest, Ruff, and package
|
|
123
|
+
builds for code checks. Commit coherent
|
|
109
124
|
milestones and push to `origin` when explicitly requested by the project owner.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rivalsdata-api"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.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"
|
|
@@ -7,10 +7,13 @@ from .exceptions import (
|
|
|
7
7
|
RivalsDataError,
|
|
8
8
|
RivalsDataHTTPError,
|
|
9
9
|
)
|
|
10
|
-
from .hero_ids import HERO_NAMES, hero_id, hero_name
|
|
10
|
+
from .hero_ids import HERO_CLASSES, HERO_NAMES, hero_class, hero_id, hero_name
|
|
11
11
|
from .models import (
|
|
12
12
|
BanRecord,
|
|
13
13
|
Character,
|
|
14
|
+
ClassModeStats,
|
|
15
|
+
ClassStatsRecord,
|
|
16
|
+
ClassStatsResponse,
|
|
14
17
|
CombatAverages,
|
|
15
18
|
CommBanHero,
|
|
16
19
|
CommBanInsights,
|
|
@@ -74,9 +77,13 @@ from .models import (
|
|
|
74
77
|
)
|
|
75
78
|
|
|
76
79
|
__all__ = [
|
|
80
|
+
"HERO_CLASSES",
|
|
77
81
|
"HERO_NAMES",
|
|
78
82
|
"BanRecord",
|
|
79
83
|
"Character",
|
|
84
|
+
"ClassModeStats",
|
|
85
|
+
"ClassStatsRecord",
|
|
86
|
+
"ClassStatsResponse",
|
|
80
87
|
"CloudflareError",
|
|
81
88
|
"CombatAverages",
|
|
82
89
|
"CommBanHero",
|
|
@@ -142,7 +149,8 @@ __all__ = [
|
|
|
142
149
|
"Top500Season",
|
|
143
150
|
"XPPage",
|
|
144
151
|
"XPRecord",
|
|
152
|
+
"hero_class",
|
|
145
153
|
"hero_id",
|
|
146
154
|
"hero_name",
|
|
147
155
|
]
|
|
148
|
-
__version__ = "1.
|
|
156
|
+
__version__ = "1.2.0"
|
|
@@ -46,9 +46,12 @@ HERO_NAMES: dict[str, str] = {
|
|
|
46
46
|
"1052": "Iron Fist",
|
|
47
47
|
"1053": "Emma Frost",
|
|
48
48
|
"1054": "Phoenix",
|
|
49
|
-
"1055": "
|
|
50
|
-
"1056": "
|
|
49
|
+
"1055": "Daredevil",
|
|
50
|
+
"1056": "Angela",
|
|
51
51
|
"1057": "Deadpool",
|
|
52
|
+
"10571": "Tankpool",
|
|
53
|
+
"10572": "DPSpool",
|
|
54
|
+
"10573": "Stratpool",
|
|
52
55
|
"1058": "Gambit",
|
|
53
56
|
"1059": "Elsa Bloodstone",
|
|
54
57
|
"1060": "White Fox",
|
|
@@ -61,6 +64,30 @@ HERO_NAMES: dict[str, str] = {
|
|
|
61
64
|
"1067": "Gorr the God Butcher",
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
# Observed on https://rivalsdata.com/stats on 2026-09-30. The unspecialized
|
|
68
|
+
# Deadpool ID (1057) has no single role and is deliberately left unresolved.
|
|
69
|
+
HERO_CLASSES: dict[str, str] = {
|
|
70
|
+
**dict.fromkeys((
|
|
71
|
+
"1011", "1018", "1022", "1027", "1035", "1037", "1039", "1042",
|
|
72
|
+
"1051", "1053", "1056", "1062", "1065", "1066", "10571",
|
|
73
|
+
), "tank"),
|
|
74
|
+
**dict.fromkeys((
|
|
75
|
+
"1016", "1020", "1023", "1025", "1028", "1031", "1046", "1047",
|
|
76
|
+
"1050", "1058", "1060", "1064", "10573",
|
|
77
|
+
), "support"),
|
|
78
|
+
**dict.fromkeys((
|
|
79
|
+
"1014", "1015", "1017", "1021", "1024", "1026", "1029", "1030",
|
|
80
|
+
"1032", "1033", "1034", "1036", "1038", "1040", "1041", "1043",
|
|
81
|
+
"1044", "1045", "1048", "1049", "1052", "1054", "1055", "1059",
|
|
82
|
+
"1061", "1063", "1067", "10572",
|
|
83
|
+
), "dps"),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def hero_class(hero_id: object) -> str | None:
|
|
88
|
+
"""Return tank, support, or dps for a known role-specific hero ID."""
|
|
89
|
+
return HERO_CLASSES.get(str(hero_id))
|
|
90
|
+
|
|
64
91
|
|
|
65
92
|
def hero_name(hero_id: object) -> str | None:
|
|
66
93
|
"""Resolve a known playable hero ID, preserving unknown IDs for callers."""
|
|
@@ -410,10 +410,14 @@ def get_player_matches(
|
|
|
410
410
|
def get_player_stats(
|
|
411
411
|
uid_or_name: str, category: str = "heroes", season: int | None = None
|
|
412
412
|
) -> Any:
|
|
413
|
-
"""Get player stats
|
|
414
|
-
|
|
413
|
+
"""Get player stats: heroes, maps, bans, or calculated classes.
|
|
414
|
+
|
|
415
|
+
Classes sum hero wins/losses by tank/support/dps and game mode; these are
|
|
416
|
+
hero participation totals, which can count a match more than once.
|
|
417
|
+
"""
|
|
418
|
+
methods = {"heroes": "heroes", "maps": "maps", "bans": "bans", "classes": "classes"}
|
|
415
419
|
if category not in methods:
|
|
416
|
-
raise ValueError("category must be one of: heroes, maps, bans")
|
|
420
|
+
raise ValueError("category must be one of: heroes, maps, bans, classes")
|
|
417
421
|
def fetch(client: RivalsDataClient, value: str, category: str,
|
|
418
422
|
season: int | None) -> Any:
|
|
419
423
|
return getattr(client.get_player(value).stats, methods[category])(season=season)
|
|
@@ -399,6 +399,47 @@ class HeroStatsRecord(Character):
|
|
|
399
399
|
self._data[key] = HeroModeStats(self._data[key])
|
|
400
400
|
|
|
401
401
|
|
|
402
|
+
class ClassModeStats(HeroModeStats):
|
|
403
|
+
"""Calculated mode totals whose win rate is always a percentage."""
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def win_rate(self) -> int | None:
|
|
407
|
+
# Upstream win-rate fields can be fractions or percentages. Derived
|
|
408
|
+
# rates use counts so that a calculated 1% is not interpreted as 100%.
|
|
409
|
+
wins, losses = self._data["wins"], self._data["losses"]
|
|
410
|
+
total = wins + losses
|
|
411
|
+
return round(wins * 100 / total) if total else None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class ClassStatsRecord(DataModel):
|
|
415
|
+
"""Calculated hero participation totals for one player class."""
|
|
416
|
+
|
|
417
|
+
player_class: str
|
|
418
|
+
role: str
|
|
419
|
+
hero_ids: list[int | str]
|
|
420
|
+
competitive: ClassModeStats
|
|
421
|
+
quickplay: ClassModeStats
|
|
422
|
+
|
|
423
|
+
def __init__(self, data: Mapping[str, Any] | None = None, **values: Any) -> None:
|
|
424
|
+
super().__init__(data, **values)
|
|
425
|
+
for key in ("competitive", "quickplay"):
|
|
426
|
+
if isinstance(self._data.get(key), Mapping):
|
|
427
|
+
self._data[key] = ClassModeStats(self._data[key])
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class ClassStatsResponse(DataModel):
|
|
431
|
+
"""Derived class totals and hero/mode rows excluded from calculation."""
|
|
432
|
+
|
|
433
|
+
classes: list[ClassStatsRecord]
|
|
434
|
+
excluded: list[DataModel]
|
|
435
|
+
|
|
436
|
+
def __init__(self, data: Mapping[str, Any] | None = None, **values: Any) -> None:
|
|
437
|
+
super().__init__(data, **values)
|
|
438
|
+
self._data["classes"] = [
|
|
439
|
+
ClassStatsRecord(row) for row in self._data.get("classes", [])
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
|
|
402
443
|
class HeroDetail(DataModel):
|
|
403
444
|
hero: Character | None
|
|
404
445
|
stats: Character | None
|
|
@@ -6,9 +6,11 @@ from collections.abc import Mapping
|
|
|
6
6
|
from typing import Any, Literal
|
|
7
7
|
from urllib.parse import quote
|
|
8
8
|
|
|
9
|
+
from .hero_ids import hero_class
|
|
9
10
|
from .models import (
|
|
10
11
|
BanRecord,
|
|
11
12
|
Character,
|
|
13
|
+
ClassStatsResponse,
|
|
12
14
|
CommBanInsights,
|
|
13
15
|
CrosshairRecord,
|
|
14
16
|
DataModel,
|
|
@@ -207,6 +209,58 @@ class PlayerStats(PlayerResource):
|
|
|
207
209
|
payload = {"season": season} if season is not None else {}
|
|
208
210
|
return _many(self._post("/player/stats/heroes", **payload), HeroStatsRecord)
|
|
209
211
|
|
|
212
|
+
def classes(self, *, season: int | None = None) -> ClassStatsResponse:
|
|
213
|
+
"""Sum hero records by tank/support/dps, separately for each mode.
|
|
214
|
+
|
|
215
|
+
Win rate uses total wins / (wins + losses), not the average of hero
|
|
216
|
+
percentages. Counts describe hero participation: switching heroes can
|
|
217
|
+
cause one match to contribute to multiple hero or class records.
|
|
218
|
+
Unknown roles and incomplete win/loss rows are listed in ``excluded``.
|
|
219
|
+
"""
|
|
220
|
+
groups = {
|
|
221
|
+
name: {"player_class": name, "role": role, "hero_ids": [],
|
|
222
|
+
"competitive": [], "quickplay": []}
|
|
223
|
+
for name, role in (("tank", "Vanguard"), ("support", "Strategist"),
|
|
224
|
+
("dps", "Duelist"))
|
|
225
|
+
}
|
|
226
|
+
excluded = []
|
|
227
|
+
for hero in self.heroes(season=season):
|
|
228
|
+
identifier = hero.get("hero_id")
|
|
229
|
+
name = hero_class(identifier)
|
|
230
|
+
if name is None:
|
|
231
|
+
excluded.append({"hero_id": identifier, "reason": "unknown_class"})
|
|
232
|
+
continue
|
|
233
|
+
group = groups[name]
|
|
234
|
+
group["hero_ids"].append(identifier)
|
|
235
|
+
for mode in ("competitive", "quickplay"):
|
|
236
|
+
row = hero.get(mode)
|
|
237
|
+
if row is None:
|
|
238
|
+
continue
|
|
239
|
+
counts = [row.get(key) for key in ("wins", "losses")]
|
|
240
|
+
if any(not isinstance(value, (int, float)) or isinstance(value, bool)
|
|
241
|
+
or value < 0 for value in counts):
|
|
242
|
+
excluded.append({"hero_id": identifier, "mode": mode,
|
|
243
|
+
"reason": "missing_or_invalid_win_loss_counts"})
|
|
244
|
+
continue
|
|
245
|
+
group[mode].append(row)
|
|
246
|
+
for group in groups.values():
|
|
247
|
+
for mode in ("competitive", "quickplay"):
|
|
248
|
+
rows = group[mode]
|
|
249
|
+
totals = {key: sum(row[key] for row in rows)
|
|
250
|
+
for key in ("wins", "losses")}
|
|
251
|
+
totals["games"] = sum(
|
|
252
|
+
row.get("games") if isinstance(row.get("games"), (int, float))
|
|
253
|
+
else row["wins"] + row["losses"] for row in rows
|
|
254
|
+
)
|
|
255
|
+
for key in ("mvps", "svps"):
|
|
256
|
+
if rows and all(isinstance(row.get(key), (int, float))
|
|
257
|
+
for row in rows):
|
|
258
|
+
totals[key] = sum(row[key] for row in rows)
|
|
259
|
+
total = totals["wins"] + totals["losses"]
|
|
260
|
+
totals["win_rate"] = round(totals["wins"] * 100 / total) if total else None
|
|
261
|
+
group[mode] = totals
|
|
262
|
+
return ClassStatsResponse({"classes": list(groups.values()), "excluded": excluded})
|
|
263
|
+
|
|
210
264
|
def maps(self, *, season: int | None = None) -> list[MapRecord]:
|
|
211
265
|
payload = {"season": season} if season is not None else {}
|
|
212
266
|
return _many(self._post("/player/stats/maps", **payload), MapRecord)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from rivalsdata import (
|
|
2
|
+
ClassModeStats,
|
|
3
|
+
ClassStatsRecord,
|
|
4
|
+
HeroModeStats,
|
|
5
|
+
hero_class,
|
|
6
|
+
hero_name,
|
|
7
|
+
)
|
|
8
|
+
from rivalsdata.resources import PlayerStats
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HeroClient:
|
|
12
|
+
def _post_json(self, path, payload):
|
|
13
|
+
self.request = path, payload
|
|
14
|
+
return [
|
|
15
|
+
{"hero_id": 1011, "competitive": {"wins": 9, "losses": 1, "games": 10}},
|
|
16
|
+
{"hero_id": 1018, "competitive": {"wins": 1, "losses": 1, "games": 2}},
|
|
17
|
+
{"hero_id": 10571, "quickplay": {"wins": 3, "losses": 1}},
|
|
18
|
+
{"hero_id": 10572, "competitive": {"wins": 2, "losses": 0}},
|
|
19
|
+
{"hero_id": 10573, "competitive": {"wins": 0, "losses": 2}},
|
|
20
|
+
{"hero_id": 1016, "competitive": {"wins": 3}},
|
|
21
|
+
{"hero_id": 1057, "competitive": {"wins": 4, "losses": 0}},
|
|
22
|
+
{"hero_id": 9999, "competitive": {"wins": 8, "losses": 0}},
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_weighted_class_totals_modes_and_exclusions():
|
|
27
|
+
client = HeroClient()
|
|
28
|
+
result = PlayerStats(client, 123).classes(season=20)
|
|
29
|
+
groups = {row.player_class: row for row in result.classes}
|
|
30
|
+
|
|
31
|
+
assert client.request == ("/player/stats/heroes", {"uid": 123, "season": 20})
|
|
32
|
+
assert isinstance(groups["tank"], ClassStatsRecord)
|
|
33
|
+
assert isinstance(groups["tank"].competitive, HeroModeStats)
|
|
34
|
+
assert groups["tank"].competitive.win_rate == 83 # 10/12; mean of hero rates is 70
|
|
35
|
+
assert groups["tank"].competitive.games == 12
|
|
36
|
+
assert groups["tank"].quickplay.games == 4
|
|
37
|
+
assert groups["tank"].quickplay.win_rate == 75
|
|
38
|
+
assert groups["support"].competitive.win_rate == 0
|
|
39
|
+
assert groups["dps"].competitive.win_rate == 100
|
|
40
|
+
assert groups["dps"].quickplay.win_rate is None
|
|
41
|
+
assert len(result.excluded) == 3
|
|
42
|
+
assert result.to_dict()["classes"][0]["competitive"]["win_rate"] == 83
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_roles_use_observed_ids_and_do_not_guess_generic_deadpool():
|
|
46
|
+
assert hero_class(10571) == "tank"
|
|
47
|
+
assert hero_class(10572) == "dps"
|
|
48
|
+
assert hero_class(10573) == "support"
|
|
49
|
+
assert hero_class(1057) is None
|
|
50
|
+
assert hero_name(1055) == "Daredevil"
|
|
51
|
+
assert hero_name(1056) == "Angela"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_one_percent_is_not_treated_as_a_fraction():
|
|
55
|
+
row = ClassModeStats({"wins": 1, "losses": 99, "win_rate": 1})
|
|
56
|
+
assert row.win_rate == 1
|
|
57
|
+
assert row.winrate == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|