SLOPSHOPPER

poker

No-Limit Texas Hold'em against styled bots in a Claude Code pane (/poker)

newpaneguardcommandtoastprompt
v0.2.0MITupdated 2026-10-07LyInfi/claude-code-mods/mods/poker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · poker
│ ┃ 德州扑克 ✕ › fix the failing auth test and add an audit log c╭────────────────╮ │ ┃ ♠ 德州扑克 · 余额 10,000 · 手数 0 · 盈亏 0 │ poker │ │ ┃ ⏺ Read(src/auth.ts) │ ✓ Claude 已完成本轮 │ │ ┃ ✓ Claude 已完成本轮 ⎿ Read 6 lines ╰────────────────╯ │ ┃ ██████╗ ██████╗ ██╗ ██╗███████╗█ ⏺ Update(src/auth.ts) │ ┃ ██╔══██╗██╔═══██╗██║ ██╔╝██╔════╝█ ⎿ Added 2 lines, removed 1 line │ ┃ ██████╔╝██║ ██║█████╔╝ █████╗ █ ⏺ Bash(bun test) │ ┃ ██╔═══╝ ██║ ██║██╔═██╗ ██╔══╝ █ ⎿ 3 pass, 1 fail │ ┃ ██║ ╚██████╔╝██║ ██╗███████╗█ │ ┃ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ♠ ♥ ♦ ♣ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ 5/10 · 买入 1,000 · 余额 10,0 › /poker │ ┃ ⎿ poker: 德州扑克 │ ┃ 选择牌桌入座 │ ┃ │ ┃ 9: 9人桌 6: 6人桌 │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 德州扑克
♠ 德州扑克 · 余额 10,000 · 手数 0 · 盈亏 0 s: 设置 ▾ ✓ Claude 已完成本轮 ██████╗ ██████╗ ██╗ ██╗███████╗██████╗ ██╔══██╗██╔═══██╗██║ ██╔╝██╔════╝██╔══██╗ ██████╔╝██║ ██║█████╔╝ █████╗ ██████╔╝ ██╔═══╝ ██║ ██║██╔═██╗ ██╔══╝ ██╔══██╗ ██║ ╚██████╔╝██║ ██╗███████╗██║ ██║ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ♠ ♥ ♦ ♣ 5/10 · 买入 1,000 · 余额 10,000 选择牌桌入座 9: 9人桌 6: 6人桌 2: 单挑
README

Claude Code Mods

Mods for Claude Code: things that live inside the TUI, written as function-hook plugins.

中文说明

ModWhat it is
pokerNo-Limit Texas Hold'em against styled bots at a 9-max, 6-max or heads-up table, in a pane beside your session.

Install

Inside Claude Code:

/plugin marketplace add LyInfi/claude-code-mods
/plugin install poker@lyinfi-mods

Then type /poker.

Compatibility. These mods use Claude Code's function-hooks plugin API, which is in early access and may change between releases. They are tested on Claude Code 2.1.292 (CI pins that build); if a mod stops loading after an update, please open an issue.

poker

A No-Limit Hold'em cash game at 5/10, drawn in box characters: each seat in its own ring, bets and the dealer button on the felt, the board in the middle. Pick a 9-max, 6-max or heads-up table from the lobby.

  • Bots with styles. Every other seat holds a bot with a hidden style: tight-aggressive, loose-aggressive, calling station or nit. Preflop they play ranges sized to the style's VPIP/PFR, looser the fewer the seats; after the flop they weigh Monte Carlo equity against the pot odds. Busted bots leave and fresh ones sit down.
  • Full rules. Side pots, short all-ins that don't reopen the betting, split pots with the odd chip, heads-up blinds.
  • Plays around your work. Claude asking for permission or a question pauses the dealing and shows an alert; finishing a turn shows a short note.
  • Bankroll that persists. 10,000 to start, 1,000 buy-ins, stats across sessions, /poker topup when you're broke.
  • Chinese and English, compact (A♠) or boxed card faces, amounts in chips or big blinds, a scrollable action log.

Keys (while the pane has focus)

KeyAction
9 / 6 / 2sit down at a 9-max / 6-max / heads-up table (in the lobby)
f / c / r / afold / check or call / min raise / all in
1–4raise 2.5x the bet / ½ pot / pot / all in; or type an amount and press Enter (in big blinds while they are shown, or with a bb suffix)
ndeal the next hand now (it deals itself after 3 seconds)
b / qrebuy / leave the table
k / jscroll the action log up / down (the mouse wheel works too)
sopen or close the settings row
l / v / uin settings: switch language / card style / chips or big blinds
Escback to the prompt

Commands: /poker, /poker stats, /poker lang, /poker cards, /poker topup.

Develop

claude --plugin-dir mods/poker      # load the working copy
claude plugin validate mods/poker   # what the engine would refuse
claude plugin test mods/poker       # the test suite

The rules engine (mods/poker/engine) is plain TypeScript with no UI; bots plug in through one BotPolicy function (mods/poker/bots). Domain terms are in CONTEXT.md.

License

MIT


中文

在 Claude Code 终端里运行的 mod 合集,基于 Claude Code 的函数钩子插件接口编写。

安装

在 Claude Code 里依次执行:

/plugin marketplace add LyInfi/claude-code-mods
/plugin install poker@lyinfi-mods

然后输入 /poker 打开牌桌。

兼容性:这些 mod 使用的函数钩子接口目前处于早期阶段,不同版本之间可能有变动。目前在 Claude Code 2.1.292 上测试(CI 固定使用这个版本);如果升级后无法加载,欢迎提 issue。

poker:德州扑克

无限注现金局,盲注 5/10,牌桌用框线字符绘制。在大厅里可以选择 9 人桌、6 人桌或单挑。

  • 分风格的电脑对手:除你之外的每个座位都是 Bot,各自随机带一种隐藏风格(紧凶、松凶、跟注站、紧弱),需要你从它们的打法里自己读出来。翻牌前按各自风格的 VPIP / PFR 划定起手牌范围,桌上人越少打得越松,翻牌后用蒙特卡洛模拟估算胜率,再和底池赔率比较来决策。输光的 Bot 会离桌,换新对手入座。
  • 完整规则:边池、不足额全下不重开加注、平分底池的零头筹码、单挑时的盲注规则都已实现。
  • 不打扰工作:Claude 请求授权或向你提问时,会暂停发牌并在牌桌上提醒;Claude 完成一轮时也会轻提示。
  • 筹码跨会话保存:初始余额 10,000,每次买入 1,000,战绩跨会话累计;输光后可以用 /poker topup 领取筹码。
  • 其他:中英文界面可切换,牌面有紧凑(A♠)和方框两种样式,金额可以切换成按大盲数(BB)显示,行动记录可以上下滚动。

快捷键见上方英文部分的表格。

Source 13 files
hooks/register.tsx 282 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Alert, Game, Profile } from '../types'
5import { styledBot } from '../bots/styled'
6import { deal, leave, newProfile, rebuyStack, recover, sitDown, step, topUp, wager } from '../engine/game'
7import { legalActions, type Action } from '../engine/hand'
8import { cryptoRng } from '../engine/table'
9import { tableView, chips, type Intents } from '../ui/view'
10import { logMaxTop } from '../ui/art'
11import { t } from '../ui/i18n'
12
13const PANE = 'poker'
14const STORE_KEY = 'profile'
15/** Pause between Bot Actions, so each one can be seen. */
16const BOT_DELAY_MS = 600
17const DONE_ALERT_MS = 8000
18/** How long a settled Hand stays on the table before the next is dealt. */
19export const NEXT_HAND_MS = 3000
20
21const GAME = { plugin: 'poker', key: 'game' } as const
22const ALERT = { plugin: 'poker', key: 'alert' } as const
23const LOG_SCROLL = { plugin: 'poker', key: 'logScroll' } as const
24const SETTINGS = { plugin: 'poker', key: 'settings' } as const
25const game = atom(GAME, { table: null, profile: newProfile(), log: [] } as Game)
26const alert = atom(ALERT, null as Alert | null)
27const logScroll = atom(LOG_SCROLL, -1)
28const settingsOpen = atom(SETTINGS, false)
29
30/**
31 * Scrolls the action log by rows, positive toward older lines. Scrolling up
32 * pins the window where it is; reaching the newest row follows the Hand again.
33 */
34const scrollLog = ($: EngineInterface, rows: number) =>
35  update($, logScroll, (top) => {
36    const from = top < 0 ? logMaxTop : top
37    const to = Math.max(0, from - rows)
38    return to >= logMaxTop ? -1 : to
39  })
40
41let botTimer: Timer | undefined
42let dealTimer: Timer | undefined
43let doneTimer: Timer | undefined
44
45/** Changes the Game, persists the Profile when it changed, and lets the Bots act. */
46async function change($: EngineInterface, fn: (g: Game) => Game) {
47  let before: Profile | undefined
48  let logLength = 0
49  let error: unknown
50  const after = await update($, game, (g) => {
51    before = g.profile
52    logLength = g.log.length
53    try {
54      return fn(g)
55    } catch (e) {
56      error = e
57      return g
58    }
59  })
60  if (error) $.ui.toast(String(error instanceof Error ? error.message : error))
61  if (after.profile !== before) await $.store.set(STORE_KEY, after.profile)
62  // a new Hand starts its log from the top: follow it again
63  if (after.log.length < logLength) await $.state.set(LOG_SCROLL, -1)
64  scheduleBots($, after)
65  scheduleDeal($, after)
66}
67
68/**
69 * Deals the next Hand a while after one settles, unless the Player is out of
70 * chips or Claude is waiting on them; then it looks again shortly.
71 */
72function scheduleDeal($: EngineInterface, g: Game) {
73  const table = g.table
74  if (dealTimer || !table?.hand?.result || table.seats[table.playerSeat]!.stack === 0) return
75  const settled = g.profile.hands
76  dealTimer = $.clock.after(NEXT_HAND_MS, () => {
77    dealTimer = undefined
78    void (async () => {
79      const waiting = await read($, alert)
80      if (waiting === 'permission' || waiting === 'question') return scheduleDeal($, await read($, game))
81      // only the Hand this timer was set for: a press of `n` may have dealt already
82      await change($, (g) => (g.table?.hand?.result && g.profile.hands === settled ? deal(g, cryptoRng) : g))
83    })()
84  })
85}
86
87/** Plays the next Bot Action after a pause while a Bot is to act. */
88function scheduleBots($: EngineInterface, g: Game) {
89  const hand = g.table?.hand
90  if (botTimer || !hand || hand.toAct === null || hand.toAct === g.table!.playerSeat) return
91  botTimer = $.clock.after(BOT_DELAY_MS, () => {
92    botTimer = undefined
93    void change($, (g) => {
94      const table = g.table
95      const seat = table?.hand?.toAct
96      if (!table || seat === null || seat === undefined) return g
97      const bot = table.seats[seat]
98      if (bot?.kind !== 'bot') return g
99      return step(g, styledBot(table.hand!, seat, bot.style, cryptoRng))
100    })
101  })
102}
103
104/** The Player's Action from a control, checked against whose turn it is. */
105function playerAction($: EngineInterface, choose: (g: Game) => Action | null) {
106  return change($, (g) => {
107    const table = g.table
108    if (!table?.hand || table.hand.toAct !== table.playerSeat) return g
109    const action = choose(g)
110    return action ? step(g, action) : g
111  })
112}
113
114function intents($: EngineInterface): Intents {
115  return {
116    act: (a) =>
117      playerAction($, (g) => {
118        const hand = g.table!.hand!
119        if (a === 'minRaise') return wager(hand, legalActions(hand).raise?.min ?? 0)
120        return { type: a }
121      }),
122    wager: (to) => playerAction($, (g) => wager(g.table!.hand!, to)),
123    amount: (text) =>
124      playerAction($, (g) => {
125        // in the unit the Table is shown in, or in big blinds whatever it is when typed with `bb`
126        const plain = text.replace(/[,\s]/g, '')
127        const inBlinds = g.profile.chipUnit === 'bb' || /bb$/i.test(plain)
128        const value = Number.parseFloat(plain.replace(/bb$/i, ''))
129        if (!Number.isFinite(value)) throw new Error(t(g.profile.lang, 'amount') + '?')
130        const hand = g.table!.hand!
131        return wager(hand, Math.round(inBlinds ? value * hand.bigBlind : value))
132      }),
133    next: () => change($, (g) => deal(g, cryptoRng)),
134    scrollLog: (rows) => void scrollLog($, rows),
135    sit: (size) => change($, (g) => sitDown(g, size, cryptoRng)),
136    leave: () => change($, leave),
137    rebuy: () => change($, rebuyStack),
138    topUp: () => change($, topUp),
139    toggleLang: () => change($, (g) => ({ ...g, profile: { ...g.profile, lang: g.profile.lang === 'zh' ? 'en' : 'zh' } })),
140    toggleCards: () =>
141      change($, (g) => ({ ...g, profile: { ...g.profile, cardStyle: g.profile.cardStyle === 'compact' ? 'boxed' : 'compact' } })),
142    toggleSettings: () => void update($, settingsOpen, (open) => !open),
143    toggleUnit: () => change($, (g) => ({ ...g, profile: { ...g.profile, chipUnit: g.profile.chipUnit === 'bb' ? 'chips' : 'bb' } })),
144  }
145}
146
147/**
148 * The pane's fill, so it matches the terminal rather than the docked pane's
149 * own shade: the terminal's black under a dark theme, the pane's default otherwise.
150 */
151async function background($: EngineInterface): Promise<string | undefined> {
152  try {
153    const theme = (await $.config.list()).find((row) => row.key === 'theme')?.value
154    return typeof theme === 'string' && theme.startsWith('dark') ? 'black' : undefined
155  } catch {
156    return undefined
157  }
158}
159
160async function raise($: EngineInterface, why: Alert) {
161  await $.state.set(ALERT, why)
162  doneTimer?.cancel()
163  doneTimer = undefined
164  const { profile } = await read($, game)
165  const key = why === 'permission' ? 'alertPermission' : why === 'question' ? 'alertQuestion' : 'alertDone'
166  $.ui.toast(t(profile.lang, key))
167  if (why === 'done') doneTimer = $.clock.after(DONE_ALERT_MS, () => void $.state.set(ALERT, null))
168}
169
170export const register: Register = (on) => {
171  on('session.start', async ($, e, next) => {
172    await $.command.register({
173      name: 'poker',
174      description: "Texas Hold'em against bots in a side pane: /poker [stats|lang|cards|units|topup]",
175    })
176    // a reload keeps the session's Game; a new session recovers the saved Profile
177    const current = await $.state.get(GAME)
178    if (current.value === undefined) {
179      const profile = recover((await $.store.get(STORE_KEY)) as Profile | undefined)
180      await $.state.set(GAME, { table: null, profile, log: [] })
181      await $.store.set(STORE_KEY, profile)
182    } else {
183      scheduleBots($, current.value)
184      scheduleDeal($, current.value)
185    }
186    return next(e)
187  })
188
189  on('session.end', async ($, e, next) => {
190    // the Hand in play counts as folded: the Stack as it stands goes back
191    const g = await read($, game)
192    if (g.table) {
193      const stack = g.table.seats[g.table.playerSeat]!.stack
194      await $.store.set(STORE_KEY, { ...g.profile, bankroll: g.profile.bankroll + stack, seated: 0 })
195    }
196    return next(e)
197  })
198
199  on('command.run', { command: 'poker' }, async ($, e) => {
200    const arg = e.args.trim().toLowerCase()
201    const g = await read($, game)
202    const lang = g.profile.lang
203    switch (arg) {
204      case 'stats': {
205        const p = g.profile
206        return {
207          text: [
208            `${t(lang, 'statsTitle')}`,
209            `${t(lang, 'bankroll')}: ${chips(p.bankroll + (g.table ? g.table.seats[g.table.playerSeat]!.stack : 0))}`,
210            `${t(lang, 'hands')}: ${p.hands}`,
211            `${t(lang, 'net')}: ${p.net >= 0 ? '+' : ''}${chips(p.net)}`,
212            `${t(lang, 'biggestPot')}: ${chips(p.biggestPot)}`,
213            `${t(lang, 'topUps')}: ${p.topUps}`,
214          ].join('\n'),
215        }
216      }
217      case 'lang':
218        await intents($).toggleLang()
219        return { text: `language: ${lang === 'zh' ? 'English' : '中文'}` }
220      case 'cards':
221        await intents($).toggleCards()
222        return { text: `cards: ${g.profile.cardStyle === 'compact' ? 'boxed' : 'compact'}` }
223      case 'units':
224        await intents($).toggleUnit()
225        return { text: `units: ${g.profile.chipUnit === 'bb' ? 'chips' : 'big blinds'}` }
226      case 'topup':
227        await intents($).topUp()
228        return { text: `${t(lang, 'bankroll')}: ${chips((await read($, game)).profile.bankroll)}` }
229      default:
230        await $.ui.open({ id: PANE, title: t(lang, 'title'), focus: true })
231        return { text: t(lang, 'title') }
232    }
233  })
234
235  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
236    const els = $.ui.resolve(e)
237    const g = await read($, game)
238    const why = await read($, alert)
239    const up = await read($, logScroll)
240    const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 80
241    const rows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 24
242    const open = await read($, settingsOpen)
243    return tableView(els as never, g, why, up, open, columns, rows, await background($), intents($))
244  })
245
246  // the wheel scrolls the action log while the whole pane fits; otherwise it scrolls the pane
247  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
248    if (e.contentRows <= e.bodyRows && (await read($, game)).table) await scrollLog($, -e.by)
249    return next(e)
250  })
251
252  // calling the Player back when Claude needs them
253  on('classic.PermissionRequest', async ($, e, next) => {
254    await raise($, 'permission')
255    return next(e)
256  })
257
258  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
259    await raise($, 'question')
260    const result = await next(e)
261    await $.state.set(ALERT, null)
262    return result
263  })
264
265  on('tool.call', async ($, e, next) => {
266    const result = await next(e)
267    // a permission prompt is answered once the call goes on
268    if ((await read($, alert)) === 'permission') await $.state.set(ALERT, null)
269    return result
270  })
271
272  on('classic.Stop', async ($, e, next) => {
273    await raise($, 'done')
274    return next(e)
275  })
276
277  on('prompt.submit', async ($, e, next) => {
278    await $.state.set(ALERT, null)
279    return next(e)
280  })
281}
282
bots/styled.ts 118 lines
1import type { Action, HandState } from '../engine/hand'
2import { legalActions } from '../engine/hand'
3import type { BotStyle, Rng, TableSize } from '../engine/table'
4import { equity } from './equity'
5import { percentile } from './preflop'
6
7/**
8 * Picks an Action for the Bot in `seat`. A policy reads only its own hole
9 * cards, the board and the betting; never another seat's cards. Any strategy
10 * (these styles, a GTO table, a model) plugs in here.
11 */
12export type BotPolicy = (hand: HandState, seat: number, style: BotStyle, rng: Rng) => Action
13
14/**
15 * The opening frequencies each Bot Style aims at for each Table Size, in
16 * percent of hands: VPIP (puts money in) and PFR (raises). Fewer seats, looser play.
17 */
18export const STYLE_TARGETS: Record<TableSize, Record<BotStyle, { vpip: number; pfr: number }>> = {
19  9: {
20    TAG: { vpip: 15, pfr: 12 },
21    LAG: { vpip: 24, pfr: 20 },
22    CallingStation: { vpip: 40, pfr: 4 },
23    Nit: { vpip: 9, pfr: 7 },
24  },
25  6: {
26    TAG: { vpip: 20, pfr: 16 },
27    LAG: { vpip: 30, pfr: 25 },
28    CallingStation: { vpip: 45, pfr: 5 },
29    Nit: { vpip: 12, pfr: 10 },
30  },
31  2: {
32    TAG: { vpip: 55, pfr: 45 },
33    LAG: { vpip: 75, pfr: 65 },
34    CallingStation: { vpip: 80, pfr: 10 },
35    Nit: { vpip: 35, pfr: 28 },
36  },
37}
38
39/** How each style plays after the flop and against raises. */
40const POSTFLOP: Record<
41  BotStyle,
42  {
43    /** Equity needed to bet when checked to, and to raise a bet. */
44    bet: number
45    raise: number
46    /** Equity over the pot odds needed to call; below 0 calls too much. */
47    callMargin: number
48    /** How often it bets or raises with nothing. */
49    bluff: number
50    /** Bet size as a share of the pot. */
51    sizing: number
52    /** The share of its opening range it continues with against a raise. */
53    defend: number
54  }
55> = {
56  TAG: { bet: 0.6, raise: 0.78, callMargin: 0.04, bluff: 0.08, sizing: 0.66, defend: 0.45 },
57  LAG: { bet: 0.52, raise: 0.7, callMargin: 0, bluff: 0.2, sizing: 0.8, defend: 0.55 },
58  CallingStation: { bet: 0.75, raise: 0.9, callMargin: -0.12, bluff: 0.02, sizing: 0.5, defend: 0.8 },
59  Nit: { bet: 0.68, raise: 0.85, callMargin: 0.1, bluff: 0.02, sizing: 0.6, defend: 0.5 },
60}
61
62/** Monte Carlo runs per decision: enough for a steady estimate, cheap enough for each Action. */
63const ITERATIONS = 400
64
65const pot = (hand: HandState) => hand.seats.reduce((sum, s) => sum + (s?.committed ?? 0), 0)
66const opponents = (hand: HandState, seat: number) => hand.seats.filter((s, i) => s && !s.folded && i !== seat).length
67
68/** A bet or raise to `to`, or all in when that is the whole Stack. */
69function aggress(hand: HandState, to: number): Action {
70  const legal = legalActions(hand)
71  if (!legal.raise) return legal.canCheck ? { type: 'check' } : { type: 'call' }
72  const total = Math.max(legal.raise.min, Math.min(legal.raise.max, Math.round(to / hand.bigBlind) * hand.bigBlind))
73  if (total >= legal.raise.max) return { type: 'allIn' }
74  return legal.canCheck ? { type: 'bet', to: total } : { type: 'raise', to: total }
75}
76
77function preflop(hand: HandState, seat: number, style: BotStyle, rng: Rng): Action {
78  const legal = legalActions(hand)
79  // the Table Size is the number of Seats the Hand was dealt around
80  const target = (STYLE_TARGETS[hand.seats.length as TableSize] ?? STYLE_TARGETS[6])[style]
81  const p = POSTFLOP[style]
82  const rank = percentile(hand.seats[seat]!.holeCards)
83  const raised = hand.currentBet > hand.bigBlind
84
85  if (!raised) {
86    // first in, or behind limpers: raise the top of the range, limp the rest of it
87    if (rank <= target.pfr) return aggress(hand, hand.bigBlind * (2.5 + opponents(hand, seat) * 0.25))
88    if (rank <= target.vpip) return legal.canCheck ? { type: 'check' } : { type: 'call' }
89    return legal.canCheck ? { type: 'check' } : { type: 'fold' }
90  }
91
92  // facing a raise: re-raise the very top, call with the stronger part of the range
93  if (rank <= target.pfr * 0.3 || (rng() < p.bluff / 3 && rank <= target.vpip)) return aggress(hand, hand.currentBet * 3)
94  const odds = legal.toCall / (pot(hand) + legal.toCall)
95  if (rank <= target.vpip * p.defend || (odds < 0.2 && rank <= target.vpip)) return { type: 'call' }
96  return { type: 'fold' }
97}
98
99function postflop(hand: HandState, seat: number, style: BotStyle, rng: Rng): Action {
100  const legal = legalActions(hand)
101  const p = POSTFLOP[style]
102  const eq = equity(hand.seats[seat]!.holeCards, hand.board, opponents(hand, seat), ITERATIONS, rng)
103  const size = pot(hand) * p.sizing
104
105  if (legal.canCheck) {
106    if (eq >= p.bet || rng() < p.bluff) return aggress(hand, size)
107    return { type: 'check' }
108  }
109  if (legal.raise && (eq >= p.raise || (rng() < p.bluff / 2 && eq > 0.3))) return aggress(hand, hand.currentBet + pot(hand) * p.sizing)
110  const odds = legal.toCall / (pot(hand) + legal.toCall)
111  if (eq >= odds + p.callMargin) return { type: 'call' }
112  return { type: 'fold' }
113}
114
115/** Plays by Bot Style: starting-hand ranges sized to the style's VPIP and PFR, then equity against the pot odds. */
116export const styledBot: BotPolicy = (hand, seat, style, rng) =>
117  hand.street === 'preflop' ? preflop(hand, seat, style, rng) : postflop(hand, seat, style, rng)
118
engine/game.ts 187 lines
1import type { Game, LogEntry, Profile, TableSize } from '../types'
2import { evaluate } from './evaluate'
3import { legalActions, type Action, type HandState, type Street } from './hand'
4import { act, buyIn, createTable, dealNext, isHandLive, rebuy, type Rng, type TableState } from './table'
5
6export const STARTING_BANKROLL = 10_000
7
8export const newProfile = (): Profile => ({
9  bankroll: STARTING_BANKROLL,
10  seated: 0,
11  hands: 0,
12  net: 0,
13  topUps: 0,
14  biggestPot: 0,
15  lang: 'zh',
16  cardStyle: 'compact',
17  chipUnit: 'chips',
18  tableSize: 6,
19})
20
21export const STAKE = { smallBlind: 5, bigBlind: 10 }
22export const BUY_IN = buyIn(STAKE)
23
24/** A saved Profile coming back: chips left seated by a Session that ended abruptly return to the Bankroll. */
25export function recover(saved: Profile | undefined): Profile {
26  const profile = { ...newProfile(), ...saved }
27  return { ...profile, bankroll: profile.bankroll + profile.seated, seated: 0 }
28}
29
30/** Sits the Player down at a Table of `size` with one Buy-in and deals the first Hand. */
31export function sitDown(g: Game, size: TableSize, rng: Rng): Game {
32  if (g.table) return g
33  if (g.profile.bankroll < BUY_IN) throw new Error('not enough for a buy-in')
34  const table = createTable(BUY_IN, rng, STAKE, size)
35  const profile = { ...g.profile, bankroll: g.profile.bankroll - BUY_IN, seated: BUY_IN, tableSize: size }
36  return deal({ table, profile, log: [] }, rng)
37}
38
39/** Deals the next Hand. */
40export function deal(g: Game, rng: Rng): Game {
41  if (!g.table) return g
42  const table = dealNext(g.table, rng)
43  return { ...g, table, log: blinds(table.hand!) }
44}
45
46/** The blinds as posted: the seat after the button (heads up, the button itself) and the one after it. */
47function blinds(hand: HandState): LogEntry[] {
48  const occupied = hand.seats.flatMap((s, i) => (s ? [i] : []))
49  const after = (from: number) => occupied.find((i) => i > from) ?? occupied[0]!
50  const sb = occupied.length === 2 ? hand.button : after(hand.button)
51  const bb = after(sb)
52  return [
53    { kind: 'blind', seat: sb, blind: 'SB', amount: hand.seats[sb]!.bet },
54    { kind: 'blind', seat: bb, blind: 'BB', amount: hand.seats[bb]!.bet },
55  ]
56}
57
58/** Whether the Player may get up now: between Hands, or once out of the one in play. */
59export function canLeave(table: TableState): boolean {
60  if (!isHandLive(table)) return true
61  return table.hand!.seats[table.playerSeat]!.folded
62}
63
64/** The Player leaves the Table and the Stack goes back to the Bankroll. */
65export function leave(g: Game): Game {
66  if (!g.table || !canLeave(g.table)) return g
67  const stack = g.table.seats[g.table.playerSeat]!.stack
68  return { table: null, profile: { ...g.profile, bankroll: g.profile.bankroll + stack, seated: 0 }, log: [] }
69}
70
71/** Between Hands, tops the Player's Stack back up to one Buy-in from the Bankroll. */
72export function rebuyStack(g: Game): Game {
73  if (!g.table || isHandLive(g.table)) return g
74  const stack = g.table.seats[g.table.playerSeat]!.stack
75  const amount = Math.min(BUY_IN - stack, g.profile.bankroll)
76  if (amount <= 0) return g
77  return {
78    ...g,
79    table: rebuy(g.table, amount),
80    profile: { ...g.profile, bankroll: g.profile.bankroll - amount, seated: stack + amount },
81  }
82}
83
84/** Whether the Player is short of a Buy-in counting chips on and off the Table. */
85export function needsTopUp(g: Game): boolean {
86  const stack = g.table ? g.table.seats[g.table.playerSeat]!.stack : 0
87  return g.profile.bankroll + stack < BUY_IN
88}
89
90/** Fills the Bankroll back up to the starting amount; counted in the Profile. */
91export function topUp(g: Game): Game {
92  if (!needsTopUp(g)) return g
93  return { ...g, profile: { ...g.profile, bankroll: STARTING_BANKROLL, topUps: g.profile.topUps + 1 } }
94}
95
96/** Applies the Action of whoever is to act, logs it, and books the Hand into the Profile once it settles. */
97export function step(g: Game, action: Action): Game {
98  if (!g.table?.hand) throw new Error('no Hand dealt')
99  const before = g.table.hand
100  const seat = before.toAct!
101  const legal = legalActions(before)
102  const table = act(g.table, action)
103  const hand = table.hand!
104
105  const log: LogEntry[] = [...g.log, { kind: 'action', seat, action: action.type, to: actionTotal(before, seat, action, legal.raise?.max) }]
106  // every street the board turned, several at once when an all in runs it out
107  const turned: [Street, number][] = [['flop', 3], ['turn', 4], ['river', 5]]
108  for (const [street, size] of turned) {
109    if (before.board.length < size && hand.board.length >= size) log.push({ kind: 'street', street })
110  }
111  let profile = g.profile
112  if (hand.result) {
113    hand.result.pots.forEach((pot, index) => {
114      const category = hand.result!.shown.length > 0 ? evaluate([...hand.seats[pot.winners[0]!]!.holeCards, ...hand.board]).category : null
115      log.push({ kind: 'pot', index, amount: pot.amount, winners: pot.winners, category })
116    })
117    profile = book(profile, table)
118  }
119  const playerStack = table.seats[table.playerSeat]!.stack
120  return { table, profile: { ...profile, seated: playerStack }, log }
121}
122
123/** The total a seat's bet comes to on this Street after `action`. */
124function actionTotal(hand: HandState, seat: number, action: Action, max: number | undefined): number {
125  const s = hand.seats[seat]!
126  switch (action.type) {
127    case 'bet':
128    case 'raise':
129      return action.to
130    case 'allIn':
131      return max ?? s.bet + s.stack
132    case 'call':
133      return Math.min(hand.currentBet, s.bet + s.stack)
134    default:
135      return s.bet
136  }
137}
138
139/** Records a settled Hand's result for the Player. */
140function book(profile: Profile, table: TableState): Profile {
141  const seat = table.playerSeat
142  const hand = table.hand!
143  const s = hand.seats[seat]!
144  return {
145    ...profile,
146    hands: profile.hands + 1,
147    net: profile.net + (s.stack - s.startStack),
148    biggestPot: Math.max(profile.biggestPot, won(hand, seat)),
149  }
150}
151
152/** Chips a seat took from the Pots of a settled Hand, uncalled bets aside. */
153function won(hand: HandState, seat: number): number {
154  const pots = hand.result?.pots ?? []
155  let total = 0
156  for (const pot of pots) {
157    const k = pot.winners.indexOf(seat)
158    if (k < 0) continue
159    const share = Math.floor(pot.amount / pot.winners.length)
160    total += share + (k === 0 ? pot.amount - share * pot.winners.length : 0)
161  }
162  return total
163}
164
165/** The raise sizes offered as one-key choices, as totals clamped to what is legal. */
166export function presets(hand: HandState): { key: '1' | '2' | '3' | '4'; label: 'x2.5' | 'halfPot' | 'fullPot' | 'allIn'; to: number }[] {
167  const legal = legalActions(hand)
168  if (!legal.raise) return []
169  const pot = hand.seats.reduce((sum, x) => sum + (x?.committed ?? 0), 0)
170  const afterCall = pot + legal.toCall
171  const clamp = (to: number) => Math.max(legal.raise!.min, Math.min(legal.raise!.max, Math.round(to)))
172  const base = hand.currentBet > 0 ? hand.currentBet : hand.bigBlind
173  return [
174    { key: '1', label: 'x2.5', to: clamp(base * 2.5) },
175    { key: '2', label: 'halfPot', to: clamp(hand.currentBet + afterCall / 2) },
176    { key: '3', label: 'fullPot', to: clamp(hand.currentBet + afterCall) },
177    { key: '4', label: 'allIn', to: legal.raise.max },
178  ]
179}
180
181/** The Action that puts the bet to `to`: a bet or raise, or all in at the top. */
182export function wager(hand: HandState, to: number): Action {
183  const legal = legalActions(hand)
184  if (legal.raise && to >= legal.raise.max) return { type: 'allIn' }
185  return legal.canCheck ? { type: 'bet', to } : { type: 'raise', to }
186}
187
engine/hand.ts 242 lines
1import { compare, evaluate, type HandValue } from './evaluate'
2import type { Card, HandResult, HandState, SeatState, Street } from '../types'
3export type { HandResult, HandState, SeatState, Street }
4
5export type TableSetup = {
6  /** One entry per Seat: the Stack sitting there, or null for an empty Seat. */
7  stacks: (number | null)[]
8  button: number
9  smallBlind: number
10  bigBlind: number
11}
12
13export type LegalActions = {
14  seat: number
15  canCheck: boolean
16  /** Chips needed to call, capped at the seat's Stack; 0 when checking is possible. */
17  toCall: number
18  /**
19   * The totals this Street a bet (nothing to call) or raise may reach, `max`
20   * being all in; below `min` only all in is allowed. Null when the seat can
21   * only call or fold.
22   */
23  raise: { min: number; max: number } | null
24}
25
26/**
27 * Deals a Hand. The deck is read in order: each occupied Seat's two hole
28 * cards, Seats from 0 upward, then the five board cards.
29 */
30export function startHand(table: TableSetup, deck: Card[]): HandState {
31  let next = 0
32  const seats = table.stacks.map((stack): SeatState | null =>
33    stack === null
34      ? null
35      : { stack, startStack: stack, holeCards: [deck[next++]!, deck[next++]!], bet: 0, committed: 0, folded: false, acted: false },
36  )
37  const state: HandState = {
38    seats,
39    button: table.button,
40    bigBlind: table.bigBlind,
41    street: 'preflop',
42    runout: deck.slice(next, next + 5),
43    board: [],
44    toAct: null,
45    currentBet: 0,
46    minRaise: table.bigBlind,
47    result: null,
48  }
49  // heads up the button is the small blind
50  const headsUp = seats.filter(Boolean).length === 2
51  const sb = headsUp ? table.button : nextSeat(state, table.button)
52  const bb = nextSeat(state, sb)
53  put(state, sb, table.smallBlind)
54  put(state, bb, table.bigBlind)
55  state.currentBet = table.bigBlind
56  state.toAct = nextSeat(state, bb)
57  return state
58}
59
60export function legalActions(state: HandState): LegalActions {
61  const seat = state.toAct
62  if (seat === null) throw new Error('the Hand is over')
63  const s = state.seats[seat]!
64  const owed = state.currentBet - s.bet
65  const max = s.bet + s.stack
66  // having acted, a seat facing more only through a short all in may call or fold
67  const reopened = !s.acted
68  const raise = reopened && max > state.currentBet ? { min: Math.min(state.currentBet + state.minRaise, max), max } : null
69  return { seat, canCheck: owed === 0, toCall: Math.min(owed, s.stack), raise }
70}
71
72/** The next occupied Seat clockwise after `from` that passes `which`. */
73function nextSeat(state: HandState, from: number, which: (s: SeatState) => boolean = () => true): number {
74  const n = state.seats.length
75  for (let i = 1; i <= n; i++) {
76    const seat = (from + i) % n
77    const s = state.seats[seat]
78    if (s && which(s)) return seat
79  }
80  throw new Error('no occupied seat')
81}
82
83/** Moves up to `amount` from a seat's Stack into its bet. */
84function put(state: HandState, seat: number, amount: number) {
85  const s = state.seats[seat]!
86  const chips = Math.min(amount, s.stack)
87  s.stack -= chips
88  s.bet += chips
89  s.committed += chips
90}
91
92export type Action =
93  | { type: 'fold' }
94  | { type: 'check' }
95  | { type: 'call' }
96  | { type: 'bet'; to: number }
97  | { type: 'raise'; to: number }
98  | { type: 'allIn' }
99
100export function applyAction(prev: HandState, action: Action): HandState {
101  const legal = legalActions(prev)
102  const seat = legal.seat
103  const state: HandState = structuredClone(prev)
104  const s = state.seats[seat]!
105  switch (action.type) {
106    case 'fold':
107      s.folded = true
108      break
109    case 'check':
110      if (!legal.canCheck) throw new Error('cannot check facing a bet')
111      break
112    case 'call':
113      if (legal.canCheck) throw new Error('nothing to call')
114      put(state, seat, legal.toCall)
115      break
116    case 'allIn':
117      if (legal.raise) raiseTo(state, seat, legal.raise.max)
118      else put(state, seat, s.stack) // all in for no more than a call
119      break
120    case 'bet':
121    case 'raise': {
122      if (!legal.raise) throw new Error('cannot raise')
123      if (action.type === 'bet' && !legal.canCheck) throw new Error('cannot bet facing a bet, raise instead')
124      if (action.type === 'raise' && legal.canCheck) throw new Error('nothing to raise, bet instead')
125      const to = action.to
126      if (to > legal.raise.max || (to < legal.raise.min && to !== legal.raise.max)) {
127        throw new Error(`cannot ${action.type} to ${to}`)
128      }
129      raiseTo(state, seat, to)
130      break
131    }
132  }
133  s.acted = true
134
135  const live = liveSeats(state)
136  if (live.length === 1) {
137    const pot = state.seats.reduce((sum, x) => sum + (x?.committed ?? 0), 0)
138    const winner = live[0]!
139    state.seats[winner]!.stack += pot
140    state.result = { pots: [{ amount: pot, winners: [winner] }], shown: [] }
141    state.toAct = null
142    return state
143  }
144  const waiting = state.seats.findIndex((_, i) => owesAction(state, i))
145  if (waiting >= 0) {
146    state.toAct = nextSeat(state, seat, (x) => owesAction(state, state.seats.indexOf(x)))
147    return state
148  }
149  return endStreet(state)
150}
151
152/** Puts a seat's bet up to `to`; a full raise sets the new minimum and reopens the action. */
153function raiseTo(state: HandState, seat: number, to: number) {
154  const increment = to - state.currentBet
155  put(state, seat, to - state.seats[seat]!.bet)
156  if (increment <= 0) return // an all in for no more than a call
157  if (increment >= state.minRaise) {
158    state.minRaise = increment
159    for (const x of state.seats) if (x) x.acted = false
160  }
161  state.currentBet = to
162}
163
164/** Whether a seat still has to act on this Street. */
165function owesAction(state: HandState, seat: number): boolean {
166  const s = state.seats[seat]
167  if (!s || s.folded || s.stack === 0) return false
168  return !s.acted || s.bet < state.currentBet
169}
170
171const NEXT_STREET: Record<Street, Street | null> = { preflop: 'flop', flop: 'turn', turn: 'river', river: null }
172const BOARD_SIZE: Record<Street, number> = { preflop: 0, flop: 3, turn: 4, river: 5 }
173
174/** Closes the betting on this Street and opens the next one, or settles the Hand. */
175function endStreet(state: HandState): HandState {
176  const street = NEXT_STREET[state.street]
177  if (street === null) return showdown(state)
178  state.street = street
179  state.board = state.runout.slice(0, BOARD_SIZE[street])
180  state.currentBet = 0
181  state.minRaise = state.bigBlind
182  for (const x of state.seats) if (x) Object.assign(x, { bet: 0, acted: false })
183  const canAct = liveSeats(state).filter((i) => state.seats[i]!.stack > 0)
184  // with at most one seat able to bet, the rest of the board just runs out
185  if (canAct.length < 2) return endStreet(state)
186  state.toAct = nextSeat(state, state.button, (x) => !x.folded && x.stack > 0)
187  return state
188}
189
190/** Splits the chips into Pots by the all-in levels and pays each to its best eligible hands. */
191function showdown(state: HandState): HandState {
192  state.board = state.runout.slice(0, 5)
193  state.toAct = null
194  const live = liveSeats(state)
195  const value = new Map<number, HandValue>(
196    live.map((i) => [i, evaluate([...state.seats[i]!.holeCards, ...state.board])]),
197  )
198  const committed = state.seats.map((x) => x?.committed ?? 0)
199  const levels = [...new Set(committed.filter((c) => c > 0))].sort((a, b) => a - b)
200
201  // seats in the order odd chips go: clockwise from the button's left
202  const n = state.seats.length
203  const fromButton = (i: number) => (i - state.button - 1 + n) % n
204  const pots: (HandResult['pots'][number] & { eligible: number[] })[] = []
205  let below = 0
206  for (const level of levels) {
207    const amount = committed.reduce((sum, c) => sum + Math.max(0, Math.min(c, level) - below), 0)
208    const eligible = live.filter((i) => committed[i]! >= level).sort((a, b) => fromButton(a) - fromButton(b))
209    below = level
210    if (eligible.length === 0) {
211      pots[pots.length - 1]!.amount += amount
212      continue
213    }
214    if (committed.filter((c) => c >= level).length === 1) {
215      // a bet nobody called goes back to whoever made it, outside any Pot
216      state.seats[eligible[0]!]!.stack += amount
217      continue
218    }
219    const last = pots[pots.length - 1]
220    if (last && last.eligible.join() === eligible.join()) {
221      last.amount += amount
222      continue
223    }
224    const best = eligible.reduce((top, i) => (compare(value.get(i)!, value.get(top)!) > 0 ? i : top))
225    const winners = eligible.filter((i) => compare(value.get(i)!, value.get(best)!) === 0)
226    pots.push({ amount, winners, eligible })
227  }
228  for (const pot of pots) {
229    const share = Math.floor(pot.amount / pot.winners.length)
230    pot.winners.forEach((i, k) => {
231      state.seats[i]!.stack += share + (k === 0 ? pot.amount - share * pot.winners.length : 0)
232    })
233  }
234  state.result = { pots: pots.map(({ amount, winners }) => ({ amount, winners })), shown: live }
235  return state
236}
237
238/** Seats still in the Hand. */
239function liveSeats(state: HandState): number[] {
240  return state.seats.flatMap((x, i) => (x && !x.folded ? [i] : []))
241}
242
engine/table.ts 102 lines
1import type { BotStyle, Card, Occupant, Rank, Suit, TableSize, TableState } from '../types'
2export type { BotStyle, Occupant, TableSize, TableState }
3import { applyAction, startHand, type Action, type HandState } from './hand'
4
5export const BOT_STYLES: readonly BotStyle[] = ['TAG', 'LAG', 'CallingStation', 'Nit']
6
7/** A source of uniform numbers in [0, 1). */
8export type Rng = () => number
9
10export const TABLE_SIZES: readonly TableSize[] = [9, 6, 2]
11export const BIG_BLINDS_PER_BUY_IN = 100
12
13const BOT_NAMES = [
14  'Ace', 'Blaze', 'Clover', 'Dusty', 'Echo', 'Fox', 'Ginger', 'Hawk', 'Iris', 'Jade', 'Kit', 'Luna',
15  'Milo', 'Nova', 'Onyx', 'Pike', 'Quinn', 'Rex', 'Sage', 'Tex', 'Uma', 'Vex', 'Wren', 'Zed',
16]
17
18export const buyIn = (t: Pick<TableState, 'bigBlind'>) => t.bigBlind * BIG_BLINDS_PER_BUY_IN
19
20/** A Table of `size` Seats with the Player in seat 0 holding `playerStack` and a fresh Bot in every other Seat. */
21export function createTable(playerStack: number, rng: Rng, stake = { smallBlind: 5, bigBlind: 10 }, size: TableSize = 6): TableState {
22  const table: TableState = {
23    seats: Array.from({ length: size }, () => null),
24    playerSeat: 0,
25    button: size - 1,
26    ...stake,
27    hand: null,
28    usedNames: [],
29  }
30  table.seats[0] = { kind: 'player', stack: playerStack }
31  for (let i = 1; i < size; i++) table.seats[i] = newBot(table, rng)
32  return table
33}
34
35function newBot(table: TableState, rng: Rng): Occupant {
36  const free = BOT_NAMES.filter((n) => !table.usedNames.includes(n))
37  const pool = free.length > 0 ? free : BOT_NAMES
38  const name = pool[Math.floor(rng() * pool.length)]!
39  table.usedNames.push(name)
40  const style = BOT_STYLES[Math.floor(rng() * BOT_STYLES.length)]!
41  return { kind: 'bot', name, style, stack: buyIn(table) }
42}
43
44/** Whether a Hand is being played: dealt and not yet settled. */
45export const isHandLive = (t: TableState) => t.hand !== null && t.hand.result === null
46
47/**
48 * Deals the next Hand: busted Bots leave and fresh ones take their Seats, the
49 * button moves on, and a shuffled deck is dealt. The Player must have chips.
50 */
51export function dealNext(prev: TableState, rng: Rng): TableState {
52  if (isHandLive(prev)) throw new Error('a Hand is still being played')
53  const table: TableState = structuredClone(prev)
54  const player = table.seats[table.playerSeat]!
55  if (player.stack === 0) throw new Error('the Player has no chips: Rebuy first')
56  table.seats = table.seats.map((o) => (o && o.kind === 'bot' && o.stack === 0 ? newBot(table, rng) : o))
57  table.button = nextOccupied(table, table.button)
58  const stacks = table.seats.map((o) => o?.stack ?? null)
59  table.hand = startHand({ stacks, button: table.button, smallBlind: table.smallBlind, bigBlind: table.bigBlind }, shuffle(rng))
60  return table
61}
62
63/** Applies the Action of whoever is to act, and carries the Stacks back to the Seats once the Hand is settled. */
64export function act(prev: TableState, action: Action): TableState {
65  if (!prev.hand) throw new Error('no Hand dealt')
66  const hand = applyAction(prev.hand, action)
67  const table: TableState = { ...prev, hand }
68  table.seats = prev.seats.map((o, i) => (o ? { ...o, stack: hand.seats[i]!.stack } : null))
69  return table
70}
71
72/** Adds chips to the Player's Stack between Hands. */
73export function rebuy(prev: TableState, amount: number): TableState {
74  if (isHandLive(prev)) throw new Error('rebuy only between Hands')
75  const seats = prev.seats.map((o, i) => (o && i === prev.playerSeat ? { ...o, stack: o.stack + amount } : o))
76  return { ...prev, seats }
77}
78
79function nextOccupied(table: TableState, from: number): number {
80  const n = table.seats.length
81  for (let i = 1; i <= n; i++) {
82    const seat = (from + i) % n
83    if (table.seats[seat]) return seat
84  }
85  throw new Error('empty table')
86}
87
88const RANKS: Rank[] = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A']
89const SUITS: Suit[] = ['s', 'h', 'd', 'c']
90
91export function shuffle(rng: Rng): Card[] {
92  const deck = RANKS.flatMap((r) => SUITS.map((s): Card => `${r}${s}`))
93  for (let i = deck.length - 1; i > 0; i--) {
94    const j = Math.floor(rng() * (i + 1))
95    ;[deck[i], deck[j]] = [deck[j]!, deck[i]!]
96  }
97  return deck
98}
99
100/** Uniform numbers from the environment's cryptographic source. */
101export const cryptoRng: Rng = () => crypto.getRandomValues(new Uint32Array(1))[0]! / 2 ** 32
102
ui/view.tsx 272 lines
1import type { Alert, Game, TableSize } from '../types'
2import { evaluate, Category } from '../engine/evaluate'
3import { legalActions, type HandState } from '../engine/hand'
4import { TABLE_SIZES, buyIn, isHandLive, type TableState } from '../engine/table'
5import { BUY_IN, STAKE, canLeave, needsTopUp, presets } from '../engine/game'
6import { ACCENT, TABLE_COLS, amountOf, chips, drawList, drawLog, drawLogo, drawTable } from './art'
7import type { Els } from './canvas'
8import { categoryName, t, type Lang } from './i18n'
9
10export { chips }
11
12/** What the view's controls ask for; the register module turns these into state changes. */
13export type Intents = {
14  act: (a: 'fold' | 'check' | 'call' | 'minRaise' | 'allIn') => void
15  wager: (to: number) => void
16  amount: (text: string) => void
17  next: () => void
18  sit: (size: TableSize) => void
19  leave: () => void
20  rebuy: () => void
21  topUp: () => void
22  toggleLang: () => void
23  toggleCards: () => void
24  toggleUnit: () => void
25  toggleSettings: () => void
26  /** Moves the action log by rows: positive toward older lines. */
27  scrollLog: (rows: number) => void
28}
29
30/** Rows kept for the controls, filled or not, so the block above them keeps its place. */
31const CONTROL_ROWS = 4
32
33const signed = (n: number) => (n > 0 ? `+${chips(n)}` : n < 0 ? `-${chips(-n)}` : '0')
34
35/** The whole pane: the header across the top, everything else centered in the space below it. */
36export function tableView(els: Els, g: Game, alert: Alert | null, logScroll: number, settingsOpen: boolean, columns: number, rows: number, background: string | undefined, on: Intents) {
37  const { Box, Text } = els
38  const lang = g.profile.lang
39  return (
40    <Box flexDirection="column" width={columns} minHeight={rows} backgroundColor={background}>
41      {header(els, g, lang, settingsOpen, on)}
42      {settingsOpen && settings(els, g, lang, on)}
43      {/* one row held for the alert, shown or not, so nothing below it moves */}
44      <Box justifyContent="center" height={1}>
45        {alert && (
46          <Text color={alert === 'done' ? 'green' : 'yellow'} bold>
47            {t(lang, alert === 'permission' ? 'alertPermission' : alert === 'question' ? 'alertQuestion' : 'alertDone')}
48          </Text>
49        )}
50      </Box>
51      <Box flexDirection="column" flexGrow={1} justifyContent="center" alignItems="center">
52        {g.table ? seated(els, g, g.table, lang, columns, logScroll, on) : lobby(els, g, lang, columns, on)}
53      </Box>
54    </Box>
55  )
56}
57
58/** One line: the game, the Stake, the Bankroll and the result on the left; the settings button on the right. */
59function header(els: Els, g: Game, lang: Lang, settingsOpen: boolean, on: Intents) {
60  const { Box, Text, Button } = els
61  const p = g.profile
62  const stake = g.table ? ` · ${g.table.smallBlind}/${g.table.bigBlind}` : ''
63  return (
64    <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
65      <Text>
66        <Text color={ACCENT} bold>
67          ♠ {t(lang, 'title')}
68        </Text>
69        <Text dimColor>{stake}</Text>
70        <Text dimColor>
71          {' · '}
72          {t(lang, 'bankroll')} {chips(p.bankroll)} · {t(lang, 'hands')} {p.hands} · {t(lang, 'net')}{' '}
73        </Text>
74        <Text color={p.net > 0 ? 'green' : p.net < 0 ? 'red' : undefined} dimColor={p.net === 0}>
75          {signed(p.net)}
76        </Text>
77      </Text>
78      <Button key="settings" hotkey="s" plain onPress={on.toggleSettings}>
79        {`${t(lang, 'settings')} ${settingsOpen ? '▴' : '▾'}`}
80      </Button>
81    </Box>
82  )
83}
84
85/**
86 * The Lobby: the logo, the Stake, Buy-in and Bankroll, and a button per Table
87 * Size, the one last chosen in focus. Short of a Buy-in, the sizes are drawn dim
88 * and the Top-up is the button.
89 */
90function lobby(els: Els, g: Game, lang: Lang, columns: number, on: Intents) {
91  const { Box, Text, Button } = els
92  const short = needsTopUp(g)
93  return (
94    <Box flexDirection="column" gap={1} alignItems="center">
95      {drawLogo(els, columns)}
96      <Text dimColor>
97        {`${STAKE.smallBlind}/${STAKE.bigBlind} · ${t(lang, 'buyIn')} ${chips(BUY_IN)} · ${t(lang, 'bankroll')} ${chips(g.profile.bankroll)}`}
98      </Text>
99      <Text dimColor>{short ? t(lang, 'broke') : t(lang, 'chooseTable')}</Text>
100      <Box flexDirection="row" gap={3} justifyContent="center" flexWrap="wrap">
101        {TABLE_SIZES.map((size) =>
102          short ? (
103            <Text key={`sit-${size}`} dimColor>
104              {`${size}: ${t(lang, `size${size}`)}`}
105            </Text>
106          ) : (
107            <Button key={`sit-${size}`} hotkey={String(size)} plain autoFocus={size === g.profile.tableSize ? true : undefined} onPress={() => on.sit(size)}>
108              {t(lang, `size${size}`)}
109            </Button>
110          ),
111        )}
112      </Box>
113      {short && (
114        <Button key="topup" hotkey="t" plain autoFocus onPress={on.topUp}>
115          {t(lang, 'topUp')}
116        </Button>
117      )}
118    </Box>
119  )
120}
121
122/** The settings row under the header, while open: each setting a button showing its current value. */
123function settings(els: Els, g: Game, lang: Lang, on: Intents) {
124  const { Box, Button } = els
125  return (
126    <Box flexDirection="row" gap={3} justifyContent="flex-end" flexWrap="wrap">
127      <Button key="lang" hotkey="l" plain onPress={on.toggleLang}>
128        {t(lang, 'language')}
129      </Button>
130      <Button key="cards" hotkey="v" plain onPress={on.toggleCards}>
131        {`${t(lang, 'cardStyle')} ${g.profile.cardStyle === 'compact' ? 'A♠' : '[A♠]'}`}
132      </Button>
133      <Button key="unit" hotkey="u" plain onPress={on.toggleUnit}>
134        {`${t(lang, 'unit')} ${g.profile.chipUnit === 'bb' ? 'BB' : t(lang, 'chips')}`}
135      </Button>
136    </Box>
137  )
138}
139
140function seated(els: Els, g: Game, table: TableState, lang: Lang, columns: number, logScroll: number, on: Intents) {
141  const { Box } = els
142  const wide = columns >= TABLE_COLS
143  return (
144    <Box flexDirection="column" width={wide ? TABLE_COLS : columns}>
145      {wide ? drawTable(els, g, table, lang) : drawList(els, g, table, lang, columns)}
146      {/* fixed heights below the table: the centered block keeps one size through the Hand */}
147      <Box marginTop={1}>{drawLog(els, g, table, lang, wide ? TABLE_COLS : columns, logScroll, { up: () => on.scrollLog(1), down: () => on.scrollLog(-1) })}</Box>
148      <Box flexDirection="column" minHeight={CONTROL_ROWS} marginTop={1}>
149        {controls(els, g, table, lang, on)}
150      </Box>
151    </Box>
152  )
153}
154
155const potTotal = (hand: HandState) => hand.seats.reduce((sum, s) => sum + (s?.committed ?? 0), 0)
156
157/** The Player's made hand and, facing a bet, the pot odds. */
158function hints(table: TableState, lang: Lang): string {
159  const hand = table.hand!
160  const me = hand.seats[table.playerSeat]!
161  const made =
162    hand.board.length > 0
163      ? categoryName(lang, evaluate([...me.holeCards, ...hand.board]).category)
164      : categoryName(lang, me.holeCards[0].charAt(0) === me.holeCards[1].charAt(0) ? Category.OnePair : Category.HighCard)
165  const legal = legalActions(hand)
166  const odds = legal.toCall > 0 ? ` · ${t(lang, 'potOdds')} ${Math.round((legal.toCall / (potTotal(hand) + legal.toCall)) * 100)}%` : ''
167  return `${made}${odds}`
168}
169
170function controls(els: Els, g: Game, table: TableState, lang: Lang, on: Intents) {
171  const { Box, Text, Button, Input } = els
172  const hand = table.hand
173  const live = isHandLive(table)
174  const myTurn = live && hand!.toAct === table.playerSeat
175  const stack = table.seats[table.playerSeat]!.stack
176  const fmt = amountOf(g, table)
177
178  if (myTurn) {
179    const legal = legalActions(hand!)
180    const raiseVerb = t(lang, legal.canCheck ? 'bet' : 'raise')
181    return (
182      <Box flexDirection="column">
183        <Text>
184          <Text color={ACCENT} bold>
185            ❯ {t(lang, 'yourTurn')}
186          </Text>
187          <Text dimColor> · {hints(table, lang)}</Text>
188        </Text>
189        <Box flexDirection="row" gap={2} flexWrap="wrap" paddingLeft={2}>
190          <Button key="fold" hotkey="f" plain onPress={() => on.act('fold')}>
191            {t(lang, 'fold')}
192          </Button>
193          {legal.canCheck ? (
194            <Button key="check" hotkey="c" plain autoFocus onPress={() => on.act('check')}>
195              {t(lang, 'check')}
196            </Button>
197          ) : (
198            <Button key="call" hotkey="c" plain autoFocus onPress={() => on.act('call')}>
199              {`${t(lang, 'call')} ${fmt(legal.toCall)}`}
200            </Button>
201          )}
202          {legal.raise && (
203            <Button key="raise" hotkey="r" plain onPress={() => on.act('minRaise')}>
204              {`${raiseVerb} ${fmt(legal.raise.min)}`}
205            </Button>
206          )}
207          <Button key="allin" hotkey="a" plain onPress={() => on.act('allIn')}>
208            {`${t(lang, 'allIn')} ${fmt(stack + hand!.seats[table.playerSeat]!.bet)}`}
209          </Button>
210        </Box>
211        {legal.raise && (
212          <Box flexDirection="row" gap={2} flexWrap="wrap" paddingLeft={2}>
213            {presets(hand!).map((p) => (
214              <Button key={`preset-${p.key}`} hotkey={p.key} plain onPress={() => on.wager(p.to)}>
215                {`${p.label === 'x2.5' ? '2.5x' : t(lang, p.label)} ${fmt(p.to)}`}
216              </Button>
217            ))}
218          </Box>
219        )}
220        {/* on a row of its own, so the field growing as it is typed in moves nothing */}
221        {legal.raise && (
222          <Box paddingLeft={2}>
223            <Input key="amount" label={raiseVerb} placeholder={`${fmt(legal.raise.min)}–${fmt(legal.raise.max)}`} onSubmit={(v) => on.amount(v)} />
224          </Box>
225        )}
226      </Box>
227    )
228  }
229
230  if (live) {
231    return (
232      <Box flexDirection="row" gap={2}>
233        <Text dimColor>{t(lang, 'waiting')}</Text>
234        {canLeave(table) && (
235          <Button key="leave" hotkey="q" plain onPress={on.leave}>
236            {t(lang, 'leave')}
237          </Button>
238        )}
239      </Box>
240    )
241  }
242
243  // between Hands
244  const busted = stack === 0
245  const canRebuy = stack < buyIn(table) && g.profile.bankroll > 0
246  return (
247    <Box flexDirection="column">
248      {busted ? <Text color="red">{t(lang, 'busted')}</Text> : <Text dimColor>{t(lang, 'autoNext')}</Text>}
249      <Box flexDirection="row" gap={2} flexWrap="wrap">
250        {!busted && (
251          <Button key="next" hotkey="n" plain autoFocus onPress={on.next}>
252            {t(lang, 'nextHand')}
253          </Button>
254        )}
255        {canRebuy && (
256          <Button key="rebuy" hotkey="b" plain autoFocus={busted ? true : undefined} onPress={on.rebuy}>
257            {t(lang, 'rebuy')}
258          </Button>
259        )}
260        {busted && needsTopUp(g) && (
261          <Button key="topup" hotkey="t" plain onPress={on.topUp}>
262            {t(lang, 'topUp')}
263          </Button>
264        )}
265        <Button key="leave" hotkey="q" plain onPress={on.leave}>
266          {t(lang, 'leave')}
267        </Button>
268      </Box>
269    </Box>
270  )
271}
272
ui/art.tsx 428 lines
1import type { Card } from '../engine/evaluate'
2import type { HandState } from '../engine/hand'
3import type { TableState } from '../engine/table'
4import type { Game } from '../types'
5import { Canvas, width, type Els, type Ink } from './canvas'
6import { categoryName, t, type Lang } from './i18n'
7
8/** The columns the drawn table takes; a narrower pane gets the list layout. */
9export const TABLE_COLS = 72
10
11/** Claude Code's own accent, for the seat to act. */
12export const ACCENT = '#D97757'
13const CHIP: Ink = { color: 'yellow', bold: true }
14const DIM: Ink = { dim: true }
15
16const SUIT_GLYPH: Record<string, string> = { s: '♠', h: '♥', d: '♦', c: '♣' }
17const face = (c: Card) => `${c.charAt(0) === 'T' ? '10' : c.charAt(0)}${SUIT_GLYPH[c.charAt(1)]}`
18const cardInk = (c: Card): Ink => ({ bold: true, color: c.charAt(1) === 'h' || c.charAt(1) === 'd' ? 'red' : undefined })
19
20export const chips = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
21
22/** Amounts at a Table as the Player reads them: chips, or big blinds to one decimal. */
23export const amountOf = (g: Game, table: Pick<TableState, 'bigBlind'>) => (n: number) =>
24  g.profile.chipUnit === 'bb' ? `${Math.round((n / table.bigBlind) * 10) / 10}BB` : chips(n)
25
26/** Compact cards as runs: `A♠ K♥`, face down `░░`, an empty board place `··`. */
27function compact(cards: (Card | null)[], slots = cards.length): [string, Ink?][] {
28  const runs: [string, Ink?][] = []
29  for (let i = 0; i < slots; i++) {
30    if (i > 0) runs.push([' '])
31    const c = cards[i]
32    if (c === undefined) runs.push(['··', DIM])
33    else if (c === null) runs.push(['░░', DIM])
34    else runs.push([face(c), cardInk(c)])
35  }
36  return runs
37}
38
39/** Boxed cards, three rows tall and five columns each, centered on `cx`. */
40function boxed(canvas: Canvas, cx: number, y: number, cards: (Card | null)[], slots = cards.length) {
41  const total = slots * 5 + (slots - 1)
42  let x = cx - Math.floor(total / 2)
43  for (let i = 0; i < slots; i++, x += 6) {
44    const c = cards[i]
45    const edge: Ink = c ? {} : DIM
46    canvas.put(x, y, '╭───╮', edge)
47    canvas.put(x, y + 1, '│', edge)
48    if (c === undefined) canvas.put(x + 1, y + 1, '   ')
49    else if (c === null) canvas.put(x + 1, y + 1, '░░░', DIM)
50    else canvas.put(x + 1, y + 1, face(c).padEnd(3), cardInk(c))
51    canvas.put(x + 4, y + 1, '│', edge)
52    canvas.put(x, y + 2, '╰───╯', edge)
53  }
54}
55
56/** Which blind a seat posted this Hand: the two seats after the button, or heads up the button itself. */
57function blindTag(table: TableState, seat: number): 'SB' | 'BB' | null {
58  const hand = table.hand
59  if (!hand) return null
60  const occupied = hand.seats.flatMap((s, i) => (s ? [i] : []))
61  const after = (from: number) => occupied.find((i) => i > from) ?? occupied[0]!
62  const sb = occupied.length === 2 ? hand.button : after(hand.button)
63  const bb = after(sb)
64  return seat === sb ? 'SB' : seat === bb ? 'BB' : null
65}
66
67const faceUp = (table: TableState, seat: number) =>
68  seat === table.playerSeat || (table.hand?.result?.shown.includes(seat) ?? false)
69
70const winners = (hand: HandState | null) => new Set(hand?.result?.pots.flatMap((p) => p.winners) ?? [])
71
72/** The pot in the middle: earlier Streets' chips while betting goes on, everything once the Hand is settled. */
73const potBehind = (hand: HandState) =>
74  hand.seats.reduce((sum, s) => sum + (s?.committed ?? 0) - (hand.result ? 0 : (s?.bet ?? 0)), 0)
75
76/** What a seat took from the Pots of a settled Hand, the odd chip to the first winner listed. */
77function winnings(hand: HandState, seat: number): number {
78  let total = 0
79  for (const pot of hand.result?.pots ?? []) {
80    const k = pot.winners.indexOf(seat)
81    if (k < 0) continue
82    const share = Math.floor(pot.amount / pot.winners.length)
83    total += share + (k === 0 ? pot.amount - share * pot.winners.length : 0)
84  }
85  return total
86}
87
88/** A Seat's ring: five rows by thirteen columns, or wider and taller around boxed cards. */
89const RING_W = 13
90const RING_H = 5
91
92/**
93 * Where each Seat sits around the felt, by its place counted clockwise from the
94 * Player's: across the top (with its share of the width), on the left and right
95 * sides, and beside the Player along the bottom.
96 */
97type Layout = { top: [pos: number, share: number][]; left?: number; right?: number; bottomLeft?: number; bottomRight?: number }
98
99const LAYOUTS: Record<number, Layout> = {
100  2: { top: [[1, 0.5]] },
101  6: { top: [[2, 0.25], [3, 0.5], [4, 0.75]], left: 1, right: 5 },
102  9: { top: [[3, 0.2], [4, 0.4], [5, 0.6], [6, 0.8]], left: 2, right: 7, bottomLeft: 1, bottomRight: 8 },
103}
104
105/** The table drawn in box characters: each Seat in its own ring around the felt, bets and the dealer button on the felt, the board in the middle. */
106export function drawTable(els: Els, g: Game, table: TableState, lang: Lang) {
107  const fmt = amountOf(g, table)
108  const tall = g.profile.cardStyle === 'boxed'
109  const hand = table.hand
110  const won = winners(hand)
111  const W = TABLE_COLS
112  const mid = Math.floor(W / 2)
113  const feltLeft = RING_W + 1
114  const feltRight = W - RING_W - 2
115
116  // rows: the top rings, then the felt (top bets, board, pot, the Player's bet), then the Player's ring;
117  // the side bets sit level with the board, or with the pot beside the wider boxed board
118  const feltTop = RING_H
119  const topSpotRow = feltTop + 1
120  const boardRow = feltTop + 2
121  const potRow = boardRow + (tall ? 3 : 1)
122  const sideSpotRow = tall ? potRow : boardRow
123  const playerSpotRow = potRow + 1
124  const feltBottom = playerSpotRow + 1
125  const sideTop = Math.round((feltTop + feltBottom) / 2 - RING_H / 2 + 0.5)
126  const playerRingH = tall ? RING_H + 2 : RING_H
127  const canvas = new Canvas(W, feltBottom + 1 + playerRingH)
128
129  canvas.box(feltLeft, feltTop, feltRight - feltLeft + 1, feltBottom - feltTop + 1, DIM)
130
131  const seats = table.seats.length
132  const layout = LAYOUTS[seats] ?? LAYOUTS[6]!
133  const seatOf = (pos: number) => (table.playerSeat + pos) % seats
134
135  /** The ring's colour: the accent for the seat to act, green for a winner, dim otherwise. */
136  const ringInk = (seat: number): Ink =>
137    won.has(seat) ? { color: 'green' } : hand?.toAct === seat ? { color: ACCENT, bold: true } : DIM
138
139  /** The seat's name, tagged with its blind. */
140  const label = (seat: number): [string, Ink?][] => {
141    const o = table.seats[seat]!
142    const hs = hand?.seats[seat]
143    const name = o.kind === 'player' ? t(lang, 'you') : o.name
144    const ink: Ink = won.has(seat)
145      ? { color: 'green', bold: true }
146      : hand?.toAct === seat
147        ? { color: ACCENT, bold: true }
148        : hs?.folded
149          ? DIM
150          : { bold: o.kind === 'player' }
151    const tag = blindTag(table, seat)
152    return tag ? [[name, ink], [` ${tag}`, DIM]] : [[name, ink]]
153  }
154
155  const stack = (seat: number): [string, Ink?][] => {
156    const o = table.seats[seat]!
157    const hs = hand?.seats[seat]
158    if (hs && hs.stack === 0 && !hs.folded && hand?.result === null) return [[t(lang, 'isAllIn'), { color: 'red', bold: true }]]
159    return [[fmt(o.stack), hs?.folded ? DIM : {}]]
160  }
161
162  const hole = (seat: number): [string, Ink?][] => {
163    const hs = hand?.seats[seat]
164    if (!hs) return []
165    if (hs.folded) return [[t(lang, 'folded'), DIM]]
166    return compact(faceUp(table, seat) ? hs.holeCards : [null, null])
167  }
168
169  /** A Seat in its ring, centered on column `cx` from row `y`: name, Stack, cards. */
170  const ring = (seat: number, cx: number, y: number) => {
171    const x = cx - Math.floor(RING_W / 2)
172    canvas.box(x, y, RING_W, RING_H, ringInk(seat))
173    canvas.center(cx, y + 1, label(seat))
174    canvas.center(cx, y + 2, stack(seat))
175    canvas.center(cx, y + 3, hole(seat))
176  }
177
178  /** What sits on the felt in front of a seat: the dealer button and the bet, or a winner's take. */
179  const spot = (seat: number): [string, Ink?][] => {
180    const runs: [string, Ink?][] = []
181    if (hand && hand.button === seat) runs.push([' D ', { inverse: true, bold: true }])
182    const amount = hand?.result ? winnings(hand, seat) : (hand?.seats[seat]?.bet ?? 0)
183    if (amount > 0) {
184      if (runs.length) runs.push([' '])
185      if (hand?.result) runs.push([`+${fmt(amount)}`, { color: 'green', bold: true }])
186      else runs.push(['● ', CHIP], [fmt(amount), CHIP])
187    }
188    return runs
189  }
190
191  // across the top
192  const crowded = layout.top.length > 3
193  layout.top.forEach(([pos, share], i) => {
194    const seat = seatOf(pos)
195    if (!table.seats[seat]) return
196    const cx = Math.round(W * share)
197    ring(seat, cx, 0)
198    // drawn a little toward the middle, clear of the side seats' bets; four across
199    // leave no room for that, so the outer two keep to the felt's edges instead
200    if (crowded && i === 0) canvas.spans(feltLeft + 2, topSpotRow, spot(seat))
201    else if (crowded && i === layout.top.length - 1) canvas.right(feltRight - 2, topSpotRow, spot(seat))
202    else canvas.center(crowded ? cx : Math.round(cx + (mid - cx) * 0.4), topSpotRow, spot(seat))
203  })
204
205  // left and right
206  const left = layout.left === undefined ? null : seatOf(layout.left)
207  if (left !== null && table.seats[left]) {
208    ring(left, Math.floor(RING_W / 2), sideTop)
209    canvas.spans(feltLeft + 2, sideSpotRow, spot(left))
210  }
211  const right = layout.right === undefined ? null : seatOf(layout.right)
212  if (right !== null && table.seats[right]) {
213    ring(right, W - 1 - Math.floor(RING_W / 2), sideTop)
214    canvas.right(feltRight - 2, sideSpotRow, spot(right))
215  }
216
217  // the board and the pot
218  const board = hand?.board ?? []
219  if (tall) boxed(canvas, mid, boardRow, board, 5)
220  else canvas.center(mid, boardRow, compact(board, 5))
221  if (hand) canvas.center(mid, potRow, [[`${t(lang, 'pot')} `, DIM], [fmt(potBehind(hand)), { bold: true }]])
222
223  // the Player at the bottom, in a ring wide enough for boxed cards
224  const me = table.playerSeat
225  canvas.center(mid, playerSpotRow, spot(me))
226  const y = feltBottom + 1
227  const mine = hand?.seats[me]
228  if (tall) {
229    const w = 15
230    canvas.box(mid - Math.floor(w / 2), y, w, playerRingH, ringInk(me))
231    canvas.center(mid, y + 1, [...label(me), [' '], ...stack(me)])
232    if (mine && !mine.folded) boxed(canvas, mid, y + 2, mine.holeCards)
233    else canvas.center(mid, y + 3, hole(me))
234  } else {
235    ring(me, mid, y)
236  }
237
238  // beside the Player along the bottom, their bets level with the Player's
239  const bottomLeft = layout.bottomLeft === undefined ? null : seatOf(layout.bottomLeft)
240  if (bottomLeft !== null && table.seats[bottomLeft]) {
241    ring(bottomLeft, Math.round(W * 0.2), y)
242    canvas.spans(feltLeft + 2, playerSpotRow, spot(bottomLeft))
243  }
244  const bottomRight = layout.bottomRight === undefined ? null : seatOf(layout.bottomRight)
245  if (bottomRight !== null && table.seats[bottomRight]) {
246    ring(bottomRight, Math.round(W * 0.8), y)
247    canvas.right(feltRight - 2, playerSpotRow, spot(bottomRight))
248  }
249
250  return canvas.draw(els, 'table')
251}
252
253/** The narrow layout: one row per Seat with its markers, cards, Stack and bet, then the board and the pot. */
254export function drawList(els: Els, g: Game, table: TableState, lang: Lang, columns: number) {
255  const fmt = amountOf(g, table)
256  const hand = table.hand
257  const won = winners(hand)
258  const seats = table.seats.flatMap((o, i) => (o ? [i] : []))
259  const canvas = new Canvas(columns, seats.length + 2)
260  seats.forEach((seat, row) => {
261    const o = table.seats[seat]!
262    const hs = hand?.seats[seat]
263    const acting = hand?.toAct === seat
264    const name = o.kind === 'player' ? t(lang, 'you') : o.name
265    const ink: Ink = won.has(seat) ? { color: 'green', bold: true } : acting ? { color: ACCENT, bold: true } : hs?.folded ? DIM : o.kind === 'player' ? { bold: true } : {}
266    canvas.put(0, row, acting ? '❯' : ' ', { color: ACCENT })
267    canvas.put(2, row, name, ink)
268    canvas.put(10, row, hand?.button === seat ? ' D ' : '', { inverse: true, bold: true })
269    canvas.put(14, row, blindTag(table, seat) ?? '', DIM)
270    if (hs) canvas.spans(18, row, hs.folded ? [[t(lang, 'folded'), DIM]] : compact(faceUp(table, seat) ? hs.holeCards : [null, null]))
271    canvas.right(31, row, hs && hs.stack === 0 && !hs.folded && hand?.result === null ? [[t(lang, 'isAllIn'), { color: 'red', bold: true }]] : [[fmt(o.stack), hs?.folded ? DIM : {}]])
272    if (hand?.result && won.has(seat)) canvas.spans(33, row, [[`+${fmt(winnings(hand, seat))}`, { color: 'green', bold: true }]])
273    else if (hs && hs.bet > 0 && !hand?.result) canvas.spans(33, row, [['● ', CHIP], [fmt(hs.bet), CHIP]])
274  })
275  const y = seats.length + 1
276  const x = canvas.spans(2, y, compact(hand?.board ?? [], 5))
277  if (hand) canvas.spans(x + 3, y, [[`${t(lang, 'pot')} `, DIM], [fmt(potBehind(hand)), { bold: true }]])
278  return canvas.draw(els, 'list')
279}
280
281/** Content rows of the action log; the box adds its two borders. */
282export const LOG_ROWS = 6
283
284/** The last row the log's window could start at when last drawn, so the scroll keys stay within it. */
285export let logMaxTop = 0
286/** Columns the street labels take, so the actions line up after them. */
287const LABEL_COLS = 6
288
289type Runs = [string, Ink?][]
290
291/**
292 * The Hand so far in a bordered box: one group per street, its label and the
293 * board it turned, then the actions joined by ` · ` and wrapped to the width,
294 * then the pots. The newest rows show when they outgrow the box.
295 */
296export function drawLog(els: Els, g: Game, table: TableState, lang: Lang, cols: number, scroll: number, on: { up: () => void; down: () => void }) {
297  const fmt = amountOf(g, table)
298  const inner = cols - 4
299  const lines: Runs[] = []
300  let line: Runs = []
301  let used = 0
302  const name = (seat: number): [string, Ink?] => {
303    const o = table.seats[seat]
304    return [o?.kind === 'player' ? t(lang, 'you') : (o?.name ?? '?'), { bold: o?.kind === 'player' }]
305  }
306  const runsWidth = (runs: Runs) => runs.reduce((w, [text]) => w + width(text), 0)
307  const newLine = (label: Runs = []) => {
308    if (line.length) lines.push(line)
309    const pad = LABEL_COLS - runsWidth(label)
310    line = [...label, [' '.repeat(Math.max(1, pad))]]
311    used = Math.max(LABEL_COLS, runsWidth(label) + 1)
312  }
313  /** Adds one item after a separator, wrapping onto an indented line when it does not fit. */
314  const add = (item: Runs) => {
315    const w = runsWidth(item)
316    const first = used <= LABEL_COLS
317    if (!first && used + 3 + w > inner) newLine()
318    if (used > LABEL_COLS && line.length > 1) {
319      line.push([' · ', DIM])
320      used += 3
321    }
322    line.push(...item)
323    used += w
324  }
325  const board = table.hand?.board ?? []
326  let settled = false
327  const SIZE = { preflop: 0, flop: 3, turn: 4, river: 5 } as const
328
329  newLine([[t(lang, 'preflop'), DIM]])
330  for (const e of g.log) {
331    switch (e.kind) {
332      case 'blind':
333        add([name(e.seat), [` ${e.blind} `, DIM], [fmt(e.amount)]])
334        break
335      case 'action': {
336        const amount: Runs = e.action === 'fold' || e.action === 'check' ? [] : [[` ${fmt(e.to)}`]]
337        add([name(e.seat), [` ${t(lang, e.action)}`, DIM], ...amount])
338        break
339      }
340      case 'street':
341        newLine([[t(lang, e.street), DIM]])
342        line.push(...compact(board.slice(0, SIZE[e.street])))
343        used += runsWidth(compact(board.slice(0, SIZE[e.street])))
344        newLine()
345        break
346      case 'pot': {
347        // the first pot starts the result group under its label; the rest follow beneath
348        newLine(settled ? [] : [[t(lang, 'settle'), DIM]])
349        settled = true
350        const label = e.index === 0 ? t(lang, 'mainPot') : `${t(lang, 'sidePot')} ${e.index}`
351        const who = e.winners.map((s) => name(s)[0]).join(', ')
352        const verb = e.winners.length > 1 ? t(lang, 'split') : t(lang, 'wins')
353        const how = e.category === null ? '' : ` (${categoryName(lang, e.category)})`
354        line.push([`${label} ${fmt(e.amount)}: ${who} ${verb}${how}`, { color: 'green' }])
355        used = inner
356        break
357      }
358    }
359  }
360  if (line.some(([text]) => text.trim() !== '')) lines.push(line)
361
362  // the window over the lines: from row `scroll`, or the newest rows while following (-1)
363  const maxTop = Math.max(0, lines.length - LOG_ROWS)
364  logMaxTop = maxTop
365  const top = scroll < 0 ? maxTop : Math.min(scroll, maxTop)
366  const shown = lines.slice(top, top + LOG_ROWS)
367
368  // the top border is a row of its own, carrying the scroll keys; the canvas draws the rest
369  const { Box, Text, Button } = els
370  const title = ` ${t(lang, 'logTitle')} `
371  const keys = 4 + 1 + 4 // `k: ↑`, a rule, `j: ↓`
372  const rule = Math.max(1, cols - 2 - width(title) - keys - 3)
373  const canvas = new Canvas(cols, LOG_ROWS + 1)
374  for (let y = 0; y < LOG_ROWS; y++) {
375    canvas.put(0, y, '│', DIM)
376    canvas.put(cols - 1, y, '│', DIM)
377  }
378  canvas.put(0, LOG_ROWS, '╰' + '─'.repeat(cols - 2) + '╯', DIM)
379  if (top > 0) canvas.put(cols - 1, 0, '▲', { color: ACCENT })
380  if (top < maxTop) canvas.put(cols - 1, LOG_ROWS - 1, '▼', { color: ACCENT })
381  shown.forEach((runs, i) => canvas.spans(2, i, runs))
382  return (
383    <Box key="log" flexDirection="column" width={cols}>
384      <Box flexDirection="row">
385        <Text dimColor>{`╭─${title}${'─'.repeat(rule)}─`}</Text>
386        <Button key="log-up" hotkey="k" plain onPress={on.up}>
387          ↑
388        </Button>
389        <Text dimColor>─</Text>
390        <Button key="log-down" hotkey="j" plain onPress={on.down}>
391          ↓
392        </Button>
393        <Text dimColor>─╮</Text>
394      </Box>
395      {canvas.draw(els, 'log-body')}
396    </Box>
397  )
398}
399
400/** POKER in the "ANSI Shadow" block letters, one array of rows per letter. */
401const LOGO_LETTERS = [
402  ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║     ', '╚═╝     '],
403  [' ██████╗ ', '██╔═══██╗', '██║   ██║', '██║   ██║', '╚██████╔╝', ' ╚═════╝ '],
404  ['██╗  ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║  ██╗', '╚═╝  ╚═╝'],
405  ['███████╗', '██╔════╝', '█████╗  ', '██╔══╝  ', '███████╗', '╚══════╝'],
406  ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║  ██║', '╚═╝  ╚═╝'],
407]
408const LOGO = LOGO_LETTERS[0]!.map((_, row) => LOGO_LETTERS.map((letter) => letter[row]).join(''))
409/** The columns the block-letter logo takes; a narrower pane gets it on one line. */
410export const LOGO_COLS = width(LOGO[0]!)
411
412/** The Lobby's POKER logo with a row of suits under it: block letters in the accent, their shadow dim. */
413export function drawLogo(els: Els, columns: number) {
414  const suits: [string, Ink?][] = [['♠', { bold: true }], ['   '], ['♥', { color: 'red', bold: true }], ['   '], ['♦', { color: 'red', bold: true }], ['   '], ['♣', { bold: true }]]
415  if (columns < LOGO_COLS) {
416    const canvas = new Canvas(columns, 1)
417    canvas.center(Math.floor(columns / 2), 0, [['♠ ', { bold: true }], ['POKER', { color: ACCENT, bold: true }], [' ♥', { color: 'red', bold: true }]])
418    return canvas.draw(els, 'logo')
419  }
420  const canvas = new Canvas(LOGO_COLS, LOGO.length + 2)
421  LOGO.forEach((line, y) => {
422    let x = 0
423    for (const ch of line) x = canvas.put(x, y, ch, ch === '█' ? { color: ACCENT, bold: true } : ch === ' ' ? {} : { color: ACCENT, dim: true })
424  })
425  canvas.center(Math.floor(LOGO_COLS / 2), LOGO.length + 1, suits)
426  return canvas.draw(els, 'logo')
427}
428
ui/i18n.ts 70 lines
1import type { Lang } from '../types'
2export type { Lang }
3
4const STRINGS = {
5  title: { zh: '德州扑克', en: "Texas Hold'em" },
6  you: { zh: '你', en: 'You' },
7  bankroll: { zh: '余额', en: 'Bankroll' },
8  hands: { zh: '手数', en: 'Hands' },
9  net: { zh: '盈亏', en: 'Net' },
10  pot: { zh: '底池', en: 'Pot' },
11  board: { zh: '公共牌', en: 'Board' },
12  fold: { zh: '弃牌', en: 'Fold' },
13  check: { zh: '过牌', en: 'Check' },
14  call: { zh: '跟注', en: 'Call' },
15  bet: { zh: '下注', en: 'Bet' },
16  raise: { zh: '加注', en: 'Raise' },
17  allIn: { zh: '全下', en: 'All-in' },
18  folded: { zh: '已弃牌', en: 'Folded' },
19  isAllIn: { zh: '全下', en: 'All-in' },
20  nextHand: { zh: '立即开始', en: 'Deal now' },
21  autoNext: { zh: '下一手即将自动开始…', en: 'Next hand coming up…' },
22  rebuy: { zh: '补码', en: 'Rebuy' },
23  leave: { zh: '离桌', en: 'Leave' },
24  size9: { zh: '9人桌', en: '9-max' },
25  size6: { zh: '6人桌', en: '6-max' },
26  size2: { zh: '单挑', en: 'Heads-up' },
27  buyIn: { zh: '买入', en: 'Buy-in' },
28  chooseTable: { zh: '选择牌桌入座', en: 'Pick a table to sit down' },
29  topUp: { zh: '领取筹码', en: 'Top-up' },
30  amount: { zh: '金额', en: 'Amount' },
31  halfPot: { zh: '½底池', en: '½ Pot' },
32  fullPot: { zh: '底池', en: 'Pot' },
33  waiting: { zh: '等待其他玩家…', en: 'Waiting for others…' },
34  yourTurn: { zh: '轮到你了', en: 'Your turn' },
35  wins: { zh: '赢得', en: 'wins' },
36  split: { zh: '平分', en: 'split' },
37  mainPot: { zh: '主池', en: 'Main pot' },
38  sidePot: { zh: '边池', en: 'Side pot' },
39  busted: { zh: '你的筹码输光了。', en: 'You are out of chips.' },
40  broke: { zh: '余额不足一次买入,可以领取筹码。', en: 'Not enough for a buy-in: take a Top-up.' },
41  alertPermission: { zh: '⚠ Claude 需要你的授权', en: '⚠ Claude needs your permission' },
42  alertQuestion: { zh: '⚠ Claude 在问你问题', en: '⚠ Claude has a question for you' },
43  alertDone: { zh: '✓ Claude 已完成本轮', en: '✓ Claude finished its turn' },
44  settings: { zh: '设置', en: 'Settings' },
45  language: { zh: '语言 中文', en: 'Language English' },
46  cardStyle: { zh: '牌面', en: 'Cards' },
47  unit: { zh: '单位', en: 'Units' },
48  chips: { zh: '筹码', en: 'chips' },
49  toCallHint: { zh: '需跟', en: 'to call' },
50  potOdds: { zh: '底池赔率', en: 'Pot odds' },
51  logTitle: { zh: '行动记录', en: 'Action log' },
52  preflop: { zh: '翻前', en: 'Pre' },
53  flop: { zh: '翻牌', en: 'Flop' },
54  turn: { zh: '转牌', en: 'Turn' },
55  river: { zh: '河牌', en: 'River' },
56  settle: { zh: '结算', en: 'Result' },
57  statsTitle: { zh: '战绩', en: 'Stats' },
58  topUps: { zh: '领取次数', en: 'Top-ups' },
59  biggestPot: { zh: '最大赢池', en: 'Biggest pot' },
60} as const
61
62export type StringKey = keyof typeof STRINGS
63export const t = (lang: Lang, key: StringKey) => STRINGS[key][lang]
64
65const CATEGORY_NAMES = {
66  zh: ['高牌', '一对', '两对', '三条', '顺子', '同花', '葫芦', '四条', '同花顺'],
67  en: ['High card', 'One pair', 'Two pair', 'Three of a kind', 'Straight', 'Flush', 'Full house', 'Four of a kind', 'Straight flush'],
68} as const
69export const categoryName = (lang: Lang, category: number) => CATEGORY_NAMES[lang][category] ?? ''
70
bots/equity.ts 34 lines
1import { compare, evaluate, type Card } from '../engine/evaluate'
2import { shuffle, type Rng } from '../engine/table'
3
4/**
5 * The share of the pot `hole` wins against `opponents` random hands, by
6 * playing the rest of the board out `iterations` times. A tie counts as the
7 * share of the pot it splits.
8 */
9export function equity(hole: Card[], board: Card[], opponents: number, iterations: number, rng: Rng): number {
10  const known = new Set<Card>([...hole, ...board])
11  const rest = shuffle(rng).filter((c) => !known.has(c))
12  let won = 0
13  for (let i = 0; i < iterations; i++) {
14    // a partial shuffle: only the cards this run deals are drawn
15    const need = opponents * 2 + (5 - board.length)
16    for (let k = 0; k < need; k++) {
17      const j = k + Math.floor(rng() * (rest.length - k))
18      ;[rest[k], rest[j]] = [rest[j]!, rest[k]!]
19    }
20    const runout = [...board, ...rest.slice(opponents * 2, need)]
21    const mine = evaluate([...hole, ...runout])
22    let ties = 1
23    let lost = false
24    for (let o = 0; o < opponents && !lost; o++) {
25      const theirs = evaluate([rest[o * 2]!, rest[o * 2 + 1]!, ...runout])
26      const c = compare(mine, theirs)
27      if (c < 0) lost = true
28      else if (c === 0) ties++
29    }
30    if (!lost) won += 1 / ties
31  }
32  return won / iterations
33}
34
bots/preflop.ts 47 lines
1import type { Card } from '../engine/evaluate'
2
3const RANKS = '23456789TJQKA'
4const rank = (c: Card) => RANKS.indexOf(c.charAt(0))
5
6/** Bill Chen's starting-hand score: the high card, doubled for a pair, plus suitedness, less the gap. */
7function chen(high: number, low: number, suited: boolean): number {
8  const points = [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 6, 7, 8, 10]
9  if (high === low) return Math.max(5, points[high]! * 2)
10  let score = points[high]! + (suited ? 2 : 0)
11  const gap = high - low - 1
12  score -= [0, 1, 2, 4][gap] ?? 5
13  if (gap <= 1 && high < RANKS.indexOf('Q')) score += 1
14  return Math.ceil(score)
15}
16
17/** The 169 starting hands, strongest first, each with where its combos end, as a percent of all 1326. */
18const LADDER: { key: string; upTo: number }[] = (() => {
19  const hands: { key: string; score: number; high: number; low: number; combos: number }[] = []
20  for (let high = 0; high < 13; high++) {
21    for (let low = 0; low <= high; low++) {
22      if (high === low) hands.push({ key: `${high}-${low}p`, score: chen(high, low, false), high, low, combos: 6 })
23      else {
24        hands.push({ key: `${high}-${low}s`, score: chen(high, low, true), high, low, combos: 4 })
25        hands.push({ key: `${high}-${low}o`, score: chen(high, low, false), high, low, combos: 12 })
26      }
27    }
28  }
29  hands.sort((a, b) => b.score - a.score || b.high - a.high || b.low - a.low)
30  let total = 0
31  return hands.map((h) => ({ key: h.key, upTo: ((total += h.combos) / 1326) * 100 }))
32})()
33
34const UP_TO = new Map(LADDER.map((h) => [h.key, h.upTo]))
35
36function keyOf(hole: Card[]): string {
37  const [a, b] = [rank(hole[0]!), rank(hole[1]!)].sort((x, y) => y - x) as [number, number]
38  if (a === b) return `${a}-${b}p`
39  return `${a}-${b}${hole[0]!.charAt(1) === hole[1]!.charAt(1) ? 's' : 'o'}`
40}
41
42/** Where a starting hand ranks, as the percent of all hands at least as strong (pocket aces about 0.5). */
43export const percentile = (hole: Card[]) => UP_TO.get(keyOf(hole))!
44
45/** Whether a starting hand is among the strongest `percent` of all starting hands. */
46export const inTopPercent = (hole: Card[], percent: number) => percentile(hole) <= percent + 1e-9
47
engine/evaluate.ts 79 lines
1import type { Card, Rank, Suit } from '../types'
2export type { Card, Rank, Suit }
3
4export const Category = {
5  HighCard: 0,
6  OnePair: 1,
7  TwoPair: 2,
8  ThreeOfAKind: 3,
9  Straight: 4,
10  Flush: 5,
11  FullHouse: 6,
12  FourOfAKind: 7,
13  StraightFlush: 8,
14} as const
15export type Category = (typeof Category)[keyof typeof Category]
16
17/** `ranks` are the five cards' tiebreak ranks, most significant first (2 = 0 … A = 12). */
18export type HandValue = { category: Category; ranks: number[] }
19
20const RANKS = '23456789TJQKA'
21const rankOf = (c: Card) => RANKS.indexOf(c.charAt(0))
22
23/** Highest rank topping a five-card run in `ranks`, the ace also playing low; -1 if none. */
24function straightHigh(ranks: number[]): number {
25  const present = new Set(ranks)
26  if (present.has(12)) present.add(-1)
27  for (let top = 12; top >= 3; top--) {
28    let run = true
29    for (let r = top; r > top - 5; r--) if (!present.has(r)) run = false
30    if (run) return top
31  }
32  return -1
33}
34
35export function evaluate(cards: Card[]): HandValue {
36  const bySuit = new Map<string, number[]>()
37  const counts = new Map<number, number>()
38  for (const c of cards) {
39    const r = rankOf(c)
40    counts.set(r, (counts.get(r) ?? 0) + 1)
41    bySuit.set(c.charAt(1), [...(bySuit.get(c.charAt(1)) ?? []), r])
42  }
43  const flush = [...bySuit.values()].find((rs) => rs.length >= 5)?.sort((a, b) => b - a)
44  const flushStraight = flush ? straightHigh(flush) : -1
45  if (flushStraight >= 0) return { category: Category.StraightFlush, ranks: [flushStraight] }
46
47  // ranks grouped by multiplicity, bigger groups first, then higher ranks
48  const groups = [...counts.entries()].sort((a, b) => b[1] - a[1] || b[0] - a[0])
49  const first = groups[0]!
50  const second = groups[1]
51  const kickers = (exclude: number[], n: number) =>
52    [...counts.keys()].filter((r) => !exclude.includes(r)).sort((a, b) => b - a).slice(0, n)
53
54  if (first[1] === 4) return { category: Category.FourOfAKind, ranks: [first[0], ...kickers([first[0]], 1)] }
55  if (first[1] === 3 && (second?.[1] ?? 0) >= 2) {
56    // a second set of trips plays as the pair; take the best pair among the rest
57    const pair = Math.max(...groups.slice(1).filter(([, n]) => n >= 2).map(([r]) => r))
58    return { category: Category.FullHouse, ranks: [first[0], pair] }
59  }
60  if (flush) return { category: Category.Flush, ranks: flush.slice(0, 5) }
61  const straight = straightHigh([...counts.keys()])
62  if (straight >= 0) return { category: Category.Straight, ranks: [straight] }
63  if (first[1] === 3) return { category: Category.ThreeOfAKind, ranks: [first[0], ...kickers([first[0]], 2)] }
64  if (first[1] === 2 && second?.[1] === 2) {
65    const pairs = [first[0], second[0]]
66    return { category: Category.TwoPair, ranks: [...pairs, ...kickers(pairs, 1)] }
67  }
68  if (first[1] === 2) return { category: Category.OnePair, ranks: [first[0], ...kickers([first[0]], 3)] }
69  return { category: Category.HighCard, ranks: kickers([], 5) }
70}
71
72export function compare(a: HandValue, b: HandValue): -1 | 0 | 1 {
73  if (a.category !== b.category) return a.category > b.category ? 1 : -1
74  for (let i = 0; i < a.ranks.length; i++) {
75    if (a.ranks[i] !== b.ranks[i]) return a.ranks[i]! > b.ranks[i]! ? 1 : -1
76  }
77  return 0
78}
79
ui/canvas.tsx 113 lines
1import type { Elements } from 'claude-code'
2
3export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Input'>
4
5/** How a run of cells is drawn. */
6export type Ink = { color?: string; dim?: boolean; bold?: boolean; inverse?: boolean }
7
8type Cell = { ch: string; ink: Ink }
9
10/** CJK and fullwidth characters take two terminal columns; everything this table draws otherwise takes one. */
11function isWide(ch: string): boolean {
12  const c = ch.codePointAt(0)!
13  return (
14    (c >= 0x1100 && c <= 0x115f) ||
15    (c >= 0x2e80 && c <= 0xa4cf) ||
16    (c >= 0xac00 && c <= 0xd7a3) ||
17    (c >= 0xf900 && c <= 0xfaff) ||
18    (c >= 0xfe30 && c <= 0xfe4f) ||
19    (c >= 0xff00 && c <= 0xff60) ||
20    (c >= 0xffe0 && c <= 0xffe6)
21  )
22}
23
24/** The columns `text` takes in a terminal. */
25export const width = (text: string) => [...text].reduce((w, ch) => w + (isWide(ch) ? 2 : 1), 0)
26
27/** A grid of character cells written by position, then drawn as one Text per row. */
28export class Canvas {
29  private rows: (Cell | null)[][]
30
31  constructor(
32    readonly cols: number,
33    readonly height: number,
34  ) {
35    this.rows = Array.from({ length: height }, () => Array.from({ length: cols }, (): Cell | null => ({ ch: ' ', ink: {} })))
36  }
37
38  /** Writes `text` from column `x`; a wide character covers the cell after it too. Off-grid cells are dropped. */
39  put(x: number, y: number, text: string, ink: Ink = {}): number {
40    const row = this.rows[y]
41    if (!row) return x
42    for (const ch of text) {
43      const wide = isWide(ch)
44      if (x >= 0 && x + (wide ? 1 : 0) < this.cols) {
45        row[x] = { ch, ink }
46        if (wide) row[x + 1] = null
47      }
48      x += wide ? 2 : 1
49    }
50    return x
51  }
52
53  /** Writes runs of differently inked text one after another; returns the column after the last. */
54  spans(x: number, y: number, runs: [string, Ink?][]): number {
55    for (const [text, ink] of runs) x = this.put(x, y, text, ink)
56    return x
57  }
58
59  /** Writes runs centered on column `cx`. */
60  center(cx: number, y: number, runs: [string, Ink?][]) {
61    const w = runs.reduce((sum, [text]) => sum + width(text), 0)
62    return this.spans(cx - Math.floor(w / 2), y, runs)
63  }
64
65  /** Writes runs ending at column `right` (inclusive). */
66  right(right: number, y: number, runs: [string, Ink?][]) {
67    const w = runs.reduce((sum, [text]) => sum + width(text), 0)
68    return this.spans(right - w + 1, y, runs)
69  }
70
71  /** A rounded box outline from (x, y) spanning `w` by `h` cells. */
72  box(x: number, y: number, w: number, h: number, ink: Ink = {}) {
73    this.put(x, y, '╭' + '─'.repeat(w - 2) + '╮', ink)
74    for (let i = 1; i < h - 1; i++) {
75      this.put(x, y + i, '│', ink)
76      this.put(x + w - 1, y + i, '│', ink)
77    }
78    this.put(x, y + h - 1, '╰' + '─'.repeat(w - 2) + '╯', ink)
79  }
80
81  /** One Text per row, each a series of inked runs. */
82  draw(els: Els, key: string) {
83    const { Box, Text } = els
84    const same = (a: Ink, b: Ink) => a.color === b.color && a.dim === b.dim && a.bold === b.bold && a.inverse === b.inverse
85    return (
86      <Box key={key} flexDirection="column">
87        {this.rows.map((row, y) => {
88          const runs: { text: string; ink: Ink }[] = []
89          for (const cell of row) {
90            if (!cell) continue
91            const last = runs[runs.length - 1]
92            if (last && same(last.ink, cell.ink)) last.text += cell.ch
93            else runs.push({ text: cell.ch, ink: cell.ink })
94          }
95          return (
96            <Text key={`${key}-${y}`} wrap="truncate">
97              {runs.map((r) =>
98                r.ink.color || r.ink.dim || r.ink.bold || r.ink.inverse ? (
99                  <Text color={r.ink.color} dimColor={r.ink.dim} bold={r.ink.bold} inverse={r.ink.inverse}>
100                    {r.text}
101                  </Text>
102                ) : (
103                  r.text
104                ),
105              )}
106            </Text>
107          )
108        })}
109      </Box>
110    )
111  }
112}
113