mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 16:48:38 +01:00
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:
parent
bf6f94a75b
commit
caee1d7067
67
poker-stakes/src/engine/handEval.compare.test.ts
Normal file
67
poker-stakes/src/engine/handEval.compare.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
/**
|
||||
|
||||
89
poker-stakes/src/engine/handFlowBetting.ts
Normal file
89
poker-stakes/src/engine/handFlowBetting.ts
Normal 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,
|
||||
})
|
||||
37
poker-stakes/src/test/handEvalFixtures.ts
Normal file
37
poker-stakes/src/test/handEvalFixtures.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
poker-stakes/src/test/useGameFixtures.tsx
Normal file
65
poker-stakes/src/test/useGameFixtures.tsx
Normal 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,
|
||||
})
|
||||
63
poker-stakes/src/ui/gameState.ts
Normal file
63
poker-stakes/src/ui/gameState.ts
Normal 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)
|
||||
156
poker-stakes/src/ui/useGame.debt.test.tsx
Normal file
156
poker-stakes/src/ui/useGame.debt.test.tsx
Normal 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' })
|
||||
})
|
||||
})
|
||||
75
poker-stakes/src/ui/useGame.reducer.test.tsx
Normal file
75
poker-stakes/src/ui/useGame.reducer.test.tsx
Normal 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 })] })
|
||||
})
|
||||
})
|
||||
219
poker-stakes/src/ui/useGame.session.test.tsx
Normal file
219
poker-stakes/src/ui/useGame.session.test.tsx
Normal 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' })
|
||||
})
|
||||
|
||||
})
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user