shufflecom-calculations 6.2.0 → 6.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": "6.2.0",
3
+ "version": "6.3.0",
4
4
  "description": "",
5
5
  "types": "lib/index.d.ts",
6
6
  "main": "lib/index.js",
@@ -16,5 +16,5 @@
16
16
  },
17
17
  "author": "",
18
18
  "license": "ISC",
19
- "gitHead": "fdea251fdf77e057a786892dc8697e8b8f288eb2"
19
+ "gitHead": "8a31556eca9eda402e9084c4da7836e411ea027e"
20
20
  }
@@ -0,0 +1,341 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import { calculateEdgeMultiplier } from '../../utils/edge';
3
+ import { FixedSequenceRng } from '../rng/fixed-rng';
4
+ import { FloorIsLava } from './floor-is-lava';
5
+ import { FloorIsLavaAutobet } from './floor-is-lava-autobet';
6
+ import { FloorIsLavaBoard } from './floor-is-lava-board';
7
+ import {
8
+ BOARD_SIZE,
9
+ DIFFICULTY_LEVEL_MAP,
10
+ FloorIsLavaActionPhase,
11
+ FloorIsLavaAutoBetStopReason,
12
+ FloorIsLavaDifficulty,
13
+ FloorIsLavaGameAction,
14
+ FloorIsLavaRules,
15
+ FloorIsLavaStartAction,
16
+ FloorIsLavaTilesByLevel,
17
+ LEVELS_PER_DIFFICULTY,
18
+ SURVIVING_TILES_MAP,
19
+ } from './floor-is-lava-rules';
20
+
21
+ const ALL_DIFFICULTIES = [FloorIsLavaDifficulty.EASY, FloorIsLavaDifficulty.MEDIUM, FloorIsLavaDifficulty.HARD, FloorIsLavaDifficulty.TOXIC];
22
+
23
+ const EDGE_BPS = 200;
24
+
25
+ const ASCENDING_BOARD = Array.from({ length: BOARD_SIZE }, (_, i) => i);
26
+
27
+ const tilesByLevel = (forLevel: (level: number) => number[]): FloorIsLavaTilesByLevel => [forLevel(0), forLevel(1), forLevel(2)];
28
+
29
+ const startAction = (difficulty: FloorIsLavaDifficulty): FloorIsLavaStartAction => ({ phase: FloorIsLavaActionPhase.START, difficulty });
30
+
31
+ const expectOk = <T extends { ok: boolean }>(result: T): Extract<T, { ok: true }> => {
32
+ if (!result.ok) {
33
+ throw new Error(`expected ok result, got ${JSON.stringify(result)}`);
34
+ }
35
+ return result as Extract<T, { ok: true }>;
36
+ };
37
+
38
+ const expectedMultiplier = (difficulty: FloorIsLavaDifficulty, levelsCleared: number, tilesRemainingInLevel: number, edgeBps: number = EDGE_BPS) =>
39
+ calculateEdgeMultiplier(edgeBps)
40
+ .multipliedBy(BigNumber(BOARD_SIZE).pow(levelsCleared + 1))
41
+ .dividedBy(BigNumber(tilesRemainingInLevel).multipliedBy(BigNumber(SURVIVING_TILES_MAP[difficulty]).pow(levelsCleared)));
42
+
43
+ const boardFromLevelBoards = (level0: number[], level1: number[], level2: number[]): FloorIsLavaBoard =>
44
+ FloorIsLavaBoard.draw(new FixedSequenceRng([...level0, ...level1.map(t => t + BOARD_SIZE), ...level2.map(t => t + BOARD_SIZE * 2)]));
45
+
46
+ describe('FloorIsLavaAutobet.autoBet', () => {
47
+ // With an ascending board, EASY (7 drops x 6 rounds) drops [0..6] in round 0, [7..13] in round 1
48
+ // and so on, leaving [42..48] surviving. Tile 48 therefore survives every round of every level,
49
+ // for every difficulty - it is the plan a fully surviving run is built from.
50
+ const ALWAYS_SAFE_TILE = BOARD_SIZE - 1;
51
+ const ASCENDING_BOARDS = boardFromLevelBoards(ASCENDING_BOARD, ASCENDING_BOARD, ASCENDING_BOARD);
52
+
53
+ // Spreads `rounds` identical picks across the per-level plan the engine takes, filling each
54
+ // level's rounds in turn.
55
+ const planOf = (difficulty: FloorIsLavaDifficulty, tile: number, rounds: number): FloorIsLavaTilesByLevel => {
56
+ const { roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
57
+ return tilesByLevel(level => Array(Math.min(roundsPerLevel, Math.max(0, rounds - level * roundsPerLevel))).fill(tile));
58
+ };
59
+
60
+ const autoBet = (difficulty: FloorIsLavaDifficulty, selectedTilesByLevel: FloorIsLavaTilesByLevel, board: FloorIsLavaBoard = ASCENDING_BOARDS) =>
61
+ FloorIsLavaAutobet.autoBet(difficulty, selectedTilesByLevel, board, EDGE_BPS);
62
+
63
+ const survivingTiles = (difficulty: FloorIsLavaDifficulty, level: number) => ASCENDING_BOARDS.boards[level].slice(-SURVIVING_TILES_MAP[difficulty]);
64
+
65
+ const survivingTilesEachLevel = (difficulty: FloorIsLavaDifficulty) => tilesByLevel(level => survivingTiles(difficulty, level));
66
+
67
+ describe('rejected plans', () => {
68
+ it('rejects an empty plan', () => {
69
+ expect(autoBet(FloorIsLavaDifficulty.EASY, [[], [], []])).toEqual({ ok: false, error: 'no-tiles-selected' });
70
+ });
71
+
72
+ it.each(ALL_DIFFICULTIES)('%s: rejects a level holding more tiles than the level has rounds', difficulty => {
73
+ const oneTooMany = Array(DIFFICULTY_LEVEL_MAP[difficulty].roundsPerLevel + 1).fill(ALWAYS_SAFE_TILE);
74
+
75
+ expect(autoBet(difficulty, [oneTooMany, [], []])).toEqual({ ok: false, error: 'too-many-tiles-selected' });
76
+ });
77
+
78
+ // Every round of a level has to be planned to reach the next one, so a gap has no reading.
79
+ it('rejects a plan that skips ahead to a later level', () => {
80
+ expect(autoBet(FloorIsLavaDifficulty.EASY, [[ALWAYS_SAFE_TILE], [ALWAYS_SAFE_TILE], []])).toEqual({
81
+ ok: false,
82
+ error: 'incomplete-level-plan',
83
+ });
84
+ });
85
+
86
+ it.each([[-1], [BOARD_SIZE], [1.5]])('rejects a plan containing the out-of-range tile %s', tile => {
87
+ expect(autoBet(FloorIsLavaDifficulty.EASY, [[ALWAYS_SAFE_TILE, tile], [], []])).toEqual({ ok: false, error: 'invalid-tile' });
88
+ });
89
+ });
90
+
91
+ describe('runs that survive', () => {
92
+ it.each(ALL_DIFFICULTIES)('%s: a plan for every round of every level completes the game', difficulty => {
93
+ const rounds = FloorIsLavaRules.maxAutoBetSelections(difficulty);
94
+ const plan = planOf(difficulty, ALWAYS_SAFE_TILE, rounds);
95
+
96
+ const { gameAction } = expectOk(autoBet(difficulty, plan));
97
+
98
+ expect(gameAction).toMatchObject({
99
+ phase: FloorIsLavaActionPhase.AUTO_BET,
100
+ difficulty,
101
+ autoBetSelectedTiles: plan,
102
+ autoBetStopReason: FloorIsLavaAutoBetStopReason.COMPLETED,
103
+ level: LEVELS_PER_DIFFICULTY - 1,
104
+ autoBetTilesThatSurviveLevel: survivingTilesEachLevel(difficulty),
105
+ });
106
+ expect(gameAction.multiplier.isEqualTo(FloorIsLavaRules.multiplierAfterRounds(difficulty, rounds, EDGE_BPS))).toBe(true);
107
+ });
108
+
109
+ // The multiplier a surviving run of N rounds pays is exactly what the max-payout pre-check
110
+ // quotes for a plan of N tiles, so the two must not drift apart.
111
+ it.each(ALL_DIFFICULTIES)('%s: a fully surviving run pays the plan length multiplier at every length', difficulty => {
112
+ for (let rounds = 1; rounds <= FloorIsLavaRules.maxAutoBetSelections(difficulty); rounds++) {
113
+ const { gameAction } = expectOk(autoBet(difficulty, planOf(difficulty, ALWAYS_SAFE_TILE, rounds)));
114
+
115
+ expect(gameAction.multiplier.isEqualTo(FloorIsLavaRules.multiplierAfterRounds(difficulty, rounds, EDGE_BPS))).toBe(true);
116
+ }
117
+ });
118
+
119
+ it('a plan shorter than the board stops when it runs out and cashes out at the multiplier reached', () => {
120
+ const { gameAction } = expectOk(autoBet(FloorIsLavaDifficulty.EASY, planOf(FloorIsLavaDifficulty.EASY, ALWAYS_SAFE_TILE, 3)));
121
+
122
+ expect(gameAction).toMatchObject({
123
+ autoBetStopReason: FloorIsLavaAutoBetStopReason.SELECTIONS_EXHAUSTED,
124
+ level: 0,
125
+ autoBetTilesThatSurviveLevel: survivingTilesEachLevel(FloorIsLavaDifficulty.EASY),
126
+ });
127
+ expect(gameAction.multiplier.isEqualTo(expectedMultiplier(FloorIsLavaDifficulty.EASY, 0, BOARD_SIZE - 3 * 7))).toBe(true);
128
+ });
129
+
130
+ it('repeating a tile that has not dropped is allowed', () => {
131
+ const { gameAction } = expectOk(autoBet(FloorIsLavaDifficulty.EASY, planOf(FloorIsLavaDifficulty.EASY, ALWAYS_SAFE_TILE, 6)));
132
+
133
+ expect(gameAction.autoBetStopReason).toBe(FloorIsLavaAutoBetStopReason.SELECTIONS_EXHAUSTED);
134
+ expect(gameAction.multiplier.isEqualTo(FloorIsLavaRules.multiplierAfterRounds(FloorIsLavaDifficulty.EASY, 6, EDGE_BPS))).toBe(true);
135
+ });
136
+
137
+ // A plan that ends exactly on a level clear reports the level it cleared, not the one it would
138
+ // have moved on to - the same shape a manual LEVEL_COMPLETE pick has.
139
+ it('reports the level just cleared when the plan ends on a level boundary', () => {
140
+ const { gameAction } = expectOk(autoBet(FloorIsLavaDifficulty.EASY, planOf(FloorIsLavaDifficulty.EASY, ALWAYS_SAFE_TILE, 6)));
141
+
142
+ expect(gameAction).toMatchObject({ level: 0 });
143
+ });
144
+
145
+ it('carries the plan into the next level once a level is cleared', () => {
146
+ const { gameAction } = expectOk(autoBet(FloorIsLavaDifficulty.EASY, planOf(FloorIsLavaDifficulty.EASY, ALWAYS_SAFE_TILE, 7)));
147
+
148
+ expect(gameAction).toMatchObject({
149
+ autoBetStopReason: FloorIsLavaAutoBetStopReason.SELECTIONS_EXHAUSTED,
150
+ level: 1,
151
+ autoBetTilesThatSurviveLevel: survivingTilesEachLevel(FloorIsLavaDifficulty.EASY),
152
+ });
153
+ });
154
+ });
155
+
156
+ describe('runs that stop early', () => {
157
+ it('a pick that lands on lava loses the whole run', () => {
158
+ const { gameAction } = expectOk(autoBet(FloorIsLavaDifficulty.EASY, [[ALWAYS_SAFE_TILE, 8, ALWAYS_SAFE_TILE], [], []]));
159
+
160
+ expect(gameAction).toMatchObject({
161
+ autoBetStopReason: FloorIsLavaAutoBetStopReason.LOST,
162
+ level: 0,
163
+ autoBetTilesThatSurviveLevel: survivingTilesEachLevel(FloorIsLavaDifficulty.EASY),
164
+ });
165
+ expect(gameAction.multiplier.isZero()).toBe(true);
166
+ });
167
+
168
+ // The plan commits tile 5 for round 5, but tile 5 dropped back in round 1 - it can never be
169
+ // stood on again, so the run cashes out at the 4 rounds it did survive.
170
+ it('a pick on a tile dropped in an earlier round of the level cashes the run out there', () => {
171
+ const blockedLevelOne = [...Array(4).fill(ALWAYS_SAFE_TILE), 5, ALWAYS_SAFE_TILE];
172
+
173
+ const { gameAction } = expectOk(autoBet(FloorIsLavaDifficulty.EASY, [blockedLevelOne, [], []]));
174
+
175
+ expect(gameAction).toMatchObject({
176
+ autoBetStopReason: FloorIsLavaAutoBetStopReason.TILE_ALREADY_DROPPED,
177
+ autoBetSelectedTiles: [blockedLevelOne, [], []],
178
+ level: 0,
179
+ });
180
+ expect(gameAction.multiplier.isEqualTo(expectedMultiplier(FloorIsLavaDifficulty.EASY, 0, BOARD_SIZE - 4 * 7))).toBe(true);
181
+ });
182
+
183
+ // HARD clears a level in 2 rounds, so this plan reaches level 1 and then picks tile 0 - lava
184
+ // back in round 0 of level 0, but every level draws its own board, so it is playable again.
185
+ it('a tile dropped in a previous level is still pickable in the current one', () => {
186
+ const safeInLevel1 = [...ASCENDING_BOARD.filter(tile => tile !== 0), 0];
187
+ const board = boardFromLevelBoards(ASCENDING_BOARD, safeInLevel1, ASCENDING_BOARD);
188
+
189
+ const { gameAction } = expectOk(autoBet(FloorIsLavaDifficulty.HARD, [[ALWAYS_SAFE_TILE, ALWAYS_SAFE_TILE], [0], []], board));
190
+
191
+ expect(gameAction).toMatchObject({ autoBetStopReason: FloorIsLavaAutoBetStopReason.SELECTIONS_EXHAUSTED, level: 1 });
192
+ expect(gameAction.multiplier.isEqualTo(FloorIsLavaRules.multiplierAfterRounds(FloorIsLavaDifficulty.HARD, 3, EDGE_BPS))).toBe(true);
193
+ });
194
+ });
195
+
196
+ it('stores the committed plan as it was submitted, one array per level', () => {
197
+ const { gameAction } = expectOk(autoBet(FloorIsLavaDifficulty.EASY, [[ALWAYS_SAFE_TILE], [], []]));
198
+
199
+ expect(gameAction.autoBetSelectedTiles).toEqual([[ALWAYS_SAFE_TILE], [], []]);
200
+ });
201
+
202
+ // Every level's board is spent the moment the run ends, so the survivors are recorded for all
203
+ // three - a reader never has to know where the run stopped to render the levels around it.
204
+ it('reveals the tiles that survive every level, not just the level the run ended on', () => {
205
+ const { gameAction } = expectOk(autoBet(FloorIsLavaDifficulty.EASY, [[ALWAYS_SAFE_TILE, 8], [], []]));
206
+
207
+ expect(gameAction).toMatchObject({ autoBetStopReason: FloorIsLavaAutoBetStopReason.LOST, level: 0 });
208
+ expect(gameAction.autoBetTilesThatSurviveLevel).toEqual(survivingTilesEachLevel(FloorIsLavaDifficulty.EASY));
209
+ });
210
+ });
211
+
212
+ // The frontend reads a finished bet the same way whatever produced it: fold the actions into a
213
+ // state, then subtract the dropped tiles from the board to see what is still standing.
214
+ describe('reading an autobet run back', () => {
215
+ const EASY = FloorIsLavaDifficulty.EASY;
216
+ const { dropsPerRound, roundsPerLevel } = DIFFICULTY_LEVEL_MAP[EASY];
217
+ const ALWAYS_SAFE_TILE = BOARD_SIZE - 1;
218
+ const board = boardFromLevelBoards(ASCENDING_BOARD, ASCENDING_BOARD, ASCENDING_BOARD);
219
+
220
+ const runOf = (selectedTilesByLevel: FloorIsLavaTilesByLevel) =>
221
+ expectOk(FloorIsLavaAutobet.autoBet(EASY, selectedTilesByLevel, board, EDGE_BPS)).gameAction;
222
+
223
+ const stillStanding = (droppedTilesInLevel: number[]) =>
224
+ Array.from({ length: BOARD_SIZE }, (_, tile) => tile).filter(tile => !droppedTilesInLevel.includes(tile));
225
+
226
+ it('reconstructs a run that stopped mid-level without a START action to lead it', () => {
227
+ const gameAction = runOf([Array(3).fill(ALWAYS_SAFE_TILE), [], []]);
228
+
229
+ expect(FloorIsLavaRules.reconstructState([gameAction])).toEqual({
230
+ status: 'completed',
231
+ difficulty: EASY,
232
+ currentLevel: 0,
233
+ roundsSurvivedInCurrentLevel: 3,
234
+ droppedTilesByLevel: [ASCENDING_BOARD.slice(0, 3 * dropsPerRound), [], []],
235
+ });
236
+ });
237
+
238
+ it('reconstructs a run that reached a later level', () => {
239
+ const gameAction = runOf([Array(roundsPerLevel).fill(ALWAYS_SAFE_TILE), [ALWAYS_SAFE_TILE], []]);
240
+
241
+ expect(FloorIsLavaRules.reconstructState([gameAction])).toMatchObject({
242
+ currentLevel: 1,
243
+ roundsSurvivedInCurrentLevel: 1,
244
+ droppedTilesByLevel: [ASCENDING_BOARD.slice(0, roundsPerLevel * dropsPerRound), ASCENDING_BOARD.slice(0, dropsPerRound), []],
245
+ });
246
+ });
247
+
248
+ // The losing round was played - its drops are recorded - but it was not survived.
249
+ it('does not count the losing round as survived', () => {
250
+ const gameAction = runOf([[ALWAYS_SAFE_TILE, 8], [], []]);
251
+
252
+ expect(gameAction.autoBetDroppedTiles[0]).toEqual(ASCENDING_BOARD.slice(0, 2 * dropsPerRound));
253
+ expect(FloorIsLavaRules.reconstructState([gameAction])).toMatchObject({ currentLevel: 0, roundsSurvivedInCurrentLevel: 1 });
254
+ });
255
+
256
+ it('rejects an AUTO_BET action mixed into a manual history', () => {
257
+ const gameAction = runOf([[ALWAYS_SAFE_TILE], [], []]);
258
+
259
+ expect(() => FloorIsLavaRules.reconstructState([startAction(EASY), gameAction])).toThrow(/cannot appear alongside/);
260
+ expect(() => FloorIsLavaRules.reconstructState([gameAction, gameAction])).toThrow(/cannot appear alongside/);
261
+ });
262
+
263
+ it('gives the tiles still standing when the run stopped, not just the ones that survive the level', () => {
264
+ const gameAction = runOf([Array(4).fill(ALWAYS_SAFE_TILE), [], []]);
265
+
266
+ const standing = stillStanding(gameAction.autoBetDroppedTiles[0]);
267
+ expect(standing).toHaveLength(BOARD_SIZE - 4 * dropsPerRound);
268
+ // the 7 that survive the whole level are a subset of the 21 standing after 4 of 6 rounds
269
+ expect(standing).toEqual(expect.arrayContaining(gameAction.autoBetTilesThatSurviveLevel[0]));
270
+ expect(gameAction.autoBetTilesThatSurviveLevel[0]).toHaveLength(SURVIVING_TILES_MAP[EASY]);
271
+ });
272
+
273
+ // An autobet run and a manual game that reached the same position must fold to the same state,
274
+ // or the frontend renders a different board depending on how the bet was placed. The level
275
+ // boundary is the case that catches a drift: the run's last pick was played on the level it
276
+ // cleared, but the player is standing at the start of the next one.
277
+ it.each(ALL_DIFFICULTIES)('%s: folds to the same state a manual game of the same length does', difficulty => {
278
+ const { roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
279
+ const levelBoards = boardFromLevelBoards(ASCENDING_BOARD, ASCENDING_BOARD, ASCENDING_BOARD);
280
+ const safeTile = BOARD_SIZE - 1;
281
+
282
+ for (let rounds = 1; rounds <= FloorIsLavaRules.maxAutoBetSelections(difficulty); rounds++) {
283
+ const manualHistory: FloorIsLavaGameAction[] = [startAction(difficulty)];
284
+ for (let round = 0; round < rounds; round++) {
285
+ manualHistory.push(expectOk(FloorIsLava.fromActions(manualHistory).next(safeTile, levelBoards, EDGE_BPS)).gameAction);
286
+ }
287
+
288
+ const plan = tilesByLevel(level => Array(Math.min(roundsPerLevel, Math.max(0, rounds - level * roundsPerLevel))).fill(safeTile));
289
+ const run = expectOk(FloorIsLavaAutobet.autoBet(difficulty, plan, levelBoards, EDGE_BPS)).gameAction;
290
+
291
+ const { status: _manualStatus, ...manualState } = FloorIsLavaRules.reconstructState(manualHistory);
292
+ const { status: _autoStatus, ...autoState } = FloorIsLavaRules.reconstructState([run]);
293
+
294
+ expect({ rounds, ...autoState }).toEqual({ rounds, ...manualState });
295
+ }
296
+ });
297
+
298
+ // Same property for runs that end on lava. The losing round is played - its drops are recorded -
299
+ // but it is not survived, so the fold has to discount it or an autobet loss reads as one round
300
+ // further along than the manual game that lost in the same place.
301
+ it.each(ALL_DIFFICULTIES)('%s: a losing run folds to the same state a manual loss does', difficulty => {
302
+ const { roundsPerLevel, dropsPerRound } = DIFFICULTY_LEVEL_MAP[difficulty];
303
+ const levelBoards = boardFromLevelBoards(ASCENDING_BOARD, ASCENDING_BOARD, ASCENDING_BOARD);
304
+ const safeTile = BOARD_SIZE - 1;
305
+
306
+ for (let survived = 0; survived < FloorIsLavaRules.maxAutoBetSelections(difficulty); survived++) {
307
+ // on an ascending board, the first tile to drop in a round is roundIndex * dropsPerRound
308
+ const fatalTile = (survived % roundsPerLevel) * dropsPerRound;
309
+ const picks = [...Array(survived).fill(safeTile), fatalTile];
310
+
311
+ const manualHistory: FloorIsLavaGameAction[] = [startAction(difficulty)];
312
+ for (const tile of picks) {
313
+ manualHistory.push(expectOk(FloorIsLava.fromActions(manualHistory).next(tile, levelBoards, EDGE_BPS)).gameAction);
314
+ }
315
+
316
+ const plan = tilesByLevel(level => picks.slice(level * roundsPerLevel, (level + 1) * roundsPerLevel));
317
+ const run = expectOk(FloorIsLavaAutobet.autoBet(difficulty, plan, levelBoards, EDGE_BPS)).gameAction;
318
+
319
+ expect(run.autoBetStopReason).toBe(FloorIsLavaAutoBetStopReason.LOST);
320
+ expect(run.multiplier.isZero()).toBe(true);
321
+
322
+ const { status: _manualStatus, ...manualState } = FloorIsLavaRules.reconstructState(manualHistory);
323
+ const { status: _autoStatus, ...autoState } = FloorIsLavaRules.reconstructState([run]);
324
+
325
+ expect({ survived, ...autoState }).toEqual({ survived, ...manualState });
326
+ expect(autoState.roundsSurvivedInCurrentLevel).toBe(survived % roundsPerLevel);
327
+ }
328
+ });
329
+
330
+ it("slices a level's drops back into the rounds they fell in", () => {
331
+ const gameAction = runOf([Array(3).fill(ALWAYS_SAFE_TILE), [], []]);
332
+ const dropped = gameAction.autoBetDroppedTiles[0];
333
+
334
+ expect(FloorIsLavaRules.roundsPlayedInLevel(EASY, dropped)).toBe(3);
335
+ expect([0, 1, 2].map(round => dropped.slice(round * dropsPerRound, (round + 1) * dropsPerRound))).toEqual([
336
+ board.tilesDroppedInRound(0, dropsPerRound, 0),
337
+ board.tilesDroppedInRound(0, dropsPerRound, 1),
338
+ board.tilesDroppedInRound(0, dropsPerRound, 2),
339
+ ]);
340
+ });
341
+ });
@@ -0,0 +1,118 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import { FloorIsLava } from './floor-is-lava';
3
+ import { FloorIsLavaBoard } from './floor-is-lava-board';
4
+ import {
5
+ DIFFICULTY_LEVEL_MAP,
6
+ FloorIsLavaActionPhase,
7
+ FloorIsLavaAutoBetAction,
8
+ FloorIsLavaAutoBetStopReason,
9
+ FloorIsLavaDifficulty,
10
+ FloorIsLavaGameAction,
11
+ FloorIsLavaGameStatus,
12
+ FloorIsLavaRules,
13
+ type FloorIsLavaTilesByLevel,
14
+ SURVIVING_TILES_MAP,
15
+ } from './floor-is-lava-rules';
16
+
17
+ export type FloorIsLavaAutoBetPlanError = 'no-tiles-selected' | 'too-many-tiles-selected' | 'incomplete-level-plan' | 'invalid-tile';
18
+
19
+ export type FloorIsLavaAutoBetPlanResult = { ok: true; plannedTiles: number[] } | { ok: false; error: FloorIsLavaAutoBetPlanError };
20
+
21
+ export type FloorIsLavaAutoBetError = FloorIsLavaAutoBetPlanError | 'no-rounds-played' | 'no-tiles-remaining';
22
+
23
+ export type FloorIsLavaAutoBetResult = { ok: true; gameAction: FloorIsLavaAutoBetAction } | { ok: false; error: FloorIsLavaAutoBetError };
24
+
25
+ export class FloorIsLavaAutobet {
26
+ public static validateAutoBetPlan(difficulty: FloorIsLavaDifficulty, selectedTilesByLevel: FloorIsLavaTilesByLevel): FloorIsLavaAutoBetPlanResult {
27
+ const { roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
28
+
29
+ if (selectedTilesByLevel.some(tiles => tiles.length > roundsPerLevel)) {
30
+ return { ok: false, error: 'too-many-tiles-selected' };
31
+ }
32
+ const firstUnfinishedLevel = selectedTilesByLevel.findIndex(tiles => tiles.length < roundsPerLevel);
33
+ if (firstUnfinishedLevel !== -1 && selectedTilesByLevel.slice(firstUnfinishedLevel + 1).some(tiles => tiles.length > 0)) {
34
+ return { ok: false, error: 'incomplete-level-plan' };
35
+ }
36
+
37
+ const plannedTiles = selectedTilesByLevel.flat();
38
+
39
+ if (plannedTiles.length === 0) {
40
+ return { ok: false, error: 'no-tiles-selected' };
41
+ }
42
+ if (plannedTiles.some(tile => !FloorIsLavaRules.isValidTile(tile))) {
43
+ return { ok: false, error: 'invalid-tile' };
44
+ }
45
+
46
+ return { ok: true, plannedTiles };
47
+ }
48
+
49
+ public static autoBet(
50
+ difficulty: FloorIsLavaDifficulty,
51
+ selectedTilesByLevel: FloorIsLavaTilesByLevel,
52
+ board: FloorIsLavaBoard,
53
+ edge: number,
54
+ ): FloorIsLavaAutoBetResult {
55
+ const plan = FloorIsLavaAutobet.validateAutoBetPlan(difficulty, selectedTilesByLevel);
56
+ if (!plan.ok) {
57
+ return plan;
58
+ }
59
+
60
+ const history: FloorIsLavaGameAction[] = [FloorIsLava.createAction(difficulty)];
61
+ const droppedTilesByLevel: FloorIsLavaTilesByLevel = [[], [], []];
62
+ let multiplier = BigNumber(0);
63
+ let level = 0;
64
+ let stopReason = FloorIsLavaAutoBetStopReason.SELECTIONS_EXHAUSTED;
65
+
66
+ for (const selectedTile of plan.plannedTiles) {
67
+ const result = FloorIsLava.fromActions(history).next(selectedTile, board, edge);
68
+
69
+ if (!result.ok) {
70
+ switch (result.error) {
71
+ case 'tile-already-dropped':
72
+ stopReason = FloorIsLavaAutoBetStopReason.TILE_ALREADY_DROPPED;
73
+ break;
74
+ case 'no-tiles-remaining':
75
+ case 'invalid-tile':
76
+ return { ok: false, error: result.error };
77
+ default:
78
+ return result.error satisfies never;
79
+ }
80
+
81
+ break;
82
+ }
83
+
84
+ const { gameAction } = result;
85
+ history.push(gameAction);
86
+ droppedTilesByLevel[gameAction.level].push(...gameAction.droppedTiles);
87
+ level = gameAction.level;
88
+ multiplier = gameAction.multiplier;
89
+
90
+ if (gameAction.gameStatus === FloorIsLavaGameStatus.LOST) {
91
+ stopReason = FloorIsLavaAutoBetStopReason.LOST;
92
+ break;
93
+ }
94
+ if (gameAction.gameStatus === FloorIsLavaGameStatus.COMPLETED) {
95
+ stopReason = FloorIsLavaAutoBetStopReason.COMPLETED;
96
+ break;
97
+ }
98
+ }
99
+
100
+ if (history.length === 1) {
101
+ return { ok: false, error: 'no-rounds-played' };
102
+ }
103
+
104
+ return {
105
+ ok: true,
106
+ gameAction: {
107
+ phase: FloorIsLavaActionPhase.AUTO_BET,
108
+ difficulty,
109
+ autoBetSelectedTiles: selectedTilesByLevel,
110
+ autoBetDroppedTiles: droppedTilesByLevel,
111
+ autoBetTilesThatSurviveLevel: board.tilesThatSurviveEachLevel(SURVIVING_TILES_MAP[difficulty]),
112
+ autoBetStopReason: stopReason,
113
+ level,
114
+ multiplier,
115
+ },
116
+ };
117
+ }
118
+ }
@@ -59,3 +59,21 @@ describe('FloorIsLavaBoard.tilesThatSurviveLevel', () => {
59
59
  expect(board.tilesThatSurviveLevel(2, 4)).toEqual([45, 46, 47, 48]);
60
60
  });
61
61
  });
62
+
63
+ describe('FloorIsLavaBoard.tilesThatSurviveEachLevel', () => {
64
+ // All three level boards differ, so a repeated or swapped level index cannot pass.
65
+ const ROTATED_BOARD = [...ASCENDING_BOARD.slice(1), 0];
66
+ const board = boardFromLevelBoards(ASCENDING_BOARD, [...ASCENDING_BOARD].reverse(), ROTATED_BOARD);
67
+
68
+ it('returns the survivors of every level, each read from its own level band', () => {
69
+ expect(board.tilesThatSurviveEachLevel(3)).toEqual([
70
+ [46, 47, 48],
71
+ [2, 1, 0],
72
+ [47, 48, 0],
73
+ ]);
74
+ });
75
+
76
+ it('gives each level exactly what tilesThatSurviveLevel gives for it', () => {
77
+ expect(board.tilesThatSurviveEachLevel(7)).toEqual([0, 1, 2].map(level => board.tilesThatSurviveLevel(level, 7)));
78
+ });
79
+ });
@@ -1,5 +1,5 @@
1
1
  import { RandomNumberGenerator } from '../rng/random-number-generator.interface';
2
- import { BOARD_SIZE, LEVELS_PER_DIFFICULTY } from './floor-is-lava-rules';
2
+ import { BOARD_SIZE, type FloorIsLavaTilesByLevel, LEVELS_PER_DIFFICULTY } from './floor-is-lava-rules';
3
3
 
4
4
  const assertValidLevel = (level: number): void => {
5
5
  const validLevels = Array.from({ length: LEVELS_PER_DIFFICULTY }, (_, i) => i);
@@ -9,9 +9,9 @@ const assertValidLevel = (level: number): void => {
9
9
  };
10
10
 
11
11
  export class FloorIsLavaBoard {
12
- private readonly levelBoards: [number[], number[], number[]];
12
+ private readonly levelBoards: FloorIsLavaTilesByLevel;
13
13
 
14
- get boards(): [number[], number[], number[]] {
14
+ get boards(): FloorIsLavaTilesByLevel {
15
15
  return this.levelBoards;
16
16
  }
17
17
 
@@ -22,7 +22,7 @@ export class FloorIsLavaBoard {
22
22
  count: BOARD_SIZE * LEVELS_PER_DIFFICULTY,
23
23
  });
24
24
 
25
- const boards: [number[], number[], number[]] = [[], [], []];
25
+ const boards: FloorIsLavaTilesByLevel = [[], [], []];
26
26
  for (const value of raw) {
27
27
  boards[Math.floor(value / BOARD_SIZE)].push(value % BOARD_SIZE);
28
28
  }
@@ -42,4 +42,12 @@ export class FloorIsLavaBoard {
42
42
  assertValidLevel(level);
43
43
  return this.levelBoards[level].slice(-survivingTilesCount);
44
44
  }
45
+
46
+ public tilesThatSurviveEachLevel(survivingTilesCount: number): FloorIsLavaTilesByLevel {
47
+ return [
48
+ this.tilesThatSurviveLevel(0, survivingTilesCount),
49
+ this.tilesThatSurviveLevel(1, survivingTilesCount),
50
+ this.tilesThatSurviveLevel(2, survivingTilesCount),
51
+ ];
52
+ }
45
53
  }
@@ -2,7 +2,10 @@ import BigNumber from 'bignumber.js';
2
2
  import { calculateEdgeMultiplier } from '../../utils/edge';
3
3
 
4
4
  export const BOARD_SIZE = 49;
5
- export const LEVELS_PER_DIFFICULTY = 3;
5
+
6
+ export type FloorIsLavaTilesByLevel = [number[], number[], number[]];
7
+
8
+ export const LEVELS_PER_DIFFICULTY: FloorIsLavaTilesByLevel['length'] = 3;
6
9
 
7
10
  export enum FloorIsLavaDifficulty {
8
11
  EASY = 'EASY',
@@ -43,6 +46,7 @@ export enum FloorIsLavaActionPhase {
43
46
  START = 'START',
44
47
  PICK = 'PICK',
45
48
  CASHOUT = 'CASHOUT',
49
+ AUTO_BET = 'AUTO_BET',
46
50
  }
47
51
 
48
52
  export enum FloorIsLavaGameStatus {
@@ -66,6 +70,24 @@ export type FloorIsLavaPickAction = {
66
70
  multiplier: BigNumber;
67
71
  };
68
72
 
73
+ export enum FloorIsLavaAutoBetStopReason {
74
+ LOST = 'LOST',
75
+ COMPLETED = 'COMPLETED',
76
+ TILE_ALREADY_DROPPED = 'TILE_ALREADY_DROPPED',
77
+ SELECTIONS_EXHAUSTED = 'SELECTIONS_EXHAUSTED',
78
+ }
79
+
80
+ export interface FloorIsLavaAutoBetAction {
81
+ phase: FloorIsLavaActionPhase.AUTO_BET;
82
+ difficulty: FloorIsLavaDifficulty;
83
+ autoBetSelectedTiles: FloorIsLavaTilesByLevel;
84
+ autoBetDroppedTiles: FloorIsLavaTilesByLevel;
85
+ autoBetTilesThatSurviveLevel: FloorIsLavaTilesByLevel;
86
+ autoBetStopReason: FloorIsLavaAutoBetStopReason;
87
+ level: number;
88
+ multiplier: BigNumber;
89
+ }
90
+
69
91
  export interface FloorIsLavaCashoutAction {
70
92
  phase: FloorIsLavaActionPhase.CASHOUT;
71
93
  level: number;
@@ -74,11 +96,13 @@ export interface FloorIsLavaCashoutAction {
74
96
 
75
97
  export type FloorIsLavaGameAction = FloorIsLavaStartAction | FloorIsLavaPickAction | FloorIsLavaCashoutAction;
76
98
 
99
+ export type FloorIsLavaStoredAction = FloorIsLavaGameAction | FloorIsLavaAutoBetAction;
100
+
77
101
  interface FloorIsLavaBaseState {
78
102
  difficulty: FloorIsLavaDifficulty;
79
103
  currentLevel: number;
80
104
  roundsSurvivedInCurrentLevel: number;
81
- droppedTilesByLevel: number[][];
105
+ droppedTilesByLevel: FloorIsLavaTilesByLevel;
82
106
  }
83
107
 
84
108
  export interface FloorIsLavaInProgressState extends FloorIsLavaBaseState {
@@ -104,6 +128,14 @@ export class FloorIsLavaRules {
104
128
  return BOARD_SIZE - roundsSurvivedInCurrentLevel * dropsPerRound;
105
129
  }
106
130
 
131
+ public static maxAutoBetSelections(difficulty: FloorIsLavaDifficulty): number {
132
+ return DIFFICULTY_LEVEL_MAP[difficulty].roundsPerLevel * LEVELS_PER_DIFFICULTY;
133
+ }
134
+
135
+ public static roundsPlayedInLevel(difficulty: FloorIsLavaDifficulty, droppedTilesInLevel: number[]): number {
136
+ return droppedTilesInLevel.length / DIFFICULTY_LEVEL_MAP[difficulty].dropsPerRound;
137
+ }
138
+
107
139
  public static isValidTile(tile: number): boolean {
108
140
  return Number.isInteger(tile) && tile >= 0 && tile <= BOARD_SIZE - 1;
109
141
  }
@@ -139,8 +171,38 @@ export class FloorIsLavaRules {
139
171
  };
140
172
  }
141
173
 
142
- public static reconstructState(gameActions: FloorIsLavaGameAction[]): FloorIsLavaReconstructedState {
174
+ public static multiplierAfterRounds(difficulty: FloorIsLavaDifficulty, roundsSurvived: number, edge: number): BigNumber {
175
+ const { dropsPerRound, roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
176
+ const levelsCleared = Math.floor(roundsSurvived / roundsPerLevel);
177
+ const roundsInCurrentLevel = roundsSurvived % roundsPerLevel;
178
+
179
+ return FloorIsLavaRules.multiplier(difficulty, levelsCleared, FloorIsLavaRules.tilesRemaining(roundsInCurrentLevel, dropsPerRound), edge);
180
+ }
181
+
182
+ public static reconstructState(gameActions: FloorIsLavaStoredAction[]): FloorIsLavaReconstructedState {
143
183
  const first = gameActions[0];
184
+
185
+ if (first?.phase === FloorIsLavaActionPhase.AUTO_BET) {
186
+ if (gameActions.length > 1) {
187
+ throw new Error('An AUTO_BET action is the whole game and cannot appear alongside other actions');
188
+ }
189
+
190
+ const { roundsPerLevel } = DIFFICULTY_LEVEL_MAP[first.difficulty];
191
+ const roundsPlayed = FloorIsLavaRules.roundsPlayedInLevel(first.difficulty, first.autoBetDroppedTiles[first.level]);
192
+ const lostTheLastRound = first.autoBetStopReason === FloorIsLavaAutoBetStopReason.LOST;
193
+ const roundsSurvived = lostTheLastRound ? roundsPlayed - 1 : roundsPlayed;
194
+
195
+ const clearedANonFinalLevel = !lostTheLastRound && roundsSurvived === roundsPerLevel && first.level < LEVELS_PER_DIFFICULTY - 1;
196
+
197
+ return {
198
+ status: 'completed',
199
+ difficulty: first.difficulty,
200
+ currentLevel: clearedANonFinalLevel ? first.level + 1 : first.level,
201
+ roundsSurvivedInCurrentLevel: clearedANonFinalLevel ? 0 : roundsSurvived,
202
+ droppedTilesByLevel: first.autoBetDroppedTiles,
203
+ };
204
+ }
205
+
144
206
  if (!first || first.phase !== FloorIsLavaActionPhase.START) {
145
207
  throw new Error('First action must be START');
146
208
  }
@@ -149,7 +211,7 @@ export class FloorIsLavaRules {
149
211
  const { roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
150
212
  let currentLevel = 0;
151
213
  let roundsSurvivedInCurrentLevel = 0;
152
- const droppedTilesByLevel: number[][] = Array.from({ length: LEVELS_PER_DIFFICULTY }, () => []);
214
+ const droppedTilesByLevel: FloorIsLavaTilesByLevel = [[], [], []];
153
215
 
154
216
  for (let i = 1; i < gameActions.length; i++) {
155
217
  const action = gameActions[i];
@@ -160,6 +222,10 @@ export class FloorIsLavaRules {
160
222
  throw new Error('START action can only appear as the first action');
161
223
  }
162
224
 
225
+ case FloorIsLavaActionPhase.AUTO_BET: {
226
+ throw new Error('An AUTO_BET action is the whole game and cannot appear alongside other actions');
227
+ }
228
+
163
229
  case FloorIsLavaActionPhase.CASHOUT: {
164
230
  if (!isLastAction) {
165
231
  throw new Error('A CASHOUT action can only appear as the last action');