Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/src/__tests__/odds-calculator-modal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<OddsCalculatorModal open={false} onClose={() => {}} />
);
expect(container.firstChild).toBeNull();
});

it("renders the calculator UI when open", () => {
render(<OddsCalculatorModal open onClose={() => {}} />);
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(<OddsCalculatorModal open onClose={() => {}} />);
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(<OddsCalculatorModal open onClose={() => {}} />);

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(<OddsCalculatorModal open onClose={() => {}} />);

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(<OddsCalculatorModal open onClose={() => { closed = true; }} />);
fireEvent.click(screen.getByText("βœ•"));
expect(closed).toBe(true);
});
});
126 changes: 126 additions & 0 deletions app/src/__tests__/odds-calculator.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 20 additions & 0 deletions app/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,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) {
Expand Down Expand Up @@ -1043,6 +1044,25 @@ export default function Home() {
πŸ“Š STATS
</Link>

{/* Odds calculator tool (Issue #163) */}
<button
onClick={() => setOddsCalculatorOpen(true)}
className="fixed top-3 right-20 z-10 text-[8px] opacity-60 hover:opacity-100 transition-opacity"
style={{
color: "#c47d2e",
fontFamily: "'Press Start 2P', monospace",
background: "none",
border: "none",
cursor: "pointer",
}}
>
🎲 ODDS
</button>
<OddsCalculatorModal
open={oddsCalculatorOpen}
onClose={() => setOddsCalculatorOpen(false)}
/>

{/* Transaction Simulation */}
{joinTableSim.showSimulation && joinTableSim.simulation && (
<TransactionSimulation
Expand Down
Loading
Loading