shufflecom-calculations 4.1.2 → 4.1.3

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.2",
3
+ "version": "4.1.3",
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": "4bbe832ca80faa167ab6dc578db44b6909747b0c"
17
+ "gitHead": "6da0f7d26f1edf330169aee529b2feec23db3443"
18
18
  }
@@ -4,17 +4,23 @@ import { FixedSequenceRng } from './fixed-rng';
4
4
  import {
5
5
  BOARD_SIZE,
6
6
  DIFFICULTY_DROP_MAP,
7
+ FloorIsLavaActionPhase,
7
8
  FloorIsLavaDifficulty,
8
- FloorIsLavaPickAction,
9
9
  FloorIsLavaRules,
10
10
  FloorIsLavaStartAction,
11
+ FloorIsLavaSuccessfulPickAction,
11
12
  } from './floor-is-lava-rules';
12
13
 
13
14
  const EDGE_BPS = 200;
14
15
 
15
- const startAction = (difficulty: FloorIsLavaDifficulty): FloorIsLavaStartAction => ({ phase: 'START', difficulty });
16
+ const startAction = (difficulty: FloorIsLavaDifficulty): FloorIsLavaStartAction => ({ phase: FloorIsLavaActionPhase.START, difficulty });
16
17
 
17
- const pickAction = (): FloorIsLavaPickAction => ({ phase: 'PICK', selectedTile: 0, droppedTiles: [], multiplier: new BigNumber(0) });
18
+ const pickAction = (): FloorIsLavaSuccessfulPickAction => ({
19
+ phase: FloorIsLavaActionPhase.SUCCESSFUL_PICK,
20
+ selectedTile: 0,
21
+ droppedTiles: [],
22
+ multiplier: new BigNumber(0),
23
+ });
18
24
 
19
25
  describe('FloorIsLavaRules.maxRounds', () => {
20
26
  it.each([
@@ -67,6 +73,14 @@ describe('FloorIsLavaRules.getRoundResults', () => {
67
73
  });
68
74
  });
69
75
 
76
+ describe('FloorIsLavaRules.lastSurvivingTile', () => {
77
+ it('returns the final tile of the full-board drop order — the last to fall', () => {
78
+ const champion = 7;
79
+ const dropOrder = [...Array.from({ length: BOARD_SIZE }, (_, i) => i).filter(tile => tile !== champion), champion];
80
+ expect(FloorIsLavaRules.lastSurvivingTile(new FixedSequenceRng(dropOrder))).toBe(champion);
81
+ });
82
+ });
83
+
70
84
  describe('FloorIsLavaRules.reconstructRounds', () => {
71
85
  it('counts the PICK actions following START as survived rounds', () => {
72
86
  const actions = [startAction(FloorIsLavaDifficulty.MEDIUM), pickAction(), pickAction()];
@@ -88,10 +102,22 @@ describe('FloorIsLavaRules.reconstructRounds', () => {
88
102
 
89
103
  it('throws when a CASHOUT action appears in the history', () => {
90
104
  expect(() =>
91
- FloorIsLavaRules.reconstructRounds([startAction(FloorIsLavaDifficulty.MEDIUM), { phase: 'CASHOUT', multiplier: new BigNumber(1) }]),
105
+ FloorIsLavaRules.reconstructRounds([
106
+ startAction(FloorIsLavaDifficulty.MEDIUM),
107
+ { phase: FloorIsLavaActionPhase.CASHOUT, multiplier: new BigNumber(1), lastSurvivingTile: 0 },
108
+ ]),
92
109
  ).toThrow('An active game cannot have a CASHOUT action');
93
110
  });
94
111
 
112
+ it('throws when a BUST_PICK action appears in the history', () => {
113
+ expect(() =>
114
+ FloorIsLavaRules.reconstructRounds([
115
+ startAction(FloorIsLavaDifficulty.MEDIUM),
116
+ { phase: FloorIsLavaActionPhase.BUST_PICK, selectedTile: 0, droppedTiles: [], lastSurvivingTile: 1 },
117
+ ]),
118
+ ).toThrow('An active game cannot have a BUST_PICK action');
119
+ });
120
+
95
121
  it('throws when survived rounds exceed the difficulty max', () => {
96
122
  expect(() => FloorIsLavaRules.reconstructRounds([startAction(FloorIsLavaDifficulty.TOXIC), pickAction(), pickAction()])).toThrow(
97
123
  'Reconstructed rounds (2) exceeds max rounds (1)',
@@ -21,24 +21,43 @@ export const DIFFICULTY_DROP_MAP: Readonly<Record<FloorIsLavaDifficulty, number>
21
21
 
22
22
  export const MAX_ROUNDS = DROPPABLE_TILES / Math.min(...Object.values(DIFFICULTY_DROP_MAP));
23
23
 
24
+ export enum FloorIsLavaActionPhase {
25
+ START = 'START',
26
+ SUCCESSFUL_PICK = 'SUCCESSFUL_PICK',
27
+ BUST_PICK = 'BUST_PICK',
28
+ CASHOUT = 'CASHOUT',
29
+ }
30
+
24
31
  export interface FloorIsLavaStartAction {
25
- phase: 'START';
32
+ phase: FloorIsLavaActionPhase.START;
26
33
  difficulty: FloorIsLavaDifficulty;
27
34
  }
28
35
 
29
- export interface FloorIsLavaPickAction {
30
- phase: 'PICK';
36
+ export interface FloorIsLavaSuccessfulPickAction {
37
+ phase: FloorIsLavaActionPhase.SUCCESSFUL_PICK;
31
38
  selectedTile: number;
32
39
  droppedTiles: number[];
33
40
  multiplier: BigNumber;
34
41
  }
35
42
 
43
+ export interface FloorIsLavaBustPickAction {
44
+ phase: FloorIsLavaActionPhase.BUST_PICK;
45
+ selectedTile: number;
46
+ droppedTiles: number[];
47
+ lastSurvivingTile: number;
48
+ }
49
+
36
50
  export interface FloorIsLavaCashoutAction {
37
- phase: 'CASHOUT';
51
+ phase: FloorIsLavaActionPhase.CASHOUT;
38
52
  multiplier: BigNumber;
53
+ lastSurvivingTile: number;
39
54
  }
40
55
 
41
- export type FloorIsLavaGameAction = FloorIsLavaStartAction | FloorIsLavaPickAction | FloorIsLavaCashoutAction;
56
+ export type FloorIsLavaGameAction =
57
+ | FloorIsLavaStartAction
58
+ | FloorIsLavaSuccessfulPickAction
59
+ | FloorIsLavaBustPickAction
60
+ | FloorIsLavaCashoutAction;
42
61
 
43
62
  export class FloorIsLavaRules {
44
63
  public static maxRounds(difficulty: FloorIsLavaDifficulty): number {
@@ -58,6 +77,11 @@ export class FloorIsLavaRules {
58
77
  return calculateEdgeMultiplier(edge).multipliedBy(BOARD_SIZE).dividedBy(FloorIsLavaRules.tilesRemaining(roundsSurvived, dropsPerRound));
59
78
  }
60
79
 
80
+ public static lastSurvivingTile(generator: RandomNumberGenerator): number {
81
+ const dropOrder = generator.getRandomIntsWithoutReplacement({ min: 0, max: BOARD_SIZE - 1, count: BOARD_SIZE });
82
+ return dropOrder[dropOrder.length - 1];
83
+ }
84
+
61
85
  public static getRoundResults(generator: RandomNumberGenerator, dropsPerRound: number, numRounds: number): number[][] {
62
86
  const dropSequence = generator.getRandomIntsWithoutReplacement({ min: 0, max: BOARD_SIZE - 1, count: numRounds * dropsPerRound });
63
87
 
@@ -70,7 +94,7 @@ export class FloorIsLavaRules {
70
94
 
71
95
  public static reconstructRounds(gameActions: FloorIsLavaGameAction[]): { roundsSurvived: number; dropsPerRound: number } {
72
96
  const first = gameActions[0];
73
- if (!first || first.phase !== 'START') {
97
+ if (!first || first.phase !== FloorIsLavaActionPhase.START) {
74
98
  throw new Error('First action must be START');
75
99
  }
76
100
 
@@ -81,15 +105,17 @@ export class FloorIsLavaRules {
81
105
  for (let i = 1; i < gameActions.length; i++) {
82
106
  const action = gameActions[i];
83
107
  switch (action.phase) {
84
- case 'START':
108
+ case FloorIsLavaActionPhase.START:
85
109
  throw new Error('START action can only appear as the first action');
86
- case 'PICK':
110
+ case FloorIsLavaActionPhase.SUCCESSFUL_PICK:
87
111
  roundsSurvived++;
88
112
  if (roundsSurvived > maxRounds) {
89
113
  throw new Error(`Reconstructed rounds (${roundsSurvived}) exceeds max rounds (${maxRounds})`);
90
114
  }
91
115
  break;
92
- case 'CASHOUT':
116
+ case FloorIsLavaActionPhase.BUST_PICK:
117
+ throw new Error('An active game cannot have a BUST_PICK action');
118
+ case FloorIsLavaActionPhase.CASHOUT:
93
119
  throw new Error('An active game cannot have a CASHOUT action');
94
120
  }
95
121
  }
@@ -6,11 +6,12 @@ import {
6
6
  DIFFICULTY_DROP_MAP,
7
7
  DROPPABLE_TILES,
8
8
  FloorIsLava,
9
+ FloorIsLavaActionPhase,
10
+ FloorIsLavaAutoBetRoundOutcome,
9
11
  FloorIsLavaDifficulty,
10
- FloorIsLavaPickAction,
11
- FloorIsLavaRoundOutcome,
12
12
  FloorIsLavaRules,
13
13
  FloorIsLavaStartAction,
14
+ FloorIsLavaSuccessfulPickAction,
14
15
  } from './floor-is-lava';
15
16
  import { TrueRandomRng } from './true-random-rng';
16
17
 
@@ -18,9 +19,22 @@ const ALL_DIFFICULTIES = [FloorIsLavaDifficulty.EASY, FloorIsLavaDifficulty.MEDI
18
19
 
19
20
  const EDGE_BPS = 200;
20
21
 
21
- const startAction = (difficulty: FloorIsLavaDifficulty): FloorIsLavaStartAction => ({ phase: 'START', difficulty });
22
+ const startAction = (difficulty: FloorIsLavaDifficulty): FloorIsLavaStartAction => ({ phase: FloorIsLavaActionPhase.START, difficulty });
22
23
 
23
- const pickAction = (): FloorIsLavaPickAction => ({ phase: 'PICK', selectedTile: 0, droppedTiles: [], multiplier: new BigNumber(0) });
24
+ const pickAction = (): FloorIsLavaSuccessfulPickAction => ({
25
+ phase: FloorIsLavaActionPhase.SUCCESSFUL_PICK,
26
+ selectedTile: 0,
27
+ droppedTiles: [],
28
+ multiplier: new BigNumber(0),
29
+ });
30
+
31
+ // A full 49-tile drop order whose leading entries are `prefix` and whose final (last-surviving) tile is `champion`.
32
+ const fullDraw = (prefix: number[], champion: number): number[] => {
33
+ const middle = Array.from({ length: BOARD_SIZE }, (_, i) => i).filter(tile => tile !== champion && !prefix.includes(tile));
34
+ return [...prefix, ...middle, champion];
35
+ };
36
+
37
+ const fullBoardRng = () => new FixedSequenceRng(Array.from({ length: BOARD_SIZE }, (_, i) => i));
24
38
 
25
39
  const gameWithRounds = (difficulty: FloorIsLavaDifficulty, roundsSurvived: number) =>
26
40
  FloorIsLava.fromActions([startAction(difficulty), ...Array.from({ length: roundsSurvived }, () => pickAction())]);
@@ -75,7 +89,11 @@ describe('FloorIsLava.fromActions', () => {
75
89
 
76
90
  it('throws when the history contains a CASHOUT action', () => {
77
91
  expect(() =>
78
- FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.MEDIUM), pickAction(), { phase: 'CASHOUT', multiplier: new BigNumber(1) }]),
92
+ FloorIsLava.fromActions([
93
+ startAction(FloorIsLavaDifficulty.MEDIUM),
94
+ pickAction(),
95
+ { phase: FloorIsLavaActionPhase.CASHOUT, multiplier: new BigNumber(1), lastSurvivingTile: 0 },
96
+ ]),
79
97
  ).toThrow('An active game cannot have a CASHOUT action');
80
98
  });
81
99
 
@@ -101,20 +119,20 @@ describe('FloorIsLava.next', () => {
101
119
  expect(result).toMatchObject({
102
120
  isLoss: false,
103
121
  isLastTile: false,
104
- gameAction: { phase: 'PICK', selectedTile: 5, droppedTiles: [10, 11, 12, 13, 14, 15] },
122
+ gameAction: { phase: 'SUCCESSFUL_PICK', selectedTile: 5, droppedTiles: [10, 11, 12, 13, 14, 15] },
105
123
  });
106
124
  expect(result.multiplier.isEqualTo(expectedMultiplier(1, 6))).toBe(true);
107
125
  });
108
126
 
109
- it('a pick that lands on a dropped tile zeroes the multiplier and completes the game', () => {
110
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15]);
127
+ it('a pick that lands on a dropped tile zeroes the multiplier and reveals the surviving tile', () => {
128
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15], 32));
111
129
  const result = expectOk(gameWithRounds(FloorIsLavaDifficulty.MEDIUM, 0).next(12, rng, EDGE_BPS));
112
130
 
113
131
  expect(result).toMatchObject({
114
132
  isLoss: true,
115
133
  isLastTile: false,
116
134
  multiplier: new BigNumber(0),
117
- gameAction: { phase: 'PICK', selectedTile: 12, droppedTiles: [10, 11, 12, 13, 14, 15] },
135
+ gameAction: { phase: 'BUST_PICK', selectedTile: 12, droppedTiles: [10, 11, 12, 13, 14, 15], lastSurvivingTile: 32 },
118
136
  });
119
137
  });
120
138
 
@@ -133,10 +151,15 @@ describe('FloorIsLava.next', () => {
133
151
  });
134
152
 
135
153
  it('a loss deep into a streak still pays zero', () => {
136
- const rng = new FixedSequenceRng([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 20, 21, 22, 23, 24, 25]);
154
+ const rng = new FixedSequenceRng(fullDraw([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 20, 21, 22, 23, 24, 25], 32));
137
155
  const result = expectOk(gameWithRounds(FloorIsLavaDifficulty.MEDIUM, 2).next(22, rng, EDGE_BPS));
138
156
 
139
- expect(result).toMatchObject({ isLoss: true, isLastTile: false, multiplier: new BigNumber(0) });
157
+ expect(result).toMatchObject({
158
+ isLoss: true,
159
+ isLastTile: false,
160
+ multiplier: new BigNumber(0),
161
+ gameAction: { phase: 'BUST_PICK', lastSurvivingTile: 32 },
162
+ });
140
163
  });
141
164
 
142
165
  it('a full clear completes the game at the top multiplier', () => {
@@ -178,7 +201,7 @@ describe('FloorIsLava.next', () => {
178
201
 
179
202
  describe('FloorIsLava — chained real sessions (fromActions replay of actual next() output)', () => {
180
203
  it('threads real next() output through fromActions across multiple rounds', () => {
181
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25]);
204
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25], 32));
182
205
 
183
206
  const game0 = FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.MEDIUM)]);
184
207
  const round1 = expectOk(game0.next(30, rng, EDGE_BPS));
@@ -192,12 +215,13 @@ describe('FloorIsLava — chained real sessions (fromActions replay of actual ne
192
215
  const game2 = FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.MEDIUM), round1.gameAction, round2.gameAction]);
193
216
  expect(game2.canCashout()).toBe(true);
194
217
 
195
- const cashoutResult = expectOk(game2.cashout(EDGE_BPS));
218
+ const cashoutResult = expectOk(game2.cashout(rng, EDGE_BPS));
196
219
  expect(cashoutResult.multiplier).toEqual(round2.multiplier);
220
+ expect(cashoutResult.gameAction.lastSurvivingTile).toBe(32);
197
221
  });
198
222
 
199
223
  it('threads a real survived pick into a second round that busts', () => {
200
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25]);
224
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25], 32));
201
225
 
202
226
  const game0 = FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.MEDIUM)]);
203
227
  const round1 = expectOk(game0.next(30, rng, EDGE_BPS));
@@ -205,7 +229,12 @@ describe('FloorIsLava — chained real sessions (fromActions replay of actual ne
205
229
  const game1 = FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.MEDIUM), round1.gameAction]);
206
230
  const round2 = expectOk(game1.next(20, rng, EDGE_BPS));
207
231
 
208
- expect(round2).toMatchObject({ isLoss: true, isLastTile: false, multiplier: new BigNumber(0) });
232
+ expect(round2).toMatchObject({
233
+ isLoss: true,
234
+ isLastTile: false,
235
+ multiplier: new BigNumber(0),
236
+ gameAction: { phase: 'BUST_PICK', lastSurvivingTile: 32 },
237
+ });
209
238
  });
210
239
  });
211
240
 
@@ -216,7 +245,7 @@ describe('FloorIsLava.cashout', () => {
216
245
  [FloorIsLavaDifficulty.HARD, 2],
217
246
  ])('cashing out %s after %i rounds pays the telescoped multiplier', (difficulty, rounds) => {
218
247
  const dropsPerRound = difficulty === FloorIsLavaDifficulty.HARD ? 8 : 6;
219
- const result = expectOk(gameWithRounds(difficulty, rounds).cashout(EDGE_BPS));
248
+ const result = expectOk(gameWithRounds(difficulty, rounds).cashout(fullBoardRng(), EDGE_BPS));
220
249
 
221
250
  expect(result.multiplier.isEqualTo(expectedMultiplier(rounds, dropsPerRound))).toBe(true);
222
251
  expect(result.gameAction.phase).toBe('CASHOUT');
@@ -224,83 +253,87 @@ describe('FloorIsLava.cashout', () => {
224
253
  });
225
254
 
226
255
  it('returns no-tiles-selected with zero rounds survived', () => {
227
- expect(gameWithRounds(FloorIsLavaDifficulty.MEDIUM, 0).cashout(EDGE_BPS)).toEqual({ ok: false, error: 'no-tiles-selected' });
256
+ expect(gameWithRounds(FloorIsLavaDifficulty.MEDIUM, 0).cashout(fullBoardRng(), EDGE_BPS)).toEqual({ ok: false, error: 'no-tiles-selected' });
228
257
  });
229
258
  });
230
259
 
231
260
  describe('FloorIsLava.autobet', () => {
232
261
  it('surviving every selected round pays the N-round multiplier', () => {
233
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 40, 41, 42, 43, 44, 45]);
262
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 40, 41, 42, 43, 44, 45], 0));
234
263
  const result = expectOk(FloorIsLava.autobet(FloorIsLavaDifficulty.MEDIUM, [30, 31, 32], rng, EDGE_BPS));
235
264
 
236
265
  expect(result).toMatchObject({
237
266
  survivedRounds: 3,
267
+ lastSurvivingTile: 0,
238
268
  results: [
239
- { droppedTiles: [10, 11, 12, 13, 14, 15], outcome: FloorIsLavaRoundOutcome.SURVIVED },
240
- { droppedTiles: [20, 21, 22, 23, 24, 25], outcome: FloorIsLavaRoundOutcome.SURVIVED },
241
- { droppedTiles: [40, 41, 42, 43, 44, 45], outcome: FloorIsLavaRoundOutcome.SURVIVED },
269
+ { droppedTiles: [10, 11, 12, 13, 14, 15], outcome: FloorIsLavaAutoBetRoundOutcome.SURVIVED },
270
+ { droppedTiles: [20, 21, 22, 23, 24, 25], outcome: FloorIsLavaAutoBetRoundOutcome.SURVIVED },
271
+ { droppedTiles: [40, 41, 42, 43, 44, 45], outcome: FloorIsLavaAutoBetRoundOutcome.SURVIVED },
242
272
  ],
243
273
  });
244
274
  expect(result.multiplier.isEqualTo(expectedMultiplier(3, 6))).toBe(true);
245
275
  });
246
276
 
247
277
  it('busts and pays zero when a selected tile drops in its own round', () => {
248
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25]);
278
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25], 0));
249
279
  const result = expectOk(FloorIsLava.autobet(FloorIsLavaDifficulty.MEDIUM, [30, 21], rng, EDGE_BPS));
250
280
 
251
281
  expect(result).toMatchObject({
252
282
  multiplier: new BigNumber(0),
253
283
  survivedRounds: 1,
284
+ lastSurvivingTile: 0,
254
285
  results: [
255
- { selectedTile: 30, outcome: FloorIsLavaRoundOutcome.SURVIVED },
256
- { selectedTile: 21, droppedTiles: [20, 21, 22, 23, 24, 25], outcome: FloorIsLavaRoundOutcome.LOST },
286
+ { selectedTile: 30, outcome: FloorIsLavaAutoBetRoundOutcome.SURVIVED },
287
+ { selectedTile: 21, droppedTiles: [20, 21, 22, 23, 24, 25], outcome: FloorIsLavaAutoBetRoundOutcome.LOST },
257
288
  ],
258
289
  });
259
290
  });
260
291
 
261
292
  it('forces a cashout (not a bust) when a selected tile had already dropped in an earlier round', () => {
262
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 40, 41, 42, 43, 44, 45]);
293
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 40, 41, 42, 43, 44, 45], 0));
263
294
  // round 3 blindly stands on tile 10, which was already lava since round 1 - the player never actually risked this round's own draw, so they're paid out at what they'd already survived
264
295
  const result = expectOk(FloorIsLava.autobet(FloorIsLavaDifficulty.MEDIUM, [30, 31, 10], rng, EDGE_BPS));
265
296
 
266
297
  expect(result).toMatchObject({
267
298
  survivedRounds: 2,
299
+ lastSurvivingTile: 0,
268
300
  results: [
269
- { selectedTile: 30, outcome: FloorIsLavaRoundOutcome.SURVIVED },
270
- { selectedTile: 31, outcome: FloorIsLavaRoundOutcome.SURVIVED },
271
- { selectedTile: 10, droppedTiles: [40, 41, 42, 43, 44, 45], outcome: FloorIsLavaRoundOutcome.CASHED_OUT },
301
+ { selectedTile: 30, outcome: FloorIsLavaAutoBetRoundOutcome.SURVIVED },
302
+ { selectedTile: 31, outcome: FloorIsLavaAutoBetRoundOutcome.SURVIVED },
303
+ { selectedTile: 10, droppedTiles: [40, 41, 42, 43, 44, 45], outcome: FloorIsLavaAutoBetRoundOutcome.CASHED_OUT },
272
304
  ],
273
305
  });
274
306
  expect(result.multiplier.isEqualTo(expectedMultiplier(2, 6))).toBe(true);
275
307
  });
276
308
 
277
309
  it('forces a cashout on the very next round after the pick was already dead', () => {
278
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25]);
310
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25], 0));
279
311
  const result = expectOk(FloorIsLava.autobet(FloorIsLavaDifficulty.MEDIUM, [30, 10], rng, EDGE_BPS));
280
312
 
281
313
  expect(result).toMatchObject({
282
314
  survivedRounds: 1,
315
+ lastSurvivingTile: 0,
283
316
  results: [
284
- { selectedTile: 30, outcome: FloorIsLavaRoundOutcome.SURVIVED },
285
- { selectedTile: 10, droppedTiles: [20, 21, 22, 23, 24, 25], outcome: FloorIsLavaRoundOutcome.CASHED_OUT },
317
+ { selectedTile: 30, outcome: FloorIsLavaAutoBetRoundOutcome.SURVIVED },
318
+ { selectedTile: 10, droppedTiles: [20, 21, 22, 23, 24, 25], outcome: FloorIsLavaAutoBetRoundOutcome.CASHED_OUT },
286
319
  ],
287
320
  });
288
321
  expect(result.multiplier.isEqualTo(expectedMultiplier(1, 6))).toBe(true);
289
322
  });
290
323
 
291
324
  it('allows revisiting a still-standing tile across rounds', () => {
292
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25]);
325
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25], 0));
293
326
  const result = expectOk(FloorIsLava.autobet(FloorIsLavaDifficulty.MEDIUM, [5, 5], rng, EDGE_BPS));
294
327
 
295
- expect(result).toMatchObject({ survivedRounds: 2 });
328
+ expect(result).toMatchObject({ survivedRounds: 2, lastSurvivingTile: 0 });
296
329
  expect(result.multiplier.isEqualTo(expectedMultiplier(2, 6))).toBe(true);
297
330
  });
298
331
 
299
332
  it('a full-length sequence full-clears at the top multiplier', () => {
300
- const rng = new FixedSequenceRng(Array.from({ length: DROPPABLE_TILES }, (_, i) => i));
333
+ const rng = new FixedSequenceRng(Array.from({ length: BOARD_SIZE }, (_, i) => i));
301
334
  const result = expectOk(FloorIsLava.autobet(FloorIsLavaDifficulty.TOXIC, [DROPPABLE_TILES], rng, EDGE_BPS));
302
335
 
303
- expect(result).toMatchObject({ survivedRounds: 1, multiplier: new BigNumber('48.02') });
336
+ expect(result).toMatchObject({ survivedRounds: 1, multiplier: new BigNumber('48.02'), lastSurvivingTile: DROPPABLE_TILES });
304
337
  });
305
338
 
306
339
  it('returns invalid-selected-tiles-count when the sequence exceeds the difficulty max rounds', () => {
@@ -327,7 +360,7 @@ describe('FloorIsLava.autobet', () => {
327
360
 
328
361
  describe('FloorIsLava — edge parameter', () => {
329
362
  it.each([100, 500, 1000])('cashout applies the given edge (%i bps), not a hardcoded value', edgeBps => {
330
- const result = expectOk(gameWithRounds(FloorIsLavaDifficulty.MEDIUM, 3).cashout(edgeBps));
363
+ const result = expectOk(gameWithRounds(FloorIsLavaDifficulty.MEDIUM, 3).cashout(fullBoardRng(), edgeBps));
331
364
  expect(result.multiplier.isEqualTo(expectedMultiplier(3, 6, edgeBps))).toBe(true);
332
365
  });
333
366
 
@@ -338,13 +371,13 @@ describe('FloorIsLava — edge parameter', () => {
338
371
  });
339
372
 
340
373
  it.each([100, 500, 1000])('autobet applies the given edge (%i bps) on a full survive', edgeBps => {
341
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25]);
374
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25], 0));
342
375
  const result = expectOk(FloorIsLava.autobet(FloorIsLavaDifficulty.MEDIUM, [30, 31], rng, edgeBps));
343
376
  expect(result.multiplier.isEqualTo(expectedMultiplier(2, 6, edgeBps))).toBe(true);
344
377
  });
345
378
 
346
379
  it('rejects an edge below the platform minimum (100 bps) on cashout', () => {
347
- expect(() => gameWithRounds(FloorIsLavaDifficulty.MEDIUM, 3).cashout(99)).toThrow('Exceeded Minimum edge allowed on platform');
380
+ expect(() => gameWithRounds(FloorIsLavaDifficulty.MEDIUM, 3).cashout(fullBoardRng(), 99)).toThrow('Exceeded Minimum edge allowed on platform');
348
381
  });
349
382
 
350
383
  it('rejects an edge below the platform minimum (100 bps) on a surviving next()', () => {
@@ -353,7 +386,7 @@ describe('FloorIsLava — edge parameter', () => {
353
386
  });
354
387
 
355
388
  it('rejects an edge below the platform minimum (100 bps) on a surviving autobet', () => {
356
- const rng = new FixedSequenceRng([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25]);
389
+ const rng = new FixedSequenceRng(fullDraw([10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25], 0));
357
390
  expect(() => FloorIsLava.autobet(FloorIsLavaDifficulty.MEDIUM, [30, 31], rng, 99)).toThrow('Exceeded Minimum edge allowed on platform');
358
391
  });
359
392
  });
@@ -429,7 +462,7 @@ describe('FloorIsLava — exhaustive multiplier & RTP across every endpoint', ()
429
462
  expect(multiplier.toNumber()).toBeCloseTo(houseEdge.dividedBy(chance).toNumber(), 9);
430
463
 
431
464
  // the game surface (fromActions + cashout) returns the same endpoint multiplier
432
- expect(expectOk(gameWithRounds(difficulty, k).cashout(EDGE_BPS)).multiplier.isEqualTo(multiplier)).toBe(true);
465
+ expect(expectOk(gameWithRounds(difficulty, k).cashout(fullBoardRng(), EDGE_BPS)).multiplier.isEqualTo(multiplier)).toBe(true);
433
466
 
434
467
  // RTP == winChance * payout is the house edge at EVERY endpoint, not just on average
435
468
  expect(chance.multipliedBy(multiplier).toNumber()).toBeCloseTo(houseEdge.toNumber(), 9);
@@ -1,12 +1,14 @@
1
1
  import BigNumber from 'bignumber.js';
2
2
  import {
3
3
  DIFFICULTY_DROP_MAP,
4
+ FloorIsLavaActionPhase,
5
+ FloorIsLavaBustPickAction,
4
6
  FloorIsLavaCashoutAction,
5
7
  FloorIsLavaDifficulty,
6
8
  FloorIsLavaGameAction,
7
- FloorIsLavaPickAction,
8
9
  FloorIsLavaRules,
9
10
  FloorIsLavaStartAction,
11
+ FloorIsLavaSuccessfulPickAction,
10
12
  } from './floor-is-lava-rules';
11
13
  import { RandomNumberGenerator } from './random-number-generator.interface';
12
14
 
@@ -15,7 +17,13 @@ export * from './floor-is-lava-rules';
15
17
  export type FloorIsLavaNextError = 'tile-already-dropped' | 'no-tiles-remaining' | 'invalid-tile';
16
18
 
17
19
  export type FloorIsLavaNextResult =
18
- | { ok: true; multiplier: BigNumber; gameAction: FloorIsLavaPickAction; isLoss: boolean; isLastTile: boolean }
20
+ | {
21
+ ok: true;
22
+ multiplier: BigNumber;
23
+ gameAction: FloorIsLavaSuccessfulPickAction | FloorIsLavaBustPickAction;
24
+ isLoss: boolean;
25
+ isLastTile: boolean;
26
+ }
19
27
  | { ok: false; error: FloorIsLavaNextError };
20
28
 
21
29
  export type FloorIsLavaCashoutError = 'no-tiles-selected';
@@ -24,22 +32,22 @@ export type FloorIsLavaCashoutResult =
24
32
  | { ok: true; multiplier: BigNumber; gameAction: FloorIsLavaCashoutAction }
25
33
  | { ok: false; error: FloorIsLavaCashoutError };
26
34
 
27
- export enum FloorIsLavaRoundOutcome {
35
+ export enum FloorIsLavaAutoBetRoundOutcome {
28
36
  SURVIVED = 'SURVIVED',
29
37
  LOST = 'LOST',
30
38
  CASHED_OUT = 'CASHED_OUT',
31
39
  }
32
40
 
33
- export interface FloorIsLavaRoundResult {
41
+ export interface FloorIsLavaAutoBetRoundResult {
34
42
  selectedTile: number;
35
43
  droppedTiles: number[];
36
- outcome: FloorIsLavaRoundOutcome;
44
+ outcome: FloorIsLavaAutoBetRoundOutcome;
37
45
  }
38
46
 
39
47
  export type FloorIsLavaAutoBetError = 'invalid-selected-tiles-count' | 'invalid-tile';
40
48
 
41
49
  export type FloorIsLavaAutoBetResult =
42
- | { ok: true; multiplier: BigNumber; results: FloorIsLavaRoundResult[]; survivedRounds: number }
50
+ | { ok: true; multiplier: BigNumber; results: FloorIsLavaAutoBetRoundResult[]; survivedRounds: number; lastSurvivingTile: number }
43
51
  | { ok: false; error: FloorIsLavaAutoBetError };
44
52
 
45
53
  export class FloorIsLava {
@@ -49,7 +57,7 @@ export class FloorIsLava {
49
57
  ) {}
50
58
 
51
59
  public static createAction(difficulty: FloorIsLavaDifficulty): FloorIsLavaStartAction {
52
- return { phase: 'START', difficulty };
60
+ return { phase: FloorIsLavaActionPhase.START, difficulty };
53
61
  }
54
62
 
55
63
  public static fromActions(gameActions: FloorIsLavaGameAction[]): FloorIsLava {
@@ -83,21 +91,25 @@ export class FloorIsLava {
83
91
  const isLastTile = FloorIsLavaRules.tilesRemaining(roundsSurvivedAfter, this.dropsPerRound) <= 1;
84
92
  const multiplier = isLoss ? BigNumber(0) : FloorIsLavaRules.multiplier(roundsSurvivedAfter, this.dropsPerRound, edge);
85
93
 
94
+ const gameAction: FloorIsLavaSuccessfulPickAction | FloorIsLavaBustPickAction = isLoss
95
+ ? {
96
+ phase: FloorIsLavaActionPhase.BUST_PICK,
97
+ selectedTile,
98
+ droppedTiles: thisRoundDrops,
99
+ lastSurvivingTile: FloorIsLavaRules.lastSurvivingTile(generator),
100
+ }
101
+ : { phase: FloorIsLavaActionPhase.SUCCESSFUL_PICK, selectedTile, droppedTiles: thisRoundDrops, multiplier };
102
+
86
103
  return {
87
104
  ok: true,
88
105
  multiplier,
89
- gameAction: {
90
- phase: 'PICK',
91
- selectedTile,
92
- droppedTiles: thisRoundDrops,
93
- multiplier,
94
- },
106
+ gameAction,
95
107
  isLoss,
96
108
  isLastTile,
97
109
  };
98
110
  }
99
111
 
100
- public cashout(edge: number): FloorIsLavaCashoutResult {
112
+ public cashout(generator: RandomNumberGenerator, edge: number): FloorIsLavaCashoutResult {
101
113
  if (!this.canCashout()) {
102
114
  return { ok: false, error: 'no-tiles-selected' };
103
115
  }
@@ -107,7 +119,7 @@ export class FloorIsLava {
107
119
  return {
108
120
  ok: true,
109
121
  multiplier,
110
- gameAction: { phase: 'CASHOUT', multiplier },
122
+ gameAction: { phase: FloorIsLavaActionPhase.CASHOUT, multiplier, lastSurvivingTile: FloorIsLavaRules.lastSurvivingTile(generator) },
111
123
  };
112
124
  }
113
125
 
@@ -130,8 +142,9 @@ export class FloorIsLava {
130
142
 
131
143
  const rounds = FloorIsLavaRules.getRoundResults(generator, dropsPerRound, selectedTiles.length);
132
144
 
133
- const results: FloorIsLavaRoundResult[] = [];
145
+ const results: FloorIsLavaAutoBetRoundResult[] = [];
134
146
  let survivedRounds = 0;
147
+ const lastSurvivingTile = FloorIsLavaRules.lastSurvivingTile(generator);
135
148
 
136
149
  for (let round = 0; round < selectedTiles.length; round++) {
137
150
  const selectedTile = selectedTiles[round];
@@ -141,20 +154,21 @@ export class FloorIsLava {
141
154
  const priorDrops = results.flatMap(result => result.droppedTiles);
142
155
  const isUnselectableDropTile = priorDrops.includes(selectedTile);
143
156
  if (isUnselectableDropTile) {
144
- results.push({ selectedTile, droppedTiles, outcome: FloorIsLavaRoundOutcome.CASHED_OUT });
157
+ results.push({ selectedTile, droppedTiles, outcome: FloorIsLavaAutoBetRoundOutcome.CASHED_OUT });
145
158
  return {
146
159
  ok: true,
147
160
  multiplier: FloorIsLavaRules.multiplier(survivedRounds, dropsPerRound, edge),
148
161
  results,
149
162
  survivedRounds,
163
+ lastSurvivingTile,
150
164
  };
151
165
  }
152
166
 
153
167
  const isLoss = droppedTiles.includes(selectedTile);
154
- results.push({ selectedTile, droppedTiles, outcome: isLoss ? FloorIsLavaRoundOutcome.LOST : FloorIsLavaRoundOutcome.SURVIVED });
168
+ results.push({ selectedTile, droppedTiles, outcome: isLoss ? FloorIsLavaAutoBetRoundOutcome.LOST : FloorIsLavaAutoBetRoundOutcome.SURVIVED });
155
169
 
156
170
  if (isLoss) {
157
- return { ok: true, multiplier: BigNumber(0), results, survivedRounds };
171
+ return { ok: true, multiplier: BigNumber(0), results, survivedRounds, lastSurvivingTile };
158
172
  }
159
173
  survivedRounds++;
160
174
  }
@@ -164,6 +178,7 @@ export class FloorIsLava {
164
178
  multiplier: FloorIsLavaRules.multiplier(survivedRounds, dropsPerRound, edge),
165
179
  results,
166
180
  survivedRounds,
181
+ lastSurvivingTile,
167
182
  };
168
183
  }
169
184
  }
@@ -638,21 +638,12 @@ export class MarketLayout {
638
638
  marketDisplayType: SportsMarketDisplayType;
639
639
  }
640
640
 
641
- export enum MarketInfoVariantKey {
642
- MAP = 'MAP',
643
- }
644
-
645
- export type MapVariant = string;
646
-
647
641
  export class BaseDisplayGroup {
648
642
  groupKey: string;
649
643
  groupName: string;
650
644
  groupSortName: string;
651
645
  selectionGroups: SelectionGroup[] | null;
652
646
  layout: MarketLayout;
653
- variantData: Nullable<{
654
- [MarketInfoVariantKey.MAP]?: MapVariant; // used for map market of oddin
655
- }>;
656
647
  is2UpAvailable: boolean;
657
648
  }
658
649
 
@@ -720,10 +711,16 @@ export class SportsNewMarketOddsUpdate extends SportsMarketOddsBase {
720
711
  expiryTime: Nullable<Date>;
721
712
  }
722
713
 
714
+ export class SgmSubGroup {
715
+ group: SportsMarketGroup;
716
+ weight: number;
717
+ }
718
+
723
719
  export class SportsMarketInfo {
724
720
  odds: SportsNewMarketOddsUpdate[];
725
721
  display: DisplayGroup[];
726
722
  updatedAt: Nullable<Date>;
723
+ sgmSubGroups: Nullable<SgmSubGroup[]>;
727
724
  }
728
725
 
729
726
  export class SportsMarketSubInfo extends SportsMarketInfo {