@typetorch/framework 0.6.0 → 0.6.2

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 CHANGED
@@ -369,6 +369,9 @@ TypeTorch.runDetached(() => store.StartSessionAsync(`Player_${userId}`)).then((p
369
369
  // Server: intents (upsert by user + kind + key; offline players by UserId), batched and swap-safe.
370
370
  notify.want(userIdOrPlayer, "gift_new", { key: letterId, until: expiresAt, params: { gift: "5 coins" }, data: `mail:${letterId}` });
371
371
  notify.drop(userIdOrPlayer, "streak", "20034", "played"); // no key: every key of the kind
372
+ // The kinds the player switched off in your settings: REPLACES their set ([] = all on), at most 32 names. The backend
373
+ // skips those intents whoever issued them (pref_off) and keeps them, so switching a kind back on resumes it.
374
+ notify.setOff(player, ["daily", "quests"]);
372
375
  trove.add(notify.onOpen((player, open) => openMail(player, open.data))); // open: { nid, kind, data, ageSeconds, first }
373
376
  // Client: Roblox's opt-in prompt, once per session (show your own explainer first). Yields until it closes.
374
377
  const result = TypeTorch.notifications().promptOptIn("streak_2"); // "shown" | "unavailable" | "error" | "busy" | "already"
@@ -541,7 +544,7 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
541
544
  when the player is alone) intersected with the players there, not a web call per player.
542
545
 
543
546
  **Settings, framework 0.4.0 / kernel 0.4.0 (plans/21):** the record's `backend` = `{ url, key, analytics?: { flushSeconds,
544
- recordShare, joinLaunchPrefix } }` (`typetorch backend setup`) is the whole sink: DuckDB at `<url>/v1/ingest` with the one key (Basin
547
+ recordShare, joinLaunchPrefix, joinData } }` (`typetorch backend setup`) is the whole sink: DuckDB at `<url>/v1/ingest` with the one key (Basin
545
548
  identities to `<url>/v1/identity`). The old `analytics` value below is read while `backend` is missing, and stays the
546
549
  way to a Basin sink. The engine also tells the kernel each player's pid (`setAnalyticsId`: the kernel's error reports
547
550
  count affected players by pid, never by name) and counts its own DataStore and HTTP requests for the budget view (dev
@@ -564,6 +567,12 @@ there are no settings unless the game passes `new AnalyticsEngine({ settings })`
564
567
  Basin Pipelines Send permission, if the stream requires authentication). Without `recordings` nothing is recorded.
565
568
  The streams' schemas must match SCHEMA.md exactly: Basin drops rows that don't, silently.
566
569
  - `duckdb`: one `POST <events>` with `{"events":[...],"recordings":[...]}`, gzip, `Authorization: Bearer <token>`.
570
+ - Join row settings (also in `backend.analytics`):
571
+
572
+ | Setting | Default | Meaning |
573
+ |---|---|---|
574
+ | `joinData` | `"full"` | `"full"`: the join row's summary props plus `jd`, a privacy-safe copy of the whole `GetJoinData()` (at most 1 KB: launch data up to 200 bytes, place / game ids, the join context as text, the TeleportData's size, type and keys but never its content, Members / referrer / followed user as pids or `"other"`, unknown fields by type; plans/30). `"summary"`: no `jd`. `"off"`: no `jd` and only the join props from before plans/29 (`from`, `ctx`, `tp`, `party`, age, prem, country, friends, ret). Anything else warns and means `"full"`. Games that put personal data in launch data should use `"summary"` |
575
+ | `joinLaunchPrefix` | `false` | `true`: the summary `launch` keeps the first 32 characters of the game's own launch data (`jd.LaunchData` follows `joinData`, not this) |
567
576
  - The token is write-only and never logged. Needs **Allow HTTP Requests** (Game Settings > Security). No key: the
568
577
  engine collects but keeps only the newest 1,000 rows until settings appear. Removing the key stops sending, live.
569
578
 
@@ -57,7 +57,7 @@ First-ever-session detail, one row per packed chunk.
57
57
 
58
58
  | kind | name | props |
59
59
  |---|---|---|
60
- | session | `join` | `from`: `direct` (Home, search, sorts: Roblox doesn't say), `teleport` (same game), `teleport_game` (another game), `referral` (invite/referral link), `notification` (a TypeTorch notification's launch data verified), `share` (other launch data), `follow` (followed someone); join metadata (plans/29): `src_place?`, `src_game?` (SourcePlaceId / SourceGameId), `ref?` / `follow?` (the referrer / followed user as their pid when this game knows them, else `"other"`: never a UserId; players in the server are free, others cost one DataStore read each, at most 2 per join and only with read budget to spare), `party?` (players teleported along) + `party_pids?` (the pids of those this game knows, at most 10), `ctx?`, `ctx_item?`, `ctx_asset?`, `ctx_outfit?`, `ctx_asset_type?` (GameJoinContext JoinSource / ItemType / AssetId / OutfitId / AssetType), `launch?` (`notification` + `nid`, `nk` (kind); `tt_bad` + `launch_bad` (`format`, `sig`, `user`, `old`); or the game's own launch data: `launch_len`, plus its first 32 chars in `launch` only with the settings' `joinLaunchPrefix`), `tp?` + `tp_bytes?` (TeleportData present, its JSON size; never the content), `age_days` (exact account age), `locale?`, `verified?` (HasVerifiedBadge), `membership` (MembershipType name); `age` (account age: `<1d`, `1-7d`, `7-30d`, `30-90d`, `90-365d`, `1-3y`, `3y+`); `prem` (Premium); `country?` (ISO code); `friends` (how many of the player's friends are in the server: one friend-list lookup per join, at most 10 pages, `0` when it fails); `ret` (days since the last visit, `-1` first visit or unknown); `late?` (the engine started over 60 s after the join). `t` is the join time |
60
+ | session | `join` | `from`: `direct` (Home, search, sorts: Roblox doesn't say), `teleport` (same game), `teleport_game` (another game), `referral` (invite/referral link), `notification` (a TypeTorch notification's launch data verified), `share` (other launch data), `follow` (followed someone); join metadata (plans/29): `src_place?`, `src_game?` (SourcePlaceId / SourceGameId), `ref?` / `follow?` (the referrer / followed user as their pid when this game knows them, else `"other"`: never a UserId; players in the server are free, others cost one DataStore read each, at most 2 per join and only with read budget to spare), `party?` (players teleported along) + `party_pids?` (the pids of those this game knows, at most 10), `ctx?`, `ctx_item?`, `ctx_asset?`, `ctx_outfit?`, `ctx_asset_type?` (GameJoinContext JoinSource / ItemType / AssetId / OutfitId / AssetType), `launch?` (`notification` + `nid`, `nk` (kind); `tt_bad` + `launch_bad` (`format`, `sig`, `user`, `old`); or the game's own launch data: `launch_len`, plus its first 32 chars in `launch` only with the settings' `joinLaunchPrefix`), `tp?` + `tp_bytes?` (TeleportData present, its JSON size; never the content), `age_days` (exact account age), `locale?`, `verified?` (HasVerifiedBadge), `membership` (MembershipType name); `age` (account age: `<1d`, `1-7d`, `7-30d`, `30-90d`, `90-365d`, `1-3y`, `3y+`); `prem` (Premium); `country?` (ISO code); `friends` (how many of the player's friends are in the server: one friend-list lookup per join, at most 10 pages, `0` when it fails); `ret` (days since the last visit, `-1` first visit or unknown); `late?` (the engine started over 60 s after the join); `jd?` (plans/30, with the settings' `joinData` `"full"`, the default: the raw join data, below). With `joinData` `"off"` only `from`, `ctx`, `tp`, `party`, `age`, `prem`, `country`, `friends`, `ret`, `late` are written. `t` is the join time |
61
61
  | session | `device` (src client) | `dev?` (the device class, as the `dev` column; frameworks after 0.4.2, older ones leave it out), `input` (`kbm`, `touch`, `gamepad`, `vr`, `unknown`), `w`, `h` (viewport points), `touch`, `kb`, `mouse`, `pad`, `vr`. Once per session. The class (Roblox gives game scripts no OS): `vr` when VRService.VREnabled; `console` when GuiService:IsTenFootInterface(); touch without a keyboard: `tablet` when the viewport's short side is 600 points or more, else `phone` (the first hello waits up to 2 s for a real viewport); a keyboard or mouse: `desktop` (touch laptops, tablets with a keyboard); else `unknown` |
62
62
  | notify | `want`, `drop` | Notifications (plans/29), written by the game server: `k` (kind), `key?` (first 12 hex of SHA-256 of the intent's key; never the key), `at?`, `until?`, `offline` (want: the user isn't in this server; the row then has no pid), `why?` (drop) |
63
63
  | notify | `optin_check`, `optin_shown`, `optin_closed` | The client's opt-in prompt, checked and written by the server (at most 3 of each per session): `where`, `can` (check), `err?` (`timeout`, `swap` or the error's first 64 chars), `secs` (closed: how long it was up) |
@@ -162,10 +162,31 @@ repo's `bun run local` does it). Every new signed copy applies at once, so dials
162
162
  | `experiments` | none | Per experiment: `active: false` (everyone gets the first variant, not stamped), `weights` (per variant, in the game's order), `variant` (force one), `players` (`{ [UserId or pid]: variant }`, at most 200: forces one player's variant over everything else, stamped `forced`; each client's hello gets only its own entry). The CLI writes them: `typetorch experiment` |
163
163
  | `identity` | the settings' `fleet` url + `/v1/identity` | basin: where identity rows go (below); none and no `fleet` in the settings: not sent. duckdb: unused |
164
164
  | `identityToken` | the settings' `fleet` token | basin: the token for `identity`. Never logged |
165
+ | `joinData` | `"full"` | `"full"`: the join row carries `jd` (below); `"summary"`: no `jd`; `"off"`: no `jd` and only the pre-plans/29 join props (no pid lookups). Anything else is an error line (warned) and means `"full"`. In `backend.analytics` (or the old `analytics` value) |
165
166
  | `joinLaunchPrefix` | false | true: the join row's `launch` keeps the first 32 characters of the game's own launch data (off: only `launch_len`). Set it in `backend.analytics` (or the old `analytics` value) |
166
167
 
167
168
  Unknown fields are ignored. Settings errors are warned once (never with the token).
168
169
 
170
+ ## The raw join data (`jd`, plans/30)
171
+
172
+ The `session` / `join` row's `jd` prop mirrors `Player:GetJoinData()` field by field, privacy-safe, at most 1024 bytes of
173
+ JSON (written by the framework's own encoder, so an empty table stays `{}` where the shape says object):
174
+
175
+ | field | content |
176
+ |---|---|
177
+ | `keys` | The top-level keys Roblox returned, sorted (each cut to 32 characters) |
178
+ | `LaunchData?`, `LaunchDataLen?` | The launch data cut to 200 bytes at a UTF-8 boundary, and its full byte length; present iff the key is |
179
+ | `SourcePlaceId?`, `SourceGameId?` | Numbers, present iff the keys are |
180
+ | `GameJoinContext?` | Present iff the key is: every field as text (EnumItems by name), `{}` when empty |
181
+ | `TeleportData?` | `{ bytes?, type, keys? }`: its JSON size, type and top-level keys (at most 20, 32 characters each, sorted); never the content |
182
+ | `Members?` | `{ count, pids? }`: each member's pid when this game knows them (players in the server, or one of the join's at most 2 budget-checked DataStore reads shared with the summary props), else `"other"`; the joining player by their own pid |
183
+ | `ReferredByPlayerId?`, `FollowUserId?` | pid or `"other"`; absent for 0 |
184
+ | `other?` | Any other top-level key: its type name only, never the value |
185
+ | `trunc?` | `true` when cut to fit: TeleportData keys first (then the list), Members pids (then the list), LaunchData (shorter), GameJoinContext values (64 each), `other` |
186
+
187
+ No other player's UserId is ever in `jd`. Example (Roblox dropped a notification's launch data):
188
+ `{"keys":["GameJoinContext","LaunchData"],"LaunchData":"","LaunchDataLen":0,"GameJoinContext":{}}`.
189
+
169
190
  ## Transport
170
191
 
171
192
  - **basin:** `POST <events>` with a JSON array of event rows and `POST <recordings>` with a JSON array of recording
@@ -55,7 +55,15 @@ export interface JoinOptions {
55
55
  * `joinLaunchPrefix`, default off: launch data can carry anything a link maker put there). `launch_len` is always kept.
56
56
  */
57
57
  launchPrefix?: boolean;
58
+ /**
59
+ * The analytics settings' `joinData` (plans/30). "off": only what the join row carried before plans/29 (`from`,
60
+ * `ctx`, `tp`, `party`; the engine adds age, prem, country, friends, ret). "full" / "summary": every summary prop.
61
+ */
62
+ mode?: JoinDataMode;
58
63
  }
64
+ /** `joinData` (plans/30): "full" (default: the summary props + `jd`), "summary" (no `jd`), "off" (see JoinOptions.mode). */
65
+ export type JoinDataMode = "full" | "summary" | "off";
66
+ export declare const JOIN_DATA_MODES: ReadonlyArray<JoinDataMode>;
59
67
  /** Party pids kept at most. */
60
68
  export declare const PARTY_PIDS_MAX = 10;
61
69
  /** The game's own launch data: this many characters are kept. */
@@ -67,3 +75,60 @@ export declare function wantedUserIds(data: JoinDataLike | undefined, facts: Joi
67
75
  * undefined.
68
76
  */
69
77
  export declare function joinProps(data: JoinDataLike | undefined, facts: JoinFacts, pidOf: (userId: number) => string | undefined, launch: LaunchVerdict, options?: JoinOptions): Record<string, unknown>;
78
+ /** Bytes of the `jd` JSON at most (props hold 4 KB; the join row's other props stay well under the rest). */
79
+ export declare const JD_MAX_BYTES = 1024;
80
+ /** LaunchData bytes kept (Roblox's own limit for launch data). */
81
+ export declare const JD_LAUNCH_MAX = 200;
82
+ /** TeleportData: top-level keys listed at most, and characters per key. */
83
+ export declare const JD_TP_KEYS_MAX = 20;
84
+ export declare const JD_KEY_CHARS = 32;
85
+ /** GameJoinContext values are cut to this when `jd` is over the cap. */
86
+ export declare const JD_CONTEXT_CUT = 64;
87
+ /** `text` cut to at most `max` bytes without splitting a UTF-8 sequence. */
88
+ export declare function cutUtf8(text: string, max: number): string;
89
+ interface JdTeleport {
90
+ bytes?: number;
91
+ type: string;
92
+ keys?: string[];
93
+ }
94
+ interface JdMembers {
95
+ count: number;
96
+ pids?: string[];
97
+ }
98
+ /** The `jd` value (see plans/30-join-data-raw.md for the shape). */
99
+ export interface JoinDataJson {
100
+ keys: string[];
101
+ LaunchData?: string;
102
+ LaunchDataLen?: number;
103
+ SourcePlaceId?: number;
104
+ SourceGameId?: number;
105
+ GameJoinContext?: Record<string, string>;
106
+ TeleportData?: JdTeleport;
107
+ Members?: JdMembers;
108
+ ReferredByPlayerId?: string;
109
+ FollowUserId?: string;
110
+ other?: Record<string, string>;
111
+ trunc?: boolean;
112
+ }
113
+ /**
114
+ * `Player:GetJoinData()` field by field, privacy-safe (plans/30): the launch data (200 bytes), place / game ids, the
115
+ * join context as text, the TeleportData's size, type and top-level keys (never its content), Members / referrer /
116
+ * followed user as pids from `pidOf` (the same lookups as the summary props) else "other", and unknown keys by type
117
+ * only. At most JD_MAX_BYTES of JSON as `encodeJoinData` writes it (`encode`: HttpService.JSONEncode in game, only to
118
+ * size the TeleportData; `quote`: a string as JSON), cut in the plan's order with `trunc`. Not a table (GetJoinData
119
+ * failed): undefined.
120
+ */
121
+ export declare function joinDataJson(raw: unknown, followUserId: number, pidOf: (userId: number) => string | undefined, encode: (value: unknown) => string, quote: (text: string) => string): JoinDataJson | undefined;
122
+ /**
123
+ * `jd` as JSON text, field order fixed. Written here rather than by HttpService.JSONEncode, which turns an empty table
124
+ * into `[]`: the plan's `GameJoinContext: {}` (present but empty, the evidence case) stays an object, and lists stay
125
+ * lists.
126
+ */
127
+ export declare function encodeJoinData(jd: JoinDataJson, quote: (text: string) => string): string;
128
+ /**
129
+ * Cuts `jd` to JD_MAX_BYTES of JSON, in the plan's order: TeleportData.keys (then the field), Members.pids (then the
130
+ * field), LaunchData (shorter), GameJoinContext values (each to 64), `other` (entry by entry); `trunc: true` once cut.
131
+ * Still over (an absurd join context): only `keys` (shortened), the ids, LaunchDataLen and `trunc` stay.
132
+ */
133
+ export declare function capJoinData(jd: JoinDataJson, quote: (text: string) => string): JoinDataJson;
134
+ export {};
@@ -14,6 +14,10 @@
14
14
  --* What the engine reads from GetJoinData (enums already turned into names).
15
15
  --* The joining player and this server.
16
16
  --* The launch data's verdict (notifications/token.ts verifyToken): undefined when it isn't a TypeTorch token.
17
+ --* `joinData` (plans/30): "full" (default: the summary props + `jd`), "summary" (no `jd`), "off" (see JoinOptions.mode).
18
+ local JOIN_DATA_MODES = { "full", "summary", "off" }
19
+ --* The props the join row had before plans/29 (joinData "off" keeps only these from joinProps).
20
+ local LEGACY_PROPS = { "from", "ctx", "tp", "party" }
17
21
  --* Party pids kept at most.
18
22
  local PARTY_PIDS_MAX = 10
19
23
  --* The game's own launch data: this many characters are kept.
@@ -168,11 +172,427 @@ local function joinProps(data, facts, pidOf, launch, options)
168
172
  if facts.membership ~= nil then
169
173
  info.membership = facts.membership
170
174
  end
175
+ if options.mode == "off" then
176
+ local legacy = {}
177
+ for _, name in LEGACY_PROPS do
178
+ if info[name] ~= nil then
179
+ legacy[name] = info[name]
180
+ end
181
+ end
182
+ return legacy
183
+ end
171
184
  return info
172
185
  end
186
+ -- The raw join data: `jd` (plans/30) -----------------------------------------------------------------------------------
187
+ --* Bytes of the `jd` JSON at most (props hold 4 KB; the join row's other props stay well under the rest).
188
+ local JD_MAX_BYTES = 1024
189
+ --* LaunchData bytes kept (Roblox's own limit for launch data).
190
+ local JD_LAUNCH_MAX = 200
191
+ --* TeleportData: top-level keys listed at most, and characters per key.
192
+ local JD_TP_KEYS_MAX = 20
193
+ local JD_KEY_CHARS = 32
194
+ --* GameJoinContext values are cut to this when `jd` is over the cap.
195
+ local JD_CONTEXT_CUT = 64
196
+ --* GetJoinData's keys this builder knows; anything else is reported by type only (`other`).
197
+ local KNOWN_KEYS = {
198
+ ["LaunchData"] = true,
199
+ ["SourcePlaceId"] = true,
200
+ ["SourceGameId"] = true,
201
+ ["GameJoinContext"] = true,
202
+ ["TeleportData"] = true,
203
+ ["Members"] = true,
204
+ ["ReferredByPlayerId"] = true,
205
+ }
206
+ --* The value's type name: `typeof` in game (Instance, EnumItem, Vector3 ...), the Lua type under Lune.
207
+ local function typeName(value)
208
+ local _value = value
209
+ return typeof(_value)
210
+ end
211
+ --* `text` cut to at most `max` bytes without splitting a UTF-8 sequence.
212
+ local function cutUtf8(text, max)
213
+ if #text <= max then
214
+ return text
215
+ end
216
+ local size = math.max(0, max)
217
+ -- A continuation byte (10xxxxxx) right after the cut means the cut splits a character: step back to its lead byte.
218
+ while size > 0 do
219
+ local following = string.byte(text, size + 1)
220
+ if following == nil or bit32.band(following, 0xc0) ~= 0x80 then
221
+ break
222
+ end
223
+ size -= 1
224
+ end
225
+ local _text = text
226
+ local _size = size
227
+ return string.sub(_text, 1, _size)
228
+ end
229
+ --* A GameJoinContext field as text: EnumItems by `.Name`, strings as they are, everything else by tostring / its type.
230
+ local function fieldText(value)
231
+ local _value = value
232
+ if typeof(_value) == "EnumItem" then
233
+ return value.Name
234
+ end
235
+ local _value_1 = value
236
+ if type(_value_1) == "string" then
237
+ return value
238
+ end
239
+ local _value_2 = value
240
+ local _condition = type(_value_2) == "number"
241
+ if not _condition then
242
+ local _value_3 = value
243
+ _condition = type(_value_3) == "boolean"
244
+ end
245
+ if _condition then
246
+ return tostring(value)
247
+ end
248
+ return typeName(value)
249
+ end
250
+ local function sortedStringKeys(value)
251
+ local keys = {}
252
+ for key in pairs(value) do
253
+ if type(key) == "string" then
254
+ table.insert(keys, key)
255
+ end
256
+ end
257
+ table.sort(keys)
258
+ return keys
259
+ end
260
+ --* The `jd` value (see plans/30-join-data-raw.md for the shape).
261
+ --[[
262
+ *
263
+ * `Player:GetJoinData()` field by field, privacy-safe (plans/30): the launch data (200 bytes), place / game ids, the
264
+ * join context as text, the TeleportData's size, type and top-level keys (never its content), Members / referrer /
265
+ * followed user as pids from `pidOf` (the same lookups as the summary props) else "other", and unknown keys by type
266
+ * only. At most JD_MAX_BYTES of JSON as `encodeJoinData` writes it (`encode`: HttpService.JSONEncode in game, only to
267
+ * size the TeleportData; `quote`: a string as JSON), cut in the plan's order with `trunc`. Not a table (GetJoinData
268
+ * failed): undefined.
269
+
270
+ ]]
271
+ local capJoinData
272
+ local function joinDataJson(raw, followUserId, pidOf, encode, quote)
273
+ local _raw = raw
274
+ if not (type(_raw) == "table") then
275
+ return nil
276
+ end
277
+ local data = raw
278
+ local keys = {}
279
+ for key in pairs(data) do
280
+ local _arg0 = if type(key) == "string" then string.sub(key, 1, JD_KEY_CHARS) else typeName(key)
281
+ table.insert(keys, _arg0)
282
+ end
283
+ table.sort(keys)
284
+ local jd = {
285
+ keys = keys,
286
+ }
287
+ local mapped = function(userId)
288
+ local _condition = pidOf(userId)
289
+ if _condition == nil then
290
+ _condition = "other"
291
+ end
292
+ return _condition
293
+ end
294
+ local other = {}
295
+ local others = 0
296
+ for key, value in pairs(data) do
297
+ if not (type(key) == "string") or not (KNOWN_KEYS[key] ~= nil) then
298
+ other[if type(key) == "string" then string.sub(key, 1, JD_KEY_CHARS) else typeName(key)] = typeName(value)
299
+ others += 1
300
+ end
301
+ end
302
+ local launch = data.LaunchData
303
+ if launch ~= nil then
304
+ if type(launch) == "string" then
305
+ jd.LaunchData = cutUtf8(launch, JD_LAUNCH_MAX)
306
+ jd.LaunchDataLen = #launch
307
+ else
308
+ other.LaunchData = typeName(launch)
309
+ others += 1
310
+ end
311
+ end
312
+ for _, name in { "SourcePlaceId", "SourceGameId" } do
313
+ local value = data[name]
314
+ if value == nil then
315
+ continue
316
+ end
317
+ if type(value) == "number" then
318
+ jd[name] = value
319
+ else
320
+ other[name] = typeName(value)
321
+ others += 1
322
+ end
323
+ end
324
+ local context = data.GameJoinContext
325
+ if context ~= nil then
326
+ if type(context) == "table" then
327
+ local fields = {}
328
+ for name, value in pairs(context) do
329
+ fields[if type(name) == "string" then string.sub(name, 1, JD_KEY_CHARS) else typeName(name)] = fieldText(value)
330
+ end
331
+ jd.GameJoinContext = fields
332
+ else
333
+ other.GameJoinContext = typeName(context)
334
+ others += 1
335
+ end
336
+ end
337
+ local teleport = data.TeleportData
338
+ if teleport ~= nil then
339
+ local info = {
340
+ type = typeName(teleport),
341
+ }
342
+ local encodedOk, encoded = pcall(function()
343
+ return encode(teleport)
344
+ end)
345
+ if encodedOk and type(encoded) == "string" then
346
+ info.bytes = #encoded
347
+ end
348
+ if type(teleport) == "table" then
349
+ local names = sortedStringKeys(teleport)
350
+ local cut = {}
351
+ for _, name in names do
352
+ if #cut >= JD_TP_KEYS_MAX then
353
+ break
354
+ end
355
+ local _arg0 = string.sub(name, 1, JD_KEY_CHARS)
356
+ table.insert(cut, _arg0)
357
+ end
358
+ info.keys = cut
359
+ end
360
+ jd.TeleportData = info
361
+ end
362
+ local members = data.Members
363
+ if members ~= nil then
364
+ if type(members) == "table" then
365
+ local pids = {}
366
+ local count = 0
367
+ for _, member in pairs(members) do
368
+ count += 1
369
+ local _arg0 = if type(member) == "number" then mapped(member) else "other"
370
+ table.insert(pids, _arg0)
371
+ end
372
+ jd.Members = {
373
+ count = count,
374
+ pids = pids,
375
+ }
376
+ else
377
+ other.Members = typeName(members)
378
+ others += 1
379
+ end
380
+ end
381
+ local referrer = data.ReferredByPlayerId
382
+ if type(referrer) == "number" then
383
+ if referrer > 0 then
384
+ jd.ReferredByPlayerId = mapped(referrer)
385
+ end
386
+ elseif referrer ~= nil then
387
+ other.ReferredByPlayerId = typeName(referrer)
388
+ others += 1
389
+ end
390
+ if followUserId > 0 then
391
+ jd.FollowUserId = mapped(followUserId)
392
+ end
393
+ if others > 0 then
394
+ jd.other = other
395
+ end
396
+ return capJoinData(jd, quote)
397
+ end
398
+ local function jsonNumber(value)
399
+ if value == value and value % 1 == 0 and math.abs(value) < 2 ^ 53 then
400
+ return string.format("%d", value)
401
+ end
402
+ return if value == value and math.abs(value) ~= math.huge then string.format("%.17g", value) else "null"
403
+ end
404
+ local function jsonStrings(list, quote)
405
+ -- ▼ ReadonlyArray.map ▼
406
+ local _newValue = table.create(#list)
407
+ local _callback = function(item)
408
+ return quote(item)
409
+ end
410
+ for _k, _v in list do
411
+ _newValue[_k] = _callback(_v, _k - 1, list)
412
+ end
413
+ -- ▲ ReadonlyArray.map ▲
414
+ return `[{table.concat(_newValue, ",")}]`
415
+ end
416
+ local function jsonObject(value, quote)
417
+ local names = sortedStringKeys(value)
418
+ -- ▼ ReadonlyArray.map ▼
419
+ local _newValue = table.create(#names)
420
+ local _callback = function(name)
421
+ return `{quote(name)}:{quote(value[name])}`
422
+ end
423
+ for _k, _v in names do
424
+ _newValue[_k] = _callback(_v, _k - 1, names)
425
+ end
426
+ -- ▲ ReadonlyArray.map ▲
427
+ return `\{{table.concat(_newValue, ",")}\}`
428
+ end
429
+ --[[
430
+ *
431
+ * `jd` as JSON text, field order fixed. Written here rather than by HttpService.JSONEncode, which turns an empty table
432
+ * into `[]`: the plan's `GameJoinContext: {}` (present but empty, the evidence case) stays an object, and lists stay
433
+ * lists.
434
+
435
+ ]]
436
+ local function encodeJoinData(jd, quote)
437
+ local parts = {}
438
+ local _arg0 = `"keys":{jsonStrings(jd.keys, quote)}`
439
+ table.insert(parts, _arg0)
440
+ if jd.LaunchData ~= nil then
441
+ local _arg0_1 = `"LaunchData":{quote(jd.LaunchData)}`
442
+ table.insert(parts, _arg0_1)
443
+ end
444
+ if jd.LaunchDataLen ~= nil then
445
+ local _arg0_1 = `"LaunchDataLen":{jsonNumber(jd.LaunchDataLen)}`
446
+ table.insert(parts, _arg0_1)
447
+ end
448
+ if jd.SourcePlaceId ~= nil then
449
+ local _arg0_1 = `"SourcePlaceId":{jsonNumber(jd.SourcePlaceId)}`
450
+ table.insert(parts, _arg0_1)
451
+ end
452
+ if jd.SourceGameId ~= nil then
453
+ local _arg0_1 = `"SourceGameId":{jsonNumber(jd.SourceGameId)}`
454
+ table.insert(parts, _arg0_1)
455
+ end
456
+ if jd.GameJoinContext ~= nil then
457
+ local _arg0_1 = `"GameJoinContext":{jsonObject(jd.GameJoinContext, quote)}`
458
+ table.insert(parts, _arg0_1)
459
+ end
460
+ local teleport = jd.TeleportData
461
+ if teleport ~= nil then
462
+ local fields = {}
463
+ if teleport.bytes ~= nil then
464
+ local _arg0_1 = `"bytes":{jsonNumber(teleport.bytes)}`
465
+ table.insert(fields, _arg0_1)
466
+ end
467
+ local _arg0_1 = `"type":{quote(teleport.type)}`
468
+ table.insert(fields, _arg0_1)
469
+ if teleport.keys ~= nil then
470
+ local _arg0_2 = `"keys":{jsonStrings(teleport.keys, quote)}`
471
+ table.insert(fields, _arg0_2)
472
+ end
473
+ local _arg0_2 = `"TeleportData":\{{table.concat(fields, ",")}\}`
474
+ table.insert(parts, _arg0_2)
475
+ end
476
+ local members = jd.Members
477
+ if members ~= nil then
478
+ local pids = if members.pids ~= nil then `,"pids":{jsonStrings(members.pids, quote)}` else ""
479
+ local _arg0_1 = `"Members":\{"count":{jsonNumber(members.count)}{pids}\}`
480
+ table.insert(parts, _arg0_1)
481
+ end
482
+ if jd.ReferredByPlayerId ~= nil then
483
+ local _arg0_1 = `"ReferredByPlayerId":{quote(jd.ReferredByPlayerId)}`
484
+ table.insert(parts, _arg0_1)
485
+ end
486
+ if jd.FollowUserId ~= nil then
487
+ local _arg0_1 = `"FollowUserId":{quote(jd.FollowUserId)}`
488
+ table.insert(parts, _arg0_1)
489
+ end
490
+ if jd.other ~= nil then
491
+ local _arg0_1 = `"other":{jsonObject(jd.other, quote)}`
492
+ table.insert(parts, _arg0_1)
493
+ end
494
+ if jd.trunc == true then
495
+ local _arg0_1 = `"trunc":true`
496
+ table.insert(parts, _arg0_1)
497
+ end
498
+ return `\{{table.concat(parts, ",")}\}`
499
+ end
500
+ --[[
501
+ *
502
+ * Cuts `jd` to JD_MAX_BYTES of JSON, in the plan's order: TeleportData.keys (then the field), Members.pids (then the
503
+ * field), LaunchData (shorter), GameJoinContext values (each to 64), `other` (entry by entry); `trunc: true` once cut.
504
+ * Still over (an absurd join context): only `keys` (shortened), the ids, LaunchDataLen and `trunc` stay.
505
+
506
+ ]]
507
+ function capJoinData(jd, quote)
508
+ local bytes = function()
509
+ return #encodeJoinData(jd, quote)
510
+ end
511
+ local over = function()
512
+ return bytes() > JD_MAX_BYTES
513
+ end
514
+ if not over() then
515
+ return jd
516
+ end
517
+ jd.trunc = true
518
+ local teleport = jd.TeleportData
519
+ if teleport and teleport.keys then
520
+ while #teleport.keys > 0 and over() do
521
+ local _exp = teleport.keys
522
+ _exp[#_exp] = nil
523
+ end
524
+ if over() then
525
+ teleport.keys = nil
526
+ end
527
+ end
528
+ local members = jd.Members
529
+ if members and members.pids and over() then
530
+ while #members.pids > 0 and over() do
531
+ local _exp = members.pids
532
+ _exp[#_exp] = nil
533
+ end
534
+ if over() then
535
+ members.pids = nil
536
+ end
537
+ end
538
+ if jd.LaunchData ~= nil and over() then
539
+ local size = #jd.LaunchData
540
+ while size > 0 and over() do
541
+ size = math.max(0, size - math.max(8, math.min(64, bytes() - JD_MAX_BYTES)))
542
+ jd.LaunchData = cutUtf8(jd.LaunchData, size)
543
+ size = #jd.LaunchData
544
+ end
545
+ end
546
+ local context = jd.GameJoinContext
547
+ if context and over() then
548
+ for name, value in pairs(context) do
549
+ context[name] = cutUtf8(value, JD_CONTEXT_CUT)
550
+ end
551
+ end
552
+ local other = jd.other
553
+ if other and over() then
554
+ for name in pairs(other) do
555
+ if not over() then
556
+ break
557
+ end
558
+ other[name] = nil
559
+ end
560
+ if (next(other)) == nil then
561
+ jd.other = nil
562
+ end
563
+ end
564
+ if not over() then
565
+ return jd
566
+ end
567
+ local minimal = {
568
+ keys = {},
569
+ LaunchDataLen = jd.LaunchDataLen,
570
+ SourcePlaceId = jd.SourcePlaceId,
571
+ SourceGameId = jd.SourceGameId,
572
+ trunc = true,
573
+ }
574
+ for _, key in jd.keys do
575
+ if #minimal.keys >= 16 then
576
+ break
577
+ end
578
+ local _exp = minimal.keys
579
+ table.insert(_exp, key)
580
+ end
581
+ return minimal
582
+ end
173
583
  return {
174
584
  wantedUserIds = wantedUserIds,
175
585
  joinProps = joinProps,
586
+ cutUtf8 = cutUtf8,
587
+ joinDataJson = joinDataJson,
588
+ encodeJoinData = encodeJoinData,
589
+ capJoinData = capJoinData,
590
+ JOIN_DATA_MODES = JOIN_DATA_MODES,
176
591
  PARTY_PIDS_MAX = PARTY_PIDS_MAX,
177
592
  LAUNCH_KEEP = LAUNCH_KEEP,
593
+ JD_MAX_BYTES = JD_MAX_BYTES,
594
+ JD_LAUNCH_MAX = JD_LAUNCH_MAX,
595
+ JD_TP_KEYS_MAX = JD_TP_KEYS_MAX,
596
+ JD_KEY_CHARS = JD_KEY_CHARS,
597
+ JD_CONTEXT_CUT = JD_CONTEXT_CUT,
178
598
  }
@@ -121,6 +121,11 @@ export interface AnalyticsSettings {
121
121
  identityToken?: string;
122
122
  /** Keep the first 32 chars of the game's own launch data on the join row (`launch`); default off (`launch_len` always). */
123
123
  joinLaunchPrefix?: boolean;
124
+ /**
125
+ * plans/30: "full" (default: the join row's summary props and `jd`, the raw join data privacy-safe), "summary" (no
126
+ * `jd`), "off" (no `jd` and only the join props from before plans/29). Anything else warns and means "full".
127
+ */
128
+ joinData?: "full" | "summary" | "off";
124
129
  }
125
130
  /** AnalyticsSettings with every default filled in. */
126
131
  export interface ResolvedSettings {
@@ -135,6 +140,7 @@ export interface ResolvedSettings {
135
140
  identity?: string;
136
141
  identityToken?: string;
137
142
  joinLaunchPrefix: boolean;
143
+ joinData: "full" | "summary" | "off";
138
144
  }
139
145
  export type AnalyticsValue = string | number | boolean;
140
146
  /** Event properties: a flat JSON object (nested tables work but are harder to query). */