@pokertools/engine 1.0.0 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +590 -444
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/actions/betting.js +83 -50
  4. package/dist/actions/dealing.js +118 -27
  5. package/dist/actions/management.d.ts +12 -1
  6. package/dist/actions/management.js +86 -5
  7. package/dist/actions/special.d.ts +18 -0
  8. package/dist/actions/special.js +20 -0
  9. package/dist/actions/validation.js +27 -2
  10. package/dist/browser.d.ts +27 -0
  11. package/dist/browser.js +73 -0
  12. package/dist/engine/PokerEngine.d.ts +23 -2
  13. package/dist/engine/PokerEngine.js +54 -2
  14. package/dist/engine/gameReducer.js +6 -0
  15. package/dist/errors/ErrorCodes.d.ts +4 -35
  16. package/dist/errors/ErrorCodes.js +7 -41
  17. package/dist/errors/index.d.ts +0 -1
  18. package/dist/errors/index.js +1 -1
  19. package/dist/history/exporter.d.ts +1 -2
  20. package/dist/history/formats/json.d.ts +1 -1
  21. package/dist/history/formats/pokerstars.d.ts +1 -1
  22. package/dist/history/handHistoryBuilder.d.ts +1 -2
  23. package/dist/history/handHistoryBuilder.js +4 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/rules/actionOrder.js +4 -4
  26. package/dist/rules/blinds.d.ts +2 -0
  27. package/dist/rules/blinds.js +27 -3
  28. package/dist/rules/headsUp.js +18 -0
  29. package/dist/rules/showdown.js +10 -0
  30. package/dist/utils/cardUtils.d.ts +2 -1
  31. package/dist/utils/cardUtils.js +2 -1
  32. package/dist/utils/invariants.js +4 -0
  33. package/dist/utils/positioning.js +2 -2
  34. package/dist/utils/serialization.d.ts +1 -0
  35. package/dist/utils/serialization.js +2 -0
  36. package/dist/utils/viewMasking.d.ts +2 -1
  37. package/dist/utils/viewMasking.js +9 -1
  38. package/package.json +30 -12
  39. package/dist/history/types.d.ts +0 -73
  40. package/dist/history/types.js +0 -5
  41. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -15,23 +15,93 @@ const positioning_1 = require("../utils/positioning");
15
15
  */
16
16
  function handleDeal(state, action) {
17
17
  // Move button (Dead Button logic: moves to next seat index regardless of occupancy)
18
- const newButtonSeat = moveButton(state);
18
+ let newButtonSeat = moveButton(state);
19
19
  // Determine if this is a tournament
20
20
  const isTournament = !!state.config.blindStructure;
21
- // Create and shuffle deck
21
+ 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
22
24
  const rng = state.config.randomProvider ?? Math.random;
23
- const deck = (0, deck_1.shuffle)((0, deck_1.createDeck)(), rng);
25
+ const deck = isClient ? [] : (0, deck_1.shuffle)((0, deck_1.createDeck)(), rng);
26
+ // Create a copy of timeBanks to modify
27
+ const newTimeBanks = new Map(state.timeBanks);
28
+ // First, merge pendingAddOn into stack for all players
29
+ const newPlayers = state.players.map((player) => {
30
+ if (!player)
31
+ return null;
32
+ // Skip reserved players (they haven't confirmed yet)
33
+ if (player.status === "RESERVED" /* PlayerStatus.RESERVED */) {
34
+ // Check if reservation has expired
35
+ if (player.reservationExpiry && action.timestamp >= player.reservationExpiry) {
36
+ // Reservation expired, remove player
37
+ newTimeBanks.delete(player.seat);
38
+ return null;
39
+ }
40
+ // Keep reserved player as-is
41
+ return player;
42
+ }
43
+ // Merge pendingAddOn into stack
44
+ const newStack = player.stack + player.pendingAddOn;
45
+ return {
46
+ ...player,
47
+ stack: newStack,
48
+ pendingAddOn: 0, // Clear pending add-on
49
+ };
50
+ });
51
+ newButtonSeat = moveHeadsUpButtonToOccupiedSeat(newButtonSeat, {
52
+ ...state,
53
+ players: newPlayers,
54
+ });
55
+ // Get blind positions for this hand
56
+ const blindPositions = (0, blinds_1.getBlindPositions)({
57
+ ...state,
58
+ buttonSeat: newButtonSeat,
59
+ players: newPlayers,
60
+ });
24
61
  // Get players who will be dealt in
25
62
  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) {
63
+ for (let seat = 0; seat < newPlayers.length; seat++) {
64
+ const player = newPlayers[seat];
65
+ // Basic eligibility checks
66
+ if (!player || player.stack <= 0 || player.status === "RESERVED" /* PlayerStatus.RESERVED */) {
67
+ continue;
68
+ }
69
+ // We check WAIT_FOR_BB *before* checking isSittingOut.
70
+ // This allows us to "unsit" a player if they hit the Big Blind.
71
+ let shouldPlay = true;
72
+ if (!isTournament && player.sitInOption === "WAIT_FOR_BB" /* SitInOption.WAIT_FOR_BB */) {
73
+ const isInBigBlind = blindPositions?.bigBlindSeat === seat;
74
+ 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.
77
+ newPlayers[seat] = {
78
+ ...player,
79
+ isSittingOut: false,
80
+ };
81
+ shouldPlay = true;
82
+ }
83
+ else {
84
+ // Not in BB yet. Force them to sit out.
85
+ // Only update if not already sitting out to avoid object churn
86
+ if (!player.isSittingOut) {
87
+ newPlayers[seat] = {
88
+ ...player,
89
+ isSittingOut: true,
90
+ };
91
+ }
92
+ shouldPlay = false;
93
+ }
94
+ }
95
+ else if (player.isSittingOut) {
96
+ // Standard sitting out check
97
+ shouldPlay = false;
98
+ }
99
+ if (shouldPlay) {
29
100
  playersToReceive.push(seat);
30
101
  }
31
102
  }
32
103
  // Deal 2 cards to each player
33
104
  let remainingDeck = deck;
34
- const newPlayers = [...state.players];
35
105
  // Initialize hands for receiving players (active, not sitting out)
36
106
  for (const seat of playersToReceive) {
37
107
  newPlayers[seat] = {
@@ -62,10 +132,17 @@ function handleDeal(state, action) {
62
132
  // Deal 2 cards, one by one, in circle (standard poker procedure)
63
133
  for (let round = 0; round < 2; round++) {
64
134
  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);
135
+ let cardStrings;
136
+ if (isClient) {
137
+ // Client mode: Deal masked cards
138
+ cardStrings = [null];
139
+ }
140
+ else {
141
+ // Server mode: Deal from deck
142
+ const [cards, nextDeck] = (0, deck_1.dealCards)(remainingDeck, 1);
143
+ remainingDeck = nextDeck;
144
+ cardStrings = (0, cardUtils_1.cardCodesToStrings)(cards);
145
+ }
69
146
  // Append to existing hand
70
147
  const currentPlayer = newPlayers[seat];
71
148
  const currentHand = currentPlayer.hand ?? []; // Should be [] from initialization
@@ -76,14 +153,15 @@ function handleDeal(state, action) {
76
153
  }
77
154
  }
78
155
  // Post blinds and antes
79
- const blindPositions = (0, blinds_1.getBlindPositions)({
156
+ // Recalculate blind positions with the new button seat
157
+ const finalBlindPositions = (0, blinds_1.getBlindPositions)({
80
158
  ...state,
81
159
  buttonSeat: newButtonSeat,
82
160
  players: newPlayers,
83
161
  });
84
162
  const currentBets = new Map();
85
- if (blindPositions) {
86
- const { smallBlindSeat, bigBlindSeat } = blindPositions;
163
+ if (finalBlindPositions) {
164
+ const { smallBlindSeat, bigBlindSeat } = finalBlindPositions;
87
165
  // Post small blind
88
166
  // In tournaments: sitting-out players MUST post to prevent "blinding off" exploit
89
167
  // In cash games: sitting-out SB is treated as "Dead Small Blind" (no post)
@@ -110,19 +188,24 @@ function handleDeal(state, action) {
110
188
  // Post big blind (Must exist for hand to start)
111
189
  const bbPlayer = newPlayers[bigBlindSeat];
112
190
  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
- };
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
193
+ const shouldPostBB = isTournament || !bbPlayer.isSittingOut;
194
+ if (shouldPostBB) {
195
+ const bbAmount = Math.min(bbPlayer.stack, state.bigBlind);
196
+ currentBets.set(bigBlindSeat, bbAmount);
197
+ newPlayers[bigBlindSeat] = {
198
+ ...bbPlayer,
199
+ stack: bbPlayer.stack - bbAmount,
200
+ betThisStreet: bbAmount,
201
+ totalInvestedThisHand: bbAmount,
202
+ status: bbAmount === bbPlayer.stack
203
+ ? "ALL_IN" /* PlayerStatus.ALL_IN */
204
+ : bbPlayer.isSittingOut
205
+ ? "FOLDED" /* PlayerStatus.FOLDED */
206
+ : "ACTIVE" /* PlayerStatus.ACTIVE */,
207
+ };
208
+ }
126
209
  }
127
210
  }
128
211
  // Post antes if configured
@@ -204,3 +287,11 @@ function moveButton(state) {
204
287
  // We do not skip empty seats here.
205
288
  return (0, positioning_1.getNextSeat)(state.buttonSeat, state.maxPlayers);
206
289
  }
290
+ function moveHeadsUpButtonToOccupiedSeat(buttonSeat, state) {
291
+ const occupiedSeats = state.players.filter((player) => player !== null && player.stack > 0);
292
+ const buttonPlayer = state.players[buttonSeat];
293
+ if (occupiedSeats.length !== 2 || (buttonPlayer !== null && buttonPlayer.stack > 0)) {
294
+ return buttonSeat;
295
+ }
296
+ return (0, positioning_1.getNextOccupiedSeat)(buttonSeat, state.players, state.maxPlayers) ?? buttonSeat;
297
+ }
@@ -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,7 +2,10 @@
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");
8
+ const betting_1 = require("./betting");
6
9
  /**
7
10
  * Handle SIT action - add player to table
8
11
  */
@@ -19,6 +22,9 @@ function handleSit(state, action) {
19
22
  totalInvestedThisHand: 0,
20
23
  isSittingOut: false,
21
24
  timeBank: state.config.timeBankSeconds ?? 30,
25
+ pendingAddOn: 0,
26
+ sitInOption: action.sitInOption ?? "IMMEDIATE" /* SitInOption.IMMEDIATE */,
27
+ reservationExpiry: null,
22
28
  };
23
29
  const newPlayers = [...state.players];
24
30
  newPlayers[action.seat] = newPlayer;
@@ -40,19 +46,94 @@ function handleStand(state, action) {
40
46
  if (!result) {
41
47
  return state;
42
48
  }
43
- const { seat } = result;
44
- const newPlayers = [...state.players];
49
+ let currentState = state;
50
+ const { player, seat } = result;
51
+ // 1. BEST PRACTICE: If the player is in a live hand, they must FOLD first.
52
+ // This resolves the "ActionTo" pointer, potential winners, and pot eligibility
53
+ // using the standard game rules defined in handleFold.
54
+ const isLiveHand = currentState.handNumber > 0 &&
55
+ currentState.street !== "SHOWDOWN" && // Not needed if hand is over
56
+ (player.status === "ACTIVE" || player.status === "ALL_IN");
57
+ if (isLiveHand) {
58
+ // Execute a "Virtual Fold" to gracefully exit the hand
59
+ currentState = (0, betting_1.handleFold)(currentState, {
60
+ type: "FOLD" /* ActionType.FOLD */,
61
+ playerId: player.id,
62
+ timestamp: action.timestamp,
63
+ });
64
+ // NOTE: handleFold returns a new state where actionTo has already been
65
+ // advanced to the next player. The invariant check will now pass.
66
+ }
67
+ // 2. Remove player from table (Standard Stand Logic)
68
+ const newPlayers = [...currentState.players];
45
69
  newPlayers[seat] = null;
46
70
  // Remove from time banks
47
- const newTimeBanks = new Map(state.timeBanks);
71
+ const newTimeBanks = new Map(currentState.timeBanks);
48
72
  newTimeBanks.delete(seat);
49
73
  // Remove from active players if present
50
- const newActivePlayers = state.activePlayers.filter((s) => s !== seat);
74
+ // (Note: handleFold might have already moved them to FOLDED status,
75
+ // but we ensure they are fully removed from tracking here)
76
+ const newActivePlayers = currentState.activePlayers.filter((s) => s !== seat);
51
77
  return {
52
- ...state,
78
+ ...currentState,
53
79
  players: newPlayers,
54
80
  activePlayers: newActivePlayers,
55
81
  timeBanks: newTimeBanks,
56
82
  timestamp: action.timestamp,
57
83
  };
58
84
  }
85
+ /**
86
+ * Handle ADD_CHIPS action - add chips to player's pending stack
87
+ * Chips are held in pendingAddOn and will be merged into stack at start of next hand
88
+ */
89
+ function handleAddChips(state, action) {
90
+ const result = (0, positioning_1.getPlayerById)(state, action.playerId);
91
+ if (!result) {
92
+ return state;
93
+ }
94
+ const { player, seat } = result;
95
+ const newPlayers = [...state.players];
96
+ newPlayers[seat] = {
97
+ ...player,
98
+ pendingAddOn: player.pendingAddOn + action.amount,
99
+ };
100
+ return {
101
+ ...state,
102
+ players: newPlayers,
103
+ timestamp: action.timestamp,
104
+ };
105
+ }
106
+ /**
107
+ * Handle RESERVE_SEAT action - reserve a seat for a player
108
+ * Marks the seat as RESERVED with an expiration timestamp
109
+ * API can use this to lock a seat while processing payment
110
+ */
111
+ function handleReserveSeat(state, action) {
112
+ // Check if seat is already occupied
113
+ if (state.players[action.seat] !== null) {
114
+ return state;
115
+ }
116
+ const reservedPlayer = {
117
+ id: action.playerId,
118
+ name: action.playerName,
119
+ seat: action.seat,
120
+ stack: 0,
121
+ hand: null,
122
+ shownCards: null,
123
+ status: "RESERVED" /* PlayerStatus.RESERVED */,
124
+ betThisStreet: 0,
125
+ totalInvestedThisHand: 0,
126
+ isSittingOut: false,
127
+ timeBank: state.config.timeBankSeconds ?? 30,
128
+ pendingAddOn: 0,
129
+ sitInOption: "IMMEDIATE" /* SitInOption.IMMEDIATE */,
130
+ reservationExpiry: action.expiryTimestamp,
131
+ };
132
+ const newPlayers = [...state.players];
133
+ newPlayers[action.seat] = reservedPlayer;
134
+ return {
135
+ ...state,
136
+ players: newPlayers,
137
+ timestamp: action.timestamp,
138
+ };
139
+ }
@@ -10,5 +10,23 @@ export declare function handleTimeout(state: GameState, action: TimeoutAction):
10
10
  * Handle TIME_BANK action
11
11
  * - Deducts time from player's time bank
12
12
  * - Keeps action on same player
13
+ *
14
+ * TIME BANK DEDUCTION POLICY:
15
+ * This implementation uses a "pay-per-activation" model where:
16
+ * - Each time bank activation deducts a fixed amount (default: 10 seconds)
17
+ * - Player receives the full deduction amount as additional time
18
+ * - If remaining time bank is less than the deduction, it is fully consumed
19
+ * - This prevents players from getting "free" time when they have < 10s remaining
20
+ *
21
+ * Example scenarios:
22
+ * - Player has 30s, activates time bank → 20s remaining, gets 10s additional time
23
+ * - Player has 5s, activates time bank → 0s remaining, gets 10s additional time
24
+ * - Player has 0s, cannot activate time bank → forced timeout/fold
25
+ *
26
+ * Alternative design consideration:
27
+ * If you want "time-as-resource" (only deduct what you use), you would need:
28
+ * - Track time used per activation in the UI layer
29
+ * - Deduct actual time consumed rather than fixed amount
30
+ * - Return unused time if action is made before deduction expires
13
31
  */
14
32
  export declare function handleTimeBank(state: GameState, action: TimeBankAction): GameState;
@@ -56,6 +56,24 @@ function handleTimeout(state, action) {
56
56
  * Handle TIME_BANK action
57
57
  * - Deducts time from player's time bank
58
58
  * - Keeps action on same player
59
+ *
60
+ * TIME BANK DEDUCTION POLICY:
61
+ * This implementation uses a "pay-per-activation" model where:
62
+ * - Each time bank activation deducts a fixed amount (default: 10 seconds)
63
+ * - Player receives the full deduction amount as additional time
64
+ * - If remaining time bank is less than the deduction, it is fully consumed
65
+ * - This prevents players from getting "free" time when they have < 10s remaining
66
+ *
67
+ * Example scenarios:
68
+ * - Player has 30s, activates time bank → 20s remaining, gets 10s additional time
69
+ * - Player has 5s, activates time bank → 0s remaining, gets 10s additional time
70
+ * - Player has 0s, cannot activate time bank → forced timeout/fold
71
+ *
72
+ * Alternative design consideration:
73
+ * If you want "time-as-resource" (only deduct what you use), you would need:
74
+ * - Track time used per activation in the UI layer
75
+ * - Deduct actual time consumed rather than fixed amount
76
+ * - Return unused time if action is made before deduction expires
59
77
  */
60
78
  function handleTimeBank(state, action) {
61
79
  const result = (0, positioning_1.getPlayerById)(state, action.playerId);
@@ -73,6 +91,7 @@ function handleTimeBank(state, action) {
73
91
  });
74
92
  }
75
93
  // Deduct time from player's time bank (configurable, default 10 seconds)
94
+ // Uses pay-per-activation model: deduct full amount even if less is available
76
95
  const deduction = state.config.timeBankDeductionSeconds ?? 10;
77
96
  const newTimeBank = Math.max(0, currentTimeBank - deduction);
78
97
  const newTimeBanks = new Map(state.timeBanks);
@@ -80,6 +99,7 @@ function handleTimeBank(state, action) {
80
99
  return {
81
100
  ...state,
82
101
  timeBanks: newTimeBanks,
102
+ timeBankActiveSeat: seat, // Mark time bank as active for this player
83
103
  timestamp: action.timestamp,
84
104
  // Keep actionTo the same (extends player's turn)
85
105
  };
@@ -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
  */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Browser-compatible entry point for PokerEngine
3
+ *
4
+ * This file provides a browser-safe RNG using Web Crypto API
5
+ * and re-exports all engine functionality for use in web applications.
6
+ */
7
+ import { PokerEngine } from "./engine/PokerEngine";
8
+ import type { TableConfig } from "@pokertools/types";
9
+ /**
10
+ * Browser-compatible RNG using Web Crypto API
11
+ * Falls back to Math.random() only in environments without crypto
12
+ */
13
+ export declare function getBrowserRNG(): () => number;
14
+ /**
15
+ * Create a PokerEngine instance with browser-compatible RNG
16
+ */
17
+ export declare function createBrowserEngine(config: TableConfig): PokerEngine;
18
+ export * from "./engine/PokerEngine";
19
+ export * from "./actions/betting";
20
+ export * from "./actions/dealing";
21
+ export * from "./actions/management";
22
+ export * from "./actions/showdownActions";
23
+ export * from "./actions/special";
24
+ export * from "./utils/viewMasking";
25
+ export * from "./utils/serialization";
26
+ export * from "./utils/cardUtils";
27
+ export * from "./history/exporter";
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ /**
3
+ * Browser-compatible entry point for PokerEngine
4
+ *
5
+ * This file provides a browser-safe RNG using Web Crypto API
6
+ * and re-exports all engine functionality for use in web applications.
7
+ */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
20
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
21
+ };
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.getBrowserRNG = getBrowserRNG;
24
+ exports.createBrowserEngine = createBrowserEngine;
25
+ const PokerEngine_1 = require("./engine/PokerEngine");
26
+ /**
27
+ * Browser-compatible RNG using Web Crypto API
28
+ * Falls back to Math.random() only in environments without crypto
29
+ */
30
+ function getBrowserRNG() {
31
+ // Check for Web Crypto API
32
+ if (typeof window !== "undefined" && window.crypto?.getRandomValues) {
33
+ return () => {
34
+ const buffer = new Uint32Array(1);
35
+ window.crypto.getRandomValues(buffer);
36
+ return buffer[0] / 0x100000000;
37
+ };
38
+ }
39
+ // Check for Node.js crypto (for SSR/testing)
40
+ if (typeof globalThis !== "undefined" && globalThis.crypto?.getRandomValues) {
41
+ return () => {
42
+ const buffer = new Uint32Array(1);
43
+ globalThis.crypto.getRandomValues(buffer);
44
+ return buffer[0] / 0x100000000;
45
+ };
46
+ }
47
+ // Fallback (warn in development)
48
+ if (process.env.NODE_ENV !== "production") {
49
+ console.warn("[PokerEngine Browser] Web Crypto API not available, using Math.random(). " +
50
+ "This is NOT cryptographically secure.");
51
+ }
52
+ return Math.random;
53
+ }
54
+ /**
55
+ * Create a PokerEngine instance with browser-compatible RNG
56
+ */
57
+ function createBrowserEngine(config) {
58
+ return new PokerEngine_1.PokerEngine({
59
+ ...config,
60
+ randomProvider: getBrowserRNG(),
61
+ });
62
+ }
63
+ // Re-export everything from main engine
64
+ __exportStar(require("./engine/PokerEngine"), exports);
65
+ __exportStar(require("./actions/betting"), exports);
66
+ __exportStar(require("./actions/dealing"), exports);
67
+ __exportStar(require("./actions/management"), exports);
68
+ __exportStar(require("./actions/showdownActions"), exports);
69
+ __exportStar(require("./actions/special"), exports);
70
+ __exportStar(require("./utils/viewMasking"), exports);
71
+ __exportStar(require("./utils/serialization"), exports);
72
+ __exportStar(require("./utils/cardUtils"), exports);
73
+ __exportStar(require("./history/exporter"), exports);
@@ -1,6 +1,6 @@
1
1
  import { GameState, TableConfig, Action, PublicState } from "@pokertools/types";
2
2
  import { Snapshot } from "../utils/serialization";
3
- import { HandHistory, ExportOptions } from "../history/types";
3
+ import { HandHistory, ExportOptions } from "@pokertools/types";
4
4
  /**
5
5
  * Event listener callback type
6
6
  */
@@ -35,6 +35,27 @@ export declare class PokerEngine {
35
35
  * If action.timestamp is not provided, the engine will automatically set it
36
36
  */
37
37
  act(action: Action): GameState;
38
+ /**
39
+ * Validate an action without executing it
40
+ * Useful for UI state (enabling/disabling buttons)
41
+ */
42
+ validate(action: Action): {
43
+ valid: true;
44
+ } | {
45
+ valid: false;
46
+ error: string;
47
+ code?: string;
48
+ };
49
+ /**
50
+ * Reconcile local state with server state
51
+ * Smoothly merges server updates into client engine
52
+ */
53
+ reconcile(serverState: PublicState | GameState): void;
54
+ /**
55
+ * Optimistically execute an action and return the provisional state
56
+ * Does not modify the engine's actual state
57
+ */
58
+ optimisticAct(action: Action): GameState;
38
59
  /**
39
60
  * Undo last action
40
61
  */
@@ -46,7 +67,7 @@ export declare class PokerEngine {
46
67
  /**
47
68
  * Get player view (masked)
48
69
  */
49
- view(playerId?: string): PublicState;
70
+ view(playerId?: string, version?: number): PublicState;
50
71
  /**
51
72
  * Get snapshot for serialization
52
73
  */
@@ -80,6 +80,57 @@ class PokerEngine {
80
80
  this.dispatch(actionWithTimestamp);
81
81
  return this.currentState;
82
82
  }
83
+ /**
84
+ * Validate an action without executing it
85
+ * Useful for UI state (enabling/disabling buttons)
86
+ */
87
+ validate(action) {
88
+ try {
89
+ // Dry-run the reducer
90
+ // We don't need to deep clone state because reducer is immutable
91
+ // and pure, and we discard the result.
92
+ (0, gameReducer_1.gameReducer)(this.currentState, action);
93
+ return { valid: true };
94
+ }
95
+ catch (err) {
96
+ const message = err?.message ?? "Invalid action";
97
+ const code = err?.code;
98
+ return {
99
+ valid: false,
100
+ error: message,
101
+ code,
102
+ };
103
+ }
104
+ }
105
+ /**
106
+ * Reconcile local state with server state
107
+ * Smoothly merges server updates into client engine
108
+ */
109
+ reconcile(serverState) {
110
+ // Hydrate PublicState into GameState if needed
111
+ const newState = {
112
+ ...serverState,
113
+ // Ensure deck exists (empty for client/public state)
114
+ deck: "deck" in serverState ? serverState.deck : [],
115
+ // Ensure players map correctly (PublicPlayer.hand is compatible with Player.hand)
116
+ players: serverState.players, // Type assertion needed due to deep readonly/mutable mismatch potential
117
+ // Ensure config carries isClient flag if set locally
118
+ config: {
119
+ ...serverState.config,
120
+ isClient: this.currentState.config.isClient,
121
+ },
122
+ };
123
+ this.currentState = newState;
124
+ }
125
+ /**
126
+ * Optimistically execute an action and return the provisional state
127
+ * Does not modify the engine's actual state
128
+ */
129
+ optimisticAct(action) {
130
+ const timestamp = action.timestamp ?? this.timeProvider();
131
+ const actionWithTimestamp = { ...action, timestamp };
132
+ return (0, gameReducer_1.gameReducer)(this.currentState, actionWithTimestamp);
133
+ }
83
134
  /**
84
135
  * Undo last action
85
136
  */
@@ -100,8 +151,8 @@ class PokerEngine {
100
151
  /**
101
152
  * Get player view (masked)
102
153
  */
103
- view(playerId) {
104
- return (0, viewMasking_1.createPublicView)(this.currentState, playerId ?? null);
154
+ view(playerId, version) {
155
+ return (0, viewMasking_1.createPublicView)(this.currentState, playerId ?? null, version ?? 0);
105
156
  }
106
157
  /**
107
158
  * Get snapshot for serialization
@@ -236,6 +287,7 @@ class PokerEngine {
236
287
  ante: initialBlinds?.ante ?? config.ante ?? 0,
237
288
  blindLevel: 0,
238
289
  timeBanks: new Map(),
290
+ timeBankActiveSeat: null,
239
291
  actionHistory: [],
240
292
  previousStates: [],
241
293
  timestamp: Date.now(),