@pokertools/engine 1.0.9 → 1.0.11

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.
@@ -15,18 +15,15 @@ const rake_1 = require("../utils/rake");
15
15
  function handleFold(state, action) {
16
16
  const result = (0, positioning_1.getPlayerById)(state, action.playerId);
17
17
  if (!result) {
18
- return state; // Should have been caught by validation
18
+ return state;
19
19
  }
20
20
  const { seat } = result;
21
21
  const newPlayers = [...state.players];
22
- // Set player status to FOLDED
23
22
  newPlayers[seat] = {
24
23
  ...newPlayers[seat],
25
24
  status: "FOLDED" /* PlayerStatus.FOLDED */,
26
25
  };
27
- // Remove from active players
28
26
  const newActivePlayers = state.activePlayers.filter((s) => s !== seat);
29
- // Add to action history
30
27
  const actionRecord = {
31
28
  action,
32
29
  seat,
@@ -39,20 +36,13 @@ function handleFold(state, action) {
39
36
  players: newPlayers,
40
37
  activePlayers: newActivePlayers,
41
38
  actionHistory: [...state.actionHistory, actionRecord],
42
- timeBankActiveSeat: null, // Clear time bank flag on any action
39
+ timeBankActiveSeat: null,
43
40
  timestamp: action.timestamp,
44
41
  };
45
- // Check if only one player with a live hand remains
46
- // Count players who have not folded (Active + All-In)
47
42
  const playersWithLiveHands = currentState.players.filter((p) => p && p.status !== "FOLDED" /* PlayerStatus.FOLDED */);
48
- // Only end the hand if exactly one player has cards
49
43
  if (playersWithLiveHands.length === 1 && playersWithLiveHands[0]) {
50
- // Award remaining pots to last player with live hand
51
44
  return awardPotToLastPlayer(currentState, playersWithLiveHands[0].seat);
52
45
  }
53
- // If we have 1 Active player but multiple Live players (others are All-In),
54
- // the game should naturally progress to Showdown via progressStreet/checkAutoRunout
55
- // Move to next player
56
46
  const nextToAct = (0, action_order_1.getNextToAct)(currentState);
57
47
  return {
58
48
  ...currentState,
@@ -68,7 +58,6 @@ function handleCheck(state, action) {
68
58
  return state;
69
59
  }
70
60
  const { seat } = result;
71
- // Add to action history
72
61
  const actionRecord = {
73
62
  action,
74
63
  seat,
@@ -79,10 +68,8 @@ function handleCheck(state, action) {
79
68
  const newState = {
80
69
  ...state,
81
70
  actionHistory: [...state.actionHistory, actionRecord],
82
- timeBankActiveSeat: null, // Clear time bank flag on any action
83
71
  timestamp: action.timestamp,
84
72
  };
85
- // Move to next player
86
73
  const nextToAct = (0, action_order_1.getNextToAct)(newState);
87
74
  return {
88
75
  ...newState,
@@ -98,14 +85,11 @@ function handleCall(state, action) {
98
85
  return state;
99
86
  }
100
87
  const { player, seat } = result;
101
- // Calculate amount to call
102
88
  const currentBet = getCurrentBet(state);
103
89
  const playerBet = state.currentBets.get(seat) ?? 0;
104
90
  const toCall = currentBet - playerBet;
105
- // Determine actual call amount (may be all-in)
106
91
  const callAmount = Math.min(toCall, player.stack);
107
92
  const isAllIn = callAmount === player.stack;
108
- // Update player
109
93
  const newPlayers = [...state.players];
110
94
  newPlayers[seat] = {
111
95
  ...player,
@@ -114,12 +98,10 @@ function handleCall(state, action) {
114
98
  totalInvestedThisHand: player.totalInvestedThisHand + callAmount,
115
99
  status: isAllIn ? "ALL_IN" /* PlayerStatus.ALL_IN */ : "ACTIVE" /* PlayerStatus.ACTIVE */,
116
100
  };
117
- // Update current bets
118
101
  const newCurrentBets = new Map(state.currentBets);
119
102
  newCurrentBets.set(seat, playerBet + callAmount);
120
- // Add to action history (include actual call amount in the action)
121
103
  const actionRecord = {
122
- action: { ...action, amount: callAmount }, // Populate amount field for history
104
+ action: { ...action, amount: callAmount },
123
105
  seat,
124
106
  resultingPot: getTotalPot(state) + callAmount,
125
107
  resultingStack: newPlayers[seat].stack,
@@ -130,10 +112,8 @@ function handleCall(state, action) {
130
112
  players: newPlayers,
131
113
  currentBets: newCurrentBets,
132
114
  actionHistory: [...state.actionHistory, actionRecord],
133
- timeBankActiveSeat: null, // Clear time bank flag on any action
134
115
  timestamp: action.timestamp,
135
116
  };
136
- // Move to next player
137
117
  const nextToAct = (0, action_order_1.getNextToAct)(newState);
138
118
  return {
139
119
  ...newState,
@@ -151,7 +131,6 @@ function handleBet(state, action) {
151
131
  const { player, seat } = result;
152
132
  const betAmount = Math.min(action.amount, player.stack);
153
133
  const isAllIn = betAmount === player.stack;
154
- // Update player
155
134
  const newPlayers = [...state.players];
156
135
  newPlayers[seat] = {
157
136
  ...player,
@@ -160,10 +139,8 @@ function handleBet(state, action) {
160
139
  totalInvestedThisHand: player.totalInvestedThisHand + betAmount,
161
140
  status: isAllIn ? "ALL_IN" /* PlayerStatus.ALL_IN */ : "ACTIVE" /* PlayerStatus.ACTIVE */,
162
141
  };
163
- // Update current bets
164
142
  const newCurrentBets = new Map(state.currentBets);
165
143
  newCurrentBets.set(seat, betAmount);
166
- // Add to action history
167
144
  const actionRecord = {
168
145
  action,
169
146
  seat,
@@ -175,14 +152,12 @@ function handleBet(state, action) {
175
152
  ...state,
176
153
  players: newPlayers,
177
154
  currentBets: newCurrentBets,
178
- minRaise: betAmount + betAmount, // Min raise is current bet + raise increment
155
+ minRaise: betAmount + betAmount,
179
156
  lastRaiseAmount: betAmount,
180
157
  lastAggressorSeat: seat,
181
158
  actionHistory: [...state.actionHistory, actionRecord],
182
- timeBankActiveSeat: null, // Clear time bank flag on any action
183
159
  timestamp: action.timestamp,
184
160
  };
185
- // Move to next player
186
161
  const nextToAct = (0, action_order_1.getNextToAct)(newState);
187
162
  return {
188
163
  ...newState,
@@ -203,9 +178,7 @@ function handleRaise(state, action) {
203
178
  const raiseAmount = Math.min(action.amount, playerBet + player.stack);
204
179
  const addedChips = raiseAmount - playerBet;
205
180
  const isAllIn = addedChips === player.stack;
206
- // Calculate raise increment
207
181
  const raiseIncrement = raiseAmount - currentBet;
208
- // Update player
209
182
  const newPlayers = [...state.players];
210
183
  newPlayers[seat] = {
211
184
  ...player,
@@ -214,10 +187,8 @@ function handleRaise(state, action) {
214
187
  totalInvestedThisHand: player.totalInvestedThisHand + addedChips,
215
188
  status: isAllIn ? "ALL_IN" /* PlayerStatus.ALL_IN */ : "ACTIVE" /* PlayerStatus.ACTIVE */,
216
189
  };
217
- // Update current bets
218
190
  const newCurrentBets = new Map(state.currentBets);
219
191
  newCurrentBets.set(seat, raiseAmount);
220
- // Add to action history
221
192
  const actionRecord = {
222
193
  action,
223
194
  seat,
@@ -225,15 +196,10 @@ function handleRaise(state, action) {
225
196
  resultingStack: newPlayers[seat].stack,
226
197
  street: state.street,
227
198
  };
228
- // Determine if this reopens betting (incomplete raise rule)
199
+ // Incomplete-raise rule: does this raise reopen the betting?
229
200
  const reopensBetting = raiseIncrement >= state.lastRaiseAmount;
230
- // Min-raise calculation:
231
- // - If reopens betting: new currentBet + new increment
232
- // - If incomplete raise: new currentBet + old increment (TDA/WSOP rule)
233
- // Example: P1 bets 100, P2 all-in 120, P3 must raise to 120+100=220 minimum
234
- const newMinRaise = reopensBetting
235
- ? raiseAmount + raiseIncrement
236
- : raiseAmount + state.lastRaiseAmount;
201
+ // Incomplete all-in raises do not change the next minimum full raise.
202
+ const newMinRaise = reopensBetting ? raiseAmount + raiseIncrement : state.minRaise;
237
203
  const newState = {
238
204
  ...state,
239
205
  players: newPlayers,
@@ -242,10 +208,8 @@ function handleRaise(state, action) {
242
208
  lastRaiseAmount: reopensBetting ? raiseIncrement : state.lastRaiseAmount,
243
209
  lastAggressorSeat: reopensBetting ? seat : state.lastAggressorSeat,
244
210
  actionHistory: [...state.actionHistory, actionRecord],
245
- timeBankActiveSeat: null, // Clear time bank flag on any action
246
211
  timestamp: action.timestamp,
247
212
  };
248
- // Move to next player
249
213
  const nextToAct = (0, action_order_1.getNextToAct)(newState);
250
214
  return {
251
215
  ...newState,
@@ -278,31 +242,26 @@ function getTotalPot(state) {
278
242
  return total;
279
243
  }
280
244
  /**
281
- * Award pots to remaining eligible players when hand ends by folds
282
- * Properly handles side pot eligibility and uncalled bets
245
+ * Award pots to remaining eligible players when hand ends by folds.
283
246
  *
284
247
  * Key principle: Uncalled bets are NOT raked and are returned to the bettor immediately.
285
- * Only the contested portion of the pot (money actually at risk) is subject to rake.
248
+ * Only the contested portion is subject to rake.
286
249
  */
287
250
  function awardPotToLastPlayer(state, winningSeat) {
288
251
  const newPlayers = [...state.players];
289
252
  const newActionHistory = [...state.actionHistory];
290
253
  const winners = [];
291
- // Process each pot separately, checking eligibility
292
254
  let totalRakeFromPots = 0;
293
255
  for (const pot of state.pots) {
294
- // Find all non-folded players eligible for this pot
295
256
  const eligibleNonFolded = pot.eligibleSeats.filter((seat) => {
296
257
  const player = state.players[seat];
297
258
  return player && player.status !== "FOLDED" /* PlayerStatus.FOLDED */;
298
259
  });
299
- // Calculate rake for this pot - GLOBAL cap applied across all pots
300
260
  const { rake: potRake } = (0, rake_1.calculateRake)(state, pot.amount, totalRakeFromPots);
301
261
  totalRakeFromPots += potRake;
302
262
  const potAfterRake = pot.amount - potRake;
303
263
  if (eligibleNonFolded.length === 0) {
304
- // No eligible players remain - should not happen, but defensive
305
- // Award to last player to fold from eligible seats (fallback)
264
+ // Defensive fallback: no eligible players remain.
306
265
  const lastEligible = pot.eligibleSeats[pot.eligibleSeats.length - 1];
307
266
  const player = newPlayers[lastEligible];
308
267
  if (player) {
@@ -319,7 +278,6 @@ function awardPotToLastPlayer(state, winningSeat) {
319
278
  }
320
279
  }
321
280
  else if (eligibleNonFolded.length === 1) {
322
- // Exactly one eligible player - they win this pot
323
281
  const winnerSeat = eligibleNonFolded[0];
324
282
  const player = newPlayers[winnerSeat];
325
283
  newPlayers[winnerSeat] = {
@@ -334,8 +292,6 @@ function awardPotToLastPlayer(state, winningSeat) {
334
292
  });
335
293
  }
336
294
  else {
337
- // Multiple eligible players remain - this means awardPotToLastPlayer was called incorrectly
338
- // The hand should have gone to showdown instead
339
295
  throw new critical_state_error_1.CriticalStateError("awardPotToLastPlayer called with multiple eligible players remaining", {
340
296
  potAmount: pot.amount,
341
297
  eligibleSeats: pot.eligibleSeats,
@@ -344,28 +300,25 @@ function awardPotToLastPlayer(state, winningSeat) {
344
300
  });
345
301
  }
346
302
  }
347
- // Handle current bets with proper uncalled bet logic
303
+ // Uncalled bet logic: three-step process.
348
304
  if (state.currentBets.size > 0) {
349
305
  const winnersBet = state.currentBets.get(winningSeat) ?? 0;
350
- // Find the second-highest bet (highest opponent bet)
351
- // This determines how much of the winner's bet was actually "called"
306
+ // Second-highest bet determines how much of winner's bet was actually "called".
352
307
  let maxOpponentBet = 0;
353
308
  for (const [seat, amount] of state.currentBets.entries()) {
354
309
  if (seat !== winningSeat && amount > maxOpponentBet) {
355
310
  maxOpponentBet = amount;
356
311
  }
357
312
  }
358
- // Calculate uncalled and called portions
359
313
  const uncalledAmount = winnersBet > maxOpponentBet ? winnersBet - maxOpponentBet : 0;
360
314
  const calledPortion = winnersBet > maxOpponentBet ? maxOpponentBet : winnersBet;
361
- // Step 1: Return uncalled bet immediately (NO RAKE on uncalled bets)
315
+ // Step 1: Return uncalled bet immediately (NO RAKE on uncalled bets).
362
316
  if (uncalledAmount > 0) {
363
317
  const player = newPlayers[winningSeat];
364
318
  newPlayers[winningSeat] = {
365
319
  ...player,
366
320
  stack: player.stack + uncalledAmount,
367
321
  };
368
- // Record the uncalled bet return
369
322
  newActionHistory.push({
370
323
  action: {
371
324
  type: "UNCALLED_BET_RETURNED" /* ActionType.UNCALLED_BET_RETURNED */,
@@ -379,14 +332,14 @@ function awardPotToLastPlayer(state, winningSeat) {
379
332
  street: state.street,
380
333
  });
381
334
  }
382
- // Step 2: Calculate contested pot (winner's called portion + all opponent bets)
335
+ // Step 2: Contested pot = winner's called portion + all opponent bets.
383
336
  let contestedPot = calledPortion;
384
337
  for (const [seat, amount] of state.currentBets.entries()) {
385
338
  if (seat !== winningSeat) {
386
339
  contestedPot += amount;
387
340
  }
388
341
  }
389
- // Step 3: Rake and award the contested portion only
342
+ // Step 3: Rake and award the contested portion only.
390
343
  if (contestedPot > 0) {
391
344
  const { rake } = (0, rake_1.calculateRake)(state, contestedPot, totalRakeFromPots);
392
345
  const totalRake = totalRakeFromPots + rake;
@@ -396,8 +349,6 @@ function awardPotToLastPlayer(state, winningSeat) {
396
349
  ...player,
397
350
  stack: player.stack + winnings,
398
351
  };
399
- // Update winners array
400
- // Only count actual winnings (contested pot after rake, minus winner's own contribution)
401
352
  const actualWinnings = winnings - calledPortion;
402
353
  if (actualWinnings > 0) {
403
354
  const existingIndex = winners.findIndex((w) => w.seat === winningSeat);
@@ -416,16 +367,13 @@ function awardPotToLastPlayer(state, winningSeat) {
416
367
  });
417
368
  }
418
369
  }
419
- // Update total rake for the hand
420
370
  totalRakeFromPots = totalRake;
421
371
  }
422
372
  }
423
- // NOTE: We do NOT reset totalInvestedThisHand here because it's used by getInitialChips()
424
- // to calculate total chips in the game. It will be reset when a new hand is dealt.
425
373
  return {
426
374
  ...state,
427
375
  players: newPlayers,
428
- street: "SHOWDOWN" /* Street.SHOWDOWN */, // Mark hand as complete
376
+ street: "SHOWDOWN" /* Street.SHOWDOWN */,
429
377
  pots: [],
430
378
  currentBets: new Map(),
431
379
  winners,
@@ -14,66 +14,56 @@ const positioning_1 = require("../utils/positioning");
14
14
  * - Sets action to first to act
15
15
  */
16
16
  function handleDeal(state, action) {
17
- // Move button (Dead Button logic: moves to next seat index regardless of occupancy)
17
+ // Dead Button rule: advances to next seat index regardless of occupancy.
18
18
  let newButtonSeat = moveButton(state);
19
- // Determine if this is a tournament
20
19
  const isTournament = !!state.config.blindStructure;
21
20
  const isClient = !!state.config.isClient;
22
- // Create and shuffle deck (server only)
23
- // In client mode, we use an empty deck and deal masked cards
21
+ // Client mode: empty deck, cards dealt as masked (null).
24
22
  const rng = state.config.randomProvider ?? Math.random;
25
23
  const deck = isClient ? [] : (0, deck_1.shuffle)((0, deck_1.createDeck)(), rng);
26
- // Create a copy of timeBanks to modify
27
24
  const newTimeBanks = new Map(state.timeBanks);
28
- // First, merge pendingAddOn into stack for all players
25
+ // Merge pending add-ons; expire stale reservations.
29
26
  const newPlayers = state.players.map((player) => {
30
27
  if (!player)
31
28
  return null;
32
- // Skip reserved players (they haven't confirmed yet)
33
29
  if (player.status === "RESERVED" /* PlayerStatus.RESERVED */) {
34
- // Check if reservation has expired
35
30
  if (player.reservationExpiry && action.timestamp >= player.reservationExpiry) {
36
- // Reservation expired, remove player
37
31
  newTimeBanks.delete(player.seat);
38
32
  return null;
39
33
  }
40
- // Keep reserved player as-is
41
34
  return player;
42
35
  }
43
- // Merge pendingAddOn into stack
44
36
  const newStack = player.stack + player.pendingAddOn;
45
37
  return {
46
38
  ...player,
47
39
  stack: newStack,
48
- pendingAddOn: 0, // Clear pending add-on
40
+ pendingAddOn: 0,
49
41
  };
50
42
  });
51
43
  newButtonSeat = moveHeadsUpButtonToOccupiedSeat(newButtonSeat, {
52
44
  ...state,
53
45
  players: newPlayers,
54
46
  });
55
- // Get blind positions for this hand
47
+ // Recompute blind positions with updated button/players.
56
48
  const blindPositions = (0, blinds_1.getBlindPositions)({
57
49
  ...state,
58
50
  buttonSeat: newButtonSeat,
59
51
  players: newPlayers,
60
52
  });
61
- // Get players who will be dealt in
53
+ // Determine which players receive cards.
62
54
  const playersToReceive = [];
63
55
  for (let seat = 0; seat < newPlayers.length; seat++) {
64
56
  const player = newPlayers[seat];
65
- // Basic eligibility checks
66
57
  if (!player || player.stack <= 0 || player.status === "RESERVED" /* PlayerStatus.RESERVED */) {
67
58
  continue;
68
59
  }
69
- // We check WAIT_FOR_BB *before* checking isSittingOut.
70
- // This allows us to "unsit" a player if they hit the Big Blind.
60
+ // WAIT_FOR_BB check: sit player IN when they reach the Big Blind,
61
+ // sit player OUT when they haven't reached it yet.
71
62
  let shouldPlay = true;
72
63
  if (!isTournament && player.sitInOption === "WAIT_FOR_BB" /* SitInOption.WAIT_FOR_BB */) {
73
64
  const isInBigBlind = blindPositions?.bigBlindSeat === seat;
74
65
  if (isInBigBlind) {
75
- // PLAYER RE-ENTRY: They are in the Big Blind. Force them active.
76
- // We must update the player object in newPlayers to reflect they are back.
66
+ // PLAYER RE-ENTRY: Force them active when they are in the Big Blind.
77
67
  newPlayers[seat] = {
78
68
  ...player,
79
69
  isSittingOut: false,
@@ -81,8 +71,7 @@ function handleDeal(state, action) {
81
71
  shouldPlay = true;
82
72
  }
83
73
  else {
84
- // Not in BB yet. Force them to sit out.
85
- // Only update if not already sitting out to avoid object churn
74
+ // Not yet in BB; force sit-out (avoids object churn if already sitting out).
86
75
  if (!player.isSittingOut) {
87
76
  newPlayers[seat] = {
88
77
  ...player,
@@ -93,67 +82,60 @@ function handleDeal(state, action) {
93
82
  }
94
83
  }
95
84
  else if (player.isSittingOut) {
96
- // Standard sitting out check
97
85
  shouldPlay = false;
98
86
  }
99
87
  if (shouldPlay) {
100
88
  playersToReceive.push(seat);
101
89
  }
102
90
  }
103
- // Deal 2 cards to each player
104
91
  let remainingDeck = deck;
105
- // Initialize hands for receiving players (active, not sitting out)
106
92
  for (const seat of playersToReceive) {
107
93
  newPlayers[seat] = {
108
94
  ...newPlayers[seat],
109
- hand: [], // Initialize empty array
110
- shownCards: null, // Reset from previous hand
95
+ hand: [],
96
+ shownCards: null,
111
97
  status: "ACTIVE" /* PlayerStatus.ACTIVE */,
112
98
  betThisStreet: 0,
113
99
  totalInvestedThisHand: 0,
114
100
  };
115
101
  }
116
- // In tournaments, initialize sitting-out players too (they must post blinds/antes)
102
+ // Tournament: sitting-out players initialized as FOLDED (must post blinds/antes).
117
103
  if (isTournament) {
118
104
  for (let seat = 0; seat < newPlayers.length; seat++) {
119
105
  const player = newPlayers[seat];
120
106
  if (player && player.stack > 0 && player.isSittingOut && !playersToReceive.includes(seat)) {
121
107
  newPlayers[seat] = {
122
108
  ...player,
123
- hand: null, // No cards dealt
109
+ hand: null,
124
110
  shownCards: null,
125
- status: "FOLDED" /* PlayerStatus.FOLDED */, // Start as folded
111
+ status: "FOLDED" /* PlayerStatus.FOLDED */,
126
112
  betThisStreet: 0,
127
113
  totalInvestedThisHand: 0,
128
114
  };
129
115
  }
130
116
  }
131
117
  }
132
- // Deal 2 cards, one by one, in circle (standard poker procedure)
118
+ // Deal 2 hole cards, one at a time, clockwise.
133
119
  for (let round = 0; round < 2; round++) {
134
120
  for (const seat of playersToReceive) {
135
121
  let cardStrings;
136
122
  if (isClient) {
137
- // Client mode: Deal masked cards
138
123
  cardStrings = [null];
139
124
  }
140
125
  else {
141
- // Server mode: Deal from deck
142
126
  const [cards, nextDeck] = (0, deck_1.dealCards)(remainingDeck, 1);
143
127
  remainingDeck = nextDeck;
144
128
  cardStrings = (0, card_utils_1.cardCodesToStrings)(cards);
145
129
  }
146
- // Append to existing hand
147
130
  const currentPlayer = newPlayers[seat];
148
- const currentHand = currentPlayer.hand ?? []; // Should be [] from initialization
131
+ const currentHand = currentPlayer.hand ?? [];
149
132
  newPlayers[seat] = {
150
133
  ...currentPlayer,
151
134
  hand: [...currentHand, ...cardStrings],
152
135
  };
153
136
  }
154
137
  }
155
- // Post blinds and antes
156
- // Recalculate blind positions with the new button seat
138
+ // Post blinds and antes.
157
139
  const finalBlindPositions = (0, blinds_1.getBlindPositions)({
158
140
  ...state,
159
141
  buttonSeat: newButtonSeat,
@@ -162,9 +144,6 @@ function handleDeal(state, action) {
162
144
  const currentBets = new Map();
163
145
  if (finalBlindPositions) {
164
146
  const { smallBlindSeat, bigBlindSeat } = finalBlindPositions;
165
- // Post small blind
166
- // In tournaments: sitting-out players MUST post to prevent "blinding off" exploit
167
- // In cash games: sitting-out SB is treated as "Dead Small Blind" (no post)
168
147
  const sbPlayer = newPlayers[smallBlindSeat];
169
148
  if (sbPlayer && sbPlayer.stack > 0) {
170
149
  const shouldPostSB = isTournament || !sbPlayer.isSittingOut;
@@ -184,12 +163,11 @@ function handleDeal(state, action) {
184
163
  };
185
164
  }
186
165
  }
187
- // If sbPlayer is null or (cash game && sitting out), Dead Small Blind applies
188
- // Post big blind (Must exist for hand to start)
166
+ // Dead Small Blind: sbPlayer is null or (cash game && sitting out).
189
167
  const bbPlayer = newPlayers[bigBlindSeat];
190
168
  if (bbPlayer) {
191
- // In Cash Games: sitting-out players should NEVER post blinds (they are skipped by getBlindPositions)
192
- // In Tournaments: sitting-out players MUST post blinds to prevent "blinding off" exploit
169
+ // In cash games sitting-out players are skipped by getBlindPositions.
170
+ // In tournaments sitting-out players MUST post blinds (anti-blinding-off).
193
171
  const shouldPostBB = isTournament || !bbPlayer.isSittingOut;
194
172
  if (shouldPostBB) {
195
173
  const bbAmount = Math.min(bbPlayer.stack, state.bigBlind);
@@ -208,9 +186,7 @@ function handleDeal(state, action) {
208
186
  }
209
187
  }
210
188
  }
211
- // Post antes if configured
212
- // In tournaments: ALL players with chips must post (including sitting-out)
213
- // In cash games: Only active players post
189
+ // Antes: tournament all players with chips (incl. sitting-out); cash — active only.
214
190
  if (state.ante > 0) {
215
191
  const playersToAnteFrom = isTournament
216
192
  ? state.players.map((p, idx) => (p && p.stack > 0 ? idx : -1)).filter((idx) => idx >= 0)
@@ -236,9 +212,7 @@ function handleDeal(state, action) {
236
212
  }
237
213
  }
238
214
  }
239
- // Start with empty pots (bets will be collected when street progresses)
240
215
  const pots = [];
241
- // Get active players
242
216
  const activePlayers = playersToReceive.filter((seat) => {
243
217
  const player = newPlayers[seat];
244
218
  return player.status === "ACTIVE" /* PlayerStatus.ACTIVE */;
@@ -258,13 +232,13 @@ function handleDeal(state, action) {
258
232
  Array.from(currentBets.values()).reduce((sum, amount) => sum + amount, 0),
259
233
  minRaise: state.bigBlind,
260
234
  lastRaiseAmount: state.bigBlind,
235
+ lastAggressorSeat: null,
261
236
  activePlayers,
262
237
  winners: null,
263
238
  rakeThisHand: 0,
264
239
  actionHistory: [],
265
240
  timestamp: action.timestamp,
266
241
  };
267
- // Set first to act
268
242
  const firstToAct = (0, action_order_1.getFirstToAct)(newState);
269
243
  return {
270
244
  ...newState,
@@ -277,7 +251,6 @@ function handleDeal(state, action) {
277
251
  */
278
252
  function moveButton(state) {
279
253
  if (state.buttonSeat === null) {
280
- // First hand, find first seated player
281
254
  for (let seat = 0; seat < state.maxPlayers; seat++) {
282
255
  if (state.players[seat] !== null) {
283
256
  return seat;
@@ -285,8 +258,7 @@ function moveButton(state) {
285
258
  }
286
259
  return 0;
287
260
  }
288
- // Simply increment seat index (Dead Button)
289
- // We do not skip empty seats here.
261
+ // Dead Button: advance to next seat index regardless of occupancy.
290
262
  return (0, positioning_1.getNextSeat)(state.buttonSeat, state.maxPlayers);
291
263
  }
292
264
  function moveHeadsUpButtonToOccupiedSeat(buttonSeat, state) {
@@ -28,7 +28,6 @@ function handleSit(state, action) {
28
28
  };
29
29
  const newPlayers = [...state.players];
30
30
  newPlayers[action.seat] = newPlayer;
31
- // Add to time banks
32
31
  const newTimeBanks = new Map(state.timeBanks);
33
32
  newTimeBanks.set(action.seat, newPlayer.timeBank);
34
33
  return {
@@ -3,47 +3,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.handleShow = handleShow;
4
4
  exports.handleMuck = handleMuck;
5
5
  const illegal_action_error_1 = require("../errors/illegal-action-error");
6
- const error_codes_1 = require("../errors/error-codes");
6
+ const types_1 = require("@pokertools/types");
7
7
  /**
8
8
  * Handle SHOW action - player reveals their cards at showdown
9
9
  */
10
10
  function handleShow(state, action) {
11
- // Find player
12
11
  const player = state.players.find((p) => p?.id === action.playerId);
13
12
  if (!player) {
14
- throw new illegal_action_error_1.IllegalActionError(error_codes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
13
+ throw new illegal_action_error_1.IllegalActionError(types_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
15
14
  }
16
- // Can only show at showdown
17
15
  if (state.street !== "SHOWDOWN" /* Street.SHOWDOWN */) {
18
- throw new illegal_action_error_1.IllegalActionError(error_codes_1.ErrorCodes.INVALID_ACTION, `Can only show cards at showdown (current street: ${state.street})`, { playerId: action.playerId, street: state.street });
16
+ throw new illegal_action_error_1.IllegalActionError(types_1.ErrorCodes.INVALID_ACTION, `Can only show cards at showdown (current street: ${state.street})`, { playerId: action.playerId, street: state.street });
19
17
  }
20
- // Player must not have folded
21
18
  if (player.status === "FOLDED" /* PlayerStatus.FOLDED */) {
22
- throw new illegal_action_error_1.IllegalActionError(error_codes_1.ErrorCodes.INVALID_ACTION, `Cannot show cards after folding`, {
19
+ throw new illegal_action_error_1.IllegalActionError(types_1.ErrorCodes.INVALID_ACTION, `Cannot show cards after folding`, {
23
20
  playerId: action.playerId,
24
21
  status: player.status,
25
22
  });
26
23
  }
27
- // Player must have cards
28
24
  if (!player.hand) {
29
- throw new illegal_action_error_1.IllegalActionError(error_codes_1.ErrorCodes.INVALID_ACTION, `Player has no cards to show`, {
25
+ throw new illegal_action_error_1.IllegalActionError(types_1.ErrorCodes.INVALID_ACTION, `Player has no cards to show`, {
30
26
  playerId: action.playerId,
31
27
  });
32
28
  }
33
- // Determine which cards to show
34
29
  let cardIndices;
35
30
  if (action.cardIndices && action.cardIndices.length > 0) {
36
- // Validate indices are within bounds
37
31
  cardIndices = action.cardIndices.filter((i) => i >= 0 && i < player.hand.length);
38
- if (cardIndices.length === 0) {
39
- return state; // Invalid indices
40
- }
32
+ if (cardIndices.length === 0)
33
+ return state;
41
34
  }
42
35
  else {
43
- // Default: show all cards
44
36
  cardIndices = Array.from({ length: player.hand.length }, (_, i) => i);
45
37
  }
46
- // Update player's shown cards
47
38
  const newPlayers = [...state.players];
48
39
  newPlayers[player.seat] = {
49
40
  ...player,
@@ -72,32 +63,27 @@ function handleShow(state, action) {
72
63
  * Handle MUCK action - player hides their cards at showdown
73
64
  */
74
65
  function handleMuck(state, action) {
75
- // Find player
76
66
  const player = state.players.find((p) => p?.id === action.playerId);
77
67
  if (!player) {
78
- throw new illegal_action_error_1.IllegalActionError(error_codes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
68
+ throw new illegal_action_error_1.IllegalActionError(types_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
79
69
  }
80
- // Can only muck at showdown
81
70
  if (state.street !== "SHOWDOWN" /* Street.SHOWDOWN */) {
82
- throw new illegal_action_error_1.IllegalActionError(error_codes_1.ErrorCodes.INVALID_ACTION, `Can only muck cards at showdown (current street: ${state.street})`, { playerId: action.playerId, street: state.street });
71
+ throw new illegal_action_error_1.IllegalActionError(types_1.ErrorCodes.INVALID_ACTION, `Can only muck cards at showdown (current street: ${state.street})`, { playerId: action.playerId, street: state.street });
83
72
  }
84
- // Player must not have folded
85
73
  if (player.status === "FOLDED" /* PlayerStatus.FOLDED */) {
86
- throw new illegal_action_error_1.IllegalActionError(error_codes_1.ErrorCodes.INVALID_ACTION, `Cannot muck cards after folding`, {
74
+ throw new illegal_action_error_1.IllegalActionError(types_1.ErrorCodes.INVALID_ACTION, `Cannot muck cards after folding`, {
87
75
  playerId: action.playerId,
88
76
  status: player.status,
89
77
  });
90
78
  }
91
- // Cannot muck if you're a winner (winners must show)
92
79
  const isWinner = state.winners?.some((w) => w.seat === player.seat);
93
80
  if (isWinner) {
94
- throw new illegal_action_error_1.IllegalActionError(error_codes_1.ErrorCodes.INVALID_ACTION, `Winners cannot muck - cards must be shown`, { playerId: action.playerId });
81
+ throw new illegal_action_error_1.IllegalActionError(types_1.ErrorCodes.INVALID_ACTION, `Winners cannot muck - cards must be shown`, { playerId: action.playerId });
95
82
  }
96
- // Set shown cards to null (mucked) - hand is preserved in player.hand
97
83
  const newPlayers = [...state.players];
98
84
  newPlayers[player.seat] = {
99
85
  ...player,
100
- shownCards: null, // Muck cards (hide them, but preserve hand data)
86
+ shownCards: null,
101
87
  };
102
88
  const actionRecord = {
103
89
  action: {