@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 PokerTools
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
# @pokertools/engine
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@pokertools/engine)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://github.com/pokertools/engine/actions)
|
|
6
|
+
[](https://codecov.io/gh/pokertools/engine)
|
|
7
|
+
[](https://bundlephobia.com/package/@pokertools/engine)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
|
|
10
|
+
An enterprise-grade, **deterministic, immutable, and high-performance** Texas Hold'em poker engine.
|
|
11
|
+
|
|
12
|
+
Built on the **Redux design pattern**, this engine treats the poker game as a finite state machine. It accepts a `GameState` and an `Action`, and returns a new `GameState`. This architecture makes it uniquely suited for:
|
|
13
|
+
|
|
14
|
+
- **Multiplayer Servers:** Easy synchronization, crash recovery, and concurrency.
|
|
15
|
+
- **AI Training:** Fast simulations for Monte Carlo / Reinforcement Learning (17m hands/sec using `@pokertools/evaluator`).
|
|
16
|
+
- **Real Money Gaming:** Auditable RNG, integer-only arithmetic, and strict invariant checking.
|
|
17
|
+
- **Solvers:** Correct handling of complex side-pots, split-pots, and heads-up positioning.
|
|
18
|
+
|
|
19
|
+
## Table of Contents
|
|
20
|
+
|
|
21
|
+
- [Features](#features)
|
|
22
|
+
- [Installation](#installation)
|
|
23
|
+
- [Quick Start](#quick-start)
|
|
24
|
+
- [Architecture](#architecture)
|
|
25
|
+
- [Project Structure](#project-structure)
|
|
26
|
+
- [Advanced Usage](#advanced-usage)
|
|
27
|
+
- [Provable Fairness (Custom RNG)](#provable-fairness-custom-rng)
|
|
28
|
+
- [Crash Recovery (Snapshots)](#crash-recovery-snapshots)
|
|
29
|
+
- [Event Subscription & Middleware](#event-subscription--middleware)
|
|
30
|
+
- [Hand History Export](#hand-history-export)
|
|
31
|
+
- [Time Banks & Timeout Logic](#time-banks--timeout-logic)
|
|
32
|
+
- [Undo & Rollback](#undo--rollback)
|
|
33
|
+
- [Tournament Blind Schedules](#tournament-blind-schedules)
|
|
34
|
+
- [Scalability & Worker Threads](#scalability--worker-threads)
|
|
35
|
+
- [Security & Integrity](#security--integrity)
|
|
36
|
+
- [View Masking (Anti-Cheat)](#view-masking-anti-cheat)
|
|
37
|
+
- [Invariant Auditing](#invariant-auditing)
|
|
38
|
+
- [Integer Arithmetic](#integer-arithmetic)
|
|
39
|
+
- [Rule Logic & Edge Cases](#rule-logic--edge-cases)
|
|
40
|
+
- [API Reference](#api-reference)
|
|
41
|
+
- [Error Handling](#error-handling)
|
|
42
|
+
- [Contributing](#contributing)
|
|
43
|
+
- [License](#license)
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Pure State Machine:** Zero internal mutation. `f(state, action) => newState`.
|
|
48
|
+
- **Provably Fair:** Inject your own RNG (e.g., CSPRNG or hardware RNG) for completely auditable shuffling.
|
|
49
|
+
- **Crash Resilient:** Export lightweight JSON snapshots and restore game state instantly from a database.
|
|
50
|
+
- **Event Driven:** Subscribe to state changes to trigger UI sounds, animations, or analytics.
|
|
51
|
+
- **Complex Logic Solved:**
|
|
52
|
+
- **Side Pots:** Handles multi-way all-ins with mathematically correct pot segregation using the iterative subtraction method.
|
|
53
|
+
- **Split Pots:** Distributes odd chips by position (closest to left of button) or suit automatically.
|
|
54
|
+
- **Heads-Up Rules:** Automatically switches Button/SB positioning logic when only 2 players remain.
|
|
55
|
+
- **Auto-Runout:** Automatically deals remaining streets and calculates winners when all active players are all-in.
|
|
56
|
+
- **Rake Support:** Configurable rake percentage and cap for cash games (tournaments automatically excluded).
|
|
57
|
+
- **Granular Card Visibility:** Players can selectively show cards at showdown (e.g., show only one card to prove a bluff).
|
|
58
|
+
- **Time Bank Support:** Native support for "Time Bank" resource management and explicit timeout resolutions.
|
|
59
|
+
- **Hand History:** Native export to standard PokerStars/PHH text formats for compatibility with tracking software (PokerTracker 4, HM3).
|
|
60
|
+
- **Strict Typing:** Written in TypeScript with exhaustive definitions for every state transition.
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# npm
|
|
66
|
+
npm install @pokertools/engine
|
|
67
|
+
|
|
68
|
+
# yarn
|
|
69
|
+
yarn add @pokertools/engine
|
|
70
|
+
|
|
71
|
+
# pnpm
|
|
72
|
+
pnpm add @pokertools/engine
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Quick Start
|
|
76
|
+
|
|
77
|
+
This example demonstrates a simple Pre-flop to Flop sequence.
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { PokerEngine, ActionType } from "@pokertools/engine";
|
|
81
|
+
|
|
82
|
+
// 1. Setup Table
|
|
83
|
+
const engine = new PokerEngine({ smallBlind: 10, bigBlind: 20 });
|
|
84
|
+
|
|
85
|
+
// 2. Event Listener (Logging)
|
|
86
|
+
// Subscribe before playing to capture all events
|
|
87
|
+
engine.on((action, oldState, newState) => {
|
|
88
|
+
console.log(`[${action.type}] ${newState.street} - Pot: ${newState.pot}`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 3. Manage Players
|
|
92
|
+
engine.sit(0, "p1", "Alice", 1000);
|
|
93
|
+
engine.sit(1, "p2", "Bob", 1000);
|
|
94
|
+
engine.sit(2, "p3", "Tom", 100);
|
|
95
|
+
|
|
96
|
+
engine.stand("p3"); // Tom leaves
|
|
97
|
+
engine.sit(2, "p4", "Charlie", 500); // Charlie takes the seat
|
|
98
|
+
|
|
99
|
+
// 4. Start Hand
|
|
100
|
+
// Button: Alice (Seat 0), SB: Bob (Seat 1), BB: Charlie (Seat 2)
|
|
101
|
+
engine.deal();
|
|
102
|
+
|
|
103
|
+
// 5. Pre-Flop Action
|
|
104
|
+
// Action starts with Button (Alice) in 3-handed play
|
|
105
|
+
engine.act({ type: ActionType.FOLD, playerId: "p1" }); // Alice folds
|
|
106
|
+
engine.act({ type: ActionType.CALL, playerId: "p2" }); // Bob completes SB to 20 (posts 10 more)
|
|
107
|
+
engine.act({ type: ActionType.CHECK, playerId: "p4" }); // Charlie checks option (already posted BB)
|
|
108
|
+
|
|
109
|
+
// 6. Flop Action (Pot: 40)
|
|
110
|
+
// Engine auto-deals Flop. Bob acts first (first active player left of button).
|
|
111
|
+
engine.act({ type: ActionType.CHECK, playerId: "p2" });
|
|
112
|
+
engine.act({ type: ActionType.BET, playerId: "p4", amount: 40 }); // Charlie bets 40
|
|
113
|
+
engine.act({ type: ActionType.CALL, playerId: "p2" }); // Bob calls 40
|
|
114
|
+
|
|
115
|
+
// 7. Inspect Data
|
|
116
|
+
// Global state (Admin/Server only - reveals all cards)
|
|
117
|
+
const globalState = engine.state;
|
|
118
|
+
|
|
119
|
+
// Player view (Safe for Client - masks opponents' cards)
|
|
120
|
+
const bobView = engine.view("p2");
|
|
121
|
+
|
|
122
|
+
console.log(`Board: ${globalState.board}`); // e.g., ["As", "Kd", "2c"]
|
|
123
|
+
console.log(`Bob's Hand: ${bobView.players[1].hand}`); // e.g., ["Ah", "Kh"]
|
|
124
|
+
console.log(`Charlie's Hand: ${bobView.players[2].hand ?? "Hidden"}`); // "Hidden" (masked)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Configuration Examples
|
|
128
|
+
|
|
129
|
+
#### Cash Game with Rake
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
const cashEngine = new PokerEngine({
|
|
133
|
+
smallBlind: 5,
|
|
134
|
+
bigBlind: 10,
|
|
135
|
+
rakePercent: 5, // 5% rake
|
|
136
|
+
rakeCap: 10, // Max 10 chips per pot
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// After showdown
|
|
140
|
+
console.log(cashEngine.state.rakeThisHand); // e.g., 5 (rake collected)
|
|
141
|
+
// Chip conservation automatically accounts for rake
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
#### Tournament (No Rake)
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
const tournamentEngine = new PokerEngine({
|
|
148
|
+
smallBlind: 25,
|
|
149
|
+
bigBlind: 50,
|
|
150
|
+
ante: 5,
|
|
151
|
+
blindStructure: [
|
|
152
|
+
{ smallBlind: 25, bigBlind: 50, ante: 5 },
|
|
153
|
+
{ smallBlind: 50, bigBlind: 100, ante: 10 },
|
|
154
|
+
{ smallBlind: 100, bigBlind: 200, ante: 25 },
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Rake is automatically disabled for tournaments
|
|
159
|
+
console.log(tournamentEngine.state.rakeThisHand); // Always 0
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Architecture
|
|
163
|
+
|
|
164
|
+
Unlike traditional object-oriented poker engines where `player.bet()` mutates the player object in place, `@pokertools/engine` uses a **Reducer Pattern**.
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
function gameReducer(state: GameState, action: Action): GameState;
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
This design enables:
|
|
171
|
+
|
|
172
|
+
1. **Time Travel:** You can save an array of `Action` objects and replay an entire hand perfectly to debug issues.
|
|
173
|
+
2. **Concurrency:** Since the state is immutable, you can safely read the state in one thread (e.g., sending updates to clients) while calculating the next state in another.
|
|
174
|
+
3. **Testability:** Testing becomes a matter of input vs. output, without complex setup/teardown of class instances.
|
|
175
|
+
|
|
176
|
+
## Project Structure
|
|
177
|
+
|
|
178
|
+
The recommended file structure for this library follows separation-of-concerns principles:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
@pokertools/engine/
|
|
182
|
+
.
|
|
183
|
+
├── LICENSE
|
|
184
|
+
├── README.md
|
|
185
|
+
├── jest.config.js
|
|
186
|
+
├── package.json
|
|
187
|
+
├── src
|
|
188
|
+
│ ├── actions
|
|
189
|
+
│ │ ├── betting.ts
|
|
190
|
+
│ │ ├── dealing.ts
|
|
191
|
+
│ │ ├── management.ts
|
|
192
|
+
│ │ ├── showdownActions.ts
|
|
193
|
+
│ │ ├── special.ts
|
|
194
|
+
│ │ ├── streetProgression.ts
|
|
195
|
+
│ │ ├── tournament.ts
|
|
196
|
+
│ │ └── validation.ts
|
|
197
|
+
│ ├── engine
|
|
198
|
+
│ │ ├── PokerEngine.ts
|
|
199
|
+
│ │ └── gameReducer.ts
|
|
200
|
+
│ ├── errors
|
|
201
|
+
│ │ ├── ConfigError.ts
|
|
202
|
+
│ │ ├── CriticalStateError.ts
|
|
203
|
+
│ │ ├── ErrorCodes.ts
|
|
204
|
+
│ │ ├── IllegalActionError.ts
|
|
205
|
+
│ │ ├── PokerEngineError.ts
|
|
206
|
+
│ │ └── index.ts
|
|
207
|
+
│ ├── history
|
|
208
|
+
│ │ ├── exporter.ts
|
|
209
|
+
│ │ ├── formats
|
|
210
|
+
│ │ │ ├── json.ts
|
|
211
|
+
│ │ │ └── pokerstars.ts
|
|
212
|
+
│ │ ├── handHistoryBuilder.ts
|
|
213
|
+
│ │ └── types.ts
|
|
214
|
+
│ ├── index.ts
|
|
215
|
+
│ ├── rules
|
|
216
|
+
│ │ ├── actionOrder.ts
|
|
217
|
+
│ │ ├── blinds.ts
|
|
218
|
+
│ │ ├── headsUp.ts
|
|
219
|
+
│ │ ├── showdown.ts
|
|
220
|
+
│ │ └── sidePots.ts
|
|
221
|
+
│ └── utils
|
|
222
|
+
│ ├── cardUtils.ts
|
|
223
|
+
│ ├── constants.ts
|
|
224
|
+
│ ├── deck.ts
|
|
225
|
+
│ ├── invariants.ts
|
|
226
|
+
│ ├── positioning.ts
|
|
227
|
+
│ ├── rake.ts
|
|
228
|
+
│ ├── serialization.ts
|
|
229
|
+
│ ├── validation.ts
|
|
230
|
+
│ └── viewMasking.ts
|
|
231
|
+
├── tests
|
|
232
|
+
│ ├── bugs
|
|
233
|
+
│ ├── debug
|
|
234
|
+
│ ├── integration
|
|
235
|
+
│ ├── property
|
|
236
|
+
│ ├── security
|
|
237
|
+
│ └── unit
|
|
238
|
+
└── tsconfig.json
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Advanced Usage
|
|
242
|
+
|
|
243
|
+
### Provable Fairness (Custom RNG)
|
|
244
|
+
|
|
245
|
+
By default, the engine uses `Math.random()`. For real-money gaming, tournaments, or replayable simulations, you **must** inject a seeded or crypto-secure generator. This allows you to prove to players that the deck was shuffled fairly.
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import seedrandom from "seedrandom";
|
|
249
|
+
|
|
250
|
+
// Create a seeded generator (or use a Crypto API)
|
|
251
|
+
const rng = seedrandom("championship-final-table-seed-12345");
|
|
252
|
+
|
|
253
|
+
const engine = new PokerEngine({
|
|
254
|
+
smallBlind: 10,
|
|
255
|
+
bigBlind: 20,
|
|
256
|
+
// The engine will use this function for all shuffling and random decisions
|
|
257
|
+
randomProvider: () => rng.quick(),
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Crash Recovery (Snapshots)
|
|
262
|
+
|
|
263
|
+
Because the state is immutable and serializable, you can save the game state to a persistent store (Redis, Postgres, File System) after every move. If your Node.js process crashes, you can restore the table instantly.
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
// --- 1. SAVING ---
|
|
267
|
+
// Get a lightweight, serializable JSON object
|
|
268
|
+
const snapshot = engine.snapshot;
|
|
269
|
+
// Save to database (e.g., Redis key "table:101")
|
|
270
|
+
await db.save("table:101", JSON.stringify(snapshot));
|
|
271
|
+
|
|
272
|
+
// ... Server Crashes or Restarts ...
|
|
273
|
+
|
|
274
|
+
// --- 2. RESTORING ---
|
|
275
|
+
const savedJson = await db.get("table:101");
|
|
276
|
+
const snapshot = JSON.parse(savedJson);
|
|
277
|
+
|
|
278
|
+
// Create a new engine instance pre-loaded with the exact previous state
|
|
279
|
+
const engine = PokerEngine.restore(snapshot);
|
|
280
|
+
|
|
281
|
+
// Resume play immediately - players won't even notice the restart
|
|
282
|
+
console.log(engine.state.actionTo);
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Event Subscription & Middleware
|
|
286
|
+
|
|
287
|
+
Since the engine is pure, you need a way to know "What just happened?" to trigger side effects like playing sounds, updating the UI, or logging to an analytics server.
|
|
288
|
+
|
|
289
|
+
The engine provides a subscription model that receives the `Action`, the `OldState`, and the `NewState`.
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
engine.on((action, oldState, newState) => {
|
|
293
|
+
// 1. Detect Phase Changes (e.g., Preflop -> Flop)
|
|
294
|
+
if (newState.street !== oldState.street) {
|
|
295
|
+
console.log(`[EVENT] Dealing ${newState.street}: ${newState.board}`);
|
|
296
|
+
socket.emit("playSound", "deal_cards");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 2. Detect Player Actions
|
|
300
|
+
if (action.type === ActionType.FOLD) {
|
|
301
|
+
console.log(`[EVENT] Player ${action.playerId} folded.`);
|
|
302
|
+
socket.emit("animation", { type: "fold", seat: action.playerId });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 3. Detect Winners
|
|
306
|
+
if (newState.winners && !oldState.winners) {
|
|
307
|
+
console.log(`[EVENT] Winners:`, newState.winners);
|
|
308
|
+
// Trigger chip gathering animation
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Hand History Export
|
|
314
|
+
|
|
315
|
+
Serious players require Hand Histories to analyze their gameplay in tools like **PokerTracker 4**, **Holdem Manager 3**, or **GTO Wizard**. The engine can export the current hand in a standardized text format.
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
// Call this at the end of a hand (Street = SHOWDOWN)
|
|
319
|
+
const historyText = engine.history();
|
|
320
|
+
|
|
321
|
+
console.log(historyText);
|
|
322
|
+
|
|
323
|
+
/* Output Example:
|
|
324
|
+
PokerStars Hand #23948239048: Hold'em No Limit ($10/$20 USD) - YYYY/MM/DD
|
|
325
|
+
Table 'Alpha' 6-max Seat #1 is the button
|
|
326
|
+
Seat 1: Alice ($1000 in chips)
|
|
327
|
+
Seat 2: Bob ($1000 in chips)
|
|
328
|
+
Bob: posts small blind $10
|
|
329
|
+
Alice: posts big blind $20
|
|
330
|
+
*** HOLE CARDS ***
|
|
331
|
+
Dealt to Bob [Ah Kh]
|
|
332
|
+
Bob: raises $40 to $60
|
|
333
|
+
...
|
|
334
|
+
*/
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Time Banks & Timeout Logic
|
|
338
|
+
|
|
339
|
+
The engine follows a strict separation of concerns:
|
|
340
|
+
|
|
341
|
+
- **The Server** manages the clock (Real-time).
|
|
342
|
+
- **The Engine** manages the Time Bank (Resource).
|
|
343
|
+
|
|
344
|
+
The engine does not include a `setTimeout`. Instead, you dispatch explicit actions when the server determines time has expired.
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// Scenario: Server timer hits 0.0s for Player P1
|
|
348
|
+
|
|
349
|
+
// 1. Check if player has Time Bank remaining
|
|
350
|
+
if (engine.canUseTimeBank("p1")) {
|
|
351
|
+
// Auto-activate time bank: Deducts time tokens and keeps action on P1
|
|
352
|
+
engine.act({ type: ActionType.TIME_BANK, playerId: "p1" });
|
|
353
|
+
console.log("Time Bank activated!");
|
|
354
|
+
} else {
|
|
355
|
+
// 2. No time left: Force a Fold (or Check if allowed)
|
|
356
|
+
// This action will also mark the player as "Sitting Out"
|
|
357
|
+
engine.act({ type: ActionType.TIMEOUT, playerId: "p1" });
|
|
358
|
+
console.log("Player timed out and folded.");
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Undo & Rollback
|
|
363
|
+
|
|
364
|
+
Essential for admin tools, friendly games, or correcting misclicks. Since the engine uses a persistent data structure, rolling back to a previous state is computationally cheap and instant.
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
// 1. Player misclicks Fold
|
|
368
|
+
engine.act({ type: ActionType.FOLD, playerId: "p1" });
|
|
369
|
+
|
|
370
|
+
// 2. Admin intervenes
|
|
371
|
+
engine.undo();
|
|
372
|
+
|
|
373
|
+
// 3. State is now exactly as it was before the fold
|
|
374
|
+
// The actionTo pointer is back on p1
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Tournament Blind Schedules
|
|
378
|
+
|
|
379
|
+
For tournaments, you can define a blind structure. The engine does not auto-increment automatically (as that is often time-based), but provides a simple API to advance levels.
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
const engine = new PokerEngine({
|
|
383
|
+
initialStack: 1500,
|
|
384
|
+
blindStructure: [
|
|
385
|
+
{ smallBlind: 10, bigBlind: 20, ante: 0 }, // Level 1
|
|
386
|
+
{ smallBlind: 20, bigBlind: 40, ante: 0 }, // Level 2
|
|
387
|
+
{ smallBlind: 50, bigBlind: 100, ante: 10 }, // Level 3
|
|
388
|
+
],
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ... Play some hands ...
|
|
392
|
+
|
|
393
|
+
// Advance to Level 2
|
|
394
|
+
engine.nextBlindLevel();
|
|
395
|
+
console.log(engine.blinds); // { smallBlind: 20, bigBlind: 40 }
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Scalability & Worker Threads
|
|
399
|
+
|
|
400
|
+
For high-volume applications (e.g., 10,000 concurrent tables), running poker logic on the main Node.js event loop can block I/O. Because the engine is state-in/state-out, it is trivial to offload to **Worker Threads**.
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
// worker.ts
|
|
404
|
+
import { parentPort } from "worker_threads";
|
|
405
|
+
import { PokerEngine } from "@pokertools/engine";
|
|
406
|
+
|
|
407
|
+
parentPort?.on("message", (msg) => {
|
|
408
|
+
if (msg.type === "PROCESS_ACTION") {
|
|
409
|
+
// 1. Rehydrate engine from snapshot
|
|
410
|
+
const engine = PokerEngine.restore(msg.snapshot);
|
|
411
|
+
// 2. Run logic
|
|
412
|
+
const newState = engine.act(msg.action);
|
|
413
|
+
// 3. Send result back to Main Thread
|
|
414
|
+
parentPort?.postMessage({ status: "success", state: newState });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## Security & Integrity
|
|
420
|
+
|
|
421
|
+
### View Masking (Anti-Cheat)
|
|
422
|
+
|
|
423
|
+
**Critical:** Never send the full `GameState` result from `state` to a client. It contains the Deck and Opponent Hole Cards.
|
|
424
|
+
|
|
425
|
+
Use the built-in view generator to create a sanitized version for specific players.
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
// Server Code
|
|
429
|
+
const globalState = engine.state;
|
|
430
|
+
|
|
431
|
+
// Send to Alice (Seat 1)
|
|
432
|
+
// - Hides Bob's cards
|
|
433
|
+
// - Hides the Deck
|
|
434
|
+
// - Respects shownCards for granular visibility
|
|
435
|
+
const aliceView = engine.view("p1");
|
|
436
|
+
socket.to("p1").emit("gameState", aliceView);
|
|
437
|
+
|
|
438
|
+
// Send to Bob (Seat 2)
|
|
439
|
+
const bobView = engine.view("p2");
|
|
440
|
+
socket.to("p2").emit("gameState", bobView);
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
#### Granular Card Visibility (New Feature)
|
|
444
|
+
|
|
445
|
+
At showdown, players can control which cards are revealed using the `shownCards` field:
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
// Player state after showdown
|
|
449
|
+
player.hand = ["As", "Kd"]; // Actual cards (always preserved)
|
|
450
|
+
player.shownCards = [0, 1]; // Both cards shown (winner)
|
|
451
|
+
|
|
452
|
+
// Or for selective showing
|
|
453
|
+
player.shownCards = [0]; // Only left card shown
|
|
454
|
+
player.shownCards = null; // Mucked (no cards shown)
|
|
455
|
+
|
|
456
|
+
// Public view respects shownCards and preserves positional context:
|
|
457
|
+
// - shownCards: [0, 1] → hand: ["As", "Kd"] (both visible)
|
|
458
|
+
// - shownCards: [0] → hand: ["As", null] (left visible, right hidden)
|
|
459
|
+
// - shownCards: [1] → hand: [null, "Kd"] (left hidden, right visible)
|
|
460
|
+
// - shownCards: null → hand: null (completely mucked)
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Important:** The view masking preserves positional context by using `null` for hidden cards. This ensures clients know which card is being shown (left vs right).
|
|
464
|
+
|
|
465
|
+
#### Optional Show Actions
|
|
466
|
+
|
|
467
|
+
Players can reveal their cards after showdown:
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
// Loser reveals both cards to show a bluff
|
|
471
|
+
engine.act({
|
|
472
|
+
type: ActionType.SHOW,
|
|
473
|
+
playerId: "loser123",
|
|
474
|
+
// cardIndices optional - defaults to all cards [0, 1]
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Or show only specific cards
|
|
478
|
+
engine.act({
|
|
479
|
+
type: ActionType.SHOW,
|
|
480
|
+
playerId: "player456",
|
|
481
|
+
cardIndices: [0], // Show only left card
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Winner can also explicitly show (already shown by default)
|
|
485
|
+
engine.act({
|
|
486
|
+
type: ActionType.SHOW,
|
|
487
|
+
playerId: "winner789",
|
|
488
|
+
});
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### Invariant Auditing
|
|
492
|
+
|
|
493
|
+
The engine implements strict accounting logic. After every single action, it runs an internal audit to ensure **Conservation of Chips**.
|
|
494
|
+
|
|
495
|
+
$$\sum(\text{PlayerStacks}) + \sum(\text{Pots}) + \sum(\text{CurrentBets}) = \text{InitialChips}$$
|
|
496
|
+
|
|
497
|
+
If a logic bug ever causes a chip to duplicate or vanish, the engine throws a `CriticalStateError`.
|
|
498
|
+
|
|
499
|
+
**Recommendation:** Wrap your `act` calls in a try/catch. If this error occurs, **freeze the table** immediately. It indicates a serious data integrity issue.
|
|
500
|
+
|
|
501
|
+
### Integer Arithmetic
|
|
502
|
+
|
|
503
|
+
To ensure financial accuracy, the engine strictly forbids floating-point numbers.
|
|
504
|
+
|
|
505
|
+
- **Input:** All stacks, bets, and blinds must be Integers (representing cents or the smallest chip unit).
|
|
506
|
+
- **Internal:** Pot divisions use integer division with deterministic remainder distribution (by position).
|
|
507
|
+
- **Safety:** This prevents "Penny Drift" exploits common in JavaScript floating-point math.
|
|
508
|
+
|
|
509
|
+
## Rule Logic & Edge Cases
|
|
510
|
+
|
|
511
|
+
This engine isn't just a loop; it is a strict implementation of standard TDA (Tournament Directors Association) rules.
|
|
512
|
+
|
|
513
|
+
### 1. Heads-Up Positioning
|
|
514
|
+
|
|
515
|
+
In a standard game (3+ players), the Small Blind is to the left of the Button.
|
|
516
|
+
|
|
517
|
+
- **The Trap:** In Heads-Up (2 players), the **Button IS the Small Blind**.
|
|
518
|
+
- **The Rule:** The Button acts **first** Pre-Flop, and acts **last** Post-Flop.
|
|
519
|
+
- **Implementation:** The engine detects when exactly 2 players are active (not folded/busted) and automatically swaps the blind posting order and action order to comply with this rule.
|
|
520
|
+
|
|
521
|
+
### 2. The "Incomplete Raise"
|
|
522
|
+
|
|
523
|
+
- **Scenario:** Player A bets 100. Player B is All-In for 120 (Raise of 20). Min raise is 100.
|
|
524
|
+
- **The Rule:** Since the raise (20) is less than 50% (or 100% depending on ruleset) of the min-raise, the betting is **NOT** re-opened for Player A. Player A can only CALL or FOLD. They cannot re-raise.
|
|
525
|
+
- **Implementation:** The engine tracks `legalActions` and will throw `ILLEGAL_ACTION` if Player A attempts to raise in this spot.
|
|
526
|
+
|
|
527
|
+
### 3. Split Pot "Odd Chip" Resolution
|
|
528
|
+
|
|
529
|
+
In split pots (e.g., High-Low or Tie), chip counts often result in decimals (e.g., 25 chips / 2 players = 12.5).
|
|
530
|
+
|
|
531
|
+
- **The Rule:** The odd chip goes to the player in the **worst position** (closest to the left of the button). In High-Low games, the odd chip goes to the High hand.
|
|
532
|
+
- **Implementation:** The engine resolves this deterministically using seat indexes relative to the dealer button.
|
|
533
|
+
|
|
534
|
+
### 4. Side Pots (Iterative Subtraction)
|
|
535
|
+
|
|
536
|
+
The engine handles complex multi-way all-ins.
|
|
537
|
+
|
|
538
|
+
- **Scenario:** A (100 chips), B (500 chips), C (1000 chips) all go All-In.
|
|
539
|
+
- **Pot 1 (Main):** 300 chips (100 each from A, B, C). A, B, and C contest this.
|
|
540
|
+
- **Pot 2 (Side):** 800 chips (400 each from B and C). Only B and C contest this.
|
|
541
|
+
- **Remaining:** C's extra 500 chips are returned (no one to match).
|
|
542
|
+
- **Resolution:** The engine evaluates hands for Pot 2 first, awards it, then evaluates Pot 1.
|
|
543
|
+
|
|
544
|
+
## API Reference
|
|
545
|
+
|
|
546
|
+
### `PokerEngine` Class
|
|
547
|
+
|
|
548
|
+
| Method | Arguments | Returns | Description |
|
|
549
|
+
| ------------- | ----------------------- | ------------- | -------------------------------------------------- |
|
|
550
|
+
| `constructor` | `config` | `PokerEngine` | Creates a new table instance. |
|
|
551
|
+
| `sit` | `seat, id, name, stack` | `void` | Adds a player to a specific seat. |
|
|
552
|
+
| `stand` | `id` | `void` | Removes a player. |
|
|
553
|
+
| `deal` | `none` | `void` | Starts the hand (Shuffles/Posts Blinds). |
|
|
554
|
+
| `act` | `action` | `GameState` | Executes a move. |
|
|
555
|
+
| `undo` | `none` | `boolean` | Reverts state to previous step. |
|
|
556
|
+
| `state` | _(Getter)_ | `GameState` | The full, internal, unmasked state. |
|
|
557
|
+
| `view` | `id?` | `PublicState` | Masked state for a player (or spectator if no ID). |
|
|
558
|
+
| `snapshot` | _(Getter)_ | `object` | Serializable JSON for database storage. |
|
|
559
|
+
| `restore` | `snapshot` | `PokerEngine` | **Static.** Recreates engine from backup. |
|
|
560
|
+
| `history` | `none` | `string` | Generates the text log (PHH/PokerStars). |
|
|
561
|
+
| `on` | `fn(act, old, new)` | `unsub` | Subscribe to state changes. |
|
|
562
|
+
|
|
563
|
+
### `Action` Types
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
interface Action {
|
|
567
|
+
type: ActionType;
|
|
568
|
+
playerId: string;
|
|
569
|
+
amount?: number; // Required for BET and RAISE
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
enum ActionType {
|
|
573
|
+
FOLD = "FOLD",
|
|
574
|
+
CHECK = "CHECK",
|
|
575
|
+
CALL = "CALL",
|
|
576
|
+
BET = "BET", // Opening a bet
|
|
577
|
+
RAISE = "RAISE", // Increasing an existing bet
|
|
578
|
+
TIMEOUT = "TIMEOUT",
|
|
579
|
+
TIME_BANK = "TIME_BANK", // Extends turn using time bank
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
## Error Handling
|
|
584
|
+
|
|
585
|
+
The engine throws typed errors. You should wrap `act` calls in a `try/catch` block.
|
|
586
|
+
|
|
587
|
+
| Error Code | Description | Recommended Action |
|
|
588
|
+
| ---------------------------- | ---------------------------------- | ------------------------------------ |
|
|
589
|
+
| `CRITICAL_INVARIANT_FAILURE` | Chips disappeared/duplicated. | **FREEZE GAME**. Contact Support. |
|
|
590
|
+
| `NOT_YOUR_TURN` | Player acted out of order. | Ignore or warn client. |
|
|
591
|
+
| `INVALID_AMOUNT` | Bet is below min-raise or > stack. | Reject action, ask for valid amount. |
|
|
592
|
+
| `ILLEGAL_ACTION` | Tried to Check when facing a bet. | Reject action. |
|
|
593
|
+
| `STALE_STATE` | Optimistic UI mismatch. | Send fresh `view()` to client. |
|
|
594
|
+
|
|
595
|
+
## Contributing
|
|
596
|
+
|
|
597
|
+
This project is part of the `@pokertools` monorepo.
|
|
598
|
+
|
|
599
|
+
1. Clone the repository.
|
|
600
|
+
2. Run `npm install`.
|
|
601
|
+
3. Run `npm test`.
|
|
602
|
+
|
|
603
|
+
**Note:** The test suite includes over 500 edge-case scenarios including split pots, kickers, and side-pot math. Please ensure all pass before submitting a PR.
|
|
604
|
+
|
|
605
|
+
## License
|
|
606
|
+
|
|
607
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { GameState, FoldAction, CheckAction, CallAction, BetAction, RaiseAction } from "@pokertools/types";
|
|
2
|
+
/**
|
|
3
|
+
* Handle FOLD action
|
|
4
|
+
*/
|
|
5
|
+
export declare function handleFold(state: GameState, action: FoldAction): GameState;
|
|
6
|
+
/**
|
|
7
|
+
* Handle CHECK action
|
|
8
|
+
*/
|
|
9
|
+
export declare function handleCheck(state: GameState, action: CheckAction): GameState;
|
|
10
|
+
/**
|
|
11
|
+
* Handle CALL action
|
|
12
|
+
*/
|
|
13
|
+
export declare function handleCall(state: GameState, action: CallAction): GameState;
|
|
14
|
+
/**
|
|
15
|
+
* Handle BET action
|
|
16
|
+
*/
|
|
17
|
+
export declare function handleBet(state: GameState, action: BetAction): GameState;
|
|
18
|
+
/**
|
|
19
|
+
* Handle RAISE action
|
|
20
|
+
*/
|
|
21
|
+
export declare function handleRaise(state: GameState, action: RaiseAction): GameState;
|