@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 +10 -1
- package/out/analytics/SCHEMA.md +22 -1
- package/out/analytics/join.d.ts +65 -0
- package/out/analytics/join.luau +420 -0
- package/out/analytics/schema.d.ts +6 -0
- package/out/analytics/server.luau +79 -31
- package/out/analytics/settings.d.ts +0 -7
- package/out/analytics/settings.luau +11 -0
- package/out/devtools/admin-ui.d.ts +6 -1
- package/out/devtools/admin-ui.luau +3 -0
- package/out/devtools/client.luau +13 -3
- package/out/devtools/palette-commands.d.ts +4 -2
- package/out/devtools/palette-commands.luau +223 -24
- package/out/devtools/palette-rules.d.ts +27 -0
- package/out/devtools/palette-rules.luau +118 -0
- package/out/kernel.d.ts +1 -0
- package/out/notifications/api.d.ts +9 -0
- package/out/notifications/client.d.ts +1 -0
- package/out/notifications/client.luau +7 -3
- package/out/notifications/protocol.d.ts +18 -1
- package/out/notifications/protocol.luau +86 -1
- package/out/notifications/server.d.ts +1 -0
- package/out/notifications/server.luau +16 -2
- package/out/typetorch.d.ts +1 -1
- package/out/typetorch.luau +5 -2
- package/package.json +1 -1
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
|
|
package/out/analytics/SCHEMA.md
CHANGED
|
@@ -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
|
package/out/analytics/join.d.ts
CHANGED
|
@@ -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 {};
|
package/out/analytics/join.luau
CHANGED
|
@@ -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). */
|