shufflecom-calculations 4.2.0 → 4.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/games/coinflip/coinflip-classic-progressive.d.ts +1 -1
- package/lib/games/coinflip/coinflip-classic.d.ts +1 -1
- package/lib/games/coinflip/coinflip-rules.d.ts +1 -1
- package/lib/games/coinflip/coinflip-target.d.ts +1 -1
- package/lib/games/floor-is-lava/floor-is-lava-board.d.ts +8 -0
- package/lib/games/floor-is-lava/floor-is-lava-board.js +37 -0
- package/lib/games/floor-is-lava/floor-is-lava-board.js.map +1 -0
- package/lib/games/floor-is-lava/floor-is-lava-rules.d.ts +69 -0
- package/lib/games/floor-is-lava/floor-is-lava-rules.js +133 -0
- package/lib/games/floor-is-lava/floor-is-lava-rules.js.map +1 -0
- package/lib/games/floor-is-lava/floor-is-lava.d.ts +34 -0
- package/lib/games/floor-is-lava/floor-is-lava.js +106 -0
- package/lib/games/floor-is-lava/floor-is-lava.js.map +1 -0
- package/lib/games/rng/fixed-rng.js.map +1 -0
- package/lib/games/rng/random-number-generator.interface.js.map +1 -0
- package/lib/games/rng/shuffle-random-number-generator.d.ts +21 -0
- package/lib/games/rng/shuffle-random-number-generator.js +76 -0
- package/lib/games/rng/shuffle-random-number-generator.js.map +1 -0
- package/lib/games/rng/true-random-rng.js.map +1 -0
- package/lib/index.d.ts +7 -2
- package/lib/index.js +7 -2
- package/lib/index.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/games/coinflip/coinflip-classic-progressive.spec.ts +14 -3
- package/src/games/coinflip/coinflip-classic-progressive.ts +1 -1
- package/src/games/coinflip/coinflip-classic.spec.ts +1 -1
- package/src/games/coinflip/coinflip-classic.ts +1 -1
- package/src/games/coinflip/coinflip-rules.spec.ts +1 -1
- package/src/games/coinflip/coinflip-rules.ts +1 -1
- package/src/games/coinflip/coinflip-target.spec.ts +1 -1
- package/src/games/coinflip/coinflip-target.ts +1 -1
- package/src/games/floor-is-lava/floor-is-lava-board.spec.ts +61 -0
- package/src/games/floor-is-lava/floor-is-lava-board.ts +41 -0
- package/src/games/floor-is-lava/floor-is-lava-rules.spec.ts +316 -0
- package/src/games/floor-is-lava/floor-is-lava-rules.ts +217 -0
- package/src/games/floor-is-lava/floor-is-lava.spec.ts +455 -0
- package/src/games/floor-is-lava/floor-is-lava.ts +138 -0
- package/src/games/rng/shuffle-random-number-generator.ts +100 -0
- package/src/index.ts +7 -2
- package/lib/games/fixed-rng.js.map +0 -1
- package/lib/games/floor-is-lava-rules.d.ts +0 -52
- package/lib/games/floor-is-lava-rules.js +0 -82
- package/lib/games/floor-is-lava-rules.js.map +0 -1
- package/lib/games/floor-is-lava.d.ts +0 -56
- package/lib/games/floor-is-lava.js +0 -134
- package/lib/games/floor-is-lava.js.map +0 -1
- package/lib/games/random-number-generator.interface.js.map +0 -1
- package/lib/games/true-random-rng.js.map +0 -1
- package/src/games/floor-is-lava-rules.spec.ts +0 -126
- package/src/games/floor-is-lava-rules.ts +0 -125
- package/src/games/floor-is-lava.spec.ts +0 -502
- package/src/games/floor-is-lava.ts +0 -184
- /package/lib/games/{fixed-rng.d.ts → rng/fixed-rng.d.ts} +0 -0
- /package/lib/games/{fixed-rng.js → rng/fixed-rng.js} +0 -0
- /package/lib/games/{random-number-generator.interface.d.ts → rng/random-number-generator.interface.d.ts} +0 -0
- /package/lib/games/{random-number-generator.interface.js → rng/random-number-generator.interface.js} +0 -0
- /package/lib/games/{true-random-rng.d.ts → rng/true-random-rng.d.ts} +0 -0
- /package/lib/games/{true-random-rng.js → rng/true-random-rng.js} +0 -0
- /package/src/games/{fixed-rng.ts → rng/fixed-rng.ts} +0 -0
- /package/src/games/{random-number-generator.interface.ts → rng/random-number-generator.interface.ts} +0 -0
- /package/src/games/{true-random-rng.ts → rng/true-random-rng.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shufflecom-calculations",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"types": "lib/index.d.ts",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
},
|
|
15
15
|
"author": "",
|
|
16
16
|
"license": "ISC",
|
|
17
|
-
"gitHead": "
|
|
17
|
+
"gitHead": "d4eb397581b49cd9c79a080502425f5d8e2fbe41"
|
|
18
18
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import BigNumber from 'bignumber.js';
|
|
2
|
-
import { FixedSequenceRng } from '../fixed-rng';
|
|
2
|
+
import { FixedSequenceRng } from '../rng/fixed-rng';
|
|
3
3
|
import {
|
|
4
4
|
CoinflipClassicProgressive,
|
|
5
5
|
CoinflipProgressiveCashoutAction,
|
|
@@ -130,7 +130,14 @@ describe('CoinflipClassicProgressive.next', () => {
|
|
|
130
130
|
});
|
|
131
131
|
|
|
132
132
|
it('a loss deep into a streak still pays zero', () => {
|
|
133
|
-
const rng = new FixedSequenceRng([
|
|
133
|
+
const rng = new FixedSequenceRng([
|
|
134
|
+
getCoinRngValue(CoinSide.HEADS),
|
|
135
|
+
getCoinRngValue(CoinSide.HEADS),
|
|
136
|
+
getCoinRngValue(CoinSide.HEADS),
|
|
137
|
+
getCoinRngValue(CoinSide.HEADS),
|
|
138
|
+
getCoinRngValue(CoinSide.HEADS),
|
|
139
|
+
getCoinRngValue(CoinSide.TAILS),
|
|
140
|
+
]);
|
|
134
141
|
expect(gameWithWins(5).next(CoinSide.HEADS, rng, EDGE_BPS)).toMatchObject({
|
|
135
142
|
multiplier: new BigNumber(0),
|
|
136
143
|
isWin: false,
|
|
@@ -178,7 +185,11 @@ describe('CoinflipClassicProgressive.next', () => {
|
|
|
178
185
|
});
|
|
179
186
|
|
|
180
187
|
it('throws when MAX_FLIPS are already revealed', () => {
|
|
181
|
-
const rng = new FixedSequenceRng(
|
|
188
|
+
const rng = new FixedSequenceRng(
|
|
189
|
+
Array(MAX_FLIPS + 1)
|
|
190
|
+
.fill(CoinSide.HEADS)
|
|
191
|
+
.map(getCoinRngValue),
|
|
192
|
+
);
|
|
182
193
|
expect(() => gameWithWins(MAX_FLIPS).next(CoinSide.HEADS, rng, EDGE_BPS)).toThrow();
|
|
183
194
|
});
|
|
184
195
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import BigNumber from 'bignumber.js';
|
|
2
|
-
import { RandomNumberGenerator } from '../random-number-generator.interface';
|
|
2
|
+
import { RandomNumberGenerator } from '../rng/random-number-generator.interface';
|
|
3
3
|
import { CoinflipRules } from './coinflip-rules';
|
|
4
4
|
import { CoinSide, MAX_FLIPS } from './constants';
|
|
5
5
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import BigNumber from 'bignumber.js';
|
|
2
|
-
import { RandomNumberGenerator } from '../random-number-generator.interface';
|
|
2
|
+
import { RandomNumberGenerator } from '../rng/random-number-generator.interface';
|
|
3
3
|
import { CoinflipRules } from './coinflip-rules';
|
|
4
4
|
import { CoinSide } from './constants';
|
|
5
5
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import BigNumber from 'bignumber.js';
|
|
2
2
|
import { calculateEdgeMultiplier } from '../../utils/edge';
|
|
3
3
|
import { nCr } from '../../utils/ncr';
|
|
4
|
-
import { RandomNumberGenerator } from '../random-number-generator.interface';
|
|
4
|
+
import { RandomNumberGenerator } from '../rng/random-number-generator.interface';
|
|
5
5
|
import type { CoinflipClassicProgressiveGameAction } from './coinflip-classic-progressive';
|
|
6
6
|
import { CoinSide, MAX_FLIPS } from './constants';
|
|
7
7
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import BigNumber from 'bignumber.js';
|
|
2
|
-
import { RandomNumberGenerator } from '../random-number-generator.interface';
|
|
2
|
+
import { RandomNumberGenerator } from '../rng/random-number-generator.interface';
|
|
3
3
|
import { CoinflipRules } from './coinflip-rules';
|
|
4
4
|
import { CoinSide } from './constants';
|
|
5
5
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { FixedSequenceRng } from '../rng/fixed-rng';
|
|
2
|
+
import { FloorIsLavaBoard } from './floor-is-lava-board';
|
|
3
|
+
import { BOARD_SIZE } from './floor-is-lava-rules';
|
|
4
|
+
|
|
5
|
+
const ASCENDING_BOARD = Array.from({ length: BOARD_SIZE }, (_, i) => i);
|
|
6
|
+
|
|
7
|
+
// Builds the 147-value raw draw for a FixedSequenceRng from 3 explicit per-level boards (each a
|
|
8
|
+
// permutation of 0..48), so a given level's contents can be controlled precisely in tests.
|
|
9
|
+
const boardFromLevelBoards = (level0: number[], level1: number[], level2: number[]): FloorIsLavaBoard =>
|
|
10
|
+
FloorIsLavaBoard.draw(new FixedSequenceRng([...level0, ...level1.map(t => t + BOARD_SIZE), ...level2.map(t => t + BOARD_SIZE * 2)]));
|
|
11
|
+
|
|
12
|
+
describe('FloorIsLavaBoard.draw', () => {
|
|
13
|
+
it('splits one 147-value shuffle by VALUE into 3 independent, order-preserving per-level boards', () => {
|
|
14
|
+
const raw: number[] = [];
|
|
15
|
+
for (let i = 0; i < BOARD_SIZE; i++) {
|
|
16
|
+
raw.push(i); // level 0: ascending
|
|
17
|
+
raw.push(BOARD_SIZE + (BOARD_SIZE - 1 - i)); // level 1: descending
|
|
18
|
+
raw.push(BOARD_SIZE * 2 + i); // level 2: ascending
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const board = FloorIsLavaBoard.draw(new FixedSequenceRng(raw));
|
|
22
|
+
|
|
23
|
+
expect(board.remainingTilesInLevel(0, 0)).toEqual(ASCENDING_BOARD);
|
|
24
|
+
expect(board.remainingTilesInLevel(1, 0)).toEqual([...ASCENDING_BOARD].reverse());
|
|
25
|
+
expect(board.remainingTilesInLevel(2, 0)).toEqual(ASCENDING_BOARD);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('draws independently per level - no leakage across levels', () => {
|
|
29
|
+
const board = boardFromLevelBoards(ASCENDING_BOARD, [...ASCENDING_BOARD].reverse(), ASCENDING_BOARD);
|
|
30
|
+
|
|
31
|
+
expect(board.remainingTilesInLevel(0, 0)).not.toEqual(board.remainingTilesInLevel(1, 0));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('FloorIsLavaBoard.tilesDroppedInRound', () => {
|
|
36
|
+
const board = boardFromLevelBoards(ASCENDING_BOARD, [...ASCENDING_BOARD].reverse(), ASCENDING_BOARD);
|
|
37
|
+
|
|
38
|
+
it('slices the requested round out of the level board', () => {
|
|
39
|
+
expect(board.tilesDroppedInRound(0, 7, 0)).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('offsets by the round index within the level', () => {
|
|
43
|
+
expect(board.tilesDroppedInRound(0, 7, 1)).toEqual([7, 8, 9, 10, 11, 12, 13]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('pulls from the requested level, not always level 0', () => {
|
|
47
|
+
expect(board.tilesDroppedInRound(1, 7, 0)).toEqual([48, 47, 46, 45, 44, 43, 42]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('FloorIsLavaBoard.remainingTilesInLevel', () => {
|
|
52
|
+
const board = boardFromLevelBoards(ASCENDING_BOARD, ASCENDING_BOARD, ASCENDING_BOARD);
|
|
53
|
+
|
|
54
|
+
it('returns everything in the level board after the consumed prefix', () => {
|
|
55
|
+
expect(board.remainingTilesInLevel(0, 42)).toEqual([42, 43, 44, 45, 46, 47, 48]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reads from the requested level band', () => {
|
|
59
|
+
expect(board.remainingTilesInLevel(2, 45)).toEqual([45, 46, 47, 48]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { RandomNumberGenerator } from '../rng/random-number-generator.interface';
|
|
2
|
+
import { BOARD_SIZE, LEVELS_PER_DIFFICULTY } from './floor-is-lava-rules';
|
|
3
|
+
|
|
4
|
+
const assertValidLevel = (level: number): void => {
|
|
5
|
+
const validLevels = Array.from({ length: LEVELS_PER_DIFFICULTY }, (_, i) => i);
|
|
6
|
+
if (!validLevels.includes(level)) {
|
|
7
|
+
throw new Error(`FloorIsLava - invalid level ${level}; must be in [0, ${LEVELS_PER_DIFFICULTY})`);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export class FloorIsLavaBoard {
|
|
12
|
+
private readonly levelBoards: [number[], number[], number[]];
|
|
13
|
+
|
|
14
|
+
private constructor(generator: RandomNumberGenerator) {
|
|
15
|
+
const raw = generator.getRandomIntsWithoutReplacement({
|
|
16
|
+
min: 0,
|
|
17
|
+
max: BOARD_SIZE * LEVELS_PER_DIFFICULTY - 1,
|
|
18
|
+
count: BOARD_SIZE * LEVELS_PER_DIFFICULTY,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const boards: [number[], number[], number[]] = [[], [], []];
|
|
22
|
+
for (const value of raw) {
|
|
23
|
+
boards[Math.floor(value / BOARD_SIZE)].push(value % BOARD_SIZE);
|
|
24
|
+
}
|
|
25
|
+
this.levelBoards = boards;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public static draw(generator: RandomNumberGenerator): FloorIsLavaBoard {
|
|
29
|
+
return new FloorIsLavaBoard(generator);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public tilesDroppedInRound(level: number, dropsPerRound: number, roundIndex: number): number[] {
|
|
33
|
+
assertValidLevel(level);
|
|
34
|
+
return this.levelBoards[level].slice(roundIndex * dropsPerRound, (roundIndex + 1) * dropsPerRound);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public remainingTilesInLevel(level: number, tilesDroppedInCurrentLevel: number): number[] {
|
|
38
|
+
assertValidLevel(level);
|
|
39
|
+
return this.levelBoards[level].slice(tilesDroppedInCurrentLevel);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import BigNumber from 'bignumber.js';
|
|
2
|
+
import { calculateEdgeMultiplier } from '../../utils/edge';
|
|
3
|
+
import {
|
|
4
|
+
BOARD_SIZE,
|
|
5
|
+
DIFFICULTY_LEVEL_MAP,
|
|
6
|
+
FloorIsLavaActionPhase,
|
|
7
|
+
FloorIsLavaCashoutAction,
|
|
8
|
+
FloorIsLavaDifficulty,
|
|
9
|
+
FloorIsLavaGameStatus,
|
|
10
|
+
FloorIsLavaInProgressState,
|
|
11
|
+
FloorIsLavaPickAction,
|
|
12
|
+
FloorIsLavaRules,
|
|
13
|
+
FloorIsLavaStartAction,
|
|
14
|
+
SURVIVING_TILES_MAP,
|
|
15
|
+
} from './floor-is-lava-rules';
|
|
16
|
+
|
|
17
|
+
const EDGE_BPS = 200;
|
|
18
|
+
|
|
19
|
+
const ALL_DIFFICULTIES = [FloorIsLavaDifficulty.EASY, FloorIsLavaDifficulty.MEDIUM, FloorIsLavaDifficulty.HARD, FloorIsLavaDifficulty.TOXIC];
|
|
20
|
+
|
|
21
|
+
const stateAt = (difficulty: FloorIsLavaDifficulty, currentLevel: number, roundsSurvivedInCurrentLevel: number): FloorIsLavaInProgressState => ({
|
|
22
|
+
status: 'in-progress',
|
|
23
|
+
difficulty,
|
|
24
|
+
currentLevel,
|
|
25
|
+
roundsSurvivedInCurrentLevel,
|
|
26
|
+
droppedTilesByLevel: [[], [], []],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const startAction = (difficulty: FloorIsLavaDifficulty): FloorIsLavaStartAction => ({ phase: FloorIsLavaActionPhase.START, difficulty });
|
|
30
|
+
|
|
31
|
+
const pickAction = (overrides: Partial<FloorIsLavaPickAction> = {}): FloorIsLavaPickAction => ({
|
|
32
|
+
phase: FloorIsLavaActionPhase.PICK,
|
|
33
|
+
level: 0,
|
|
34
|
+
selectedTile: 0,
|
|
35
|
+
droppedTiles: [],
|
|
36
|
+
gameStatus: FloorIsLavaGameStatus.IN_PROGRESS,
|
|
37
|
+
multiplier: new BigNumber(0),
|
|
38
|
+
...overrides,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const cashoutAction = (overrides: Partial<FloorIsLavaCashoutAction> = {}): FloorIsLavaCashoutAction => ({
|
|
42
|
+
phase: FloorIsLavaActionPhase.CASHOUT,
|
|
43
|
+
level: 0,
|
|
44
|
+
multiplier: new BigNumber(1),
|
|
45
|
+
...overrides,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('DIFFICULTY_LEVEL_MAP / SURVIVING_TILES_MAP', () => {
|
|
49
|
+
it.each(Object.values(FloorIsLavaDifficulty))('%s: dropsPerRound * roundsPerLevel + survivingTiles equals the board size', difficulty => {
|
|
50
|
+
const { dropsPerRound, roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
|
|
51
|
+
expect(dropsPerRound * roundsPerLevel + SURVIVING_TILES_MAP[difficulty]).toBe(BOARD_SIZE);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('FloorIsLavaRules.tilesRemaining', () => {
|
|
56
|
+
it('returns the full board when no rounds have been survived in the level', () => {
|
|
57
|
+
expect(FloorIsLavaRules.tilesRemaining(0, 7)).toBe(BOARD_SIZE);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('subtracts dropped tiles for the rounds survived so far in the level', () => {
|
|
61
|
+
expect(FloorIsLavaRules.tilesRemaining(2, 7)).toBe(BOARD_SIZE - 14);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('FloorIsLavaRules.isValidTile', () => {
|
|
66
|
+
it.each([0, 1, BOARD_SIZE - 1])('accepts an in-range integer tile (%s)', tile => {
|
|
67
|
+
expect(FloorIsLavaRules.isValidTile(tile)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it.each([-1, BOARD_SIZE, 1.5, NaN, Infinity])('rejects an out-of-range or non-integer tile (%s)', tile => {
|
|
71
|
+
expect(FloorIsLavaRules.isValidTile(tile)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('FloorIsLavaRules.multiplier', () => {
|
|
76
|
+
it('scales the edge multiplier by board size over standing tiles, compounding survived levels', () => {
|
|
77
|
+
const expected = calculateEdgeMultiplier(EDGE_BPS)
|
|
78
|
+
.multipliedBy(new BigNumber(BOARD_SIZE).pow(2))
|
|
79
|
+
.dividedBy(new BigNumber(28).multipliedBy(new BigNumber(SURVIVING_TILES_MAP[FloorIsLavaDifficulty.EASY]).pow(1)));
|
|
80
|
+
expect(FloorIsLavaRules.multiplier(FloorIsLavaDifficulty.EASY, 1, 28, EDGE_BPS).isEqualTo(expected)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('is continuous at a level boundary: clearing a level equals the fresh multiplier one level deeper', () => {
|
|
84
|
+
const difficulty = FloorIsLavaDifficulty.EASY;
|
|
85
|
+
const survivingTiles = SURVIVING_TILES_MAP[difficulty];
|
|
86
|
+
const clearing = FloorIsLavaRules.multiplier(difficulty, 0, survivingTiles, EDGE_BPS); // just cleared level 0 (y = s)
|
|
87
|
+
const freshNextLevel = FloorIsLavaRules.multiplier(difficulty, 1, BOARD_SIZE, EDGE_BPS); // level 1, 0 rounds played (y = 49)
|
|
88
|
+
expect(clearing.isEqualTo(freshNextLevel)).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('FloorIsLavaRules.multipliers', () => {
|
|
93
|
+
it('returns the current-round and next-round multipliers derived from the state', () => {
|
|
94
|
+
const difficulty = FloorIsLavaDifficulty.EASY;
|
|
95
|
+
const { dropsPerRound } = DIFFICULTY_LEVEL_MAP[difficulty];
|
|
96
|
+
const state = stateAt(difficulty, 1, 2);
|
|
97
|
+
|
|
98
|
+
const result = FloorIsLavaRules.multipliers(state, EDGE_BPS);
|
|
99
|
+
|
|
100
|
+
expect(
|
|
101
|
+
result.currentMultiplier.isEqualTo(FloorIsLavaRules.multiplier(difficulty, 1, FloorIsLavaRules.tilesRemaining(2, dropsPerRound), EDGE_BPS)),
|
|
102
|
+
).toBe(true);
|
|
103
|
+
expect(
|
|
104
|
+
result.nextMultiplier.isEqualTo(FloorIsLavaRules.multiplier(difficulty, 1, FloorIsLavaRules.tilesRemaining(3, dropsPerRound), EDGE_BPS)),
|
|
105
|
+
).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('reads dropsPerRound from the state difficulty, not a fixed value', () => {
|
|
109
|
+
const difficulty = FloorIsLavaDifficulty.HARD; // dropsPerRound 23
|
|
110
|
+
const { dropsPerRound } = DIFFICULTY_LEVEL_MAP[difficulty];
|
|
111
|
+
const state = stateAt(difficulty, 0, 0);
|
|
112
|
+
|
|
113
|
+
const result = FloorIsLavaRules.multipliers(state, EDGE_BPS);
|
|
114
|
+
|
|
115
|
+
expect(
|
|
116
|
+
result.nextMultiplier.isEqualTo(FloorIsLavaRules.multiplier(difficulty, 0, FloorIsLavaRules.tilesRemaining(1, dropsPerRound), EDGE_BPS)),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('applies the given edge, not a hardcoded value', () => {
|
|
121
|
+
const difficulty = FloorIsLavaDifficulty.MEDIUM;
|
|
122
|
+
const { dropsPerRound } = DIFFICULTY_LEVEL_MAP[difficulty];
|
|
123
|
+
const state = stateAt(difficulty, 0, 1);
|
|
124
|
+
|
|
125
|
+
const result = FloorIsLavaRules.multipliers(state, 500);
|
|
126
|
+
|
|
127
|
+
expect(
|
|
128
|
+
result.currentMultiplier.isEqualTo(FloorIsLavaRules.multiplier(difficulty, 0, FloorIsLavaRules.tilesRemaining(1, dropsPerRound), 500)),
|
|
129
|
+
).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('is continuous across a level boundary: nextMultiplier at the last round equals currentMultiplier fresh one level deeper', () => {
|
|
133
|
+
const difficulty = FloorIsLavaDifficulty.EASY;
|
|
134
|
+
const { roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
|
|
135
|
+
|
|
136
|
+
const { nextMultiplier } = FloorIsLavaRules.multipliers(stateAt(difficulty, 0, roundsPerLevel - 1), EDGE_BPS);
|
|
137
|
+
const { currentMultiplier } = FloorIsLavaRules.multipliers(stateAt(difficulty, 1, 0), EDGE_BPS);
|
|
138
|
+
|
|
139
|
+
expect(nextMultiplier.isEqualTo(currentMultiplier)).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('FloorIsLavaRules.reconstructState', () => {
|
|
144
|
+
describe('in-progress histories', () => {
|
|
145
|
+
it('counts IN_PROGRESS PICK actions within the current level and accumulates their drops', () => {
|
|
146
|
+
const actions = [
|
|
147
|
+
startAction(FloorIsLavaDifficulty.MEDIUM),
|
|
148
|
+
pickAction({ selectedTile: 20, droppedTiles: [0, 1, 2, 3] }),
|
|
149
|
+
pickAction({ selectedTile: 21, droppedTiles: [4, 5, 6, 7] }),
|
|
150
|
+
];
|
|
151
|
+
expect(FloorIsLavaRules.reconstructState(actions)).toEqual({
|
|
152
|
+
status: 'in-progress',
|
|
153
|
+
difficulty: FloorIsLavaDifficulty.MEDIUM,
|
|
154
|
+
currentLevel: 0,
|
|
155
|
+
roundsSurvivedInCurrentLevel: 2,
|
|
156
|
+
droppedTilesByLevel: [[0, 1, 2, 3, 4, 5, 6, 7], [], []],
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('advances to the next level and resets rounds on LEVEL_COMPLETE, bucketing drops per level', () => {
|
|
161
|
+
const actions = [
|
|
162
|
+
startAction(FloorIsLavaDifficulty.MEDIUM),
|
|
163
|
+
pickAction({ selectedTile: 20, droppedTiles: [0, 1, 2, 3] }),
|
|
164
|
+
pickAction({ selectedTile: 21, droppedTiles: [4, 5, 6, 7], gameStatus: FloorIsLavaGameStatus.LEVEL_COMPLETE }),
|
|
165
|
+
pickAction({ level: 1, selectedTile: 30, droppedTiles: [10, 11, 12, 13] }),
|
|
166
|
+
];
|
|
167
|
+
expect(FloorIsLavaRules.reconstructState(actions)).toEqual({
|
|
168
|
+
status: 'in-progress',
|
|
169
|
+
difficulty: FloorIsLavaDifficulty.MEDIUM,
|
|
170
|
+
currentLevel: 1,
|
|
171
|
+
roundsSurvivedInCurrentLevel: 1,
|
|
172
|
+
droppedTilesByLevel: [[0, 1, 2, 3, 4, 5, 6, 7], [10, 11, 12, 13], []],
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('completed histories', () => {
|
|
178
|
+
it('does not throw on a CASHOUT action as the last action, returning a completed state', () => {
|
|
179
|
+
const actions = [
|
|
180
|
+
startAction(FloorIsLavaDifficulty.MEDIUM),
|
|
181
|
+
pickAction({ selectedTile: 20, droppedTiles: [0, 1, 2, 3] }),
|
|
182
|
+
cashoutAction({ level: 0, multiplier: new BigNumber(2) }),
|
|
183
|
+
];
|
|
184
|
+
expect(FloorIsLavaRules.reconstructState(actions)).toEqual({
|
|
185
|
+
status: 'completed',
|
|
186
|
+
difficulty: FloorIsLavaDifficulty.MEDIUM,
|
|
187
|
+
currentLevel: 0,
|
|
188
|
+
roundsSurvivedInCurrentLevel: 1,
|
|
189
|
+
droppedTilesByLevel: [[0, 1, 2, 3], [], []],
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('does not throw on a LOST PICK action as the last action, and does not count it as a survived round', () => {
|
|
194
|
+
const actions = [
|
|
195
|
+
startAction(FloorIsLavaDifficulty.MEDIUM),
|
|
196
|
+
pickAction({ selectedTile: 20, droppedTiles: [0, 1, 2, 3] }),
|
|
197
|
+
pickAction({ selectedTile: 25, droppedTiles: [4, 5, 6, 7], gameStatus: FloorIsLavaGameStatus.LOST }),
|
|
198
|
+
];
|
|
199
|
+
expect(FloorIsLavaRules.reconstructState(actions)).toEqual({
|
|
200
|
+
status: 'completed',
|
|
201
|
+
difficulty: FloorIsLavaDifficulty.MEDIUM,
|
|
202
|
+
currentLevel: 0,
|
|
203
|
+
roundsSurvivedInCurrentLevel: 1,
|
|
204
|
+
droppedTilesByLevel: [[0, 1, 2, 3, 4, 5, 6, 7], [], []],
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('does not throw on a COMPLETED PICK action as the last action, and counts it as a survived round', () => {
|
|
209
|
+
const actions = [
|
|
210
|
+
startAction(FloorIsLavaDifficulty.MEDIUM),
|
|
211
|
+
pickAction({ selectedTile: 20, droppedTiles: [0, 1, 2, 3] }),
|
|
212
|
+
pickAction({ selectedTile: 25, droppedTiles: [4, 5, 6, 7], gameStatus: FloorIsLavaGameStatus.COMPLETED }),
|
|
213
|
+
];
|
|
214
|
+
expect(FloorIsLavaRules.reconstructState(actions)).toEqual({
|
|
215
|
+
status: 'completed',
|
|
216
|
+
difficulty: FloorIsLavaDifficulty.MEDIUM,
|
|
217
|
+
currentLevel: 0,
|
|
218
|
+
roundsSurvivedInCurrentLevel: 2,
|
|
219
|
+
droppedTilesByLevel: [[0, 1, 2, 3, 4, 5, 6, 7], [], []],
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('validation errors', () => {
|
|
225
|
+
it('throws on an empty history', () => {
|
|
226
|
+
expect(() => FloorIsLavaRules.reconstructState([])).toThrow('First action must be START');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('throws when the first action is not START', () => {
|
|
230
|
+
expect(() => FloorIsLavaRules.reconstructState([pickAction()])).toThrow('First action must be START');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('throws when a START action appears after the first position', () => {
|
|
234
|
+
expect(() => FloorIsLavaRules.reconstructState([startAction(FloorIsLavaDifficulty.MEDIUM), startAction(FloorIsLavaDifficulty.MEDIUM)])).toThrow(
|
|
235
|
+
'START action can only appear as the first action',
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('throws when a CASHOUT action appears before the end of the history', () => {
|
|
240
|
+
expect(() => FloorIsLavaRules.reconstructState([startAction(FloorIsLavaDifficulty.MEDIUM), cashoutAction(), pickAction()])).toThrow(
|
|
241
|
+
'A CASHOUT action can only appear as the last action',
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('throws when a LOST PICK action appears before the end of the history', () => {
|
|
246
|
+
expect(() =>
|
|
247
|
+
FloorIsLavaRules.reconstructState([
|
|
248
|
+
startAction(FloorIsLavaDifficulty.MEDIUM),
|
|
249
|
+
pickAction({ gameStatus: FloorIsLavaGameStatus.LOST }),
|
|
250
|
+
pickAction(),
|
|
251
|
+
]),
|
|
252
|
+
).toThrow('A PICK action with gameStatus LOST can only appear as the last action');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('throws when a COMPLETED PICK action appears before the end of the history', () => {
|
|
256
|
+
expect(() =>
|
|
257
|
+
FloorIsLavaRules.reconstructState([
|
|
258
|
+
startAction(FloorIsLavaDifficulty.MEDIUM),
|
|
259
|
+
pickAction({ gameStatus: FloorIsLavaGameStatus.COMPLETED }),
|
|
260
|
+
pickAction(),
|
|
261
|
+
]),
|
|
262
|
+
).toThrow('A PICK action with gameStatus COMPLETED can only appear as the last action');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('throws when survived rounds exceed the level max', () => {
|
|
266
|
+
// TOXIC drops all 48 in one round, so its level max is 1 round
|
|
267
|
+
expect(() => FloorIsLavaRules.reconstructState([startAction(FloorIsLavaDifficulty.TOXIC), pickAction(), pickAction()])).toThrow(
|
|
268
|
+
'Reconstructed rounds (2) exceeds level max rounds (1)',
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('throws when more LEVEL_COMPLETE transitions occur than there are levels', () => {
|
|
273
|
+
const clear = () => pickAction({ gameStatus: FloorIsLavaGameStatus.LEVEL_COMPLETE });
|
|
274
|
+
expect(() => FloorIsLavaRules.reconstructState([startAction(FloorIsLavaDifficulty.MEDIUM), clear(), clear(), clear()])).toThrow(
|
|
275
|
+
'Reconstructed level (3) exceeds max level index (2)',
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('FloorIsLava — hardcoded multiplier table (published EASY/MEDIUM/HARD/TOXIC reference values)', () => {
|
|
282
|
+
// [round, tilesRemainingAfterRound, level1Mult, level2Mult, level3Mult], transcribed from the published tables.
|
|
283
|
+
// levelN is the multiplier at levelsCleared (N-1) for the tilesRemaining reached after `round` picks in the current level.
|
|
284
|
+
const TABLES: Record<FloorIsLavaDifficulty, Array<[number, number, number, number, number]>> = {
|
|
285
|
+
[FloorIsLavaDifficulty.EASY]: [
|
|
286
|
+
[1, 42, 1.1433, 8.0033, 56.0233],
|
|
287
|
+
[2, 35, 1.372, 9.604, 67.228],
|
|
288
|
+
[3, 28, 1.715, 12.005, 84.035],
|
|
289
|
+
[4, 21, 2.2867, 16.0067, 112.0467],
|
|
290
|
+
[5, 14, 3.43, 24.01, 168.07],
|
|
291
|
+
[6, 7, 6.86, 48.02, 336.14],
|
|
292
|
+
],
|
|
293
|
+
[FloorIsLavaDifficulty.MEDIUM]: [
|
|
294
|
+
[1, 38, 1.2637, 12.3841, 121.3642],
|
|
295
|
+
[2, 27, 1.7785, 17.4295, 170.8089],
|
|
296
|
+
[3, 16, 3.0012, 29.4123, 288.24],
|
|
297
|
+
[4, 5, 9.604, 94.1192, 922.3682],
|
|
298
|
+
],
|
|
299
|
+
[FloorIsLavaDifficulty.HARD]: [
|
|
300
|
+
[1, 26, 1.8469, 30.1664, 492.718],
|
|
301
|
+
[2, 3, 16.0067, 261.4422, 4270.223],
|
|
302
|
+
],
|
|
303
|
+
[FloorIsLavaDifficulty.TOXIC]: [[1, 1, 48.02, 2352.98, 115296.02]],
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const cases = ALL_DIFFICULTIES.flatMap(difficulty =>
|
|
307
|
+
TABLES[difficulty].flatMap(([round, tilesRemaining, level1, level2, level3]) =>
|
|
308
|
+
[level1, level2, level3].map((expected, levelsCleared) => [difficulty, round, tilesRemaining, levelsCleared, expected] as const),
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
it.each(cases)('%s round %i (tilesRemaining %i) at levelsCleared %i pays %f×', (difficulty, round, tilesRemaining, levelsCleared, expected) => {
|
|
313
|
+
expect(FloorIsLavaRules.tilesRemaining(round, DIFFICULTY_LEVEL_MAP[difficulty].dropsPerRound)).toBe(tilesRemaining);
|
|
314
|
+
expect(FloorIsLavaRules.multiplier(difficulty, levelsCleared, tilesRemaining, EDGE_BPS).toNumber()).toBeCloseTo(expected, 3);
|
|
315
|
+
});
|
|
316
|
+
});
|