@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,410 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleFold = handleFold;
4
+ exports.handleCheck = handleCheck;
5
+ exports.handleCall = handleCall;
6
+ exports.handleBet = handleBet;
7
+ exports.handleRaise = handleRaise;
8
+ const positioning_1 = require("../utils/positioning");
9
+ const actionOrder_1 = require("../rules/actionOrder");
10
+ const CriticalStateError_1 = require("../errors/CriticalStateError");
11
+ const rake_1 = require("../utils/rake");
12
+ /**
13
+ * Handle FOLD action
14
+ */
15
+ function handleFold(state, action) {
16
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
17
+ if (!result) {
18
+ return state; // Should have been caught by validation
19
+ }
20
+ const { seat } = result;
21
+ const newPlayers = [...state.players];
22
+ // Set player status to FOLDED
23
+ newPlayers[seat] = {
24
+ ...newPlayers[seat],
25
+ status: "FOLDED" /* PlayerStatus.FOLDED */,
26
+ };
27
+ // Remove from active players
28
+ const newActivePlayers = state.activePlayers.filter((s) => s !== seat);
29
+ // Add to action history
30
+ const actionRecord = {
31
+ action,
32
+ seat,
33
+ resultingPot: getTotalPot(state),
34
+ resultingStack: newPlayers[seat].stack,
35
+ street: state.street,
36
+ };
37
+ const currentState = {
38
+ ...state,
39
+ players: newPlayers,
40
+ activePlayers: newActivePlayers,
41
+ actionHistory: [...state.actionHistory, actionRecord],
42
+ timestamp: action.timestamp,
43
+ };
44
+ // Check if only one player with a live hand remains
45
+ // Count players who have not folded (Active + All-In)
46
+ const playersWithLiveHands = currentState.players.filter((p) => p && p.status !== "FOLDED" /* PlayerStatus.FOLDED */);
47
+ // Only end the hand if exactly one player has cards
48
+ if (playersWithLiveHands.length === 1 && playersWithLiveHands[0]) {
49
+ // Award remaining pots to last player with live hand
50
+ return awardPotToLastPlayer(currentState, playersWithLiveHands[0].seat);
51
+ }
52
+ // If we have 1 Active player but multiple Live players (others are All-In),
53
+ // the game should naturally progress to Showdown via progressStreet/checkAutoRunout
54
+ // Move to next player
55
+ const nextToAct = (0, actionOrder_1.getNextToAct)(currentState);
56
+ return {
57
+ ...currentState,
58
+ actionTo: nextToAct,
59
+ };
60
+ }
61
+ /**
62
+ * Handle CHECK action
63
+ */
64
+ function handleCheck(state, action) {
65
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
66
+ if (!result) {
67
+ return state;
68
+ }
69
+ const { seat } = result;
70
+ // Add to action history
71
+ const actionRecord = {
72
+ action,
73
+ seat,
74
+ resultingPot: getTotalPot(state),
75
+ resultingStack: state.players[seat].stack,
76
+ street: state.street,
77
+ };
78
+ const newState = {
79
+ ...state,
80
+ actionHistory: [...state.actionHistory, actionRecord],
81
+ timestamp: action.timestamp,
82
+ };
83
+ // Move to next player
84
+ const nextToAct = (0, actionOrder_1.getNextToAct)(newState);
85
+ return {
86
+ ...newState,
87
+ actionTo: nextToAct,
88
+ };
89
+ }
90
+ /**
91
+ * Handle CALL action
92
+ */
93
+ function handleCall(state, action) {
94
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
95
+ if (!result) {
96
+ return state;
97
+ }
98
+ const { player, seat } = result;
99
+ // Calculate amount to call
100
+ const currentBet = getCurrentBet(state);
101
+ const playerBet = state.currentBets.get(seat) ?? 0;
102
+ const toCall = currentBet - playerBet;
103
+ // Determine actual call amount (may be all-in)
104
+ const callAmount = Math.min(toCall, player.stack);
105
+ const isAllIn = callAmount === player.stack;
106
+ // Update player
107
+ const newPlayers = [...state.players];
108
+ newPlayers[seat] = {
109
+ ...player,
110
+ stack: player.stack - callAmount,
111
+ betThisStreet: playerBet + callAmount,
112
+ totalInvestedThisHand: player.totalInvestedThisHand + callAmount,
113
+ status: isAllIn ? "ALL_IN" /* PlayerStatus.ALL_IN */ : "ACTIVE" /* PlayerStatus.ACTIVE */,
114
+ };
115
+ // Update current bets
116
+ const newCurrentBets = new Map(state.currentBets);
117
+ newCurrentBets.set(seat, playerBet + callAmount);
118
+ // Add to action history
119
+ const actionRecord = {
120
+ action,
121
+ seat,
122
+ resultingPot: getTotalPot(state) + callAmount,
123
+ resultingStack: newPlayers[seat].stack,
124
+ street: state.street,
125
+ };
126
+ const newState = {
127
+ ...state,
128
+ players: newPlayers,
129
+ currentBets: newCurrentBets,
130
+ actionHistory: [...state.actionHistory, actionRecord],
131
+ timestamp: action.timestamp,
132
+ };
133
+ // Move to next player
134
+ const nextToAct = (0, actionOrder_1.getNextToAct)(newState);
135
+ return {
136
+ ...newState,
137
+ actionTo: nextToAct,
138
+ };
139
+ }
140
+ /**
141
+ * Handle BET action
142
+ */
143
+ function handleBet(state, action) {
144
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
145
+ if (!result) {
146
+ return state;
147
+ }
148
+ const { player, seat } = result;
149
+ const betAmount = Math.min(action.amount, player.stack);
150
+ const isAllIn = betAmount === player.stack;
151
+ // Update player
152
+ const newPlayers = [...state.players];
153
+ newPlayers[seat] = {
154
+ ...player,
155
+ stack: player.stack - betAmount,
156
+ betThisStreet: betAmount,
157
+ totalInvestedThisHand: player.totalInvestedThisHand + betAmount,
158
+ status: isAllIn ? "ALL_IN" /* PlayerStatus.ALL_IN */ : "ACTIVE" /* PlayerStatus.ACTIVE */,
159
+ };
160
+ // Update current bets
161
+ const newCurrentBets = new Map(state.currentBets);
162
+ newCurrentBets.set(seat, betAmount);
163
+ // Add to action history
164
+ const actionRecord = {
165
+ action,
166
+ seat,
167
+ resultingPot: getTotalPot(state) + betAmount,
168
+ resultingStack: newPlayers[seat].stack,
169
+ street: state.street,
170
+ };
171
+ const newState = {
172
+ ...state,
173
+ players: newPlayers,
174
+ currentBets: newCurrentBets,
175
+ minRaise: betAmount + betAmount, // Min raise is current bet + raise increment
176
+ lastRaiseAmount: betAmount,
177
+ lastAggressorSeat: seat,
178
+ actionHistory: [...state.actionHistory, actionRecord],
179
+ timestamp: action.timestamp,
180
+ };
181
+ // Move to next player
182
+ const nextToAct = (0, actionOrder_1.getNextToAct)(newState);
183
+ return {
184
+ ...newState,
185
+ actionTo: nextToAct,
186
+ };
187
+ }
188
+ /**
189
+ * Handle RAISE action
190
+ */
191
+ function handleRaise(state, action) {
192
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
193
+ if (!result) {
194
+ return state;
195
+ }
196
+ const { player, seat } = result;
197
+ const currentBet = getCurrentBet(state);
198
+ const playerBet = state.currentBets.get(seat) ?? 0;
199
+ const raiseAmount = Math.min(action.amount, playerBet + player.stack);
200
+ const addedChips = raiseAmount - playerBet;
201
+ const isAllIn = addedChips === player.stack;
202
+ // Calculate raise increment
203
+ const raiseIncrement = raiseAmount - currentBet;
204
+ // Update player
205
+ const newPlayers = [...state.players];
206
+ newPlayers[seat] = {
207
+ ...player,
208
+ stack: player.stack - addedChips,
209
+ betThisStreet: raiseAmount,
210
+ totalInvestedThisHand: player.totalInvestedThisHand + addedChips,
211
+ status: isAllIn ? "ALL_IN" /* PlayerStatus.ALL_IN */ : "ACTIVE" /* PlayerStatus.ACTIVE */,
212
+ };
213
+ // Update current bets
214
+ const newCurrentBets = new Map(state.currentBets);
215
+ newCurrentBets.set(seat, raiseAmount);
216
+ // Add to action history
217
+ const actionRecord = {
218
+ action,
219
+ seat,
220
+ resultingPot: getTotalPot(state) + addedChips,
221
+ resultingStack: newPlayers[seat].stack,
222
+ street: state.street,
223
+ };
224
+ // Determine if this reopens betting (incomplete raise rule)
225
+ const reopensBetting = raiseIncrement >= state.lastRaiseAmount;
226
+ // Min-raise calculation:
227
+ // - If reopens betting: new currentBet + new increment
228
+ // - If incomplete raise: new currentBet + old increment (TDA/WSOP rule)
229
+ // Example: P1 bets 100, P2 all-in 120, P3 must raise to 120+100=220 minimum
230
+ const newMinRaise = reopensBetting
231
+ ? raiseAmount + raiseIncrement
232
+ : raiseAmount + state.lastRaiseAmount;
233
+ const newState = {
234
+ ...state,
235
+ players: newPlayers,
236
+ currentBets: newCurrentBets,
237
+ minRaise: newMinRaise,
238
+ lastRaiseAmount: reopensBetting ? raiseIncrement : state.lastRaiseAmount,
239
+ lastAggressorSeat: reopensBetting ? seat : state.lastAggressorSeat,
240
+ actionHistory: [...state.actionHistory, actionRecord],
241
+ timestamp: action.timestamp,
242
+ };
243
+ // Move to next player
244
+ const nextToAct = (0, actionOrder_1.getNextToAct)(newState);
245
+ return {
246
+ ...newState,
247
+ actionTo: nextToAct,
248
+ };
249
+ }
250
+ /**
251
+ * Get current highest bet
252
+ */
253
+ function getCurrentBet(state) {
254
+ let maxBet = 0;
255
+ for (const bet of state.currentBets.values()) {
256
+ if (bet > maxBet) {
257
+ maxBet = bet;
258
+ }
259
+ }
260
+ return maxBet;
261
+ }
262
+ /**
263
+ * Get total pot size
264
+ */
265
+ function getTotalPot(state) {
266
+ let total = 0;
267
+ for (const pot of state.pots) {
268
+ total += pot.amount;
269
+ }
270
+ for (const bet of state.currentBets.values()) {
271
+ total += bet;
272
+ }
273
+ return total;
274
+ }
275
+ /**
276
+ * Award pots to remaining eligible players when hand ends by folds
277
+ * Properly handles side pot eligibility and uncontested pots
278
+ */
279
+ function awardPotToLastPlayer(state, winningSeat) {
280
+ const newPlayers = [...state.players];
281
+ const winners = [];
282
+ // Process each pot separately, checking eligibility
283
+ let totalRakeFromPots = 0;
284
+ for (const pot of state.pots) {
285
+ // Find all non-folded players eligible for this pot
286
+ const eligibleNonFolded = pot.eligibleSeats.filter((seat) => {
287
+ const player = state.players[seat];
288
+ return player && player.status !== "FOLDED" /* PlayerStatus.FOLDED */;
289
+ });
290
+ // Calculate rake for this pot - GLOBAL cap applied across all pots
291
+ const { rake: potRake } = (0, rake_1.calculateRake)(state, pot.amount, totalRakeFromPots);
292
+ totalRakeFromPots += potRake;
293
+ const potAfterRake = pot.amount - potRake;
294
+ if (eligibleNonFolded.length === 0) {
295
+ // No eligible players remain - should not happen, but defensive
296
+ // Award to last player to fold from eligible seats (fallback)
297
+ const lastEligible = pot.eligibleSeats[pot.eligibleSeats.length - 1];
298
+ const player = newPlayers[lastEligible];
299
+ if (player) {
300
+ newPlayers[lastEligible] = {
301
+ ...player,
302
+ stack: player.stack + potAfterRake,
303
+ };
304
+ winners.push({
305
+ seat: lastEligible,
306
+ amount: potAfterRake,
307
+ hand: null,
308
+ handRank: null,
309
+ });
310
+ }
311
+ }
312
+ else if (eligibleNonFolded.length === 1) {
313
+ // Exactly one eligible player - they win this pot
314
+ const winnerSeat = eligibleNonFolded[0];
315
+ const player = newPlayers[winnerSeat];
316
+ newPlayers[winnerSeat] = {
317
+ ...player,
318
+ stack: player.stack + potAfterRake,
319
+ };
320
+ winners.push({
321
+ seat: winnerSeat,
322
+ amount: potAfterRake,
323
+ hand: null,
324
+ handRank: null,
325
+ });
326
+ }
327
+ else {
328
+ // Multiple eligible players remain - this means awardPotToLastPlayer was called incorrectly
329
+ // The hand should have gone to showdown instead
330
+ throw new CriticalStateError_1.CriticalStateError("awardPotToLastPlayer called with multiple eligible players remaining", {
331
+ potAmount: pot.amount,
332
+ eligibleSeats: pot.eligibleSeats,
333
+ eligibleNonFolded,
334
+ winningSeat,
335
+ });
336
+ }
337
+ }
338
+ // Award current bets to last active player
339
+ // Note: We need to subtract the winner's own bet since that's just a refund, not winnings
340
+ let currentBetsTotal = 0;
341
+ for (const bet of state.currentBets.values()) {
342
+ currentBetsTotal += bet;
343
+ }
344
+ const newActionHistory = [...state.actionHistory];
345
+ let totalRake = 0;
346
+ if (currentBetsTotal > 0) {
347
+ const player = newPlayers[winningSeat];
348
+ const winnersBet = state.currentBets.get(winningSeat) ?? 0;
349
+ // Calculate rake on the pot (before awarding) - GLOBAL cap applied
350
+ const { rake } = (0, rake_1.calculateRake)(state, currentBetsTotal, totalRakeFromPots);
351
+ totalRake = totalRakeFromPots + rake;
352
+ const potAfterRake = currentBetsTotal - rake;
353
+ // Award the pot after rake deduction
354
+ newPlayers[winningSeat] = {
355
+ ...player,
356
+ stack: player.stack + potAfterRake,
357
+ };
358
+ // Record uncalled bet refund if winner had a bet
359
+ if (winnersBet > 0) {
360
+ const uncalledBetAction = {
361
+ action: {
362
+ type: "UNCALLED_BET_RETURNED" /* ActionType.UNCALLED_BET_RETURNED */,
363
+ playerId: player.id,
364
+ amount: winnersBet,
365
+ timestamp: state.timestamp,
366
+ },
367
+ seat: winningSeat,
368
+ resultingPot: 0, // Pot will be empty after this
369
+ resultingStack: player.stack + winnersBet,
370
+ street: state.street,
371
+ };
372
+ newActionHistory.push(uncalledBetAction);
373
+ }
374
+ // Record only the actual winnings (opponents' bets, not their own bet refund, after rake)
375
+ // Winnings = potAfterRake - winnersBet (refund doesn't count as winnings)
376
+ const actualWinnings = potAfterRake - winnersBet;
377
+ if (actualWinnings > 0) {
378
+ // Add to winners if not already there, or update amount
379
+ const existingIndex = winners.findIndex((w) => w.seat === winningSeat);
380
+ if (existingIndex >= 0) {
381
+ // Update existing winner's amount
382
+ winners[existingIndex] = {
383
+ ...winners[existingIndex],
384
+ amount: winners[existingIndex].amount + actualWinnings,
385
+ };
386
+ }
387
+ else {
388
+ winners.push({
389
+ seat: winningSeat,
390
+ amount: actualWinnings,
391
+ hand: null,
392
+ handRank: null,
393
+ });
394
+ }
395
+ }
396
+ }
397
+ // NOTE: We do NOT reset totalInvestedThisHand here because it's used by getInitialChips()
398
+ // to calculate total chips in the game. It will be reset when a new hand is dealt.
399
+ return {
400
+ ...state,
401
+ players: newPlayers,
402
+ street: "SHOWDOWN" /* Street.SHOWDOWN */, // Mark hand as complete
403
+ pots: [],
404
+ currentBets: new Map(),
405
+ winners,
406
+ actionTo: null,
407
+ actionHistory: newActionHistory,
408
+ rakeThisHand: state.rakeThisHand + totalRake, // totalRake already includes rake from both pots and currentBets
409
+ };
410
+ }
@@ -0,0 +1,9 @@
1
+ import { GameState, DealAction } from "@pokertools/types";
2
+ /**
3
+ * Deal a new hand
4
+ * - Shuffles deck
5
+ * - Posts blinds and antes
6
+ * - Deals 2 cards to each active player
7
+ * - Sets action to first to act
8
+ */
9
+ export declare function handleDeal(state: GameState, action: DealAction): GameState;
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleDeal = handleDeal;
4
+ const deck_1 = require("../utils/deck");
5
+ const cardUtils_1 = require("../utils/cardUtils");
6
+ const blinds_1 = require("../rules/blinds");
7
+ const actionOrder_1 = require("../rules/actionOrder");
8
+ const positioning_1 = require("../utils/positioning");
9
+ /**
10
+ * Deal a new hand
11
+ * - Shuffles deck
12
+ * - Posts blinds and antes
13
+ * - Deals 2 cards to each active player
14
+ * - Sets action to first to act
15
+ */
16
+ function handleDeal(state, action) {
17
+ // Move button (Dead Button logic: moves to next seat index regardless of occupancy)
18
+ const newButtonSeat = moveButton(state);
19
+ // Determine if this is a tournament
20
+ const isTournament = !!state.config.blindStructure;
21
+ // Create and shuffle deck
22
+ const rng = state.config.randomProvider ?? Math.random;
23
+ const deck = (0, deck_1.shuffle)((0, deck_1.createDeck)(), rng);
24
+ // Get players who will be dealt in
25
+ const playersToReceive = [];
26
+ for (let seat = 0; seat < state.players.length; seat++) {
27
+ const player = state.players[seat];
28
+ if (player && player.stack > 0 && !player.isSittingOut) {
29
+ playersToReceive.push(seat);
30
+ }
31
+ }
32
+ // Deal 2 cards to each player
33
+ let remainingDeck = deck;
34
+ const newPlayers = [...state.players];
35
+ // Initialize hands for receiving players (active, not sitting out)
36
+ for (const seat of playersToReceive) {
37
+ newPlayers[seat] = {
38
+ ...newPlayers[seat],
39
+ hand: [], // Initialize empty array
40
+ shownCards: null, // Reset from previous hand
41
+ status: "ACTIVE" /* PlayerStatus.ACTIVE */,
42
+ betThisStreet: 0,
43
+ totalInvestedThisHand: 0,
44
+ };
45
+ }
46
+ // In tournaments, initialize sitting-out players too (they must post blinds/antes)
47
+ if (isTournament) {
48
+ for (let seat = 0; seat < newPlayers.length; seat++) {
49
+ const player = newPlayers[seat];
50
+ if (player && player.stack > 0 && player.isSittingOut && !playersToReceive.includes(seat)) {
51
+ newPlayers[seat] = {
52
+ ...player,
53
+ hand: null, // No cards dealt
54
+ shownCards: null,
55
+ status: "FOLDED" /* PlayerStatus.FOLDED */, // Start as folded
56
+ betThisStreet: 0,
57
+ totalInvestedThisHand: 0,
58
+ };
59
+ }
60
+ }
61
+ }
62
+ // Deal 2 cards, one by one, in circle (standard poker procedure)
63
+ for (let round = 0; round < 2; round++) {
64
+ for (const seat of playersToReceive) {
65
+ // Deal 1 card
66
+ const [cards, nextDeck] = (0, deck_1.dealCards)(remainingDeck, 1);
67
+ remainingDeck = nextDeck;
68
+ const cardStrings = (0, cardUtils_1.cardCodesToStrings)(cards);
69
+ // Append to existing hand
70
+ const currentPlayer = newPlayers[seat];
71
+ const currentHand = currentPlayer.hand ?? []; // Should be [] from initialization
72
+ newPlayers[seat] = {
73
+ ...currentPlayer,
74
+ hand: [...currentHand, ...cardStrings],
75
+ };
76
+ }
77
+ }
78
+ // Post blinds and antes
79
+ const blindPositions = (0, blinds_1.getBlindPositions)({
80
+ ...state,
81
+ buttonSeat: newButtonSeat,
82
+ players: newPlayers,
83
+ });
84
+ const currentBets = new Map();
85
+ if (blindPositions) {
86
+ const { smallBlindSeat, bigBlindSeat } = blindPositions;
87
+ // Post small blind
88
+ // In tournaments: sitting-out players MUST post to prevent "blinding off" exploit
89
+ // In cash games: sitting-out SB is treated as "Dead Small Blind" (no post)
90
+ const sbPlayer = newPlayers[smallBlindSeat];
91
+ if (sbPlayer && sbPlayer.stack > 0) {
92
+ const shouldPostSB = isTournament || !sbPlayer.isSittingOut;
93
+ if (shouldPostSB) {
94
+ const sbAmount = Math.min(sbPlayer.stack, state.smallBlind);
95
+ currentBets.set(smallBlindSeat, sbAmount);
96
+ newPlayers[smallBlindSeat] = {
97
+ ...sbPlayer,
98
+ stack: sbPlayer.stack - sbAmount,
99
+ betThisStreet: sbAmount,
100
+ totalInvestedThisHand: sbAmount,
101
+ status: sbAmount === sbPlayer.stack
102
+ ? "ALL_IN" /* PlayerStatus.ALL_IN */
103
+ : sbPlayer.isSittingOut
104
+ ? "FOLDED" /* PlayerStatus.FOLDED */
105
+ : "ACTIVE" /* PlayerStatus.ACTIVE */,
106
+ };
107
+ }
108
+ }
109
+ // If sbPlayer is null or (cash game && sitting out), Dead Small Blind applies
110
+ // Post big blind (Must exist for hand to start)
111
+ const bbPlayer = newPlayers[bigBlindSeat];
112
+ if (bbPlayer) {
113
+ const bbAmount = Math.min(bbPlayer.stack, state.bigBlind);
114
+ currentBets.set(bigBlindSeat, bbAmount);
115
+ newPlayers[bigBlindSeat] = {
116
+ ...bbPlayer,
117
+ stack: bbPlayer.stack - bbAmount,
118
+ betThisStreet: bbAmount,
119
+ totalInvestedThisHand: bbAmount,
120
+ status: bbAmount === bbPlayer.stack
121
+ ? "ALL_IN" /* PlayerStatus.ALL_IN */
122
+ : bbPlayer.isSittingOut
123
+ ? "FOLDED" /* PlayerStatus.FOLDED */
124
+ : "ACTIVE" /* PlayerStatus.ACTIVE */,
125
+ };
126
+ }
127
+ }
128
+ // Post antes if configured
129
+ // In tournaments: ALL players with chips must post (including sitting-out)
130
+ // In cash games: Only active players post
131
+ if (state.ante > 0) {
132
+ const playersToAnteFrom = isTournament
133
+ ? state.players.map((p, idx) => (p && p.stack > 0 ? idx : -1)).filter((idx) => idx >= 0)
134
+ : playersToReceive;
135
+ for (const seat of playersToAnteFrom) {
136
+ const player = newPlayers[seat];
137
+ const anteAmount = Math.min(player.stack, state.ante);
138
+ if (anteAmount > 0) {
139
+ const currentBet = currentBets.get(seat) ?? 0;
140
+ currentBets.set(seat, currentBet + anteAmount);
141
+ const newStack = player.stack - anteAmount;
142
+ newPlayers[seat] = {
143
+ ...player,
144
+ stack: newStack,
145
+ betThisStreet: player.betThisStreet + anteAmount,
146
+ totalInvestedThisHand: player.totalInvestedThisHand + anteAmount,
147
+ status: newStack === 0
148
+ ? "ALL_IN" /* PlayerStatus.ALL_IN */
149
+ : player.isSittingOut && isTournament
150
+ ? "FOLDED" /* PlayerStatus.FOLDED */
151
+ : player.status,
152
+ };
153
+ }
154
+ }
155
+ }
156
+ // Start with empty pots (bets will be collected when street progresses)
157
+ const pots = [];
158
+ // Get active players
159
+ const activePlayers = playersToReceive.filter((seat) => {
160
+ const player = newPlayers[seat];
161
+ return player.status === "ACTIVE" /* PlayerStatus.ACTIVE */;
162
+ });
163
+ const newState = {
164
+ ...state,
165
+ handNumber: state.handNumber + 1,
166
+ handId: `hand-${action.timestamp}-${Math.floor(rng() * 1000000)}`,
167
+ buttonSeat: newButtonSeat,
168
+ deck: remainingDeck,
169
+ board: [],
170
+ street: "PREFLOP" /* Street.PREFLOP */,
171
+ players: newPlayers,
172
+ pots,
173
+ currentBets,
174
+ minRaise: state.bigBlind,
175
+ lastRaiseAmount: state.bigBlind,
176
+ activePlayers,
177
+ winners: null,
178
+ rakeThisHand: 0,
179
+ actionHistory: [],
180
+ timestamp: action.timestamp,
181
+ };
182
+ // Set first to act
183
+ const firstToAct = (0, actionOrder_1.getFirstToAct)(newState);
184
+ return {
185
+ ...newState,
186
+ actionTo: firstToAct,
187
+ };
188
+ }
189
+ /**
190
+ * Move button to next seat
191
+ * Dead Button Rule: Moves to next index regardless of player presence
192
+ */
193
+ function moveButton(state) {
194
+ if (state.buttonSeat === null) {
195
+ // First hand, find first seated player
196
+ for (let seat = 0; seat < state.maxPlayers; seat++) {
197
+ if (state.players[seat] !== null) {
198
+ return seat;
199
+ }
200
+ }
201
+ return 0;
202
+ }
203
+ // Simply increment seat index (Dead Button)
204
+ // We do not skip empty seats here.
205
+ return (0, positioning_1.getNextSeat)(state.buttonSeat, state.maxPlayers);
206
+ }
@@ -0,0 +1,9 @@
1
+ import { GameState, SitAction, StandAction } from "@pokertools/types";
2
+ /**
3
+ * Handle SIT action - add player to table
4
+ */
5
+ export declare function handleSit(state: GameState, action: SitAction): GameState;
6
+ /**
7
+ * Handle STAND action - remove player from table
8
+ */
9
+ export declare function handleStand(state: GameState, action: StandAction): GameState;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleSit = handleSit;
4
+ exports.handleStand = handleStand;
5
+ const positioning_1 = require("../utils/positioning");
6
+ /**
7
+ * Handle SIT action - add player to table
8
+ */
9
+ function handleSit(state, action) {
10
+ const newPlayer = {
11
+ id: action.playerId,
12
+ name: action.playerName,
13
+ seat: action.seat,
14
+ stack: action.stack,
15
+ hand: null,
16
+ shownCards: null,
17
+ status: "WAITING" /* PlayerStatus.WAITING */,
18
+ betThisStreet: 0,
19
+ totalInvestedThisHand: 0,
20
+ isSittingOut: false,
21
+ timeBank: state.config.timeBankSeconds ?? 30,
22
+ };
23
+ const newPlayers = [...state.players];
24
+ newPlayers[action.seat] = newPlayer;
25
+ // Add to time banks
26
+ const newTimeBanks = new Map(state.timeBanks);
27
+ newTimeBanks.set(action.seat, newPlayer.timeBank);
28
+ return {
29
+ ...state,
30
+ players: newPlayers,
31
+ timeBanks: newTimeBanks,
32
+ timestamp: action.timestamp,
33
+ };
34
+ }
35
+ /**
36
+ * Handle STAND action - remove player from table
37
+ */
38
+ function handleStand(state, action) {
39
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
40
+ if (!result) {
41
+ return state;
42
+ }
43
+ const { seat } = result;
44
+ const newPlayers = [...state.players];
45
+ newPlayers[seat] = null;
46
+ // Remove from time banks
47
+ const newTimeBanks = new Map(state.timeBanks);
48
+ newTimeBanks.delete(seat);
49
+ // Remove from active players if present
50
+ const newActivePlayers = state.activePlayers.filter((s) => s !== seat);
51
+ return {
52
+ ...state,
53
+ players: newPlayers,
54
+ activePlayers: newActivePlayers,
55
+ timeBanks: newTimeBanks,
56
+ timestamp: action.timestamp,
57
+ };
58
+ }