From caee1d70674c197104e152f34740ad503396d2a5 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 15:26:18 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../src/engine/handEval.compare.test.ts | 67 +++ ...andEval.test.ts => handEval.rank5.test.ts} | 101 +--- poker-stakes/src/engine/handFlow.ts | 92 +--- poker-stakes/src/engine/handFlowBetting.ts | 89 ++++ poker-stakes/src/test/handEvalFixtures.ts | 37 ++ poker-stakes/src/test/useGameFixtures.tsx | 65 +++ poker-stakes/src/ui/gameState.ts | 63 +++ poker-stakes/src/ui/useGame.debt.test.tsx | 156 ++++++ poker-stakes/src/ui/useGame.reducer.test.tsx | 75 +++ poker-stakes/src/ui/useGame.session.test.tsx | 219 ++++++++ poker-stakes/src/ui/useGame.test.tsx | 494 ------------------ poker-stakes/src/ui/useGame.ts | 64 +-- 12 files changed, 789 insertions(+), 733 deletions(-) create mode 100644 poker-stakes/src/engine/handEval.compare.test.ts rename poker-stakes/src/engine/{handEval.test.ts => handEval.rank5.test.ts} (65%) create mode 100644 poker-stakes/src/engine/handFlowBetting.ts create mode 100644 poker-stakes/src/test/handEvalFixtures.ts create mode 100644 poker-stakes/src/test/useGameFixtures.tsx create mode 100644 poker-stakes/src/ui/gameState.ts create mode 100644 poker-stakes/src/ui/useGame.debt.test.tsx create mode 100644 poker-stakes/src/ui/useGame.reducer.test.tsx create mode 100644 poker-stakes/src/ui/useGame.session.test.tsx delete mode 100644 poker-stakes/src/ui/useGame.test.tsx diff --git a/poker-stakes/src/engine/handEval.compare.test.ts b/poker-stakes/src/engine/handEval.compare.test.ts new file mode 100644 index 0000000..5580580 --- /dev/null +++ b/poker-stakes/src/engine/handEval.compare.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { compareHandScore, evaluate7, rankLabel } from './handEval' +import type { HandScore } from './handEval' +import { card, seven } from '../test/handEvalFixtures' + +describe('evaluate7', () => { + it('picks the best 5 of 7 when the best hand uses only some hole+board cards', () => { + const hand = seven([ + card(14, 'clubs'), + card(14, 'diamonds'), + card(14, 'hearts'), + card(14, 'spades'), + card(2, 'clubs'), + card(3, 'diamonds'), + card(4, 'hearts'), + ]) + const score = evaluate7(hand) + expect(score.category).toBe('quads') + }) + + it('matches the best rank5 result across all 21 combinations', () => { + const cards = seven([ + card(10, 'clubs'), + card(11, 'clubs'), + card(12, 'clubs'), + card(13, 'clubs'), + card(14, 'clubs'), + card(2, 'hearts'), + card(2, 'diamonds'), + ]) + const score = evaluate7(cards) + expect(score.category).toBe('straightFlush') + expect(score.tiebreakers).toEqual([14]) + }) +}) + +describe('compareHandScore category comparison', () => { + it('ranks a lower category below a higher one in both directions', () => { + const high: HandScore = { category: 'quads', tiebreakers: [2] } + const low: HandScore = { category: 'highCard', tiebreakers: [14] } + expect(compareHandScore(high, low)).toBe(1) + expect(compareHandScore(low, high)).toBe(-1) + }) +}) + +describe('compareHandScore tiebreaker padding', () => { + it('treats a missing tiebreaker slot as lower than a present one', () => { + const a: HandScore = { category: 'highCard', tiebreakers: [14, 10] } + const b: HandScore = { category: 'highCard', tiebreakers: [14] } + expect(compareHandScore(a, b)).toBe(1) + expect(compareHandScore(b, a)).toBe(-1) + }) +}) + +describe('rankLabel', () => { + it('labels face cards and ace', () => { + expect(rankLabel(14)).toBe('A') + expect(rankLabel(13)).toBe('K') + expect(rankLabel(12)).toBe('Q') + expect(rankLabel(11)).toBe('J') + }) + + it('labels number cards as their numeral', () => { + expect(rankLabel(10)).toBe('10') + expect(rankLabel(2)).toBe('2') + }) +}) diff --git a/poker-stakes/src/engine/handEval.test.ts b/poker-stakes/src/engine/handEval.rank5.test.ts similarity index 65% rename from poker-stakes/src/engine/handEval.test.ts rename to poker-stakes/src/engine/handEval.rank5.test.ts index ae0d208..4216a26 100644 --- a/poker-stakes/src/engine/handEval.test.ts +++ b/poker-stakes/src/engine/handEval.rank5.test.ts @@ -1,41 +1,9 @@ import { describe, expect, it } from 'vitest' import { buildDeck } from './deck' -import { compareHandScore, evaluate7, rank5, rankLabel } from './handEval' -import type { HandCategory, HandScore } from './handEval' +import { compareHandScore, rank5 } from './handEval' +import type { HandCategory } from './handEval' import type { Card } from './types' - -const card = (rank: Card['rank'], suit: Card['suit']): Card => ({ rank, suit }) - -const five = (cards: readonly Card[]): [Card, Card, Card, Card, Card] => { - if (cards.length !== 5) { - throw new Error('five: expected exactly 5 cards') - } - const [a, b, c, d, e] = cards - return [a, b, c, d, e] as [Card, Card, Card, Card, Card] -} - -const seven = (cards: readonly Card[]): [Card, Card, Card, Card, Card, Card, Card] => { - if (cards.length !== 7) { - throw new Error('seven: expected exactly 7 cards') - } - const [a, b, c, d, e, f, g] = cards - return [a, b, c, d, e, f, g] as [Card, Card, Card, Card, Card, Card, Card] -} - -/** Generates all C(52,5) 5-card combinations from a 52-card deck as index tuples. */ -function* combinations5(length: number): Generator { - for (let a = 0; a < length; a += 1) { - for (let b = a + 1; b < length; b += 1) { - for (let c = b + 1; c < length; c += 1) { - for (let d = c + 1; d < length; d += 1) { - for (let e = d + 1; e < length; e += 1) { - yield [a, b, c, d, e] - } - } - } - } - } -} +import { card, combinations5, five } from '../test/handEvalFixtures' describe('rank5 exhaustive classification', () => { it('classifies every 5-card hand out of 52 into the correct standard-count category', () => { @@ -204,66 +172,3 @@ describe('rank5 named fixtures', () => { expect(compareHandScore(a, b)).toBe(0) }) }) - -describe('evaluate7', () => { - it('picks the best 5 of 7 when the best hand uses only some hole+board cards', () => { - const hand = seven([ - card(14, 'clubs'), - card(14, 'diamonds'), - card(14, 'hearts'), - card(14, 'spades'), - card(2, 'clubs'), - card(3, 'diamonds'), - card(4, 'hearts'), - ]) - const score = evaluate7(hand) - expect(score.category).toBe('quads') - }) - - it('matches the best rank5 result across all 21 combinations', () => { - const cards = seven([ - card(10, 'clubs'), - card(11, 'clubs'), - card(12, 'clubs'), - card(13, 'clubs'), - card(14, 'clubs'), - card(2, 'hearts'), - card(2, 'diamonds'), - ]) - const score = evaluate7(cards) - expect(score.category).toBe('straightFlush') - expect(score.tiebreakers).toEqual([14]) - }) -}) - -describe('compareHandScore category comparison', () => { - it('ranks a lower category below a higher one in both directions', () => { - const high: HandScore = { category: 'quads', tiebreakers: [2] } - const low: HandScore = { category: 'highCard', tiebreakers: [14] } - expect(compareHandScore(high, low)).toBe(1) - expect(compareHandScore(low, high)).toBe(-1) - }) -}) - -describe('compareHandScore tiebreaker padding', () => { - it('treats a missing tiebreaker slot as lower than a present one', () => { - const a: HandScore = { category: 'highCard', tiebreakers: [14, 10] } - const b: HandScore = { category: 'highCard', tiebreakers: [14] } - expect(compareHandScore(a, b)).toBe(1) - expect(compareHandScore(b, a)).toBe(-1) - }) -}) - -describe('rankLabel', () => { - it('labels face cards and ace', () => { - expect(rankLabel(14)).toBe('A') - expect(rankLabel(13)).toBe('K') - expect(rankLabel(12)).toBe('Q') - expect(rankLabel(11)).toBe('J') - }) - - it('labels number cards as their numeral', () => { - expect(rankLabel(10)).toBe('10') - expect(rankLabel(2)).toBe('2') - }) -}) diff --git a/poker-stakes/src/engine/handFlow.ts b/poker-stakes/src/engine/handFlow.ts index 8f9a9a2..0b977ff 100644 --- a/poker-stakes/src/engine/handFlow.ts +++ b/poker-stakes/src/engine/handFlow.ts @@ -1,11 +1,11 @@ -import type { Action, BettingState, SeatState } from './bettingRound' +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, Street } from './types' +import type { Card, Seat } from './types' export interface HeadsUpStacks { player: number @@ -30,87 +30,17 @@ export interface HandResult { revealedHoleCards: Record | null } -const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player') +import { + initialBettingState, + other, + potFromBetting, + returnUncalledExcess, + stacksFromBetting, + STREET_ORDER, +} from './handFlowBetting' -const STREET_ORDER: readonly Street[] = ['preflop', 'flop', 'turn', 'river'] +export { potFromBetting } from './handFlowBetting' -const initialBettingState = (setup: HandSetup, street: Street, pot: number, stacks: HeadsUpStacks): BettingState => { - const nonButton = other(setup.buttonSeat) - // A seat already at 0 chips (all-in from an earlier street's blind/bet) stays all-in on every - // later street — a fresh `allIn: false` here would wrongly offer fold/check on a 0 stack. - const buttonSeat: SeatState = { - stack: stacks[setup.buttonSeat], - committed: 0, - hasActed: false, - folded: false, - allIn: stacks[setup.buttonSeat] === 0, - } - const nonButtonSeat: SeatState = { - stack: stacks[nonButton], - committed: 0, - hasActed: false, - folded: false, - allIn: stacks[nonButton] === 0, - } - - if (street === 'preflop') { - // Heads-up: button posts small blind and acts FIRST preflop. - const buttonPosted: SeatState = { ...buttonSeat, ...postBlind(buttonSeat, setup.smallBlind) } - const nonButtonPosted: SeatState = { ...nonButtonSeat, ...postBlind(nonButtonSeat, setup.bigBlind) } - return { - street, - pot, - toAct: setup.buttonSeat, - seats: { [setup.buttonSeat]: buttonPosted, [nonButton]: nonButtonPosted } as Record, - betToCall: nonButtonPosted.committed, - minRaiseSize: setup.bigBlind, - roundOver: false, - } - } - - // Postflop: non-button (big blind) acts first. - return { - street, - pot, - toAct: nonButton, - seats: { [setup.buttonSeat]: buttonSeat, [nonButton]: nonButtonSeat } as Record, - betToCall: 0, - minRaiseSize: setup.bigBlind, - roundOver: false, - } -} - -const postBlind = (seatState: SeatState, amount: number): SeatState => { - const spend = Math.min(amount, seatState.stack) - return { stack: seatState.stack - spend, committed: spend, hasActed: false, folded: false, allIn: seatState.stack - spend === 0 } -} - -/** Returns the uncalled excess of the seat with the higher committed amount, and the state with it returned. */ -const returnUncalledExcess = (betting: BettingState): BettingState => { - const { player, opponent } = betting.seats - if (player.committed === opponent.committed) { - return betting - } - const [higher, higherSeat, lowerCommitted] = - player.committed > opponent.committed ? [player, 'player' as Seat, opponent.committed] : [opponent, 'opponent' as Seat, player.committed] - const excess = higher.committed - lowerCommitted - return { - ...betting, - seats: { - ...betting.seats, - [higherSeat]: { ...higher, committed: higher.committed - excess, stack: higher.stack + excess }, - }, - } -} - -/** The live pot, including both seats' amounts committed on the current street (not yet swept into `betting.pot`). */ -export const potFromBetting = (betting: BettingState): number => - betting.pot + betting.seats.player.committed + betting.seats.opponent.committed - -const stacksFromBetting = (betting: BettingState): HeadsUpStacks => ({ - player: betting.seats.player.stack, - opponent: betting.seats.opponent.stack, -}) export interface HandFlowCallbacks { /** diff --git a/poker-stakes/src/engine/handFlowBetting.ts b/poker-stakes/src/engine/handFlowBetting.ts new file mode 100644 index 0000000..211f037 --- /dev/null +++ b/poker-stakes/src/engine/handFlowBetting.ts @@ -0,0 +1,89 @@ +import type { BettingState, SeatState } from './bettingRound' +import type { Seat, Street } from './types' +import type { HandSetup, HeadsUpStacks } from './handFlow' + +/** + * Betting-state helpers for handFlow, split out to stay under the 250-line cap. + */ + +export const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player') + +export const STREET_ORDER: readonly Street[] = ['preflop', 'flop', 'turn', 'river'] + +export const initialBettingState = (setup: HandSetup, street: Street, pot: number, stacks: HeadsUpStacks): BettingState => { + const nonButton = other(setup.buttonSeat) + // A seat already at 0 chips (all-in from an earlier street's blind/bet) stays all-in on every + // later street — a fresh `allIn: false` here would wrongly offer fold/check on a 0 stack. + const buttonSeat: SeatState = { + stack: stacks[setup.buttonSeat], + committed: 0, + hasActed: false, + folded: false, + allIn: stacks[setup.buttonSeat] === 0, + } + const nonButtonSeat: SeatState = { + stack: stacks[nonButton], + committed: 0, + hasActed: false, + folded: false, + allIn: stacks[nonButton] === 0, + } + + if (street === 'preflop') { + // Heads-up: button posts small blind and acts FIRST preflop. + const buttonPosted: SeatState = { ...buttonSeat, ...postBlind(buttonSeat, setup.smallBlind) } + const nonButtonPosted: SeatState = { ...nonButtonSeat, ...postBlind(nonButtonSeat, setup.bigBlind) } + return { + street, + pot, + toAct: setup.buttonSeat, + seats: { [setup.buttonSeat]: buttonPosted, [nonButton]: nonButtonPosted } as Record, + betToCall: nonButtonPosted.committed, + minRaiseSize: setup.bigBlind, + roundOver: false, + } + } + + // Postflop: non-button (big blind) acts first. + return { + street, + pot, + toAct: nonButton, + seats: { [setup.buttonSeat]: buttonSeat, [nonButton]: nonButtonSeat } as Record, + betToCall: 0, + minRaiseSize: setup.bigBlind, + roundOver: false, + } +} + +export const postBlind = (seatState: SeatState, amount: number): SeatState => { + const spend = Math.min(amount, seatState.stack) + return { stack: seatState.stack - spend, committed: spend, hasActed: false, folded: false, allIn: seatState.stack - spend === 0 } +} + +/** Returns the uncalled excess of the seat with the higher committed amount, and the state with it returned. */ +export const returnUncalledExcess = (betting: BettingState): BettingState => { + const { player, opponent } = betting.seats + if (player.committed === opponent.committed) { + return betting + } + const [higher, higherSeat, lowerCommitted] = + player.committed > opponent.committed ? [player, 'player' as Seat, opponent.committed] : [opponent, 'opponent' as Seat, player.committed] + const excess = higher.committed - lowerCommitted + return { + ...betting, + seats: { + ...betting.seats, + [higherSeat]: { ...higher, committed: higher.committed - excess, stack: higher.stack + excess }, + }, + } +} + +/** The live pot, including both seats' amounts committed on the current street (not yet swept into `betting.pot`). */ +export const potFromBetting = (betting: BettingState): number => + betting.pot + betting.seats.player.committed + betting.seats.opponent.committed + +export const stacksFromBetting = (betting: BettingState): HeadsUpStacks => ({ + player: betting.seats.player.stack, + opponent: betting.seats.opponent.stack, +}) diff --git a/poker-stakes/src/test/handEvalFixtures.ts b/poker-stakes/src/test/handEvalFixtures.ts new file mode 100644 index 0000000..ece72bc --- /dev/null +++ b/poker-stakes/src/test/handEvalFixtures.ts @@ -0,0 +1,37 @@ +import type { Card } from '../engine/types' + +/** Shared helpers for the handEval test files, split to stay under the 250-line cap. */ + +export const card = (rank: Card['rank'], suit: Card['suit']): Card => ({ rank, suit }) + + +export const five = (cards: readonly Card[]): [Card, Card, Card, Card, Card] => { + if (cards.length !== 5) { + throw new Error('five: expected exactly 5 cards') + } + const [a, b, c, d, e] = cards + return [a, b, c, d, e] as [Card, Card, Card, Card, Card] +} + +export const seven = (cards: readonly Card[]): [Card, Card, Card, Card, Card, Card, Card] => { + if (cards.length !== 7) { + throw new Error('seven: expected exactly 7 cards') + } + const [a, b, c, d, e, f, g] = cards + return [a, b, c, d, e, f, g] as [Card, Card, Card, Card, Card, Card, Card] +} + +/** Generates all C(52,5) 5-card combinations from a 52-card deck as index tuples. */ +export function* combinations5(length: number): Generator { + for (let a = 0; a < length; a += 1) { + for (let b = a + 1; b < length; b += 1) { + for (let c = b + 1; c < length; c += 1) { + for (let d = c + 1; d < length; d += 1) { + for (let e = d + 1; e < length; e += 1) { + yield [a, b, c, d, e] + } + } + } + } + } +} diff --git a/poker-stakes/src/test/useGameFixtures.tsx b/poker-stakes/src/test/useGameFixtures.tsx new file mode 100644 index 0000000..0748c0d --- /dev/null +++ b/poker-stakes/src/test/useGameFixtures.tsx @@ -0,0 +1,65 @@ +import { StrictMode } from 'react' +import type { ReactElement, ReactNode } from 'react' +import type { SettledDebt } from '../stakes/types' +import type { GameState } from '../ui/useGame' + +/** + * Shared fixtures for the useGame test files. + * + * useGame.test.tsx was split by describe-block to stay under the 250-line cap; + * these helpers are the part all three files need. + */ + +export const debt = (overrides: Partial = {}): SettledDebt => ({ + id: 'd1', + taskTypeId: 'pushups', + owedUnits: 100, + reason: 'cashOut', + settledAt: 1, + paidUnits: 0, + ...overrides, +}) + +export const initialState: GameState = { + debtLoad: { status: 'ok', debts: [] }, + buyIn: null, + session: null, + handPhase: null, +} + +export const wrapper = ({ children }: { children: ReactNode }): ReactElement => {children} + +/** An in-memory Storage stand-in, so tests never touch real localStorage. */ +export const makeStorage = (): Storage => { + const store = new Map() + return { + getItem: (key: string): string | null => store.get(key) ?? null, + setItem: (key: string, value: string): void => { + store.set(key, value) + }, + removeItem: (key: string): void => { + store.delete(key) + }, + clear: (): void => { + store.clear() + }, + key: (): null => null, + get length(): number { + return store.size + }, + } +} + +/** Storage that throws on every read/write, for the fail-closed paths. */ +export const unreadableStorage = (): Storage => ({ + getItem: (): never => { + throw new Error('denied') + }, + setItem: (): never => { + throw new Error('denied') + }, + removeItem: (): undefined => undefined, + clear: (): undefined => undefined, + key: (): null => null, + length: 0, +}) diff --git a/poker-stakes/src/ui/gameState.ts b/poker-stakes/src/ui/gameState.ts new file mode 100644 index 0000000..a0e6905 --- /dev/null +++ b/poker-stakes/src/ui/gameState.ts @@ -0,0 +1,63 @@ +import type { HandResult, HandStep } from '../engine/handFlow' +import type { SessionState } from '../engine/session' +import type { DebtLoad } from '../stakes/storage' +import type { BuyIn, SettledDebt } from '../stakes/types' +/** + * Only meaningful while `session !== null`. `betweenHands` is a real, UI-visible pause point — no + * hand is in progress, `session.stacks` is trustworthy, and `cashOut` is only ever legal here. A + * hand result stays visible (`lastResult`) until the next hand actually starts, so HandLog has + * something to render. This is deliberately NOT auto-advanced past by `submitPlayerAction` — see + * `dealHand` below for why cash-out mid-hand would be a settlement exploit if it were. + */ +export type HandPhase = { kind: 'awaiting'; step: HandStep } | { kind: 'betweenHands'; lastResult: HandResult | null } + +export interface GameState { + debtLoad: DebtLoad + buyIn: BuyIn | null + session: SessionState | null + handPhase: HandPhase | null +} + +type GameAction = + | { type: 'buyIn'; buyIn: BuyIn; session: SessionState } + | { type: 'handPhaseChanged'; session: SessionState; handPhase: HandPhase } + | { type: 'cashedOut'; session: SessionState } + | { type: 'debtSettled'; debts: SettledDebt[] } + | { type: 'debtProgressLogged'; debts: SettledDebt[] } + +export const initialGameState: GameState = { + debtLoad: { status: 'ok', debts: [] }, + buyIn: null, + session: null, + handPhase: null, +} + +/** + * Pure state transitions. All randomness/ids/clock reads happen in the hook, never in here. + * `GameAction` is exhaustively handled — TS errors on a missing case instead of a silent + * fallthrough, which is why there is no `default` branch. + */ +export const gameReducer = (state: GameState, action: GameAction): GameState => { + switch (action.type) { + case 'buyIn': + return { ...state, buyIn: action.buyIn, session: action.session, handPhase: null } + case 'handPhaseChanged': + return { ...state, session: action.session, handPhase: action.handPhase } + case 'cashedOut': + return { ...state, session: action.session } + case 'debtSettled': + return { + ...state, + debtLoad: { status: 'ok', debts: action.debts }, + buyIn: null, + session: null, + handPhase: null, + } + case 'debtProgressLogged': + return { ...state, debtLoad: { status: 'ok', debts: action.debts } } + } +} + +/** An unpaid debt (owedUnits not yet fully paid) blocks starting a new session — a hard gate. */ +export const hasOutstandingDebt = (debtLoad: DebtLoad): boolean => + debtLoad.status === 'unreadable' || debtLoad.debts.some((d) => d.paidUnits < d.owedUnits) diff --git a/poker-stakes/src/ui/useGame.debt.test.tsx b/poker-stakes/src/ui/useGame.debt.test.tsx new file mode 100644 index 0000000..6fa4b7a --- /dev/null +++ b/poker-stakes/src/ui/useGame.debt.test.tsx @@ -0,0 +1,156 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { hasOutstandingDebt, useGame } from './useGame' +import { makeStorage, wrapper } from '../test/useGameFixtures' + +describe('useGame: debt settlement and persistence', () => { + it('a zero-player-decision hand that the all-in player survives lands in betweenHands, not settled', () => { + // Seed 1, 4-chip buy-in, always folding when a decision exists: by hand 3 (handsPlayed=2) the + // player is down to 1 chip and posts it as the small blind — all-in, zero decision points. + // The all-in player wins/ties that hand and ends above 0, so the session must survive into + // `betweenHands` rather than settling — the sibling branch to the bust case above. + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 4 }) + }) + let guard = 0 + while (result.current.state.session !== null && result.current.state.session.handsPlayed < 3 && guard < 10) { + const phase = result.current.state.handPhase + if (phase?.kind === 'betweenHands') { + act(() => { + result.current.dealHand() + }) + } else if (phase?.kind === 'awaiting') { + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + } else { + break + } + guard += 1 + } + expect(guard).toBeLessThan(10) + expect(result.current.state.session).not.toBeNull() + expect(result.current.state.session?.handsPlayed).toBe(3) + expect(result.current.state.handPhase?.kind).toBe('betweenHands') + expect(result.current.state.session?.stacks.player).toBeGreaterThan(0) + }) + + it('logDebtProgress with an unknown debt id leaves the debt list unchanged', () => { + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + act(() => { + result.current.cashOut() + }) + const before = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] + act(() => { + result.current.logDebtProgress('no-such-id', 10) + }) + const after = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] + expect(after).toEqual(before) + }) + + it('cashing out between hands (never played a hand to showdown) settles a debt equal to the full buy-in', () => { + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + expect(result.current.state.handPhase?.kind).toBe('betweenHands') + act(() => { + result.current.cashOut() + }) + expect(result.current.state.session).toBeNull() + expect(result.current.state.debtLoad.status).toBe('ok') + const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] + expect(debts).toHaveLength(1) + expect(debts[0]?.reason).toBe('cashOut') + }) + + it('an unpaid debt persists to storage and blocks a subsequent load', () => { + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + act(() => { + result.current.cashOut() + }) + const { result: second } = renderHook(() => useGame(storage, 2), { wrapper }) + expect(hasOutstandingDebt(second.current.state.debtLoad)).toBe(true) + }) + + it('logDebtProgress clamps paidUnits to owedUnits and persists', () => { + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + act(() => { + result.current.cashOut() + }) + const debtId = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts[0]?.id : undefined + if (debtId === undefined) { + throw new Error('expected a settled debt') + } + act(() => { + result.current.logDebtProgress(debtId, 500) + }) + const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] + expect(debts[0]?.paidUnits).toBe(debts[0]?.owedUnits) + + const { result: reloaded } = renderHook(() => useGame(storage, 3), { wrapper }) + expect(hasOutstandingDebt(reloaded.current.state.debtLoad)).toBe(false) + }) + + it('bust ends the session with a debt of exactly 2×buyIn', () => { + const storage = makeStorage() + // Seed 4 at a 20-chip buy-in, always checking/calling as the player, deterministically busts + // the player within a handful of hands (found by probing seeds 1-10; every other seed either + // busts with the same owedUnits=40 result or ends in the opponent-bust/cashOut branch covered + // above). Deals explicitly via dealHand() between hands, matching the new no-auto-continue API. + const { result } = renderHook(() => useGame(storage, 4), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 20 }) + }) + let guard = 0 + while (result.current.state.session !== null && guard < 20) { + const phase = result.current.state.handPhase + if (phase?.kind === 'betweenHands') { + act(() => { + result.current.dealHand() + }) + } else if (phase?.kind === 'awaiting') { + const { betting } = phase.step + const toCall = betting.betToCall - betting.seats.player.committed + act(() => { + result.current.submitPlayerAction(toCall > 0 ? { type: 'call' } : { type: 'check' }) + }) + } else { + break + } + guard += 1 + } + expect(guard).toBeLessThan(20) + expect(result.current.state.session).toBeNull() + const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] + expect(debts).toHaveLength(1) + expect(debts[0]).toMatchObject({ owedUnits: 40, reason: 'bust' }) + }) +}) diff --git a/poker-stakes/src/ui/useGame.reducer.test.tsx b/poker-stakes/src/ui/useGame.reducer.test.tsx new file mode 100644 index 0000000..4f49806 --- /dev/null +++ b/poker-stakes/src/ui/useGame.reducer.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { gameReducer, hasOutstandingDebt } from './useGame' +import type { GameState } from './useGame' +import { debt, initialState } from '../test/useGameFixtures' + +describe('hasOutstandingDebt', () => { + it('blocks when storage was unreadable', () => { + expect(hasOutstandingDebt({ status: 'unreadable' })).toBe(true) + }) + + it('does not block an empty debt list', () => { + expect(hasOutstandingDebt({ status: 'ok', debts: [] })).toBe(false) + }) + + it('does not block a fully paid debt', () => { + expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 50, paidUnits: 50 })] })).toBe(false) + }) + + it('does not block a debt that owes zero units (doubled-up cash-out)', () => { + expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 0, paidUnits: 0 })] })).toBe(false) + }) + + it('blocks a partially paid debt', () => { + expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] })).toBe(true) + }) +}) + +describe('gameReducer', () => { + it('buyIn sets buyIn/session and clears handPhase', () => { + const session: GameState['session'] = { + stacks: { player: 100, opponent: 100 }, + buttonSeat: 'player', + handsPlayed: 0, + outcome: null, + } + const next = gameReducer(initialState, { + type: 'buyIn', + buyIn: { taskTypeId: 'pushups', chips: 100 }, + session, + }) + expect(next).toEqual({ + debtLoad: { status: 'ok', debts: [] }, + buyIn: { taskTypeId: 'pushups', chips: 100 }, + session, + handPhase: null, + }) + }) + + it('debtSettled clears the live session and resets to the buy-in gate', () => { + const mid: GameState = { + ...initialState, + buyIn: { taskTypeId: 'pushups', chips: 100 }, + session: { + stacks: { player: 0, opponent: 200 }, + buttonSeat: 'player', + handsPlayed: 3, + outcome: { reason: 'bust', finalChips: 0 }, + }, + handPhase: { kind: 'betweenHands', lastResult: null }, + } + const settled = [debt({ owedUnits: 200 })] + const next = gameReducer(mid, { type: 'debtSettled', debts: settled }) + expect(next).toEqual({ + debtLoad: { status: 'ok', debts: settled }, + buyIn: null, + session: null, + handPhase: null, + }) + }) + + it('debtProgressLogged updates debtLoad only', () => { + const next = gameReducer(initialState, { type: 'debtProgressLogged', debts: [debt({ paidUnits: 20 })] }) + expect(next.debtLoad).toEqual({ status: 'ok', debts: [debt({ paidUnits: 20 })] }) + }) +}) diff --git a/poker-stakes/src/ui/useGame.session.test.tsx b/poker-stakes/src/ui/useGame.session.test.tsx new file mode 100644 index 0000000..313d067 --- /dev/null +++ b/poker-stakes/src/ui/useGame.session.test.tsx @@ -0,0 +1,219 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { hasOutstandingDebt, useGame } from './useGame' +import { makeStorage, unreadableStorage, wrapper } from '../test/useGameFixtures' + +describe('useGame', () => { + it('starts with no debt and no session, blocked=false', () => { + const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) + expect(hasOutstandingDebt(result.current.state.debtLoad)).toBe(false) + expect(result.current.state.session).toBeNull() + }) + + it('starts blocked when storage is unreadable, fail-closed', () => { + const { result } = renderHook(() => useGame(unreadableStorage(), 1), { wrapper }) + expect(result.current.state.debtLoad).toEqual({ status: 'unreadable' }) + expect(hasOutstandingDebt(result.current.state.debtLoad)).toBe(true) + }) + + it('buyIn is a no-op when storage is unreadable — the hard gate itself blocks, not just the UI', () => { + // Regression: an earlier version derived the gate's debt list via a helper that collapsed + // `{status: 'unreadable'}` to `[]`, which made `hasOutstandingDebt` see an empty (non-blocking) + // list and let a session start despite fail-closed storage. The hook's `buyIn` must itself + // refuse, not rely on a UI component to have already checked `state.debtLoad`. + const { result } = renderHook(() => useGame(unreadableStorage(), 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + expect(result.current.state.session).toBeNull() + }) + + it('buy-in starts a session and pauses on the player seat (StrictMode double-invoke safe)', () => { + const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + expect(result.current.state.session).not.toBeNull() + expect(result.current.state.session?.handsPlayed).toBe(0) + expect(result.current.state.handPhase?.kind).toBe('awaiting') + // If StrictMode's double-invoke perturbed the generator, stacks would already be inconsistent. + const stacks = result.current.state.session?.stacks + expect(stacks?.player).toBeGreaterThan(0) + expect(stacks?.opponent).toBeGreaterThan(0) + }) + + it('buyIn is a no-op while a debt is outstanding (hard gate, not just BuyInScreen UI)', () => { + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + // Cash out immediately: `cashOut` requires `betweenHands`, but hand 1 just started as + // `awaiting`. Fold to reach `betweenHands` deterministically without depending on a specific + // showdown outcome. + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + expect(result.current.state.handPhase?.kind).toBe('betweenHands') + act(() => { + result.current.cashOut() + }) + expect(result.current.state.session).toBeNull() + const debtsBefore = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] + expect(debtsBefore).toHaveLength(1) + + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 50 }) + }) + expect(result.current.state.session).toBeNull() + const debtsAfter = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] + expect(debtsAfter).toHaveLength(1) + }) + + it('buyIn rejects a non-positive or fractional chip count (chips are integers throughout)', () => { + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 0 }) + }) + expect(result.current.state.session).toBeNull() + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: -5 }) + }) + expect(result.current.state.session).toBeNull() + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 2.5 }) + }) + expect(result.current.state.session).toBeNull() + }) + + it('submitting a second action right after a hand resolves into betweenHands does not throw', () => { + // Regression: handRef used to only be cleared on settle, not on the betweenHands path, so a + // late/duplicate submitPlayerAction call after a hand resolved without ending the session hit + // `hand.submit()` on an already-complete InteractiveHand and threw out of the event handler. + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + expect(result.current.state.handPhase?.kind).toBe('betweenHands') + expect(() => { + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + }).not.toThrow() + // Still safely between hands — the stale submit was a no-op, not a state corruption. + expect(result.current.state.handPhase?.kind).toBe('betweenHands') + }) + + it('cashOut is a no-op when no session is live', () => { + const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) + act(() => { + result.current.cashOut() + }) + expect(result.current.state.session).toBeNull() + expect(result.current.state.debtLoad).toEqual({ status: 'ok', debts: [] }) + }) + + it('cashOut is a no-op mid-hand (only legal between hands, per the settlement-exploit fix)', () => { + // Buying in leaves a live hand `awaiting` a player decision. Cashing out here must be a + // no-op: `session.stacks` predates this hand's blinds/bets, so settling against it would let + // a player see the deal, cash out at the pre-hand stack, and have posted chips vanish. + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + expect(result.current.state.handPhase?.kind).toBe('awaiting') + act(() => { + result.current.cashOut() + }) + expect(result.current.state.session).not.toBeNull() + expect(result.current.state.debtLoad).toEqual({ status: 'ok', debts: [] }) + }) + + it('a hand that reaches showdown lands in betweenHands, not auto-dealing the next one', () => { + // Seed 1 at a 200-chip buy-in, always checking/calling: 4 submits carry the first hand to + // showdown. The session must land in `betweenHands` (handsPlayed incremented, no live hand) + // rather than silently starting hand 2 — that pause is what makes cashOut safe. + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + for (let i = 0; i < 4; i += 1) { + const phase = result.current.state.handPhase + if (phase?.kind !== 'awaiting') { + break + } + const { betting } = phase.step + const toCall = betting.betToCall - betting.seats.player.committed + act(() => { + result.current.submitPlayerAction(toCall > 0 ? { type: 'call' } : { type: 'check' }) + }) + } + expect(result.current.state.session?.handsPlayed).toBe(1) + expect(result.current.state.session?.outcome).toBeNull() + const phase = result.current.state.handPhase + expect(phase).toMatchObject({ kind: 'betweenHands' }) + expect(phase?.kind === 'betweenHands' ? phase.lastResult : undefined).not.toBeNull() + }) + + it('dealHand starts the next hand after betweenHands', () => { + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) + }) + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + expect(result.current.state.handPhase?.kind).toBe('betweenHands') + act(() => { + result.current.dealHand() + }) + expect(result.current.state.handPhase?.kind).toBe('awaiting') + expect(result.current.state.session?.handsPlayed).toBe(1) + }) + + it('dealHand is a no-op before any buy-in', () => { + const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) + act(() => { + result.current.dealHand() + }) + expect(result.current.state.session).toBeNull() + expect(result.current.state.handPhase).toBeNull() + }) + + it('submitPlayerAction is a no-op before any buy-in (no live hand to advance)', () => { + const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) + act(() => { + result.current.submitPlayerAction({ type: 'fold' }) + }) + expect(result.current.state.session).toBeNull() + expect(result.current.state.handPhase).toBeNull() + }) + + it('a zero-player-decision hand (all-in from the blind post) settles instead of soft-locking', () => { + // Buy-in of 1 chip: deriveBlindSize floors BB at 2, SB at 1 — the button posts their entire + // 1-chip stack as the small blind, an immediate all-in with zero player decision points. + // `dealHand` must fold that hand's result into the session and settle rather than leaving a + // live, un-settled session with no pending decision (the soft-lock this session's advisor + // review caught: `initialBettingState` used to reset `allIn: false` on every street). + // Seed 1 deterministically busts the player on this exact first hand. + const storage = makeStorage() + const { result } = renderHook(() => useGame(storage, 1), { wrapper }) + act(() => { + result.current.buyIn({ taskTypeId: 'pushups', chips: 1 }) + }) + expect(result.current.state.session).toBeNull() + expect(result.current.state.handPhase).toBeNull() + const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] + expect(debts).toHaveLength(1) + expect(debts[0]).toMatchObject({ owedUnits: 2, reason: 'bust' }) + }) + +}) diff --git a/poker-stakes/src/ui/useGame.test.tsx b/poker-stakes/src/ui/useGame.test.tsx deleted file mode 100644 index 913ee51..0000000 --- a/poker-stakes/src/ui/useGame.test.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { act, renderHook } from '@testing-library/react' -import { StrictMode } from 'react' -import type { ReactElement, ReactNode } from 'react' -import { describe, expect, it } from 'vitest' -import type { SettledDebt } from '../stakes/types' -import { gameReducer, hasOutstandingDebt, useGame } from './useGame' -import type { GameState } from './useGame' - -const debt = (overrides: Partial = {}): SettledDebt => ({ - id: 'd1', - taskTypeId: 'pushups', - owedUnits: 100, - reason: 'cashOut', - settledAt: 1, - paidUnits: 0, - ...overrides, -}) - -const initialState: GameState = { - debtLoad: { status: 'ok', debts: [] }, - buyIn: null, - session: null, - handPhase: null, -} - -describe('hasOutstandingDebt', () => { - it('blocks when storage was unreadable', () => { - expect(hasOutstandingDebt({ status: 'unreadable' })).toBe(true) - }) - - it('does not block an empty debt list', () => { - expect(hasOutstandingDebt({ status: 'ok', debts: [] })).toBe(false) - }) - - it('does not block a fully paid debt', () => { - expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 50, paidUnits: 50 })] })).toBe(false) - }) - - it('does not block a debt that owes zero units (doubled-up cash-out)', () => { - expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 0, paidUnits: 0 })] })).toBe(false) - }) - - it('blocks a partially paid debt', () => { - expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] })).toBe(true) - }) -}) - -describe('gameReducer', () => { - it('buyIn sets buyIn/session and clears handPhase', () => { - const session: GameState['session'] = { - stacks: { player: 100, opponent: 100 }, - buttonSeat: 'player', - handsPlayed: 0, - outcome: null, - } - const next = gameReducer(initialState, { - type: 'buyIn', - buyIn: { taskTypeId: 'pushups', chips: 100 }, - session, - }) - expect(next).toEqual({ - debtLoad: { status: 'ok', debts: [] }, - buyIn: { taskTypeId: 'pushups', chips: 100 }, - session, - handPhase: null, - }) - }) - - it('debtSettled clears the live session and resets to the buy-in gate', () => { - const mid: GameState = { - ...initialState, - buyIn: { taskTypeId: 'pushups', chips: 100 }, - session: { - stacks: { player: 0, opponent: 200 }, - buttonSeat: 'player', - handsPlayed: 3, - outcome: { reason: 'bust', finalChips: 0 }, - }, - handPhase: { kind: 'betweenHands', lastResult: null }, - } - const settled = [debt({ owedUnits: 200 })] - const next = gameReducer(mid, { type: 'debtSettled', debts: settled }) - expect(next).toEqual({ - debtLoad: { status: 'ok', debts: settled }, - buyIn: null, - session: null, - handPhase: null, - }) - }) - - it('debtProgressLogged updates debtLoad only', () => { - const next = gameReducer(initialState, { type: 'debtProgressLogged', debts: [debt({ paidUnits: 20 })] }) - expect(next.debtLoad).toEqual({ status: 'ok', debts: [debt({ paidUnits: 20 })] }) - }) -}) - -const wrapper = ({ children }: { children: ReactNode }): ReactElement => {children} - -const makeStorage = (): Storage => { - const store = new Map() - return { - getItem: (key: string): string | null => store.get(key) ?? null, - setItem: (key: string, value: string): void => { - store.set(key, value) - }, - removeItem: (key: string): void => { - store.delete(key) - }, - clear: (): void => { - store.clear() - }, - key: (): null => null, - get length(): number { - return store.size - }, - } -} - -const unreadableStorage = (): Storage => ({ - getItem: (): never => { - throw new Error('denied') - }, - setItem: (): never => { - throw new Error('denied') - }, - removeItem: (): undefined => undefined, - clear: (): undefined => undefined, - key: (): null => null, - length: 0, -}) - -describe('useGame', () => { - it('starts with no debt and no session, blocked=false', () => { - const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) - expect(hasOutstandingDebt(result.current.state.debtLoad)).toBe(false) - expect(result.current.state.session).toBeNull() - }) - - it('starts blocked when storage is unreadable, fail-closed', () => { - const { result } = renderHook(() => useGame(unreadableStorage(), 1), { wrapper }) - expect(result.current.state.debtLoad).toEqual({ status: 'unreadable' }) - expect(hasOutstandingDebt(result.current.state.debtLoad)).toBe(true) - }) - - it('buyIn is a no-op when storage is unreadable — the hard gate itself blocks, not just the UI', () => { - // Regression: an earlier version derived the gate's debt list via a helper that collapsed - // `{status: 'unreadable'}` to `[]`, which made `hasOutstandingDebt` see an empty (non-blocking) - // list and let a session start despite fail-closed storage. The hook's `buyIn` must itself - // refuse, not rely on a UI component to have already checked `state.debtLoad`. - const { result } = renderHook(() => useGame(unreadableStorage(), 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - expect(result.current.state.session).toBeNull() - }) - - it('buy-in starts a session and pauses on the player seat (StrictMode double-invoke safe)', () => { - const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - expect(result.current.state.session).not.toBeNull() - expect(result.current.state.session?.handsPlayed).toBe(0) - expect(result.current.state.handPhase?.kind).toBe('awaiting') - // If StrictMode's double-invoke perturbed the generator, stacks would already be inconsistent. - const stacks = result.current.state.session?.stacks - expect(stacks?.player).toBeGreaterThan(0) - expect(stacks?.opponent).toBeGreaterThan(0) - }) - - it('buyIn is a no-op while a debt is outstanding (hard gate, not just BuyInScreen UI)', () => { - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - // Cash out immediately: `cashOut` requires `betweenHands`, but hand 1 just started as - // `awaiting`. Fold to reach `betweenHands` deterministically without depending on a specific - // showdown outcome. - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - expect(result.current.state.handPhase?.kind).toBe('betweenHands') - act(() => { - result.current.cashOut() - }) - expect(result.current.state.session).toBeNull() - const debtsBefore = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] - expect(debtsBefore).toHaveLength(1) - - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 50 }) - }) - expect(result.current.state.session).toBeNull() - const debtsAfter = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] - expect(debtsAfter).toHaveLength(1) - }) - - it('buyIn rejects a non-positive or fractional chip count (chips are integers throughout)', () => { - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 0 }) - }) - expect(result.current.state.session).toBeNull() - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: -5 }) - }) - expect(result.current.state.session).toBeNull() - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 2.5 }) - }) - expect(result.current.state.session).toBeNull() - }) - - it('submitting a second action right after a hand resolves into betweenHands does not throw', () => { - // Regression: handRef used to only be cleared on settle, not on the betweenHands path, so a - // late/duplicate submitPlayerAction call after a hand resolved without ending the session hit - // `hand.submit()` on an already-complete InteractiveHand and threw out of the event handler. - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - expect(result.current.state.handPhase?.kind).toBe('betweenHands') - expect(() => { - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - }).not.toThrow() - // Still safely between hands — the stale submit was a no-op, not a state corruption. - expect(result.current.state.handPhase?.kind).toBe('betweenHands') - }) - - it('cashOut is a no-op when no session is live', () => { - const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) - act(() => { - result.current.cashOut() - }) - expect(result.current.state.session).toBeNull() - expect(result.current.state.debtLoad).toEqual({ status: 'ok', debts: [] }) - }) - - it('cashOut is a no-op mid-hand (only legal between hands, per the settlement-exploit fix)', () => { - // Buying in leaves a live hand `awaiting` a player decision. Cashing out here must be a - // no-op: `session.stacks` predates this hand's blinds/bets, so settling against it would let - // a player see the deal, cash out at the pre-hand stack, and have posted chips vanish. - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - expect(result.current.state.handPhase?.kind).toBe('awaiting') - act(() => { - result.current.cashOut() - }) - expect(result.current.state.session).not.toBeNull() - expect(result.current.state.debtLoad).toEqual({ status: 'ok', debts: [] }) - }) - - it('a hand that reaches showdown lands in betweenHands, not auto-dealing the next one', () => { - // Seed 1 at a 200-chip buy-in, always checking/calling: 4 submits carry the first hand to - // showdown. The session must land in `betweenHands` (handsPlayed incremented, no live hand) - // rather than silently starting hand 2 — that pause is what makes cashOut safe. - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - for (let i = 0; i < 4; i += 1) { - const phase = result.current.state.handPhase - if (phase?.kind !== 'awaiting') { - break - } - const { betting } = phase.step - const toCall = betting.betToCall - betting.seats.player.committed - act(() => { - result.current.submitPlayerAction(toCall > 0 ? { type: 'call' } : { type: 'check' }) - }) - } - expect(result.current.state.session?.handsPlayed).toBe(1) - expect(result.current.state.session?.outcome).toBeNull() - const phase = result.current.state.handPhase - expect(phase).toMatchObject({ kind: 'betweenHands' }) - expect(phase?.kind === 'betweenHands' ? phase.lastResult : undefined).not.toBeNull() - }) - - it('dealHand starts the next hand after betweenHands', () => { - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - expect(result.current.state.handPhase?.kind).toBe('betweenHands') - act(() => { - result.current.dealHand() - }) - expect(result.current.state.handPhase?.kind).toBe('awaiting') - expect(result.current.state.session?.handsPlayed).toBe(1) - }) - - it('dealHand is a no-op before any buy-in', () => { - const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) - act(() => { - result.current.dealHand() - }) - expect(result.current.state.session).toBeNull() - expect(result.current.state.handPhase).toBeNull() - }) - - it('submitPlayerAction is a no-op before any buy-in (no live hand to advance)', () => { - const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper }) - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - expect(result.current.state.session).toBeNull() - expect(result.current.state.handPhase).toBeNull() - }) - - it('a zero-player-decision hand (all-in from the blind post) settles instead of soft-locking', () => { - // Buy-in of 1 chip: deriveBlindSize floors BB at 2, SB at 1 — the button posts their entire - // 1-chip stack as the small blind, an immediate all-in with zero player decision points. - // `dealHand` must fold that hand's result into the session and settle rather than leaving a - // live, un-settled session with no pending decision (the soft-lock this session's advisor - // review caught: `initialBettingState` used to reset `allIn: false` on every street). - // Seed 1 deterministically busts the player on this exact first hand. - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 1 }) - }) - expect(result.current.state.session).toBeNull() - expect(result.current.state.handPhase).toBeNull() - const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] - expect(debts).toHaveLength(1) - expect(debts[0]).toMatchObject({ owedUnits: 2, reason: 'bust' }) - }) - - it('a zero-player-decision hand that the all-in player survives lands in betweenHands, not settled', () => { - // Seed 1, 4-chip buy-in, always folding when a decision exists: by hand 3 (handsPlayed=2) the - // player is down to 1 chip and posts it as the small blind — all-in, zero decision points. - // The all-in player wins/ties that hand and ends above 0, so the session must survive into - // `betweenHands` rather than settling — the sibling branch to the bust case above. - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 4 }) - }) - let guard = 0 - while (result.current.state.session !== null && result.current.state.session.handsPlayed < 3 && guard < 10) { - const phase = result.current.state.handPhase - if (phase?.kind === 'betweenHands') { - act(() => { - result.current.dealHand() - }) - } else if (phase?.kind === 'awaiting') { - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - } else { - break - } - guard += 1 - } - expect(guard).toBeLessThan(10) - expect(result.current.state.session).not.toBeNull() - expect(result.current.state.session?.handsPlayed).toBe(3) - expect(result.current.state.handPhase?.kind).toBe('betweenHands') - expect(result.current.state.session?.stacks.player).toBeGreaterThan(0) - }) - - it('logDebtProgress with an unknown debt id leaves the debt list unchanged', () => { - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - act(() => { - result.current.cashOut() - }) - const before = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] - act(() => { - result.current.logDebtProgress('no-such-id', 10) - }) - const after = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] - expect(after).toEqual(before) - }) - - it('cashing out between hands (never played a hand to showdown) settles a debt equal to the full buy-in', () => { - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - expect(result.current.state.handPhase?.kind).toBe('betweenHands') - act(() => { - result.current.cashOut() - }) - expect(result.current.state.session).toBeNull() - expect(result.current.state.debtLoad.status).toBe('ok') - const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] - expect(debts).toHaveLength(1) - expect(debts[0]?.reason).toBe('cashOut') - }) - - it('an unpaid debt persists to storage and blocks a subsequent load', () => { - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - act(() => { - result.current.cashOut() - }) - const { result: second } = renderHook(() => useGame(storage, 2), { wrapper }) - expect(hasOutstandingDebt(second.current.state.debtLoad)).toBe(true) - }) - - it('logDebtProgress clamps paidUnits to owedUnits and persists', () => { - const storage = makeStorage() - const { result } = renderHook(() => useGame(storage, 1), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 200 }) - }) - act(() => { - result.current.submitPlayerAction({ type: 'fold' }) - }) - act(() => { - result.current.cashOut() - }) - const debtId = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts[0]?.id : undefined - if (debtId === undefined) { - throw new Error('expected a settled debt') - } - act(() => { - result.current.logDebtProgress(debtId, 500) - }) - const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] - expect(debts[0]?.paidUnits).toBe(debts[0]?.owedUnits) - - const { result: reloaded } = renderHook(() => useGame(storage, 3), { wrapper }) - expect(hasOutstandingDebt(reloaded.current.state.debtLoad)).toBe(false) - }) - - it('bust ends the session with a debt of exactly 2×buyIn', () => { - const storage = makeStorage() - // Seed 4 at a 20-chip buy-in, always checking/calling as the player, deterministically busts - // the player within a handful of hands (found by probing seeds 1-10; every other seed either - // busts with the same owedUnits=40 result or ends in the opponent-bust/cashOut branch covered - // above). Deals explicitly via dealHand() between hands, matching the new no-auto-continue API. - const { result } = renderHook(() => useGame(storage, 4), { wrapper }) - act(() => { - result.current.buyIn({ taskTypeId: 'pushups', chips: 20 }) - }) - let guard = 0 - while (result.current.state.session !== null && guard < 20) { - const phase = result.current.state.handPhase - if (phase?.kind === 'betweenHands') { - act(() => { - result.current.dealHand() - }) - } else if (phase?.kind === 'awaiting') { - const { betting } = phase.step - const toCall = betting.betToCall - betting.seats.player.committed - act(() => { - result.current.submitPlayerAction(toCall > 0 ? { type: 'call' } : { type: 'check' }) - }) - } else { - break - } - guard += 1 - } - expect(guard).toBeLessThan(20) - expect(result.current.state.session).toBeNull() - const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : [] - expect(debts).toHaveLength(1) - expect(debts[0]).toMatchObject({ owedUnits: 40, reason: 'bust' }) - }) -}) diff --git a/poker-stakes/src/ui/useGame.ts b/poker-stakes/src/ui/useGame.ts index d46491d..56532f1 100644 --- a/poker-stakes/src/ui/useGame.ts +++ b/poker-stakes/src/ui/useGame.ts @@ -1,6 +1,5 @@ import { useCallback, useReducer, useRef } from 'react' import type { Action as PokerAction } from '../engine/bettingRound' -import type { HandResult, HandStep } from '../engine/handFlow' import type { InteractiveHand } from '../engine/interactiveHand' import { startInteractiveHand } from '../engine/interactiveHand' import { createRng } from '../engine/rng' @@ -16,66 +15,11 @@ import type { BuyIn, SettledDebt } from '../stakes/types' const POLICY_SEED_SALT = 0x9e3779b9 const OPPONENT_ITERATIONS = 200 +import { gameReducer, hasOutstandingDebt, initialGameState } from './gameState' +import type { GameState } from './gameState' -/** - * Only meaningful while `session !== null`. `betweenHands` is a real, UI-visible pause point — no - * hand is in progress, `session.stacks` is trustworthy, and `cashOut` is only ever legal here. A - * hand result stays visible (`lastResult`) until the next hand actually starts, so HandLog has - * something to render. This is deliberately NOT auto-advanced past by `submitPlayerAction` — see - * `dealHand` below for why cash-out mid-hand would be a settlement exploit if it were. - */ -export type HandPhase = { kind: 'awaiting'; step: HandStep } | { kind: 'betweenHands'; lastResult: HandResult | null } - -export interface GameState { - debtLoad: DebtLoad - buyIn: BuyIn | null - session: SessionState | null - handPhase: HandPhase | null -} - -type GameAction = - | { type: 'buyIn'; buyIn: BuyIn; session: SessionState } - | { type: 'handPhaseChanged'; session: SessionState; handPhase: HandPhase } - | { type: 'cashedOut'; session: SessionState } - | { type: 'debtSettled'; debts: SettledDebt[] } - | { type: 'debtProgressLogged'; debts: SettledDebt[] } - -const initialGameState: GameState = { - debtLoad: { status: 'ok', debts: [] }, - buyIn: null, - session: null, - handPhase: null, -} - -/** - * Pure state transitions. All randomness/ids/clock reads happen in the hook, never in here. - * `GameAction` is exhaustively handled — TS errors on a missing case instead of a silent - * fallthrough, which is why there is no `default` branch. - */ -export const gameReducer = (state: GameState, action: GameAction): GameState => { - switch (action.type) { - case 'buyIn': - return { ...state, buyIn: action.buyIn, session: action.session, handPhase: null } - case 'handPhaseChanged': - return { ...state, session: action.session, handPhase: action.handPhase } - case 'cashedOut': - return { ...state, session: action.session } - case 'debtSettled': - return { - ...state, - debtLoad: { status: 'ok', debts: action.debts }, - buyIn: null, - session: null, - handPhase: null, - } - case 'debtProgressLogged': - return { ...state, debtLoad: { status: 'ok', debts: action.debts } } - } -} - -/** An unpaid debt (owedUnits not yet fully paid) blocks starting a new session — a hard gate. */ -export const hasOutstandingDebt = (debtLoad: DebtLoad): boolean => - debtLoad.status === 'unreadable' || debtLoad.debts.some((d) => d.paidUnits < d.owedUnits) +export type { GameState, HandPhase } from './gameState' +export { gameReducer, hasOutstandingDebt } from './gameState' export interface UseGameResult { state: GameState