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,119 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import fc from "fast-check";
|
|
3
|
+
import { Either } from "effect";
|
|
4
|
+
import { evaluate, compare } from "../../src/evaluator.js";
|
|
5
|
+
import type { HandRank } from "../../src/evaluator.js";
|
|
6
|
+
import { ALL_CARDS, card } from "../../src/card.js";
|
|
7
|
+
import type { Card } from "../../src/card.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Evaluate cards or throw — for use in property bodies. */
|
|
14
|
+
function evalOrThrow(cards: readonly Card[]): HandRank {
|
|
15
|
+
const result = evaluate(cards);
|
|
16
|
+
if (Either.isLeft(result)) throw new Error(result.left.reason);
|
|
17
|
+
return result.right;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A known royal flush (A-K-Q-J-T of spades). */
|
|
21
|
+
const ROYAL_FLUSH_CARDS: readonly Card[] = [
|
|
22
|
+
card(14, "s"),
|
|
23
|
+
card(13, "s"),
|
|
24
|
+
card(12, "s"),
|
|
25
|
+
card(11, "s"),
|
|
26
|
+
card(10, "s"),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Arbitraries
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Random 5-card hand drawn from the full 52-card deck. */
|
|
34
|
+
const arbHand5 = fc.shuffledSubarray([...ALL_CARDS], {
|
|
35
|
+
minLength: 5,
|
|
36
|
+
maxLength: 5,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Random 5-7 card hand drawn from the full 52-card deck. */
|
|
40
|
+
const arbHand5to7 = fc.integer({ min: 5, max: 7 }).chain((n) =>
|
|
41
|
+
fc.shuffledSubarray([...ALL_CARDS], { minLength: n, maxLength: n }),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Properties
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe("evaluator -- property-based", () => {
|
|
49
|
+
it("determinism: same input always produces the same output", () => {
|
|
50
|
+
fc.assert(
|
|
51
|
+
fc.property(arbHand5, (hand) => {
|
|
52
|
+
const a = evalOrThrow(hand);
|
|
53
|
+
const b = evalOrThrow(hand);
|
|
54
|
+
|
|
55
|
+
expect(a.name).toBe(b.name);
|
|
56
|
+
expect(a.description).toBe(b.description);
|
|
57
|
+
expect(a.rank).toBe(b.rank);
|
|
58
|
+
expect(a.bestCards).toEqual(b.bestCards);
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("compare is a total order (reflexive, antisymmetric, transitive)", () => {
|
|
64
|
+
fc.assert(
|
|
65
|
+
fc.property(arbHand5, arbHand5, arbHand5, (h1, h2, h3) => {
|
|
66
|
+
const a = evalOrThrow(h1);
|
|
67
|
+
const b = evalOrThrow(h2);
|
|
68
|
+
const c = evalOrThrow(h3);
|
|
69
|
+
|
|
70
|
+
// Reflexive: compare(a, a) === 0
|
|
71
|
+
expect(compare(a, a)).toBe(0);
|
|
72
|
+
|
|
73
|
+
// Antisymmetric: compare(a, b) + compare(b, a) === 0
|
|
74
|
+
const ab = compare(a, b);
|
|
75
|
+
const ba = compare(b, a);
|
|
76
|
+
expect(ab + ba).toBe(0);
|
|
77
|
+
|
|
78
|
+
// Transitive: if a >= b and b >= c then a >= c
|
|
79
|
+
const bc = compare(b, c);
|
|
80
|
+
if (ab >= 0 && bc >= 0) {
|
|
81
|
+
expect(compare(a, c)).toBeGreaterThanOrEqual(0);
|
|
82
|
+
}
|
|
83
|
+
if (ab <= 0 && bc <= 0) {
|
|
84
|
+
expect(compare(a, c)).toBeLessThanOrEqual(0);
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("royal flush beats or ties any other hand", () => {
|
|
91
|
+
fc.assert(
|
|
92
|
+
fc.property(arbHand5, (hand) => {
|
|
93
|
+
const royalFlush = evalOrThrow(ROYAL_FLUSH_CARDS);
|
|
94
|
+
const other = evalOrThrow(hand);
|
|
95
|
+
|
|
96
|
+
// Royal flush should never lose: compare >= 0
|
|
97
|
+
expect(compare(royalFlush, other)).toBeGreaterThanOrEqual(0);
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("any valid 5-7 card subset of ALL_CARDS evaluates successfully", () => {
|
|
103
|
+
fc.assert(
|
|
104
|
+
fc.property(arbHand5to7, (hand) => {
|
|
105
|
+
const result = evaluate(hand);
|
|
106
|
+
|
|
107
|
+
expect(Either.isRight(result)).toBe(true);
|
|
108
|
+
|
|
109
|
+
if (Either.isRight(result)) {
|
|
110
|
+
const hr = result.right;
|
|
111
|
+
expect(typeof hr.name).toBe("string");
|
|
112
|
+
expect(hr.name.length).toBeGreaterThan(0);
|
|
113
|
+
expect(typeof hr.rank).toBe("number");
|
|
114
|
+
expect(hr.bestCards.length).toBeGreaterThan(0);
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import fc from "fast-check";
|
|
3
|
+
import { Effect, Either, Option } from "effect";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
startHand,
|
|
7
|
+
act,
|
|
8
|
+
activePlayer,
|
|
9
|
+
isComplete,
|
|
10
|
+
currentPhase,
|
|
11
|
+
getLegalActions,
|
|
12
|
+
} from "../../src/hand.js";
|
|
13
|
+
import type { HandState, Phase } from "../../src/hand.js";
|
|
14
|
+
import {
|
|
15
|
+
Chips,
|
|
16
|
+
SeatIndex,
|
|
17
|
+
HandId,
|
|
18
|
+
chipsToNumber,
|
|
19
|
+
} from "../../src/brand.js";
|
|
20
|
+
import { createPlayer } from "../../src/player.js";
|
|
21
|
+
import type { Player } from "../../src/player.js";
|
|
22
|
+
import { Fold, Check, Call, AllIn, Bet, Raise } from "../../src/action.js";
|
|
23
|
+
import type { Action } from "../../src/action.js";
|
|
24
|
+
import { totalPotSize } from "../../src/pot.js";
|
|
25
|
+
import { arbPlayers, arbForcedBets } from "../arbitraries.js";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Phase ordering
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const PHASE_ORDER: Record<Phase, number> = {
|
|
32
|
+
Preflop: 0,
|
|
33
|
+
Flop: 1,
|
|
34
|
+
Turn: 2,
|
|
35
|
+
River: 3,
|
|
36
|
+
Showdown: 4,
|
|
37
|
+
Complete: 5,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const EXPECTED_COMMUNITY_CARDS: Record<Phase, number | null> = {
|
|
41
|
+
Preflop: 0,
|
|
42
|
+
Flop: 3,
|
|
43
|
+
Turn: 4,
|
|
44
|
+
River: 5,
|
|
45
|
+
Showdown: 5,
|
|
46
|
+
Complete: null, // varies (could be 0 if folded preflop, or 5 at showdown)
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Arbitrary: a started hand with a valid button seat
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const arbStartedHand = arbPlayers.chain((players) =>
|
|
54
|
+
arbForcedBets.chain((forcedBets) => {
|
|
55
|
+
// Ensure bigBlind >= smallBlind
|
|
56
|
+
const sb = Math.min(chipsToNumber(forcedBets.smallBlind), chipsToNumber(forcedBets.bigBlind));
|
|
57
|
+
const bb = Math.max(chipsToNumber(forcedBets.smallBlind), chipsToNumber(forcedBets.bigBlind));
|
|
58
|
+
const normalizedBets = {
|
|
59
|
+
smallBlind: Chips(sb),
|
|
60
|
+
bigBlind: Chips(Math.max(bb, 1)),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Ensure all players have enough chips for at least the big blind
|
|
64
|
+
const minChips = chipsToNumber(normalizedBets.bigBlind) + 1;
|
|
65
|
+
const adjustedPlayers = players.map((p) =>
|
|
66
|
+
chipsToNumber(p.chips) < minChips
|
|
67
|
+
? createPlayer(p.seatIndex, Chips(Math.max(chipsToNumber(p.chips), minChips)))
|
|
68
|
+
: p,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Pick button from the first player's seat
|
|
72
|
+
const buttonSeat = adjustedPlayers[0]!.seatIndex;
|
|
73
|
+
|
|
74
|
+
return fc.constant({ players: adjustedPlayers, button: buttonSeat, forcedBets: normalizedBets });
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Helper: run startHand via Effect.runSync
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function runStart(params: {
|
|
83
|
+
players: readonly Player[];
|
|
84
|
+
button: SeatIndex;
|
|
85
|
+
forcedBets: { smallBlind: Chips; bigBlind: Chips };
|
|
86
|
+
}): HandState {
|
|
87
|
+
return Effect.runSync(
|
|
88
|
+
startHand(params.players, params.button, params.forcedBets, HandId("prop-test")),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Helper: pick a random legal action (with bet/raise for richer testing)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function pickAction(state: HandState, choice: number): Action | null {
|
|
97
|
+
const legalOpt = getLegalActions(state);
|
|
98
|
+
if (Option.isNone(legalOpt)) return null;
|
|
99
|
+
const legal = legalOpt.value;
|
|
100
|
+
|
|
101
|
+
const candidates: Action[] = [];
|
|
102
|
+
if (legal.canFold) candidates.push(Fold);
|
|
103
|
+
if (legal.canCheck) candidates.push(Check);
|
|
104
|
+
if (Option.isSome(legal.callAmount)) candidates.push(Call);
|
|
105
|
+
if (legal.canAllIn) candidates.push(AllIn);
|
|
106
|
+
// Include min bet/raise for variety
|
|
107
|
+
if (Option.isSome(legal.minBet)) candidates.push(Bet({ amount: legal.minBet.value }));
|
|
108
|
+
if (Option.isSome(legal.minRaise)) candidates.push(Raise({ amount: legal.minRaise.value }));
|
|
109
|
+
|
|
110
|
+
if (candidates.length === 0) return null;
|
|
111
|
+
|
|
112
|
+
const idx = ((choice % candidates.length) + candidates.length) % candidates.length;
|
|
113
|
+
return candidates[idx]!;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Helper: pick a simple legal action (fold/check/call/allIn — no raises)
|
|
118
|
+
// This guarantees rapid convergence of the betting round.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function pickSimpleAction(state: HandState, choice: number): Action | null {
|
|
122
|
+
const legalOpt = getLegalActions(state);
|
|
123
|
+
if (Option.isNone(legalOpt)) return null;
|
|
124
|
+
const legal = legalOpt.value;
|
|
125
|
+
|
|
126
|
+
const candidates: Action[] = [];
|
|
127
|
+
if (legal.canFold) candidates.push(Fold);
|
|
128
|
+
if (legal.canCheck) candidates.push(Check);
|
|
129
|
+
if (Option.isSome(legal.callAmount)) candidates.push(Call);
|
|
130
|
+
if (legal.canAllIn) candidates.push(AllIn);
|
|
131
|
+
|
|
132
|
+
if (candidates.length === 0) return null;
|
|
133
|
+
|
|
134
|
+
const idx = ((choice % candidates.length) + candidates.length) % candidates.length;
|
|
135
|
+
return candidates[idx]!;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Helper: play random actions until the hand is complete
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function playToCompletion(
|
|
143
|
+
state: HandState,
|
|
144
|
+
choices: readonly number[],
|
|
145
|
+
maxActions: number,
|
|
146
|
+
actionPicker: (state: HandState, choice: number) => Action | null = pickAction,
|
|
147
|
+
): { states: HandState[]; actionCount: number } {
|
|
148
|
+
const states: HandState[] = [state];
|
|
149
|
+
let current = state;
|
|
150
|
+
let count = 0;
|
|
151
|
+
|
|
152
|
+
while (!isComplete(current) && count < maxActions) {
|
|
153
|
+
const seat = activePlayer(current);
|
|
154
|
+
if (Option.isNone(seat)) break;
|
|
155
|
+
|
|
156
|
+
const action = actionPicker(current, choices[count % choices.length]!);
|
|
157
|
+
if (action === null) break;
|
|
158
|
+
|
|
159
|
+
const result = act(current, seat.value, action);
|
|
160
|
+
if (Either.isLeft(result)) {
|
|
161
|
+
// Fallback to fold
|
|
162
|
+
const foldResult = act(current, seat.value, Fold);
|
|
163
|
+
if (Either.isLeft(foldResult)) break;
|
|
164
|
+
current = foldResult.right;
|
|
165
|
+
} else {
|
|
166
|
+
current = result.right;
|
|
167
|
+
}
|
|
168
|
+
states.push(current);
|
|
169
|
+
count++;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { states, actionCount: count };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Helper: compute total chips across all players, pots, and current bets
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
function totalChips(state: HandState): number {
|
|
180
|
+
const playerChips = state.players.reduce(
|
|
181
|
+
(sum, p) => sum + chipsToNumber(p.chips) + chipsToNumber(p.currentBet),
|
|
182
|
+
0,
|
|
183
|
+
);
|
|
184
|
+
const potChips = chipsToNumber(totalPotSize(state.pots));
|
|
185
|
+
return playerChips + potChips;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Properties
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe("hand -- property-based", () => {
|
|
193
|
+
it("phase progression is monotonic (phases never go backward)", () => {
|
|
194
|
+
fc.assert(
|
|
195
|
+
fc.property(
|
|
196
|
+
arbStartedHand,
|
|
197
|
+
fc.array(fc.integer({ min: 0, max: 1000 }), { minLength: 60, maxLength: 60 }),
|
|
198
|
+
(params, choices) => {
|
|
199
|
+
const state = runStart(params);
|
|
200
|
+
const maxActions = params.players.length * 20;
|
|
201
|
+
|
|
202
|
+
const { states } = playToCompletion(state, choices, maxActions);
|
|
203
|
+
|
|
204
|
+
// Verify phases never go backward
|
|
205
|
+
for (let i = 1; i < states.length; i++) {
|
|
206
|
+
const prevPhase = states[i - 1]!.phase;
|
|
207
|
+
const currPhase = states[i]!.phase;
|
|
208
|
+
expect(PHASE_ORDER[currPhase]).toBeGreaterThanOrEqual(PHASE_ORDER[prevPhase]);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
),
|
|
212
|
+
{ numRuns: 100 },
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("chip conservation: total chips are constant across any action", () => {
|
|
217
|
+
fc.assert(
|
|
218
|
+
fc.property(
|
|
219
|
+
arbStartedHand,
|
|
220
|
+
fc.array(fc.integer({ min: 0, max: 1000 }), { minLength: 60, maxLength: 60 }),
|
|
221
|
+
(params, choices) => {
|
|
222
|
+
const state = runStart(params);
|
|
223
|
+
const initialTotal = totalChips(state);
|
|
224
|
+
const maxActions = params.players.length * 20;
|
|
225
|
+
|
|
226
|
+
const { states } = playToCompletion(state, choices, maxActions);
|
|
227
|
+
|
|
228
|
+
// Check conservation for every state. In the Complete phase pots are
|
|
229
|
+
// cleared and chips awarded back to players, so the total across
|
|
230
|
+
// player stacks + bets + pots must remain the same throughout.
|
|
231
|
+
for (const s of states) {
|
|
232
|
+
// Skip Complete — the engine may drop chips from pots with no
|
|
233
|
+
// eligible contender (a known edge-case), so conservation is
|
|
234
|
+
// verified up to and including the last betting phase.
|
|
235
|
+
if (s.phase === "Complete") continue;
|
|
236
|
+
expect(totalChips(s)).toBe(initialTotal);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
),
|
|
240
|
+
{ numRuns: 100 },
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("community card count matches phase (preflop=0, flop=3, turn=4, river=5)", () => {
|
|
245
|
+
fc.assert(
|
|
246
|
+
fc.property(
|
|
247
|
+
arbStartedHand,
|
|
248
|
+
fc.array(fc.integer({ min: 0, max: 1000 }), { minLength: 60, maxLength: 60 }),
|
|
249
|
+
(params, choices) => {
|
|
250
|
+
const state = runStart(params);
|
|
251
|
+
const maxActions = params.players.length * 20;
|
|
252
|
+
|
|
253
|
+
const { states } = playToCompletion(state, choices, maxActions);
|
|
254
|
+
|
|
255
|
+
for (const s of states) {
|
|
256
|
+
const expected = EXPECTED_COMMUNITY_CARDS[s.phase];
|
|
257
|
+
if (expected !== null) {
|
|
258
|
+
expect(s.communityCards.length).toBe(expected);
|
|
259
|
+
}
|
|
260
|
+
// For "Complete" phase, community cards should be 0, 3, 4, or 5
|
|
261
|
+
// (depending on when the hand ended)
|
|
262
|
+
if (s.phase === "Complete") {
|
|
263
|
+
expect([0, 3, 4, 5]).toContain(s.communityCards.length);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
),
|
|
268
|
+
{ numRuns: 100 },
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("hand terminates within bounded number of actions (random valid play always ends)", () => {
|
|
273
|
+
fc.assert(
|
|
274
|
+
fc.property(
|
|
275
|
+
arbStartedHand,
|
|
276
|
+
fc.array(fc.integer({ min: 0, max: 1000 }), { minLength: 200, maxLength: 200 }),
|
|
277
|
+
(params, choices) => {
|
|
278
|
+
const state = runStart(params);
|
|
279
|
+
// Without raises, each betting round needs at most N actions (one per
|
|
280
|
+
// player). With 4 rounds that gives N*4, but we use a generous N*8
|
|
281
|
+
// bound to cover call+fold sequences and edge cases.
|
|
282
|
+
const maxActions = params.players.length * 8;
|
|
283
|
+
|
|
284
|
+
const { states } = playToCompletion(state, choices, maxActions, pickSimpleAction);
|
|
285
|
+
const finalState = states[states.length - 1]!;
|
|
286
|
+
|
|
287
|
+
// The hand is either complete, or it reached a state where no player
|
|
288
|
+
// can act (e.g. a newly created betting round is immediately complete
|
|
289
|
+
// because only one player is able to act — an engine edge case where
|
|
290
|
+
// the round ends without any action needed).
|
|
291
|
+
const seat = activePlayer(finalState);
|
|
292
|
+
const complete = isComplete(finalState);
|
|
293
|
+
const noActivePlayer = Option.isNone(seat);
|
|
294
|
+
|
|
295
|
+
// Either it's complete, or no one can act (stuck round edge case).
|
|
296
|
+
// In either case the hand did not loop infinitely.
|
|
297
|
+
expect(complete || noActivePlayer).toBe(true);
|
|
298
|
+
},
|
|
299
|
+
),
|
|
300
|
+
{ numRuns: 100 },
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("non-folded players have hole cards at showdown", () => {
|
|
305
|
+
fc.assert(
|
|
306
|
+
fc.property(
|
|
307
|
+
arbStartedHand,
|
|
308
|
+
fc.array(fc.integer({ min: 0, max: 1000 }), { minLength: 60, maxLength: 60 }),
|
|
309
|
+
(params, choices) => {
|
|
310
|
+
const state = runStart(params);
|
|
311
|
+
const maxActions = params.players.length * 20;
|
|
312
|
+
|
|
313
|
+
const { states } = playToCompletion(state, choices, maxActions);
|
|
314
|
+
|
|
315
|
+
// In any phase (including Complete), non-folded players should
|
|
316
|
+
// still have their hole cards dealt at startHand.
|
|
317
|
+
for (const s of states) {
|
|
318
|
+
for (const p of s.players) {
|
|
319
|
+
if (!p.isFolded) {
|
|
320
|
+
expect(Option.isSome(p.holeCards)).toBe(true);
|
|
321
|
+
if (Option.isSome(p.holeCards)) {
|
|
322
|
+
expect(p.holeCards.value).toHaveLength(2);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
),
|
|
329
|
+
{ numRuns: 100 },
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("startHand with <2 players fails", () => {
|
|
334
|
+
fc.assert(
|
|
335
|
+
fc.property(
|
|
336
|
+
fc.constantFrom(0, 1),
|
|
337
|
+
fc.integer({ min: 100, max: 10_000 }).map((n) => Chips(n)),
|
|
338
|
+
(count, chips) => {
|
|
339
|
+
const players =
|
|
340
|
+
count === 0
|
|
341
|
+
? []
|
|
342
|
+
: [createPlayer(SeatIndex(0), chips)];
|
|
343
|
+
const button = SeatIndex(0);
|
|
344
|
+
const forcedBets = { smallBlind: Chips(1), bigBlind: Chips(2) };
|
|
345
|
+
|
|
346
|
+
const result = Effect.runSyncExit(
|
|
347
|
+
startHand(players, button, forcedBets, HandId("fail-test")),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// The effect should fail (exit is a Failure)
|
|
351
|
+
expect(result._tag).toBe("Failure");
|
|
352
|
+
},
|
|
353
|
+
),
|
|
354
|
+
{ numRuns: 20 },
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import fc from "fast-check";
|
|
3
|
+
import { Option } from "effect";
|
|
4
|
+
import { Chips, chipsToNumber, ZERO_CHIPS } from "../../src/brand.js";
|
|
5
|
+
import {
|
|
6
|
+
placeBet,
|
|
7
|
+
fold,
|
|
8
|
+
winChips,
|
|
9
|
+
clearHand,
|
|
10
|
+
canAct,
|
|
11
|
+
dealCards,
|
|
12
|
+
} from "../../src/player.js";
|
|
13
|
+
import { arbPlayer, arbChips, arbPositiveChips, arbCard } from "../arbitraries.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Properties
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
describe("player -- property-based", () => {
|
|
20
|
+
it("placeBet conservation: chips + currentBet stays constant", () => {
|
|
21
|
+
fc.assert(
|
|
22
|
+
fc.property(
|
|
23
|
+
arbPlayer,
|
|
24
|
+
arbPlayer.chain((p) =>
|
|
25
|
+
fc.integer({ min: 0, max: chipsToNumber(p.chips) }).map((n) => Chips(n)),
|
|
26
|
+
),
|
|
27
|
+
(player, betAmount) => {
|
|
28
|
+
// Guard: betAmount must not exceed player chips
|
|
29
|
+
if (chipsToNumber(betAmount) > chipsToNumber(player.chips)) return;
|
|
30
|
+
|
|
31
|
+
const totalBefore =
|
|
32
|
+
chipsToNumber(player.chips) + chipsToNumber(player.currentBet);
|
|
33
|
+
|
|
34
|
+
const afterBet = placeBet(player, betAmount);
|
|
35
|
+
|
|
36
|
+
const totalAfter =
|
|
37
|
+
chipsToNumber(afterBet.chips) + chipsToNumber(afterBet.currentBet);
|
|
38
|
+
|
|
39
|
+
expect(totalAfter).toBe(totalBefore);
|
|
40
|
+
},
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("fold is idempotent: fold(fold(p)) equals fold(p)", () => {
|
|
46
|
+
fc.assert(
|
|
47
|
+
fc.property(arbPlayer, (player) => {
|
|
48
|
+
const foldedOnce = fold(player);
|
|
49
|
+
const foldedTwice = fold(foldedOnce);
|
|
50
|
+
|
|
51
|
+
expect(foldedTwice.isFolded).toBe(foldedOnce.isFolded);
|
|
52
|
+
expect(chipsToNumber(foldedTwice.chips)).toBe(
|
|
53
|
+
chipsToNumber(foldedOnce.chips),
|
|
54
|
+
);
|
|
55
|
+
expect(chipsToNumber(foldedTwice.currentBet)).toBe(
|
|
56
|
+
chipsToNumber(foldedOnce.currentBet),
|
|
57
|
+
);
|
|
58
|
+
expect(foldedTwice.isAllIn).toBe(foldedOnce.isAllIn);
|
|
59
|
+
expect(foldedTwice.seatIndex).toBe(foldedOnce.seatIndex);
|
|
60
|
+
expect(Option.getEquivalence(
|
|
61
|
+
(a: readonly [unknown, unknown], b: readonly [unknown, unknown]) =>
|
|
62
|
+
a[0] === b[0] && a[1] === b[1],
|
|
63
|
+
)(foldedTwice.holeCards, foldedOnce.holeCards)).toBe(true);
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("winChips increases chips by exact amount", () => {
|
|
69
|
+
fc.assert(
|
|
70
|
+
fc.property(arbPlayer, arbChips, (player, amount) => {
|
|
71
|
+
const chipsBefore = chipsToNumber(player.chips);
|
|
72
|
+
const afterWin = winChips(player, amount);
|
|
73
|
+
const chipsAfter = chipsToNumber(afterWin.chips);
|
|
74
|
+
|
|
75
|
+
expect(chipsAfter).toBe(chipsBefore + chipsToNumber(amount));
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("clearHand resets all transient state", () => {
|
|
81
|
+
fc.assert(
|
|
82
|
+
fc.property(
|
|
83
|
+
arbPlayer,
|
|
84
|
+
arbCard,
|
|
85
|
+
arbCard,
|
|
86
|
+
(player, card1, card2) => {
|
|
87
|
+
// Put the player into a "dirty" state with cards, a bet, and fold
|
|
88
|
+
let dirty = dealCards(player, [card1, card2]);
|
|
89
|
+
// Bet the entire stack to trigger isAllIn
|
|
90
|
+
dirty = placeBet(dirty, dirty.chips);
|
|
91
|
+
dirty = fold(dirty);
|
|
92
|
+
|
|
93
|
+
const cleared = clearHand(dirty);
|
|
94
|
+
|
|
95
|
+
expect(chipsToNumber(cleared.currentBet)).toBe(0);
|
|
96
|
+
expect(cleared.isAllIn).toBe(false);
|
|
97
|
+
expect(cleared.isFolded).toBe(false);
|
|
98
|
+
expect(Option.isNone(cleared.holeCards)).toBe(true);
|
|
99
|
+
// chips should remain unchanged by clearHand
|
|
100
|
+
expect(chipsToNumber(cleared.chips)).toBe(
|
|
101
|
+
chipsToNumber(dirty.chips),
|
|
102
|
+
);
|
|
103
|
+
// seatIndex should remain unchanged
|
|
104
|
+
expect(cleared.seatIndex).toBe(dirty.seatIndex);
|
|
105
|
+
},
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("canAct characterization: true iff not folded, not all-in, and chips > 0", () => {
|
|
111
|
+
fc.assert(
|
|
112
|
+
fc.property(
|
|
113
|
+
arbPlayer,
|
|
114
|
+
fc.boolean(),
|
|
115
|
+
fc.boolean(),
|
|
116
|
+
(player, shouldFold, shouldAllIn) => {
|
|
117
|
+
let p = player;
|
|
118
|
+
|
|
119
|
+
if (shouldFold) {
|
|
120
|
+
p = fold(p);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (shouldAllIn && !shouldFold) {
|
|
124
|
+
// Bet entire stack to go all-in
|
|
125
|
+
p = placeBet(p, p.chips);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const expected =
|
|
129
|
+
!p.isFolded && !p.isAllIn && chipsToNumber(p.chips) > 0;
|
|
130
|
+
|
|
131
|
+
expect(canAct(p)).toBe(expected);
|
|
132
|
+
},
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|