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/lib/games/floor-is-lava/floor-is-lava-autobet.d.ts +22 -0
- package/lib/games/floor-is-lava/floor-is-lava-autobet.js +87 -0
- package/lib/games/floor-is-lava/floor-is-lava-autobet.js.map +1 -0
- package/lib/games/floor-is-lava/floor-is-lava-board.d.ts +3 -1
- package/lib/games/floor-is-lava/floor-is-lava-board.js +7 -0
- package/lib/games/floor-is-lava/floor-is-lava-board.js.map +1 -1
- package/lib/games/floor-is-lava/floor-is-lava-rules.d.ts +26 -4
- package/lib/games/floor-is-lava/floor-is-lava-rules.js +42 -2
- package/lib/games/floor-is-lava/floor-is-lava-rules.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/games/floor-is-lava/floor-is-lava-autobet.spec.ts +341 -0
- package/src/games/floor-is-lava/floor-is-lava-autobet.ts +118 -0
- package/src/games/floor-is-lava/floor-is-lava-board.spec.ts +18 -0
- package/src/games/floor-is-lava/floor-is-lava-board.ts +12 -4
- package/src/games/floor-is-lava/floor-is-lava-rules.ts +70 -4
- package/src/index.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shufflecom-calculations",
|
|
3
|
-
"version": "6.
|
|
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": "
|
|
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:
|
|
12
|
+
private readonly levelBoards: FloorIsLavaTilesByLevel;
|
|
13
13
|
|
|
14
|
-
get boards():
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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');
|