texasholdem 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/.claude/settings.local.json +14 -0
- package/.editorconfig +15 -0
- package/.github/workflows/publish.yml +36 -0
- package/.prettierrc +10 -0
- package/PLAN.md +215 -0
- package/README.md +182 -0
- package/eslint.config.js +57 -0
- package/package.json +36 -0
- package/src/action.ts +209 -0
- package/src/betting.ts +330 -0
- package/src/brand.ts +140 -0
- package/src/card.ts +179 -0
- package/src/deck.ts +155 -0
- package/src/error.ts +127 -0
- package/src/evaluator.ts +94 -0
- package/src/event.ts +48 -0
- package/src/hand.ts +609 -0
- package/src/index.ts +35 -0
- package/src/player.ts +96 -0
- package/src/pokersolver.d.ts +15 -0
- package/src/pot.ts +243 -0
- package/src/table.ts +300 -0
- package/test/action.test.ts +86 -0
- package/test/arbitraries.ts +74 -0
- package/test/betting.test.ts +139 -0
- package/test/brand.test.ts +18 -0
- package/test/card.test.ts +60 -0
- package/test/deck.test.ts +99 -0
- package/test/evaluator.test.ts +142 -0
- package/test/hand.test.ts +161 -0
- package/test/integration.test.ts +354 -0
- package/test/player.test.ts +21 -0
- package/test/pot.test.ts +192 -0
- package/test/properties/action.properties.ts +251 -0
- package/test/properties/betting.properties.ts +331 -0
- package/test/properties/brand.properties.ts +103 -0
- package/test/properties/card.properties.ts +100 -0
- package/test/properties/deck.properties.ts +160 -0
- package/test/properties/evaluator.properties.ts +119 -0
- package/test/properties/hand.properties.ts +357 -0
- package/test/properties/player.properties.ts +136 -0
- package/test/properties/pot.properties.ts +140 -0
- package/test/properties/table.properties.ts +377 -0
- package/test/table.test.ts +181 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"WebFetch(domain:github.com)",
|
|
5
|
+
"WebFetch(domain:raw.githubusercontent.com)",
|
|
6
|
+
"WebFetch(domain:cdn.jsdelivr.net)",
|
|
7
|
+
"Bash(pnpm config:*)",
|
|
8
|
+
"Bash(pnpm add:*)",
|
|
9
|
+
"Bash(wc:*)",
|
|
10
|
+
"Bash(pnpm test:*)",
|
|
11
|
+
"Bash(pnpm tsc:*)"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
package/.editorconfig
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
id-token: write
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: pnpm/action-setup@v4
|
|
18
|
+
with:
|
|
19
|
+
version: 9
|
|
20
|
+
|
|
21
|
+
- uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: 22
|
|
24
|
+
cache: pnpm
|
|
25
|
+
registry-url: https://registry.npmjs.org
|
|
26
|
+
|
|
27
|
+
- run: pnpm install --frozen-lockfile
|
|
28
|
+
- run: pnpm typecheck
|
|
29
|
+
- run: pnpm test
|
|
30
|
+
- run: pnpm build
|
|
31
|
+
|
|
32
|
+
- run: |
|
|
33
|
+
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> .npmrc
|
|
34
|
+
npm publish --provenance --access public
|
|
35
|
+
env:
|
|
36
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/.prettierrc
ADDED
package/PLAN.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Texas Hold'em Engine - Implementation Plan
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Build a modular, functional Texas Hold'em poker engine in TypeScript using Effect-TS. Inspired by [poker-ts](https://github.com/claudijo/poker-ts) but improved: immutable state, typed errors, event logging, and composable modules. Hand evaluation delegated to [pokersolver](https://github.com/goldfire/pokersolver) (battle-tested, production-proven) behind a clean abstraction boundary.
|
|
6
|
+
|
|
7
|
+
**Key improvements over poker-ts:**
|
|
8
|
+
- Immutable state transitions (`(state, action) => newState`) instead of mutation
|
|
9
|
+
- Typed errors via `Data.TaggedError` instead of thrown strings
|
|
10
|
+
- Event log accumulated in state for observability/hand history
|
|
11
|
+
- Automatic phase advancement (no manual `endBettingRound()` calls)
|
|
12
|
+
- Branded types (`Chips`, `SeatIndex`) prevent mixing up numbers at compile time
|
|
13
|
+
- Configurable seat count (poker-ts hardcodes 9)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Module Architecture
|
|
18
|
+
|
|
19
|
+
12 source files, strict bottom-up dependency order. Each module is independently usable.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
brand.ts ─── card.ts ─── deck.ts ───────────────────┐
|
|
23
|
+
│ │ │
|
|
24
|
+
│ └── evaluator.ts (pokersolver wrap) │
|
|
25
|
+
│ │
|
|
26
|
+
├── player.ts ── action.ts ── event.ts │
|
|
27
|
+
│ │ │ │
|
|
28
|
+
│ └── pot.ts ──┤ │
|
|
29
|
+
│ │ │ │
|
|
30
|
+
│ betting.ts ──┘ │
|
|
31
|
+
│ │ │
|
|
32
|
+
│ hand.ts ────────────────────────┘
|
|
33
|
+
│ │
|
|
34
|
+
└───────────────── table.ts
|
|
35
|
+
│
|
|
36
|
+
index.ts (barrel exports)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Module Details
|
|
42
|
+
|
|
43
|
+
### 1. `brand.ts` — Branded domain types
|
|
44
|
+
- `Chips` — non-negative integer (runtime validated via `Brand.refined`)
|
|
45
|
+
- `SeatIndex` — valid seat number
|
|
46
|
+
- `HandId` — unique hand identifier
|
|
47
|
+
- Schema counterparts for each (enables free fast-check arbitraries via `Arbitrary.make`)
|
|
48
|
+
|
|
49
|
+
### 2. `card.ts` — Card primitives
|
|
50
|
+
- `Rank` (2-14), `Suit` ("c"|"d"|"h"|"s") as const objects + union types
|
|
51
|
+
- `Card` — readonly `{ rank, suit }` struct
|
|
52
|
+
- `ALL_CARDS` — 52-element constant array
|
|
53
|
+
- `toPokersolverString(card)` — converts to "Ad", "Th" format
|
|
54
|
+
|
|
55
|
+
### 3. `deck.ts` — Deck with Effect-based shuffle
|
|
56
|
+
- `Deck = readonly Card[]`
|
|
57
|
+
- `shuffled: Effect<Deck>` — uses `Random.shuffle` (the **only** effectful operation in the engine)
|
|
58
|
+
- `draw(deck, count) => [drawn, remaining]` — pure
|
|
59
|
+
- `dealHoleCards(deck, seats) => [Map<SeatIndex, [Card,Card]>, remaining]` — pure
|
|
60
|
+
- `dealFlop / dealOne` — pure convenience functions
|
|
61
|
+
|
|
62
|
+
### 4. `evaluator.ts` — pokersolver wrapper
|
|
63
|
+
- `HandRank` — our own type: `{ name, description, rank, bestCards }`
|
|
64
|
+
- `evaluate(cards) => HandRank` — wraps `pokersolver.Hand.solve`
|
|
65
|
+
- `compare(a, b) => -1|0|1`
|
|
66
|
+
- `winners(hands) => HandRank[]`
|
|
67
|
+
- `evaluateHoldem(holeCards, communityCards) => HandRank`
|
|
68
|
+
- Includes `declare module "pokersolver"` type declarations
|
|
69
|
+
- **pokersolver types never leak** — all conversion happens inside this module
|
|
70
|
+
|
|
71
|
+
### 5. `player.ts` — Immutable player state
|
|
72
|
+
- `Player` — readonly struct: `{ seatIndex, chips, currentBet, isAllIn, isFolded, holeCards }`
|
|
73
|
+
- Pure transitions: `placeBet`, `fold`, `winChips`, `collectBet`, `dealCards`, `clearHand`
|
|
74
|
+
- Derived: `stack(player)` = chips - currentBet, `canAct(player)` = !folded && !allIn
|
|
75
|
+
|
|
76
|
+
### 6. `action.ts` — Player actions + validation
|
|
77
|
+
- `Action` — discriminated union: Fold | Check | Call | Bet | Raise | AllIn
|
|
78
|
+
- `LegalActions` — what the active player can do: canFold, canCheck, callAmount, betRange, raiseRange
|
|
79
|
+
- `computeLegalActions(player, biggestBet, minRaise)` — pure computation
|
|
80
|
+
- `validateAction(action, legalActions) => Either<Error, Action>`
|
|
81
|
+
|
|
82
|
+
### 7. `event.ts` — Game events (discriminated union)
|
|
83
|
+
- `GameEvent` — HandStarted, BlindsPosted, HoleCardsDealt, PlayerActed, BettingRoundEnded, CommunityCardsDealt, ShowdownStarted, PotAwarded, HandEnded, PlayerSatDown, PlayerStoodUp
|
|
84
|
+
- Events accumulated in state as a readonly array — no callbacks/event bus needed
|
|
85
|
+
|
|
86
|
+
### 8. `error.ts` — Typed error hierarchy
|
|
87
|
+
- `InvalidAction`, `NotPlayersTurn`, `InvalidGameState`, `InsufficientChips`, `SeatOccupied`
|
|
88
|
+
- All extend `Data.TaggedError` for exhaustive pattern matching
|
|
89
|
+
- `PokerError` union type
|
|
90
|
+
|
|
91
|
+
### 9. `pot.ts` — Side-pot calculation
|
|
92
|
+
- `Pot` — `{ amount: Chips, eligibleSeats: SeatIndex[] }`
|
|
93
|
+
- `collectBets(players, existingPots) => { pots, players }` — the min-bet collection algorithm
|
|
94
|
+
- `awardPots(pots, playerHands, buttonSeat) => awards[]` — distributes winnings, odd chips clockwise from button
|
|
95
|
+
- `totalPotSize(pots) => Chips`
|
|
96
|
+
|
|
97
|
+
### 10. `betting.ts` — Betting round state machine
|
|
98
|
+
Combines poker-ts's `Round` + `BettingRound` into one module.
|
|
99
|
+
- `BettingRoundState` — name, players, activeIndex, activeSeatOrder, biggestBet, minRaise, lastAggressor, isComplete
|
|
100
|
+
- `createBettingRound(name, players, firstToAct, biggestBet, minRaise)`
|
|
101
|
+
- `applyAction(state, seat, action) => Either<Error, { state, events }>` — validates, applies, advances turn, detects completion
|
|
102
|
+
- `getLegalActions(state) => LegalActions`
|
|
103
|
+
- `activePlayer(state) => SeatIndex | null`
|
|
104
|
+
|
|
105
|
+
### 11. `hand.ts` — Full hand lifecycle
|
|
106
|
+
The composition point. Manages phases: Preflop → Flop → Turn → River → Showdown → Complete.
|
|
107
|
+
- `HandState` — phase, players, communityCards, deck, pots, bettingRound, button, forcedBets, events
|
|
108
|
+
- `startHand(players, button, forcedBets) => Effect<HandState>` — effectful (shuffles deck)
|
|
109
|
+
- `act(state, seat, action) => Either<Error, HandState>` — **auto-advances** phase when betting round completes (deals community cards, starts next round, triggers showdown)
|
|
110
|
+
- `activePlayer`, `currentPhase`, `getLegalActions`, `getEvents` — read-only queries
|
|
111
|
+
|
|
112
|
+
### 12. `table.ts` — Multi-hand session manager
|
|
113
|
+
- `TableState` — config, seats (Map), button, currentHand, handCount, events
|
|
114
|
+
- `createTable(config)`, `sitDown`, `standUp`
|
|
115
|
+
- `startNextHand(state) => Effect<TableState, PokerError>` — moves button, starts hand
|
|
116
|
+
- `act(state, seat, action) => Either<Error, TableState>` — forwards to hand module
|
|
117
|
+
- Button movement, busted player removal automatic
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Effect-TS Usage Strategy
|
|
122
|
+
|
|
123
|
+
| Where | What | Why |
|
|
124
|
+
|-------|------|-----|
|
|
125
|
+
| `deck.ts` shuffled | `Effect<Deck>` | Randomness is a side effect |
|
|
126
|
+
| `hand.ts` startHand | `Effect<HandState>` | Calls shuffle |
|
|
127
|
+
| `table.ts` startNextHand | `Effect<TableState, PokerError>` | Calls startHand |
|
|
128
|
+
| Everything else | Pure functions / `Either` | No side effects needed |
|
|
129
|
+
| Branded types | `Brand.refined` | Compile-time + runtime type safety |
|
|
130
|
+
| Errors | `Data.TaggedError` | Pattern matchable typed errors |
|
|
131
|
+
| Tests | `Random.make` with seed | Deterministic shuffle for reproducible tests |
|
|
132
|
+
|
|
133
|
+
We deliberately **avoid** `Layer`/`Context`/`Service` — this is a library, not an application.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Testing Strategy
|
|
138
|
+
|
|
139
|
+
### fast-check property tests (`test/properties/`)
|
|
140
|
+
|
|
141
|
+
**Pot invariants:**
|
|
142
|
+
- Chip conservation: sum of bets in === sum of pot amounts out
|
|
143
|
+
- Every non-folded player eligible for at least the main pot
|
|
144
|
+
- Side pots created only when bet levels differ (all-in scenarios)
|
|
145
|
+
|
|
146
|
+
**Betting round invariants:**
|
|
147
|
+
- Legal actions always non-empty for active player
|
|
148
|
+
- Player chips never go negative
|
|
149
|
+
- Total chip count constant throughout a round
|
|
150
|
+
- Betting round always terminates (no infinite loops)
|
|
151
|
+
- Calling sets bet equal to biggest bet; raising exceeds it
|
|
152
|
+
|
|
153
|
+
**Hand lifecycle invariants:**
|
|
154
|
+
- Phases progress strictly: Preflop → Flop → Turn → River → Showdown → Complete
|
|
155
|
+
- Community cards: 0 → 3 → 4 → 5
|
|
156
|
+
- Total chips (players + pots) constant throughout hand
|
|
157
|
+
- Hand always terminates
|
|
158
|
+
|
|
159
|
+
### Schema-derived arbitraries
|
|
160
|
+
Use `Arbitrary.make(ChipsSchema)`, `Arbitrary.make(CardSchema)` etc. to generate valid domain values for property tests.
|
|
161
|
+
|
|
162
|
+
### Unit tests per module (`test/*.test.ts`)
|
|
163
|
+
Each module gets focused unit tests for specific scenarios: known hand rankings, heads-up blinds, multi-way side pots, etc.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Implementation Order (TDD — Tests First)
|
|
168
|
+
|
|
169
|
+
For each module: **write tests first**, then implement until tests pass. Build bottom-up.
|
|
170
|
+
|
|
171
|
+
| Step | Phase 1: Tests | Phase 2: Implementation |
|
|
172
|
+
|------|---------------|------------------------|
|
|
173
|
+
| 1 | Project setup | package.json, tsconfig, vitest config, install deps |
|
|
174
|
+
| 2 | `brand.test.ts` — Chips rejects negatives/floats, SeatIndex validates range | `brand.ts` + `error.ts` |
|
|
175
|
+
| 3 | `card.test.ts` — ALL_CARDS has 52 unique, toPokersolverString roundtrips | `card.ts` |
|
|
176
|
+
| 4 | `evaluator.test.ts` — known rankings (royal flush > full house), ties, kickers | `evaluator.ts` |
|
|
177
|
+
| 5 | `deck.test.ts` — shuffled has 52 unique cards, draw reduces size, empty deck errors | `deck.ts` |
|
|
178
|
+
| 6 | `player.test.ts` — bet reduces stack, fold sets flag, can't bet > chips, allIn detection | `player.ts` |
|
|
179
|
+
| 7 | `action.test.ts` — legal actions computed correctly for various scenarios | `action.ts` + `event.ts` |
|
|
180
|
+
| 8 | `pot.test.ts` + `pot.properties.ts` — side pots, chip conservation property | `pot.ts` |
|
|
181
|
+
| 9 | `betting.test.ts` + `betting.properties.ts` — turn order, round completion, chip invariants | `betting.ts` |
|
|
182
|
+
| 10 | `hand.test.ts` + `hand.properties.ts` — full hand scenarios, phase progression | `hand.ts` |
|
|
183
|
+
| 11 | `table.test.ts` — seating, button movement, multi-hand sessions | `table.ts` |
|
|
184
|
+
| 12 | `integration.test.ts` — end-to-end scenarios through public API | `index.ts` |
|
|
185
|
+
|
|
186
|
+
Each step: write failing tests → implement module → all tests green → move on.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Verification
|
|
191
|
+
|
|
192
|
+
1. **Unit tests**: `vitest run` — every module has focused tests
|
|
193
|
+
2. **Property tests**: fast-check verifies invariants across thousands of random inputs
|
|
194
|
+
3. **Integration test**: Full hand scenarios driven through `table.ts`:
|
|
195
|
+
- 2-player heads-up hand (fold preflop)
|
|
196
|
+
- 3-player hand to showdown with side pot
|
|
197
|
+
- Split pot scenario
|
|
198
|
+
- Multiple consecutive hands with button movement
|
|
199
|
+
- Player bust-out and removal
|
|
200
|
+
4. **Type safety**: `tsc --noEmit` passes with strict mode — branded types catch misuse at compile time
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Project Setup
|
|
205
|
+
|
|
206
|
+
**Package manager:** pnpm
|
|
207
|
+
|
|
208
|
+
**Dependencies:**
|
|
209
|
+
- `effect` ^3.x — core library
|
|
210
|
+
- `pokersolver` ^2.1.4 — hand evaluation
|
|
211
|
+
|
|
212
|
+
**Dev dependencies:**
|
|
213
|
+
- `typescript` ^5.7, `vitest` ^3.x, `fast-check` ^3.x, `@effect/vitest`, `@fast-check/vitest`
|
|
214
|
+
|
|
215
|
+
**tsconfig:** strict, ES2022 target, NodeNext module, esModuleInterop (for pokersolver CJS)
|
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# texasholdem
|
|
2
|
+
|
|
3
|
+
A modular, functional Texas Hold'em poker engine built with [Effect-TS](https://effect.website/).
|
|
4
|
+
|
|
5
|
+
Immutable state transitions, typed errors, branded domain types, and event logging — no mutation, no thrown strings, no callbacks.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Immutable state machine** — `(state, action) => newState`, every transition returns a new state
|
|
10
|
+
- **Typed errors** — `Data.TaggedError` hierarchy for exhaustive pattern matching
|
|
11
|
+
- **Branded types** — `Chips`, `SeatIndex`, `HandId` prevent mixing up numbers at compile time
|
|
12
|
+
- **Event log** — every game action is recorded as a `GameEvent` for hand history / observability
|
|
13
|
+
- **Automatic phase advancement** — betting round completion triggers the next phase (deal, showdown) automatically
|
|
14
|
+
- **Side pots** — correct multi-way all-in pot splitting with odd-chip distribution
|
|
15
|
+
- **Configurable** — 2-10 seat tables, custom blinds/antes
|
|
16
|
+
- **Minimal Effect usage** — only deck shuffle is effectful; everything else is pure functions / `Either`
|
|
17
|
+
- **Hand evaluation** — delegated to [pokersolver](https://github.com/goldfire/pokersolver) behind a clean abstraction
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install texasholdem
|
|
23
|
+
# or
|
|
24
|
+
pnpm add texasholdem
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Peer dependency:** `effect` ^3.12
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { Effect, Either, Option } from "effect";
|
|
33
|
+
import {
|
|
34
|
+
Chips,
|
|
35
|
+
SeatIndex,
|
|
36
|
+
createTable,
|
|
37
|
+
sitDown,
|
|
38
|
+
startNextHand,
|
|
39
|
+
tableAct,
|
|
40
|
+
getActivePlayer,
|
|
41
|
+
getTableLegalActions,
|
|
42
|
+
Call,
|
|
43
|
+
Fold,
|
|
44
|
+
Check,
|
|
45
|
+
} from "texasholdem";
|
|
46
|
+
|
|
47
|
+
// 1. Create a table
|
|
48
|
+
const table = Either.getOrThrow(
|
|
49
|
+
createTable({
|
|
50
|
+
maxSeats: 6,
|
|
51
|
+
forcedBets: { smallBlind: Chips(5), bigBlind: Chips(10) },
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// 2. Seat players
|
|
56
|
+
let state = Either.getOrThrow(sitDown(table, SeatIndex(0), Chips(1000)));
|
|
57
|
+
state = Either.getOrThrow(sitDown(state, SeatIndex(1), Chips(1000)));
|
|
58
|
+
state = Either.getOrThrow(sitDown(state, SeatIndex(2), Chips(1000)));
|
|
59
|
+
|
|
60
|
+
// 3. Start a hand (effectful — shuffles the deck)
|
|
61
|
+
state = Effect.runSync(startNextHand(state));
|
|
62
|
+
|
|
63
|
+
// 4. Game loop — act until the hand is complete
|
|
64
|
+
while (Option.isSome(getActivePlayer(state))) {
|
|
65
|
+
const seat = getActivePlayer(state).value;
|
|
66
|
+
const legal = Option.getOrThrow(getTableLegalActions(state));
|
|
67
|
+
|
|
68
|
+
// Pick an action based on legal moves
|
|
69
|
+
const action = legal.canCheck ? Check : Call;
|
|
70
|
+
|
|
71
|
+
state = Either.getOrThrow(tableAct(state, seat, action));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 5. Hand is complete — check results
|
|
75
|
+
console.log(`Hand finished. Events: ${state.events.length}`);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Architecture
|
|
79
|
+
|
|
80
|
+
12 modules in strict bottom-up dependency order:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
brand.ts ─── card.ts ─── deck.ts ───────────────────┐
|
|
84
|
+
│ │ │
|
|
85
|
+
│ └── evaluator.ts (pokersolver wrap) │
|
|
86
|
+
│ │
|
|
87
|
+
├── player.ts ── action.ts ── event.ts │
|
|
88
|
+
│ │ │ │
|
|
89
|
+
│ └── pot.ts ──┤ │
|
|
90
|
+
│ │ │ │
|
|
91
|
+
│ betting.ts ──┘ │
|
|
92
|
+
│ │ │
|
|
93
|
+
│ hand.ts ────────────────────────┘
|
|
94
|
+
│ │
|
|
95
|
+
└───────────────── table.ts
|
|
96
|
+
│
|
|
97
|
+
index.ts (barrel exports)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Module Summary
|
|
101
|
+
|
|
102
|
+
| Module | Purpose |
|
|
103
|
+
|--------|---------|
|
|
104
|
+
| `brand` | Branded types: `Chips`, `SeatIndex`, `HandId` with runtime validation |
|
|
105
|
+
| `card` | `Card`, `Rank`, `Suit`, `ALL_CARDS`, pokersolver string conversion |
|
|
106
|
+
| `deck` | Shuffle (the only `Effect`), draw, deal hole cards / community cards |
|
|
107
|
+
| `evaluator` | Hand ranking via pokersolver — `evaluate`, `compare`, `winners` |
|
|
108
|
+
| `player` | Immutable player state + transitions: `placeBet`, `fold`, `winChips` |
|
|
109
|
+
| `action` | `Action` union (Fold/Check/Call/Bet/Raise/AllIn) + `LegalActions` computation |
|
|
110
|
+
| `event` | `GameEvent` discriminated union — full hand history in state |
|
|
111
|
+
| `error` | `PokerError` hierarchy via `Data.TaggedError` |
|
|
112
|
+
| `pot` | Side-pot calculation, pot merging, award distribution with odd-chip handling |
|
|
113
|
+
| `betting` | Betting round state machine: turn order, completion detection, action validation |
|
|
114
|
+
| `hand` | Full hand lifecycle: Preflop → Flop → Turn → River → Showdown → Complete |
|
|
115
|
+
| `table` | Multi-hand session: seating, button movement, busted player removal |
|
|
116
|
+
|
|
117
|
+
## API Overview
|
|
118
|
+
|
|
119
|
+
### Table-Level (multi-hand sessions)
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
createTable(config: TableConfig): Either<TableState, InvalidConfig>
|
|
123
|
+
sitDown(state, seat, chips): Either<TableState, SeatOccupied | TableFull>
|
|
124
|
+
standUp(state, seat): Either<TableState, SeatEmpty | HandInProgress>
|
|
125
|
+
startNextHand(state): Effect<TableState, PokerError>
|
|
126
|
+
tableAct(state, seat, action): Either<TableState, PokerError>
|
|
127
|
+
getActivePlayer(state): Option<SeatIndex>
|
|
128
|
+
getTableLegalActions(state): Option<LegalActions>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Hand-Level (single hand)
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
startHand(players, button, forcedBets, handId): Effect<HandState, PokerError>
|
|
135
|
+
act(state, seat, action): Either<HandState, PokerError>
|
|
136
|
+
activePlayer(state): Option<SeatIndex>
|
|
137
|
+
getLegalActions(state): Option<LegalActions>
|
|
138
|
+
currentPhase(state): Phase
|
|
139
|
+
getEvents(state): readonly GameEvent[]
|
|
140
|
+
isComplete(state): boolean
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Actions
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
Fold // Give up the hand
|
|
147
|
+
Check // Pass (when no bet to match)
|
|
148
|
+
Call // Match the current bet
|
|
149
|
+
Bet({ amount }) // Open betting (branded Chips)
|
|
150
|
+
Raise({ amount }) // Raise over current bet (branded Chips)
|
|
151
|
+
AllIn // Put all remaining chips in
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Effect-TS Usage
|
|
155
|
+
|
|
156
|
+
| Where | What | Why |
|
|
157
|
+
|-------|------|-----|
|
|
158
|
+
| `deck.ts` shuffle | `Effect<Deck>` | Randomness is a side effect |
|
|
159
|
+
| `hand.ts` startHand | `Effect<HandState, PokerError>` | Calls shuffle |
|
|
160
|
+
| `table.ts` startNextHand | `Effect<TableState, PokerError>` | Calls startHand |
|
|
161
|
+
| Everything else | Pure functions / `Either` | No side effects needed |
|
|
162
|
+
| Branded types | `Brand.refined` | Compile-time + runtime safety |
|
|
163
|
+
| Errors | `Data.TaggedError` | Pattern-matchable typed errors |
|
|
164
|
+
|
|
165
|
+
No `Layer`/`Context`/`Service` — this is a library, not an application.
|
|
166
|
+
|
|
167
|
+
## Testing
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
pnpm test # run all tests
|
|
171
|
+
pnpm test:watch # watch mode
|
|
172
|
+
pnpm typecheck # tsc --noEmit
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
132 tests across 21 files:
|
|
176
|
+
- **Unit tests** — focused scenarios per module
|
|
177
|
+
- **Property-based tests** — fast-check verifies invariants (chip conservation, betting termination, phase progression) across thousands of random inputs
|
|
178
|
+
- **Integration tests** — end-to-end scenarios through the public API
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import eslint from "@eslint/js";
|
|
3
|
+
import tseslint from "typescript-eslint";
|
|
4
|
+
import effectPlugin from "@effect/eslint-plugin";
|
|
5
|
+
import prettierConfig from "eslint-config-prettier";
|
|
6
|
+
|
|
7
|
+
export default tseslint.config(
|
|
8
|
+
// Global ignores
|
|
9
|
+
{
|
|
10
|
+
ignores: ["dist/", "node_modules/", "**/*.d.ts", "vitest.config.ts"],
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
// Base ESLint recommended rules
|
|
14
|
+
eslint.configs.recommended,
|
|
15
|
+
|
|
16
|
+
// TypeScript recommended rules with type-checking
|
|
17
|
+
...tseslint.configs.recommendedTypeChecked,
|
|
18
|
+
{
|
|
19
|
+
languageOptions: {
|
|
20
|
+
parserOptions: {
|
|
21
|
+
projectService: true,
|
|
22
|
+
tsconfigRootDir: import.meta.dirname,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
// Effect plugin rules
|
|
28
|
+
{
|
|
29
|
+
plugins: {
|
|
30
|
+
"@effect": effectPlugin,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
// Project-specific rule overrides
|
|
35
|
+
{
|
|
36
|
+
rules: {
|
|
37
|
+
// Allow type assertions used extensively for branded types (Chips, SeatIndex)
|
|
38
|
+
"@typescript-eslint/no-unsafe-argument": "off",
|
|
39
|
+
"@typescript-eslint/no-unsafe-assignment": "off",
|
|
40
|
+
"@typescript-eslint/no-unsafe-call": "off",
|
|
41
|
+
"@typescript-eslint/no-unsafe-member-access": "off",
|
|
42
|
+
"@typescript-eslint/no-unsafe-return": "off",
|
|
43
|
+
// Allow unused vars prefixed with _
|
|
44
|
+
"@typescript-eslint/no-unused-vars": [
|
|
45
|
+
"error",
|
|
46
|
+
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
47
|
+
],
|
|
48
|
+
// Allow empty object types in tagged errors (e.g., TableFull, HandInProgress)
|
|
49
|
+
"@typescript-eslint/no-empty-object-type": "off",
|
|
50
|
+
// Allow non-null assertions where array bounds are checked manually
|
|
51
|
+
"@typescript-eslint/no-non-null-assertion": "warn",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Prettier — must be last to disable conflicting formatting rules
|
|
56
|
+
prettierConfig,
|
|
57
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "texasholdem",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Modular, functional Texas Hold'em poker engine built with Effect-TS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"lint": "eslint src/ test/",
|
|
14
|
+
"lint:fix": "eslint --fix src/ test/",
|
|
15
|
+
"format": "prettier --write 'src/**/*.ts' 'test/**/*.ts'"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"effect": "^3.12.0",
|
|
19
|
+
"pokersolver": "^2.1.4"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@effect/eslint-plugin": "^0.3.2",
|
|
23
|
+
"@effect/vitest": "^0.27.0",
|
|
24
|
+
"@eslint/js": "^10.0.1",
|
|
25
|
+
"@fast-check/vitest": "^0.2.0",
|
|
26
|
+
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
27
|
+
"@typescript-eslint/parser": "^8.56.0",
|
|
28
|
+
"eslint": "^10.0.0",
|
|
29
|
+
"eslint-config-prettier": "^10.1.8",
|
|
30
|
+
"fast-check": "^3.23.0",
|
|
31
|
+
"prettier": "^3.8.1",
|
|
32
|
+
"typescript": "^5.7.0",
|
|
33
|
+
"typescript-eslint": "^8.56.0",
|
|
34
|
+
"vitest": "^3.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|