lib
Trainer logic
The drill trainer's grading, spot parsing, raise sizing and session state, preflop and postflop. The action bars, feedback and history read it.
Installation
$ npx shadcn@latest add https://www.pokerstudy.ai/r/trainer-lib.jsonSource
lib/trainer/grade.ts
import type { NodeAction, NodeResponse } from '@/lib/trainer/types';
export type FeedbackTier = 'perfect' | 'correct' | 'inaccuracy' | 'mistake' | 'blunder';
export const TIER_LABEL: Record<FeedbackTier, string> = {
perfect: 'Perfect',
correct: 'Correct',
inaccuracy: 'Inaccuracy',
mistake: 'Mistake',
blunder: 'Blunder',
};
const FOLD_ACTION = 'Fold';
/**
* EV-loss thresholds in small blinds — the same idea as centipawn loss in chess.
*
* Calibrated against the 100bb NL v2 bundles: folding ATo from UTG costs 0.08sb
* (inaccuracy), folding AQo costs 0.27sb (mistake), folding AKs costs 3.2sb and
* folding AA costs 18.3sb (blunders).
*/
export const EV_LOSS_THRESHOLDS = {
/** Below this the loss is solver noise, not a decision error. */
correct: 0.05,
inaccuracy: 0.25,
mistake: 1.0,
} as const;
/**
* Largest standard error, in big blinds, an estimated EV may carry and still
* score a mistake. HydraSolver estimates about 0.35% of reached EVs; most
* errors are ~0.001bb, but rare multiway all-in lines reach ~100bb. Above this
* the EV is shown as uncertain and the grade falls back to frequency alone.
*/
export const EV_UNCERTAIN_STDERR_BB = 0.5;
export function tierFromEvLoss(evLoss: number): FeedbackTier {
if (evLoss <= 0) return 'perfect';
if (evLoss < EV_LOSS_THRESHOLDS.correct) return 'correct';
if (evLoss < EV_LOSS_THRESHOLDS.inaccuracy) return 'inaccuracy';
if (evLoss < EV_LOSS_THRESHOLDS.mistake) return 'mistake';
return 'blunder';
}
export function totalStrategyMass(strategy: Record<string, number> | undefined): number {
if (!strategy) return 0;
let sum = 0;
for (const v of Object.values(strategy)) sum += v;
return sum;
}
/** Why a grade carries the EV loss it does — drives copy and coverage logging. */
export type GradeBasis =
/** Chosen action is in the solver's strategy for this hand: EV loss is 0 by
* the equilibrium indifference condition. */
| 'equilibrium'
/** Exact: we had an EV for the chosen action (or it was a fold, which is the
* 0 baseline in every exported bundle). */
| 'ev'
/** The solver never takes this action with this hand and the bundles carry no
* EV for unplayed actions, so the magnitude is structural, not measured. */
| 'estimated';
export interface GradeResult {
chosen: string;
tier: FeedbackTier;
freq: number;
strategy: Record<string, number>;
outOfRange: boolean;
/** EV given up vs the solver's best line, in small blinds. 0 when optimal. */
evLoss: number;
/** EV of the chosen action in small blinds. Fold is 0; missing when unpriced. */
chosenEv?: number;
basis: GradeBasis;
/** The chosen action's EV is an estimate too noisy to show or score by
* (`EV_UNCERTAIN_STDERR_BB`). `chosenEv` is then omitted. */
evUncertain?: boolean;
}
/**
* EV of folding when the node stores none. Most nodes record fold EV as 0,
* the baseline the other actions are measured against. A blind facing a
* raise is the exception: its EVs count the blind it posted, so the stored
* fold EV is the blind lost (-2sb in the BB) and is used instead.
*/
const FOLD_EV = 0;
/** True when `hand`'s EV under `action` is an estimate with too large an error to use. */
export function isEvUncertain(action: NodeAction | undefined, hand: string | undefined): boolean {
if (!action || !hand || action.action === FOLD_ACTION) return false;
const stdErr = action.evStdErrs?.[hand];
return stdErr !== undefined && stdErr > EV_UNCERTAIN_STDERR_BB;
}
/**
* EV of `hand` under `action`, or undefined when the solver never plays it or
* the EV is an estimate too noisy to grade by (`isEvUncertain`).
*/
function evOf(action: NodeAction | undefined, hand: string | undefined): number | undefined {
if (!action || !hand) return undefined;
if (action.action === FOLD_ACTION) return action.evs?.[hand] ?? FOLD_EV;
if (isEvUncertain(action, hand)) return undefined;
return action.evs?.[hand];
}
/**
* Best EV available to `hand` at this node.
*
* Only actions the solver actually plays with this hand are candidates. At
* equilibrium those are all EV-equal, so the max is well-defined and stable;
* taking the max rather than the first guards against rounding drift.
*/
function bestEvFor(actions: NodeAction[], hand: string | undefined): number | undefined {
let best: number | undefined;
for (const action of actions) {
const weight = hand ? (action.weights?.[hand] ?? 0) : 0;
if (weight <= 0) continue;
const ev = evOf(action, hand);
if (ev === undefined) continue;
best = best === undefined ? ev : Math.max(best, ev);
}
return best;
}
/** Chips a losing action risks, used only to size an unmeasurable error. */
function isCommitting(action: NodeAction | undefined): boolean {
return action?.kind === 'raise' || action?.kind === 'allin';
}
export function gradeAction(node: NodeResponse, chosenAction: string): GradeResult {
const rawStrategy = node.strategy ?? {};
const mass = totalStrategyMass(rawStrategy);
const outOfRange = mass === 0;
const hand = node.hand;
const actions = node.actions ?? [];
const chosen = actions.find((a) => a.action === chosenAction);
const evUncertain = isEvUncertain(chosen, hand);
const uncertainFlag = evUncertain ? { evUncertain: true } : {};
// Hero's class isn't in any action's weights — the solver folds it outright,
// so folding is optimal and anything else gives up EV we can't measure.
if (outOfRange) {
const synthetic: Record<string, number> = { [FOLD_ACTION]: 1 };
const folded = chosenAction === FOLD_ACTION;
return {
chosen: chosenAction,
tier: folded ? 'perfect' : isCommitting(chosen) ? 'blunder' : 'mistake',
freq: folded ? 1 : 0,
strategy: synthetic,
outOfRange: true,
evLoss: 0,
chosenEv: folded ? FOLD_EV : evOf(chosen, hand),
basis: folded ? 'equilibrium' : 'estimated',
...uncertainFlag,
};
}
// Normalize: solver outputs occasionally sum to ~0.999, which would skew
// boundary cases. Renormalize so freqs sum to exactly 1.
const normalized: Record<string, number> = {};
for (const [k, v] of Object.entries(rawStrategy)) normalized[k] = v / mass;
const freq = normalized[chosenAction] ?? 0;
const chosenEv = evOf(chosen, hand);
const bestEv = bestEvFor(actions, hand);
const base = {
chosen: chosenAction,
freq,
strategy: normalized,
outOfRange: false,
chosenEv,
...uncertainFlag,
};
// The chosen action is part of the solver's mixed strategy for this hand.
// Every action played with positive probability at equilibrium has identical
// EV, so a minority branch costs nothing and must not be marked down.
if (freq > 0) {
return {
...base,
tier: 'perfect',
evLoss: 0,
// Equal EVs at equilibrium would let us show the best line's, but an
// uncertain EV is hidden, not substituted.
chosenEv: evUncertain ? undefined : (chosenEv ?? bestEv),
basis: 'equilibrium',
};
}
if (bestEv !== undefined && chosenEv !== undefined) {
const evLoss = Math.max(0, bestEv - chosenEv);
return { ...base, tier: tierFromEvLoss(evLoss), evLoss, basis: 'ev' };
}
// The bundles only carry EV for actions a hand actually takes, and an EV
// estimate with a large standard error is no measurement either (it is
// dropped by `evOf`), so we cannot measure what this line costs. Per the "never skip — approximate and log
// demand" rule, size it structurally and mark it estimated: committing chips
// on a hand the solver never commits with is the severe case.
return {
...base,
tier: isCommitting(chosen) ? 'blunder' : 'mistake',
evLoss: 0,
basis: 'estimated',
};
}
/** Chosen-action EV in bb. Preflop stores small blinds. */
export function actionEvBb(chosenEv: number | undefined, unit: "sb" | "bb"): number | null {
if (chosenEv === undefined) return null;
const bb = unit === "sb" ? chosenEv / 2 : chosenEv;
return bb === 0 ? 0 : bb;
}
export function sortActionsForDisplay(actions: NodeAction[]): NodeAction[] {
const order: Record<string, number> = { fold: 0, call: 1, raise: 2, allin: 3, other: 4 };
return [...actions].sort((a, b) => {
const ka = order[a.kind] ?? 99;
const kb = order[b.kind] ?? 99;
if (ka !== kb) return ka - kb;
// NL raises sort by % size; PLO raises by betting depth (Open < 3B < 4B).
return (
(a.sizePct ?? a.raiseDepth ?? 0) - (b.sizePct ?? b.raiseDepth ?? 0) ||
(a.sizeCode ?? 0) - (b.sizeCode ?? 0)
);
});
}
/** One action at a node for the hand: how often the solver plays it and what it is worth. */
export interface NodeActionEv {
action: string;
freq: number;
/** EV in small blinds; missing when the node does not price it for this hand. */
ev?: number;
/** What it gives up against the solver's best line, in small blinds. 0 for any action the solver plays. */
evLoss?: number;
}
/**
* Every action at `node` for its hand, in node order. Actions the solver
* plays cost nothing (equilibrium indifference), the same rule `gradeAction`
* grades by; an unplayed one costs its EV gap to the best played line.
*/
export function nodeActionEvs(node: NodeResponse): NodeActionEv[] {
const hand = node.hand;
const actions = node.actions ?? [];
const mass = totalStrategyMass(node.strategy);
const best = bestEvFor(actions, hand);
return actions.map((a) => {
const freq = mass > 0 ? (node.strategy?.[a.action] ?? 0) / mass : 0;
const ev = evOf(a, hand);
const evLoss = freq > 0 ? 0 : best !== undefined && ev !== undefined ? Math.max(0, best - ev) : undefined;
return { action: a.action, freq, ...(ev !== undefined ? { ev } : {}), ...(evLoss !== undefined ? { evLoss } : {}) };
});
}
lib/trainer/parse-history.ts
import type { Game, Position } from '@/lib/trainer/types';
// 6-max preflop play order (CW from UTG). MP isn't present in the dataset.
export const PLAY_ORDER: Position[] = ['UTG', 'HJ', 'CO', 'BTN', 'SB', 'BB'];
/** PLO5 6-max: HUD UTG is this tree's LJ. */
export const PLO5_PLAY_ORDER: Position[] = ['LJ', 'HJ', 'CO', 'BTN', 'SB', 'BB'];
/** PLO6 HU tree: BTN posts the small blind, BB the big blind. */
export const HU_PLAY_ORDER: Position[] = ['BTN', 'BB'];
export function playOrderFor(game: Game): Position[] {
if (game === 'plo6hu') return HU_PLAY_ORDER;
if (game === 'plo5') return PLO5_PLAY_ORDER;
return PLAY_ORDER;
}
/** True when the history only names BTN/BB — the PLO6 HU tree, not 6-max BTN. */
export function isHuHistory(history: string): boolean {
if (/(^|_)(UTG|LJ|HJ|CO|SB)(_|$)/.test(history)) return false;
return /(^|_)(BTN|BB)(_|$)/.test(history);
}
export function isPlo5History(history: string): boolean {
return /(^|_)LJ(_|$)/.test(history);
}
function orderForHistory(history: string): Position[] {
if (isHuHistory(history)) return HU_PLAY_ORDER;
if (isPlo5History(history)) return PLO5_PLAY_ORDER;
return PLAY_ORDER;
}
const PLO6_RAISE_RE = /^Raise-([\d.]+)x$/i;
export interface SeatState {
position: Position;
action: string | null;
committed: number;
remaining: number;
folded: boolean;
isHero: boolean;
isDealer: boolean;
}
export interface ParsedTable {
seats: Record<Position, SeatState>;
pot: number;
hero: Position;
/** Highest amount committed so far — what the hero must match. */
toCall: number;
/** Last full raise increment (min-raise sizing). */
lastRaiseSize: number;
}
// PLO depth-raise tokens: Open (first raise), 3B, 4B, … optionally carrying a
// "-<sizeCode>" suffix. NL raises are always "<pct>%", so the namespaces don't
// collide. A bare token is a pot-size raise (our packs' default); the suffix is
// MonkerSolver's raw size id, present only in mid-stack trees that offer more
// than one raise size at a node.
const PLO_RAISE_RE = /^(Open|3B|4B|5B|6B|7B|8B)(?:-(\d+))?$/;
function displayAction(token: string): string {
if (token === 'Fold') return 'Fold';
if (token === 'Call') return 'Call';
if (token === 'AI' || token === 'AllIn') return 'All-in';
const plo6 = token.match(PLO6_RAISE_RE);
if (plo6) return `Raise ${plo6[1]}bb`;
// Strip the size-code suffix from PLO raises so the chip label stays clean.
const plo = token.match(PLO_RAISE_RE);
if (plo) return plo[1];
return token;
}
export interface RaiseCtx {
/** Highest amount committed so far (the amount to match). */
toCall: number;
/** Sum of all committed chips before this raise (includes the raiser's own). */
potBefore: number;
/** The raiser's own chips already in the pot. */
heroIn: number;
stackBb: number;
/** Size of the last full raise increment (for min-raise sizing). */
lastRaiseSize: number;
}
/**
* Decode a PLO depth-raise token's size into a raise-TO amount in bb.
*
* MonkerSolver size ids (see the `monkersolver-decode` reference):
* • no code → pot-size raise (the packs' default)
* • n > 40000 → (n − 40000)% of pot
* • n > 11 → raise-to of (n − 11) small blinds
* • preset ints → 2=pot 8=2pot 9=¾pot 4=½pot 7=¼pot 3=all-in 5=min 6=bet
*
* A "% of pot" raise is a raise BY that fraction of the pot after the raiser
* calls, so raise-to = toCall + pct·(potBefore + amountToCall).
*/
export function decodePloRaiseTo(sizeCode: number | undefined, ctx: RaiseCtx): number {
const amountToCall = ctx.toCall - ctx.heroIn;
const potAfterCall = ctx.potBefore + amountToCall;
const potPct = (pct: number) => ctx.toCall + (pct / 100) * potAfterCall;
if (sizeCode === undefined) return potPct(100);
if (sizeCode > 40000) return potPct(sizeCode - 40000);
if (sizeCode > 11) return (sizeCode - 11) * 0.5;
switch (sizeCode) {
case 8:
return potPct(200);
case 9:
return potPct(75);
case 4:
return potPct(50);
case 7:
return potPct(25);
case 3:
return ctx.stackBb;
case 5:
return ctx.toCall + ctx.lastRaiseSize;
// 2 = POT, 6 = BET, and any unknown preset fall back to a pot-size raise.
default:
return potPct(100);
}
}
function sumCommitted(seats: Record<Position, SeatState>, order: Position[]): number {
let s = 0;
for (const p of order) s += seats[p]?.committed ?? 0;
return s;
}
export function parseHistory(history: string, stackBb: number): ParsedTable {
const order = orderForHistory(history);
const seats: Record<Position, SeatState> = Object.create(null);
for (const p of order) {
seats[p] = {
position: p,
action: null,
committed: 0,
remaining: stackBb,
folded: false,
isHero: false,
isDealer: p === 'BTN',
};
}
seats.SB.committed = 0.5;
seats.BB.committed = 1;
const tokens = history.split('_');
let prevIdx = -1;
let toCall = 1;
// The BB counts as the standing bet, so a min-raise increment starts at 1bb.
let lastRaiseSize = 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;
}
}
seats[pos].action = displayAction(action);
if (action === 'Fold') {
seats[pos].folded = true;
} else if (action === 'Call') {
seats[pos].committed = toCall;
} else if (action === 'AI' || action === 'AllIn') {
lastRaiseSize = Math.max(0, stackBb - toCall);
seats[pos].committed = stackBb;
toCall = stackBb;
} else if (action === 'Min') {
// Min-raise: match the standing bet plus the last full raise increment
// (opening over the 1bb blind → raise to 2bb).
const raiseTo = toCall + lastRaiseSize;
seats[pos].committed = raiseTo;
toCall = raiseTo;
} else if (action.endsWith('%')) {
const potBefore = sumCommitted(seats, order);
const raiseTo = toCall + (parseFloat(action) / 100) * potBefore;
lastRaiseSize = raiseTo - toCall;
seats[pos].committed = raiseTo;
toCall = raiseTo;
} else {
const plo6 = action.match(PLO6_RAISE_RE);
if (plo6) {
const raiseTo = Math.min(stackBb, Number(plo6[1]));
lastRaiseSize = raiseTo - toCall;
seats[pos].committed = raiseTo;
toCall = raiseTo;
} else {
const plo = action.match(PLO_RAISE_RE);
if (plo) {
const heroIn = seats[pos].committed;
const potBefore = sumCommitted(seats, order);
const sizeCode = plo[2] !== undefined ? Number(plo[2]) : undefined;
const raiseTo = Math.min(
stackBb,
decodePloRaiseTo(sizeCode, { toCall, potBefore, heroIn, stackBb, lastRaiseSize }),
);
lastRaiseSize = raiseTo - toCall;
seats[pos].committed = raiseTo;
toCall = raiseTo;
}
}
}
prevIdx = posIdx;
}
const hero = (tokens[tokens.length - 1] || order[0] || 'UTG') as Position;
const heroIdx = order.indexOf(hero);
if (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, stackBb - seats[p].committed);
}
return { seats, pot: sumCommitted(seats, order), hero, toCall, lastRaiseSize };
}
/**
* Heads-up cash: BTN posts the small blind, BB the big blind, BTN acts first.
* The PLO6 HU tree uses this seating (HUD "SB" is solver BTN).
*/
export function parseHuHistory(history: string, stackBb: number): ParsedTable {
const seats: Record<Position, SeatState> = Object.create(null);
for (const p of PLAY_ORDER) {
seats[p] = {
position: p,
action: null,
committed: 0,
remaining: stackBb,
folded: p !== 'BTN' && p !== 'BB',
isHero: false,
isDealer: p === 'BTN',
};
if (p !== 'BTN' && p !== 'BB') seats[p].action = 'Fold';
}
seats.BTN.committed = 0.5;
seats.BB.committed = 1;
const tokens = history.split('_');
let toCall = 1;
let lastRaiseSize = 1;
for (let i = 0; i + 1 < tokens.length; i += 2) {
const pos = tokens[i] as Position;
const action = tokens[i + 1];
if (pos !== 'BTN' && pos !== 'BB') continue;
seats[pos].action = displayAction(action);
if (action === 'Fold') {
seats[pos].folded = true;
} else if (action === 'Call') {
seats[pos].committed = toCall;
} else if (action === 'AI' || action === 'AllIn') {
lastRaiseSize = Math.max(0, stackBb - toCall);
seats[pos].committed = stackBb;
toCall = stackBb;
} else {
const plo6 = action.match(PLO6_RAISE_RE);
if (plo6) {
const raiseTo = Math.min(stackBb, Number(plo6[1]));
lastRaiseSize = raiseTo - toCall;
seats[pos].committed = raiseTo;
toCall = raiseTo;
}
}
}
const hero = (tokens[tokens.length - 1] || 'BTN') as Position;
if (seats[hero]) seats[hero].isHero = true;
for (const p of PLAY_ORDER) {
seats[p].remaining = Math.max(0, stackBb - seats[p].committed);
}
const pot = seats.BTN.committed + seats.BB.committed;
return { seats, pot, hero, toCall, lastRaiseSize };
}
export function parseSpotHistory(history: string, stackBb: number): ParsedTable {
return isHuHistory(history) ? parseHuHistory(history, stackBb) : parseHistory(history, stackBb);
}
// For a setup chain (per-position actions selected by the user), returns the
// history at the FIRST position with no selected action — i.e., the spot the
// trainer should drill. Empty 6-max chain → "UTG"; empty HU chain → "BTN".
export function studyHistoryFromChain(
chain: { position: Position; action: string }[],
order: Position[] = PLAY_ORDER,
): string {
const parts: string[] = [];
for (let i = 0; i < chain.length; i++) {
parts.push(chain[i].position, chain[i].action);
}
const studyPos = order[chain.length] ?? order[0] ?? 'UTG';
parts.push(studyPos);
return parts.join('_');
}
// Returns the history leading up to the i-th position card in the chain, where
// the i-th position is the actor (not yet committed to an action). Used to
// fetch the available actions for that card. i = 0 → first seat in `order`.
export function historyAtChainIndex(
chain: { position: Position; action: string }[],
index: number,
order: Position[] = PLAY_ORDER,
): string | null {
if (index < 0 || index >= order.length) return null;
const pos = order[index];
const parts: string[] = [];
for (let i = 0; i < index; i++) {
if (i >= chain.length) return null;
parts.push(chain[i].position, chain[i].action);
}
parts.push(pos);
return parts.join('_');
}
// Returns the position that acts after `action` is taken at the current node.
// Returns null if the line would terminate (no remaining live seats).
export function nextActorAfter(history: string, action: string, stackBb: number): Position | null {
const order = orderForHistory(history);
const { seats } = parseHistory(history, stackBb);
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') 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;
}
lib/trainer/size-display.ts
"use client";
import { useSyncExternalStore } from "react";
import * as storage from "./storage";
/**
* How raise sizes render on action buttons: the solver's pot-percentage tokens
* ("BET 111%") or converted raise-to amounts in big blinds ("BET 7"). Percent is
* the default; the toggle lives in the trainer's Settings panel. One tiny
* external store so every action bar re-renders when it flips.
*/
export type SizeDisplay = "percent" | "bb";
const KEY = "trainer.sizeDisplay.v1";
let current: SizeDisplay = "percent";
let hydrated = false;
const listeners = new Set<() => void>();
/** The server snapshot must stay constant across renders, so hydration happens
* on the first client subscribe rather than at module load. */
function hydrate(): void {
if (hydrated) return;
hydrated = true;
if (storage.getItem(KEY) === "bb") {
current = "bb";
listeners.forEach((l) => l());
}
}
export function setSizeDisplay(next: SizeDisplay): void {
if (next === current) return;
current = next;
listeners.forEach((l) => l());
storage.setItem(KEY, next);
}
export function useSizeDisplay(): SizeDisplay {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
hydrate();
return () => {
listeners.delete(cb);
};
},
() => current,
() => "percent" as SizeDisplay,
);
}
lib/trainer/state.ts
import { createContext, type Dispatch } from 'react';
import type { Game, HandClass, NodeResponse, Position } from '@/lib/trainer/types';
import type { Card } from './hand';
import type { GradeResult, FeedbackTier } from './grade';
import type { DepthFilter } from './depth';
export interface ChainEntry {
position: Position;
action: string;
}
export interface TrainerSettings {
game: Game;
stack: number;
depths: DepthFilter[];
skipPureFold: boolean;
chain: ChainEntry[];
}
export const DEFAULT_SETTINGS: TrainerSettings = {
game: 'nl',
stack: 100,
depths: [1, 2, 3, 4],
skipPureFold: false,
chain: [],
};
export interface HistoryEntry {
id: string;
history: string;
hand: HandClass;
/** 2 cards for NL, 4 for PLO, 6 for PLO6 HU. */
cards: Card[];
grade: GradeResult;
timestamp: number;
}
export interface CurrentSpot {
history: string;
hand: HandClass;
/** 2 cards for NL, 4 for PLO, 6 for PLO6 HU. */
cards: Card[];
node: NodeResponse;
}
export interface TrainerState {
settings: TrainerSettings;
status: 'idle' | 'loading' | 'ready' | 'graded' | 'error';
error: string | null;
current: CurrentSpot | null;
pendingGrade: GradeResult | null;
history: HistoryEntry[];
tally: Record<FeedbackTier, number>;
}
const EMPTY_TALLY: Record<FeedbackTier, number> = {
perfect: 0,
correct: 0,
inaccuracy: 0,
mistake: 0,
blunder: 0,
};
export const INITIAL_STATE: TrainerState = {
settings: DEFAULT_SETTINGS,
status: 'idle',
error: null,
current: null,
pendingGrade: null,
history: [],
tally: { ...EMPTY_TALLY },
};
export type TrainerAction =
| { type: 'HYDRATE_SETTINGS'; settings: TrainerSettings }
| { type: 'UPDATE_SETTINGS'; patch: Partial<TrainerSettings> }
| { type: 'LOAD_START' }
| { type: 'LOAD_SPOT'; spot: CurrentSpot }
| { type: 'LOAD_ERROR'; error: string }
| { type: 'GRADE_ACTION'; grade: GradeResult }
| { type: 'NEXT_SPOT' }
| { type: 'RESET_SESSION' };
function makeId(): string {
return `h_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
}
export function trainerReducer(state: TrainerState, action: TrainerAction): TrainerState {
switch (action.type) {
case 'HYDRATE_SETTINGS':
return { ...state, settings: { ...DEFAULT_SETTINGS, ...action.settings } };
case 'UPDATE_SETTINGS':
return {
...state,
settings: { ...DEFAULT_SETTINGS, ...state.settings, ...action.patch },
};
case 'LOAD_START':
return { ...state, status: 'loading', error: null, pendingGrade: null };
case 'LOAD_SPOT':
return { ...state, status: 'ready', current: action.spot, pendingGrade: null };
case 'LOAD_ERROR':
return { ...state, status: 'error', error: action.error };
case 'GRADE_ACTION': {
if (!state.current) return state;
const entry: HistoryEntry = {
id: makeId(),
history: state.current.history,
hand: state.current.hand,
cards: state.current.cards,
grade: action.grade,
timestamp: Date.now(),
};
return {
...state,
status: 'graded',
pendingGrade: action.grade,
history: [entry, ...state.history].slice(0, 100),
tally: { ...state.tally, [action.grade.tier]: state.tally[action.grade.tier] + 1 },
};
}
case 'NEXT_SPOT':
return { ...state, status: 'idle', pendingGrade: null, current: null };
case 'RESET_SESSION':
return {
...state,
status: 'idle',
current: null,
pendingGrade: null,
history: [],
tally: { ...EMPTY_TALLY },
};
default:
return state;
}
}
export const TrainerStateContext = createContext<TrainerState | null>(null);
export const TrainerDispatchContext = createContext<Dispatch<TrainerAction> | null>(null);
export function computeAccuracy(tally: Record<FeedbackTier, number>): {
hands: number;
correctPct: number;
} {
const hands = tally.perfect + tally.correct + tally.inaccuracy + tally.mistake + tally.blunder;
if (hands === 0) return { hands: 0, correctPct: 0 };
const good = tally.perfect + tally.correct;
return { hands, correctPct: Math.round((good / hands) * 100) };
}
lib/trainer/postflop/types.ts
// Wire types for the postflop drill trainer (`/api/trainer/postflop/*`).
// Mirror of `web/src/lib/drills/types.ts` — keep the two in sync.
//
// `DrillActionOption` is the server type, not a copy. A second interface is
// what let the client keep reading `ev` after the wire had moved to `evLoss`,
// so every 0% action graded as an unmeasured blunder.
import type { DrillActionOption as WireAction } from '@/lib/drills/types';
export type DrillActionOption = WireAction;
export type DrillVariant = 'nlhe' | 'plo';
export type DrillSeat = 'oop' | 'ip' | 'mp';
export type DrillStreet = 'flop' | 'turn' | 'river';
export type DrillActionKind = 'fold' | 'check' | 'call' | 'raise' | 'allin';
export interface DrillStep {
actor: DrillSeat | 'deal';
label: string;
}
/** One hero decision of the full-hand preflop script (see web
* drills/preflop-script.ts): the NL preflop node the hero acts at, and the
* action token that continues the pack's line. */
export interface PreflopScriptEntry {
history: string;
action: string;
}
/** One seat of the 6-max table render. */
export interface DrillTableSeat {
pos: string;
stackBb: number;
committed: number;
folded: boolean;
seat: DrillSeat | null;
dealer: boolean;
action: string | null;
}
export interface DrillSpotState {
spotId: string;
packId: string;
/** Display title of the pack being drilled. */
packTitle: string;
variant: DrillVariant;
stackBb: number;
preflopLine: string;
board: string[];
street: DrillStreet;
heroSeat: DrillSeat;
heroPos: string;
villainPos: string;
heroCards: string[];
pot: number;
effStack: number;
steps: DrillStep[];
seats: DrillTableSeat[];
/** Hero's preflop decisions for full-hand mode (may be empty). */
preflopScript: PreflopScriptEntry[];
/** The whole preflop line as display steps for the continuous strip. */
preflopLineSteps: { pos: string; label: string }[];
decision: { actions: DrillActionOption[] };
}
export interface DrillTerminal {
steps: DrillStep[];
board: string[];
note: string;
}
export interface DrillSpotResponse {
spot: DrillSpotState;
}
export type DrillContinueResponse =
| { done: false; spot: DrillSpotState }
| { done: true; terminal: DrillTerminal };
export interface DrillPackInfo {
id: string;
variant: DrillVariant;
title: string;
family: string;
stackBb: number;
preflopLine: string;
boards: number;
oopPos: string;
ipPos: string;
mpPos?: string;
line: { pos: string; label: string }[];
/** Per-seat hero scripts — lets PLO full hand run its preflop phase while
* the PLO postflop content is still generating. */
scripts: { oop: PreflopScriptEntry[]; ip: PreflopScriptEntry[] };
}
lib/trainer/postflop/state.ts
import type { NodeResponse } from '@/lib/trainer/types';
import type { FeedbackTier, GradeResult } from '@/lib/trainer/grade';
import type { PostflopGradeResult } from './grade';
import type { DrillSeat, DrillSpotState, DrillTerminal, PreflopScriptEntry } from './types';
export interface PostflopSettings {
/** undefined → server default pack. */
packId?: string;
/** 'any' → server picks a random seat per hand. */
heroSeat: DrillSeat | 'any';
}
export const DEFAULT_POSTFLOP_SETTINGS: PostflopSettings = {
heroSeat: 'any',
};
/**
* The postflop drill loop differs from preflop in one structural way: a hand
* is a *sequence* of graded decisions. After each grade the client continues
* the same hand (`continuing`) until the server reports a terminal, which is
* shown as a hand summary before the next deal.
*/
/**
* Full-hand mode's preflop phase: the hero replays the pack's preflop line
* decision by decision (graded against the NL preflop dataset) before the
* postflop spot begins. Off-script choices end the hand.
*/
export interface PreflopPhase {
entries: PreflopScriptEntry[];
index: number;
node: NodeResponse | null;
}
/** One graded hero decision within the current hand, for the end-of-hand
* summary. `evLoss` is in bb (preflop grades are converted from sb). */
export interface HandDecision {
phase: 'preflop' | 'flop' | 'turn' | 'river';
label: string;
tier: FeedbackTier;
freq: number;
evLoss: number;
/** Chosen-action EV in bb. */
chosenEv?: number;
basis: 'equilibrium' | 'ev' | 'estimated';
/** What GTO actually does here: the non-zero strategy mix, biggest first —
* the summary shows this so a mistake names the right play. */
gtoMix: { label: string; freq: number }[];
}
/** Top of a normalized strategy map, for the summary's "GTO:" line. */
export function gtoMixOf(strategy: Record<string, number>, max = 3): { label: string; freq: number }[] {
return Object.entries(strategy)
.filter(([, f]) => f >= 0.005)
.sort((a, b) => b[1] - a[1])
.slice(0, max)
.map(([label, freq]) => ({ label, freq }));
}
/** One finished hand, kept for the session history drawer. */
export interface PostflopHandLogEntry {
id: string;
heroCards: string[];
/** Board as far as it ran. */
board: string[];
/** Where the hand ended, in the server's words. */
note: string;
decisions: HandDecision[];
}
export interface PostflopState {
settings: PostflopSettings;
status:
| 'idle'
| 'loading'
| 'preflop-ready'
| 'preflop-graded'
| 'ready'
| 'graded'
| 'continuing'
| 'terminal'
| 'error';
error: string | null;
spot: DrillSpotState | null;
preflop: PreflopPhase | null;
pendingGrade: PostflopGradeResult | null;
pendingPreflopGrade: GradeResult | null;
terminal: DrillTerminal | null;
/** Hero decisions of the CURRENT hand (kept through the terminal summary). */
handDecisions: HandDecision[];
/** Per-decision tally for the session strip (preflop + postflop). */
tally: Record<FeedbackTier, number>;
/** Completed hands this session. */
handCount: number;
/** Finished hands, newest first — the history drawer's source. */
log: PostflopHandLogEntry[];
}
const EMPTY_TALLY: Record<FeedbackTier, number> = {
perfect: 0,
correct: 0,
inaccuracy: 0,
mistake: 0,
blunder: 0,
};
export const INITIAL_POSTFLOP_STATE: PostflopState = {
settings: DEFAULT_POSTFLOP_SETTINGS,
status: 'idle',
error: null,
spot: null,
preflop: null,
pendingGrade: null,
pendingPreflopGrade: null,
terminal: null,
handDecisions: [],
tally: { ...EMPTY_TALLY },
handCount: 0,
log: [],
};
export type PostflopAction =
| { type: 'UPDATE_SETTINGS'; patch: Partial<PostflopSettings> }
| { type: 'LOAD_START' }
| { type: 'LOAD_SPOT'; spot: DrillSpotState }
| { type: 'LOAD_ERROR'; error: string }
| { type: 'PREFLOP_BEGIN'; entries: PreflopScriptEntry[] }
| { type: 'PREFLOP_NODE'; node: NodeResponse }
| { type: 'PREFLOP_GRADE'; grade: GradeResult }
| { type: 'PREFLOP_NEXT' }
| { type: 'PREFLOP_DONE' }
| { type: 'GRADE'; grade: PostflopGradeResult; street: 'flop' | 'turn' | 'river' }
| { type: 'CONTINUE_START' }
| { type: 'TERMINAL'; terminal: DrillTerminal }
| { type: 'RESET_SESSION' };
export function postflopReducer(state: PostflopState, action: PostflopAction): PostflopState {
switch (action.type) {
case 'UPDATE_SETTINGS':
return { ...state, settings: { ...state.settings, ...action.patch } };
case 'LOAD_START':
return {
...state,
status: 'loading',
error: null,
spot: null,
preflop: null,
pendingGrade: null,
pendingPreflopGrade: null,
terminal: null,
handDecisions: [],
};
case 'LOAD_SPOT':
return {
...state,
status: 'ready',
spot: action.spot,
pendingGrade: null,
terminal: null,
};
case 'LOAD_ERROR':
return { ...state, status: 'error', error: action.error };
case 'PREFLOP_BEGIN':
return {
...state,
status: 'loading',
preflop: { entries: action.entries, index: 0, node: null },
};
case 'PREFLOP_NODE':
if (!state.preflop) return state;
return {
...state,
status: 'preflop-ready',
preflop: { ...state.preflop, node: action.node },
pendingPreflopGrade: null,
};
case 'PREFLOP_GRADE':
if (!state.preflop) return state;
return {
...state,
status: 'preflop-graded',
pendingPreflopGrade: action.grade,
handDecisions: [
...state.handDecisions,
{
phase: 'preflop',
label: action.grade.chosen,
tier: action.grade.tier,
freq: action.grade.freq,
// Preflop grades measure EV in small blinds; the summary is bb.
evLoss: action.grade.evLoss / 2,
chosenEv:
action.grade.chosenEv === undefined ? undefined : action.grade.chosenEv / 2,
basis: action.grade.basis,
gtoMix: gtoMixOf(action.grade.strategy),
},
],
tally: { ...state.tally, [action.grade.tier]: state.tally[action.grade.tier] + 1 },
};
case 'PREFLOP_NEXT':
if (!state.preflop) return state;
return {
...state,
status: 'loading',
preflop: { entries: state.preflop.entries, index: state.preflop.index + 1, node: null },
pendingPreflopGrade: null,
};
case 'PREFLOP_DONE':
return { ...state, status: 'ready', preflop: null, pendingPreflopGrade: null };
case 'GRADE':
if (!state.spot) return state;
return {
...state,
status: 'graded',
pendingGrade: action.grade,
handDecisions: [
...state.handDecisions,
{
phase: action.street,
label: action.grade.chosen,
tier: action.grade.tier,
freq: action.grade.freq,
evLoss: action.grade.evLoss,
chosenEv: action.grade.chosenEv,
basis: action.grade.basis,
gtoMix: gtoMixOf(action.grade.strategy),
},
],
tally: { ...state.tally, [action.grade.tier]: state.tally[action.grade.tier] + 1 },
};
case 'CONTINUE_START':
return { ...state, status: 'continuing', pendingGrade: null };
case 'TERMINAL':
return {
...state,
status: 'terminal',
terminal: action.terminal,
preflop: null,
pendingGrade: null,
pendingPreflopGrade: null,
handCount: state.handCount + 1,
// Newest first, capped: this is a session log, not an archive — the
// counts of record go to the DB via /api/trainer/*/results.
log: [
{
id: `h${state.handCount + 1}`,
heroCards: state.spot?.heroCards ?? [],
board: action.terminal.board.length ? action.terminal.board : (state.spot?.board ?? []),
note: action.terminal.note,
decisions: state.handDecisions,
},
...state.log,
].slice(0, 100),
};
case 'RESET_SESSION':
return {
...INITIAL_POSTFLOP_STATE,
settings: state.settings,
};
default:
return state;
}
}
lib/trainer/postflop/grade.ts
/** Trainer entry. The implementation lives in `@/lib/postflop/grade`, which Play calls too. */
export {
EV_LOSS_THRESHOLDS,
gradePostflopAction,
tierFromEvLoss,
type FeedbackTier,
type GradeBasis,
type PostflopGradeResult,
} from "@/lib/postflop/grade";
lib/postflop/grade.ts
import type { DrillActionOption } from "@/lib/drills/types";
import {
EV_LOSS_THRESHOLDS,
tierFromEvLoss,
type FeedbackTier,
type GradeBasis,
} from "@/lib/trainer/grade";
/**
* Postflop evaluation. Play and the trainer both call this: a dealt flop is
* resolved to a solved combo first, then this function turns that combo's
* frequencies and ev_loss into the tier.
*
* - `equilibrium`: the chosen action has freq > 0 for the hero combo. All
* positively-weighted actions at equilibrium have identical EV, so a
* minority branch costs nothing and must not be marked down.
* - `ev`: the pack's per-action EV loss, already the bb this combo gives up
* versus the best action the solve plays.
* - `estimated`: the solver never takes this action with this combo and the
* pack stored no EV loss for it; size the error structurally (committing
* chips is the severe case).
*
* EV loss is in bb. The preflop grader's thresholds are in small blinds; the
* numbers are shared until those are re-calibrated.
*/
export { EV_LOSS_THRESHOLDS, tierFromEvLoss };
export type { FeedbackTier, GradeBasis };
export interface PostflopGradeResult {
chosen: string;
tier: FeedbackTier;
freq: number;
/** Normalized hero-combo strategy over action labels. */
strategy: Record<string, number>;
/** True when the hero combo has no strategy mass at this node. */
outOfRange: boolean;
/** EV given up in bb; 0 for frequency-based grades. */
evLoss: number;
/** EV of the chosen action in bb, when the wire carries it. */
chosenEv?: number;
basis: GradeBasis;
}
function isCommitting(kind: DrillActionOption["kind"] | undefined): boolean {
return kind === "raise" || kind === "allin";
}
function isFree(kind: DrillActionOption["kind"] | undefined): boolean {
return kind === "fold" || kind === "check";
}
export function gradePostflopAction(
actions: DrillActionOption[],
chosenLabel: string,
): PostflopGradeResult {
const chosen = actions.find((a) => a.label === chosenLabel);
let mass = 0;
for (const a of actions) mass += a.freq;
const outOfRange = mass === 0;
// No strategy mass. A measured per-action loss still grades the action:
// a 0% bet with an ev_loss is that loss, not an unmeasured blunder.
// With no measurement, giving up is fine and committing chips is not.
if (outOfRange) {
if (chosen?.evLoss != null) {
const evLoss = Math.max(0, chosen.evLoss);
return {
chosen: chosenLabel,
tier: tierFromEvLoss(evLoss),
freq: 0,
strategy: {},
outOfRange: false,
evLoss,
basis: "ev",
};
}
const free = isFree(chosen?.kind);
return {
chosen: chosenLabel,
tier: free ? "perfect" : isCommitting(chosen?.kind) ? "blunder" : "mistake",
freq: 0,
strategy: {},
outOfRange: true,
evLoss: 0,
basis: free ? "equilibrium" : "estimated",
};
}
const strategy: Record<string, number> = {};
for (const a of actions) strategy[a.label] = a.freq / mass;
const freq = strategy[chosenLabel] ?? 0;
const base = { chosen: chosenLabel, freq, strategy, outOfRange: false };
if (freq > 0) {
return { ...base, tier: "perfect", evLoss: 0, basis: "equilibrium" };
}
// The pack already stores what this action gives up against the best line
// the solve plays. A 0% all-in still has that number; it is not unmeasured.
if (chosen?.evLoss != null) {
const evLoss = Math.max(0, chosen.evLoss);
return { ...base, tier: tierFromEvLoss(evLoss), evLoss, basis: "ev" };
}
return {
...base,
tier: isCommitting(chosen?.kind) ? "blunder" : "mistake",
evLoss: 0,
basis: "estimated",
};
}
lib/drills/types.ts
// Shared types for the postflop drill trainer (`/api/trainer/postflop/*`).
// Client-safe: mirrored by the mobile app (`mobile/src/lib/postflop/types.ts`)
// — keep the two in sync when the wire shape changes.
//
// Design: the client never sees a solved bundle. Every response carries only
// what one decision needs — the hero combo's own strategy row — so payloads
// stay tiny for NLHE and (later) PLO alike. See docs/POSTFLOP_TRAINER_PLAN.md.
export type DrillVariant = "nlhe" | "plo";
/** Postflop seat. OOP acts first; `mp` is the middle seat in a 3-way pot. */
export type DrillSeat = "oop" | "ip" | "mp";
export type DrillStreet = "flop" | "turn" | "river";
export type DrillActionKind = "fold" | "check" | "call" | "raise" | "allin";
/** One legal action at the hero's decision node. */
export interface DrillActionOption {
/**
* The engine's path segment for this action ("Bet 5.8", "Check") — unique at
* the node, used both as the display key and as the `action` a continue
* request names.
*/
label: string;
/** Chips this action adds beyond the current pot, in bb. 0 for check/fold. */
add: number;
kind: DrillActionKind;
/** The hero combo's equilibrium frequency for this action (0..1). */
freq: number;
/**
* Chips the hero combo gives up by taking this action instead of the best
* action the solve actually plays, in bb. Never negative; the best
* played action is 0. This is the number shown as "EV lost".
*
* Null on packs solved before per-action EV existed; grading falls back
* to frequency + structural tiers there.
*/
evLoss: number | null;
}
/** One step of the line played so far, for the client's action strip. */
export interface DrillStep {
/** Who moved: a seat, or "deal" for a runout card. */
actor: DrillSeat | "deal";
/** "Bet 5.8" / "Check" / "Th" (for deals). */
label: string;
}
/** One hero decision of the full-hand preflop script: the NL preflop dataset
* node the hero acts at, and the action token that continues the pack's
* line (any other graded choice ends the hand — the postflop solve only
* covers this line). */
export interface PreflopScriptEntry {
history: string;
action: string;
}
/** One seat of the 6-max table render (client stays a pure renderer). */
export interface DrillTableSeat {
/** Table position: UTG | HJ | CO | BTN | SB | BB. */
pos: string;
/** Chips behind, in bb (after blinds/preflop/postflop contributions). */
stackBb: number;
/** Chips in front this street (dead blinds for folded posters), in bb. */
committed: number;
folded: boolean;
/** Which postflop player sits here; null for folded preflop seats. */
seat: DrillSeat | null;
dealer: boolean;
/** The seat's most recent action this street, e.g. "Bet 5.8". */
action: string | null;
}
/** The drill state at a hero decision. */
export interface DrillSpotState {
/** Opaque replay token; echo it back to /continue. */
spotId: string;
packId: string;
/** The pack's display title — how a client names what it's drilling
* ("3-bet pot · BTN vs EP · 100bb", or your own solve's title). */
packTitle: string;
variant: DrillVariant;
stackBb: number;
/** Human line, e.g. "EP opens, BTN 3-bets, EP calls". */
preflopLine: string;
/** Board cards visible at this decision (start board + dealt runouts). */
board: string[];
street: DrillStreet;
heroSeat: DrillSeat;
/** Position labels, e.g. "BTN" / "BB". */
heroPos: string;
villainPos: string;
/** Hero's hole cards, e.g. ["Ah","Kd"] (4 entries for PLO later). */
heroCards: string[];
/** Total pot before hero acts, in bb. */
pot: number;
/** Chips behind, in bb. */
effStack: number;
/** Everything that happened since the spot started (villain moves + deals). */
steps: DrillStep[];
/** All six seats in table order (UTG first), for the oval render. */
seats: DrillTableSeat[];
/** The hero's preflop decisions for full-hand mode (empty when the script
* couldn't be resolved — clients skip straight to the flop). */
preflopScript: PreflopScriptEntry[];
/** The whole preflop line as display steps for the continuous action
* strip, e.g. [{pos:"UTG",label:"Fold"},…,{pos:"BB",label:"Call"}]. */
preflopLineSteps: { pos: string; label: string }[];
decision: {
actions: DrillActionOption[];
/** Hero combo's equilibrium EV at this decision, in bb from hero's
* perspective — null for packs whose bundles predate node EV. */
heroEv: number | null;
};
}
/** Terminal summary when a hand ends (fold, showdown, or dump horizon). */
export interface DrillTerminal {
steps: DrillStep[];
board: string[];
note: string;
}
export interface DrillSpotResponse {
spot: DrillSpotState;
}
export type DrillContinueResponse =
| { done: false; spot: DrillSpotState }
| { done: true; terminal: DrillTerminal };
/** Catalog entry for the pack picker. */
export interface DrillPackInfo {
id: string;
variant: DrillVariant;
title: string;
/** Spot family bucket, e.g. "srp" | "3bet". */
family: string;
stackBb: number;
preflopLine: string;
/** Number of drillable boards currently available. */
boards: number;
/** How deep the solved content goes: "full-hand" plays to showdown.
* "flop-turn" is a separate drill pack whose solve stops earlier; it is
* dealt only when that pack is opened, never as a full-hand continuation. */
depth: "full-hand" | "flop-turn";
/** OOP / IP seat positions (UTG/HJ/CO/BTN/SB/BB). */
oopPos: string;
ipPos: string;
mpPos?: string;
/**
* Flop bet menu for this pack. The unsuffixed NLHE trees bet 75% of the pot
* on every street, plus all-in. The `-flop33` trees are a separate blob and
* are not these rows.
*/
betSizing?: string;
/** The exact preflop line this pack solves, as position/action steps —
* free-play clients match their walked line against this token-for-token
* to decide whether the flop is covered. */
line: { pos: string; label: string }[];
/** Per-seat hero scripts (node histories + continuing action) — lets a
* client run the preflop phase without a spot (PLO full hand, whose
* postflop content is still generating). */
scripts: { oop: PreflopScriptEntry[]; ip: PreflopScriptEntry[] };
}
/** Map an engine action label to a kind (drives button colors + grading). */
export function kindForLabel(label: string): DrillActionKind {
const head = label.split(" ")[0];
switch (head) {
case "Fold":
return "fold";
case "Check":
return "check";
case "Call":
return "call";
case "AllIn":
return "allin";
default:
// "Bet x" / "Raise x"
return "raise";
}
}