@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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { GameState, ShowAction, MuckAction } from "@pokertools/types";
|
|
2
|
+
/**
|
|
3
|
+
* Handle SHOW action - player reveals their cards at showdown
|
|
4
|
+
*/
|
|
5
|
+
export declare function handleShow(state: GameState, action: ShowAction): GameState;
|
|
6
|
+
/**
|
|
7
|
+
* Handle MUCK action - player hides their cards at showdown
|
|
8
|
+
*/
|
|
9
|
+
export declare function handleMuck(state: GameState, action: MuckAction): GameState;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleShow = handleShow;
|
|
4
|
+
exports.handleMuck = handleMuck;
|
|
5
|
+
const IllegalActionError_1 = require("../errors/IllegalActionError");
|
|
6
|
+
const ErrorCodes_1 = require("../errors/ErrorCodes");
|
|
7
|
+
/**
|
|
8
|
+
* Handle SHOW action - player reveals their cards at showdown
|
|
9
|
+
*/
|
|
10
|
+
function handleShow(state, action) {
|
|
11
|
+
// Find player
|
|
12
|
+
const player = state.players.find((p) => p?.id === action.playerId);
|
|
13
|
+
if (!player) {
|
|
14
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
|
|
15
|
+
}
|
|
16
|
+
// Can only show at showdown
|
|
17
|
+
if (state.street !== "SHOWDOWN" /* Street.SHOWDOWN */) {
|
|
18
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Can only show cards at showdown (current street: ${state.street})`, { playerId: action.playerId, street: state.street });
|
|
19
|
+
}
|
|
20
|
+
// Player must not have folded
|
|
21
|
+
if (player.status === "FOLDED" /* PlayerStatus.FOLDED */) {
|
|
22
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Cannot show cards after folding`, {
|
|
23
|
+
playerId: action.playerId,
|
|
24
|
+
status: player.status,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
// Player must have cards
|
|
28
|
+
if (!player.hand) {
|
|
29
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Player has no cards to show`, {
|
|
30
|
+
playerId: action.playerId,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// Determine which cards to show
|
|
34
|
+
let cardIndices;
|
|
35
|
+
if (action.cardIndices && action.cardIndices.length > 0) {
|
|
36
|
+
// Validate indices are within bounds
|
|
37
|
+
cardIndices = action.cardIndices.filter((i) => i >= 0 && i < player.hand.length);
|
|
38
|
+
if (cardIndices.length === 0) {
|
|
39
|
+
return state; // Invalid indices
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// Default: show all cards
|
|
44
|
+
cardIndices = Array.from({ length: player.hand.length }, (_, i) => i);
|
|
45
|
+
}
|
|
46
|
+
// Update player's shown cards
|
|
47
|
+
const newPlayers = [...state.players];
|
|
48
|
+
newPlayers[player.seat] = {
|
|
49
|
+
...player,
|
|
50
|
+
shownCards: cardIndices,
|
|
51
|
+
};
|
|
52
|
+
const actionRecord = {
|
|
53
|
+
action: {
|
|
54
|
+
type: "SHOW" /* ActionType.SHOW */,
|
|
55
|
+
playerId: action.playerId,
|
|
56
|
+
cardIndices: action.cardIndices,
|
|
57
|
+
timestamp: action.timestamp,
|
|
58
|
+
},
|
|
59
|
+
seat: player.seat,
|
|
60
|
+
resultingPot: state.pots.reduce((sum, pot) => sum + pot.amount, 0),
|
|
61
|
+
resultingStack: player.stack,
|
|
62
|
+
street: state.street,
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
...state,
|
|
66
|
+
players: newPlayers,
|
|
67
|
+
actionHistory: [...state.actionHistory, actionRecord],
|
|
68
|
+
timestamp: action.timestamp,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Handle MUCK action - player hides their cards at showdown
|
|
73
|
+
*/
|
|
74
|
+
function handleMuck(state, action) {
|
|
75
|
+
// Find player
|
|
76
|
+
const player = state.players.find((p) => p?.id === action.playerId);
|
|
77
|
+
if (!player) {
|
|
78
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
|
|
79
|
+
}
|
|
80
|
+
// Can only muck at showdown
|
|
81
|
+
if (state.street !== "SHOWDOWN" /* Street.SHOWDOWN */) {
|
|
82
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Can only muck cards at showdown (current street: ${state.street})`, { playerId: action.playerId, street: state.street });
|
|
83
|
+
}
|
|
84
|
+
// Player must not have folded
|
|
85
|
+
if (player.status === "FOLDED" /* PlayerStatus.FOLDED */) {
|
|
86
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Cannot muck cards after folding`, {
|
|
87
|
+
playerId: action.playerId,
|
|
88
|
+
status: player.status,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Cannot muck if you're a winner (winners must show)
|
|
92
|
+
const isWinner = state.winners?.some((w) => w.seat === player.seat);
|
|
93
|
+
if (isWinner) {
|
|
94
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, `Winners cannot muck - cards must be shown`, { playerId: action.playerId });
|
|
95
|
+
}
|
|
96
|
+
// Set shown cards to null (mucked) - hand is preserved in player.hand
|
|
97
|
+
const newPlayers = [...state.players];
|
|
98
|
+
newPlayers[player.seat] = {
|
|
99
|
+
...player,
|
|
100
|
+
shownCards: null, // Muck cards (hide them, but preserve hand data)
|
|
101
|
+
};
|
|
102
|
+
const actionRecord = {
|
|
103
|
+
action: {
|
|
104
|
+
type: "MUCK" /* ActionType.MUCK */,
|
|
105
|
+
playerId: action.playerId,
|
|
106
|
+
timestamp: action.timestamp,
|
|
107
|
+
},
|
|
108
|
+
seat: player.seat,
|
|
109
|
+
resultingPot: state.pots.reduce((sum, pot) => sum + pot.amount, 0),
|
|
110
|
+
resultingStack: player.stack,
|
|
111
|
+
street: state.street,
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
...state,
|
|
115
|
+
players: newPlayers,
|
|
116
|
+
actionHistory: [...state.actionHistory, actionRecord],
|
|
117
|
+
timestamp: action.timestamp,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { GameState, TimeoutAction, TimeBankAction } from "@pokertools/types";
|
|
2
|
+
/**
|
|
3
|
+
* Handle TIMEOUT action
|
|
4
|
+
* - Folds player if they have bet to call
|
|
5
|
+
* - Checks if allowed, otherwise folds
|
|
6
|
+
* - Marks player as sitting out
|
|
7
|
+
*/
|
|
8
|
+
export declare function handleTimeout(state: GameState, action: TimeoutAction): GameState;
|
|
9
|
+
/**
|
|
10
|
+
* Handle TIME_BANK action
|
|
11
|
+
* - Deducts time from player's time bank
|
|
12
|
+
* - Keeps action on same player
|
|
13
|
+
*/
|
|
14
|
+
export declare function handleTimeBank(state: GameState, action: TimeBankAction): GameState;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleTimeout = handleTimeout;
|
|
4
|
+
exports.handleTimeBank = handleTimeBank;
|
|
5
|
+
const positioning_1 = require("../utils/positioning");
|
|
6
|
+
const actionOrder_1 = require("../rules/actionOrder");
|
|
7
|
+
/**
|
|
8
|
+
* Handle TIMEOUT action
|
|
9
|
+
* - Folds player if they have bet to call
|
|
10
|
+
* - Checks if allowed, otherwise folds
|
|
11
|
+
* - Marks player as sitting out
|
|
12
|
+
*/
|
|
13
|
+
function handleTimeout(state, action) {
|
|
14
|
+
const result = (0, positioning_1.getPlayerById)(state, action.playerId);
|
|
15
|
+
if (!result) {
|
|
16
|
+
return state;
|
|
17
|
+
}
|
|
18
|
+
const { player, seat } = result;
|
|
19
|
+
// Determine if player needs to call
|
|
20
|
+
const currentBet = getCurrentBet(state);
|
|
21
|
+
const playerBet = state.currentBets.get(seat) ?? 0;
|
|
22
|
+
const needsToCall = currentBet > playerBet;
|
|
23
|
+
const newPlayers = [...state.players];
|
|
24
|
+
if (needsToCall) {
|
|
25
|
+
// Player must fold
|
|
26
|
+
newPlayers[seat] = {
|
|
27
|
+
...player,
|
|
28
|
+
status: "FOLDED" /* PlayerStatus.FOLDED */,
|
|
29
|
+
isSittingOut: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
// Player can check, but mark as sitting out
|
|
34
|
+
newPlayers[seat] = {
|
|
35
|
+
...player,
|
|
36
|
+
isSittingOut: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const newActivePlayers = needsToCall
|
|
40
|
+
? state.activePlayers.filter((s) => s !== seat)
|
|
41
|
+
: state.activePlayers;
|
|
42
|
+
const newState = {
|
|
43
|
+
...state,
|
|
44
|
+
players: newPlayers,
|
|
45
|
+
activePlayers: newActivePlayers,
|
|
46
|
+
timestamp: action.timestamp,
|
|
47
|
+
};
|
|
48
|
+
// Move to next player
|
|
49
|
+
const nextToAct = (0, actionOrder_1.getNextToAct)(newState);
|
|
50
|
+
return {
|
|
51
|
+
...newState,
|
|
52
|
+
actionTo: nextToAct,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Handle TIME_BANK action
|
|
57
|
+
* - Deducts time from player's time bank
|
|
58
|
+
* - Keeps action on same player
|
|
59
|
+
*/
|
|
60
|
+
function handleTimeBank(state, action) {
|
|
61
|
+
const result = (0, positioning_1.getPlayerById)(state, action.playerId);
|
|
62
|
+
if (!result) {
|
|
63
|
+
return state;
|
|
64
|
+
}
|
|
65
|
+
const { seat } = result;
|
|
66
|
+
const currentTimeBank = state.timeBanks.get(seat) ?? 0;
|
|
67
|
+
if (currentTimeBank <= 0) {
|
|
68
|
+
// No time bank left, force timeout
|
|
69
|
+
return handleTimeout(state, {
|
|
70
|
+
type: "TIMEOUT" /* ActionType.TIMEOUT */,
|
|
71
|
+
playerId: action.playerId,
|
|
72
|
+
timestamp: action.timestamp,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// Deduct time from player's time bank (configurable, default 10 seconds)
|
|
76
|
+
const deduction = state.config.timeBankDeductionSeconds ?? 10;
|
|
77
|
+
const newTimeBank = Math.max(0, currentTimeBank - deduction);
|
|
78
|
+
const newTimeBanks = new Map(state.timeBanks);
|
|
79
|
+
newTimeBanks.set(seat, newTimeBank);
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
timeBanks: newTimeBanks,
|
|
83
|
+
timestamp: action.timestamp,
|
|
84
|
+
// Keep actionTo the same (extends player's turn)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get current highest bet
|
|
89
|
+
*/
|
|
90
|
+
function getCurrentBet(state) {
|
|
91
|
+
let maxBet = 0;
|
|
92
|
+
for (const bet of state.currentBets.values()) {
|
|
93
|
+
if (bet > maxBet) {
|
|
94
|
+
maxBet = bet;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return maxBet;
|
|
98
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { GameState } from "@pokertools/types";
|
|
2
|
+
/**
|
|
3
|
+
* Progress to next street
|
|
4
|
+
* - Collects bets into pots
|
|
5
|
+
* - Deals community cards
|
|
6
|
+
* - Resets action
|
|
7
|
+
*/
|
|
8
|
+
export declare function progressStreet(state: GameState): GameState;
|
|
9
|
+
/**
|
|
10
|
+
* Check if we should progress to next street
|
|
11
|
+
* (All players have acted and matched bets)
|
|
12
|
+
*/
|
|
13
|
+
export declare function shouldProgressStreet(state: GameState): boolean;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.progressStreet = progressStreet;
|
|
4
|
+
exports.shouldProgressStreet = shouldProgressStreet;
|
|
5
|
+
const deck_1 = require("../utils/deck");
|
|
6
|
+
const cardUtils_1 = require("../utils/cardUtils");
|
|
7
|
+
const actionOrder_1 = require("../rules/actionOrder");
|
|
8
|
+
/**
|
|
9
|
+
* Progress to next street
|
|
10
|
+
* - Collects bets into pots
|
|
11
|
+
* - Deals community cards
|
|
12
|
+
* - Resets action
|
|
13
|
+
*/
|
|
14
|
+
function progressStreet(state) {
|
|
15
|
+
// Determine next street
|
|
16
|
+
const nextStreet = getNextStreet(state.street);
|
|
17
|
+
if (nextStreet === null) {
|
|
18
|
+
// Already at showdown or beyond
|
|
19
|
+
return state;
|
|
20
|
+
}
|
|
21
|
+
// Check if we should auto-runout (all remaining players all-in)
|
|
22
|
+
const shouldAutoRunout = checkAutoRunout(state);
|
|
23
|
+
if (shouldAutoRunout) {
|
|
24
|
+
return handleAutoRunout(state);
|
|
25
|
+
}
|
|
26
|
+
// Deal community cards
|
|
27
|
+
const { board, deck } = dealCommunityCards(state, nextStreet);
|
|
28
|
+
// Reset for new street
|
|
29
|
+
// Note: Pots are calculated by recalculatePots() before progressStreet() is called
|
|
30
|
+
const newState = {
|
|
31
|
+
...state,
|
|
32
|
+
street: nextStreet,
|
|
33
|
+
board,
|
|
34
|
+
deck,
|
|
35
|
+
pots: state.pots, // Keep existing pots (already updated by recalculatePots)
|
|
36
|
+
currentBets: new Map(),
|
|
37
|
+
lastAggressorSeat: null,
|
|
38
|
+
// Keep timestamp from last action (street progression is not a user action)
|
|
39
|
+
};
|
|
40
|
+
// Set first to act
|
|
41
|
+
const firstToAct = (0, actionOrder_1.getFirstToAct)(newState);
|
|
42
|
+
return {
|
|
43
|
+
...newState,
|
|
44
|
+
actionTo: firstToAct,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Get next street in sequence
|
|
49
|
+
* Uses exhaustive mapping for type safety
|
|
50
|
+
*/
|
|
51
|
+
function getNextStreet(current) {
|
|
52
|
+
const nextStreetMap = {
|
|
53
|
+
["PREFLOP" /* Street.PREFLOP */]: "FLOP" /* Street.FLOP */,
|
|
54
|
+
["FLOP" /* Street.FLOP */]: "TURN" /* Street.TURN */,
|
|
55
|
+
["TURN" /* Street.TURN */]: "RIVER" /* Street.RIVER */,
|
|
56
|
+
["RIVER" /* Street.RIVER */]: "SHOWDOWN" /* Street.SHOWDOWN */,
|
|
57
|
+
["SHOWDOWN" /* Street.SHOWDOWN */]: null,
|
|
58
|
+
};
|
|
59
|
+
return nextStreetMap[current];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Deal community cards for the given street
|
|
63
|
+
*/
|
|
64
|
+
function dealCommunityCards(state, street) {
|
|
65
|
+
const currentBoard = [...state.board];
|
|
66
|
+
const deck = [...state.deck];
|
|
67
|
+
switch (street) {
|
|
68
|
+
case "FLOP" /* Street.FLOP */:
|
|
69
|
+
// Burn 1, deal 3
|
|
70
|
+
const [flopCards, flopDeck] = (0, deck_1.burnAndDeal)(deck, 3);
|
|
71
|
+
return {
|
|
72
|
+
board: [...currentBoard, ...(0, cardUtils_1.cardCodesToStrings)(flopCards)],
|
|
73
|
+
deck: flopDeck,
|
|
74
|
+
};
|
|
75
|
+
case "TURN" /* Street.TURN */:
|
|
76
|
+
// Burn 1, deal 1
|
|
77
|
+
const [turnCards, turnDeck] = (0, deck_1.burnAndDeal)(deck, 1);
|
|
78
|
+
return {
|
|
79
|
+
board: [...currentBoard, ...(0, cardUtils_1.cardCodesToStrings)(turnCards)],
|
|
80
|
+
deck: turnDeck,
|
|
81
|
+
};
|
|
82
|
+
case "RIVER" /* Street.RIVER */:
|
|
83
|
+
// Burn 1, deal 1
|
|
84
|
+
const [riverCards, riverDeck] = (0, deck_1.burnAndDeal)(deck, 1);
|
|
85
|
+
return {
|
|
86
|
+
board: [...currentBoard, ...(0, cardUtils_1.cardCodesToStrings)(riverCards)],
|
|
87
|
+
deck: riverDeck,
|
|
88
|
+
};
|
|
89
|
+
default:
|
|
90
|
+
return { board: currentBoard, deck };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Check if all remaining players are all-in (auto-runout condition)
|
|
95
|
+
*/
|
|
96
|
+
function checkAutoRunout(state) {
|
|
97
|
+
let activeCount = 0;
|
|
98
|
+
let allInCount = 0;
|
|
99
|
+
for (const player of state.players) {
|
|
100
|
+
if (!player)
|
|
101
|
+
continue;
|
|
102
|
+
if (player.status === "ACTIVE" /* PlayerStatus.ACTIVE */ && player.stack > 0) {
|
|
103
|
+
activeCount++;
|
|
104
|
+
}
|
|
105
|
+
else if (player.status === "ALL_IN" /* PlayerStatus.ALL_IN */) {
|
|
106
|
+
allInCount++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Auto-runout if ≤1 active player with chips (rest are all-in or folded)
|
|
110
|
+
return activeCount <= 1 && allInCount > 0;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Handle auto-runout: deal all remaining streets at once
|
|
114
|
+
* This manually deals cards without calling progressStreet to avoid infinite recursion
|
|
115
|
+
*/
|
|
116
|
+
function handleAutoRunout(state) {
|
|
117
|
+
let currentState = state;
|
|
118
|
+
let currentStreet = state.street;
|
|
119
|
+
// Deal remaining streets manually (FLOP -> TURN -> RIVER)
|
|
120
|
+
while (currentStreet !== "RIVER" /* Street.RIVER */) {
|
|
121
|
+
const nextStreet = getNextStreet(currentStreet);
|
|
122
|
+
if (nextStreet === null || nextStreet === "SHOWDOWN" /* Street.SHOWDOWN */)
|
|
123
|
+
break;
|
|
124
|
+
// Deal community cards for this street
|
|
125
|
+
const { board, deck } = dealCommunityCards(currentState, nextStreet);
|
|
126
|
+
// Update state with new street and board
|
|
127
|
+
currentState = {
|
|
128
|
+
...currentState,
|
|
129
|
+
street: nextStreet,
|
|
130
|
+
board,
|
|
131
|
+
deck,
|
|
132
|
+
currentBets: new Map(), // Clear bets between streets
|
|
133
|
+
lastAggressorSeat: null,
|
|
134
|
+
};
|
|
135
|
+
currentStreet = nextStreet;
|
|
136
|
+
}
|
|
137
|
+
// Move to showdown
|
|
138
|
+
return {
|
|
139
|
+
...currentState,
|
|
140
|
+
street: "SHOWDOWN" /* Street.SHOWDOWN */,
|
|
141
|
+
actionTo: null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Check if we should progress to next street
|
|
146
|
+
* (All players have acted and matched bets)
|
|
147
|
+
*/
|
|
148
|
+
function shouldProgressStreet(state) {
|
|
149
|
+
if (state.actionTo !== null) {
|
|
150
|
+
return false; // Action still in progress
|
|
151
|
+
}
|
|
152
|
+
if (state.street === "SHOWDOWN" /* Street.SHOWDOWN */) {
|
|
153
|
+
return false; // Already at showdown
|
|
154
|
+
}
|
|
155
|
+
// Check if all active players have acted
|
|
156
|
+
return (0, actionOrder_1.isActionComplete)(state);
|
|
157
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleNextBlindLevel = handleNextBlindLevel;
|
|
4
|
+
/**
|
|
5
|
+
* Handle NEXT_BLIND_LEVEL action - advance to next blind level in tournament
|
|
6
|
+
*/
|
|
7
|
+
function handleNextBlindLevel(state, action) {
|
|
8
|
+
// Only applicable for tournaments
|
|
9
|
+
if (!state.config.blindStructure) {
|
|
10
|
+
return state;
|
|
11
|
+
}
|
|
12
|
+
const nextLevel = state.blindLevel + 1;
|
|
13
|
+
// Check if we're at max level
|
|
14
|
+
if (nextLevel >= state.config.blindStructure.length) {
|
|
15
|
+
return state; // At max level, no change
|
|
16
|
+
}
|
|
17
|
+
const blindLevel = state.config.blindStructure[nextLevel];
|
|
18
|
+
// Record action to history
|
|
19
|
+
const actionRecord = {
|
|
20
|
+
action: {
|
|
21
|
+
type: "NEXT_BLIND_LEVEL" /* ActionType.NEXT_BLIND_LEVEL */,
|
|
22
|
+
timestamp: action.timestamp,
|
|
23
|
+
},
|
|
24
|
+
seat: null, // Table-level action
|
|
25
|
+
resultingPot: state.pots.reduce((sum, pot) => sum + pot.amount, 0),
|
|
26
|
+
resultingStack: 0, // Not applicable
|
|
27
|
+
street: state.street,
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
...state,
|
|
31
|
+
blindLevel: nextLevel,
|
|
32
|
+
smallBlind: blindLevel.smallBlind,
|
|
33
|
+
bigBlind: blindLevel.bigBlind,
|
|
34
|
+
ante: blindLevel.ante,
|
|
35
|
+
actionHistory: [...state.actionHistory, actionRecord],
|
|
36
|
+
timestamp: action.timestamp,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateAction = validateAction;
|
|
4
|
+
const IllegalActionError_1 = require("../errors/IllegalActionError");
|
|
5
|
+
const ErrorCodes_1 = require("../errors/ErrorCodes");
|
|
6
|
+
const positioning_1 = require("../utils/positioning");
|
|
7
|
+
/**
|
|
8
|
+
* Validate that an action is legal in the current game state
|
|
9
|
+
* Throws IllegalActionError if action is invalid
|
|
10
|
+
*/
|
|
11
|
+
function validateAction(state, action) {
|
|
12
|
+
// Type-specific validation
|
|
13
|
+
switch (action.type) {
|
|
14
|
+
case "FOLD" /* ActionType.FOLD */:
|
|
15
|
+
case "CHECK" /* ActionType.CHECK */:
|
|
16
|
+
case "CALL" /* ActionType.CALL */:
|
|
17
|
+
case "BET" /* ActionType.BET */:
|
|
18
|
+
case "RAISE" /* ActionType.RAISE */:
|
|
19
|
+
validateBettingAction(state, action);
|
|
20
|
+
break;
|
|
21
|
+
case "DEAL" /* ActionType.DEAL */:
|
|
22
|
+
validateDealAction(state);
|
|
23
|
+
break;
|
|
24
|
+
case "SIT" /* ActionType.SIT */:
|
|
25
|
+
validateSitAction(state, action);
|
|
26
|
+
break;
|
|
27
|
+
case "STAND" /* ActionType.STAND */:
|
|
28
|
+
validateStandAction(state, action);
|
|
29
|
+
break;
|
|
30
|
+
case "TIMEOUT" /* ActionType.TIMEOUT */:
|
|
31
|
+
case "TIME_BANK" /* ActionType.TIME_BANK */:
|
|
32
|
+
validateTimeAction(state, action);
|
|
33
|
+
break;
|
|
34
|
+
default:
|
|
35
|
+
// Other actions don't need validation
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function validateBettingAction(state, action) {
|
|
40
|
+
if (!("playerId" in action)) {
|
|
41
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_ACTION, "Action missing playerId");
|
|
42
|
+
}
|
|
43
|
+
const result = (0, positioning_1.getPlayerById)(state, action.playerId);
|
|
44
|
+
if (!result) {
|
|
45
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
|
|
46
|
+
}
|
|
47
|
+
const { player, seat } = result;
|
|
48
|
+
// Check if it's player's turn
|
|
49
|
+
if (state.actionTo !== seat) {
|
|
50
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NOT_YOUR_TURN, `Player ${action.playerId} attempted to act, but action is on seat ${state.actionTo}`, {
|
|
51
|
+
playerId: action.playerId,
|
|
52
|
+
playerSeat: seat,
|
|
53
|
+
actionTo: state.actionTo,
|
|
54
|
+
street: state.street,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// Check player status
|
|
58
|
+
if (player.status !== "ACTIVE" /* PlayerStatus.ACTIVE */) {
|
|
59
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_ACTIVE, `Player ${action.playerId} cannot act with status ${player.status}`, { playerId: action.playerId, status: player.status });
|
|
60
|
+
}
|
|
61
|
+
// Check player has chips
|
|
62
|
+
if (player.stack === 0 && action.type !== "FOLD" /* ActionType.FOLD */) {
|
|
63
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NO_CHIPS, `Player ${action.playerId} has no chips`, {
|
|
64
|
+
playerId: action.playerId,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// Action-specific validation
|
|
68
|
+
const currentBet = getCurrentBet(state);
|
|
69
|
+
const playerBet = state.currentBets.get(seat) ?? 0;
|
|
70
|
+
const toCall = currentBet - playerBet;
|
|
71
|
+
switch (action.type) {
|
|
72
|
+
case "CHECK" /* ActionType.CHECK */:
|
|
73
|
+
if (toCall > 0) {
|
|
74
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.CANNOT_CHECK, `Player ${action.playerId} cannot check with ${toCall} to call`, { playerId: action.playerId, toCall });
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
case "CALL" /* ActionType.CALL */:
|
|
78
|
+
if (toCall === 0) {
|
|
79
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NOTHING_TO_CALL, `Player ${action.playerId} has nothing to call`, { playerId: action.playerId });
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
case "BET" /* ActionType.BET */:
|
|
83
|
+
// Note: We allow BET even when currentBet > 0 because the reducer will auto-convert it to RAISE or CALL
|
|
84
|
+
// This handles UI implementations that don't distinguish between BET and RAISE buttons
|
|
85
|
+
if ("amount" in action) {
|
|
86
|
+
// Reject bets below the current bet (string bet exploit)
|
|
87
|
+
if (currentBet > 0 && action.amount < currentBet) {
|
|
88
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.BET_TOO_SMALL, `Bet of ${action.amount} is below current bet ${currentBet}`, { amount: action.amount, currentBet });
|
|
89
|
+
}
|
|
90
|
+
// Reject bets below big blind (when no current bet)
|
|
91
|
+
if (action.amount < state.bigBlind && action.amount < player.stack) {
|
|
92
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.BET_TOO_SMALL, `Bet of ${action.amount} is below minimum ${state.bigBlind}`, { amount: action.amount, minimum: state.bigBlind });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
case "RAISE" /* ActionType.RAISE */:
|
|
97
|
+
// If the current player is still marked as the last aggressor, it means
|
|
98
|
+
// intermediate actions (like calls or incomplete all-in raises) did NOT
|
|
99
|
+
// reopen the betting. Therefore, they cannot re-raise their own bet.
|
|
100
|
+
if (state.lastAggressorSeat === seat) {
|
|
101
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.CANNOT_RERAISE, "Betting has not been re-opened to you (incomplete raise or no action)", {
|
|
102
|
+
playerId: action.playerId,
|
|
103
|
+
seat,
|
|
104
|
+
lastAggressor: state.lastAggressorSeat,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (currentBet === 0) {
|
|
108
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.CANNOT_RAISE, `Player ${action.playerId} cannot raise when there's no bet`, { playerId: action.playerId });
|
|
109
|
+
}
|
|
110
|
+
if ("amount" in action) {
|
|
111
|
+
// Check if player is going all-in (incomplete raise exception)
|
|
112
|
+
const isAllIn = action.amount >= playerBet + player.stack;
|
|
113
|
+
// Reject raises that don't exceed current bet (unless all-in)
|
|
114
|
+
if (action.amount <= currentBet && !isAllIn) {
|
|
115
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.RAISE_TOO_SMALL, `Raise to ${action.amount} must be greater than current bet ${currentBet}`, { amount: action.amount, currentBet });
|
|
116
|
+
}
|
|
117
|
+
// Check minimum raise requirement (unless player is going all-in)
|
|
118
|
+
const raiseIncrement = action.amount - currentBet;
|
|
119
|
+
if (!isAllIn && action.amount < state.minRaise) {
|
|
120
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.RAISE_TOO_SMALL, `Raise to ${action.amount} is below minimum ${state.minRaise}`, {
|
|
121
|
+
amount: action.amount,
|
|
122
|
+
currentBet,
|
|
123
|
+
raiseIncrement,
|
|
124
|
+
minRaise: state.minRaise,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function validateDealAction(state) {
|
|
132
|
+
if (state.street !== "PREFLOP" /* Street.PREFLOP */ || state.handNumber > 0) {
|
|
133
|
+
// Allow dealing if we're at showdown (hand complete) or haven't started
|
|
134
|
+
if (state.street !== "SHOWDOWN" /* Street.SHOWDOWN */ && state.handNumber > 0) {
|
|
135
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.CANNOT_DEAL, "Cannot deal while hand is in progress", { street: state.street });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Check we have enough players
|
|
139
|
+
const activePlayers = state.players.filter((p) => p && p.stack > 0 && !p.isSittingOut);
|
|
140
|
+
if (activePlayers.length < 2) {
|
|
141
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NOT_ENOUGH_PLAYERS, `Need at least 2 players to deal, found ${activePlayers.length}`, { playerCount: activePlayers.length });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function validateSitAction(state, action) {
|
|
145
|
+
if (action.seat < 0 || action.seat >= state.maxPlayers) {
|
|
146
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_SEAT, `Seat ${action.seat} is invalid (max: ${state.maxPlayers - 1})`, { seat: action.seat, maxPlayers: state.maxPlayers });
|
|
147
|
+
}
|
|
148
|
+
if (state.players[action.seat] !== null) {
|
|
149
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.SEAT_OCCUPIED, `Seat ${action.seat} is already occupied`, { seat: action.seat });
|
|
150
|
+
}
|
|
151
|
+
if (action.stack <= 0) {
|
|
152
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.INVALID_STACK, `Stack must be positive, got ${action.stack}`, { stack: action.stack });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function validateStandAction(state, action) {
|
|
156
|
+
const result = (0, positioning_1.getPlayerById)(state, action.playerId);
|
|
157
|
+
if (!result) {
|
|
158
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function validateTimeAction(state, action) {
|
|
162
|
+
const result = (0, positioning_1.getPlayerById)(state, action.playerId);
|
|
163
|
+
if (!result) {
|
|
164
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.PLAYER_NOT_FOUND, `Player ${action.playerId} not found`, { playerId: action.playerId });
|
|
165
|
+
}
|
|
166
|
+
const { seat } = result;
|
|
167
|
+
if (state.actionTo !== seat) {
|
|
168
|
+
throw new IllegalActionError_1.IllegalActionError(ErrorCodes_1.ErrorCodes.NOT_YOUR_TURN, `Player ${action.playerId} cannot use time action when it's not their turn`, { playerId: action.playerId, actionTo: state.actionTo });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Get current highest bet this street
|
|
173
|
+
*/
|
|
174
|
+
function getCurrentBet(state) {
|
|
175
|
+
let maxBet = 0;
|
|
176
|
+
for (const bet of state.currentBets.values()) {
|
|
177
|
+
if (bet > maxBet) {
|
|
178
|
+
maxBet = bet;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return maxBet;
|
|
182
|
+
}
|