@typetorch/framework 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,6 +120,17 @@ export class CoinService extends Module implements OnStart {
120
120
  logs (server, own client, other players' clients), client and server dex, network stats, module state, a branch and
121
121
  build picker (Server > Branch) and a Claude prompt. Prod-channel servers are read-only. The window can be dragged by
122
122
  its header and resized from its corner (double-tap the header to reset).
123
+ - **Panes and windows (dev menu):** every page shows in a pane with a small header: tap the title to pick another
124
+ page, or use Split right, Split down, Open in new window and Close. Drag the line between two panes to resize them.
125
+ The sidebar opens pages in the focused pane (the highlighted one; tap a pane to focus it). Floating windows move by
126
+ their title bar and resize from their corner (double-tap the title bar to maximise); their title bar also has
127
+ Minimise, Dock (back into the main panel) and Close. Windows can be split too. The layout, the windows and each
128
+ pane's own settings (for example the open nodes of two Modules > State panes) survive swaps; a fresh join starts
129
+ with one pane. Claude, Server > Branch and the Manage pages open once (picking them again focuses their pane); every
130
+ other page can be open several times. Panes nobody sees (menu closed, window minimised) stop refreshing, and panes
131
+ on the same data share one request (Status, Overview, Assets, Network > Stats, server Logs, Packets); State panes
132
+ share one query budget. On phones and small screens panes only stack (two at most), and windows open maximised with
133
+ one pane each.
123
134
  - **State explorer (Modules > State, Server | Client):** every live module of the running generation (services on the
124
135
  server, controllers on the client) and the persist store. Tap a row to open it: the module's own fields (not its
125
136
  methods), then tables, arrays, Maps and Sets, 100 entries per page (Prev / Next). Each value has a short preview:
@@ -297,7 +308,9 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
297
308
  and the next generation sends them. In edit mode (UI Labs) it is inert.
298
309
  - **Server calls take the player first** (`track(player, ...)`); without one an event is server-only (no player id).
299
310
  Client calls are always about the local player and are marked `src = "client"` (a client can lie). The server
300
- checks their shape, size (props at most 4 KB) and rate.
311
+ checks their shape, size (props at most 4 KB) and rate. `purchase` and `currency` are server-only (revenue and the
312
+ economy are server-authoritative): on the client they warn once and send nothing, and the server refuses them from
313
+ clients.
301
314
  - **Options** (all on by default): `sessions` (joins, leaves, device, join source, account age bucket, Premium,
302
315
  country, friends in the server, first-ever vs returning, days since the last visit), `tech` (FPS, ping, memory, load
303
316
  time, client and server errors, swaps and rollbacks, leaves within 60 s of a swap), `zones` (parts or models tagged
@@ -328,6 +341,16 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
328
341
  `flushSeconds` (15) or at 500 rows, at most ~10 HttpService requests a minute, retries with backoff, and a last
329
342
  flush on shutdown (kernel 0.3.2). Delivery is at least once (a swap mid-request sends that batch again).
330
343
  `analytics.stats()` (server) has the counters; `flush()` sends soon.
344
+ - **The cloud test sends nothing.** `typetorch test --cloud` boots the payload headless in a Luau Execution task where
345
+ HttpService works; there the engine (stub kernel `test = true`, or `workspace:GetAttribute("TypeTorchTest")`) collects
346
+ as usual but never uploads: no event rows, no identity rows, no HTTP request, so a prod deploy never puts a fake
347
+ server session into your analytics.
348
+ - **Bounds on what a client can cause:** at most 32 experiments per session (each is stamped on every later row), and
349
+ a client's `exp` messages count against its event budget (120 a minute, 5,000 a session). Clients can't send
350
+ `purchase` or `currency` rows; the analytics server's ingest also refuses client-marked ones (older engines) and its
351
+ revenue queries count `purchase` rows the server sent.
352
+ - **Friends in the server** (the join's `friends`): one `Players:GetFriendsAsync` per join (at most 10 pages, none
353
+ when the player is alone) intersected with the players there, not a web call per player.
331
354
 
332
355
  **Settings** (server only, never sent to clients): the ConfigService key `TypeTorchAnalytics`, written with
333
356
  `writeSettings()` from `@typetorch/analytics` (Open Cloud, universe:write) or in Creator Hub (Configs); no CLI command
@@ -367,6 +390,8 @@ bun run build # rbxtsc --type package -> out/
367
390
  `scripts/test-state-inspect.luau` checks the state explorer's core (previews, raw reads only, cycles, paging,
368
391
  Maps/Sets with Instance keys, the depth limit, filters, request validation) and that `src/version.ts` matches
369
392
  package.json.
393
+ `scripts/test-layout.luau` checks the dev menu's pane layout core (`devtools/layout.ts`: splits and their limits,
394
+ closing, pop out and dock, singleton pages, ratios, focus, and cleaning a remembered layout).
370
395
  `scripts/test-analytics.luau` checks the analytics engine's pure parts (experiment assignment, settings, the queue
371
396
  and HTTP budget, tt-rec-1, the sink request bodies); `test-analytics-server.luau` and `test-analytics-client.luau`
372
397
  run the compiled server and client cores against mocked services (sessions, intake, retries, a swap with a request
@@ -31,7 +31,7 @@ Integers are written with every digit (never exponent notation).
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 |
33
33
  | `sexp` | string | The server's experiment: the artifact id of a kernel A/B experiment pin, or `""` |
34
- | `src` | string | `server`, or `client` (sent by a client: it can lie; the server checked shapes and rate) |
34
+ | `src` | string | `server`, or `client` (sent by a client: it can lie; the server checked shapes and rate). Never `client` on `purchase` or `currency` rows |
35
35
  | `props` | string | JSON object text, at most 4096 bytes. Too large: `{"_trunc":<bytes>}`; not encodable: `{"_err":"encode"}`; a non-object value `x`: `{"value":x}` |
36
36
 
37
37
  ### recordings
@@ -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), `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` (friends in the server, at most 30 checked); `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), `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
61
  | session | `device` (src client) | `input` (`kbm`, `touch`, `gamepad`, `vr`, `unknown`), `w`, `h` (viewport points), `touch`, `kb`, `mouse`, `pad`, `vr`. Once per session |
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) |
@@ -71,17 +71,21 @@ First-ever-session detail, one row per packed chunk.
71
71
  | zone | `leave` | `zone`, `to?`, `secs` |
72
72
  | state | `activity` | `to`, `from` (server or client src) |
73
73
  | state | `screen` | `to`, `from` (`""` = none). Client: ScreenGuis in PlayerGui (Enabled), GuiObjects tagged `TTScreen` (Visible), `screen()` |
74
- | experiment | `<experiment>` | `variant`, `variants` (count), `forced?`. Once per session when assigned or changed |
74
+ | experiment | `<experiment>` | `variant`, `variants` (count), `forced?`. Once per session when assigned or changed. At most 32 experiments per session: a 33rd `experiment()` returns the control (the first variant) and warns. A client's experiment calls count against its event budget (below) |
75
75
  | recording_meta | `start` | `share` (the recorded share then) |
76
76
  | recording_meta | `end` | `chunks`, `bytes`, `why`: `window` (60 s after the first input), `cap` (10 min, or the server's 256 KB / 80 chunks), `left`, `end` |
77
77
  | fleet | `heartbeat` | The kernel's `fleetStatus()` (kernel 0.3.2+), every 60 ± 10 s and once at each generation start. pid `""` |
78
78
  | fleet | `deploy_report` | One kernel deploy report `{s, b, a, j, r, e?, d?, t, g, k, p}` (r: `swapped`, `failed`, `rolled_back`, `skipped`, `booted`), once per (s, j, r) per server, across generations. pid `""` |
79
79
  | custom | `<name>` | `track(name, props)`: the game's props |
80
80
  | funnel | `<funnel>` | `step(funnel, index, name?)`: `i`, `step?` |
81
- | purchase | `<kind>` | `purchase({product, robux, where?, kind?})`: name = `kind` (default `product`); `product`, `robux`, `where?` |
82
- | currency | `<currency>` | `currency(name, delta, reason)`: `delta`, `reason` |
81
+ | purchase | `<kind>` | Server only. `purchase(player?, {product, robux, where?, kind?})`: name = `kind` (default `product`); `product`, `robux`, `where?` |
82
+ | currency | `<currency>` | Server only. `currency(player?, name, delta, reason)`: `delta`, `reason` |
83
83
 
84
- Client-sent kinds are limited to `custom`, `funnel`, `purchase`, `currency`, `state`, `tech`.
84
+ Client-sent kinds are limited to `custom`, `funnel`, `state`, `tech`, within a budget of 120 a minute and 5,000 a
85
+ session per player. `purchase` and `currency` are server-only (revenue and the economy are server-authoritative): on
86
+ the client `purchase()` and `currency()` warn once and send nothing, the server refuses those kinds from clients, and
87
+ the analytics server's ingest refuses such rows marked `src = client` (older engines let clients send them). Revenue
88
+ counts only server-sent `purchase` rows; rows from before `src` existed (no `src`) still count.
85
89
 
86
90
  ## tt-rec-1
87
91
 
@@ -174,6 +178,9 @@ Unknown fields are ignored. Settings errors are warned once (never with the toke
174
178
  other 4xx drop the batch (counted as rejected).
175
179
  - **Delivery is at least once:** a hot swap during a request sends its rows again. Readers may drop exact duplicate
176
180
  rows.
181
+ - **The cloud test sends nothing:** inside `typetorch test --cloud` (the stub kernel's `test = true`, or the workspace
182
+ attribute `TypeTorchTest`) rows are collected as usual but dropped at each flush: no event, recording or identity
183
+ rows and no HTTP request, so a deploy never puts a fake server session into the analytics.
177
184
  - **Queue:** 10,000 events (1,000 while no settings are known), 2,000 fleet rows, 300 recording chunks; over a cap the
178
185
  oldest are dropped and counted (`tech/server` `dropped`). The queue lives in the kernel's persist store across hot
179
186
  swaps and is flushed on shutdown (kernel 0.3.2 onClose; older kernels: one BindToClose relay).
@@ -15,10 +15,16 @@ export declare class AnalyticsEngine {
15
15
  /** A funnel step, e.g. `step("onboarding", 3, "opened_shop")`. Indexes start at 1 by convention. */
16
16
  step(funnel: string, index: number, name?: string): void;
17
17
  step(player: Player, funnel: string, index: number, name?: string): void;
18
- /** A Robux purchase (log it once the receipt is granted). The event name is `kind` ("product" by default). */
18
+ /**
19
+ * A Robux purchase (log it once the receipt is granted). The event name is `kind` ("product" by default). Server
20
+ * only: revenue is server-authoritative, so on the client this warns once and sends nothing.
21
+ */
19
22
  purchase(purchase: AnalyticsPurchase): void;
20
23
  purchase(player: Player, purchase: AnalyticsPurchase): void;
21
- /** Currency in (+) or out (-), e.g. `currency("coins", 50, "round_reward")`. */
24
+ /**
25
+ * Currency in (+) or out (-), e.g. `currency(player, "coins", 50, "round_reward")`. Server only: the economy is
26
+ * server-authoritative, so on the client this warns once and sends nothing.
27
+ */
22
28
  currency(name: string, delta: number, reason: string): void;
23
29
  currency(player: Player, name: string, delta: number, reason: string): void;
24
30
  /**
@@ -26,6 +26,17 @@ local function isPlayer(value)
26
26
  end
27
27
  return _condition
28
28
  end
29
+ --* Server-only calls made on the client: warned once each per generation; the server refuses these kinds from clients.
30
+ local warnedServerOnly = {}
31
+ local function serverOnly(call)
32
+ local _call = call
33
+ if warnedServerOnly[_call] ~= nil then
34
+ return nil
35
+ end
36
+ local _call_1 = call
37
+ warnedServerOnly[_call_1] = true
38
+ warn("[src/analytics/engine.ts:31]", `[analytics] {call}() is server-only (revenue and currency are server-authoritative): the client sends nothing`)
39
+ end
29
40
  local function currentServer(options)
30
41
  if serverCore then
31
42
  return serverCore
@@ -87,11 +98,11 @@ do
87
98
  local existing = serverCore ~= nil
88
99
  self.server = currentServer(options)
89
100
  if existing and options.settings ~= nil then
90
- warn("[src/analytics/engine.ts:67]", "[analytics] an engine already runs: its settings stay")
101
+ warn("[src/analytics/engine.ts:75]", "[analytics] an engine already runs: its settings stay")
91
102
  end
92
103
  else
93
104
  if options.settings ~= nil then
94
- warn("[src/analytics/engine.ts:69]", "[analytics] settings are read on the server only; the client ignores them")
105
+ warn("[src/analytics/engine.ts:77]", "[analytics] settings are read on the server only; the client ignores them")
95
106
  end
96
107
  self.client = currentClient(options)
97
108
  end
@@ -150,6 +161,10 @@ do
150
161
  end
151
162
  end
152
163
  function AnalyticsEngine:purchase(first, second)
164
+ if self.client then
165
+ serverOnly("purchase")
166
+ return nil
167
+ end
153
168
  local player = if isPlayer(first) then first else nil
154
169
  local info = (if player then second else first)
155
170
  local _condition = not info
@@ -162,7 +177,7 @@ do
162
177
  end
163
178
  end
164
179
  if _condition then
165
- warn("[src/analytics/engine.ts:114]", "[analytics] purchase({ product, robux }) needs numbers")
180
+ warn("[src/analytics/engine.ts:129]", "[analytics] purchase({ product, robux }) needs numbers")
166
181
  return nil
167
182
  end
168
183
  local _kind = info.kind
@@ -180,37 +195,27 @@ do
180
195
  if _result ~= nil then
181
196
  _result:track(player, "purchase", kind, props)
182
197
  end
183
- local _result_1 = self.client
184
- if _result_1 ~= nil then
185
- _result_1:track("purchase", kind, props)
186
- end
187
198
  end
188
199
  function AnalyticsEngine:currency(first, second, third, fourth)
200
+ if self.client then
201
+ serverOnly("currency")
202
+ return nil
203
+ end
189
204
  if isPlayer(first) then
190
- local props = {
191
- delta = third,
192
- reason = fourth,
193
- }
194
205
  local _result = self.server
195
206
  if _result ~= nil then
196
- _result:track(first, "currency", second, props)
197
- end
198
- local _result_1 = self.client
199
- if _result_1 ~= nil then
200
- _result_1:track("currency", second, props)
207
+ _result:track(first, "currency", second, {
208
+ delta = third,
209
+ reason = fourth,
210
+ })
201
211
  end
202
212
  else
203
- local props = {
204
- delta = second,
205
- reason = third,
206
- }
207
213
  local _result = self.server
208
214
  if _result ~= nil then
209
- _result:track(nil, "currency", first, props)
210
- end
211
- local _result_1 = self.client
212
- if _result_1 ~= nil then
213
- _result_1:track("currency", first, props)
215
+ _result:track(nil, "currency", first, {
216
+ delta = second,
217
+ reason = third,
218
+ })
214
219
  end
215
220
  end
216
221
  end
@@ -281,7 +286,7 @@ do
281
286
  return self.client:experiment(first, variants)
282
287
  end
283
288
  if self.server then
284
- warn("[src/analytics/engine.ts:187]", `[analytics] experiment("{first}") on the server needs the player: experiment(player, name, variants)`)
289
+ warn("[src/analytics/engine.ts:204]", `[analytics] experiment("{first}") on the server needs the player: experiment(player, name, variants)`)
285
290
  end
286
291
  local _condition = variants[1]
287
292
  if _condition == nil then
@@ -52,7 +52,10 @@ export interface ServerHello {
52
52
  /** Live experiment overrides by name. */
53
53
  exps: Record<string, ExperimentOverride>;
54
54
  }
55
- /** Kinds a client may send in "ev". */
55
+ /**
56
+ * Kinds a client may send in "ev". Never `purchase` or `currency`: revenue and the economy are server-authoritative
57
+ * (a client could otherwise add fake payers and Robux), so the server refuses them from clients.
58
+ */
56
59
  export declare const CLIENT_KINDS: Set<string>;
57
60
  /** A client's hello, checked field by field (it comes from an untrusted client). */
58
61
  export declare function cleanHello(value: unknown): HelloInfo | undefined;
@@ -38,12 +38,15 @@ local INPUTS = {
38
38
  ["vr"] = true,
39
39
  ["unknown"] = true,
40
40
  }
41
- --* Kinds a client may send in "ev".
41
+ --[[
42
+ *
43
+ * Kinds a client may send in "ev". Never `purchase` or `currency`: revenue and the economy are server-authoritative
44
+ * (a client could otherwise add fake payers and Robux), so the server refuses them from clients.
45
+
46
+ ]]
42
47
  local CLIENT_KINDS = {
43
48
  ["custom"] = true,
44
49
  ["funnel"] = true,
45
- ["purchase"] = true,
46
- ["currency"] = true,
47
50
  ["state"] = true,
48
51
  ["tech"] = true,
49
52
  }
@@ -74,6 +74,12 @@ export declare class ServerAnalytics {
74
74
  private readonly identity;
75
75
  private sexp;
76
76
  private stopped;
77
+ /**
78
+ * `typetorch test --cloud`: the payload boots headless in a Luau Execution task, where HttpService works. The engine
79
+ * then collects as usual but sends nothing (no rows, no identity rows, no HTTP), or every prod deploy would put a
80
+ * fake server session into the owner's analytics. Set by the stub kernel's `test` or the TypeTorchTest attribute.
81
+ */
82
+ private readonly testMode;
77
83
  constructor(options: AnalyticsOptions, scope: GenerationScope, trove: Trove);
78
84
  private serverExperiment;
79
85
  private stateOf;
@@ -109,6 +115,12 @@ export declare class ServerAnalytics {
109
115
  */
110
116
  private resolve;
111
117
  private joinSource;
118
+ /**
119
+ * How many of the player's friends are in this server: one `GetFriendsAsync` per join (at most FRIEND_PAGES_MAX
120
+ * pages) intersected with the players here, instead of an IsFriendsWith call per player. Stops early once every
121
+ * other player was found; after each yield it stops with the count so far if the player left or the generation
122
+ * stopped. No call when the player is alone; a failed call counts what it read (0 at first).
123
+ */
112
124
  private friendsHere;
113
125
  private emitJoin;
114
126
  private onPlayer;
@@ -117,6 +129,8 @@ export declare class ServerAnalytics {
117
129
  private recordingAllowed;
118
130
  private sendHello;
119
131
  private onHello;
132
+ /** One client-sent event (or an "exp" message, which may add a row): within the player's budget and the session cap. */
133
+ private takeClientEvent;
120
134
  private onEvents;
121
135
  private onChunk;
122
136
  private startIntake;
@@ -88,6 +88,16 @@ local PENDING_MAX = 200
88
88
  local CLIENT_BURST = 120
89
89
  local CLIENT_PER_MINUTE = 120
90
90
  local CLIENT_SESSION_MAX = 5000
91
+ --[[
92
+ *
93
+ * Distinct experiments one session may hold. Each one is re-encoded into `exp` and stamped on every later row, and a
94
+ * client can name experiments ("exp" messages), so without a cap one player could grow `exp` past the ingest limit
95
+ * (1 KB) and have every row of theirs dropped, or spend the server's time re-encoding it.
96
+
97
+ ]]
98
+ local EXPERIMENTS_MAX = 32
99
+ --* Friend-list pages read per join for the join's `friends` (one web call each; most players fit on the first).
100
+ local FRIEND_PAGES_MAX = 10
91
101
  --* A recording: at most this many bytes, chunks and seconds after the join.
92
102
  local RECORDING_MAX_BYTES = 262144
93
103
  local RECORDING_MAX_CHUNKS = 80
@@ -131,6 +141,20 @@ local function ageBucket(days)
131
141
  end
132
142
  return "3y+"
133
143
  end
144
+ --* Whether this generation runs inside `typetorch test --cloud` (cli/src/cloudtest.ts: stub kernel `test = true`, attribute).
145
+ local function isCloudTest(kernel)
146
+ local _result = kernel
147
+ if _result ~= nil then
148
+ _result = _result.test
149
+ end
150
+ if _result == true then
151
+ return true
152
+ end
153
+ local ok, flag = pcall(function()
154
+ return Workspace:GetAttribute("TypeTorchTest")
155
+ end)
156
+ return ok and flag == true
157
+ end
134
158
  local function newSession()
135
159
  return {
136
160
  pid = "",
@@ -214,6 +238,7 @@ do
214
238
  self.sexp = ""
215
239
  self.stopped = false
216
240
  self.kernel = scope.server
241
+ self.testMode = isCloudTest(scope.server)
217
242
  self.store = TypeTorch:persist(PERSIST_KEY, newStore)
218
243
  self.events = RowQueue.new(self.store.events, EVENTS_CAP_UNCONFIGURED)
219
244
  self.recordings = RowQueue.new(self.store.recordings, RECORDINGS_CAP)
@@ -256,6 +281,9 @@ do
256
281
  trove:add(function()
257
282
  self.stopped = true
258
283
  end)
284
+ if self.testMode then
285
+ print("[analytics] cloud test: rows are collected but nothing is sent (no uploads, no identity rows)")
286
+ end
259
287
  self:startSettings()
260
288
  self:startIntake()
261
289
  self:startSessions()
@@ -456,7 +484,7 @@ do
456
484
  end
457
485
  function ServerAnalytics:track(player, kind, name, props)
458
486
  if not isEventName(name) then
459
- warn("[src/analytics/server.ts:435]", `[analytics] {kind} event name must be 1-64 characters: {string.sub(tostring(name), 1, 80)}`)
487
+ warn("[src/analytics/server.ts:458]", `[analytics] {kind} event name must be 1-64 characters: {string.sub(tostring(name), 1, 80)}`)
460
488
  return nil
461
489
  end
462
490
  local session = if player then self:sessionOf(player) else nil
@@ -564,7 +592,7 @@ do
564
592
  end
565
593
  local control = _condition
566
594
  if not isExperimentName(name) or not isVariantList(variants) then
567
- warn("[src/analytics/server.ts:486]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
595
+ warn("[src/analytics/server.ts:509]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
568
596
  return control
569
597
  end
570
598
  local session = self:sessionOf(player)
@@ -578,24 +606,40 @@ do
578
606
  if not session.ready then
579
607
  return control
580
608
  end
581
- local _exp = session.pid
582
- local _exp_1 = name
583
- local _exp_2 = variants
609
+ local _exp = session.exp
610
+ local _name = name
611
+ local _condition_1 = not (_exp[_name] ~= nil)
612
+ if _condition_1 then
613
+ -- ▼ ReadonlyMap.size ▼
614
+ local _size = 0
615
+ for _ in session.exp do
616
+ _size += 1
617
+ end
618
+ -- ▲ ReadonlyMap.size ▲
619
+ _condition_1 = _size >= EXPERIMENTS_MAX
620
+ end
621
+ if _condition_1 then
622
+ self:warn(`experiment("{name}") not assigned: a session holds at most {EXPERIMENTS_MAX} experiments`)
623
+ return control
624
+ end
625
+ local _exp_1 = session.pid
626
+ local _exp_2 = name
627
+ local _exp_3 = variants
584
628
  local _result = self.settings
585
629
  if _result ~= nil then
586
630
  local _experiments_1 = _result.experiments
587
- local _name = name
588
- _result = _experiments_1[_name]
631
+ local _name_1 = name
632
+ _result = _experiments_1[_name_1]
589
633
  end
590
- local assignment = assignVariant(_exp, _exp_1, _exp_2, _result)
591
- local _exp_3 = session.exp
592
- local _name = name
593
- local current = _exp_3[_name]
634
+ local assignment = assignVariant(_exp_1, _exp_2, _exp_3, _result)
635
+ local _exp_4 = session.exp
636
+ local _name_1 = name
637
+ local current = _exp_4[_name_1]
594
638
  if assignment.active and current ~= assignment.variant then
595
- local _exp_4 = session.exp
596
- local _name_1 = name
639
+ local _exp_5 = session.exp
640
+ local _name_2 = name
597
641
  local _variant = assignment.variant
598
- _exp_4[_name_1] = _variant
642
+ _exp_5[_name_2] = _variant
599
643
  session.expJson = encodeProps(session.exp)
600
644
  self:emit(session, "experiment", name, encodeProps({
601
645
  variant = assignment.variant,
@@ -603,9 +647,9 @@ do
603
647
  forced = assignment.forced or nil,
604
648
  }))
605
649
  elseif not assignment.active and current ~= nil then
606
- local _exp_4 = session.exp
607
- local _name_1 = name
608
- _exp_4[_name_1] = nil
650
+ local _exp_5 = session.exp
651
+ local _name_2 = name
652
+ _exp_5[_name_2] = nil
609
653
  session.expJson = encodeProps(session.exp)
610
654
  end
611
655
  return assignment.variant
@@ -613,7 +657,7 @@ do
613
657
  function ServerAnalytics:applySettings(parsed)
614
658
  local errorsText = table.concat(parsed.errors, "; ")
615
659
  if errorsText ~= table.concat(self.settingsErrors, "; ") and errorsText ~= "" then
616
- warn("[src/analytics/server.ts:517]", `[analytics] {SETTINGS_KEY}: {errorsText}`)
660
+ warn("[src/analytics/server.ts:544]", `[analytics] {SETTINGS_KEY}: {errorsText}`)
617
661
  end
618
662
  self.settingsErrors = parsed.errors
619
663
  local hadSettings = self.settings ~= nil
@@ -690,7 +734,7 @@ do
690
734
  snapshot = result
691
735
  else
692
736
  if attempt == 1 then
693
- warn("[src/analytics/server.ts:563]", `[analytics] ConfigService unavailable: {string.sub(tostring(result), 1, 120)}`)
737
+ warn("[src/analytics/server.ts:590]", `[analytics] ConfigService unavailable: {string.sub(tostring(result), 1, 120)}`)
694
738
  end
695
739
  task.wait(delay)
696
740
  delay = math.min(delay * 2, 30)
@@ -930,21 +974,71 @@ do
930
974
  return info
931
975
  end
932
976
  function ServerAnalytics:friendsHere(player)
933
- local count = 0
934
- local checked = 0
977
+ local others = {}
935
978
  for _, other in Players:GetPlayers() do
936
- if other == player then
937
- continue
979
+ if other ~= player then
980
+ local _userId = other.UserId
981
+ others[_userId] = true
982
+ end
983
+ end
984
+ -- ▼ ReadonlySet.size ▼
985
+ local _size = 0
986
+ for _ in others do
987
+ _size += 1
988
+ end
989
+ -- ▲ ReadonlySet.size ▲
990
+ if _size == 0 then
991
+ return 0
992
+ end
993
+ local ok, pages = pcall(function()
994
+ return Players:GetFriendsAsync(player.UserId)
995
+ end)
996
+ if not ok or pages == nil then
997
+ return 0
998
+ end
999
+ local count = 0
1000
+ for page = 1, FRIEND_PAGES_MAX do
1001
+ if self.stopped or player.Parent == nil then
1002
+ break
1003
+ end
1004
+ local pageOk, entries = pcall(function()
1005
+ return pages:GetCurrentPage()
1006
+ end)
1007
+ if not pageOk or not (type(entries) == "table") then
1008
+ break
1009
+ end
1010
+ for _, entry in entries do
1011
+ local _condition = type(entry) == "table"
1012
+ if _condition then
1013
+ local _id = entry.Id
1014
+ -- ▼ Set.delete ▼
1015
+ local _valueExisted = others[_id] ~= nil
1016
+ others[_id] = nil
1017
+ -- ▲ Set.delete ▲
1018
+ _condition = _valueExisted
1019
+ end
1020
+ if _condition then
1021
+ count += 1
1022
+ end
1023
+ end
1024
+ -- ▼ ReadonlySet.size ▼
1025
+ local _size_1 = 0
1026
+ for _ in others do
1027
+ _size_1 += 1
1028
+ end
1029
+ -- ▲ ReadonlySet.size ▲
1030
+ local _condition = _size_1 == 0
1031
+ if not _condition then
1032
+ _condition = pages.IsFinished or page == FRIEND_PAGES_MAX
938
1033
  end
939
- if checked >= 30 then
1034
+ if _condition then
940
1035
  break
941
1036
  end
942
- checked += 1
943
- local ok, friends = pcall(function()
944
- return player:IsFriendsWith(other.UserId)
1037
+ local nextOk = pcall(function()
1038
+ return pages:AdvanceToNextPageAsync()
945
1039
  end)
946
- if ok and friends then
947
- count += 1
1040
+ if not nextOk then
1041
+ break
948
1042
  end
949
1043
  end
950
1044
  return count
@@ -1203,14 +1297,9 @@ do
1203
1297
  _helloWaiting[_player] = true
1204
1298
  end
1205
1299
  end
1206
- function ServerAnalytics:onEvents(player, session, batch)
1207
- local _batch = batch
1208
- if not (type(_batch) == "table") then
1209
- return nil
1210
- end
1211
- local list = batch
1212
- if #list > CLIENT_BATCH_MAX then
1213
- return nil
1300
+ function ServerAnalytics:takeClientEvent(player, session)
1301
+ if session.clientEvents >= CLIENT_SESSION_MAX then
1302
+ return false
1214
1303
  end
1215
1304
  local _clientBudgets = self.clientBudgets
1216
1305
  local _player = player
@@ -1225,15 +1314,24 @@ do
1225
1314
  local _budget = budget
1226
1315
  _clientBudgets_1[_player_1] = _budget
1227
1316
  end
1317
+ if not takeToken(budget, os.clock(), CLIENT_BURST, CLIENT_PER_MINUTE) then
1318
+ return false
1319
+ end
1320
+ session.clientEvents += 1
1321
+ return true
1322
+ end
1323
+ function ServerAnalytics:onEvents(player, session, batch)
1324
+ local _batch = batch
1325
+ if not (type(_batch) == "table") then
1326
+ return nil
1327
+ end
1328
+ local list = batch
1329
+ if #list > CLIENT_BATCH_MAX then
1330
+ return nil
1331
+ end
1228
1332
  local now = nowMs()
1229
1333
  local halfPing = math.floor(math.clamp(player:GetNetworkPing(), 0, 2) * 500)
1230
1334
  for _, raw in list do
1231
- if session.clientEvents >= CLIENT_SESSION_MAX then
1232
- return nil
1233
- end
1234
- if not takeToken(budget, os.clock(), CLIENT_BURST, CLIENT_PER_MINUTE) then
1235
- return nil
1236
- end
1237
1335
  local event = cleanClientEvent(raw)
1238
1336
  if not event then
1239
1337
  continue
@@ -1247,7 +1345,9 @@ do
1247
1345
  if not ok or not (type(decoded) == "table") then
1248
1346
  continue
1249
1347
  end
1250
- session.clientEvents += 1
1348
+ if not self:takeClientEvent(player, session) then
1349
+ return nil
1350
+ end
1251
1351
  local t = now - age - halfPing
1252
1352
  if kind == "state" then
1253
1353
  local to = decoded.to
@@ -1367,8 +1467,9 @@ do
1367
1467
  local _binding = args
1368
1468
  local name = _binding[1]
1369
1469
  local variants = _binding[2]
1470
+ -- A client names an experiment: within its event budget (it may add an "experiment" row and grows `exp`).
1370
1471
  -- experiment() may wait for the pid: not on the kernel's message thread.
1371
- if isExperimentName(name) and isVariantList(variants) then
1472
+ if isExperimentName(name) and isVariantList(variants) and self:takeClientEvent(player, session) then
1372
1473
  task.spawn(function()
1373
1474
  return self:experiment(player, name, variants)
1374
1475
  end)
@@ -1380,7 +1481,7 @@ do
1380
1481
  end
1381
1482
  end)
1382
1483
  if not ok then
1383
- warn("[src/analytics/server.ts:1008]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1484
+ warn("[src/analytics/server.ts:1058]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1384
1485
  end
1385
1486
  end)
1386
1487
  self.trove:add(function()
@@ -1660,7 +1761,7 @@ do
1660
1761
  return nil
1661
1762
  end
1662
1763
  self.lastWarnAt = now
1663
- warn("[src/analytics/server.ts:1197]", `[analytics] {message}`)
1764
+ warn("[src/analytics/server.ts:1247]", `[analytics] {message}`)
1664
1765
  end
1665
1766
  function ServerAnalytics:post(request)
1666
1767
  local ok, response = pcall(function()
@@ -1682,6 +1783,14 @@ do
1682
1783
  return response.StatusCode
1683
1784
  end
1684
1785
  function ServerAnalytics:flushOnce(final)
1786
+ if self.testMode then
1787
+ -- The cloud test: drop what was collected instead of sending it (the task's rows are not real play).
1788
+ table.clear(self.store.events.rows)
1789
+ table.clear(self.store.fleet.rows)
1790
+ table.clear(self.store.recordings.rows)
1791
+ self.store.identities = {}
1792
+ return nil
1793
+ end
1685
1794
  local settings = self.settings
1686
1795
  if not settings or self.flushing then
1687
1796
  return nil
@@ -1850,7 +1959,7 @@ do
1850
1959
  self.nextFlushAt = 0
1851
1960
  end
1852
1961
  function ServerAnalytics:close()
1853
- if self.closing or self.stopped then
1962
+ if self.closing or self.stopped or self.testMode then
1854
1963
  return nil
1855
1964
  end
1856
1965
  self.closing = true
@@ -85,7 +85,10 @@ export interface AdminServer {
85
85
  kernel?: string;
86
86
  /** Its kernel takes pin messages (0.2.3+); undefined when unknown. */
87
87
  ab?: boolean;
88
- /** Kernel 0.3.2+: "ok" | "failed" (nothing runs) | "unverified" | "degraded" (a deploy failed or rolled back there). */
88
+ /**
89
+ * Kernel 0.3.2+: "ok" | "failed" (nothing runs) | "unverified" | "degraded" (a deploy failed or rolled back there);
90
+ * 0.3.6: "backup" (the backup build baked into the place runs).
91
+ */
89
92
  health?: string;
90
93
  /** Kernel 0.3.2+: its applied deploy seq. */
91
94
  seq?: number;