mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 19:27:05 +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 { describe, expect, it } from 'vitest'
|
||||||
import { buildDeck } from './deck'
|
import { buildDeck } from './deck'
|
||||||
import { compareHandScore, evaluate7, rank5, rankLabel } from './handEval'
|
import { compareHandScore, rank5 } from './handEval'
|
||||||
import type { HandCategory, HandScore } from './handEval'
|
import type { HandCategory } from './handEval'
|
||||||
import type { Card } from './types'
|
import type { Card } from './types'
|
||||||
|
import { card, combinations5, five } from '../test/handEvalFixtures'
|
||||||
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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('rank5 exhaustive classification', () => {
|
describe('rank5 exhaustive classification', () => {
|
||||||
it('classifies every 5-card hand out of 52 into the correct standard-count category', () => {
|
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)
|
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 { applyAction, legalActions } from './bettingRound'
|
||||||
import { createDealCursor, dealCards, shuffleDeck } from './deck'
|
import { createDealCursor, dealCards, shuffleDeck } from './deck'
|
||||||
import { buildDeck } from './deck'
|
import { buildDeck } from './deck'
|
||||||
import { compareHandScore, evaluate7 } from './handEval'
|
import { compareHandScore, evaluate7 } from './handEval'
|
||||||
import type { HandScore } from './handEval'
|
import type { HandScore } from './handEval'
|
||||||
import type { Rng } from './rng'
|
import type { Rng } from './rng'
|
||||||
import type { Card, Seat, Street } from './types'
|
import type { Card, Seat } from './types'
|
||||||
|
|
||||||
export interface HeadsUpStacks {
|
export interface HeadsUpStacks {
|
||||||
player: number
|
player: number
|
||||||
@ -30,87 +30,17 @@ export interface HandResult {
|
|||||||
revealedHoleCards: Record<Seat, readonly Card[]> | null
|
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 {
|
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 { useCallback, useReducer, useRef } from 'react'
|
||||||
import type { Action as PokerAction } from '../engine/bettingRound'
|
import type { Action as PokerAction } from '../engine/bettingRound'
|
||||||
import type { HandResult, HandStep } from '../engine/handFlow'
|
|
||||||
import type { InteractiveHand } from '../engine/interactiveHand'
|
import type { InteractiveHand } from '../engine/interactiveHand'
|
||||||
import { startInteractiveHand } from '../engine/interactiveHand'
|
import { startInteractiveHand } from '../engine/interactiveHand'
|
||||||
import { createRng } from '../engine/rng'
|
import { createRng } from '../engine/rng'
|
||||||
@ -16,66 +15,11 @@ import type { BuyIn, SettledDebt } from '../stakes/types'
|
|||||||
const POLICY_SEED_SALT = 0x9e3779b9
|
const POLICY_SEED_SALT = 0x9e3779b9
|
||||||
|
|
||||||
const OPPONENT_ITERATIONS = 200
|
const OPPONENT_ITERATIONS = 200
|
||||||
|
import { gameReducer, hasOutstandingDebt, initialGameState } from './gameState'
|
||||||
|
import type { GameState } from './gameState'
|
||||||
|
|
||||||
/**
|
export type { GameState, HandPhase } from './gameState'
|
||||||
* Only meaningful while `session !== null`. `betweenHands` is a real, UI-visible pause point — no
|
export { gameReducer, hasOutstandingDebt } from './gameState'
|
||||||
* 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 interface UseGameResult {
|
export interface UseGameResult {
|
||||||
state: GameState
|
state: GameState
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user