texasholdem 1.0.0 → 1.0.3

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.
@@ -0,0 +1,73 @@
1
+ name: Auto-tag & publish on version bump
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ paths: [package.json]
7
+
8
+ jobs:
9
+ tag:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+ outputs:
14
+ changed: ${{ steps.version.outputs.changed }}
15
+ tag: ${{ steps.version.outputs.tag }}
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 2
20
+
21
+ - name: Check if version changed
22
+ id: version
23
+ run: |
24
+ NEW=$(node -p "require('./package.json').version")
25
+ git checkout HEAD~1 -- package.json 2>/dev/null
26
+ OLD=$(node -p "require('./package.json').version")
27
+ git checkout HEAD -- package.json
28
+ if [ "$OLD" != "$NEW" ]; then
29
+ echo "changed=true" >> "$GITHUB_OUTPUT"
30
+ echo "tag=v$NEW" >> "$GITHUB_OUTPUT"
31
+ echo "Version changed: $OLD -> $NEW"
32
+ else
33
+ echo "changed=false" >> "$GITHUB_OUTPUT"
34
+ echo "Version unchanged: $OLD"
35
+ fi
36
+
37
+ - name: Create and push tag
38
+ if: steps.version.outputs.changed == 'true'
39
+ run: |
40
+ git tag ${{ steps.version.outputs.tag }}
41
+ git push origin ${{ steps.version.outputs.tag }}
42
+
43
+ publish:
44
+ needs: tag
45
+ if: needs.tag.outputs.changed == 'true'
46
+ runs-on: ubuntu-latest
47
+ environment: production
48
+ permissions:
49
+ id-token: write
50
+ contents: read
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+
54
+ - uses: pnpm/action-setup@v4
55
+ with:
56
+ version: 9
57
+
58
+ - uses: actions/setup-node@v4
59
+ with:
60
+ node-version: "24"
61
+ registry-url: "https://registry.npmjs.org"
62
+ cache: pnpm
63
+
64
+ - run: npm install -g npm@latest
65
+ - run: pnpm install --frozen-lockfile
66
+ - run: pnpm typecheck
67
+ - run: pnpm test
68
+ - run: pnpm build
69
+
70
+ - name: Publish with OIDC
71
+ run: npm publish --provenance --access public
72
+ env:
73
+ NODE_AUTH_TOKEN: ""
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [master]
6
+ push:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ check:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: pnpm/action-setup@v4
16
+ with:
17
+ version: 9
18
+
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: "24"
22
+ cache: pnpm
23
+
24
+ - run: pnpm install --frozen-lockfile
25
+ - run: pnpm typecheck
26
+ - run: pnpm lint
27
+ - run: pnpm test
28
+ - run: pnpm build
@@ -5,12 +5,14 @@ on:
5
5
  tags:
6
6
  - "v*"
7
7
 
8
+ permissions:
9
+ id-token: write
10
+ contents: read
11
+
8
12
  jobs:
9
13
  publish:
10
14
  runs-on: ubuntu-latest
11
- permissions:
12
- contents: read
13
- id-token: write
15
+ environment: production
14
16
  steps:
15
17
  - uses: actions/checkout@v4
16
18
 
@@ -20,17 +22,17 @@ jobs:
20
22
 
21
23
  - uses: actions/setup-node@v4
22
24
  with:
23
- node-version: 22
25
+ node-version: "24"
26
+ registry-url: "https://registry.npmjs.org"
24
27
  cache: pnpm
25
- registry-url: https://registry.npmjs.org
26
28
 
29
+ - run: npm install -g npm@latest
27
30
  - run: pnpm install --frozen-lockfile
28
31
  - run: pnpm typecheck
29
32
  - run: pnpm test
30
33
  - run: pnpm build
31
34
 
32
- - run: |
33
- echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> .npmrc
34
- npm publish --provenance --access public
35
+ - name: Publish with OIDC
36
+ run: npm publish --provenance --access public
35
37
  env:
36
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
38
+ NODE_AUTH_TOKEN: ""
package/README.md CHANGED
@@ -28,6 +28,58 @@ pnpm add texasholdem
28
28
 
29
29
  ## Quick Start
30
30
 
31
+ ### Strategy-based game loop (recommended)
32
+
33
+ The simplest way to run poker hands — define a strategy function and let the engine drive the loop:
34
+
35
+ ```typescript
36
+ import { Effect, Either } from "effect";
37
+ import {
38
+ Chips,
39
+ SeatIndex,
40
+ createTable,
41
+ sitDown,
42
+ playGame,
43
+ fromSync,
44
+ stopAfterHands,
45
+ Check,
46
+ Call,
47
+ Fold,
48
+ } from "texasholdem";
49
+
50
+ // 1. Create a table and seat players
51
+ let table = Either.getOrThrow(
52
+ createTable({
53
+ maxSeats: 6,
54
+ forcedBets: { smallBlind: Chips(5), bigBlind: Chips(10) },
55
+ })
56
+ );
57
+ for (const i of [0, 1, 2, 3]) {
58
+ table = Either.getOrThrow(sitDown(table, SeatIndex(i), Chips(1000)));
59
+ }
60
+
61
+ // 2. Define a strategy — receives full positional context
62
+ const myStrategy = fromSync((ctx) => {
63
+ if (ctx.legalActions.canCheck) return Check;
64
+ return Call;
65
+ });
66
+
67
+ // 3. Run 100 hands
68
+ const result = Effect.runSync(
69
+ playGame(myStrategy, {
70
+ stopWhen: stopAfterHands(100),
71
+ onEvent: (ev) => console.log(ev._tag),
72
+ defaultAction: Fold,
73
+ })(table)
74
+ );
75
+
76
+ console.log(`Played ${result.handsPlayed} hands`);
77
+ ```
78
+
79
+ ### Manual loop (full control)
80
+
81
+ For UI-driven games, bots with external I/O, or when you need to control each action individually:
82
+
31
83
  ```typescript
32
84
  import { Effect, Either, Option } from "effect";
33
85
  import {
@@ -39,9 +91,8 @@ import {
39
91
  tableAct,
40
92
  getActivePlayer,
41
93
  getTableLegalActions,
42
- Call,
43
- Fold,
44
94
  Check,
95
+ Call,
45
96
  } from "texasholdem";
46
97
 
47
98
  // 1. Create a table
@@ -77,7 +128,7 @@ console.log(`Hand finished. Events: ${state.events.length}`);
77
128
 
78
129
  ## Architecture
79
130
 
80
- 12 modules in strict bottom-up dependency order:
131
+ 14 modules in strict bottom-up dependency order:
81
132
 
82
133
  ```
83
134
  brand.ts ─── card.ts ─── deck.ts ───────────────────┐
@@ -93,8 +144,12 @@ brand.ts ─── card.ts ─── deck.ts ───────────
93
144
  │ hand.ts ────────────────────────┘
94
145
  │ │
95
146
  └───────────────── table.ts
96
-
97
- index.ts (barrel exports)
147
+
148
+ position.ts
149
+ │ │
150
+ loop.ts
151
+
152
+ index.ts (barrel exports)
98
153
  ```
99
154
 
100
155
  ### Module Summary
@@ -113,9 +168,99 @@ brand.ts ─── card.ts ─── deck.ts ───────────
113
168
  | `betting` | Betting round state machine: turn order, completion detection, action validation |
114
169
  | `hand` | Full hand lifecycle: Preflop → Flop → Turn → River → Showdown → Complete |
115
170
  | `table` | Multi-hand session: seating, button movement, busted player removal |
171
+ | `position` | Positional roles (`Button`, `UTG`, `CO`, …) and `StrategyContext` builder |
172
+ | `loop` | Strategy-driven game loop: `playHand`, `playGame`, timeout/fallback handling |
116
173
 
117
174
  ## API Overview
118
175
 
176
+ ### Game Loop (strategy-driven)
177
+
178
+ The highest-level API — define a strategy function and the engine handles dealing, betting rounds, phase advancement, and multi-hand sessions automatically.
179
+
180
+ ```typescript
181
+ // Strategy receives full context, returns an action
182
+ type Strategy = (ctx: StrategyContext) => Effect.Effect<Action>
183
+ type SyncStrategy = (ctx: StrategyContext) => Action
184
+
185
+ // Wrap a synchronous function as a Strategy
186
+ fromSync(fn: SyncStrategy): Strategy
187
+
188
+ // Drive a single hand (table must already have a hand started)
189
+ playOneHand(strategy, opts?): (state: TableState) => Effect<PlayHandResult, PokerError>
190
+
191
+ // Start + drive a single hand
192
+ playHand(strategy, opts?): (state: TableState) => Effect<PlayHandResult, PokerError>
193
+
194
+ // Multi-hand loop — keeps dealing until a stop condition is met
195
+ playGame(strategy, opts?): (state: TableState) => Effect<PlayGameResult, PokerError>
196
+ ```
197
+
198
+ **Options:**
199
+
200
+ ```typescript
201
+ interface PlayHandOptions {
202
+ actionTimeout?: Duration.DurationInput // e.g. "5 seconds"
203
+ defaultAction?: Action | ((ctx: StrategyContext) => Action)
204
+ onEvent?: (event: GameEvent) => void
205
+ maxActionsPerHand?: number // default 500 (safety circuit breaker)
206
+ }
207
+
208
+ interface PlayGameOptions extends PlayHandOptions {
209
+ stopWhen?: StopCondition
210
+ maxHands?: number // default 10_000
211
+ }
212
+ ```
213
+
214
+ **Built-in stop conditions:**
215
+
216
+ ```typescript
217
+ stopAfterHands(n: number): StopCondition
218
+ stopWhenFewPlayers(min?: number): StopCondition // default min = 2
219
+ ```
220
+
221
+ **Built-in strategies:**
222
+
223
+ ```typescript
224
+ alwaysFold: Strategy // folds every hand
225
+ passiveStrategy: Strategy // checks when possible, calls otherwise
226
+ ```
227
+
228
+ **Resilience:** when a strategy returns an invalid action, the engine applies a three-level fallback: (1) the returned action, (2) `defaultAction` if provided, (3) Check > Call > Fold. Strategies never need to be defensive about illegal moves.
229
+
230
+ ### Strategy Context
231
+
232
+ Every strategy call receives a `StrategyContext` — everything a decision-maker needs:
233
+
234
+ ```typescript
235
+ interface StrategyContext {
236
+ // Identity
237
+ seat: SeatIndex
238
+ chips: Chips
239
+ holeCards: Option<readonly [Card, Card]>
240
+
241
+ // Position
242
+ role: PositionalRole // "Button" | "SmallBlind" | "BigBlind" | "UTG" | "UTG1" | "UTG2" | "LJ" | "HJ" | "CO"
243
+ buttonSeat: SeatIndex
244
+ smallBlindSeat: SeatIndex
245
+ bigBlindSeat: SeatIndex
246
+ playersToActAfter: number
247
+
248
+ // Hand state
249
+ phase: Phase // "Preflop" | "Flop" | "Turn" | "River" | "Showdown" | "Complete"
250
+ communityCards: Card[]
251
+ potTotal: Chips
252
+ bigBlind: Chips
253
+ activeSeatCount: number // non-folded, non-busted players
254
+
255
+ // Action
256
+ legalActions: LegalActions
257
+ players: PlayerView[] // all players visible state
258
+ newEvents: GameEvent[] // events since your last action
259
+ }
260
+ ```
261
+
262
+ Positional roles are assigned automatically based on player count (2–10), following standard poker conventions (heads-up: Button = SB).
263
+
119
264
  ### Table-Level (multi-hand sessions)
120
265
 
121
266
  ```typescript
@@ -130,6 +275,8 @@ getTableLegalActions(state): Option<LegalActions>
130
275
 
131
276
  ### Hand-Level (single hand)
132
277
 
278
+ Lower-level API for controlling a single hand directly. Most users should prefer the Table or Game Loop API.
279
+
133
280
  ```typescript
134
281
  startHand(players, button, forcedBets, handId): Effect<HandState, PokerError>
135
282
  act(state, seat, action): Either<HandState, PokerError>
@@ -151,6 +298,39 @@ Raise({ amount }) // Raise over current bet (branded Chips)
151
298
  AllIn // Put all remaining chips in
152
299
  ```
153
300
 
301
+ ### LegalActions
302
+
303
+ Tells a strategy what moves are currently valid:
304
+
305
+ ```typescript
306
+ interface LegalActions {
307
+ canFold: boolean
308
+ canCheck: boolean
309
+ callAmount: Option<Chips> // None = no bet to match
310
+ minBet: Option<Chips> // available when no prior bet (opening aggression)
311
+ maxBet: Option<Chips>
312
+ minRaise: Option<Chips> // available when a bet already exists
313
+ maxRaise: Option<Chips>
314
+ canAllIn: boolean
315
+ allInAmount: Chips
316
+ }
317
+ ```
318
+
319
+ ### Events
320
+
321
+ Every state change is recorded as a `GameEvent` — a full hand history / audit log:
322
+
323
+ ```typescript
324
+ type GameEvent =
325
+ | HandStarted | BlindsPosted | HoleCardsDealt
326
+ | PlayerActed | BettingRoundEnded
327
+ | CommunityCardsDealt | ShowdownStarted
328
+ | PotAwarded | HandEnded
329
+ | PlayerSatDown | PlayerStoodUp
330
+ ```
331
+
332
+ Use `state.events` for table-level events, or the `onEvent` callback in the game loop for real-time streaming.
333
+
154
334
  ## Effect-TS Usage
155
335
 
156
336
  | Where | What | Why |
@@ -158,6 +338,7 @@ AllIn // Put all remaining chips in
158
338
  | `deck.ts` shuffle | `Effect<Deck>` | Randomness is a side effect |
159
339
  | `hand.ts` startHand | `Effect<HandState, PokerError>` | Calls shuffle |
160
340
  | `table.ts` startNextHand | `Effect<TableState, PokerError>` | Calls startHand |
341
+ | `loop.ts` playHand / playGame | `Effect<Result, PokerError>` | Orchestrates effectful hand starts + strategy calls |
161
342
  | Everything else | Pure functions / `Either` | No side effects needed |
162
343
  | Branded types | `Brand.refined` | Compile-time + runtime safety |
163
344
  | Errors | `Data.TaggedError` | Pattern-matchable typed errors |
package/eslint.config.js CHANGED
@@ -49,9 +49,20 @@ export default tseslint.config(
49
49
  "@typescript-eslint/no-empty-object-type": "off",
50
50
  // Allow non-null assertions where array bounds are checked manually
51
51
  "@typescript-eslint/no-non-null-assertion": "warn",
52
+ // Ban type assertions (as Type) — use type narrowing instead
53
+ "@typescript-eslint/consistent-type-assertions": [
54
+ "error",
55
+ { assertionStyle: "never" },
56
+ ],
52
57
  },
53
58
  },
54
59
 
60
+ // Disable type-checked rules for test files (not in tsconfig project)
61
+ {
62
+ files: ["test/**/*.ts"],
63
+ ...tseslint.configs.disableTypeChecked,
64
+ },
65
+
55
66
  // Prettier — must be last to disable conflicting formatting rules
56
67
  prettierConfig,
57
68
  );
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "texasholdem",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "Modular, functional Texas Hold'em poker engine built with Effect-TS",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/eliraz-refael/texasHoldem-ts.git"
9
+ },
6
10
  "main": "./dist/index.js",
7
11
  "types": "./dist/index.d.ts",
8
12
  "scripts": {
package/src/action.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * @module
5
5
  */
6
6
 
7
- import { Data, Either, Match, Option, pipe } from "effect";
7
+ import { Data, Either, Match, Option, Schema, pipe } from "effect";
8
8
  import type { Chips } from "./brand.js";
9
- import { Chips as makeChips, chipsToNumber } from "./brand.js";
9
+ import { Chips as makeChips, chipsToNumber, ChipsSchema } from "./brand.js";
10
10
  import { InvalidAction } from "./error.js";
11
11
 
12
12
  // ---------------------------------------------------------------------------
@@ -49,6 +49,19 @@ export interface LegalActions {
49
49
  readonly allInAmount: Chips;
50
50
  }
51
51
 
52
+ /** Schema for LegalActions — enables composition in StrategyContextSchema and Arbitrary generation. */
53
+ export const LegalActionsSchema = Schema.Struct({
54
+ canFold: Schema.Boolean,
55
+ canCheck: Schema.Boolean,
56
+ callAmount: Schema.Option(ChipsSchema),
57
+ minBet: Schema.Option(ChipsSchema),
58
+ maxBet: Schema.Option(ChipsSchema),
59
+ minRaise: Schema.Option(ChipsSchema),
60
+ maxRaise: Schema.Option(ChipsSchema),
61
+ canAllIn: Schema.Boolean,
62
+ allInAmount: ChipsSchema,
63
+ });
64
+
52
65
  // ---------------------------------------------------------------------------
53
66
  // computeLegalActions
54
67
  // ---------------------------------------------------------------------------
package/src/betting.ts CHANGED
@@ -194,7 +194,7 @@ export function applyAction(
194
194
  const legal = getLegalActions(state);
195
195
  const validated = validateAction(action, legal);
196
196
  if (Either.isLeft(validated)) {
197
- return validated as Either.Either<never, InvalidAction>;
197
+ return Either.left(validated.left);
198
198
  }
199
199
 
200
200
  const player = getPlayer(state, seat);
package/src/brand.ts CHANGED
@@ -51,18 +51,18 @@ export const ZERO_CHIPS: Chips = Chips(0);
51
51
 
52
52
  /** Add two Chips values. */
53
53
  export const addChips = (a: Chips, b: Chips): Chips =>
54
- Chips((a as number) + (b as number));
54
+ Chips(a + b);
55
55
 
56
56
  /** Subtract `b` from `a`. Caller must ensure `a >= b`. */
57
57
  export const subtractChips = (a: Chips, b: Chips): Chips =>
58
- Chips((a as number) - (b as number));
58
+ Chips(a - b);
59
59
 
60
60
  /** Return the smaller of two Chips values. */
61
61
  export const minChips = (a: Chips, b: Chips): Chips =>
62
- (a as number) <= (b as number) ? a : b;
62
+ a <= b ? a : b;
63
63
 
64
64
  /** Unwrap a Chips value to a plain number. */
65
- export const chipsToNumber = (c: Chips): number => c as number;
65
+ export const chipsToNumber = (c: Chips): number => c;
66
66
 
67
67
  /** Order instance for Chips (ascending by numeric value). */
68
68
  export const ChipsOrder: Order.Order<Chips> = Order.mapInput(
@@ -106,7 +106,7 @@ export const SeatIndexSchema = Schema.Number.pipe(
106
106
  );
107
107
 
108
108
  /** Unwrap a SeatIndex value to a plain number. */
109
- export const seatIndexToNumber = (s: SeatIndex): number => s as number;
109
+ export const seatIndexToNumber = (s: SeatIndex): number => s;
110
110
 
111
111
  /** Order instance for SeatIndex (ascending by numeric value). */
112
112
  export const SeatIndexOrder: Order.Order<SeatIndex> = Order.mapInput(
package/src/card.ts CHANGED
@@ -93,7 +93,9 @@ const CHAR_TO_RANK: Record<string, Rank> = {
93
93
  A: 14,
94
94
  };
95
95
 
96
- const VALID_SUITS = new Set<string>(SUITS);
96
+ function isSuit(s: string): s is Suit {
97
+ return SUITS.some((suit) => suit === s);
98
+ }
97
99
 
98
100
  /**
99
101
  * Convert a Card to its two-character pokersolver string.
@@ -139,7 +141,7 @@ export const cardFromString = (s: string): Either.Either<Card, InvalidCard> => {
139
141
  );
140
142
  }
141
143
 
142
- if (!VALID_SUITS.has(suitChar)) {
144
+ if (!isSuit(suitChar)) {
143
145
  return Either.left(
144
146
  new InvalidCard({
145
147
  input: s,
@@ -148,7 +150,7 @@ export const cardFromString = (s: string): Either.Either<Card, InvalidCard> => {
148
150
  );
149
151
  }
150
152
 
151
- return Either.right(card(rank, suitChar as Suit));
153
+ return Either.right(card(rank, suitChar));
152
154
  };
153
155
 
154
156
  /**
package/src/deck.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Array as A, Chunk, Effect, Either, HashMap, Random, pipe } from "effect";
1
+ import { Array as A, Chunk, Effect, Either, HashMap, Random } from "effect";
2
2
  import type { Card } from "./card.js";
3
3
  import { ALL_CARDS } from "./card.js";
4
4
  import type { SeatIndex } from "./brand.js";
package/src/event.ts CHANGED
@@ -18,7 +18,7 @@ import type { Action } from "./action.js";
18
18
  // ---------------------------------------------------------------------------
19
19
 
20
20
  export type GameEvent = Data.TaggedEnum<{
21
- HandStarted: { readonly handId: HandId; readonly button: SeatIndex; readonly players: readonly SeatIndex[] };
21
+ HandStarted: { readonly handId: HandId; readonly button: SeatIndex; readonly smallBlind: SeatIndex; readonly bigBlind: SeatIndex; readonly players: readonly SeatIndex[] };
22
22
  BlindsPosted: { readonly smallBlind: { readonly seat: SeatIndex; readonly amount: Chips }; readonly bigBlind: { readonly seat: SeatIndex; readonly amount: Chips } };
23
23
  HoleCardsDealt: { readonly seat: SeatIndex };
24
24
  PlayerActed: { readonly seat: SeatIndex; readonly action: Action };
package/src/hand.ts CHANGED
@@ -10,7 +10,6 @@ import type { Chips, SeatIndex, HandId } from "./brand.js";
10
10
  import {
11
11
  ZERO_CHIPS,
12
12
  minChips,
13
- chipsToNumber,
14
13
  SeatIndexOrder,
15
14
  } from "./brand.js";
16
15
  import type { Card } from "./card.js";
@@ -234,7 +233,7 @@ export function startHand(
234
233
  const bbAmount = minChips(forcedBets.bigBlind, bbPlayer.chips);
235
234
  currentPlayers = updatePlayer(currentPlayers, bbSeat, (p) => placeBet(p, bbAmount));
236
235
 
237
- events.push(HandStarted({ handId, button, players: seatOrder }));
236
+ events.push(HandStarted({ handId, button, smallBlind: sbSeat, bigBlind: bbSeat, players: seatOrder }));
238
237
  events.push(
239
238
  BlindsPosted({
240
239
  smallBlind: { seat: sbSeat, amount: sbAmount },
package/src/index.ts CHANGED
@@ -33,3 +33,23 @@ export {
33
33
  getActivePlayer,
34
34
  getTableLegalActions,
35
35
  } from "./table.js";
36
+
37
+ export * from "./position.js";
38
+
39
+ export {
40
+ type Strategy,
41
+ type SyncStrategy,
42
+ type StopCondition,
43
+ type PlayHandOptions,
44
+ type PlayHandResult,
45
+ type PlayGameOptions,
46
+ type PlayGameResult,
47
+ fromSync,
48
+ playOneHand,
49
+ playHand,
50
+ playGame,
51
+ stopAfterHands,
52
+ stopWhenFewPlayers,
53
+ alwaysFold,
54
+ passiveStrategy,
55
+ } from "./loop.js";