@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.
- package/LICENSE +21 -0
- package/README.md +607 -0
- package/dist/actions/betting.d.ts +21 -0
- package/dist/actions/betting.js +410 -0
- package/dist/actions/dealing.d.ts +9 -0
- package/dist/actions/dealing.js +206 -0
- package/dist/actions/management.d.ts +9 -0
- package/dist/actions/management.js +58 -0
- package/dist/actions/showdownActions.d.ts +9 -0
- package/dist/actions/showdownActions.js +119 -0
- package/dist/actions/special.d.ts +14 -0
- package/dist/actions/special.js +98 -0
- package/dist/actions/streetProgression.d.ts +13 -0
- package/dist/actions/streetProgression.js +157 -0
- package/dist/actions/tournament.d.ts +5 -0
- package/dist/actions/tournament.js +38 -0
- package/dist/actions/validation.d.ts +6 -0
- package/dist/actions/validation.js +182 -0
- package/dist/engine/PokerEngine.d.ts +92 -0
- package/dist/engine/PokerEngine.js +246 -0
- package/dist/engine/gameReducer.d.ts +10 -0
- package/dist/engine/gameReducer.js +135 -0
- package/dist/errors/ConfigError.d.ts +8 -0
- package/dist/errors/ConfigError.js +15 -0
- package/dist/errors/CriticalStateError.d.ts +8 -0
- package/dist/errors/CriticalStateError.js +15 -0
- package/dist/errors/ErrorCodes.d.ts +38 -0
- package/dist/errors/ErrorCodes.js +46 -0
- package/dist/errors/IllegalActionError.d.ts +9 -0
- package/dist/errors/IllegalActionError.js +15 -0
- package/dist/errors/PokerEngineError.d.ts +8 -0
- package/dist/errors/PokerEngineError.js +19 -0
- package/dist/errors/index.d.ts +5 -0
- package/dist/errors/index.js +22 -0
- package/dist/history/exporter.d.ts +28 -0
- package/dist/history/exporter.js +60 -0
- package/dist/history/formats/json.d.ts +14 -0
- package/dist/history/formats/json.js +46 -0
- package/dist/history/formats/pokerstars.d.ts +10 -0
- package/dist/history/formats/pokerstars.js +188 -0
- package/dist/history/handHistoryBuilder.d.ts +10 -0
- package/dist/history/handHistoryBuilder.js +179 -0
- package/dist/history/types.d.ts +73 -0
- package/dist/history/types.js +5 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +38 -0
- package/dist/rules/actionOrder.d.ts +14 -0
- package/dist/rules/actionOrder.js +211 -0
- package/dist/rules/blinds.d.ts +24 -0
- package/dist/rules/blinds.js +64 -0
- package/dist/rules/headsUp.d.ts +15 -0
- package/dist/rules/headsUp.js +44 -0
- package/dist/rules/showdown.d.ts +9 -0
- package/dist/rules/showdown.js +164 -0
- package/dist/rules/sidePots.d.ts +32 -0
- package/dist/rules/sidePots.js +173 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/utils/cardUtils.d.ts +12 -0
- package/dist/utils/cardUtils.js +30 -0
- package/dist/utils/constants.d.ts +38 -0
- package/dist/utils/constants.js +41 -0
- package/dist/utils/deck.d.ts +46 -0
- package/dist/utils/deck.js +126 -0
- package/dist/utils/invariants.d.ts +39 -0
- package/dist/utils/invariants.js +163 -0
- package/dist/utils/positioning.d.ts +36 -0
- package/dist/utils/positioning.js +97 -0
- package/dist/utils/rake.d.ts +13 -0
- package/dist/utils/rake.js +45 -0
- package/dist/utils/serialization.d.ts +53 -0
- package/dist/utils/serialization.js +106 -0
- package/dist/utils/validation.d.ts +20 -0
- package/dist/utils/validation.js +52 -0
- package/dist/utils/viewMasking.d.ts +20 -0
- package/dist/utils/viewMasking.js +90 -0
- 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
|
+
}
|