copsapi-wrapper 1.0.0__tar.gz → 1.1.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.
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/CHANGELOG.md +6 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/PKG-INFO +47 -1
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/README.md +46 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/pyproject.toml +1 -1
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/__init__.py +38 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/_core.py +216 -216
- copsapi_wrapper-1.1.0/src/copsapi/enums.py +179 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/.gitignore +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/LICENSE +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/__main__.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/_async_client.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/_io_async.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/_io_sync.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/_sync_client.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/cache.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/catalog.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/cli.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/errors.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/helpers.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/models.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/py.typed +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/quota.py +0 -0
- {copsapi_wrapper-1.0.0 → copsapi_wrapper-1.1.0}/src/copsapi/watcher.py +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
- Named constants for every code the API uses, such as `BanReason.HACKING`, `UserType.MODERATOR`, `MapId.CASTELLO`, `GameModeId.TOURNAMENT_MODE`, `LeaderboardType.KILLS` and `AutohostCommand.KICK`. Number codes are `IntEnum`s and text codes are `str` enums, so both compare equal to the plain values in the models.
|
|
6
|
+
- `InputDeviceFlag` is an `IntFlag`: `InputDeviceFlag(player.input_device_flags)` decodes the bitmask, and `has_input_device()` checks for one device.
|
|
7
|
+
- Every client method that takes a map, mode, game type, leaderboard type or command also accepts these constants. Nothing else changed, so existing code keeps working.
|
|
8
|
+
|
|
3
9
|
## 1.0.0
|
|
4
10
|
|
|
5
11
|
First release, covering the Critical Ops Public API v2.0.0:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: copsapi-wrapper
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Typed, quota-aware Python client for the Critical Ops Public API v2, sync and async: profiles, leaderboards, match history, weapon stats, server status and Autohost with a live room watcher.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Matchmaking-Live/copsapi-wrapper-python
|
|
6
6
|
Project-URL: Repository, https://github.com/Matchmaking-Live/copsapi-wrapper-python
|
|
@@ -80,6 +80,7 @@ It needs Python 3.10 or newer, and its only dependency is [httpx](https://www.py
|
|
|
80
80
|
- [Errors](#errors)
|
|
81
81
|
- [Caching](#caching)
|
|
82
82
|
- [Game catalog](#game-catalog)
|
|
83
|
+
- [Named constants](#named-constants)
|
|
83
84
|
- [Helpers](#helpers)
|
|
84
85
|
- [Command line](#command-line)
|
|
85
86
|
- [Models](#models)
|
|
@@ -507,6 +508,51 @@ The package also exports these label tables:
|
|
|
507
508
|
- `BAN_REASONS`, `USER_TYPES`, `ACCOUNT_AGES`, `PARTY_INVITE_PERMISSIONS` and `CLAN_RANKS`, which map API codes to readable text.
|
|
508
509
|
- `LEADERBOARD_TYPES`, `MATCH_STATES`, `GAME_TYPES`, `INPUT_DEVICES` and `AUTOHOST_COMMANDS`.
|
|
509
510
|
|
|
511
|
+
## Named constants
|
|
512
|
+
|
|
513
|
+
Every code the API uses has a named constant, so you don't have to remember what `2` or `15847` means:
|
|
514
|
+
|
|
515
|
+
```python
|
|
516
|
+
from copsapi import BanReason, UserType, MapId, GameModeId, LeaderboardType, AutohostCommand, InputDeviceFlag
|
|
517
|
+
|
|
518
|
+
if profile.ban and profile.ban.reason == BanReason.HACKING:
|
|
519
|
+
flag_for_review(profile)
|
|
520
|
+
if player.user_type == UserType.MODERATOR:
|
|
521
|
+
print("a moderator is in the room")
|
|
522
|
+
|
|
523
|
+
cops.autohost.create(server_id=server_id, map=MapId.CASTELLO, mode=GameModeId.TOURNAMENT_MODE, room_name="Final")
|
|
524
|
+
cops.autohost.control(game_id, AutohostCommand.KICK, user_id)
|
|
525
|
+
page = cops.leaderboards.page(LeaderboardType.KILLS)
|
|
526
|
+
|
|
527
|
+
devices = InputDeviceFlag(player.input_device_flags)
|
|
528
|
+
if InputDeviceFlag.TOUCH in devices:
|
|
529
|
+
print("plays on a touch screen")
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
| Constant | Kind | Members |
|
|
533
|
+
| --- | --- | --- |
|
|
534
|
+
| `UserType` | `IntEnum` | `SPECTATOR`, `USER`, `ASSISTANT_MOD`, `MODERATOR`, `VETERAN_MOD`, `DEVELOPER` |
|
|
535
|
+
| `BanType` | `IntEnum` | `PERMANENT`, `TEMPORARY` |
|
|
536
|
+
| `BanReason` | `IntEnum` | `UNKNOWN`, `FRAUD_ATTEMPT`, `HACKING`, `GRIEFING`, `VERBAL_ABUSE`, `IDLE`, `SPAM`, `REPORT_ABUSE`, `ACCOUNT_TRADING`, `GLITCHING`, `GHOSTING`, `PARTYING_WITH_HACKER`, `UNDER_INVESTIGATION`, `MARKETPLACE_ABUSE` |
|
|
537
|
+
| `ClanMemberRank` | `IntEnum` | `MEMBER`, `OFFICER`, `CO_LEADER`, `LEADER` |
|
|
538
|
+
| `PartyInvitePermission` | `IntEnum` | `ANYBODY`, `ONLY_FRIENDS_AND_CLAN_MEMBERS`, `NOBODY` |
|
|
539
|
+
| `GameType` | `IntEnum` | `RANKED`, `CASUAL` |
|
|
540
|
+
| `MatchResult` | `IntEnum` | `WIN`, `LOSS`, `DRAW` |
|
|
541
|
+
| `InputDeviceFlag` | `IntFlag` | `KEYBOARD_MOUSE`, `TOUCH`, `GAMEPAD` |
|
|
542
|
+
| `MapId` | `IntEnum` | Every map, such as `CANALS` or `CASTELLO` |
|
|
543
|
+
| `GameModeId` | `IntEnum` | Every hostable mode, such as `DEFUSE`, `TEAM_DEATHMATCH` or `TOURNAMENT_MODE` |
|
|
544
|
+
| `AccountAge` | `str` enum | `UNDER_A_WEEK` (`"1w"`) to `FOUR_YEARS_OR_MORE` (`"5y+"`) |
|
|
545
|
+
| `WinCondition` | `str` enum | `TEAM_ELIMINATED`, `TEAM_KILLS`, `TIME_UP`, `SURRENDERED` |
|
|
546
|
+
| `LeaveReason` | `str` enum | `LEFT`, `TIMEOUT`, `KICKED`, `BANNED`, `PING_LIMIT`, `AFK` |
|
|
547
|
+
| `MatchState` | `str` enum | `WAITING_FOR_PLAYERS`, `STARTING`, `FREEZETIME`, `ON_GOING`, `BOMB_PLANTED`, `WIN`, `TEAM_FLIP`, `GAME_END`, `CHANGING_MAP` |
|
|
548
|
+
| `AutohostCommand` | `str` enum | `KICK`, `RESTART`, `ROOM_OWNER_ADD`, `ROOM_OWNER_REMOVE`, `CLOSE` |
|
|
549
|
+
| `LeaderboardType` | `str` enum | `MMR`, `KILLS`, `DEATHS`, `ASSISTS`, `MATCHES`, `WINS`, `LOSSES`, `XP`, `MVP` |
|
|
550
|
+
| `GameStatus` | `str` enum | `OPEN`, `ENDED` |
|
|
551
|
+
|
|
552
|
+
The models keep plain `int` and `str` values, and every constant compares equal to them: `BanReason.HACKING == 2` and `LeaveReason.AFK == "AFK"` are both true. Every client method that takes a map, mode, game type, leaderboard type or command also accepts the constants.
|
|
553
|
+
|
|
554
|
+
Some values can grow as Critical Force updates the game: maps, game modes, win conditions, leave reasons and match states. The fields stay plain values, so a new map or reason never raises an error while parsing a response. If you convert a value yourself, for example `MapId(game.room.map_id)`, handle the `ValueError` you get for a value added after this release.
|
|
555
|
+
|
|
510
556
|
## Helpers
|
|
511
557
|
|
|
512
558
|
| Helper | Returns |
|
|
@@ -45,6 +45,7 @@ It needs Python 3.10 or newer, and its only dependency is [httpx](https://www.py
|
|
|
45
45
|
- [Errors](#errors)
|
|
46
46
|
- [Caching](#caching)
|
|
47
47
|
- [Game catalog](#game-catalog)
|
|
48
|
+
- [Named constants](#named-constants)
|
|
48
49
|
- [Helpers](#helpers)
|
|
49
50
|
- [Command line](#command-line)
|
|
50
51
|
- [Models](#models)
|
|
@@ -472,6 +473,51 @@ The package also exports these label tables:
|
|
|
472
473
|
- `BAN_REASONS`, `USER_TYPES`, `ACCOUNT_AGES`, `PARTY_INVITE_PERMISSIONS` and `CLAN_RANKS`, which map API codes to readable text.
|
|
473
474
|
- `LEADERBOARD_TYPES`, `MATCH_STATES`, `GAME_TYPES`, `INPUT_DEVICES` and `AUTOHOST_COMMANDS`.
|
|
474
475
|
|
|
476
|
+
## Named constants
|
|
477
|
+
|
|
478
|
+
Every code the API uses has a named constant, so you don't have to remember what `2` or `15847` means:
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
from copsapi import BanReason, UserType, MapId, GameModeId, LeaderboardType, AutohostCommand, InputDeviceFlag
|
|
482
|
+
|
|
483
|
+
if profile.ban and profile.ban.reason == BanReason.HACKING:
|
|
484
|
+
flag_for_review(profile)
|
|
485
|
+
if player.user_type == UserType.MODERATOR:
|
|
486
|
+
print("a moderator is in the room")
|
|
487
|
+
|
|
488
|
+
cops.autohost.create(server_id=server_id, map=MapId.CASTELLO, mode=GameModeId.TOURNAMENT_MODE, room_name="Final")
|
|
489
|
+
cops.autohost.control(game_id, AutohostCommand.KICK, user_id)
|
|
490
|
+
page = cops.leaderboards.page(LeaderboardType.KILLS)
|
|
491
|
+
|
|
492
|
+
devices = InputDeviceFlag(player.input_device_flags)
|
|
493
|
+
if InputDeviceFlag.TOUCH in devices:
|
|
494
|
+
print("plays on a touch screen")
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
| Constant | Kind | Members |
|
|
498
|
+
| --- | --- | --- |
|
|
499
|
+
| `UserType` | `IntEnum` | `SPECTATOR`, `USER`, `ASSISTANT_MOD`, `MODERATOR`, `VETERAN_MOD`, `DEVELOPER` |
|
|
500
|
+
| `BanType` | `IntEnum` | `PERMANENT`, `TEMPORARY` |
|
|
501
|
+
| `BanReason` | `IntEnum` | `UNKNOWN`, `FRAUD_ATTEMPT`, `HACKING`, `GRIEFING`, `VERBAL_ABUSE`, `IDLE`, `SPAM`, `REPORT_ABUSE`, `ACCOUNT_TRADING`, `GLITCHING`, `GHOSTING`, `PARTYING_WITH_HACKER`, `UNDER_INVESTIGATION`, `MARKETPLACE_ABUSE` |
|
|
502
|
+
| `ClanMemberRank` | `IntEnum` | `MEMBER`, `OFFICER`, `CO_LEADER`, `LEADER` |
|
|
503
|
+
| `PartyInvitePermission` | `IntEnum` | `ANYBODY`, `ONLY_FRIENDS_AND_CLAN_MEMBERS`, `NOBODY` |
|
|
504
|
+
| `GameType` | `IntEnum` | `RANKED`, `CASUAL` |
|
|
505
|
+
| `MatchResult` | `IntEnum` | `WIN`, `LOSS`, `DRAW` |
|
|
506
|
+
| `InputDeviceFlag` | `IntFlag` | `KEYBOARD_MOUSE`, `TOUCH`, `GAMEPAD` |
|
|
507
|
+
| `MapId` | `IntEnum` | Every map, such as `CANALS` or `CASTELLO` |
|
|
508
|
+
| `GameModeId` | `IntEnum` | Every hostable mode, such as `DEFUSE`, `TEAM_DEATHMATCH` or `TOURNAMENT_MODE` |
|
|
509
|
+
| `AccountAge` | `str` enum | `UNDER_A_WEEK` (`"1w"`) to `FOUR_YEARS_OR_MORE` (`"5y+"`) |
|
|
510
|
+
| `WinCondition` | `str` enum | `TEAM_ELIMINATED`, `TEAM_KILLS`, `TIME_UP`, `SURRENDERED` |
|
|
511
|
+
| `LeaveReason` | `str` enum | `LEFT`, `TIMEOUT`, `KICKED`, `BANNED`, `PING_LIMIT`, `AFK` |
|
|
512
|
+
| `MatchState` | `str` enum | `WAITING_FOR_PLAYERS`, `STARTING`, `FREEZETIME`, `ON_GOING`, `BOMB_PLANTED`, `WIN`, `TEAM_FLIP`, `GAME_END`, `CHANGING_MAP` |
|
|
513
|
+
| `AutohostCommand` | `str` enum | `KICK`, `RESTART`, `ROOM_OWNER_ADD`, `ROOM_OWNER_REMOVE`, `CLOSE` |
|
|
514
|
+
| `LeaderboardType` | `str` enum | `MMR`, `KILLS`, `DEATHS`, `ASSISTS`, `MATCHES`, `WINS`, `LOSSES`, `XP`, `MVP` |
|
|
515
|
+
| `GameStatus` | `str` enum | `OPEN`, `ENDED` |
|
|
516
|
+
|
|
517
|
+
The models keep plain `int` and `str` values, and every constant compares equal to them: `BanReason.HACKING == 2` and `LeaveReason.AFK == "AFK"` are both true. Every client method that takes a map, mode, game type, leaderboard type or command also accepts the constants.
|
|
518
|
+
|
|
519
|
+
Some values can grow as Critical Force updates the game: maps, game modes, win conditions, leave reasons and match states. The fields stay plain values, so a new map or reason never raises an error while parsing a response. If you convert a value yourself, for example `MapId(game.room.map_id)`, handle the `ValueError` you get for a value added after this release.
|
|
520
|
+
|
|
475
521
|
## Helpers
|
|
476
522
|
|
|
477
523
|
| Helper | Returns |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "copsapi-wrapper"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "Typed, quota-aware Python client for the Critical Ops Public API v2, sync and async: profiles, leaderboards, match history, weapon stats, server status and Autohost with a live room watcher."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -39,6 +39,26 @@ from .catalog import (
|
|
|
39
39
|
maps_for,
|
|
40
40
|
modes_for,
|
|
41
41
|
)
|
|
42
|
+
from .enums import (
|
|
43
|
+
AccountAge,
|
|
44
|
+
AutohostCommand,
|
|
45
|
+
BanReason,
|
|
46
|
+
BanType,
|
|
47
|
+
ClanMemberRank,
|
|
48
|
+
GameModeId,
|
|
49
|
+
GameStatus,
|
|
50
|
+
GameType,
|
|
51
|
+
InputDeviceFlag,
|
|
52
|
+
LeaderboardType,
|
|
53
|
+
LeaveReason,
|
|
54
|
+
MapId,
|
|
55
|
+
MatchResult,
|
|
56
|
+
MatchState,
|
|
57
|
+
PartyInvitePermission,
|
|
58
|
+
UserType,
|
|
59
|
+
WinCondition,
|
|
60
|
+
has_input_device,
|
|
61
|
+
)
|
|
42
62
|
from .errors import (
|
|
43
63
|
AuthenticationError,
|
|
44
64
|
BadRequestError,
|
|
@@ -103,6 +123,24 @@ from .watcher import EVENTS, AsyncAutohostWatcher, AutohostWatcher, JoinInfo
|
|
|
103
123
|
__version__ = VERSION
|
|
104
124
|
|
|
105
125
|
__all__ = [
|
|
126
|
+
"AccountAge",
|
|
127
|
+
"AutohostCommand",
|
|
128
|
+
"BanReason",
|
|
129
|
+
"BanType",
|
|
130
|
+
"ClanMemberRank",
|
|
131
|
+
"GameModeId",
|
|
132
|
+
"GameStatus",
|
|
133
|
+
"GameType",
|
|
134
|
+
"InputDeviceFlag",
|
|
135
|
+
"LeaderboardType",
|
|
136
|
+
"LeaveReason",
|
|
137
|
+
"MapId",
|
|
138
|
+
"MatchResult",
|
|
139
|
+
"MatchState",
|
|
140
|
+
"PartyInvitePermission",
|
|
141
|
+
"UserType",
|
|
142
|
+
"WinCondition",
|
|
143
|
+
"has_input_device",
|
|
106
144
|
"ACCOUNT_AGES",
|
|
107
145
|
"AUTOHOST_COMMANDS",
|
|
108
146
|
"AsyncAutohostApi",
|
|
@@ -1,216 +1,216 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
from typing import Any, Iterable, Optional, Sequence, TypeVar, Union
|
|
7
|
-
|
|
8
|
-
from .catalog import GAME_TYPES, game_mode, is_hostable, map_id, map_name
|
|
9
|
-
from .errors import API_CODES, CopsApiError, make_error
|
|
10
|
-
from .models import Profile
|
|
11
|
-
|
|
12
|
-
T = TypeVar("T")
|
|
13
|
-
|
|
14
|
-
VERSION = "1.
|
|
15
|
-
DEFAULT_BASE_URL = "https://latest.prod.copsapi.criticalforce.fi/api/public/v2"
|
|
16
|
-
TOKEN_PATTERN = re.compile(r"^cops_[0-9a-f]{48}$", re.I)
|
|
17
|
-
MATCH_ID_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
18
|
-
|
|
19
|
-
AUTOHOST_DENIED = (
|
|
20
|
-
"Your API token does not have the Autohost permission, which hosting games (autohost.create, poll and control) "
|
|
21
|
-
"needs. Ask Critical Force to grant it to your token; every other endpoint works without it."
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
PlayerRef = Union[int, str]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def resolve_token(token: Optional[str], validate: bool) -> str:
|
|
28
|
-
value = (token if token is not None else os.environ.get("COPS_API_TOKEN", "")).strip()
|
|
29
|
-
if not value:
|
|
30
|
-
raise make_error(
|
|
31
|
-
"missing_token",
|
|
32
|
-
'CopsClient needs your own Critical Ops API token: CopsClient("cops_...") or set COPS_API_TOKEN. '
|
|
33
|
-
"Tokens are issued by Critical Force to a Critical Ops account.",
|
|
34
|
-
local=True,
|
|
35
|
-
)
|
|
36
|
-
if validate and not TOKEN_PATTERN.match(value):
|
|
37
|
-
raise make_error(
|
|
38
|
-
"invalid_token",
|
|
39
|
-
'That does not look like a Critical Ops API token: tokens are "cops_" followed by 48 hex digits. '
|
|
40
|
-
"Pass validate_token=False if the format has changed.",
|
|
41
|
-
local=True,
|
|
42
|
-
)
|
|
43
|
-
return value
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def resolve_base_url(base_url: Optional[str]) -> str:
|
|
47
|
-
return (base_url or os.environ.get("COPS_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def build_query(query: Optional[dict[str, Any]]) -> dict[str, str]:
|
|
51
|
-
out: dict[str, str] = {}
|
|
52
|
-
for key, value in (query or {}).items():
|
|
53
|
-
if value is None:
|
|
54
|
-
continue
|
|
55
|
-
if isinstance(value, (list, tuple)):
|
|
56
|
-
if value:
|
|
57
|
-
out[key] = ",".join(str(v) for v in value)
|
|
58
|
-
elif isinstance(value, bool):
|
|
59
|
-
out[key] = "true" if value else "false"
|
|
60
|
-
else:
|
|
61
|
-
out[key] = str(value)
|
|
62
|
-
return out
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def fallback_code(status: int) -> str:
|
|
66
|
-
if status == 401:
|
|
67
|
-
return "invalid_token"
|
|
68
|
-
if status == 403:
|
|
69
|
-
return "insufficient_permissions"
|
|
70
|
-
if status == 429:
|
|
71
|
-
return "rate_limited"
|
|
72
|
-
if status == 503:
|
|
73
|
-
return "server_unavailable"
|
|
74
|
-
if status >= 500:
|
|
75
|
-
return "internal_error"
|
|
76
|
-
return "bad_request"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def positive(value: Any) -> Optional[float]:
|
|
80
|
-
try:
|
|
81
|
-
number = float(value)
|
|
82
|
-
except (TypeError, ValueError):
|
|
83
|
-
return None
|
|
84
|
-
return number if number > 0 else None
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def parse_json(text: str) -> Any:
|
|
88
|
-
if not text:
|
|
89
|
-
return None
|
|
90
|
-
try:
|
|
91
|
-
return json.loads(text)
|
|
92
|
-
except ValueError:
|
|
93
|
-
return None
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def api_error(status: int, body: Any, headers: Any, method: str, path: str) -> CopsApiError:
|
|
97
|
-
detail: dict[str, Any] = body["error"] if isinstance(body, dict) and isinstance(body.get("error"), dict) else {}
|
|
98
|
-
raw_code = detail.get("code")
|
|
99
|
-
code = raw_code if raw_code in API_CODES else fallback_code(status)
|
|
100
|
-
retry_after = positive(headers.get("retry-after")) or positive(detail.get("retry_after"))
|
|
101
|
-
message = detail.get("message") or f"Critical Ops API error {status}"
|
|
102
|
-
return make_error(code, message, status, retry_after, method=method, path=path)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def local_error(code: str, message: str, path: str, method: str = "GET") -> CopsApiError:
|
|
106
|
-
return make_error(code, message, 0, None, method=method, path=path, local=True)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def chunks(items: Sequence[T], size: int) -> list[list[T]]:
|
|
110
|
-
return [list(items[i : i + size]) for i in range(0, len(items), size)]
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def ref_key(ref: PlayerRef) -> str:
|
|
114
|
-
return f"profile:id:{ref}" if isinstance(ref, int) else f"profile:name:{ref.strip().lower()}"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def dedupe_refs(players: Iterable[PlayerRef]) -> list[PlayerRef]:
|
|
118
|
-
seen: set[str] = set()
|
|
119
|
-
out: list[PlayerRef] = []
|
|
120
|
-
for raw in players:
|
|
121
|
-
if isinstance(raw, bool) or (isinstance(raw, int) and raw <= 0):
|
|
122
|
-
raise local_error("bad_request", f"Invalid player id {raw!r}.", "/profile")
|
|
123
|
-
ref: PlayerRef = raw if isinstance(raw, int) else str(raw).strip()
|
|
124
|
-
if ref == "":
|
|
125
|
-
continue
|
|
126
|
-
key = ref_key(ref)
|
|
127
|
-
if key in seen:
|
|
128
|
-
continue
|
|
129
|
-
seen.add(key)
|
|
130
|
-
out.append(ref)
|
|
131
|
-
return out
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def split_refs(refs: Sequence[PlayerRef]) -> tuple[list[int], list[str]]:
|
|
135
|
-
return [r for r in refs if isinstance(r, int)], [r for r in refs if isinstance(r, str)]
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def match_refs(refs: Sequence[PlayerRef], profiles: Sequence[Profile]) -> list[tuple[PlayerRef, Profile]]:
|
|
139
|
-
ids, names = split_refs(refs)
|
|
140
|
-
by_id = {p.basic_info.user_id: p for p in profiles}
|
|
141
|
-
by_name = {p.basic_info.name.lower(): p for p in profiles}
|
|
142
|
-
out: list[tuple[PlayerRef, Profile]] = [(i, by_id[i]) for i in ids if i in by_id]
|
|
143
|
-
unmatched = [p for p in profiles if p.basic_info.user_id not in ids]
|
|
144
|
-
for index, name in enumerate(names):
|
|
145
|
-
hit = by_name.get(name.lower()) or (unmatched[index] if index < len(unmatched) else None)
|
|
146
|
-
if hit is not None:
|
|
147
|
-
out.append((name, hit))
|
|
148
|
-
return out
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def order_profiles(refs: Sequence[PlayerRef], found: dict[str, Profile]) -> list[Profile]:
|
|
152
|
-
seen: set[int] = set()
|
|
153
|
-
out: list[Profile] = []
|
|
154
|
-
for ref in refs:
|
|
155
|
-
profile = found.get(ref_key(ref))
|
|
156
|
-
if profile is not None and profile.basic_info.user_id not in seen:
|
|
157
|
-
seen.add(profile.basic_info.user_id)
|
|
158
|
-
out.append(profile)
|
|
159
|
-
return out
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def game_type_code(game_type: Union[str, int]) -> int:
|
|
163
|
-
if isinstance(game_type, int):
|
|
164
|
-
if game_type not in (1, 2):
|
|
165
|
-
raise ValueError("game_type must be 1 (ranked) or 2 (casual)")
|
|
166
|
-
return game_type
|
|
167
|
-
if game_type not in GAME_TYPES:
|
|
168
|
-
raise ValueError("game_type must be 'ranked' or 'casual'")
|
|
169
|
-
return GAME_TYPES[game_type]
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def check_match_ids(ids: Iterable[str]) -> list[str]:
|
|
173
|
-
out: list[str] = []
|
|
174
|
-
for raw in ids:
|
|
175
|
-
mid = raw.strip().lower()
|
|
176
|
-
if not MATCH_ID_PATTERN.match(mid):
|
|
177
|
-
raise local_error("bad_request", f"Invalid match id {raw!r}: expected 64 hex digits.", "/matches/fetch")
|
|
178
|
-
if mid not in out:
|
|
179
|
-
out.append(mid)
|
|
180
|
-
return out
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def check_rank(rank: Optional[int]) -> None:
|
|
184
|
-
if rank is not None and not (isinstance(rank, int) and 0 <= rank <= 9):
|
|
185
|
-
raise local_error("bad_request", f"rank must be 0 to 9, got {rank!r}.", "/leaderboards")
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def create_body(server_id: int, map_: Union[int, str], mode: Union[int, str], room_name: str, password: Optional[str], validate: bool) -> dict[str, Any]:
|
|
189
|
-
path = "/autohost/create"
|
|
190
|
-
found_mode = game_mode(mode)
|
|
191
|
-
found_map = map_id(map_)
|
|
192
|
-
mode_id = found_mode.id if found_mode else (mode if isinstance(mode, int) else None)
|
|
193
|
-
map_value = found_map if found_map is not None else (map_ if isinstance(map_, int) else None)
|
|
194
|
-
if mode_id is None:
|
|
195
|
-
raise local_error("bad_request", f"Unknown game mode {mode!r}.", path, "POST")
|
|
196
|
-
if map_value is None:
|
|
197
|
-
raise local_error("bad_request", f"Unknown map {map_!r}.", path, "POST")
|
|
198
|
-
if validate and not is_hostable(mode_id, map_value):
|
|
199
|
-
mode_label = found_mode.name if found_mode else f"Game mode {mode_id}"
|
|
200
|
-
map_label = map_name(map_value) or f"map {map_value}"
|
|
201
|
-
raise local_error(
|
|
202
|
-
"bad_request",
|
|
203
|
-
f"{mode_label} cannot be hosted on {map_label}. Pass validate=False if the API has added it since this release.",
|
|
204
|
-
path,
|
|
205
|
-
"POST",
|
|
206
|
-
)
|
|
207
|
-
if not room_name.strip():
|
|
208
|
-
raise local_error("bad_request", "room_name must not be empty.", path, "POST")
|
|
209
|
-
return {"server_id": server_id, "room_name": room_name, "password": password or "", "map_id": map_value, "game_mode_id": mode_id}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def check_command(command: str, user_id: Optional[int]) -> None:
|
|
213
|
-
if command not in ("kick", "restart", "room_owner_add", "room_owner_remove", "close"):
|
|
214
|
-
raise local_error("bad_request", f"Unknown command {command!r}.", "/autohost/control", "POST")
|
|
215
|
-
if command in ("kick", "room_owner_add", "room_owner_remove") and user_id is None:
|
|
216
|
-
raise local_error("bad_request", f"{command} needs a user id.", "/autohost/control", "POST")
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Iterable, Optional, Sequence, TypeVar, Union
|
|
7
|
+
|
|
8
|
+
from .catalog import GAME_TYPES, game_mode, is_hostable, map_id, map_name
|
|
9
|
+
from .errors import API_CODES, CopsApiError, make_error
|
|
10
|
+
from .models import Profile
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
VERSION = "1.1.0"
|
|
15
|
+
DEFAULT_BASE_URL = "https://latest.prod.copsapi.criticalforce.fi/api/public/v2"
|
|
16
|
+
TOKEN_PATTERN = re.compile(r"^cops_[0-9a-f]{48}$", re.I)
|
|
17
|
+
MATCH_ID_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
18
|
+
|
|
19
|
+
AUTOHOST_DENIED = (
|
|
20
|
+
"Your API token does not have the Autohost permission, which hosting games (autohost.create, poll and control) "
|
|
21
|
+
"needs. Ask Critical Force to grant it to your token; every other endpoint works without it."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
PlayerRef = Union[int, str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_token(token: Optional[str], validate: bool) -> str:
|
|
28
|
+
value = (token if token is not None else os.environ.get("COPS_API_TOKEN", "")).strip()
|
|
29
|
+
if not value:
|
|
30
|
+
raise make_error(
|
|
31
|
+
"missing_token",
|
|
32
|
+
'CopsClient needs your own Critical Ops API token: CopsClient("cops_...") or set COPS_API_TOKEN. '
|
|
33
|
+
"Tokens are issued by Critical Force to a Critical Ops account.",
|
|
34
|
+
local=True,
|
|
35
|
+
)
|
|
36
|
+
if validate and not TOKEN_PATTERN.match(value):
|
|
37
|
+
raise make_error(
|
|
38
|
+
"invalid_token",
|
|
39
|
+
'That does not look like a Critical Ops API token: tokens are "cops_" followed by 48 hex digits. '
|
|
40
|
+
"Pass validate_token=False if the format has changed.",
|
|
41
|
+
local=True,
|
|
42
|
+
)
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_base_url(base_url: Optional[str]) -> str:
|
|
47
|
+
return (base_url or os.environ.get("COPS_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_query(query: Optional[dict[str, Any]]) -> dict[str, str]:
|
|
51
|
+
out: dict[str, str] = {}
|
|
52
|
+
for key, value in (query or {}).items():
|
|
53
|
+
if value is None:
|
|
54
|
+
continue
|
|
55
|
+
if isinstance(value, (list, tuple)):
|
|
56
|
+
if value:
|
|
57
|
+
out[key] = ",".join(str(v) for v in value)
|
|
58
|
+
elif isinstance(value, bool):
|
|
59
|
+
out[key] = "true" if value else "false"
|
|
60
|
+
else:
|
|
61
|
+
out[key] = str(value)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fallback_code(status: int) -> str:
|
|
66
|
+
if status == 401:
|
|
67
|
+
return "invalid_token"
|
|
68
|
+
if status == 403:
|
|
69
|
+
return "insufficient_permissions"
|
|
70
|
+
if status == 429:
|
|
71
|
+
return "rate_limited"
|
|
72
|
+
if status == 503:
|
|
73
|
+
return "server_unavailable"
|
|
74
|
+
if status >= 500:
|
|
75
|
+
return "internal_error"
|
|
76
|
+
return "bad_request"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def positive(value: Any) -> Optional[float]:
|
|
80
|
+
try:
|
|
81
|
+
number = float(value)
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
return number if number > 0 else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_json(text: str) -> Any:
|
|
88
|
+
if not text:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
return json.loads(text)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def api_error(status: int, body: Any, headers: Any, method: str, path: str) -> CopsApiError:
|
|
97
|
+
detail: dict[str, Any] = body["error"] if isinstance(body, dict) and isinstance(body.get("error"), dict) else {}
|
|
98
|
+
raw_code = detail.get("code")
|
|
99
|
+
code = raw_code if raw_code in API_CODES else fallback_code(status)
|
|
100
|
+
retry_after = positive(headers.get("retry-after")) or positive(detail.get("retry_after"))
|
|
101
|
+
message = detail.get("message") or f"Critical Ops API error {status}"
|
|
102
|
+
return make_error(code, message, status, retry_after, method=method, path=path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def local_error(code: str, message: str, path: str, method: str = "GET") -> CopsApiError:
|
|
106
|
+
return make_error(code, message, 0, None, method=method, path=path, local=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def chunks(items: Sequence[T], size: int) -> list[list[T]]:
|
|
110
|
+
return [list(items[i : i + size]) for i in range(0, len(items), size)]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def ref_key(ref: PlayerRef) -> str:
|
|
114
|
+
return f"profile:id:{ref}" if isinstance(ref, int) else f"profile:name:{ref.strip().lower()}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def dedupe_refs(players: Iterable[PlayerRef]) -> list[PlayerRef]:
|
|
118
|
+
seen: set[str] = set()
|
|
119
|
+
out: list[PlayerRef] = []
|
|
120
|
+
for raw in players:
|
|
121
|
+
if isinstance(raw, bool) or (isinstance(raw, int) and raw <= 0):
|
|
122
|
+
raise local_error("bad_request", f"Invalid player id {raw!r}.", "/profile")
|
|
123
|
+
ref: PlayerRef = raw if isinstance(raw, int) else str(raw).strip()
|
|
124
|
+
if ref == "":
|
|
125
|
+
continue
|
|
126
|
+
key = ref_key(ref)
|
|
127
|
+
if key in seen:
|
|
128
|
+
continue
|
|
129
|
+
seen.add(key)
|
|
130
|
+
out.append(ref)
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def split_refs(refs: Sequence[PlayerRef]) -> tuple[list[int], list[str]]:
|
|
135
|
+
return [r for r in refs if isinstance(r, int)], [r for r in refs if isinstance(r, str)]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def match_refs(refs: Sequence[PlayerRef], profiles: Sequence[Profile]) -> list[tuple[PlayerRef, Profile]]:
|
|
139
|
+
ids, names = split_refs(refs)
|
|
140
|
+
by_id = {p.basic_info.user_id: p for p in profiles}
|
|
141
|
+
by_name = {p.basic_info.name.lower(): p for p in profiles}
|
|
142
|
+
out: list[tuple[PlayerRef, Profile]] = [(i, by_id[i]) for i in ids if i in by_id]
|
|
143
|
+
unmatched = [p for p in profiles if p.basic_info.user_id not in ids]
|
|
144
|
+
for index, name in enumerate(names):
|
|
145
|
+
hit = by_name.get(name.lower()) or (unmatched[index] if index < len(unmatched) else None)
|
|
146
|
+
if hit is not None:
|
|
147
|
+
out.append((name, hit))
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def order_profiles(refs: Sequence[PlayerRef], found: dict[str, Profile]) -> list[Profile]:
|
|
152
|
+
seen: set[int] = set()
|
|
153
|
+
out: list[Profile] = []
|
|
154
|
+
for ref in refs:
|
|
155
|
+
profile = found.get(ref_key(ref))
|
|
156
|
+
if profile is not None and profile.basic_info.user_id not in seen:
|
|
157
|
+
seen.add(profile.basic_info.user_id)
|
|
158
|
+
out.append(profile)
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def game_type_code(game_type: Union[str, int]) -> int:
|
|
163
|
+
if isinstance(game_type, int):
|
|
164
|
+
if game_type not in (1, 2):
|
|
165
|
+
raise ValueError("game_type must be 1 (ranked) or 2 (casual)")
|
|
166
|
+
return game_type
|
|
167
|
+
if game_type not in GAME_TYPES:
|
|
168
|
+
raise ValueError("game_type must be 'ranked' or 'casual'")
|
|
169
|
+
return GAME_TYPES[game_type]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def check_match_ids(ids: Iterable[str]) -> list[str]:
|
|
173
|
+
out: list[str] = []
|
|
174
|
+
for raw in ids:
|
|
175
|
+
mid = raw.strip().lower()
|
|
176
|
+
if not MATCH_ID_PATTERN.match(mid):
|
|
177
|
+
raise local_error("bad_request", f"Invalid match id {raw!r}: expected 64 hex digits.", "/matches/fetch")
|
|
178
|
+
if mid not in out:
|
|
179
|
+
out.append(mid)
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def check_rank(rank: Optional[int]) -> None:
|
|
184
|
+
if rank is not None and not (isinstance(rank, int) and 0 <= rank <= 9):
|
|
185
|
+
raise local_error("bad_request", f"rank must be 0 to 9, got {rank!r}.", "/leaderboards")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def create_body(server_id: int, map_: Union[int, str], mode: Union[int, str], room_name: str, password: Optional[str], validate: bool) -> dict[str, Any]:
|
|
189
|
+
path = "/autohost/create"
|
|
190
|
+
found_mode = game_mode(mode)
|
|
191
|
+
found_map = map_id(map_)
|
|
192
|
+
mode_id = found_mode.id if found_mode else (mode if isinstance(mode, int) else None)
|
|
193
|
+
map_value = found_map if found_map is not None else (map_ if isinstance(map_, int) else None)
|
|
194
|
+
if mode_id is None:
|
|
195
|
+
raise local_error("bad_request", f"Unknown game mode {mode!r}.", path, "POST")
|
|
196
|
+
if map_value is None:
|
|
197
|
+
raise local_error("bad_request", f"Unknown map {map_!r}.", path, "POST")
|
|
198
|
+
if validate and not is_hostable(mode_id, map_value):
|
|
199
|
+
mode_label = found_mode.name if found_mode else f"Game mode {mode_id}"
|
|
200
|
+
map_label = map_name(map_value) or f"map {map_value}"
|
|
201
|
+
raise local_error(
|
|
202
|
+
"bad_request",
|
|
203
|
+
f"{mode_label} cannot be hosted on {map_label}. Pass validate=False if the API has added it since this release.",
|
|
204
|
+
path,
|
|
205
|
+
"POST",
|
|
206
|
+
)
|
|
207
|
+
if not room_name.strip():
|
|
208
|
+
raise local_error("bad_request", "room_name must not be empty.", path, "POST")
|
|
209
|
+
return {"server_id": server_id, "room_name": room_name, "password": password or "", "map_id": map_value, "game_mode_id": mode_id}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def check_command(command: str, user_id: Optional[int]) -> None:
|
|
213
|
+
if command not in ("kick", "restart", "room_owner_add", "room_owner_remove", "close"):
|
|
214
|
+
raise local_error("bad_request", f"Unknown command {command!r}.", "/autohost/control", "POST")
|
|
215
|
+
if command in ("kick", "room_owner_add", "room_owner_remove") and user_id is None:
|
|
216
|
+
raise local_error("bad_request", f"{command} needs a user id.", "/autohost/control", "POST")
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum, IntEnum, IntFlag
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _StrEnum(str, Enum):
|
|
7
|
+
def __str__(self) -> str:
|
|
8
|
+
return str(self.value)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UserType(IntEnum):
|
|
12
|
+
SPECTATOR = 0
|
|
13
|
+
USER = 1
|
|
14
|
+
ASSISTANT_MOD = 2
|
|
15
|
+
MODERATOR = 4
|
|
16
|
+
VETERAN_MOD = 8
|
|
17
|
+
DEVELOPER = 64
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BanType(IntEnum):
|
|
21
|
+
PERMANENT = 0
|
|
22
|
+
TEMPORARY = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BanReason(IntEnum):
|
|
26
|
+
UNKNOWN = 0
|
|
27
|
+
FRAUD_ATTEMPT = 1
|
|
28
|
+
HACKING = 2
|
|
29
|
+
GRIEFING = 3
|
|
30
|
+
VERBAL_ABUSE = 4
|
|
31
|
+
IDLE = 5
|
|
32
|
+
SPAM = 6
|
|
33
|
+
REPORT_ABUSE = 7
|
|
34
|
+
ACCOUNT_TRADING = 8
|
|
35
|
+
GLITCHING = 9
|
|
36
|
+
GHOSTING = 10
|
|
37
|
+
PARTYING_WITH_HACKER = 11
|
|
38
|
+
UNDER_INVESTIGATION = 12
|
|
39
|
+
MARKETPLACE_ABUSE = 13
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ClanMemberRank(IntEnum):
|
|
43
|
+
MEMBER = 10
|
|
44
|
+
OFFICER = 20
|
|
45
|
+
CO_LEADER = 30
|
|
46
|
+
LEADER = 40
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PartyInvitePermission(IntEnum):
|
|
50
|
+
ANYBODY = 0
|
|
51
|
+
ONLY_FRIENDS_AND_CLAN_MEMBERS = 1
|
|
52
|
+
NOBODY = 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GameType(IntEnum):
|
|
56
|
+
RANKED = 1
|
|
57
|
+
CASUAL = 2
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MatchResult(IntEnum):
|
|
61
|
+
WIN = 1
|
|
62
|
+
LOSS = 2
|
|
63
|
+
DRAW = 3
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InputDeviceFlag(IntFlag):
|
|
67
|
+
KEYBOARD_MOUSE = 1
|
|
68
|
+
TOUCH = 2
|
|
69
|
+
GAMEPAD = 4
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MapId(IntEnum):
|
|
73
|
+
CANALS = 613
|
|
74
|
+
PLAZA = 615
|
|
75
|
+
LEGACY = 617
|
|
76
|
+
GROUNDED = 618
|
|
77
|
+
BUREAU = 619
|
|
78
|
+
BREWERY = 839
|
|
79
|
+
DIVISION = 841
|
|
80
|
+
UPLINK = 1311
|
|
81
|
+
YARD = 1312
|
|
82
|
+
RAID = 1360
|
|
83
|
+
HEAT = 4483
|
|
84
|
+
WAREHOUSE = 6655
|
|
85
|
+
GALLERY = 6670
|
|
86
|
+
PORT = 6677
|
|
87
|
+
PARK = 7201
|
|
88
|
+
PURIFY = 7316
|
|
89
|
+
CARGO = 7554
|
|
90
|
+
ARCTIC = 7584
|
|
91
|
+
BOUT = 8024
|
|
92
|
+
SOAR = 8427
|
|
93
|
+
SHANTY = 8872
|
|
94
|
+
HANGAR = 9310
|
|
95
|
+
VILLAGE = 13410
|
|
96
|
+
IMPASSE = 14066
|
|
97
|
+
OVERHAUL = 15028
|
|
98
|
+
CASTELLO = 15391
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GameModeId(IntEnum):
|
|
102
|
+
DEFUSE = 15773
|
|
103
|
+
ELIMINATION = 15775
|
|
104
|
+
GUNGAME = 15776
|
|
105
|
+
TEAM_DEATHMATCH = 15777
|
|
106
|
+
MANHUNT = 15846
|
|
107
|
+
TOURNAMENT_MODE = 15847
|
|
108
|
+
RUSH = 15860
|
|
109
|
+
DEFEND = 15861
|
|
110
|
+
MANHUNT_Z = 18596
|
|
111
|
+
WINGMAN = 18850
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AccountAge(_StrEnum):
|
|
115
|
+
UNDER_A_WEEK = "1w"
|
|
116
|
+
UNDER_A_MONTH = "1m"
|
|
117
|
+
UNDER_THREE_MONTHS = "3m"
|
|
118
|
+
UNDER_A_YEAR = "1y"
|
|
119
|
+
UNDER_TWO_YEARS = "2y"
|
|
120
|
+
UNDER_THREE_YEARS = "3y"
|
|
121
|
+
UNDER_FOUR_YEARS = "4y"
|
|
122
|
+
FOUR_YEARS_OR_MORE = "5y+"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class WinCondition(_StrEnum):
|
|
126
|
+
TEAM_ELIMINATED = "TeamEliminated"
|
|
127
|
+
TEAM_KILLS = "TeamKills"
|
|
128
|
+
TIME_UP = "TimeUp"
|
|
129
|
+
SURRENDERED = "Surrendered"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class LeaveReason(_StrEnum):
|
|
133
|
+
LEFT = "Left"
|
|
134
|
+
TIMEOUT = "Timeout"
|
|
135
|
+
KICKED = "Kicked"
|
|
136
|
+
BANNED = "Banned"
|
|
137
|
+
PING_LIMIT = "PingLimit"
|
|
138
|
+
AFK = "AFK"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class MatchState(_StrEnum):
|
|
142
|
+
WAITING_FOR_PLAYERS = "WaitingForPlayers"
|
|
143
|
+
STARTING = "Starting"
|
|
144
|
+
FREEZETIME = "Freezetime"
|
|
145
|
+
ON_GOING = "OnGoing"
|
|
146
|
+
BOMB_PLANTED = "BombPlanted"
|
|
147
|
+
WIN = "Win"
|
|
148
|
+
TEAM_FLIP = "TeamFlip"
|
|
149
|
+
GAME_END = "GameEnd"
|
|
150
|
+
CHANGING_MAP = "ChangingMap"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class AutohostCommand(_StrEnum):
|
|
154
|
+
KICK = "kick"
|
|
155
|
+
RESTART = "restart"
|
|
156
|
+
ROOM_OWNER_ADD = "room_owner_add"
|
|
157
|
+
ROOM_OWNER_REMOVE = "room_owner_remove"
|
|
158
|
+
CLOSE = "close"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class LeaderboardType(_StrEnum):
|
|
162
|
+
MMR = "mmr"
|
|
163
|
+
KILLS = "kills"
|
|
164
|
+
DEATHS = "deaths"
|
|
165
|
+
ASSISTS = "assists"
|
|
166
|
+
MATCHES = "matches"
|
|
167
|
+
WINS = "wins"
|
|
168
|
+
LOSSES = "losses"
|
|
169
|
+
XP = "xp"
|
|
170
|
+
MVP = "mvp"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class GameStatus(_StrEnum):
|
|
174
|
+
OPEN = "open"
|
|
175
|
+
ENDED = "ended"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def has_input_device(flags: int, device: InputDeviceFlag) -> bool:
|
|
179
|
+
return bool(flags & device)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|