block
Range explorer
Walk a solved preflop tree seat by seat: the table redrawn at every decision, the acting seat's range on the hand matrix, every action's share of the range, and one hand's frequency and EV per action. Bring your own node loader.
Walk the tree from any decision: the table redraws at each step, the acting seat's range fills the matrix with each action's share in its legend, and the seat strip offers the actions that walk the line on. The block owns the walk and you own the data: pass a loadNode that answers each history (null once the line has closed) and a table with the seats, stacks and antes. This preview reads the free 100bb 6-max ranges through preflop-range-explorer, starting with the button facing a UTG open.
Pot
3.4bb
To call
1.9bb
Players in
4
Effective stack
98.1bb
Installation
$ npx shadcn@latest add https://www.pokerstudy.ai/r/range-explorer.jsonGenerative UI
A model can draw this with json-render. Install generative UI and spread its definitions into your catalog. The model reads the description and the schema below, and answers with a spec like the example.
RangeExplorer
The Range Explorer at a solver preflop decision: the table, the acting seat's range as a matrix, every action's share of the range and any hand's EV, walkable from there. Fetches each node itself.
Pot
3.4bb
To call
1.9bb
Players in
4
Effective stack
98.1bb
Props
Generated from the component source with react-docgen-typescript.
RangeExplorer
| Prop | Type | Default | Description |
|---|---|---|---|
| actionsFor | ((node: WalkNode, colored: ExplorerAction[]) => ExplorerAction[]) | – | Share of the range per action when the data isn't a 13×13 grid. |
| avatars | boolean | false | Draw seat portraits on the table. Off unless your app serves `/avatars`. |
| contentClassName | string | "" | Classes for the box round the panels (padding, max width) when the header is on. |
| fill | boolean | false | Fill the host's height (a drawer, a full-page viewer): the explorer takes the whole box, the stat strip is compact, and on a wide screen the table and the matrix size to the height left under the seat strip, so the walk fits without scrolling. A narrow screen stacks the panels and scrolls them as usual. The host's box needs a definite height. |
| footer | ReactNode | ((ctx: { node: WalkNode | null; history: string; actions: ExplorerAction[]; }) => ReactNode) | – | Content under the panels, such as a link to the full pack; a function sees the node it sits under. |
| header | { title?: ReactNode; trailing?: ReactNode; } | – | A bar across the top with Back and Reset; `trailing` takes the host's close button, `title` is optional. |
| initialHand | string | null | null | Hand class to pin first, e.g. "A5s". |
| initialHistory | string | – | Where to start; the first seat's open by default. |
| loadNode * | (history: string) => Promise<WalkNode | null> | – | The decision at a history, or null when the line has closed there. Throw to show an error. |
| onHistoryChange | ((history: string) => void) | – | Called with each history the walk reaches, for a shareable URL. |
| palette | "classic" | "midnight" | "frost" | "mint" | – | Action colors: "classic" (fold blue, call green, raises red), "midnight", "frost" or "mint". |
| renderError | ((message: string) => ReactNode) | – | Draw a `loadNode` error your own way (a sign-in prompt); plain text by default. |
| renderRange | ((ctx: { node: WalkNode; history: string; actions: ExplorerAction[]; }) => ReactNode) | – | Draw the range another way (a PLO hand list); the matrix by default. |
| stats | { label: string; value: string; }[] | – | The strip of figures above the table: the pack's facts (game, stack, players, rake, ante). The live pot, to-call and stacks by default. |
| table * | TableSpec | – | Seats, stacks and antes of the table the tree was solved for. |
| tableClassName | string | min-h-[380px] | Height of the table box; it grows to match the range beside it. |
| theme | "native" | "web" | "felt" | "matrix" | "coinpoker" | "ggpoker" | "acr" | "ignition" | "halloween" | "pokerdata" | – | Pin the table to one theme ("pokerdata" for its monochrome felt). Omitted: the page's theme. |
SeatStrip
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | ExplorerAction[] | – | The acting seat's actions, in display order. |
| actor * | string | null | – | – |
| loading * | boolean | – | – |
| onRewind * | (pos: string) => void | – | – |
| onTake * | (action: WalkAction) => void | – | – |
| seats * | WalkSeat[] | – | – |
Widget
| Prop | Type | Default | Description |
|---|---|---|---|
| aside | ReactNode | – | Right end of the title row: a caption or small controls. |
| className | string | "" | – |
| title | ReactNode | – | Omitted: no title row; the content starts at the top of the card. |
StatGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| compact | boolean | false | – |
| stats * | { label: string; value: string; }[] | – | – |
ExplorerHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| canBack * | boolean | – | – |
| className | string | "" | – |
| onBack * | () => void | – | – |
| onReset * | () => void | – | – |
| progress | number | 0 | 0–1: how far round the table the line has gone. |
| title | ReactNode | – | Omitted: the bar holds only the controls. |
| trailing | ReactNode | – | Right end of the bar, after Reset: the host's close button. |
ActionMenu
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | ExplorerAction[] | – | – |
| actor * | string | null | – | – |
| onTake * | (action: WalkAction) => void | – | – |
| seat | WalkSeat | – | The acting seat, for its chips in front and behind. |
HandDetail
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | ExplorerAction[] | – | – |
| actor * | string | null | – | – |
| evUnit * | "sb" | "usd" | – | "sb": the data's EVs are small blinds (shown as bb). "usd": tournament dollars. |
| hand * | string | null | – | – |
| onClear * | () => void | – | – |
| pinned * | boolean | – | – |
EndCard
| Prop | Type | Default | Description |
|---|---|---|---|
| end * | WalkEnd | – | – |
| onRestart * | () => void | – | – |
Source
"use client";
import type { ReactNode } from "react";
import { ArrowLeft, ChevronRight, RotateCcw } from "lucide-react";
import { comboCount } from "@/lib/pokerstudy/poker";
import { formatBbExact, type WalkAction, type WalkEnd, type WalkSeat } from "@/lib/pokerstudy/preflop-walk";
/**
* The panels round the Range Explorer's table and matrix: the header with
* the line and its controls, the seat strip, the widget frames, and how a
* closed line ended. Each is a plain view of the walk; the explorer owns the
* state. `ActionMenu` and `HandDetail` stay for hosts that want them.
*/
/** One action at the node with its colour, label and share of the range. */
export interface ExplorerAction {
action: WalkAction;
label: string;
color: string;
/** Share of the range taking it, 0–1; undefined when the data has no figure. */
freq?: number;
/** "240.9 combos" / "3,261 hands". */
detail?: string;
}
// ─── Seat strip ────────────────────────────────────────────────────────────
/**
* Every seat in acting order. The acting seat lists its actions; a seat that
* has acted shows what it did and rewinds the line to that decision when
* clicked.
*/
export function SeatStrip({
seats,
actor,
actions,
loading,
onTake,
onRewind,
}: {
seats: WalkSeat[];
actor: string | null;
/** The acting seat's actions, in display order. */
actions: ExplorerAction[];
loading: boolean;
onTake: (action: WalkAction) => void;
onRewind: (pos: string) => void;
}) {
return (
<div role="list" aria-label="Seats" className="flex shrink-0 gap-2 overflow-x-auto pb-1">
{seats.map((seat) => {
const isActor = seat.pos === actor;
const rewinds = !isActor && !!seat.action;
const frame = `flex min-h-24 w-[132px] shrink-0 flex-col gap-1 rounded-[var(--rb-r-sm,6px)] border p-2 text-left transition-colors ${
isActor ? "border-foreground/40 bg-background" : "border-border bg-background/60"
} ${rewinds ? "cursor-pointer hover:border-foreground/40" : ""}`;
const header = (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5">
<span className={`text-sm font-semibold ${seat.folded ? "text-muted-foreground/60" : "text-foreground"}`}>
{seat.pos}
</span>
{seat.isDealer ? (
<span className="flex size-3.5 items-center justify-center rounded-full bg-foreground text-[8px] font-bold text-background">
D
</span>
) : null}
</span>
<span className={`text-[11px] font-medium tabular-nums ${seat.folded ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{formatBbExact(seat.remaining)}
</span>
</div>
);
const body = isActor ? (
<div className="flex flex-col">
{actions.length === 0 ? (
<span className="py-1 text-xs text-muted-foreground">{loading ? "…" : "—"}</span>
) : (
actions.map((a) => (
<button
key={a.action.action}
type="button"
onClick={() => onTake(a.action)}
className="flex cursor-pointer items-center justify-between gap-1 rounded px-1 py-1 text-left text-xs text-foreground transition-colors hover:bg-muted"
>
<span className="truncate">{a.label}</span>
<ChevronRight aria-hidden="true" className="size-3 shrink-0 text-muted-foreground" />
</button>
))
)}
</div>
) : (
<div className="flex flex-1 items-center py-1">
{seat.action ? (
<span
className={`inline-block max-w-full truncate rounded px-2 py-0.5 text-xs font-semibold ${
seat.folded ? "border border-border text-muted-foreground/70" : "bg-muted text-foreground"
}`}
>
{seat.action}
</span>
) : (
<span className="text-xs text-muted-foreground/60">—</span>
)}
</div>
);
return rewinds ? (
<button key={seat.pos} type="button" role="listitem" onClick={() => onRewind(seat.pos)} className={frame}>
{header}
{body}
</button>
) : (
<div key={seat.pos} role="listitem" aria-current={isActor || undefined} className={frame}>
{header}
{body}
</div>
);
})}
</div>
);
}
// ─── Widgets ───────────────────────────────────────────────────────────────
/** The widget frame of the dashboard blocks: a bordered card with a title row. */
export function Widget({
title,
aside,
className = "",
children,
}: {
/** Omitted: no title row; the content starts at the top of the card. */
title?: ReactNode;
/** Right end of the title row: a caption or small controls. */
aside?: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<section className={`relative flex min-w-0 flex-col gap-3 overflow-hidden rounded-[var(--rb-r-lg,10px)] border border-border bg-card p-4 text-card-foreground ${className}`}>
{title === undefined && !aside ? null : (
<div className="flex min-h-7 items-center justify-between gap-3">
<h2 className="min-w-0 truncate text-[14px]/[16px] font-[450] text-foreground">{title}</h2>
{aside}
</div>
)}
{children}
</section>
);
}
/**
* The stat strip across the top: one cell per figure, ruled like the
* dashboard tiles. On a phone the figures sit two to a row, an odd last one
* across the row. `compact` halves the strip's height for a viewer that has
* to fit a screen: smaller figures, three to a row once there is room.
*/
export function StatGroup({ stats, compact = false }: { stats: { label: string; value: string }[]; compact?: boolean }) {
return (
<div className="@container rounded-[var(--rb-r-lg,10px)] border border-border bg-card">
<div
className={`grid grid-cols-2 overflow-hidden *:-mt-px *:-ml-px *:border-t *:border-l *:border-border/60 *:last:odd:col-span-2 @md:grid-flow-col @md:auto-cols-fr @md:grid-cols-none @md:*:last:odd:col-span-1 ${
compact ? "@sm:grid-cols-3" : ""
}`}
>
{stats.map((s) => (
<div key={s.label} className={`flex min-w-0 flex-col ${compact ? "gap-1 px-3 py-2" : "gap-2 px-4 py-3"}`}>
<p className={`truncate font-[450] text-foreground/80 ${compact ? "text-[12px]/[16px]" : "text-[14px]/[16px]"}`}>{s.label}</p>
<p className={`truncate font-[450] text-foreground tabular-nums ${compact ? "text-[18px]/[24px] @md:text-[20px]/[24px]" : "text-[28px]/[32px]"}`}>
{s.value}
</p>
</div>
))}
</div>
</div>
);
}
// ─── Header ────────────────────────────────────────────────────────────────
const ghostButton =
"relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[var(--rb-r-sm,6px)] bg-transparent text-foreground transition-[color,background-color,scale] duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)] select-none hover:not-disabled:bg-foreground/5 active:scale-[0.98] active:bg-foreground/8 disabled:cursor-not-allowed disabled:text-foreground/40 motion-reduce:active:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground";
/**
* The bar across the top of the explorer, in the article-header idiom: a
* back button at the left, the title, Reset and the host's own controls (a
* close button) at the right, and a hairline along the bottom that fills as
* the walk goes round the table.
*/
export function ExplorerHeader({
title,
canBack,
onBack,
onReset,
progress = 0,
trailing,
className = "",
}: {
/** Omitted: the bar holds only the controls. */
title?: ReactNode;
canBack: boolean;
onBack: () => void;
onReset: () => void;
/** 0–1: how far round the table the line has gone. */
progress?: number;
/** Right end of the bar, after Reset: the host's close button. */
trailing?: ReactNode;
className?: string;
}) {
return (
<header className={`relative z-20 shrink-0 border-b border-border bg-background ${className}`}>
<div className="flex h-14 items-center gap-2 px-2 sm:px-3">
<button type="button" aria-label="Back one step" onClick={onBack} disabled={!canBack} className={`${ghostButton} size-8`}>
<ArrowLeft aria-hidden="true" className="size-4" />
</button>
<p className="min-w-0 flex-1 truncate text-[14px]/[16px] font-[450] text-foreground">{title}</p>
<div className="flex shrink-0 items-center gap-0.5">
<button type="button" onClick={onReset} disabled={!canBack} className={`${ghostButton} h-8 px-3 text-[14px]/[16px] font-[450] text-foreground/80 hover:not-disabled:text-foreground`}>
<RotateCcw aria-hidden="true" className="size-4" />
<span className="hidden sm:inline">Reset</span>
</button>
{trailing}
</div>
</div>
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 h-[2px]">
<div
style={{ width: `${Math.round(Math.min(1, Math.max(0, progress)) * 100)}%` }}
className="block h-full bg-foreground transition-[width] duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none"
/>
</div>
</header>
);
}
// ─── Action menu ───────────────────────────────────────────────────────────
/**
* Every action the solved tree offers at this node, including the ones the
* strategy never takes, with its share of the range. A row walks the line
* forward, like the seat strip.
*/
export function ActionMenu({
actions,
actor,
seat,
onTake,
}: {
actions: ExplorerAction[];
actor: string | null;
/** The acting seat, for its chips in front and behind. */
seat?: WalkSeat;
onTake: (action: WalkAction) => void;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2 text-[12px]/[16px] text-muted-foreground tabular-nums">
<span>
{actor ? `${actor} · ` : ""}
{actions.length} in tree
</span>
{seat ? (
<span>
{formatBbExact(seat.committed)}bb in · {formatBbExact(seat.remaining)}bb behind
</span>
) : null}
</div>
{actions.some((a) => a.freq !== undefined) ? (
<div className="flex h-2 w-full overflow-hidden rounded-full bg-secondary">
{actions.map((a) =>
!a.freq || a.freq <= 0.0005 ? null : (
<div key={a.action.action} style={{ flexGrow: a.freq, backgroundColor: a.color }} />
),
)}
</div>
) : null}
<div className="overflow-hidden rounded-[var(--rb-r-sm,6px)] border border-border">
<div className="grid grid-cols-[minmax(0,1fr)_92px_12px] gap-2 border-b border-border bg-muted/50 px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>Action</span>
<span className="text-right">Range</span>
<span />
</div>
{actions.map((a) => {
const zero = a.freq !== undefined && a.freq <= 0.0005;
return (
<button
key={a.action.action}
type="button"
onClick={() => onTake(a.action)}
title={`Take ${a.label}${a.action.sizeBb != null ? ` (to ${formatBbExact(a.action.sizeBb)}bb)` : ""}`}
className="group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_92px_12px] items-center gap-2 border-b border-border/60 px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-muted"
>
<span className="flex min-w-0 items-center gap-2">
<span
aria-hidden
className="size-2.5 shrink-0 rounded-[3px]"
style={{ backgroundColor: a.color, opacity: zero ? 0.45 : 1 }}
/>
<span className={`truncate text-[13px] font-medium ${zero ? "text-muted-foreground" : "text-foreground"}`}>
{a.label}
</span>
</span>
<span className="flex flex-col items-end leading-tight">
<span className={`text-xs font-semibold tabular-nums ${zero ? "text-muted-foreground" : "text-foreground"}`}>
{a.freq === undefined ? "—" : `${(a.freq * 100).toFixed(1)}%`}
</span>
{a.detail ? (
<span className="whitespace-nowrap text-[10px] text-muted-foreground tabular-nums">{a.detail}</span>
) : null}
</span>
<ChevronRight
aria-hidden="true"
className="size-3.5 text-muted-foreground/60 transition-colors group-hover:text-foreground"
/>
</button>
);
})}
</div>
</div>
);
}
// ─── Hand detail ───────────────────────────────────────────────────────────
/**
* One hand class at this node: its frequency for every action and each
* action's EV. A 0% the data omitted shows as 0%; an EV it doesn't carry is
* "n/a", never a made-up zero.
*/
export function HandDetail({
hand,
pinned,
actions,
evUnit,
actor,
onClear,
}: {
hand: string | null;
pinned: boolean;
actions: ExplorerAction[];
/** "sb": the data's EVs are small blinds (shown as bb). "usd": tournament dollars. */
evUnit: "sb" | "usd";
actor: string | null;
onClear: () => void;
}) {
const usd = evUnit === "usd";
if (!hand) {
return (
<div className="rounded-[var(--rb-r-sm,6px)] border border-dashed border-border px-4 py-5 text-center text-[13px]/[18px] text-muted-foreground">
Hover or tap a hand in the matrix to see its frequency and EV for every action. Click to pin it.
</div>
);
}
const rows = actions.map((a) => {
const w = a.action.weights[hand];
const e = a.action.evs?.[hand];
return { a, freq: w ?? 0, omitted: w === undefined, ev: e === undefined ? undefined : usd ? e : e / 2 };
});
const inRange = rows.reduce((s, r) => s + r.freq, 0);
// Folding costs every hand the same, so when this hand's fold EV is
// missing, any other hand's fold EV at the node is it.
const ownFoldEv = rows.find((r) => r.a.action.kind === "fold")?.ev;
const sharedFoldEv = ownFoldEv === undefined ? nodeFoldEv(actions, usd) : undefined;
const foldEv = ownFoldEv ?? sharedFoldEv;
let evW = 0;
let evD = 0;
for (const r of rows) {
if (r.ev !== undefined && r.freq > 0) {
evW += r.freq * r.ev;
evD += r.freq;
}
}
const handEv = evD > 0 ? evW / evD : undefined;
const fmt = (v: number) => (usd ? `${v < 0 ? "-" : ""}$${Math.abs(v).toFixed(2)}` : `${v > 0 ? "+" : ""}${v.toFixed(2)}`);
const unit = usd ? "$" : "bb";
return (
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<span className="text-[12px]/[16px] text-muted-foreground">
{actor ?? ""}
{pinned ? " · pinned" : " · hovering"}
</span>
{pinned ? (
<button
type="button"
onClick={onClear}
className="cursor-pointer text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
Unpin
</button>
) : null}
</div>
<div className="overflow-hidden rounded-[var(--rb-r-sm,6px)] border border-border">
<div className="flex items-baseline justify-between border-b border-border bg-muted/50 px-3 py-2">
<span className="text-base font-semibold text-foreground">{hand}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{comboCount(hand)} combos ·{" "}
{inRange > 0.0005 ? (
<>
EV{" "}
<span className="text-foreground">{handEv === undefined ? "n/a" : `${fmt(handEv)}${usd ? "" : "bb"}`}</span>
</>
) : (
"not in this range"
)}
</span>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_56px_60px_60px] gap-2 border-b border-border/60 px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>Action</span>
<span className="text-right">Freq</span>
<span className="text-right">EV {unit}</span>
<span className="text-right">vs fold</span>
</div>
{rows.map((r) => (
<div
key={r.a.action.action}
className="grid grid-cols-[minmax(0,1fr)_56px_60px_60px] items-center gap-2 border-b border-border/60 px-3 py-1.5 last:border-b-0"
>
<span className="flex min-w-0 items-center gap-2">
<span aria-hidden className="size-2.5 shrink-0 rounded-[3px]" style={{ backgroundColor: r.a.color }} />
<span className="truncate text-xs text-foreground">{r.a.label}</span>
</span>
<span
className={`text-right text-xs tabular-nums ${r.freq > 0 ? "font-semibold text-foreground" : "text-muted-foreground"}`}
title={r.omitted ? "Omitted by the data, which means 0" : undefined}
>
{(r.freq * 100).toFixed(1)}%
</span>
<span
className={`text-right text-xs tabular-nums ${r.ev === undefined ? "text-muted-foreground/60" : "text-foreground"}`}
title={r.ev === undefined ? "The data carries no EV for this action and hand" : undefined}
>
{r.ev !== undefined
? fmt(r.ev)
: r.a.action.kind === "fold" && sharedFoldEv !== undefined
? fmt(sharedFoldEv)
: "n/a"}
</span>
<span className="text-right text-xs text-muted-foreground tabular-nums">
{foldEv === undefined
? "n/a"
: r.a.action.kind === "fold"
? fmt(0)
: r.ev === undefined
? "n/a"
: fmt(r.ev - foldEv)}
</span>
</div>
))}
</div>
</div>
);
}
/** The fold EV shared by every hand at a node, when the data's values agree. */
function nodeFoldEv(actions: ExplorerAction[], usd: boolean): number | undefined {
const fold = actions.find((a) => a.action.kind === "fold")?.action.evs;
if (!fold || usd) return undefined;
const values = new Set(Object.values(fold).map((v) => Math.round(v * 1000)));
if (values.size !== 1) return undefined;
return Object.values(fold)[0] / 2;
}
// ─── End of line ───────────────────────────────────────────────────────────
/** How the line ended: folded to one player, or a flop. */
export function EndCard({ end, onRestart, children }: { end: WalkEnd; onRestart: () => void; children?: React.ReactNode }) {
return (
<div className="flex flex-col items-center gap-3 rounded-[var(--rb-r-sm,6px)] border border-dashed border-border px-6 py-7 text-center">
{end.reason === "won" ? (
<span className="text-sm text-muted-foreground">
Hand ends preflop — {end.survivors[0] ? `${end.survivors[0]} wins uncontested` : "everyone folded"}.
</span>
) : (
<>
<span className="text-sm font-medium text-foreground">Preflop complete — {end.survivors.join(" / ")} see the flop</span>
<span className="text-xs text-muted-foreground">
{end.survivors.length}-way · pot {formatBbExact(end.potBb)}bb · {formatBbExact(end.effStackBb)}bb behind
</span>
</>
)}
{children}
<button
type="button"
onClick={onRestart}
className="cursor-pointer rounded-full border border-border px-3.5 py-1.5 text-xs font-medium text-muted-foreground hover:border-foreground/40 hover:text-foreground"
>
Start over
</button>
</div>
);
}
import {
ALL_HANDS,
actionPalette,
comboCount,
sortActions,
type ActionPaletteId,
type NodeAction,
} from "@/lib/pokerstudy/poker";
/**
* The preflop walk behind the Range Explorer: a history string such as
* `UTG_60%_HJ_Fold_CO` replayed into what every seat has done, what it has in
* front, what is left behind and the pot, plus who acts next after an action.
* Pure functions, no fetching: the explorer asks its data source for each
* node and hands the answer here.
*
* A history alternates seat and action tokens. An odd count ends on the seat
* to act; an even count ends on an action, which closed the line.
*/
/** One action at a decision as the explorer needs it: poker-core's node action plus the solver's raise-to. */
export interface WalkAction extends NodeAction {
/** Raise-to in big blinds when the data source decoded it; the token is sized otherwise. */
sizeBb?: number | null;
}
/** The decision at a history: who acts and every action the tree offers. */
export interface WalkNode {
actor?: string;
actions: WalkAction[];
}
/** The table a walk runs on. */
export interface TableSpec {
/** Seats in preflop acting order, clockwise from the first to act, `BTN`, `SB` and `BB` among them (heads-up: just `SB`, the button, then `BB`). */
order: string[];
/** Effective stack in big blinds. */
stack: number;
/** Per-seat starting stacks when they differ (an ICM stage); every other seat has `stack`. */
seatStacks?: Record<string, number>;
/** Big-blind ante, posted by the BB and counted in the pot. */
bbAnte?: number;
/** Ante every seat posts, counted in the pot. */
antePerSeat?: number;
/** Dead ante nobody's stack pays, counted in the pot. */
deadAnte?: number;
/** Raise-to in bb for a raise token the generic rules can't size (house codes); null to fall through. */
sizeOf?: (token: string) => number | null;
}
export interface WalkSeat {
pos: string;
/** What the seat did on this line, or null while it waits. */
action: string | null;
/** Chips in front, blinds included, in bb. */
committed: number;
/** Chips still behind, in bb. */
remaining: number;
folded: boolean;
isDealer: boolean;
/** Ante paid from this seat's stack. */
ante: number;
/** Raise-to of the seat's last raise, when it raised. */
raiseTo?: number;
}
export interface WalkTable {
seats: Record<string, WalkSeat>;
pot: number;
/** The seat to act, or null when the history ends on an action (the line is closed). */
actor: string | null;
/** The bet to match right now. */
toCall: number;
/** Antes in the pot. */
antes: number;
}
/** Pot-limit raise tokens: `Open`, `3B`, … optionally suffixed with a size code. */
const PLO_RAISE_RE = /^(Open|3B|4B|5B|6B|7B|8B)(?:-(\d+))?$/;
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",
};
/** HydraSolver PLO5 raise: `Raise-4.7x` is a raise to 4.7bb. */
const PLO5_RAISE_RE = /^Raise-(\d+(?:\.\d+)?)x$/;
function plo5RaiseToBb(token: string): number | null {
const m = PLO5_RAISE_RE.exec(token);
return m ? parseFloat(m[1]) : null;
}
/** bb cut (never rounded up) to two decimals, trailing zeros dropped: 39.875 → 39.87. */
export function formatBbExact(v: number): string {
return String(Math.trunc(v * 100 + (v < 0 ? -1e-6 : 1e-6)) / 100);
}
function isFold(token: string): boolean {
return token === "Fold" || token === "F";
}
function isCall(token: string): boolean {
return token === "Call" || token === "C" || token === "X" || token === "Limp";
}
function isAllIn(token: string): boolean {
return token === "AI" || token === "AllIn";
}
/** The word a seat shows for a token it took. */
function displayAction(token: string, sizeOf?: TableSpec["sizeOf"]): string {
if (isFold(token)) return "Fold";
if (token === "Call" || token === "C") return "Call";
if (token === "Limp") return "Limp";
if (token === "X") return "Check";
if (isAllIn(token)) return "All-in";
const plo5 = plo5RaiseToBb(token);
if (plo5 !== null) return `Raise ${formatBbExact(plo5)}`;
const plo = token.match(PLO_RAISE_RE);
if (plo) return PLO_RAISE_LABEL[plo[1]] ?? token;
const house = sizeOf?.(token) ?? null;
if (house !== null) return `Raise ${formatBbExact(house)}`;
return token;
}
/** The first seat to act, which is where a walk starts. */
export function rootHistory(spec: Pick<TableSpec, "order">): string {
return spec.order[0];
}
/** A history that ends on an action rather than a seat: the line closed. */
export function isClosed(history: string): boolean {
return history.split("_").length % 2 === 0;
}
/**
* Replay a history on the table. `sizes` maps `"<history>_<token>"` to the
* raise-to the solver reported for that line, which wins over decoding the
* token (a token like `Open` carries no size of its own).
*/
export function parseHistory(history: string, spec: TableSpec, sizes: Record<string, number> = {}): WalkTable {
const { order, stack, seatStacks, bbAnte = 0, antePerSeat = 0, deadAnte = 0, sizeOf } = spec;
const stackOf = (p: string) => seatStacks?.[p] ?? stack;
const seats: Record<string, WalkSeat> = {};
for (const p of order) {
seats[p] = {
pos: p,
action: null,
committed: p === "SB" ? 0.5 : p === "BB" ? 1 : 0,
remaining: stackOf(p),
folded: false,
// Heads-up has no BTN seat: the small blind holds the button.
isDealer: p === "BTN" || (p === "SB" && !order.includes("BTN")),
ante: (p === "BB" ? bbAnte : 0) + antePerSeat,
};
}
const sum = () => order.reduce((s, p) => s + seats[p].committed, 0);
// Seats passed over between two acting seats folded.
const foldBetween = (fromIdx: number, toIdx: number) => {
let cur = (fromIdx + 1) % order.length;
while (cur !== toIdx) {
const s = seats[order[cur]];
if (!s.folded && s.action === null) {
s.folded = true;
s.action = "Fold";
}
cur = (cur + 1) % order.length;
}
};
const tokens = history.split("_");
let prevIdx = -1;
let toCall = 1;
for (let i = 0; i + 1 < tokens.length; i += 2) {
const pos = tokens[i];
const token = tokens[i + 1];
const posIdx = order.indexOf(pos);
if (posIdx === -1) continue;
if (prevIdx >= 0) foldBetween(prevIdx, posIdx);
const seat = seats[pos];
const sized = sizes[tokens.slice(0, i + 2).join("_")];
const house = sizeOf?.(token) ?? plo5RaiseToBb(token);
seat.action = displayAction(token, sizeOf);
const raiseTo = (to: number) => {
const capped = Math.min(stackOf(pos), to);
seat.committed = capped;
seat.raiseTo = capped;
toCall = Math.max(toCall, capped);
};
if (isFold(token)) {
seat.folded = true;
} else if (isCall(token)) {
seat.committed = toCall;
} else if (isAllIn(token)) {
raiseTo(stackOf(pos) - seat.ante);
} else if (sized !== undefined) {
raiseTo(sized);
seat.action = `Raise ${formatBbExact(seat.raiseTo ?? sized)}`;
} else if (house !== null) {
raiseTo(house);
} else if (token.endsWith("%")) {
// A percentage of the pot after the call.
raiseTo(toCall + (parseFloat(token) / 100) * sum());
} else if (PLO_RAISE_RE.test(token)) {
// Pot-limit raise: call, then raise by the pot after the call.
raiseTo(2 * toCall + sum() - seat.committed);
}
prevIdx = posIdx;
}
const closed = tokens.length % 2 === 0;
const actor = closed ? null : tokens[tokens.length - 1] || order[0];
const actorIdx = actor ? order.indexOf(actor) : -1;
if (prevIdx >= 0 && actorIdx !== -1) foldBetween(prevIdx, actorIdx);
for (const p of order) {
seats[p].remaining = Math.max(0, stackOf(p) - seats[p].committed - seats[p].ante);
}
const antes = bbAnte + antePerSeat * order.length + deadAnte;
return {
seats,
pot: sum() + antes,
actor: actor && seats[actor] ? actor : null,
toCall: Math.max(...order.map((p) => seats[p].committed)),
antes,
};
}
/** The seat that acts after `token` is taken at `history`, or null when nobody is left to act. */
export function nextActorAfter(history: string, token: string, spec: TableSpec): string | null {
const { order } = spec;
const { seats } = parseHistory(history, spec);
const tokens = history.split("_");
const actor = tokens[tokens.length - 1];
const actorIdx = order.indexOf(actor);
if (actorIdx === -1) return null;
const live = new Set(order.filter((p) => !seats[p].folded));
if (isFold(token)) live.delete(actor);
for (let i = 1; i <= order.length; i++) {
const cand = order[(actorIdx + i) % order.length];
if (live.has(cand)) return cand;
}
return null;
}
/** `history` with `token` taken: the next seat's decision, or a closed line. */
export function advanceHistory(history: string, token: string, spec: TableSpec): string {
const next = nextActorAfter(history, token, spec);
return next ? `${history}_${token}_${next}` : `${history}_${token}`;
}
/** `history` with the last action undone (and the seat it handed the turn to). */
export function backHistory(history: string): string {
const tokens = history.split("_");
if (tokens.length < 2) return history;
return tokens.slice(0, isClosed(history) ? -1 : -2).join("_");
}
/** `history` rewound to `pos`'s last decision on the line, or null if it hasn't acted. */
export function rewindHistory(history: string, pos: string): string | null {
const tokens = history.split("_");
let last = -1;
for (let i = 0; i < tokens.length; i += 2) if (tokens[i] === pos) last = i;
return last === -1 ? null : tokens.slice(0, last + 1).join("_");
}
/** Who is still in and what the pot holds once a line has closed. */
export interface WalkEnd {
/** "won": folded to one player. "flop": two or more see the flop. */
reason: "won" | "flop";
survivors: string[];
potBb: number;
effStackBb: number;
}
export function endOfWalk(table: WalkTable, order: string[]): WalkEnd {
const survivors = order.filter((p) => !table.seats[p].folded);
const effStackBb = survivors.length ? Math.min(...survivors.map((p) => table.seats[p].remaining)) : 0;
return { reason: survivors.length <= 1 ? "won" : "flop", survivors, potBb: table.pot, effStackBb };
}
/** Display label for an action: the solver's raise-to beats any token spelling. */
export function walkActionLabel(action: WalkAction, stack: number, sizeOf?: TableSpec["sizeOf"]): 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" || isAllIn(action.action)) return `All-in ${formatBbExact(action.sizeBb ?? stack)}`;
if (action.sizeBb != null) return `Raise ${formatBbExact(action.sizeBb)}`;
if (action.kind === "raise") {
if (action.action === "Open") return "Open";
const house = sizeOf?.(action.action) ?? plo5RaiseToBb(action.action);
if (house !== null) return `Raise ${formatBbExact(house)}`;
const plo = action.action.match(PLO_RAISE_RE);
if (plo) return plo[2] ? `${PLO_RAISE_LABEL[plo[1]]} pot (${plo[2]})` : `${PLO_RAISE_LABEL[plo[1]]} pot`;
return `Raise ${action.action}`;
}
return action.action;
}
/** Fold → call → raises by size → all-in, each with its stable color (from `palette`, Classic by default) and label. */
export function colorWalkActions(
actions: WalkAction[],
stack: number,
sizeOf?: TableSpec["sizeOf"],
palette?: ActionPaletteId | null,
): { action: WalkAction; label: string; color: string }[] {
const p = actionPalette(palette);
const sorted = sortActions(actions).sort((a, b) =>
a.kind === b.kind && a.kind === "raise" && a.sizePct == null && b.sizePct == null
? (a.sizeBb ?? 0) - (b.sizeBb ?? 0)
: 0,
);
let raise = 0;
return sorted.map((action) => {
let color: string;
if (action.kind === "fold") color = p.Fold;
else if (action.kind === "call") color = p.Call;
else if (action.kind === "allin") color = p.AllIn;
else color = p.raises[Math.min(raise++, p.raises.length - 1)];
return { action, label: walkActionLabel(action, stack, sizeOf), color };
});
}
/** Each action's share of the range on the 13×13 grid, weighting every class by its combos. */
export function rangeMix(colored: { action: WalkAction; label: string; color: string }[]) {
let rangeCombos = 0;
for (const hand of ALL_HANDS) {
if (colored.some(({ action }) => (action.weights[hand] ?? 0) > 0)) rangeCombos += comboCount(hand);
}
const denom = rangeCombos || 1;
return colored.map(({ action, label, color }) => {
let combos = 0;
for (const hand of ALL_HANDS) {
const w = action.weights[hand] ?? 0;
if (w > 0) combos += w * comboCount(hand);
}
return { label, color, freq: combos / denom, detail: `${combos.toFixed(1)} combos` };
});
}