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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rivalsdata-api
3
- Version: 1.1.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.1.0`.
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
- No automated test suite is currently tracked. Don't add network-dependent
107
- checks to routine development; use mocked tests when the project owner asks for
108
- tests. Ruff and wheel builds are available for code checks. Commit coherent
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.1.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.1.0"
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": "Angela",
50
- "1056": "Daredevil",
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; category must be heroes, maps, or bans."""
414
- methods = {"heroes": "heroes", "maps": "maps", "bans": "bans"}
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