ravensight-playtest 0.5.2 → 0.7.0

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.
Files changed (99) hide show
  1. package/README.md +319 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +120 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +233 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +647 -70
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/lists.js +206 -0
  24. package/src/commands/profile.js +25 -6
  25. package/src/commands/punch.js +116 -0
  26. package/src/commands/replay.js +204 -0
  27. package/src/commands/resume.js +7 -0
  28. package/src/commands/run.js +200 -24
  29. package/src/commands/tape.js +74 -0
  30. package/src/commands/template.js +70 -0
  31. package/src/commands/validate.js +414 -0
  32. package/src/commands/validateHosted.js +292 -0
  33. package/src/create/scaffold.js +192 -0
  34. package/src/run/acceptance.js +212 -0
  35. package/src/run/args.js +18 -1
  36. package/src/run/checks.js +705 -0
  37. package/src/run/dimension.js +95 -0
  38. package/src/run/drivers/godot-observation.js +59 -11
  39. package/src/run/drivers/godot-project.js +201 -31
  40. package/src/run/drivers/godot.js +532 -45
  41. package/src/run/drivers/web.js +18 -0
  42. package/src/run/evidence.js +170 -0
  43. package/src/run/featureLoop.js +288 -0
  44. package/src/run/fixLoop.js +82 -12
  45. package/src/run/git.js +110 -20
  46. package/src/run/json.js +65 -9
  47. package/src/run/labsOutcome.js +136 -0
  48. package/src/run/liveWork.js +103 -0
  49. package/src/run/model.js +105 -5
  50. package/src/run/openPr.js +107 -0
  51. package/src/run/paths.js +4 -1
  52. package/src/run/pendingRegister.js +81 -0
  53. package/src/run/personaLoop.js +111 -4
  54. package/src/run/profile.js +68 -7
  55. package/src/run/replay.js +236 -0
  56. package/src/run/repoTools.js +227 -27
  57. package/src/run/report.js +7 -1
  58. package/src/run/resume.js +19 -1
  59. package/src/run/secretScan.js +35 -0
  60. package/src/run/settle.js +41 -0
  61. package/src/run/tools.js +33 -4
  62. package/src/run/trace.js +124 -0
  63. package/src/simulation/godotPack.js +112 -0
  64. package/src/ui/index.js +61 -9
  65. package/src/upload/allowlist.js +6 -2
  66. package/src/upload/index.js +5 -4
  67. package/templates/godot-2d/README.md +36 -0
  68. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  69. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  70. package/templates/godot-2d/export_presets.cfg +27 -0
  71. package/templates/godot-2d/gitignore +10 -0
  72. package/templates/godot-2d/project.godot +58 -0
  73. package/templates/godot-2d/scenes/main.tscn +67 -0
  74. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  75. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  76. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  77. package/templates/godot-2d/scripts/main.gd +32 -0
  78. package/templates/godot-2d/scripts/player.gd +10 -0
  79. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  80. package/templates/godot-2d/template.json +14 -0
  81. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  82. package/templates/godot-2d/tools/selftest.gd +70 -0
  83. package/templates/godot-3d/README.md +49 -0
  84. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  85. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  86. package/templates/godot-3d/export_presets.cfg +27 -0
  87. package/templates/godot-3d/gitignore +10 -0
  88. package/templates/godot-3d/project.godot +56 -0
  89. package/templates/godot-3d/scenes/main.tscn +154 -0
  90. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  91. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  92. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  93. package/templates/godot-3d/scripts/beacon.gd +20 -0
  94. package/templates/godot-3d/scripts/main.gd +49 -0
  95. package/templates/godot-3d/scripts/player.gd +19 -0
  96. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  97. package/templates/godot-3d/template.json +16 -0
  98. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  99. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -0,0 +1,1823 @@
1
+ # Ravensight.gd: Ravensight SDK for Godot 4
2
+ # Global autoload script for tracking game events against the hosted
3
+ # Ravensight API (/api/v1).
4
+ #
5
+ # Setup: Project -> Project Settings -> Autoload -> add this script,
6
+ # name it "Ravensight", enable it. Then set `ingest_key` (and `api_url` if
7
+ # you do not use https://api.ravensight.io) before the autoload's _ready()
8
+ # runs, e.g. from an earlier autoload, by editing the defaults below, or
9
+ # through the project setting ravensight/ingest_key. With no key the SDK is
10
+ # inert: no files, no requests, every call a no-op.
11
+ #
12
+ # Full guide: https://ravensight.io/docs/#godot-sdk
13
+
14
+ extends Node
15
+
16
+ ## The SDK version. Bumped with every tagged release.
17
+ const VERSION := "0.3.1"
18
+
19
+ ## --- Signals -------------------------------------------------------------
20
+
21
+ ## Emitted once a session token has been issued and is ready to use.
22
+ signal config_ready(source: String)
23
+
24
+ signal session_ready
25
+ ## Emitted when opening a session fails. `reason` is "invalid_key" when the
26
+ ## ingest key was refused, otherwise the server's error code (or "offline").
27
+ signal session_failed(reason: String)
28
+ ## Emitted when tracking turns off: source "server" for the kill switch,
29
+ ## "player" for the player's opt-out (set_tracking_enabled(false)) or, with
30
+ ## consent = "opt_in", for a start with no yes saved yet, "init" when the
31
+ ## game turned the SDK off (enabled = false or set_enabled(false)), "key"
32
+ ## when the server refused the ingest key and the run ended.
33
+ signal tracking_disabled(source: String)
34
+ ## Emitted whenever get_status_reason() changes. `reason` is one of
35
+ ## "no_key", "disabled", "invalid_key", "tracking_disabled", "offline",
36
+ ## "rate_limited", "session_failed", "playtest_job_closed", or "" when
37
+ ## nothing is wrong.
38
+ signal status_changed(reason: String)
39
+ ## Emitted after a batch of events is delivered (the server answered 2xx).
40
+ ## `count` is the number sent; the server may store fewer, e.g. it drops
41
+ ## events whose timestamp is older than 90 days.
42
+ signal events_flushed(count: int)
43
+ ## Emitted when a batch flush attempt fails (it is retried automatically,
44
+ ## except a single event the server refuses with 400, which is dropped).
45
+ ## Also emitted with "event_too_large" for an event whose data is over
46
+ ## MAX_EVENT_BYTES as JSON, which is dropped before it is queued.
47
+ signal flush_failed(reason: String)
48
+ ## Emitted when a feedback submission is accepted.
49
+ signal feedback_submitted
50
+ ## Emitted when a feedback submission fails, locally ("disabled",
51
+ ## "empty_message", "invalid_category", "invalid_rating", "no_session") or
52
+ ## on the server (its error code).
53
+ signal feedback_failed(reason: String)
54
+ ## EXPERIMENTAL: emitted with the result of fetch_suggestions().
55
+ ## Suggestions are AI-generated design hints and may change shape over time.
56
+ signal suggestions_received(suggestions: Array)
57
+ ## Emitted every time an event is queued, with the data actually queued
58
+ ## (playtest tags included, in a playtest run). Lets something else in the
59
+ ## running game mirror tracked events, e.g. a playtest driver watching
60
+ ## gameplay from outside the process.
61
+ signal event_tracked(event_name: String, data: Dictionary)
62
+
63
+ ## --- Configuration ---------------------------------------------------------
64
+
65
+ ## The Ravensight API host. With or without a trailing "/api/v1" and with or
66
+ ## without a trailing slash: see api_base(). Empty means the default.
67
+ @export var api_url: String = "https://api.ravensight.io"
68
+
69
+ ## Your game's PUBLISHABLE ingest key (gt_live_... or, for a build anyone
70
+ ## can download, a public gt_pub_... key), copied from the Ravensight
71
+ ## dashboard. This key is safe to ship inside your game binary: it can open
72
+ ## sessions, read the tracking kill switch and read this game's design
73
+ ## suggestions. It can NOT read analytics, feedback, or manage your account.
74
+ ## Rotate it from the dashboard if it ever needs to change.
75
+ ## Empty (the default) falls back to the project setting
76
+ ## ravensight/ingest_key; empty there too, the SDK is inert for the run: it
77
+ ## writes no files, makes no request and every call is a quiet no-op.
78
+ @export var ingest_key: String = ""
79
+
80
+ ## True silences the one warning an inert start (no key) prints.
81
+ @export var optional: bool = false
82
+
83
+ ## "opt_out" (the default): tracking runs until the player turns it off.
84
+ ## "opt_in": nothing happens before the player's yes through
85
+ ## set_tracking_enabled(true): no device id, no settings check, no session,
86
+ ## and events tracked before the yes are dropped. The yes is saved at
87
+ ## OPTIN_PATH. Anything else counts as "opt_out".
88
+ @export var consent: String = "opt_out"
89
+
90
+ ## A short label of your own for this build, such as "native-release",
91
+ ## "web-itch" or "native-demo", sent on the session as `distribution`. At
92
+ ## most 40 characters (UTF-16 units, as the server counts them), no control
93
+ ## characters; anything else is not sent (one warning). Empty sends nothing.
94
+ @export var distribution: String = ""
95
+
96
+ ## Reported to the server as the client's game version.
97
+ @export var game_version: String = "1.0.0"
98
+
99
+ ## False turns the SDK off for this run: no request of any kind, nothing
100
+ ## queued, no game_started, until set_enabled(true). Never saved. For a
101
+ ## player's own choice use set_tracking_enabled(), which is saved.
102
+ @export var enabled: bool = true
103
+
104
+ ## Maximum number of not-yet-sent events kept in memory while offline or
105
+ ## between flushes. Oldest events are dropped first once this is exceeded
106
+ ## (a queued game_started is kept).
107
+ @export var max_queue_size: int = 500
108
+
109
+ ## Seconds an event may wait so it can go out with others. 50 waiting
110
+ ## events are sent at once without waiting.
111
+ @export var flush_interval: float = 5.0
112
+
113
+ ## On Android and iOS: how long the app may sit in the background before
114
+ ## the session counts as over (game_exited at the moment it went to the
115
+ ## background, then a new session with game_started on return).
116
+ @export var session_timeout: float = 1800.0
117
+
118
+ ## Opt-in per-session performance sampling. When true the SDK samples the
119
+ ## frame time once per second, and sends one `health` event per session
120
+ ## (frames per second median and 10th percentile, memory where the platform
121
+ ## exposes it, boot time) when the app goes to the background (mobile), the
122
+ ## page is hidden (web) or the game quits (desktop). Off by default.
123
+ @export var performance: bool = false
124
+
125
+ ## --- Constants --------------------------------------------------------------
126
+
127
+ const MAX_BATCH_SIZE: int = 50 # server hard limit on /track/batch
128
+ const DEFAULT_RETRY_SECONDS: float = 10.0
129
+ const MAX_BACKOFF_SECONDS: float = 300.0 # cap exponential backoff at 5 min
130
+ ## Backoff waits are spread by plus or minus this fraction, so many games
131
+ ## that failed together do not retry together. Retry-After is never spread.
132
+ const JITTER: float = 0.2
133
+ ## The largest event the API accepts, measured as the server measures it:
134
+ ## the JSON of the event's data. Bigger events are dropped before queueing.
135
+ const MAX_EVENT_BYTES: int = 8192
136
+ const MAX_DISTRIBUTION_LENGTH: int = 40
137
+ const CONSENT_OPT_OUT := "opt_out"
138
+ const CONSENT_OPT_IN := "opt_in"
139
+ ## Every request gives up after this long and counts as offline.
140
+ const REQUEST_TIMEOUT_SECONDS: float = 15.0
141
+ ## A batch 401 reopens the session at most this many times in a row.
142
+ const MAX_REAUTH_ATTEMPTS: int = 2
143
+ ## The categories the feedback endpoint accepts. Anything else is refused
144
+ ## before it is sent.
145
+ const FEEDBACK_CATEGORIES: Array[String] = ["bug", "suggestion", "complaint", "praise", "playtest", "other"]
146
+ ## Where this install's random device id lives (see _load_or_create_device_id).
147
+ const HEALTH_RESERVOIR_SIZE: int = 600
148
+ const HEALTH_SAMPLE_SECONDS: float = 1.0
149
+ const MAX_ERROR_MESSAGE_CHARS: int = 200
150
+ const MAX_ERROR_STACK_CHARS: int = 2000
151
+
152
+ const DEVICE_ID_PATH: String = "user://device_id.save"
153
+ ## Present only while the player has opted out (see set_tracking_enabled).
154
+ const OPTOUT_PATH: String = "user://ravensight_optout.save"
155
+ ## With consent = "opt_in": present only once the player has said yes.
156
+ const OPTIN_PATH: String = "user://ravensight_optin.save"
157
+ ## Present only while the app is in the background on Android or iOS.
158
+ const BACKGROUND_MARKER_PATH: String = "user://ravensight_bg.save"
159
+ const INVALID_KEY_MESSAGE := "Ravensight: ingest key refused (invalid_key), sending stopped for this run. Check your gt_live_ key."
160
+
161
+ ## --- State -------------------------------------------------------------------
162
+
163
+ var device_id: String = ""
164
+ var _config_document: Dictionary = {}
165
+ var _config_scope: String = ""
166
+ var _config_pending: bool = false
167
+ var _config_generation: int = 0
168
+ const CONFIG_PATH := "user://ravensight_config.json"
169
+
170
+
171
+ ## The open session's token, or "" until one opens. Read it if your game
172
+ ## server should join the same session (send it as `joinToken`, see the
173
+ ## HTTP API). Never write it.
174
+ var session_token: String = ""
175
+ var session_expires_at: int = 0
176
+ var _session_request_pending: bool = false
177
+ ## Bumped every time a session is requested or dropped. Bound into each
178
+ ## session request's callback so a stale response (from a request
179
+ ## superseded by a newer one, e.g. set_playtest_context() switching identity
180
+ ## mid-flight) is ignored instead of clobbering session state.
181
+ var _session_request_generation: int = 0
182
+
183
+ ## Read-only: true while events are collected and sent. False when the game
184
+ ## turned the SDK off (enabled), the player opted out, the server kill
185
+ ## switch is off, or a playtest run was refused.
186
+ var tracking_enabled: bool:
187
+ get:
188
+ return _is_active()
189
+ set(_value):
190
+ push_warning("Ravensight: tracking_enabled is read-only, use set_tracking_enabled() or set_enabled()")
191
+
192
+ ## The player's persisted opt-out, read from OPTOUT_PATH when the SDK is
193
+ ## constructed, before anything can queue or send an event.
194
+ var _player_opted_out: bool = false
195
+ ## The server kill switch said off (settings check or a 403
196
+ ## tracking_disabled). Final for the rest of the run.
197
+ var _killed: bool = false
198
+ ## The server refused the ingest key (401 or 403 that is not the kill
199
+ ## switch or a playtest refusal). Final for the rest of the run.
200
+ var _key_refused: bool = false
201
+ ## Started with no ingest key: inert for the rest of the run.
202
+ var _no_key: bool = false
203
+ ## The distribution label actually sent, after validation.
204
+ var _distribution: String = ""
205
+ ## rs_event_id parts: a per-launch nonce from the secure random source and
206
+ ## a counter.
207
+ var _event_nonce: String = ""
208
+ var _event_counter: int = 0
209
+ var _settings_checked: bool = false
210
+ var _settings_pending: bool = false
211
+ ## Set at the end of _ready(). Before that, track_event() only queues.
212
+ var _is_ready: bool = false
213
+
214
+ var pending_events: Array = []
215
+ var _flush_in_progress: bool = false
216
+ ## Bumped every time the queue is discarded (opt-out, reset_device_id(), the
217
+ ## kill switch). Bound into each batch request's callback so a response for
218
+ ## a batch taken from the old queue is ignored, instead of slicing that
219
+ ## many events off the front of the new one.
220
+ var _queue_generation: int = 0
221
+ ## When a 400 forced a split, flushes use this reduced batch size until the
222
+ ## poison event is isolated and dropped (0 = no split active).
223
+ var _split_batch_size: int = 0
224
+ ## When above 0, the first this-many queued events belong to the current
225
+ ## session and the session is dropped once they are delivered (the mobile
226
+ ## background rule: game_exited goes out under the old session, then a new
227
+ ## one opens for game_started).
228
+ var _session_boundary: int = 0
229
+ ## _now() when the oldest unsent event was queued, or -1.
230
+ var _first_queued_at: float = -1.0
231
+ var _reauth_attempts: int = 0
232
+
233
+ var _backoff_seconds: float = DEFAULT_RETRY_SECONDS
234
+ ## _now() at which the retry wait ends, or -1 when none is running.
235
+ var _retry_at: float = -1.0
236
+ ## True when the running wait came from the server (Retry-After), which an
237
+ ## explicit flush() does not cut short.
238
+ var _retry_from_server: bool = false
239
+
240
+ var _started_queued: bool = false
241
+ var _exit_queued: bool = false
242
+ ## Unix time the SDK was constructed: game_started's timestamp.
243
+ var _start_unix: int = 0
244
+ ## Unix time the app went to the background on Android or iOS, or 0.
245
+ var _backgrounded_at: int = 0
246
+
247
+ var _status_reason: String = ""
248
+ ## The latest transient failure: "", "offline", "rate_limited" or
249
+ ## "session_failed". get_status_reason() reports it when nothing worse holds.
250
+ var _transient_reason: String = ""
251
+ var _logged_once: Dictionary = {}
252
+
253
+ ## --- Quit handling --------------------------------------------------------------
254
+
255
+ ## When true (the default), the SDK takes over the window close so the last
256
+ ## events, including game_exited, can be sent: it sets the SceneTree's
257
+ ## auto_accept_quit to false in _ready(), and on a close request it flushes,
258
+ ## then quits once the flush is answered or after
259
+ ## QUIT_FLUSH_TIMEOUT_SECONDS, whichever comes first. The Android back
260
+ ## button is handled the same way when the project's
261
+ ## application/config/quit_on_go_back setting is on. Set this to false
262
+ ## before the autoload is ready if your game manages the window close
263
+ ## itself, e.g. with a confirm dialog; the SDK then only flushes on a close
264
+ ## request, and your game quits with Ravensight.quit(), which sends
265
+ ## game_exited.
266
+ ## It is also treated as false when auto_accept_quit is already off at
267
+ ## _ready().
268
+ @export var handle_quit: bool = true
269
+
270
+ ## Longest the SDK delays a quit while it waits for the final flush.
271
+ const QUIT_FLUSH_TIMEOUT_SECONDS: float = 1.5
272
+
273
+ ## True when the SDK owns the window close (see handle_quit).
274
+ var _owns_close_quit: bool = false
275
+ ## True when the SDK owns the Android back button quit (see handle_quit).
276
+ var _owns_go_back_quit: bool = false
277
+ ## Set once the flush then quit sequence has started.
278
+ var _quitting: bool = false
279
+ var _quit_called: bool = false
280
+ var _quit_deadline: float = -1.0
281
+ var _quit_exit_code: int = 0
282
+
283
+ ## Health sampling state: a ring of at most HEALTH_RESERVOIR_SIZE frames
284
+ ## per second readings, the seconds since the last reading, and whether this
285
+ ## session already sent its one health event.
286
+ var _fps_samples: Array = []
287
+ var _fps_next: int = 0
288
+ var _sample_clock: float = 0.0
289
+ var _health_sent: bool = false
290
+ var _boot_ms: int = -1
291
+ var _last_memory_mb: float = -1.0
292
+
293
+ ## --- Playtest mode ------------------------------------------------------------
294
+ ##
295
+ ## Set from RAVENSIGHT_PLAYTEST_TOKEN and RAVENSIGHT_PLAYTEST_RUN_ID (or the
296
+ ## --ravensight-playtest-token= and --ravensight-playtest-run-id= launch
297
+ ## args) when this run is driven by the Ravensight CLI's playtest runner.
298
+ ## Empty in a normal player build, which never sends the playtest header or
299
+ ## tags below.
300
+
301
+ ## The playtest auth token, sent as the X-Ravensight-Playtest header on every
302
+ ## request. Empty outside a playtest run.
303
+ var playtest_token := ""
304
+ ## Tags merged into every tracked event's data while playtest_token is set;
305
+ ## see set_playtest_context().
306
+ var _playtest_tags := {}
307
+ ## Set when the server has permanently rejected this run's playtest token,
308
+ ## reports the job closed, or a token arrived without a run id. Nothing is
309
+ ## sent for the rest of the run. Cleared only by a fresh
310
+ ## set_playtest_context() call, which is a genuinely new attempt.
311
+ var _playtest_blocked: bool = false
312
+ var _playtest_reason: String = ""
313
+
314
+ ## --- Lifecycle ----------------------------------------------------------------
315
+
316
+ func _init() -> void:
317
+ _start_unix = _unix_now()
318
+ _apply_playtest_env()
319
+ if playtest_token.is_empty():
320
+ # Read only: a saved id is used, but none is created before _ready()
321
+ # knows there is a key and, with consent = "opt_in", a yes.
322
+ device_id = _read_saved_device_id()
323
+ # Read now, so a track_event() or set_tracking_enabled() from an
324
+ # earlier autoload already sees the player's choice.
325
+ _player_opted_out = not _saved_choice()
326
+
327
+ func _ready() -> void:
328
+ _is_ready = true
329
+ if ingest_key.strip_edges().is_empty():
330
+ ingest_key = str(ProjectSettings.get_setting("ravensight/ingest_key", "")).strip_edges()
331
+ if ingest_key.strip_edges().is_empty():
332
+ # A keyless clone or a development build: inert, not an error. No
333
+ # file, no request, no timer, and the window close is left alone.
334
+ _no_key = true
335
+ _clear_queue()
336
+ set_process(false)
337
+ if not optional:
338
+ _log_once("no_key", "Ravensight: no ingest_key, so analytics is off for this run (set optional = true to silence this line)", true)
339
+ _update_reason()
340
+ return
341
+ # Keep the timers running while the game is paused, so a paused game
342
+ # still sends and still quits.
343
+ process_mode = Node.PROCESS_MODE_ALWAYS
344
+ _take_over_quit()
345
+ _distribution = _validated_distribution()
346
+
347
+ if playtest_token.is_empty():
348
+ # The game may have set `consent` after construction: read the
349
+ # choice again for the final mode.
350
+ _player_opted_out = not _saved_choice()
351
+ if not _player_opted_out:
352
+ _load_identity()
353
+ _is_ready = true
354
+ _boot_ms = _ticks_msec()
355
+
356
+ _send_exit_marker_if_any()
357
+
358
+ if _playtest_blocked:
359
+ _clear_queue()
360
+ _update_reason()
361
+ return
362
+ if not enabled:
363
+ _clear_queue()
364
+ print("Ravensight: disabled for this run (enabled = false)")
365
+ _update_reason()
366
+ tracking_disabled.emit("init")
367
+ return
368
+ if _player_opted_out:
369
+ # Nothing leaves the device, not even the settings check, and
370
+ # anything an earlier autoload tracked is dropped.
371
+ _clear_queue()
372
+ if _consent_mode() == CONSENT_OPT_IN:
373
+ print("Ravensight: waiting for the player to opt in (consent = \"opt_in\")")
374
+ else:
375
+ print("Ravensight: tracking disabled by player opt-out")
376
+ _update_reason()
377
+ tracking_disabled.emit("player")
378
+ return
379
+ _activate(true)
380
+
381
+ func _process(delta: float) -> void:
382
+ _tick()
383
+ _sample_frame(delta)
384
+
385
+ ## Runs the timers: the retry wait, the batching window and the quit
386
+ ## deadline. Called every frame.
387
+ func _tick() -> void:
388
+ var t := _now()
389
+ if _retry_at >= 0.0 and t >= _retry_at:
390
+ _retry_at = -1.0
391
+ _retry_from_server = false
392
+ _flush_pending()
393
+ if _retry_at < 0.0 and _first_queued_at >= 0.0 and t - _first_queued_at >= flush_interval:
394
+ _flush_pending()
395
+ if _quitting and _quit_deadline >= 0.0 and t >= _quit_deadline:
396
+ _finish_quit()
397
+
398
+ ## Starts tracking once the SDK is ready and active: queues game_started
399
+ ## (once per run) and runs the kill switch check if it has not run.
400
+ ## `at_boot` stamps game_started with the launch time; a game enabled later
401
+ ## in the run stamps it with the time it was enabled.
402
+ func _activate(at_boot: bool = false) -> void:
403
+ if not _is_ready or not _is_active():
404
+ return
405
+ _update_reason()
406
+ _queue_game_started(at_boot)
407
+ if not _settings_checked:
408
+ _check_server_settings()
409
+ else:
410
+ _flush_pending()
411
+
412
+ ## Godot quits the moment a close request arrives while auto_accept_quit is
413
+ ## on, which leaves no time to send anything. Unless the game opted out (see
414
+ ## handle_quit), the SDK turns that off and quits itself after the final
415
+ ## flush. The Android back button quits through a separate switch,
416
+ ## quit_on_go_back, so it is taken over only when the project has it on.
417
+ func _take_over_quit() -> void:
418
+ var tree := get_tree()
419
+ if not handle_quit or not tree.auto_accept_quit:
420
+ return
421
+ tree.auto_accept_quit = false
422
+ _owns_close_quit = true
423
+ if bool(ProjectSettings.get_setting("application/config/quit_on_go_back", true)) and tree.quit_on_go_back:
424
+ tree.quit_on_go_back = false
425
+ _owns_go_back_quit = true
426
+
427
+ func _notification(what: int) -> void:
428
+ match what:
429
+ NOTIFICATION_WM_CLOSE_REQUEST:
430
+ if _owns_close_quit:
431
+ _flush_then_quit(0)
432
+ else:
433
+ _flush_on_close_request()
434
+ NOTIFICATION_WM_GO_BACK_REQUEST:
435
+ if _owns_go_back_quit:
436
+ _flush_then_quit(0)
437
+ else:
438
+ # The back button does not quit this project: just send what
439
+ # is queued, in case the app is about to be backgrounded.
440
+ _flush_pending(true)
441
+ NOTIFICATION_APPLICATION_PAUSED:
442
+ if _is_mobile():
443
+ _on_backgrounded()
444
+ else:
445
+ if _is_web():
446
+ _send_health_once()
447
+ _flush_pending(true)
448
+ NOTIFICATION_APPLICATION_RESUMED:
449
+ if _is_mobile():
450
+ _on_foregrounded()
451
+ NOTIFICATION_APPLICATION_FOCUS_OUT:
452
+ # Focus loss is not an exit: nothing is queued, the queue is sent.
453
+ _flush_pending(true)
454
+
455
+ ## Quits the game after sending game_exited and the queue, holding the quit
456
+ ## at most QUIT_FLUSH_TIMEOUT_SECONDS. Call it from your own Quit button
457
+ ## instead of get_tree().quit(); it works whatever handle_quit is.
458
+ func quit(exit_code: int = 0) -> void:
459
+ _flush_then_quit(exit_code)
460
+
461
+ ## The game manages the window close itself, e.g. with a "really quit?"
462
+ ## dialog that may be cancelled: send what is queued. game_exited is left to
463
+ ## the game's own Ravensight.quit(), so a cancelled close sends no exit.
464
+ func _flush_on_close_request() -> void:
465
+ _flush_pending(true)
466
+
467
+ ## Records game_exited, sends the queue and quits when that flush is answered
468
+ ## or after QUIT_FLUSH_TIMEOUT_SECONDS, whichever comes first. A flush already
469
+ ## in flight is not interrupted: game_exited is queued behind it and sent by
470
+ ## the follow-up flush that a successful response starts.
471
+ func _flush_then_quit(exit_code: int) -> void:
472
+ if _quitting:
473
+ return
474
+ _quitting = true
475
+ _quit_exit_code = exit_code
476
+ _send_health_once()
477
+ _queue_game_exited()
478
+ if not _is_ready or not _is_active() or pending_events.is_empty():
479
+ _finish_quit()
480
+ return
481
+ _flush_pending(true)
482
+ if not (_flush_in_progress or _session_request_pending):
483
+ # Nothing could go out (no session and none could be opened).
484
+ _finish_quit()
485
+ return
486
+ _quit_deadline = _now() + QUIT_FLUSH_TIMEOUT_SECONDS
487
+
488
+ func _finish_quit() -> void:
489
+ if _quit_called:
490
+ return
491
+ _quit_called = true
492
+ _quit_tree(_quit_exit_code)
493
+
494
+ func _quit_tree(exit_code: int) -> void:
495
+ # quit() may be called before this node is in the tree.
496
+ var tree := get_tree() if is_inside_tree() else Engine.get_main_loop() as SceneTree
497
+ if tree:
498
+ tree.quit(exit_code)
499
+
500
+ ## --- Mobile background (Android and iOS) -------------------------------------
501
+
502
+ func _is_mobile() -> bool:
503
+ return OS.has_feature("android") or OS.has_feature("ios")
504
+
505
+ func _is_web() -> bool:
506
+ return OS.has_feature("web")
507
+
508
+ func _ticks_msec() -> int:
509
+ return Time.get_ticks_msec()
510
+
511
+ ## Megabytes of memory in use, or -1.0 where the platform does not say.
512
+ func _memory_mb() -> float:
513
+ var bytes := OS.get_static_memory_usage()
514
+ if bytes <= 0:
515
+ return -1.0
516
+ return float(bytes) / 1048576.0
517
+
518
+ ## The app went to the background. Mobile may kill it there without another
519
+ ## notification, so the queue is sent now and a small marker is saved: if
520
+ ## the app never comes back, the next launch reports game_exited for this
521
+ ## session from the marker. No event is queued.
522
+ func _on_backgrounded() -> void:
523
+ if not _is_ready or not _is_active():
524
+ return
525
+ _backgrounded_at = _unix_now()
526
+ _send_health_once()
527
+ _flush_pending(true)
528
+ if playtest_token.is_empty() and not session_token.is_empty():
529
+ _write_text(BACKGROUND_MARKER_PATH, JSON.stringify({
530
+ "sessionToken": session_token,
531
+ "expiresAt": session_expires_at,
532
+ "backgroundedAt": _backgrounded_at,
533
+ }))
534
+
535
+ ## Back in the foreground. Under session_timeout nothing happens. At or past
536
+ ## it, the old session ends with game_exited at the moment the app went to
537
+ ## the background, and a new one starts with game_started now.
538
+ func _on_foregrounded() -> void:
539
+ # A resume delivered before _ready() is the launch itself: the marker
540
+ # from an earlier run is _ready()'s to read.
541
+ if not _is_ready:
542
+ return
543
+ _remove_file(BACKGROUND_MARKER_PATH)
544
+ var backgrounded_at := _backgrounded_at
545
+ _backgrounded_at = 0
546
+ if backgrounded_at <= 0 or not _is_active():
547
+ return
548
+ if float(_unix_now() - backgrounded_at) < session_timeout:
549
+ return
550
+ _queue_game_exited(backgrounded_at)
551
+ # Everything queued so far belongs to the old session; once it is
552
+ # delivered the session is dropped and a new one opens.
553
+ _session_boundary = pending_events.size()
554
+ _health_sent = false
555
+ _fps_samples.clear()
556
+ _fps_next = 0
557
+ _started_queued = false
558
+ _exit_queued = false
559
+ _queue_game_started(false)
560
+ _flush_pending(true)
561
+
562
+ ## On launch: a marker left by a run that was killed in the background
563
+ ## reports that run's game_exited, one attempt, then the marker is deleted
564
+ ## whatever the answer.
565
+ func _send_exit_marker_if_any() -> void:
566
+ if not playtest_token.is_empty():
567
+ return
568
+ var text := _read_text(BACKGROUND_MARKER_PATH)
569
+ if text.is_empty():
570
+ return
571
+ _remove_file(BACKGROUND_MARKER_PATH)
572
+ if not enabled or _player_opted_out:
573
+ return
574
+ var marker = JSON.parse_string(text)
575
+ if not (marker is Dictionary):
576
+ return
577
+ var token := str(marker.get("sessionToken", ""))
578
+ var expires_at := int(marker.get("expiresAt", 0))
579
+ var backgrounded_at := int(marker.get("backgroundedAt", 0))
580
+ if token.is_empty() or backgrounded_at <= 0 or _unix_now() >= expires_at:
581
+ return
582
+ var body := JSON.stringify({"events": [{"event": "game_exited", "data": {}, "timestamp": backgrounded_at}]})
583
+ _send("POST", "/track/batch", ["Content-Type: application/json", "X-Session-Token: " + token], body, _ignore_response)
584
+
585
+ func _ignore_response(_code: int, _headers: PackedStringArray, _body: PackedByteArray) -> void:
586
+ pass
587
+
588
+ ## --- Device ID persistence ----------------------------------------------------
589
+
590
+ func _load_or_create_device_id() -> String:
591
+ # A saved id is kept only when it has the random shape that
592
+ # _generate_device_id() produces. SDKs before the random id saved
593
+ # OS.get_unique_id() + "_" + OS.get_name() + "_" + a timestamp, which is
594
+ # derived from the hardware or OS install; such an id is replaced with a
595
+ # fresh random one (and saved) so an upgraded install carries no hardware
596
+ # identifier either. That install starts a new analytics identity.
597
+ var saved_id := _read_saved_device_id()
598
+ if not saved_id.is_empty():
599
+ return saved_id
600
+ var new_id := _generate_device_id()
601
+ _write_text(DEVICE_ID_PATH, new_id)
602
+ return new_id
603
+
604
+ ## The saved device id when it has the random shape, otherwise "". Never
605
+ ## writes.
606
+ func _read_saved_device_id() -> String:
607
+ var saved_id := _read_text(DEVICE_ID_PATH).strip_edges()
608
+ return saved_id if _is_random_device_id(saved_id) else ""
609
+
610
+ ## Makes sure this install has a device id: called once there is a key and
611
+ ## the player's choice allows it. Events queued before it get their
612
+ ## rs_event_id here.
613
+ func _load_identity() -> void:
614
+ if not playtest_token.is_empty():
615
+ return
616
+ if device_id.is_empty():
617
+ device_id = _load_or_create_device_id()
618
+ _stamp_event_ids()
619
+
620
+ ## --- Consent ------------------------------------------------------------------
621
+
622
+ ## "opt_in" or "opt_out" from the consent option; anything else is opt_out.
623
+ func _consent_mode() -> String:
624
+ return CONSENT_OPT_IN if consent == CONSENT_OPT_IN else CONSENT_OPT_OUT
625
+
626
+ ## The player's saved choice for the consent mode: true when tracking may
627
+ ## run. opt_out: on unless an opt-out was saved. opt_in: off unless a yes
628
+ ## was saved.
629
+ func _saved_choice() -> bool:
630
+ if _consent_mode() == CONSENT_OPT_IN:
631
+ return FileAccess.file_exists(_path(OPTIN_PATH))
632
+ return not FileAccess.file_exists(_path(OPTOUT_PATH))
633
+
634
+ ## Saves the player's choice in the file the consent mode reads.
635
+ func _save_choice(on: bool) -> void:
636
+ if _consent_mode() == CONSENT_OPT_IN:
637
+ if on:
638
+ _write_text(OPTIN_PATH, "1")
639
+ else:
640
+ _remove_file(OPTIN_PATH)
641
+ elif on:
642
+ _remove_file(OPTOUT_PATH)
643
+ else:
644
+ _write_text(OPTOUT_PATH, "1")
645
+
646
+ ## The distribution option when it is valid, otherwise "" (one warning).
647
+ func _validated_distribution() -> String:
648
+ var label := distribution.strip_edges()
649
+ if label.is_empty():
650
+ return ""
651
+ var control := RegEx.new()
652
+ control.compile("[\\x00-\\x1f\\x7f]")
653
+ # Counted in UTF-16 units, as the server counts characters.
654
+ if label.to_utf16_buffer().size() / 2 > MAX_DISTRIBUTION_LENGTH or control.search(label) != null:
655
+ _log_once("bad_distribution", "Ravensight: distribution must be at most %d characters with no control characters; it is not sent" % MAX_DISTRIBUTION_LENGTH, true)
656
+ return ""
657
+ return label
658
+
659
+ ## True when `id` has the shape _generate_device_id() produces: exactly 32
660
+ ## lowercase hex characters, a dash, then the lowercased OS name. A legacy
661
+ ## hardware derived id never matches, since it joins its parts with
662
+ ## underscores and keeps the OS name's capitals.
663
+ func _is_random_device_id(id: String) -> bool:
664
+ var pattern := RegEx.new()
665
+ pattern.compile("^[0-9a-f]{32}-[a-z0-9]+$")
666
+ return pattern.search(id) != null
667
+
668
+ ## A new device id: 16 random bytes from the platform's secure random source
669
+ ## as lowercase hex, a dash, and the lowercased OS name (kept because the
670
+ ## per-OS split in analytics reads it), e.g. "3f1c...9a-windows". It
671
+ ## identifies this install of this game and nothing else. No id, old or
672
+ ## new, was ever linked to an account: Ravensight has no player accounts.
673
+ func _generate_device_id() -> String:
674
+ var random_bytes := Crypto.new().generate_random_bytes(16)
675
+ return "%s-%s" % [random_bytes.hex_encode(), OS.get_name().to_lower()]
676
+
677
+ ## --- Storage --------------------------------------------------------------------
678
+
679
+ ## Where the SDK's files live. Tests point this somewhere else.
680
+ func _storage_dir() -> String:
681
+ return "user://"
682
+
683
+ func _path(user_path: String) -> String:
684
+ return _storage_dir() + user_path.trim_prefix("user://")
685
+
686
+ func _read_text(user_path: String) -> String:
687
+ var p := _path(user_path)
688
+ if not FileAccess.file_exists(p):
689
+ return ""
690
+ var file := FileAccess.open(p, FileAccess.READ)
691
+ if file == null:
692
+ return ""
693
+ var text := file.get_as_text()
694
+ file.close()
695
+ return text
696
+
697
+ func _write_text(user_path: String, text: String) -> void:
698
+ var file := FileAccess.open(_path(user_path), FileAccess.WRITE)
699
+ if file:
700
+ file.store_string(text)
701
+ file.close()
702
+
703
+ func _remove_file(user_path: String) -> void:
704
+ var p := _path(user_path)
705
+ if FileAccess.file_exists(p):
706
+ DirAccess.remove_absolute(p)
707
+
708
+ ## --- Clocks and transport (replaced in tests) -------------------------------------
709
+
710
+ ## Monotonic seconds, for timers.
711
+ func _now() -> float:
712
+ return Time.get_ticks_msec() / 1000.0
713
+
714
+ ## Unix seconds, for event timestamps and session expiry.
715
+ func _unix_now() -> int:
716
+ return int(Time.get_unix_time_from_system())
717
+
718
+ ## 0 <= x < 1, for backoff jitter.
719
+ func _random() -> float:
720
+ return randf()
721
+
722
+ ## 4 bytes from the platform's secure random source as 8 hex characters.
723
+ func _random_nonce() -> String:
724
+ return Crypto.new().generate_random_bytes(4).hex_encode()
725
+
726
+ ## Every request the SDK makes goes through here. `path` is relative to
727
+ ## api_base(); `callback` is called with (status, headers, body), where
728
+ ## status is 0 for a network failure or a timeout.
729
+ func _send(method: String, path: String, headers: Array, body: Variant, callback: Callable) -> void:
730
+ var http := HTTPRequest.new()
731
+ http.timeout = REQUEST_TIMEOUT_SECONDS
732
+ add_child(http)
733
+ http.request_completed.connect(func(result: int, code: int, response_headers: PackedStringArray, response_body: PackedByteArray) -> void:
734
+ http.queue_free()
735
+ var status := code if result == HTTPRequest.RESULT_SUCCESS else 0
736
+ callback.call(status, response_headers, response_body))
737
+ var http_method := HTTPClient.METHOD_POST if method == "POST" else (HTTPClient.METHOD_DELETE if method == "DELETE" else HTTPClient.METHOD_GET)
738
+ var url := path if path.begins_with("https://") else api_base() + path
739
+ http.max_redirects = 0
740
+ var err: int
741
+ if body is PackedByteArray:
742
+ err = http.request_raw(url, PackedStringArray(headers), http_method, body)
743
+ else:
744
+ err = http.request(url, PackedStringArray(headers), http_method, str(body))
745
+ if err != OK:
746
+ http.queue_free()
747
+ callback.call_deferred(0, PackedStringArray(), PackedByteArray())
748
+
749
+ ## The API root every request goes to: api_url with surrounding whitespace,
750
+ ## every trailing "/" and a trailing "/api/v1" removed, then "/api/v1"
751
+ ## appended. Empty means https://api.ravensight.io.
752
+ func api_base() -> String:
753
+ var base := api_url.strip_edges()
754
+ while base.ends_with("/"):
755
+ base = base.substr(0, base.length() - 1)
756
+ if base.ends_with("/api/v1"):
757
+ base = base.substr(0, base.length() - "/api/v1".length())
758
+ while base.ends_with("/"):
759
+ base = base.substr(0, base.length() - 1)
760
+ if base.is_empty():
761
+ base = "https://api.ravensight.io"
762
+ return base + "/api/v1"
763
+
764
+ func _key_headers() -> Array:
765
+ var headers := ["X-API-Key: " + ingest_key]
766
+ if not playtest_token.is_empty():
767
+ headers.append("X-Ravensight-Playtest: " + playtest_token)
768
+ return headers
769
+
770
+ func _session_headers() -> Array:
771
+ var headers := ["Content-Type: application/json", "X-Session-Token: " + session_token]
772
+ if not playtest_token.is_empty():
773
+ headers.append("X-Ravensight-Playtest: " + playtest_token)
774
+ return headers
775
+
776
+ ## --- Playtest mode setup --------------------------------------------------------
777
+
778
+ ## Reads RAVENSIGHT_PLAYTEST_TOKEN/RUN_ID/JOB_ID/PERSONA from the environment
779
+ ## (set by the Ravensight CLI's playtest runner before it launches the game),
780
+ ## or the --ravensight-playtest-token= and --ravensight-playtest-run-id=
781
+ ## user args, and applies them the same way set_playtest_context() would.
782
+ ## A token without a run id never runs as a real player: the run is stopped.
783
+ ## A no-op when no token is present, which is every normal player build.
784
+ func _apply_playtest_env() -> void:
785
+ var token := OS.get_environment("RAVENSIGHT_PLAYTEST_TOKEN")
786
+ if token.is_empty():
787
+ token = _get_cmdline_arg_value("ravensight-playtest-token")
788
+ if token.is_empty():
789
+ return
790
+ var run_id := OS.get_environment("RAVENSIGHT_PLAYTEST_RUN_ID")
791
+ if run_id.is_empty():
792
+ run_id = _get_cmdline_arg_value("ravensight-playtest-run-id")
793
+ if run_id.is_empty():
794
+ playtest_token = token
795
+ device_id = ""
796
+ _playtest_blocked = true
797
+ _playtest_reason = "session_failed"
798
+ _log_once("playtest_no_run", "Ravensight: playtest token without a run id, nothing will be sent this run", true)
799
+ return
800
+
801
+ set_playtest_context({
802
+ "token": token,
803
+ "run_id": run_id,
804
+ "job_id": OS.get_environment("RAVENSIGHT_PLAYTEST_JOB_ID"),
805
+ "persona": OS.get_environment("RAVENSIGHT_PLAYTEST_PERSONA"),
806
+ })
807
+
808
+ func _get_cmdline_arg_value(arg_name: String) -> String:
809
+ var prefix := "--%s=" % arg_name
810
+ for arg in OS.get_cmdline_user_args():
811
+ if arg.begins_with(prefix):
812
+ return arg.substr(prefix.length())
813
+ return ""
814
+
815
+ ## Switches this SDK instance into playtest mode: every request carries the
816
+ ## playtest header, sessions use the playtest platform, device_id becomes
817
+ ## "pt-<run_id>" (never read from or written to disk), the player's saved
818
+ ## opt-out is ignored, and every tracked event is tagged as synthetic.
819
+ ## Called automatically when the SDK is constructed from the
820
+ ## RAVENSIGHT_PLAYTEST_* environment (see _apply_playtest_env()); call it
821
+ ## directly if you wire a driver up some other way.
822
+ ##
823
+ ## ctx keys: "token" and "run_id" are required; "job_id", "persona" and
824
+ ## "tier" are optional (persona/job_id default to "", tier defaults to
825
+ ## "T2_driven_build"). Restarts the session if one is already open or in
826
+ ## flight, so every event from this point on carries the new tags.
827
+ func set_playtest_context(ctx: Dictionary) -> void:
828
+ var token := str(ctx.get("token", ""))
829
+ var run_id := str(ctx.get("run_id", ""))
830
+ if token.is_empty() or run_id.is_empty():
831
+ push_warning("Ravensight: set_playtest_context() needs both token and run_id")
832
+ return
833
+
834
+ playtest_token = token
835
+ device_id = "pt-" + run_id
836
+ # A fresh context is a fresh attempt, even if the previous one ended in
837
+ # a fatal rejection.
838
+ _playtest_blocked = false
839
+ _playtest_reason = ""
840
+ # A playtest run ignores the player's opt-out; the flag on disk is left
841
+ # exactly as the player set it.
842
+ _player_opted_out = false
843
+
844
+ _playtest_tags = {
845
+ "synthetic": true,
846
+ "persona": str(ctx.get("persona", "")),
847
+ "pt_job": str(ctx.get("job_id", "")),
848
+ "pt_run": run_id,
849
+ "pt_source": "sdk",
850
+ "pt_tier": str(ctx.get("tier", "T2_driven_build")),
851
+ }
852
+
853
+ var had_session := not session_token.is_empty() or _session_request_pending
854
+ if had_session:
855
+ # Dropping bumps the generation, so a response for the old identity
856
+ # still in flight is ignored when it lands.
857
+ _drop_session()
858
+ _start_session()
859
+ elif _is_ready:
860
+ _activate()
861
+
862
+ ## True in a run driven by the Ravensight playtest runner.
863
+ func is_playtest() -> bool:
864
+ return not playtest_token.is_empty()
865
+
866
+ ## --- Server kill switch (GET /api/v1/settings) ---------------------------------
867
+
868
+ func _check_server_settings() -> void:
869
+ if not _is_ready or not _is_active() or _settings_pending:
870
+ return
871
+ _settings_pending = true
872
+ _send("GET", "/settings", _key_headers(), "", _on_settings_response)
873
+
874
+ func _on_settings_response(code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
875
+ _settings_pending = false
876
+ _settings_checked = true
877
+ var error := _extract_error(body, "")
878
+ if code == 200:
879
+ var parsed = _parse_json(body)
880
+ if parsed is Dictionary and parsed.has("trackingEnabled") and not bool(parsed["trackingEnabled"]):
881
+ _on_server_tracking_disabled()
882
+ return
883
+ elif code == 403 and error == "tracking_disabled":
884
+ _on_server_tracking_disabled()
885
+ return
886
+ elif code == 403 and _is_fatal_playtest_error(error):
887
+ _on_playtest_refused(error)
888
+ return
889
+ elif code == 401 or code == 403:
890
+ _refuse_key()
891
+ return
892
+ elif code == 429 or (code == 503 and _extract_retry_after(headers) >= 0.0):
893
+ # The kill switch is assumed on; the session waits the server's time.
894
+ _transient_reason = "rate_limited"
895
+ _update_reason()
896
+ _schedule_retry(_extract_retry_after(headers))
897
+ return
898
+ else:
899
+ _log_once("settings_failed", "Ravensight: could not fetch settings (HTTP %d); assuming tracking enabled" % code, false)
900
+ _flush_pending()
901
+
902
+ ## --- Session management (POST /api/v1/session) ---------------------------------
903
+
904
+ func _is_session_valid() -> bool:
905
+ return session_token.length() > 0 and _unix_now() < session_expires_at
906
+
907
+ ## Opens a session unless one is open or opening, nothing may be sent, or a
908
+ ## wait is running. `explicit` (flush(), quit, background) cuts a backoff
909
+ ## wait short and makes one more attempt after a refused key; a server's
910
+ ## Retry-After wait always holds.
911
+ func _start_session(explicit: bool = false) -> void:
912
+ if not _is_ready or not _is_active() or not _settings_checked:
913
+ return
914
+ if _session_request_pending or device_id.is_empty():
915
+ return
916
+ if not _can_send_now(explicit):
917
+ return
918
+ _session_request_pending = true
919
+ _session_request_generation += 1
920
+ var generation := _session_request_generation
921
+
922
+ var headers := ["Content-Type: application/json", "X-API-Key: " + ingest_key]
923
+ var platform := OS.get_name()
924
+ if not playtest_token.is_empty():
925
+ headers.append("X-Ravensight-Playtest: " + playtest_token)
926
+ platform = "ravensight-playtest"
927
+
928
+ var session_body := {
929
+ "deviceId": device_id,
930
+ "gameVersion": game_version,
931
+ "platform": platform,
932
+ "sdk": "godot/" + VERSION,
933
+ }
934
+ if not _distribution.is_empty():
935
+ session_body["distribution"] = _distribution
936
+ _send("POST", "/session", headers, JSON.stringify(session_body), _on_session_response.bind(generation))
937
+
938
+ func _on_session_response(code: int, headers: PackedStringArray, body: PackedByteArray, generation: int) -> void:
939
+ # A newer session attempt has started (or the session was dropped) since
940
+ # this request went out. Ignore it rather than let it overwrite state.
941
+ if generation != _session_request_generation:
942
+ return
943
+ _session_request_pending = false
944
+ var error := _extract_error(body, "")
945
+
946
+ if code == 201:
947
+ var parsed = _parse_json(body)
948
+ if parsed is Dictionary and parsed.has("token"):
949
+ session_token = str(parsed["token"])
950
+ # The server sends expiresIn (seconds) and expiresAt (an ISO
951
+ # string, which int() would mangle). expiresIn is the contract.
952
+ var expires_in := int(parsed.get("expiresIn", 86400))
953
+ session_expires_at = _unix_now() + expires_in
954
+ _backoff_seconds = DEFAULT_RETRY_SECONDS
955
+ # A success ends any wait an explicit flush cut short.
956
+ _stop_retry()
957
+ _transient_reason = ""
958
+ _update_reason()
959
+ print("Ravensight: session ready")
960
+ session_ready.emit()
961
+ _flush_pending()
962
+ fetch_config()
963
+ else:
964
+ push_error("Ravensight: malformed session response")
965
+ _transient_reason = "session_failed"
966
+ _update_reason()
967
+ session_failed.emit("malformed_response")
968
+ _schedule_retry()
969
+ elif code == 403 and error == "tracking_disabled":
970
+ session_failed.emit("tracking_disabled")
971
+ _on_server_tracking_disabled()
972
+ return
973
+ elif code == 403 and _is_fatal_playtest_error(error):
974
+ session_failed.emit(error)
975
+ _on_playtest_refused(error)
976
+ return
977
+ elif code == 401 or code == 403:
978
+ session_failed.emit("invalid_key")
979
+ _refuse_key()
980
+ elif code == 429 or (code == 503 and _extract_retry_after(headers) >= 0.0):
981
+ var reason := error if not error.is_empty() else "rate_limited"
982
+ push_warning("Ravensight: session creation rate-limited (%s)" % reason)
983
+ _transient_reason = "rate_limited"
984
+ _update_reason()
985
+ session_failed.emit(reason)
986
+ _schedule_retry(_extract_retry_after(headers))
987
+ else:
988
+ var reason2 := error if not error.is_empty() else ("offline" if code == 0 else "http_%d" % code)
989
+ push_warning("Ravensight: session creation failed (%s)" % reason2)
990
+ _transient_reason = "offline" if code == 0 else "session_failed"
991
+ _update_reason()
992
+ session_failed.emit(reason2)
993
+ _schedule_retry()
994
+
995
+ # Quitting and no session came of it: nothing more can be sent.
996
+ if _quitting and code != 201:
997
+ _finish_quit()
998
+
999
+ ## --- Retry / backoff ------------------------------------------------------------
1000
+
1001
+ ## Waits before the next automatic attempt: `server_wait_seconds` exactly
1002
+ ## when the server gave one (Retry-After), otherwise the backoff, which
1003
+ ## starts at 10 seconds and doubles up to 5 minutes, spread by plus or
1004
+ ## minus JITTER (never past 5 minutes).
1005
+ func _schedule_retry(server_wait_seconds: float = -1.0) -> void:
1006
+ var wait := _backoff_seconds
1007
+ if server_wait_seconds >= 0.0:
1008
+ wait = server_wait_seconds
1009
+ _retry_from_server = true
1010
+ else:
1011
+ var factor := 1.0 - JITTER + 2.0 * JITTER * _random()
1012
+ wait = minf(_backoff_seconds * factor, MAX_BACKOFF_SECONDS)
1013
+ _backoff_seconds = minf(_backoff_seconds * 2.0, MAX_BACKOFF_SECONDS)
1014
+ _retry_from_server = false
1015
+ _retry_at = _now() + wait
1016
+
1017
+ func _stop_retry() -> void:
1018
+ _retry_at = -1.0
1019
+ _retry_from_server = false
1020
+
1021
+ func _can_send_now(explicit: bool) -> bool:
1022
+ if _retry_at < 0.0:
1023
+ return true
1024
+ if _retry_from_server:
1025
+ return false
1026
+ return explicit
1027
+
1028
+ ## --- Public API: event tracking --------------------------------------------------
1029
+
1030
+ ## Queue an event. It is sent with others: flush_interval seconds after the
1031
+ ## first waiting event, at once when 50 are waiting, on flush(), when the
1032
+ ## app goes to the background, and on quit. Safe to call from any autoload,
1033
+ ## even before this one is ready: such events wait for the opt-out and kill
1034
+ ## switch checks.
1035
+ func track_event(event_name: String, data: Dictionary = {}) -> void:
1036
+ if not _is_active():
1037
+ return
1038
+ _enqueue_and_emit(event_name, data)
1039
+ if not _is_ready:
1040
+ return
1041
+ if pending_events.size() >= MAX_BATCH_SIZE:
1042
+ _flush_pending()
1043
+
1044
+ ## Config is public game data, never secrets. Invalid/offline responses keep
1045
+ ## the last valid cache for this exact endpoint, key, player and build.
1046
+ func _current_config_scope() -> String:
1047
+ return JSON.stringify([api_base(), ingest_key, device_id, _config_platform(), game_version]).sha256_text()
1048
+
1049
+ func _config_platform() -> String:
1050
+ return OS.get_name() if playtest_token.is_empty() else "ravensight-playtest"
1051
+
1052
+ func _config_matches(pattern: String, value: Variant) -> bool:
1053
+ if not value is String:
1054
+ return false
1055
+ var regex := RegEx.new()
1056
+ regex.compile(pattern)
1057
+ return regex.search(value) != null
1058
+
1059
+ func _config_json_safe(value: Variant, depth: int = 0) -> bool:
1060
+ if depth > 20:
1061
+ return false
1062
+ if value is Dictionary:
1063
+ for key in value:
1064
+ if not key is String or key in ["__proto__", "prototype", "constructor"] or not _config_json_safe(value[key], depth + 1):
1065
+ return false
1066
+ elif value is Array:
1067
+ for child in value:
1068
+ if not _config_json_safe(child, depth + 1):
1069
+ return false
1070
+ elif value is float:
1071
+ return is_finite(value)
1072
+ elif not (value == null or value is String or value is bool or value is int):
1073
+ return false
1074
+ return true
1075
+
1076
+ func _valid_config(value: Variant) -> bool:
1077
+ if not value is Dictionary or not value.get("values") is Dictionary or not value.get("experiments") is Array:
1078
+ return false
1079
+ if value["values"].size() > 50 or value["experiments"].size() > 10 or not _config_matches('^"[a-f0-9]{64}"$', value.get("etag")):
1080
+ return false
1081
+ for key in value["values"]:
1082
+ if not _config_matches("^[a-z][a-z0-9_]{0,63}$", key) or key in ["constructor", "prototype"] or not _config_json_safe(value["values"][key]) or JSON.stringify(value["values"][key]).to_utf8_buffer().size() > 8192:
1083
+ return false
1084
+ var seen := {}
1085
+ for row in value["experiments"]:
1086
+ if not row is Dictionary or not _config_matches("^[a-z][a-z0-9_]{0,63}$", row.get("key")) or not _config_matches("^[a-z][a-z0-9_-]{0,39}$", row.get("variant")) or seen.has(row["key"]):
1087
+ return false
1088
+ seen[row["key"]] = true
1089
+ return true
1090
+
1091
+ func _cached_config() -> Dictionary:
1092
+ if not _is_active() or ingest_key.strip_edges().is_empty() or device_id.is_empty():
1093
+ return {}
1094
+ var scope := _current_config_scope()
1095
+ if _config_scope == scope:
1096
+ return _config_document
1097
+ _config_scope = scope
1098
+ _config_document = {}
1099
+ if playtest_token.is_empty():
1100
+ var text := _read_text(CONFIG_PATH)
1101
+ var cached = JSON.parse_string(text) if not text.is_empty() else null
1102
+ if cached is Dictionary and cached.get("scope") == scope and _valid_config(cached.get("document")):
1103
+ _config_document = cached["document"]
1104
+ return _config_document
1105
+
1106
+ ## No type coercion, except JSON numbers support both int and float defaults.
1107
+ ## Integer defaults accept only whole numbers within the exact JSON range.
1108
+ func get_config(key: String, fallback: Variant) -> Variant:
1109
+ var values: Dictionary = _cached_config().get("values", {})
1110
+ if not values.has(key):
1111
+ return fallback
1112
+ var value: Variant = values[key]
1113
+ if fallback is int and (value is float or value is int):
1114
+ return int(value) if is_finite(float(value)) and float(value) == floor(float(value)) and abs(float(value)) <= 9007199254740991.0 else fallback
1115
+ if fallback is float and (value is float or value is int):
1116
+ return float(value)
1117
+ if typeof(value) != typeof(fallback):
1118
+ return fallback
1119
+ return value.duplicate(true) if value is Dictionary or value is Array else value
1120
+
1121
+ func fetch_config() -> void:
1122
+ if not _is_active() or ingest_key.strip_edges().is_empty() or _quitting or device_id.is_empty() or _config_pending:
1123
+ return
1124
+ var cached := _cached_config()
1125
+ var headers := _key_headers()
1126
+ if cached.has("etag"):
1127
+ headers.append("If-None-Match: " + cached["etag"])
1128
+ _config_pending = true
1129
+ _config_generation += 1
1130
+ var path := "/ingest/config?deviceId=" + device_id.uri_encode() + "&platform=" + _config_platform().uri_encode() + "&gameVersion=" + game_version.uri_encode()
1131
+ _send("GET", path, headers, "", _on_config_response.bind(_config_generation, _current_config_scope()))
1132
+
1133
+ func _on_config_response(code: int, _headers: PackedStringArray, body: PackedByteArray, generation: int, scope: String) -> void:
1134
+ if generation != _config_generation:
1135
+ return
1136
+ _config_pending = false
1137
+ if not _is_active() or scope != _current_config_scope():
1138
+ return
1139
+ var source := "cache" if not _cached_config().is_empty() else "default"
1140
+ if code == 200 and body.size() <= 430000:
1141
+ var parsed = _parse_json(body)
1142
+ if _valid_config(parsed):
1143
+ _config_document = parsed
1144
+ _config_scope = scope
1145
+ source = "network"
1146
+ if playtest_token.is_empty():
1147
+ _write_text(CONFIG_PATH, JSON.stringify({"scope": scope, "document": parsed}))
1148
+ config_ready.emit(source)
1149
+
1150
+ ## --- Typed events ---------------------------------------------------------------
1151
+ ##
1152
+ ## Thin helpers over track_event(): each builds the reserved rs_* properties
1153
+ ## and queues an ordinary event, so batching, retries and opt-out are the
1154
+ ## same. `options` may carry `data` (extra properties, merged first so the
1155
+ ## reserved keys win) and `event_name` (replaces the default name). Nothing
1156
+ ## here validates or throws: the server flags a malformed event instead.
1157
+
1158
+ ## A real-money purchase. `amount` is whole minor units of `currency` (499 is
1159
+ ## 4.99 USD), an integer or a digit string. `options`: cart, transaction_id,
1160
+ ## data, event_name.
1161
+ func track_purchase(item_type: Variant, item_id: Variant, amount: Variant, currency: Variant, options: Dictionary = {}) -> void:
1162
+ var props := _typed_base(options)
1163
+ props["rs_kind"] = "purchase"
1164
+ props["rs_amount"] = amount
1165
+ props["rs_currency"] = str(currency).to_upper()
1166
+ props["rs_item_type"] = item_type
1167
+ props["rs_item_id"] = item_id
1168
+ _typed_optional(props, options, "rs_cart", "cart")
1169
+ _typed_optional(props, options, "rs_transaction_id", "transaction_id")
1170
+ track_event(str(options.get("event_name", "purchase")), props)
1171
+
1172
+ ## An in-game resource gained (`source`) or spent (`sink`). `amount` is a
1173
+ ## number. `options`: item_type, item_id, data, event_name.
1174
+ func track_resource(flow: Variant, resource: Variant, amount: Variant, options: Dictionary = {}) -> void:
1175
+ var props := _typed_base(options)
1176
+ props["rs_kind"] = "resource"
1177
+ props["rs_flow"] = flow
1178
+ props["rs_resource"] = resource
1179
+ props["rs_amount"] = amount
1180
+ _typed_optional(props, options, "rs_item_type", "item_type")
1181
+ _typed_optional(props, options, "rs_item_id", "item_id")
1182
+ track_event(str(options.get("event_name", "resource_" + str(flow))), props)
1183
+
1184
+ ## A progression step: `status` is `start`, `complete` or `fail`, `p1` the
1185
+ ## node. `options`: p2, p3, score, data, event_name.
1186
+ func track_progression(status: Variant, p1: Variant, options: Dictionary = {}) -> void:
1187
+ var props := _typed_base(options)
1188
+ props["rs_kind"] = "progression"
1189
+ props["rs_status"] = status
1190
+ props["rs_p1"] = p1
1191
+ _typed_optional(props, options, "rs_p2", "p2")
1192
+ _typed_optional(props, options, "rs_p3", "p3")
1193
+ _typed_optional(props, options, "rs_score", "score")
1194
+ track_event(str(options.get("event_name", "progression_" + str(status))), props)
1195
+
1196
+ ## An error. `severity` is `debug`, `info`, `warning`, `error` or `critical`.
1197
+ ## The message is cut to 200 characters and the stack to 2000. `options`:
1198
+ ## stack, data, event_name.
1199
+ func track_error(severity: Variant, message: Variant, options: Dictionary = {}) -> void:
1200
+ var props := _typed_base(options)
1201
+ props["rs_kind"] = "error"
1202
+ props["rs_severity"] = severity
1203
+ props["rs_message"] = str(message).substr(0, MAX_ERROR_MESSAGE_CHARS)
1204
+ if options.get("stack") != null:
1205
+ props["rs_stack"] = str(options["stack"]).substr(0, MAX_ERROR_STACK_CHARS)
1206
+ track_event(str(options.get("event_name", "error")), props)
1207
+
1208
+ ## The caller's extra properties, copied, ready for the reserved keys.
1209
+ func _typed_base(options: Dictionary) -> Dictionary:
1210
+ var extra = options.get("data", {})
1211
+ return (extra as Dictionary).duplicate() if extra is Dictionary else {}
1212
+
1213
+ func _typed_optional(props: Dictionary, options: Dictionary, key: String, option: String) -> void:
1214
+ if options.get(option) != null:
1215
+ props[key] = options[option]
1216
+
1217
+ ## --- Health sampling ---------------------------------------------------------------
1218
+
1219
+ ## Called every frame. Takes one reading per HEALTH_SAMPLE_SECONDS, never
1220
+ ## sends anything.
1221
+ func _sample_frame(delta: float) -> void:
1222
+ if not performance or delta <= 0.0:
1223
+ return
1224
+ _sample_clock += delta
1225
+ if _sample_clock < HEALTH_SAMPLE_SECONDS:
1226
+ return
1227
+ _sample_clock = 0.0
1228
+ _add_fps_sample(1.0 / delta)
1229
+ var mb := _memory_mb()
1230
+ if mb >= 0.0:
1231
+ _last_memory_mb = mb
1232
+
1233
+ ## Keeps at most HEALTH_RESERVOIR_SIZE readings; the oldest is replaced.
1234
+ func _add_fps_sample(fps: float) -> void:
1235
+ if _fps_samples.size() < HEALTH_RESERVOIR_SIZE:
1236
+ _fps_samples.append(fps)
1237
+ else:
1238
+ _fps_samples[_fps_next] = fps
1239
+ _fps_next = (_fps_next + 1) % HEALTH_RESERVOIR_SIZE
1240
+
1241
+ ## The health properties for the samples taken, or {} when there are none.
1242
+ func _health_summary() -> Dictionary:
1243
+ if _fps_samples.is_empty():
1244
+ return {}
1245
+ var sorted := _fps_samples.duplicate()
1246
+ sorted.sort()
1247
+ var n := sorted.size()
1248
+ var median: float = sorted[n / 2] if n % 2 == 1 else (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
1249
+ var p10: float = sorted[maxi(ceili(0.1 * n) - 1, 0)]
1250
+ var props := {
1251
+ "rs_kind": "health",
1252
+ "rs_fps_median": snappedf(median, 0.01),
1253
+ "rs_fps_p10": snappedf(p10, 0.01),
1254
+ }
1255
+ if _last_memory_mb >= 0.0:
1256
+ props["rs_memory_mb"] = snappedf(_last_memory_mb, 0.1)
1257
+ if _boot_ms >= 0:
1258
+ props["rs_boot_ms"] = _boot_ms
1259
+ return props
1260
+
1261
+ ## Queues the session's one health event, if sampling is on and any sample
1262
+ ## was taken. Goes through track_event(), so opt-out and the kill switch
1263
+ ## apply. Never called from the frame hook.
1264
+ func _send_health_once() -> void:
1265
+ if not performance or _health_sent or not _is_active():
1266
+ return
1267
+ var props := _health_summary()
1268
+ if props.is_empty():
1269
+ return
1270
+ _health_sent = true
1271
+ track_event("health", props)
1272
+
1273
+ ## Send what is queued now instead of waiting for the timer. Cuts a backoff
1274
+ ## wait short; a server's Retry-After wait still holds.
1275
+ func flush() -> void:
1276
+ _flush_pending(true)
1277
+
1278
+ ## --- Public API: status ----------------------------------------------------------
1279
+
1280
+ ## Why the SDK is not sending, or "" when nothing is wrong (a session is open
1281
+ ## or opening normally). One of "no_key" (started with no ingest key),
1282
+ ## "disabled" (enabled is false, the player opted out, or consent =
1283
+ ## "opt_in" and no yes yet), "invalid_key", "tracking_disabled" (the server
1284
+ ## kill switch), "offline", "rate_limited", "session_failed",
1285
+ ## "playtest_job_closed". status_changed fires whenever it changes.
1286
+ func get_status_reason() -> String:
1287
+ return _status_reason
1288
+
1289
+ func _update_reason() -> void:
1290
+ var reason := _transient_reason
1291
+ if _no_key:
1292
+ reason = "no_key"
1293
+ elif not enabled or _player_opted_out:
1294
+ reason = "disabled"
1295
+ elif _killed:
1296
+ reason = "tracking_disabled"
1297
+ elif _playtest_blocked:
1298
+ reason = _playtest_reason if not _playtest_reason.is_empty() else "session_failed"
1299
+ elif _key_refused:
1300
+ reason = "invalid_key"
1301
+ if reason != _status_reason:
1302
+ _status_reason = reason
1303
+ status_changed.emit(reason)
1304
+
1305
+ func _is_active() -> bool:
1306
+ return enabled and not _player_opted_out and not _killed and not _playtest_blocked and not _no_key and not _key_refused
1307
+
1308
+ ## True only while the SDK is collecting: it has a key, and nobody switched
1309
+ ## it off (enabled, the player, consent = "opt_in" without a yes, the
1310
+ ## server, or a refused key). Use it to skip work that only feeds
1311
+ ## analytics. The same answer as is_tracking_enabled().
1312
+ func is_enabled() -> bool:
1313
+ return _is_active()
1314
+
1315
+ ## --- Public API: switching the SDK on and off ------------------------------------
1316
+
1317
+ ## The game's own switch for this run, never saved (see `enabled`). false
1318
+ ## drops the queue and stops every request; true starts again, running the
1319
+ ## kill switch check and sending game_started if they have not run yet.
1320
+ func set_enabled(value: bool) -> void:
1321
+ if value == enabled:
1322
+ return
1323
+ enabled = value
1324
+ if not _is_ready:
1325
+ return
1326
+ if not value:
1327
+ _clear_queue()
1328
+ _stop_retry()
1329
+ _update_reason()
1330
+ tracking_disabled.emit("init")
1331
+ else:
1332
+ _update_reason()
1333
+ _activate()
1334
+
1335
+ ## --- Public API: player privacy -------------------------------------------------
1336
+ ##
1337
+ ## Ravensight ships no consent UI. If your game shows its own consent or
1338
+ ## privacy screen, or a settings toggle, wire it to the two calls below.
1339
+
1340
+ ## Turns tracking off or on for this player and remembers the choice across
1341
+ ## launches (written at once: the flag file at OPTOUT_PATH, or with consent =
1342
+ ## "opt_in" the yes at OPTIN_PATH). Safe to call at any time, even from an
1343
+ ## earlier autoload, while enabled is false or while inert with no key (the
1344
+ ## choice is saved, nothing starts). While off: no session is opened,
1345
+ ## track_event() drops the event, anything already queued is discarded, and
1346
+ ## nothing is sent, not even the settings check on the next boot. Turning
1347
+ ## it on starts again (with "opt_in", this is the first moment a device id
1348
+ ## is made); events tracked right after the yes are queued while the
1349
+ ## settings check runs. Turning it off again keeps the device id; use
1350
+ ## reset_device_id() to forget it. game_started is sent at most once per
1351
+ ## run. After a refused key the choice is saved and nothing restarts this
1352
+ ## run. A no-op in a playtest run.
1353
+ func set_tracking_enabled(value: bool) -> void:
1354
+ if not playtest_token.is_empty():
1355
+ print("Ravensight: set_tracking_enabled() ignored in a playtest run")
1356
+ return
1357
+ _save_choice(value)
1358
+ if value != _player_opted_out:
1359
+ # Already as asked.
1360
+ return
1361
+ _player_opted_out = not value
1362
+
1363
+ if _player_opted_out:
1364
+ _drop_session()
1365
+ _clear_queue()
1366
+ _stop_retry()
1367
+ print("Ravensight: tracking disabled by player opt-out")
1368
+ _update_reason()
1369
+ # Before ready, _ready() announces it.
1370
+ if _is_ready:
1371
+ tracking_disabled.emit("player")
1372
+ else:
1373
+ if _is_ready and not _no_key and not _key_refused:
1374
+ _load_identity()
1375
+ _update_reason()
1376
+ _activate()
1377
+
1378
+ ## True when events are being collected: the SDK is enabled, the player has
1379
+ ## not opted out, and the server kill switch is on. The same answer as
1380
+ ## reading tracking_enabled.
1381
+ func is_tracking_enabled() -> bool:
1382
+ return _is_active()
1383
+
1384
+ ## Forgets this install's analytics identity: generates a fresh random
1385
+ ## device id and saves it, and drops the current session and the queue, so
1386
+ ## the next tracked event opens a new session under the new id. Returns the
1387
+ ## new id. Wire this to a "reset my analytics identity" setting if you offer
1388
+ ## one. Events already on the server stay under the old id, which nothing
1389
+ ## links back to this player. A no-op in a playtest run, whose id is a
1390
+ ## throwaway that is never saved, and while inert with no key. With consent
1391
+ ## = "opt_in" and no yes, it forgets the saved id and makes none, returning
1392
+ ## "".
1393
+ func reset_device_id() -> String:
1394
+ if not playtest_token.is_empty():
1395
+ print("Ravensight: reset_device_id() ignored in a playtest run")
1396
+ return device_id
1397
+ if _no_key:
1398
+ return device_id
1399
+ if _consent_mode() == CONSENT_OPT_IN and _player_opted_out:
1400
+ _remove_file(DEVICE_ID_PATH)
1401
+ device_id = ""
1402
+ _drop_session()
1403
+ _clear_queue()
1404
+ return ""
1405
+ device_id = _generate_device_id()
1406
+ _write_text(DEVICE_ID_PATH, device_id)
1407
+ _drop_session()
1408
+ _clear_queue()
1409
+ return device_id
1410
+
1411
+ ## Ends the current session without touching the queue. Bumping the request
1412
+ ## generation makes _on_session_response() ignore any session request still
1413
+ ## in flight for the old identity.
1414
+ func _drop_session() -> void:
1415
+ session_token = ""
1416
+ session_expires_at = 0
1417
+ _session_request_pending = false
1418
+ _session_request_generation += 1
1419
+ _config_generation += 1
1420
+ _config_pending = false
1421
+ _config_document = {}
1422
+ _config_scope = ""
1423
+
1424
+ ## Discards every queued event. Bumping _queue_generation makes the response
1425
+ ## to any batch still in flight a no-op (see _on_batch_response()), and the
1426
+ ## in-flight flag is released so events queued from now on can go out without
1427
+ ## waiting for that stale answer.
1428
+ func _clear_queue() -> void:
1429
+ pending_events.clear()
1430
+ _queue_generation += 1
1431
+ _flush_in_progress = false
1432
+ _split_batch_size = 0
1433
+ _session_boundary = 0
1434
+ _first_queued_at = -1.0
1435
+
1436
+ ## The kill switch is off (settings said so, or a 403 tracking_disabled):
1437
+ ## final for the rest of the run. Nothing is queued, sent or retried.
1438
+ func _on_server_tracking_disabled() -> void:
1439
+ _killed = true
1440
+ _drop_session()
1441
+ _clear_queue()
1442
+ _stop_retry()
1443
+ _log_once("killed", "Ravensight: tracking disabled by server kill switch", false)
1444
+ _update_reason()
1445
+ tracking_disabled.emit("server")
1446
+ if _quitting:
1447
+ _finish_quit()
1448
+
1449
+ ## The ingest key was refused (revoked, rotated, wrong). Retrying cannot
1450
+ ## help, so the run ends here: the queue is cleared, nothing more is sent
1451
+ ## or retried, and a later set_tracking_enabled(true) restarts nothing.
1452
+ func _refuse_key() -> void:
1453
+ if _key_refused:
1454
+ return
1455
+ _key_refused = true
1456
+ _drop_session()
1457
+ _clear_queue()
1458
+ _stop_retry()
1459
+ _log_once("invalid_key", INVALID_KEY_MESSAGE, true)
1460
+ _update_reason()
1461
+ tracking_disabled.emit("key")
1462
+ if _quitting:
1463
+ _finish_quit()
1464
+
1465
+ ## The playtest token is bad or the job has closed: neither resolves itself.
1466
+ func _on_playtest_refused(error: String) -> void:
1467
+ _playtest_blocked = true
1468
+ _playtest_reason = "playtest_job_closed" if error == "playtest_job_closed" else "session_failed"
1469
+ _drop_session()
1470
+ _clear_queue()
1471
+ _stop_retry()
1472
+ _log_once("playtest_refused", "Ravensight: playtest run refused (%s), nothing more will be sent" % error, true)
1473
+ _update_reason()
1474
+ if _quitting:
1475
+ _finish_quit()
1476
+
1477
+ func _is_fatal_playtest_error(error: String) -> bool:
1478
+ return error == "invalid_playtest_token" or error == "playtest_job_closed"
1479
+
1480
+ ## --- Queue ---------------------------------------------------------------------------
1481
+
1482
+ ## Queues the event and announces it on event_tracked with the data actually
1483
+ ## queued. An event too large to send is dropped and not announced.
1484
+ func _enqueue_and_emit(event_name: String, data: Dictionary, timestamp: int = -1, at_front: bool = false) -> void:
1485
+ var queued_data = _enqueue(event_name, data, timestamp, at_front)
1486
+ if queued_data is Dictionary:
1487
+ event_tracked.emit(event_name, queued_data)
1488
+
1489
+ ## Queues the event and returns the data dictionary actually stored (a copy
1490
+ ## carrying rs_event_id, plus the playtest tags in a playtest run), or null
1491
+ ## when the data is over MAX_EVENT_BYTES as JSON and the event was dropped.
1492
+ func _enqueue(event_name: String, data: Dictionary, timestamp: int = -1, at_front: bool = false):
1493
+ # Never mutate the caller's dictionary.
1494
+ var event_data := data.duplicate()
1495
+ for assignment in _cached_config().get("experiments", []):
1496
+ event_data["rs_exp_" + assignment["key"]] = assignment["variant"]
1497
+ if not playtest_token.is_empty():
1498
+ event_data.merge(_playtest_tags, true)
1499
+ if not device_id.is_empty():
1500
+ event_data["rs_event_id"] = _next_event_id()
1501
+ if _event_bytes(event_data) > MAX_EVENT_BYTES:
1502
+ _log_once("event_too_large", "Ravensight: dropped event \"%s\": larger than %d bytes as JSON (further drops are not logged)" % [event_name, MAX_EVENT_BYTES], true)
1503
+ flush_failed.emit("event_too_large")
1504
+ return null
1505
+
1506
+ while not pending_events.is_empty() and pending_events.size() >= max_queue_size:
1507
+ # Drop the oldest, but keep a queued game_started at the front.
1508
+ if pending_events.size() > 1 and str(pending_events[0].get("event", "")) == "game_started":
1509
+ _remove_queued(1)
1510
+ else:
1511
+ _remove_queued(0)
1512
+
1513
+ var event := {
1514
+ "event": event_name,
1515
+ "data": event_data,
1516
+ "timestamp": timestamp if timestamp >= 0 else _unix_now(),
1517
+ }
1518
+ if at_front:
1519
+ pending_events.push_front(event)
1520
+ if _session_boundary > 0:
1521
+ _session_boundary += 1
1522
+ else:
1523
+ pending_events.append(event)
1524
+ if _first_queued_at < 0.0:
1525
+ _first_queued_at = _now()
1526
+ return event_data
1527
+
1528
+ ## The serialized size of an event's data, as the server measures it.
1529
+ func _event_bytes(data: Dictionary) -> int:
1530
+ return JSON.stringify(data).to_utf8_buffer().size()
1531
+
1532
+ ## The next rs_event_id: the first 16 characters of the device id, the
1533
+ ## launch time in hex, a per-launch nonce of 8 hex characters and a
1534
+ ## counter, e.g. "3f1c0a9be2d4c711-6b49d200-9e0c11aa-1". It is kept inside
1535
+ ## the event's data, so a resend after a failure carries the same id and the
1536
+ ## server can recognise it.
1537
+ func _next_event_id() -> String:
1538
+ if _event_nonce.is_empty():
1539
+ _event_nonce = _random_nonce()
1540
+ _event_counter += 1
1541
+ var prefix := device_id.substr(0, 16)
1542
+ var unsafe := RegEx.new()
1543
+ unsafe.compile("[^A-Za-z0-9_-]")
1544
+ prefix = unsafe.sub(prefix, "_", true)
1545
+ return "%s-%x-%s-%d" % [prefix, _start_unix, _event_nonce, _event_counter]
1546
+
1547
+ ## Gives every queued event that has none an rs_event_id. Events tracked
1548
+ ## before this install had a device id (an earlier autoload on the very
1549
+ ## first launch) get theirs here, before anything is sent.
1550
+ func _stamp_event_ids() -> void:
1551
+ if device_id.is_empty():
1552
+ return
1553
+ for e in pending_events:
1554
+ var d = e.get("data")
1555
+ if d is Dictionary and not d.has("rs_event_id"):
1556
+ d["rs_event_id"] = _next_event_id()
1557
+
1558
+ func _remove_queued(index: int) -> void:
1559
+ pending_events.remove_at(index)
1560
+ if index < _session_boundary:
1561
+ _session_boundary -= 1
1562
+
1563
+ ## Drops the first `count` queued events (they were delivered or refused).
1564
+ ## When that finishes the old session's share (see _session_boundary), the
1565
+ ## session is dropped so the rest opens a new one.
1566
+ func _drop_front(count: int) -> void:
1567
+ pending_events = pending_events.slice(count, pending_events.size())
1568
+ if _session_boundary > 0:
1569
+ _session_boundary = maxi(0, _session_boundary - count)
1570
+ if _session_boundary == 0:
1571
+ _drop_session()
1572
+
1573
+ ## game_started, once per session: first in the queue at boot, carrying the
1574
+ ## time the SDK was constructed.
1575
+ func _queue_game_started(at_boot: bool) -> void:
1576
+ if _started_queued or not _is_active():
1577
+ return
1578
+ _started_queued = true
1579
+ if at_boot:
1580
+ _enqueue_and_emit("game_started", {}, _start_unix, true)
1581
+ else:
1582
+ _enqueue_and_emit("game_started", {})
1583
+
1584
+ ## game_exited, once.
1585
+ func _queue_game_exited(timestamp: int = -1) -> void:
1586
+ if _exit_queued or not _is_active():
1587
+ return
1588
+ _exit_queued = true
1589
+ _enqueue_and_emit("game_exited", {}, timestamp)
1590
+
1591
+ ## Sends the next batch, opening a session first when there is none.
1592
+ func _flush_pending(explicit: bool = false) -> void:
1593
+ if not _is_ready or not _is_active():
1594
+ return
1595
+ if _flush_in_progress or pending_events.is_empty():
1596
+ return
1597
+ if not _is_session_valid():
1598
+ _start_session(explicit)
1599
+ return
1600
+ if not _can_send_now(explicit):
1601
+ return
1602
+ _flush_in_progress = true
1603
+ _first_queued_at = -1.0
1604
+ _stamp_event_ids()
1605
+
1606
+ var batch_size: int = mini(MAX_BATCH_SIZE, pending_events.size())
1607
+ if _split_batch_size > 0:
1608
+ batch_size = mini(batch_size, _split_batch_size)
1609
+ if _session_boundary > 0:
1610
+ batch_size = mini(batch_size, _session_boundary)
1611
+ var batch: Array = pending_events.slice(0, batch_size)
1612
+
1613
+ _send("POST", "/track/batch", _session_headers(), JSON.stringify({"events": batch}),
1614
+ _on_batch_response.bind(batch_size, _queue_generation))
1615
+
1616
+ func _on_batch_response(code: int, headers: PackedStringArray, body: PackedByteArray, batch_size: int, generation: int) -> void:
1617
+ # The queue this batch was taken from has been discarded since it went
1618
+ # out (see _clear_queue()). Its events are gone, so slicing batch_size
1619
+ # off the front now would drop events queued afterwards; the flag was
1620
+ # already released by the clear.
1621
+ if generation != _queue_generation:
1622
+ return
1623
+ _flush_in_progress = false
1624
+ var error := _extract_error(body, "")
1625
+
1626
+ if code == 202 or code == 200:
1627
+ _drop_front(batch_size)
1628
+ _backoff_seconds = DEFAULT_RETRY_SECONDS
1629
+ _stop_retry()
1630
+ _split_batch_size = 0
1631
+ _reauth_attempts = 0
1632
+ _transient_reason = ""
1633
+ _update_reason()
1634
+ events_flushed.emit(batch_size)
1635
+ if not pending_events.is_empty():
1636
+ _flush_pending(_quitting)
1637
+ elif code == 401:
1638
+ # The session expired or was revoked. Queued events stay; a fresh
1639
+ # session is opened, at most MAX_REAUTH_ATTEMPTS times in a row,
1640
+ # then the backoff takes over.
1641
+ session_token = ""
1642
+ session_expires_at = 0
1643
+ _reauth_attempts += 1
1644
+ if _reauth_attempts <= MAX_REAUTH_ATTEMPTS:
1645
+ push_warning("Ravensight: session invalid or expired, refreshing")
1646
+ _start_session(_quitting)
1647
+ else:
1648
+ flush_failed.emit(error if not error.is_empty() else "http_401")
1649
+ _schedule_retry()
1650
+ elif code == 403 and error == "tracking_disabled":
1651
+ _on_server_tracking_disabled()
1652
+ return
1653
+ elif code == 403 and _is_fatal_playtest_error(error):
1654
+ _on_playtest_refused(error)
1655
+ return
1656
+ elif code == 403:
1657
+ flush_failed.emit("invalid_key")
1658
+ _refuse_key()
1659
+ elif code == 429 or (code == 503 and _extract_retry_after(headers) >= 0.0):
1660
+ var reason := error if not error.is_empty() else "rate_limited"
1661
+ push_warning("Ravensight: track/batch rate-limited (%s)" % reason)
1662
+ _transient_reason = "rate_limited"
1663
+ _update_reason()
1664
+ flush_failed.emit(reason)
1665
+ _schedule_retry(_extract_retry_after(headers))
1666
+ elif code == 400:
1667
+ # The server refuses the whole batch when any event in it violates a
1668
+ # ceiling. Halve until the poison event is isolated, then drop it:
1669
+ # one bloated event must never cost the rest of its batch, and
1670
+ # retrying the identical payload forever would deliver nothing.
1671
+ var reason400 := error if not error.is_empty() else "http_400"
1672
+ if batch_size <= 1:
1673
+ _drop_front(batch_size)
1674
+ _log_once("dropped_400", "Ravensight: dropped one event the server refused (%s)" % reason400, false)
1675
+ flush_failed.emit(reason400)
1676
+ else:
1677
+ _split_batch_size = maxi(1, batch_size / 2)
1678
+ push_warning("Ravensight: batch rejected (%s), splitting to %d" % [reason400, _split_batch_size])
1679
+ _flush_pending(_quitting)
1680
+ else:
1681
+ var reason2 := error if not error.is_empty() else ("offline" if code == 0 else "http_%d" % code)
1682
+ push_warning("Ravensight: batch flush failed (%s), will retry" % reason2)
1683
+ if code == 0:
1684
+ _transient_reason = "offline"
1685
+ _update_reason()
1686
+ flush_failed.emit(reason2)
1687
+ _schedule_retry()
1688
+
1689
+ # Quitting: this answer is what the quit was waiting for. Keep going only
1690
+ # while the queue is still draining (more batches, a split, or a session
1691
+ # refresh); a failure is not retried. The quit deadline bounds the wait.
1692
+ if _quitting:
1693
+ var still_sending := _flush_in_progress or _session_request_pending
1694
+ if not still_sending:
1695
+ _finish_quit()
1696
+
1697
+ ## --- Public API: feedback (POST /api/v1/feedback) --------------------------------
1698
+
1699
+ ## Submit free-form player feedback. `category` is optional and one of
1700
+ ## FEEDBACK_CATEGORIES; `rating` is optional, 0 to omit it or a whole number
1701
+ ## from 1 to 5. Anything else fails at once on feedback_failed
1702
+ ## ("invalid_category" or "invalid_rating") and nothing is sent.
1703
+ func submit_feedback(message: String, category: String = "", rating: int = 0) -> void:
1704
+ if not _is_active():
1705
+ feedback_failed.emit("disabled")
1706
+ return
1707
+ if message.is_empty():
1708
+ push_warning("Ravensight: feedback message is empty")
1709
+ feedback_failed.emit("empty_message")
1710
+ return
1711
+ if not category.is_empty() and not FEEDBACK_CATEGORIES.has(category):
1712
+ push_warning("Ravensight: feedback category '%s' is not one of bug, suggestion, complaint, praise, playtest, other" % category)
1713
+ feedback_failed.emit("invalid_category")
1714
+ return
1715
+ if rating != 0 and (rating < 1 or rating > 5):
1716
+ push_warning("Ravensight: feedback rating must be 1 to 5, or 0 to omit it")
1717
+ feedback_failed.emit("invalid_rating")
1718
+ return
1719
+ if not _is_session_valid():
1720
+ push_warning("Ravensight: no valid session yet, cannot submit feedback")
1721
+ feedback_failed.emit("no_session")
1722
+ _start_session()
1723
+ return
1724
+
1725
+ var payload := {"message": message}
1726
+ if not category.is_empty():
1727
+ payload["category"] = category
1728
+ if rating != 0:
1729
+ payload["rating"] = rating
1730
+ _send("POST", "/feedback", _session_headers(), JSON.stringify(payload), _on_feedback_response)
1731
+
1732
+ func _on_feedback_response(code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
1733
+ var error := _extract_error(body, "")
1734
+ if code == 201:
1735
+ feedback_submitted.emit()
1736
+ return
1737
+ push_warning("Ravensight: feedback submission failed (HTTP %d)" % code)
1738
+ feedback_failed.emit(error if not error.is_empty() else ("offline" if code == 0 else "http_%d" % code))
1739
+ if code == 403 and error == "tracking_disabled":
1740
+ _on_server_tracking_disabled()
1741
+ elif code == 403 and _is_fatal_playtest_error(error):
1742
+ _on_playtest_refused(error)
1743
+ elif code == 403:
1744
+ _refuse_key()
1745
+ elif code == 401:
1746
+ # The session expired; the next send opens a new one.
1747
+ session_token = ""
1748
+ session_expires_at = 0
1749
+
1750
+ ## --- EXPERIMENTAL: agent suggestions (GET /api/v1/agent/suggestions) -------------
1751
+ ##
1752
+ ## This hits an AI-generated suggestions endpoint that is still evolving on
1753
+ ## the server side (empty array until your game has accumulated enough data
1754
+ ## for weekly digests). Shape may change; treat this as experimental and
1755
+ ## don't build critical game logic around it. While tracking is off nothing
1756
+ ## is requested and suggestions_received fires with an empty list.
1757
+
1758
+ func fetch_suggestions() -> void:
1759
+ if not _is_ready or not _is_active():
1760
+ # Deferred, so a caller that awaits the signal after calling still
1761
+ # receives it.
1762
+ call_deferred("_emit_no_suggestions")
1763
+ return
1764
+ _send("GET", "/agent/suggestions", _key_headers(), "", _on_suggestions_response)
1765
+
1766
+ func _emit_no_suggestions() -> void:
1767
+ suggestions_received.emit([])
1768
+
1769
+ func _on_suggestions_response(code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
1770
+ if code == 200:
1771
+ var parsed = _parse_json(body)
1772
+ if parsed is Dictionary and parsed.has("suggestions") and parsed["suggestions"] is Array:
1773
+ suggestions_received.emit(parsed["suggestions"])
1774
+ else:
1775
+ suggestions_received.emit([])
1776
+ return
1777
+ push_warning("Ravensight: fetch_suggestions failed (HTTP %d)" % code)
1778
+ suggestions_received.emit([])
1779
+ var error := _extract_error(body, "")
1780
+ if code == 403 and error == "tracking_disabled":
1781
+ _on_server_tracking_disabled()
1782
+ elif code == 403 and _is_fatal_playtest_error(error):
1783
+ _on_playtest_refused(error)
1784
+ elif code == 401 or code == 403:
1785
+ _refuse_key()
1786
+
1787
+ ## --- Helpers ------------------------------------------------------------------
1788
+
1789
+ func _parse_json(body: PackedByteArray):
1790
+ var json := JSON.new()
1791
+ var err := json.parse(body.get_string_from_utf8())
1792
+ if err != OK:
1793
+ return null
1794
+ return json.data
1795
+
1796
+ func _extract_error(body: PackedByteArray, fallback: String) -> String:
1797
+ var parsed = _parse_json(body)
1798
+ if parsed is Dictionary and parsed.has("error"):
1799
+ return str(parsed["error"])
1800
+ return fallback
1801
+
1802
+ ## Parses a Retry-After header (delta-seconds form) if present. Returns -1.0
1803
+ ## when absent so callers fall back to their own exponential backoff.
1804
+ func _extract_retry_after(headers: PackedStringArray) -> float:
1805
+ for h in headers:
1806
+ if h.to_lower().begins_with("retry-after:"):
1807
+ var parts := h.split(":", true, 1)
1808
+ if parts.size() == 2:
1809
+ var val := parts[1].strip_edges()
1810
+ if val.is_valid_int():
1811
+ return float(int(val))
1812
+ return -1.0
1813
+
1814
+ ## Logs a message the first time `key` is seen this run, at warning level
1815
+ ## when `warning` is true, otherwise as a plain print.
1816
+ func _log_once(key: String, message: String, warning: bool) -> void:
1817
+ if _logged_once.has(key):
1818
+ return
1819
+ _logged_once[key] = true
1820
+ if warning:
1821
+ push_warning(message)
1822
+ else:
1823
+ print(message)