@pokertools/engine 1.0.9 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/actions/betting.js +15 -65
- package/dist/actions/dealing.js +23 -52
- package/dist/actions/management.js +0 -1
- package/dist/actions/showdown-actions.js +12 -26
- package/dist/actions/special.js +0 -4
- package/dist/actions/street-progression.js +9 -24
- package/dist/actions/tournament.js +1 -4
- package/dist/actions/validation.js +28 -34
- package/dist/browser.js +0 -1
- package/dist/engine/game-reducer.js +5 -24
- package/dist/engine/poker-engine.js +6 -20
- package/dist/errors/illegal-action-error.d.ts +1 -1
- package/dist/errors/index.js +0 -2
- package/dist/errors/poker-engine-error.js +2 -0
- package/dist/history/formats/pokerstars.js +0 -8
- package/dist/history/hand-history-builder.js +1 -2
- package/dist/index.js +0 -1
- package/dist/rules/action-order.js +2 -7
- package/dist/rules/blinds.js +1 -1
- package/dist/rules/heads-up.js +0 -1
- package/dist/rules/showdown.js +11 -28
- package/dist/rules/side-pots.js +5 -18
- package/dist/utils/deck.js +1 -4
- package/dist/utils/rake.js +6 -8
- package/dist/utils/serialization.js +2 -3
- package/dist/utils/validation.js +7 -7
- package/package.json +6 -6
- package/dist/errors/error-codes.d.ts +0 -7
- package/dist/errors/error-codes.js +0 -12
package/dist/actions/betting.js
CHANGED
|
@@ -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;
|
|
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,
|
|
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 },
|
|
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,
|
|
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,12 +196,9 @@ function handleRaise(state, action) {
|
|
|
225
196
|
resultingStack: newPlayers[seat].stack,
|
|
226
197
|
street: state.street,
|
|
227
198
|
};
|
|
228
|
-
//
|
|
199
|
+
// Incomplete-raise rule: does this raise reopen the betting?
|
|
229
200
|
const reopensBetting = raiseIncrement >= state.lastRaiseAmount;
|
|
230
|
-
//
|
|
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
|
|
201
|
+
// When reopened, min raise = new bet + new increment; otherwise, new bet + old increment.
|
|
234
202
|
const newMinRaise = reopensBetting
|
|
235
203
|
? raiseAmount + raiseIncrement
|
|
236
204
|
: raiseAmount + state.lastRaiseAmount;
|
|
@@ -242,10 +210,8 @@ function handleRaise(state, action) {
|
|
|
242
210
|
lastRaiseAmount: reopensBetting ? raiseIncrement : state.lastRaiseAmount,
|
|
243
211
|
lastAggressorSeat: reopensBetting ? seat : state.lastAggressorSeat,
|
|
244
212
|
actionHistory: [...state.actionHistory, actionRecord],
|
|
245
|
-
timeBankActiveSeat: null, // Clear time bank flag on any action
|
|
246
213
|
timestamp: action.timestamp,
|
|
247
214
|
};
|
|
248
|
-
// Move to next player
|
|
249
215
|
const nextToAct = (0, action_order_1.getNextToAct)(newState);
|
|
250
216
|
return {
|
|
251
217
|
...newState,
|
|
@@ -278,31 +244,26 @@ function getTotalPot(state) {
|
|
|
278
244
|
return total;
|
|
279
245
|
}
|
|
280
246
|
/**
|
|
281
|
-
* Award pots to remaining eligible players when hand ends by folds
|
|
282
|
-
* Properly handles side pot eligibility and uncalled bets
|
|
247
|
+
* Award pots to remaining eligible players when hand ends by folds.
|
|
283
248
|
*
|
|
284
249
|
* Key principle: Uncalled bets are NOT raked and are returned to the bettor immediately.
|
|
285
|
-
* Only the contested portion
|
|
250
|
+
* Only the contested portion is subject to rake.
|
|
286
251
|
*/
|
|
287
252
|
function awardPotToLastPlayer(state, winningSeat) {
|
|
288
253
|
const newPlayers = [...state.players];
|
|
289
254
|
const newActionHistory = [...state.actionHistory];
|
|
290
255
|
const winners = [];
|
|
291
|
-
// Process each pot separately, checking eligibility
|
|
292
256
|
let totalRakeFromPots = 0;
|
|
293
257
|
for (const pot of state.pots) {
|
|
294
|
-
// Find all non-folded players eligible for this pot
|
|
295
258
|
const eligibleNonFolded = pot.eligibleSeats.filter((seat) => {
|
|
296
259
|
const player = state.players[seat];
|
|
297
260
|
return player && player.status !== "FOLDED" /* PlayerStatus.FOLDED */;
|
|
298
261
|
});
|
|
299
|
-
// Calculate rake for this pot - GLOBAL cap applied across all pots
|
|
300
262
|
const { rake: potRake } = (0, rake_1.calculateRake)(state, pot.amount, totalRakeFromPots);
|
|
301
263
|
totalRakeFromPots += potRake;
|
|
302
264
|
const potAfterRake = pot.amount - potRake;
|
|
303
265
|
if (eligibleNonFolded.length === 0) {
|
|
304
|
-
//
|
|
305
|
-
// Award to last player to fold from eligible seats (fallback)
|
|
266
|
+
// Defensive fallback: no eligible players remain.
|
|
306
267
|
const lastEligible = pot.eligibleSeats[pot.eligibleSeats.length - 1];
|
|
307
268
|
const player = newPlayers[lastEligible];
|
|
308
269
|
if (player) {
|
|
@@ -319,7 +280,6 @@ function awardPotToLastPlayer(state, winningSeat) {
|
|
|
319
280
|
}
|
|
320
281
|
}
|
|
321
282
|
else if (eligibleNonFolded.length === 1) {
|
|
322
|
-
// Exactly one eligible player - they win this pot
|
|
323
283
|
const winnerSeat = eligibleNonFolded[0];
|
|
324
284
|
const player = newPlayers[winnerSeat];
|
|
325
285
|
newPlayers[winnerSeat] = {
|
|
@@ -334,8 +294,6 @@ function awardPotToLastPlayer(state, winningSeat) {
|
|
|
334
294
|
});
|
|
335
295
|
}
|
|
336
296
|
else {
|
|
337
|
-
// Multiple eligible players remain - this means awardPotToLastPlayer was called incorrectly
|
|
338
|
-
// The hand should have gone to showdown instead
|
|
339
297
|
throw new critical_state_error_1.CriticalStateError("awardPotToLastPlayer called with multiple eligible players remaining", {
|
|
340
298
|
potAmount: pot.amount,
|
|
341
299
|
eligibleSeats: pot.eligibleSeats,
|
|
@@ -344,28 +302,25 @@ function awardPotToLastPlayer(state, winningSeat) {
|
|
|
344
302
|
});
|
|
345
303
|
}
|
|
346
304
|
}
|
|
347
|
-
//
|
|
305
|
+
// Uncalled bet logic: three-step process.
|
|
348
306
|
if (state.currentBets.size > 0) {
|
|
349
307
|
const winnersBet = state.currentBets.get(winningSeat) ?? 0;
|
|
350
|
-
//
|
|
351
|
-
// This determines how much of the winner's bet was actually "called"
|
|
308
|
+
// Second-highest bet determines how much of winner's bet was actually "called".
|
|
352
309
|
let maxOpponentBet = 0;
|
|
353
310
|
for (const [seat, amount] of state.currentBets.entries()) {
|
|
354
311
|
if (seat !== winningSeat && amount > maxOpponentBet) {
|
|
355
312
|
maxOpponentBet = amount;
|
|
356
313
|
}
|
|
357
314
|
}
|
|
358
|
-
// Calculate uncalled and called portions
|
|
359
315
|
const uncalledAmount = winnersBet > maxOpponentBet ? winnersBet - maxOpponentBet : 0;
|
|
360
316
|
const calledPortion = winnersBet > maxOpponentBet ? maxOpponentBet : winnersBet;
|
|
361
|
-
// Step 1: Return uncalled bet immediately (NO RAKE on uncalled bets)
|
|
317
|
+
// Step 1: Return uncalled bet immediately (NO RAKE on uncalled bets).
|
|
362
318
|
if (uncalledAmount > 0) {
|
|
363
319
|
const player = newPlayers[winningSeat];
|
|
364
320
|
newPlayers[winningSeat] = {
|
|
365
321
|
...player,
|
|
366
322
|
stack: player.stack + uncalledAmount,
|
|
367
323
|
};
|
|
368
|
-
// Record the uncalled bet return
|
|
369
324
|
newActionHistory.push({
|
|
370
325
|
action: {
|
|
371
326
|
type: "UNCALLED_BET_RETURNED" /* ActionType.UNCALLED_BET_RETURNED */,
|
|
@@ -379,14 +334,14 @@ function awardPotToLastPlayer(state, winningSeat) {
|
|
|
379
334
|
street: state.street,
|
|
380
335
|
});
|
|
381
336
|
}
|
|
382
|
-
// Step 2:
|
|
337
|
+
// Step 2: Contested pot = winner's called portion + all opponent bets.
|
|
383
338
|
let contestedPot = calledPortion;
|
|
384
339
|
for (const [seat, amount] of state.currentBets.entries()) {
|
|
385
340
|
if (seat !== winningSeat) {
|
|
386
341
|
contestedPot += amount;
|
|
387
342
|
}
|
|
388
343
|
}
|
|
389
|
-
// Step 3: Rake and award the contested portion only
|
|
344
|
+
// Step 3: Rake and award the contested portion only.
|
|
390
345
|
if (contestedPot > 0) {
|
|
391
346
|
const { rake } = (0, rake_1.calculateRake)(state, contestedPot, totalRakeFromPots);
|
|
392
347
|
const totalRake = totalRakeFromPots + rake;
|
|
@@ -396,8 +351,6 @@ function awardPotToLastPlayer(state, winningSeat) {
|
|
|
396
351
|
...player,
|
|
397
352
|
stack: player.stack + winnings,
|
|
398
353
|
};
|
|
399
|
-
// Update winners array
|
|
400
|
-
// Only count actual winnings (contested pot after rake, minus winner's own contribution)
|
|
401
354
|
const actualWinnings = winnings - calledPortion;
|
|
402
355
|
if (actualWinnings > 0) {
|
|
403
356
|
const existingIndex = winners.findIndex((w) => w.seat === winningSeat);
|
|
@@ -416,16 +369,13 @@ function awardPotToLastPlayer(state, winningSeat) {
|
|
|
416
369
|
});
|
|
417
370
|
}
|
|
418
371
|
}
|
|
419
|
-
// Update total rake for the hand
|
|
420
372
|
totalRakeFromPots = totalRake;
|
|
421
373
|
}
|
|
422
374
|
}
|
|
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
375
|
return {
|
|
426
376
|
...state,
|
|
427
377
|
players: newPlayers,
|
|
428
|
-
street: "SHOWDOWN" /* Street.SHOWDOWN */,
|
|
378
|
+
street: "SHOWDOWN" /* Street.SHOWDOWN */,
|
|
429
379
|
pots: [],
|
|
430
380
|
currentBets: new Map(),
|
|
431
381
|
winners,
|
package/dist/actions/dealing.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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,
|
|
40
|
+
pendingAddOn: 0,
|
|
49
41
|
};
|
|
50
42
|
});
|
|
51
43
|
newButtonSeat = moveHeadsUpButtonToOccupiedSeat(newButtonSeat, {
|
|
52
44
|
...state,
|
|
53
45
|
players: newPlayers,
|
|
54
46
|
});
|
|
55
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
70
|
-
//
|
|
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:
|
|
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
|
|
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: [],
|
|
110
|
-
shownCards: null,
|
|
95
|
+
hand: [],
|
|
96
|
+
shownCards: null,
|
|
111
97
|
status: "ACTIVE" /* PlayerStatus.ACTIVE */,
|
|
112
98
|
betThisStreet: 0,
|
|
113
99
|
totalInvestedThisHand: 0,
|
|
114
100
|
};
|
|
115
101
|
}
|
|
116
|
-
//
|
|
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,
|
|
109
|
+
hand: null,
|
|
124
110
|
shownCards: null,
|
|
125
|
-
status: "FOLDED" /* PlayerStatus.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
|
|
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 ?? [];
|
|
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
|
-
//
|
|
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
|
|
192
|
-
// In
|
|
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
|
-
//
|
|
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 */;
|
|
@@ -264,7 +238,6 @@ function handleDeal(state, action) {
|
|
|
264
238
|
actionHistory: [],
|
|
265
239
|
timestamp: action.timestamp,
|
|
266
240
|
};
|
|
267
|
-
// Set first to act
|
|
268
241
|
const firstToAct = (0, action_order_1.getFirstToAct)(newState);
|
|
269
242
|
return {
|
|
270
243
|
...newState,
|
|
@@ -277,7 +250,6 @@ function handleDeal(state, action) {
|
|
|
277
250
|
*/
|
|
278
251
|
function moveButton(state) {
|
|
279
252
|
if (state.buttonSeat === null) {
|
|
280
|
-
// First hand, find first seated player
|
|
281
253
|
for (let seat = 0; seat < state.maxPlayers; seat++) {
|
|
282
254
|
if (state.players[seat] !== null) {
|
|
283
255
|
return seat;
|
|
@@ -285,8 +257,7 @@ function moveButton(state) {
|
|
|
285
257
|
}
|
|
286
258
|
return 0;
|
|
287
259
|
}
|
|
288
|
-
//
|
|
289
|
-
// We do not skip empty seats here.
|
|
260
|
+
// Dead Button: advance to next seat index regardless of occupancy.
|
|
290
261
|
return (0, positioning_1.getNextSeat)(state.buttonSeat, state.maxPlayers);
|
|
291
262
|
}
|
|
292
263
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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;
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
86
|
+
shownCards: null,
|
|
101
87
|
};
|
|
102
88
|
const actionRecord = {
|
|
103
89
|
action: {
|
package/dist/actions/special.js
CHANGED
|
@@ -24,13 +24,11 @@ function handleTimeout(state, action) {
|
|
|
24
24
|
return state;
|
|
25
25
|
}
|
|
26
26
|
const { player, seat } = result;
|
|
27
|
-
// Determine if player needs to call
|
|
28
27
|
const currentBet = getCurrentBet(state);
|
|
29
28
|
const playerBet = state.currentBets.get(seat) ?? 0;
|
|
30
29
|
const needsToCall = currentBet > playerBet;
|
|
31
30
|
const newPlayers = [...state.players];
|
|
32
31
|
if (needsToCall) {
|
|
33
|
-
// Player must fold
|
|
34
32
|
newPlayers[seat] = {
|
|
35
33
|
...player,
|
|
36
34
|
status: "FOLDED" /* PlayerStatus.FOLDED */,
|
|
@@ -38,7 +36,6 @@ function handleTimeout(state, action) {
|
|
|
38
36
|
};
|
|
39
37
|
}
|
|
40
38
|
else {
|
|
41
|
-
// Player can check, but mark as sitting out
|
|
42
39
|
newPlayers[seat] = {
|
|
43
40
|
...player,
|
|
44
41
|
isSittingOut: true,
|
|
@@ -61,7 +58,6 @@ function handleTimeout(state, action) {
|
|
|
61
58
|
actionHistory: [...state.actionHistory, actionRecord],
|
|
62
59
|
timestamp: action.timestamp,
|
|
63
60
|
};
|
|
64
|
-
// Move to next player
|
|
65
61
|
const nextToAct = (0, action_order_1.getNextToAct)(newState);
|
|
66
62
|
const actionableNext = nextToAct !== null && !newPlayers[nextToAct]?.isSittingOut ? nextToAct : null;
|
|
67
63
|
return {
|