gamepulse-sdk 0.1.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.
gamepulse/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """GamePulse Python SDK — public API surface."""
2
+ from __future__ import annotations
3
+
4
+ from contextlib import contextmanager
5
+ from typing import Any
6
+
7
+ from gamepulse.client import GamePulseClient, get_client
8
+ from gamepulse.config import SDKConfig
9
+ from gamepulse.events import economy, gameplay, progression
10
+ from gamepulse.session import Session
11
+ from gamepulse_core.version import __version__
12
+
13
+ __all__ = [
14
+ "GamePulseClient",
15
+ "SDKConfig",
16
+ "Session",
17
+ "__version__",
18
+ "economy",
19
+ "flush",
20
+ "gameplay",
21
+ "identify",
22
+ "init",
23
+ "progression",
24
+ "session",
25
+ "shutdown",
26
+ "track",
27
+ ]
28
+
29
+
30
+ def init(
31
+ api_key: str | None,
32
+ project: str | None = None,
33
+ player_id: str | None = None,
34
+ api_url: str | None = None,
35
+ **kwargs: Any,
36
+ ) -> GamePulseClient:
37
+ """Initialize the global SDK client.
38
+
39
+ When *api_key* is ``None`` the SDK runs in no-op mode (safe for tests).
40
+ When *api_key* is set, *api_url* must also be provided.
41
+ """
42
+ if api_key and not api_url:
43
+ raise ValueError(
44
+ "api_url is required when api_key is set. "
45
+ "Pass the URL of your GamePulse API, e.g. api_url='https://your-server.com'."
46
+ )
47
+ cfg = SDKConfig(
48
+ api_key=api_key,
49
+ project=project,
50
+ player_id=player_id,
51
+ api_url=api_url or "",
52
+ **kwargs,
53
+ )
54
+ return GamePulseClient.initialize(cfg)
55
+
56
+
57
+ def track(event_name: str, **payload: Any) -> None:
58
+ get_client().track(event_name, **payload)
59
+
60
+
61
+ def identify(player_id: str, **attributes: Any) -> None:
62
+ get_client().identify(player_id, **attributes)
63
+
64
+
65
+ def flush(timeout_s: float | None = None) -> None:
66
+ get_client().flush(timeout_s)
67
+
68
+
69
+ def shutdown() -> None:
70
+ get_client().shutdown()
71
+
72
+
73
+ @contextmanager
74
+ def session(**kwargs: Any):
75
+ client = get_client()
76
+ sess = client.start_session(**kwargs)
77
+ try:
78
+ yield sess
79
+ except BaseException:
80
+ client.end_session(end_reason="crash")
81
+ raise
82
+ else:
83
+ client.end_session(end_reason="normal")
gamepulse/client.py ADDED
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import threading
5
+ from collections import defaultdict
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+ from gamepulse_core import (
11
+ BaseEvent,
12
+ BatchEventsRequest,
13
+ CrashIngestRequest,
14
+ EventCategory,
15
+ SessionEndReason,
16
+ SessionEndRequest,
17
+ SessionStartRequest,
18
+ )
19
+ from gamepulse_core.version import __version__ as CORE_VERSION
20
+
21
+ from gamepulse.config import SDKConfig
22
+ from gamepulse.context import build_device_context
23
+ from gamepulse.queue import EventQueue
24
+ from gamepulse.session import Session
25
+ from gamepulse.storage import OfflineStore
26
+ from gamepulse.transport import Transport
27
+ from gamepulse.utils.logging import log
28
+
29
+ # Send outcomes used by the offline-persistence paths.
30
+ _OK = "ok" # 2xx — accepted by the backend
31
+ _RETRY = "retry" # transient (network / 5xx / offline) — persist and retry later
32
+ _DROP = "drop" # permanent (4xx) — give up, never retry
33
+
34
+
35
+ def _chunks(seq: list, size: int):
36
+ size = max(size, 1)
37
+ for i in range(0, len(seq), size):
38
+ yield seq[i : i + size]
39
+
40
+
41
+ def _fire_error(cfg: SDKConfig, description: str, count: int) -> None:
42
+ """Call the user's on_send_error callback if one is registered. Never raises."""
43
+ if cfg.on_send_error is None:
44
+ return
45
+ try:
46
+ cfg.on_send_error(description, count)
47
+ except Exception as exc:
48
+ log.warning("gamepulse: on_send_error callback raised: %s", exc)
49
+
50
+
51
+ class GamePulseClient:
52
+ _instance: GamePulseClient | None = None
53
+ _lock = threading.Lock()
54
+
55
+ def __init__(self, cfg: SDKConfig) -> None:
56
+ self.cfg = cfg
57
+ self.device = build_device_context(cfg.app_version)
58
+ self.transport = Transport(cfg)
59
+ self.queue = EventQueue(cfg, flush_fn=self._flush_batch)
60
+ self.session: Session | None = None
61
+ self._closed = False
62
+
63
+ self.store: OfflineStore | None = None
64
+ if cfg.offline_storage and cfg.api_key:
65
+ try:
66
+ self.store = OfflineStore(
67
+ cfg.offline_storage_path,
68
+ max_events=cfg.max_offline_events,
69
+ max_bytes=cfg.max_offline_bytes,
70
+ )
71
+ # Best-effort: flush anything left over from a previous run.
72
+ self._replay_offline()
73
+ except Exception as e: # never let storage break init
74
+ log.warning("gamepulse: offline storage init failed: %s", e)
75
+ self.store = None
76
+
77
+ atexit.register(self.shutdown)
78
+
79
+ if cfg.debug:
80
+ import logging as _logging
81
+ _handler = _logging.StreamHandler()
82
+ _handler.setFormatter(_logging.Formatter("%(asctime)s [gamepulse] %(levelname)s %(message)s"))
83
+ log.addHandler(_handler)
84
+ log.setLevel(_logging.DEBUG)
85
+
86
+ if cfg.enable_crash_capture and cfg.api_key:
87
+ from gamepulse import crash
88
+ crash.install(self)
89
+
90
+ # ---- lifecycle ----
91
+
92
+ @classmethod
93
+ def initialize(cls, cfg: SDKConfig) -> GamePulseClient:
94
+ with cls._lock:
95
+ if cls._instance is not None:
96
+ cls._instance.shutdown()
97
+ cls._instance = cls(cfg)
98
+ return cls._instance
99
+
100
+ def shutdown(self) -> None:
101
+ if self._closed:
102
+ return
103
+ self._closed = True
104
+ try:
105
+ if self.session and self.session.id and not self.session.ended_at:
106
+ self.end_session(end_reason="timeout")
107
+ self.queue.shutdown()
108
+ finally:
109
+ self.transport.close()
110
+
111
+ # ---- session ----
112
+
113
+ def start_session(self, **_: Any) -> Session:
114
+ now = datetime.now(UTC)
115
+ if not self.cfg.player_id:
116
+ log.debug("gamepulse: start_session without player_id")
117
+ self.session = Session.new_local()
118
+ return self.session
119
+
120
+ req = SessionStartRequest(
121
+ player_external_id=self.cfg.player_id,
122
+ started_at=now,
123
+ device=self.device,
124
+ app_version=self.cfg.app_version,
125
+ )
126
+ resp = self.transport.post("/v1/sessions/start", req.model_dump(mode="json"))
127
+ if resp is None or resp.status_code >= 300:
128
+ status_info = f"HTTP {resp.status_code}" if resp is not None else "network error"
129
+ log.warning("gamepulse: start_session failed (%s) — using local session", status_info)
130
+ _fire_error(self.cfg, status_info, 0)
131
+ self.session = Session.new_local()
132
+ return self.session
133
+ data = resp.json()
134
+ self.session = Session(
135
+ id=UUID(data["session_id"]),
136
+ player_id=UUID(data["player_id"]),
137
+ started_at=now,
138
+ )
139
+ return self.session
140
+
141
+ def end_session(self, end_reason: str = "normal") -> None:
142
+ if not self.session or not self.session.id:
143
+ self.session = None
144
+ return
145
+ now = datetime.now(UTC)
146
+ try:
147
+ reason = SessionEndReason(end_reason)
148
+ except ValueError:
149
+ reason = SessionEndReason.NORMAL
150
+ req = SessionEndRequest(session_id=self.session.id, ended_at=now, end_reason=reason)
151
+ resp = self.transport.post("/v1/sessions/end", req.model_dump(mode="json"))
152
+ if resp is not None and resp.status_code >= 300:
153
+ log.warning("gamepulse: end_session -> HTTP %d", resp.status_code)
154
+ _fire_error(self.cfg, f"HTTP {resp.status_code}", 0)
155
+ self.session.ended_at = now
156
+ self.session.end_reason = end_reason
157
+ self.session = None
158
+
159
+ # ---- events ----
160
+
161
+ def track(self, event_name: str, **payload: Any) -> None:
162
+ try:
163
+ category, name = (
164
+ event_name.split(".", 1) if "." in event_name else ("custom", event_name)
165
+ )
166
+ try:
167
+ category_enum = EventCategory(category)
168
+ except ValueError:
169
+ category_enum = EventCategory.CUSTOM
170
+ ev = BaseEvent(
171
+ type=f"{category_enum.value}.{name}",
172
+ category=category_enum,
173
+ name=name,
174
+ session_id=self.session.id if self.session else None,
175
+ player_id=self.cfg.player_id,
176
+ payload=payload,
177
+ sdk_version=CORE_VERSION,
178
+ )
179
+ self.queue.put(ev)
180
+ except Exception as e:
181
+ log.warning("gamepulse: track failed: %s", e)
182
+
183
+ def identify(self, player_id: str, **attributes: Any) -> None:
184
+ self.cfg.player_id = player_id
185
+ if not self.cfg.api_key:
186
+ return
187
+ log.debug("gamepulse: identify player_id=%s attrs=%s", player_id, list(attributes.keys()))
188
+ resp = self.transport.post(
189
+ "/v1/players/identify",
190
+ {"external_id": player_id, "attributes": attributes},
191
+ )
192
+ if resp is None:
193
+ log.warning("gamepulse: identify failed — no response (network error or retries exhausted)")
194
+ _fire_error(self.cfg, "network error", 0)
195
+ elif resp.status_code >= 400:
196
+ log.warning("gamepulse: identify -> HTTP %d", resp.status_code)
197
+ _fire_error(self.cfg, f"HTTP {resp.status_code}", 0)
198
+ else:
199
+ log.debug("gamepulse: identify -> HTTP %d", resp.status_code)
200
+
201
+ def flush(self, timeout_s: float | None = None) -> None:
202
+ self.queue.flush(timeout_s)
203
+
204
+ # ---- internal ----
205
+
206
+ def _post_outcome(self, path: str, payload: dict[str, Any]) -> tuple[str, int | None]:
207
+ """Send one request and classify the result.
208
+
209
+ Returns ``(outcome, http_status_or_None)`` where outcome is one of:
210
+ ``_OK`` (2xx accepted), ``_DROP`` (4xx permanent client error — never
211
+ retry), or ``_RETRY`` (network error, 5xx — worth persisting and retrying
212
+ later). ``http_status_or_None`` is None when all retries were exhausted
213
+ without a response (pure network failure).
214
+ """
215
+ resp = self.transport.post(path, payload)
216
+ if resp is None:
217
+ return _RETRY, None
218
+ if resp.status_code < 300:
219
+ return _OK, resp.status_code
220
+ if 400 <= resp.status_code < 500:
221
+ return _DROP, resp.status_code
222
+ return _RETRY, resp.status_code
223
+
224
+ def _send_events(self, player_external_id: str, events: list[BaseEvent]) -> tuple[str, int | None]:
225
+ req = BatchEventsRequest(
226
+ player_external_id=player_external_id,
227
+ events=events,
228
+ device=self.device,
229
+ )
230
+ return self._post_outcome("/v1/events/batch", req.model_dump(mode="json"))
231
+
232
+ def _send_crash(self, data: dict[str, Any]) -> tuple[str, int | None]:
233
+ return self._post_outcome("/v1/crashes", data)
234
+
235
+ def _flush_batch(self, events: list[BaseEvent]) -> None:
236
+ if not self.cfg.api_key or not self.cfg.player_id:
237
+ return
238
+ outcome, status = self._send_events(self.cfg.player_id, events)
239
+ if outcome == _RETRY and self.store is not None:
240
+ self._persist_events(events)
241
+ elif outcome == _DROP:
242
+ log.warning("gamepulse: batch rejected (4xx) — dropping %d event(s)", len(events))
243
+ if outcome != _OK:
244
+ _fire_error(self.cfg, f"HTTP {status}" if status else "network error", len(events))
245
+
246
+ def _persist_events(self, events: list[BaseEvent]) -> None:
247
+ try:
248
+ self.store.append_events( # type: ignore[union-attr]
249
+ [(str(ev.event_id), ev.model_dump(mode="json")) for ev in events]
250
+ )
251
+ except Exception as e:
252
+ log.warning("gamepulse: failed to persist events offline: %s", e)
253
+
254
+ def _report_crash(
255
+ self,
256
+ *,
257
+ fingerprint: str,
258
+ exc_type: str,
259
+ message: str | None,
260
+ stacktrace: str,
261
+ occurred_at: datetime,
262
+ ) -> None:
263
+ if not self.cfg.api_key or not self.cfg.player_id:
264
+ return
265
+ req = CrashIngestRequest(
266
+ player_external_id=self.cfg.player_id,
267
+ session_id=self.session.id if self.session else None,
268
+ fingerprint=fingerprint,
269
+ exc_type=exc_type,
270
+ message=message,
271
+ stacktrace=stacktrace,
272
+ occurred_at=occurred_at,
273
+ context={"app_version": self.cfg.app_version},
274
+ )
275
+ data = req.model_dump(mode="json")
276
+ outcome, status = self._send_crash(data)
277
+ if outcome == _RETRY and self.store is not None:
278
+ # Crash reports are high-value — persist so they survive the process
279
+ # ending while offline. The id is stable across retries, so re-reading
280
+ # the same crash next launch maps to one store entry.
281
+ cid = f"{fingerprint}:{occurred_at.isoformat()}"
282
+ try:
283
+ self.store.append_crashes([(cid, data)])
284
+ except Exception as e:
285
+ log.warning("gamepulse: failed to persist crash offline: %s", e)
286
+ if outcome != _OK:
287
+ _fire_error(self.cfg, f"HTTP {status}" if status else "network error", 0)
288
+
289
+ # ---- offline replay ----
290
+
291
+ def _replay_offline(self) -> None:
292
+ """Best-effort: re-send anything left on disk from a previous run."""
293
+ try:
294
+ self._replay_events()
295
+ except Exception as e:
296
+ log.warning("gamepulse: offline event replay failed: %s", e)
297
+ try:
298
+ self._replay_crashes()
299
+ except Exception as e:
300
+ log.warning("gamepulse: offline crash replay failed: %s", e)
301
+
302
+ def _replay_events(self) -> None:
303
+ assert self.store is not None
304
+ pending = self.store.load_events()
305
+ if not pending or not self.cfg.api_key:
306
+ return
307
+
308
+ resolved: set[str] = set() # ids to remove (sent, or permanently unsendable)
309
+ by_player: dict[str, list[tuple[str, dict]]] = defaultdict(list)
310
+ for rec_id, data in pending:
311
+ pid = data.get("player_id") or self.cfg.player_id
312
+ if pid:
313
+ by_player[pid].append((rec_id, data))
314
+ else:
315
+ resolved.add(rec_id) # no player → can never send; drop it
316
+
317
+ for pid, items in by_player.items():
318
+ stop = False
319
+ for chunk in _chunks(items, self.cfg.batch_size):
320
+ events: list[BaseEvent] = []
321
+ chunk_ids: list[str] = []
322
+ for rec_id, data in chunk:
323
+ try:
324
+ events.append(BaseEvent.model_validate(data))
325
+ chunk_ids.append(rec_id)
326
+ except Exception:
327
+ resolved.add(rec_id) # corrupt record → drop, don't retry forever
328
+ if not events:
329
+ continue
330
+ outcome, _ = self._send_events(pid, events)
331
+ if outcome == _OK:
332
+ resolved.update(chunk_ids)
333
+ elif outcome == _DROP:
334
+ resolved.update(chunk_ids) # permanent — drop so it can't block the queue
335
+ log.warning("gamepulse: dropping %d unsendable offline event(s)", len(chunk_ids))
336
+ else: # _RETRY — still offline, leave the rest for next launch
337
+ stop = True
338
+ break
339
+ if stop:
340
+ break
341
+
342
+ if resolved:
343
+ self.store.remove_events(resolved)
344
+
345
+ def _replay_crashes(self) -> None:
346
+ assert self.store is not None
347
+ pending = self.store.load_crashes()
348
+ if not pending or not self.cfg.api_key:
349
+ return
350
+ done: set[str] = set()
351
+ for rec_id, data in pending:
352
+ outcome, _ = self._send_crash(data)
353
+ if outcome in (_OK, _DROP):
354
+ done.add(rec_id) # sent, or permanently rejected — stop tracking it
355
+ else: # _RETRY — still offline, retry the rest next launch
356
+ break
357
+ if done:
358
+ self.store.remove_crashes(done)
359
+
360
+
361
+ def get_client() -> GamePulseClient:
362
+ if GamePulseClient._instance is None:
363
+ GamePulseClient.initialize(SDKConfig(api_key=None))
364
+ assert GamePulseClient._instance is not None
365
+ return GamePulseClient._instance
gamepulse/config.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from gamepulse_core.constants import DEFAULT_BATCH_SIZE, DEFAULT_FLUSH_INTERVAL_S
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class SDKConfig:
11
+ api_key: str | None
12
+ project: str | None = None
13
+ player_id: str | None = None
14
+ api_url: str = ""
15
+
16
+ # transport
17
+ timeout_s: float = 10.0
18
+ max_retries: int = 3
19
+ backoff_base_s: float = 0.5
20
+
21
+ # queue
22
+ batch_size: int = DEFAULT_BATCH_SIZE
23
+ flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S
24
+ max_queue_size: int = 10_000
25
+
26
+ # behavior
27
+ enable_crash_capture: bool = True
28
+ auto_session: bool = True
29
+ app_version: str | None = None
30
+ debug: bool = False
31
+
32
+ # offline persistence — survive restarts / API downtime
33
+ offline_storage: bool = False
34
+ offline_storage_path: str | None = None
35
+ max_offline_events: int = 10_000
36
+ max_offline_bytes: int = 5 * 1024 * 1024 # 5 MB
37
+
38
+ # error visibility — called on the sending thread when a request fails.
39
+ # signature: on_send_error(description: str, event_count: int)
40
+ # description is e.g. "HTTP 401", "HTTP 500", or "network error"
41
+ # event_count is 0 for non-batch calls (identify, crash)
42
+ on_send_error: Any = field(default=None, compare=False)
gamepulse/context.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import locale
4
+ import platform
5
+ import sys
6
+
7
+ from gamepulse_core import Platform
8
+ from gamepulse_core.schemas import DeviceContext
9
+
10
+
11
+ def _detect_platform() -> Platform:
12
+ system = platform.system().lower()
13
+ return {
14
+ "windows": Platform.WINDOWS,
15
+ "darwin": Platform.MACOS,
16
+ "linux": Platform.LINUX,
17
+ }.get(system, Platform.UNKNOWN)
18
+
19
+
20
+ def build_device_context(app_version: str | None) -> DeviceContext:
21
+ try:
22
+ loc = locale.getlocale()[0]
23
+ except Exception:
24
+ loc = None
25
+ return DeviceContext(
26
+ platform=_detect_platform(),
27
+ os_version=platform.release(),
28
+ locale=loc,
29
+ python_version=sys.version.split()[0],
30
+ app_version=app_version,
31
+ )
gamepulse/crash.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import sys
5
+ import threading
6
+ import traceback
7
+ from datetime import UTC, datetime
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from gamepulse.client import GamePulseClient
12
+
13
+
14
+ def _fingerprint(exc_type: type[BaseException], tb: str) -> str:
15
+ h = hashlib.sha1()
16
+ h.update(exc_type.__name__.encode())
17
+ for line in tb.splitlines():
18
+ s = line.strip()
19
+ if s.startswith("File ") or s.startswith("in "):
20
+ h.update(s.encode())
21
+ return h.hexdigest()
22
+
23
+
24
+ def install(client: GamePulseClient) -> None:
25
+ prev_excepthook = sys.excepthook
26
+ prev_thread_hook = getattr(threading, "excepthook", None)
27
+
28
+ def _send(exc_type, exc, tb):
29
+ try:
30
+ stack = "".join(traceback.format_exception(exc_type, exc, tb))
31
+ client._report_crash( # noqa: SLF001
32
+ fingerprint=_fingerprint(exc_type, stack),
33
+ exc_type=exc_type.__name__,
34
+ message=str(exc) if exc else None,
35
+ stacktrace=stack,
36
+ occurred_at=datetime.now(UTC),
37
+ )
38
+ except Exception:
39
+ pass
40
+
41
+ def _hook(exc_type, exc, tb):
42
+ _send(exc_type, exc, tb)
43
+ prev_excepthook(exc_type, exc, tb)
44
+
45
+ def _thread_hook(args):
46
+ _send(args.exc_type, args.exc_value, args.exc_traceback)
47
+ if prev_thread_hook:
48
+ prev_thread_hook(args)
49
+
50
+ sys.excepthook = _hook
51
+ if hasattr(threading, "excepthook"):
52
+ threading.excepthook = _thread_hook
@@ -0,0 +1,3 @@
1
+ from gamepulse.events import custom, economy, gameplay, progression
2
+
3
+ __all__ = ["custom", "economy", "gameplay", "progression"]
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from gamepulse.client import get_client
6
+
7
+
8
+ def emit(name: str, **payload: Any) -> None:
9
+ get_client().track(f"custom.{name}", **payload)
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from gamepulse.client import get_client
6
+
7
+
8
+ def earn(currency: str, amount: float, source: str | None = None, **extra: Any) -> None:
9
+ get_client().track(
10
+ "economy.currency_earn", currency=currency, amount=amount, source=source, **extra
11
+ )
12
+
13
+
14
+ def spend(currency: str, amount: float, item: str | None = None, **extra: Any) -> None:
15
+ get_client().track(
16
+ "economy.currency_spend", currency=currency, amount=amount, item=item, **extra
17
+ )
18
+
19
+
20
+ def purchase(sku: str, price: float, currency: str = "USD", **extra: Any) -> None:
21
+ get_client().track(
22
+ "economy.iap", sku=sku, price=price, currency=currency, **extra
23
+ )
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from gamepulse.client import get_client
6
+
7
+
8
+ def action(name: str, **extra: Any) -> None:
9
+ get_client().track("gameplay.action", action=name, **extra)
10
+
11
+
12
+ def ability(name: str, **extra: Any) -> None:
13
+ get_client().track("gameplay.ability_used", ability=name, **extra)
14
+
15
+
16
+ def score(value: float, **extra: Any) -> None:
17
+ get_client().track("gameplay.score", value=value, **extra)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from gamepulse.client import get_client
6
+
7
+
8
+ def start(level: int | str, **extra: Any) -> None:
9
+ get_client().track("progression.level_start", level=level, **extra)
10
+
11
+
12
+ def complete(level: int | str, stars: int | None = None, **extra: Any) -> None:
13
+ get_client().track("progression.level_complete", level=level, stars=stars, **extra)
14
+
15
+
16
+ def fail(level: int | str, reason: str | None = None, **extra: Any) -> None:
17
+ get_client().track("progression.level_fail", level=level, reason=reason, **extra)
gamepulse/py.typed ADDED
File without changes