shufflecom-calculations 4.2.0 → 4.2.1

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.
Files changed (62) hide show
  1. package/lib/games/coinflip/coinflip-classic-progressive.d.ts +1 -1
  2. package/lib/games/coinflip/coinflip-classic.d.ts +1 -1
  3. package/lib/games/coinflip/coinflip-rules.d.ts +1 -1
  4. package/lib/games/coinflip/coinflip-target.d.ts +1 -1
  5. package/lib/games/floor-is-lava/floor-is-lava-board.d.ts +8 -0
  6. package/lib/games/floor-is-lava/floor-is-lava-board.js +37 -0
  7. package/lib/games/floor-is-lava/floor-is-lava-board.js.map +1 -0
  8. package/lib/games/floor-is-lava/floor-is-lava-rules.d.ts +69 -0
  9. package/lib/games/floor-is-lava/floor-is-lava-rules.js +133 -0
  10. package/lib/games/floor-is-lava/floor-is-lava-rules.js.map +1 -0
  11. package/lib/games/floor-is-lava/floor-is-lava.d.ts +34 -0
  12. package/lib/games/floor-is-lava/floor-is-lava.js +106 -0
  13. package/lib/games/floor-is-lava/floor-is-lava.js.map +1 -0
  14. package/lib/games/rng/fixed-rng.js.map +1 -0
  15. package/lib/games/rng/random-number-generator.interface.js.map +1 -0
  16. package/lib/games/rng/shuffle-random-number-generator.d.ts +21 -0
  17. package/lib/games/rng/shuffle-random-number-generator.js +76 -0
  18. package/lib/games/rng/shuffle-random-number-generator.js.map +1 -0
  19. package/lib/games/rng/true-random-rng.js.map +1 -0
  20. package/lib/index.d.ts +7 -2
  21. package/lib/index.js +7 -2
  22. package/lib/index.js.map +1 -1
  23. package/lib/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +2 -2
  25. package/src/games/coinflip/coinflip-classic-progressive.spec.ts +14 -3
  26. package/src/games/coinflip/coinflip-classic-progressive.ts +1 -1
  27. package/src/games/coinflip/coinflip-classic.spec.ts +1 -1
  28. package/src/games/coinflip/coinflip-classic.ts +1 -1
  29. package/src/games/coinflip/coinflip-rules.spec.ts +1 -1
  30. package/src/games/coinflip/coinflip-rules.ts +1 -1
  31. package/src/games/coinflip/coinflip-target.spec.ts +1 -1
  32. package/src/games/coinflip/coinflip-target.ts +1 -1
  33. package/src/games/floor-is-lava/floor-is-lava-board.spec.ts +61 -0
  34. package/src/games/floor-is-lava/floor-is-lava-board.ts +41 -0
  35. package/src/games/floor-is-lava/floor-is-lava-rules.spec.ts +316 -0
  36. package/src/games/floor-is-lava/floor-is-lava-rules.ts +217 -0
  37. package/src/games/floor-is-lava/floor-is-lava.spec.ts +455 -0
  38. package/src/games/floor-is-lava/floor-is-lava.ts +138 -0
  39. package/src/games/rng/shuffle-random-number-generator.ts +100 -0
  40. package/src/index.ts +7 -2
  41. package/lib/games/fixed-rng.js.map +0 -1
  42. package/lib/games/floor-is-lava-rules.d.ts +0 -52
  43. package/lib/games/floor-is-lava-rules.js +0 -82
  44. package/lib/games/floor-is-lava-rules.js.map +0 -1
  45. package/lib/games/floor-is-lava.d.ts +0 -56
  46. package/lib/games/floor-is-lava.js +0 -134
  47. package/lib/games/floor-is-lava.js.map +0 -1
  48. package/lib/games/random-number-generator.interface.js.map +0 -1
  49. package/lib/games/true-random-rng.js.map +0 -1
  50. package/src/games/floor-is-lava-rules.spec.ts +0 -126
  51. package/src/games/floor-is-lava-rules.ts +0 -125
  52. package/src/games/floor-is-lava.spec.ts +0 -502
  53. package/src/games/floor-is-lava.ts +0 -184
  54. /package/lib/games/{fixed-rng.d.ts → rng/fixed-rng.d.ts} +0 -0
  55. /package/lib/games/{fixed-rng.js → rng/fixed-rng.js} +0 -0
  56. /package/lib/games/{random-number-generator.interface.d.ts → rng/random-number-generator.interface.d.ts} +0 -0
  57. /package/lib/games/{random-number-generator.interface.js → rng/random-number-generator.interface.js} +0 -0
  58. /package/lib/games/{true-random-rng.d.ts → rng/true-random-rng.d.ts} +0 -0
  59. /package/lib/games/{true-random-rng.js → rng/true-random-rng.js} +0 -0
  60. /package/src/games/{fixed-rng.ts → rng/fixed-rng.ts} +0 -0
  61. /package/src/games/{random-number-generator.interface.ts → rng/random-number-generator.interface.ts} +0 -0
  62. /package/src/games/{true-random-rng.ts → rng/true-random-rng.ts} +0 -0
@@ -0,0 +1,217 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import { calculateEdgeMultiplier } from '../../utils/edge';
3
+
4
+ export const BOARD_SIZE = 49;
5
+ export const LEVELS_PER_DIFFICULTY = 3;
6
+
7
+ export enum FloorIsLavaDifficulty {
8
+ EASY = 'EASY',
9
+ MEDIUM = 'MEDIUM',
10
+ HARD = 'HARD',
11
+ TOXIC = 'TOXIC',
12
+ }
13
+
14
+ export interface FloorIsLavaLevelConfig {
15
+ dropsPerRound: number;
16
+ roundsPerLevel: number;
17
+ }
18
+
19
+ // Same (dropsPerRound, rounds) reused for all 3 levels of a difficulty - only the random board
20
+ // differs per level, not the round/drop shape. dropsPerRound * rounds + survivingTiles = 49.
21
+ export const DIFFICULTY_LEVEL_MAP: Readonly<Record<FloorIsLavaDifficulty, FloorIsLavaLevelConfig>> = {
22
+ [FloorIsLavaDifficulty.EASY]: { dropsPerRound: 7, roundsPerLevel: 6 },
23
+ [FloorIsLavaDifficulty.MEDIUM]: { dropsPerRound: 11, roundsPerLevel: 4 },
24
+ [FloorIsLavaDifficulty.HARD]: { dropsPerRound: 23, roundsPerLevel: 2 },
25
+ [FloorIsLavaDifficulty.TOXIC]: { dropsPerRound: 48, roundsPerLevel: 1 },
26
+ };
27
+
28
+ // Fails loudly at import time if a config is malformed - a static invariant, not a per-request check.
29
+ const computeSurvivingTiles = (difficulty: FloorIsLavaDifficulty): number => {
30
+ const { dropsPerRound, roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
31
+ const survivingTiles = BOARD_SIZE - dropsPerRound * roundsPerLevel;
32
+ if (survivingTiles < 1) {
33
+ throw new Error(`FloorIsLava - ${difficulty} config leaves ${survivingTiles} surviving tiles; must be >= 1`);
34
+ }
35
+ return survivingTiles;
36
+ };
37
+
38
+ export const SURVIVING_TILES_MAP: Readonly<Record<FloorIsLavaDifficulty, number>> = Object.fromEntries(
39
+ Object.values(FloorIsLavaDifficulty).map(difficulty => [difficulty, computeSurvivingTiles(difficulty)]),
40
+ ) as Record<FloorIsLavaDifficulty, number>;
41
+
42
+ export enum FloorIsLavaActionPhase {
43
+ START = 'START',
44
+ PICK = 'PICK',
45
+ CASHOUT = 'CASHOUT',
46
+ }
47
+
48
+ export enum FloorIsLavaGameStatus {
49
+ IN_PROGRESS = 'IN_PROGRESS',
50
+ LEVEL_COMPLETE = 'LEVEL_COMPLETE',
51
+ COMPLETED = 'COMPLETED',
52
+ LOST = 'LOST',
53
+ }
54
+
55
+ export interface FloorIsLavaStartAction {
56
+ phase: FloorIsLavaActionPhase.START;
57
+ difficulty: FloorIsLavaDifficulty;
58
+ }
59
+
60
+ export type FloorIsLavaPickAction = {
61
+ phase: FloorIsLavaActionPhase.PICK;
62
+ level: number;
63
+ selectedTile: number;
64
+ droppedTiles: number[];
65
+ gameStatus: FloorIsLavaGameStatus;
66
+ multiplier: BigNumber;
67
+ };
68
+
69
+ export interface FloorIsLavaCashoutAction {
70
+ phase: FloorIsLavaActionPhase.CASHOUT;
71
+ level: number;
72
+ multiplier: BigNumber;
73
+ }
74
+
75
+ export type FloorIsLavaGameAction = FloorIsLavaStartAction | FloorIsLavaPickAction | FloorIsLavaCashoutAction;
76
+
77
+ interface FloorIsLavaBaseState {
78
+ difficulty: FloorIsLavaDifficulty;
79
+ currentLevel: number;
80
+ roundsSurvivedInCurrentLevel: number;
81
+ droppedTilesByLevel: number[][];
82
+ }
83
+
84
+ export interface FloorIsLavaInProgressState extends FloorIsLavaBaseState {
85
+ status: 'in-progress';
86
+ }
87
+
88
+ interface FloorIsLavaCompletedState extends FloorIsLavaBaseState {
89
+ status: 'completed';
90
+ }
91
+
92
+ // reconstructState never throws on a legitimately finished game (LOST/COMPLETED/CASHOUT) - only on
93
+ // structurally corrupt histories. FloorIsLava.fromActions() is the one place that then insists on
94
+ // 'in-progress', since only an active game can be resumed; the frontend can read either variant.
95
+ type FloorIsLavaReconstructedState = FloorIsLavaInProgressState | FloorIsLavaCompletedState;
96
+
97
+ export interface FloorIsLavaMultipliers {
98
+ currentMultiplier: BigNumber;
99
+ nextMultiplier: BigNumber;
100
+ }
101
+
102
+ export class FloorIsLavaRules {
103
+ public static tilesRemaining(roundsSurvivedInCurrentLevel: number, dropsPerRound: number): number {
104
+ return BOARD_SIZE - roundsSurvivedInCurrentLevel * dropsPerRound;
105
+ }
106
+
107
+ public static isValidTile(tile: number): boolean {
108
+ return Number.isInteger(tile) && tile >= 0 && tile <= BOARD_SIZE - 1;
109
+ }
110
+
111
+ // multiplier = edgeMultiplier * 49^(levelsCleared+1) / (tilesRemainingInLevel * survivingTiles^levelsCleared)
112
+ public static multiplier(difficulty: FloorIsLavaDifficulty, levelsCleared: number, tilesRemainingInLevel: number, edge: number): BigNumber {
113
+ const survivingTiles = SURVIVING_TILES_MAP[difficulty];
114
+ return calculateEdgeMultiplier(edge)
115
+ .multipliedBy(BigNumber(BOARD_SIZE).pow(levelsCleared + 1))
116
+ .dividedBy(BigNumber(tilesRemainingInLevel).multipliedBy(BigNumber(survivingTiles).pow(levelsCleared)));
117
+ }
118
+
119
+ // currentLevel never needs to advance for "next" - clearing the level and starting fresh one
120
+ // level deeper is the same multiplier by construction (see the continuity property above), so
121
+ // roundsSurvivedInCurrentLevel + 1 alone is enough even on the level's final round.
122
+ public static multipliers(state: FloorIsLavaInProgressState, edge: number): FloorIsLavaMultipliers {
123
+ const { difficulty, currentLevel, roundsSurvivedInCurrentLevel } = state;
124
+ const { dropsPerRound } = DIFFICULTY_LEVEL_MAP[difficulty];
125
+
126
+ return {
127
+ currentMultiplier: FloorIsLavaRules.multiplier(
128
+ difficulty,
129
+ currentLevel,
130
+ FloorIsLavaRules.tilesRemaining(roundsSurvivedInCurrentLevel, dropsPerRound),
131
+ edge,
132
+ ),
133
+ nextMultiplier: FloorIsLavaRules.multiplier(
134
+ difficulty,
135
+ currentLevel,
136
+ FloorIsLavaRules.tilesRemaining(roundsSurvivedInCurrentLevel + 1, dropsPerRound),
137
+ edge,
138
+ ),
139
+ };
140
+ }
141
+
142
+ public static reconstructState(gameActions: FloorIsLavaGameAction[]): FloorIsLavaReconstructedState {
143
+ const first = gameActions[0];
144
+ if (!first || first.phase !== FloorIsLavaActionPhase.START) {
145
+ throw new Error('First action must be START');
146
+ }
147
+
148
+ const { difficulty } = first;
149
+ const { roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
150
+ let currentLevel = 0;
151
+ let roundsSurvivedInCurrentLevel = 0;
152
+ const droppedTilesByLevel: number[][] = Array.from({ length: LEVELS_PER_DIFFICULTY }, () => []);
153
+
154
+ for (let i = 1; i < gameActions.length; i++) {
155
+ const action = gameActions[i];
156
+ const isLastAction = i === gameActions.length - 1;
157
+
158
+ switch (action.phase) {
159
+ case FloorIsLavaActionPhase.START: {
160
+ throw new Error('START action can only appear as the first action');
161
+ }
162
+
163
+ case FloorIsLavaActionPhase.CASHOUT: {
164
+ if (!isLastAction) {
165
+ throw new Error('A CASHOUT action can only appear as the last action');
166
+ }
167
+
168
+ return { status: 'completed', difficulty, currentLevel, roundsSurvivedInCurrentLevel, droppedTilesByLevel };
169
+ }
170
+
171
+ case FloorIsLavaActionPhase.PICK: {
172
+ switch (action.gameStatus) {
173
+ case FloorIsLavaGameStatus.IN_PROGRESS: {
174
+ droppedTilesByLevel[currentLevel].push(...action.droppedTiles);
175
+ roundsSurvivedInCurrentLevel++;
176
+ if (roundsSurvivedInCurrentLevel > roundsPerLevel) {
177
+ throw new Error(`Reconstructed rounds (${roundsSurvivedInCurrentLevel}) exceeds level max rounds (${roundsPerLevel})`);
178
+ }
179
+ break;
180
+ }
181
+
182
+ case FloorIsLavaGameStatus.LEVEL_COMPLETE: {
183
+ droppedTilesByLevel[currentLevel].push(...action.droppedTiles);
184
+ currentLevel++;
185
+ roundsSurvivedInCurrentLevel = 0;
186
+ if (currentLevel > LEVELS_PER_DIFFICULTY - 1) {
187
+ throw new Error(`Reconstructed level (${currentLevel}) exceeds max level index (${LEVELS_PER_DIFFICULTY - 1})`);
188
+ }
189
+ break;
190
+ }
191
+
192
+ case FloorIsLavaGameStatus.COMPLETED:
193
+ case FloorIsLavaGameStatus.LOST: {
194
+ if (!isLastAction) {
195
+ throw new Error(`A PICK action with gameStatus ${action.gameStatus} can only appear as the last action`);
196
+ }
197
+ droppedTilesByLevel[currentLevel].push(...action.droppedTiles);
198
+ if (action.gameStatus === FloorIsLavaGameStatus.COMPLETED) {
199
+ roundsSurvivedInCurrentLevel++;
200
+ }
201
+ return { status: 'completed', difficulty, currentLevel, roundsSurvivedInCurrentLevel, droppedTilesByLevel };
202
+ }
203
+
204
+ default:
205
+ action.gameStatus satisfies never;
206
+ }
207
+ break;
208
+ }
209
+
210
+ default:
211
+ action satisfies never;
212
+ }
213
+ }
214
+
215
+ return { status: 'in-progress', difficulty, currentLevel, roundsSurvivedInCurrentLevel, droppedTilesByLevel };
216
+ }
217
+ }
@@ -0,0 +1,455 @@
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 { FloorIsLavaBoard } from './floor-is-lava-board';
6
+ import {
7
+ BOARD_SIZE,
8
+ DIFFICULTY_LEVEL_MAP,
9
+ FloorIsLavaActionPhase,
10
+ FloorIsLavaDifficulty,
11
+ FloorIsLavaGameAction,
12
+ FloorIsLavaGameStatus,
13
+ FloorIsLavaPickAction,
14
+ FloorIsLavaRules,
15
+ FloorIsLavaStartAction,
16
+ LEVELS_PER_DIFFICULTY,
17
+ SURVIVING_TILES_MAP,
18
+ } from './floor-is-lava-rules';
19
+
20
+ const ALL_DIFFICULTIES = [FloorIsLavaDifficulty.EASY, FloorIsLavaDifficulty.MEDIUM, FloorIsLavaDifficulty.HARD, FloorIsLavaDifficulty.TOXIC];
21
+
22
+ const EDGE_BPS = 200;
23
+
24
+ const ASCENDING_BOARD = Array.from({ length: BOARD_SIZE }, (_, i) => i);
25
+
26
+ const startAction = (difficulty: FloorIsLavaDifficulty): FloorIsLavaStartAction => ({ phase: FloorIsLavaActionPhase.START, difficulty });
27
+
28
+ const pickAction = (overrides: Partial<FloorIsLavaPickAction> = {}): FloorIsLavaPickAction => ({
29
+ phase: FloorIsLavaActionPhase.PICK,
30
+ level: 0,
31
+ selectedTile: 0,
32
+ droppedTiles: [],
33
+ gameStatus: FloorIsLavaGameStatus.IN_PROGRESS,
34
+ multiplier: new BigNumber(0),
35
+ ...overrides,
36
+ });
37
+
38
+ // Builds a game that has cleared `levelsCleared` levels and survived `roundsSurvivedInCurrentLevel` rounds
39
+ // of the current one.
40
+ const gameAtState = (difficulty: FloorIsLavaDifficulty, levelsCleared: number, roundsSurvivedInCurrentLevel: number): FloorIsLava => {
41
+ const actions: FloorIsLavaGameAction[] = [startAction(difficulty)];
42
+ for (let level = 0; level < levelsCleared; level++) {
43
+ actions.push(pickAction({ level, gameStatus: FloorIsLavaGameStatus.LEVEL_COMPLETE }));
44
+ }
45
+ for (let i = 0; i < roundsSurvivedInCurrentLevel; i++) {
46
+ actions.push(pickAction({ level: levelsCleared }));
47
+ }
48
+ return FloorIsLava.fromActions(actions);
49
+ };
50
+
51
+ const expectOk = <T extends { ok: boolean }>(result: T): Extract<T, { ok: true }> => {
52
+ if (!result.ok) {
53
+ throw new Error(`Expected an ok result but got: ${JSON.stringify(result)}`);
54
+ }
55
+ return result as Extract<T, { ok: true }>;
56
+ };
57
+
58
+ const expectedMultiplier = (difficulty: FloorIsLavaDifficulty, levelsCleared: number, tilesRemainingInLevel: number, edgeBps: number = EDGE_BPS) =>
59
+ calculateEdgeMultiplier(edgeBps)
60
+ .multipliedBy(new BigNumber(BOARD_SIZE).pow(levelsCleared + 1))
61
+ .dividedBy(new BigNumber(tilesRemainingInLevel).multipliedBy(new BigNumber(SURVIVING_TILES_MAP[difficulty]).pow(levelsCleared)));
62
+
63
+ // A full 49-tile board whose leading entries are `prefix`, in order.
64
+ const boardWithPrefix = (prefix: number[]): number[] => [...prefix, ...ASCENDING_BOARD.filter(tile => !prefix.includes(tile))];
65
+
66
+ // Builds a FloorIsLavaBoard from 3 explicit per-level boards (each a permutation of 0..48), so a
67
+ // given level's drops can be controlled precisely in tests.
68
+ const boardFromLevelBoards = (level0: number[], level1: number[], level2: number[]): FloorIsLavaBoard =>
69
+ FloorIsLavaBoard.draw(new FixedSequenceRng([...level0, ...level1.map(t => t + BOARD_SIZE), ...level2.map(t => t + BOARD_SIZE * 2)]));
70
+
71
+ // Convenience for tests that only care about level 0's drops - levels 1/2 get an arbitrary filler board.
72
+ const boardForLevel0 = (level0Board: number[]): FloorIsLavaBoard => boardFromLevelBoards(level0Board, ASCENDING_BOARD, ASCENDING_BOARD);
73
+
74
+ describe('FloorIsLava.createAction', () => {
75
+ it('returns a START action carrying the difficulty', () => {
76
+ expect(FloorIsLava.createAction(FloorIsLavaDifficulty.MEDIUM)).toEqual({ phase: 'START', difficulty: FloorIsLavaDifficulty.MEDIUM });
77
+ });
78
+ });
79
+
80
+ describe('FloorIsLava.fromActions', () => {
81
+ describe('valid histories', () => {
82
+ it('a start-only history cannot be cashed out', () => {
83
+ expect(gameAtState(FloorIsLavaDifficulty.MEDIUM, 0, 0).canCashout()).toBe(false);
84
+ });
85
+
86
+ it('accumulates surviving rounds within a level into cashout eligibility', () => {
87
+ expect(gameAtState(FloorIsLavaDifficulty.MEDIUM, 0, 3).canCashout()).toBe(true);
88
+ });
89
+
90
+ it('having cleared a level makes cashout eligible even with zero rounds in the new level', () => {
91
+ expect(gameAtState(FloorIsLavaDifficulty.MEDIUM, 1, 0).canCashout()).toBe(true);
92
+ });
93
+
94
+ it('accepts a history at exactly the level max round count', () => {
95
+ const { roundsPerLevel } = DIFFICULTY_LEVEL_MAP[FloorIsLavaDifficulty.EASY];
96
+ expect(gameAtState(FloorIsLavaDifficulty.EASY, 0, roundsPerLevel).canCashout()).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe('validation errors', () => {
101
+ it('throws on an empty history', () => {
102
+ expect(() => FloorIsLava.fromActions([])).toThrow('First action must be START');
103
+ });
104
+
105
+ it('throws when the first action is not START', () => {
106
+ expect(() => FloorIsLava.fromActions([pickAction()])).toThrow('First action must be START');
107
+ });
108
+
109
+ it('throws when a START action appears after the first position', () => {
110
+ expect(() =>
111
+ FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.MEDIUM), pickAction(), startAction(FloorIsLavaDifficulty.MEDIUM)]),
112
+ ).toThrow('START action can only appear as the first action');
113
+ });
114
+
115
+ it('throws when the history contains a CASHOUT action (game already ended)', () => {
116
+ expect(() =>
117
+ FloorIsLava.fromActions([
118
+ startAction(FloorIsLavaDifficulty.MEDIUM),
119
+ pickAction(),
120
+ { phase: FloorIsLavaActionPhase.CASHOUT, level: 0, multiplier: new BigNumber(1) },
121
+ ]),
122
+ ).toThrow('FloorIsLava - cannot resume a game that has already ended (status: completed)');
123
+ });
124
+
125
+ it('throws when the history contains a LOST PICK action (game already ended)', () => {
126
+ expect(() =>
127
+ FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.MEDIUM), pickAction({ gameStatus: FloorIsLavaGameStatus.LOST })]),
128
+ ).toThrow('FloorIsLava - cannot resume a game that has already ended (status: completed)');
129
+ });
130
+
131
+ it('throws when the history contains a COMPLETED PICK action (game already ended)', () => {
132
+ expect(() =>
133
+ FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.MEDIUM), pickAction({ gameStatus: FloorIsLavaGameStatus.COMPLETED })]),
134
+ ).toThrow('FloorIsLava - cannot resume a game that has already ended (status: completed)');
135
+ });
136
+
137
+ it('throws when survived rounds exceed the level max', () => {
138
+ // TOXIC drops all 48 in one round, so its level max is 1 round
139
+ expect(() => FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.TOXIC), pickAction(), pickAction()])).toThrow(
140
+ 'Reconstructed rounds (2) exceeds level max rounds (1)',
141
+ );
142
+ });
143
+
144
+ it('throws when more levels are cleared than exist', () => {
145
+ const clear = (level: number) => pickAction({ level, gameStatus: FloorIsLavaGameStatus.LEVEL_COMPLETE });
146
+ expect(() => FloorIsLava.fromActions([startAction(FloorIsLavaDifficulty.MEDIUM), clear(0), clear(1), clear(2)])).toThrow(
147
+ 'Reconstructed level (3) exceeds max level index (2)',
148
+ );
149
+ });
150
+ });
151
+ });
152
+
153
+ describe('FloorIsLava.getDetailedState', () => {
154
+ it('returns the reconstructed in-progress state', () => {
155
+ expect(gameAtState(FloorIsLavaDifficulty.MEDIUM, 1, 2).getDetailedState()).toMatchObject({
156
+ status: 'in-progress',
157
+ difficulty: FloorIsLavaDifficulty.MEDIUM,
158
+ currentLevel: 1,
159
+ roundsSurvivedInCurrentLevel: 2,
160
+ });
161
+ });
162
+ });
163
+
164
+ describe('FloorIsLava.getMultipliers', () => {
165
+ it('matches FloorIsLavaRules.multipliers computed from the same reconstructed state', () => {
166
+ const game = gameAtState(FloorIsLavaDifficulty.MEDIUM, 1, 2);
167
+
168
+ const result = game.getMultipliers(EDGE_BPS);
169
+ const expected = FloorIsLavaRules.multipliers(game.getDetailedState(), EDGE_BPS);
170
+
171
+ expect(result.currentMultiplier.isEqualTo(expected.currentMultiplier)).toBe(true);
172
+ expect(result.nextMultiplier.isEqualTo(expected.nextMultiplier)).toBe(true);
173
+ });
174
+
175
+ it.each([100, 500, 1000])('applies the given edge (%i bps), not a hardcoded value', edgeBps => {
176
+ const { dropsPerRound } = DIFFICULTY_LEVEL_MAP[FloorIsLavaDifficulty.MEDIUM];
177
+ const result = gameAtState(FloorIsLavaDifficulty.MEDIUM, 0, 2).getMultipliers(edgeBps);
178
+
179
+ expect(
180
+ result.currentMultiplier.isEqualTo(
181
+ expectedMultiplier(FloorIsLavaDifficulty.MEDIUM, 0, FloorIsLavaRules.tilesRemaining(2, dropsPerRound), edgeBps),
182
+ ),
183
+ ).toBe(true);
184
+ expect(
185
+ result.nextMultiplier.isEqualTo(
186
+ expectedMultiplier(FloorIsLavaDifficulty.MEDIUM, 0, FloorIsLavaRules.tilesRemaining(3, dropsPerRound), edgeBps),
187
+ ),
188
+ ).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe('FloorIsLava.next', () => {
193
+ const anyValidBoard = boardFromLevelBoards(ASCENDING_BOARD, ASCENDING_BOARD, ASCENDING_BOARD);
194
+
195
+ it.each([-1, 49, 1.5, NaN])('rejects an out-of-range selectedTile (%s)', tile => {
196
+ expect(gameAtState(FloorIsLavaDifficulty.MEDIUM, 0, 0).next(tile, anyValidBoard, EDGE_BPS)).toEqual({ ok: false, error: 'invalid-tile' });
197
+ });
198
+
199
+ it('a surviving first pick keeps the game open and pays the 1-round multiplier', () => {
200
+ const board = boardForLevel0(boardWithPrefix([10, 11, 12, 13, 14, 15, 16])); // EASY: dropsPerRound 7
201
+ const result = expectOk(gameAtState(FloorIsLavaDifficulty.EASY, 0, 0).next(5, board, EDGE_BPS));
202
+
203
+ expect(result).toMatchObject({
204
+ tilesDroppedInCurrentLevel: 7,
205
+ gameAction: {
206
+ phase: 'PICK',
207
+ level: 0,
208
+ gameStatus: 'IN_PROGRESS',
209
+ selectedTile: 5,
210
+ droppedTiles: [10, 11, 12, 13, 14, 15, 16],
211
+ },
212
+ });
213
+ expect(result.multiplier.isEqualTo(expectedMultiplier(FloorIsLavaDifficulty.EASY, 0, 42))).toBe(true);
214
+ });
215
+
216
+ it('a pick that lands on a dropped tile zeroes the multiplier and reports LOST', () => {
217
+ const board = boardForLevel0(boardWithPrefix([10, 11, 12, 13, 14, 15, 16]));
218
+ const result = expectOk(gameAtState(FloorIsLavaDifficulty.EASY, 0, 0).next(12, board, EDGE_BPS));
219
+
220
+ expect(result).toMatchObject({
221
+ multiplier: new BigNumber(0),
222
+ tilesDroppedInCurrentLevel: 7,
223
+ gameAction: {
224
+ phase: 'PICK',
225
+ level: 0,
226
+ gameStatus: 'LOST',
227
+ selectedTile: 12,
228
+ droppedTiles: [10, 11, 12, 13, 14, 15, 16],
229
+ multiplier: new BigNumber(0),
230
+ },
231
+ });
232
+ });
233
+
234
+ it('drops this round from the level board and excludes prior-round drops', () => {
235
+ const priorRounds = Array.from({ length: 14 }, (_, i) => i); // 2 rounds of 7 already survived
236
+ const thisRound = [20, 21, 22, 23, 24, 25, 26];
237
+ const board = boardForLevel0(boardWithPrefix([...priorRounds, ...thisRound]));
238
+
239
+ const result = expectOk(gameAtState(FloorIsLavaDifficulty.EASY, 0, 2).next(30, board, EDGE_BPS));
240
+
241
+ expect(result).toMatchObject({ tilesDroppedInCurrentLevel: 21, gameAction: { droppedTiles: thisRound } });
242
+ expect(result.multiplier.isEqualTo(expectedMultiplier(FloorIsLavaDifficulty.EASY, 0, 28))).toBe(true);
243
+ });
244
+
245
+ it('a pick on a tile dropped in an earlier round of this level is rejected', () => {
246
+ const priorRounds = Array.from({ length: 14 }, (_, i) => i); // 2 rounds of 7 already survived
247
+ const thisRound = [20, 21, 22, 23, 24, 25, 26];
248
+ const board = boardForLevel0(boardWithPrefix([...priorRounds, ...thisRound]));
249
+
250
+ // Built manually (not via gameAtState) so droppedTilesByLevel actually contains the prior drops -
251
+ // next()'s already-dropped check reads from reconstructed history, not from the board.
252
+ const game = FloorIsLava.fromActions([
253
+ startAction(FloorIsLavaDifficulty.EASY),
254
+ pickAction({ selectedTile: 30, droppedTiles: priorRounds.slice(0, 7) }),
255
+ pickAction({ selectedTile: 31, droppedTiles: priorRounds.slice(7, 14) }),
256
+ ]);
257
+
258
+ expect(game.next(2, board, EDGE_BPS)).toEqual({ ok: false, error: 'tile-already-dropped' });
259
+ });
260
+
261
+ it('returns no-tiles-remaining once the level rounds are exhausted', () => {
262
+ const { roundsPerLevel } = DIFFICULTY_LEVEL_MAP[FloorIsLavaDifficulty.EASY];
263
+ expect(gameAtState(FloorIsLavaDifficulty.EASY, 0, roundsPerLevel).next(0, anyValidBoard, EDGE_BPS)).toEqual({
264
+ ok: false,
265
+ error: 'no-tiles-remaining',
266
+ });
267
+ });
268
+
269
+ it('clearing the final round of a non-final level emits LEVEL_COMPLETE', () => {
270
+ const difficulty = FloorIsLavaDifficulty.EASY;
271
+ const { dropsPerRound, roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
272
+ const priorRoundsDrops = Array.from({ length: (roundsPerLevel - 1) * dropsPerRound }, (_, i) => i); // rounds 1-5
273
+ const lastRoundDrops = [40, 41, 42, 43, 44, 45, 46];
274
+ const board = boardForLevel0(boardWithPrefix([...priorRoundsDrops, ...lastRoundDrops]));
275
+
276
+ const result = expectOk(gameAtState(difficulty, 0, roundsPerLevel - 1).next(35, board, EDGE_BPS));
277
+
278
+ expect(result).toMatchObject({
279
+ tilesDroppedInCurrentLevel: roundsPerLevel * dropsPerRound,
280
+ gameAction: { phase: 'PICK', level: 0, gameStatus: 'LEVEL_COMPLETE', selectedTile: 35, droppedTiles: lastRoundDrops },
281
+ });
282
+ expect(result.multiplier.isEqualTo(expectedMultiplier(difficulty, 0, SURVIVING_TILES_MAP[difficulty]))).toBe(true);
283
+ });
284
+
285
+ it('clearing the final round of the final level emits COMPLETED', () => {
286
+ const difficulty = FloorIsLavaDifficulty.EASY;
287
+ const { dropsPerRound, roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
288
+ const priorRoundsDrops = Array.from({ length: (roundsPerLevel - 1) * dropsPerRound }, (_, i) => i);
289
+ const lastRoundDrops = [40, 41, 42, 43, 44, 45, 46];
290
+ const board = boardFromLevelBoards(ASCENDING_BOARD, ASCENDING_BOARD, boardWithPrefix([...priorRoundsDrops, ...lastRoundDrops]));
291
+
292
+ const result = expectOk(gameAtState(difficulty, LEVELS_PER_DIFFICULTY - 1, roundsPerLevel - 1).next(35, board, EDGE_BPS));
293
+
294
+ expect(result).toMatchObject({
295
+ tilesDroppedInCurrentLevel: roundsPerLevel * dropsPerRound,
296
+ gameAction: {
297
+ phase: 'PICK',
298
+ level: LEVELS_PER_DIFFICULTY - 1,
299
+ gameStatus: 'COMPLETED',
300
+ selectedTile: 35,
301
+ droppedTiles: lastRoundDrops,
302
+ },
303
+ });
304
+ expect(result.multiplier.isEqualTo(expectedMultiplier(difficulty, LEVELS_PER_DIFFICULTY - 1, SURVIVING_TILES_MAP[difficulty]))).toBe(true);
305
+ });
306
+
307
+ it('draws an independent board per level - no leakage across a level transition', () => {
308
+ const level0Board = boardWithPrefix([10, 11, 12, 13, 14, 15, 16]);
309
+ const level1Board = boardWithPrefix([30, 31, 32, 33, 34, 35, 36]); // deliberately different pattern
310
+ const board = boardFromLevelBoards(level0Board, level1Board, ASCENDING_BOARD);
311
+
312
+ const level0Result = expectOk(gameAtState(FloorIsLavaDifficulty.EASY, 0, 0).next(20, board, EDGE_BPS));
313
+ expect(level0Result.gameAction.droppedTiles).toEqual([10, 11, 12, 13, 14, 15, 16]);
314
+
315
+ const level1Result = expectOk(gameAtState(FloorIsLavaDifficulty.EASY, 1, 0).next(20, board, EDGE_BPS));
316
+ expect(level1Result.gameAction.droppedTiles).toEqual([30, 31, 32, 33, 34, 35, 36]);
317
+ expect(level1Result.gameAction.droppedTiles).not.toEqual(level0Result.gameAction.droppedTiles);
318
+ });
319
+ });
320
+
321
+ describe('FloorIsLava — chained real sessions (fromActions replay of actual next()/cashout() output)', () => {
322
+ it('threads real next() output through fromActions across rounds and a level transition', () => {
323
+ const difficulty = FloorIsLavaDifficulty.HARD; // dropsPerRound 23, roundsPerLevel 2, survivingTiles 3
324
+ const level0Board = ASCENDING_BOARD; // rounds: [0-22], [23-45], remaining [46,47,48]
325
+ const level1Board = [...ASCENDING_BOARD].reverse(); // round 0: [48..26]
326
+ const board = boardFromLevelBoards(level0Board, level1Board, ASCENDING_BOARD);
327
+
328
+ const game0 = FloorIsLava.fromActions([startAction(difficulty)]);
329
+ const round1 = expectOk(game0.next(46, board, EDGE_BPS));
330
+ expect(round1).toMatchObject({ gameAction: { level: 0, gameStatus: 'IN_PROGRESS', droppedTiles: level0Board.slice(0, 23) } });
331
+
332
+ const game1 = FloorIsLava.fromActions([startAction(difficulty), round1.gameAction]);
333
+ const round2 = expectOk(game1.next(47, board, EDGE_BPS));
334
+ expect(round2).toMatchObject({ gameAction: { level: 0, gameStatus: 'LEVEL_COMPLETE', droppedTiles: level0Board.slice(23, 46) } });
335
+
336
+ const game2 = FloorIsLava.fromActions([startAction(difficulty), round1.gameAction, round2.gameAction]);
337
+ expect(game2.canCashout()).toBe(true);
338
+
339
+ const level1Round1 = expectOk(game2.next(0, board, EDGE_BPS));
340
+ expect(level1Round1).toMatchObject({ gameAction: { level: 1, gameStatus: 'IN_PROGRESS', droppedTiles: level1Board.slice(0, 23) } });
341
+ expect(level1Round1.gameAction.droppedTiles).not.toEqual(round1.gameAction.droppedTiles);
342
+
343
+ const game3 = FloorIsLava.fromActions([startAction(difficulty), round1.gameAction, round2.gameAction, level1Round1.gameAction]);
344
+ const cashoutResult = expectOk(game3.cashout(EDGE_BPS));
345
+ expect(cashoutResult.gameAction).toEqual({ phase: FloorIsLavaActionPhase.CASHOUT, level: 1, multiplier: cashoutResult.multiplier });
346
+ expect(cashoutResult.multiplier.isEqualTo(expectedMultiplier(difficulty, 1, FloorIsLavaRules.tilesRemaining(1, 23)))).toBe(true);
347
+ });
348
+
349
+ it('threads a real survived pick into a second round that busts', () => {
350
+ const difficulty = FloorIsLavaDifficulty.EASY; // dropsPerRound 7
351
+ const board = boardForLevel0(boardWithPrefix([0, 1, 2, 3, 4, 5, 6, 10, 11, 12, 13, 14, 15, 16]));
352
+
353
+ const game0 = FloorIsLava.fromActions([startAction(difficulty)]);
354
+ const round1 = expectOk(game0.next(20, board, EDGE_BPS));
355
+ expect(round1.gameAction.gameStatus).toBe('IN_PROGRESS');
356
+
357
+ const game1 = FloorIsLava.fromActions([startAction(difficulty), round1.gameAction]);
358
+ const round2 = expectOk(game1.next(12, board, EDGE_BPS));
359
+
360
+ expect(round2).toMatchObject({
361
+ multiplier: new BigNumber(0),
362
+ gameAction: { level: 0, gameStatus: 'LOST', selectedTile: 12, droppedTiles: [10, 11, 12, 13, 14, 15, 16] },
363
+ });
364
+ });
365
+ });
366
+
367
+ describe('FloorIsLava.cashout', () => {
368
+ it.each([
369
+ [FloorIsLavaDifficulty.MEDIUM, 0, 1],
370
+ [FloorIsLavaDifficulty.MEDIUM, 0, 3],
371
+ [FloorIsLavaDifficulty.HARD, 0, 2],
372
+ [FloorIsLavaDifficulty.MEDIUM, 1, 0],
373
+ [FloorIsLavaDifficulty.MEDIUM, 1, 4],
374
+ [FloorIsLavaDifficulty.EASY, 2, 3],
375
+ ])('cashing out %s after clearing %i level(s) and %i round(s) pays the telescoped multiplier', (difficulty, levelsCleared, rounds) => {
376
+ const { dropsPerRound } = DIFFICULTY_LEVEL_MAP[difficulty];
377
+ const result = expectOk(gameAtState(difficulty, levelsCleared, rounds).cashout(EDGE_BPS));
378
+
379
+ const tilesRemainingInLevel = FloorIsLavaRules.tilesRemaining(rounds, dropsPerRound);
380
+ expect(result.multiplier.isEqualTo(expectedMultiplier(difficulty, levelsCleared, tilesRemainingInLevel))).toBe(true);
381
+ expect(result.gameAction).toEqual({ phase: FloorIsLavaActionPhase.CASHOUT, level: levelsCleared, multiplier: result.multiplier });
382
+ expect(result.tilesDroppedInCurrentLevel).toBe(rounds * dropsPerRound);
383
+ });
384
+
385
+ it('returns no-tiles-selected with zero rounds survived at level 0', () => {
386
+ expect(gameAtState(FloorIsLavaDifficulty.MEDIUM, 0, 0).cashout(EDGE_BPS)).toEqual({ ok: false, error: 'no-tiles-selected' });
387
+ });
388
+ });
389
+
390
+ describe('FloorIsLava — edge parameter', () => {
391
+ it.each([100, 500, 1000])('cashout applies the given edge (%i bps), not a hardcoded value', edgeBps => {
392
+ const { dropsPerRound } = DIFFICULTY_LEVEL_MAP[FloorIsLavaDifficulty.MEDIUM];
393
+ const result = expectOk(gameAtState(FloorIsLavaDifficulty.MEDIUM, 0, 3).cashout(edgeBps));
394
+ expect(
395
+ result.multiplier.isEqualTo(expectedMultiplier(FloorIsLavaDifficulty.MEDIUM, 0, FloorIsLavaRules.tilesRemaining(3, dropsPerRound), edgeBps)),
396
+ ).toBe(true);
397
+ });
398
+
399
+ it.each([100, 500, 1000])('next applies the given edge (%i bps) to a surviving pick', edgeBps => {
400
+ const board = boardForLevel0(boardWithPrefix([10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20])); // MEDIUM: dropsPerRound 11
401
+ const result = expectOk(gameAtState(FloorIsLavaDifficulty.MEDIUM, 0, 0).next(5, board, edgeBps));
402
+ expect(result.multiplier.isEqualTo(expectedMultiplier(FloorIsLavaDifficulty.MEDIUM, 0, BOARD_SIZE - 11, edgeBps))).toBe(true);
403
+ });
404
+
405
+ it('rejects an edge below the platform minimum (100 bps) on cashout', () => {
406
+ expect(() => gameAtState(FloorIsLavaDifficulty.MEDIUM, 0, 3).cashout(99)).toThrow('Exceeded Minimum edge allowed on platform');
407
+ });
408
+
409
+ it('rejects an edge below the platform minimum (100 bps) on a surviving next()', () => {
410
+ const board = boardForLevel0(boardWithPrefix([10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]));
411
+ expect(() => gameAtState(FloorIsLavaDifficulty.MEDIUM, 0, 0).next(5, board, 99)).toThrow('Exceeded Minimum edge allowed on platform');
412
+ });
413
+ });
414
+
415
+ describe('FloorIsLava — exhaustive multiplier & RTP across every level and round', () => {
416
+ const houseEdge = calculateEdgeMultiplier(EDGE_BPS); // 0.98 == 98% RTP
417
+
418
+ // Win chance derived independently, round by round within a level, as the product of each round's
419
+ // survival probability. Makes no assumption about telescoping.
420
+ const roundsWinChance = (rounds: number, dropsPerRound: number): BigNumber => {
421
+ let chance = new BigNumber(1);
422
+ for (let r = 1; r <= rounds; r++) {
423
+ const standingBefore = BOARD_SIZE - (r - 1) * dropsPerRound;
424
+ chance = chance.multipliedBy(standingBefore - dropsPerRound).dividedBy(standingBefore);
425
+ }
426
+ return chance;
427
+ };
428
+
429
+ it.each(ALL_DIFFICULTIES)('%s: every reachable (levelsCleared, round) endpoint pays fair odds and returns 98%% RTP', difficulty => {
430
+ const { dropsPerRound, roundsPerLevel } = DIFFICULTY_LEVEL_MAP[difficulty];
431
+ const levelClearChance = roundsWinChance(roundsPerLevel, dropsPerRound); // equals survivingTiles/49, derived independently
432
+
433
+ for (let levelsCleared = 0; levelsCleared < LEVELS_PER_DIFFICULTY; levelsCleared++) {
434
+ const priorLevelsChance = levelClearChance.pow(levelsCleared);
435
+
436
+ for (let k = 0; k <= roundsPerLevel; k++) {
437
+ const tilesRemainingInLevel = FloorIsLavaRules.tilesRemaining(k, dropsPerRound);
438
+ const chance = priorLevelsChance.multipliedBy(roundsWinChance(k, dropsPerRound));
439
+ const multiplier = FloorIsLavaRules.multiplier(difficulty, levelsCleared, tilesRemainingInLevel, EDGE_BPS);
440
+
441
+ // fair payout inverts the win chance and applies the edge once: houseEdge / chance
442
+ expect(multiplier.toNumber()).toBeCloseTo(houseEdge.dividedBy(chance).toNumber(), 9);
443
+
444
+ // the game surface (fromActions + cashout) returns the same endpoint multiplier, except at
445
+ // levelsCleared=0,k=0 which has nothing selected yet and cannot be cashed out
446
+ if (levelsCleared > 0 || k > 0) {
447
+ expect(expectOk(gameAtState(difficulty, levelsCleared, k).cashout(EDGE_BPS)).multiplier.isEqualTo(multiplier)).toBe(true);
448
+ }
449
+
450
+ // RTP == winChance * payout is the house edge at EVERY endpoint, not just on average
451
+ expect(chance.multipliedBy(multiplier).toNumber()).toBeCloseTo(houseEdge.toNumber(), 9);
452
+ }
453
+ }
454
+ });
455
+ });