@esportsplus/random 0.0.32 → 0.0.33
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 +25 -239
- package/build/index.d.ts +2 -1
- package/build/index.js +2 -1
- package/package.json +4 -2
- package/src/index.ts +3 -1
- package/tests/alphanumeric.ts +45 -0
- package/tests/between.ts +57 -0
- package/tests/coinflip.ts +36 -0
- package/tests/exponential.ts +52 -0
- package/tests/gaussian.ts +73 -0
- package/tests/groups.ts +67 -0
- package/tests/hex.ts +45 -0
- package/tests/item.ts +86 -0
- package/tests/pick.ts +57 -0
- package/tests/range.ts +75 -0
- package/tests/rng.ts +78 -0
- package/tests/roll.ts +60 -0
- package/tests/sample.ts +57 -0
- package/tests/shuffle.ts +67 -0
- package/vitest.config.ts +14 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Cryptographically secure random utilities with optional seeded/provably fair mode.
|
|
4
4
|
|
|
5
|
-
All functions accept an optional `seed`
|
|
5
|
+
All functions accept an optional `seed` string as the last argument for reproducible results.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -108,6 +108,22 @@ range(1, 10, true, 'seed'); // Reproducible integer
|
|
|
108
108
|
|
|
109
109
|
---
|
|
110
110
|
|
|
111
|
+
### `rng(seed?)`
|
|
112
|
+
Core random number generator. Returns a number in `[0, 1)`. Without a seed, uses `crypto.getRandomValues`. With a seed, uses a deterministic PRNG (cyrb128 + sfc32).
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
rng(); // 0.7234... (crypto random)
|
|
116
|
+
rng('my-seed'); // Deterministic result
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`rng.seed()` generates a cryptographically secure 64-character hex string.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
rng.seed(); // 'a1b2c3d4e5f6...' (64 chars)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
111
127
|
### `roll(numerator, denominator, seed?)`
|
|
112
128
|
Roll "N in D" chance.
|
|
113
129
|
|
|
@@ -137,251 +153,21 @@ shuffle([1, 2, 3, 4, 5]); // [3, 1, 5, 2, 4]
|
|
|
137
153
|
|
|
138
154
|
---
|
|
139
155
|
|
|
140
|
-
|
|
141
|
-
Generate UUID v4.
|
|
142
|
-
|
|
143
|
-
```ts
|
|
144
|
-
uuid(); // "550e8400-e29b-41d4-a716-446655440000"
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
---
|
|
156
|
+
## Seeded Mode
|
|
148
157
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
---
|
|
152
|
-
|
|
153
|
-
### rng
|
|
154
|
-
|
|
155
|
-
Cryptographically secure random number generator with optional seeded/provably fair mode.
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
rng(): number // Crypto RNG (0-1)
|
|
159
|
-
rng(seed: string): number // Seeded RNG (deterministic)
|
|
160
|
-
```
|
|
158
|
+
All functions accept an optional `seed` string as the last argument. Same seed = same result, enabling reproducible and provably fair outcomes.
|
|
161
159
|
|
|
162
160
|
```ts
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// Cryptographic random
|
|
166
|
-
let random = rng(); // 0.7234...
|
|
167
|
-
|
|
168
|
-
// Seeded/provably fair (same seed = same result)
|
|
169
|
-
let seeded = rng('server-seed:client-seed:nonce'); // Deterministic
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
#### Provably Fair Item Drop Example
|
|
173
|
-
|
|
174
|
-
```ts
|
|
175
|
-
import { hash, rng } from '@esportsplus/crypto';
|
|
176
|
-
|
|
177
|
-
// 1. Server generates secret seed (hidden until round ends)
|
|
178
|
-
let serverSeed = rng.seed();
|
|
179
|
-
let serverSeedHash = await hash(serverSeed); // Reveal this to client before roll
|
|
180
|
-
|
|
181
|
-
// 2. Client provides their seed
|
|
182
|
-
let clientSeed = 'my-lucky-seed-123';
|
|
183
|
-
|
|
184
|
-
// 3. Combine seeds with nonce for deterministic result
|
|
185
|
-
let nonce = 0; // Increment per roll
|
|
186
|
-
let combinedSeed = `${serverSeed}:${clientSeed}:${nonce}`;
|
|
187
|
-
|
|
188
|
-
// 4. Generate roll and determine drop
|
|
189
|
-
let items = [
|
|
190
|
-
{ name: 'Common Sword', weight: 0.70 },
|
|
191
|
-
{ name: 'Rare Armor', weight: 0.25 },
|
|
192
|
-
{ name: 'Legendary Helm', weight: 0.05 }
|
|
193
|
-
];
|
|
194
|
-
|
|
195
|
-
let roll = rng(combinedSeed); // 0.0 - 1.0
|
|
196
|
-
let cumulative = 0;
|
|
197
|
-
let drop = items[0];
|
|
198
|
-
|
|
199
|
-
for (let i = 0, n = items.length; i < n; i++) {
|
|
200
|
-
cumulative += items[i].weight;
|
|
201
|
-
if (roll < cumulative) {
|
|
202
|
-
drop = items[i];
|
|
203
|
-
break;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// 5. After round: reveal serverSeed so client can verify
|
|
208
|
-
// Client checks: hash(serverSeed) === serverSeedHash
|
|
209
|
-
// Client recalculates: rng(`${serverSeed}:${clientSeed}:${nonce}`) === roll
|
|
161
|
+
range(1, 100, true, 'my-seed'); // Always the same integer
|
|
162
|
+
shuffle([1, 2, 3, 4, 5], 'my-seed'); // Always the same order
|
|
210
163
|
```
|
|
211
164
|
|
|
212
165
|
---
|
|
213
166
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
Generate a cryptographically secure 32-byte hex seed.
|
|
217
|
-
|
|
218
|
-
```ts
|
|
219
|
-
rng.seed(): string
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
**Returns:** 64-character hex string
|
|
223
|
-
|
|
224
|
-
```ts
|
|
225
|
-
import { rng } from '@esportsplus/crypto';
|
|
226
|
-
|
|
227
|
-
let serverSeed = rng.seed(); // 'a1b2c3d4...' (64 chars)
|
|
228
|
-
let clientSeed = rng.seed();
|
|
229
|
-
|
|
230
|
-
// Combine for provably fair result
|
|
231
|
-
let result = rng(`${serverSeed}:${clientSeed}:0`);
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
#### Provably Fair System - Complete Flow
|
|
167
|
+
## Testing
|
|
235
168
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
interface UserSeeds {
|
|
239
|
-
id: string;
|
|
240
|
-
serverSeed: string; // Current secret (never exposed until rotation)
|
|
241
|
-
serverSeedHash: string; // SHA-256 of serverSeed (shown to user)
|
|
242
|
-
clientSeed: string; // User-provided or auto-generated
|
|
243
|
-
nonce: number; // Roll counter, increments each roll
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
**1. Initial Setup (User Registration/First Visit)**
|
|
248
|
-
```ts
|
|
249
|
-
import { hash, rng } from '@esportsplus/crypto';
|
|
250
|
-
|
|
251
|
-
// Server generates seeds
|
|
252
|
-
let serverSeed = rng.seed();
|
|
253
|
-
let serverSeedHash = await hash(serverSeed);
|
|
254
|
-
let clientSeed = rng.seed(); // Or user provides custom value
|
|
255
|
-
|
|
256
|
-
// Store in database
|
|
257
|
-
await db.user.create({
|
|
258
|
-
id: odx(),
|
|
259
|
-
serverSeed, // Hidden from user
|
|
260
|
-
serverSeedHash, // Visible to user
|
|
261
|
-
clientSeed, // Visible to user
|
|
262
|
-
nonce: 0
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// Send to client (NEVER send serverSeed)
|
|
266
|
-
return { serverSeedHash, clientSeed, nonce: 0 };
|
|
169
|
+
```bash
|
|
170
|
+
pnpm test
|
|
267
171
|
```
|
|
268
172
|
|
|
269
|
-
|
|
270
|
-
```ts
|
|
271
|
-
// Server-side roll
|
|
272
|
-
async function roll(userId: string) {
|
|
273
|
-
let user = await db.user.get(userId);
|
|
274
|
-
let { serverSeed, clientSeed, nonce } = user;
|
|
275
|
-
|
|
276
|
-
// Generate deterministic result
|
|
277
|
-
let combinedSeed = `${serverSeed}:${clientSeed}:${nonce}`;
|
|
278
|
-
let result = rng(combinedSeed);
|
|
279
|
-
|
|
280
|
-
// Increment nonce for next roll
|
|
281
|
-
await db.user.update(userId, { nonce: nonce + 1 });
|
|
282
|
-
|
|
283
|
-
// Return result (NOT the serverSeed)
|
|
284
|
-
return {
|
|
285
|
-
result,
|
|
286
|
-
nonce, // So user can verify later
|
|
287
|
-
clientSeed, // Confirm which seed was used
|
|
288
|
-
serverSeedHash // Confirm commitment
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
**3. User Changes Client Seed (Anytime)**
|
|
294
|
-
```ts
|
|
295
|
-
async function updateClientSeed(userId: string, newClientSeed: string) {
|
|
296
|
-
// User can change client seed freely
|
|
297
|
-
// Does NOT reveal server seed
|
|
298
|
-
// Does NOT reset nonce (same server seed epoch)
|
|
299
|
-
await db.user.update(userId, { clientSeed: newClientSeed });
|
|
300
|
-
}
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
**4. Seed Rotation (User-Initiated)**
|
|
304
|
-
```ts
|
|
305
|
-
async function rotateServerSeed(userId: string) {
|
|
306
|
-
let user = await db.user.get(userId);
|
|
307
|
-
|
|
308
|
-
// Generate new server seed
|
|
309
|
-
let newServerSeed = rng.seed();
|
|
310
|
-
let newServerSeedHash = await hash(newServerSeed);
|
|
311
|
-
|
|
312
|
-
// Update database
|
|
313
|
-
await db.user.update(userId, {
|
|
314
|
-
serverSeed: newServerSeed,
|
|
315
|
-
serverSeedHash: newServerSeedHash,
|
|
316
|
-
nonce: 0 // Reset nonce for new seed epoch
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// NOW reveal the old serverSeed for verification
|
|
320
|
-
return {
|
|
321
|
-
previousServerSeed: user.serverSeed, // User can now verify all past rolls
|
|
322
|
-
previousServerSeedHash: user.serverSeedHash,
|
|
323
|
-
previousNonce: user.nonce, // Total rolls under old seed
|
|
324
|
-
newServerSeedHash, // New commitment
|
|
325
|
-
nonce: 0
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
**5. Client-Side Verification (After Rotation)**
|
|
331
|
-
```ts
|
|
332
|
-
import { hash, rng } from '@esportsplus/crypto';
|
|
333
|
-
|
|
334
|
-
async function verifyRolls(
|
|
335
|
-
previousServerSeed: string,
|
|
336
|
-
previousServerSeedHash: string,
|
|
337
|
-
clientSeed: string,
|
|
338
|
-
rolls: { nonce: number; result: number }[]
|
|
339
|
-
) {
|
|
340
|
-
// Step 1: Verify server didn't change the seed
|
|
341
|
-
let computedHash = await hash(previousServerSeed);
|
|
342
|
-
if (computedHash !== previousServerSeedHash) {
|
|
343
|
-
throw new Error('Server seed does not match committed hash!');
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Step 2: Verify each roll result
|
|
347
|
-
for (let i = 0, n = rolls.length; i < n; i++) {
|
|
348
|
-
let { nonce, result } = rolls[i];
|
|
349
|
-
let combinedSeed = `${previousServerSeed}:${clientSeed}:${nonce}`;
|
|
350
|
-
let expectedResult = rng(combinedSeed);
|
|
351
|
-
|
|
352
|
-
if (expectedResult !== result) {
|
|
353
|
-
throw new Error(`Roll ${nonce} was manipulated!`);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
return true; // All rolls verified
|
|
358
|
-
}
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
**Security Properties:**
|
|
362
|
-
| Property | Guarantee |
|
|
363
|
-
|----------|-----------|
|
|
364
|
-
| Server can't predict | Client seed unknown when server commits |
|
|
365
|
-
| Server can't manipulate | Hash commitment locks server seed |
|
|
366
|
-
| Client can't predict | Server seed hidden until rotation |
|
|
367
|
-
| Deterministic | Same seeds + nonce = same result always |
|
|
368
|
-
| Verifiable | User can audit all rolls after rotation |
|
|
369
|
-
|
|
370
|
-
**Timeline Example:**
|
|
371
|
-
```
|
|
372
|
-
Day 1: User signs up
|
|
373
|
-
→ serverSeed=ABC, hash(ABC)=XYZ shown, clientSeed=123, nonce=0
|
|
374
|
-
|
|
375
|
-
Day 1: User rolls 50 times
|
|
376
|
-
→ nonce increments 0→49, serverSeed stays ABC (hidden)
|
|
377
|
-
|
|
378
|
-
Day 3: User changes clientSeed to "lucky-charm"
|
|
379
|
-
→ nonce continues at 50, serverSeed stays ABC (hidden)
|
|
380
|
-
|
|
381
|
-
Day 5: User rolls 100 more times
|
|
382
|
-
→ nonce increments 50→149
|
|
383
|
-
|
|
384
|
-
Day 7: User clicks "Rotate Seed"
|
|
385
|
-
→ ABC revealed, user verifies all 150 rolls
|
|
386
|
-
→ New serverSeed=DEF, hash(DEF)=UVW shown, nonce=0
|
|
387
|
-
```
|
|
173
|
+
90 tests across 14 files covering all 13 modules — determinism, error paths, edge cases, and statistical validation.
|
package/build/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rng } from './rng.js';
|
|
1
2
|
import alphanumeric from './alphanumeric.js';
|
|
2
3
|
import between from './between.js';
|
|
3
4
|
import coinflip from './coinflip.js';
|
|
@@ -11,4 +12,4 @@ import range from './range.js';
|
|
|
11
12
|
import roll from './roll.js';
|
|
12
13
|
import sample from './sample.js';
|
|
13
14
|
import shuffle from './shuffle.js';
|
|
14
|
-
export { alphanumeric, between, coinflip, exponential, gaussian, groups, hex, item, pick, range, roll, sample, shuffle };
|
|
15
|
+
export { alphanumeric, between, coinflip, exponential, gaussian, groups, hex, item, pick, range, rng, roll, sample, shuffle };
|
package/build/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rng } from './rng.js';
|
|
1
2
|
import alphanumeric from './alphanumeric.js';
|
|
2
3
|
import between from './between.js';
|
|
3
4
|
import coinflip from './coinflip.js';
|
|
@@ -11,4 +12,4 @@ import range from './range.js';
|
|
|
11
12
|
import roll from './roll.js';
|
|
12
13
|
import sample from './sample.js';
|
|
13
14
|
import shuffle from './shuffle.js';
|
|
14
|
-
export { alphanumeric, between, coinflip, exponential, gaussian, groups, hex, item, pick, range, roll, sample, shuffle };
|
|
15
|
+
export { alphanumeric, between, coinflip, exponential, gaussian, groups, hex, item, pick, range, rng, roll, sample, shuffle };
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"author": "ICJR",
|
|
3
3
|
"devDependencies": {
|
|
4
|
-
"@esportsplus/typescript": "^0.9.2"
|
|
4
|
+
"@esportsplus/typescript": "^0.9.2",
|
|
5
|
+
"vitest": "^4.1.1"
|
|
5
6
|
},
|
|
6
7
|
"main": "build/index.js",
|
|
7
8
|
"name": "@esportsplus/random",
|
|
@@ -12,9 +13,10 @@
|
|
|
12
13
|
},
|
|
13
14
|
"type": "module",
|
|
14
15
|
"types": "build/index.d.ts",
|
|
15
|
-
"version": "0.0.
|
|
16
|
+
"version": "0.0.33",
|
|
16
17
|
"scripts": {
|
|
17
18
|
"build": "tsc && tsc-alias",
|
|
19
|
+
"test": "vitest run",
|
|
18
20
|
"-": "-"
|
|
19
21
|
}
|
|
20
22
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { rng } from './rng';
|
|
2
|
+
|
|
1
3
|
import alphanumeric from './alphanumeric';
|
|
2
4
|
import between from './between';
|
|
3
5
|
import coinflip from './coinflip';
|
|
@@ -13,4 +15,4 @@ import sample from './sample';
|
|
|
13
15
|
import shuffle from './shuffle';
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
export { alphanumeric, between, coinflip, exponential, gaussian, groups, hex, item, pick, range, roll, sample, shuffle };
|
|
18
|
+
export { alphanumeric, between, coinflip, exponential, gaussian, groups, hex, item, pick, range, rng, roll, sample, shuffle };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import alphanumeric from '~/alphanumeric';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('alphanumeric', () => {
|
|
7
|
+
describe('validation', () => {
|
|
8
|
+
it('throws when length is 0', () => {
|
|
9
|
+
expect(() => alphanumeric(0)).toThrow('@esportsplus/random: length must be positive');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('throws when length is negative', () => {
|
|
13
|
+
expect(() => alphanumeric(-1)).toThrow('@esportsplus/random: length must be positive');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('output', () => {
|
|
18
|
+
it('returns string of correct length', () => {
|
|
19
|
+
expect(alphanumeric(10)).toHaveLength(10);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('contains only alphanumeric characters', () => {
|
|
23
|
+
let result = alphanumeric(200);
|
|
24
|
+
|
|
25
|
+
expect(result).toMatch(/^[0-9A-Za-z]+$/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles length of 1', () => {
|
|
29
|
+
expect(alphanumeric(1)).toHaveLength(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('handles length of 100', () => {
|
|
33
|
+
expect(alphanumeric(100)).toHaveLength(100);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('determinism', () => {
|
|
38
|
+
it('same seed produces same string', () => {
|
|
39
|
+
let a = alphanumeric(20, 'test-seed'),
|
|
40
|
+
b = alphanumeric(20, 'test-seed');
|
|
41
|
+
|
|
42
|
+
expect(a).toBe(b);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
package/tests/between.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import between from '~/between';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('between', () => {
|
|
7
|
+
it('throws with less than 2 items', () => {
|
|
8
|
+
expect(() => between([])).toThrow('@esportsplus/random: need at least 2 items');
|
|
9
|
+
expect(() => between([1])).toThrow('@esportsplus/random: need at least 2 items');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('returns both items when exactly 2', () => {
|
|
13
|
+
let result = between([1, 2], 'seed');
|
|
14
|
+
|
|
15
|
+
expect(result).toHaveLength(2);
|
|
16
|
+
expect(result.slice().sort((a, b) => a - b)).toEqual([1, 2]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns two different items', () => {
|
|
20
|
+
let items = [10, 20, 30, 40, 50],
|
|
21
|
+
result = between(items, 'seed');
|
|
22
|
+
|
|
23
|
+
expect(result[0]).not.toBe(result[1]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('is deterministic with same seed', () => {
|
|
27
|
+
let a = between([10, 20, 30, 40, 50], 'seed'),
|
|
28
|
+
b = between([10, 20, 30, 40, 50], 'seed');
|
|
29
|
+
|
|
30
|
+
expect(a).toEqual(b);
|
|
31
|
+
expect(a).toEqual([10, 20]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('seeded rng is stateless so same seed same rng value', () => {
|
|
35
|
+
// rng(seed) returns the same value for the same seed (no state progression)
|
|
36
|
+
// first = (r * n) >>> 0, second = (r * (n-1)) >>> 0
|
|
37
|
+
// These may differ due to different multipliers, but are deterministic
|
|
38
|
+
let items = ['a', 'b', 'c', 'd', 'e'];
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < 20; i++) {
|
|
41
|
+
let seed = `test-${i}`,
|
|
42
|
+
a = between(items, seed),
|
|
43
|
+
b = between(items, seed);
|
|
44
|
+
|
|
45
|
+
expect(a).toEqual(b);
|
|
46
|
+
expect(a[0]).not.toBe(a[1]);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns items from the input array', () => {
|
|
51
|
+
let items = [10, 20, 30, 40, 50],
|
|
52
|
+
result = between(items, 'check');
|
|
53
|
+
|
|
54
|
+
expect(items).toContain(result[0]);
|
|
55
|
+
expect(items).toContain(result[1]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import coinflip from '~/coinflip';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('coinflip', () => {
|
|
7
|
+
describe('output', () => {
|
|
8
|
+
it('returns a boolean', () => {
|
|
9
|
+
expect(typeof coinflip()).toBe('boolean');
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('determinism', () => {
|
|
14
|
+
it('same seed produces same result', () => {
|
|
15
|
+
let a = coinflip('test-seed'),
|
|
16
|
+
b = coinflip('test-seed');
|
|
17
|
+
|
|
18
|
+
expect(a).toBe(b);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('distribution', () => {
|
|
23
|
+
it('roughly 50% true over 1000 trials', () => {
|
|
24
|
+
let trueCount = 0;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < 1000; i++) {
|
|
27
|
+
if (coinflip()) {
|
|
28
|
+
trueCount++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
expect(trueCount).toBeGreaterThan(350);
|
|
33
|
+
expect(trueCount).toBeLessThan(650);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import exponential from '~/exponential';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('exponential', () => {
|
|
7
|
+
describe('lambda <= 0 throws', () => {
|
|
8
|
+
it('throws when lambda is zero', () => {
|
|
9
|
+
expect(() => exponential(0)).toThrow('@esportsplus/random: lambda must be positive');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('throws when lambda is negative', () => {
|
|
13
|
+
expect(() => exponential(-1)).toThrow('@esportsplus/random: lambda must be positive');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('returns positive number', () => {
|
|
18
|
+
it('output is always > 0', () => {
|
|
19
|
+
for (let i = 0; i < 100; i++) {
|
|
20
|
+
expect(exponential(1)).toBeGreaterThan(0);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('deterministic with seed', () => {
|
|
26
|
+
it('same seed produces same output', () => {
|
|
27
|
+
let a = exponential(1, 'fixed'),
|
|
28
|
+
b = exponential(1, 'fixed'),
|
|
29
|
+
c = exponential(1, 'fixed');
|
|
30
|
+
|
|
31
|
+
expect(a).toBe(b);
|
|
32
|
+
expect(b).toBe(c);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('statistical validation', () => {
|
|
37
|
+
it('sample mean approximates 1/lambda', () => {
|
|
38
|
+
let lambda = 1,
|
|
39
|
+
n = 10000,
|
|
40
|
+
sum = 0;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < n; i++) {
|
|
43
|
+
sum += exponential(lambda);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let sampleMean = sum / n;
|
|
47
|
+
|
|
48
|
+
expect(sampleMean).toBeGreaterThan(0.9);
|
|
49
|
+
expect(sampleMean).toBeLessThan(1.1);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import gaussian from '~/gaussian';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('gaussian', () => {
|
|
7
|
+
describe('negative stddev throws', () => {
|
|
8
|
+
it('throws when stddev is negative', () => {
|
|
9
|
+
expect(() => gaussian(0, -1)).toThrow('@esportsplus/random: stddev cannot be negative');
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('zero stddev', () => {
|
|
14
|
+
it('always returns mean exactly', () => {
|
|
15
|
+
expect(gaussian(5, 0, 'seed-a')).toBe(5);
|
|
16
|
+
expect(gaussian(100, 0, 'seed-b')).toBe(100);
|
|
17
|
+
expect(gaussian(-3, 0, 'seed-c')).toBe(-3);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('deterministic with seed', () => {
|
|
22
|
+
it('same seed produces same output', () => {
|
|
23
|
+
let a = gaussian(10, 5, 'fixed'),
|
|
24
|
+
b = gaussian(10, 5, 'fixed'),
|
|
25
|
+
c = gaussian(10, 5, 'fixed');
|
|
26
|
+
|
|
27
|
+
expect(a).toBe(b);
|
|
28
|
+
expect(b).toBe(c);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('returns a number', () => {
|
|
33
|
+
it('output is of type number', () => {
|
|
34
|
+
expect(typeof gaussian(0, 1)).toBe('number');
|
|
35
|
+
expect(typeof gaussian(50, 10, 'seed')).toBe('number');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('statistical validation', () => {
|
|
40
|
+
it('sample mean and stddev approximate parameters', () => {
|
|
41
|
+
let mean = 100,
|
|
42
|
+
n = 10000,
|
|
43
|
+
stddev = 10,
|
|
44
|
+
values: number[] = [];
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < n; i++) {
|
|
47
|
+
values.push(gaussian(mean, stddev));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let sum = 0;
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < n; i++) {
|
|
53
|
+
sum += values[i];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let sampleMean = sum / n,
|
|
57
|
+
sumSq = 0;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < n; i++) {
|
|
60
|
+
let diff = values[i] - sampleMean;
|
|
61
|
+
|
|
62
|
+
sumSq += diff * diff;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let sampleStddev = Math.sqrt(sumSq / (n - 1));
|
|
66
|
+
|
|
67
|
+
expect(sampleMean).toBeGreaterThan(mean - 2);
|
|
68
|
+
expect(sampleMean).toBeLessThan(mean + 2);
|
|
69
|
+
expect(sampleStddev).toBeGreaterThan(stddev - 3);
|
|
70
|
+
expect(sampleStddev).toBeLessThan(stddev + 3);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
package/tests/groups.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import groups from '~/groups';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('groups', () => {
|
|
7
|
+
it('throws when total is 0 or negative', () => {
|
|
8
|
+
expect(() => groups([1, 2, 3], 0)).toThrow('@esportsplus/random: total must be positive');
|
|
9
|
+
expect(() => groups([1, 2, 3], -1)).toThrow('@esportsplus/random: total must be positive');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('divides evenly', () => {
|
|
13
|
+
let result = groups([1, 2, 3, 4, 5, 6], 3, 'seed');
|
|
14
|
+
|
|
15
|
+
expect(result).toHaveLength(3);
|
|
16
|
+
|
|
17
|
+
for (let i = 0, n = result.length; i < n; i++) {
|
|
18
|
+
expect(result[i]).toHaveLength(2);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('handles uneven division with all items present', () => {
|
|
23
|
+
let result = groups([1, 2, 3, 4, 5, 6, 7], 3, 'seed');
|
|
24
|
+
|
|
25
|
+
expect(result).toHaveLength(3);
|
|
26
|
+
|
|
27
|
+
let flat = result.flat().sort((a, b) => a - b);
|
|
28
|
+
|
|
29
|
+
expect(flat).toEqual([1, 2, 3, 4, 5, 6, 7]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('preserves all items across groups', () => {
|
|
33
|
+
let input = [1, 2, 3, 4, 5, 6];
|
|
34
|
+
let result = groups(input, 3, 'seed');
|
|
35
|
+
let flat = result.flat().sort((a, b) => a - b);
|
|
36
|
+
|
|
37
|
+
expect(flat).toEqual([1, 2, 3, 4, 5, 6]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('is deterministic with same seed', () => {
|
|
41
|
+
let a = groups([1, 2, 3, 4, 5, 6], 3, 'seed'),
|
|
42
|
+
b = groups([1, 2, 3, 4, 5, 6], 3, 'seed');
|
|
43
|
+
|
|
44
|
+
expect(a).toEqual(b);
|
|
45
|
+
expect(a).toEqual([[2, 3], [4, 5], [6, 1]]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('produces empty groups when more groups than items', () => {
|
|
49
|
+
let result = groups([1, 2], 5, 'seed');
|
|
50
|
+
|
|
51
|
+
expect(result).toHaveLength(5);
|
|
52
|
+
|
|
53
|
+
let flat = result.flat().sort((a, b) => a - b);
|
|
54
|
+
|
|
55
|
+
expect(flat).toEqual([1, 2]);
|
|
56
|
+
|
|
57
|
+
let emptyCount = 0;
|
|
58
|
+
|
|
59
|
+
for (let i = 0, n = result.length; i < n; i++) {
|
|
60
|
+
if (result[i].length === 0) {
|
|
61
|
+
emptyCount++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
expect(emptyCount).toBeGreaterThan(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
package/tests/hex.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import hex from '~/hex';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('hex', () => {
|
|
7
|
+
describe('validation', () => {
|
|
8
|
+
it('throws when length is 0', () => {
|
|
9
|
+
expect(() => hex(0)).toThrow('@esportsplus/random: length must be positive');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('throws when length is negative', () => {
|
|
13
|
+
expect(() => hex(-1)).toThrow('@esportsplus/random: length must be positive');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('output', () => {
|
|
18
|
+
it('returns string of correct length', () => {
|
|
19
|
+
expect(hex(10)).toHaveLength(10);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('contains only lowercase hex characters', () => {
|
|
23
|
+
let result = hex(200);
|
|
24
|
+
|
|
25
|
+
expect(result).toMatch(/^[0-9a-f]+$/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles length of 1', () => {
|
|
29
|
+
expect(hex(1)).toHaveLength(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('handles length of 64', () => {
|
|
33
|
+
expect(hex(64)).toHaveLength(64);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('determinism', () => {
|
|
38
|
+
it('same seed produces same string', () => {
|
|
39
|
+
let a = hex(32, 'test-seed'),
|
|
40
|
+
b = hex(32, 'test-seed');
|
|
41
|
+
|
|
42
|
+
expect(a).toBe(b);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
package/tests/item.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import item from '~/item';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('item', () => {
|
|
7
|
+
describe('empty map throws', () => {
|
|
8
|
+
it('throws when map has no entries', () => {
|
|
9
|
+
expect(() => item(new Map())).toThrow('@esportsplus/random: map cannot be empty');
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('single item map', () => {
|
|
14
|
+
it('always returns the only item', () => {
|
|
15
|
+
let map = new Map([['only', 1]]);
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < 50; i++) {
|
|
18
|
+
expect(item(map)).toBe('only');
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('weighted selection with seed', () => {
|
|
24
|
+
it('same seed produces same result', () => {
|
|
25
|
+
let map = new Map([['a', 1], ['b', 2], ['c', 3]]),
|
|
26
|
+
a = item(map, 'deterministic'),
|
|
27
|
+
b = item(map, 'deterministic'),
|
|
28
|
+
c = item(map, 'deterministic');
|
|
29
|
+
|
|
30
|
+
expect(a).toBe(b);
|
|
31
|
+
expect(b).toBe(c);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('multiple items', () => {
|
|
36
|
+
it('result is always one of the map keys', () => {
|
|
37
|
+
let keys = ['x', 'y', 'z'],
|
|
38
|
+
map = new Map([['x', 1], ['y', 1], ['z', 1]]);
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < 100; i++) {
|
|
41
|
+
expect(keys).toContain(item(map));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('zero weight item', () => {
|
|
47
|
+
it('item with weight 0 is almost never selected', () => {
|
|
48
|
+
let map = new Map([['zero', 0], ['heavy', 100]]),
|
|
49
|
+
zeroCount = 0;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < 1000; i++) {
|
|
52
|
+
if (item(map) === 'zero') {
|
|
53
|
+
zeroCount++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
expect(zeroCount).toBeLessThan(5);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('fallback throw with negative weights', () => {
|
|
62
|
+
it('throws when weighted pick fails due to negative weights', () => {
|
|
63
|
+
let map = new Map([['a', -10], ['b', 1]]);
|
|
64
|
+
|
|
65
|
+
expect(() => item(map, 'any-seed')).toThrow('@esportsplus/random: weighted item pick failed');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('equal weights', () => {
|
|
70
|
+
it('all items are roughly equally likely', () => {
|
|
71
|
+
let counts: Record<string, number> = { a: 0, b: 0, c: 0 },
|
|
72
|
+
map = new Map([['a', 1], ['b', 1], ['c', 1]]),
|
|
73
|
+
n = 3000;
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < n; i++) {
|
|
76
|
+
let result = item(map);
|
|
77
|
+
|
|
78
|
+
counts[result]++;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (let key of Object.keys(counts)) {
|
|
82
|
+
expect(counts[key] / n).toBeGreaterThan(0.15);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
package/tests/pick.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import pick from '~/pick';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('pick', () => {
|
|
7
|
+
describe('validation', () => {
|
|
8
|
+
it('throws when items array is empty', () => {
|
|
9
|
+
expect(() => pick([], 1)).toThrow('@esportsplus/random: items cannot be empty');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('throws when count is 0', () => {
|
|
13
|
+
expect(() => pick([1], 0)).toThrow('@esportsplus/random: count must be positive');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('throws when count is negative', () => {
|
|
17
|
+
expect(() => pick([1], -1)).toThrow('@esportsplus/random: count must be positive');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('output', () => {
|
|
22
|
+
it('returns correct count of items', () => {
|
|
23
|
+
let result = pick([1, 2, 3], 5);
|
|
24
|
+
|
|
25
|
+
expect(result).toHaveLength(5);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('all returned items exist in source array', () => {
|
|
29
|
+
let items = ['a', 'b', 'c', 'd'],
|
|
30
|
+
result = pick(items, 20);
|
|
31
|
+
|
|
32
|
+
for (let i = 0, n = result.length; i < n; i++) {
|
|
33
|
+
expect(items).toContain(result[i]);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('allows duplicates when count exceeds items length', () => {
|
|
38
|
+
let items = [1, 2],
|
|
39
|
+
result = pick(items, 100);
|
|
40
|
+
|
|
41
|
+
expect(result).toHaveLength(100);
|
|
42
|
+
|
|
43
|
+
for (let i = 0, n = result.length; i < n; i++) {
|
|
44
|
+
expect(items).toContain(result[i]);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('determinism', () => {
|
|
50
|
+
it('same seed produces same result', () => {
|
|
51
|
+
let a = pick([1, 2, 3, 4, 5], 3, 'test-seed'),
|
|
52
|
+
b = pick([1, 2, 3, 4, 5], 3, 'test-seed');
|
|
53
|
+
|
|
54
|
+
expect(a).toEqual(b);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
package/tests/range.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import range from '~/range';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('range', () => {
|
|
7
|
+
it('throws when min is greater than max', () => {
|
|
8
|
+
expect(() => range(5, 3)).toThrow('@esportsplus/random: min cannot be greater than max');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns float in [min, max] by default', () => {
|
|
12
|
+
for (let i = 0; i < 100; i++) {
|
|
13
|
+
let result = range(1, 10);
|
|
14
|
+
|
|
15
|
+
expect(result).toBeGreaterThanOrEqual(1);
|
|
16
|
+
expect(result).toBeLessThanOrEqual(10);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns integer in integer mode', () => {
|
|
21
|
+
for (let i = 0; i < 100; i++) {
|
|
22
|
+
let result = range(1, 10, true);
|
|
23
|
+
|
|
24
|
+
expect(Number.isInteger(result)).toBe(true);
|
|
25
|
+
expect(result).toBeGreaterThanOrEqual(1);
|
|
26
|
+
expect(result).toBeLessThanOrEqual(10);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns min when min equals max in float mode', () => {
|
|
31
|
+
let result = range(5, 5, false, 'seed');
|
|
32
|
+
|
|
33
|
+
expect(result).toBe(5);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns min when min equals max in integer mode', () => {
|
|
37
|
+
let result = range(5, 5, true, 'seed');
|
|
38
|
+
|
|
39
|
+
expect(result).toBe(5);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is deterministic with same seed', () => {
|
|
43
|
+
let a = range(1, 10, false, 'seed'),
|
|
44
|
+
b = range(1, 10, false, 'seed');
|
|
45
|
+
|
|
46
|
+
expect(a).toBe(b);
|
|
47
|
+
expect(a).toBeCloseTo(2.2514521240376526);
|
|
48
|
+
|
|
49
|
+
let c = range(1, 10, true, 'seed'),
|
|
50
|
+
d = range(1, 10, true, 'seed');
|
|
51
|
+
|
|
52
|
+
expect(c).toBe(d);
|
|
53
|
+
expect(c).toBe(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles fractional min/max in integer mode', () => {
|
|
57
|
+
// range(1.5, 2.5, true) → ceil(1.5)=2, floor(2.5)+1=3, so only 2 is possible
|
|
58
|
+
for (let i = 0; i < 50; i++) {
|
|
59
|
+
expect(range(1.5, 2.5, true)).toBe(2);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns 0 or 1 for range(0, 1, true)', () => {
|
|
64
|
+
let seen = new Set<number>();
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < 100; i++) {
|
|
67
|
+
let result = range(0, 1, true);
|
|
68
|
+
|
|
69
|
+
expect(result === 0 || result === 1).toBe(true);
|
|
70
|
+
seen.add(result);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
expect(seen.size).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/tests/rng.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { rng } from '~/rng';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('rng', () => {
|
|
6
|
+
describe('determinism', () => {
|
|
7
|
+
it('same seed always returns same value', () => {
|
|
8
|
+
let a = rng('test-seed'),
|
|
9
|
+
b = rng('test-seed'),
|
|
10
|
+
c = rng('test-seed');
|
|
11
|
+
|
|
12
|
+
expect(a).toBe(b);
|
|
13
|
+
expect(b).toBe(c);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('different seeds produce different values', () => {
|
|
18
|
+
it('distinct seeds yield distinct outputs', () => {
|
|
19
|
+
let a = rng('seed-a'),
|
|
20
|
+
b = rng('seed-b');
|
|
21
|
+
|
|
22
|
+
expect(a).not.toBe(b);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('output range', () => {
|
|
27
|
+
it('seeded output is in [0, 1)', () => {
|
|
28
|
+
let seeds = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta'];
|
|
29
|
+
|
|
30
|
+
for (let i = 0, n = seeds.length; i < n; i++) {
|
|
31
|
+
let value = rng(seeds[i]);
|
|
32
|
+
|
|
33
|
+
expect(value).toBeGreaterThanOrEqual(0);
|
|
34
|
+
expect(value).toBeLessThan(1);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('unseeded path', () => {
|
|
40
|
+
it('returns a number in [0, 1)', () => {
|
|
41
|
+
for (let i = 0; i < 100; i++) {
|
|
42
|
+
let value = rng();
|
|
43
|
+
|
|
44
|
+
expect(value).toBeGreaterThanOrEqual(0);
|
|
45
|
+
expect(value).toBeLessThan(1);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('empty string seed', () => {
|
|
51
|
+
it('treats empty string as unseeded (falsy)', () => {
|
|
52
|
+
let results = new Set<number>();
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < 10; i++) {
|
|
55
|
+
results.add(rng(''));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// If seeded, all 10 would be identical. Unseeded → likely all different.
|
|
59
|
+
expect(results.size).toBeGreaterThan(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('rng.seed', () => {
|
|
64
|
+
it('returns a 64-character hex string', () => {
|
|
65
|
+
let seed = rng.seed();
|
|
66
|
+
|
|
67
|
+
expect(seed).toHaveLength(64);
|
|
68
|
+
expect(seed).toMatch(/^[0-9a-f]{64}$/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('two calls produce different seeds', () => {
|
|
72
|
+
let a = rng.seed(),
|
|
73
|
+
b = rng.seed();
|
|
74
|
+
|
|
75
|
+
expect(a).not.toBe(b);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
package/tests/roll.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import roll from '~/roll';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('roll', () => {
|
|
7
|
+
describe('validation', () => {
|
|
8
|
+
it('throws when numerator is 0', () => {
|
|
9
|
+
expect(() => roll(0, 6)).toThrow('@esportsplus/random: numerator and denominator must be positive');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('throws when numerator is negative', () => {
|
|
13
|
+
expect(() => roll(-1, 6)).toThrow('@esportsplus/random: numerator and denominator must be positive');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('throws when denominator is 0', () => {
|
|
17
|
+
expect(() => roll(1, 0)).toThrow('@esportsplus/random: numerator and denominator must be positive');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('throws when denominator is negative', () => {
|
|
21
|
+
expect(() => roll(1, -1)).toThrow('@esportsplus/random: numerator and denominator must be positive');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('output', () => {
|
|
26
|
+
it('returns a boolean', () => {
|
|
27
|
+
expect(typeof roll(1, 2)).toBe('boolean');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('1/1 always returns true', () => {
|
|
31
|
+
for (let i = 0; i < 100; i++) {
|
|
32
|
+
expect(roll(1, 1)).toBe(true);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('determinism', () => {
|
|
38
|
+
it('same seed produces same result', () => {
|
|
39
|
+
let a = roll(1, 2, 'test-seed'),
|
|
40
|
+
b = roll(1, 2, 'test-seed');
|
|
41
|
+
|
|
42
|
+
expect(a).toBe(b);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('distribution', () => {
|
|
47
|
+
it('roll(1, 2) is roughly 50% true over 1000 trials', () => {
|
|
48
|
+
let trueCount = 0;
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < 1000; i++) {
|
|
51
|
+
if (roll(1, 2)) {
|
|
52
|
+
trueCount++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
expect(trueCount).toBeGreaterThan(400);
|
|
57
|
+
expect(trueCount).toBeLessThan(600);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
package/tests/sample.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import sample from '~/sample';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('sample', () => {
|
|
7
|
+
it('throws when items is empty', () => {
|
|
8
|
+
expect(() => sample([], 1)).toThrow('@esportsplus/random: items cannot be empty');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('throws when count is 0 or negative', () => {
|
|
12
|
+
expect(() => sample([1], 0)).toThrow('@esportsplus/random: count must be positive');
|
|
13
|
+
expect(() => sample([1], -1)).toThrow('@esportsplus/random: count must be positive');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('throws when count exceeds items length', () => {
|
|
17
|
+
expect(() => sample([1, 2], 3)).toThrow('@esportsplus/random: count cannot exceed items length');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns correct count', () => {
|
|
21
|
+
let result = sample([1, 2, 3, 4, 5], 3, 'seed');
|
|
22
|
+
|
|
23
|
+
expect(result).toHaveLength(3);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns no duplicates', () => {
|
|
27
|
+
let result = sample([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5, 'no-dup'),
|
|
28
|
+
unique = new Set(result);
|
|
29
|
+
|
|
30
|
+
expect(unique.size).toBe(result.length);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns only items from source', () => {
|
|
34
|
+
let input = [10, 20, 30, 40, 50],
|
|
35
|
+
result = sample(input, 3, 'source-check');
|
|
36
|
+
|
|
37
|
+
for (let i = 0, n = result.length; i < n; i++) {
|
|
38
|
+
expect(input).toContain(result[i]);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is deterministic with same seed', () => {
|
|
43
|
+
let a = sample([1, 2, 3, 4, 5], 3, 'seed'),
|
|
44
|
+
b = sample([1, 2, 3, 4, 5], 3, 'seed');
|
|
45
|
+
|
|
46
|
+
expect(a).toEqual(b);
|
|
47
|
+
expect(a).toEqual([2, 3, 4]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns all items when count equals length', () => {
|
|
51
|
+
let input = [1, 2, 3, 4, 5],
|
|
52
|
+
result = sample(input, 5, 'seed');
|
|
53
|
+
|
|
54
|
+
expect(result).toHaveLength(5);
|
|
55
|
+
expect(result.slice().sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/tests/shuffle.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import shuffle from '~/shuffle';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('shuffle', () => {
|
|
7
|
+
it('returns same empty array', () => {
|
|
8
|
+
let arr: number[] = [],
|
|
9
|
+
result = shuffle(arr);
|
|
10
|
+
|
|
11
|
+
expect(result).toBe(arr);
|
|
12
|
+
expect(result).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns array with single element', () => {
|
|
16
|
+
let arr = [42],
|
|
17
|
+
result = shuffle(arr);
|
|
18
|
+
|
|
19
|
+
expect(result).toEqual([42]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('preserves all elements', () => {
|
|
23
|
+
let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
|
24
|
+
result = shuffle([...arr], 'preserve-test');
|
|
25
|
+
|
|
26
|
+
expect(result.slice().sort((a, b) => a - b)).toEqual(arr);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('mutates in place and returns same reference', () => {
|
|
30
|
+
let arr = [1, 2, 3, 4, 5],
|
|
31
|
+
result = shuffle(arr, 'ref-test');
|
|
32
|
+
|
|
33
|
+
expect(result).toBe(arr);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('is deterministic with same seed', () => {
|
|
37
|
+
let a = shuffle([1, 2, 3, 4, 5], 'seed'),
|
|
38
|
+
b = shuffle([1, 2, 3, 4, 5], 'seed');
|
|
39
|
+
|
|
40
|
+
expect(a).toEqual(b);
|
|
41
|
+
expect(a).toEqual([2, 3, 4, 5, 1]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('actually shuffles with many elements', () => {
|
|
45
|
+
let original = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
|
46
|
+
differed = false;
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < 10; i++) {
|
|
49
|
+
let result = shuffle([...original]);
|
|
50
|
+
let same = true;
|
|
51
|
+
|
|
52
|
+
for (let j = 0, n = original.length; j < n; j++) {
|
|
53
|
+
if (result[j] !== original[j]) {
|
|
54
|
+
same = false;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!same) {
|
|
60
|
+
differed = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
expect(differed).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
package/vitest.config.ts
ADDED