From 97836e3cdd9f63d8848c1a36a3215eaf336bcb23 Mon Sep 17 00:00:00 2001 From: Gbolahan Akande Date: Sun, 30 Aug 2026 23:10:58 +0100 Subject: [PATCH] feat: add standalone poker odds calculator tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - odds-calculator.ts: Monte Carlo win/tie/loss estimator built on the existing bestHandRank evaluator. Takes hole cards, known board cards, and an opponent count (each opponent modeled as a random unknown hand); validates inputs (card count, duplicates, range, remaining-deck size) before simulating. - OddsCalculatorModal.tsx: standalone modal with rank/suit pickers for hole cards and up to 5 board cards, an opponent-count slider, and a win/tie/loss result display. - page.tsx: adds a "🎲 ODDS" button next to the existing STATS link to open the calculator as a standalone tool, independent of any active table. Closes #163 --- .../__tests__/odds-calculator-modal.test.tsx | 63 +++++ app/src/__tests__/odds-calculator.test.ts | 126 ++++++++++ app/src/app/page.tsx | 21 ++ app/src/components/OddsCalculatorModal.tsx | 236 ++++++++++++++++++ app/src/lib/odds-calculator.ts | 138 ++++++++++ 5 files changed, 584 insertions(+) create mode 100644 app/src/__tests__/odds-calculator-modal.test.tsx create mode 100644 app/src/__tests__/odds-calculator.test.ts create mode 100644 app/src/components/OddsCalculatorModal.tsx create mode 100644 app/src/lib/odds-calculator.ts diff --git a/app/src/__tests__/odds-calculator-modal.test.tsx b/app/src/__tests__/odds-calculator-modal.test.tsx new file mode 100644 index 0000000..55b7ca4 --- /dev/null +++ b/app/src/__tests__/odds-calculator-modal.test.tsx @@ -0,0 +1,63 @@ +import { render, fireEvent, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { OddsCalculatorModal } from "../components/OddsCalculatorModal"; + +describe("OddsCalculatorModal (Issue #163)", () => { + it("renders nothing when closed", () => { + const { container } = render( + {}} /> + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders the calculator UI when open", () => { + render( {}} />); + expect(screen.getByText("ODDS CALCULATOR")).toBeTruthy(); + expect(screen.getByText("YOUR HOLE CARDS")).toBeTruthy(); + expect(screen.getByText("BOARD (OPTIONAL)")).toBeTruthy(); + expect(screen.getByText("OPPONENTS")).toBeTruthy(); + expect(screen.getByText("CALCULATE ODDS")).toBeTruthy(); + }); + + it("shows a validation error when calculating without both hole cards selected", () => { + render( {}} />); + fireEvent.click(screen.getByText("CALCULATE ODDS")); + expect(screen.getByText("Select both hole cards")).toBeTruthy(); + }); + + it("calculates and displays win/tie/loss once both hole cards are selected", () => { + render( {}} />); + + fireEvent.change(screen.getByLabelText("Card 1 rank"), { target: { value: "12" } }); // Ace + fireEvent.change(screen.getByLabelText("Card 1 suit"), { target: { value: "2" } }); // hearts + fireEvent.change(screen.getByLabelText("Card 2 rank"), { target: { value: "12" } }); // Ace + fireEvent.change(screen.getByLabelText("Card 2 suit"), { target: { value: "1" } }); // diamonds + + fireEvent.click(screen.getByText("CALCULATE ODDS")); + + expect(screen.getByText("WIN")).toBeTruthy(); + expect(screen.getByText("TIE")).toBeTruthy(); + expect(screen.getByText("LOSS")).toBeTruthy(); + expect(screen.getByText(/Estimated from [\d,]+ simulated hands\./)).toBeTruthy(); + }); + + it("shows an error instead of crashing when the same card is picked twice", () => { + render( {}} />); + + fireEvent.change(screen.getByLabelText("Card 1 rank"), { target: { value: "12" } }); + fireEvent.change(screen.getByLabelText("Card 1 suit"), { target: { value: "2" } }); + fireEvent.change(screen.getByLabelText("Card 2 rank"), { target: { value: "12" } }); + fireEvent.change(screen.getByLabelText("Card 2 suit"), { target: { value: "2" } }); // same card as Card 1 + + fireEvent.click(screen.getByText("CALCULATE ODDS")); + + expect(screen.getByText("Duplicate cards are not allowed")).toBeTruthy(); + }); + + it("calls onClose when the close button is clicked", () => { + let closed = false; + render( { closed = true; }} />); + fireEvent.click(screen.getByText("✕")); + expect(closed).toBe(true); + }); +}); diff --git a/app/src/__tests__/odds-calculator.test.ts b/app/src/__tests__/odds-calculator.test.ts new file mode 100644 index 0000000..947ddc8 --- /dev/null +++ b/app/src/__tests__/odds-calculator.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { calculateOdds, OddsCalculatorError } from "../lib/odds-calculator"; + +// Card encoding: value = suit * 13 + rankIndex. +// Suits: 0=clubs, 1=diamonds, 2=hearts, 3=spades. +// Ranks: 0="2" .. 8="10", 9="J", 10="Q", 11="K", 12="A". +const SPADE = 3; +const TEN = 8, JACK = 9, QUEEN = 10, KING = 11, ACE = 12; +const TEN_SPADES = SPADE * 13 + TEN; +const JACK_SPADES = SPADE * 13 + JACK; +const QUEEN_SPADES = SPADE * 13 + QUEEN; +const KING_SPADES = SPADE * 13 + KING; +const ACE_SPADES = SPADE * 13 + ACE; +const TWO_CLUBS = 0 * 13 + 0; +const THREE_DIAMONDS = 1 * 13 + 1; +const ACE_HEARTS = 2 * 13 + ACE; +const ACE_DIAMONDS = 1 * 13 + ACE; + +describe("calculateOdds validation (Issue #163)", () => { + it("rejects a hole card count other than two", () => { + expect(() => + calculateOdds({ + holeCards: [1, 2, 3] as unknown as [number, number], + boardCards: [], + numOpponents: 1, + }) + ).toThrow(OddsCalculatorError); + }); + + it("rejects more than five board cards", () => { + expect(() => + calculateOdds({ + holeCards: [ACE_SPADES, KING_SPADES], + boardCards: [1, 2, 3, 4, 5, 6], + numOpponents: 1, + }) + ).toThrow(OddsCalculatorError); + }); + + it("rejects zero or too many opponents", () => { + expect(() => + calculateOdds({ holeCards: [1, 2], boardCards: [], numOpponents: 0 }) + ).toThrow(OddsCalculatorError); + expect(() => + calculateOdds({ holeCards: [1, 2], boardCards: [], numOpponents: 9 }) + ).toThrow(OddsCalculatorError); + }); + + it("rejects duplicate cards across hole and board", () => { + expect(() => + calculateOdds({ + holeCards: [ACE_SPADES, KING_SPADES], + boardCards: [ACE_SPADES, 1, 2], + numOpponents: 1, + }) + ).toThrow(OddsCalculatorError); + }); + + it("rejects out-of-range card values", () => { + expect(() => + calculateOdds({ holeCards: [-1, 2], boardCards: [], numOpponents: 1 }) + ).toThrow(OddsCalculatorError); + expect(() => + calculateOdds({ holeCards: [52, 2], boardCards: [], numOpponents: 1 }) + ).toThrow(OddsCalculatorError); + }); + + it("rejects too many opponents for the remaining deck size", () => { + // Nearly the whole deck already known as board cards leaves too few + // remaining cards to deal 8 opponents two hole cards each. + const manyBoardCards = Array.from({ length: 3 }, (_, i) => i + 10); + expect(() => + calculateOdds({ + holeCards: [ACE_SPADES, KING_SPADES], + boardCards: manyBoardCards, + numOpponents: 8, + iterations: 10, + }) + ).not.toThrow(); // 3 known board cards still leaves plenty of deck — sanity check this doesn't over-reject + }); +}); + +describe("calculateOdds deterministic outcomes", () => { + it("gives 100% win rate when the hero already holds the best possible hand (royal flush) on a complete board", () => { + // Hero: K♠ A♠. Board (river, all 5 known): 10♠ J♠ Q♠ 2♣ 3♦. + // Hero's best 5-card hand is unambiguously a royal flush — the single + // best possible hand in poker — so no opponent hand can tie or beat it, + // regardless of the random cards they're dealt. + const result = calculateOdds({ + holeCards: [KING_SPADES, ACE_SPADES], + boardCards: [TEN_SPADES, JACK_SPADES, QUEEN_SPADES, TWO_CLUBS, THREE_DIAMONDS], + numOpponents: 3, + iterations: 200, + }); + + expect(result.win).toBe(1); + expect(result.tie).toBe(0); + expect(result.loss).toBe(0); + expect(result.iterations).toBe(200); + }); + + it("returns fractions that sum to 1 (within floating point tolerance)", () => { + const result = calculateOdds({ + holeCards: [ACE_HEARTS, ACE_DIAMONDS], + boardCards: [], + numOpponents: 2, + iterations: 500, + }); + expect(result.win + result.tie + result.loss).toBeCloseTo(1, 5); + }); +}); + +describe("calculateOdds statistical sanity check", () => { + it("gives pocket aces a strong (>70%) win rate heads-up preflop against one random opponent", () => { + // Well-known baseline: AA vs a random hand heads-up is ~85% equity. + // Using a generous >70% threshold and enough iterations to avoid + // Monte Carlo flakiness while still keeping the test fast. + const result = calculateOdds({ + holeCards: [ACE_HEARTS, ACE_DIAMONDS], + boardCards: [], + numOpponents: 1, + iterations: 4000, + }); + expect(result.win).toBeGreaterThan(0.7); + }); +}); diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 88cbf10..ea303f3 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -8,6 +8,7 @@ import { PixelCat } from "@/components/PixelCat"; import { PixelChip } from "@/components/PixelChip"; import { TransactionSimulation } from "@/components/TransactionSimulation"; import { TokenSelector } from "@/components/TokenSelector"; +import { OddsCalculatorModal } from "@/components/OddsCalculatorModal"; import * as api from "@/lib/api"; import { useJoinTableSimulation } from "@/lib/use-transaction-simulation"; import { @@ -74,6 +75,7 @@ export default function Home() { const [tableSearch, setTableSearch] = useState(""); const [filterSeatsOpen, setFilterSeatsOpen] = useState(false); const [filterMyStakes, setFilterMyStakes] = useState(false); + const [oddsCalculatorOpen, setOddsCalculatorOpen] = useState(false); const joinTableSim = useJoinTableSimulation(wallet, () => { if (pendingTableId) { @@ -944,6 +946,25 @@ export default function Home() { 📊 STATS + {/* Odds calculator tool (Issue #163) */} + + setOddsCalculatorOpen(false)} + /> + {/* Transaction Simulation */} {joinTableSim.showSimulation && joinTableSim.simulation && ( void; +} + +// Mirrors the encoding in cards.ts (value = suit * 13 + rankIndex) — cards.ts +// only exports a decoder, so the picker builds its own encode side from the +// same label order rather than modifying that module's exports. +const SUIT_LABELS = ["clubs", "diamonds", "hearts", "spades"] as const; +const SUIT_SYMBOLS: Record<(typeof SUIT_LABELS)[number], string> = { + clubs: "♣", + diamonds: "♦", + hearts: "♥", + spades: "♠", +}; +const RANK_LABELS = [ + "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A", +] as const; + +type CardSelection = { rank: number; suit: number } | null; + +function encodeCard(selection: CardSelection): CardValue | null { + if (!selection) return null; + return selection.suit * 13 + selection.rank; +} + +function CardPicker({ + label, + value, + onChange, + optional = false, +}: { + label: string; + value: CardSelection; + onChange: (v: CardSelection) => void; + optional?: boolean; +}) { + return ( +
+ {label} +
+ + +
+
+ ); +} + +function formatPct(fraction: number): string { + return `${(fraction * 100).toFixed(1)}%`; +} + +export function OddsCalculatorModal({ open, onClose }: OddsCalculatorModalProps) { + const [hole1, setHole1] = useState(null); + const [hole2, setHole2] = useState(null); + const [board, setBoard] = useState([null, null, null, null, null]); + const [numOpponents, setNumOpponents] = useState(1); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const boardValues = useMemo( + () => board.map(encodeCard).filter((v): v is CardValue => v !== null), + [board] + ); + + if (!open) return null; + + const handleCalculate = () => { + setError(null); + setResult(null); + const h1 = encodeCard(hole1); + const h2 = encodeCard(hole2); + if (h1 === null || h2 === null) { + setError("Select both hole cards"); + return; + } + try { + const res = calculateOdds({ + holeCards: [h1, h2], + boardCards: boardValues, + numOpponents, + }); + setResult(res); + } catch (e) { + setError(e instanceof OddsCalculatorError ? e.message : "Failed to calculate odds"); + } + }; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+ + ODDS CALCULATOR + + +
+ +
+ YOUR HOLE CARDS +
+ + +
+
+ +
+ BOARD (OPTIONAL) +
+ {board.map((c, i) => ( + { + const next = [...board]; + next[i] = v; + setBoard(next); + }} + /> + ))} +
+
+ +
+ OPPONENTS +
+ setNumOpponents(Number(e.target.value))} + aria-label="Number of opponents" + /> + {numOpponents} +
+ + Each opponent is modeled with a random unknown hand. + +
+ + {error && ( +
{error}
+ )} + + + + {result && ( +
+
+ WIN{formatPct(result.win)} +
+
+ TIE{formatPct(result.tie)} +
+
+ LOSS{formatPct(result.loss)} +
+
+ Estimated from {result.iterations.toLocaleString()} simulated hands. +
+
+ )} +
+
+ ); +} diff --git a/app/src/lib/odds-calculator.ts b/app/src/lib/odds-calculator.ts new file mode 100644 index 0000000..128e423 --- /dev/null +++ b/app/src/lib/odds-calculator.ts @@ -0,0 +1,138 @@ +import { bestHandRank, type HandRank } from "./hand-rank"; + +/** Same 0-51 card encoding as cards.ts / hand-rank.ts: value = suit * 13 + rankIndex. */ +export type CardValue = number; + +export interface OddsCalculatorInput { + /** The player's two hole cards. */ + holeCards: [CardValue, CardValue]; + /** 0-5 known community cards (flop/turn/river as revealed so far). */ + boardCards: CardValue[]; + /** Number of opponents, each modeled as a random unknown two-card hand. */ + numOpponents: number; + /** Monte Carlo trial count. Higher = more accurate, slower. */ + iterations?: number; +} + +export interface OddsResult { + /** Fraction of trials the hero's hand was strictly best (0-1). */ + win: number; + /** Fraction of trials the hero tied for best (0-1). */ + tie: number; + /** Fraction of trials the hero lost (0-1). */ + loss: number; + iterations: number; +} + +const FULL_DECK: CardValue[] = Array.from({ length: 52 }, (_, i) => i); +const DEFAULT_ITERATIONS = 3000; + +function compareHandRank(a: HandRank, b: HandRank): number { + if (a.category !== b.category) return a.category - b.category; + const len = Math.max(a.tiebreak.length, b.tiebreak.length); + for (let i = 0; i < len; i++) { + const av = a.tiebreak[i] ?? 0; + const bv = b.tiebreak[i] ?? 0; + if (av !== bv) return av - bv; + } + return 0; +} + +/** Fisher-Yates shuffle. Not cryptographically secure — fine for an + * odds-estimation tool, unlike real card dealing (which happens via the + * on-chain MPC nodes, not this client-side utility). */ +function shuffle(items: T[]): T[] { + const arr = [...items]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +export class OddsCalculatorError extends Error {} + +function validateInput(input: OddsCalculatorInput): void { + const { holeCards, boardCards, numOpponents } = input; + if (holeCards.length !== 2) { + throw new OddsCalculatorError("Exactly two hole cards are required"); + } + if (boardCards.length > 5) { + throw new OddsCalculatorError("At most five board cards are allowed"); + } + if (numOpponents < 1 || numOpponents > 8) { + throw new OddsCalculatorError("Number of opponents must be between 1 and 8"); + } + const allKnown = [...holeCards, ...boardCards]; + const uniqueKnown = new Set(allKnown); + if (uniqueKnown.size !== allKnown.length) { + throw new OddsCalculatorError("Duplicate cards are not allowed"); + } + for (const c of allKnown) { + if (!Number.isInteger(c) || c < 0 || c > 51) { + throw new OddsCalculatorError(`Invalid card value: ${c}`); + } + } +} + +/** + * Estimates win/tie/loss probability for a hand via Monte Carlo simulation + * against `numOpponents` random unknown hands, given whatever board cards + * are already known (Issue #163). + */ +export function calculateOdds(input: OddsCalculatorInput): OddsResult { + validateInput(input); + const iterations = input.iterations ?? DEFAULT_ITERATIONS; + const { holeCards, boardCards, numOpponents } = input; + + const known = new Set([...holeCards, ...boardCards]); + const remainingDeck = FULL_DECK.filter((c) => !known.has(c)); + + const boardSlotsNeeded = 5 - boardCards.length; + const cardsNeededPerTrial = boardSlotsNeeded + numOpponents * 2; + + if (cardsNeededPerTrial > remainingDeck.length) { + throw new OddsCalculatorError( + "Not enough remaining cards in the deck for this many opponents" + ); + } + + let wins = 0; + let ties = 0; + let losses = 0; + + for (let trial = 0; trial < iterations; trial++) { + const drawn = shuffle(remainingDeck).slice(0, cardsNeededPerTrial); + const fullBoard = [...boardCards, ...drawn.slice(0, boardSlotsNeeded)]; + + const heroRank = bestHandRank([...holeCards, ...fullBoard]); + if (!heroRank) continue; // shouldn't happen once board has 3+ cards + + let bestOpponentRank: HandRank | null = null; + for (let opp = 0; opp < numOpponents; opp++) { + const start = boardSlotsNeeded + opp * 2; + const oppHole = drawn.slice(start, start + 2); + const oppRank = bestHandRank([...oppHole, ...fullBoard]); + if (oppRank && (!bestOpponentRank || compareHandRank(oppRank, bestOpponentRank) > 0)) { + bestOpponentRank = oppRank; + } + } + + if (!bestOpponentRank) continue; + const cmp = compareHandRank(heroRank, bestOpponentRank); + if (cmp > 0) wins++; + else if (cmp === 0) ties++; + else losses++; + } + + const total = wins + ties + losses; + if (total === 0) { + return { win: 0, tie: 0, loss: 0, iterations: 0 }; + } + return { + win: wins / total, + tie: ties / total, + loss: losses / total, + iterations: total, + }; +}