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.
- package/README.md +319 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +120 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +233 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +647 -70
- package/src/commands/init.js +19 -3
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +25 -6
- package/src/commands/punch.js +116 -0
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/tape.js +74 -0
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- 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)
|