@idealic/poker-engine
Version:
Poker game engine and hand evaluator
945 lines (867 loc) • 31.3 kB
text/typescript
import { describe, expect, it } from 'vitest';
import * as Poker from '../../..';
import { applyAction } from '../../../game/progress';
import { getPokerMetrics } from '../../../stats/metrics';
/**
* @fileoverview Financial Statistics Tests for the Poker Engine
*
* @description
* This file contains tests focused on verifying the accuracy of financial calculations
* throughout a poker hand. The core philosophy is to track stats on a per-street basis
* to allow for detailed analysis of player actions.
*
* --- Assertion Strategy ---
*
* 1. **Per-Street Granularity**: All stats are stored in `PlayerStreetStats` objects.
* It's crucial to verify the state of these objects after each street's action.
*
* 2. **`investments`**: This reflects the amount a player put into the pot *on that specific street*.
* It does not include amounts invested on previous streets.
*
* 3. **`balance`**: This is a strictly per-street metric. It is calculated as:
* `winnings - (investments - returns)`.
* - For most streets, where winnings and returns are 0, this will simply be `-investments`.
* - For the final street of action for a player, it will include any winnings from the hand.
* - It should NOT reflect the total, cumulative hand profit/loss.
*
* 4. **`winnings`, `losses`, `profits`, `returns`**: These are hand-level outcomes. They are
* recorded ONLY on the `PlayerStreetStats` object corresponding to the player's *final* street
* of action (e.g., the street they folded, went all-in, or showed down). All other streets
* will have these values as 0.
*
* 5. **`stackBefore`, `stackAfter`**: These track the player's stack at the beginning and end
* of a single street, providing a clear audit trail of their financial state changes.
*
* 6. **Aggregated Verification**: After all per-street assertions, each test must conclude
* by calling `getPokerMetrics` to verify the aggregated hand-level financials for each
* player and for the table total. This ensures that the sum of all actions results in a
* balanced and correct final outcome (e.g., total profits - total losses = total rake).
*
* By adhering to these principles, the tests ensure that both the granular per-street actions
* and the final hand outcomes are calculated and attributed correctly.
*/
const sampleGame: Poker.Hand = {
variant: 'NT',
players: [],
startingStacks: [],
blindsOrStraddles: [],
antes: [],
actions: [],
minBet: 20,
seed: 12345,
};
describe('Financial Statistics', () => {
describe('Complex Scenarios: All-ins and Side Pots', () => {
it('should correctly track finances in a multiway all-in with side pots and a short stack', () => {
// SCENARIO: P1 (short stack) is all-in pre-flop. P2 and P3 build a side pot on the flop and turn.
// OUTCOME: P1 wins the main pot, P2 wins the side pot, and P3 loses both bets.
// VERIFY: Correct per-street investments, winnings, profits, and losses for all three players.
const table = Poker.Game({
...sampleGame,
players: ['P1_BTN_Short', 'P2_SB', 'P3_BB'],
blindsOrStraddles: [0, 10, 20],
startingStacks: [200, 1000, 1000],
rakePercentage: 0.05,
});
// --- PREFLOP ---
// P1 (BTN) shoves, P2 (SB) and P3 (BB) call. Main pot is 600.
applyAction(table, 'd dh p1 AsAc');
applyAction(table, 'd dh p2 KsKc');
applyAction(table, 'd dh p3 QsQc');
applyAction(table, 'p1 cbr 200');
applyAction(table, 'p2 cc');
applyAction(table, 'p3 cc');
// --- FLOP ---
// P2 and P3 build a side pot. Side pot is 600.
applyAction(table, 'd db 2h7d8c');
applyAction(table, 'p2 cbr 300');
applyAction(table, 'p3 cc');
// --- TURN & RIVER ---
// Checked down.
applyAction(table, 'd db 9s');
applyAction(table, 'p2 cc');
applyAction(table, 'p3 cc');
applyAction(table, 'd db Ts');
applyAction(table, 'p2 cc');
applyAction(table, 'p3 cc');
// --- SHOWDOWN ---
// P1 wins the main pot with a pair of Aces.
// P2 wins the side pot, as their pair of Kings beats P3's pair of Queens.
applyAction(table, 'p2 sm KsKc');
applyAction(table, 'p3 sm QsQc');
applyAction(table, 'p1 sm AsAc');
// --- PER-STREET STAT VERIFICATION ---
// P1 (Short stack) - All-in preflop for 200. Final stats are recorded on this street.
expect(Poker.Stats.forPlayerStreet(table, 0, 'preflop')).toMatchObject({
investments: 200,
winnings: 570, // Won main pot of 600, minus 30 rake.
profits: 370, // 570 winnings - 200 investment.
losses: 0,
balance: 370, // Net change in stack.
rake: 30,
stackBefore: 200,
stackAfter: 570, // 200 (start) - 200 (invest) + 570 (win)
});
// P2 (Side pot winner)
// Preflop: Called P1's all-in of 200 (invested 190 on top of 10 SB).
expect(Poker.Stats.forPlayerStreet(table, 1, 'preflop')).toMatchObject({
investments: 200,
winnings: 0,
profits: 0,
losses: 0,
balance: -200, // Balance reflects money invested on this street.
returns: 0,
rake: 0,
stackBefore: 1000,
stackAfter: 800,
});
// Flop: Bet 300 into the side pot.
expect(Poker.Stats.forPlayerStreet(table, 1, 'flop')).toMatchObject({
investments: 300,
winnings: 0,
profits: 0,
losses: 0,
balance: -300,
returns: 0,
rake: 0,
stackBefore: 800,
stackAfter: 500,
});
// Turn: Checked.
expect(Poker.Stats.forPlayerStreet(table, 1, 'turn')).toMatchObject({
investments: 0,
winnings: 0,
profits: 0,
losses: 0,
balance: 0,
returns: 0,
rake: 0,
stackBefore: 500,
stackAfter: 500,
});
// River: Final hand results are recorded on the last street of action.
expect(Poker.Stats.forPlayerStreet(table, 1, 'river')).toMatchObject({
investments: 0,
winnings: 570, // Won side pot of 600, minus 30 rake.
profits: 70, // 570 winnings - 500 total investment (200 pre, 300 flop).
losses: 0,
balance: 570, // Balance for this street is winnings - street_investment.
returns: 0,
rake: 30,
stackBefore: 500,
stackAfter: 1070, // 500 (start) + 570 (win)
});
// P3 (Loser)
// Preflop: Called P1's all-in of 200 (invested 180 on top of 20 BB).
expect(Poker.Stats.forPlayerStreet(table, 2, 'preflop')).toMatchObject({
investments: 200,
winnings: 0,
profits: 0,
losses: 0,
balance: -200,
returns: 0,
rake: 0,
stackBefore: 1000,
stackAfter: 800,
});
// Flop: Called P2's bet of 300.
expect(Poker.Stats.forPlayerStreet(table, 2, 'flop')).toMatchObject({
investments: 300,
winnings: 0,
profits: 0,
losses: 0,
balance: -300,
returns: 0,
rake: 0,
stackBefore: 800,
stackAfter: 500,
});
// Turn: Checked.
expect(Poker.Stats.forPlayerStreet(table, 2, 'turn')).toMatchObject({
investments: 0,
winnings: 0,
profits: 0,
losses: 0,
balance: 0,
returns: 0,
rake: 0,
stackBefore: 500,
stackAfter: 500,
});
// River: Final stats recorded. Lost both main and side pots.
expect(Poker.Stats.forPlayerStreet(table, 2, 'river')).toMatchObject({
investments: 0,
winnings: 0,
profits: 0,
losses: 500, // Lost total investment of 200 preflop + 300 flop.
balance: 0,
returns: 0,
rake: 0,
stackBefore: 500,
stackAfter: 500,
});
// --- AGGREGATED METRICS VERIFICATION ---
const aggregated = getPokerMetrics(table.stats, ['player'] as const);
// P1 Aggregated
expect(aggregated['P1_BTN_Short']).toMatchObject({
investments: 200,
winnings: 570,
profits: 370,
losses: 0,
balance: 370,
rake: 30,
});
// P2 Aggregated
expect(aggregated['P2_SB']).toMatchObject({
investments: 500, // 200 pre + 300 flop
winnings: 570,
profits: 70,
losses: 0,
balance: 70,
rake: 30,
});
// P3 Aggregated
expect(aggregated['P3_BB']).toMatchObject({
investments: 500, // 200 pre + 300 flop
winnings: 0,
profits: 0,
losses: 500,
balance: -500,
rake: 0,
});
// Total Aggregated
expect(aggregated.total).toMatchObject({
investments: 1200, // 200 + 500 + 500
winnings: 1140, // 570 + 570
profits: 440, // 370 + 70
losses: 500,
balance: -60, // 440 - 500
rake: 60,
});
});
it('should correctly track winnings with side pots from a short-stack all-in', () => {
const table = Poker.Game({
...sampleGame,
players: ['BTN', 'SB_Short', 'BB'],
blindsOrStraddles: [0, 10, 20],
startingStacks: [1000, 200, 1000],
});
// --- SETUP ---
applyAction(table, 'd dh p1 2h2c'); // BTN
applyAction(table, 'd dh p2 3h3c'); // SB (short)
applyAction(table, 'd dh p3 4h4c'); // BB
// --- PREFLOP ---
// BTN opens, SB shoves, BB calls, BTN calls. Main pot is 600.
applyAction(table, 'p1 cbr 60');
applyAction(table, 'p2 cbr 200');
applyAction(table, 'p3 cc');
applyAction(table, 'p1 cc');
// --- FLOP ---
// BTN and BB build a side pot of 600.
applyAction(table, 'd db 3d7s9c');
applyAction(table, 'p3 cc');
applyAction(table, 'p1 cbr 300');
applyAction(table, 'p3 cc');
// --- TURN & RIVER ---
// Checked down.
applyAction(table, 'd db 5h');
applyAction(table, 'p3 cc');
applyAction(table, 'p1 cc');
applyAction(table, 'd db Ah');
applyAction(table, 'p3 cc');
applyAction(table, 'p1 cc');
// --- SHOWDOWN ---
// On a board of 3d7s9c5hAh:
// P2 (SB) wins the main pot with a set of threes.
// P3 (BB) wins the side pot with a pair of fours, beating P1's pair of twos.
applyAction(table, 'p3 sm 4h4c');
applyAction(table, 'p1 sm 2h2c');
applyAction(table, 'p2 sm 3h3c');
// --- DETAILED PER-STREET VERIFICATION ---
// P1 (BTN) - Loser
expect(Poker.Stats.forPlayerStreet(table, 0, 'preflop')).toMatchObject({
investments: 200,
balance: -200,
stackBefore: 1000,
stackAfter: 800,
});
expect(Poker.Stats.forPlayerStreet(table, 0, 'flop')).toMatchObject({
investments: 300,
balance: -300,
stackBefore: 800,
stackAfter: 500,
});
expect(Poker.Stats.forPlayerStreet(table, 0, 'turn')).toMatchObject({
investments: 0,
balance: 0,
stackBefore: 500,
stackAfter: 500,
});
expect(Poker.Stats.forPlayerStreet(table, 0, 'river')).toMatchObject({
investments: 0,
balance: 0,
stackBefore: 500,
stackAfter: 500,
winnings: 0,
losses: 500,
profits: 0,
});
// P2 (SB_Short) - Main Pot Winner (All-in Preflop)
expect(Poker.Stats.forPlayerStreet(table, 1, 'preflop')).toMatchObject({
investments: 200,
stackBefore: 200,
stackAfter: 600,
winnings: 600,
profits: 400,
losses: 0,
balance: 400,
});
// Check that post-flop streets are empty for the all-in player
expect(Poker.Stats.forPlayerStreet(table, 1, 'flop')).toBeUndefined();
expect(Poker.Stats.forPlayerStreet(table, 1, 'turn')).toBeUndefined();
expect(Poker.Stats.forPlayerStreet(table, 1, 'river')).toBeUndefined();
// P3 (BB) - Side Pot Winner
expect(Poker.Stats.forPlayerStreet(table, 2, 'preflop')).toMatchObject({
investments: 200,
balance: -200,
stackBefore: 1000,
stackAfter: 800,
});
expect(Poker.Stats.forPlayerStreet(table, 2, 'flop')).toMatchObject({
investments: 300,
balance: -300,
stackBefore: 800,
stackAfter: 500,
});
expect(Poker.Stats.forPlayerStreet(table, 2, 'turn')).toMatchObject({
investments: 0,
balance: 0,
stackBefore: 500,
stackAfter: 500,
});
expect(Poker.Stats.forPlayerStreet(table, 2, 'river')).toMatchObject({
investments: 0,
stackBefore: 500,
stackAfter: 1100,
winnings: 600,
profits: 100,
losses: 0,
balance: 600,
});
// --- AGGREGATED METRICS VERIFICATION ---
const aggregated = getPokerMetrics(table.stats, ['player'] as const);
expect(aggregated['BTN']).toMatchObject({
investments: 500,
winnings: 0,
profits: 0,
losses: 500,
balance: -500,
});
expect(aggregated['SB_Short']).toMatchObject({
investments: 200,
winnings: 600,
profits: 400,
losses: 0,
balance: 400,
});
expect(aggregated['BB']).toMatchObject({
investments: 500,
winnings: 600,
profits: 100,
losses: 0,
balance: 100,
});
expect(aggregated.total).toMatchObject({
investments: 1200,
winnings: 1200,
profits: 500,
losses: 500,
balance: 0,
});
});
it('should correctly handle a split pot in a multiway all-in', () => {
const table = Poker.Game({
...sampleGame,
players: ['Alice', 'Bob', 'Carol'],
blindsOrStraddles: [0, 10, 20],
startingStacks: [1000, 1000, 1000],
});
// Deal hole cards
applyAction(table, 'd dh p1 AhKh');
applyAction(table, 'd dh p2 QhJh');
applyAction(table, 'd dh p3 2c3c');
// Preflop - P1 raises, P2 3-bets, P3 folds, P1 4-bets all-in, P2 calls.
applyAction(table, 'p1 cbr 100');
applyAction(table, 'p2 cbr 350');
applyAction(table, 'p3 f');
applyAction(table, 'p1 cbr 1000');
applyAction(table, 'p2 cc');
// Board and showdown
applyAction(table, 'd db 4c5c6d');
applyAction(table, 'd db 7h');
applyAction(table, 'd db 8h');
// --- SHOWDOWN ---
// On a board of 4c5c6d7h8h, both players have a 4-8 straight and play the board.
// This results in a split pot.
applyAction(table, 'p2 sm QhJh');
applyAction(table, 'p1 sm AhKh');
// --- DETAILED PER-STREET VERIFICATION ---
// All significant action occurred preflop. Final results are recorded there.
// P1 (Split Pot)
expect(Poker.Stats.forPlayerStreet(table, 0, 'preflop')).toMatchObject({
investments: 1000,
winnings: 1010, // Pot of 2020 is split
profits: 10, // 1010 winnings - 1000 investment
losses: 0,
balance: 10, // 1010 winnings - 1000 investment
returns: 0,
rake: 0,
stackBefore: 1000,
stackAfter: 1010,
});
// P2 (Split Pot)
expect(Poker.Stats.forPlayerStreet(table, 1, 'preflop')).toMatchObject({
investments: 1000,
winnings: 1010, // Pot of 2020 is split
profits: 10, // 1010 winnings - 1000 investment
losses: 0,
balance: 10, // 1010 winnings - 1000 investment
returns: 0,
rake: 0,
stackBefore: 1000,
stackAfter: 1010,
});
// P3 (Folded Preflop)
expect(Poker.Stats.forPlayerStreet(table, 2, 'preflop')).toMatchObject({
investments: 20, // Just the BB
winnings: 0,
profits: 0,
losses: 20,
balance: -20,
returns: 0,
rake: 0,
stackBefore: 1000,
stackAfter: 980,
});
// Post-flop streets should have no financial data for any player
for (const street of ['flop', 'turn', 'river'] as const) {
for (let i = 0; i < 3; i++) {
expect(Poker.Stats.forPlayerStreet(table, i, street)).toBeUndefined();
}
}
// --- AGGREGATED METRICS VERIFICATION ---
const aggregated = getPokerMetrics(table.stats, ['player'] as const);
expect(aggregated['Alice']).toMatchObject({
investments: 1000,
winnings: 1010,
profits: 10,
balance: 10,
});
expect(aggregated['Bob']).toMatchObject({
investments: 1000,
winnings: 1010,
profits: 10,
balance: 10,
});
expect(aggregated['Carol']).toMatchObject({
investments: 20,
losses: 20,
balance: -20,
});
expect(aggregated.total).toMatchObject({
investments: 2020,
winnings: 2020,
profits: 20,
losses: 20,
balance: 0,
});
});
it('should track finances correctly when one player decisively wins a multiway all-in', () => {
const table = Poker.Game({
...sampleGame,
players: ['Alice', 'Bob', 'Carol'],
blindsOrStraddles: [0, 10, 20],
startingStacks: [1000, 1000, 1000],
});
// Deal hole cards
applyAction(table, 'd dh p1 AhKh');
applyAction(table, 'd dh p2 QsJs'); // P2 has a different hand now
applyAction(table, 'd dh p3 2c3c');
// Preflop - P1 raises, P2 3-bets, P3 folds, P1 4-bets all-in, P2 calls.
applyAction(table, 'p1 cbr 100');
applyAction(table, 'p2 cbr 350');
applyAction(table, 'p3 f');
applyAction(table, 'p1 cbr 1000');
applyAction(table, 'p2 cc');
// Board and showdown
applyAction(table, 'd db Ac2d7h'); // Board is changed so P1 wins
applyAction(table, 'd db 8s');
applyAction(table, 'd db 9h');
// --- SHOWDOWN ---
// On a board of Ac2d7h8s9h, P1 wins with a pair of Aces. P2 has Queen-high.
applyAction(table, 'p2 sm QsJs');
applyAction(table, 'p1 sm AhKh');
// --- DETAILED PER-STREET VERIFICATION ---
// P1 (Winner)
expect(Poker.Stats.forPlayerStreet(table, 0, 'preflop')).toMatchObject({
investments: 1000,
winnings: 2020,
profits: 1020,
losses: 0,
balance: 1020, // 2020 winnings - 1000 investment
returns: 0,
rake: 0,
stackBefore: 1000,
stackAfter: 2020,
});
// P2 (Loser)
expect(Poker.Stats.forPlayerStreet(table, 1, 'preflop')).toMatchObject({
investments: 1000,
winnings: 0,
profits: 0,
losses: 1000,
balance: -1000,
returns: 0,
rake: 0,
stackBefore: 1000,
stackAfter: 0,
});
// P3 (Folded Preflop)
expect(Poker.Stats.forPlayerStreet(table, 2, 'preflop')).toMatchObject({
investments: 20,
losses: 20,
});
// --- AGGREGATED METRICS VERIFICATION ---
const aggregated = getPokerMetrics(table.stats, ['player'] as const);
expect(aggregated['Alice']).toMatchObject({
investments: 1000,
winnings: 2020,
profits: 1020,
balance: 1020,
});
expect(aggregated['Bob']).toMatchObject({
investments: 1000,
winnings: 0,
profits: 0,
losses: 1000,
balance: -1000,
});
expect(aggregated['Carol']).toMatchObject({
investments: 20,
losses: 20,
balance: -20,
});
expect(aggregated.total).toMatchObject({
investments: 2020,
winnings: 2020,
profits: 1020,
losses: 1020,
balance: 0,
});
});
});
describe('Standard Pot Scenarios', () => {
it('should correctly handle an uncalled bet being returned to a player', () => {
// SCENARIO: In a heads-up pot, P1 bets the river, and P2 folds.
// OUTCOME: P1 wins the pot, and their uncalled river bet is returned to their stack.
// VERIFY: The uncalled bet is correctly tracked in `returns`, and not counted towards net investment for profit calculation.
const table = Poker.Game({
...sampleGame,
players: ['P1_BTN', 'P2_BB'],
blindsOrStraddles: [10, 20],
startingStacks: [1000, 1000],
});
// --- PREFLOP --- P1 raises to 60, P2 calls. Pot: 120
applyAction(table, 'd dh p1 AhKh');
applyAction(table, 'd dh p2 QsJs');
applyAction(table, 'p1 cbr 60');
applyAction(table, 'p2 cc');
// --- FLOP --- P1 bets 100, P2 calls. Pot: 320
applyAction(table, 'd db 2h7d8c');
applyAction(table, 'p2 cc');
applyAction(table, 'p1 cbr 100'); // Bet is 100 for this street
applyAction(table, 'p2 cc');
// --- TURN --- P1 bets 200, P2 calls. Pot: 720
applyAction(table, 'd db 9s');
applyAction(table, 'p2 cc');
applyAction(table, 'p1 cbr 200'); // Bet is 200 for this street
applyAction(table, 'p2 cc');
// --- RIVER --- P1 bets 500, P2 folds.
applyAction(table, 'd db Ts');
applyAction(table, 'p2 cc');
applyAction(table, 'p1 cbr 500'); // Bet is 500 for this street
// --- FINAL ACTION ---
// P2 folds, so P1 wins the pot. P1's uncalled 500 river bet is returned.
applyAction(table, 'p2 f'); // P2 folds
// --- DETAILED PER-STREET VERIFICATION ---
// P1 (Winner)
expect(Poker.Stats.forPlayerStreet(table, 0, 'preflop')).toMatchObject({
investments: 60,
balance: -60,
returns: 0,
winnings: 0,
profits: 0,
losses: 0,
stackBefore: 1000,
stackAfter: 940,
});
expect(Poker.Stats.forPlayerStreet(table, 0, 'flop')).toMatchObject({
investments: 100,
balance: -100,
returns: 0,
winnings: 0,
profits: 0,
losses: 0,
stackBefore: 940,
stackAfter: 840,
});
expect(Poker.Stats.forPlayerStreet(table, 0, 'turn')).toMatchObject({
investments: 200,
balance: -200,
returns: 0,
winnings: 0,
profits: 0,
losses: 0,
stackBefore: 840,
stackAfter: 640,
});
expect(Poker.Stats.forPlayerStreet(table, 0, 'river')).toMatchObject({
investments: 500,
returns: 500, // The uncalled 500 bet is returned.
winnings: 720, // Pot was 360 from each player = 720.
profits: 360, // 720 winnings - 360 matched investment.
balance: 720, // On this street: 720 winnings - (500 inv - 500 ret)
losses: 0,
stackBefore: 640,
stackAfter: 1360, // 640 (current) - 500 (bet) + 500 (return) + 720 (win)
});
// P2 (Loser)
expect(Poker.Stats.forPlayerStreet(table, 1, 'preflop')).toMatchObject({
investments: 60,
balance: -60,
returns: 0,
winnings: 0,
profits: 0,
losses: 0,
stackBefore: 1000,
stackAfter: 940,
});
expect(Poker.Stats.forPlayerStreet(table, 1, 'flop')).toMatchObject({
investments: 100,
balance: -100,
returns: 0,
winnings: 0,
profits: 0,
losses: 0,
stackBefore: 940,
stackAfter: 840,
});
expect(Poker.Stats.forPlayerStreet(table, 1, 'turn')).toMatchObject({
investments: 200,
balance: -200,
returns: 0,
winnings: 0,
profits: 0,
losses: 0,
stackBefore: 840,
stackAfter: 640,
});
expect(Poker.Stats.forPlayerStreet(table, 1, 'river')).toMatchObject({
investments: 0,
returns: 0,
winnings: 0,
profits: 0,
losses: 360, // Lost total matched investment (60+100+200).
balance: 0, // 0 winnings - 0 investments on this street
stackBefore: 640,
stackAfter: 640,
});
// --- AGGREGATED STATS ---
const aggregated = getPokerMetrics(table.stats, ['player'] as const);
expect(aggregated['P1_BTN']).toMatchObject({
investments: 860, // 60 + 100 + 200 + 500
returns: 500,
winnings: 720,
profits: 360, // 720 winnings - (860 gross inv - 500 returns)
balance: 360,
losses: 0,
});
expect(aggregated['P2_BB']).toMatchObject({
investments: 360, // 60 + 100 + 200
returns: 0,
winnings: 0,
profits: 0,
losses: 360,
balance: -360,
});
expect(aggregated.total).toMatchObject({
investments: 1220,
returns: 500,
winnings: 720,
profits: 360,
losses: 360,
balance: 0,
});
});
it('should correctly calculate profit for a player who gets a walk in the big blind', () => {
// SCENARIO: All players fold to the big blind pre-flop.
// OUTCOME: The big blind wins the small blind and any antes.
// VERIFY: The BB's `profits` equal the small blind amount plus antes. Their own blind is returned and not counted as a loss.
// FIXME: Currently the walk is not implemented correctly, as its still subject to the side pot calculation. Values are correct now, but they wont be if antes are involved.
const table = Poker.Game({
...sampleGame,
players: ['BTN', 'SB', 'BB'],
blindsOrStraddles: [0, 10, 20],
startingStacks: [1000, 1000, 1000],
});
// --- DEAL CARDS ---
applyAction(table, 'd dh p1 2h7c');
applyAction(table, 'd dh p2 8s3d');
applyAction(table, 'd dh p3 AcKc');
// --- ACTION ---
applyAction(table, 'p1 f'); // BTN folds
applyAction(table, 'p2 f'); // SB folds
// --- VERIFICATION ---
// The hand ends immediately. BB wins the SB's 10 chips.
const bbStats = Poker.Stats.forPlayerStreet(table, 2, 'preflop');
expect(bbStats).toMatchObject({
investments: 20,
winnings: 20, // Own 10 back + SB's 10, extra own 10 is returned
returns: 10,
profits: 10,
losses: 0,
balance: 10, // 30 winnings - 20 investment
stackBefore: 1000,
stackAfter: 1010,
});
const sbStats = Poker.Stats.forPlayerStreet(table, 1, 'preflop');
expect(sbStats).toMatchObject({
investments: 10,
returns: 0,
losses: 10,
winnings: 0,
profits: 0,
balance: -10,
});
// --- AGGREGATED METRICS VERIFICATION ---
const aggregated = getPokerMetrics(table.stats, ['player'] as const);
expect(aggregated.total).toMatchObject({
investments: 30,
returns: 10,
winnings: 20,
profits: 10,
losses: 10,
balance: 0,
});
});
it('should accurately account for rake and respect the rake cap', () => {
// SCENARIO: Two players go to a large showdown pot.
// VERIFY: Rake is calculated correctly based on the percentage, and the final rake taken does not exceed the cap.
const table = Poker.Game({
...sampleGame,
players: ['P1', 'P2'],
blindsOrStraddles: [10, 20],
startingStacks: [5000, 5000],
rakePercentage: 0.05, // 5% rake
rakeCap: 100, // Capped at 100
});
// --- SETUP ---
applyAction(table, 'd dh p1 AsAc');
applyAction(table, 'd dh p2 KsKc');
// --- ACTION ---
// Players go all-in preflop, creating a pot of 10000.
// 5% of 10000 is 500, which is > the 100 cap.
applyAction(table, 'p1 cbr 5000');
applyAction(table, 'p2 cc');
// Board runout
applyAction(table, 'd db 2h7d8c');
applyAction(table, 'd db 9s');
applyAction(table, 'd db Ts');
// --- SHOWDOWN ---
applyAction(table, 'p2 sm KsKc');
applyAction(table, 'p1 sm AsAc');
// --- VERIFICATION ---
const p1Stats = Poker.Stats.forPlayerStreet(table, 0, 'preflop');
expect(p1Stats).toMatchObject({
winnings: 9900, // 10000 pot - 100 capped rake
profits: 4900, // 9900 winnings - 5000 investment
rake: 100, // Rake should be capped at 100
});
const p2Stats = Poker.Stats.forPlayerStreet(table, 1, 'preflop');
expect(p2Stats).toMatchObject({
losses: 5000,
rake: 0,
});
const aggregated = getPokerMetrics(table.stats, ['player'] as const);
expect(aggregated.total).toMatchObject({
investments: 10000,
winnings: 9900,
profits: 4900,
losses: 5000,
balance: -100, // Total balance should be the negative of the rake taken
rake: 100,
});
});
it('should track finances when a player is all-in for less than the minimum bet', () => {
// SCENARIO: P3 is all-in for 40, which is less than the 50 BB.
// INPUT: 3 players, BB=50, SB=25, P3 has only 40 chips (short stack)
// EXPECTED: Main pot 120 (40*3), P1 and P2 create side pot. P3's losses capped at 40.
const table = Poker.Game({
...sampleGame,
players: ['P1_BTN', 'P2_SB', 'P3_BB_Short'],
blindsOrStraddles: [0, 25, 50],
startingStacks: [1000, 1000, 40], // P3 is short
minBet: 50, // BB = 50, SB = 25
});
// --- SETUP ---
applyAction(table, 'd dh p1 AsAc');
applyAction(table, 'd dh p2 KsKc');
applyAction(table, 'd dh p3 QsQc');
// --- PREFLOP ---
// P1 raises to 100, P2 calls. P3 is already all-in.
applyAction(table, 'p1 cbr 100');
applyAction(table, 'p2 cc');
// --- FLOP, TURN, RIVER ---
// P1 and P2 play for a side pot.
applyAction(table, 'd db 2h7d8c');
applyAction(table, 'p2 cc');
applyAction(table, 'p1 cbr 200');
applyAction(table, 'p2 cc');
applyAction(table, 'd db 9s');
applyAction(table, 'p2 cc');
applyAction(table, 'p1 cc');
applyAction(table, 'd db Ts');
applyAction(table, 'p2 cc');
applyAction(table, 'p1 cc');
// --- SHOWDOWN ---
// P1 wins both pots.
applyAction(table, 'p2 sm KsKc');
applyAction(table, 'p1 sm AsAc');
applyAction(table, 'p3 sm QsQc');
// --- VERIFICATION ---
// P3 (Short Stack)
expect(Poker.Stats.forPlayerStreet(table, 2, 'preflop')).toMatchObject({
investments: 40,
losses: 40,
balance: -40,
winnings: 0,
profits: 0,
stackBefore: 40,
stackAfter: 0,
});
// P1 (Winner)
const p1RiverStats = Poker.Stats.forPlayerStreet(table, 0, 'river');
expect(p1RiverStats?.winnings).toBe(640); // Main pot 120 + Side pot 520
expect(p1RiverStats?.profits).toBe(340); // 640 winnings - 300 total investment
// P2 (Loser)
const p2RiverStats = Poker.Stats.forPlayerStreet(table, 1, 'river');
expect(p2RiverStats?.losses).toBe(300);
// --- AGGREGATED ---
const aggregated = getPokerMetrics(table.stats, ['player'] as const);
expect(aggregated.total.balance).toBe(0);
expect(aggregated['P1_BTN'].balance).toBe(340);
expect(aggregated['P2_SB'].balance).toBe(-300);
expect(aggregated['P3_BB_Short'].balance).toBe(-40);
});
});
});