@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.
- package/LICENSE +22 -22
- package/README.md +22 -10
- package/out/analytics/SCHEMA.md +4 -4
- package/out/analytics/client.luau +26 -15
- package/out/analytics/experiments.d.ts +7 -1
- package/out/analytics/experiments.luau +43 -1
- package/out/analytics/protocol.d.ts +21 -0
- package/out/analytics/protocol.luau +28 -0
- package/out/analytics/schema.d.ts +7 -0
- package/out/analytics/server.d.ts +1 -0
- package/out/analytics/server.luau +16 -10
- package/out/analytics/settings.d.ts +2 -0
- package/out/analytics/settings.luau +37 -0
- package/out/devtools/access.d.ts +71 -0
- package/out/devtools/access.luau +140 -0
- package/out/devtools/admin-server.d.ts +2 -1
- package/out/devtools/admin-server.luau +12 -14
- package/out/devtools/claude-ui.luau +52 -5
- package/out/devtools/claude.d.ts +6 -1
- package/out/devtools/claude.luau +106 -51
- package/out/devtools/client.d.ts +0 -5
- package/out/devtools/client.luau +245 -82
- package/out/devtools/explorer-server.d.ts +4 -2
- package/out/devtools/explorer-server.luau +9 -4
- package/out/devtools/health.d.ts +18 -2
- package/out/devtools/health.luau +54 -13
- package/out/devtools/network-inspector.d.ts +7 -0
- package/out/devtools/network-inspector.luau +12 -14
- package/out/devtools/protocol.d.ts +5 -0
- package/out/devtools/remote-debug.d.ts +38 -0
- package/out/devtools/remote-debug.luau +250 -0
- package/out/devtools/server.d.ts +2 -2
- package/out/devtools/server.luau +29 -10
- package/out/devtools/toolbox-server.d.ts +8 -6
- package/out/devtools/toolbox-server.luau +16 -14
- package/out/kernel.d.ts +59 -0
- package/out/kernel.luau +26 -0
- package/out/runtime/safe-clean.d.ts +11 -0
- package/out/runtime/safe-clean.luau +57 -0
- package/out/runtime/start.luau +26 -12
- package/out/settings.luau +2 -1
- package/out/version.d.ts +1 -1
- package/out/version.luau +1 -1
- 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.
|
|
156
|
-
its header and resized from its corner (double-tap the
|
|
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):
|
|
175
|
-
|
|
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-
|
|
189
|
-
|
|
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
|
|
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),
|
|
458
|
-
|
|
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
|
package/out/analytics/SCHEMA.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
64
|
+
--* The camera's viewport in points (0 x 0 without a camera).
|
|
65
|
+
local function viewportSize()
|
|
64
66
|
local camera = Workspace.CurrentCamera
|
|
65
|
-
|
|
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 =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
}
|