Track poker-stakes so the file-length cap can reach it

The directory has existed on disk with 58 untracked files, four of them over
the 250-line cap. An untracked file is invisible to every pre-commit hook, so
those violations could not be fixed or gated while it stayed that way -- and
its CI workflow was untracked too, so nothing ran against it.

Landed with its already-written evidence artifact and a contract, since this
crosses the 4-file threshold.

Verified before staging: 58 plain text source/config files, no binaries, no
node_modules, no build output. npm run check passes -- typecheck, eslint
--max-warnings 0, and 220 tests at 100% statements/branches/functions/lines.

Splitting its four oversized files is deliberately a separate commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 15:19:07 +02:00
parent 004b4b0b29
commit 72a5e28456
61 changed files with 9916 additions and 0 deletions

31
.github/workflows/poker-stakes-ci.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: poker-stakes CI
on:
push:
branches: [main]
paths:
- "poker-stakes/**"
- ".github/workflows/poker-stakes-ci.yml"
pull_request:
branches: [main]
paths:
- "poker-stakes/**"
- ".github/workflows/poker-stakes-ci.yml"
defaults:
run:
working-directory: poker-stakes
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: poker-stakes/package-lock.json
- run: npm ci
- run: npm run check
- run: npm run build

View File

@ -0,0 +1,18 @@
{
"title": "Track poker-stakes in git so the file-length cap can reach it",
"objective": "poker-stakes/ exists on disk with 58 untracked files, four of which exceed the 250-line cap. An untracked directory is invisible to every pre-commit hook, so its violations cannot be fixed or gated until it is committed. Track it with its CI workflow and its existing evidence artifact.",
"acceptance_criteria": [
"All 58 poker-stakes source and config files are tracked by git.",
".github/workflows/poker-stakes-ci.yml is tracked so CI runs on the directory.",
"docs/superpowers/evidence/poker-stakes-ui-wiring-2026-08-14.json is tracked alongside the code it documents.",
"npm run check passes in poker-stakes/: typecheck, eslint --max-warnings 0, and vitest coverage at the configured 100% thresholds.",
"No binaries, no node_modules and no build output are staged.",
"The repo's pre-commit hooks all pass on the staged set."
],
"out_of_scope": [
"Splitting poker-stakes' four over-250-line files; that lands as a separate commit once the directory is tracked.",
"Any change to poker-stakes application behaviour, dependencies or CI definition.",
"The remaining file-length violations elsewhere in the repo."
],
"verifier": "cd poker-stakes && npm run check (exit 0, 220 tests, 100% statements/branches/functions/lines); git status --porcelain -uall poker-stakes returns empty afterwards; git ls-files poker-stakes | wc -l reports 58."
}

View File

@ -0,0 +1,67 @@
{
"intent": "Finish the poker-stakes UI layer so a full session is playable end to end in the browser: buy in, play hands, cash out or bust, and settle the resulting real-world task debt into a durable ledger that gates the next buy-in.",
"scope": [
"poker-stakes/src/App.tsx (wired the real app; was a placeholder div)",
"poker-stakes/src/ui/DebtHistoryPanel.tsx + test (new durable ledger panel)",
"poker-stakes/src/ui/OutstandingDebtRow.tsx + test (extracted shared log-progress control)",
"poker-stakes/src/ui/taskLabel.ts + test (extracted so the row file exports only components)",
"poker-stakes/src/ui/BuyInScreen.tsx (uses the shared row; non-empty-tuple TASK_TYPES fix)",
"poker-stakes/src/stakes/storage.ts (exported DEBTS_STORAGE_KEY for test seeding)",
"poker-stakes/src/main.tsx (import styles.css - it was never imported)",
".github/workflows/poker-stakes-ci.yml + README.md row",
"Non-goal: mid-session persistence across reload (V1 accepts losing an in-progress hand; no debt is settled yet at that point)"
],
"changes": [
"Wired App.tsx to useGame with injected `storage`/`seed` props (lazy `useState(() => seed ?? Date.now())`) so tests can script a deterministic deal; removed a dead 'Deal first hand' branch after confirming useGame.buyIn already deals the opening hand.",
"Extracted the log-progress control into a shared OutstandingDebtRow so BuyInScreen's hard gate and DebtHistoryPanel cannot drift; per the implemented SettledDebt type there is no mark-paid boolean, so logging units is the only way paidUnits advances.",
"Fixed a real bug the jsdom suite could not catch: src/styles.css (215 lines of design-system tokens) was never imported, so the app rendered entirely unstyled. Confirmed by the CSS bundle now appearing in `npm run build` output (3.03 kB).",
"Replaced two coverage-gap tests that could never exercise their guard (typing '-5' into a `min={1}` input is blocked by native constraint validation before the handler fires) with cleared/empty-field tests that actually reach the JS guard.",
"Strengthened two weak assertions: the cash-out test now pins `2*buyIn - finalChips` end to end, and a new test asserts a bust settles at exactly 2x the buy-in (seed 7 busts deterministically).",
"Fixed a third browser-only bug in Table.tsx: at a showdown the opponent's revealed hand and the board rendered but the player's OWN hole cards did not, because `playerHoleCards` was only populated while `isAwaiting`. `HandResult.revealedHoleCards` is a `Record<Seat, ...>` carrying both seats, so the data was present and simply unread. The player could win a pot without being able to see what they won with.",
"The Table test named 'reveals both hole cards at a showdown' was itself vacuous: its fixture set `player: [A-spades, A-clubs]` but the assertions only checked the opponent's two cards and one board card. Added the missing player-card assertions (verified by mutation: reintroducing the bug now fails the test) plus a companion test that a fold reveals no hands at all.",
"Closed two more vacuous-test gaps found by systematic mutation testing. (a) bettingRound's `minRaiseSize: Math.max(state.minRaiseSize, raiseSize)` floor was untested - every existing raise fixture had raiseSize >= minRaiseSize, so the Math.max never mattered; added a short-all-in case (raise of 5 over a min-raise of 50) asserting the floor holds. (b) storage's `isSettledDebt` per-field guards were untested in isolation: the only invalid-entry fixture omitted five of six fields, so deleting any single guard still tripped another. Added table-driven per-field cases (wrong-type and missing) holding all other fields valid.",
"Documented the `Math.max(0, ...)` floor in bettingRound's `owedToCall` as defensive-only after empirically confirming the whole 219-test suite passes without it (betToCall is always the highest committed, so the subtraction cannot go negative) - a comment rather than a test, since the branch is structurally unreachable.",
"Added src/engine/invariants.test.ts: a property sweep over 40 seeds playing sessions to completion, asserting chip conservation (a heads-up table is a closed system - invented chips would become forgiven debt), non-negative stacks, and owedUnits == max(0, 2*buyIn - finalChips) with the 2x cap. Includes counter assertions so the sweep cannot pass by silently doing nothing."
],
"verification": [
{
"command": "cd poker-stakes && npm run check",
"result": "pass",
"evidence": "24 test files, 220 tests passed. Coverage 100% statements (572/572), 100% branches (299/299), 100% functions (109/109), 100% lines (542/542). eslint --max-warnings 0 clean, zero suppressions."
},
{
"command": "cd poker-stakes && npm run build",
"result": "pass",
"evidence": "tsc --noEmit clean; vite build emitted dist/assets/index-*.css 3.03 kB (confirms the styles.css import fix) and index-*.js 208.15 kB."
},
{
"command": "headless chromium + CDP against npm run dev (localhost:5173), full scripted session",
"result": "pass",
"evidence": "Cash-out branch: stack 99 -> panel previewed 'would owe 101 units' -> persisted ledger {owedUnits:101, reason:'cashOut'} == 2*100-99. Bust branch: persisted {owedUnits:200, reason:'bust'} == 2*buyIn cap. Buy-in gate with debt outstanding: sitDownPresent=false, gateMessage=true. After logging 101 units the gate reopened (Sit down present) and the history row read 'Pushups: 101 owed (cashed out) - cleared'. Screenshot confirmed the design-system styling renders. Post-fix showdown screenshot shows BOTH hands (opponent 10s/10c, player 2c/3c) where the player's seat was previously empty."
},
{
"command": "headless chromium audit: console/exception capture, a11y names, label association, layout at 390/768/1440 widths, mid-session reload, rapid triple-click",
"result": "pass",
"evidence": "Zero console errors, zero console warnings, zero uncaught exceptions across a full session. Every interactive control has an accessible name; both labels resolve via htmlFor. No horizontal overflow, no clipped cards and no sub-32px tap targets at any of the three widths. Mid-session reload loses in-progress hand state but no settled debt (matches plan Open Choice #2). Triple-clicking a betting action left the state coherent (no duplicate-submit corruption)."
},
{
"command": "mutation check on the new Table assertion",
"result": "pass",
"evidence": "Reintroducing the showdown bug (playerHoleCards = null between hands) makes 'reveals both hole cards at a showdown' FAIL; restoring the fix makes it pass. The assertion is non-vacuous."
},
{
"command": "systematic mutation testing across settlement / handEval / bettingRound / session / useGame / storage",
"result": "pass",
"evidence": "Deliberate source mutations confirmed each new assertion is non-vacuous: dropping the minRaiseSize floor fails the new short-all-in test; dropping isSettledDebt's owedUnits or id guard fails the matching per-field tests. settlement.ts, handEval.ts (exhaustive C(52,5) sweep), session.ts and useGame.ts (debt gate, cash-out guard, paidUnits clamp) caught every mutation attempted - no vacuous assertions found there. All mutated files restored and verified byte-identical to their originals."
}
],
"risks": [
"The bust test pins reason='bust' for seed 7 with an always-first-legal-action strategy; changing the RNG, blind derivation, or opponent policy could flip that branch and fail the test. That is intended - it is a real behavioural assertion, not a smoke test.",
"App-level tests drive the UI by clicking the first button inside `.controls`, so renaming that class or reordering legal actions changes what they exercise.",
"Chrome DevTools port 9222 is already taken by Steam's webhelper on this machine; the verification scripts use 9333 to avoid attaching to Steam."
],
"rollback": [
"Revert the poker-stakes/ directory and .github/workflows/poker-stakes-ci.yml plus the README.md table row; nothing outside poker-stakes/ is touched by the app changes.",
"After rollback, run `cd poker-stakes && npm run check` to confirm the tree is still green, and re-check that src/main.tsx still imports './styles.css' if any part of the UI work is kept."
]
}

3
poker-stakes/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
coverage

View File

@ -0,0 +1,47 @@
import js from '@eslint/js'
import react from '@eslint-react/eslint-plugin'
import vitest from '@vitest/eslint-plugin'
import { defineConfig } from 'eslint/config'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default defineConfig(
{ ignores: ['dist', 'coverage', 'node_modules'] },
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
react.configs['strict-type-checked'],
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-shadow': 'error',
'no-console': 'error',
'eqeqeq': ['error', 'always'],
'prefer-const': ['error', { destructuring: 'all' }],
},
},
{
files: ['src/**/*.test.{ts,tsx}', 'src/test/**/*.ts'],
extends: [vitest.configs.recommended],
languageOptions: { globals: { ...globals.browser, ...globals.node } },
},
{
files: ['eslint.config.js'],
extends: [tseslint.configs.disableTypeChecked],
},
)

12
poker-stakes/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Poker Stakes — lose the hand, pay the price</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4852
poker-stakes/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
poker-stakes/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "poker-stakes",
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint . --max-warnings 0",
"test": "vitest run",
"coverage": "vitest run --coverage",
"check": "npm run typecheck && npm run lint && npm run coverage"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@eslint-react/eslint-plugin": "5.18.1",
"@eslint/js": "10.0.1",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.0.5",
"@vitest/coverage-v8": "4.1.10",
"@vitest/eslint-plugin": "1.6.24",
"eslint": "10.8.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.3",
"globals": "17.8.0",
"jsdom": "30.0.1",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "8.2.0",
"vitest": "4.1.10"
},
"private": true,
"type": "module"
}

View File

@ -0,0 +1,196 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it } from 'vitest'
import { DEBTS_STORAGE_KEY } from './stakes/storage'
import type { SettledDebt } from './stakes/types'
import { App } from './App'
/** In-memory Storage so each test gets an isolated ledger, never the real localStorage. */
const memoryStorage = (seed: Record<string, string> = {}): Storage => {
const map = new Map<string, string>(Object.entries(seed))
return {
get length(): number {
return map.size
},
clear: (): void => {
map.clear()
},
getItem: (key: string): string | null => map.get(key) ?? null,
key: (index: number): string | null => [...map.keys()][index] ?? null,
removeItem: (key: string): void => {
map.delete(key)
},
setItem: (key: string, value: string): void => {
map.set(key, value)
},
}
}
const debt = (overrides: Partial<SettledDebt> = {}): SettledDebt => ({
id: 'd1',
taskTypeId: 'pushups',
owedUnits: 100,
reason: 'cashOut',
settledAt: 1,
paidUnits: 0,
...overrides,
})
const storageWithDebts = (debts: SettledDebt[]): Storage =>
memoryStorage({ [DEBTS_STORAGE_KEY]: JSON.stringify(debts) })
/** BuyInScreen's pre-filled chip count — the value every test below sits down with. */
const DEFAULT_BUY_IN = 100
/** The betting controls, or null between hands / before any session exists. */
const bettingControls = (): HTMLElement | null => document.querySelector<HTMLElement>('.controls')
/** The first legal action currently offered, or null if no decision is pending. */
const firstLegalAction = (): HTMLElement | null => {
const controls = bettingControls()
if (controls === null) {
return null
}
return within(controls).getAllByRole('button')[0] ?? null
}
/** Plays hands until the session ends, always taking the first legal action offered. */
const playUntilSettled = async (user: ReturnType<typeof userEvent.setup>, maxActions = 400): Promise<void> => {
for (let i = 0; i < maxActions; i += 1) {
const deal = screen.queryByText('Deal next hand')
if (deal !== null) {
await user.click(deal)
continue
}
const action = firstLegalAction()
if (action === null) {
return
}
await user.click(action)
}
}
describe('App', () => {
beforeEach(() => {
localStorage.clear()
})
it('renders the title and the buy-in screen before any session starts', () => {
render(<App storage={memoryStorage()} seed={1} />)
expect(screen.getByText('Poker Stakes')).toBeInTheDocument()
expect(screen.getByText('Sit down')).toBeInTheDocument()
expect(screen.getByText('No debts settled yet.')).toBeInTheDocument()
})
it('defaults to the real localStorage and a clock-derived seed when no props are given', () => {
render(<App />)
expect(screen.getByText('Poker Stakes')).toBeInTheDocument()
expect(screen.getByText('Sit down')).toBeInTheDocument()
})
it('buys in and deals the opening hand straight away', async () => {
const user = userEvent.setup()
render(<App storage={memoryStorage()} seed={7} />)
await user.click(screen.getByText('Sit down'))
expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
// Sitting down deals the opening hand immediately, so the player faces a decision at once.
expect(bettingControls()).not.toBeNull()
})
it('records a debt in the history panel once the session settles', async () => {
const user = userEvent.setup()
render(<App storage={memoryStorage()} seed={7} />)
await user.click(screen.getByText('Sit down'))
await playUntilSettled(user)
// Settling returns to the buy-in screen; the debt is now in the durable ledger. It appears
// twice — once in the buy-in gate, once in the history panel — which is the intended layout.
expect(screen.queryByText('No debts settled yet.')).not.toBeInTheDocument()
expect(screen.getAllByText(/Pushups: /).length).toBeGreaterThan(0)
})
it('blocks a new buy-in while a debt is outstanding, and unblocks once it is logged off', async () => {
const user = userEvent.setup()
render(<App storage={storageWithDebts([debt({ owedUnits: 10, paidUnits: 0 })])} seed={7} />)
expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
expect(screen.getByText(/outstanding debt/)).toBeInTheDocument()
// The gate and the history panel each render a control for the same debt; either clears it.
const [gateInput] = screen.getAllByLabelText('Log units done for Pushups')
const [logButton] = screen.getAllByText('Log progress')
if (gateInput === undefined || logButton === undefined) {
throw new Error('expected the outstanding-debt controls to render')
}
await user.type(gateInput, '10')
await user.click(logButton)
expect(screen.getByText('Sit down')).toBeInTheDocument()
})
it('blocks buying in when the stored ledger is unreadable (fail-closed)', () => {
render(<App storage={memoryStorage({ [DEBTS_STORAGE_KEY]: 'not json' })} seed={7} />)
expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
// Both surfaces fail closed independently: the buy-in gate and the history panel.
expect(
screen.getByText('Your debt history could not be read. Buying in is blocked until this is resolved.'),
).toBeInTheDocument()
expect(screen.getByText('Your debt history could not be read.')).toBeInTheDocument()
})
it('cashes out between hands and settles the resulting debt', async () => {
const user = userEvent.setup()
render(<App storage={memoryStorage()} seed={7} />)
await user.click(screen.getByText('Sit down'))
// Play the opening hand to completion so we land in `betweenHands`, where cash-out is legal.
for (let i = 0; i < 200 && screen.queryByText('Deal next hand') === null; i += 1) {
const action = firstLegalAction()
if (action === null) {
break
}
await user.click(action)
}
// Pin the actual numbers: the panel previews `2*buyIn - stack`, and the settled ledger row
// must show exactly that same figure. A looser assertion here would pass whether or not a
// debt was recorded at all.
const preview = screen.getByText(/Current stack: \d+ chips\./)
const previewText = preview.textContent
const parsed = /Current stack: (\d+) chips\. Cashing out now would owe (\d+) units\./.exec(previewText)
if (parsed === null) {
throw new Error(`cash-out panel did not match the expected copy: ${previewText}`)
}
const [, stackText, owedText] = parsed
const finalChips = Number(stackText)
const previewOwed = Number(owedText)
expect(previewOwed).toBe(Math.max(0, 2 * DEFAULT_BUY_IN - finalChips))
await user.click(screen.getByText('Leave table'))
// Cashing out settles the session, so the app returns to the (now debt-gated) buy-in screen,
// and the ledger row carries the same owed figure the preview promised.
expect(screen.getByText(/outstanding debt/)).toBeInTheDocument()
expect(screen.getAllByText(`Pushups: 0 / ${String(previewOwed)} done`).length).toBeGreaterThan(0)
})
it('settles a bust at exactly twice the buy-in', async () => {
const user = userEvent.setup()
const storage = memoryStorage()
render(<App storage={storage} seed={7} />)
await user.click(screen.getByText('Sit down'))
await playUntilSettled(user)
const stored: unknown = JSON.parse(storage.getItem(DEBTS_STORAGE_KEY) ?? '[]')
expect(Array.isArray(stored)).toBe(true)
const debts = stored as SettledDebt[]
expect(debts.length).toBeGreaterThan(0)
const settled = debts[debts.length - 1]
if (settled === undefined) {
throw new Error('expected a settled debt')
}
// Seed 7 + always-take-the-first-legal-action busts deterministically, so this pins the
// bust case exactly rather than accepting either branch. Busting to zero is the formula's
// natural maximum, not a special case: 2*buyIn - 0.
expect(settled.reason).toBe('bust')
expect(settled.owedUnits).toBe(2 * DEFAULT_BUY_IN)
})
})

58
poker-stakes/src/App.tsx Normal file
View File

@ -0,0 +1,58 @@
import { useState } from 'react'
import type { ReactElement } from 'react'
import { BettingControls } from './ui/BettingControls'
import { BuyInScreen } from './ui/BuyInScreen'
import { CashOutPanel } from './ui/CashOutPanel'
import { DebtHistoryPanel } from './ui/DebtHistoryPanel'
import { HandLog } from './ui/HandLog'
import { Table } from './ui/Table'
import { useGame } from './ui/useGame'
interface AppProps {
/** Injected so tests can drive a scripted in-memory ledger; defaults to the real localStorage. */
storage?: Storage
/** Injected so tests can script a deterministic deal; defaults to a per-mount clock read. */
seed?: number
}
export const App = ({ storage = localStorage, seed }: AppProps = {}): ReactElement => {
// Lazy init: `Date.now()` must be read once per mount, not on every render — an inline default
// would make the component impure and re-seed the deck on each re-render.
const [dealSeed] = useState(() => seed ?? Date.now())
const { state, buyIn, dealHand, submitPlayerAction, cashOut, logDebtProgress } = useGame(storage, dealSeed)
const { debtLoad, buyIn: activeBuyIn, session, handPhase } = state
return (
<main className="frame">
<header className="masthead">
<h1>Poker Stakes</h1>
<p>Lose the hand, pay the price — in pushups, problems or reviews.</p>
</header>
{session === null || activeBuyIn === null ? (
<BuyInScreen debtLoad={debtLoad} onBuyIn={buyIn} onLogDebtProgress={logDebtProgress} />
) : (
<>
<Table session={session} handPhase={handPhase} />
{handPhase?.kind === 'awaiting' && (
<BettingControls betting={handPhase.step.betting} onAction={submitPlayerAction} />
)}
{/*
`handPhase` is never null while a session exists — `useGame.buyIn` deals the opening
hand as part of sitting down — so the only two states here are the awaiting/between
pair above and below.
*/}
{handPhase?.kind === 'betweenHands' && (
<>
<HandLog lastResult={handPhase.lastResult} />
<button type="button" className="button button--primary" onClick={dealHand}>
Deal next hand
</button>
</>
)}
<CashOutPanel session={session} buyIn={activeBuyIn} handPhase={handPhase} onCashOut={cashOut} />
</>
)}
<DebtHistoryPanel debtLoad={debtLoad} onLogDebtProgress={logDebtProgress} />
</main>
)
}

View File

@ -0,0 +1,244 @@
import { describe, expect, it } from 'vitest'
import { applyAction, legalActions } from './bettingRound'
import type { BettingState, SeatState } from './bettingRound'
const seat = (overrides: Partial<SeatState> = {}): SeatState => ({
stack: 100,
committed: 0,
hasActed: false,
folded: false,
allIn: false,
...overrides,
})
const state = (overrides: Partial<BettingState> = {}): BettingState => ({
street: 'preflop',
pot: 0,
toAct: 'player',
seats: { player: seat(), opponent: seat() },
betToCall: 0,
minRaiseSize: 10,
roundOver: false,
...overrides,
})
const totalChips = (s: BettingState): number =>
s.pot + s.seats.player.stack + s.seats.player.committed + s.seats.opponent.stack + s.seats.opponent.committed
describe('legalActions', () => {
it('returns no actions once the round is over', () => {
expect(legalActions(state({ roundOver: true }))).toEqual([])
})
it('returns no actions for a folded actor', () => {
expect(legalActions(state({ seats: { player: seat({ folded: true }), opponent: seat() } }))).toEqual([])
})
it('returns no actions for an all-in actor', () => {
expect(
legalActions(state({ seats: { player: seat({ allIn: true, stack: 0 }), opponent: seat() } })),
).toEqual([])
})
it('offers check (not call) when nothing is owed', () => {
const actions = legalActions(state({ betToCall: 0 }))
expect(actions).toContainEqual({ type: 'fold' })
expect(actions).toContainEqual({ type: 'check' })
expect(actions.some((a) => a.type === 'call')).toBe(false)
})
it('offers call (not check) when a bet is owed', () => {
const actions = legalActions(
state({ betToCall: 20, seats: { player: seat({ committed: 0 }), opponent: seat({ committed: 20 }) } }),
)
expect(actions).toContainEqual({ type: 'fold' })
expect(actions).toContainEqual({ type: 'call' })
expect(actions.some((a) => a.type === 'check')).toBe(false)
})
it('offers bet (not raise) as the open-action name when nothing is committed yet', () => {
const actions = legalActions(state({ betToCall: 0 }))
expect(actions.some((a) => a.type === 'bet')).toBe(true)
expect(actions.some((a) => a.type === 'raise')).toBe(false)
})
it('offers raise (not bet) as the open-action name when a bet is already in', () => {
const actions = legalActions(
state({ betToCall: 20, seats: { player: seat({ committed: 0 }), opponent: seat({ committed: 20 }) } }),
)
expect(actions.some((a) => a.type === 'raise')).toBe(true)
expect(actions.some((a) => a.type === 'bet')).toBe(false)
})
it('offers a single bet/raise size (all-in only) when stack is too small for a full min-raise', () => {
const actions = legalActions(
state({
betToCall: 20,
minRaiseSize: 50,
seats: { player: seat({ committed: 0, stack: 25 }), opponent: seat({ committed: 20 }) },
}),
)
const raises = actions.filter((a) => a.type === 'raise')
expect(raises).toHaveLength(1)
expect(raises[0]).toEqual({ type: 'raise', amount: 25 })
})
it('offers no bet/raise when the actor can only call all-in (stack equals amount owed)', () => {
const actions = legalActions(
state({
betToCall: 20,
seats: { player: seat({ committed: 0, stack: 20 }), opponent: seat({ committed: 20 }) },
}),
)
expect(actions.some((a) => a.type === 'bet' || a.type === 'raise')).toBe(false)
expect(actions).toContainEqual({ type: 'call' })
})
it('offers both a minimum and an all-in size when stack exceeds the minimum raise', () => {
const actions = legalActions(state({ betToCall: 0, minRaiseSize: 10, seats: { player: seat({ stack: 100 }), opponent: seat() } }))
const bets = actions.filter((a) => a.type === 'bet')
expect(bets).toHaveLength(2)
expect(bets[0]).toEqual({ type: 'bet', amount: 10 })
expect(bets[1]).toEqual({ type: 'bet', amount: 100 })
})
})
describe('applyAction: fold', () => {
it('marks the actor folded and ends the round', () => {
const s = state()
const next = applyAction(s, { type: 'fold' })
expect(next.seats.player.folded).toBe(true)
expect(next.roundOver).toBe(true)
expect(totalChips(next)).toBe(totalChips(s))
})
})
describe('applyAction: check', () => {
it('passes action to the other seat without ending the round if they have not acted', () => {
const s = state()
const next = applyAction(s, { type: 'check' })
expect(next.toAct).toBe('opponent')
expect(next.roundOver).toBe(false)
})
it('ends the round when both seats have checked', () => {
const s = state()
const afterPlayer = applyAction(s, { type: 'check' })
const afterOpponent = applyAction(afterPlayer, { type: 'check' })
expect(afterOpponent.roundOver).toBe(true)
expect(totalChips(afterOpponent)).toBe(totalChips(s))
})
})
describe('applyAction: call', () => {
it('matches the bet, ends the round, and preserves total chips (opponent already acted)', () => {
const s = state({
betToCall: 20,
toAct: 'player',
seats: {
player: seat({ committed: 0, stack: 100 }),
opponent: seat({ committed: 20, stack: 80, hasActed: true }),
},
})
const next = applyAction(s, { type: 'call' })
expect(next.seats.player.committed).toBe(20)
expect(next.seats.player.stack).toBe(80)
expect(next.roundOver).toBe(true)
expect(totalChips(next)).toBe(totalChips(s))
})
it('does NOT end the round when the other seat has not yet acted (heads-up big-blind option)', () => {
// SB completes preflop to match the BB's posted blind; BB has not acted this round yet.
const s = state({
betToCall: 10,
toAct: 'player',
seats: {
player: seat({ committed: 0, stack: 100 }),
opponent: seat({ committed: 10, stack: 90, hasActed: false }),
},
})
const next = applyAction(s, { type: 'call' })
expect(next.roundOver).toBe(false)
expect(next.toAct).toBe('opponent')
expect(totalChips(next)).toBe(totalChips(s))
})
it('goes all-in when the stack is smaller than the call amount owed is not offered, but a call for exactly the stack works', () => {
const s = state({
betToCall: 20,
seats: { player: seat({ committed: 0, stack: 20 }), opponent: seat({ committed: 20 }) },
})
const next = applyAction(s, { type: 'call' })
expect(next.seats.player.stack).toBe(0)
expect(next.seats.player.allIn).toBe(true)
expect(totalChips(next)).toBe(totalChips(s))
})
})
describe('applyAction: bet/raise', () => {
it('opens a bet, updates betToCall and minRaiseSize, and requires the other seat to act again', () => {
const s = state({ minRaiseSize: 10 })
const next = applyAction(s, { type: 'bet', amount: 10 })
expect(next.betToCall).toBe(10)
expect(next.minRaiseSize).toBe(10)
expect(next.toAct).toBe('opponent')
expect(next.roundOver).toBe(false)
expect(next.seats.opponent.hasActed).toBe(false)
expect(totalChips(next)).toBe(totalChips(s))
})
// A short all-in (a raise that adds LESS than the current minimum) must not lower the floor for
// whoever acts next — that is what `Math.max(state.minRaiseSize, raiseSize)` protects. Without
// the floor, a 5-chip all-in over a min-raise of 50 would let the next player re-raise by 5.
it('does not lower minRaiseSize when an all-in raise is smaller than the current minimum', () => {
const s = state({
betToCall: 20,
minRaiseSize: 50,
toAct: 'opponent',
seats: {
player: seat({ committed: 20, stack: 80, hasActed: true }),
opponent: seat({ committed: 0, stack: 25 }),
},
})
// All-in for 25 total: only 5 more than the 20 to call, well under the 50 min-raise.
const next = applyAction(s, { type: 'raise', amount: 25 })
expect(next.betToCall).toBe(25)
expect(next.minRaiseSize).toBe(50)
expect(next.seats.opponent.allIn).toBe(true)
expect(totalChips(next)).toBe(totalChips(s))
})
it('raises on top of an existing bet and tracks the new min-raise size', () => {
const s = state({
betToCall: 10,
minRaiseSize: 10,
toAct: 'opponent',
seats: { player: seat({ committed: 10, stack: 90, hasActed: true }), opponent: seat({ committed: 0, stack: 100 }) },
})
const next = applyAction(s, { type: 'raise', amount: 30 })
expect(next.betToCall).toBe(30)
expect(next.minRaiseSize).toBe(20)
expect(next.toAct).toBe('player')
expect(totalChips(next)).toBe(totalChips(s))
})
it('caps spend at the actor stack and marks all-in when betting the full stack', () => {
const s = state({ seats: { player: seat({ stack: 40 }), opponent: seat() } })
const next = applyAction(s, { type: 'bet', amount: 40 })
expect(next.seats.player.stack).toBe(0)
expect(next.seats.player.allIn).toBe(true)
expect(totalChips(next)).toBe(totalChips(s))
})
})
describe('a full betting round preserves total chips end to end', () => {
it('bet, raise, call sequence', () => {
let s = state({ minRaiseSize: 10, seats: { player: seat({ stack: 200 }), opponent: seat({ stack: 200 }) } })
const initialTotal = totalChips(s)
s = applyAction(s, { type: 'bet', amount: 20 })
s = applyAction(s, { type: 'raise', amount: 60 })
s = applyAction(s, { type: 'call' })
expect(s.roundOver).toBe(true)
expect(totalChips(s)).toBe(initialTotal)
})
})

View File

@ -0,0 +1,139 @@
import type { Seat, Street } from './types'
export type Action =
| { type: 'fold' }
| { type: 'check' }
| { type: 'call' }
| { type: 'bet'; amount: number }
| { type: 'raise'; amount: number }
export interface SeatState {
stack: number
committed: number // chips this seat has put in during the CURRENT betting round
hasActed: boolean
folded: boolean
allIn: boolean
}
export interface BettingState {
street: Street
pot: number // chips committed in PREVIOUS rounds, not yet including this round
toAct: Seat
seats: Record<Seat, SeatState>
betToCall: number // highest `committed` this round
minRaiseSize: number // minimum additional amount a raise must add beyond a call, per no-limit rules
roundOver: boolean
}
const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player')
/**
* Chips this seat still needs to add to match betToCall.
*
* The `Math.max(0, ...)` floor is defensive only: `applyAction` always sets `betToCall` to the
* highest `committed` in the round, so no seat's `committed` can exceed it and the subtraction is
* never negative in practice — the full suite passes with the floor removed. It stays because it
* costs nothing and makes the "never negative" contract local to this function rather than an
* invariant a reader has to reconstruct from every `betToCall` write site.
*/
const owedToCall = (state: BettingState, seat: Seat): number =>
Math.max(0, state.betToCall - state.seats[seat].committed)
/** Generates the legal actions for the seat to act. Illegal actions are never representable in the UI. */
export const legalActions = (state: BettingState): readonly Action[] => {
if (state.roundOver) {
return []
}
const actor = state.seats[state.toAct]
if (actor.folded || actor.allIn) {
return []
}
const toCall = owedToCall(state, state.toAct)
const actions: Action[] = [{ type: 'fold' }]
if (toCall === 0) {
actions.push({ type: 'check' })
} else {
actions.push({ type: 'call' })
}
const maxAdditional = actor.stack
const minOpenOrRaiseAdditional = toCall + state.minRaiseSize
if (maxAdditional > toCall) {
const kind = state.betToCall === 0 ? 'bet' : 'raise'
const minAdditional = Math.min(minOpenOrRaiseAdditional, maxAdditional)
// Every legal size from the minimum up to an all-in is available; expose min and all-in as
// concrete options rather than an open-ended range, since the UI drives a discrete control.
actions.push({ type: kind, amount: minAdditional })
if (maxAdditional > minAdditional) {
actions.push({ type: kind, amount: maxAdditional })
}
}
return actions
}
const applyChips = (seat: SeatState, additional: number): SeatState => {
const spend = Math.min(additional, seat.stack)
return {
...seat,
stack: seat.stack - spend,
committed: seat.committed + spend,
allIn: seat.stack - spend === 0,
}
}
/** Applies an action, returning the next BettingState. Assumes the action came from legalActions(state). */
export const applyAction = (state: BettingState, action: Action): BettingState => {
const actingSeat = state.toAct
const opponentSeat = other(actingSeat)
if (action.type === 'fold') {
return {
...state,
seats: { ...state.seats, [actingSeat]: { ...state.seats[actingSeat], folded: true, hasActed: true } },
roundOver: true,
}
}
if (action.type === 'check') {
const nextSeats: Record<Seat, SeatState> = {
...state.seats,
[actingSeat]: { ...state.seats[actingSeat], hasActed: true },
}
const bothActed = nextSeats[opponentSeat].hasActed
return { ...state, seats: nextSeats, toAct: opponentSeat, roundOver: bothActed }
}
if (action.type === 'call') {
const toCall = owedToCall(state, actingSeat)
const nextSeats: Record<Seat, SeatState> = {
...state.seats,
[actingSeat]: { ...applyChips(state.seats[actingSeat], toCall), hasActed: true },
}
// A call closes the round UNLESS the other seat has never acted this round yet (the
// heads-up big-blind-option case: SB completes preflop, BB still gets first say).
const bothActed = nextSeats[opponentSeat].hasActed
return { ...state, seats: nextSeats, toAct: opponentSeat, roundOver: bothActed }
}
// bet or raise
const nextActingSeatState: SeatState = {
...applyChips(state.seats[actingSeat], action.amount),
hasActed: true,
}
const raiseSize = nextActingSeatState.committed - state.betToCall
return {
...state,
seats: {
...state.seats,
[actingSeat]: nextActingSeatState,
[opponentSeat]: { ...state.seats[opponentSeat], hasActed: false },
},
betToCall: nextActingSeatState.committed,
minRaiseSize: Math.max(state.minRaiseSize, raiseSize),
toAct: opponentSeat,
roundOver: false,
}
}

View File

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { buildDeck, createDealCursor, dealCards, shuffleDeck } from './deck'
import { createRng } from './rng'
import type { Card } from './types'
const cardKey = (card: Card): string => `${String(card.rank)}-${card.suit}`
describe('buildDeck', () => {
it('builds exactly 52 unique cards', () => {
const deck = buildDeck()
expect(deck).toHaveLength(52)
expect(new Set(deck.map(cardKey)).size).toBe(52)
})
})
describe('shuffleDeck', () => {
it('returns a permutation of the input deck', () => {
const deck = buildDeck()
const shuffled = shuffleDeck(createRng(1), deck)
expect(shuffled).toHaveLength(52)
expect(new Set(shuffled.map(cardKey))).toEqual(new Set(deck.map(cardKey)))
})
it('is deterministic for a given seed', () => {
const deck = buildDeck()
const a = shuffleDeck(createRng(42), deck)
const b = shuffleDeck(createRng(42), deck)
expect(a).toEqual(b)
})
it('produces a different order for a different seed', () => {
const deck = buildDeck()
const a = shuffleDeck(createRng(1), deck)
const b = shuffleDeck(createRng(2), deck)
expect(a).not.toEqual(b)
})
it('does not mutate the input deck', () => {
const deck = buildDeck()
const before = [...deck]
shuffleDeck(createRng(7), deck)
expect(deck).toEqual(before)
})
})
describe('dealCards', () => {
it('deals the requested count and advances the cursor', () => {
const deck = shuffleDeck(createRng(3), buildDeck())
const cursor0 = createDealCursor(deck)
const { cards, cursor: cursor1 } = dealCards(cursor0, 2)
expect(cards).toEqual(deck.slice(0, 2))
expect(cursor1.next).toBe(2)
})
it('deals sequential batches without overlap', () => {
const deck = shuffleDeck(createRng(4), buildDeck())
const { cursor: afterHole } = dealCards(createDealCursor(deck), 4)
const { cards: flop, cursor: afterFlop } = dealCards(afterHole, 3)
expect(flop).toEqual(deck.slice(4, 7))
expect(afterFlop.next).toBe(7)
})
it('throws when asked for more cards than remain', () => {
const deck = buildDeck()
const cursor = createDealCursor(deck)
expect(() => dealCards(cursor, 53)).toThrow('not enough cards remaining')
})
})

View File

@ -0,0 +1,52 @@
import type { Rng } from './rng'
import { nextInt } from './rng'
import type { Card, Rank, Suit } from './types'
const SUITS: readonly Suit[] = ['clubs', 'diamonds', 'hearts', 'spades']
const RANKS: readonly Rank[] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
/** Builds a deterministic 52-card deck in fixed suit/rank order. */
export const buildDeck = (): readonly Card[] => {
const deck: Card[] = []
for (const suit of SUITS) {
for (const rank of RANKS) {
deck.push({ rank, suit })
}
}
return deck
}
/**
* Selection-sampling shuffle: repeatedly pulls a random remaining card into
* the output. Uses splice (never indexed reads) so no element access is ever
* typed `Card | undefined` under noUncheckedIndexedAccess.
*/
export const shuffleDeck = (rng: Rng, deck: readonly Card[]): Card[] => {
const pool: Card[] = [...deck]
const out: Card[] = []
while (pool.length > 0) {
for (const card of pool.splice(nextInt(rng, pool.length), 1)) {
out.push(card)
}
}
return out
}
export interface DealCursor {
deck: readonly Card[]
next: number
}
export const createDealCursor = (deck: readonly Card[]): DealCursor => ({ deck, next: 0 })
/** Deals `count` cards from the cursor, returning the cards and an advanced cursor. */
export const dealCards = (
cursor: DealCursor,
count: number,
): { cards: Card[]; cursor: DealCursor } => {
if (cursor.next + count > cursor.deck.length) {
throw new Error('dealCards: not enough cards remaining in deck')
}
const cards = cursor.deck.slice(cursor.next, cursor.next + count)
return { cards, cursor: { deck: cursor.deck, next: cursor.next + count } }
}

View File

@ -0,0 +1,269 @@
import { describe, expect, it } from 'vitest'
import { buildDeck } from './deck'
import { compareHandScore, evaluate7, rank5, rankLabel } from './handEval'
import type { HandCategory, HandScore } 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]
}
}
}
}
}
}
describe('rank5 exhaustive classification', () => {
it('classifies every 5-card hand out of 52 into the correct standard-count category', () => {
const deck = buildDeck()
const histogram: Record<HandCategory, number> = {
highCard: 0,
onePair: 0,
twoPair: 0,
trips: 0,
straight: 0,
flush: 0,
fullHouse: 0,
quads: 0,
straightFlush: 0,
}
let total = 0
for (const [i0, i1, i2, i3, i4] of combinations5(deck.length)) {
const hand = five([deck[i0], deck[i1], deck[i2], deck[i3], deck[i4]] as Card[])
const { category } = rank5(hand)
histogram[category] += 1
total += 1
}
expect(total).toBe(2_598_960)
// Straight/flush counts here fold the royal flush into straightFlush (A-high straight flush).
expect(histogram).toEqual({
highCard: 1_302_540,
onePair: 1_098_240,
twoPair: 123_552,
trips: 54_912,
straight: 10_200,
flush: 5_108,
fullHouse: 3_744,
quads: 624,
straightFlush: 40,
})
}, 30_000)
})
describe('rank5 named fixtures', () => {
it('scores a wheel straight (A-2-3-4-5) as 5-high', () => {
const hand = five([
card(14, 'clubs'),
card(2, 'diamonds'),
card(3, 'hearts'),
card(4, 'spades'),
card(5, 'clubs'),
])
const score = rank5(hand)
expect(score.category).toBe('straight')
expect(score.tiebreakers).toEqual([5])
})
it('scores a wheel straight flush as 5-high straight flush', () => {
const hand = five([
card(14, 'clubs'),
card(2, 'clubs'),
card(3, 'clubs'),
card(4, 'clubs'),
card(5, 'clubs'),
])
const score = rank5(hand)
expect(score.category).toBe('straightFlush')
expect(score.tiebreakers).toEqual([5])
})
it('scores a broadway (ace-high) straight correctly', () => {
const hand = five([
card(14, 'clubs'),
card(13, 'diamonds'),
card(12, 'hearts'),
card(11, 'spades'),
card(10, 'clubs'),
])
const score = rank5(hand)
expect(score.category).toBe('straight')
expect(score.tiebreakers).toEqual([14])
})
it('breaks ties between two one-pair hands by kicker', () => {
const a = rank5(
five([card(9, 'clubs'), card(9, 'diamonds'), card(14, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
)
const b = rank5(
five([card(9, 'clubs'), card(9, 'diamonds'), card(13, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
)
expect(compareHandScore(a, b)).toBe(1)
expect(compareHandScore(b, a)).toBe(-1)
})
it('breaks ties between two full houses by trips rank, not pair rank', () => {
// 2-2-2-A-A must beat 3-3-3-K-K: trips rank (2 < 3) decides, not the pair kicker (A > K).
const twosOverAces = rank5(
five([card(2, 'clubs'), card(2, 'diamonds'), card(2, 'hearts'), card(14, 'spades'), card(14, 'clubs')]),
)
const threesOverKings = rank5(
five([card(3, 'clubs'), card(3, 'diamonds'), card(3, 'hearts'), card(13, 'spades'), card(13, 'clubs')]),
)
expect(compareHandScore(threesOverKings, twosOverAces)).toBe(1)
expect(compareHandScore(twosOverAces, threesOverKings)).toBe(-1)
})
it('breaks ties between two two-pair hands by top pair, then bottom pair, then kicker', () => {
const acesAndTwos = rank5(
five([card(14, 'clubs'), card(14, 'diamonds'), card(2, 'hearts'), card(2, 'spades'), card(5, 'clubs')]),
)
const acesAndThrees = rank5(
five([card(14, 'hearts'), card(14, 'spades'), card(3, 'clubs'), card(3, 'diamonds'), card(4, 'hearts')]),
)
expect(compareHandScore(acesAndThrees, acesAndTwos)).toBe(1)
expect(compareHandScore(acesAndTwos, acesAndThrees)).toBe(-1)
})
it('breaks ties between two quads hands by the quad rank', () => {
const fours = rank5(
five([card(4, 'clubs'), card(4, 'diamonds'), card(4, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
)
const fives = rank5(
five([card(5, 'clubs'), card(5, 'diamonds'), card(5, 'hearts'), card(5, 'spades'), card(2, 'diamonds')]),
)
expect(compareHandScore(fives, fours)).toBe(1)
expect(compareHandScore(fours, fives)).toBe(-1)
})
it('breaks ties between two trips hands by trips rank then kickers', () => {
const treys = rank5(
five([card(3, 'clubs'), card(3, 'diamonds'), card(3, 'hearts'), card(14, 'spades'), card(13, 'clubs')]),
)
const fours = rank5(
five([card(4, 'clubs'), card(4, 'diamonds'), card(4, 'hearts'), card(2, 'spades'), card(3, 'clubs')]),
)
expect(compareHandScore(fours, treys)).toBe(1)
expect(compareHandScore(treys, fours)).toBe(-1)
})
it('breaks ties between two flushes by highest differing card', () => {
const aceHighFlush = rank5(
five([card(14, 'clubs'), card(9, 'clubs'), card(7, 'clubs'), card(4, 'clubs'), card(2, 'clubs')]),
)
const kingHighFlush = rank5(
five([card(13, 'diamonds'), card(10, 'diamonds'), card(8, 'diamonds'), card(5, 'diamonds'), card(3, 'diamonds')]),
)
expect(compareHandScore(aceHighFlush, kingHighFlush)).toBe(1)
expect(compareHandScore(kingHighFlush, aceHighFlush)).toBe(-1)
})
it('breaks ties between two straights by the high card', () => {
const sevenHigh = rank5(
five([card(7, 'clubs'), card(6, 'diamonds'), card(5, 'hearts'), card(4, 'spades'), card(3, 'clubs')]),
)
const eightHigh = rank5(
five([card(8, 'clubs'), card(7, 'diamonds'), card(6, 'hearts'), card(5, 'spades'), card(4, 'clubs')]),
)
expect(compareHandScore(eightHigh, sevenHigh)).toBe(1)
expect(compareHandScore(sevenHigh, eightHigh)).toBe(-1)
})
it('recognizes an exact split (identical hand scores)', () => {
const a = rank5(
five([card(9, 'clubs'), card(9, 'diamonds'), card(14, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
)
const b = rank5(
five([card(9, 'hearts'), card(9, 'spades'), card(14, 'clubs'), card(4, 'diamonds'), card(2, 'hearts')]),
)
expect(compareHandScore(a, b)).toBe(0)
})
})
describe('evaluate7', () => {
it('picks the best 5 of 7 when the best hand uses only some hole+board cards', () => {
const hand = seven([
card(14, 'clubs'),
card(14, 'diamonds'),
card(14, 'hearts'),
card(14, 'spades'),
card(2, 'clubs'),
card(3, 'diamonds'),
card(4, 'hearts'),
])
const score = evaluate7(hand)
expect(score.category).toBe('quads')
})
it('matches the best rank5 result across all 21 combinations', () => {
const cards = seven([
card(10, 'clubs'),
card(11, 'clubs'),
card(12, 'clubs'),
card(13, 'clubs'),
card(14, 'clubs'),
card(2, 'hearts'),
card(2, 'diamonds'),
])
const score = evaluate7(cards)
expect(score.category).toBe('straightFlush')
expect(score.tiebreakers).toEqual([14])
})
})
describe('compareHandScore category comparison', () => {
it('ranks a lower category below a higher one in both directions', () => {
const high: HandScore = { category: 'quads', tiebreakers: [2] }
const low: HandScore = { category: 'highCard', tiebreakers: [14] }
expect(compareHandScore(high, low)).toBe(1)
expect(compareHandScore(low, high)).toBe(-1)
})
})
describe('compareHandScore tiebreaker padding', () => {
it('treats a missing tiebreaker slot as lower than a present one', () => {
const a: HandScore = { category: 'highCard', tiebreakers: [14, 10] }
const b: HandScore = { category: 'highCard', tiebreakers: [14] }
expect(compareHandScore(a, b)).toBe(1)
expect(compareHandScore(b, a)).toBe(-1)
})
})
describe('rankLabel', () => {
it('labels face cards and ace', () => {
expect(rankLabel(14)).toBe('A')
expect(rankLabel(13)).toBe('K')
expect(rankLabel(12)).toBe('Q')
expect(rankLabel(11)).toBe('J')
})
it('labels number cards as their numeral', () => {
expect(rankLabel(10)).toBe('10')
expect(rankLabel(2)).toBe('2')
})
})

View File

@ -0,0 +1,182 @@
import type { Card, Rank } from './types'
export type HandCategory =
| 'highCard'
| 'onePair'
| 'twoPair'
| 'trips'
| 'straight'
| 'flush'
| 'fullHouse'
| 'quads'
| 'straightFlush'
const CATEGORY_RANK: Record<HandCategory, number> = {
highCard: 0,
onePair: 1,
twoPair: 2,
trips: 3,
straight: 4,
flush: 5,
fullHouse: 6,
quads: 7,
straightFlush: 8,
}
export interface HandScore {
category: HandCategory
tiebreakers: readonly number[]
}
type Five = readonly [Card, Card, Card, Card, Card]
type Seven = readonly [Card, Card, Card, Card, Card, Card, Card]
/** -1 if a < b, 0 if equal, 1 if a > b. Higher HandScore wins. */
export const compareHandScore = (a: HandScore, b: HandScore): -1 | 0 | 1 => {
const catA = CATEGORY_RANK[a.category]
const catB = CATEGORY_RANK[b.category]
if (catA !== catB) {
return catA > catB ? 1 : -1
}
const len = Math.max(a.tiebreakers.length, b.tiebreakers.length)
for (let i = 0; i < len; i += 1) {
const ta = a.tiebreakers[i] ?? 0
const tb = b.tiebreakers[i] ?? 0
if (ta !== tb) {
return ta > tb ? 1 : -1
}
}
return 0
}
const countBy = <T extends string | number>(values: readonly T[]): Map<T, number> => {
const counts = new Map<T, number>()
for (const value of values) {
counts.set(value, (counts.get(value) ?? 0) + 1)
}
return counts
}
/** Ranks sorted descending, deduped, from a set of cards. */
const descendingRanks = (cards: Five): number[] => {
const ranks = cards.map((c) => c.rank)
return [...new Set(ranks)].sort((a, b) => b - a)
}
/** Straight high card, or null if the 5 ranks (deduped) don't form a run of 5. Wheel (A-2-3-4-5) scores as 5-high. */
const straightHigh = (uniqueDescRanks: readonly number[]): number | null => {
if (uniqueDescRanks.length !== 5) {
return null
}
const [r0, r1, r2, r3, r4] = uniqueDescRanks as readonly [number, number, number, number, number]
const isRun = r0 - r1 === 1 && r1 - r2 === 1 && r2 - r3 === 1 && r3 - r4 === 1
if (isRun) {
return r0
}
const isWheel = r0 === 14 && r1 === 5 && r2 === 4 && r3 === 3 && r4 === 2
return isWheel ? 5 : null
}
/** Evaluates exactly 5 cards. */
export const rank5 = (cards: Five): HandScore => {
const suits = cards.map((c) => c.suit)
const isFlush = new Set(suits).size === 1
const rankCounts = countBy(cards.map((c) => c.rank))
const uniqueDesc = descendingRanks(cards)
const straightTop = straightHigh(uniqueDesc)
const groups = [...rankCounts.entries()].sort((a, b) => {
if (a[1] !== b[1]) {
return b[1] - a[1]
}
return b[0] - a[0]
})
const groupCounts = groups.map(([, count]) => count)
const groupRanks = groups.map(([rank]) => rank)
if (isFlush && straightTop !== null) {
return { category: 'straightFlush', tiebreakers: [straightTop] }
}
if (groupCounts[0] === 4) {
return { category: 'quads', tiebreakers: groupRanks }
}
if (groupCounts[0] === 3 && groupCounts[1] === 2) {
return { category: 'fullHouse', tiebreakers: groupRanks }
}
if (isFlush) {
return { category: 'flush', tiebreakers: uniqueDesc }
}
if (straightTop !== null) {
return { category: 'straight', tiebreakers: [straightTop] }
}
if (groupCounts[0] === 3) {
return { category: 'trips', tiebreakers: groupRanks }
}
if (groupCounts[0] === 2 && groupCounts[1] === 2) {
return { category: 'twoPair', tiebreakers: groupRanks }
}
if (groupCounts[0] === 2) {
return { category: 'onePair', tiebreakers: groupRanks }
}
return { category: 'highCard', tiebreakers: uniqueDesc }
}
type FiveIndices = readonly [number, number, number, number, number]
/** All C(7,5) = 21 five-card index combinations out of positions 0-6, written as a literal tuple. */
const SEVEN_CHOOSE_FIVE: readonly [FiveIndices, ...FiveIndices[]] = [
[0, 1, 2, 3, 4],
[0, 1, 2, 3, 5],
[0, 1, 2, 3, 6],
[0, 1, 2, 4, 5],
[0, 1, 2, 4, 6],
[0, 1, 2, 5, 6],
[0, 1, 3, 4, 5],
[0, 1, 3, 4, 6],
[0, 1, 3, 5, 6],
[0, 1, 4, 5, 6],
[0, 2, 3, 4, 5],
[0, 2, 3, 4, 6],
[0, 2, 3, 5, 6],
[0, 2, 4, 5, 6],
[0, 3, 4, 5, 6],
[1, 2, 3, 4, 5],
[1, 2, 3, 4, 6],
[1, 2, 3, 5, 6],
[1, 2, 4, 5, 6],
[1, 3, 4, 5, 6],
[2, 3, 4, 5, 6],
]
/** Best 5-card HandScore out of exactly 7 cards. */
export const evaluate7 = (cards: Seven): HandScore => {
const [firstCombo, ...restCombos] = SEVEN_CHOOSE_FIVE
const fiveFor = (combo: FiveIndices): Five =>
[cards[combo[0]], cards[combo[1]], cards[combo[2]], cards[combo[3]], cards[combo[4]]] as Five
let best = rank5(fiveFor(firstCombo))
for (const combo of restCombos) {
const score = rank5(fiveFor(combo))
if (compareHandScore(score, best) > 0) {
best = score
}
}
return best
}
export const rankLabel = (rank: Rank): string => {
if (rank === 14) {
return 'A'
}
if (rank === 13) {
return 'K'
}
if (rank === 12) {
return 'Q'
}
if (rank === 11) {
return 'J'
}
return String(rank)
}

View File

@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import type { Action, BettingState } from './bettingRound'
import { legalActions } from './bettingRound'
import { playHand } from './handFlow'
import type { HandSetup } from './handFlow'
import { createRng } from './rng'
import type { Seat } from './types'
const setup = (overrides: Partial<HandSetup> = {}): HandSetup => ({
buttonSeat: 'player',
bigBlind: 10,
smallBlind: 5,
stacks: { player: 200, opponent: 200 },
...overrides,
})
const totalStack = (setupArg: HandSetup): number => setupArg.stacks.player + setupArg.stacks.opponent
/** Wraps a decide() callback, asserting the returned action is always one legalActions offered. */
const enforceLegal = (
decide: (betting: BettingState, seat: Seat) => Action,
): ((betting: BettingState, seat: Seat) => Action) => {
return (betting, seat) => {
const action = decide(betting, seat)
expect(legalActions(betting)).toContainEqual(action)
return action
}
}
/** Builds a decide() callback from a fixed queue of actions, applied in call order regardless of seat. */
const scripted = (actions: readonly Action[]): ((betting: BettingState, seat: Seat) => Action) => {
const queue = [...actions]
return enforceLegal(() => {
const next = queue.shift()
if (next === undefined) {
throw new Error('scripted: ran out of scripted actions')
}
return next
})
}
/** Always checks/calls — used to run a hand to showdown with minimal betting. */
const alwaysCheckOrCall = enforceLegal((betting) => {
const toCall = betting.betToCall - betting.seats[betting.toAct].committed
return toCall > 0 ? { type: 'call' } : { type: 'check' }
})
describe('playHand: showdown', () => {
it('runs a full hand to showdown and awards the pot to the better hand', () => {
const s = setup()
const result = playHand(createRng(1), s, { decide: alwaysCheckOrCall })
expect(result.wentToShowdown).toBe(true)
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
expect(['player', 'opponent', 'split']).toContain(result.winner)
})
it('is deterministic for a fixed seed', () => {
const s = setup()
const a = playHand(createRng(99), s, { decide: alwaysCheckOrCall })
const b = playHand(createRng(99), s, { decide: alwaysCheckOrCall })
expect(a).toEqual(b)
})
})
describe('playHand: fold preflop', () => {
it('awards the pot to the non-folder and refunds nothing beyond the folded blind', () => {
const s = setup()
const decide = scripted([{ type: 'fold' }])
const result = playHand(createRng(2), s, { decide })
expect(result.wentToShowdown).toBe(false)
expect(result.loserSeat).toBe('player')
expect(result.winner).toBe('opponent')
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
})
})
describe('playHand: fold postflop', () => {
it('carries the pot across streets and awards it on a later fold', () => {
const s = setup()
// Preflop: player calls, opponent checks (round ends). Flop: opponent bets, player folds.
const decide = scripted([{ type: 'call' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'fold' }])
const result = playHand(createRng(3), s, { decide })
expect(result.wentToShowdown).toBe(false)
expect(result.loserSeat).toBe('player')
expect(result.potAwarded).toBeGreaterThanOrEqual(s.bigBlind * 2)
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
})
})
describe('playHand: early all-in preserves total chips', () => {
/** Shoves all-in whenever a bet/raise is legal, otherwise calls/checks — drives every hand to an all-in quickly. */
const shoveOrCheckCall = enforceLegal((betting, seat) => {
const actions = legalActions(betting)
const shove = actions.find((a) => a.type === 'bet' || a.type === 'raise')
if (shove !== undefined) {
return shove
}
return alwaysCheckOrCall(betting, seat)
})
it('handles a short-stacked all-in preflop and resolves at showdown', () => {
const s = setup({ stacks: { player: 15, opponent: 200 } })
const result = playHand(createRng(4), s, { decide: shoveOrCheckCall })
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
expect(result.wentToShowdown).toBe(true)
})
})
describe('playHand: fold — opponent as loser', () => {
it('awards the pot to the player when the opponent folds', () => {
const s = setup()
// Preflop: player calls, opponent gets the option and folds.
const decide = scripted([{ type: 'call' }, { type: 'fold' }])
const result = playHand(createRng(6), s, { decide })
expect(result.loserSeat).toBe('opponent')
expect(result.winner).toBe('player')
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
})
})
describe('playHand: uncalled excess is returned in both directions', () => {
it('returns excess to the opponent when the opponent bets more and the player folds', () => {
const s = setup()
// Preflop: call, check. Flop: opponent bets, player folds — opponent's committed ends up
// higher than player's, exercising the "opponent higher" branch of returnUncalledExcess.
const decide = scripted([{ type: 'call' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'fold' }])
const result = playHand(createRng(3), s, { decide })
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
})
it('returns excess to the player when the player bets more and the opponent folds', () => {
const s = setup()
// Preflop: call, check. Flop: opponent checks first, player bets, opponent folds — player's
// committed ends up higher, exercising the "player higher" branch of returnUncalledExcess.
const decide = scripted([{ type: 'call' }, { type: 'check' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'fold' }])
const result = playHand(createRng(3), s, { decide })
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
})
})
describe('playHand: split pot', () => {
it('splits the pot evenly on an exact tie (deterministic seed)', () => {
// Seed 15 with check/call-only play produces a genuine board-play tie at showdown.
const s = setup()
const result = playHand(createRng(15), s, { decide: alwaysCheckOrCall })
expect(result.winner).toBe('split')
expect(result.potAwarded % 2).toBe(0)
expect(result.finalStacks).toEqual({ player: 200, opponent: 200 })
})
it('conserves total chips across many seeds regardless of outcome', () => {
const s = setup()
for (let seed = 1; seed <= 50; seed += 1) {
const result = playHand(createRng(seed), s, { decide: alwaysCheckOrCall })
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
expect(result.potAwarded % 2).toBe(0)
}
})
})
describe('playHand: button rotation', () => {
it('lets the opponent be the button/small blind and act first preflop', () => {
const s = setup({ buttonSeat: 'opponent' })
const result = playHand(createRng(5), s, { decide: alwaysCheckOrCall })
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
})
})
describe('playHand: a seat all-in from a blind post never acts again', () => {
it('never offers a decision to a 0-stack seat on a later street (button posts SB all-in)', () => {
// Button posts smallBlind=1 from a 1-chip stack, going all-in preflop. A fresh, buggy
// `allIn: false` reset on flop/turn/river would let this 0-stack seat fold/check again.
const s = setup({ buttonSeat: 'player', bigBlind: 2, smallBlind: 1, stacks: { player: 1, opponent: 200 } })
const seenActingWithZeroStack: Seat[] = []
const decide = (betting: BettingState, seat: Seat): Action => {
if (betting.seats[seat].stack === 0) {
seenActingWithZeroStack.push(seat)
}
return alwaysCheckOrCall(betting, seat)
}
const result = playHand(createRng(1), s, { decide })
expect(seenActingWithZeroStack).toEqual([])
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
})
it('never offers a decision to a 0-stack seat on a later street (non-button posts BB all-in)', () => {
const s = setup({ buttonSeat: 'player', bigBlind: 2, smallBlind: 1, stacks: { player: 200, opponent: 2 } })
const seenActingWithZeroStack: Seat[] = []
const decide = (betting: BettingState, seat: Seat): Action => {
if (betting.seats[seat].stack === 0) {
seenActingWithZeroStack.push(seat)
}
return alwaysCheckOrCall(betting, seat)
}
const result = playHand(createRng(1), s, { decide })
expect(seenActingWithZeroStack).toEqual([])
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
})
})

View File

@ -0,0 +1,252 @@
import type { Action, BettingState, SeatState } 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'
export interface HeadsUpStacks {
player: number
opponent: number
}
export interface HandSetup {
buttonSeat: Seat // heads-up: button posts the small blind and acts first preflop
bigBlind: number
smallBlind: number
stacks: HeadsUpStacks
}
export interface HandResult {
winner: Seat | 'split'
potAwarded: number
wentToShowdown: boolean
finalStacks: HeadsUpStacks
loserSeat: Seat | null
board: readonly Card[]
/** Both seats' hole cards, but ONLY at a real showdown — a folder's cards are never revealed. */
revealedHoleCards: Record<Seat, readonly Card[]> | null
}
const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player')
const STREET_ORDER: readonly Street[] = ['preflop', 'flop', 'turn', 'river']
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 {
/**
* Called whenever it's a seat's turn; must return one of legalActions(betting). Receives ONLY
* the acting seat's own hole cards — never the opponent's — so a decision function can never
* see cards it shouldn't, by construction rather than by discipline.
*/
decide: (betting: BettingState, seat: Seat, board: readonly Card[], ownHoleCards: readonly Card[]) => Action
}
/** One paused decision point within a hand: who must act, and on what they may see. */
export interface HandStep {
betting: BettingState
seat: Seat
board: readonly Card[]
ownHoleCards: readonly Card[]
}
/**
* Runs a single hand end-to-end as a generator: posts blinds, deals hole cards, and pauses at
* every decision point (`yield`ing a HandStep) instead of calling a callback directly. The caller
* resumes it by passing the chosen Action into `.next(action)`. This lets a UI drive the hand one
* decision at a time (suspend on the player's turn, wait for a click) without making the engine
* itself async — it's suspension, not IO.
*/
export function* playHandSteps(rng: Rng, setup: HandSetup): Generator<HandStep, HandResult, Action> {
const deck = shuffleDeck(rng, buildDeck())
let cursor = createDealCursor(deck)
const dealtPlayer = dealCards(cursor, 2)
cursor = dealtPlayer.cursor
const dealtOpponent = dealCards(cursor, 2)
cursor = dealtOpponent.cursor
const holeCards: Record<Seat, readonly Card[]> = {
player: dealtPlayer.cards,
opponent: dealtOpponent.cards,
}
let board: Card[] = []
let betting = initialBettingState(setup, 'preflop', 0, setup.stacks)
for (const street of STREET_ORDER) {
if (street !== 'preflop') {
const settled = returnUncalledExcess(betting)
const communityCount = street === 'flop' ? 3 : 1
const dealt = dealCards(cursor, communityCount)
cursor = dealt.cursor
board = [...board, ...dealt.cards]
betting = initialBettingState(setup, street, potFromBetting(settled), stacksFromBetting(settled))
}
while (legalActions(betting).length > 0) {
const action = yield { betting, seat: betting.toAct, board, ownHoleCards: holeCards[betting.toAct] }
betting = applyAction(betting, action)
}
if (betting.seats.player.folded || betting.seats.opponent.folded) {
return resolveByFold(betting, board)
}
}
return resolveByShowdown(betting, board, holeCards)
}
/**
* Runs a single hand end-to-end: posts blinds, deals hole cards, runs betting rounds interleaved
* with community-card deals, and resolves the pot at fold or showdown. Thin synchronous driver
* over `playHandSteps` for callers (tests, the PC-only path) that don't need to suspend mid-hand.
*/
export const playHand = (rng: Rng, setup: HandSetup, callbacks: HandFlowCallbacks): HandResult => {
const gen = playHandSteps(rng, setup)
let step = gen.next()
while (!step.done) {
const { betting, seat, board, ownHoleCards } = step.value
step = gen.next(callbacks.decide(betting, seat, board, ownHoleCards))
}
return step.value
}
const resolveByFold = (betting: BettingState, board: readonly Card[]): HandResult => {
const settled = returnUncalledExcess(betting)
const pot = potFromBetting(settled)
const loserSeat: Seat = settled.seats.player.folded ? 'player' : 'opponent'
const winnerSeat = other(loserSeat)
const finalStacks: HeadsUpStacks = {
player: settled.seats.player.stack + (winnerSeat === 'player' ? pot : 0),
opponent: settled.seats.opponent.stack + (winnerSeat === 'opponent' ? pot : 0),
}
// A folder's cards are never revealed, even to show what they held.
return { winner: winnerSeat, potAwarded: pot, wentToShowdown: false, finalStacks, loserSeat, board, revealedHoleCards: null }
}
const resolveByShowdown = (
betting: BettingState,
board: readonly Card[],
holeCards: Record<Seat, readonly Card[]>,
): HandResult => {
const settled = returnUncalledExcess(betting)
const pot = potFromBetting(settled)
const playerCards = [...holeCards.player, ...board] as [Card, Card, Card, Card, Card, Card, Card]
const opponentCards = [...holeCards.opponent, ...board] as [Card, Card, Card, Card, Card, Card, Card]
const playerScore: HandScore = evaluate7(playerCards)
const opponentScore: HandScore = evaluate7(opponentCards)
const cmp = compareHandScore(playerScore, opponentScore)
const stacks = stacksFromBetting(settled)
if (cmp === 0) {
// Split pot: heads-up play always returns uncalled excess before showdown, so both seats'
// committed amounts are equal and the pot is provably even — no odd-chip remainder rule needed.
const half = pot / 2
return {
winner: 'split',
potAwarded: pot,
wentToShowdown: true,
finalStacks: {
player: stacks.player + half,
opponent: stacks.opponent + half,
},
loserSeat: null,
board,
revealedHoleCards: holeCards,
}
}
const winnerSeat: Seat = cmp > 0 ? 'player' : 'opponent'
const loserSeat = other(winnerSeat)
return {
winner: winnerSeat,
potAwarded: pot,
wentToShowdown: true,
finalStacks: {
player: stacks.player + (winnerSeat === 'player' ? pot : 0),
opponent: stacks.opponent + (winnerSeat === 'opponent' ? pot : 0),
},
loserSeat,
board,
revealedHoleCards: holeCards,
}
}

View File

@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import { legalActions } from './bettingRound'
import type { HandSetup } from './handFlow'
import { startInteractiveHand } from './interactiveHand'
import { createRng } from './rng'
const setup = (overrides: Partial<HandSetup> = {}): HandSetup => ({
buttonSeat: 'player',
bigBlind: 10,
smallBlind: 5,
stacks: { player: 200, opponent: 200 },
...overrides,
})
const totalStack = (setupArg: HandSetup): number => setupArg.stacks.player + setupArg.stacks.opponent
/** Drives an interactive hand to completion always checking/calling on the player's turns. */
const runToCompletion = (hand: ReturnType<typeof startInteractiveHand>): void => {
while (hand.status.kind === 'awaiting') {
const betting = hand.status.step.betting
const toCall = betting.betToCall - betting.seats.player.committed
hand.submit(toCall > 0 ? { type: 'call' } : { type: 'check' })
}
}
describe('startInteractiveHand', () => {
it('pauses on the player seat only, never surfacing the opponent as awaiting', () => {
const s = setup()
const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
expect(hand.status.kind).toBe('awaiting')
if (hand.status.kind !== 'awaiting') {
throw new Error('unreachable: asserted above')
}
expect(hand.status.step.seat).toBe('player')
})
it('every offered awaiting action is legal for the current betting state', () => {
const s = setup()
const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
while (hand.status.kind === 'awaiting') {
const { step } = hand.status
const legal = legalActions(step.betting)
const toCall = step.betting.betToCall - step.betting.seats.player.committed
const action = toCall > 0 ? { type: 'call' as const } : { type: 'check' as const }
expect(legal).toContainEqual(action)
hand.submit(action)
}
})
it('runs a full hand to completion via alternating player submits and internal opponent turns', () => {
const s = setup()
const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
runToCompletion(hand)
expect(hand.status.kind).toBe('complete')
if (hand.status.kind !== 'complete') {
throw new Error('unreachable: asserted above')
}
const { result } = hand.status
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
})
it('resolves a preflop player fold without ever pausing again', () => {
const s = setup()
const hand = startInteractiveHand(createRng(2), createRng(2 ^ 0x9e3779b9), s, 1)
expect(hand.status.kind).toBe('awaiting')
hand.submit({ type: 'fold' })
expect(hand.status.kind).toBe('complete')
if (hand.status.kind !== 'complete') {
throw new Error('unreachable: asserted above')
}
expect(hand.status.result.loserSeat).toBe('player')
})
it('throws if submit is called with no decision pending', () => {
const s = setup()
const hand = startInteractiveHand(createRng(2), createRng(2 ^ 0x9e3779b9), s, 1)
hand.submit({ type: 'fold' })
expect(hand.status.kind).toBe('complete')
expect(() => {
hand.submit({ type: 'fold' })
}).toThrow('no player decision is pending')
})
it('is deterministic for fixed deal and policy seeds', () => {
const s = setup()
const a = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
runToCompletion(a)
const b = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
runToCompletion(b)
expect(a.status).toEqual(b.status)
})
it('changing opponent iterations does not change the deal (dealRng and policyRng are independent)', () => {
const s = setup()
const collectBoards = (opponentIterations: number): unknown[] => {
const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, opponentIterations)
const boards: unknown[] = []
while (hand.status.kind === 'awaiting') {
const { step } = hand.status
boards.push(step.board)
const toCall = step.betting.betToCall - step.betting.seats.player.committed
hand.submit(toCall > 0 ? { type: 'call' } : { type: 'check' })
}
return boards
}
// Different iteration counts consume policyRng differently; if it leaked into dealing, the
// board sequence collected at each player-decision pause would diverge between the two runs.
expect(collectBoards(1)).toEqual(collectBoards(5))
})
})

View File

@ -0,0 +1,64 @@
import type { Action } from './bettingRound'
import { legalActions } from './bettingRound'
import type { HandResult, HandSetup, HandStep } from './handFlow'
import { playHandSteps } from './handFlow'
import { decideAction } from './opponentPolicy'
import type { Rng } from './rng'
import type { Card } from './types'
/**
* Exactly one of the two states holds at any time — a discriminated union instead of two
* independently-nullable fields, so "both null" / "both set" are unrepresentable rather than
* defensive-but-unreachable states a caller has to guard against.
*/
export type HandStatus = { kind: 'awaiting'; step: HandStep } | { kind: 'complete'; result: HandResult }
export interface InteractiveHand {
readonly status: HandStatus
/** Submits the player's action for the current `awaiting` step, resolves any opponent turns, and advances. */
submit: (action: Action) => void
}
/**
* Drives `playHandSteps` to completion, resolving every OPPONENT decision point internally via
* `decideAction` (using its own policy RNG, separate from the dealing RNG so tuning `iterations`
* never perturbs deal order) and only ever pausing at `status.step` on the PLAYER's turn. This is
* the seam a UI drives: read `status`, call `submit(action)` on a click, repeat until `complete`.
*/
export const startInteractiveHand = (
dealRng: Rng,
policyRng: Rng,
setup: HandSetup,
opponentIterations: number,
): InteractiveHand => {
const gen = playHandSteps(dealRng, setup)
const advance = (next: IteratorResult<HandStep, HandResult>): HandStatus => {
let step = next
while (!step.done) {
if (step.value.seat === 'player') {
return { kind: 'awaiting', step: step.value }
}
const { betting, board, ownHoleCards } = step.value
const legal = legalActions(betting)
const ownHole = ownHoleCards as [Card, Card]
const action = decideAction(betting, legal, ownHole, board, policyRng, opponentIterations)
step = gen.next(action)
}
return { kind: 'complete', result: step.value }
}
let status: HandStatus = advance(gen.next())
return {
get status(): HandStatus {
return status
},
submit: (action: Action): void => {
if (status.kind !== 'awaiting') {
throw new Error('startInteractiveHand: no player decision is pending')
}
status = advance(gen.next(action))
},
}
}

View File

@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import { legalActions } from './bettingRound'
import { startInteractiveHand } from './interactiveHand'
import { createRng } from './rng'
import { applyHandResult, cashOutSession, deriveBlindSize, startSession } from './session'
import { settleSession } from '../stakes/settlement'
const BUY_IN_CHIPS = 100
const SEEDS = 40
const MAX_HANDS = 400
const POLICY_SEED_SALT = 0x9e3779b9
/**
* Property sweep over many seeds, complementing the scripted per-module tests: those pin specific
* scenarios, this asserts the properties that must hold for EVERY session the engine can produce.
*
* Chip conservation is the important one — a heads-up table is a closed system, so if chips are
* ever created or destroyed the stakes model is compromised (the settlement formula reads
* `finalChips` directly, so invented chips would become forgiven debt).
*/
describe('engine invariants across many seeds', () => {
it('conserves chips, keeps stacks non-negative, and always settles by the formula', () => {
const failures: string[] = []
let totalDecisions = 0
let sessionsEnded = 0
for (let seed = 1; seed <= SEEDS; seed += 1) {
let session = startSession({ buyInChips: BUY_IN_CHIPS })
const dealRng = createRng(seed)
const policyRng = createRng(seed ^ POLICY_SEED_SALT)
const tableTotal = session.stacks.player + session.stacks.opponent
for (let hand = 0; hand < MAX_HANDS && session.outcome === null; hand += 1) {
const { bigBlind, smallBlind } = deriveBlindSize(BUY_IN_CHIPS)
const interactive = startInteractiveHand(
dealRng,
policyRng,
{ buttonSeat: session.buttonSeat, bigBlind, smallBlind, stacks: session.stacks },
50,
)
// Always take the first legal action — an arbitrary but deterministic strategy. The
// invariants below must hold regardless of how badly the player plays.
let guard = 0
while (interactive.status.kind === 'awaiting' && guard < 200) {
const [first] = legalActions(interactive.status.step.betting)
if (first === undefined) {
break
}
interactive.submit(first)
totalDecisions += 1
guard += 1
}
const status = interactive.status
if (status.kind !== 'complete') {
failures.push(`seed ${String(seed)} hand ${String(hand)}: hand never completed`)
break
}
session = applyHandResult(session, status.result)
const sum = session.stacks.player + session.stacks.opponent
if (sum !== tableTotal) {
failures.push(`seed ${String(seed)} hand ${String(hand)}: chips ${String(sum)} != ${String(tableTotal)}`)
}
if (session.stacks.player < 0 || session.stacks.opponent < 0) {
failures.push(`seed ${String(seed)} hand ${String(hand)}: negative stack`)
}
}
if (session.outcome !== null) {
sessionsEnded += 1
}
const ended = session.outcome !== null ? session : cashOutSession(session)
const { outcome } = ended
if (outcome !== null) {
const debt = settleSession({ taskTypeId: 'pushups', chips: BUY_IN_CHIPS }, outcome, 'id', 0)
const expected = Math.max(0, 2 * BUY_IN_CHIPS - outcome.finalChips)
if (debt.owedUnits !== expected) {
failures.push(`seed ${String(seed)}: owed ${String(debt.owedUnits)} != ${String(expected)}`)
}
if (debt.owedUnits > 2 * BUY_IN_CHIPS) {
failures.push(`seed ${String(seed)}: owed ${String(debt.owedUnits)} exceeds the 2x cap`)
}
}
}
// Guards against the sweep silently doing nothing (an empty loop would otherwise "pass").
expect(totalDecisions).toBeGreaterThan(500)
expect(sessionsEnded).toBe(SEEDS)
expect(failures.slice(0, 10)).toEqual([])
})
})

View File

@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import type { Action, BettingState, SeatState } from './bettingRound'
import { legalActions } from './bettingRound'
import { chooseAction, decideAction, estimateEquity, potOddsToCall } from './opponentPolicy'
import { createRng } from './rng'
import type { Card } from './types'
const card = (rank: Card['rank'], suit: Card['suit']): Card => ({ rank, suit })
const seat = (overrides: Partial<SeatState> = {}): SeatState => ({
stack: 100,
committed: 0,
hasActed: false,
folded: false,
allIn: false,
...overrides,
})
const betting = (overrides: Partial<BettingState> = {}): BettingState => ({
street: 'preflop',
pot: 0,
toAct: 'player',
seats: { player: seat(), opponent: seat() },
betToCall: 0,
minRaiseSize: 10,
roundOver: false,
...overrides,
})
describe('chooseAction', () => {
it('bets/raises the max size on strong equity when available', () => {
const legal: Action[] = [{ type: 'fold' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'bet', amount: 100 }]
expect(chooseAction(legal, 0.9, 0.2)).toEqual({ type: 'bet', amount: 100 })
})
it('bets/raises the min size on moderate equity when strong-equity threshold is not met', () => {
const legal: Action[] = [{ type: 'fold' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'bet', amount: 100 }]
expect(chooseAction(legal, 0.6, 0.2)).toEqual({ type: 'bet', amount: 10 })
})
it('calls/checks when equity clears pot odds but is below the aggressive thresholds', () => {
const legal: Action[] = [{ type: 'fold' }, { type: 'call' }]
expect(chooseAction(legal, 0.3, 0.2)).toEqual({ type: 'call' })
})
it('folds when equity is below pot odds and folding is available', () => {
const legal: Action[] = [{ type: 'fold' }, { type: 'call' }]
expect(chooseAction(legal, 0.1, 0.5)).toEqual({ type: 'fold' })
})
it('checks for free (no fold available, e.g. facing no bet) when equity is below thresholds', () => {
const legal: Action[] = [{ type: 'check' }]
expect(chooseAction(legal, 0.1, 0)).toEqual({ type: 'check' })
})
it('falls back to the only legal action when nothing else matches', () => {
const legal: Action[] = [{ type: 'call' }]
// High equity but no aggressive option and no fold available: falls through to passive.
expect(chooseAction(legal, 0.95, 0.5)).toEqual({ type: 'call' })
})
it('picks the first legal action as a last resort when only aggressive options exist above threshold gating', () => {
// Only a single bet is legal and equity/pot-odds are both very low with no fold/passive offered.
const legal: Action[] = [{ type: 'bet', amount: 10 }]
expect(chooseAction(legal, 0, 1)).toEqual({ type: 'bet', amount: 10 })
})
it('checks/calls when equity is low and there is no fold option available (facing an all-in check)', () => {
// No fold offered (e.g. already all-in facing no further decision except check) — falls
// through the "fold" branch to the passive one instead.
const legal: Action[] = [{ type: 'check' }]
expect(chooseAction(legal, 0, 1)).toEqual({ type: 'check' })
})
it('throws when given no legal actions at all', () => {
expect(() => chooseAction([], 0.5, 0.5)).toThrow('no legal actions to choose from')
})
})
describe('potOddsToCall', () => {
it('is zero when nothing is owed', () => {
expect(potOddsToCall(betting({ betToCall: 0 }))).toBe(0)
})
it('computes the fraction of the resulting pot the call would represent', () => {
const s = betting({
betToCall: 20,
pot: 0,
seats: { player: seat({ committed: 0 }), opponent: seat({ committed: 20 }) },
})
// toCall = 20, potAfterCall = 0 + 0 + 20 + 20 = 40, odds = 20/40 = 0.5
expect(potOddsToCall(s)).toBe(0.5)
})
})
describe('estimateEquity', () => {
it('returns a value in [0, 1]', () => {
const ownHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
const equity = estimateEquity(ownHole, [], createRng(1), 50)
expect(equity).toBeGreaterThanOrEqual(0)
expect(equity).toBeLessThanOrEqual(1)
})
it('is deterministic for a fixed seed', () => {
const ownHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
const a = estimateEquity(ownHole, [], createRng(7), 30)
const b = estimateEquity(ownHole, [], createRng(7), 30)
expect(a).toBe(b)
})
it('returns 0 for zero iterations', () => {
const ownHole: [Card, Card] = [card(2, 'clubs'), card(3, 'diamonds')]
expect(estimateEquity(ownHole, [], createRng(1), 0)).toBe(0)
})
it('rates pocket aces higher than a weak hand on average, over many samples', () => {
const strongHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
const weakHole: [Card, Card] = [card(2, 'hearts'), card(7, 'spades')]
const strongEquity = estimateEquity(strongHole, [], createRng(3), 200)
const weakEquity = estimateEquity(weakHole, [], createRng(3), 200)
expect(strongEquity).toBeGreaterThan(weakEquity)
})
it('estimates equity with a partially dealt board (flop known)', () => {
const ownHole: [Card, Card] = [card(14, 'clubs'), card(13, 'clubs')]
const board: Card[] = [card(12, 'clubs'), card(11, 'clubs'), card(2, 'hearts')]
const equity = estimateEquity(ownHole, board, createRng(5), 40)
expect(equity).toBeGreaterThanOrEqual(0)
expect(equity).toBeLessThanOrEqual(1)
})
})
describe('decideAction', () => {
it('always returns one of the offered legal actions', () => {
const s = betting({ betToCall: 0, minRaiseSize: 10, seats: { player: seat({ stack: 100 }), opponent: seat() } })
const legal = legalActions(s)
const ownHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
const action = decideAction(s, legal, ownHole, [], createRng(1), 20)
expect(legal).toContainEqual(action)
})
})

View File

@ -0,0 +1,105 @@
import type { Action, BettingState } from './bettingRound'
import { buildDeck, shuffleDeck } from './deck'
import { compareHandScore, evaluate7 } from './handEval'
import type { Rng } from './rng'
import type { Card } from './types'
const cardKey = (card: Card): string => `${String(card.rank)}-${card.suit}`
/**
* Monte Carlo equity estimate: samples `iterations` plausible completions of the unknown cards
* (opponent's hole cards drawn from this seat's perspective, plus any remaining board cards) and
* returns the fraction of samples this seat's final hand wins or ties.
*
* Only ever sees `ownHole` and the current `board` — never the real opponent's hole cards.
*/
export const estimateEquity = (
ownHole: readonly [Card, Card],
board: readonly Card[],
rng: Rng,
iterations: number,
): number => {
const known = new Set([...ownHole, ...board].map(cardKey))
const remaining = buildDeck().filter((card) => !known.has(cardKey(card)))
const boardCardsNeeded = 5 - board.length
let wins = 0
for (let i = 0; i < iterations; i += 1) {
const shuffled = shuffleDeck(rng, remaining)
const opponentHole = shuffled.slice(0, 2) as [Card, Card]
const remainingBoard = shuffled.slice(2, 2 + boardCardsNeeded)
const fullBoard = [...board, ...remainingBoard]
const ownSeven = [...ownHole, ...fullBoard] as [Card, Card, Card, Card, Card, Card, Card]
const oppSeven = [...opponentHole, ...fullBoard] as [Card, Card, Card, Card, Card, Card, Card]
const cmp = compareHandScore(evaluate7(ownSeven), evaluate7(oppSeven))
if (cmp >= 0) {
wins += 1
}
}
return iterations === 0 ? 0 : wins / iterations
}
/**
* Picks one of the legal actions using a hand-strength/pot-odds heuristic. Never constructs an
* action outside `legal` — the returned action is always a member of that list.
*/
export const chooseAction = (legal: readonly Action[], equity: number, potOdds: number): Action => {
const fold = legal.find((a) => a.type === 'fold')
const passive = legal.find((a) => a.type === 'check' || a.type === 'call')
const aggressiveActions = legal.filter((a) => a.type === 'bet' || a.type === 'raise')
const minAggressive = aggressiveActions[0]
const maxAggressive = aggressiveActions[aggressiveActions.length - 1]
// Strong equity: bet/raise big when possible, otherwise take whatever passive/fold action is offered.
if (equity >= 0.7 && maxAggressive !== undefined) {
return maxAggressive
}
// Moderate equity: open/raise a standard (minimum) size when possible.
if (equity >= 0.55 && minAggressive !== undefined) {
return minAggressive
}
// Equity clears the price being laid: call/check rather than fold.
if (equity >= potOdds && passive !== undefined) {
return passive
}
// Equity doesn't justify continuing: fold if there's a cost to continue, else check for free.
if (fold !== undefined) {
return fold
}
if (passive !== undefined) {
return passive
}
const fallback = legal[0]
if (fallback === undefined) {
throw new Error('chooseAction: no legal actions to choose from')
}
return fallback
}
/** Computes pot odds: the price (as equity fraction) required to profitably call the amount owed. */
export const potOddsToCall = (betting: BettingState): number => {
const toCall = betting.betToCall - betting.seats[betting.toAct].committed
if (toCall <= 0) {
return 0
}
const potAfterCall = betting.pot + betting.seats.player.committed + betting.seats.opponent.committed + toCall
return toCall / potAfterCall
}
/**
* Composes estimateEquity + chooseAction into a full decision for the given betting state.
* `ownHole` must be the deciding seat's own hole cards only.
*/
export const decideAction = (
betting: BettingState,
legal: readonly Action[],
ownHole: readonly [Card, Card],
board: readonly Card[],
rng: Rng,
iterations: number,
): Action => {
const equity = estimateEquity(ownHole, board, rng, iterations)
const potOdds = potOddsToCall(betting)
return chooseAction(legal, equity, potOdds)
}

View File

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { createRng, nextFloat, nextInt, pick } from './rng'
describe('rng', () => {
it('is deterministic for a given seed', () => {
const a = createRng(123)
const b = createRng(123)
expect([nextFloat(a), nextFloat(a), nextFloat(a)]).toEqual([
nextFloat(b),
nextFloat(b),
nextFloat(b),
])
})
it('produces different sequences for different seeds', () => {
expect(nextFloat(createRng(1))).not.toBe(nextFloat(createRng(2)))
})
it('stays within [0, 1)', () => {
const rng = createRng(77)
for (let i = 0; i < 200; i += 1) {
const v = nextFloat(rng)
expect(v).toBeGreaterThanOrEqual(0)
expect(v).toBeLessThan(1)
}
})
it('wraps negative seeds into unsigned space', () => {
expect(createRng(-1).s).toBe(0xffffffff)
})
it('nextInt stays within [0, max)', () => {
const rng = createRng(9)
for (let i = 0; i < 100; i += 1) {
const v = nextInt(rng, 4)
expect(v).toBeGreaterThanOrEqual(0)
expect(v).toBeLessThan(4)
}
})
it('pick eventually returns every element of a tuple', () => {
const rng = createRng(5)
const seen = new Set<string>()
for (let i = 0; i < 64; i += 1) {
seen.add(pick(rng, ['a', 'b', 'c'] as const))
}
expect(seen).toEqual(new Set(['a', 'b', 'c']))
})
it('pick is deterministic', () => {
expect(pick(createRng(42), [10, 20, 30] as const)).toBe(
pick(createRng(42), [10, 20, 30] as const),
)
})
})

View File

@ -0,0 +1,33 @@
/** Mutable state for a mulberry32 generator. */
export interface Rng {
s: number
}
export const createRng = (seed: number): Rng => ({ s: seed >>> 0 })
/** Deterministic float in [0, 1). Advances the generator. */
export const nextFloat = (rng: Rng): number => {
rng.s = (rng.s + 0x6d2b79f5) >>> 0
let t = rng.s
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
/** Deterministic integer in [0, maxExclusive). */
export const nextInt = (rng: Rng, maxExclusive: number): number =>
Math.floor(nextFloat(rng) * maxExclusive)
/** Deterministic pick from a non-empty tuple. */
export const pick = <T>(rng: Rng, items: readonly [T, ...T[]]): T => {
const idx = nextInt(rng, items.length)
let out: T = items[0]
let i = 0
for (const item of items) {
if (i === idx) {
out = item
}
i += 1
}
return out
}

View File

@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import type { Action, BettingState } from './bettingRound'
import { legalActions } from './bettingRound'
import { createRng } from './rng'
import { cashOutSession, deriveBlindSize, playSessionHand, startSession } from './session'
import type { Seat } from './types'
const enforceLegal = (
decide: (betting: BettingState, seat: Seat) => Action,
): ((betting: BettingState, seat: Seat) => Action) => {
return (betting, seat) => {
const action = decide(betting, seat)
expect(legalActions(betting)).toContainEqual(action)
return action
}
}
const alwaysCheckOrCall = enforceLegal((betting) => {
const toCall = betting.betToCall - betting.seats[betting.toAct].committed
return toCall > 0 ? { type: 'call' } : { type: 'check' }
})
/** Shoves all-in whenever a bet/raise is legal for the given seat, otherwise checks/calls. */
const shoveForSeat = (targetSeat: Seat): ((betting: BettingState, seat: Seat) => Action) =>
enforceLegal((betting, seat) => {
if (seat !== targetSeat) {
return alwaysCheckOrCall(betting, seat)
}
const actions = legalActions(betting)
const shove = actions.find((a) => a.type === 'bet' || a.type === 'raise')
return shove ?? alwaysCheckOrCall(betting, seat)
})
describe('deriveBlindSize', () => {
it('floors a tiny buy-in to the minimum viable blinds (BB=2, SB=1)', () => {
expect(deriveBlindSize(20)).toEqual({ bigBlind: 2, smallBlind: 1 })
})
it('scales blinds proportionally for a large buy-in', () => {
expect(deriveBlindSize(1000)).toEqual({ bigBlind: 10, smallBlind: 5 })
})
it('never produces a small blind equal to the big blind', () => {
for (const buyIn of [1, 2, 20, 99, 100, 250, 10_000]) {
const { bigBlind, smallBlind } = deriveBlindSize(buyIn)
expect(smallBlind).toBeLessThan(bigBlind)
expect(smallBlind).toBeGreaterThanOrEqual(1)
}
})
})
describe('startSession', () => {
it('starts both stacks equal to the buy-in with the player on the button', () => {
const session = startSession({ buyInChips: 100 })
expect(session.stacks).toEqual({ player: 100, opponent: 100 })
expect(session.buttonSeat).toBe('player')
expect(session.handsPlayed).toBe(0)
expect(session.outcome).toBeNull()
})
})
describe('playSessionHand', () => {
it('updates stacks, increments handsPlayed, and rotates the button', () => {
const session = startSession({ buyInChips: 200 })
const { session: next } = playSessionHand(session, createRng(1), 200, { decide: alwaysCheckOrCall })
expect(next.handsPlayed).toBe(1)
expect(next.buttonSeat).toBe('opponent')
expect(next.stacks.player + next.stacks.opponent).toBe(400)
})
it('throws when called on an already-ended session', () => {
const session = startSession({ buyInChips: 200 })
const ended = cashOutSession(session)
expect(() => playSessionHand(ended, createRng(1), 200, { decide: alwaysCheckOrCall })).toThrow(
'session has already ended',
)
})
it('records a bust outcome when the player stack hits zero (deterministic seed)', () => {
// Seed 1: player always shoves all-in, opponent always checks/calls — player loses and busts.
const session = startSession({ buyInChips: 10 })
const { session: after } = playSessionHand(session, createRng(1), 10, { decide: shoveForSeat('player') })
expect(after.outcome).toEqual({ reason: 'bust', finalChips: 0 })
expect(after.stacks.player).toBe(0)
})
it('records a cash-out-equivalent outcome when the opponent stack hits zero (deterministic seed)', () => {
// Seed 100: opponent always shoves all-in, player always checks/calls — opponent loses and busts.
const session = startSession({ buyInChips: 10 })
const { session: after } = playSessionHand(session, createRng(100), 10, { decide: shoveForSeat('opponent') })
expect(after.stacks.opponent).toBe(0)
expect(after.outcome).toEqual({ reason: 'cashOut', finalChips: after.stacks.player })
expect(after.stacks.player).toBeGreaterThan(0)
})
it('conserves total chips across many hands until the session ends', () => {
let session = startSession({ buyInChips: 100 })
let seed = 1
while (session.outcome === null && session.handsPlayed < 30) {
const result = playSessionHand(session, createRng(seed), 100, { decide: alwaysCheckOrCall })
session = result.session
seed += 1
expect(session.stacks.player + session.stacks.opponent).toBe(200)
}
})
})
describe('cashOutSession', () => {
it('records the current player stack as the outcome', () => {
const session = startSession({ buyInChips: 100 })
const { session: afterHand } = playSessionHand(session, createRng(1), 100, { decide: alwaysCheckOrCall })
const cashedOut = cashOutSession(afterHand)
expect(cashedOut.outcome).toEqual({ reason: 'cashOut', finalChips: afterHand.stacks.player })
})
it('throws when called on an already-ended session', () => {
const session = startSession({ buyInChips: 100 })
const cashedOut = cashOutSession(session)
expect(() => cashOutSession(cashedOut)).toThrow('session has already ended')
})
})

View File

@ -0,0 +1,91 @@
import type { HandFlowCallbacks, HandResult, HeadsUpStacks } from './handFlow'
import { playHand } from './handFlow'
import type { Rng } from './rng'
import type { Seat } from './types'
export interface SessionConfig {
buyInChips: number
}
export type SessionEndReason = 'cashOut' | 'bust'
export interface SessionOutcome {
reason: SessionEndReason
finalChips: number // the player's stack at the moment the session ended
}
export interface SessionState {
stacks: HeadsUpStacks
buttonSeat: Seat // rotates each hand
handsPlayed: number
outcome: SessionOutcome | null // null while the session is still live
}
/** Big blind derived from the buy-in; small blind is always ≥1 and strictly less than BB. */
export const deriveBlindSize = (buyInChips: number): { bigBlind: number; smallBlind: number } => {
const bigBlind = Math.max(2, Math.floor(buyInChips / 100))
const smallBlind = Math.floor(bigBlind / 2)
return { bigBlind, smallBlind }
}
export const startSession = (config: SessionConfig): SessionState => ({
stacks: { player: config.buyInChips, opponent: config.buyInChips },
buttonSeat: 'player',
handsPlayed: 0,
outcome: null,
})
const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player')
/**
* Folds a completed hand's result into the session: updates stacks, rotates the button, and
* detects bust/cash-out. Pure — shared by `playSessionHand` (the PC-only synchronous path) and
* any interactive driver that runs a hand via `playHandSteps` instead.
*/
export const applyHandResult = (session: SessionState, handResult: HandResult): SessionState => {
const nextStacks = handResult.finalStacks
// Player busting is a real debt event. Opponent busting means the player holds every chip in
// play, which settles to owing nothing under the settlement formula — model it the same as a
// cash-out (at the max chip count) rather than a special case, since no further hand is possible.
let outcome: SessionOutcome | null = null
if (nextStacks.player === 0) {
outcome = { reason: 'bust', finalChips: 0 }
} else if (nextStacks.opponent === 0) {
outcome = { reason: 'cashOut', finalChips: nextStacks.player }
}
return {
stacks: nextStacks,
buttonSeat: other(session.buttonSeat),
handsPlayed: session.handsPlayed + 1,
outcome,
}
}
/** Plays one hand, updates stacks, rotates the button, and detects bust. No-op if already ended. */
export const playSessionHand = (
session: SessionState,
rng: Rng,
buyInChips: number,
callbacks: HandFlowCallbacks,
): { session: SessionState; handResult: HandResult } => {
if (session.outcome !== null) {
throw new Error('playSessionHand: session has already ended')
}
const { bigBlind, smallBlind } = deriveBlindSize(buyInChips)
const handResult = playHand(
rng,
{ buttonSeat: session.buttonSeat, bigBlind, smallBlind, stacks: session.stacks },
callbacks,
)
return { session: applyHandResult(session, handResult), handResult }
}
/** Ends a live session by cash-out, recording the player's current chip count. */
export const cashOutSession = (session: SessionState): SessionState => {
if (session.outcome !== null) {
throw new Error('cashOutSession: session has already ended')
}
return { ...session, outcome: { reason: 'cashOut', finalChips: session.stacks.player } }
}

View File

@ -0,0 +1,13 @@
/** Card ranks, stored high (Ace = 14). Wheel straights are handled explicitly in handEval. */
export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
export type Suit = 'clubs' | 'diamonds' | 'hearts' | 'spades'
export interface Card {
rank: Rank
suit: Suit
}
export type Seat = 'player' | 'opponent'
export type Street = 'preflop' | 'flop' | 'turn' | 'river' | 'showdown'

View File

@ -0,0 +1,27 @@
import { act } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { vi } from 'vitest'
beforeEach(() => {
vi.resetModules()
document.body.innerHTML = ''
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('main', () => {
it('refuses to boot without a #root element', async () => {
await expect(import('./main')).rejects.toThrow('root element not found')
})
it('mounts the app into #root', async () => {
document.body.innerHTML = '<div id="root"></div>'
await act(async () => {
await import('./main')
})
// Asserts the mount happened, not the whole app's copy — App has its own tests for that.
expect(document.querySelector('#root')?.textContent).toContain('Poker Stakes')
})
})

15
poker-stakes/src/main.tsx Normal file
View File

@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles.css'
const rootElement = document.getElementById('root')
if (rootElement === null) {
throw new Error('root element not found')
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import type { SessionOutcome } from '../engine/session'
import { settleSession } from './settlement'
import type { BuyIn } from './types'
const buyIn: BuyIn = { taskTypeId: 'pushups', chips: 100 }
describe('settleSession', () => {
const cases: { name: string; outcome: SessionOutcome; owedUnits: number }[] = [
{ name: 'doubled up', outcome: { reason: 'cashOut', finalChips: 200 }, owedUnits: 0 },
{ name: 'partial win', outcome: { reason: 'cashOut', finalChips: 150 }, owedUnits: 50 },
{ name: 'break-even', outcome: { reason: 'cashOut', finalChips: 100 }, owedUnits: 100 },
{ name: 'partial loss', outcome: { reason: 'cashOut', finalChips: 50 }, owedUnits: 150 },
{ name: 'bust to zero', outcome: { reason: 'bust', finalChips: 0 }, owedUnits: 200 },
]
it.each(cases)('$name: owes $owedUnits units', ({ outcome, owedUnits }) => {
const debt = settleSession(buyIn, outcome, 'debt-1', 12345)
expect(debt.owedUnits).toBe(owedUnits)
})
it('carries the task type, reason, id, and settledAt through unchanged', () => {
const outcome: SessionOutcome = { reason: 'bust', finalChips: 0 }
const debt = settleSession(buyIn, outcome, 'debt-42', 999)
expect(debt).toEqual({
id: 'debt-42',
taskTypeId: 'pushups',
owedUnits: 200,
reason: 'bust',
settledAt: 999,
paidUnits: 0,
})
})
})

View File

@ -0,0 +1,16 @@
import type { SessionOutcome } from '../engine/session'
import type { BuyIn, SettledDebt } from './types'
/**
* Converts a finished session's outcome into a debt record. `owedUnits` scales inversely with
* how well the player did: `2 × buyIn − finalChips`, floored at 0. Bust (finalChips === 0) needs
* no special case — it's just this formula's natural output (2 × buyIn, the cap) for that input.
*/
export const settleSession = (buyIn: BuyIn, outcome: SessionOutcome, id: string, settledAt: number): SettledDebt => ({
id,
taskTypeId: buyIn.taskTypeId,
owedUnits: Math.max(0, 2 * buyIn.chips - outcome.finalChips),
reason: outcome.reason,
settledAt,
paidUnits: 0,
})

View File

@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { loadDebts, saveDebts } from './storage'
import type { SettledDebt } from './types'
const debt: SettledDebt = {
id: 'd1',
taskTypeId: 'pushups',
owedUnits: 100,
reason: 'cashOut',
settledAt: 1,
paidUnits: 0,
}
const makeStorage = (initial: Record<string, string> = {}): Storage => {
const store = new Map(Object.entries(initial))
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 throwingStorage = (): 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('loadDebts', () => {
it('returns an empty ok list when nothing is stored yet', () => {
expect(loadDebts(makeStorage())).toEqual({ status: 'ok', debts: [] })
})
it('returns the stored debts when the shape is valid', () => {
const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([debt]) })
expect(loadDebts(storage)).toEqual({ status: 'ok', debts: [debt] })
})
it('is unreadable when getItem throws (storage disabled/denied)', () => {
expect(loadDebts(throwingStorage())).toEqual({ status: 'unreadable' })
})
it('is unreadable on malformed JSON', () => {
const storage = makeStorage({ 'poker-stakes:settledDebts': '{not json' })
expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
})
it('is unreadable when the stored value is not an array', () => {
const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify({ not: 'an array' }) })
expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
})
it('is unreadable when an array entry is missing required fields', () => {
const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([{ id: 'd1' }]) })
expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
})
// Each field's guard is asserted in ISOLATION — all other fields stay valid. The
// missing-required-fields case above drops five fields at once, so it would still trip some
// other check if any single guard were deleted, leaving that guard effectively untested.
it.each([
['id', { id: 42 }],
['taskTypeId', { taskTypeId: null }],
['owedUnits', { owedUnits: 'ten' }],
['reason', { reason: 'quit' }],
['settledAt', { settledAt: '2026-01-01' }],
['paidUnits', { paidUnits: [] }],
])('is unreadable when only %s has the wrong type', (_field, override) => {
const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([{ ...debt, ...override }]) })
expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
})
it.each(['id', 'taskTypeId', 'owedUnits', 'reason', 'settledAt', 'paidUnits'])(
'is unreadable when only %s is missing',
(field) => {
// Built by filtering rather than `delete`, which lint forbids on computed keys.
const partial = Object.fromEntries(Object.entries(debt).filter(([key]) => key !== field))
const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([partial]) })
expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
},
)
it('is unreadable when an array entry has an invalid reason value', () => {
const bad = { ...debt, reason: 'quit' }
const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([bad]) })
expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
})
it('is unreadable when an array entry is null', () => {
const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([null]) })
expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
})
})
describe('saveDebts', () => {
it('round-trips through loadDebts', () => {
const storage = makeStorage()
saveDebts(storage, [debt])
expect(loadDebts(storage)).toEqual({ status: 'ok', debts: [debt] })
})
})

View File

@ -0,0 +1,48 @@
import type { SettledDebt } from './types'
/** Exported so tests can seed a scripted ledger without duplicating the key string. */
export const DEBTS_STORAGE_KEY = 'poker-stakes:settledDebts'
export type DebtLoad = { status: 'ok'; debts: SettledDebt[] } | { status: 'unreadable' }
const isSettledDebt = (value: unknown): value is SettledDebt => {
if (typeof value !== 'object' || value === null) {
return false
}
const v = value as Record<string, unknown>
return (
typeof v.id === 'string' &&
typeof v.taskTypeId === 'string' &&
typeof v.owedUnits === 'number' &&
(v.reason === 'cashOut' || v.reason === 'bust') &&
typeof v.settledAt === 'number' &&
typeof v.paidUnits === 'number'
)
}
/** Fail-closed: any read/parse/shape error yields 'unreadable', never a silently-empty list. */
export const loadDebts = (storage: Storage): DebtLoad => {
let raw: string | null
try {
raw = storage.getItem(DEBTS_STORAGE_KEY)
} catch {
return { status: 'unreadable' }
}
if (raw === null) {
return { status: 'ok', debts: [] }
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return { status: 'unreadable' }
}
if (!Array.isArray(parsed) || !parsed.every(isSettledDebt)) {
return { status: 'unreadable' }
}
return { status: 'ok', debts: parsed }
}
export const saveDebts = (storage: Storage, debts: SettledDebt[]): void => {
storage.setItem(DEBTS_STORAGE_KEY, JSON.stringify(debts))
}

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { TASK_TYPES } from './taskTypes'
describe('TASK_TYPES', () => {
it('is non-empty', () => {
expect(TASK_TYPES.length).toBeGreaterThan(0)
})
it('has unique ids', () => {
const ids = TASK_TYPES.map((t) => t.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('gives every entry a non-empty id and label', () => {
for (const taskType of TASK_TYPES) {
expect(taskType.id.length).toBeGreaterThan(0)
expect(taskType.label.length).toBeGreaterThan(0)
}
})
})

View File

@ -0,0 +1,7 @@
import type { TaskType } from './types'
export const TASK_TYPES: readonly [TaskType, ...TaskType[]] = [
{ id: 'pushups', label: 'Pushups' },
{ id: 'leetcode', label: 'LeetCode problems' },
{ id: 'anki', label: 'Anki reviews' },
]

View File

@ -0,0 +1,20 @@
import type { SessionEndReason } from '../engine/session'
export interface TaskType {
id: string
label: string
}
export interface BuyIn {
taskTypeId: string
chips: number
}
export interface SettledDebt {
id: string
taskTypeId: string
owedUnits: number // 2×buyIn − finalChips, floored at 0
reason: SessionEndReason // display-only; never branches settlement math
settledAt: number
paidUnits: number // partial progress; starts at 0, incremented via UI
}

215
poker-stakes/src/styles.css Normal file
View File

@ -0,0 +1,215 @@
:root {
--ink: #211d1b;
--ink-raised-1: #2b2624;
--ink-raised-2: #38312e;
--line-dark: #463e3a;
--text-on-dark: #ecebe9;
--muted-on-dark: #aaa09a;
--accent: #b8862e;
--success: #8a9a3c;
--warning: #e0a63c;
--danger: #e2585f;
--on-fill: #211d1b;
--font-body: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
--font-data: ui-monospace, "Cascadia Mono", "JetBrains Mono", Menlo, monospace;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-xxl: 48px;
--radius-sm: 8px;
--radius-md: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--ink);
color: var(--text-on-dark);
font-family: var(--font-body);
min-height: 100vh;
}
button {
font-family: inherit;
font-size: 16px;
}
.frame {
max-width: 960px;
margin: 0 auto;
padding: var(--space-lg) var(--space-md) var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.masthead h1 {
font-size: 24px;
letter-spacing: -0.1px;
margin: 0 0 var(--space-xs);
}
.masthead p {
margin: 0;
color: var(--muted-on-dark);
font-size: 14px;
}
.panel {
background: var(--ink-raised-1);
border: 1px solid var(--line-dark);
border-radius: var(--radius-md);
padding: var(--space-lg);
}
.button {
border: 1px solid var(--line-dark);
border-radius: var(--radius-sm);
padding: 12px 24px;
background: var(--ink-raised-2);
color: var(--text-on-dark);
cursor: pointer;
}
.button:hover:not(:disabled) {
background: var(--line-dark);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button--primary {
background: var(--accent);
color: var(--on-fill);
border-color: var(--accent);
}
.button--danger {
background: var(--danger);
color: var(--on-fill);
border-color: var(--danger);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-bottom: var(--space-md);
}
.field label {
font-size: 14px;
letter-spacing: 0.05em;
color: var(--muted-on-dark);
}
.field input,
.field select {
font-family: inherit;
font-size: 16px;
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-dark);
background: var(--ink-raised-2);
color: var(--text-on-dark);
}
.table {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
}
.table__seat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
}
.table__cards {
display: flex;
gap: var(--space-xs);
}
.table__pot {
font-family: var(--font-data);
font-size: 20px;
color: var(--accent);
}
.card {
width: 44px;
height: 62px;
border-radius: var(--radius-sm);
background: var(--text-on-dark);
color: var(--ink);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-data);
font-weight: 600;
font-size: 16px;
border: 1px solid var(--line-dark);
}
.card--red {
color: var(--danger);
}
.card--back {
background: var(--ink-raised-2);
color: var(--muted-on-dark);
}
.controls {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
justify-content: center;
}
.debt-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.debt-list li {
border: 1px solid var(--line-dark);
border-radius: var(--radius-sm);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.log-progress {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.log-progress input {
width: 96px;
}
.hand-log {
font-size: 14px;
color: var(--muted-on-dark);
}

View File

@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})

View File

@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { BettingState, SeatState } from '../engine/bettingRound'
import { BettingControls } from './BettingControls'
const seat = (overrides: Partial<SeatState> = {}): SeatState => ({
stack: 100,
committed: 0,
hasActed: false,
folded: false,
allIn: false,
...overrides,
})
const betting = (overrides: Partial<BettingState> = {}): BettingState => ({
street: 'preflop',
pot: 0,
toAct: 'player',
seats: { player: seat(), opponent: seat() },
betToCall: 0,
minRaiseSize: 10,
roundOver: false,
...overrides,
})
describe('BettingControls', () => {
it('renders fold/check/bet when nothing is owed', () => {
render(<BettingControls betting={betting()} onAction={vi.fn()} />)
expect(screen.getByText('Fold')).toBeInTheDocument()
expect(screen.getByText('Check')).toBeInTheDocument()
expect(screen.getByText('Bet 10')).toBeInTheDocument()
expect(screen.getByText('Bet 100')).toBeInTheDocument()
expect(screen.queryByText('Call')).not.toBeInTheDocument()
})
it('renders fold/call/raise when a bet is owed', () => {
const state = betting({ betToCall: 20, seats: { player: seat(), opponent: seat({ committed: 20 }) } })
render(<BettingControls betting={state} onAction={vi.fn()} />)
expect(screen.getByText('Fold')).toBeInTheDocument()
expect(screen.getByText('Call')).toBeInTheDocument()
expect(screen.getByText('Raise 30')).toBeInTheDocument()
})
it('renders no bet/raise option when the acting seat cannot cover more than a call', () => {
const state = betting({
betToCall: 20,
seats: { player: seat({ stack: 20 }), opponent: seat({ committed: 20 }) },
})
render(<BettingControls betting={state} onAction={vi.fn()} />)
expect(screen.queryByText(/Raise/)).not.toBeInTheDocument()
})
it('calls onAction with the exact action when a button is clicked', async () => {
const user = userEvent.setup()
const onAction = vi.fn()
render(<BettingControls betting={betting()} onAction={onAction} />)
await user.click(screen.getByText('Check'))
expect(onAction).toHaveBeenCalledWith({ type: 'check' })
await user.click(screen.getByText('Bet 10'))
expect(onAction).toHaveBeenCalledWith({ type: 'bet', amount: 10 })
})
})

View File

@ -0,0 +1,45 @@
import type { ReactElement } from 'react'
import type { Action, BettingState } from '../engine/bettingRound'
import { legalActions } from '../engine/bettingRound'
const ACTION_LABELS: Record<Action['type'], string> = {
fold: 'Fold',
check: 'Check',
call: 'Call',
bet: 'Bet',
raise: 'Raise',
}
const actionKey = (action: Action): string =>
action.type === 'bet' || action.type === 'raise' ? `${action.type}-${String(action.amount)}` : action.type
const actionLabel = (action: Action): string =>
action.type === 'bet' || action.type === 'raise'
? `${ACTION_LABELS[action.type]} ${String(action.amount)}`
: ACTION_LABELS[action.type]
interface BettingControlsProps {
betting: BettingState
onAction: (action: Action) => void
}
/** Renders exactly the legal-action set for the current betting state — illegal actions are never representable. */
export const BettingControls = ({ betting, onAction }: BettingControlsProps): ReactElement => {
const actions = legalActions(betting)
return (
<div className="controls">
{actions.map((action) => (
<button
key={actionKey(action)}
type="button"
className="button"
onClick={() => {
onAction(action)
}}
>
{actionLabel(action)}
</button>
))}
</div>
)
}

View File

@ -0,0 +1,139 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { DebtLoad } from '../stakes/storage'
import type { SettledDebt } from '../stakes/types'
import { BuyInScreen } from './BuyInScreen'
const debt = (overrides: Partial<SettledDebt> = {}): SettledDebt => ({
id: 'd1',
taskTypeId: 'pushups',
owedUnits: 100,
reason: 'cashOut',
settledAt: 1,
paidUnits: 0,
...overrides,
})
describe('BuyInScreen', () => {
it('renders the buy-in form when there is no outstanding debt', () => {
render(<BuyInScreen debtLoad={{ status: 'ok', debts: [] }} onBuyIn={vi.fn()} onLogDebtProgress={vi.fn()} />)
expect(screen.getByText('Sit down')).toBeInTheDocument()
expect(screen.queryByText(/outstanding debt/)).not.toBeInTheDocument()
})
it('submits the selected task type and chip count', async () => {
const user = userEvent.setup()
const onBuyIn = vi.fn()
render(<BuyInScreen debtLoad={{ status: 'ok', debts: [] }} onBuyIn={onBuyIn} onLogDebtProgress={vi.fn()} />)
const chipInput = screen.getByLabelText('Chip count')
await user.clear(chipInput)
await user.type(chipInput, '250')
await user.click(screen.getByText('Sit down'))
expect(onBuyIn).toHaveBeenCalledWith({ taskTypeId: 'pushups', chips: 250 })
})
// Passes because native `min={1}` validation aborts the submit, NOT because the JS guard ran —
// the cleared-field test below is what actually exercises the guard. Keep both.
it('does not submit a non-positive or fractional chip count', async () => {
const user = userEvent.setup()
const onBuyIn = vi.fn()
render(<BuyInScreen debtLoad={{ status: 'ok', debts: [] }} onBuyIn={onBuyIn} onLogDebtProgress={vi.fn()} />)
const chipInput = screen.getByLabelText('Chip count')
await user.clear(chipInput)
await user.type(chipInput, '0')
await user.click(screen.getByText('Sit down'))
expect(onBuyIn).not.toHaveBeenCalled()
})
// An empty field is the only non-positive value that actually reaches the JS guard: the
// browser's native `min={1}` constraint aborts submission for a typed "-5"/"0" before the
// handler fires, whereas an empty input is valid HTML-wise and parses to `Number('') === 0`.
it('does not submit when the chip count is cleared to empty', async () => {
const user = userEvent.setup()
const onBuyIn = vi.fn()
render(<BuyInScreen debtLoad={{ status: 'ok', debts: [] }} onBuyIn={onBuyIn} onLogDebtProgress={vi.fn()} />)
await user.clear(screen.getByLabelText('Chip count'))
await user.click(screen.getByText('Sit down'))
expect(onBuyIn).not.toHaveBeenCalled()
})
it('buys in with the task type picked from the select', async () => {
const user = userEvent.setup()
const onBuyIn = vi.fn()
render(<BuyInScreen debtLoad={{ status: 'ok', debts: [] }} onBuyIn={onBuyIn} onLogDebtProgress={vi.fn()} />)
await user.selectOptions(screen.getByLabelText('Task type'), 'leetcode')
await user.click(screen.getByText('Sit down'))
expect(onBuyIn).toHaveBeenCalledWith({ taskTypeId: 'leetcode', chips: 100 })
})
it('replaces the buy-in form with outstanding debts when a debt is unpaid (hard gate)', () => {
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
render(<BuyInScreen debtLoad={debtLoad} onBuyIn={vi.fn()} onLogDebtProgress={vi.fn()} />)
expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
expect(screen.getByText(/outstanding debt/)).toBeInTheDocument()
expect(screen.getByText('Pushups: 40 / 100 done')).toBeInTheDocument()
})
it('does not list a fully paid debt among the outstanding ones', () => {
const debtLoad: DebtLoad = {
status: 'ok',
debts: [debt({ id: 'paid', owedUnits: 50, paidUnits: 50 }), debt({ id: 'owed', owedUnits: 100, paidUnits: 0 })],
}
render(<BuyInScreen debtLoad={debtLoad} onBuyIn={vi.fn()} onLogDebtProgress={vi.fn()} />)
expect(screen.queryByText('Pushups: 50 / 50 done')).not.toBeInTheDocument()
expect(screen.getByText('Pushups: 0 / 100 done')).toBeInTheDocument()
})
it('a doubled-up debt (owedUnits 0) does not block a new buy-in', () => {
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 0, paidUnits: 0 })] }
render(<BuyInScreen debtLoad={debtLoad} onBuyIn={vi.fn()} onLogDebtProgress={vi.fn()} />)
expect(screen.getByText('Sit down')).toBeInTheDocument()
})
it('logs progress against an outstanding debt via the log-progress control', async () => {
const user = userEvent.setup()
const onLogDebtProgress = vi.fn()
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
render(<BuyInScreen debtLoad={debtLoad} onBuyIn={vi.fn()} onLogDebtProgress={onLogDebtProgress} />)
const input = screen.getByLabelText('Log units done for Pushups')
await user.type(input, '20')
await user.click(screen.getByText('Log progress'))
expect(onLogDebtProgress).toHaveBeenCalledWith('d1', 20)
})
// Passes because native `min={1}` validation aborts the submit, NOT because the JS guard ran —
// the empty-field test below is what actually exercises the guard. Keep both.
it('does not log a non-positive or fractional progress amount', async () => {
const user = userEvent.setup()
const onLogDebtProgress = vi.fn()
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
render(<BuyInScreen debtLoad={debtLoad} onBuyIn={vi.fn()} onLogDebtProgress={onLogDebtProgress} />)
const input = screen.getByLabelText('Log units done for Pushups')
await user.type(input, '-5')
await user.click(screen.getByText('Log progress'))
expect(onLogDebtProgress).not.toHaveBeenCalled()
})
// Same reasoning as the chip-count empty case: the empty field is what reaches the guard.
it('does not log progress when the units field is left empty', async () => {
const user = userEvent.setup()
const onLogDebtProgress = vi.fn()
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
render(<BuyInScreen debtLoad={debtLoad} onBuyIn={vi.fn()} onLogDebtProgress={onLogDebtProgress} />)
await user.click(screen.getByText('Log progress'))
expect(onLogDebtProgress).not.toHaveBeenCalled()
})
it('blocks with a fail-closed message when storage was unreadable', () => {
render(<BuyInScreen debtLoad={{ status: 'unreadable' }} onBuyIn={vi.fn()} onLogDebtProgress={vi.fn()} />)
expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
expect(screen.getByText(/could not be read/)).toBeInTheDocument()
})
it('falls back to the raw taskTypeId label if it is not in the fixed list', () => {
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ taskTypeId: 'unknown-type', owedUnits: 10, paidUnits: 0 })] }
render(<BuyInScreen debtLoad={debtLoad} onBuyIn={vi.fn()} onLogDebtProgress={vi.fn()} />)
expect(screen.getByText('unknown-type: 0 / 10 done')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,91 @@
import { useState } from 'react'
import type { ChangeEvent, ReactElement, SubmitEvent } from 'react'
import { TASK_TYPES } from '../stakes/taskTypes'
import type { BuyIn } from '../stakes/types'
import type { DebtLoad } from '../stakes/storage'
import { OutstandingDebtRow } from './OutstandingDebtRow'
import { hasOutstandingDebt } from './useGame'
interface BuyInScreenProps {
debtLoad: DebtLoad
onBuyIn: (buyIn: BuyIn) => void
onLogDebtProgress: (debtId: string, units: number) => void
}
/**
* Hard gate, not a soft warning: while any debt is outstanding (or storage is unreadable, per the
* fail-closed rule), the buy-in form is replaced entirely by the outstanding-debt list and a
* log-progress control — matches the same gate `useGame.buyIn` enforces internally.
*/
export const BuyInScreen = ({ debtLoad, onBuyIn, onLogDebtProgress }: BuyInScreenProps): ReactElement => {
const [taskTypeId, setTaskTypeId] = useState(TASK_TYPES[0].id)
const [chips, setChips] = useState('100')
if (debtLoad.status === 'unreadable') {
return (
<div className="panel">
<p>Your debt history could not be read. Buying in is blocked until this is resolved.</p>
</div>
)
}
if (hasOutstandingDebt(debtLoad)) {
const outstanding = debtLoad.debts.filter((d) => d.paidUnits < d.owedUnits)
return (
<div className="panel">
<p>You have outstanding debt. Pay it off before buying into a new session.</p>
<ul className="debt-list">
{outstanding.map((debt) => (
<OutstandingDebtRow key={debt.id} debt={debt} onLogDebtProgress={onLogDebtProgress} />
))}
</ul>
</div>
)
}
const submit = (event: SubmitEvent<HTMLFormElement>): void => {
event.preventDefault()
const parsedChips = Number(chips)
if (!Number.isInteger(parsedChips) || parsedChips <= 0) {
return
}
onBuyIn({ taskTypeId, chips: parsedChips })
}
return (
<form className="panel" onSubmit={submit}>
<div className="field">
<label htmlFor="task-type">Task type</label>
<select
id="task-type"
value={taskTypeId}
onChange={(event: ChangeEvent<HTMLSelectElement>) => {
setTaskTypeId(event.target.value)
}}
>
{TASK_TYPES.map((taskType) => (
<option key={taskType.id} value={taskType.id}>
{taskType.label}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="chip-count">Chip count</label>
<input
id="chip-count"
type="number"
min={1}
step={1}
value={chips}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setChips(event.target.value)
}}
/>
</div>
<button type="submit" className="button button--primary">
Sit down
</button>
</form>
)
}

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { Card as CardModel } from '../engine/types'
import { Card, CardBack } from './Card'
describe('Card', () => {
it('renders a black-suit card without the red modifier', () => {
const card: CardModel = { rank: 14, suit: 'spades' }
render(<Card card={card} />)
const el = screen.getByLabelText('A of spades')
expect(el.className).not.toContain('card--red')
expect(el.textContent).toBe('A♠')
})
it('renders a red-suit card with the red modifier', () => {
const card: CardModel = { rank: 10, suit: 'hearts' }
render(<Card card={card} />)
const el = screen.getByLabelText('10 of hearts')
expect(el.className).toContain('card--red')
expect(el.textContent).toBe('10♥')
})
it('renders every rank label correctly', () => {
const ranks: { rank: CardModel['rank']; label: string }[] = [
{ rank: 2, label: '2' },
{ rank: 9, label: '9' },
{ rank: 10, label: '10' },
{ rank: 11, label: 'J' },
{ rank: 12, label: 'Q' },
{ rank: 13, label: 'K' },
{ rank: 14, label: 'A' },
]
for (const { rank, label } of ranks) {
const { unmount } = render(<Card card={{ rank, suit: 'clubs' }} />)
expect(screen.getByText(`${label}♣`)).toBeInTheDocument()
unmount()
}
})
})
describe('CardBack', () => {
it('renders a face-down placeholder', () => {
render(<CardBack />)
expect(screen.getByLabelText('face-down card')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,47 @@
import type { ReactElement } from 'react'
import type { Card as CardModel } from '../engine/types'
const RANK_LABELS: Record<CardModel['rank'], string> = {
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
8: '8',
9: '9',
10: '10',
11: 'J',
12: 'Q',
13: 'K',
14: 'A',
}
const SUIT_SYMBOLS: Record<CardModel['suit'], string> = {
clubs: '♣',
diamonds: '♦',
hearts: '♥',
spades: '♠',
}
const RED_SUITS: ReadonlySet<CardModel['suit']> = new Set(['diamonds', 'hearts'])
interface CardProps {
card: CardModel
}
export const Card = ({ card }: CardProps): ReactElement => {
const isRed = RED_SUITS.has(card.suit)
return (
<div className={`card${isRed ? ' card--red' : ''}`} aria-label={`${RANK_LABELS[card.rank]} of ${card.suit}`}>
{RANK_LABELS[card.rank]}
{SUIT_SYMBOLS[card.suit]}
</div>
)
}
export const CardBack = (): ReactElement => (
<div className="card card--back" aria-label="face-down card">
?
</div>
)

View File

@ -0,0 +1,54 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { HandStep } from '../engine/handFlow'
import type { SessionState } from '../engine/session'
import type { BuyIn } from '../stakes/types'
import type { HandPhase } from './useGame'
import { CashOutPanel } from './CashOutPanel'
const session = (playerStack: number): SessionState => ({
stacks: { player: playerStack, opponent: 200 },
buttonSeat: 'player',
handsPlayed: 1,
outcome: null,
})
const buyIn: BuyIn = { taskTypeId: 'pushups', chips: 100 }
describe('CashOutPanel', () => {
it('renders nothing while a hand is in progress (mirrors the mid-hand cashOut refusal)', () => {
const awaitingPhase: HandPhase = { kind: 'awaiting', step: {} as HandStep }
const { container } = render(
<CashOutPanel session={session(150)} buyIn={buyIn} handPhase={awaitingPhase} onCashOut={vi.fn()} />,
)
expect(container.textContent).toBe('')
})
it('renders nothing when handPhase is null (no live session yet)', () => {
const { container } = render(<CashOutPanel session={session(100)} buyIn={buyIn} handPhase={null} onCashOut={vi.fn()} />)
expect(container.textContent).toBe('')
})
it('shows a zero owed preview when the player has doubled their buy-in', () => {
const phase: HandPhase = { kind: 'betweenHands', lastResult: null }
render(<CashOutPanel session={session(200)} buyIn={buyIn} handPhase={phase} onCashOut={vi.fn()} />)
expect(screen.getByText(/owe 0 units/)).toBeInTheDocument()
})
it('shows the correct owed preview for a partial loss', () => {
const phase: HandPhase = { kind: 'betweenHands', lastResult: null }
render(<CashOutPanel session={session(50)} buyIn={buyIn} handPhase={phase} onCashOut={vi.fn()} />)
// owed = max(0, 2*100 - 50) = 150
expect(screen.getByText(/owe 150 units/)).toBeInTheDocument()
})
it('calls onCashOut when the leave-table button is clicked', async () => {
const user = userEvent.setup()
const onCashOut = vi.fn()
const phase: HandPhase = { kind: 'betweenHands', lastResult: null }
render(<CashOutPanel session={session(120)} buyIn={buyIn} handPhase={phase} onCashOut={onCashOut} />)
await user.click(screen.getByText('Leave table'))
expect(onCashOut).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,33 @@
import type { ReactElement } from 'react'
import type { SessionState } from '../engine/session'
import type { BuyIn } from '../stakes/types'
import type { HandPhase } from './useGame'
interface CashOutPanelProps {
session: SessionState
buyIn: BuyIn
handPhase: HandPhase | null
onCashOut: () => void
}
/**
* Visible only between hands — mid-hand `session.stacks` is a stale pre-hand snapshot, and
* `useGame.cashOut` itself refuses mid-hand (the settlement-exploit fix), so this mirrors that
* restriction rather than showing a button that would silently do nothing when clicked.
*/
export const CashOutPanel = ({ session, buyIn, handPhase, onCashOut }: CashOutPanelProps): ReactElement | null => {
if (handPhase?.kind !== 'betweenHands') {
return null
}
const owedIfCashedOutNow = Math.max(0, 2 * buyIn.chips - session.stacks.player)
return (
<div className="panel">
<p>
Current stack: {session.stacks.player} chips. Cashing out now would owe {owedIfCashedOutNow} units.
</p>
<button type="button" className="button button--danger" onClick={onCashOut}>
Leave table
</button>
</div>
)
}

View File

@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { DebtLoad } from '../stakes/storage'
import type { SettledDebt } from '../stakes/types'
import { DebtHistoryPanel } from './DebtHistoryPanel'
const debt = (overrides: Partial<SettledDebt> = {}): SettledDebt => ({
id: 'd1',
taskTypeId: 'pushups',
owedUnits: 100,
reason: 'cashOut',
settledAt: 1,
paidUnits: 0,
...overrides,
})
describe('DebtHistoryPanel', () => {
it('says so when nothing has been settled yet', () => {
render(<DebtHistoryPanel debtLoad={{ status: 'ok', debts: [] }} onLogDebtProgress={vi.fn()} />)
expect(screen.getByText('No debts settled yet.')).toBeInTheDocument()
})
it('reports unreadable storage instead of an empty ledger (fail-closed)', () => {
render(<DebtHistoryPanel debtLoad={{ status: 'unreadable' }} onLogDebtProgress={vi.fn()} />)
expect(screen.getByText('Your debt history could not be read.')).toBeInTheDocument()
expect(screen.queryByText('No debts settled yet.')).not.toBeInTheDocument()
})
it('gives an outstanding debt the log-progress control', async () => {
const user = userEvent.setup()
const onLogDebtProgress = vi.fn()
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
render(<DebtHistoryPanel debtLoad={debtLoad} onLogDebtProgress={onLogDebtProgress} />)
expect(screen.getByText('Pushups: 40 / 100 done')).toBeInTheDocument()
await user.type(screen.getByLabelText('Log units done for Pushups'), '10')
await user.click(screen.getByText('Log progress'))
expect(onLogDebtProgress).toHaveBeenCalledWith('d1', 10)
})
it('renders a cleared debt as read-only history with its reason', () => {
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 50, paidUnits: 50, reason: 'bust' })] }
render(<DebtHistoryPanel debtLoad={debtLoad} onLogDebtProgress={vi.fn()} />)
expect(screen.getByText('Pushups: 50 owed (bust) — cleared')).toBeInTheDocument()
expect(screen.queryByText('Log progress')).not.toBeInTheDocument()
})
it('labels a cash-out reason distinctly from a bust', () => {
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 50, paidUnits: 50, reason: 'cashOut' })] }
render(<DebtHistoryPanel debtLoad={debtLoad} onLogDebtProgress={vi.fn()} />)
expect(screen.getByText('Pushups: 50 owed (cashed out) — cleared')).toBeInTheDocument()
})
// A doubled-up session settles to owedUnits 0, which is already cleared at settle time.
it('treats a zero-owed debt as cleared', () => {
const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 0, paidUnits: 0 })] }
render(<DebtHistoryPanel debtLoad={debtLoad} onLogDebtProgress={vi.fn()} />)
expect(screen.getByText('Pushups: 0 owed (cashed out) — cleared')).toBeInTheDocument()
})
it('lists debts newest first regardless of stored order', () => {
const debtLoad: DebtLoad = {
status: 'ok',
debts: [
debt({ id: 'old', taskTypeId: 'anki', owedUnits: 10, paidUnits: 10, settledAt: 100 }),
debt({ id: 'new', taskTypeId: 'leetcode', owedUnits: 20, paidUnits: 20, settledAt: 900 }),
],
}
render(<DebtHistoryPanel debtLoad={debtLoad} onLogDebtProgress={vi.fn()} />)
const rows = screen.getAllByRole('listitem').map((li) => li.textContent)
expect(rows[0]).toContain('LeetCode problems')
expect(rows[1]).toContain('Anki reviews')
})
})

View File

@ -0,0 +1,63 @@
import type { ReactElement } from 'react'
import type { DebtLoad } from '../stakes/storage'
import type { SettledDebt } from '../stakes/types'
import { OutstandingDebtRow } from './OutstandingDebtRow'
import { taskLabel } from './taskLabel'
interface DebtHistoryPanelProps {
debtLoad: DebtLoad
onLogDebtProgress: (debtId: string, units: number) => void
}
const reasonLabel = (reason: SettledDebt['reason']): string => (reason === 'bust' ? 'bust' : 'cashed out')
/** Cleared debts are read-only history — there is no control to un-pay one. */
const ClearedDebtRow = ({ debt }: { debt: SettledDebt }): ReactElement => (
<li>
<span>
{taskLabel(debt.taskTypeId)}: {debt.owedUnits} owed ({reasonLabel(debt.reason)}) — cleared
</span>
</li>
)
/**
* The durable ledger, separate from any single session: every `SettledDebt` ever recorded, newest
* first. Outstanding entries carry the same log-progress control `BuyInScreen`'s gate shows (the
* shared `OutstandingDebtRow`), so progress can be logged from either place with identical rules.
*/
export const DebtHistoryPanel = ({ debtLoad, onLogDebtProgress }: DebtHistoryPanelProps): ReactElement => {
if (debtLoad.status === 'unreadable') {
return (
<div className="panel">
<h2>Debt history</h2>
<p>Your debt history could not be read.</p>
</div>
)
}
if (debtLoad.debts.length === 0) {
return (
<div className="panel">
<h2>Debt history</h2>
<p>No debts settled yet.</p>
</div>
)
}
const newestFirst = [...debtLoad.debts].sort((a, b) => b.settledAt - a.settledAt)
return (
<div className="panel">
<h2>Debt history</h2>
<ul className="debt-list">
{newestFirst.map((debt) =>
debt.paidUnits < debt.owedUnits ? (
<OutstandingDebtRow key={debt.id} debt={debt} onLogDebtProgress={onLogDebtProgress} />
) : (
<ClearedDebtRow key={debt.id} debt={debt} />
),
)}
</ul>
</div>
)
}

View File

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { HandResult } from '../engine/handFlow'
import { HandLog } from './HandLog'
const result = (overrides: Partial<HandResult> = {}): HandResult => ({
winner: 'player',
potAwarded: 40,
wentToShowdown: true,
finalStacks: { player: 240, opponent: 160 },
loserSeat: 'opponent',
board: [],
revealedHoleCards: null,
...overrides,
})
describe('HandLog', () => {
it('renders nothing before any hand has completed', () => {
const { container } = render(<HandLog lastResult={null} />)
expect(container.textContent).toBe('')
})
it('summarizes a player win at showdown', () => {
render(<HandLog lastResult={result({ winner: 'player', wentToShowdown: true, potAwarded: 40 })} />)
expect(screen.getByText('You won at showdown — 40 chips.')).toBeInTheDocument()
})
it('summarizes an opponent win by fold', () => {
render(<HandLog lastResult={result({ winner: 'opponent', wentToShowdown: false, potAwarded: 15 })} />)
expect(screen.getByText('Opponent won by fold — 15 chips.')).toBeInTheDocument()
})
it('summarizes a split pot', () => {
render(<HandLog lastResult={result({ winner: 'split', wentToShowdown: true, potAwarded: 60, loserSeat: null })} />)
expect(screen.getByText('Split pot at showdown — 60 chips divided evenly.')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,24 @@
import type { ReactElement } from 'react'
import type { HandResult } from '../engine/handFlow'
interface HandLogProps {
lastResult: HandResult | null
}
const summarize = (result: HandResult): string => {
const outcome = result.wentToShowdown ? 'at showdown' : 'by fold'
const potAwarded = String(result.potAwarded)
if (result.winner === 'split') {
return `Split pot ${outcome} — ${potAwarded} chips divided evenly.`
}
const winnerLabel = result.winner === 'player' ? 'You won' : 'Opponent won'
return `${winnerLabel} ${outcome} — ${potAwarded} chips.`
}
/** Shows the most recently completed hand's outcome — nothing until the first hand resolves. */
export const HandLog = ({ lastResult }: HandLogProps): ReactElement | null => {
if (lastResult === null) {
return null
}
return <p className="hand-log">{summarize(lastResult)}</p>
}

View File

@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { SettledDebt } from '../stakes/types'
import { OutstandingDebtRow } from './OutstandingDebtRow'
const debt = (overrides: Partial<SettledDebt> = {}): SettledDebt => ({
id: 'd1',
taskTypeId: 'pushups',
owedUnits: 100,
reason: 'cashOut',
settledAt: 1,
paidUnits: 40,
...overrides,
})
const renderRow = (overrides: Partial<SettledDebt> = {}): ReturnType<typeof vi.fn> => {
const onLogDebtProgress = vi.fn()
render(
<ul>
<OutstandingDebtRow debt={debt(overrides)} onLogDebtProgress={onLogDebtProgress} />
</ul>,
)
return onLogDebtProgress
}
describe('OutstandingDebtRow', () => {
it('shows progress against the owed amount', () => {
renderRow()
expect(screen.getByText('Pushups: 40 / 100 done')).toBeInTheDocument()
})
it('logs a positive integer of units and clears the field afterwards', async () => {
const user = userEvent.setup()
const onLogDebtProgress = renderRow()
const input = screen.getByLabelText('Log units done for Pushups')
await user.type(input, '20')
await user.click(screen.getByText('Log progress'))
expect(onLogDebtProgress).toHaveBeenCalledWith('d1', 20)
expect(input).toHaveValue(null)
})
// Empty is the only non-positive value that reaches the JS guard — native `min={1}` validation
// aborts submission for a typed "-5" or "0" before the handler runs.
it('does not log when the units field is empty', async () => {
const user = userEvent.setup()
const onLogDebtProgress = renderRow()
await user.click(screen.getByText('Log progress'))
expect(onLogDebtProgress).not.toHaveBeenCalled()
})
it('does not log a fractional amount', async () => {
const user = userEvent.setup()
const onLogDebtProgress = renderRow()
await user.type(screen.getByLabelText('Log units done for Pushups'), '2.5')
await user.click(screen.getByText('Log progress'))
expect(onLogDebtProgress).not.toHaveBeenCalled()
})
it('labels the input with the raw id when the task type is unknown', () => {
renderRow({ taskTypeId: 'unknown-type' })
expect(screen.getByLabelText('Log units done for unknown-type')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,50 @@
import { useState } from 'react'
import type { ChangeEvent, ReactElement, SubmitEvent } from 'react'
import type { SettledDebt } from '../stakes/types'
import { taskLabel } from './taskLabel'
interface OutstandingDebtRowProps {
debt: SettledDebt
onLogDebtProgress: (debtId: string, units: number) => void
}
/**
* Shared by `BuyInScreen` (the hard gate) and `DebtHistoryPanel` (the ledger) so the two can never
* drift apart — the plan's "log N units done" control is the only way `paidUnits` advances, and
* there is deliberately no mark-paid checkbox to short-circuit it.
*/
export const OutstandingDebtRow = ({ debt, onLogDebtProgress }: OutstandingDebtRowProps): ReactElement => {
const [units, setUnits] = useState('')
const submit = (event: SubmitEvent<HTMLFormElement>): void => {
event.preventDefault()
const parsed = Number(units)
if (!Number.isInteger(parsed) || parsed <= 0) {
return
}
onLogDebtProgress(debt.id, parsed)
setUnits('')
}
return (
<li>
<span>
{taskLabel(debt.taskTypeId)}: {debt.paidUnits} / {debt.owedUnits} done
</span>
<form className="log-progress" onSubmit={submit}>
<input
type="number"
min={1}
value={units}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setUnits(event.target.value)
}}
aria-label={`Log units done for ${taskLabel(debt.taskTypeId)}`}
/>
<button type="submit" className="button">
Log progress
</button>
</form>
</li>
)
}

View File

@ -0,0 +1,115 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { startInteractiveHand } from '../engine/interactiveHand'
import { createRng } from '../engine/rng'
import type { SessionState } from '../engine/session'
import type { HandPhase } from './useGame'
import { Table } from './Table'
const session: SessionState = {
stacks: { player: 200, opponent: 200 },
buttonSeat: 'player',
handsPlayed: 0,
outcome: null,
}
describe('Table', () => {
it('shows the player their own hole cards and hides the opponent behind card backs mid-hand', () => {
const hand = startInteractiveHand(
createRng(1),
createRng(1 ^ 0x9e3779b9),
{ buttonSeat: 'player', bigBlind: 10, smallBlind: 5, stacks: { player: 200, opponent: 200 } },
1,
)
if (hand.status.kind !== 'awaiting') {
throw new Error('expected an awaiting hand for this seed')
}
const handPhase: HandPhase = { kind: 'awaiting', step: hand.status.step }
render(<Table session={session} handPhase={handPhase} />)
expect(screen.getAllByLabelText(/face-down card/)).toHaveLength(2)
// Two own hole cards render as real Card elements, not face-down.
const allCards = screen.getAllByLabelText(/ of /)
expect(allCards.length).toBeGreaterThanOrEqual(2)
})
it('hides the opponent when betweenHands after a fold (no reveal)', () => {
const handPhase: HandPhase = {
kind: 'betweenHands',
lastResult: {
winner: 'opponent',
potAwarded: 15,
wentToShowdown: false,
finalStacks: { player: 190, opponent: 210 },
loserSeat: 'player',
board: [],
revealedHoleCards: null,
},
}
render(<Table session={session} handPhase={handPhase} />)
expect(screen.queryByLabelText(/face-down card/)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/ of /)).not.toBeInTheDocument()
})
it('reveals both hole cards at a showdown result while betweenHands', () => {
const handPhase: HandPhase = {
kind: 'betweenHands',
lastResult: {
winner: 'player',
potAwarded: 40,
wentToShowdown: true,
finalStacks: { player: 220, opponent: 180 },
loserSeat: 'opponent',
board: [
{ rank: 2, suit: 'clubs' },
{ rank: 5, suit: 'diamonds' },
{ rank: 9, suit: 'hearts' },
],
revealedHoleCards: {
player: [
{ rank: 14, suit: 'spades' },
{ rank: 14, suit: 'clubs' },
],
opponent: [
{ rank: 3, suit: 'hearts' },
{ rank: 4, suit: 'spades' },
],
},
},
}
render(<Table session={session} handPhase={handPhase} />)
// The opponent's revealed hand.
expect(screen.getByLabelText('3 of hearts')).toBeInTheDocument()
expect(screen.getByLabelText('4 of spades')).toBeInTheDocument()
// ...and your OWN hand, which must stay visible after the hand resolves — otherwise a
// showdown shows the opponent's cards and the board while your seat sits empty and you
// cannot see what you won or lost with. This is what "both" in the test name means.
expect(screen.getByLabelText('A of spades')).toBeInTheDocument()
expect(screen.getByLabelText('A of clubs')).toBeInTheDocument()
// Board cards render too.
expect(screen.getByLabelText('9 of hearts')).toBeInTheDocument()
})
it('leaves your seat empty after a fold, since no hand is revealed then', () => {
const handPhase: HandPhase = {
kind: 'betweenHands',
lastResult: {
winner: 'opponent',
potAwarded: 15,
wentToShowdown: false,
finalStacks: { player: 190, opponent: 210 },
loserSeat: 'player',
board: [],
revealedHoleCards: null,
},
}
render(<Table session={session} handPhase={handPhase} />)
expect(screen.queryByLabelText(/ of /)).not.toBeInTheDocument()
})
it('reads stacks and pot=0 from session when handPhase is null (pre-first-hand transient)', () => {
render(<Table session={session} handPhase={null} />)
expect(screen.getByText('Pot: 0')).toBeInTheDocument()
expect(screen.getByText('You — 200 chips')).toBeInTheDocument()
expect(screen.getByText('Opponent — 200 chips')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,66 @@
import type { ReactElement } from 'react'
import { potFromBetting } from '../engine/handFlow'
import type { SessionState } from '../engine/session'
import type { Card as CardModel } from '../engine/types'
import type { HandPhase } from './useGame'
import { Card, CardBack } from './Card'
interface TableProps {
session: SessionState
handPhase: HandPhase | null
}
/** Stable per-hand key: a hand never contains a duplicate card, so rank+suit uniquely identifies one. */
const cardKey = (card: CardModel): string => `${String(card.rank)}-${card.suit}`
/**
* Reads stacks/pot from the live source of truth for the current moment: `handPhase.step.betting`
* mid-hand (which reflects THIS hand's blinds/bets — `session.stacks` is still the pre-hand
* snapshot then), and `session.stacks` between hands (pot is 0, no betting state exists).
*/
export const Table = ({ session, handPhase }: TableProps): ReactElement => {
const isAwaiting = handPhase?.kind === 'awaiting'
const betting = isAwaiting ? handPhase.step.betting : null
const stacks = betting === null ? session.stacks : { player: betting.seats.player.stack, opponent: betting.seats.opponent.stack }
const pot = betting === null ? 0 : potFromBetting(betting)
const board = isAwaiting ? handPhase.step.board : (handPhase?.kind === 'betweenHands' ? handPhase.lastResult?.board : undefined) ?? []
const revealed = handPhase?.kind === 'betweenHands' ? (handPhase.lastResult?.revealedHoleCards ?? null) : null
const revealedOpponentCards = revealed?.opponent ?? null
// Your own hand stays visible after the hand resolves, not just while a decision is pending —
// otherwise a showdown shows the opponent's cards and the board while your seat sits empty, and
// you cannot see what you actually won or lost with.
const playerHoleCards = isAwaiting ? handPhase.step.ownHoleCards : (revealed?.player ?? null)
return (
<div className="table">
<div className="table__seat">
<span>Opponent — {stacks.opponent} chips</span>
<div className="table__cards">
{revealedOpponentCards !== null
? revealedOpponentCards.map((card) => <Card key={cardKey(card)} card={card} />)
: isAwaiting && (
<>
<CardBack />
<CardBack />
</>
)}
</div>
</div>
<div className="table__pot">Pot: {pot}</div>
<div className="table__cards">
{board.map((card) => (
<Card key={cardKey(card)} card={card} />
))}
</div>
<div className="table__seat">
<div className="table__cards">
{playerHoleCards?.map((card) => <Card key={cardKey(card)} card={card} />)}
</div>
<span>You — {stacks.player} chips</span>
</div>
</div>
)
}

View File

@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { taskLabel } from './taskLabel'
describe('taskLabel', () => {
it('maps a known task type id to its label', () => {
expect(taskLabel('pushups')).toBe('Pushups')
})
it('falls back to the raw id for an unknown task type', () => {
expect(taskLabel('unknown-type')).toBe('unknown-type')
})
})

View File

@ -0,0 +1,5 @@
import { TASK_TYPES } from '../stakes/taskTypes'
/** Falls back to the raw id so a debt recorded under a since-removed task type still renders. */
export const taskLabel = (taskTypeId: string): string =>
TASK_TYPES.find((t) => t.id === taskTypeId)?.label ?? taskTypeId

View File

@ -0,0 +1,494 @@
import { act, renderHook } from '@testing-library/react'
import { StrictMode } from 'react'
import type { ReactElement, ReactNode } from 'react'
import { describe, expect, it } from 'vitest'
import type { SettledDebt } from '../stakes/types'
import { gameReducer, hasOutstandingDebt, useGame } from './useGame'
import type { GameState } from './useGame'
const debt = (overrides: Partial<SettledDebt> = {}): SettledDebt => ({
id: 'd1',
taskTypeId: 'pushups',
owedUnits: 100,
reason: 'cashOut',
settledAt: 1,
paidUnits: 0,
...overrides,
})
const initialState: GameState = {
debtLoad: { status: 'ok', debts: [] },
buyIn: null,
session: null,
handPhase: null,
}
describe('hasOutstandingDebt', () => {
it('blocks when storage was unreadable', () => {
expect(hasOutstandingDebt({ status: 'unreadable' })).toBe(true)
})
it('does not block an empty debt list', () => {
expect(hasOutstandingDebt({ status: 'ok', debts: [] })).toBe(false)
})
it('does not block a fully paid debt', () => {
expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 50, paidUnits: 50 })] })).toBe(false)
})
it('does not block a debt that owes zero units (doubled-up cash-out)', () => {
expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 0, paidUnits: 0 })] })).toBe(false)
})
it('blocks a partially paid debt', () => {
expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] })).toBe(true)
})
})
describe('gameReducer', () => {
it('buyIn sets buyIn/session and clears handPhase', () => {
const session: GameState['session'] = {
stacks: { player: 100, opponent: 100 },
buttonSeat: 'player',
handsPlayed: 0,
outcome: null,
}
const next = gameReducer(initialState, {
type: 'buyIn',
buyIn: { taskTypeId: 'pushups', chips: 100 },
session,
})
expect(next).toEqual({
debtLoad: { status: 'ok', debts: [] },
buyIn: { taskTypeId: 'pushups', chips: 100 },
session,
handPhase: null,
})
})
it('debtSettled clears the live session and resets to the buy-in gate', () => {
const mid: GameState = {
...initialState,
buyIn: { taskTypeId: 'pushups', chips: 100 },
session: {
stacks: { player: 0, opponent: 200 },
buttonSeat: 'player',
handsPlayed: 3,
outcome: { reason: 'bust', finalChips: 0 },
},
handPhase: { kind: 'betweenHands', lastResult: null },
}
const settled = [debt({ owedUnits: 200 })]
const next = gameReducer(mid, { type: 'debtSettled', debts: settled })
expect(next).toEqual({
debtLoad: { status: 'ok', debts: settled },
buyIn: null,
session: null,
handPhase: null,
})
})
it('debtProgressLogged updates debtLoad only', () => {
const next = gameReducer(initialState, { type: 'debtProgressLogged', debts: [debt({ paidUnits: 20 })] })
expect(next.debtLoad).toEqual({ status: 'ok', debts: [debt({ paidUnits: 20 })] })
})
})
const wrapper = ({ children }: { children: ReactNode }): ReactElement => <StrictMode>{children}</StrictMode>
const makeStorage = (): Storage => {
const store = new Map<string, string>()
return {
getItem: (key: string): string | null => store.get(key) ?? null,
setItem: (key: string, value: string): void => {
store.set(key, value)
},
removeItem: (key: string): void => {
store.delete(key)
},
clear: (): void => {
store.clear()
},
key: (): null => null,
get length(): number {
return store.size
},
}
}
const unreadableStorage = (): Storage => ({
getItem: (): never => {
throw new Error('denied')
},
setItem: (): never => {
throw new Error('denied')
},
removeItem: (): undefined => undefined,
clear: (): undefined => undefined,
key: (): null => null,
length: 0,
})
describe('useGame', () => {
it('starts with no debt and no session, blocked=false', () => {
const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
expect(hasOutstandingDebt(result.current.state.debtLoad)).toBe(false)
expect(result.current.state.session).toBeNull()
})
it('starts blocked when storage is unreadable, fail-closed', () => {
const { result } = renderHook(() => useGame(unreadableStorage(), 1), { wrapper })
expect(result.current.state.debtLoad).toEqual({ status: 'unreadable' })
expect(hasOutstandingDebt(result.current.state.debtLoad)).toBe(true)
})
it('buyIn is a no-op when storage is unreadable — the hard gate itself blocks, not just the UI', () => {
// Regression: an earlier version derived the gate's debt list via a helper that collapsed
// `{status: 'unreadable'}` to `[]`, which made `hasOutstandingDebt` see an empty (non-blocking)
// list and let a session start despite fail-closed storage. The hook's `buyIn` must itself
// refuse, not rely on a UI component to have already checked `state.debtLoad`.
const { result } = renderHook(() => useGame(unreadableStorage(), 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
expect(result.current.state.session).toBeNull()
})
it('buy-in starts a session and pauses on the player seat (StrictMode double-invoke safe)', () => {
const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
expect(result.current.state.session).not.toBeNull()
expect(result.current.state.session?.handsPlayed).toBe(0)
expect(result.current.state.handPhase?.kind).toBe('awaiting')
// If StrictMode's double-invoke perturbed the generator, stacks would already be inconsistent.
const stacks = result.current.state.session?.stacks
expect(stacks?.player).toBeGreaterThan(0)
expect(stacks?.opponent).toBeGreaterThan(0)
})
it('buyIn is a no-op while a debt is outstanding (hard gate, not just BuyInScreen UI)', () => {
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
// Cash out immediately: `cashOut` requires `betweenHands`, but hand 1 just started as
// `awaiting`. Fold to reach `betweenHands` deterministically without depending on a specific
// showdown outcome.
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
expect(result.current.state.handPhase?.kind).toBe('betweenHands')
act(() => {
result.current.cashOut()
})
expect(result.current.state.session).toBeNull()
const debtsBefore = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
expect(debtsBefore).toHaveLength(1)
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 50 })
})
expect(result.current.state.session).toBeNull()
const debtsAfter = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
expect(debtsAfter).toHaveLength(1)
})
it('buyIn rejects a non-positive or fractional chip count (chips are integers throughout)', () => {
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 0 })
})
expect(result.current.state.session).toBeNull()
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: -5 })
})
expect(result.current.state.session).toBeNull()
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 2.5 })
})
expect(result.current.state.session).toBeNull()
})
it('submitting a second action right after a hand resolves into betweenHands does not throw', () => {
// Regression: handRef used to only be cleared on settle, not on the betweenHands path, so a
// late/duplicate submitPlayerAction call after a hand resolved without ending the session hit
// `hand.submit()` on an already-complete InteractiveHand and threw out of the event handler.
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
expect(result.current.state.handPhase?.kind).toBe('betweenHands')
expect(() => {
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
}).not.toThrow()
// Still safely between hands — the stale submit was a no-op, not a state corruption.
expect(result.current.state.handPhase?.kind).toBe('betweenHands')
})
it('cashOut is a no-op when no session is live', () => {
const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
act(() => {
result.current.cashOut()
})
expect(result.current.state.session).toBeNull()
expect(result.current.state.debtLoad).toEqual({ status: 'ok', debts: [] })
})
it('cashOut is a no-op mid-hand (only legal between hands, per the settlement-exploit fix)', () => {
// Buying in leaves a live hand `awaiting` a player decision. Cashing out here must be a
// no-op: `session.stacks` predates this hand's blinds/bets, so settling against it would let
// a player see the deal, cash out at the pre-hand stack, and have posted chips vanish.
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
expect(result.current.state.handPhase?.kind).toBe('awaiting')
act(() => {
result.current.cashOut()
})
expect(result.current.state.session).not.toBeNull()
expect(result.current.state.debtLoad).toEqual({ status: 'ok', debts: [] })
})
it('a hand that reaches showdown lands in betweenHands, not auto-dealing the next one', () => {
// Seed 1 at a 200-chip buy-in, always checking/calling: 4 submits carry the first hand to
// showdown. The session must land in `betweenHands` (handsPlayed incremented, no live hand)
// rather than silently starting hand 2 — that pause is what makes cashOut safe.
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
for (let i = 0; i < 4; i += 1) {
const phase = result.current.state.handPhase
if (phase?.kind !== 'awaiting') {
break
}
const { betting } = phase.step
const toCall = betting.betToCall - betting.seats.player.committed
act(() => {
result.current.submitPlayerAction(toCall > 0 ? { type: 'call' } : { type: 'check' })
})
}
expect(result.current.state.session?.handsPlayed).toBe(1)
expect(result.current.state.session?.outcome).toBeNull()
const phase = result.current.state.handPhase
expect(phase).toMatchObject({ kind: 'betweenHands' })
expect(phase?.kind === 'betweenHands' ? phase.lastResult : undefined).not.toBeNull()
})
it('dealHand starts the next hand after betweenHands', () => {
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
expect(result.current.state.handPhase?.kind).toBe('betweenHands')
act(() => {
result.current.dealHand()
})
expect(result.current.state.handPhase?.kind).toBe('awaiting')
expect(result.current.state.session?.handsPlayed).toBe(1)
})
it('dealHand is a no-op before any buy-in', () => {
const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
act(() => {
result.current.dealHand()
})
expect(result.current.state.session).toBeNull()
expect(result.current.state.handPhase).toBeNull()
})
it('submitPlayerAction is a no-op before any buy-in (no live hand to advance)', () => {
const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
expect(result.current.state.session).toBeNull()
expect(result.current.state.handPhase).toBeNull()
})
it('a zero-player-decision hand (all-in from the blind post) settles instead of soft-locking', () => {
// Buy-in of 1 chip: deriveBlindSize floors BB at 2, SB at 1 — the button posts their entire
// 1-chip stack as the small blind, an immediate all-in with zero player decision points.
// `dealHand` must fold that hand's result into the session and settle rather than leaving a
// live, un-settled session with no pending decision (the soft-lock this session's advisor
// review caught: `initialBettingState` used to reset `allIn: false` on every street).
// Seed 1 deterministically busts the player on this exact first hand.
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 1 })
})
expect(result.current.state.session).toBeNull()
expect(result.current.state.handPhase).toBeNull()
const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
expect(debts).toHaveLength(1)
expect(debts[0]).toMatchObject({ owedUnits: 2, reason: 'bust' })
})
it('a zero-player-decision hand that the all-in player survives lands in betweenHands, not settled', () => {
// Seed 1, 4-chip buy-in, always folding when a decision exists: by hand 3 (handsPlayed=2) the
// player is down to 1 chip and posts it as the small blind — all-in, zero decision points.
// The all-in player wins/ties that hand and ends above 0, so the session must survive into
// `betweenHands` rather than settling — the sibling branch to the bust case above.
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 4 })
})
let guard = 0
while (result.current.state.session !== null && result.current.state.session.handsPlayed < 3 && guard < 10) {
const phase = result.current.state.handPhase
if (phase?.kind === 'betweenHands') {
act(() => {
result.current.dealHand()
})
} else if (phase?.kind === 'awaiting') {
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
} else {
break
}
guard += 1
}
expect(guard).toBeLessThan(10)
expect(result.current.state.session).not.toBeNull()
expect(result.current.state.session?.handsPlayed).toBe(3)
expect(result.current.state.handPhase?.kind).toBe('betweenHands')
expect(result.current.state.session?.stacks.player).toBeGreaterThan(0)
})
it('logDebtProgress with an unknown debt id leaves the debt list unchanged', () => {
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
act(() => {
result.current.cashOut()
})
const before = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
act(() => {
result.current.logDebtProgress('no-such-id', 10)
})
const after = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
expect(after).toEqual(before)
})
it('cashing out between hands (never played a hand to showdown) settles a debt equal to the full buy-in', () => {
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
expect(result.current.state.handPhase?.kind).toBe('betweenHands')
act(() => {
result.current.cashOut()
})
expect(result.current.state.session).toBeNull()
expect(result.current.state.debtLoad.status).toBe('ok')
const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
expect(debts).toHaveLength(1)
expect(debts[0]?.reason).toBe('cashOut')
})
it('an unpaid debt persists to storage and blocks a subsequent load', () => {
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
act(() => {
result.current.cashOut()
})
const { result: second } = renderHook(() => useGame(storage, 2), { wrapper })
expect(hasOutstandingDebt(second.current.state.debtLoad)).toBe(true)
})
it('logDebtProgress clamps paidUnits to owedUnits and persists', () => {
const storage = makeStorage()
const { result } = renderHook(() => useGame(storage, 1), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
})
act(() => {
result.current.submitPlayerAction({ type: 'fold' })
})
act(() => {
result.current.cashOut()
})
const debtId = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts[0]?.id : undefined
if (debtId === undefined) {
throw new Error('expected a settled debt')
}
act(() => {
result.current.logDebtProgress(debtId, 500)
})
const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
expect(debts[0]?.paidUnits).toBe(debts[0]?.owedUnits)
const { result: reloaded } = renderHook(() => useGame(storage, 3), { wrapper })
expect(hasOutstandingDebt(reloaded.current.state.debtLoad)).toBe(false)
})
it('bust ends the session with a debt of exactly 2×buyIn', () => {
const storage = makeStorage()
// Seed 4 at a 20-chip buy-in, always checking/calling as the player, deterministically busts
// the player within a handful of hands (found by probing seeds 1-10; every other seed either
// busts with the same owedUnits=40 result or ends in the opponent-bust/cashOut branch covered
// above). Deals explicitly via dealHand() between hands, matching the new no-auto-continue API.
const { result } = renderHook(() => useGame(storage, 4), { wrapper })
act(() => {
result.current.buyIn({ taskTypeId: 'pushups', chips: 20 })
})
let guard = 0
while (result.current.state.session !== null && guard < 20) {
const phase = result.current.state.handPhase
if (phase?.kind === 'betweenHands') {
act(() => {
result.current.dealHand()
})
} else if (phase?.kind === 'awaiting') {
const { betting } = phase.step
const toCall = betting.betToCall - betting.seats.player.committed
act(() => {
result.current.submitPlayerAction(toCall > 0 ? { type: 'call' } : { type: 'check' })
})
} else {
break
}
guard += 1
}
expect(guard).toBeLessThan(20)
expect(result.current.state.session).toBeNull()
const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
expect(debts).toHaveLength(1)
expect(debts[0]).toMatchObject({ owedUnits: 40, reason: 'bust' })
})
})

View File

@ -0,0 +1,268 @@
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'
import type { Rng } from '../engine/rng'
import { applyHandResult, cashOutSession, deriveBlindSize, startSession } from '../engine/session'
import type { SessionState } from '../engine/session'
import { settleSession } from '../stakes/settlement'
import { loadDebts, saveDebts } from '../stakes/storage'
import type { DebtLoad } from '../stakes/storage'
import type { BuyIn, SettledDebt } from '../stakes/types'
/** XORed into the deal seed for the opponent's independent policy RNG, per the spec's seam. */
const POLICY_SEED_SALT = 0x9e3779b9
const OPPONENT_ITERATIONS = 200
/**
* 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 interface UseGameResult {
state: GameState
buyIn: (buyIn: BuyIn) => void
dealHand: () => void
submitPlayerAction: (action: PokerAction) => void
cashOut: () => void
logDebtProgress: (debtId: string, units: number) => void
}
/** Computed once via lazy init on both `useReducer` and `useRef` below — pure given `storage`. */
const readInitialDebts = (debtLoad: DebtLoad): SettledDebt[] => (debtLoad.status === 'ok' ? debtLoad.debts : [])
export const useGame = (storage: Storage, dealSeed: number): UseGameResult => {
const [state, dispatch] = useReducer(gameReducer, initialGameState, (): GameState => ({
...initialGameState,
debtLoad: loadDebts(storage),
}))
const handRef = useRef<InteractiveHand | null>(null)
const dealRngRef = useRef<Rng>(createRng(dealSeed))
const policyRngRef = useRef<Rng>(createRng(dealSeed ^ POLICY_SEED_SALT))
const sessionRef = useRef<SessionState | null>(null)
const buyInRef = useRef<BuyIn | null>(null)
// Authoritative debt list, independent of render state, so an unreadable-storage snapshot in
// `state.debtLoad` can never cause a settle to silently drop existing debts (fail-open bug).
// `useRef`'s initial-value argument is evaluated on every render (only the first is kept), so
// this and `storageUnreadableRef` below both see the same `loadDebts(storage)` read `state`'s
// own lazy initializer used — no separate read, no risk of the two disagreeing.
const debtsRef = useRef<SettledDebt[]>(readInitialDebts(loadDebts(storage)))
// Sticky: unreadable at load blocks buyIn for the lifetime of this hook instance, even though
// `debtsRef` itself only ever holds `SettledDebt[]` (never a nullable/unreadable marker) — this
// is what lets `buyIn`'s gate see 'unreadable' without a helper that collapses it to `[]` first
// (the exact bug this session's review caught: silently unblocking the gate).
const storageUnreadableRef = useRef<boolean>(loadDebts(storage).status !== 'ok')
const persistDebts = useCallback(
(debts: SettledDebt[]): SettledDebt[] => {
debtsRef.current = debts
saveDebts(storage, debts)
return debts
},
[storage],
)
const settleAndPersist = useCallback(
// Caller (dealHand / cashOut) guarantees `session.outcome` is non-null and `buyInSnapshot` is
// the live buy-in; not re-guarded here.
(session: SessionState & { outcome: NonNullable<SessionState['outcome']> }, buyInSnapshot: BuyIn): void => {
const debt = settleSession(buyInSnapshot, session.outcome, crypto.randomUUID(), Date.now())
const nextDebts = persistDebts([...debtsRef.current, debt])
handRef.current = null
buyInRef.current = null
sessionRef.current = null
dispatch({ type: 'debtSettled', debts: nextDebts })
},
[persistDebts],
)
/**
* Deals one hand. In the rare case a seat is already all-in from posting a blind (stack ≤
* blind size), the hand can complete with ZERO player decision points — `startInteractiveHand`
* resolves every opponent turn internally and returns already `complete`. That result is folded
* into the session immediately (not left for a UI round-trip that would never come), and either
* settles the session or lands in `betweenHands` so the caller can deal again.
*/
const dealHand = useCallback((): void => {
const session = sessionRef.current
const buyInSnapshot = buyInRef.current
// Reachable only if called with no live session (before any buy-in, or after settlement) —
// the UI only exposes this action while `handPhase.kind === 'betweenHands'`.
if (session === null || buyInSnapshot === null) {
return
}
const { bigBlind, smallBlind } = deriveBlindSize(buyInSnapshot.chips)
const hand = startInteractiveHand(
dealRngRef.current,
policyRngRef.current,
{ buttonSeat: session.buttonSeat, bigBlind, smallBlind, stacks: session.stacks },
OPPONENT_ITERATIONS,
)
const status = hand.status
if (status.kind === 'awaiting') {
handRef.current = hand
dispatch({ type: 'handPhaseChanged', session, handPhase: { kind: 'awaiting', step: status.step } })
return
}
const nextSession = applyHandResult(session, status.result)
sessionRef.current = nextSession
if (nextSession.outcome !== null) {
settleAndPersist({ ...nextSession, outcome: nextSession.outcome }, buyInSnapshot)
return
}
dispatch({
type: 'handPhaseChanged',
session: nextSession,
handPhase: { kind: 'betweenHands', lastResult: status.result },
})
}, [settleAndPersist])
const buyIn = useCallback(
(nextBuyIn: BuyIn): void => {
// Hard gate: an outstanding debt blocks starting a new session, per the confirmed stakes
// model — never just a UI-level warning. Chips are integers throughout (per the plan), so a
// non-positive or fractional buy-in is rejected here too, not just via an input `min`/`step`.
if (
storageUnreadableRef.current ||
hasOutstandingDebt({ status: 'ok', debts: debtsRef.current }) ||
!Number.isInteger(nextBuyIn.chips) ||
nextBuyIn.chips <= 0
) {
return
}
const session = startSession({ buyInChips: nextBuyIn.chips })
buyInRef.current = nextBuyIn
sessionRef.current = session
dispatch({ type: 'buyIn', buyIn: nextBuyIn, session })
dealHand()
},
[dealHand],
)
const submitPlayerAction = useCallback(
(action: PokerAction): void => {
const hand = handRef.current
const session = sessionRef.current
const buyInSnapshot = buyInRef.current
// Reachable only via a submit before any buy-in — `hand.submit` itself throws if no
// decision is pending, which per this session's review is otherwise unreachable: `dealHand`
// never leaves `handRef` holding an already-`complete` hand.
if (hand === null || session === null || buyInSnapshot === null) {
return
}
hand.submit(action)
const status = hand.status
if (status.kind === 'awaiting') {
dispatch({ type: 'handPhaseChanged', session, handPhase: { kind: 'awaiting', step: status.step } })
return
}
// Hand resolved. Land in `betweenHands` — do NOT auto-deal the next hand: `session.stacks`
// must reflect a just-completed hand so `cashOut` (only legal in `betweenHands`) settles
// against real chips, and mid-hand cash-out would let a player see a bad flop, cash out at
// the PRE-hand stack, and have the posted blinds/bets vanish with the discarded generator.
handRef.current = null
const nextSession = applyHandResult(session, status.result)
sessionRef.current = nextSession
if (nextSession.outcome !== null) {
settleAndPersist({ ...nextSession, outcome: nextSession.outcome }, buyInSnapshot)
return
}
dispatch({
type: 'handPhaseChanged',
session: nextSession,
handPhase: { kind: 'betweenHands', lastResult: status.result },
})
},
[settleAndPersist],
)
const cashOut = useCallback((): void => {
const session = sessionRef.current
const buyInSnapshot = buyInRef.current
// Only legal between hands: `handRef.current !== null` means a hand is in progress, and
// mid-hand `session.stacks` predates that hand's blinds/bets — cashing out then would settle
// against a stale stack and let a player see the deal before deciding to cash out. handRef is
// read (a synchronous ref) rather than `state.handPhase` (React state) because this guard is
// security-critical: it must reflect what actually happened, not what has rendered yet.
if (session?.outcome !== null || buyInSnapshot === null || handRef.current !== null) {
return
}
const nextSession = cashOutSession(session)
sessionRef.current = nextSession
dispatch({ type: 'cashedOut', session: nextSession })
// cashOutSession always sets outcome, so this cast just restates that fact for the type.
settleAndPersist(nextSession as SessionState & { outcome: NonNullable<SessionState['outcome']> }, buyInSnapshot)
}, [settleAndPersist])
const logDebtProgress = useCallback(
(debtId: string, units: number): void => {
const nextDebts = debtsRef.current.map((d) =>
d.id === debtId ? { ...d, paidUnits: Math.min(d.owedUnits, d.paidUnits + units) } : d,
)
persistDebts(nextDebts)
dispatch({ type: 'debtProgressLogged', debts: nextDebts })
},
[persistDebts],
)
return { state, buyIn, dealHand, submitPlayerAction, cashOut, logDebtProgress }
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,18 @@
/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'],
thresholds: { lines: 100, functions: 100, branches: 100, statements: 100 },
reporter: ['text', 'json-summary'],
},
},
})