@pokertools/engine 1.0.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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +607 -0
  3. package/dist/actions/betting.d.ts +21 -0
  4. package/dist/actions/betting.js +410 -0
  5. package/dist/actions/dealing.d.ts +9 -0
  6. package/dist/actions/dealing.js +206 -0
  7. package/dist/actions/management.d.ts +9 -0
  8. package/dist/actions/management.js +58 -0
  9. package/dist/actions/showdownActions.d.ts +9 -0
  10. package/dist/actions/showdownActions.js +119 -0
  11. package/dist/actions/special.d.ts +14 -0
  12. package/dist/actions/special.js +98 -0
  13. package/dist/actions/streetProgression.d.ts +13 -0
  14. package/dist/actions/streetProgression.js +157 -0
  15. package/dist/actions/tournament.d.ts +5 -0
  16. package/dist/actions/tournament.js +38 -0
  17. package/dist/actions/validation.d.ts +6 -0
  18. package/dist/actions/validation.js +182 -0
  19. package/dist/engine/PokerEngine.d.ts +92 -0
  20. package/dist/engine/PokerEngine.js +246 -0
  21. package/dist/engine/gameReducer.d.ts +10 -0
  22. package/dist/engine/gameReducer.js +135 -0
  23. package/dist/errors/ConfigError.d.ts +8 -0
  24. package/dist/errors/ConfigError.js +15 -0
  25. package/dist/errors/CriticalStateError.d.ts +8 -0
  26. package/dist/errors/CriticalStateError.js +15 -0
  27. package/dist/errors/ErrorCodes.d.ts +38 -0
  28. package/dist/errors/ErrorCodes.js +46 -0
  29. package/dist/errors/IllegalActionError.d.ts +9 -0
  30. package/dist/errors/IllegalActionError.js +15 -0
  31. package/dist/errors/PokerEngineError.d.ts +8 -0
  32. package/dist/errors/PokerEngineError.js +19 -0
  33. package/dist/errors/index.d.ts +5 -0
  34. package/dist/errors/index.js +22 -0
  35. package/dist/history/exporter.d.ts +28 -0
  36. package/dist/history/exporter.js +60 -0
  37. package/dist/history/formats/json.d.ts +14 -0
  38. package/dist/history/formats/json.js +46 -0
  39. package/dist/history/formats/pokerstars.d.ts +10 -0
  40. package/dist/history/formats/pokerstars.js +188 -0
  41. package/dist/history/handHistoryBuilder.d.ts +10 -0
  42. package/dist/history/handHistoryBuilder.js +179 -0
  43. package/dist/history/types.d.ts +73 -0
  44. package/dist/history/types.js +5 -0
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.js +38 -0
  47. package/dist/rules/actionOrder.d.ts +14 -0
  48. package/dist/rules/actionOrder.js +211 -0
  49. package/dist/rules/blinds.d.ts +24 -0
  50. package/dist/rules/blinds.js +64 -0
  51. package/dist/rules/headsUp.d.ts +15 -0
  52. package/dist/rules/headsUp.js +44 -0
  53. package/dist/rules/showdown.d.ts +9 -0
  54. package/dist/rules/showdown.js +164 -0
  55. package/dist/rules/sidePots.d.ts +32 -0
  56. package/dist/rules/sidePots.js +173 -0
  57. package/dist/tsconfig.tsbuildinfo +1 -0
  58. package/dist/utils/cardUtils.d.ts +12 -0
  59. package/dist/utils/cardUtils.js +30 -0
  60. package/dist/utils/constants.d.ts +38 -0
  61. package/dist/utils/constants.js +41 -0
  62. package/dist/utils/deck.d.ts +46 -0
  63. package/dist/utils/deck.js +126 -0
  64. package/dist/utils/invariants.d.ts +39 -0
  65. package/dist/utils/invariants.js +163 -0
  66. package/dist/utils/positioning.d.ts +36 -0
  67. package/dist/utils/positioning.js +97 -0
  68. package/dist/utils/rake.d.ts +13 -0
  69. package/dist/utils/rake.js +45 -0
  70. package/dist/utils/serialization.d.ts +53 -0
  71. package/dist/utils/serialization.js +106 -0
  72. package/dist/utils/validation.d.ts +20 -0
  73. package/dist/utils/validation.js +52 -0
  74. package/dist/utils/viewMasking.d.ts +20 -0
  75. package/dist/utils/viewMasking.js +90 -0
  76. package/package.json +58 -0
@@ -0,0 +1,9 @@
1
+ import { GameState, ShowAction, MuckAction } from "@pokertools/types";
2
+ /**
3
+ * Handle SHOW action - player reveals their cards at showdown
4
+ */
5
+ export declare function handleShow(state: GameState, action: ShowAction): GameState;
6
+ /**
7
+ * Handle MUCK action - player hides their cards at showdown
8
+ */
9
+ export declare function handleMuck(state: GameState, action: MuckAction): GameState;
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleShow = handleShow;
4
+ exports.handleMuck = handleMuck;
5
+ const IllegalActionError_1 = require("../errors/IllegalActionError");
6
+ const ErrorCodes_1 = require("../errors/ErrorCodes");
7
+ /**
8
+ * Handle SHOW action - player reveals their cards at showdown
9
+ */
10
+ function handleShow(state, action) {
11
+ // Find player
12
+ const player = state.players.find((p) => p?.id === action.playerId);
13
+ if (!player) {
14
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
15
+ }
16
+ // Can only show at showdown
17
+ if (state.street !== "SHOWDOWN" /* Street.SHOWDOWN */) {
18
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Can only show cards at showdown (current street: ${state.street})`, { playerId: action.playerId, street: state.street });
19
+ }
20
+ // Player must not have folded
21
+ if (player.status === "FOLDED" /* PlayerStatus.FOLDED */) {
22
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Cannot show cards after folding`, {
23
+ playerId: action.playerId,
24
+ status: player.status,
25
+ });
26
+ }
27
+ // Player must have cards
28
+ if (!player.hand) {
29
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Player has no cards to show`, {
30
+ playerId: action.playerId,
31
+ });
32
+ }
33
+ // Determine which cards to show
34
+ let cardIndices;
35
+ if (action.cardIndices && action.cardIndices.length > 0) {
36
+ // Validate indices are within bounds
37
+ cardIndices = action.cardIndices.filter((i) => i >= 0 && i < player.hand.length);
38
+ if (cardIndices.length === 0) {
39
+ return state; // Invalid indices
40
+ }
41
+ }
42
+ else {
43
+ // Default: show all cards
44
+ cardIndices = Array.from({ length: player.hand.length }, (_, i) => i);
45
+ }
46
+ // Update player's shown cards
47
+ const newPlayers = [...state.players];
48
+ newPlayers[player.seat] = {
49
+ ...player,
50
+ shownCards: cardIndices,
51
+ };
52
+ const actionRecord = {
53
+ action: {
54
+ type: "SHOW" /* ActionType.SHOW */,
55
+ playerId: action.playerId,
56
+ cardIndices: action.cardIndices,
57
+ timestamp: action.timestamp,
58
+ },
59
+ seat: player.seat,
60
+ resultingPot: state.pots.reduce((sum, pot) => sum + pot.amount, 0),
61
+ resultingStack: player.stack,
62
+ street: state.street,
63
+ };
64
+ return {
65
+ ...state,
66
+ players: newPlayers,
67
+ actionHistory: [...state.actionHistory, actionRecord],
68
+ timestamp: action.timestamp,
69
+ };
70
+ }
71
+ /**
72
+ * Handle MUCK action - player hides their cards at showdown
73
+ */
74
+ function handleMuck(state, action) {
75
+ // Find player
76
+ const player = state.players.find((p) => p?.id === action.playerId);
77
+ if (!player) {
78
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
79
+ }
80
+ // Can only muck at showdown
81
+ if (state.street !== "SHOWDOWN" /* Street.SHOWDOWN */) {
82
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Can only muck cards at showdown (current street: ${state.street})`, { playerId: action.playerId, street: state.street });
83
+ }
84
+ // Player must not have folded
85
+ if (player.status === "FOLDED" /* PlayerStatus.FOLDED */) {
86
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Cannot muck cards after folding`, {
87
+ playerId: action.playerId,
88
+ status: player.status,
89
+ });
90
+ }
91
+ // Cannot muck if you're a winner (winners must show)
92
+ const isWinner = state.winners?.some((w) => w.seat === player.seat);
93
+ if (isWinner) {
94
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Winners cannot muck - cards must be shown`, { playerId: action.playerId });
95
+ }
96
+ // Set shown cards to null (mucked) - hand is preserved in player.hand
97
+ const newPlayers = [...state.players];
98
+ newPlayers[player.seat] = {
99
+ ...player,
100
+ shownCards: null, // Muck cards (hide them, but preserve hand data)
101
+ };
102
+ const actionRecord = {
103
+ action: {
104
+ type: "MUCK" /* ActionType.MUCK */,
105
+ playerId: action.playerId,
106
+ timestamp: action.timestamp,
107
+ },
108
+ seat: player.seat,
109
+ resultingPot: state.pots.reduce((sum, pot) => sum + pot.amount, 0),
110
+ resultingStack: player.stack,
111
+ street: state.street,
112
+ };
113
+ return {
114
+ ...state,
115
+ players: newPlayers,
116
+ actionHistory: [...state.actionHistory, actionRecord],
117
+ timestamp: action.timestamp,
118
+ };
119
+ }
@@ -0,0 +1,14 @@
1
+ import { GameState, TimeoutAction, TimeBankAction } from "@pokertools/types";
2
+ /**
3
+ * Handle TIMEOUT action
4
+ * - Folds player if they have bet to call
5
+ * - Checks if allowed, otherwise folds
6
+ * - Marks player as sitting out
7
+ */
8
+ export declare function handleTimeout(state: GameState, action: TimeoutAction): GameState;
9
+ /**
10
+ * Handle TIME_BANK action
11
+ * - Deducts time from player's time bank
12
+ * - Keeps action on same player
13
+ */
14
+ export declare function handleTimeBank(state: GameState, action: TimeBankAction): GameState;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleTimeout = handleTimeout;
4
+ exports.handleTimeBank = handleTimeBank;
5
+ const positioning_1 = require("../utils/positioning");
6
+ const actionOrder_1 = require("../rules/actionOrder");
7
+ /**
8
+ * Handle TIMEOUT action
9
+ * - Folds player if they have bet to call
10
+ * - Checks if allowed, otherwise folds
11
+ * - Marks player as sitting out
12
+ */
13
+ function handleTimeout(state, action) {
14
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
15
+ if (!result) {
16
+ return state;
17
+ }
18
+ const { player, seat } = result;
19
+ // Determine if player needs to call
20
+ const currentBet = getCurrentBet(state);
21
+ const playerBet = state.currentBets.get(seat) ?? 0;
22
+ const needsToCall = currentBet > playerBet;
23
+ const newPlayers = [...state.players];
24
+ if (needsToCall) {
25
+ // Player must fold
26
+ newPlayers[seat] = {
27
+ ...player,
28
+ status: "FOLDED" /* PlayerStatus.FOLDED */,
29
+ isSittingOut: true,
30
+ };
31
+ }
32
+ else {
33
+ // Player can check, but mark as sitting out
34
+ newPlayers[seat] = {
35
+ ...player,
36
+ isSittingOut: true,
37
+ };
38
+ }
39
+ const newActivePlayers = needsToCall
40
+ ? state.activePlayers.filter((s) => s !== seat)
41
+ : state.activePlayers;
42
+ const newState = {
43
+ ...state,
44
+ players: newPlayers,
45
+ activePlayers: newActivePlayers,
46
+ timestamp: action.timestamp,
47
+ };
48
+ // Move to next player
49
+ const nextToAct = (0, actionOrder_1.getNextToAct)(newState);
50
+ return {
51
+ ...newState,
52
+ actionTo: nextToAct,
53
+ };
54
+ }
55
+ /**
56
+ * Handle TIME_BANK action
57
+ * - Deducts time from player's time bank
58
+ * - Keeps action on same player
59
+ */
60
+ function handleTimeBank(state, action) {
61
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
62
+ if (!result) {
63
+ return state;
64
+ }
65
+ const { seat } = result;
66
+ const currentTimeBank = state.timeBanks.get(seat) ?? 0;
67
+ if (currentTimeBank <= 0) {
68
+ // No time bank left, force timeout
69
+ return handleTimeout(state, {
70
+ type: "TIMEOUT" /* ActionType.TIMEOUT */,
71
+ playerId: action.playerId,
72
+ timestamp: action.timestamp,
73
+ });
74
+ }
75
+ // Deduct time from player's time bank (configurable, default 10 seconds)
76
+ const deduction = state.config.timeBankDeductionSeconds ?? 10;
77
+ const newTimeBank = Math.max(0, currentTimeBank - deduction);
78
+ const newTimeBanks = new Map(state.timeBanks);
79
+ newTimeBanks.set(seat, newTimeBank);
80
+ return {
81
+ ...state,
82
+ timeBanks: newTimeBanks,
83
+ timestamp: action.timestamp,
84
+ // Keep actionTo the same (extends player's turn)
85
+ };
86
+ }
87
+ /**
88
+ * Get current highest bet
89
+ */
90
+ function getCurrentBet(state) {
91
+ let maxBet = 0;
92
+ for (const bet of state.currentBets.values()) {
93
+ if (bet > maxBet) {
94
+ maxBet = bet;
95
+ }
96
+ }
97
+ return maxBet;
98
+ }
@@ -0,0 +1,13 @@
1
+ import { GameState } from "@pokertools/types";
2
+ /**
3
+ * Progress to next street
4
+ * - Collects bets into pots
5
+ * - Deals community cards
6
+ * - Resets action
7
+ */
8
+ export declare function progressStreet(state: GameState): GameState;
9
+ /**
10
+ * Check if we should progress to next street
11
+ * (All players have acted and matched bets)
12
+ */
13
+ export declare function shouldProgressStreet(state: GameState): boolean;
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.progressStreet = progressStreet;
4
+ exports.shouldProgressStreet = shouldProgressStreet;
5
+ const deck_1 = require("../utils/deck");
6
+ const cardUtils_1 = require("../utils/cardUtils");
7
+ const actionOrder_1 = require("../rules/actionOrder");
8
+ /**
9
+ * Progress to next street
10
+ * - Collects bets into pots
11
+ * - Deals community cards
12
+ * - Resets action
13
+ */
14
+ function progressStreet(state) {
15
+ // Determine next street
16
+ const nextStreet = getNextStreet(state.street);
17
+ if (nextStreet === null) {
18
+ // Already at showdown or beyond
19
+ return state;
20
+ }
21
+ // Check if we should auto-runout (all remaining players all-in)
22
+ const shouldAutoRunout = checkAutoRunout(state);
23
+ if (shouldAutoRunout) {
24
+ return handleAutoRunout(state);
25
+ }
26
+ // Deal community cards
27
+ const { board, deck } = dealCommunityCards(state, nextStreet);
28
+ // Reset for new street
29
+ // Note: Pots are calculated by recalculatePots() before progressStreet() is called
30
+ const newState = {
31
+ ...state,
32
+ street: nextStreet,
33
+ board,
34
+ deck,
35
+ pots: state.pots, // Keep existing pots (already updated by recalculatePots)
36
+ currentBets: new Map(),
37
+ lastAggressorSeat: null,
38
+ // Keep timestamp from last action (street progression is not a user action)
39
+ };
40
+ // Set first to act
41
+ const firstToAct = (0, actionOrder_1.getFirstToAct)(newState);
42
+ return {
43
+ ...newState,
44
+ actionTo: firstToAct,
45
+ };
46
+ }
47
+ /**
48
+ * Get next street in sequence
49
+ * Uses exhaustive mapping for type safety
50
+ */
51
+ function getNextStreet(current) {
52
+ const nextStreetMap = {
53
+ ["PREFLOP" /* Street.PREFLOP */]: "FLOP" /* Street.FLOP */,
54
+ ["FLOP" /* Street.FLOP */]: "TURN" /* Street.TURN */,
55
+ ["TURN" /* Street.TURN */]: "RIVER" /* Street.RIVER */,
56
+ ["RIVER" /* Street.RIVER */]: "SHOWDOWN" /* Street.SHOWDOWN */,
57
+ ["SHOWDOWN" /* Street.SHOWDOWN */]: null,
58
+ };
59
+ return nextStreetMap[current];
60
+ }
61
+ /**
62
+ * Deal community cards for the given street
63
+ */
64
+ function dealCommunityCards(state, street) {
65
+ const currentBoard = [...state.board];
66
+ const deck = [...state.deck];
67
+ switch (street) {
68
+ case "FLOP" /* Street.FLOP */:
69
+ // Burn 1, deal 3
70
+ const [flopCards, flopDeck] = (0, deck_1.burnAndDeal)(deck, 3);
71
+ return {
72
+ board: [...currentBoard, ...(0, cardUtils_1.cardCodesToStrings)(flopCards)],
73
+ deck: flopDeck,
74
+ };
75
+ case "TURN" /* Street.TURN */:
76
+ // Burn 1, deal 1
77
+ const [turnCards, turnDeck] = (0, deck_1.burnAndDeal)(deck, 1);
78
+ return {
79
+ board: [...currentBoard, ...(0, cardUtils_1.cardCodesToStrings)(turnCards)],
80
+ deck: turnDeck,
81
+ };
82
+ case "RIVER" /* Street.RIVER */:
83
+ // Burn 1, deal 1
84
+ const [riverCards, riverDeck] = (0, deck_1.burnAndDeal)(deck, 1);
85
+ return {
86
+ board: [...currentBoard, ...(0, cardUtils_1.cardCodesToStrings)(riverCards)],
87
+ deck: riverDeck,
88
+ };
89
+ default:
90
+ return { board: currentBoard, deck };
91
+ }
92
+ }
93
+ /**
94
+ * Check if all remaining players are all-in (auto-runout condition)
95
+ */
96
+ function checkAutoRunout(state) {
97
+ let activeCount = 0;
98
+ let allInCount = 0;
99
+ for (const player of state.players) {
100
+ if (!player)
101
+ continue;
102
+ if (player.status === "ACTIVE" /* PlayerStatus.ACTIVE */ && player.stack > 0) {
103
+ activeCount++;
104
+ }
105
+ else if (player.status === "ALL_IN" /* PlayerStatus.ALL_IN */) {
106
+ allInCount++;
107
+ }
108
+ }
109
+ // Auto-runout if ≤1 active player with chips (rest are all-in or folded)
110
+ return activeCount <= 1 && allInCount > 0;
111
+ }
112
+ /**
113
+ * Handle auto-runout: deal all remaining streets at once
114
+ * This manually deals cards without calling progressStreet to avoid infinite recursion
115
+ */
116
+ function handleAutoRunout(state) {
117
+ let currentState = state;
118
+ let currentStreet = state.street;
119
+ // Deal remaining streets manually (FLOP -> TURN -> RIVER)
120
+ while (currentStreet !== "RIVER" /* Street.RIVER */) {
121
+ const nextStreet = getNextStreet(currentStreet);
122
+ if (nextStreet === null || nextStreet === "SHOWDOWN" /* Street.SHOWDOWN */)
123
+ break;
124
+ // Deal community cards for this street
125
+ const { board, deck } = dealCommunityCards(currentState, nextStreet);
126
+ // Update state with new street and board
127
+ currentState = {
128
+ ...currentState,
129
+ street: nextStreet,
130
+ board,
131
+ deck,
132
+ currentBets: new Map(), // Clear bets between streets
133
+ lastAggressorSeat: null,
134
+ };
135
+ currentStreet = nextStreet;
136
+ }
137
+ // Move to showdown
138
+ return {
139
+ ...currentState,
140
+ street: "SHOWDOWN" /* Street.SHOWDOWN */,
141
+ actionTo: null,
142
+ };
143
+ }
144
+ /**
145
+ * Check if we should progress to next street
146
+ * (All players have acted and matched bets)
147
+ */
148
+ function shouldProgressStreet(state) {
149
+ if (state.actionTo !== null) {
150
+ return false; // Action still in progress
151
+ }
152
+ if (state.street === "SHOWDOWN" /* Street.SHOWDOWN */) {
153
+ return false; // Already at showdown
154
+ }
155
+ // Check if all active players have acted
156
+ return (0, actionOrder_1.isActionComplete)(state);
157
+ }
@@ -0,0 +1,5 @@
1
+ import { GameState, NextBlindLevelAction } from "@pokertools/types";
2
+ /**
3
+ * Handle NEXT_BLIND_LEVEL action - advance to next blind level in tournament
4
+ */
5
+ export declare function handleNextBlindLevel(state: GameState, action: NextBlindLevelAction): GameState;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleNextBlindLevel = handleNextBlindLevel;
4
+ /**
5
+ * Handle NEXT_BLIND_LEVEL action - advance to next blind level in tournament
6
+ */
7
+ function handleNextBlindLevel(state, action) {
8
+ // Only applicable for tournaments
9
+ if (!state.config.blindStructure) {
10
+ return state;
11
+ }
12
+ const nextLevel = state.blindLevel + 1;
13
+ // Check if we're at max level
14
+ if (nextLevel >= state.config.blindStructure.length) {
15
+ return state; // At max level, no change
16
+ }
17
+ const blindLevel = state.config.blindStructure[nextLevel];
18
+ // Record action to history
19
+ const actionRecord = {
20
+ action: {
21
+ type: "NEXT_BLIND_LEVEL" /* ActionType.NEXT_BLIND_LEVEL */,
22
+ timestamp: action.timestamp,
23
+ },
24
+ seat: null, // Table-level action
25
+ resultingPot: state.pots.reduce((sum, pot) => sum + pot.amount, 0),
26
+ resultingStack: 0, // Not applicable
27
+ street: state.street,
28
+ };
29
+ return {
30
+ ...state,
31
+ blindLevel: nextLevel,
32
+ smallBlind: blindLevel.smallBlind,
33
+ bigBlind: blindLevel.bigBlind,
34
+ ante: blindLevel.ante,
35
+ actionHistory: [...state.actionHistory, actionRecord],
36
+ timestamp: action.timestamp,
37
+ };
38
+ }
@@ -0,0 +1,6 @@
1
+ import { GameState, Action } from "@pokertools/types";
2
+ /**
3
+ * Validate that an action is legal in the current game state
4
+ * Throws IllegalActionError if action is invalid
5
+ */
6
+ export declare function validateAction(state: GameState, action: Action): void;
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateAction = validateAction;
4
+ const IllegalActionError_1 = require("../errors/IllegalActionError");
5
+ const ErrorCodes_1 = require("../errors/ErrorCodes");
6
+ const positioning_1 = require("../utils/positioning");
7
+ /**
8
+ * Validate that an action is legal in the current game state
9
+ * Throws IllegalActionError if action is invalid
10
+ */
11
+ function validateAction(state, action) {
12
+ // Type-specific validation
13
+ switch (action.type) {
14
+ case "FOLD" /* ActionType.FOLD */:
15
+ case "CHECK" /* ActionType.CHECK */:
16
+ case "CALL" /* ActionType.CALL */:
17
+ case "BET" /* ActionType.BET */:
18
+ case "RAISE" /* ActionType.RAISE */:
19
+ validateBettingAction(state, action);
20
+ break;
21
+ case "DEAL" /* ActionType.DEAL */:
22
+ validateDealAction(state);
23
+ break;
24
+ case "SIT" /* ActionType.SIT */:
25
+ validateSitAction(state, action);
26
+ break;
27
+ case "STAND" /* ActionType.STAND */:
28
+ validateStandAction(state, action);
29
+ break;
30
+ case "TIMEOUT" /* ActionType.TIMEOUT */:
31
+ case "TIME_BANK" /* ActionType.TIME_BANK */:
32
+ validateTimeAction(state, action);
33
+ break;
34
+ default:
35
+ // Other actions don't need validation
36
+ break;
37
+ }
38
+ }
39
+ function validateBettingAction(state, action) {
40
+ if (!("playerId" in action)) {
41
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, "Action missing playerId");
42
+ }
43
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
44
+ if (!result) {
45
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
46
+ }
47
+ const { player, seat } = result;
48
+ // Check if it's player's turn
49
+ if (state.actionTo !== seat) {
50
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NOT_YOUR_TURN, `Player ${action.playerId} attempted to act, but action is on seat ${state.actionTo}`, {
51
+ playerId: action.playerId,
52
+ playerSeat: seat,
53
+ actionTo: state.actionTo,
54
+ street: state.street,
55
+ });
56
+ }
57
+ // Check player status
58
+ if (player.status !== "ACTIVE" /* PlayerStatus.ACTIVE */) {
59
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_ACTIVE, `Player ${action.playerId} cannot act with status ${player.status}`, { playerId: action.playerId, status: player.status });
60
+ }
61
+ // Check player has chips
62
+ if (player.stack === 0 && action.type !== "FOLD" /* ActionType.FOLD */) {
63
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NO_CHIPS, `Player ${action.playerId} has no chips`, {
64
+ playerId: action.playerId,
65
+ });
66
+ }
67
+ // Action-specific validation
68
+ const currentBet = getCurrentBet(state);
69
+ const playerBet = state.currentBets.get(seat) ?? 0;
70
+ const toCall = currentBet - playerBet;
71
+ switch (action.type) {
72
+ case "CHECK" /* ActionType.CHECK */:
73
+ if (toCall > 0) {
74
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.CANNOT_CHECK, `Player ${action.playerId} cannot check with ${toCall} to call`, { playerId: action.playerId, toCall });
75
+ }
76
+ break;
77
+ case "CALL" /* ActionType.CALL */:
78
+ if (toCall === 0) {
79
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NOTHING_TO_CALL, `Player ${action.playerId} has nothing to call`, { playerId: action.playerId });
80
+ }
81
+ break;
82
+ case "BET" /* ActionType.BET */:
83
+ // Note: We allow BET even when currentBet > 0 because the reducer will auto-convert it to RAISE or CALL
84
+ // This handles UI implementations that don't distinguish between BET and RAISE buttons
85
+ if ("amount" in action) {
86
+ // Reject bets below the current bet (string bet exploit)
87
+ if (currentBet > 0 && action.amount < currentBet) {
88
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.BET_TOO_SMALL, `Bet of ${action.amount} is below current bet ${currentBet}`, { amount: action.amount, currentBet });
89
+ }
90
+ // Reject bets below big blind (when no current bet)
91
+ if (action.amount < state.bigBlind && action.amount < player.stack) {
92
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.BET_TOO_SMALL, `Bet of ${action.amount} is below minimum ${state.bigBlind}`, { amount: action.amount, minimum: state.bigBlind });
93
+ }
94
+ }
95
+ break;
96
+ case "RAISE" /* ActionType.RAISE */:
97
+ // If the current player is still marked as the last aggressor, it means
98
+ // intermediate actions (like calls or incomplete all-in raises) did NOT
99
+ // reopen the betting. Therefore, they cannot re-raise their own bet.
100
+ if (state.lastAggressorSeat === seat) {
101
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.CANNOT_RERAISE, "Betting has not been re-opened to you (incomplete raise or no action)", {
102
+ playerId: action.playerId,
103
+ seat,
104
+ lastAggressor: state.lastAggressorSeat,
105
+ });
106
+ }
107
+ if (currentBet === 0) {
108
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.CANNOT_RAISE, `Player ${action.playerId} cannot raise when there's no bet`, { playerId: action.playerId });
109
+ }
110
+ if ("amount" in action) {
111
+ // Check if player is going all-in (incomplete raise exception)
112
+ const isAllIn = action.amount >= playerBet + player.stack;
113
+ // Reject raises that don't exceed current bet (unless all-in)
114
+ if (action.amount <= currentBet && !isAllIn) {
115
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.RAISE_TOO_SMALL, `Raise to ${action.amount} must be greater than current bet ${currentBet}`, { amount: action.amount, currentBet });
116
+ }
117
+ // Check minimum raise requirement (unless player is going all-in)
118
+ const raiseIncrement = action.amount - currentBet;
119
+ if (!isAllIn && action.amount < state.minRaise) {
120
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.RAISE_TOO_SMALL, `Raise to ${action.amount} is below minimum ${state.minRaise}`, {
121
+ amount: action.amount,
122
+ currentBet,
123
+ raiseIncrement,
124
+ minRaise: state.minRaise,
125
+ });
126
+ }
127
+ }
128
+ break;
129
+ }
130
+ }
131
+ function validateDealAction(state) {
132
+ if (state.street !== "PREFLOP" /* Street.PREFLOP */ || state.handNumber > 0) {
133
+ // Allow dealing if we're at showdown (hand complete) or haven't started
134
+ if (state.street !== "SHOWDOWN" /* Street.SHOWDOWN */ && state.handNumber > 0) {
135
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.CANNOT_DEAL, "Cannot deal while hand is in progress", { street: state.street });
136
+ }
137
+ }
138
+ // Check we have enough players
139
+ const activePlayers = state.players.filter((p) => p && p.stack > 0 && !p.isSittingOut);
140
+ if (activePlayers.length < 2) {
141
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NOT_ENOUGH_PLAYERS, `Need at least 2 players to deal, found ${activePlayers.length}`, { playerCount: activePlayers.length });
142
+ }
143
+ }
144
+ function validateSitAction(state, action) {
145
+ if (action.seat < 0 || action.seat >= state.maxPlayers) {
146
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_SEAT, `Seat ${action.seat} is invalid (max: ${state.maxPlayers - 1})`, { seat: action.seat, maxPlayers: state.maxPlayers });
147
+ }
148
+ if (state.players[action.seat] !== null) {
149
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.SEAT_OCCUPIED, `Seat ${action.seat} is already occupied`, { seat: action.seat });
150
+ }
151
+ if (action.stack <= 0) {
152
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_STACK, `Stack must be positive, got ${action.stack}`, { stack: action.stack });
153
+ }
154
+ }
155
+ function validateStandAction(state, action) {
156
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
157
+ if (!result) {
158
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
159
+ }
160
+ }
161
+ function validateTimeAction(state, action) {
162
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
163
+ if (!result) {
164
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
165
+ }
166
+ const { seat } = result;
167
+ if (state.actionTo !== seat) {
168
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NOT_YOUR_TURN, `Player ${action.playerId} cannot use time action when it's not their turn`, { playerId: action.playerId, actionTo: state.actionTo });
169
+ }
170
+ }
171
+ /**
172
+ * Get current highest bet this street
173
+ */
174
+ function getCurrentBet(state) {
175
+ let maxBet = 0;
176
+ for (const bet of state.currentBets.values()) {
177
+ if (bet > maxBet) {
178
+ maxBet = bet;
179
+ }
180
+ }
181
+ return maxBet;
182
+ }