@typetorch/framework 0.4.0 → 0.5.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.
Files changed (44) hide show
  1. package/LICENSE +22 -22
  2. package/README.md +22 -10
  3. package/out/analytics/SCHEMA.md +4 -4
  4. package/out/analytics/client.luau +26 -15
  5. package/out/analytics/experiments.d.ts +7 -1
  6. package/out/analytics/experiments.luau +43 -1
  7. package/out/analytics/protocol.d.ts +21 -0
  8. package/out/analytics/protocol.luau +28 -0
  9. package/out/analytics/schema.d.ts +7 -0
  10. package/out/analytics/server.d.ts +1 -0
  11. package/out/analytics/server.luau +16 -10
  12. package/out/analytics/settings.d.ts +2 -0
  13. package/out/analytics/settings.luau +37 -0
  14. package/out/devtools/access.d.ts +71 -0
  15. package/out/devtools/access.luau +140 -0
  16. package/out/devtools/admin-server.d.ts +2 -1
  17. package/out/devtools/admin-server.luau +12 -14
  18. package/out/devtools/claude-ui.luau +52 -5
  19. package/out/devtools/claude.d.ts +6 -1
  20. package/out/devtools/claude.luau +106 -51
  21. package/out/devtools/client.d.ts +0 -5
  22. package/out/devtools/client.luau +245 -82
  23. package/out/devtools/explorer-server.d.ts +4 -2
  24. package/out/devtools/explorer-server.luau +9 -4
  25. package/out/devtools/health.d.ts +18 -2
  26. package/out/devtools/health.luau +54 -13
  27. package/out/devtools/network-inspector.d.ts +7 -0
  28. package/out/devtools/network-inspector.luau +12 -14
  29. package/out/devtools/protocol.d.ts +5 -0
  30. package/out/devtools/remote-debug.d.ts +38 -0
  31. package/out/devtools/remote-debug.luau +250 -0
  32. package/out/devtools/server.d.ts +2 -2
  33. package/out/devtools/server.luau +29 -10
  34. package/out/devtools/toolbox-server.d.ts +8 -6
  35. package/out/devtools/toolbox-server.luau +16 -14
  36. package/out/kernel.d.ts +59 -0
  37. package/out/kernel.luau +26 -0
  38. package/out/runtime/safe-clean.d.ts +11 -0
  39. package/out/runtime/safe-clean.luau +57 -0
  40. package/out/runtime/start.luau +26 -12
  41. package/out/settings.luau +2 -1
  42. package/out/version.d.ts +1 -1
  43. package/out/version.luau +1 -1
  44. package/package.json +1 -1
package/LICENSE CHANGED
@@ -1,22 +1,22 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 TypeTorch contributors
4
- Copyright (c) 2021 Flamework (src/reflection: the Reflect and Modding runtime, adapted from @flamework/core)
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in all
14
- copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TypeTorch contributors
4
+ Copyright (c) 2021 Flamework (src/reflection: the Reflect and Modding runtime, adapted from @flamework/core)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -152,8 +152,18 @@ export class CoinService extends Module implements OnStart {
152
152
  - **Dev menu:** devs (Studio, project members, dev badge) get a DEV button, `Ctrl+Shift+D` and `/tt dev`: artifact
153
153
  (with how long the server and client generations have run, the server's uptime and the build's age), server status,
154
154
  logs (server, own client, other players' clients), client and server dex, network stats, module state, a branch and
155
- build picker (Server > Branch) and a Claude prompt. Prod-channel servers are read-only. The window can be dragged by
156
- its header and resized from its corner (double-tap the header to reset).
155
+ build picker (Server > Branch) and a Claude prompt. The sidebar, top to bottom: Artifact, Server, Network, Modules,
156
+ Manage, Logs, Dex, Claude. The window can be dragged by its header and resized from its corner (double-tap the
157
+ header to reset).
158
+ - **Server TPS (framework 0.4.2, kernel 0.4.2):** Server > Status has a *TPS* line next to Memory and Lua heap: the
159
+ last minute's average, its slowest second and the physics FPS (`59.9 avg, 52.0 min, physics 60`; the warning colour
160
+ under 50 on average). Older kernels show `needs kernel 0.4.2`. The same numbers ride on fleet heartbeats (the
161
+ explorer's fleet table).
162
+ - **Dev-only tools (framework 0.4.1, `devtools/access.ts`):** Claude, Logs > Upload, Dex edits on the server, Network
163
+ packet blocking and the hot-swap sound work under dev rules (private, reserved and Studio servers on a dev branch)
164
+ for every dev, and on a public server an owner switched to a dev branch (Server > Branch) for owners only. Everywhere
165
+ else the menu is read-only, with a short reason: "Dev branch only", "Owners only on public servers", "Owner-switched
166
+ servers only". The rules themselves don't change (`TypeTorch.channel`, signatures, owner-only rollbacks).
157
167
  - **Panes and windows (dev menu):** every page shows in a pane with a small header: tap the title to pick another
158
168
  page, or use Split right, Split down, Open in new window and Close. Drag the line between two panes to resize them.
159
169
  The sidebar opens pages in the focused pane (the highlighted one; tap a pane to focus it). Floating windows move by
@@ -171,8 +181,8 @@ export class CoinService extends Module implements OnStart {
171
181
  strings (cut), numbers, booleans, nil, datatypes through `tostring`, an Instance as its full path, a Player as its
172
182
  name, `function`, `thread`, buffer sizes. Nothing is ever called (raw reads only: no metamethods, no getters); a
173
183
  value that is its own ancestor shows as a cycle. A key filter (open nodes stay listed), Refresh and Auto (every 2 s).
174
- Server state comes from the op `state.inspect` (rate-limited, size-capped): devs on dev-channel servers, owners only
175
- on prod-effective servers, since server state can hold player data.
184
+ Server state comes from the op `state.inspect` (rate-limited, size-capped): owners everywhere, other devs where the
185
+ dev-only tools are open to them, since server state can hold player data.
176
186
  - **Logs > Upload:** sends the logs shown (Server: this server's log ring; Client: your own client's; Others: the
177
187
  picked player's client) to the dev PC paired in the Claude tab, which saves them under `<repo>/.typetorch/logs/`.
178
188
  No Claude run, no prompt quota. Not paired: "Pair in the Claude tab first".
@@ -182,11 +192,12 @@ export class CoinService extends Module implements OnStart {
182
192
  verified head). A public switch lasts for that server's lifetime and is never stored, so new servers still boot the
183
193
  signed prod head. Others see "Join", which moves only them to a reserved server. Manage > Servers "Load a build..."
184
194
  does the same for this server, the ticked servers or a share of one branch (A/B pins), with one status line under
185
- the list.
195
+ the list. A public server an owner switched to a dev branch opens the dev-only tools (Claude too) to owners.
186
196
  - **Owners and devs:** two roles (no admins: an older kernel's or typetorch.json's "admin" counts as a dev). The Manage
187
197
  group (Players, Servers, Bans) is for owners only.
188
- - **Remote Claude** (dev-channel servers only): while `typetorch remote-claude` (`@typetorch/dev-server`) runs on a dev's machine, allowlisted
189
- devs prompt Claude Code from the Claude tab. Each dev pairs once by pasting the pairing code printed by
198
+ - **Remote Claude** (a dev-only tool: dev rules, or owners on a public server an owner switched to a dev branch):
199
+ while `typetorch remote-claude` (`@typetorch/dev-server`) runs on a dev's machine, allowlisted devs prompt Claude
200
+ Code from the Claude tab. Each dev pairs once by pasting the pairing code printed by
190
201
  typetorch-dev-server; the game server keeps the session URL and the tokens in memory and never sends them to clients.
191
202
  No Roblox secret is needed.
192
203
 
@@ -416,7 +427,7 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
416
427
  row carries a UserId.
417
428
  - **Identities** (option `identity`, default on): once the pid is known, one row `{ pid, uid, t }` (the UserId, nothing
418
429
  else) per session goes to the dev's own server only, never into the events: duckdb games in the batch body, Basin
419
- games to the fleet API (the settings' `fleet` url + `/v1/identity`, or the `identity` / `identityToken` settings). The
430
+ games to the backend (the record's `backend` url + `/v1/identity`, or the `identity` / `identityToken` settings). The
420
431
  analytics server keeps pid -> UserId in a deletable table (support lookups, Right to Erasure).
421
432
  - **Player ids:** DataStore `TypeTorchAnalytics`, key `p/<UserId>` -> `{ pid, first, last }`: one read per join, a
422
433
  write on the first join and at leave. Deleting the key (Right to Erasure) leaves that player's rows anonymous.
@@ -454,8 +465,9 @@ count affected players by pid, never by name) and counts its own DataStore and H
454
465
  menu **Server > Budget**: bars per Roblox limit for the player count, who spends it, the DataStore budget left, memory).
455
466
 
456
467
  **Settings** (server only, never sent to clients): the signed settings record's `analytics` field (kernel 0.3.8,
457
- plans/20), written with `typetorch settings set analytics -` (JSON on stdin; `@typetorch/analytics`' `writeSettings()`
458
- and `bun run local` call it). Every new copy applies live within seconds (the CLI pings servers). Before kernel 0.3.8
468
+ plans/20), derived from `backend` by `typetorch backend setup` (`writeBackendSettings()` in `@typetorch/backend` runs it, and
469
+ `bun run local` too). Every new copy applies live within seconds (the CLI pings servers). A Basin sink has no CLI writer
470
+ since CLI 0.9: `backend setup` replaces it. Before kernel 0.3.8
459
471
  there are no settings unless the game passes `new AnalyticsEngine({ settings })`:
460
472
 
461
473
  ```json
@@ -26,7 +26,7 @@ Integers are written with every digit (never exponent notation).
26
26
  | `seq` | int64 | That artifact's deploy seq (`0` when unknown) |
27
27
  | `branch` | string | Server branch |
28
28
  | `channel` | string | Effective channel: `prod` or `dev` |
29
- | `dev` | string | `desktop`, `phone`, `tablet`, `console`, `vr`, `unknown` (from the client's hello; `unknown` for server-only events and until the client reports) |
29
+ | `dev` | string | The session's device class: `desktop`, `phone`, `tablet`, `console`, `vr`, `unknown`. Set once per session from the client's first hello (`unknown` for server-only events and until the client reports; frameworks after 0.4.2 keep it when a later hello says otherwise). How it is decided: the `session` / `device` row below |
30
30
  | `newp` | bool | The event belongs to the player's first-ever session |
31
31
  | `state` | string | The player's state **after** this event: `zone:<z>\|screen:<s>\|activity:<a>`, empty parts left out (`""` when nothing is known). Server-only events: `activity:<server activity>` or `""` |
32
32
  | `exp` | string | JSON object of the player's active experiments, `{"onboarding":"short"}`; `{}` when none |
@@ -58,7 +58,7 @@ First-ever-session detail, one row per packed chunk.
58
58
  | kind | name | props |
59
59
  |---|---|---|
60
60
  | session | `join` | `from`: `direct` (Home, search, sorts: Roblox doesn't say), `teleport` (same game), `teleport_game` (another game), `referral` (invite/referral link), `share` (launch data), `follow` (followed someone); `ctx?` (GameJoinContext.JoinSource name); `tp?` (had teleport data); `party?` (players teleported along); `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 |
61
- | session | `device` (src client) | `input` (`kbm`, `touch`, `gamepad`, `vr`, `unknown`), `w`, `h` (viewport points), `touch`, `kb`, `mouse`, `pad`, `vr`. Once per session |
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
  | session | `leave` | `secs` (session length), `why`: `left`, `shutdown`, `gone` (left while no generation ran the engine), `swap60?` (left within 60 s of a hot swap) |
63
63
  | tech | `server` | Every `techEvery` s: `fps` (physics), `hb` (Heartbeat rate), `mem` (MB, 10s), `players`, `ping50?`, `ping90?` (ms), `q`, `qr` (queued events, recordings), `dropped`, `sent`, `fails`, `health?`, `errors?` (kernel 0.3.2 health) |
64
64
  | tech | `client` (src client) | Every `techEvery` s: `fps`, `mem` (MB, 10s), `ping?` (ms) |
@@ -142,7 +142,7 @@ repo's `bun run local` does it). Every new signed copy applies at once, so dials
142
142
  "flushSeconds": 15,
143
143
  "recordShare": 1,
144
144
  "techEvery": 60,
145
- "experiments": { "onboarding": { "active": true, "weights": [1, 1], "variant": "short" } }
145
+ "experiments": { "onboarding": { "active": true, "weights": [1, 1], "variant": "short", "players": { "123456": "long" } } }
146
146
  }
147
147
  ```
148
148
 
@@ -155,7 +155,7 @@ repo's `bun run local` does it). Every new signed copy applies at once, so dials
155
155
  | `flushSeconds` | 15 | 5..300 |
156
156
  | `recordShare` | 1 | 0..1, share of new players recorded (stable per pid) |
157
157
  | `techEvery` | 60 | 15..3600 seconds between tech samples |
158
- | `experiments` | none | Per experiment: `active: false` (everyone gets the first variant, not stamped), `weights` (per variant, in the game's order), `variant` (force one) |
158
+ | `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` |
159
159
  | `identity` | the settings' `fleet` url + `/v1/identity` | basin: where identity rows go (below); none and no `fleet` in the settings: not sent. duckdb: unused |
160
160
  | `identityToken` | the settings' `fleet` token | basin: the token for `identity`. Never logged |
161
161
 
@@ -26,6 +26,7 @@ local CHUNK_SECONDS = _protocol.CHUNK_SECONDS
26
26
  local CHUNK_TARGET_BYTES = _protocol.CHUNK_TARGET_BYTES
27
27
  local CLIENT_BATCH_BYTES = _protocol.CLIENT_BATCH_BYTES
28
28
  local CLIENT_BATCH_MAX = _protocol.CLIENT_BATCH_MAX
29
+ local deviceClass = _protocol.deviceClass
29
30
  local isEventName = _protocol.isEventName
30
31
  local _props = TS.import(script, script.Parent, "props")
31
32
  local encodeProps = _props.encodeProps
@@ -60,23 +61,28 @@ local IGNORED_GUIS = {
60
61
  }
61
62
  --* A screen that closes and opens again within this many seconds (ResetOnSpawn) never left.
62
63
  local SCREEN_DEBOUNCE = 1
63
- local function deviceInfo()
64
+ --* The camera's viewport in points (0 x 0 without a camera).
65
+ local function viewportSize()
64
66
  local camera = Workspace.CurrentCamera
65
- local viewport = if camera then camera.ViewportSize else Vector2.new(0, 0)
67
+ return if camera then camera.ViewportSize else Vector2.new(0, 0)
68
+ end
69
+ --* Seconds the first hello waits for a real viewport (a camera reports 1 x 1 for a moment at startup).
70
+ local VIEWPORT_WAIT = 2
71
+ local function deviceInfo()
72
+ local viewport = viewportSize()
66
73
  local vr = VRService.VREnabled
67
74
  local consoleOk, isConsole = pcall(function()
68
75
  return GuiService:IsTenFootInterface()
69
76
  end)
70
- local dev = "unknown"
71
- if vr then
72
- dev = "vr"
73
- elseif consoleOk and isConsole then
74
- dev = "console"
75
- elseif UserInputService.TouchEnabled and not UserInputService.KeyboardEnabled then
76
- dev = if math.min(viewport.X, viewport.Y) >= 600 then "tablet" else "phone"
77
- elseif UserInputService.KeyboardEnabled or UserInputService.MouseEnabled then
78
- dev = "desktop"
79
- end
77
+ local dev = deviceClass({
78
+ vr = vr,
79
+ tenFoot = consoleOk and isConsole == true,
80
+ touch = UserInputService.TouchEnabled,
81
+ keyboard = UserInputService.KeyboardEnabled,
82
+ mouse = UserInputService.MouseEnabled,
83
+ w = viewport.X,
84
+ h = viewport.Y,
85
+ })
80
86
  local last = UserInputService:GetLastInputType()
81
87
  local input = "unknown"
82
88
  if vr then
@@ -698,7 +704,7 @@ do
698
704
  end
699
705
  end)
700
706
  if not ok then
701
- warn("[src/analytics/client.ts:506]", `[analytics] server message failed: {string.sub(tostring(err), 1, 200)}`)
707
+ warn("[src/analytics/client.ts:518]", `[analytics] server message failed: {string.sub(tostring(err), 1, 200)}`)
702
708
  end
703
709
  end)
704
710
  trove:add(function()
@@ -805,6 +811,11 @@ do
805
811
  end
806
812
  function ClientAnalytics:startSending()
807
813
  self.trove:add(task.spawn(function()
814
+ -- The device class reads the viewport (phone vs tablet): give the camera a moment to report a real size.
815
+ local waitUntil = os.clock() + VIEWPORT_WAIT
816
+ while math.min(viewportSize().X, viewportSize().Y) <= 1 and os.clock() < waitUntil and not self.stopped do
817
+ task.wait(0.1)
818
+ end
808
819
  -- Hello until the server answers (its engine may start later than this one): every 5 s for a minute, then
809
820
  -- every 30 s.
810
821
  local tries = 0
@@ -839,7 +850,7 @@ do
839
850
  end
840
851
  function ClientAnalytics:track(kind, name, props)
841
852
  if not isEventName(name) then
842
- warn("[src/analytics/client.ts:622]", `[analytics] {kind} event name must be 1-64 characters: {string.sub(tostring(name), 1, 80)}`)
853
+ warn("[src/analytics/client.ts:637]", `[analytics] {kind} event name must be 1-64 characters: {string.sub(tostring(name), 1, 80)}`)
843
854
  return nil
844
855
  end
845
856
  self:push(kind, name, encodeProps(props))
@@ -897,7 +908,7 @@ do
897
908
  end
898
909
  local control = _condition
899
910
  if not isExperimentName(name) or not isVariantList(variants) then
900
- warn("[src/analytics/client.ts:656]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
911
+ warn("[src/analytics/client.ts:671]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
901
912
  return control
902
913
  end
903
914
  local deadline = os.clock() + 10
@@ -19,10 +19,16 @@ export interface Assignment {
19
19
  /**
20
20
  * The variant of experiment `name` for player `pid`. The first variant is the control: everyone gets it while the
21
21
  * experiment is switched off (`active: false`). `weights` split players unevenly (`[9, 1]`: 10% get the second);
22
- * `variant` forces one for everyone. The point in [0, 1) is unitHash(`<name>:<pid>`), so changing weights moves as few
22
+ * `variant` forces one for everyone; `player` (one player's entry of `players`, see `playerOverride`) forces one for
23
+ * this player and wins over the rest. The point in [0, 1) is unitHash(`<name>:<pid>`), so changing weights moves as few
23
24
  * players as possible.
24
25
  */
25
26
  export declare function assignVariant(pid: string, name: string, variants: string[], override?: ExperimentOverride): Assignment;
27
+ /**
28
+ * One player's view of an override: `players` resolved to `player` (by UserId, else pid) and dropped, so a client's
29
+ * hello never carries other players' ids.
30
+ */
31
+ export declare function playerOverride(override: ExperimentOverride | undefined, userId: number | undefined, pid: string): ExperimentOverride | undefined;
26
32
  /** Whether player `pid` is in the recorded share (0..1) of new players. Stable per player. */
27
33
  export declare function inShare(pid: string, share: number): boolean;
28
34
  /** A valid experiment name: 1-64 chars, letters, digits, `_`, `-`, `.`, `/`. */
@@ -44,7 +44,8 @@ end
44
44
  *
45
45
  * The variant of experiment `name` for player `pid`. The first variant is the control: everyone gets it while the
46
46
  * experiment is switched off (`active: false`). `weights` split players unevenly (`[9, 1]`: 10% get the second);
47
- * `variant` forces one for everyone. The point in [0, 1) is unitHash(`<name>:<pid>`), so changing weights moves as few
47
+ * `variant` forces one for everyone; `player` (one player's entry of `players`, see `playerOverride`) forces one for
48
+ * this player and wins over the rest. The point in [0, 1) is unitHash(`<name>:<pid>`), so changing weights moves as few
48
49
  * players as possible.
49
50
 
50
51
  ]]
@@ -61,6 +62,18 @@ local function assignVariant(pid, name, variants, override)
61
62
  forced = false,
62
63
  }
63
64
  end
65
+ local _mine = override
66
+ if _mine ~= nil then
67
+ _mine = _mine.player
68
+ end
69
+ local mine = _mine
70
+ if type(mine) == "string" and table.find(variants, mine) ~= nil then
71
+ return {
72
+ variant = mine,
73
+ active = true,
74
+ forced = true,
75
+ }
76
+ end
64
77
  local _result = override
65
78
  if _result ~= nil then
66
79
  _result = _result.active
@@ -129,6 +142,34 @@ local function assignVariant(pid, name, variants, override)
129
142
  forced = false,
130
143
  }
131
144
  end
145
+ --[[
146
+ *
147
+ * One player's view of an override: `players` resolved to `player` (by UserId, else pid) and dropped, so a client's
148
+ * hello never carries other players' ids.
149
+
150
+ ]]
151
+ local function playerOverride(override, userId, pid)
152
+ if override == nil then
153
+ return nil
154
+ end
155
+ local result = {
156
+ active = override.active,
157
+ weights = override.weights,
158
+ variant = override.variant,
159
+ }
160
+ local players = override.players
161
+ if players ~= nil then
162
+ local _condition = (if userId ~= nil then players[tostring(userId)] else nil)
163
+ if _condition == nil then
164
+ _condition = players[pid]
165
+ end
166
+ local mine = _condition
167
+ if mine ~= nil then
168
+ result.player = mine
169
+ end
170
+ end
171
+ return result
172
+ end
132
173
  --* Whether player `pid` is in the recorded share (0..1) of new players. Stable per player.
133
174
  local function inShare(pid, share)
134
175
  if share >= 1 then
@@ -182,6 +223,7 @@ return {
182
223
  hash32 = hash32,
183
224
  unitHash = unitHash,
184
225
  assignVariant = assignVariant,
226
+ playerOverride = playerOverride,
185
227
  inShare = inShare,
186
228
  isExperimentName = isExperimentName,
187
229
  isVariantList = isVariantList,
@@ -35,6 +35,27 @@ export interface HelloInfo {
35
35
  /** Seconds from the client starting to the engine starting (first client generation only). */
36
36
  load?: number;
37
37
  }
38
+ /** What the client knows about its device when it says hello (UserInputService, VRService, GuiService, the camera). */
39
+ export interface DeviceFacts {
40
+ vr: boolean;
41
+ /** GuiService:IsTenFootInterface(): a console (Xbox, PlayStation). */
42
+ tenFoot: boolean;
43
+ touch: boolean;
44
+ keyboard: boolean;
45
+ mouse: boolean;
46
+ /** Viewport size in points (0 when the camera has none yet). */
47
+ w: number;
48
+ h: number;
49
+ }
50
+ /** A touch-only device whose viewport's short side is at least this many points is a tablet, else a phone. */
51
+ export declare const TABLET_MIN_SHORT_SIDE = 600;
52
+ /**
53
+ * The session's device class (the `dev` column), in this order: VR headset; console (ten-foot interface); touch without
54
+ * a keyboard: tablet when the viewport's short side is 600 points or more, else phone (also when the viewport isn't
55
+ * known); a keyboard or mouse: desktop (a touch laptop or a tablet with a keyboard counts here); else unknown. Roblox
56
+ * gives game scripts no OS, so this is the closest a game gets.
57
+ */
58
+ export declare function deviceClass(f: DeviceFacts): DeviceKind;
38
59
  /** [ageMs, kind, name, propsJson] */
39
60
  export type ClientEvent = [
40
61
  age: number,
@@ -22,6 +22,32 @@ local CLIENT_BATCH_BYTES = 12000
22
22
  local CHUNK_TARGET_BYTES = 6000
23
23
  local CHUNK_MAX_BYTES = 16384
24
24
  local CHUNK_SECONDS = 10
25
+ --* What the client knows about its device when it says hello (UserInputService, VRService, GuiService, the camera).
26
+ --* A touch-only device whose viewport's short side is at least this many points is a tablet, else a phone.
27
+ local TABLET_MIN_SHORT_SIDE = 600
28
+ --[[
29
+ *
30
+ * The session's device class (the `dev` column), in this order: VR headset; console (ten-foot interface); touch without
31
+ * a keyboard: tablet when the viewport's short side is 600 points or more, else phone (also when the viewport isn't
32
+ * known); a keyboard or mouse: desktop (a touch laptop or a tablet with a keyboard counts here); else unknown. Roblox
33
+ * gives game scripts no OS, so this is the closest a game gets.
34
+
35
+ ]]
36
+ local function deviceClass(f)
37
+ if f.vr then
38
+ return "vr"
39
+ end
40
+ if f.tenFoot then
41
+ return "console"
42
+ end
43
+ if f.touch and not f.keyboard then
44
+ return if math.min(f.w, f.h) >= TABLET_MIN_SHORT_SIDE then "tablet" else "phone"
45
+ end
46
+ if f.keyboard or f.mouse then
47
+ return "desktop"
48
+ end
49
+ return "unknown"
50
+ end
25
51
  --* [ageMs, kind, name, propsJson]
26
52
  local DEVICES = {
27
53
  ["desktop"] = true,
@@ -127,6 +153,7 @@ local function cleanClientEvent(value)
127
153
  return { math.floor(age), kind, name, props }
128
154
  end
129
155
  return {
156
+ deviceClass = deviceClass,
130
157
  cleanHello = cleanHello,
131
158
  isEventName = isEventName,
132
159
  cleanClientEvent = cleanClientEvent,
@@ -136,5 +163,6 @@ return {
136
163
  CHUNK_TARGET_BYTES = CHUNK_TARGET_BYTES,
137
164
  CHUNK_MAX_BYTES = CHUNK_MAX_BYTES,
138
165
  CHUNK_SECONDS = CHUNK_SECONDS,
166
+ TABLET_MIN_SHORT_SIDE = TABLET_MIN_SHORT_SIDE,
139
167
  CLIENT_KINDS = CLIENT_KINDS,
140
168
  }
@@ -86,6 +86,13 @@ export interface ExperimentOverride {
86
86
  weights?: number[];
87
87
  /** Everyone gets this variant (must be one of the game's variants). */
88
88
  variant?: string;
89
+ /**
90
+ * Per-player forced variants, keyed by UserId (`"123456"`) or pid (32 hex): wins over `active`, `variant` and
91
+ * `weights` for that player (QA, support). Server-only: a client gets only its own entry, as `player`.
92
+ */
93
+ players?: Record<string, string>;
94
+ /** This player's forced variant (resolved from `players` by `playerOverride`; what a client's hello carries). */
95
+ player?: string;
89
96
  }
90
97
  /** The sink settings: the signed settings' `analytics` (kernel 0.3.8, server-only), or `new AnalyticsEngine({ settings })`. */
91
98
  export interface AnalyticsSettings {
@@ -101,6 +101,7 @@ export declare class ServerAnalytics {
101
101
  /** Activity: per player, or server-wide (every player without their own) when `player` is undefined. */
102
102
  setActivity(player: Player | undefined, activity: string | undefined): void;
103
103
  setScreen(session: PlayerSession, screen: string, src: "server" | "client", t?: number, props?: string): void;
104
+ /** The overrides as this player sees them (their own `players` entry only; hello). */
104
105
  private overrides;
105
106
  /** The player's variant (stamped on their later events while active). May yield until the player's pid loads. */
106
107
  experiment(player: Player, name: string, variants: string[]): string;
@@ -28,6 +28,7 @@ local assignVariant = _experiments.assignVariant
28
28
  local inShare = _experiments.inShare
29
29
  local isExperimentName = _experiments.isExperimentName
30
30
  local isVariantList = _experiments.isVariantList
31
+ local playerOverride = _experiments.playerOverride
31
32
  local _protocol = TS.import(script, script.Parent, "protocol")
32
33
  local CHANNEL = _protocol.CHANNEL
33
34
  local CHUNK_MAX_BYTES = _protocol.CHUNK_MAX_BYTES
@@ -586,11 +587,11 @@ do
586
587
  end
587
588
  _self:emit(_exp, "state", "screen", _condition, src, t)
588
589
  end
589
- function ServerAnalytics:overrides()
590
+ function ServerAnalytics:overrides(player, session)
590
591
  local result = {}
591
592
  if self.settings then
592
593
  for name, override in self.settings.experiments do
593
- result[name] = override
594
+ result[name] = playerOverride(override, player.UserId, session.pid)
594
595
  end
595
596
  end
596
597
  return result
@@ -602,7 +603,7 @@ do
602
603
  end
603
604
  local control = _condition
604
605
  if not isExperimentName(name) or not isVariantList(variants) then
605
- warn("[src/analytics/server.ts:522]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
606
+ warn("[src/analytics/server.ts:523]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
606
607
  return control
607
608
  end
608
609
  local session = self:sessionOf(player)
@@ -641,7 +642,7 @@ do
641
642
  local _name_1 = name
642
643
  _result = _experiments_1[_name_1]
643
644
  end
644
- local assignment = assignVariant(_exp_1, _exp_2, _exp_3, _result)
645
+ local assignment = assignVariant(_exp_1, _exp_2, _exp_3, playerOverride(_result, player.UserId, session.pid))
645
646
  local _exp_4 = session.exp
646
647
  local _name_1 = name
647
648
  local current = _exp_4[_name_1]
@@ -667,7 +668,7 @@ do
667
668
  function ServerAnalytics:applySettings(parsed)
668
669
  local errorsText = table.concat(parsed.errors, "; ")
669
670
  if errorsText ~= table.concat(self.settingsErrors, "; ") and errorsText ~= "" then
670
- warn("[src/analytics/server.ts:557]", `[analytics] the sink settings (settings.backend or settings.analytics): {errorsText}`)
671
+ warn("[src/analytics/server.ts:558]", `[analytics] the sink settings (settings.backend or settings.analytics): {errorsText}`)
671
672
  end
672
673
  self.settingsErrors = parsed.errors
673
674
  local hadSettings = self.settings ~= nil
@@ -697,7 +698,7 @@ do
697
698
  -- `new AnalyticsEngine({ settings })`.
698
699
  if not settingsSupported() then
699
700
  if self.kernel ~= nil and self.kernel.test ~= true then
700
- warn("[src/analytics/server.ts:584]", `[analytics] no sink settings: they come from the signed settings record (kernel 0.3.8+, "typetorch backend setup"); this server runs kernel {self.kernel.kernelVersion}. Or pass new AnalyticsEngine(\{ settings \})`)
701
+ warn("[src/analytics/server.ts:585]", `[analytics] no sink settings: they come from the signed settings record (kernel 0.3.8+, "typetorch backend setup"); this server runs kernel {self.kernel.kernelVersion}. Or pass new AnalyticsEngine(\{ settings \})`)
701
702
  end
702
703
  self.settingsTried = true
703
704
  return nil
@@ -1273,7 +1274,7 @@ do
1273
1274
  _condition = DEFAULT_TECH_EVERY
1274
1275
  end
1275
1276
  _object[_left] = _condition
1276
- _object.exps = self:overrides()
1277
+ _object.exps = self:overrides(player, session)
1277
1278
  local hello = _object
1278
1279
  dispatcher.kernel:send(player, CHANNEL, "hi", hello)
1279
1280
  end
@@ -1284,9 +1285,14 @@ do
1284
1285
  end
1285
1286
  local first = session.device == nil
1286
1287
  session.device = info
1287
- session.dev = info.dev
1288
+ -- The device class is the session's: set by the first hello and kept (a later hello after a hot swap, or a keyboard
1289
+ -- plugged into a tablet, doesn't move the session's rows to another class), unless the first one didn't know.
1290
+ if first or session.dev == "unknown" then
1291
+ session.dev = info.dev
1292
+ end
1288
1293
  if first and self.options.sessions ~= false then
1289
1294
  self:emit(session, "session", "device", encodeProps({
1295
+ dev = info.dev,
1290
1296
  input = info.input,
1291
1297
  w = info.w,
1292
1298
  h = info.h,
@@ -1494,7 +1500,7 @@ do
1494
1500
  end
1495
1501
  end)
1496
1502
  if not ok then
1497
- warn("[src/analytics/server.ts:1075]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1503
+ warn("[src/analytics/server.ts:1078]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1498
1504
  end
1499
1505
  end)
1500
1506
  self.trove:add(function()
@@ -1777,7 +1783,7 @@ do
1777
1783
  self.lastWarnAt = now
1778
1784
  local more = if self.suppressed > 0 then ` (+{self.suppressed} more in the last minute)` else ""
1779
1785
  self.suppressed = 0
1780
- warn("[src/analytics/server.ts:1270]", `[analytics] {message}{more}`)
1786
+ warn("[src/analytics/server.ts:1273]", `[analytics] {message}{more}`)
1781
1787
  return true
1782
1788
  end
1783
1789
  function ServerAnalytics:noteSuccess()
@@ -13,6 +13,8 @@ export interface ParsedSettings {
13
13
  settings?: ResolvedSettings;
14
14
  errors: string[];
15
15
  }
16
+ /** Per-player overrides one experiment may hold (`players`). */
17
+ export declare const EXPERIMENT_PLAYERS_MAX = 200;
16
18
  /**
17
19
  * Reads the settings value: a table (the configs API stores JSON objects) or a JSON string. `undefined` (no key) gives
18
20
  * no settings and no errors. Unknown fields are ignored.
@@ -35,6 +35,19 @@ local function clampNumber(value, fallback, low, high, label, errors)
35
35
  end
36
36
  return math.clamp(value, low, high)
37
37
  end
38
+ --* Per-player overrides one experiment may hold (`players`).
39
+ local EXPERIMENT_PLAYERS_MAX = 200
40
+ --* A `players` key: a UserId (digits) or a pid (32 lowercase hex).
41
+ local function isPlayerKey(key)
42
+ local _key = key
43
+ if not (type(_key) == "string") then
44
+ return false
45
+ end
46
+ if #key >= 1 and #key <= 19 and (string.match(key, "^%d+$")) ~= nil then
47
+ return true
48
+ end
49
+ return #key == 32 and (string.match(key, "^[0-9a-f]+$")) ~= nil
50
+ end
38
51
  local function parseExperiments(value, errors)
39
52
  local result = {}
40
53
  if value == nil then
@@ -103,6 +116,29 @@ local function parseExperiments(value, errors)
103
116
  table.insert(_errors, _arg0)
104
117
  end
105
118
  end
119
+ if entry.players ~= nil then
120
+ local players = {}
121
+ local _players = entry.players
122
+ local ok = type(_players) == "table"
123
+ local count = 0
124
+ if ok then
125
+ for key, variant in pairs(entry.players) do
126
+ count += 1
127
+ if not isPlayerKey(key) or not (type(variant) == "string") or #variant < 1 or #variant > 32 then
128
+ ok = false
129
+ else
130
+ players[key] = variant
131
+ end
132
+ end
133
+ end
134
+ if ok and count <= EXPERIMENT_PLAYERS_MAX then
135
+ override.players = players
136
+ else
137
+ local _errors = errors
138
+ local _arg0 = `experiments.{name}.players must map UserIds or pids to variant names (at most {EXPERIMENT_PLAYERS_MAX})`
139
+ table.insert(_errors, _arg0)
140
+ end
141
+ end
106
142
  result[name] = override
107
143
  end
108
144
  return result
@@ -326,4 +362,5 @@ return {
326
362
  recordsSupported = recordsSupported,
327
363
  DEFAULT_FLUSH_SECONDS = DEFAULT_FLUSH_SECONDS,
328
364
  DEFAULT_TECH_EVERY = DEFAULT_TECH_EVERY,
365
+ EXPERIMENT_PLAYERS_MAX = EXPERIMENT_PLAYERS_MAX,
329
366
  }