shufflecom-calculations 2.2.32 → 2.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shufflecom-calculations",
3
- "version": "2.2.32",
3
+ "version": "2.3.0",
4
4
  "description": "",
5
5
  "types": "lib/index.d.ts",
6
6
  "main": "lib/index.js",
@@ -14,5 +14,5 @@
14
14
  },
15
15
  "author": "",
16
16
  "license": "ISC",
17
- "gitHead": "7df79f5b309a3e52bcb255c2341cda860b8644c2"
17
+ "gitHead": "337880d365f72dfd40af9bffce3e99f3ff9e9f58"
18
18
  }
@@ -0,0 +1,102 @@
1
+ import { Chicken, ChickenDifficulty } from './chicken';
2
+ import { GameCalculation } from './gameCalculation';
3
+
4
+ const edgeBps = 200;
5
+
6
+ const resultMultipliers = {
7
+ [ChickenDifficulty.EASY]: [1.03, 1.09, 1.15, 1.23, 1.31, 1.4, 1.51, 1.63, 1.78, 1.96, 2.18, 2.45, 2.8, 3.27, 3.92, 4.9, 6.53, 9.8, 19.6],
8
+ [ChickenDifficulty.MEDIUM]: [1.15, 1.37, 1.64, 2.0, 2.46, 3.07, 3.91, 5.08, 6.77, 9.31, 13.3, 19.95, 31.92, 55.86, 111.72, 279.3, 1117.2],
9
+ [ChickenDifficulty.HARD]: [1.31, 1.77, 2.46, 3.48, 5.06, 7.59, 11.81, 19.18, 32.89, 60.29, 120.59, 271.32, 723.52, 2532.32, 15193.92],
10
+ [ChickenDifficulty.EXPERT]: [1.96, 4.14, 9.31, 22.61, 60.29, 180.88, 633.08, 2743.35, 16460.08, 181060.88],
11
+ };
12
+
13
+ describe('Chicken', () => {
14
+ it('should calculate the correct multiplier', () => {
15
+ Object.keys(resultMultipliers).forEach(difficulty => {
16
+ const multiplierArr = resultMultipliers[difficulty as ChickenDifficulty];
17
+ multiplierArr.forEach((multiplier, index) => {
18
+ expect(Chicken.calculateMultiplierBN(difficulty as ChickenDifficulty, index, edgeBps).toFixed(2)).toBe(multiplier.toFixed(2));
19
+ });
20
+ });
21
+ });
22
+
23
+ it('chicken provably fair', () => {
24
+ const gameSeeds = [
25
+ {
26
+ serverSeed: 'da5a4cd5eca04d91d36569207265bd79d2989a19378eea897399755f2c9b83e6',
27
+ clientSeed: '7qkHqOhx-F',
28
+ nonce: '1',
29
+ rounds: Chicken.DIFFICULTY_TO_HEX_ROUNDS[ChickenDifficulty.EASY],
30
+ difficulty: ChickenDifficulty.EASY,
31
+ results: [1],
32
+ },
33
+ {
34
+ serverSeed: 'db9e7314084e5dd9335424abccd3277903e238d0160b82ea8b82439e1935c202',
35
+ clientSeed: '7bsnudbuu5',
36
+ nonce: '6',
37
+ rounds: Chicken.DIFFICULTY_TO_HEX_ROUNDS[ChickenDifficulty.EASY],
38
+ difficulty: ChickenDifficulty.EASY,
39
+ results: [8],
40
+ },
41
+ {
42
+ serverSeed: '779ddeb1e84ef941e25f495cd2d5a011b3be8a8024192a276d332aad5925175f',
43
+ clientSeed: '92p9csrt50',
44
+ nonce: '13',
45
+ rounds: Chicken.DIFFICULTY_TO_HEX_ROUNDS[ChickenDifficulty.MEDIUM],
46
+ difficulty: ChickenDifficulty.MEDIUM,
47
+ results: [16, 19, 17],
48
+ },
49
+ {
50
+ serverSeed: 'ebd3ff712ee7ed3cabe3753146f95847b017f847758ed254c531ff0d45686cea',
51
+ clientSeed: 'pf4jl5q97q',
52
+ nonce: '13',
53
+ rounds: Chicken.DIFFICULTY_TO_HEX_ROUNDS[ChickenDifficulty.MEDIUM],
54
+ difficulty: ChickenDifficulty.MEDIUM,
55
+ results: [9, 3, 13],
56
+ },
57
+ {
58
+ serverSeed: 'da5a4cd5eca04d91d36569207265bd79d2989a19378eea897399755f2c9b83e6',
59
+ clientSeed: '7qkHqOhxLF',
60
+ nonce: '27',
61
+ rounds: Chicken.DIFFICULTY_TO_HEX_ROUNDS[ChickenDifficulty.HARD],
62
+ difficulty: ChickenDifficulty.HARD,
63
+ results: [12, 0, 14, 17, 18],
64
+ },
65
+ {
66
+ serverSeed: '9b0499f7abbebf3ea889e3f958d1bc2d77c514792143d07dc66378711f7eb313',
67
+ clientSeed: '92p9csrt50',
68
+ nonce: '30',
69
+ rounds: Chicken.DIFFICULTY_TO_HEX_ROUNDS[ChickenDifficulty.HARD],
70
+ difficulty: ChickenDifficulty.HARD,
71
+ results: [9, 18, 16, 13, 7],
72
+ },
73
+ {
74
+ serverSeed: '9b0499f7abbebf3ea889e3f958d1bc2d77c514792143d07dc66378711f7eb313',
75
+ clientSeed: '7bsnudbuu5',
76
+ nonce: '35',
77
+ rounds: Chicken.DIFFICULTY_TO_HEX_ROUNDS[ChickenDifficulty.EXPERT],
78
+ difficulty: ChickenDifficulty.EXPERT,
79
+ results: [12, 10, 18, 14, 11, 6, 13, 15, 3, 9],
80
+ },
81
+ {
82
+ serverSeed: '779ddeb1e84ef941e25f495cd2d5a011b3be8a8024192a276d332aad5925175f',
83
+ clientSeed: 'pf4jl5q97q',
84
+ nonce: '40',
85
+ rounds: Chicken.DIFFICULTY_TO_HEX_ROUNDS[ChickenDifficulty.EXPERT],
86
+ difficulty: ChickenDifficulty.EXPERT,
87
+ results: [5, 2, 10, 18, 3, 7, 12, 19, 11, 14],
88
+ },
89
+ ];
90
+
91
+ gameSeeds.forEach(e => {
92
+ const resultHex = GameCalculation.generateDigestHex({
93
+ serverSeed: e.serverSeed,
94
+ clientSeed: e.clientSeed,
95
+ nonce: e.nonce,
96
+ rounds: e.rounds,
97
+ });
98
+ const results = Chicken.getResults(resultHex, e.difficulty);
99
+ expect(results).toEqual(e.results);
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,59 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import { calculateEdgeMultiplier } from '../utils/edge';
3
+ import { nCr } from '../utils/ncr';
4
+ import { GameCalculation } from './gameCalculation';
5
+
6
+ export enum ChickenDifficulty {
7
+ EASY = 'EASY',
8
+ MEDIUM = 'MEDIUM',
9
+ HARD = 'HARD',
10
+ EXPERT = 'EXPERT',
11
+ }
12
+
13
+ export class Chicken extends GameCalculation {
14
+ static readonly TOTAL_LANES = 20;
15
+
16
+ static readonly DIFFICULTY_TO_VALID_LANE_COUNT: Record<ChickenDifficulty, number> = {
17
+ [ChickenDifficulty.EASY]: 19,
18
+ [ChickenDifficulty.MEDIUM]: 17,
19
+ [ChickenDifficulty.HARD]: 15,
20
+ [ChickenDifficulty.EXPERT]: 10,
21
+ };
22
+
23
+ static readonly DIFFICULTY_TO_HEX_ROUNDS: Record<ChickenDifficulty, number> = {
24
+ [ChickenDifficulty.EASY]: 1,
25
+ [ChickenDifficulty.MEDIUM]: 1,
26
+ [ChickenDifficulty.HARD]: 1,
27
+ [ChickenDifficulty.EXPERT]: 2,
28
+ };
29
+
30
+ static getResults(hexStr: string[], difficulty: ChickenDifficulty): number[] {
31
+ const losingLaneCount = this.TOTAL_LANES - this.DIFFICULTY_TO_VALID_LANE_COUNT[difficulty];
32
+
33
+ return this.pickUniqueNumbers(hexStr, losingLaneCount, this.TOTAL_LANES);
34
+ }
35
+
36
+ static calculateWinChanceBN(difficulty: ChickenDifficulty, selectedLane: number): BigNumber {
37
+ const validLanes = this.DIFFICULTY_TO_VALID_LANE_COUNT[difficulty];
38
+ const selectedLaneCount = selectedLane + 1; // selectedLane is 0-indexed
39
+
40
+ return nCr(validLanes, selectedLaneCount).dividedBy(nCr(this.TOTAL_LANES, selectedLaneCount));
41
+ }
42
+
43
+ static calculateMultiplierBN(difficulty: ChickenDifficulty, selectedLane: number, edgeBps: number): BigNumber {
44
+ const winChance = this.calculateWinChanceBN(difficulty, selectedLane);
45
+
46
+ return calculateEdgeMultiplier(edgeBps).div(winChance);
47
+ }
48
+
49
+ static firstDeathLane(results: number[]): number {
50
+ if (results.length === 0) {
51
+ throw new Error('Results array is empty, cannot determine first death lane.');
52
+ }
53
+ return Math.min(...results);
54
+ }
55
+
56
+ static isLoss(results: number[], selected: number): boolean {
57
+ return selected >= this.firstDeathLane(results);
58
+ }
59
+ }
@@ -0,0 +1,153 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import { hexToBytes } from '../utils/hex-to-bytes';
3
+ import { createHmac } from 'crypto';
4
+
5
+ export abstract class GameCalculation {
6
+ private static readonly BYTE_TOTAL = new BigNumber(256);
7
+ private static readonly BYTES_PER_HEX_STRING = 32;
8
+ private static readonly BYTES_PER_RANDOM_NUMBER = 4;
9
+ private static readonly NUMBERS_PER_HEX_STRING = 8;
10
+
11
+ /**
12
+ * Convert 4 consecutive bytes to a probability in [0, 1)
13
+ * This is the foundation of all randomness
14
+ *
15
+ * @param byteArray - The full byte array (Uint8Array)
16
+ * @param startIndex - Starting position to read 4 bytes from
17
+ * @returns A probability value between 0 (inclusive) and 1 (exclusive)
18
+ */
19
+ private static bytesToProbability(byteArray: Uint8Array, startIndex: number): BigNumber {
20
+ let probability = new BigNumber(0);
21
+
22
+ // Read 4 consecutive bytes starting at startIndex
23
+ for (let i = 0; i < this.BYTES_PER_RANDOM_NUMBER; i++) {
24
+ const byteValue = byteArray[startIndex + i];
25
+ probability = probability.plus(new BigNumber(byteValue).dividedBy(this.BYTE_TOTAL.pow(i + 1)));
26
+ }
27
+
28
+ return probability;
29
+ }
30
+
31
+ /**
32
+ * Generate a random integer in range [0, maxValue)
33
+ *
34
+ * @param byteArray - The full byte array
35
+ * @param startIndex - Starting position to read 4 bytes from
36
+ * @param maxValue - Upper bound (exclusive)
37
+ * @returns Random integer in [0, maxValue)
38
+ */
39
+ private static getRandomInt(byteArray: Uint8Array, startIndex: number, maxValue: number): number {
40
+ const probability = this.bytesToProbability(byteArray, startIndex);
41
+ return probability.multipliedBy(maxValue).integerValue(BigNumber.ROUND_DOWN).toNumber();
42
+ }
43
+
44
+ /**
45
+ * Pick N unique numbers from a pool (Fisher-Yates shuffle / sampling without replacement)
46
+ *
47
+ * How it works:
48
+ * 1. Start with pool: [0, 1, 2, ..., poolSize-1]
49
+ * 2. Generate random position in remaining pool
50
+ * 3. Pick number at that position and REMOVE it from pool
51
+ * 4. Repeat until we have N unique numbers
52
+ *
53
+ * @param hexStrings - Array of hex strings for randomness
54
+ * @param count - How many unique numbers to pick
55
+ * @param poolSize - Size of the pool to pick from (0 to poolSize-1)
56
+ * @returns Array of unique numbers
57
+ */
58
+ static pickUniqueNumbers(hexStrings: string[], count: number, poolSize: number): number[] {
59
+ const bytesAvailable = hexStrings.length * this.BYTES_PER_HEX_STRING;
60
+ const bytesNeeded = count * this.BYTES_PER_RANDOM_NUMBER;
61
+ if (bytesAvailable < bytesNeeded) {
62
+ throw new Error(`Not enough randomness: need ${bytesNeeded} bytes for ${count} numbers, only have ${bytesAvailable} bytes`);
63
+ }
64
+
65
+ const results: number[] = [];
66
+ const pool = Array.from({ length: poolSize }, (_, i) => i);
67
+
68
+ const byteArrays = hexStrings.map(hexToBytes);
69
+
70
+ for (let hexIndex = 0; hexIndex < byteArrays.length; hexIndex++) {
71
+ const byteArray = byteArrays[hexIndex];
72
+
73
+ // Each hex string can generate up to 8 numbers (32 bytes / 4 bytes per number)
74
+ for (let numberIndex = 0; numberIndex < this.NUMBERS_PER_HEX_STRING; numberIndex++) {
75
+ if (results.length >= count) {
76
+ return results;
77
+ }
78
+
79
+ const byteOffset = numberIndex * this.BYTES_PER_RANDOM_NUMBER;
80
+ const remainingPoolSize = pool.length;
81
+ const randomPosition = this.getRandomInt(byteArray, byteOffset, remainingPoolSize);
82
+
83
+ const pickedNumber = pool.splice(randomPosition, 1)[0];
84
+ results.push(pickedNumber);
85
+ }
86
+ }
87
+
88
+ return results;
89
+ }
90
+
91
+ /**
92
+ * Generate N independent random numbers (duplicates allowed)
93
+ *
94
+ * Each number is generated independently - the pool doesn't shrink.
95
+ * Same number can appear multiple times.
96
+ *
97
+ *
98
+ * @param hexStrings - Array of hex strings for randomness
99
+ * @param count - How many random numbers to generate
100
+ * @param maxValue - Upper bound (exclusive) for each number
101
+ * @returns Array of random numbers (may contain duplicates)
102
+ */
103
+ static generateIndependentNumbers(hexStrings: string[], count: number, maxValue: number): number[] {
104
+ const bytesAvailable = hexStrings.length * this.BYTES_PER_HEX_STRING;
105
+ const bytesNeeded = count * this.BYTES_PER_RANDOM_NUMBER;
106
+ if (bytesAvailable < bytesNeeded) {
107
+ throw new Error(`Not enough randomness: need ${bytesNeeded} bytes for ${count} numbers, only have ${bytesAvailable} bytes`);
108
+ }
109
+
110
+ const results: number[] = [];
111
+ const byteArrays = hexStrings.map(hexToBytes);
112
+
113
+ for (let hexIndex = 0; hexIndex < byteArrays.length; hexIndex++) {
114
+ const byteArray = byteArrays[hexIndex];
115
+
116
+ for (let numberIndex = 0; numberIndex < this.NUMBERS_PER_HEX_STRING; numberIndex++) {
117
+ if (results.length >= count) {
118
+ return results;
119
+ }
120
+
121
+ const byteOffset = numberIndex * this.BYTES_PER_RANDOM_NUMBER;
122
+ const randomNumber = this.getRandomInt(byteArray, byteOffset, maxValue);
123
+ results.push(randomNumber);
124
+ }
125
+ }
126
+
127
+ return results;
128
+ }
129
+
130
+ /**
131
+ * Generate provably fair random hex strings using HMAC-SHA256
132
+ *
133
+ * @param serverSeed - Secret server seed (revealed after game/rotation)
134
+ * @param clientSeed - Player-provided seed for transparency
135
+ * @param nonce - Sequential number that increments with each bet
136
+ * @param rounds - How many hex strings to generate (each = 32 bytes of randomness)
137
+ * @returns Array of hex strings, each representing 32 bytes of cryptographic randomness
138
+ */
139
+ static generateDigestHex({
140
+ serverSeed,
141
+ clientSeed,
142
+ nonce,
143
+ rounds,
144
+ }: { serverSeed: string; clientSeed: string; nonce: string; rounds: number }): string[] {
145
+ const results = Array.from(Array(rounds).keys()).map(round => {
146
+ const hmac = createHmac('sha256', serverSeed);
147
+ hmac.update(`${clientSeed}:${nonce}:${round}`);
148
+ return hmac.digest('hex');
149
+ });
150
+
151
+ return results;
152
+ }
153
+ }