@pokertools/engine 1.0.0 → 1.0.1

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 CHANGED
@@ -1,9 +1,9 @@
1
1
  # @pokertools/engine
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@pokertools/engine.svg)](https://www.npmjs.com/package/@pokertools/engine)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Build Status](https://img.shields.io/github/actions/workflow/status/pokertools/engine/main.yml?branch=main)](https://github.com/pokertools/engine/actions)
6
- [![Coverage](https://img.shields.io/codecov/c/github/pokertools/engine)](https://codecov.io/gh/pokertools/engine)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+ [![Build Status](https://img.shields.io/github/actions/workflow/status/aaurelions/pokertools/ci.yml?branch=main)](https://github.com/aaurelions/pokertools/actions)
6
+ [![Coverage](https://img.shields.io/codecov/c/github/aaurelions/pokertools)](https://codecov.io/gh/aaurelions/pokertools)
7
7
  [![Bundle Size](https://img.shields.io/bundlephobia/minzip/@pokertools/engine)](https://bundlephobia.com/package/@pokertools/engine)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-100%25-blue.svg)](https://www.typescriptlang.org/)
9
9
 
@@ -274,10 +274,14 @@ function getTotalPot(state) {
274
274
  }
275
275
  /**
276
276
  * Award pots to remaining eligible players when hand ends by folds
277
- * Properly handles side pot eligibility and uncontested pots
277
+ * Properly handles side pot eligibility and uncalled bets
278
+ *
279
+ * Key principle: Uncalled bets are NOT raked and are returned to the bettor immediately.
280
+ * Only the contested portion of the pot (money actually at risk) is subject to rake.
278
281
  */
279
282
  function awardPotToLastPlayer(state, winningSeat) {
280
283
  const newPlayers = [...state.players];
284
+ const newActionHistory = [...state.actionHistory];
281
285
  const winners = [];
282
286
  // Process each pot separately, checking eligibility
283
287
  let totalRakeFromPots = 0;
@@ -335,63 +339,87 @@ function awardPotToLastPlayer(state, winningSeat) {
335
339
  });
336
340
  }
337
341
  }
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];
342
+ // Handle current bets with proper uncalled bet logic
343
+ if (state.currentBets.size > 0) {
348
344
  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 = {
345
+ // Find the second-highest bet (highest opponent bet)
346
+ // This determines how much of the winner's bet was actually "called"
347
+ let maxOpponentBet = 0;
348
+ for (const [seat, amount] of state.currentBets.entries()) {
349
+ if (seat !== winningSeat && amount > maxOpponentBet) {
350
+ maxOpponentBet = amount;
351
+ }
352
+ }
353
+ // Calculate uncalled and called portions
354
+ let uncalledAmount = 0;
355
+ let calledPortion = 0;
356
+ if (winnersBet > maxOpponentBet) {
357
+ uncalledAmount = winnersBet - maxOpponentBet;
358
+ calledPortion = maxOpponentBet;
359
+ }
360
+ else {
361
+ calledPortion = winnersBet;
362
+ }
363
+ // Step 1: Return uncalled bet immediately (NO RAKE on uncalled bets)
364
+ if (uncalledAmount > 0) {
365
+ const player = newPlayers[winningSeat];
366
+ newPlayers[winningSeat] = {
367
+ ...player,
368
+ stack: player.stack + uncalledAmount,
369
+ };
370
+ // Record the uncalled bet return
371
+ newActionHistory.push({
361
372
  action: {
362
373
  type: "UNCALLED_BET_RETURNED" /* ActionType.UNCALLED_BET_RETURNED */,
363
374
  playerId: player.id,
364
- amount: winnersBet,
375
+ amount: uncalledAmount,
365
376
  timestamp: state.timestamp,
366
377
  },
367
378
  seat: winningSeat,
368
- resultingPot: 0, // Pot will be empty after this
369
- resultingStack: player.stack + winnersBet,
379
+ resultingPot: getTotalPot(state) - uncalledAmount,
380
+ resultingStack: player.stack + uncalledAmount,
370
381
  street: state.street,
371
- };
372
- newActionHistory.push(uncalledBetAction);
382
+ });
373
383
  }
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
- };
384
+ // Step 2: Calculate contested pot (winner's called portion + all opponent bets)
385
+ let contestedPot = calledPortion;
386
+ for (const [seat, amount] of state.currentBets.entries()) {
387
+ if (seat !== winningSeat) {
388
+ contestedPot += amount;
386
389
  }
387
- else {
388
- winners.push({
389
- seat: winningSeat,
390
- amount: actualWinnings,
391
- hand: null,
392
- handRank: null,
393
- });
390
+ }
391
+ // Step 3: Rake and award the contested portion only
392
+ if (contestedPot > 0) {
393
+ const { rake } = (0, rake_1.calculateRake)(state, contestedPot, totalRakeFromPots);
394
+ const totalRake = totalRakeFromPots + rake;
395
+ const winnings = contestedPot - rake;
396
+ const player = newPlayers[winningSeat];
397
+ newPlayers[winningSeat] = {
398
+ ...player,
399
+ stack: player.stack + winnings,
400
+ };
401
+ // Update winners array
402
+ // Only count actual winnings (contested pot after rake, minus winner's own contribution)
403
+ const actualWinnings = winnings - calledPortion;
404
+ if (actualWinnings > 0) {
405
+ const existingIndex = winners.findIndex((w) => w.seat === winningSeat);
406
+ if (existingIndex >= 0) {
407
+ winners[existingIndex] = {
408
+ ...winners[existingIndex],
409
+ amount: winners[existingIndex].amount + actualWinnings,
410
+ };
411
+ }
412
+ else {
413
+ winners.push({
414
+ seat: winningSeat,
415
+ amount: actualWinnings,
416
+ hand: null,
417
+ handRank: null,
418
+ });
419
+ }
394
420
  }
421
+ // Update total rake for the hand
422
+ totalRakeFromPots = totalRake;
395
423
  }
396
424
  }
397
425
  // NOTE: We do NOT reset totalInvestedThisHand here because it's used by getInitialChips()
@@ -405,6 +433,6 @@ function awardPotToLastPlayer(state, winningSeat) {
405
433
  winners,
406
434
  actionTo: null,
407
435
  actionHistory: newActionHistory,
408
- rakeThisHand: state.rakeThisHand + totalRake, // totalRake already includes rake from both pots and currentBets
436
+ rakeThisHand: state.rakeThisHand + totalRakeFromPots,
409
437
  };
410
438
  }
@@ -21,17 +21,81 @@ function handleDeal(state, action) {
21
21
  // Create and shuffle deck
22
22
  const rng = state.config.randomProvider ?? Math.random;
23
23
  const deck = (0, deck_1.shuffle)((0, deck_1.createDeck)(), rng);
24
+ // Create a copy of timeBanks to modify
25
+ const newTimeBanks = new Map(state.timeBanks);
26
+ // First, merge pendingAddOn into stack for all players
27
+ const newPlayers = state.players.map((player) => {
28
+ if (!player)
29
+ return null;
30
+ // Skip reserved players (they haven't confirmed yet)
31
+ if (player.status === "RESERVED" /* PlayerStatus.RESERVED */) {
32
+ // Check if reservation has expired
33
+ if (player.reservationExpiry && action.timestamp >= player.reservationExpiry) {
34
+ // Reservation expired, remove player
35
+ newTimeBanks.delete(player.seat);
36
+ return null;
37
+ }
38
+ // Keep reserved player as-is
39
+ return player;
40
+ }
41
+ // Merge pendingAddOn into stack
42
+ const newStack = player.stack + player.pendingAddOn;
43
+ return {
44
+ ...player,
45
+ stack: newStack,
46
+ pendingAddOn: 0, // Clear pending add-on
47
+ };
48
+ });
49
+ // Get blind positions for this hand
50
+ const blindPositions = (0, blinds_1.getBlindPositions)({
51
+ ...state,
52
+ buttonSeat: newButtonSeat,
53
+ players: newPlayers,
54
+ });
24
55
  // Get players who will be dealt in
25
56
  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) {
57
+ for (let seat = 0; seat < newPlayers.length; seat++) {
58
+ const player = newPlayers[seat];
59
+ // Basic eligibility checks
60
+ if (!player || player.stack <= 0 || player.status === "RESERVED" /* PlayerStatus.RESERVED */) {
61
+ continue;
62
+ }
63
+ // We check WAIT_FOR_BB *before* checking isSittingOut.
64
+ // This allows us to "unsit" a player if they hit the Big Blind.
65
+ let shouldPlay = true;
66
+ if (!isTournament && player.sitInOption === "WAIT_FOR_BB" /* SitInOption.WAIT_FOR_BB */) {
67
+ const isInBigBlind = blindPositions?.bigBlindSeat === seat;
68
+ if (isInBigBlind) {
69
+ // PLAYER RE-ENTRY: They are in the Big Blind. Force them active.
70
+ // We must update the player object in newPlayers to reflect they are back.
71
+ newPlayers[seat] = {
72
+ ...player,
73
+ isSittingOut: false,
74
+ };
75
+ shouldPlay = true;
76
+ }
77
+ else {
78
+ // Not in BB yet. Force them to sit out.
79
+ // Only update if not already sitting out to avoid object churn
80
+ if (!player.isSittingOut) {
81
+ newPlayers[seat] = {
82
+ ...player,
83
+ isSittingOut: true,
84
+ };
85
+ }
86
+ shouldPlay = false;
87
+ }
88
+ }
89
+ else if (player.isSittingOut) {
90
+ // Standard sitting out check
91
+ shouldPlay = false;
92
+ }
93
+ if (shouldPlay) {
29
94
  playersToReceive.push(seat);
30
95
  }
31
96
  }
32
97
  // Deal 2 cards to each player
33
98
  let remainingDeck = deck;
34
- const newPlayers = [...state.players];
35
99
  // Initialize hands for receiving players (active, not sitting out)
36
100
  for (const seat of playersToReceive) {
37
101
  newPlayers[seat] = {
@@ -76,14 +140,15 @@ function handleDeal(state, action) {
76
140
  }
77
141
  }
78
142
  // Post blinds and antes
79
- const blindPositions = (0, blinds_1.getBlindPositions)({
143
+ // Recalculate blind positions with the new button seat
144
+ const finalBlindPositions = (0, blinds_1.getBlindPositions)({
80
145
  ...state,
81
146
  buttonSeat: newButtonSeat,
82
147
  players: newPlayers,
83
148
  });
84
149
  const currentBets = new Map();
85
- if (blindPositions) {
86
- const { smallBlindSeat, bigBlindSeat } = blindPositions;
150
+ if (finalBlindPositions) {
151
+ const { smallBlindSeat, bigBlindSeat } = finalBlindPositions;
87
152
  // Post small blind
88
153
  // In tournaments: sitting-out players MUST post to prevent "blinding off" exploit
89
154
  // In cash games: sitting-out SB is treated as "Dead Small Blind" (no post)
@@ -1,4 +1,4 @@
1
- import { GameState, SitAction, StandAction } from "@pokertools/types";
1
+ import { GameState, SitAction, StandAction, AddChipsAction, ReserveSeatAction } from "@pokertools/types";
2
2
  /**
3
3
  * Handle SIT action - add player to table
4
4
  */
@@ -7,3 +7,14 @@ export declare function handleSit(state: GameState, action: SitAction): GameStat
7
7
  * Handle STAND action - remove player from table
8
8
  */
9
9
  export declare function handleStand(state: GameState, action: StandAction): GameState;
10
+ /**
11
+ * Handle ADD_CHIPS action - add chips to player's pending stack
12
+ * Chips are held in pendingAddOn and will be merged into stack at start of next hand
13
+ */
14
+ export declare function handleAddChips(state: GameState, action: AddChipsAction): GameState;
15
+ /**
16
+ * Handle RESERVE_SEAT action - reserve a seat for a player
17
+ * Marks the seat as RESERVED with an expiration timestamp
18
+ * API can use this to lock a seat while processing payment
19
+ */
20
+ export declare function handleReserveSeat(state: GameState, action: ReserveSeatAction): GameState;
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.handleSit = handleSit;
4
4
  exports.handleStand = handleStand;
5
+ exports.handleAddChips = handleAddChips;
6
+ exports.handleReserveSeat = handleReserveSeat;
5
7
  const positioning_1 = require("../utils/positioning");
6
8
  /**
7
9
  * Handle SIT action - add player to table
@@ -19,6 +21,9 @@ function handleSit(state, action) {
19
21
  totalInvestedThisHand: 0,
20
22
  isSittingOut: false,
21
23
  timeBank: state.config.timeBankSeconds ?? 30,
24
+ pendingAddOn: 0,
25
+ sitInOption: action.sitInOption ?? "IMMEDIATE" /* SitInOption.IMMEDIATE */,
26
+ reservationExpiry: null,
22
27
  };
23
28
  const newPlayers = [...state.players];
24
29
  newPlayers[action.seat] = newPlayer;
@@ -56,3 +61,58 @@ function handleStand(state, action) {
56
61
  timestamp: action.timestamp,
57
62
  };
58
63
  }
64
+ /**
65
+ * Handle ADD_CHIPS action - add chips to player's pending stack
66
+ * Chips are held in pendingAddOn and will be merged into stack at start of next hand
67
+ */
68
+ function handleAddChips(state, action) {
69
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
70
+ if (!result) {
71
+ return state;
72
+ }
73
+ const { player, seat } = result;
74
+ const newPlayers = [...state.players];
75
+ newPlayers[seat] = {
76
+ ...player,
77
+ pendingAddOn: player.pendingAddOn + action.amount,
78
+ };
79
+ return {
80
+ ...state,
81
+ players: newPlayers,
82
+ timestamp: action.timestamp,
83
+ };
84
+ }
85
+ /**
86
+ * Handle RESERVE_SEAT action - reserve a seat for a player
87
+ * Marks the seat as RESERVED with an expiration timestamp
88
+ * API can use this to lock a seat while processing payment
89
+ */
90
+ function handleReserveSeat(state, action) {
91
+ // Check if seat is already occupied
92
+ if (state.players[action.seat] !== null) {
93
+ return state;
94
+ }
95
+ const reservedPlayer = {
96
+ id: action.playerId,
97
+ name: action.playerName,
98
+ seat: action.seat,
99
+ stack: 0,
100
+ hand: null,
101
+ shownCards: null,
102
+ status: "RESERVED" /* PlayerStatus.RESERVED */,
103
+ betThisStreet: 0,
104
+ totalInvestedThisHand: 0,
105
+ isSittingOut: false,
106
+ timeBank: state.config.timeBankSeconds ?? 30,
107
+ pendingAddOn: 0,
108
+ sitInOption: "IMMEDIATE" /* SitInOption.IMMEDIATE */,
109
+ reservationExpiry: action.expiryTimestamp,
110
+ };
111
+ const newPlayers = [...state.players];
112
+ newPlayers[action.seat] = reservedPlayer;
113
+ return {
114
+ ...state,
115
+ players: newPlayers,
116
+ timestamp: action.timestamp,
117
+ };
118
+ }
@@ -31,6 +31,12 @@ function validateAction(state, action) {
31
31
  case "TIME_BANK" /* ActionType.TIME_BANK */:
32
32
  validateTimeAction(state, action);
33
33
  break;
34
+ case "ADD_CHIPS" /* ActionType.ADD_CHIPS */:
35
+ validateAddChipsAction(state, action);
36
+ break;
37
+ case "RESERVE_SEAT" /* ActionType.RESERVE_SEAT */:
38
+ validateReserveSeatAction(state, action);
39
+ break;
34
40
  default:
35
41
  // Other actions don't need validation
36
42
  break;
@@ -145,8 +151,13 @@ function validateSitAction(state, action) {
145
151
  if (action.seat < 0 || action.seat >= state.maxPlayers) {
146
152
  throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_SEAT, `Seat ${action.seat} is invalid (max: ${state.maxPlayers - 1})`, { seat: action.seat, maxPlayers: state.maxPlayers });
147
153
  }
148
- if (state.players[action.seat] !== null) {
149
- throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.SEAT_OCCUPIED, `Seat ${action.seat} is already occupied`, { seat: action.seat });
154
+ const existingPlayer = state.players[action.seat];
155
+ if (existingPlayer !== null) {
156
+ // Allow claiming the seat if it is RESERVED by THIS player
157
+ const isMyReservation = existingPlayer.status === "RESERVED" /* PlayerStatus.RESERVED */ && existingPlayer.id === action.playerId;
158
+ if (!isMyReservation) {
159
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.SEAT_OCCUPIED, `Seat ${action.seat} is already occupied`, { seat: action.seat });
160
+ }
150
161
  }
151
162
  if (action.stack <= 0) {
152
163
  throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_STACK, `Stack must be positive, got ${action.stack}`, { stack: action.stack });
@@ -168,6 +179,20 @@ function validateTimeAction(state, action) {
168
179
  throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NOT_YOUR_TURN, `Player ${action.playerId} cannot use time action when it's not their turn`, { playerId: action.playerId, actionTo: state.actionTo });
169
180
  }
170
181
  }
182
+ function validateAddChipsAction(state, action) {
183
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
184
+ if (!result) {
185
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
186
+ }
187
+ }
188
+ function validateReserveSeatAction(state, action) {
189
+ if (action.seat < 0 || action.seat >= state.maxPlayers) {
190
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_SEAT, `Seat ${action.seat} is invalid (max: ${state.maxPlayers - 1})`, { seat: action.seat, maxPlayers: state.maxPlayers });
191
+ }
192
+ if (state.players[action.seat] !== null) {
193
+ throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.SEAT_OCCUPIED, `Seat ${action.seat} is already occupied`, { seat: action.seat });
194
+ }
195
+ }
171
196
  /**
172
197
  * Get current highest bet this street
173
198
  */
@@ -34,6 +34,12 @@ function gameReducer(state, action) {
34
34
  case "STAND" /* ActionType.STAND */:
35
35
  newState = (0, management_1.handleStand)(state, action);
36
36
  break;
37
+ case "ADD_CHIPS" /* ActionType.ADD_CHIPS */:
38
+ newState = (0, management_1.handleAddChips)(state, action);
39
+ break;
40
+ case "RESERVE_SEAT" /* ActionType.RESERVE_SEAT */:
41
+ newState = (0, management_1.handleReserveSeat)(state, action);
42
+ break;
37
43
  // Dealing
38
44
  case "DEAL" /* ActionType.DEAL */:
39
45
  newState = (0, dealing_1.handleDeal)(state, action);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pokertools/engine",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Enterprise-grade Texas Hold'em poker engine",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,13 +46,5 @@
46
46
  "dependencies": {
47
47
  "@pokertools/evaluator": "*",
48
48
  "@pokertools/types": "*"
49
- },
50
- "devDependencies": {
51
- "@types/jest": "^30.0.0",
52
- "@types/node": "^24.10.1",
53
- "fast-check": "^3.15.0",
54
- "jest": "^30.2.0",
55
- "ts-jest": "^29.4.5",
56
- "typescript": "^5.9.3"
57
49
  }
58
50
  }