@typetorch/framework 0.5.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 +4 -3
- package/out/analytics/SCHEMA.md +2 -2
- package/out/analytics/experiments.d.ts +7 -1
- package/out/analytics/experiments.luau +43 -1
- package/out/analytics/schema.d.ts +7 -0
- package/out/analytics/server.d.ts +1 -0
- package/out/analytics/server.luau +10 -9
- package/out/analytics/settings.d.ts +2 -0
- package/out/analytics/settings.luau +37 -0
- package/out/devtools/client.d.ts +0 -5
- package/out/devtools/client.luau +82 -5
- package/out/devtools/health.d.ts +1 -1
- package/out/devtools/health.luau +1 -1
- package/out/kernel.d.ts +3 -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
|
@@ -427,7 +427,7 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
|
|
|
427
427
|
row carries a UserId.
|
|
428
428
|
- **Identities** (option `identity`, default on): once the pid is known, one row `{ pid, uid, t }` (the UserId, nothing
|
|
429
429
|
else) per session goes to the dev's own server only, never into the events: duckdb games in the batch body, Basin
|
|
430
|
-
games to the
|
|
430
|
+
games to the backend (the record's `backend` url + `/v1/identity`, or the `identity` / `identityToken` settings). The
|
|
431
431
|
analytics server keeps pid -> UserId in a deletable table (support lookups, Right to Erasure).
|
|
432
432
|
- **Player ids:** DataStore `TypeTorchAnalytics`, key `p/<UserId>` -> `{ pid, first, last }`: one read per join, a
|
|
433
433
|
write on the first join and at leave. Deleting the key (Right to Erasure) leaves that player's rows anonymous.
|
|
@@ -465,8 +465,9 @@ count affected players by pid, never by name) and counts its own DataStore and H
|
|
|
465
465
|
menu **Server > Budget**: bars per Roblox limit for the player count, who spends it, the DataStore budget left, memory).
|
|
466
466
|
|
|
467
467
|
**Settings** (server only, never sent to clients): the signed settings record's `analytics` field (kernel 0.3.8,
|
|
468
|
-
plans/20),
|
|
469
|
-
|
|
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
|
|
470
471
|
there are no settings unless the game passes `new AnalyticsEngine({ settings })`:
|
|
471
472
|
|
|
472
473
|
```json
|
package/out/analytics/SCHEMA.md
CHANGED
|
@@ -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
|
|
|
@@ -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,
|
|
@@ -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
|
|
@@ -1499,7 +1500,7 @@ do
|
|
|
1499
1500
|
end
|
|
1500
1501
|
end)
|
|
1501
1502
|
if not ok then
|
|
1502
|
-
warn("[src/analytics/server.ts:
|
|
1503
|
+
warn("[src/analytics/server.ts:1078]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
|
|
1503
1504
|
end
|
|
1504
1505
|
end)
|
|
1505
1506
|
self.trove:add(function()
|
|
@@ -1782,7 +1783,7 @@ do
|
|
|
1782
1783
|
self.lastWarnAt = now
|
|
1783
1784
|
local more = if self.suppressed > 0 then ` (+{self.suppressed} more in the last minute)` else ""
|
|
1784
1785
|
self.suppressed = 0
|
|
1785
|
-
warn("[src/analytics/server.ts:
|
|
1786
|
+
warn("[src/analytics/server.ts:1273]", `[analytics] {message}{more}`)
|
|
1786
1787
|
return true
|
|
1787
1788
|
end
|
|
1788
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
|
}
|
package/out/devtools/client.d.ts
CHANGED
|
@@ -5,9 +5,4 @@ export interface DevtoolsClient {
|
|
|
5
5
|
/** Call once the client generation is up (all modules started). */
|
|
6
6
|
readonly started: () => void;
|
|
7
7
|
}
|
|
8
|
-
/**
|
|
9
|
-
* Starts the dev menu for this client generation. Everything it creates or connects lives in `trove`, so a swap
|
|
10
|
-
* removes it; the open state, tab, sub-tab, window rectangle and options survive the swap through the kernel persist
|
|
11
|
-
* store.
|
|
12
|
-
*/
|
|
13
8
|
export declare function startDevtoolsClient(kernel: ClientKernel, dispatcher: ClientDispatcher, trove: Trove): DevtoolsClient;
|
package/out/devtools/client.luau
CHANGED
|
@@ -467,6 +467,52 @@ local function shareableLogs(entries)
|
|
|
467
467
|
return ordered
|
|
468
468
|
end
|
|
469
469
|
-- The menu ----------------------------------------------------------------------------------------------------------
|
|
470
|
+
--[[
|
|
471
|
+
*
|
|
472
|
+
* The channel in a Builds group heading: the branch's own channel, not its newest artifact's. A promoted prod artifact
|
|
473
|
+
* keeps channel "prod" on a dev branch, and the heading used to say "(prod)" until the next dev deploy flipped it back.
|
|
474
|
+
* This server's branch: `branchChannel` from its status (kernel 0.3.9); another branch: the branch list's
|
|
475
|
+
* `branchChannel` (kernel 0.5.2), else its `channel`. "dev, prod artifact" when the newest artifact differs.
|
|
476
|
+
|
|
477
|
+
]]
|
|
478
|
+
local function buildsHeadingChannel(branchName, artifactChannel, branches, status)
|
|
479
|
+
local channel
|
|
480
|
+
if status ~= nil and status.branch == branchName and status.branchChannel ~= nil then
|
|
481
|
+
channel = status.branchChannel
|
|
482
|
+
end
|
|
483
|
+
if channel == nil then
|
|
484
|
+
-- ▼ ReadonlyArray.find ▼
|
|
485
|
+
local _callback = function(branch)
|
|
486
|
+
return branch.name == branchName
|
|
487
|
+
end
|
|
488
|
+
local _result
|
|
489
|
+
for _i, _v in branches do
|
|
490
|
+
if _callback(_v, _i - 1, branches) == true then
|
|
491
|
+
_result = _v
|
|
492
|
+
break
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
-- ▲ ReadonlyArray.find ▲
|
|
496
|
+
local info = _result
|
|
497
|
+
local _result_1 = info
|
|
498
|
+
if _result_1 ~= nil then
|
|
499
|
+
_result_1 = _result_1.branchChannel
|
|
500
|
+
end
|
|
501
|
+
local _condition = _result_1
|
|
502
|
+
if _condition == nil then
|
|
503
|
+
local _result_2 = info
|
|
504
|
+
if _result_2 ~= nil then
|
|
505
|
+
_result_2 = _result_2.channel
|
|
506
|
+
end
|
|
507
|
+
_condition = _result_2
|
|
508
|
+
end
|
|
509
|
+
channel = _condition
|
|
510
|
+
end
|
|
511
|
+
if channel == nil or channel == artifactChannel then
|
|
512
|
+
return artifactChannel
|
|
513
|
+
end
|
|
514
|
+
return `{channel}, {artifactChannel} artifact`
|
|
515
|
+
end
|
|
470
516
|
--[[
|
|
471
517
|
*
|
|
472
518
|
* Kernel 0.3.9: "dev" for a dev branch, plus the rules when they are stricter ("dev (prod rules)": a dev branch on a
|
|
@@ -488,7 +534,36 @@ end
|
|
|
488
534
|
* store.
|
|
489
535
|
|
|
490
536
|
]]
|
|
537
|
+
local DEV_GUI_NAME = "TypeTorchDev"
|
|
538
|
+
--* The client generation that built a dev menu gui (an attribute on the ScreenGui).
|
|
539
|
+
local DEV_GUI_GENERATION = "TypeTorchGeneration"
|
|
540
|
+
--[[
|
|
541
|
+
*
|
|
542
|
+
* Destroys dev menus an older generation left behind. Its trove should have removed them, but a generation whose stop
|
|
543
|
+
* was cut short (a throwing cleanup, before framework 0.5.2) left its menu on screen, dead, over the new one.
|
|
544
|
+
* Menus without the attribute come from a framework before 0.5.2: always older, since this generation hasn't built one.
|
|
545
|
+
|
|
546
|
+
]]
|
|
547
|
+
local function sweepStaleMenus(playerGui, generation)
|
|
548
|
+
for _, child in playerGui:GetChildren() do
|
|
549
|
+
if child.Name ~= DEV_GUI_NAME or not child:IsA("ScreenGui") then
|
|
550
|
+
continue
|
|
551
|
+
end
|
|
552
|
+
local built = child:GetAttribute(DEV_GUI_GENERATION)
|
|
553
|
+
if type(built) == "number" and built >= generation then
|
|
554
|
+
continue
|
|
555
|
+
end
|
|
556
|
+
pcall(function()
|
|
557
|
+
return child:Destroy()
|
|
558
|
+
end)
|
|
559
|
+
end
|
|
560
|
+
end
|
|
491
561
|
local function startDevtoolsClient(kernel, dispatcher, trove)
|
|
562
|
+
-- Before anything else, so a leaked menu goes away even for players who never get the dev menu.
|
|
563
|
+
local startGui = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui")
|
|
564
|
+
if startGui then
|
|
565
|
+
sweepStaleMenus(startGui, kernel.generation)
|
|
566
|
+
end
|
|
492
567
|
local state = kernel:persist(PERSIST_KEY, function()
|
|
493
568
|
return {
|
|
494
569
|
open = false,
|
|
@@ -664,7 +739,7 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
|
|
|
664
739
|
-- picks the file up on its PC).
|
|
665
740
|
local _devGui = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui")
|
|
666
741
|
if _devGui ~= nil then
|
|
667
|
-
_devGui = _devGui:FindFirstChild(
|
|
742
|
+
_devGui = _devGui:FindFirstChild(DEV_GUI_NAME)
|
|
668
743
|
end
|
|
669
744
|
local devGui = _devGui
|
|
670
745
|
local taken = takeScreenshot(if devGui ~= nil and devGui:IsA("ScreenGui") then { devGui } else {})
|
|
@@ -2080,7 +2155,7 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
|
|
|
2080
2155
|
end
|
|
2081
2156
|
for _, branchName in order do
|
|
2082
2157
|
local group = groups[branchName]
|
|
2083
|
-
local heading = body:text(`{branchName} ({group[1].channel})`, COLORS.text)
|
|
2158
|
+
local heading = body:text(`{branchName} ({buildsHeadingChannel(branchName, group[1].channel, data.branches, status)})`, COLORS.text)
|
|
2084
2159
|
heading.Font = Enum.Font.BuilderSansBold
|
|
2085
2160
|
local showAll = expanded[branchName] ~= nil
|
|
2086
2161
|
-- ▼ ReadonlyArray.forEach ▼
|
|
@@ -2472,7 +2547,7 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
|
|
|
2472
2547
|
})
|
|
2473
2548
|
end)
|
|
2474
2549
|
if not ok then
|
|
2475
|
-
warn("[src/devtools/client.ts:
|
|
2550
|
+
warn("[src/devtools/client.ts:1768]", `[devtools] {key} tab failed: {err}`)
|
|
2476
2551
|
page:text(`This tab failed: {err}`, COLORS.bad)
|
|
2477
2552
|
end
|
|
2478
2553
|
end
|
|
@@ -2543,13 +2618,15 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
|
|
|
2543
2618
|
local open
|
|
2544
2619
|
local build = function()
|
|
2545
2620
|
local playerGui = Players.LocalPlayer:WaitForChild("PlayerGui")
|
|
2621
|
+
sweepStaleMenus(playerGui, kernel.generation)
|
|
2546
2622
|
local gui = trove:add(make("ScreenGui", {
|
|
2547
|
-
Name =
|
|
2623
|
+
Name = DEV_GUI_NAME,
|
|
2548
2624
|
ResetOnSpawn = false,
|
|
2549
2625
|
DisplayOrder = DEV_MENU_DISPLAY_ORDER,
|
|
2550
2626
|
IgnoreGuiInset = true,
|
|
2551
2627
|
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
|
2552
2628
|
}))
|
|
2629
|
+
gui:SetAttribute(DEV_GUI_GENERATION, kernel.generation)
|
|
2553
2630
|
local toggle = style(make("TextButton", {
|
|
2554
2631
|
Name = "Toggle",
|
|
2555
2632
|
AutoButtonColor = true,
|
|
@@ -3077,7 +3154,7 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
|
|
|
3077
3154
|
return playReloadSound()
|
|
3078
3155
|
end)
|
|
3079
3156
|
if not ok then
|
|
3080
|
-
warn("[src/devtools/client.ts:
|
|
3157
|
+
warn("[src/devtools/client.ts:2189]", `[devtools] reload sound failed: {err}`)
|
|
3081
3158
|
end
|
|
3082
3159
|
end))
|
|
3083
3160
|
end,
|
package/out/devtools/health.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AnalyticsStats } from "../analytics/schema";
|
|
|
2
2
|
import type { AssetFacts } from "../assets/manifest";
|
|
3
3
|
import type { DetachedStatus, ErrorReportsStatus, FleetSenderInfo, HealthInfo, KernelStatus, PerfSummary, SettingsStatus } from "../kernel";
|
|
4
4
|
/** The newest kernel this framework release knows about. Bump it with every kernel release. */
|
|
5
|
-
export declare const LATEST_KERNEL = "0.
|
|
5
|
+
export declare const LATEST_KERNEL = "0.5.1";
|
|
6
6
|
/**
|
|
7
7
|
* The live health window while it is open (kernel 0.3.7 thresholds; older kernels: 3): "1/3 errors, 24 s left", plus
|
|
8
8
|
* ", no rollback" when a failure would only mark the server degraded. Undefined once the window closed.
|
package/out/devtools/health.luau
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
local TS = _G[script]
|
|
3
3
|
local describeFleetError = TS.import(script, script.Parent.Parent, "analytics", "hints").describeFleetError
|
|
4
4
|
--* The newest kernel this framework release knows about. Bump it with every kernel release.
|
|
5
|
-
local LATEST_KERNEL = "0.
|
|
5
|
+
local LATEST_KERNEL = "0.5.1"
|
|
6
6
|
--* Kernels before 0.3.7 use fixed thresholds: 3 errors within 30 s of ready roll back.
|
|
7
7
|
local OLD_HEALTH_ERRORS = 3
|
|
8
8
|
local OLD_HEALTH_WINDOW = 30
|
package/out/kernel.d.ts
CHANGED
|
@@ -353,7 +353,10 @@ export interface ArtifactEntry {
|
|
|
353
353
|
}
|
|
354
354
|
export interface BranchInfo {
|
|
355
355
|
name: string;
|
|
356
|
+
/** The head artifact's channel when the settings don't configure the branch (a promoted prod build: "prod"). */
|
|
356
357
|
channel: Channel;
|
|
358
|
+
/** Kernel 0.5.2: what the branch is ("prod": the default branch or one configured prod; else "dev"). */
|
|
359
|
+
branchChannel?: Channel;
|
|
357
360
|
artifactId?: string;
|
|
358
361
|
/** Kernel 0.2+. */
|
|
359
362
|
assetId?: number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Trove } from "@rbxts/trove";
|
|
2
|
+
/**
|
|
3
|
+
* `trove.clean()`, but every object is cleaned in its own pcall: a cleanup that throws warns (`<label> cleanup threw`)
|
|
4
|
+
* and the rest still run. `trove.clean()` stops at the first error and leaves the trove stuck "cleaning", so one
|
|
5
|
+
* `trove.remove(x)` inside a cleanup ("Cannot call trove.remove() while cleaning") used to abort the whole generation
|
|
6
|
+
* stop: later modules' troves, the network and the dev menu stayed alive. Child troves (`trove.extend()`) are swept the
|
|
7
|
+
* same way. Falls back to a pcalled `trove.clean()` for a trove it doesn't recognize.
|
|
8
|
+
*/
|
|
9
|
+
export declare function cleanTrove(trove: Trove, label: string): void;
|
|
10
|
+
/** Runs one step of a generation stop; a step that throws warns and the stop goes on. */
|
|
11
|
+
export declare function stopStep(label: string, step: () => void): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
--* The parts of @rbxts/trove 1.x that `trove.clean()` uses; read so one throwing cleanup can't skip the rest.
|
|
3
|
+
--[[
|
|
4
|
+
*
|
|
5
|
+
* `trove.clean()`, but every object is cleaned in its own pcall: a cleanup that throws warns (`<label> cleanup threw`)
|
|
6
|
+
* and the rest still run. `trove.clean()` stops at the first error and leaves the trove stuck "cleaning", so one
|
|
7
|
+
* `trove.remove(x)` inside a cleanup ("Cannot call trove.remove() while cleaning") used to abort the whole generation
|
|
8
|
+
* stop: later modules' troves, the network and the dev menu stayed alive. Child troves (`trove.extend()`) are swept the
|
|
9
|
+
* same way. Falls back to a pcalled `trove.clean()` for a trove it doesn't recognize.
|
|
10
|
+
|
|
11
|
+
]]
|
|
12
|
+
local function cleanTrove(trove, label)
|
|
13
|
+
local internals = trove
|
|
14
|
+
local objects = internals.objects
|
|
15
|
+
local cleanupObj = internals.cleanupObj
|
|
16
|
+
if not (type(objects) == "table") or not (type(cleanupObj) == "function") then
|
|
17
|
+
local ok, err = pcall(function()
|
|
18
|
+
return trove:clean()
|
|
19
|
+
end)
|
|
20
|
+
if not ok then
|
|
21
|
+
warn("[src/runtime/safe-clean.ts:28]", `{label} cleanup threw: {err}`)
|
|
22
|
+
end
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
-- Already being cleaned further up the stack (a cleanup that cleans its own trove): leave it to that clean.
|
|
26
|
+
if internals.cleaning == true then
|
|
27
|
+
return nil
|
|
28
|
+
end
|
|
29
|
+
internals.cleaning = true
|
|
30
|
+
local troveClass = getmetatable(trove)
|
|
31
|
+
for _, track in objects do
|
|
32
|
+
local obj = track.obj
|
|
33
|
+
local ok, err = pcall(function()
|
|
34
|
+
if type(obj) == "table" and getmetatable(obj) == troveClass then
|
|
35
|
+
cleanTrove(obj, label)
|
|
36
|
+
else
|
|
37
|
+
cleanupObj(internals, track)
|
|
38
|
+
end
|
|
39
|
+
end)
|
|
40
|
+
if not ok then
|
|
41
|
+
warn("[src/runtime/safe-clean.ts:41]", `{label} cleanup threw: {err}`)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
table.clear(objects)
|
|
45
|
+
internals.cleaning = false
|
|
46
|
+
end
|
|
47
|
+
--* Runs one step of a generation stop; a step that throws warns and the stop goes on.
|
|
48
|
+
local function stopStep(label, step)
|
|
49
|
+
local ok, err = pcall(step)
|
|
50
|
+
if not ok then
|
|
51
|
+
warn("[src/runtime/safe-clean.ts:50]", `TypeTorch stop: {label} threw: {err}`)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
return {
|
|
55
|
+
cleanTrove = cleanTrove,
|
|
56
|
+
stopStep = stopStep,
|
|
57
|
+
}
|
package/out/runtime/start.luau
CHANGED
|
@@ -36,6 +36,9 @@ local _registry = TS.import(script, script.Parent, "registry")
|
|
|
36
36
|
local persistKeys = _registry.persistKeys
|
|
37
37
|
local registered = _registry.registered
|
|
38
38
|
local runningModules = _registry.runningModules
|
|
39
|
+
local _safe_clean = TS.import(script, script.Parent, "safe-clean")
|
|
40
|
+
local cleanTrove = _safe_clean.cleanTrove
|
|
41
|
+
local stopStep = _safe_clean.stopStep
|
|
39
42
|
local function has(instance, method)
|
|
40
43
|
local _arg0 = instance[method]
|
|
41
44
|
return type(_arg0) == "function"
|
|
@@ -305,14 +308,29 @@ local function start(realm, kernel, options)
|
|
|
305
308
|
return (running.instance):onStop()
|
|
306
309
|
end)
|
|
307
310
|
if not ok then
|
|
308
|
-
warn("[src/runtime/start.ts:
|
|
311
|
+
warn("[src/runtime/start.ts:206]", `{running.name}.onStop threw: {err}`)
|
|
309
312
|
end
|
|
310
313
|
end
|
|
311
|
-
|
|
314
|
+
-- Object by object, each pcalled: a cleanup that throws (e.g. trove.remove inside a cleanup) can't skip the
|
|
315
|
+
-- rest of this module, the modules after it, or the rest of the stop.
|
|
316
|
+
cleanTrove(running.trove, running.name)
|
|
317
|
+
stopStep(`{running.name} trove`, function()
|
|
318
|
+
return root:remove(running.trove)
|
|
319
|
+
end)
|
|
312
320
|
end
|
|
313
321
|
table.clear(runningModules)
|
|
314
322
|
setModulePhase("stopped")
|
|
315
323
|
end
|
|
324
|
+
--* Every step runs even when an earlier one throws (each warns), so a bad cleanup can't keep a dead generation alive.
|
|
325
|
+
local stopGeneration = function()
|
|
326
|
+
stopStep("stopping modules", stopModules)
|
|
327
|
+
stopStep("stopping the network", stopNetwork)
|
|
328
|
+
stopStep("unbinding TypeTorch", unbindTypeTorch)
|
|
329
|
+
cleanTrove(root, "TypeTorch generation")
|
|
330
|
+
stopStep("destroying the generation trove", function()
|
|
331
|
+
return root:destroy()
|
|
332
|
+
end)
|
|
333
|
+
end
|
|
316
334
|
setModulePhase("constructing")
|
|
317
335
|
local initialized, initError = pcall(function()
|
|
318
336
|
for _, mod in ordered do
|
|
@@ -377,10 +395,7 @@ local function start(realm, kernel, options)
|
|
|
377
395
|
end
|
|
378
396
|
end)
|
|
379
397
|
if not initialized then
|
|
380
|
-
|
|
381
|
-
stopNetwork()
|
|
382
|
-
unbindTypeTorch()
|
|
383
|
-
root:destroy()
|
|
398
|
+
stopGeneration()
|
|
384
399
|
error(`TypeTorch {realm} failed to start: {initError}`, 0)
|
|
385
400
|
end
|
|
386
401
|
for _, running in runningModules do
|
|
@@ -446,14 +461,13 @@ local function start(realm, kernel, options)
|
|
|
446
461
|
_result()
|
|
447
462
|
end
|
|
448
463
|
startedTypeTorch()
|
|
449
|
-
print("[src/runtime/start.ts:
|
|
464
|
+
print("[src/runtime/start.ts:305]", `TypeTorch {realm} started {#runningModules} modules in {math.floor((os.clock() - startedAt) * 1000)} ms (artifact {kernel.artifact.id}, generation {kernel.generation})`)
|
|
450
465
|
return function(info)
|
|
451
466
|
-- TypeTorch.onSwapOut first, while every module still runs (so they can save into persist).
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
root:destroy()
|
|
467
|
+
stopStep("onSwapOut", function()
|
|
468
|
+
return swapOutTypeTorch(info)
|
|
469
|
+
end)
|
|
470
|
+
stopGeneration()
|
|
457
471
|
end
|
|
458
472
|
end
|
|
459
473
|
--* Call from the game's `src/server/boot.ts`: `export function boot(kernel) { return startServer(kernel, {...}) }`.
|
package/out/settings.luau
CHANGED
|
@@ -168,13 +168,14 @@ do
|
|
|
168
168
|
if _result == "client" then
|
|
169
169
|
error(`TypeTorch.liveConfig("{self.key}") is server-only`, 2)
|
|
170
170
|
end
|
|
171
|
+
-- `typetorch test --cloud` runs a stub kernel with no signed settings at all: the default is expected there.
|
|
171
172
|
local _result_1 = current
|
|
172
173
|
if _result_1 ~= nil then
|
|
173
174
|
_result_1 = _result_1.kernel
|
|
174
175
|
end
|
|
175
176
|
local _condition = _result_1 ~= nil
|
|
176
177
|
if _condition then
|
|
177
|
-
_condition = not settingsSupported()
|
|
178
|
+
_condition = current.kernel.test ~= true and not settingsSupported()
|
|
178
179
|
end
|
|
179
180
|
if _condition then
|
|
180
181
|
warnOnce(`old:{self.key}`, `TypeTorch.liveConfig("{self.key}") needs kernel {SETTINGS_KERNEL} (signed settings; this server runs {current.kernelVersion}): using the default`)
|
package/out/version.d.ts
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
* This framework's version, as package.json says (scripts/test-state-inspect.luau checks they match). Shown in
|
|
3
3
|
* uploaded log headers (Logs > Upload); bump it with package.json.
|
|
4
4
|
*/
|
|
5
|
-
export declare const FRAMEWORK_VERSION = "0.5.
|
|
5
|
+
export declare const FRAMEWORK_VERSION = "0.5.2";
|
package/out/version.luau
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typetorch/framework",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "TypeTorch framework for roblox-ts: hot-swappable modules (dependency injection, lifecycle, troves), guarded networking, hot assets and the in-game dev menu. Ships inside every TypeTorch artifact.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "TypeTorch",
|