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 72a5e28456
commit 73b85f5326
14 changed files with 849 additions and 733 deletions

View File

@ -0,0 +1,18 @@
{
"title": "Split poker-stakes' four over-250-line files",
"objective": "Bring every file in poker-stakes under the shared 250-line cap by extracting cohesive units, without weakening the 100% coverage gate or changing application behaviour.",
"acceptance_criteria": [
"No file under poker-stakes/ exceeds 250 lines.",
"npm run check passes: typecheck, eslint --max-warnings 0, and vitest at the configured 100% thresholds for statements, branches, functions and lines.",
"All 220 tests still pass; no test is deleted, skipped or weakened.",
"npm run build succeeds, proving the module graph still resolves after the extractions.",
"useGame.ts keeps its public API by re-exporting GameState, HandPhase, gameReducer and hasOutstandingDebt, so no importer changes.",
"No coverage-exclusion entry is added to vite.config.ts."
],
"out_of_scope": [
"Any change to poker-stakes application behaviour, engine logic or UI.",
"The pre-existing 5s-timeout flake in invariants.test.ts under parallel coverage load.",
"File-length violations outside poker-stakes/."
],
"verifier": "cd poker-stakes && npm run check (exit 0, 220 tests, 100% on all four metrics) && npm run build (exit 0); bash ~/utils/scripts/check_file_length.sh --all | grep -c poker-stakes returns 0."
}

View File

@ -0,0 +1,42 @@
{
"intent": "Bring poker-stakes' four over-250-line files under the shared cap without weakening its 100% coverage gate.",
"scope": [
"poker-stakes/src/ui/useGame.test.tsx (494) -> reducer/session/debt test files + shared fixtures",
"poker-stakes/src/engine/handEval.test.ts (269) -> rank5/compare test files + shared fixtures",
"poker-stakes/src/ui/useGame.ts (268) -> useGame.ts + gameState.ts",
"poker-stakes/src/engine/handFlow.ts (252) -> handFlow.ts + handFlowBetting.ts",
"Non-goal: any behaviour change; non-goal: violations outside poker-stakes/"
],
"changes": [
"Split useGame.test.tsx by describe-block into useGame.reducer/session/debt.test.tsx (75/219/156 lines).",
"Split handEval.test.ts at the evaluate7 boundary into handEval.rank5.test.ts (174) and handEval.compare.test.ts (67).",
"Extracted shared test helpers to src/test/useGameFixtures.tsx and src/test/handEvalFixtures.ts. Placed under src/test/ deliberately: vite.config.ts already excludes that directory from coverage, so no NEW exclusion entry was needed. An earlier attempt put the fixtures beside the source, where v8 counted their unused branches as production code and dropped coverage to 98.57%.",
"Extracted GameState/HandPhase/gameReducer/hasOutstandingDebt into gameState.ts; useGame.ts re-exports them so no importer changed.",
"Extracted the betting-state helpers into handFlowBetting.ts; handFlow.ts re-exports potFromBetting for its existing consumers."
],
"verification": [
{
"command": "cd poker-stakes && npm run check",
"result": "pass",
"evidence": "exit 0; 220 tests passed (unchanged count); Statements 100% (572/572), Branches 100% (299/299), Functions 100% (109/109), Lines 100% (542/542)."
},
{
"command": "cd poker-stakes && npm run build",
"result": "pass",
"evidence": "built in 84ms; dist/assets/index-zO7BhOW2.js 208.17 kB. Confirms the module graph still resolves after the extractions."
},
{
"command": "bash ~/utils/scripts/check_file_length.sh --all",
"result": "pass",
"evidence": "poker-stakes violations: 0. Repo total 196 -> 192."
}
],
"risks": [
"invariants.test.ts failed once on a 5s timeout during this work. Investigated rather than retried blindly: it passes standalone in 2.56s and passed on re-run of the full suite, so it is a pre-existing contention flake under parallel coverage load, not a regression from the split. Left unfixed as out of scope, but it will recur in CI.",
"The test splits move tests between files; a future reader must not assume a describe-block's tests are all in one file."
],
"rollback": [
"git revert this commit; the four original files return intact.",
"Validate with: cd poker-stakes && npm run check"
]
}

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