copsapi-wrapper 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,630 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ import random
5
+ import time
6
+ from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Sequence, Union
7
+
8
+ import httpx
9
+
10
+ from ._core import (
11
+ AUTOHOST_DENIED,
12
+ VERSION,
13
+ PlayerRef,
14
+ api_error,
15
+ build_query,
16
+ check_command,
17
+ check_match_ids,
18
+ check_rank,
19
+ chunks,
20
+ create_body,
21
+ dedupe_refs,
22
+ game_type_code,
23
+ match_refs,
24
+ order_profiles,
25
+ parse_json,
26
+ ref_key,
27
+ resolve_base_url,
28
+ resolve_token,
29
+ split_refs,
30
+ )
31
+ from ._io_async import Gate, HttpClient, resolve, sleep
32
+ from .cache import CacheStore, MemoryCache, resolve_ttls
33
+ from .errors import CopsApiError, make_error
34
+ from .helpers import random_password, region_of
35
+ from .models import (
36
+ AutohostGame,
37
+ GameServer,
38
+ HostedRoom,
39
+ LeaderboardEntry,
40
+ LeaderboardPage,
41
+ MatchHistoryEntry,
42
+ MatchInfo,
43
+ Profile,
44
+ QuotaSnapshot,
45
+ TokenCheck,
46
+ WeaponStats,
47
+ from_dict,
48
+ )
49
+ from .quota import QuotaTracker
50
+ from .watcher import AsyncAutohostWatcher
51
+
52
+ Hook = Optional[Callable[[dict[str, Any]], Any]]
53
+
54
+
55
+ class AsyncTransport:
56
+ def __init__(
57
+ self,
58
+ *,
59
+ token: str,
60
+ base_url: str,
61
+ retries: int,
62
+ gateway_retries: int,
63
+ max_rate_limit_wait: float,
64
+ auth_header: str,
65
+ user_agent: Optional[str],
66
+ quota: QuotaTracker,
67
+ gate: Gate,
68
+ http: Any,
69
+ on_request: Hook,
70
+ on_response: Hook,
71
+ on_retry: Hook,
72
+ ) -> None:
73
+ self._token = token
74
+ self._base_url = base_url
75
+ self._retries = retries
76
+ self._gateway_retries = gateway_retries
77
+ self._max_rate_limit_wait = max_rate_limit_wait
78
+ self._auth_header = auth_header
79
+ self._user_agent = user_agent
80
+ self._quota = quota
81
+ self._gate = gate
82
+ self._http = http
83
+ self._on_request = on_request
84
+ self._on_response = on_response
85
+ self._on_retry = on_retry
86
+
87
+ def _headers(self, has_body: bool, authenticated: bool) -> dict[str, str]:
88
+ headers = {"Accept": "application/json"}
89
+ if has_body:
90
+ headers["Content-Type"] = "application/json"
91
+ if self._user_agent:
92
+ headers["User-Agent"] = self._user_agent
93
+ if authenticated:
94
+ if self._auth_header == "x-api-key":
95
+ headers["X-Api-Key"] = self._token
96
+ else:
97
+ headers["Authorization"] = f"Bearer {self._token}"
98
+ return headers
99
+
100
+ async def request(
101
+ self,
102
+ path: str,
103
+ *,
104
+ method: str = "GET",
105
+ query: Optional[dict[str, Any]] = None,
106
+ body: Any = None,
107
+ priority: str = "normal",
108
+ authenticated: bool = True,
109
+ ) -> Any:
110
+ params = build_query(query)
111
+ key = f"{priority} {path}?{sorted(params.items())}" if method == "GET" else None
112
+
113
+ async def send() -> Any:
114
+ return await self._send(path, method, params, body, priority, authenticated)
115
+
116
+ return await self._gate.run(key, send)
117
+
118
+ async def _retry(self, info: dict[str, Any], delay: float) -> None:
119
+ if self._on_retry:
120
+ self._on_retry(info)
121
+ await sleep(delay)
122
+
123
+ async def _send(self, path: str, method: str, params: dict[str, str], body: Any, priority: str, authenticated: bool) -> Any:
124
+ idempotent = method == "GET"
125
+ headers = self._headers(body is not None, authenticated)
126
+ url = self._base_url + path
127
+ attempt = network_retries = gateway_retries = server_retries = rate_waits = 0
128
+ while True:
129
+ attempt += 1
130
+ if authenticated:
131
+ while True:
132
+ wait = self._quota.try_acquire(priority)
133
+ if wait <= 0:
134
+ break
135
+ await sleep(wait)
136
+ if self._on_request:
137
+ self._on_request({"method": method, "path": path, "attempt": attempt})
138
+ started = time.monotonic()
139
+ try:
140
+ response = await self._http.request(method, url, params=params, json=body, headers=headers)
141
+ except httpx.TimeoutException as err:
142
+ if authenticated:
143
+ self._quota.release()
144
+ error = make_error("timeout", "No answer from the Critical Ops API in time.", method=method, path=path)
145
+ if idempotent and network_retries < self._retries:
146
+ network_retries += 1
147
+ await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "timeout", "error": error}, 0.25 * 2 ** (network_retries - 1))
148
+ continue
149
+ raise error from err
150
+ except httpx.TransportError as err:
151
+ if authenticated:
152
+ self._quota.release()
153
+ error = make_error("network", f"Critical Ops API unreachable: {err}", method=method, path=path)
154
+ if idempotent and network_retries < self._retries:
155
+ network_retries += 1
156
+ await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "network", "error": error}, 0.25 * 2 ** (network_retries - 1))
157
+ continue
158
+ raise error from err
159
+
160
+ if authenticated:
161
+ self._quota.update(response.headers)
162
+ self._quota.release()
163
+ status = response.status_code
164
+ if self._on_response:
165
+ self._on_response({"method": method, "path": path, "attempt": attempt, "status": status, "duration": time.monotonic() - started})
166
+ text = response.text
167
+ data = parse_json(text)
168
+
169
+ if 200 <= status < 300:
170
+ if data is None:
171
+ raise make_error("invalid_response", f"Expected JSON from {path}, got {text[:60] or 'an empty body'}.", status, method=method, path=path)
172
+ return data
173
+
174
+ has_api_error = isinstance(data, dict) and isinstance(data.get("error"), dict)
175
+ if not has_api_error and (status == 404 or status >= 500):
176
+ error = make_error("gateway", f"Critical Ops API gateway answered {status} {text[:40].strip()}".strip(), status, method=method, path=path)
177
+ if gateway_retries < self._gateway_retries:
178
+ gateway_retries += 1
179
+ await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "gateway", "error": error}, 0.15 * gateway_retries)
180
+ continue
181
+ raise error
182
+
183
+ error = api_error(status, data, response.headers, method, path)
184
+ if error.code == "rate_limited":
185
+ self._quota.exhaust(error.retry_after)
186
+ wait = error.retry_after if error.retry_after is not None else self._quota.seconds_until_reset()
187
+ if rate_waits < 2 and wait <= self._max_rate_limit_wait:
188
+ rate_waits += 1
189
+ await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "rate_limited", "error": error}, wait)
190
+ continue
191
+ raise error
192
+
193
+ if idempotent and error.code in ("internal_error", "server_unavailable") and server_retries < self._retries:
194
+ server_retries += 1
195
+ await self._retry({"method": method, "path": path, "attempt": attempt, "reason": "server_error", "error": error}, 0.5 * 2 ** (server_retries - 1))
196
+ continue
197
+ raise error
198
+
199
+
200
+ class AsyncCopsClient:
201
+ def __init__(
202
+ self,
203
+ token: Optional[str] = None,
204
+ *,
205
+ base_url: Optional[str] = None,
206
+ timeout: float = 10.0,
207
+ retries: int = 2,
208
+ gateway_retries: int = 5,
209
+ max_rate_limit_wait: float = 15.0,
210
+ rate_limit: int = 60,
211
+ rate_limit_reserve: int = 0,
212
+ on_quota_exhausted: str = "wait",
213
+ max_quota_wait: float = 65.0,
214
+ max_concurrent: int = 8,
215
+ dedupe: bool = True,
216
+ cache: Union[bool, CacheStore] = True,
217
+ cache_ttls: Optional[dict[str, float]] = None,
218
+ auth_header: str = "bearer",
219
+ user_agent: Optional[str] = f"copsapi-wrapper-python/{VERSION}",
220
+ max_players_per_request: int = 20,
221
+ validate_token: bool = True,
222
+ http_client: Optional[Any] = None,
223
+ transport: Optional[Any] = None,
224
+ on_request: Hook = None,
225
+ on_response: Hook = None,
226
+ on_retry: Hook = None,
227
+ ) -> None:
228
+ if auth_header not in ("bearer", "x-api-key"):
229
+ raise ValueError("auth_header must be 'bearer' or 'x-api-key'")
230
+ resolved = resolve_token(token, validate_token)
231
+ self.base_url = resolve_base_url(base_url)
232
+ self.max_players_per_request = max(1, max_players_per_request)
233
+ self._quota = QuotaTracker(rate_limit, rate_limit_reserve, on_quota_exhausted, max_quota_wait)
234
+ self._owns_http = http_client is None
235
+ self._http = http_client if http_client is not None else HttpClient(timeout=timeout, transport=transport)
236
+ self._transport = AsyncTransport(
237
+ token=resolved,
238
+ base_url=self.base_url,
239
+ retries=retries,
240
+ gateway_retries=gateway_retries,
241
+ max_rate_limit_wait=max_rate_limit_wait,
242
+ auth_header=auth_header,
243
+ user_agent=user_agent,
244
+ quota=self._quota,
245
+ gate=Gate(max_concurrent, dedupe),
246
+ http=self._http,
247
+ on_request=on_request,
248
+ on_response=on_response,
249
+ on_retry=on_retry,
250
+ )
251
+ self._store: Optional[CacheStore] = None if cache is False else (MemoryCache() if cache is True else cache)
252
+ self._ttls = resolve_ttls(cache_ttls)
253
+ self._autohost_allowed: Optional[bool] = None
254
+ self.profiles = AsyncProfilesApi(self)
255
+ self.servers = AsyncServersApi(self)
256
+ self.matches = AsyncMatchesApi(self)
257
+ self.stats = AsyncStatsApi(self)
258
+ self.leaderboards = AsyncLeaderboardsApi(self)
259
+ self.autohost = AsyncAutohostApi(self)
260
+
261
+ def __repr__(self) -> str:
262
+ return f"{type(self).__name__}(base_url={self.base_url!r})"
263
+
264
+ async def __aenter__(self) -> AsyncCopsClient:
265
+ return self
266
+
267
+ async def __aexit__(self, *exc: Any) -> None:
268
+ await self.aclose()
269
+
270
+ async def aclose(self) -> None:
271
+ if self._owns_http:
272
+ await self._http.aclose()
273
+
274
+ @property
275
+ def quota(self) -> QuotaSnapshot:
276
+ return self._quota.snapshot()
277
+
278
+ @property
279
+ def autohost_permission(self) -> Optional[bool]:
280
+ return self._autohost_allowed
281
+
282
+ def set_autohost_permission(self, allowed: Optional[bool]) -> None:
283
+ self._autohost_allowed = allowed
284
+
285
+ async def request(self, path: str, **kwargs: Any) -> Any:
286
+ return await self._transport.request(path, **kwargs)
287
+
288
+ async def open_api_spec(self) -> dict[str, Any]:
289
+ return await self._transport.request("/openapi.json", authenticated=False)
290
+
291
+ async def clear_cache(self) -> None:
292
+ if self._store is not None and hasattr(self._store, "clear"):
293
+ await resolve(self._store.clear())
294
+
295
+ async def verify_token(self) -> TokenCheck:
296
+ try:
297
+ await self.servers.list(fresh=True)
298
+ except CopsApiError as err:
299
+ if err.code in ("missing_token", "invalid_token", "token_revoked"):
300
+ return TokenCheck(False, False, err, self.quota)
301
+ raise
302
+ autohost = await self.autohost.is_available(fresh=True)
303
+ return TokenCheck(True, autohost, None, self.quota)
304
+
305
+ async def _cache_get(self, key: str) -> Any:
306
+ if self._store is None:
307
+ return None
308
+ return await resolve(self._store.get(key))
309
+
310
+ async def _cache_set(self, key: str, value: Any, kind: str) -> None:
311
+ if self._store is not None:
312
+ await resolve(self._store.set(key, value, self._ttls[kind]))
313
+
314
+ async def _cached(self, key: str, kind: str, fresh: bool, loader: Callable[[], Awaitable[Any]]) -> Any:
315
+ if not fresh:
316
+ hit = await self._cache_get(key)
317
+ if hit is not None:
318
+ return hit
319
+ value = await loader()
320
+ await self._cache_set(key, value, kind)
321
+ return value
322
+
323
+
324
+ class AsyncProfilesApi:
325
+ def __init__(self, client: AsyncCopsClient) -> None:
326
+ self._client = client
327
+
328
+ async def get(self, player: PlayerRef, *, fresh: bool = False) -> Profile:
329
+ found = await self.many([player], fresh=fresh, skip_missing=False)
330
+ if not found:
331
+ raise make_error("user_not_found", f"No Critical Ops player {player!r}.", 404, path="/profile", method="GET")
332
+ return found[0]
333
+
334
+ async def find(self, player: PlayerRef, *, fresh: bool = False) -> Optional[Profile]:
335
+ found = await self.many([player], fresh=fresh)
336
+ return found[0] if found else None
337
+
338
+ async def many(self, players: Sequence[PlayerRef], *, fresh: bool = False, skip_missing: bool = True) -> list[Profile]:
339
+ refs = dedupe_refs(players)
340
+ found: dict[str, Profile] = {}
341
+ missing: list[PlayerRef] = []
342
+ for ref in refs:
343
+ hit = None if fresh else await self._client._cache_get(ref_key(ref))
344
+ if hit is not None:
345
+ found[ref_key(ref)] = hit
346
+ else:
347
+ missing.append(ref)
348
+ for group in chunks(missing, self._client.max_players_per_request):
349
+ for ref, profile in await self._load(group, skip_missing):
350
+ found[ref_key(ref)] = profile
351
+ await self._client._cache_set(ref_key(ref), profile, "profile")
352
+ await self._client._cache_set(ref_key(profile.basic_info.user_id), profile, "profile")
353
+ return order_profiles(refs, found)
354
+
355
+ async def by_ids(self, ids: Sequence[int], *, fresh: bool = False) -> dict[int, Profile]:
356
+ return {p.basic_info.user_id: p for p in await self.many(ids, fresh=fresh)}
357
+
358
+ async def by_usernames(self, names: Sequence[str], *, fresh: bool = False) -> dict[str, Profile]:
359
+ by_lower = {p.basic_info.name.lower(): p for p in await self.many(names, fresh=fresh)}
360
+ return {name: by_lower[name.strip().lower()] for name in names if name.strip().lower() in by_lower}
361
+
362
+ async def _load(self, refs: list[PlayerRef], skip_missing: bool) -> list[tuple[PlayerRef, Profile]]:
363
+ ids, names = split_refs(refs)
364
+ try:
365
+ data = await self._client.request("/profile", query={"ids": ids, "usernames": names})
366
+ except CopsApiError as err:
367
+ if not skip_missing or err.code != "user_not_found":
368
+ raise
369
+ if len(refs) <= 1:
370
+ return []
371
+ mid = (len(refs) + 1) // 2
372
+ return [*(await self._load(refs[:mid], True)), *(await self._load(refs[mid:], True))]
373
+ return match_refs(refs, [from_dict(Profile, p) for p in data])
374
+
375
+
376
+ class AsyncServersApi:
377
+ def __init__(self, client: AsyncCopsClient) -> None:
378
+ self._client = client
379
+
380
+ async def list(self, *, fresh: bool = False) -> list[GameServer]:
381
+ async def load() -> list[GameServer]:
382
+ data = await self._client.request("/status/servers")
383
+ servers = []
384
+ for key, value in data.items():
385
+ if not str(key).isdigit():
386
+ continue
387
+ name = str(value.get("name", ""))
388
+ servers.append(GameServer(int(key), name, value.get("status") == "online", region_of(name)))
389
+ return sorted(servers, key=lambda s: s.id)
390
+
391
+ return await self._client._cached("servers", "servers", fresh, load)
392
+
393
+ async def online(self, *, fresh: bool = False) -> builtins.list[GameServer]:
394
+ return [s for s in await self.list(fresh=fresh) if s.online]
395
+
396
+ async def get(self, server_id: int, *, fresh: bool = False) -> Optional[GameServer]:
397
+ return next((s for s in await self.list(fresh=fresh) if s.id == server_id), None)
398
+
399
+ async def pick(self, *, region: Optional[str] = None, name: Optional[str] = None, fallback_to_any: bool = True, fresh: bool = False) -> Optional[GameServer]:
400
+ online = await self.online(fresh=fresh)
401
+ matches = [
402
+ s
403
+ for s in online
404
+ if (region is None or s.region == region) and (name is None or name.lower() in s.name.lower())
405
+ ]
406
+ if matches:
407
+ return random.choice(matches)
408
+ return online[0] if fallback_to_any and online else None
409
+
410
+
411
+ class AsyncMatchesApi:
412
+ def __init__(self, client: AsyncCopsClient) -> None:
413
+ self._client = client
414
+
415
+ async def history(self, user_id: int, *, fresh: bool = False) -> list[MatchHistoryEntry]:
416
+ async def load() -> list[MatchHistoryEntry]:
417
+ data = await self._client.request("/matches/player", query={"user_id": user_id})
418
+ return [from_dict(MatchHistoryEntry, m) for m in data.get("matches", [])]
419
+
420
+ return await self._client._cached(f"history:{user_id}", "history", fresh, load)
421
+
422
+ async def get(self, ids: Sequence[str], *, fresh: bool = False) -> list[MatchInfo]:
423
+ wanted = check_match_ids(ids)
424
+ found: dict[str, MatchInfo] = {}
425
+ missing: list[str] = []
426
+ for mid in wanted:
427
+ hit = None if fresh else await self._client._cache_get(f"match:{mid}")
428
+ if hit is not None:
429
+ found[mid] = hit
430
+ else:
431
+ missing.append(mid)
432
+ for group in chunks(missing, 20):
433
+ data = await self._client.request("/matches/fetch", query={"ids": group})
434
+ for raw in data.get("matches", []):
435
+ match = from_dict(MatchInfo, raw)
436
+ found[match.id.lower()] = match
437
+ await self._client._cache_set(f"match:{match.id.lower()}", match, "match")
438
+ return [found[mid] for mid in wanted if mid in found]
439
+
440
+ async def one(self, match_id: str, *, fresh: bool = False) -> Optional[MatchInfo]:
441
+ found = await self.get([match_id], fresh=fresh)
442
+ return found[0] if found else None
443
+
444
+ async def recent(self, user_id: int, *, limit: int = 5, game_type: Optional[Union[str, int]] = None, fresh: bool = False) -> list[tuple[MatchHistoryEntry, Optional[MatchInfo]]]:
445
+ history = await self.history(user_id, fresh=fresh)
446
+ if game_type is not None:
447
+ code = game_type_code(game_type)
448
+ history = [h for h in history if h.game_type == code]
449
+ history = history[:limit]
450
+ matches = {m.id.lower(): m for m in await self.get([h.id for h in history], fresh=fresh)} if history else {}
451
+ return [(entry, matches.get(entry.id.lower())) for entry in history]
452
+
453
+
454
+ class AsyncStatsApi:
455
+ def __init__(self, client: AsyncCopsClient) -> None:
456
+ self._client = client
457
+
458
+ async def weapons(self, user_id: int, game_type: Union[str, int] = "ranked", *, fresh: bool = False) -> list[WeaponStats]:
459
+ code = game_type_code(game_type)
460
+
461
+ async def load() -> list[WeaponStats]:
462
+ data = await self._client.request("/stats/weapons", query={"user_id": user_id, "game_type": code})
463
+ return [from_dict(WeaponStats, w) for w in data.get("weapons", [])]
464
+
465
+ return await self._client._cached(f"weapons:{user_id}:{code}", "weapons", fresh, load)
466
+
467
+ async def top_weapons(self, user_id: int, *, game_type: Union[str, int] = "ranked", by: str = "k", limit: int = 5, fresh: bool = False) -> list[WeaponStats]:
468
+ if by not in ("k", "a", "h"):
469
+ raise ValueError("by must be 'k', 'a' or 'h'")
470
+ weapons = await self.weapons(user_id, game_type, fresh=fresh)
471
+ return sorted(weapons, key=lambda w: getattr(w, by), reverse=True)[:limit]
472
+
473
+
474
+ class AsyncLeaderboardsApi:
475
+ def __init__(self, client: AsyncCopsClient) -> None:
476
+ self._client = client
477
+
478
+ async def page(self, type: str, *, page: int = 1, rank: Optional[int] = None, fresh: bool = False) -> LeaderboardPage:
479
+ check_rank(rank)
480
+ page = max(1, int(page))
481
+
482
+ async def load() -> LeaderboardPage:
483
+ data = await self._client.request("/leaderboards", query={"type": type, "page": page, "rank": rank})
484
+ size = int(data.get("leaderboard_size", 0))
485
+ entries = [from_dict(LeaderboardEntry, e) for e in data.get("entries", [])]
486
+ return LeaderboardPage(type, page, rank, size, -(-size // 10), entries)
487
+
488
+ return await self._client._cached(f"leaderboard:{type}:{rank}:{page}", "leaderboard", fresh, load)
489
+
490
+ async def iterate(self, type: str, *, rank: Optional[int] = None, start_page: int = 1, max_pages: Optional[int] = None, fresh: bool = False) -> AsyncIterator[LeaderboardEntry]:
491
+ seen: set[int] = set()
492
+ page = start_page
493
+ fetched = 0
494
+ while max_pages is None or fetched < max_pages:
495
+ result = await self.page(type, page=page, rank=rank, fresh=fresh)
496
+ fetched += 1
497
+ if not result.entries:
498
+ return
499
+ for entry in result.entries:
500
+ if entry.basic_info.user_id in seen:
501
+ continue
502
+ seen.add(entry.basic_info.user_id)
503
+ yield entry
504
+ if page >= result.pages:
505
+ return
506
+ page += 1
507
+
508
+ async def top(self, type: str, count: int = 10, *, rank: Optional[int] = None, fresh: bool = False) -> list[LeaderboardEntry]:
509
+ out: list[LeaderboardEntry] = []
510
+ async for entry in self.iterate(type, rank=rank, max_pages=-(-count // 10) + 1, fresh=fresh):
511
+ out.append(entry)
512
+ if len(out) >= count:
513
+ break
514
+ return out
515
+
516
+
517
+ class AsyncAutohostApi:
518
+ def __init__(self, client: AsyncCopsClient) -> None:
519
+ self._client = client
520
+
521
+ async def is_available(self, *, fresh: bool = False) -> bool:
522
+ known = self._client.autohost_permission
523
+ if known is not None and not fresh:
524
+ return known
525
+ try:
526
+ await self._client.request("/autohost/poll", query={"ids": [1]}, priority="high")
527
+ self._client.set_autohost_permission(True)
528
+ except CopsApiError as err:
529
+ if err.code == "insufficient_permissions":
530
+ self._client.set_autohost_permission(False)
531
+ elif err.code in ("game_not_found", "bad_request"):
532
+ self._client.set_autohost_permission(True)
533
+ else:
534
+ raise
535
+ return self._client.autohost_permission is True
536
+
537
+ async def _call(self, path: str, method: str, **kwargs: Any) -> Any:
538
+ if self._client.autohost_permission is False:
539
+ raise make_error("insufficient_permissions", AUTOHOST_DENIED, 403, method=method, path=path, local=True)
540
+ try:
541
+ result = await self._client.request(path, method=method, priority="high", **kwargs)
542
+ except CopsApiError as err:
543
+ if err.code == "insufficient_permissions" and not err.local:
544
+ self._client.set_autohost_permission(False)
545
+ raise make_error("insufficient_permissions", AUTOHOST_DENIED, err.status, method=method, path=path) from err
546
+ raise
547
+ self._client.set_autohost_permission(True)
548
+ return result
549
+
550
+ async def create(self, *, server_id: int, map: Union[int, str], mode: Union[int, str], room_name: str, password: Optional[str] = None, validate: bool = True) -> AutohostGame:
551
+ body = create_body(server_id, map, mode, room_name, password, validate)
552
+ data = await self._call("/autohost/create", "POST", body=body)
553
+ return from_dict(AutohostGame, data["autohost"])
554
+
555
+ async def poll(self, ids: Sequence[int]) -> list[AutohostGame]:
556
+ unique = list(dict.fromkeys(ids))
557
+ games: list[AutohostGame] = []
558
+ for group in chunks(unique, 50):
559
+ data = await self._call("/autohost/poll", "GET", query={"ids": group})
560
+ games.extend(from_dict(AutohostGame, g) for g in data.get("autohosts", []))
561
+ return games
562
+
563
+ async def get(self, game_id: int) -> Optional[AutohostGame]:
564
+ games = await self.poll([game_id])
565
+ return games[0] if games else None
566
+
567
+ async def control(self, game_id: int, command: str, user_id: Optional[int] = None) -> AutohostGame:
568
+ check_command(command, user_id)
569
+ data = await self._call("/autohost/control", "POST", body={"id": game_id, "command": command, "user_id": user_id})
570
+ return from_dict(AutohostGame, data["autohost"])
571
+
572
+ async def kick(self, game_id: int, user_id: int) -> AutohostGame:
573
+ return await self.control(game_id, "kick", user_id)
574
+
575
+ async def restart(self, game_id: int) -> AutohostGame:
576
+ return await self.control(game_id, "restart")
577
+
578
+ async def add_owner(self, game_id: int, user_id: int) -> AutohostGame:
579
+ return await self.control(game_id, "room_owner_add", user_id)
580
+
581
+ async def remove_owner(self, game_id: int, user_id: int) -> AutohostGame:
582
+ return await self.control(game_id, "room_owner_remove", user_id)
583
+
584
+ async def close(self, game_id: int) -> AutohostGame:
585
+ return await self.control(game_id, "close")
586
+
587
+ async def wait_for_room(self, game_id: int, *, timeout: float = 35.0, interval: float = 1.5) -> AutohostGame:
588
+ interval = max(0.5, interval)
589
+ deadline = time.monotonic() + timeout
590
+ while True:
591
+ game = await self.get(game_id)
592
+ if game is not None and game.room.code:
593
+ return game
594
+ if game is not None and game.status == "ended":
595
+ raise make_error("game_rejected", f"The game server refused game {game_id}: {game.rejection_reason or 'no reason given'}.", path="/autohost/poll")
596
+ if time.monotonic() + interval > deadline:
597
+ if game is None:
598
+ raise make_error("game_gone", f"Game {game_id} is gone: the game server never created its room.", path="/autohost/poll")
599
+ raise make_error("timeout", f"Game {game_id} had no room code after {timeout:g} seconds.", path="/autohost/poll")
600
+ await sleep(interval)
601
+
602
+ async def host(
603
+ self,
604
+ *,
605
+ server_id: int,
606
+ map: Union[int, str],
607
+ mode: Union[int, str],
608
+ room_name: str,
609
+ password: Union[str, bool, None] = None,
610
+ owners: Sequence[int] = (),
611
+ validate: bool = True,
612
+ timeout: float = 35.0,
613
+ interval: float = 1.5,
614
+ ) -> HostedRoom:
615
+ if password is False:
616
+ room_password: Optional[str] = None
617
+ elif password is None or password is True:
618
+ room_password = random_password()
619
+ else:
620
+ room_password = str(password)
621
+ created = await self.create(server_id=server_id, map=map, mode=mode, room_name=room_name, password=room_password, validate=validate)
622
+ game = await self.wait_for_room(created.id, timeout=timeout, interval=interval)
623
+ for owner in owners:
624
+ latest = await self.add_owner(created.id, owner)
625
+ if latest.room.code:
626
+ game = latest
627
+ return HostedRoom(created.id, game.room.code or "", room_password, created.server_id, game)
628
+
629
+ def watch(self, *, interval: float = 5.0, missing_grace: float = 60.0, untrack_ended: bool = True) -> AsyncAutohostWatcher:
630
+ return AsyncAutohostWatcher(self._client, interval=interval, missing_grace=missing_grace, untrack_ended=untrack_ended)