rivalsdata-api 1.1.1__tar.gz → 1.2.1__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.1
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,46 @@ 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"`. Pass a numeric season ID for that season, or
91
+ `season="all"` for combined all-seasons data, matching `player.heroes.fetch`.
92
+ Omitting the season uses the endpoint default. `player.stats.heroes` also
93
+ accepts `season="all"`. Each row contains `player_class` (`tank`, `support`,
94
+ `dps`), the official role, hero IDs, and separate `competitive` and `quickplay`
95
+ totals for games, wins, losses, and available MVP/SVP counts.
96
+
97
+ ```python
98
+ with RivalsDataClient() as rd:
99
+ player = rd.get_player("GS-")
100
+ stats = player.stats.classes(season=20)
101
+ all_seasons = player.stats.classes(season="all")
102
+ for row in stats.classes:
103
+ print(row.player_class, row.competitive.win_rate)
104
+ print(stats.excluded) # Unknown roles or incomplete win/loss records
105
+ ```
106
+
107
+ For MCP, use `get_player_stats(uid_or_name="GS-", category="classes", season=20)`
108
+ for one season, or `season="all"` for combined all-seasons stats. Both return
109
+ the same class response structure.
110
+
111
+ Win rates are `total wins / (total wins + total losses)`, rounded to an integer
112
+ percent. They are weighted by hero records, rather than averaging hero win
113
+ rates. Switching heroes can make one match contribute to multiple records;
114
+ these totals describe hero participation, not distinct matches. Empty modes
115
+ have a `None` win rate. Role mappings were observed on RivalsData on
116
+ 2026-09-30, including Deadpool's separate role IDs; generic Deadpool and unknown
117
+ IDs are excluded rather than assigned a guessed class.
118
+
119
+ Character playtime was checked with Camoufox on 2026-09-30. Player hero stats
120
+ did not expose cumulative hours, including in All Seasons. Match details do
121
+ provide seconds in `match.teams[].players[].heroes[].play_time`; the site shows
122
+ these as minutes and seconds when hovering a hero portrait. Sum the relevant
123
+ player's entries across distinct retrieved matches and divide by 3600 to get
124
+ character hours for those matches. Incomplete history prevents treating this as
125
+ a lifetime total. See [the playtime investigation](docs/API.md#character-playtime-investigation-2026-09-30)
126
+ for the observed fields and example.
127
+
88
128
  ## MCP server (ChatGPT and Claude)
89
129
 
90
130
  Install the MCP extra and the package:
@@ -49,6 +49,46 @@ 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"`. Pass a numeric season ID for that season, or
55
+ `season="all"` for combined all-seasons data, matching `player.heroes.fetch`.
56
+ Omitting the season uses the endpoint default. `player.stats.heroes` also
57
+ accepts `season="all"`. Each row contains `player_class` (`tank`, `support`,
58
+ `dps`), the official role, hero IDs, and separate `competitive` and `quickplay`
59
+ totals for games, wins, losses, and available MVP/SVP counts.
60
+
61
+ ```python
62
+ with RivalsDataClient() as rd:
63
+ player = rd.get_player("GS-")
64
+ stats = player.stats.classes(season=20)
65
+ all_seasons = player.stats.classes(season="all")
66
+ for row in stats.classes:
67
+ print(row.player_class, row.competitive.win_rate)
68
+ print(stats.excluded) # Unknown roles or incomplete win/loss records
69
+ ```
70
+
71
+ For MCP, use `get_player_stats(uid_or_name="GS-", category="classes", season=20)`
72
+ for one season, or `season="all"` for combined all-seasons stats. Both return
73
+ the same class response structure.
74
+
75
+ Win rates are `total wins / (total wins + total losses)`, rounded to an integer
76
+ percent. They are weighted by hero records, rather than averaging hero win
77
+ rates. Switching heroes can make one match contribute to multiple records;
78
+ these totals describe hero participation, not distinct matches. Empty modes
79
+ have a `None` win rate. Role mappings were observed on RivalsData on
80
+ 2026-09-30, including Deadpool's separate role IDs; generic Deadpool and unknown
81
+ IDs are excluded rather than assigned a guessed class.
82
+
83
+ Character playtime was checked with Camoufox on 2026-09-30. Player hero stats
84
+ did not expose cumulative hours, including in All Seasons. Match details do
85
+ provide seconds in `match.teams[].players[].heroes[].play_time`; the site shows
86
+ these as minutes and seconds when hovering a hero portrait. Sum the relevant
87
+ player's entries across distinct retrieved matches and divide by 3600 to get
88
+ character hours for those matches. Incomplete history prevents treating this as
89
+ a lifetime total. See [the playtime investigation](docs/API.md#character-playtime-investigation-2026-09-30)
90
+ for the observed fields and example.
91
+
52
92
  ## MCP server (ChatGPT and Claude)
53
93
 
54
94
  Install the MCP extra and the package:
@@ -50,6 +50,45 @@ 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
+ accepting a numeric season ID or `season="all"` (sent to the API as `-1`). The
57
+ all-seasons selection combines the returned hero records across seasons;
58
+ omitting the season keeps the endpoint default. `player.stats.heroes` and MCP
59
+ `get_player_stats` support the same selector. The method also sums
60
+ MVP/SVP counts when every included row supplies them. Win rate is calculated
61
+ from summed wins and losses. Unknown classes and incomplete win/loss rows appear
62
+ in `excluded`. Hero switching can count a single match in multiple hero records,
63
+ so class totals are participation counts rather than distinct matches.
64
+
65
+ The roster at `/stats` on 2026-09-30 identifies Deadpool variants as 10571
66
+ (tank), 10572 (DPS), and 10573 (support), Daredevil as 1055, and Angela as 1056.
67
+
68
+ ### Character playtime investigation (2026-09-30)
69
+
70
+ Using Camoufox (the package's optional browser dependency), the public GS-
71
+ profile (`1970288503`) was inspected in Stats > Heroes for the current season
72
+ and All Seasons. Expanded hero cards showed games, wins/losses, win rate, KDA,
73
+ accuracy, MVP/SVP counts, combat averages, and team-ups. Neither the UI nor the
74
+ captured `/player/heroes` and `/player/stats/heroes` responses supplied cumulative
75
+ hero playtime. The all-seasons stats request returned 49 hero rows, including
76
+ Deadpool's distinct role variants.
77
+
78
+ Match details do expose per-character time in seconds at
79
+ `teams[].players[].heroes[].play_time`. For match
80
+ `5518155_1790655030_1272083_11001_11`, the match duration was 584 seconds and GS-'s
81
+ Loki entry contained `play_time=583.8668914120644`. Hovering the hero portrait
82
+ displayed `Loki` and `9:43`. The observed frontend formatter takes
83
+ `floor(play_time / 60)` minutes and `floor(play_time % 60)` seconds.
84
+
85
+ Hours for a character over a supplied set of matches can be calculated by
86
+ summing that player's matching hero entries and dividing by 3600. Include all
87
+ matching entries (a hero can have multiple usage segments), and deduplicate
88
+ match IDs. This produces time for the retrieved matches, not a guaranteed
89
+ lifetime or season total: history and detail availability may be incomplete.
90
+ No automatic bulk match fetch was added as part of class statistics.
91
+
53
92
  | UI route | What the UI exposes |
54
93
  | --- | --- |
55
94
  | `/` | 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.1`.
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,25 @@ 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. Supply a numeric season ID for one season or `season="all"` for
59
+ combined all-seasons stats; the latter sends `season=-1` to the upstream API.
60
+ The detailed `player.stats.heroes` method and MCP stats tool accept the same
61
+ selector. Omitting the season keeps the endpoint default.
62
+ It sums games, wins, losses, and available MVP/SVP counts separately
63
+ for competitive and quickplay, with win rates calculated from summed wins and
64
+ losses. The response has typed `classes` rows and an `excluded` list for unknown
65
+ roles or incomplete win/loss data. MCP exposes it through `get_player_stats`
66
+ with `category="classes"`. Hero switching means totals count participation,
67
+ not unique matches. Role IDs and the corrected Angela/Daredevil IDs were
68
+ verified against RivalsData's roster on 2026-09-30; Deadpool has separate role
69
+ variants (10571/10572/10573), while generic 1057 remains unclassified.
70
+
71
+ Camoufox inspection found no cumulative hero hours in current-season or
72
+ all-seasons player stats. Match hero usage does include `play_time` in seconds.
73
+ See `docs/API.md` for the recorded investigation; no playtime aggregation
74
+ method was added.
75
+
57
76
  ## Observed API details
58
77
 
59
78
  The major UI areas inspected are home, player profile/tabs, global leaderboard,
@@ -103,7 +122,7 @@ Use a virtual environment. Install editable dependencies with
103
122
  fallback work. The global host Python has unrelated package conflicts; don't
104
123
  change global dependencies to resolve those.
105
124
 
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
125
+ Mocked tests in `tests/` cover hero lookups, typed responses, and calculated
126
+ class stats. Keep network-dependent checks opt-in. Run pytest, Ruff, and package
127
+ builds for code checks. Commit coherent
109
128
  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.1"
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.1"
@@ -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."""
@@ -408,15 +408,23 @@ def get_player_matches(
408
408
 
409
409
  @mcp.tool()
410
410
  def get_player_stats(
411
- uid_or_name: str, category: str = "heroes", season: int | None = None
411
+ uid_or_name: str, category: str = "heroes",
412
+ season: int | Literal["all"] | None = None,
412
413
  ) -> Any:
413
- """Get player stats; category must be heroes, maps, or bans."""
414
- methods = {"heroes": "heroes", "maps": "maps", "bans": "bans"}
414
+ """Get player stats: heroes, maps, bans, or calculated classes.
415
+
416
+ Classes sum hero wins/losses by tank/support/dps and game mode; these are
417
+ hero participation totals, which can count a match more than once.
418
+ Supply a season ID or "all" for combined all-seasons totals. Omitting the
419
+ season uses the endpoint default.
420
+ """
421
+ methods = {"heroes": "heroes", "maps": "maps", "bans": "bans", "classes": "classes"}
415
422
  if category not in methods:
416
- raise ValueError("category must be one of: heroes, maps, bans")
423
+ raise ValueError("category must be one of: heroes, maps, bans, classes")
417
424
  def fetch(client: RivalsDataClient, value: str, category: str,
418
- season: int | None) -> Any:
419
- return getattr(client.get_player(value).stats, methods[category])(season=season)
425
+ season: int | Literal["all"] | None) -> Any:
426
+ season_id = -1 if season == "all" else season
427
+ return getattr(client.get_player(value).stats, methods[category])(season=season_id)
420
428
  return _call(fetch, uid_or_name, category, season)
421
429
 
422
430
 
@@ -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,
@@ -203,10 +205,74 @@ class PlayerHeroes(PlayerResource):
203
205
  class PlayerStats(PlayerResource):
204
206
  """Detailed per-player statistics tabs."""
205
207
 
206
- def heroes(self, *, season: int | None = None) -> list[HeroStatsRecord]:
207
- payload = {"season": season} if season is not None else {}
208
+ def heroes(
209
+ self, *, season: int | Literal["all"] | None = None
210
+ ) -> list[HeroStatsRecord]:
211
+ """Fetch mode-specific hero stats for a season or all seasons.
212
+
213
+ ``season="all"`` sends the API's all-seasons selector (-1). Omitting
214
+ ``season`` preserves the endpoint default.
215
+ """
216
+ season_id = -1 if season == "all" else season
217
+ payload = {"season": season_id} if season_id is not None else {}
208
218
  return _many(self._post("/player/stats/heroes", **payload), HeroStatsRecord)
209
219
 
220
+ def classes(
221
+ self, *, season: int | Literal["all"] | None = None
222
+ ) -> ClassStatsResponse:
223
+ """Sum hero records by tank/support/dps, separately for each mode.
224
+
225
+ Supply a season ID or ``season="all"`` for combined all-seasons totals.
226
+ Omitting ``season`` preserves the endpoint default.
227
+ 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.
230
+ Unknown roles and incomplete win/loss rows are listed in ``excluded``.
231
+ """
232
+ groups = {
233
+ name: {"player_class": name, "role": role, "hero_ids": [],
234
+ "competitive": [], "quickplay": []}
235
+ for name, role in (("tank", "Vanguard"), ("support", "Strategist"),
236
+ ("dps", "Duelist"))
237
+ }
238
+ excluded = []
239
+ for hero in self.heroes(season=season):
240
+ identifier = hero.get("hero_id")
241
+ name = hero_class(identifier)
242
+ if name is None:
243
+ excluded.append({"hero_id": identifier, "reason": "unknown_class"})
244
+ continue
245
+ group = groups[name]
246
+ group["hero_ids"].append(identifier)
247
+ for mode in ("competitive", "quickplay"):
248
+ row = hero.get(mode)
249
+ if row is None:
250
+ continue
251
+ counts = [row.get(key) for key in ("wins", "losses")]
252
+ if any(not isinstance(value, (int, float)) or isinstance(value, bool)
253
+ or value < 0 for value in counts):
254
+ excluded.append({"hero_id": identifier, "mode": mode,
255
+ "reason": "missing_or_invalid_win_loss_counts"})
256
+ continue
257
+ group[mode].append(row)
258
+ for group in groups.values():
259
+ for mode in ("competitive", "quickplay"):
260
+ rows = group[mode]
261
+ totals = {key: sum(row[key] for row in rows)
262
+ for key in ("wins", "losses")}
263
+ totals["games"] = sum(
264
+ row.get("games") if isinstance(row.get("games"), (int, float))
265
+ else row["wins"] + row["losses"] for row in rows
266
+ )
267
+ for key in ("mvps", "svps"):
268
+ if rows and all(isinstance(row.get(key), (int, float))
269
+ for row in rows):
270
+ totals[key] = sum(row[key] for row in rows)
271
+ total = totals["wins"] + totals["losses"]
272
+ totals["win_rate"] = round(totals["wins"] * 100 / total) if total else None
273
+ group[mode] = totals
274
+ return ClassStatsResponse({"classes": list(groups.values()), "excluded": excluded})
275
+
210
276
  def maps(self, *, season: int | None = None) -> list[MapRecord]:
211
277
  payload = {"season": season} if season is not None else {}
212
278
  return _many(self._post("/player/stats/maps", **payload), MapRecord)
@@ -0,0 +1,102 @@
1
+ import pytest
2
+
3
+ from rivalsdata import (
4
+ ClassModeStats,
5
+ ClassStatsRecord,
6
+ HeroModeStats,
7
+ hero_class,
8
+ hero_name,
9
+ )
10
+ from rivalsdata.resources import PlayerStats
11
+
12
+
13
+ class HeroClient:
14
+ def _post_json(self, path, payload):
15
+ self.request = path, payload
16
+ return [
17
+ {"hero_id": 1011, "competitive": {"wins": 9, "losses": 1, "games": 10}},
18
+ {"hero_id": 1018, "competitive": {"wins": 1, "losses": 1, "games": 2}},
19
+ {"hero_id": 10571, "quickplay": {"wins": 3, "losses": 1}},
20
+ {"hero_id": 10572, "competitive": {"wins": 2, "losses": 0}},
21
+ {"hero_id": 10573, "competitive": {"wins": 0, "losses": 2}},
22
+ {"hero_id": 1016, "competitive": {"wins": 3}},
23
+ {"hero_id": 1057, "competitive": {"wins": 4, "losses": 0}},
24
+ {"hero_id": 9999, "competitive": {"wins": 8, "losses": 0}},
25
+ ]
26
+
27
+
28
+ def test_weighted_class_totals_modes_and_exclusions():
29
+ client = HeroClient()
30
+ result = PlayerStats(client, 123).classes(season=20)
31
+ groups = {row.player_class: row for row in result.classes}
32
+
33
+ assert client.request == ("/player/stats/heroes", {"uid": 123, "season": 20})
34
+ assert isinstance(groups["tank"], ClassStatsRecord)
35
+ assert isinstance(groups["tank"].competitive, HeroModeStats)
36
+ assert groups["tank"].competitive.win_rate == 83 # 10/12; mean of hero rates is 70
37
+ assert groups["tank"].competitive.games == 12
38
+ assert groups["tank"].quickplay.games == 4
39
+ assert groups["tank"].quickplay.win_rate == 75
40
+ assert groups["support"].competitive.win_rate == 0
41
+ assert groups["dps"].competitive.win_rate == 100
42
+ assert groups["dps"].quickplay.win_rate is None
43
+ assert len(result.excluded) == 3
44
+ assert result.to_dict()["classes"][0]["competitive"]["win_rate"] == 83
45
+
46
+
47
+ def test_roles_use_observed_ids_and_do_not_guess_generic_deadpool():
48
+ assert hero_class(10571) == "tank"
49
+ assert hero_class(10572) == "dps"
50
+ assert hero_class(10573) == "support"
51
+ assert hero_class(1057) is None
52
+ assert hero_name(1055) == "Daredevil"
53
+ assert hero_name(1056) == "Angela"
54
+
55
+
56
+ def test_one_percent_is_not_treated_as_a_fraction():
57
+ row = ClassModeStats({"wins": 1, "losses": 99, "win_rate": 1})
58
+ assert row.win_rate == 1
59
+ assert row.winrate == 1
60
+
61
+
62
+ class SeasonClient:
63
+ def _post_json(self, path, payload):
64
+ self.request = path, payload
65
+ wins = {None: 2, 20: 3, -1: 12}[payload.get("season")]
66
+ return [{"hero_id": 1016, "competitive": {"wins": wins, "losses": 1}}]
67
+
68
+
69
+ @pytest.mark.parametrize("season,payload,wins", [
70
+ (None, {"uid": 123}, 2),
71
+ (20, {"uid": 123, "season": 20}, 3),
72
+ ("all", {"uid": 123, "season": -1}, 12),
73
+ (-1, {"uid": 123, "season": -1}, 12),
74
+ ])
75
+ def test_class_season_selector_uses_the_selected_hero_data(season, payload, wins):
76
+ client = SeasonClient()
77
+ result = PlayerStats(client, 123).classes(season=season)
78
+ assert client.request == ("/player/stats/heroes", payload)
79
+ assert result.classes[1].competitive.wins == wins
80
+
81
+
82
+ def test_detailed_hero_stats_accept_the_same_all_seasons_selector():
83
+ client = SeasonClient()
84
+ rows = PlayerStats(client, 123).heroes(season="all")
85
+ assert client.request == ("/player/stats/heroes", {"uid": 123, "season": -1})
86
+ assert rows[0].competitive.wins == 12
87
+
88
+
89
+ def test_mcp_stats_exposes_all_seasons_and_forwards_the_selector(monkeypatch):
90
+ import asyncio
91
+ from types import SimpleNamespace
92
+
93
+ server = pytest.importorskip("rivalsdata.mcp_server", exc_type=ImportError)
94
+ client = SeasonClient()
95
+ client.get_player = lambda value: SimpleNamespace(stats=PlayerStats(client, 123))
96
+ monkeypatch.setattr(server, "_call", lambda fn, *a, **kw: fn(client, *a, **kw))
97
+ result = server.get_player_stats("123", category="classes", season="all")
98
+ assert client.request == ("/player/stats/heroes", {"uid": 123, "season": -1})
99
+ assert result.classes[1].competitive.wins == 12
100
+ tools = asyncio.run(server.mcp.list_tools())
101
+ schema = next(tool.inputSchema for tool in tools if tool.name == "get_player_stats")
102
+ assert {"const": "all", "type": "string"} in schema["properties"]["season"]["anyOf"]
File without changes