shufflecom-calculations 4.1.1 → 4.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shufflecom-calculations",
3
- "version": "4.1.1",
3
+ "version": "4.1.2",
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": "bcbb5c5dc18844832e0e7277e722b91baff0445d"
17
+ "gitHead": "4bbe832ca80faa167ab6dc578db44b6909747b0c"
18
18
  }
@@ -193,8 +193,6 @@ describe('CoinflipRules.validateActiveProgressiveClassicGame', () => {
193
193
  });
194
194
 
195
195
  describe('CoinflipRules.isValidTargetConfig', () => {
196
- const EDGE_BPS = 200;
197
-
198
196
  it('returns true for every allowed (N, M) pair from the game-design table', () => {
199
197
  const allowedPairs: [number, number][] = [
200
198
  [1, 1],
@@ -219,7 +217,7 @@ describe('CoinflipRules.isValidTargetConfig', () => {
219
217
  [20, 9],
220
218
  ];
221
219
  for (const [n, m] of allowedPairs) {
222
- expect(CoinflipRules.isValidTargetConfig(n, m, EDGE_BPS)).toBe(true);
220
+ expect(CoinflipRules.isValidTargetConfig(n, m)).toBe(true);
223
221
  }
224
222
  });
225
223
 
@@ -244,19 +242,19 @@ describe('CoinflipRules.isValidTargetConfig', () => {
244
242
  [20, 8],
245
243
  ];
246
244
  for (const [totalFlips, minSuccesses] of belowMinimum) {
247
- expect(CoinflipRules.isValidTargetConfig(totalFlips, minSuccesses, EDGE_BPS)).toBe(false);
245
+ expect(CoinflipRules.isValidTargetConfig(totalFlips, minSuccesses)).toBe(false);
248
246
  }
249
247
  });
250
248
 
251
249
  it('rejects totalFlips outside 1..MAX_FLIPS', () => {
252
- expect(CoinflipRules.isValidTargetConfig(0, 1, EDGE_BPS)).toBe(false);
253
- expect(CoinflipRules.isValidTargetConfig(MAX_FLIPS + 1, 1, EDGE_BPS)).toBe(false);
254
- expect(CoinflipRules.isValidTargetConfig(-1, 1, EDGE_BPS)).toBe(false);
250
+ expect(CoinflipRules.isValidTargetConfig(0, 1)).toBe(false);
251
+ expect(CoinflipRules.isValidTargetConfig(MAX_FLIPS + 1, 1)).toBe(false);
252
+ expect(CoinflipRules.isValidTargetConfig(-1, 1)).toBe(false);
255
253
  });
256
254
 
257
255
  it('rejects minSuccesses outside 1..totalFlips', () => {
258
- expect(CoinflipRules.isValidTargetConfig(5, 0, EDGE_BPS)).toBe(false);
259
- expect(CoinflipRules.isValidTargetConfig(5, 6, EDGE_BPS)).toBe(false);
260
- expect(CoinflipRules.isValidTargetConfig(5, -1, EDGE_BPS)).toBe(false);
256
+ expect(CoinflipRules.isValidTargetConfig(5, 0)).toBe(false);
257
+ expect(CoinflipRules.isValidTargetConfig(5, 6)).toBe(false);
258
+ expect(CoinflipRules.isValidTargetConfig(5, -1)).toBe(false);
261
259
  });
262
260
  });
@@ -92,7 +92,7 @@ export class CoinflipRules {
92
92
  return calculateEdgeMultiplier(edgeBps).dividedBy(CoinflipRules.calculateTargetWinChance(flips, minSuccesses));
93
93
  }
94
94
 
95
- public static isValidTargetConfig(totalFlips: number, minSuccesses: number, edgeBps: number): boolean {
95
+ public static isValidTargetConfig(totalFlips: number, minSuccesses: number): boolean {
96
96
  if (totalFlips < 1 || totalFlips > MAX_FLIPS) {
97
97
  return false;
98
98
  }
@@ -0,0 +1,100 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import { calculateEdgeMultiplier } from '../utils/edge';
3
+ import { FixedSequenceRng } from './fixed-rng';
4
+ import {
5
+ BOARD_SIZE,
6
+ DIFFICULTY_DROP_MAP,
7
+ FloorIsLavaDifficulty,
8
+ FloorIsLavaPickAction,
9
+ FloorIsLavaRules,
10
+ FloorIsLavaStartAction,
11
+ } from './floor-is-lava-rules';
12
+
13
+ const EDGE_BPS = 200;
14
+
15
+ const startAction = (difficulty: FloorIsLavaDifficulty): FloorIsLavaStartAction => ({ phase: 'START', difficulty });
16
+
17
+ const pickAction = (): FloorIsLavaPickAction => ({ phase: 'PICK', selectedTile: 0, droppedTiles: [], multiplier: new BigNumber(0) });
18
+
19
+ describe('FloorIsLavaRules.maxRounds', () => {
20
+ it.each([
21
+ [FloorIsLavaDifficulty.EASY, 12],
22
+ [FloorIsLavaDifficulty.MEDIUM, 8],
23
+ [FloorIsLavaDifficulty.HARD, 6],
24
+ [FloorIsLavaDifficulty.TOXIC, 1],
25
+ ])('%s allows %i rounds', (difficulty, expected) => {
26
+ expect(FloorIsLavaRules.maxRounds(difficulty)).toBe(expected);
27
+ });
28
+ });
29
+
30
+ describe('FloorIsLavaRules.tilesRemaining', () => {
31
+ it('returns the full board when no rounds have been survived', () => {
32
+ expect(FloorIsLavaRules.tilesRemaining(0, 6)).toBe(BOARD_SIZE);
33
+ });
34
+
35
+ it('subtracts dropped tiles for the rounds survived so far', () => {
36
+ expect(FloorIsLavaRules.tilesRemaining(2, 6)).toBe(BOARD_SIZE - 12);
37
+ });
38
+ });
39
+
40
+ describe('FloorIsLavaRules.isValidTile', () => {
41
+ it.each([0, 1, BOARD_SIZE - 1])('accepts an in-range integer tile (%s)', tile => {
42
+ expect(FloorIsLavaRules.isValidTile(tile)).toBe(true);
43
+ });
44
+
45
+ it.each([-1, BOARD_SIZE, 1.5, NaN, Infinity])('rejects an out-of-range or non-integer tile (%s)', tile => {
46
+ expect(FloorIsLavaRules.isValidTile(tile)).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('FloorIsLavaRules.multiplier', () => {
51
+ it('scales the edge multiplier by board size over standing tiles', () => {
52
+ const expected = calculateEdgeMultiplier(EDGE_BPS)
53
+ .multipliedBy(BOARD_SIZE)
54
+ .dividedBy(BOARD_SIZE - 6);
55
+ expect(FloorIsLavaRules.multiplier(1, 6, EDGE_BPS).isEqualTo(expected)).toBe(true);
56
+ });
57
+ });
58
+
59
+ describe('FloorIsLavaRules.getRoundResults', () => {
60
+ it('chunks the drawn sequence into per-round groups of dropsPerRound', () => {
61
+ const rng = new FixedSequenceRng([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
62
+ expect(FloorIsLavaRules.getRoundResults(rng, 4, 3)).toEqual([
63
+ [0, 1, 2, 3],
64
+ [4, 5, 6, 7],
65
+ [8, 9, 10, 11],
66
+ ]);
67
+ });
68
+ });
69
+
70
+ describe('FloorIsLavaRules.reconstructRounds', () => {
71
+ it('counts the PICK actions following START as survived rounds', () => {
72
+ const actions = [startAction(FloorIsLavaDifficulty.MEDIUM), pickAction(), pickAction()];
73
+ expect(FloorIsLavaRules.reconstructRounds(actions)).toEqual({
74
+ roundsSurvived: 2,
75
+ dropsPerRound: DIFFICULTY_DROP_MAP[FloorIsLavaDifficulty.MEDIUM],
76
+ });
77
+ });
78
+
79
+ it('throws when the first action is not START', () => {
80
+ expect(() => FloorIsLavaRules.reconstructRounds([pickAction()])).toThrow('First action must be START');
81
+ });
82
+
83
+ it('throws when a START action appears after the first position', () => {
84
+ expect(() => FloorIsLavaRules.reconstructRounds([startAction(FloorIsLavaDifficulty.MEDIUM), startAction(FloorIsLavaDifficulty.MEDIUM)])).toThrow(
85
+ 'START action can only appear as the first action',
86
+ );
87
+ });
88
+
89
+ it('throws when a CASHOUT action appears in the history', () => {
90
+ expect(() =>
91
+ FloorIsLavaRules.reconstructRounds([startAction(FloorIsLavaDifficulty.MEDIUM), { phase: 'CASHOUT', multiplier: new BigNumber(1) }]),
92
+ ).toThrow('An active game cannot have a CASHOUT action');
93
+ });
94
+
95
+ it('throws when survived rounds exceed the difficulty max', () => {
96
+ expect(() => FloorIsLavaRules.reconstructRounds([startAction(FloorIsLavaDifficulty.TOXIC), pickAction(), pickAction()])).toThrow(
97
+ 'Reconstructed rounds (2) exceeds max rounds (1)',
98
+ );
99
+ });
100
+ });
@@ -0,0 +1,99 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import { calculateEdgeMultiplier } from '../utils/edge';
3
+ import { RandomNumberGenerator } from './random-number-generator.interface';
4
+
5
+ export const BOARD_SIZE = 49;
6
+ export const DROPPABLE_TILES = BOARD_SIZE - 1;
7
+
8
+ export enum FloorIsLavaDifficulty {
9
+ EASY = 'EASY',
10
+ MEDIUM = 'MEDIUM',
11
+ HARD = 'HARD',
12
+ TOXIC = 'TOXIC',
13
+ }
14
+
15
+ export const DIFFICULTY_DROP_MAP: Readonly<Record<FloorIsLavaDifficulty, number>> = {
16
+ [FloorIsLavaDifficulty.EASY]: 4,
17
+ [FloorIsLavaDifficulty.MEDIUM]: 6,
18
+ [FloorIsLavaDifficulty.HARD]: 8,
19
+ [FloorIsLavaDifficulty.TOXIC]: 48,
20
+ };
21
+
22
+ export const MAX_ROUNDS = DROPPABLE_TILES / Math.min(...Object.values(DIFFICULTY_DROP_MAP));
23
+
24
+ export interface FloorIsLavaStartAction {
25
+ phase: 'START';
26
+ difficulty: FloorIsLavaDifficulty;
27
+ }
28
+
29
+ export interface FloorIsLavaPickAction {
30
+ phase: 'PICK';
31
+ selectedTile: number;
32
+ droppedTiles: number[];
33
+ multiplier: BigNumber;
34
+ }
35
+
36
+ export interface FloorIsLavaCashoutAction {
37
+ phase: 'CASHOUT';
38
+ multiplier: BigNumber;
39
+ }
40
+
41
+ export type FloorIsLavaGameAction = FloorIsLavaStartAction | FloorIsLavaPickAction | FloorIsLavaCashoutAction;
42
+
43
+ export class FloorIsLavaRules {
44
+ public static maxRounds(difficulty: FloorIsLavaDifficulty): number {
45
+ return DROPPABLE_TILES / DIFFICULTY_DROP_MAP[difficulty];
46
+ }
47
+
48
+ public static tilesRemaining(roundsSurvived: number, dropsPerRound: number): number {
49
+ return BOARD_SIZE - roundsSurvived * dropsPerRound;
50
+ }
51
+
52
+ public static isValidTile(tile: number): boolean {
53
+ return Number.isInteger(tile) && tile >= 0 && tile <= BOARD_SIZE - 1;
54
+ }
55
+
56
+ // RTP * 49 / surviving tiles = edge multiplier
57
+ public static multiplier(roundsSurvived: number, dropsPerRound: number, edge: number): BigNumber {
58
+ return calculateEdgeMultiplier(edge).multipliedBy(BOARD_SIZE).dividedBy(FloorIsLavaRules.tilesRemaining(roundsSurvived, dropsPerRound));
59
+ }
60
+
61
+ public static getRoundResults(generator: RandomNumberGenerator, dropsPerRound: number, numRounds: number): number[][] {
62
+ const dropSequence = generator.getRandomIntsWithoutReplacement({ min: 0, max: BOARD_SIZE - 1, count: numRounds * dropsPerRound });
63
+
64
+ const rounds: number[][] = [];
65
+ for (let round = 0; round < numRounds; round++) {
66
+ rounds.push(dropSequence.slice(round * dropsPerRound, (round + 1) * dropsPerRound));
67
+ }
68
+ return rounds;
69
+ }
70
+
71
+ public static reconstructRounds(gameActions: FloorIsLavaGameAction[]): { roundsSurvived: number; dropsPerRound: number } {
72
+ const first = gameActions[0];
73
+ if (!first || first.phase !== 'START') {
74
+ throw new Error('First action must be START');
75
+ }
76
+
77
+ const dropsPerRound = DIFFICULTY_DROP_MAP[first.difficulty];
78
+ const maxRounds = FloorIsLavaRules.maxRounds(first.difficulty);
79
+ let roundsSurvived = 0;
80
+
81
+ for (let i = 1; i < gameActions.length; i++) {
82
+ const action = gameActions[i];
83
+ switch (action.phase) {
84
+ case 'START':
85
+ throw new Error('START action can only appear as the first action');
86
+ case 'PICK':
87
+ roundsSurvived++;
88
+ if (roundsSurvived > maxRounds) {
89
+ throw new Error(`Reconstructed rounds (${roundsSurvived}) exceeds max rounds (${maxRounds})`);
90
+ }
91
+ break;
92
+ case 'CASHOUT':
93
+ throw new Error('An active game cannot have a CASHOUT action');
94
+ }
95
+ }
96
+
97
+ return { roundsSurvived, dropsPerRound };
98
+ }
99
+ }