block
Range drawer
pokerdata.io's Browse drawer: one pack's solution, slid in over the page. Preflop packs walk the tree in the Range Explorer (hold'em grid, PLO and PLO5 hand lists, ICM risk premium); postflop packs get the node walker and game tree. Point it at your feeds with setExplorerEndpoints.
The Browse drawer from pokerdata.io and Downloads. Pass a catalog row's browsetarget. The explorers read pokerdata.io's feeds by default; call setExplorerEndpoints to use your own.
Live
Installation
$ npx shadcn@latest add https://www.pokerstudy.ai/r/range-drawer.jsonProps
Generated from the component source with react-docgen-typescript.
ActionBar
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | ActionItem[] | – | – |
GatePrompt
| Prop | Type | Default | Description |
|---|---|---|---|
| message * | string | – | – |
IcmRangeTabs
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | ExplorerAction[] | – | – |
| history * | string | – | – |
| node * | WalkNode | – | – |
| structure * | IcmStructure | – | – |
| table * | TableSpec | – | – |
PackExplorer
| Prop | Type | Default | Description |
|---|---|---|---|
| embedded | boolean | false | Drawer mode: the explorer fills its parent and scrolls its own panels, with `header` across the top (Back, Reset, and the drawer's close). |
| footer | ((props: { node: WalkNode | null; }) => ReactNode) | – | Below the explorer, for the current node (the full page's hand summary). |
| game * | "nl" | "nl9" | "plo" | "plo9" | "plo5" | "mtt9" | "nlmtt8" | "nlh8-hydra" | "nlh8-icm" | "nlhu" | – | – |
| header | { title?: ReactNode; trailing?: ReactNode; } | – | – |
| rake | string | – | The catalog's rake line ("5%, 2bb cap", "None — tournament…"). |
| scenario | string | – | ICM scenario id (`35left-avg40-even`) for the nlh8-icm game. |
| seatStacks | number[] | – | An ICM stage's stack per seat, in seat order; omit when every seat has `stack`. |
| stack * | number | – | – |
PloRangeList
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | NodeAction[] | – | – |
| stack * | number | – | – |
Plo5RangePanel
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | NodeAction[] | – | – |
| history * | string | – | – |
| stack * | number | – | – |
PreflopStrategyGrid
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | NodeAction[] | – | – |
| onHover | ((hand: string | null) => void) | – | Hovering a cell (null on leaving the grid). |
| onSelect | ((hand: string) => void) | – | Clicking a cell. |
| selected | string | null | – | The hand outlined in the grid (hovered or pinned). |
RangeDrawer
| Prop | Type | Default | Description |
|---|---|---|---|
| onClose * | () => void | – | – |
| target * | RangeDrawerTarget | null | – | – |
GameTree
| Prop | Type | Default | Description |
|---|---|---|---|
| board * | string | – | – |
| generatedAt | string | – | – |
| onSelect * | (path: string[]) => void | – | – |
| pack * | string | – | – |
| selectedPath * | string[] | – | – |
PostflopBrowser
| Prop | Type | Default | Description |
|---|---|---|---|
| board * | string | – | – |
| onPathChange | ((path: string[]) => void) | – | – |
| pack * | string | – | – |
| path | string[] | – | When set, the walker stays on this API path (game-tree clicks). |
StrategyGrid
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | SolverActionInfo[] | – | – |
| hands * | SolverHandStrategy[] | – | – |
| reach * | ReadonlyMap<string, number> | – | – |
Source
components/pokerdata/explorer/gate-prompt.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { buttonVariants } from "@/components/ui/button";
import {
GATE_PLANS_HREF,
GATE_SIGN_IN_HREF,
gateReason,
} from "@/lib/pokerdata/explorer/gate-copy";
/**
* What a data view shows in place of its error line. A plan-gate denial gets
* a panel that says what unlocks the data and links there (sign in, see
* plans); anything else stays the plain error it was.
*/
export function GatePrompt({ message }: { message: string }) {
const pathname = usePathname();
const reason = gateReason(message);
if (!reason) return <p className="text-xs text-red-400">{message}</p>;
return (
<div className="flex flex-col gap-3 rounded-xl border border-neutral-800 bg-neutral-900/60 p-4 font-sans sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-neutral-100">{message}</p>
<p className="text-xs text-neutral-400">
Browsing is free. Downloads and the API are paid.
</p>
</div>
<div className="flex shrink-0 gap-2">
{reason === "sign-in" ? (
<Link
href={`${GATE_SIGN_IN_HREF}${encodeURIComponent(pathname)}`}
className={buttonVariants({ size: "lg" })}
>
Sign in
</Link>
) : null}
<Link
href={GATE_PLANS_HREF}
className={buttonVariants({
size: "lg",
variant: reason === "sign-in" ? "outline" : "default",
})}
>
Create account
</Link>
</div>
</div>
);
}
components/pokerdata/explorer/icm-range-tabs.tsx
"use client";
import { useMemo, useState } from "react";
import { HandMatrix, type MatrixCell } from "@/components/pokerstudy/hand-matrix";
import type { ExplorerAction } from "@/components/pokerstudy/range-explorer-panels";
import { ALL_HANDS } from "@/lib/pokerstudy/poker";
import { parseHistory, type TableSpec, type WalkNode } from "@/lib/pokerstudy/preflop-walk";
import { icmPressure, icmStackSwings, type IcmStructure, type StackSwing } from "@/lib/pokerdata/explorer/icm-pressure";
import { dollars, payoutRows } from "@/lib/pokerdata/payouts";
/**
* The range panel for an ICM pack: the acting seat's 13×13 range, or its ICM
* risk curve against one other seat — what winning or losing chips to that
* seat does to its tournament $EV, and the gap between the two (the risk
* premium). Stacks are the pack's starting stacks, the same ones the
* details page's bubble factor grid uses. A third view lists the payouts
* the solve used.
*/
type View = "range" | "risk" | "payouts";
export function IcmRangeTabs({
node,
history,
actions,
table,
structure,
}: {
node: WalkNode;
history: string;
actions: ExplorerAction[];
table: TableSpec;
structure: IcmStructure;
}) {
const [view, setView] = useState<View>("range");
const [pinned, setPinned] = useState<string | null>(null);
const seats = table.order;
const stacks = useMemo(
() => seats.map((p) => table.seatStacks?.[p] ?? table.stack),
[seats, table.seatStacks, table.stack],
);
// Hero is the seat to act; villain defaults to the seat with the most in
// the pot (the raiser), or the big blind when nobody has raised.
const walk = useMemo(() => parseHistory(history, table), [history, table]);
const heroPos = node.actor ?? walk.actor ?? seats[0];
const hero = Math.max(0, seats.indexOf(heroPos));
const defaultVillain = useMemo(() => {
let best = -1;
let most = 1;
seats.forEach((p, i) => {
if (i !== hero && walk.seats[p].committed > most) {
most = walk.seats[p].committed;
best = i;
}
});
if (best >= 0) return best;
const bb = seats.indexOf("BB");
return bb !== hero ? bb : seats.indexOf("SB");
}, [seats, walk, hero]);
const [villainPick, setVillainPick] = useState<number | null>(null);
const villain = villainPick !== null && villainPick !== hero ? villainPick : defaultVillain;
const colors = actions.map((a) => a.color);
const cells = useMemo<(MatrixCell | null)[]>(
() =>
ALL_HANDS.map((h) => {
const freqs = actions.map((a) => a.action.weights[h] ?? 0);
return freqs.reduce((s, f) => s + f, 0) > 0.001 ? { label: h, freqs } : null;
}),
[actions],
);
const foldIndex = actions.findIndex((a) => a.action.kind === "fold");
return (
<div className="flex w-full flex-col gap-4">
<div
role="tablist"
aria-label="Range view"
className="inline-flex w-fit rounded-full border border-neutral-800 bg-neutral-950 p-0.5 text-xs"
>
{(
[
["range", "Range"],
["risk", "Risk premium"],
["payouts", "Payouts"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={view === key}
onClick={() => setView(key)}
className={`h-7 cursor-pointer rounded-full px-3 transition-colors duration-150 ${
view === key ? "bg-neutral-800 text-neutral-100" : "text-neutral-500 hover:text-neutral-200"
}`}
>
{label}
</button>
))}
</div>
{view === "range" ? (
/* In the drawer the explorer sizes the range to the card's height
(`cqh`); the tab row above the matrix takes another 2.75rem. */
<div className="w-full lg:w-[min(100%,calc(100cqh_-_5.5rem))]">
<HandMatrix
cells={cells}
colors={colors}
legend={actions.map((a) => ({
label: `${a.label} ${a.freq === undefined ? "" : `${(a.freq * 100).toFixed(0)}%`}`.trim(),
color: a.color,
}))}
selected={pinned}
onSelect={(h) => setPinned((cur) => (cur === h ? null : h))}
foldIndex={foldIndex < 0 ? undefined : foldIndex}
aggressiveFirst
/>
</div>
) : view === "payouts" ? (
<PayoutsPanel structure={structure} />
) : (
<RiskPanel
seats={seats}
stacks={stacks}
structure={structure}
hero={hero}
villain={villain}
onVillain={setVillainPick}
/>
)}
</div>
);
}
const fmtBb = (bb: number) => `${Number.isInteger(bb) ? bb : bb.toFixed(1)}bb`;
const fmtUsd = (v: number) => `${v < 0 ? "−" : v > 0 ? "+" : ""}$${Math.abs(v).toFixed(0)}`;
function RiskPanel({
seats,
stacks,
structure,
hero,
villain,
onVillain,
}: {
seats: string[];
stacks: number[];
structure: IcmStructure;
hero: number;
villain: number;
onVillain: (i: number) => void;
}) {
const swings = useMemo(
() => icmStackSwings(stacks, structure, hero, villain),
[stacks, structure, hero, villain],
);
const pressure = useMemo(() => icmPressure(stacks, structure), [stacks, structure]);
const bf = pressure.bubbleFactor[hero][villain] ?? 1;
const rp = pressure.riskPremium[hero][villain] ?? 0;
const last = swings[swings.length - 1];
return (
<div className="flex w-full max-w-[560px] flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5">
{seats.map((p, i) =>
i === hero ? null : (
<button
key={p}
type="button"
onClick={() => onVillain(i)}
className={`h-7 cursor-pointer rounded-full px-3 text-xs transition-colors duration-150 ${
i === villain
? "bg-neutral-100 font-medium text-neutral-950"
: "border border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:text-neutral-200"
}`}
>
{p} {fmtBb(stacks[i])}
</button>
),
)}
</div>
</div>
<SwingChart swings={swings} />
<dl className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<Fact label="Bubble factor" value={bf.toFixed(2)} />
<Fact label="Risk premium" value={`${rp >= 0 ? "+" : ""}${(rp * 100).toFixed(1)}%`} />
<Fact label={`Win ${fmtBb(last.bb)}`} value={fmtUsd(last.win)} />
<Fact label={`Lose ${fmtBb(last.bb)}`} value={fmtUsd(last.lose)} />
</dl>
</div>
);
}
/** The payouts the solve used: place, prize and share of what's left. */
function PayoutsPanel({ structure }: { structure: IcmStructure }) {
const rows = payoutRows(structure.payouts);
return (
<div className="flex w-full max-w-[560px] flex-col gap-3">
<p className="text-xs text-neutral-400">
{structure.source.replace(/^./, (c) => c.toUpperCase())}. {structure.playersLeft} players left
{structure.playersLeft > 8
? `, everyone off this table at ${fmtBb(structure.fieldAvgBb)}`
: ", all at this table"}
.
</p>
<div className="grid grid-cols-[1fr_auto_auto] gap-x-6 text-xs text-neutral-500">
<span className="pb-1.5">Place</span>
<span className="pb-1.5 text-right">Prize</span>
<span className="pb-1.5 text-right">Share</span>
</div>
<dl className="-mt-3 divide-y divide-neutral-800">
{rows.map((r) => (
<div key={r.places} className="grid grid-cols-[1fr_auto_auto] items-center gap-x-6 py-1.5 text-[13px]">
<dt className="text-neutral-300">{r.places}</dt>
<dd className="text-right tabular-nums text-neutral-100">{dollars(r.prize)}</dd>
<dd className="w-12 text-right tabular-nums text-neutral-500">{(r.share * 100).toFixed(1)}%</dd>
</div>
))}
</dl>
</div>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5 rounded-[var(--rb-r-md,8px)] border border-neutral-800 bg-neutral-900/60 px-3 py-2">
<dt className="text-neutral-500">{label}</dt>
<dd className="text-[13px] font-medium tabular-nums text-neutral-100">{value}</dd>
</div>
);
}
const WIN = "#86b56f";
const LOSE = "#4f7fd1";
const PREMIUM = "#c9533e";
/** $EV change by chips won or lost: three filled areas from the zero line. */
function SwingChart({ swings }: { swings: StackSwing[] }) {
const W = 560;
const H = 320;
const pad = { l: 52, r: 44, t: 12, b: 32 };
const maxBb = swings[swings.length - 1].bb || 1;
const top = Math.max(...swings.map((s) => s.win), 1);
const bottom = Math.min(...swings.map((s) => s.lose), -1);
const span = niceStep((top - bottom) / 5);
const yMax = Math.ceil(top / span) * span;
const yMin = Math.floor(bottom / span) * span;
const x = (bb: number) => pad.l + (bb / maxBb) * (W - pad.l - pad.r);
const y = (v: number) => pad.t + ((yMax - v) / (yMax - yMin)) * (H - pad.t - pad.b);
const area = (key: "win" | "lose" | "premium") =>
`M${x(0)},${y(0)} ` +
swings.map((s) => `L${x(s.bb).toFixed(1)},${y(s[key]).toFixed(1)}`).join(" ") +
` L${x(maxBb)},${y(0)} Z`;
const line = (key: "win" | "lose" | "premium") =>
swings.map((s, i) => `${i ? "L" : "M"}${x(s.bb).toFixed(1)},${y(s[key]).toFixed(1)}`).join(" ");
const yTicks: number[] = [];
for (let v = yMin; v <= yMax + 1e-9; v += span) yTicks.push(v);
const xStep = niceStep(maxBb / 5);
const xTicks: number[] = [];
for (let v = xStep; v <= maxBb + 1e-9; v += xStep) xTicks.push(v);
const last = swings[swings.length - 1];
return (
<figure className="flex flex-col gap-2">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label="Change to tournament $EV by chips won or lost">
{yTicks.map((v) => (
<g key={`y${v}`}>
<line x1={pad.l} x2={W - pad.r} y1={y(v)} y2={y(v)} stroke="currentColor" className="text-neutral-800" />
<text x={pad.l - 8} y={y(v)} dy="0.32em" textAnchor="end" className="fill-neutral-500 text-[11px] tabular-nums">
{fmtUsd(v)}
</text>
</g>
))}
{xTicks.map((v) => (
<g key={`x${v}`}>
<line x1={x(v)} x2={x(v)} y1={pad.t} y2={H - pad.b} stroke="currentColor" className="text-neutral-800/60" />
<text x={x(v)} y={H - pad.b + 18} textAnchor="middle" className="fill-neutral-500 text-[11px] tabular-nums">
{fmtBb(v)}
</text>
</g>
))}
<path d={area("win")} fill={WIN} fillOpacity={0.35} />
<path d={area("lose")} fill={LOSE} fillOpacity={0.35} />
<path d={area("premium")} fill={PREMIUM} fillOpacity={0.45} />
<path d={line("win")} fill="none" stroke={WIN} strokeWidth={2} />
<path d={line("lose")} fill="none" stroke={LOSE} strokeWidth={2} />
<path d={line("premium")} fill="none" stroke={PREMIUM} strokeWidth={2} />
<line x1={pad.l} x2={W - pad.r} y1={y(0)} y2={y(0)} stroke="currentColor" className="text-neutral-500" />
{(["win", "premium", "lose"] as const).map((k) => (
<text key={k} x={x(maxBb) + 6} y={y(last[k])} dy="0.32em" className="fill-neutral-200 text-[11px] tabular-nums">
{fmtUsd(last[k])}
</text>
))}
</svg>
<figcaption className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-400">
<Key color={WIN} label="$EV change if we win" />
<Key color={LOSE} label="$EV change if we lose" />
<Key color={PREMIUM} label="Risk premium" />
<span className="text-neutral-500">x: chips won or lost</span>
</figcaption>
</figure>
);
}
function Key({ color, label }: { color: string; label: string }) {
return (
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-2.5 rounded-sm" style={{ background: color }} />
{label}
</span>
);
}
/** 1, 2, 2.5 or 5 times a power of ten, at least `raw`. */
function niceStep(raw: number): number {
const p = 10 ** Math.floor(Math.log10(Math.max(raw, 1e-9)));
for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= raw) return m * p;
return 10 * p;
}
components/pokerdata/explorer/pack-explorer.tsx
"use client";
// One preflop pack in the pokerstudy.ai Range Explorer block: the block walks
// the tree one seat at a time, asking `getNodeOrNull` for each decision. A 404
// closes the line: the packs are preflop-only, so that is the end of the
// preview. The pack's catalog facts (rake, an ICM stage's seat stacks) come in
// as props, so any host can show any pack without the catalog itself.
import { type ReactNode, useCallback, useMemo } from "react";
import { GatePrompt } from "@/components/pokerdata/explorer/gate-prompt";
import { RangeExplorer } from "@/components/pokerstudy/range-explorer";
import type { ExplorerAction } from "@/components/pokerstudy/range-explorer-panels";
import { houseRaiseToBb } from "@/lib/pokerdata/explorer/house";
import type { TableSpec, WalkNode } from "@/lib/pokerstudy/preflop-walk";
import {
getNodeOrNull,
SEAT_ORDER,
type Game,
type NodeAction,
type Position,
} from "@/lib/pokerdata/explorer/preflop";
import { icmStructureForScenario } from "@/lib/pokerdata/explorer/icm-pressure";
import { IcmRangeTabs } from "@/components/pokerdata/explorer/icm-range-tabs";
import { PloRangeList } from "@/components/pokerdata/explorer/plo-range-list";
import { Plo5RangePanel } from "@/components/pokerdata/explorer/plo5-range-panel";
/** Dead big-blind ante per game — MTT packs play a 1bb BB-ante. */
const ANTE_BB: Record<Game, number> = {
nl: 0,
nl9: 0,
plo: 0,
plo9: 0,
plo5: 0,
mtt9: 1,
nlmtt8: 1,
"nlh8-hydra": 0,
"nlh8-icm": 0,
nlhu: 0,
};
/** Ante every seat posts — the classic-ante MTT set plays 0.1bb each, ICM 0.125bb. */
const ANTE_PER_SEAT_BB: Record<Game, number> = {
nl: 0,
nl9: 0,
plo: 0,
plo9: 0,
plo5: 0,
mtt9: 0,
nlmtt8: 0,
"nlh8-hydra": 0.1,
"nlh8-icm": 0.125,
nlhu: 0.125,
};
/** Dead ante no seat's stack pays — the PLO5 tree's 1.2bb. */
const DEAD_ANTE_BB: Partial<Record<Game, number>> = { plo5: 1.2 };
/** Games rendered on the 13×13 hold'em grid (vs the PLO hand-class list). */
export function usesGrid(game: Game): boolean {
return (
game === "nl" ||
game === "nl9" ||
game === "mtt9" ||
game === "nlmtt8" ||
game === "nlh8-hydra" ||
game === "nlh8-icm" ||
game === "nlhu"
);
}
/** The game and seat count, for the explorer's stat strip. */
const GAME_FACTS: Record<Game, { game: string; players: string }> = {
nl: { game: "NLHE", players: "6-max" },
nl9: { game: "NLHE", players: "9-max" },
nlmtt8: { game: "NLHE", players: "8-max" },
"nlh8-hydra": { game: "NLHE", players: "8-max" },
"nlh8-icm": { game: "NLHE", players: "8-max" },
nlhu: { game: "NLHE", players: "Heads-up" },
mtt9: { game: "NLHE", players: "9-max" },
plo: { game: "PLO", players: "6-max" },
plo9: { game: "PLO", players: "9-max" },
plo5: { game: "PLO5", players: "6-max" },
};
/** The ante structure the pack was solved with. */
function anteFact(game: Game): string {
// The tables hold 0 for "no ante of this kind", so fall through on 0, not just undefined.
const perSeat = ANTE_PER_SEAT_BB[game];
if (perSeat) return `${perSeat}bb per seat`;
const ante = ANTE_BB[game] || DEAD_ANTE_BB[game];
return ante ? `${ante}bb` : "None";
}
/** The catalog's rake line, short: "5%, 0.5bb cap", or none for a tournament. */
function rakeFact(rake: string | undefined): string {
const m = /^(\d+(?:\.\d+)?%),\s*([\d.]+bb cap)/.exec(rake ?? "");
if (m) return `${m[1]}, ${m[2]}`;
if (/^none/i.test(rake ?? "")) return "None";
return rake || "—";
}
export interface PackExplorerProps {
game: Game;
stack: number;
/** ICM scenario id (`35left-avg40-even`) for the nlh8-icm game. */
scenario?: string;
/** The catalog's rake line ("5%, 2bb cap", "None — tournament…"). */
rake?: string;
/** An ICM stage's stack per seat, in seat order; omit when every seat has `stack`. */
seatStacks?: number[];
/**
* Drawer mode: the explorer fills its parent and scrolls its own panels,
* with `header` across the top (Back, Reset, and the drawer's close).
*/
embedded?: boolean;
header?: { title?: ReactNode; trailing?: ReactNode };
/** Below the explorer, for the current node (the full page's hand summary). */
footer?: (props: { node: WalkNode | null }) => ReactNode;
}
export function PackExplorer({
game,
stack,
scenario,
rake,
seatStacks,
embedded = false,
header,
footer,
}: PackExplorerProps) {
// The table the pack was solved on: its seats, antes and (for an ICM
// stage) each seat's own stack. House raise codes size the retired MTT set.
const table = useMemo<TableSpec>(() => {
const order = SEAT_ORDER[game];
return {
order,
stack,
seatStacks: seatStacks
? (Object.fromEntries(order.map((p, i) => [p, seatStacks[i] ?? stack])) as Partial<
Record<Position, number>
>)
: undefined,
bbAnte: ANTE_BB[game],
antePerSeat: ANTE_PER_SEAT_BB[game],
deadAnte: DEAD_ANTE_BB[game] ?? 0,
sizeOf: houseRaiseToBb,
};
}, [game, stack, seatStacks]);
// A 404 (null) means the betting round closed on this line; a gate answer
// throws, and the block shows it through `GatePrompt`.
const loadNode = useCallback(
(history: string): Promise<WalkNode | null> =>
getNodeOrNull(game, stack, history, game === "nlh8-icm" ? scenario : undefined),
[game, stack, scenario],
);
const grid = usesGrid(game);
const stats = useMemo(() => {
const f = GAME_FACTS[game];
return [
{ label: "Game", value: f.game },
{ label: "Stack", value: `${stack}bb` },
{ label: "Players", value: f.players },
{ label: "Rake", value: rakeFact(rake) },
{ label: "Ante", value: anteFact(game) },
];
}, [game, stack, rake]);
// PLO nodes carry no 13×13 weights: each action's share is its pool % (PLO5)
// or its hand count, and the range is a hand-class list.
const actionsFor = useMemo(
() =>
grid
? undefined
: (node: WalkNode, colored: ExplorerAction[]): ExplorerAction[] => {
const pool = colored.reduce((s, { action }) => s + ((action as NodeAction).poolPct ?? 0), 0);
return colored.map((c) => {
const a = c.action as NodeAction;
return {
...c,
freq: pool > 0 ? (a.poolPct ?? 0) / pool : undefined,
detail: a.hands != null ? `${a.hands.toLocaleString("en-US")} hands` : undefined,
};
});
},
[grid],
);
// ICM packs tab between the range and the acting seat's risk premium.
const icm = useMemo(
() => (game === "nlh8-icm" && scenario ? icmStructureForScenario(scenario) : null),
[game, scenario],
);
const renderRange = useMemo(
() =>
icm
? ({ node, history, actions }: { node: WalkNode; history: string; actions: ExplorerAction[] }) => (
<IcmRangeTabs node={node} history={history} actions={actions} table={table} structure={icm} />
)
: grid
? undefined
: ({ node, history }: { node: WalkNode; history: string }) =>
game === "plo5" ? (
<Plo5RangePanel actions={node.actions as NodeAction[]} stack={stack} history={history} />
) : (
<PloRangeList actions={node.actions as NodeAction[]} stack={stack} />
),
[icm, table, grid, game, stack],
);
return (
// Keyed so a pack change restarts the walk at the root.
<RangeExplorer
key={`${game}-${stack}-${scenario ?? ""}`}
table={table}
loadNode={loadNode}
actionsFor={actionsFor}
renderRange={renderRange}
renderError={(message) => <GatePrompt message={message} />}
header={embedded ? header : undefined}
fill={embedded}
stats={stats}
theme="pokerdata"
contentClassName="mx-auto w-full max-w-[1760px] p-3 sm:p-4"
footer={footer}
/>
);
}
components/pokerdata/explorer/plo-range-list.tsx
"use client";
// PLO hand-class viewer for the range explorer — GTO-Wizard-style layout: one
// section per action (Fold / Call / Raise pot …), each listing every hand that
// takes the action, sorted by EV. PLO has no 13×13 grid (16,432
// suit-isomorphism classes), so hands render as four-color card tiles. The
// filter chips (ported from the mobile app's PloRangeList) apply to every
// section at once.
import { useMemo, useState } from "react";
import {
labelForAction,
sortActionsForDisplay,
type NodeAction,
} from "@/lib/pokerdata/explorer/preflop";
import {
ploStringToCards,
SUIT_COLOR_4,
suitGlyph,
type Suit,
} from "@/lib/pokerdata/explorer/plo-hand";
import {
FILTER_GROUPS,
handProps,
matchesFilters,
type FilterId,
type HandProps,
} from "@/lib/pokerdata/explorer/plo-filter";
import { colorActions } from "./preflop-strategy-grid";
interface Row {
hand: string;
/** Frequency of taking THIS action, in [0, 1]. */
freq: number;
/** EV (bb) of this action, when the data carries EV. */
evBb: number | null;
/** EV edge (bb) over the hand's best alternative action; null if unknown. */
evDiffBb: number | null;
props: HandProps;
}
/** Rows initially rendered per section — expand on demand so the 13k-hand
* fold list can't lock the tab. */
const SECTION_PREVIEW = 150;
export function PloRangeList({
actions,
stack,
}: {
actions: NodeAction[];
stack: number;
}) {
const [active, setActive] = useState<Set<FilterId>>(() => new Set());
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const colored = useMemo(
() => colorActions(sortActionsForDisplay(actions)),
[actions],
);
// hand → filterable props, computed once across all actions' key sets.
const propsByHand = useMemo(() => {
const map = new Map<string, HandProps>();
for (const { action } of colored)
for (const h of Object.keys(action.weights))
if (!map.has(h)) map.set(h, handProps(h));
return map;
}, [colored]);
// One section per action: every hand taking it, sorted by EV desc.
const sections = useMemo(() => {
return colored.map(({ action, color }, idx) => {
const rows: Row[] = [];
for (const [hand, freq] of Object.entries(action.weights)) {
if (freq <= 0.0005) continue;
const ev = action.evs?.[hand];
// Edge over the best alternative action offering an EV for this hand.
let bestOther: number | null = null;
colored.forEach(({ action: other }, j) => {
if (j === idx) return;
const e = other.evs?.[hand];
if (e !== undefined && (bestOther === null || e > bestOther))
bestOther = e;
});
rows.push({
hand,
freq,
evBb: ev === undefined ? null : ev / 2,
evDiffBb:
ev === undefined || bestOther === null
? null
: (ev - bestOther) / 2,
props: propsByHand.get(hand)!,
});
}
rows.sort(
(a, b) => (b.evBb ?? -Infinity) - (a.evBb ?? -Infinity) || b.freq - a.freq,
);
const mass = rows.reduce((s, r) => s + r.freq, 0);
return { action, color, label: labelForAction(action, stack), rows, mass };
});
}, [colored, propsByHand, stack]);
const totalMass =
sections.reduce((s, sec) => s + sec.mass, 0) || 1;
const toggle = (id: FilterId) =>
setActive((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const filtered = sections.map((sec) => ({
...sec,
rows:
active.size === 0
? sec.rows
: sec.rows.filter((r) => matchesFilters(r.props, active)),
}));
const matching = useMemo(() => {
if (active.size === 0) return propsByHand.size;
let n = 0;
for (const p of propsByHand.values()) if (matchesFilters(p, active)) n++;
return n;
}, [propsByHand, active]);
const hasEv = sections.some((s) => s.rows.some((r) => r.evBb !== null));
return (
<div className="flex flex-col gap-3">
{/* Filter chips: OR within a group, AND across groups; they slice every
action section below at once. */}
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-neutral-500 tabular-nums">
{matching.toLocaleString("en-US")} of{" "}
{propsByHand.size.toLocaleString("en-US")} hands
</span>
{active.size > 0 ? (
<button
type="button"
onClick={() => setActive(new Set())}
className="text-xs text-neutral-400 underline underline-offset-2 hover:text-neutral-200"
>
Clear filters
</button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{FILTER_GROUPS.map((group, gi) => (
<div key={group.key} className="flex items-center gap-1.5">
{gi > 0 ? <span className="h-4 w-px bg-neutral-800" /> : null}
{group.chips.map((chip) => {
const on = active.has(chip.id);
return (
<button
key={chip.id}
type="button"
onClick={() => toggle(chip.id)}
className={
on
? "rounded-full bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-950"
: "rounded-full border border-neutral-700 px-3 py-1 text-xs text-neutral-400 hover:border-neutral-500 hover:text-neutral-200"
}
>
{chip.label}
</button>
);
})}
</div>
))}
</div>
{/* One section per action, side by side on wide screens. */}
<div
className="grid gap-3"
style={{
gridTemplateColumns: `repeat(auto-fit, minmax(280px, 1fr))`,
}}
>
{filtered.map((sec) => {
const isOpen = expanded.has(sec.action.action);
const shown = isOpen ? sec.rows : sec.rows.slice(0, SECTION_PREVIEW);
const pct = (sec.mass / totalMass) * 100;
return (
<section
key={sec.action.action}
className="flex flex-col overflow-hidden rounded-xl border border-neutral-800 bg-neutral-950"
>
{/* Tinted header, GTO-Wizard style. */}
<div
className="flex flex-col gap-1.5 px-3 py-2.5"
style={{ backgroundColor: `${sec.color}26` }}
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium text-neutral-100">
{sec.label}
</span>
<span className="text-[11px] text-neutral-400 tabular-nums">
{sec.rows.length.toLocaleString("en-US")}{" "}
{sec.rows.length === 1 ? "hand" : "hands"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-base font-semibold text-neutral-50 tabular-nums">
{pct.toFixed(1)}%
</span>
<div className="flex h-1.5 flex-1 overflow-hidden rounded-full bg-white/[0.08]">
<div
style={{
width: `${pct}%`,
backgroundColor: sec.color,
}}
/>
</div>
</div>
</div>
<div className="flex max-h-[560px] flex-col overflow-y-auto px-2 pb-2">
<div
className={`sticky top-0 z-10 grid items-center gap-2 bg-neutral-950 px-1 py-1.5 text-[10px] uppercase tracking-wide text-neutral-600 ${
hasEv
? "grid-cols-[minmax(0,1fr)_44px_52px_52px]"
: "grid-cols-[minmax(0,1fr)_44px]"
}`}
>
<span>Hand</span>
<span className="text-right">Freq %</span>
{hasEv ? (
<>
<span className="text-right">EV bb</span>
<span className="text-right">Diff bb</span>
</>
) : null}
</div>
{shown.length === 0 ? (
<p className="py-6 text-center text-xs text-neutral-600">
No hands match these filters.
</p>
) : (
shown.map((r) => (
<div
key={r.hand}
className={`grid items-center gap-2 rounded px-1 py-[3px] hover:bg-neutral-900/60 ${
hasEv
? "grid-cols-[minmax(0,1fr)_44px_52px_52px]"
: "grid-cols-[minmax(0,1fr)_44px]"
}`}
style={{
contentVisibility: "auto",
containIntrinsicSize: "auto 30px",
}}
>
<HandCards hand={r.hand} />
<span className="text-right text-[11px] text-neutral-300 tabular-nums">
{Math.round(r.freq * 100)}
</span>
{hasEv ? (
<>
<span className="text-right text-[11px] text-neutral-300 tabular-nums">
{r.evBb === null ? "—" : r.evBb.toFixed(2)}
</span>
<span
className={`text-right text-[11px] tabular-nums ${
r.evDiffBb === null
? "text-neutral-600"
: r.evDiffBb >= 0
? "text-emerald-400"
: "text-rose-400"
}`}
>
{r.evDiffBb === null
? "—"
: `${r.evDiffBb >= 0 ? "+" : ""}${r.evDiffBb.toFixed(2)}`}
</span>
</>
) : null}
</div>
))
)}
{!isOpen && sec.rows.length > SECTION_PREVIEW ? (
<button
type="button"
onClick={() =>
setExpanded((prev) => new Set(prev).add(sec.action.action))
}
className="mx-1 my-1 self-start rounded-full border border-neutral-800 px-3 py-1 text-[11px] text-neutral-500 hover:border-neutral-600 hover:text-neutral-300"
>
Show all {sec.rows.length.toLocaleString("en-US")} ↓
</button>
) : null}
</div>
</section>
);
})}
</div>
</div>
);
}
// Four-color deck so each suit reads at a glance: the canonical hand string is
// expanded to 4 concrete cards (parenthesised cards share a suit), then each is
// drawn as a small suit-tinted tile.
function HandCards({ hand }: { hand: string }) {
const cards = ploStringToCards(hand);
return (
<div className="flex items-center gap-[3px]">
{cards.map((card, i) => {
const suit = card[1] as Suit;
return (
<span
key={`${hand}-${i}`}
className="flex h-[26px] w-5 flex-col items-center justify-center rounded text-white"
style={{ backgroundColor: SUIT_COLOR_4[suit] }}
>
<span className="text-[11px] font-bold leading-[12px]">
{card[0] === "T" ? "10" : card[0]}
</span>
<span className="text-[8px] leading-[9px]">{suitGlyph(suit)}</span>
</span>
);
})}
</div>
);
}
components/pokerdata/explorer/plo5-range-panel.tsx
"use client";
// PLO5 range viewer for the explorer, ported from pokerstudy.ai's Explorer
// (web/src/components/explorer/plo5-range-panel.tsx). A PLO5 node has 204k
// hand classes, so the node carries only each action's pool %; the hand list
// is each action's top hands by EV, fetched a page at a time, with a rank
// filter (`AK222`) and a one-combo lookup (`AsAhKdKcQs`).
import { useEffect, useState } from "react";
import {
getPlo5HandStrategy,
getPlo5NodeList,
labelForAction,
plo5RaiseToBb,
formatBb,
type NodeAction,
type Plo5ActionList,
type Plo5HandStrategy,
type Plo5ListedHand,
} from "@/lib/pokerdata/explorer/preflop";
import { SUIT_COLOR_4, suitGlyph, type Suit } from "@/lib/pokerdata/explorer/plo-hand";
import { colorActions } from "./preflop-strategy-grid";
const LIST_PAGE = 200;
const LIST_MAX = 2000;
/** A rank filter as the API reads it (`AK222`), or "" when `q` isn't one. */
function rankFilter(q: string): string {
const compact = q.replace(/[\s,]+/g, "").toUpperCase();
return /^[AKQJT2-9]{1,5}$/.test(compact) ? compact : "";
}
/** Five distinct cards typed in any spacing or case. */
function isCombo(q: string): boolean {
const compact = q.replace(/[\s,]+/g, "");
if (!/^([AKQJT2-9][shdc]){5}$/i.test(compact)) return false;
const cards = compact.toLowerCase().match(/../g)!;
return new Set(cards).size === 5;
}
function actionName(action: string): string {
const bb = plo5RaiseToBb(action);
if (bb !== null) return `Raise ${formatBb(bb)}`;
return action === "AllIn" ? "All-in" : action;
}
export function Plo5RangePanel({
actions,
stack,
history,
}: {
actions: NodeAction[];
stack: number;
history: string;
}) {
const colored = colorActions(actions);
const denom = colored.reduce((s, { action }) => s + (action.poolPct ?? 0), 0) || 1;
const [query, setQuery] = useState("");
const [lookup, setLookup] = useState<Plo5HandStrategy | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [listed, setListed] = useState<Plo5ActionList[] | null>(null);
const [listLimit, setListLimit] = useState(LIST_PAGE);
const [listBusy, setListBusy] = useState(false);
const [listError, setListError] = useState<string | null>(null);
const ranks = rankFilter(query);
const [debouncedRanks, setDebouncedRanks] = useState("");
useEffect(() => {
const t = window.setTimeout(() => setDebouncedRanks(ranks), 300);
return () => window.clearTimeout(t);
}, [ranks]);
useEffect(() => {
let cancelled = false;
setListed(null);
setListLimit(LIST_PAGE);
setListError(null);
setListBusy(true);
getPlo5NodeList(stack, history, LIST_PAGE, debouncedRanks || undefined)
.then((rows) => {
if (!cancelled) setListed(rows);
})
.catch((err) => {
if (!cancelled) setListError(err instanceof Error ? err.message : "Could not load hands.");
})
.finally(() => {
if (!cancelled) setListBusy(false);
});
return () => {
cancelled = true;
};
}, [stack, history, debouncedRanks]);
// A new spot drops the last spot's lookup.
useEffect(() => {
setLookup(null);
setError(null);
}, [history]);
const loadMore = async () => {
const next = Math.min(listLimit + LIST_PAGE, LIST_MAX);
setListBusy(true);
setListError(null);
try {
setListed(await getPlo5NodeList(stack, history, next, debouncedRanks || undefined));
setListLimit(next);
} catch (err) {
setListError(err instanceof Error ? err.message : "Could not load more hands.");
} finally {
setListBusy(false);
}
};
const runLookup = async () => {
const hand = query.trim();
if (!hand || ranks) return;
if (!isCombo(hand)) {
setLookup(null);
setError("Use ranks (AK222) or five cards with suits (AsAhKdKcQs).");
return;
}
setBusy(true);
setError(null);
try {
setLookup(await getPlo5HandStrategy(stack, history, hand));
} catch (err) {
setLookup(null);
setError(err instanceof Error ? err.message : "Lookup failed.");
} finally {
setBusy(false);
}
};
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-neutral-500">
Filter by ranks (<span className="font-mono text-neutral-300">AK222</span>) or look up
a hand (<span className="font-mono text-neutral-300">AsAhKdKcQs</span>). Five ranks
match exactly; fewer mean “at least these.”
</p>
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
void runLookup();
}}
>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="AK222 or AsAhKdKcQs"
spellCheck={false}
aria-label="Filter by ranks or look up a hand"
className="h-8 min-w-[180px] flex-1 rounded-md border border-neutral-800 bg-neutral-950 px-2.5 font-mono text-xs text-neutral-100 placeholder:text-neutral-600"
/>
<button
type="submit"
disabled={busy || !query.trim() || !!ranks}
className="inline-flex h-8 items-center rounded-md border border-neutral-700 px-3 text-xs font-medium text-neutral-200 hover:border-neutral-500 disabled:opacity-50"
>
{busy ? "Looking up…" : "Look up"}
</button>
</form>
{error ? <p className="text-xs text-rose-400">{error}</p> : null}
{lookup ? (
<div className="flex flex-col gap-2 rounded-xl border border-neutral-800 bg-neutral-950 px-3 py-2.5">
<div className="flex flex-wrap items-center gap-2">
<HandCards hand={lookup.hdvHand} />
{lookup.isoMapped ? (
<span className="text-[11px] text-neutral-500">same as {lookup.hand}</span>
) : null}
</div>
<div className="flex flex-col">
{lookup.actions.map((a) => (
<div key={a.action} className="flex items-center justify-between gap-3 py-0.5 text-xs">
<span className="text-neutral-200">{actionName(a.action)}</span>
<span className="tabular-nums text-neutral-400">
{(a.freq * 100).toFixed(1)}%
{a.ev_sb != null ? ` · ${(a.ev_sb / 2).toFixed(2)} bb` : ""}
</span>
</div>
))}
</div>
</div>
) : null}
{debouncedRanks ? (
<p className="text-xs text-neutral-500">
Hands with <span className="font-mono text-neutral-300">{debouncedRanks}</span>
</p>
) : null}
{listError ? <p className="text-xs text-rose-400">{listError}</p> : null}
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
{colored.map(({ action, color }) => {
const block = listed?.find((a) => a.action === action.action);
return (
<ActionSection
key={action.action}
label={labelForAction(action, stack)}
color={color}
pct={((action.poolPct ?? 0) / denom) * 100}
rows={block?.listed ?? []}
total={block?.listedTotal ?? action.hands ?? 0}
busy={listBusy}
onMore={listLimit < LIST_MAX ? () => void loadMore() : undefined}
/>
);
})}
</div>
</div>
);
}
/** Whole percents, but a sliver of a mix (0.3%) isn't shown as 0. */
function formatFreq(freq: number): string {
const pct = freq * 100;
return pct > 0 && pct < 0.95 ? pct.toFixed(1) : String(Math.round(pct));
}
function ActionSection({
label,
color,
pct,
rows,
total,
busy,
onMore,
}: {
label: string;
color: string;
/** Pool share of this action, 0–100. */
pct: number;
rows: Plo5ListedHand[];
total: number;
busy: boolean;
onMore?: () => void;
}) {
const hasEv = rows.some((r) => r.ev != null);
const cols = hasEv ? "grid-cols-[minmax(0,1fr)_44px_52px]" : "grid-cols-[minmax(0,1fr)_44px]";
return (
<section className="flex flex-col overflow-hidden rounded-xl border border-neutral-800 bg-neutral-950">
<div className="flex flex-col gap-1.5 px-3 py-2.5" style={{ backgroundColor: `${color}26` }}>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium text-neutral-100">{label}</span>
<span className="text-[11px] tabular-nums text-neutral-400">
{total.toLocaleString("en-US")} {total === 1 ? "hand" : "hands"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-base font-semibold tabular-nums text-neutral-50">
{pct.toFixed(1)}%
</span>
<div className="flex h-1.5 flex-1 overflow-hidden rounded-full bg-white/[0.08]">
<div style={{ width: `${pct}%`, backgroundColor: color }} />
</div>
</div>
</div>
<div className="flex max-h-[560px] flex-col overflow-y-auto px-2 pb-2">
<div
className={`sticky top-0 z-10 grid items-center gap-2 bg-neutral-950 px-1 py-1.5 text-[10px] uppercase tracking-wide text-neutral-600 ${cols}`}
>
<span>Hand</span>
<span className="text-right">Freq %</span>
{hasEv ? <span className="text-right">EV bb</span> : null}
</div>
{busy && rows.length === 0 ? (
<p className="px-1 py-6 text-xs text-neutral-500">Loading hands…</p>
) : rows.length === 0 ? (
<p className="py-6 text-center text-xs text-neutral-600">No hands listed for this action.</p>
) : (
rows.map((r) => (
<div
key={r.hand}
className={`grid items-center gap-2 rounded px-1 py-[3px] hover:bg-neutral-900/60 ${cols}`}
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 30px" }}
>
<HandCards hand={r.hand} />
<span className="text-right text-[11px] tabular-nums text-neutral-300">
{formatFreq(r.freq)}
</span>
{hasEv ? (
<span className="text-right text-[11px] tabular-nums text-neutral-300">
{r.ev == null ? "—" : (r.ev / 2).toFixed(2)}
</span>
) : null}
</div>
))
)}
{onMore && rows.length > 0 && rows.length < total ? (
<button
type="button"
onClick={onMore}
disabled={busy}
className="mx-1 my-1 self-start rounded-full border border-neutral-800 px-3 py-1 text-[11px] text-neutral-500 hover:border-neutral-600 hover:text-neutral-300 disabled:opacity-50"
>
{busy
? "Loading…"
: `Show more (${rows.length.toLocaleString("en-US")} of ${total.toLocaleString("en-US")})`}
</button>
) : null}
</div>
</section>
);
}
const RANK_ORDER = "23456789TJQKA";
const SUIT_ORDER = "shdc";
/** `2s2h2d2c3s` → cards, high rank first. */
export function plo5Cards(hand: string): string[] {
const compact = hand.replace(/[\s,]+/g, "");
const out: string[] = [];
for (let i = 0; i + 1 < compact.length; i += 2) {
out.push(compact[i].toUpperCase() + compact[i + 1].toLowerCase());
}
return out.sort(
(a, b) =>
RANK_ORDER.indexOf(b[0]) - RANK_ORDER.indexOf(a[0]) ||
SUIT_ORDER.indexOf(a[1]) - SUIT_ORDER.indexOf(b[1]),
);
}
// Same four-color tiles as the PLO hand-class list.
function HandCards({ hand }: { hand: string }) {
return (
<div className="flex items-center gap-[3px]">
{plo5Cards(hand).map((card, i) => {
const suit = card[1] as Suit;
return (
<span
key={`${hand}-${i}`}
className="flex h-[26px] w-5 flex-col items-center justify-center rounded text-white"
style={{ backgroundColor: SUIT_COLOR_4[suit] }}
>
<span className="text-[11px] font-bold leading-[12px]">
{card[0] === "T" ? "10" : card[0]}
</span>
<span className="text-[8px] leading-[9px]">{suitGlyph(suit)}</span>
</span>
);
})}
</div>
);
}
components/pokerdata/explorer/preflop-strategy-grid.tsx
"use client";
// The 13×13 preflop strategy grid. The ranges API hands us each action's
// per-class frequency directly (`NodeAction.weights`), so a cell is just the
// stacked action split for that hand class. Ported from pokerstudy.ai's
// Explorer (web/src/components/explorer/preflop-strategy-grid.tsx).
import { useMemo } from "react";
import { ALL_HANDS, comboCountOf } from "@/lib/pokerdata/explorer/range-model";
import {
labelForAction,
sortActionsForDisplay,
type NodeAction,
} from "@/lib/pokerdata/explorer/preflop";
import { actionPalette } from "@/lib/pokerstudy/poker";
import type { ActionItem } from "./action-bar";
// pokerstudy.ai's default range palette (Mint): blue fold, call (and the
// BB's check) green, raises a red that darkens by size so multiple sizings
// stay distinguishable when stacked in one cell.
const PALETTE = actionPalette();
/** Stable per-action colors for a node, ordered fold → call → raises → all-in. */
export function colorActions(
actions: NodeAction[],
): { action: NodeAction; color: string }[] {
const sorted = sortActionsForDisplay(actions);
let raiseIdx = 0;
return sorted.map((action) => {
let color: string;
if (action.kind === "fold") color = PALETTE.Fold;
else if (action.kind === "call") color = PALETTE.Call;
else if (action.kind === "allin") color = PALETTE.AllIn;
else color = PALETTE.raises[Math.min(raiseIdx++, PALETTE.raises.length - 1)];
return { action, color };
});
}
/** Combo-weighted aggregate action frequencies for the ActionBar. */
export function preflopActionItems(
actions: NodeAction[],
stack: number,
): ActionItem[] {
const colored = colorActions(actions);
// Total combos in the range: weight every in-range class by its combo count.
let rangeCombos = 0;
for (const hand of ALL_HANDS) {
const any = colored.some(({ action }) => (action.weights[hand] ?? 0) > 0);
if (any) rangeCombos += comboCountOf(hand);
}
const denom = rangeCombos || 1;
return colored.map(({ action, color }) => {
let combos = 0;
for (const hand of ALL_HANDS) {
const w = action.weights[hand] ?? 0;
if (w > 0) combos += w * comboCountOf(hand);
}
return {
label: labelForAction(action, stack),
color,
freq: combos / denom,
detail: `${combos.toFixed(1)} combos`,
};
});
}
interface Cell {
label: string;
/** Per-action frequency for this class, parallel to `colored`. */
freqs: number[];
}
export function PreflopStrategyGrid({
actions,
selected,
onHover,
onSelect,
}: {
actions: NodeAction[];
/** The hand outlined in the grid (hovered or pinned). */
selected?: string | null;
/** Hovering a cell (null on leaving the grid). */
onHover?: (hand: string | null) => void;
/** Clicking a cell. */
onSelect?: (hand: string) => void;
}) {
const colored = useMemo(() => colorActions(actions), [actions]);
const cells = useMemo<Cell[]>(() => {
return ALL_HANDS.map((hand) => ({
label: hand,
freqs: colored.map(({ action }) => action.weights[hand] ?? 0),
}));
}, [colored]);
const foldIndex = colored.findIndex(({ action }) => action.kind === "fold");
return (
<div className="flex w-full max-w-[680px] flex-col gap-4">
<div
onMouseLeave={onHover ? () => onHover(null) : undefined}
className="grid aspect-square w-full grid-cols-[repeat(13,minmax(0,1fr))] grid-rows-[repeat(13,minmax(0,1fr))] gap-px overflow-hidden rounded-md bg-[#0b0b0c]">
{cells.map((cell, i) => {
const sum = cell.freqs.reduce((s, f) => s + f, 0);
// Out of range: the label goes muted. Always folding: a softer
// white that still reads on the blue fold.
const inRange = sum > 0.001;
const folds = inRange && foldIndex >= 0 && cell.freqs[foldIndex] >= sum - 0.001;
const isSelected = selected === cell.label;
return (
<div
key={i}
onMouseEnter={onHover ? () => onHover(cell.label) : undefined}
onClick={onSelect ? () => onSelect(cell.label) : undefined}
className={`relative overflow-hidden rounded-[1px] bg-[#111113] ${
onSelect ? "cursor-pointer" : ""
}`}
>
{inRange ? (
// Raises on the left, fold on the right.
<div className="absolute inset-0 flex flex-row-reverse">
{cell.freqs.map((f, a) =>
f <= 0.001 ? null : (
<div
key={a}
style={{ flexGrow: f, backgroundColor: colored[a].color }}
/>
),
)}
</div>
) : null}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span
className={`text-[9px] font-bold leading-[11px] ${
!inRange
? "text-white/30"
: folds
? "text-white/70"
: "text-white [text-shadow:0_0_2px_rgba(0,0,0,0.85)]"
}`}
>
{cell.label}
</span>
</div>
{isSelected ? (
<div className="pointer-events-none absolute inset-0 rounded-[1px] ring-2 ring-inset ring-white" />
) : null}
</div>
);
})}
</div>
</div>
);
}
components/pokerdata/explorer/range-drawer.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { X } from "lucide-react";
import { PackExplorer } from "@/components/pokerdata/explorer/pack-explorer";
import { GameTree } from "@/components/pokerdata/postflop/game-tree";
import { PostflopBrowser } from "@/components/pokerdata/postflop/postflop-browser";
import type { Game } from "@/lib/pokerdata/explorer/preflop";
import { explorerEndpoints } from "@/lib/pokerdata/explorer/endpoints";
/**
* Slide-in solution viewer, in the shell's drawer idiom (same easing and
* surfaces as pd-shell's mobile nav): the catalog's Browse buttons open it
* with the row's pack preselected.
*
* Preflop packs get the embedded range browser full-page (the 13×13 grid
* and the PLO action columns need the width) — seat walk, range, action
* legend; no picker, no hand summary. Postflop packs get the node walker with
* a board picker across the pack's solved boards.
*/
const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
const EASE_DRAWER: [number, number, number, number] = [0.32, 0.72, 0, 1];
export type RangeDrawerTarget =
| {
kind: "preflop";
game: Game;
stack: number;
title: string;
/** ICM scenario id (`35left-avg40-even`) when the pack is an ICM stage. */
scenario?: string;
/** The catalog's rake line, for the explorer's stat strip. */
rake?: string;
/** An ICM stage's stack per seat, in seat order. */
seatStacks?: number[];
}
| {
kind: "postflop";
pack: string;
/** Solved boards, in the API's spaced form ("3h 3d 2s"). */
boards: string[];
title: string;
};
export function RangeDrawer({
target,
onClose,
}: {
target: RangeDrawerTarget | null;
onClose: () => void;
}) {
const reduce = useReducedMotion();
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!target) return;
const doc = panelRef.current?.ownerDocument ?? document;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
doc.addEventListener("keydown", onKeyDown);
return () => doc.removeEventListener("keydown", onKeyDown);
}, [target, onClose]);
return (
<AnimatePresence>
{target && (
<>
<motion.div
aria-hidden="true"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.16, ease: EASE_OUT } }}
transition={{ duration: 0.24, ease: EASE_OUT }}
className="fixed inset-0 z-40 bg-neutral-950/60 backdrop-blur-[2px]"
/>
<motion.aside
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={`${target.title} solution`}
initial={reduce ? { opacity: 0 } : { x: "100%" }}
animate={reduce ? { opacity: 1 } : { x: 0 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.16 } }
: { x: "100%", transition: { duration: 0.22, ease: EASE_DRAWER } }
}
transition={{ duration: 0.32, ease: EASE_DRAWER }}
className={`fixed inset-y-0 right-0 z-50 flex w-full flex-col bg-neutral-950 ${
target.kind === "preflop" ? "" : "max-w-4xl border-l border-neutral-800"
}`}
>
{target.kind === "preflop" ? null : (
<div className="flex h-14 shrink-0 items-center justify-between border-b border-neutral-800 px-4 sm:px-6">
<span className="truncate text-[14px] font-medium text-neutral-100">
{target.title}
</span>
<button
type="button"
aria-label="Close solution viewer"
onClick={onClose}
className="inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-[var(--rb-r-md,8px)] text-neutral-400 transition-colors duration-150 hover:bg-neutral-800 hover:text-neutral-100"
>
<X aria-hidden="true" className="h-4 w-4" />
</button>
</div>
)}
{/* The preflop explorer fills the drawer and scrolls its own panels
(so the walk fits the screen); the postflop body scrolls here. */}
<div
className={`min-h-0 flex-1 ${target.kind === "preflop" ? "flex flex-col" : "overflow-y-auto p-4 sm:p-6"}`}
>
{target.kind === "preflop" ? (
/* Keyed so switching packs resets the walk instead of
carrying the previous pack's line into the new one. The
explorer's own header carries the title and the close. */
<PackExplorer
key={`${target.game}-${target.stack}-${target.scenario ?? ""}`}
game={target.game}
stack={target.stack}
scenario={target.scenario}
rake={target.rake}
seatStacks={target.seatStacks}
embedded
header={{
trailing: (
<button
type="button"
aria-label="Close solution viewer"
onClick={onClose}
className="inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-[var(--rb-r-sm,6px)] text-neutral-400 transition-colors duration-150 hover:bg-neutral-800 hover:text-neutral-100"
>
<X aria-hidden="true" className="size-4" />
</button>
),
}}
/>
) : (
<PostflopDrawerBody key={target.pack} target={target} />
)}
</div>
</motion.aside>
</>
)}
</AnimatePresence>
);
}
function PostflopDrawerBody({
target,
}: {
target: Extract<RangeDrawerTarget, { kind: "postflop" }>;
}) {
const [boardId, setBoardId] = useState(target.boards[0] ?? "");
const [path, setPath] = useState<string[]>([]);
const [showTree, setShowTree] = useState(false);
const [generatedAt, setGeneratedAt] = useState<string | undefined>();
useEffect(() => {
let alive = true;
void fetch(`${explorerEndpoints().postflop}/catalog`)
.then((r) => (r.ok ? r.json() : null))
.then((body: { packs?: { id: string; generatedAt?: string }[] } | null) => {
if (!alive || !body?.packs) return;
const pack = body.packs.find((p) => p.id === target.pack);
if (pack?.generatedAt) setGeneratedAt(pack.generatedAt);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [target.pack]);
if (!boardId) {
return <p className="text-sm text-neutral-500">No solved boards in this pack.</p>;
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-neutral-500">
Board
</span>
<select
value={boardId}
onChange={(e) => {
setBoardId(e.target.value);
setPath([]);
}}
className="rounded-[var(--rb-r-md,8px)] border border-neutral-800 bg-neutral-900 px-2 py-1.5 text-sm text-neutral-200"
>
{target.boards.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowTree((open) => !open)}
className={`rounded-[var(--rb-r-md,8px)] border px-2.5 py-1.5 text-xs font-medium transition-colors ${
showTree
? "border-neutral-500 bg-neutral-800 text-neutral-100"
: "border-neutral-800 bg-neutral-900 text-neutral-300 hover:border-neutral-600"
}`}
>
Game tree
</button>
</div>
{showTree ? (
<GameTree
pack={target.pack}
board={boardId}
generatedAt={generatedAt}
selectedPath={path}
onSelect={setPath}
/>
) : null}
{/* Keyed so a board change restarts the walk at the root. */}
<PostflopBrowser
key={boardId}
pack={target.pack}
board={boardId}
path={path}
onPathChange={setPath}
/>
</div>
);
}
components/pokerdata/postflop/game-tree.tsx
"use client";
import { useCallback, useEffect, useState } from "react";
import { GitBranch } from "lucide-react";
import { explorerEndpoints } from "@/lib/pokerdata/explorer/endpoints";
import { GatePrompt } from "@/components/pokerdata/explorer/gate-prompt";
import { GATE_UPGRADE } from "@/lib/pokerdata/explorer/gate-copy";
/**
* Flop-first game tree for a published pack/board. Every row is one
* `/api/postflop/node` response — same payload as the walker — so legal
* actions, sizes, pot, actor, and aggregate frequencies come from the API.
* Turn/river runouts stay collapsed until clicked (49 deals per street).
*/
type NodeWire = {
node: {
player: 0 | 1;
pot: number;
actions: { label: string; add: number }[];
} | null;
childSegments: string[];
actionFreqs: number[];
};
export type TreeRow = {
path: string[];
actor: string;
pot: number | null;
actions: { label: string; freq: number }[];
deals: number;
children: string[];
};
function actionLabel(a: { label: string; add: number }): string {
return a.add > 0 ? `${a.label} ${Math.round(a.add * 10) / 10}` : a.label;
}
function compactBoard(board: string): string {
return board.replace(/\s+/g, "");
}
async function fetchNode(
pack: string,
board: string,
path: string[],
): Promise<NodeWire | null> {
const q = new URLSearchParams({ pack, board, path: JSON.stringify(path) });
const res = await fetch(`${explorerEndpoints().postflop}/node?${q}`);
if (res.status === 401 || res.status === 403 || res.status === 429) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? GATE_UPGRADE);
}
if (!res.ok) return null;
return (await res.json()) as NodeWire;
}
function toRow(path: string[], view: NodeWire): TreeRow {
const deals = view.childSegments.filter((s) => s.startsWith("deal:")).length;
const children = view.childSegments.filter((s) => !s.startsWith("deal:"));
if (!view.node) {
return {
path,
actor: "Deal",
pot: null,
actions: [],
deals,
children,
};
}
return {
path,
actor: view.node.player === 0 ? "OOP" : "IP",
pot: view.node.pot,
actions: view.node.actions.map((a, i) => ({
label: actionLabel(a),
freq: view.actionFreqs[i] ?? 0,
})),
deals,
children,
};
}
export function GameTree({
pack,
board,
generatedAt,
selectedPath,
onSelect,
}: {
pack: string;
board: string;
generatedAt?: string;
selectedPath: string[];
onSelect: (path: string[]) => void;
}) {
const [rows, setRows] = useState<TreeRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [expanding, setExpanding] = useState<string | null>(null);
const loadTree = useCallback(async () => {
setLoading(true);
setError(null);
try {
const root = await fetchNode(pack, board, []);
if (!root) throw new Error("No root node for this board.");
const out: TreeRow[] = [toRow([], root)];
const queue = root.childSegments
.filter((s) => !s.startsWith("deal:"))
.map((s) => [s]);
// Flop action tree only — enough to see every size/raise without
// pulling 49 turn cards per closed street.
while (queue.length > 0 && out.length < 48) {
const path = queue.shift()!;
const view = await fetchNode(pack, board, path);
if (!view) continue;
const row = toRow(path, view);
out.push(row);
for (const child of row.children) queue.push([...path, child]);
}
setRows(out);
} catch (err) {
setError(err instanceof Error ? err.message : "failed to load tree");
setRows([]);
} finally {
setLoading(false);
}
}, [pack, board]);
useEffect(() => {
void loadTree();
}, [loadTree]);
const expandDeals = async (path: string[]) => {
const key = path.join(" / ");
setExpanding(key);
try {
const view = await fetchNode(pack, board, path);
if (!view) return;
const deals = view.childSegments.filter((s) => s.startsWith("deal:"));
const extra: TreeRow[] = [];
for (const deal of deals.slice(0, 12)) {
const childPath = [...path, deal];
const child = await fetchNode(pack, board, childPath);
if (child) extra.push(toRow(childPath, child));
}
setRows((current) => {
const seen = new Set(current.map((r) => r.path.join("\0")));
const next = [...current];
for (const row of extra) {
const id = row.path.join("\0");
if (!seen.has(id)) {
seen.add(id);
next.push(row);
}
}
return next;
});
} catch (err) {
setError(err instanceof Error ? err.message : "failed to expand");
} finally {
setExpanding(null);
}
};
const selected = selectedPath.join("\0");
const { postflop, postflopPage } = explorerEndpoints();
const pageHref = postflopPage ? `${postflopPage}/${pack}/${compactBoard(board)}` : null;
const apiHref = `${postflop}/node?pack=${encodeURIComponent(pack)}&board=${encodeURIComponent(board)}&path=${encodeURIComponent(JSON.stringify(selectedPath))}`;
return (
<div className="flex flex-col gap-3 rounded-xl border border-neutral-800 bg-neutral-950 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="flex items-center gap-1.5 text-xs font-semibold text-neutral-100">
<GitBranch className="size-3.5 text-neutral-400" />
Game tree
</p>
<p className="mt-0.5 text-[11px] text-neutral-500">
{pack}
{generatedAt ? ` · ${generatedAt}` : ""} · flop actions from{" "}
<code className="text-neutral-400">{postflop}/node</code>
</p>
</div>
<div className="flex flex-wrap gap-2 text-[11px]">
{pageHref ? (
<a
href={pageHref}
className="text-neutral-400 underline underline-offset-2 hover:text-neutral-200"
>
Full page
</a>
) : null}
<a
href={apiHref}
target="_blank"
rel="noreferrer"
className="text-neutral-400 underline underline-offset-2 hover:text-neutral-200"
>
This node JSON
</a>
</div>
</div>
{error ? <GatePrompt message={error} /> : null}
{loading ? (
<p className="text-xs text-neutral-500">Loading nodes…</p>
) : (
<ul className="flex max-h-[280px] flex-col gap-1 overflow-y-auto">
{rows.map((row) => {
const id = row.path.join("\0");
const depth = row.path.length;
const active = id === selected;
return (
<li key={id || "root"}>
<button
type="button"
onClick={() => onSelect(row.path)}
className={`flex w-full flex-col gap-0.5 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
active
? "bg-neutral-800 text-neutral-100"
: "text-neutral-300 hover:bg-neutral-900"
}`}
style={{ paddingLeft: 8 + depth * 12 }}
>
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-semibold">{row.actor}</span>
{row.pot != null ? (
<span className="tabular-nums text-neutral-500">
pot {row.pot}bb
</span>
) : null}
<span className="truncate text-neutral-500">
{row.path.length === 0
? "flop root"
: row.path
.map((s) => (s.startsWith("deal:") ? s.slice(5) : s))
.join(" › ")}
</span>
</span>
{row.actions.length > 0 ? (
<span className="flex flex-wrap gap-1.5 text-neutral-400">
{row.actions.map((a) => (
<span key={a.label} className="tabular-nums">
{a.label} {(a.freq * 100).toFixed(0)}%
</span>
))}
</span>
) : null}
</button>
{row.deals > 0 ? (
<button
type="button"
onClick={() => void expandDeals(row.path)}
className="ml-4 text-[11px] text-neutral-500 underline underline-offset-2 hover:text-neutral-300"
style={{ marginLeft: 8 + depth * 12 }}
>
{expanding === row.path.join(" / ")
? "Loading runouts…"
: `${row.deals} runouts — expand sample`}
</button>
) : null}
</li>
);
})}
</ul>
)}
</div>
);
}
components/pokerdata/postflop/postflop-browser.tsx
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { StrategyGrid, ACTION_COLORS } from "./strategy-grid";
import { ActionBar } from "@/components/pokerdata/explorer/action-bar";
import type { SolverNode } from "@/lib/pokerdata/postflop/types";
import { explorerEndpoints } from "@/lib/pokerdata/explorer/endpoints";
import { GatePrompt } from "@/components/pokerdata/explorer/gate-prompt";
import { GATE_UPGRADE } from "@/lib/pokerdata/explorer/gate-copy";
/**
* The postflop walker in the preflop drawer's idiom: a horizontal strip of
* step cards (each taken action is a card you can click to rewind; the acting
* player's card lists its options as rows), then the strategy grid on the
* left with the aggregate action legend on the right — the same navigation
* and range-view layout as `PackExplorer`.
*
* The fetch contract is the /postflop viewer's: one `/api/postflop/node` call
* per step, boards in the API's spaced form.
*/
interface NodeViewWire {
node: SolverNode | null;
childSegments: string[];
board: string[];
reach: [string, number][];
actionFreqs: number[];
}
const SUIT_GLYPH: Record<string, string> = { s: "♠", h: "♥", d: "♦", c: "♣" };
const SUIT_CLASS: Record<string, string> = {
s: "text-neutral-200",
h: "text-rose-400",
d: "text-sky-400",
c: "text-emerald-400",
};
function BoardCard({ card }: { card: string }) {
const suit = card[1]?.toLowerCase() ?? "";
return (
<span
className={`inline-flex items-center gap-0.5 rounded bg-neutral-800 px-1.5 py-0.5 text-xs font-semibold ${SUIT_CLASS[suit] ?? ""}`}
>
{card[0]?.toUpperCase()}
{SUIT_GLYPH[suit] ?? suit}
</span>
);
}
/** One taken step, remembered so past cards can label themselves without
* refetching the nodes they came from. */
type Step = {
seg: string;
/** Who acted: "OOP", "IP", or "Deal" for a runout card. */
actor: string;
};
const stripCard = (active: boolean, clickable: boolean) =>
`flex min-h-[96px] w-[132px] shrink-0 flex-col gap-1 rounded-xl border p-2 text-left transition-colors ${
active ? "border-neutral-400 bg-neutral-900" : "border-neutral-800 bg-neutral-950"
} ${clickable ? "cursor-pointer hover:border-neutral-500" : ""}`;
export function PostflopBrowser({
pack,
board,
path,
onPathChange,
}: {
pack: string;
board: string;
/** When set, the walker stays on this API path (game-tree clicks). */
path?: string[];
onPathChange?: (path: string[]) => void;
}) {
const [steps, setSteps] = useState<Step[]>([]);
const [view, setView] = useState<NodeViewWire | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(
async (nextSteps: Step[]) => {
setLoading(true);
setError(null);
try {
const q = new URLSearchParams({
pack,
board,
path: JSON.stringify(nextSteps.map((s) => s.seg)),
});
const res = await fetch(`${explorerEndpoints().postflop}/node?${q}`);
if (!res.ok)
throw new Error(
res.status === 404 ? "No solved node here."
: res.status === 401 || res.status === 403 || res.status === 429
? ((await res.json().catch(() => null)) as { error?: string } | null)?.error ?? GATE_UPGRADE
: `HTTP ${res.status}`,
);
setView((await res.json()) as NodeViewWire);
setSteps(nextSteps);
onPathChange?.(nextSteps.map((s) => s.seg));
} catch (err) {
setError(err instanceof Error ? err.message : "failed to load");
} finally {
setLoading(false);
}
},
[pack, board, onPathChange],
);
useEffect(() => {
void load([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pack, board]);
const external = (path ?? []).join("\0");
const internal = steps.map((s) => s.seg).join("\0");
useEffect(() => {
if (path === undefined || external === internal) return;
void load(
path.map((seg) => ({
seg,
actor: seg.startsWith("deal:") ? "Deal" : "…",
})),
);
// Only react to parent-driven jumps (game tree), not our own onPathChange.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [external]);
const reachMap = useMemo(() => new Map(view?.reach ?? []), [view]);
const actionSegs = useMemo(() => {
if (!view?.node) return [];
return view.node.actions.map((a) =>
a.add > 0 ? `${a.label} ${Math.round(a.add * 10) / 10}` : a.label,
);
}, [view]);
const deals = useMemo(
() => (view?.childSegments ?? []).filter((s) => s.startsWith("deal:")),
[view],
);
const actor = view?.node ? (view.node.player === 0 ? "OOP" : "IP") : view ? "Deal" : "";
const legendItems = useMemo(() => {
if (!view?.node) return [];
return view.node.actions.map((a, i) => ({
label: actionSegs[i],
color: ACTION_COLORS[a.label],
freq: view.actionFreqs[i] ?? 0,
}));
}, [view, actionSegs]);
return (
<div className="flex flex-col gap-4">
{/* Step strip — the postflop counterpart of the preflop seat strip:
board first, one card per taken action (click to rewind), then the
acting player's card listing its options. */}
<div className="flex gap-2 overflow-x-auto pb-1">
<div className={stripCard(false, false)}>
<span className="text-sm font-semibold text-neutral-100">Board</span>
<span className="flex flex-wrap gap-1 py-1">
{(view?.board ?? []).map((c) => (
<BoardCard key={c} card={c} />
))}
</span>
{view?.node && (
<span className="text-[11px] font-medium tabular-nums text-neutral-500">
pot {view.node.pot}bb
</span>
)}
</div>
{steps.map((s, i) => (
<button
key={`${s.seg}-${i}`}
type="button"
onClick={() => void load(steps.slice(0, i))}
className={stripCard(false, true)}
>
<span className="flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-100">
{s.actor}
</span>
</span>
<span className="flex flex-1 items-center py-1">
<span className="inline-block max-w-full truncate rounded bg-neutral-800 px-2 py-0.5 text-xs font-semibold text-neutral-100">
{s.seg.startsWith("deal:") ? s.seg.slice(5) : s.seg}
</span>
</span>
</button>
))}
<div className={stripCard(true, false)}>
<span className="flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-100">
{actor || "…"}
</span>
<span className="text-[11px] font-medium text-neutral-500">to act</span>
</span>
<span className="flex flex-col">
{!view ? (
<span className="py-1 text-xs text-neutral-500">
{loading ? "Loading…" : "—"}
</span>
) : (
(view.node?.actions ?? []).map((a, i) => (
<button
key={actionSegs[i]}
type="button"
onClick={() =>
void load([...steps, { seg: actionSegs[i], actor }])
}
className="flex items-center justify-between gap-1 rounded px-1 py-1 text-left text-xs text-neutral-100 transition-colors hover:bg-neutral-800"
>
<span className="flex min-w-0 items-center gap-1.5">
<span
className="size-2 shrink-0 rounded-sm"
style={{ backgroundColor: ACTION_COLORS[a.label] }}
/>
<span className="truncate">{actionSegs[i]}</span>
</span>
<span className="shrink-0 tabular-nums text-neutral-500">
{((view.actionFreqs[i] ?? 0) * 100).toFixed(0)}%
</span>
</button>
))
)}
{deals.length > 0 && (
<select
className="mt-1 rounded border border-neutral-800 bg-neutral-900 px-1 py-1 text-xs text-neutral-300"
value=""
onChange={(e) =>
e.target.value &&
void load([...steps, { seg: e.target.value, actor: "Deal" }])
}
>
<option value="">Deal runout…</option>
{deals.map((d) => (
<option key={d} value={d}>
{d.slice(5)}
</option>
))}
</select>
)}
</span>
</div>
</div>
{error ? <GatePrompt message={error} /> : null}
{view?.node ? (
<div
className={`grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,320px)] ${
loading ? "opacity-60" : ""
}`}
>
<div className="flex flex-col gap-3">
<StrategyGrid
actions={view.node.actions}
hands={view.node.hands}
reach={reachMap}
/>
</div>
<div className="flex flex-col gap-4">
<ActionBar items={legendItems} />
</div>
</div>
) : view && !view.node ? (
<p className="text-xs text-neutral-500">Street closed — pick a runout to continue.</p>
) : loading ? (
// A board's first open reads its bundle from Blob storage, which can
// take several seconds: say so, or the drawer reads as broken.
<div
role="status"
className="flex min-h-[200px] flex-col items-center justify-center gap-3 text-center"
>
<div className="size-4 animate-spin rounded-full border-2 border-neutral-700 border-t-neutral-300" />
<p className="text-xs text-neutral-400">
Loading the {board} solve
<span className="block text-neutral-500">
First open of a board can take a few seconds.
</span>
</p>
</div>
) : null}
</div>
);
}
components/pokerdata/postflop/strategy-grid.tsx
"use client";
// The postflop 13×13 strategy grid (a hand list for Omaha) — ported from pokerstudy.ai's
// web/src/components/solver/strategy-grid.tsx. Per-combo strategies aggregate
// into hand classes weighted by how often the acting player actually HOLDS
// each combo at this node; without reach weighting a deep node renders a wall
// of action for hands the line already removed.
import { useMemo } from "react";
import type { SolverActionInfo, SolverHandStrategy } from "@/lib/pokerdata/postflop/types";
import { RANGE_COLORS } from "@/lib/pokerstudy/poker";
const RANKS = ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"];
// pokerstudy.ai's default range palette (Mint: blue fold, check and call
// green, bets red), so an action reads the same in the postflop and preflop
// explorers.
export const ACTION_COLORS: Record<SolverActionInfo["label"], string> = RANGE_COLORS;
interface MatrixCell {
label: string;
freqs: number[];
}
function classOf(combo: string): { row: number; col: number; label: string } | null {
if (combo.length !== 4) return null;
const r1 = RANKS.indexOf(combo[0].toUpperCase());
const r2 = RANKS.indexOf(combo[2].toUpperCase());
if (r1 < 0 || r2 < 0) return null;
const suited = combo[1].toLowerCase() === combo[3].toLowerCase();
if (r1 === r2) return { row: r1, col: r1, label: `${RANKS[r1]}${RANKS[r1]}` };
const hi = Math.min(r1, r2);
const lo = Math.max(r1, r2);
const label = `${RANKS[hi]}${RANKS[lo]}${suited ? "s" : "o"}`;
return suited ? { row: hi, col: lo, label } : { row: lo, col: hi, label };
}
const REACH_EPS = 1e-9;
function buildGrid(
actions: SolverActionInfo[],
hands: SolverHandStrategy[],
reach: ReadonlyMap<string, number>,
): (MatrixCell | null)[] {
const n = actions.length;
const sums = RANKS.map(() =>
RANKS.map(() => ({ freqs: new Array<number>(n).fill(0), weight: 0, label: "" })),
);
for (const hand of hands) {
const cls = classOf(hand.combo);
if (!cls) continue;
const w = reach.get(hand.combo) ?? 0;
if (w <= REACH_EPS) continue;
const slot = sums[cls.row][cls.col];
slot.label = cls.label;
slot.weight += w;
for (let a = 0; a < n; a++) slot.freqs[a] += w * (hand.freqs[a] ?? 0);
}
return sums
.map((rowArr) =>
rowArr.map((slot) =>
slot.weight <= REACH_EPS
? null
: { label: slot.label, freqs: slot.freqs.map((f) => f / slot.weight) },
),
)
.flat();
}
/** Omaha combos are 8 or 10 characters and have no 13×13 class. */
function isOmaha(hands: SolverHandStrategy[]): boolean {
return hands.some((h) => h.combo.length > 4);
}
export function StrategyGrid({
actions,
hands,
reach,
}: {
actions: SolverActionInfo[];
hands: SolverHandStrategy[];
reach: ReadonlyMap<string, number>;
}) {
const omaha = useMemo(() => isOmaha(hands), [hands]);
const cells = useMemo(
() => (omaha ? [] : buildGrid(actions, hands, reach)),
[omaha, actions, hands, reach],
);
const colors = useMemo(() => actions.map((a) => ACTION_COLORS[a.label]), [actions]);
const legend = useMemo(
() =>
actions.map((a) => ({
label: `${a.label}${a.add > 0 ? ` ${Math.round(a.add * 100) / 100}` : ""}`,
color: ACTION_COLORS[a.label],
})),
[actions],
);
if (omaha) {
return <HandList hands={hands} reach={reach} colors={colors} legend={legend} />;
}
return (
<div className="flex w-full max-w-[560px] flex-col gap-4">
<div className="grid aspect-square w-full grid-cols-[repeat(13,minmax(0,1fr))] grid-rows-[repeat(13,minmax(0,1fr))] gap-px overflow-hidden rounded-md bg-[#0b0b0c]">
{cells.map((cell, i) => (
<div key={i} className="relative bg-[#111113]">
{cell && (
<>
<div className="absolute inset-0 flex flex-row-reverse">
{cell.freqs.map((f, a) =>
f > 0 ? (
<div
key={a}
style={{ width: `${f * 100}%`, backgroundColor: colors[a], opacity: 0.85 }}
/>
) : null,
)}
</div>
<span className="absolute left-0.5 top-0 text-[9px] font-medium leading-tight text-white/90 sm:text-[10px]">
{cell.label}
</span>
</>
)}
</div>
))}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-400">
{legend.map((l) => (
<span key={l.label} className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: l.color }} />
{l.label}
</span>
))}
</div>
</div>
);
}
const SUIT_CLASS: Record<string, string> = {
s: "text-neutral-200",
h: "text-rose-400",
d: "text-sky-400",
c: "text-emerald-400",
};
/**
* Omaha hands one per row: the acting player's sampled hands still in reach,
* strongest EV first, each with its action split. A grid of ~1,000 five-card
* hands has no 13×13 shape to fold into.
*/
function HandList({
hands,
reach,
colors,
legend,
}: {
hands: SolverHandStrategy[];
reach: ReadonlyMap<string, number>;
colors: string[];
legend: { label: string; color: string }[];
}) {
const rows = useMemo(
() =>
hands
.filter((h) => (reach.get(h.combo) ?? 0) > REACH_EPS)
.sort((a, b) => (b.ev ?? 0) - (a.ev ?? 0)),
[hands, reach],
);
return (
<div className="flex w-full max-w-[560px] flex-col gap-4">
<p className="text-xs text-neutral-500">
{rows.length} hands in range, highest EV first.
</p>
<ul className="flex max-h-[560px] flex-col gap-px overflow-y-auto rounded-md">
{rows.map((h) => (
<li
key={h.combo}
className="grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)_3.5rem] items-center gap-3 bg-neutral-900 px-2 py-1 text-xs"
>
<span className="font-mono tabular-nums">
{h.combo.match(/../g)?.map((c, i) => (
<span key={i} className={SUIT_CLASS[c[1]] ?? ""}>
{c}
</span>
))}
</span>
<span className="flex h-2.5 overflow-hidden rounded-sm bg-neutral-800">
{h.freqs.map((f, a) =>
f > 0 ? (
<span
key={a}
style={{ width: `${f * 100}%`, backgroundColor: colors[a], opacity: 0.85 }}
/>
) : null,
)}
</span>
<span className="text-right tabular-nums text-neutral-400">
{typeof h.ev === "number" ? `${h.ev.toFixed(2)}bb` : "—"}
</span>
</li>
))}
</ul>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-400">
{legend.map((l) => (
<span key={l.label} className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: l.color }} />
{l.label}
</span>
))}
</div>
</div>
);
}
lib/pokerdata/explorer/endpoints.ts
/**
* Where the explorers read their data. The defaults are pokerdata.io's own
* same-origin routes; another host (pokerstudy.ai's /downloads) points them at
* its own feeds once, before the drawer opens, with `setExplorerEndpoints`.
*
* Every feed keeps the same contract wherever it is served: a 401/403 answers
* with the gate copy, a 429 with the Browse rate limit's own words, and a 404
* on a node means the betting round closed there.
*/
export interface ExplorerEndpoints {
/** Ranges feed, the `/api/ranges/*` layout (`/nl/v2/node?…`, `/plo5/node?…`). */
ranges: string;
/** PLO 9-max per-node feed: `/manifest.json`, `/<stack>/<history>.json`. */
plo9: string;
/** Postflop feed: `/node?pack=…&board=…&path=…` and `/catalog`. */
postflop: string;
/** Bundled preview assets (the retired MTT 9-max set). */
previews: string;
/** The full-page postflop viewer (`/<pack>/<board>`), or null for none. */
postflopPage: string | null;
}
let current: ExplorerEndpoints = {
ranges: "/api/explorer/ranges",
plo9: "/api/explorer/plo9",
postflop: "/api/postflop",
previews: "/previews",
postflopPage: "/postflop",
};
export function explorerEndpoints(): ExplorerEndpoints {
return current;
}
export function setExplorerEndpoints(next: Partial<ExplorerEndpoints>): void {
current = { ...current, ...next };
}
lib/pokerdata/explorer/gate-copy.ts
/**
* The words the data gate answers with. Shared by the server routes that deny
* (lib/access) and the client views that show the denial, so the views can
* tell a plan gate apart from a real error and offer a way in instead.
*/
export const GATE_SIGN_IN = "Sign in to browse this data.";
export const GATE_UPGRADE = "This data is not available to browse yet.";
/** Where a gated visitor goes next. */
export const GATE_SIGN_IN_HREF = "/sign-in?redirect_url=";
export const GATE_PLANS_HREF = "/sign-up";
export type GateReason = "sign-in" | "upgrade";
/** The gate reason a message carries, or null for any other error. */
export function gateReason(message: string | null | undefined): GateReason | null {
if (message === GATE_SIGN_IN) return "sign-in";
if (message === GATE_UPGRADE) return "upgrade";
return null;
}
lib/pokerdata/explorer/house.ts
// House-solver bundle adapter — the range explorer's "mtt9" game mode.
//
// The tournament packs aren't MonkerSolver exports served by pokerstudy.ai's
// ranges API; they're gzipped JSON strategy bundles from the in-house GPU
// solver (repo issue #296). The bundles are small enough to ship decompressed
// as public preview assets, so this adapter fetches
// `/previews/NLH-MTT-9max-{stack}bb.json` once and answers the explorer's
// manifest/node queries from it — shaped exactly like the NL ranges API's
// responses so the 13×13 grid and tree walk render identically.
import { explorerEndpoints } from "./endpoints";
import type {
ActionKind,
Manifest,
NodeAction,
NodeResponse,
Position,
} from "./preflop";
// ─── Bundle format (pokerstudy-house-preflop-v0) ───────────────────────────
/** One decision node in a house bundle. */
export interface HouseNode {
id: number;
/** Seat index into HouseBundle.positions. */
actor: number;
/** "/"-joined action labels from the root ("" = root, e.g. "F/R220/AI"). */
path: string;
/** Action labels offered at this node ("F", "C", "X", "R220", "AI"). */
labels: string[];
reach_ssum: number;
/** [169 hand classes] × [labels.length] action frequencies. */
strategy: number[][];
}
export interface HouseBundle {
format: string;
game: string;
stack_bb: number;
/** Seat names in act order, indexed by HouseNode.actor. */
positions: string[];
nodes: HouseNode[];
}
// ─── 169-class indexing ────────────────────────────────────────────────────
// Bundle strategy rows are indexed by 169-class id: pairs hi·13+hi, suited
// hi·13+lo, offsuit lo·13+hi, with rank 2 = 0 … A = 12. Precompute id → the
// explorer's hand key ("AA", "AKs", "AKo" — higher rank written first, the
// same keys range-model.ts's ALL_HANDS uses), so the grid renders these
// weights exactly like the NL ranges API's.
const RANKS_ASC = "23456789TJQKA";
const HAND_BY_CLASS: string[] = (() => {
const hands = new Array<string>(169);
for (let hi = 0; hi < 13; hi++) {
for (let lo = 0; lo <= hi; lo++) {
const h = RANKS_ASC[hi];
const l = RANKS_ASC[lo];
if (hi === lo) hands[hi * 13 + hi] = `${h}${h}`;
else {
hands[hi * 13 + lo] = `${h}${l}s`;
hands[lo * 13 + hi] = `${h}${l}o`;
}
}
}
return hands;
})();
// ─── Action labels ─────────────────────────────────────────────────────────
/** House raise token ("R220" = raise to 2.20bb) → bb amount, or null. */
export function houseRaiseToBb(token: string): number | null {
const m = token.match(/^[RB](\d+)$/);
return m ? Number(m[1]) / 100 : null;
}
function kindOf(label: string): ActionKind {
if (label === "F") return "fold";
if (label === "C" || label === "X") return "call";
if (label === "AI") return "allin";
if (houseRaiseToBb(label) !== null) return "raise";
return "other";
}
// ─── Manifest ──────────────────────────────────────────────────────────────
/** Stacks with a published preview asset under public/previews/. */
export const HOUSE_PREVIEW_STACKS = [10, 15, 20, 30, 40, 50, 75, 100];
export const HOUSE_POSITIONS: Position[] = [
"UTG",
"UTG1",
"UTG2",
"LJ",
"HJ",
"CO",
"BTN",
"SB",
"BB",
];
/** Static manifest — the preview assets ship with the app, no fetch needed. */
export function houseManifest(): Manifest {
return {
game: "mtt9",
generatedAt: "",
stacks: HOUSE_PREVIEW_STACKS,
positions: HOUSE_POSITIONS,
};
}
// ─── Node lookup ───────────────────────────────────────────────────────────
/** Explorer history ("UTG_F_UTG1_AI_BB") → bundle path ("F/AI"). */
export function historyToPath(history: string): string {
const tokens = history.split("_");
const labels: string[] = [];
for (let i = 1; i < tokens.length; i += 2) labels.push(tokens[i]);
return labels.join("/");
}
const nodeIndexes = new WeakMap<HouseBundle, Map<string, HouseNode>>();
function nodeIndex(bundle: HouseBundle): Map<string, HouseNode> {
let index = nodeIndexes.get(bundle);
if (!index) {
index = new Map(bundle.nodes.map((n) => [n.path, n]));
nodeIndexes.set(bundle, index);
}
return index;
}
/**
* The bundle's decision node at `history` as a ranges-API-shaped NodeResponse,
* or null when the bundle has no node there — i.e. the betting round closed
* on this line (the adapter's equivalent of the ranges API's 404).
*/
export function houseNodeFromBundle(
bundle: HouseBundle,
stack: number,
history: string,
): NodeResponse | null {
const node = nodeIndex(bundle).get(historyToPath(history));
if (!node) return null;
const actions: NodeAction[] = node.labels.map((label, a) => {
const weights: Record<string, number> = {};
for (let c = 0; c < node.strategy.length; c++) {
const w = node.strategy[c][a];
if (w > 0) weights[HAND_BY_CLASS[c]] = w;
}
const action: NodeAction = { action: label, kind: kindOf(label), weights };
// Multiple raise sizes at one node sort ascending in the display order.
const bb = houseRaiseToBb(label);
if (bb !== null) action.sizeCode = Math.round(bb * 100);
return action;
});
return {
game: "mtt9",
stack,
history,
actor: bundle.positions[node.actor] as Position,
actions,
};
}
// ─── Preview-asset client ──────────────────────────────────────────────────
const bundleCache = new Map<number, Promise<HouseBundle>>();
export function loadHouseBundle(stack: number): Promise<HouseBundle> {
const hit = bundleCache.get(stack);
if (hit) return hit;
const promise = (async () => {
const res = await fetch(`${explorerEndpoints().previews}/NLH-MTT-9max-${stack}bb.json`, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`GET house preview ${stack}bb -> ${res.status}`);
}
return (await res.json()) as HouseBundle;
})();
bundleCache.set(stack, promise);
promise.catch(() => bundleCache.delete(stack));
return promise;
}
export async function getHouseNodeOrNull(
stack: number,
history: string,
): Promise<NodeResponse | null> {
return houseNodeFromBundle(await loadHouseBundle(stack), stack, history);
}
lib/pokerdata/explorer/icm-pressure.ts
/**
* Bubble factor and risk premium for the 8-max ICM packs, from each spot's
* stacks and payouts — the same ICM model the solves used, so these numbers
* agree with the ranges.
*
* The model is solver-engine's `IcmModel` (src/icm.rs): exact
* Malmuth-Harville over the eight table seats plus the off-table players,
* who all sit at the field average. A player who busts takes the bottom
* place among the players left. Payouts and field come from the solve
* configs (solver-engine bench/icm/prod-gtow for the 33-left tables, the
* HydraSolver 35-left benchmark for the older pack).
*
* Bubble factor (hero vs villain): hero and villain get all-in for the
* smaller of their two starting stacks, everyone else unchanged. It is the
* prize equity hero gives up by losing, divided by what hero gains by
* winning. Risk premium is what that costs in a stack-for-stack flip with no
* dead money: a call needs BF / (1 + BF) equity instead of 50%, and the risk
* premium is the difference.
*/
const EPS = 1e-9;
export type IcmStructure = {
/** Prize per remaining place, 1st first, in dollars. */
payouts: number[];
playersLeft: number;
/** Stack of every off-table player, in bb. */
fieldAvgBb: number;
/** Where the numbers come from, for the page's footnote. */
source: string;
};
/** GTO Wizard's MTT 200-player payout table, 33 left, 30 paid: $40,000 still to pay. */
const GTOW_33_LEFT = [
7656, 5668, 4196, 3204, 2456, 1924, 1484, 1140, 888, 748, 748, 676, 676, 596, 596, 596, 596, 512,
512, 512, 512, 512, 512, 440, 440, 440, 440, 440, 440, 440,
];
/** The HydraSolver 35-left benchmark: 200 players, 30 paid, $50,000 still to pay. */
const HYDRA_35_LEFT = [
8839, 5148, 3752, 2998, 2519, 2185, 1937, 1746, 1592, 1156, 1156, 1156, 1156, 1156, 1156, 1156,
1156, 1156, 740, 740, 740, 740, 740, 740, 740, 740, 740, 740, 740, 740,
];
/**
* The payout table the ICM packs on sale were solved with, for the game
* page's "View payouts" dialog: the near-the-bubble tables pay all 30
* places, the final table the first 8 of the same list.
*/
export const ICM_PAYOUT_TABLE = {
payouts: GTOW_33_LEFT,
fieldSize: 200,
paid: 30,
source: "GTO Wizard's 200-player MTT payout table",
} as const;
/**
* The payouts and field an ICM scenario id (`33left-avg30-short-btn`) was
* solved with, or null for a stage we have no solve config for.
*/
export function icmStructureForScenario(id: string): IcmStructure | null {
const m = /^(\d+)left-avg(\d+)-/.exec(id);
if (!m) return null;
const playersLeft = Number(m[1]);
const fieldAvgBb = Number(m[2]);
if (playersLeft === 33) {
return {
payouts: GTOW_33_LEFT,
playersLeft,
fieldAvgBb,
source: "GTO Wizard's 200-player payout table, 30 paid, $40,000 left in the pool",
};
}
if (playersLeft === 8) {
return {
payouts: GTOW_33_LEFT.slice(0, 8),
playersLeft,
fieldAvgBb,
source: "GTO Wizard's 200-player payout table, final 8 places, $27,728 left in the pool",
};
}
if (playersLeft === 35) {
return {
payouts: HYDRA_35_LEFT,
playersLeft,
fieldAvgBb,
source: "a 200-player field, 30 paid, $50,000 left in the pool",
};
}
return null;
}
/**
* Each table seat's prize equity, in the payouts' unit. `stacks` are the
* table's stacks after the hand, `start` before it (only used to order
* players who bust in the same hand). The `fieldPlayers` off-table players
* all have `fieldStack`. Port of solver-engine's `IcmModel::equities_now`
* for one field group.
*/
export function icmEquities(
stacks: number[],
start: number[],
payouts: number[],
fieldPlayers: number,
fieldStack: number,
): number[] {
const n = stacks.length;
const m = fieldStack > 0 ? fieldPlayers : 0;
const prize = (k: number) => payouts[k] ?? 0;
const eq = new Array<number>(n).fill(0);
const live = stacks.flatMap((s, i) => (s > EPS ? [i] : []));
const busted = stacks.flatMap((s, i) => (s > EPS ? [] : [i]));
const livePlaces = n + m - busted.length;
const total = live.reduce((t, i) => t + stacks[i], 0) + m * fieldStack;
// prob[mask * (m + 1) + j]: the live table seats in `mask` and j field
// players have taken the first places, in some order.
const ln = live.length;
const radix = m + 1;
const prob = new Float64Array((1 << ln) * radix);
prob[0] = 1;
const maskSum = new Float64Array(1 << ln);
const popcount = new Uint8Array(1 << ln);
for (let mask = 1; mask < 1 << ln; mask++) {
const low = 31 - Math.clz32(mask & -mask);
maskSum[mask] = maskSum[mask & (mask - 1)] + stacks[live[low]];
popcount[mask] = popcount[mask & (mask - 1)] + 1;
}
const byCount: number[][] = Array.from({ length: ln + 1 }, () => []);
for (let mask = 0; mask < 1 << ln; mask++) byCount[popcount[mask]].push(mask);
const paidLive = Math.min(livePlaces, payouts.length);
for (let placed = 0; placed < paidLive; placed++) {
const pay = prize(placed);
for (let k = Math.max(0, placed - m); k <= Math.min(placed, ln); k++) {
const j = placed - k;
for (const mask of byCount[k]) {
const p = prob[mask * radix + j];
if (p === 0) continue;
const rest = total - maskSum[mask] - j * fieldStack;
if (rest <= EPS) continue;
for (let bit = 0; bit < ln; bit++) {
if (mask & (1 << bit)) continue;
const seat = live[bit];
const q = (p * stacks[seat]) / rest;
eq[seat] += q * pay;
prob[(mask | (1 << bit)) * radix + j] += q;
}
if (j < m) prob[mask * radix + j + 1] += (p * (m - j) * fieldStack) / rest;
}
}
}
// Busted players take the places from livePlaces down, bigger starting
// stack first; equal starting stacks split their places.
const order = [...busted].sort((x, y) => start[y] - start[x]);
let place = livePlaces;
for (let g = 0; g < order.length; ) {
let h = g + 1;
while (h < order.length && Math.abs(start[order[h]] - start[order[g]]) <= EPS) h++;
let share = 0;
for (let k = place; k < place + (h - g); k++) share += prize(k);
share /= h - g;
for (const seat of order.slice(g, h)) eq[seat] = share;
place += h - g;
g = h;
}
return eq;
}
export type IcmPressure = {
/** bubbleFactor[hero][villain]; null on the diagonal. */
bubbleFactor: (number | null)[][];
/** Extra equity over 50% a stack-for-stack call needs, as a fraction; null on the diagonal. */
riskPremium: (number | null)[][];
};
/** Bubble factor and risk premium for every hero, villain pair at a table. */
export function icmPressure(stacks: number[], s: IcmStructure): IcmPressure {
const fieldPlayers = s.playersLeft - stacks.length;
const equity = (after: number[]) =>
icmEquities(after, stacks, s.payouts, fieldPlayers, s.fieldAvgBb);
const now = equity(stacks);
const bubbleFactor = stacks.map((_, hero) =>
stacks.map((_, villain) => {
if (hero === villain) return null;
const x = Math.min(stacks[hero], stacks[villain]);
const swing = (sign: number) => {
const after = [...stacks];
after[hero] += sign * x;
after[villain] -= sign * x;
return equity(after)[hero];
};
const gain = swing(1) - now[hero];
const loss = now[hero] - swing(-1);
return loss / gain;
}),
);
const riskPremium = bubbleFactor.map((row) =>
row.map((bf) => (bf === null ? null : bf / (1 + bf) - 0.5)),
);
return { bubbleFactor, riskPremium };
}
export type StackSwing = {
/** Chips that change hands, in bb. */
bb: number;
/** Hero's prize equity change, in dollars, after winning `bb` from villain. */
win: number;
/** The same after losing `bb` to villain (negative). */
lose: number;
/** `win + lose`: what an even-money flip for `bb` costs hero (negative under ICM). */
premium: number;
};
/**
* Hero's $EV change as chips move between hero and villain, from 0 up to the
* smaller of their two starting stacks, everyone else unchanged — the
* curve behind the bubble factor (its last point is the all-in one).
*/
export function icmStackSwings(
stacks: number[],
s: IcmStructure,
hero: number,
villain: number,
steps = 40,
): StackSwing[] {
const fieldPlayers = s.playersLeft - stacks.length;
const equity = (after: number[]) =>
icmEquities(after, stacks, s.payouts, fieldPlayers, s.fieldAvgBb)[hero];
const now = equity(stacks);
const cover = Math.min(stacks[hero], stacks[villain]);
return Array.from({ length: steps + 1 }, (_, i) => {
const bb = (cover * i) / steps;
const moved = (sign: number) => {
const after = [...stacks];
after[hero] += sign * bb;
after[villain] -= sign * bb;
return equity(after) - now;
};
const win = moved(1);
const lose = moved(-1);
return { bb, win, lose, premium: win + lose };
});
}
lib/pokerdata/explorer/plo-filter.ts
/**
* PLO hand-property filtering for the range explorer — ported verbatim from
* mobile/src/lib/trainer/plo-filter.ts.
*
* Filter the 16,432 starting hands by suitedness, connectedness (rundown /
* gaps), pairing, and card height. Properties are derived from a canonical
* MonkerSolver string (see plo-hand.ts) so they line up exactly with the hands
* in a node's weights.
*/
import { parseHandString, type Rank, rankIndex } from "./plo-hand";
export type Suitedness = "Double Suited" | "Single Suited" | "Rainbow";
export type Pairing = "Quads" | "Trips" | "Two Pair" | "One Pair" | "Unpaired";
export type Connect = "Rundown" | "One Gap" | "Two Gap" | null;
export type Height = "High" | "Low" | "High-Low";
export interface HandProps {
pairing: Pairing;
suitedness: Suitedness;
/** True when the hand holds a pair of aces (AAxx). */
hasAces: boolean;
/** Connectedness, only defined for four distinct ranks (else null). */
connect: Connect;
height: Height;
}
// Higher rank → higher value. A=12 … 2=0. (RANKS is 'AKQJT98765432'.)
function rankValue(r: Rank): number {
return 12 - rankIndex(r);
}
/** Sum of interior gaps spanned by a set of distinct rank values. */
function spanGaps(values: number[]): number {
const s = [...values].sort((a, b) => a - b);
return s[s.length - 1] - s[0] - (s.length - 1);
}
function connectedness(ranks: Rank[]): Connect {
const distinct = [...new Set(ranks)];
if (distinct.length !== 4) return null;
const base = distinct.map(rankValue);
let gaps = spanGaps(base);
// An ace also plays low for wheel-ish hands (A-2-3-4); take the tighter span.
if (distinct.includes("A")) {
gaps = Math.min(gaps, spanGaps(base.map((v) => (v === 12 ? -1 : v))));
}
if (gaps === 0) return "Rundown";
if (gaps === 1) return "One Gap";
if (gaps === 2) return "Two Gap";
return null;
}
function heightOf(ranks: Rank[]): Height {
// High cards are T,J,Q,K,A (rankIndex 0–4); everything else is low.
let high = 0;
let low = 0;
for (const r of ranks) {
if (rankIndex(r) <= 4) high++;
else low++;
}
if (low === 0) return "High";
if (high === 0) return "Low";
return "High-Low";
}
/** Derive filterable properties from a canonical PLO hand string. */
export function handProps(hand: string): HandProps {
const groups = parseHandString(hand);
const ranks = groups.flat() as Rank[];
const counts = new Map<Rank, number>();
for (const r of ranks) counts.set(r, (counts.get(r) ?? 0) + 1);
const vals = [...counts.values()];
const maxOfRank = Math.max(...vals);
const pairs = vals.filter((v) => v >= 2).length;
let pairing: Pairing;
if (maxOfRank >= 4) pairing = "Quads";
else if (maxOfRank === 3) pairing = "Trips";
else if (pairs >= 2) pairing = "Two Pair";
else if (pairs === 1) pairing = "One Pair";
else pairing = "Unpaired";
const suited = groups.filter((g) => g.length >= 2).length;
const suitedness: Suitedness =
suited >= 2 ? "Double Suited" : suited === 1 ? "Single Suited" : "Rainbow";
return {
pairing,
suitedness,
hasAces: (counts.get("A") ?? 0) >= 2,
connect: connectedness(ranks),
height: heightOf(ranks),
};
}
export type FilterId =
| "ds"
| "ss"
| "rainbow"
| "rundown"
| "gap1"
| "gap2"
| "aces"
| "twopair"
| "trips"
| "unpaired"
| "high"
| "low"
| "highlow";
interface ChipDef {
id: FilterId;
label: string;
}
/**
* Chips grouped by dimension. Selecting multiple chips in one group is an OR;
* selecting across groups is an AND (e.g. Double Suited AND Rundown).
*/
export const FILTER_GROUPS: { key: string; chips: ChipDef[] }[] = [
{
key: "Suits",
chips: [
{ id: "ds", label: "Double Suited" },
{ id: "ss", label: "Single Suited" },
{ id: "rainbow", label: "Rainbow" },
],
},
{
key: "Shape",
chips: [
{ id: "rundown", label: "Rundown" },
{ id: "gap1", label: "1-Gap" },
{ id: "gap2", label: "2-Gap" },
],
},
{
key: "Pairs",
chips: [
{ id: "aces", label: "Aces" },
{ id: "twopair", label: "Two Pair" },
{ id: "trips", label: "Trips+" },
{ id: "unpaired", label: "Unpaired" },
],
},
{
key: "Height",
chips: [
{ id: "high", label: "High" },
{ id: "low", label: "Low" },
{ id: "highlow", label: "High-Low" },
],
},
];
const PREDICATE: Record<FilterId, (p: HandProps) => boolean> = {
ds: (p) => p.suitedness === "Double Suited",
ss: (p) => p.suitedness === "Single Suited",
rainbow: (p) => p.suitedness === "Rainbow",
rundown: (p) => p.connect === "Rundown",
gap1: (p) => p.connect === "One Gap",
gap2: (p) => p.connect === "Two Gap",
aces: (p) => p.hasAces,
twopair: (p) => p.pairing === "Two Pair",
trips: (p) => p.pairing === "Trips" || p.pairing === "Quads",
unpaired: (p) => p.pairing === "Unpaired",
high: (p) => p.height === "High",
low: (p) => p.height === "Low",
highlow: (p) => p.height === "High-Low",
};
/** A hand passes when, for every constrained dimension, it matches ≥1 chip. */
export function matchesFilters(p: HandProps, active: Set<FilterId>): boolean {
if (active.size === 0) return true;
for (const group of FILTER_GROUPS) {
const selected = group.chips.filter((c) => active.has(c.id));
if (selected.length === 0) continue;
if (!selected.some((c) => PREDICATE[c.id](p))) return false;
}
return true;
}
lib/pokerdata/explorer/plo-hand.ts
/**
* PLO (4-card) hand-class engine for the range explorer — ported from the
* mobile app (mobile/src/lib/trainer/plo-hand.ts + hand.ts), viewing subset.
*
* MonkerSolver names each starting hand with suit-grouping notation over the
* 16,432 suit-isomorphism classes — parentheses wrap cards sharing a suit, e.g.
*
* AAAA four aces (each a different suit)
* (2A)AA A-A-A-2 with the 2 suited to one ace
* (KA)(KA) AAKK double-suited
* AKQJ four broadways, all different suits (rainbow)
*/
export const RANKS = "AKQJT98765432";
export const SUITS = ["c", "d", "h", "s"] as const;
export type Suit = (typeof SUITS)[number];
export type Rank = (typeof RANKS)[number];
export type Card = `${Rank}${Suit}`;
export function rankIndex(r: Rank): number {
return RANKS.indexOf(r);
}
/**
* Four-color deck: a distinct, vivid color per suit so suits read at a glance
* (used as a solid card-tile background with white text). Spades use a slate
* rather than near-black so the tile stays visible on the dark background.
*/
export const SUIT_COLOR_4: Record<Suit, string> = {
s: "#39414f", // spades — slate
h: "#FF3366", // hearts — red
d: "#20A4F3", // diamonds — blue
c: "#1f9d57", // clubs — green
};
export function suitGlyph(suit: Suit): string {
switch (suit) {
case "s":
return "♠";
case "h":
return "♥";
case "d":
return "♦";
case "c":
return "♣";
}
}
export type HandCategory =
| "Unpaired Double Suited"
| "Unpaired Single Suited"
| "Unpaired Rainbow"
| "One Pair Double Suited"
| "One Pair Single Suited"
| "One Pair Rainbow"
| "Two Pair Double Suited"
| "Two Pair Single Suited"
| "Two Pair Rainbow"
| "Trips Single Suited"
| "Trips Rainbow"
| "Quads Rainbow";
/** A group of ranks sharing one suit, sorted ascending by rank. */
export type Group = Rank[];
/** Parse a canonical string into suit groups (parens = same-suit group). */
export function parseHandString(s: string): Group[] {
const groups: Group[] = [];
let i = 0;
while (i < s.length) {
if (s[i] === "(") {
const j = s.indexOf(")", i);
const ranks = s.slice(i + 1, j).split("") as Rank[];
groups.push(sortRanks(ranks));
i = j + 1;
} else {
groups.push([s[i] as Rank]);
i += 1;
}
}
return groups;
}
function sortRanks(ranks: Rank[]): Rank[] {
return [...ranks].sort((a, b) => rankIndex(a) - rankIndex(b));
}
/** Classify a parsed hand into the viewer's category buckets. */
export function categorizeGroups(groups: Group[]): HandCategory {
const counts = new Map<Rank, number>();
for (const g of groups) for (const r of g) counts.set(r, (counts.get(r) ?? 0) + 1);
const values = [...counts.values()];
const pairs = values.filter((v) => v >= 2).length;
const maxOfRank = Math.max(...values);
let pairing: string;
if (maxOfRank >= 4) pairing = "Quads";
else if (maxOfRank === 3) pairing = "Trips";
else if (pairs >= 2) pairing = "Two Pair";
else if (pairs === 1) pairing = "One Pair";
else pairing = "Unpaired";
const suited = groups.filter((g) => g.length >= 2).length;
const suitedness =
suited >= 2 ? "Double Suited" : suited === 1 ? "Single Suited" : "Rainbow";
return `${pairing} ${suitedness}` as HandCategory;
}
/** Classify a canonical hand string. */
export function categorizeHand(hand: string): HandCategory {
return categorizeGroups(parseHandString(hand));
}
/**
* Expand a canonical string into 4 concrete display cards, assigning a distinct
* suit per group so the parenthesised cards visibly share a suit. Deterministic
* (for stable rendering); suits are cosmetic since ranges are suit-isomorphic.
*/
export function ploStringToCards(hand: string): Card[] {
const groups = parseHandString(hand);
const cards: Card[] = [];
groups.forEach((g, gi) => {
const suit = SUITS[gi % SUITS.length];
for (const r of g) cards.push(`${r}${suit}` as Card);
});
return cards;
}
lib/pokerdata/explorer/preflop.ts
// Preflop-tree walk + ranges-API client for the range explorer.
//
// Ported from pokerstudy.ai's Explorer (web/src/lib/solver/preflop.ts), minus
// the postflop-solve plumbing — this app only previews ranges. Data comes
// straight from pokerstudy.ai's public NL ranges API (CORS-open, CDN-cached),
// which serves the same MonkerSolver solves the NLHE packs are exported from —
// so a buyer previews exactly what's in the download, this app serves no range
// bytes itself, and the paid `/api/v1/ranges` proxy stays the only ranges API
// on this origin. The house-solved tournament packs ("mtt9") are the
// exception: their JSON bundles ship as public preview assets and house.ts
// adapts them to the same manifest/node contract.
import { GATE_SIGN_IN, GATE_UPGRADE } from "@/lib/pokerdata/explorer/gate-copy";
import { explorerEndpoints } from "@/lib/pokerdata/explorer/endpoints";
import {
getHouseNodeOrNull,
houseManifest,
houseRaiseToBb,
HOUSE_POSITIONS,
} from "./house";
export type Game =
| "nl"
| "nl9"
| "plo"
| "plo9"
| "plo5"
| "mtt9"
| "nlmtt8"
| "nlh8-hydra"
| "nlh8-icm"
| "nlhu";
export type Position =
| "UTG"
| "UTG1"
| "UTG2"
| "LJ"
| "MP"
| "HJ"
| "CO"
| "BTN"
| "SB"
| "BB";
export type ActionKind = "fold" | "call" | "raise" | "allin" | "other";
export interface NodeAction {
action: string;
kind: ActionKind;
/** hand → frequency in [0, 1] for this action. */
weights: Record<string, number>;
/**
* hand → EV for this action (when the bundle carries EV): small blinds,
* except tournament dollars for the ICM game (`evUnit(game)`).
*/
evs?: Record<string, number>;
/** NL: raise size as % of pot. */
sizePct?: number;
/** NL: weighted concrete-combo count. */
combos?: number;
/** PLO: betting level of the raise (1 = open, 2 = 3-bet, …). */
raiseDepth?: number;
/** PLO: opaque MonkerSolver size id when a node offers >1 raise size. */
sizeCode?: number;
/** PLO: count of hand classes taking this action. */
hands?: number;
/**
* PLO5: reach-weighted share of the range taking this action, 0–100. Its
* nodes carry no per-hand `weights` (204k hand classes); the hand list comes
* from `getPlo5NodeList` instead.
*/
poolPct?: number;
/**
* Raise-to in bb, as the feed decodes it from the solver's token (NL, MTT).
* All-ins carry the solved stack depth. Absent for PLO, where the walk
* derives the pot-limit size itself.
*/
sizeBb?: number | null;
/** NL: the raise as a percent of the pot after calling, when it is one. */
sizePctPot?: number | null;
}
export interface NodeResponse {
game: Game;
stack: number;
history: string;
actor: Position;
actions: NodeAction[];
continuingCombos?: number;
continuingHands?: number;
/** Unit of `evs` as the feed states it ("sb", or "usd" for ICM). */
evUnit?: string;
}
export interface Manifest {
game: Game;
generatedAt: string;
stacks: number[];
positions: Position[];
}
export interface SeatState {
position: Position;
action: string | null;
committed: number;
remaining: number;
folded: boolean;
isHero: boolean;
isDealer: boolean;
/** Blind posted before any action (0.5 SB, 1 BB, else 0). */
blind: number;
/** Ante this seat paid from its stack (BB ante, or every seat's ante). */
ante: number;
/** Raise-to of this seat's last raise, when it raised. */
raiseTo?: number;
}
export interface ParsedTable {
seats: Record<Position, SeatState>;
pot: number;
/**
* The seat to act, or null when the history ends on an action — i.e. the
* line is closed (everyone folded to one player, or the round is complete).
*/
hero: Position | null;
/** The bet to match right now (the largest commitment). */
toCall: number;
/** Antes in the pot, from seats' stacks and dead (PLO5). */
antes: number;
}
// Preflop play order (CW from UTG) per game. MP isn't present in the 6-max
// datasets; the 9-max orders match the plo9 dataset's / house bundles' seat
// naming.
export const SEAT_ORDER: Record<Game, Position[]> = {
nl: ["UTG", "HJ", "CO", "BTN", "SB", "BB"],
nl9: ["UTG", "UTG1", "UTG2", "LJ", "HJ", "CO", "BTN", "SB", "BB"],
plo: ["UTG", "HJ", "CO", "BTN", "SB", "BB"],
plo9: ["UTG", "UTG1", "UTG2", "LJ", "HJ", "CO", "BTN", "SB", "BB"],
// HydraSolver PLO5: the first seat is LJ (a HUD's UTG).
plo5: ["LJ", "HJ", "CO", "BTN", "SB", "BB"],
mtt9: HOUSE_POSITIONS,
nlmtt8: ["UTG", "UTG1", "LJ", "HJ", "CO", "BTN", "SB", "BB"],
"nlh8-hydra": ["UTG", "UTG1", "LJ", "HJ", "CO", "BTN", "SB", "BB"],
"nlh8-icm": ["UTG", "UTG1", "LJ", "HJ", "CO", "BTN", "SB", "BB"],
// Heads-up: the SB is the button and acts first preflop.
nlhu: ["SB", "BB"],
};
/**
* Unit of a node's `evs`: small blinds, or for the ICM scenarios tournament
* dollars (the change in prize equity, each hand's fold at 0).
*/
export function evUnit(game: Game): "sb" | "usd" {
return game === "nlh8-icm" ? "usd" : "sb";
}
export const ROOT_HISTORY = "UTG";
/** The first seat to act, which is where a walk starts: UTG, or LJ for PLO5. */
export function rootHistory(game: Game): string {
return SEAT_ORDER[game][0];
}
// ─── Ranges data client ────────────────────────────────────────────────────
// NL and PLO 6-max read our same-origin explorer feed (/api/explorer/ranges),
// which asserts the visitor's plan to pokerstudy.ai's plan-gated ranges API;
// a 401/403 means sign in / upgrade. PLO 9-max is different: its position bundles are too large for
// that API's whole-bundle reader (issues #35/#36), so its nodes are published
// as one small JSON blob per decision node, relayed one at a time by
// /api/explorer/plo9 so they count against the same Browse rate limit.
// Same contract either way: 404 at a history ⇒ the betting round closed there.
// Feed origins come from `explorerEndpoints()`: same-origin on pokerdata.io,
// the host's own feeds elsewhere. The PLO 9-max layout is the per-node blob
// one seeded by web/scripts/upload-plo9-nodes.ts.
// mtt9 never reaches these URL builders — getManifest/getNodeOrNull answer it
// from the bundled preview asset (house.ts) before any URL is built.
const API_BASE: Record<
"nl" | "nl9" | "plo" | "plo5" | "nlmtt8" | "nlh8-hydra" | "nlh8-icm" | "nlhu",
string
> = {
nl: "/nl/v2",
nl9: "/nl9",
plo: "/plo",
plo5: "/plo5",
nlmtt8: "/nlmtt8",
"nlh8-hydra": "/nlh8-hydra",
"nlh8-icm": "/nlh8-icm",
nlhu: "/nlhu",
};
function manifestUrl(game: Exclude<Game, "mtt9">): string {
const { plo9, ranges } = explorerEndpoints();
if (game === "plo9") return `${plo9}/manifest.json`;
return `${ranges}${API_BASE[game]}`;
}
function nodeUrl(
game: Exclude<Game, "mtt9">,
stack: number,
history: string,
scenario?: string,
): string {
const { plo9, ranges } = explorerEndpoints();
if (game === "plo9") return `${plo9}/${stack}/${history}.json`;
// An ICM depth can hold several stages, so name the scenario when we have it.
const params = new URLSearchParams(
scenario ? { scenario, history } : { stack: String(stack), history },
);
return `${ranges}${API_BASE[game]}/node?${params.toString()}`;
}
const cache = new Map<string, Promise<unknown>>();
/** Readable message for the plan gate's answers; null for anything else. */
function gateMessage(status: number): string | null {
if (status === 401) return GATE_SIGN_IN;
if (status === 403) return GATE_UPGRADE;
return null;
}
/** The Browse rate limit's own words (it says when to come back). */
async function rateLimitText(res: Response): Promise<string> {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
return body?.error ?? "You're browsing faster than we allow. Try again in a minute.";
}
async function getJson<T>(path: string): Promise<T> {
const hit = cache.get(path) as Promise<T> | undefined;
if (hit) return hit;
const promise = (async () => {
const res = await fetch(path, { headers: { Accept: "application/json" } });
const gated = gateMessage(res.status);
if (gated) throw new Error(gated);
if (res.status === 429) throw new Error(await rateLimitText(res));
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`GET ${path} -> ${res.status}: ${body.slice(0, 200)}`);
}
return (await res.json()) as T;
})();
cache.set(path, promise);
promise.catch(() => cache.delete(path));
return promise;
}
export function getManifest(game: Game): Promise<Manifest> {
if (game === "mtt9") return Promise.resolve(houseManifest());
return getJson<Manifest>(manifestUrl(game));
}
/**
* The decision node at `history`, or null when the dataset has no decision
* there (HTTP 404) — i.e. the preflop betting round has closed on this line.
* The browser uses this to walk the full multiway tree and detect the end of
* preflop, rather than assuming where the line stops. Throws on real failures.
*/
export async function getNodeOrNull(
game: Game,
stack: number,
history: string,
/** ICM scenario id (`35left-avg40-even`); only the nlh8-icm game reads it. */
scenario?: string,
): Promise<NodeResponse | null> {
if (game === "mtt9") return getHouseNodeOrNull(stack, history);
const res = await fetch(nodeUrl(game, stack, history, scenario), {
headers: { Accept: "application/json" },
});
if (res.status === 404) return null;
const gated = gateMessage(res.status);
if (gated) throw new Error(gated);
if (res.status === 429) throw new Error(await rateLimitText(res));
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`GET node -> ${res.status}: ${body.slice(0, 200)}`);
}
const node = (await res.json()) as NodeResponse;
// PLO5 nodes send pool % instead of per-hand weights.
return {
...node,
actions: node.actions.map((a) => ({ ...a, weights: a.weights ?? {} })),
};
}
// ─── PLO5 hand lists ───────────────────────────────────────────────────────
// A PLO5 node has 204k hand classes, so the node itself carries only each
// action's pool %. `list=1` asks for each action's top hands by EV, and
// `hand=` looks up one five-card combo. Same feed, same Browse rate limit.
export interface Plo5ListedHand {
/** Ten characters, ranks then suits: `2s2h2d2c3s`. */
hand: string;
/** Frequency of this action, 0–1. */
freq: number;
/** EV of this action in small blinds. */
ev: number | null;
}
export interface Plo5ActionList {
action: string;
listed?: Plo5ListedHand[];
/** Hands that take the action at all (`listed` is the top slice). */
listedTotal?: number;
}
export interface Plo5HandStrategy {
hand: string;
/** The stored spelling the lookup landed on (a suit relabeling of `hand`). */
hdvHand: string;
isoMapped: boolean;
actions: { action: string; kind: ActionKind; freq: number; ev_sb: number | null }[];
}
async function getPlo5(params: URLSearchParams): Promise<unknown> {
const res = await fetch(`${explorerEndpoints().ranges}/plo5/node?${params.toString()}`, {
headers: { Accept: "application/json" },
});
const gated = gateMessage(res.status);
if (gated) throw new Error(gated);
if (res.status === 429) throw new Error(await rateLimitText(res));
const body = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) throw new Error(body?.error ?? `GET plo5 node -> ${res.status}`);
return body;
}
/** Each action's top `limit` hands by EV; `ranks` (`AK222`) filters them. */
export async function getPlo5NodeList(
stack: number,
history: string,
limit: number,
ranks?: string,
): Promise<Plo5ActionList[]> {
const params = new URLSearchParams({
stack: String(stack),
history,
list: "1",
limit: String(limit),
});
if (ranks) params.set("ranks", ranks);
const body = (await getPlo5(params)) as { actions?: Plo5ActionList[] } | null;
return body?.actions ?? [];
}
/** One five-card combo's strategy at a node. */
export async function getPlo5HandStrategy(
stack: number,
history: string,
hand: string,
): Promise<Plo5HandStrategy> {
const params = new URLSearchParams({ stack: String(stack), history, hand });
return (await getPlo5(params)) as Plo5HandStrategy;
}
// ─── History parsing (mirror of web/src/lib/solver/preflop.ts) ─────────────
//
// NL action tokens: Fold, Call, AI, "<pct>%" (raise to toCall + pct·pot).
// PLO action tokens (MonkerSolver depth tags): Fold, Call, Open, 3B…8B, each
// optionally size-disambiguated as "Open-<code>". Every PLO raise in these
// packs is pot-sized (capped by stack): raise-to = 2·toCall + pot − committed.
// House (mtt9) tokens are the bundle's own labels: F, C, X, AI and
// R<bb·100> ("R220" = raise to 2.2bb).
const PLO_RAISE_RE = /^(Open|3B|4B|5B|6B|7B|8B)(?:-(\d+))?$/;
/** HydraSolver PLO5 raise: `Raise-4.7x` is a raise to 4.7bb. */
const PLO5_RAISE_RE = /^Raise-(\d+(?:\.\d+)?)x$/;
/** Raise-to in bb of a PLO5 raise token, or null. */
export function plo5RaiseToBb(token: string): number | null {
const m = PLO5_RAISE_RE.exec(token);
return m ? parseFloat(m[1]) : null;
}
/** PLO depth-tag → human label ("Open" → "Pot", "3B" → "3-Bet", …). */
const PLO_RAISE_LABEL: Record<string, string> = {
Open: "Pot",
"3B": "3-Bet",
"4B": "4-Bet",
"5B": "5-Bet",
"6B": "6-Bet",
"7B": "7-Bet",
"8B": "8-Bet",
};
function displayAction(token: string): string {
if (token === "Fold" || token === "F") return "Fold";
if (token === "Call" || token === "C") return "Call";
if (token === "Limp") return "Limp";
if (token === "X") return "Check";
if (token === "AI" || token === "AllIn") return "All-in";
const plo5 = plo5RaiseToBb(token);
if (plo5 !== null) return `Raise ${formatBb(plo5)}`;
const plo = token.match(PLO_RAISE_RE);
if (plo) return PLO_RAISE_LABEL[plo[1]] ?? token;
const houseBb = houseRaiseToBb(token);
if (houseBb !== null) return `Raise ${formatBb(houseBb)}`;
return token;
}
function sumCommitted(
seats: Record<Position, SeatState>,
order: Position[],
): number {
let s = 0;
for (const p of order) s += seats[p].committed;
return s;
}
export function parseHistory(
history: string,
stackBb: number,
order: Position[] = SEAT_ORDER.nl,
/** Dead big-blind ante (mtt9): posted by the BB, counted in the pot. */
anteBb = 0,
/** Ante every seat posts (nlh8-hydra, nlh8-icm, nlhu), counted in the pot. */
antePerSeatBb = 0,
/** Dead ante nobody's stack pays (PLO5's 1.2bb), counted in the pot. */
deadAnteBb = 0,
/**
* Raise-to in bb per line (`"<history>_<token>"` → bb), from the feed's
* `sizeBb`. Wins over decoding the token, which can't size NL MTT tokens
* like `Open`/`3B` or cash's `Min`.
*/
sizes: Record<string, number> = {},
/** Per-seat starting stacks when they differ (ICM stages); else every seat has `stackBb`. */
seatStacks?: Partial<Record<Position, number>>,
): ParsedTable {
const stackOf = (p: Position) => seatStacks?.[p] ?? stackBb;
const seats: Record<Position, SeatState> = Object.create(null);
for (const p of order) {
seats[p] = {
position: p,
action: null,
committed: 0,
remaining: stackOf(p),
folded: false,
isHero: false,
isDealer: p === "BTN",
blind: p === "SB" ? 0.5 : p === "BB" ? 1 : 0,
ante: (p === "BB" ? anteBb : 0) + antePerSeatBb,
};
}
seats.SB.committed = 0.5;
seats.BB.committed = 1;
const tokens = history.split("_");
let prevIdx = -1;
let toCall = 1;
for (let i = 0; i + 1 < tokens.length; i += 2) {
const pos = tokens[i] as Position;
const action = tokens[i + 1];
const posIdx = order.indexOf(pos);
if (posIdx === -1) continue;
if (prevIdx >= 0) {
let cur = (prevIdx + 1) % order.length;
while (cur !== posIdx) {
const s = seats[order[cur]];
if (!s.folded && s.action === null) {
s.folded = true;
s.action = "Fold";
}
cur = (cur + 1) % order.length;
}
}
const line = tokens.slice(0, i + 2).join("_");
const sized = sizes[line];
const houseRaise = houseRaiseToBb(action) ?? plo5RaiseToBb(action);
seats[pos].action = displayAction(action);
if (action === "Fold" || action === "F") {
seats[pos].folded = true;
} else if (
sized !== undefined &&
action !== "Call" &&
action !== "C" &&
action !== "X" &&
action !== "Limp"
) {
const raiseTo = Math.min(stackOf(pos), sized);
seats[pos].committed = raiseTo;
seats[pos].raiseTo = raiseTo;
if (!(action === "AI" || action === "AllIn")) {
seats[pos].action = `Raise ${formatBbExact(raiseTo)}`;
}
toCall = Math.max(toCall, raiseTo);
} else if (
action === "Call" ||
action === "C" ||
action === "X" ||
action === "Limp"
) {
seats[pos].committed = toCall;
} else if (action === "AI" || action === "AllIn") {
const allIn = stackOf(pos) - seats[pos].ante;
seats[pos].committed = allIn;
seats[pos].raiseTo = allIn;
toCall = Math.max(toCall, allIn);
} else if (houseRaise !== null) {
const raiseTo = Math.min(stackOf(pos), houseRaise);
seats[pos].committed = raiseTo;
seats[pos].raiseTo = raiseTo;
toCall = raiseTo;
} else if (action.endsWith("%")) {
const potBefore = sumCommitted(seats, order);
const raiseTo = toCall + (parseFloat(action) / 100) * potBefore;
seats[pos].committed = raiseTo;
seats[pos].raiseTo = raiseTo;
toCall = raiseTo;
} else if (PLO_RAISE_RE.test(action)) {
// Pot-limit raise: call, then raise by the pot after the call — i.e.
// raise-to = 2·toCall + pot − alreadyCommitted, capped by the stack.
const potBefore = sumCommitted(seats, order);
const raiseTo = Math.min(
stackOf(pos),
2 * toCall + potBefore - seats[pos].committed,
);
seats[pos].committed = raiseTo;
seats[pos].raiseTo = raiseTo;
toCall = raiseTo;
}
prevIdx = posIdx;
}
// An odd token count ends on a seat (its decision); an even one ends on an
// action, which closed the line.
const closed = tokens.length % 2 === 0;
const hero = closed ? null : ((tokens[tokens.length - 1] || order[0]) as Position);
const heroIdx = hero ? order.indexOf(hero) : -1;
if (hero && seats[hero]) seats[hero].isHero = true;
if (prevIdx >= 0 && heroIdx !== -1) {
let cur = (prevIdx + 1) % order.length;
while (cur !== heroIdx) {
const s = seats[order[cur]];
if (!s.folded && s.action === null) {
s.folded = true;
s.action = "Fold";
}
cur = (cur + 1) % order.length;
}
}
for (const p of order) {
seats[p].remaining = Math.max(0, stackOf(p) - seats[p].committed - seats[p].ante);
}
const antes = anteBb + antePerSeatBb * order.length + deadAnteBb;
return {
seats,
pot: sumCommitted(seats, order) + antes,
hero,
toCall: Math.max(...order.map((p) => seats[p].committed)),
antes,
};
}
/** The seat that acts after `action` is taken; null if the line terminates. */
export function nextActorAfter(
history: string,
action: string,
stackBb: number,
order: Position[] = SEAT_ORDER.nl,
): Position | null {
const { seats } = parseHistory(history, stackBb, order);
const tokens = history.split("_");
const actor = tokens[tokens.length - 1] as Position;
const actorIdx = order.indexOf(actor);
if (actorIdx === -1) return null;
const liveAfter = new Set<Position>();
for (const p of order) if (!seats[p].folded) liveAfter.add(p);
if (action === "Fold" || action === "F") liveAfter.delete(actor);
for (let i = 1; i <= order.length; i++) {
const cand = order[(actorIdx + i) % order.length];
if (liveAfter.has(cand)) return cand;
}
return null;
}
// ─── Display helpers ───────────────────────────────────────────────────────
const KIND_ORDER: Record<string, number> = {
fold: 0,
call: 1,
raise: 2,
allin: 3,
other: 4,
};
export function sortActionsForDisplay(actions: NodeAction[]): NodeAction[] {
return [...actions].sort((a, b) => {
const ka = KIND_ORDER[a.kind] ?? 99;
const kb = KIND_ORDER[b.kind] ?? 99;
if (ka !== kb) return ka - kb;
// NL raises sort by pot %, PLO5 by raise-to, PLO by depth then size code.
return (
(a.sizePct ?? 0) - (b.sizePct ?? 0) ||
(plo5RaiseToBb(a.action) ?? 0) - (plo5RaiseToBb(b.action) ?? 0) ||
(a.raiseDepth ?? 0) - (b.raiseDepth ?? 0) ||
(a.sizeCode ?? 0) - (b.sizeCode ?? 0)
);
});
}
export function labelForAction(action: NodeAction, stack: number): string {
if (action.kind === "fold") return "Fold";
if (action.kind === "call") {
if (action.action === "X") return "Check";
if (action.action === "Limp") return "Limp";
return "Call";
}
if (action.kind === "allin" || action.action === "AI" || action.action === "AllIn") return `All-in ${formatBbExact(action.sizeBb ?? stack)}`;
// The feed's decoded raise-to beats any token spelling ("Open", "3B", "Min").
if (action.sizeBb != null) return `Raise ${formatBbExact(action.sizeBb)}`;
if (action.kind === "raise") {
if (action.action === "Open") return "Open";
const houseBb = houseRaiseToBb(action.action) ?? plo5RaiseToBb(action.action);
if (houseBb !== null) return `Raise ${formatBb(houseBb)}`;
const plo = action.action.match(PLO_RAISE_RE);
if (plo) {
const label =
(action.raiseDepth ?? 1) === 1
? "Raise pot"
: `${PLO_RAISE_LABEL[plo[1]] ?? plo[1]} pot`;
return plo[2] ? `${label} (${plo[2]})` : label;
}
return `Raise ${action.action}`;
}
return action.action;
}
export function formatBb(v: number): string {
if (v < 100) return v.toFixed(v % 1 === 0 ? 0 : 1);
return Math.round(v).toString();
}
/**
* Bb cut (never rounded up) to two decimals, trailing zeros dropped: 2.19,
* 39.875 → 39.87, 40. The epsilon keeps float noise (2.19 → 2.1899…) from
* cutting a whole cent.
*/
export function formatBbExact(v: number): string {
return String(Math.trunc(v * 100 + (v < 0 ? -1e-6 : 1e-6)) / 100);
}
lib/pokerdata/explorer/range-model.ts
// 169-class hand-grid model for the range explorer. A range is a map from
// hand-class ("AA", "AKs", "AKo", …) to a weight in [0, 1] — the same shape the
// ranges API returns (NodeAction.weights). Trimmed port of pokerstudy.ai's
// web/src/lib/solver/range-model.ts (the solve-time serialization is dropped;
// this app only views ranges).
export type RangeWeights = Record<string, number>;
const RANKS = "AKQJT98765432";
/**
* Every 169 hand-class in grid order: suited upper-right, offsuit lower-left,
* pairs on the diagonal ("AA", "AKs", …, "32o"). Higher rank is written first.
*/
export const ALL_HANDS: string[] = (() => {
const hands: string[] = [];
for (let r = 0; r < 13; r++) {
for (let c = 0; c < 13; c++) {
const hi = RANKS[Math.min(r, c)];
const lo = RANKS[Math.max(r, c)];
if (r === c) hands.push(`${RANKS[r]}${RANKS[r]}`);
else if (r < c) hands.push(`${hi}${lo}s`);
else hands.push(`${hi}${lo}o`);
}
}
return hands;
})();
/** Concrete combos a class represents, ignoring card removal (pair 6, suited 4, offsuit 12). */
export function comboCountOf(hand: string): number {
if (hand.length === 2) {
return hand[0] === hand[1] && RANKS.includes(hand[0]) ? 6 : 0;
}
if (hand.length === 3 && RANKS.includes(hand[0]) && RANKS.includes(hand[1])) {
if (hand[2] === "s") return 4;
if (hand[2] === "o") return 12;
}
return 0;
}
lib/pokerdata/postflop/types.ts
// Wire types for postflop solver nodes — mirrors pokerstudy.ai's
// web/src/lib/solver/types.ts (the dump_nodes shape). Keep in sync.
export type SolverActionLabel = "Fold" | "Check" | "Call" | "Bet" | "Raise" | "AllIn";
export interface SolverActionInfo {
label: SolverActionLabel;
/** Chips added by taking this action (chips = bb in these solves). */
add: number;
}
export interface SolverHandStrategy {
/** Combo string, 4 chars for NLHE ("AsKh"). */
combo: string;
/** Frequency per action, parallel to the node's `actions`. */
freqs: number[];
/** Per-combo equilibrium EV in bb (node-EV solves; absent on older data). */
ev?: number;
/** Absolute per-action EV in bb, reconstructed by PokerData when available. */
action_ev?: number[];
/** Per-action EV given up vs the best action (action-EV solves). */
ev_loss?: number[];
/** Distinguishes derived action values from fields published by the solver. */
ev_source?:
| "reconstructed_complete_turn_tree"
| "reconstructed_action_loss_anchor";
}
export interface SolverNode {
path: string[];
player: 0 | 1;
pot: number;
actions: SolverActionInfo[];
hands: SolverHandStrategy[];
}
/** Per-combo reach weights ("AsKh" → weight). */
export type ComboWeights = Map<string, number>;