@trioforge/random 0.0.0-stage → 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @trioforge/random
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#6](https://github.com/Trio-Forge-Studio/packages/pull/6) [`dac820b`](https://github.com/Trio-Forge-Studio/packages/commit/dac820b44abb72c663f694c6125de44e1a1a8fcd) Thanks [@Hezaerd](https://github.com/Hezaerd)! - The source moved to [Trio-Forge-Studio/packages](https://github.com/Trio-Forge-Studio/packages), so the package's repository and issue links point there. The package now ships its `CHANGELOG.md`. The code is the same as the previous version.
8
+
9
+ ## 0.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - First release. `Rng` is a seeded xoshiro128\*\* generator in plain Luau, so every client and server draws the same sequence from the same seed. It draws integers, floats, chances and normal values, and it picks by weight, shuffles and samples. A state can be saved, restored, cloned and forked. ([Trio-Forge-Studio/random#1](https://github.com/Trio-Forge-Studio/random/pull/1))
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trio Forge Studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # @trioforge/random
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Seeded random numbers for roblox-ts. Every client and every server draws the same sequence from the same seed, today and after any engine update.
4
+
5
+ Roblox's `Random` doesn't promise that. Its algorithm and `Random:Shuffle` belong to the engine and can change between versions, which matters as soon as a server and its clients must agree on a draw. This package runs [xoshiro128\*\*](https://prng.di.unimi.it/) in plain Luau instead, and checks it against the algorithm's reference output.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @trioforge/random
11
+ ```
12
+
13
+ roblox-ts only looks for packages in the scopes your project lists, so add the `@trioforge` scope in two places.
14
+
15
+ 1. In `tsconfig.json`, add it to `typeRoots`:
16
+
17
+ ```json
18
+ "typeRoots": ["node_modules/@rbxts", "node_modules/@trioforge"]
19
+ ```
20
+
21
+ 2. In your Rojo project, map it beside `@rbxts` under `rbxts_include/node_modules`:
22
+
23
+ ```json
24
+ "node_modules": {
25
+ "$className": "Folder",
26
+ "@rbxts": { "$path": "node_modules/@rbxts" },
27
+ "@trioforge": { "$path": "node_modules/@trioforge" }
28
+ }
29
+ ```
30
+
31
+ ## Example
32
+
33
+ ```ts
34
+ import { Rng } from "@trioforge/random";
35
+
36
+ const rng = new Rng("chalet", 12345);
37
+
38
+ print(rng.int(1, 6));
39
+ // → 5
40
+ print(rng.pick(["lamp", "vase", "clock"]));
41
+ // → vase
42
+ print(rng.shuffle([1, 2, 3, 4, 5]).join(" "));
43
+ // → 5 4 1 2 3
44
+ ```
45
+
46
+ ## Seeds
47
+
48
+ `new Rng(...seed)` takes any mix of strings and integers and hashes them into a 128-bit state. The same parts in the same order always give the same stream. Different parts give unrelated streams, so a seed can name what it's for:
49
+
50
+ ```ts
51
+ import { Rng } from "@trioforge/random";
52
+
53
+ const houseSeed = 2024;
54
+ const furniture = new Rng("furniture", houseSeed);
55
+ const weather = new Rng("weather", houseSeed);
56
+ ```
57
+
58
+ The two streams above don't move together, and drawing from one never changes the other. Seed numbers must be integers within ±2^53. `new Rng()` with no parts seeds itself from the clock and `math.random`, for draws nobody needs to repeat.
59
+
60
+ `fork()` takes four draws from a stream and seeds a new one from them. Hand a fork to a system that draws an unknown number of times, and the parent's sequence after it stays the same whatever the fork does.
61
+
62
+ `state()` returns the four 32-bit words of the stream's position. `Rng.fromState(state)` resumes from them, so a server can save a stream or send it to clients. `clone()` copies a stream at its current position.
63
+
64
+ ## Draws
65
+
66
+ | Method | Returns |
67
+ | --- | --- |
68
+ | `uint32()` | An integer from 0 to 2^32 − 1, the generator's raw output |
69
+ | `float()` | A number in [0, 1) with 53 random bits |
70
+ | `float(min, max)` | A number in [min, max) |
71
+ | `int(min, max)` | An integer in [min, max], both inclusive, without modulo bias |
72
+ | `chance(probability)` | `true` with the given probability |
73
+ | `normal(mean?, deviation?)` | A normally distributed number, by default with mean 0 and deviation 1 |
74
+ | `pick(items)` | One item |
75
+ | `weightedIndex(weights)` | An index, chosen in proportion to its weight |
76
+ | `weighted(items, weight)` | An item, chosen in proportion to `weight(item, index)` |
77
+ | `shuffle(items)` | A shuffled copy. The array you pass stays as it was |
78
+ | `sample(items, count)` | `count` distinct items, in the order they were drawn |
79
+
80
+ Indices are 0-based, as everywhere in roblox-ts. Weights must be finite and non-negative, with a positive total. A zero weight is never chosen.
81
+
82
+ ```ts
83
+ import { Rng } from "@trioforge/random";
84
+
85
+ const rng = new Rng("loot", 7);
86
+ const rarities = ["common", "rare", "epic"];
87
+ print(rarities[rng.weightedIndex([80, 18, 2])]);
88
+ // → common
89
+ ```
90
+
91
+ Each method's sequence for a given state is part of the package's contract. A change that would draw differently from the same seed is a breaking change.
package/out/index.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export type SeedPart = string | number;
2
+ export type RngState = readonly [number, number, number, number];
3
+ export declare class Rng {
4
+ private a;
5
+ private b;
6
+ private c;
7
+ private d;
8
+ constructor(...seed: SeedPart[]);
9
+ static fromState(state: RngState): Rng;
10
+ state(): RngState;
11
+ clone(): Rng;
12
+ fork(): Rng;
13
+ uint32(): number;
14
+ float(): number;
15
+ float(min: number, max: number): number;
16
+ int(min: number, max: number): number;
17
+ chance(probability: number): boolean;
18
+ normal(mean?: number, deviation?: number): number;
19
+ pick<T extends defined>(items: readonly T[]): T;
20
+ weightedIndex(weights: readonly number[]): number;
21
+ weighted<T extends defined>(items: readonly T[], weight: (item: T, index: number) => number): T;
22
+ shuffle<T extends defined>(items: readonly T[]): T[];
23
+ sample<T extends defined>(items: readonly T[], count: number): T[];
24
+ }
package/out/init.luau ADDED
@@ -0,0 +1,301 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local WORD = 2 ^ 32
3
+ local DOUBLE = 2 ^ 53
4
+ -- Luau has no 32-bit multiply, and a full product of two words overflows a double's 53 bits, so it splits one side.
5
+ local function imul(a, b)
6
+ local low = a * (b % 65536)
7
+ local high = ((a * math.floor(b / 65536)) % WORD) * 65536
8
+ return (low + high) % WORD
9
+ end
10
+ local function fmix(h)
11
+ h = imul(bit32.bxor(h, bit32.rshift(h, 16)), 0x85ebca6b)
12
+ h = imul(bit32.bxor(h, bit32.rshift(h, 13)), 0xc2b2ae35)
13
+ return bit32.bxor(h, bit32.rshift(h, 16))
14
+ end
15
+ local STRING = 0x5354524e
16
+ local NUMBER = 0x4e554d42
17
+ local function words(parts)
18
+ local result = { #parts }
19
+ for _, part in parts do
20
+ if type(part) == "string" then
21
+ local _arg1 = #part
22
+ -- ▼ Array.push ▼
23
+ table.insert(result, STRING)
24
+ table.insert(result, _arg1)
25
+ -- ▲ Array.push ▲
26
+ for index = 1, #part, 4 do
27
+ local a, b, c, d = string.byte(part, index, index + 3)
28
+ local _condition = a
29
+ if _condition == nil then
30
+ _condition = 0
31
+ end
32
+ local _condition_1 = b
33
+ if _condition_1 == nil then
34
+ _condition_1 = 0
35
+ end
36
+ local _exp = _condition + _condition_1 * 0x100
37
+ local _condition_2 = c
38
+ if _condition_2 == nil then
39
+ _condition_2 = 0
40
+ end
41
+ local _exp_1 = _exp + _condition_2 * 0x10000
42
+ local _condition_3 = d
43
+ if _condition_3 == nil then
44
+ _condition_3 = 0
45
+ end
46
+ table.insert(result, _exp_1 + _condition_3 * 0x1000000)
47
+ end
48
+ else
49
+ if part ~= part or part % 1 ~= 0 or math.abs(part) > DOUBLE then
50
+ error(`a seed number must be an integer within ±2^53, got {part}`, 3)
51
+ end
52
+ local _arg1 = part % WORD
53
+ local _arg2 = math.floor(part / WORD) % WORD
54
+ -- ▼ Array.push ▼
55
+ table.insert(result, NUMBER)
56
+ table.insert(result, _arg1)
57
+ table.insert(result, _arg2)
58
+ -- ▲ Array.push ▲
59
+ end
60
+ end
61
+ return result
62
+ end
63
+ local LANES = { 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a }
64
+ local function hashState(input)
65
+ -- ▼ ReadonlyArray.map ▼
66
+ local _newValue = table.create(#LANES)
67
+ local _callback = function(lane)
68
+ local h = lane
69
+ for _, word in input do
70
+ h = fmix((imul(bit32.bxor(h, word), 0x9e3779b1) + lane) % WORD)
71
+ end
72
+ return fmix(h)
73
+ end
74
+ for _k, _v in LANES do
75
+ _newValue[_k] = _callback(_v, _k - 1, LANES)
76
+ end
77
+ -- ▲ ReadonlyArray.map ▲
78
+ local state = _newValue
79
+ -- ▼ ReadonlyArray.every ▼
80
+ local _result = true
81
+ local _callback_1 = function(word)
82
+ return word == 0
83
+ end
84
+ for _k, _v in state do
85
+ if not _callback_1(_v, _k - 1, state) then
86
+ _result = false
87
+ break
88
+ end
89
+ end
90
+ -- ▲ ReadonlyArray.every ▲
91
+ if _result then
92
+ state[1] = 1
93
+ end
94
+ return { state[1], state[2], state[3], state[4] }
95
+ end
96
+ local function entropy()
97
+ return { math.random(0, 0xffff), math.random(0, 0xffff), math.random(0, 0xffff), math.floor(os.clock() * 1e6) % WORD }
98
+ end
99
+ local Rng
100
+ do
101
+ Rng = setmetatable({}, {
102
+ __tostring = function()
103
+ return "Rng"
104
+ end,
105
+ })
106
+ Rng.__index = Rng
107
+ function Rng.new(...)
108
+ local self = setmetatable({}, Rng)
109
+ return self:constructor(...) or self
110
+ end
111
+ function Rng:constructor(...)
112
+ local seed = { ... }
113
+ local _binding = hashState(if #seed > 0 then words(seed) else entropy())
114
+ self.a = _binding[1]
115
+ self.b = _binding[2]
116
+ self.c = _binding[3]
117
+ self.d = _binding[4]
118
+ end
119
+ function Rng:fromState(state)
120
+ local rng = Rng.new(0)
121
+ for _, word in state do
122
+ if word % 1 ~= 0 or word < 0 or word >= WORD then
123
+ error(`a state word must be a uint32, got {word}`, 2)
124
+ end
125
+ end
126
+ -- ▼ ReadonlyArray.every ▼
127
+ local _result = true
128
+ local _callback = function(word)
129
+ return word == 0
130
+ end
131
+ for _k, _v in state do
132
+ if not _callback(_v, _k - 1, state) then
133
+ _result = false
134
+ break
135
+ end
136
+ end
137
+ -- ▲ ReadonlyArray.every ▲
138
+ if _result then
139
+ error("a state can't be all zeroes", 2)
140
+ end
141
+ local _binding = state
142
+ rng.a = _binding[1]
143
+ rng.b = _binding[2]
144
+ rng.c = _binding[3]
145
+ rng.d = _binding[4]
146
+ return rng
147
+ end
148
+ function Rng:state()
149
+ return { self.a, self.b, self.c, self.d }
150
+ end
151
+ function Rng:clone()
152
+ return Rng:fromState(self:state())
153
+ end
154
+ function Rng:fork()
155
+ return Rng.new(self:uint32(), self:uint32(), self:uint32(), self:uint32())
156
+ end
157
+ function Rng:uint32()
158
+ local result = (bit32.lrotate((self.b * 5) % WORD, 7) * 9) % WORD
159
+ local t = bit32.lshift(self.b, 9)
160
+ self.c = bit32.bxor(self.c, self.a)
161
+ self.d = bit32.bxor(self.d, self.b)
162
+ self.b = bit32.bxor(self.b, self.c)
163
+ self.a = bit32.bxor(self.a, self.d)
164
+ self.c = bit32.bxor(self.c, t)
165
+ self.d = bit32.lrotate(self.d, 11)
166
+ return result
167
+ end
168
+ function Rng:float(min, max)
169
+ local unit = (bit32.rshift(self:uint32(), 5) * 2 ^ 26 + bit32.rshift(self:uint32(), 6)) / DOUBLE
170
+ if min == nil or max == nil then
171
+ return unit
172
+ end
173
+ if not (min <= max) then
174
+ error(`float needs min <= max, got {min} and {max}`, 2)
175
+ end
176
+ return min + unit * (max - min)
177
+ end
178
+ function Rng:int(min, max)
179
+ if min % 1 ~= 0 or max % 1 ~= 0 then
180
+ error(`int needs integer bounds, got {min} and {max}`, 2)
181
+ end
182
+ local span = max - min + 1
183
+ if span < 1 then
184
+ error(`int needs min <= max, got {min} and {max}`, 2)
185
+ end
186
+ if span > WORD then
187
+ error(`int spans at most 2^32 values, got {span}`, 2)
188
+ end
189
+ local limit = WORD - (WORD % span)
190
+ local value = self:uint32()
191
+ while value >= limit do
192
+ value = self:uint32()
193
+ end
194
+ return min + (value % span)
195
+ end
196
+ function Rng:chance(probability)
197
+ return self:float() < probability
198
+ end
199
+ function Rng:normal(mean, deviation)
200
+ if mean == nil then
201
+ mean = 0
202
+ end
203
+ if deviation == nil then
204
+ deviation = 1
205
+ end
206
+ local radius = math.sqrt(-2 * math.log(1 - self:float()))
207
+ return mean + deviation * radius * math.cos(2 * math.pi * self:float())
208
+ end
209
+ function Rng:pick(items)
210
+ if #items == 0 then
211
+ error("can't pick from an empty array", 2)
212
+ end
213
+ return items[self:int(0, #items - 1) + 1]
214
+ end
215
+ function Rng:weightedIndex(weights)
216
+ local total = 0
217
+ for _, weight in weights do
218
+ if not (weight >= 0 and weight < math.huge) then
219
+ error(`a weight must be finite and non-negative, got {weight}`, 2)
220
+ end
221
+ total += weight
222
+ end
223
+ if total <= 0 then
224
+ error("weights must add up to more than zero", 2)
225
+ end
226
+ local roll = self:float() * total
227
+ local last = 0
228
+ for index = 0, #weights - 1 do
229
+ if weights[index + 1] <= 0 then
230
+ continue
231
+ end
232
+ last = index
233
+ roll -= weights[index + 1]
234
+ if roll < 0 then
235
+ return index
236
+ end
237
+ end
238
+ -- Rounding can leave a sliver of the roll past the last bucket.
239
+ return last
240
+ end
241
+ function Rng:weighted(items, weight)
242
+ local _exp = items
243
+ local _self = self
244
+ local _items = items
245
+ local _weight = weight
246
+ -- ▼ ReadonlyArray.map ▼
247
+ local _newValue = table.create(#_items)
248
+ for _k, _v in _items do
249
+ _newValue[_k] = _weight(_v, _k - 1, _items)
250
+ end
251
+ -- ▲ ReadonlyArray.map ▲
252
+ return _exp[_self:weightedIndex(_newValue) + 1]
253
+ end
254
+ function Rng:shuffle(items)
255
+ local _array = {}
256
+ local _length = #_array
257
+ table.move(items, 1, #items, _length + 1, _array)
258
+ local result = _array
259
+ for index = #result - 1, 1, -1 do
260
+ local other = self:int(0, index)
261
+ local _index = index + 1
262
+ local _index_1 = other + 1
263
+ result[_index], result[_index_1] = result[other + 1], result[index + 1]
264
+ end
265
+ return result
266
+ end
267
+ function Rng:sample(items, count)
268
+ if count % 1 ~= 0 or count < 0 or count > #items then
269
+ error(`sample needs a count from 0 to {#items}, got {count}`, 2)
270
+ end
271
+ local _array = {}
272
+ local _length = #_array
273
+ table.move(items, 1, #items, _length + 1, _array)
274
+ local pool = _array
275
+ local result = {}
276
+ do
277
+ local index = 0
278
+ local _shouldIncrement = false
279
+ while true do
280
+ if _shouldIncrement then
281
+ index += 1
282
+ else
283
+ _shouldIncrement = true
284
+ end
285
+ if not (index < count) then
286
+ break
287
+ end
288
+ local other = self:int(index, #pool - 1)
289
+ local _index = index + 1
290
+ local _index_1 = other + 1
291
+ pool[_index], pool[_index_1] = pool[other + 1], pool[index + 1]
292
+ local _arg0 = pool[index + 1]
293
+ table.insert(result, _arg0)
294
+ end
295
+ end
296
+ return result
297
+ end
298
+ end
299
+ return {
300
+ Rng = Rng,
301
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@trioforge/random",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Seeded, deterministic random numbers for roblox-ts that every client and server draws the same",
5
+ "keywords": [
6
+ "roblox",
7
+ "roblox-ts",
8
+ "random",
9
+ "rng",
10
+ "prng",
11
+ "seed",
12
+ "xoshiro"
13
+ ],
14
+ "bugs": "https://github.com/Trio-Forge-Studio/packages/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Trio-Forge-Studio/packages.git",
18
+ "directory": "packages/random"
19
+ },
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "main": "out/init.luau",
23
+ "types": "out/index.d.ts",
24
+ "files": [
25
+ "out",
26
+ "!**/*.tsbuildinfo",
27
+ "README.md",
28
+ "CHANGELOG.md",
29
+ "LICENSE"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "devDependencies": {
35
+ "@rbxts/compiler-types": "3.0.0-types.0",
36
+ "@rbxts/types": "^1.0.958",
37
+ "@types/node": "^24.19.2",
38
+ "roblox-ts": "^3.0.0",
39
+ "typescript": "5.5.3",
40
+ "vite-plus": "1.3.0"
41
+ },
42
+ "scripts": {
43
+ "build": "rbxtsc --type package",
44
+ "test": "vp test",
45
+ "check": "tsc -p test && vp test"
46
+ }
6
47
  }