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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rivalsdata-api
3
- Version: 1.2.2
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. Switching heroes can make one match contribute to multiple records;
120
- these totals describe hero participation, not distinct matches. Empty modes
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. Switching heroes can make one match contribute to multiple records;
84
- these totals describe hero participation, not distinct matches. Empty modes
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` | 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`. |
@@ -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`. Hero switching can count a single match in multiple hero records,
69
- so class totals are participation counts rather than distinct matches.
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.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,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
- 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`
72
- with `category="classes"`. Hero switching means totals count participation,
73
- not unique matches. Role IDs and the corrected Angela/Daredevil IDs were
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.2.2"
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,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 participation totals, which can count a match more than once.
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) -> Any:
440
+ season: int | Literal["all"] | None,
441
+ mode: Literal["competitive", "quickplay"] | None) -> Any:
431
442
  season_id = -1 if season == "all" else season
432
- return getattr(client.get_player(value).stats, methods[category])(season=season_id)
433
- 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)
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 and hero/mode rows excluded from calculation."""
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, *, 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)
@@ -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 describe hero participation: switching heroes can
229
- cause one match to contribute to multiple hero or class records.
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.heroes(season=season):
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
- return ClassStatsResponse({"classes": list(groups.values()), "excluded": excluded})
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