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
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 15:26:18 +02:00
parent bf6f94a75b
commit caee1d7067
12 changed files with 789 additions and 733 deletions

View File

@ -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')
})
})

View File

@ -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<readonly [number, number, number, number, number]> {
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')
})
})

View File

@ -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<Seat, readonly Card[]> | 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<Seat, SeatState>,
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<Seat, SeatState>,
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 {
/**

View File

@ -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<Seat, SeatState>,
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<Seat, SeatState>,
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,
})

View File

@ -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<readonly [number, number, number, number, number]> {
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]
}
}
}
}
}
}

View File

@ -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> = {}): 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 => <StrictMode>{children}</StrictMode>
/** An in-memory Storage stand-in, so tests never touch real localStorage. */
export const makeStorage = (): Storage => {
const store = new Map<string, string>()
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,
})

View File

@ -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)

View File

@ -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' })
})
})

View File

@ -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 })] })
})
})

View File

@ -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' })
})
})

View File

@ -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> = {}): 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 => <StrictMode>{children}</StrictMode>
const makeStorage = (): Storage => {
const store = new Map<string, string>()
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' })
})
})

View File

@ -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