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