rivalsdata-api 1.2.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rivalsdata-api
3
- Version: 1.2.3
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,6 +114,29 @@ 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
142
  rates. The response's `metadata` identifies the source, formula, and requested
@@ -78,6 +78,29 @@ 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
106
  rates. The response's `metadata` identifies the source, formula, and requested
@@ -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` | Array rows: `competitive`, `hero_id`, `quickplay`, `rank`. Each mode includes games, wins/losses, KDA, MVP/SVP counts, accuracy, and `per_10`/`per_game` combat averages. |
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`. |
@@ -77,6 +77,34 @@ unverified. Warnings flag those limitations and any excluded records.
77
77
  The roster at `/stats` on 2026-09-30 identifies Deadpool variants as 10571
78
78
  (tank), 10572 (DPS), and 10573 (support), Daredevil as 1055, and Angela as 1056.
79
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
+
80
108
  ### Character playtime investigation (2026-09-30)
81
109
 
82
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.2.2`.
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,7 +65,17 @@ 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
- It sums games, wins, losses, and available MVP/SVP counts separately
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`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rivalsdata-api"
7
- version = "1.2.3"
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"
@@ -153,4 +153,4 @@ __all__ = [
153
153
  "hero_id",
154
154
  "hero_name",
155
155
  ]
156
- __version__ = "1.2.2"
156
+ __version__ = "1.3.0"
@@ -415,9 +415,15 @@ 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
428
  summed hero records, which may overlap within a match. The response metadata
423
429
  explains the calculation and scope; class totals cannot establish a player's
@@ -428,11 +434,17 @@ def get_player_stats(
428
434
  methods = {"heroes": "heroes", "maps": "maps", "bans": "bans", "classes": "classes"}
429
435
  if category not in methods:
430
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")
431
439
  def fetch(client: RivalsDataClient, value: str, category: str,
432
- season: int | Literal["all"] | None) -> Any:
440
+ season: int | Literal["all"] | None,
441
+ mode: Literal["competitive", "quickplay"] | None) -> Any:
433
442
  season_id = -1 if season == "all" else season
434
- return getattr(client.get_player(value).stats, methods[category])(season=season_id)
435
- return _call(fetch, uid_or_name, category, season)
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)
436
448
 
437
449
 
438
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])
@@ -206,13 +206,32 @@ class PlayerStats(PlayerResource):
206
206
  """Detailed per-player statistics tabs."""
207
207
 
208
208
  def heroes(
209
- self, *, season: int | Literal["all"] | None = None
209
+ self, *, mode: Literal["competitive", "quickplay"],
210
+ season: int | Literal["all"] | None = None,
210
211
  ) -> list[HeroStatsRecord]:
211
- """Fetch mode-specific hero stats for a season or all seasons.
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)
@@ -238,7 +257,7 @@ class PlayerStats(PlayerResource):
238
257
  ("dps", "Duelist"))
239
258
  }
240
259
  excluded = []
241
- for hero in self.heroes(season=season):
260
+ for hero in self._hero_records(season=season):
242
261
  identifier = hero.get("hero_id")
243
262
  name = hero_class(identifier)
244
263
  if name is None:
@@ -275,8 +294,8 @@ class PlayerStats(PlayerResource):
275
294
  group[mode] = totals
276
295
  all_seasons = season in ("all", -1)
277
296
  warnings = [
278
- "Hero records may overlap within a match. Class totals must not "
279
- "be used to calculate the player's overall match win rate."
297
+ ("Hero records may overlap within a match. Class totals must not "
298
+ "be used to calculate the player's overall match win rate.")
280
299
  ]
281
300
  if all_seasons:
282
301
  warnings.append(
@@ -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