testsAndMisc-archive/poker-stakes/src/engine/handFlow.ts
Krzysztof kuhy Rudnicki caee1d7067 Split poker-stakes' four oversized files under the 250-line cap
useGame.test.tsx (494) splits by describe-block into reducer/session/debt
files; handEval.test.ts (269) splits at the evaluate7 boundary; useGame.ts
(268) sheds its state and reducer into gameState.ts; handFlow.ts (252) sheds
its betting-state helpers into handFlowBetting.ts.

useGame.ts and handFlow.ts re-export what they moved, so no importer changed.

The shared test helpers live in src/test/ rather than beside the source they
exercise. That is load-bearing: vite.config.ts already excludes src/test/ from
coverage, so no new exclusion entry was needed. Putting them next to the source
first dropped coverage to 98.57% because v8 counted the fixtures' unused
branches as production code -- fixed by moving them, not by excluding them.

Verified: 220 tests still pass, coverage back to 100% on statements, branches,
functions and lines, and npm run build succeeds. poker-stakes violations 4 -> 0;
repo total 196 -> 192.

invariants.test.ts hit its 5s timeout once mid-work. It passes standalone in
2.56s and on re-run, so it is a pre-existing contention flake under parallel
coverage load rather than a regression here -- noted, not papered over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 15:26:18 +02:00

183 lines
6.4 KiB
TypeScript

import type { Action, BettingState } from './bettingRound'
import { applyAction, legalActions } from './bettingRound'
import { createDealCursor, dealCards, shuffleDeck } from './deck'
import { buildDeck } from './deck'
import { compareHandScore, evaluate7 } from './handEval'
import type { HandScore } from './handEval'
import type { Rng } from './rng'
import type { Card, Seat } from './types'
export interface HeadsUpStacks {
player: number
opponent: number
}
export interface HandSetup {
buttonSeat: Seat // heads-up: button posts the small blind and acts first preflop
bigBlind: number
smallBlind: number
stacks: HeadsUpStacks
}
export interface HandResult {
winner: Seat | 'split'
potAwarded: number
wentToShowdown: boolean
finalStacks: HeadsUpStacks
loserSeat: Seat | null
board: readonly Card[]
/** Both seats' hole cards, but ONLY at a real showdown — a folder's cards are never revealed. */
revealedHoleCards: Record<Seat, readonly Card[]> | null
}
import {
initialBettingState,
other,
potFromBetting,
returnUncalledExcess,
stacksFromBetting,
STREET_ORDER,
} from './handFlowBetting'
export { potFromBetting } from './handFlowBetting'
export interface HandFlowCallbacks {
/**
* Called whenever it's a seat's turn; must return one of legalActions(betting). Receives ONLY
* the acting seat's own hole cards — never the opponent's — so a decision function can never
* see cards it shouldn't, by construction rather than by discipline.
*/
decide: (betting: BettingState, seat: Seat, board: readonly Card[], ownHoleCards: readonly Card[]) => Action
}
/** One paused decision point within a hand: who must act, and on what they may see. */
export interface HandStep {
betting: BettingState
seat: Seat
board: readonly Card[]
ownHoleCards: readonly Card[]
}
/**
* Runs a single hand end-to-end as a generator: posts blinds, deals hole cards, and pauses at
* every decision point (`yield`ing a HandStep) instead of calling a callback directly. The caller
* resumes it by passing the chosen Action into `.next(action)`. This lets a UI drive the hand one
* decision at a time (suspend on the player's turn, wait for a click) without making the engine
* itself async — it's suspension, not IO.
*/
export function* playHandSteps(rng: Rng, setup: HandSetup): Generator<HandStep, HandResult, Action> {
const deck = shuffleDeck(rng, buildDeck())
let cursor = createDealCursor(deck)
const dealtPlayer = dealCards(cursor, 2)
cursor = dealtPlayer.cursor
const dealtOpponent = dealCards(cursor, 2)
cursor = dealtOpponent.cursor
const holeCards: Record<Seat, readonly Card[]> = {
player: dealtPlayer.cards,
opponent: dealtOpponent.cards,
}
let board: Card[] = []
let betting = initialBettingState(setup, 'preflop', 0, setup.stacks)
for (const street of STREET_ORDER) {
if (street !== 'preflop') {
const settled = returnUncalledExcess(betting)
const communityCount = street === 'flop' ? 3 : 1
const dealt = dealCards(cursor, communityCount)
cursor = dealt.cursor
board = [...board, ...dealt.cards]
betting = initialBettingState(setup, street, potFromBetting(settled), stacksFromBetting(settled))
}
while (legalActions(betting).length > 0) {
const action = yield { betting, seat: betting.toAct, board, ownHoleCards: holeCards[betting.toAct] }
betting = applyAction(betting, action)
}
if (betting.seats.player.folded || betting.seats.opponent.folded) {
return resolveByFold(betting, board)
}
}
return resolveByShowdown(betting, board, holeCards)
}
/**
* Runs a single hand end-to-end: posts blinds, deals hole cards, runs betting rounds interleaved
* with community-card deals, and resolves the pot at fold or showdown. Thin synchronous driver
* over `playHandSteps` for callers (tests, the PC-only path) that don't need to suspend mid-hand.
*/
export const playHand = (rng: Rng, setup: HandSetup, callbacks: HandFlowCallbacks): HandResult => {
const gen = playHandSteps(rng, setup)
let step = gen.next()
while (!step.done) {
const { betting, seat, board, ownHoleCards } = step.value
step = gen.next(callbacks.decide(betting, seat, board, ownHoleCards))
}
return step.value
}
const resolveByFold = (betting: BettingState, board: readonly Card[]): HandResult => {
const settled = returnUncalledExcess(betting)
const pot = potFromBetting(settled)
const loserSeat: Seat = settled.seats.player.folded ? 'player' : 'opponent'
const winnerSeat = other(loserSeat)
const finalStacks: HeadsUpStacks = {
player: settled.seats.player.stack + (winnerSeat === 'player' ? pot : 0),
opponent: settled.seats.opponent.stack + (winnerSeat === 'opponent' ? pot : 0),
}
// A folder's cards are never revealed, even to show what they held.
return { winner: winnerSeat, potAwarded: pot, wentToShowdown: false, finalStacks, loserSeat, board, revealedHoleCards: null }
}
const resolveByShowdown = (
betting: BettingState,
board: readonly Card[],
holeCards: Record<Seat, readonly Card[]>,
): HandResult => {
const settled = returnUncalledExcess(betting)
const pot = potFromBetting(settled)
const playerCards = [...holeCards.player, ...board] as [Card, Card, Card, Card, Card, Card, Card]
const opponentCards = [...holeCards.opponent, ...board] as [Card, Card, Card, Card, Card, Card, Card]
const playerScore: HandScore = evaluate7(playerCards)
const opponentScore: HandScore = evaluate7(opponentCards)
const cmp = compareHandScore(playerScore, opponentScore)
const stacks = stacksFromBetting(settled)
if (cmp === 0) {
// Split pot: heads-up play always returns uncalled excess before showdown, so both seats'
// committed amounts are equal and the pot is provably even — no odd-chip remainder rule needed.
const half = pot / 2
return {
winner: 'split',
potAwarded: pot,
wentToShowdown: true,
finalStacks: {
player: stacks.player + half,
opponent: stacks.opponent + half,
},
loserSeat: null,
board,
revealedHoleCards: holeCards,
}
}
const winnerSeat: Seat = cmp > 0 ? 'player' : 'opponent'
const loserSeat = other(winnerSeat)
return {
winner: winnerSeat,
potAwarded: pot,
wentToShowdown: true,
finalStacks: {
player: stacks.player + (winnerSeat === 'player' ? pot : 0),
opponent: stacks.opponent + (winnerSeat === 'opponent' ? pot : 0),
},
loserSeat,
board,
revealedHoleCards: holeCards,
}
}