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

Mods for Claude Code: things that live inside the TUI, written as function-hook plugins.
| Mod | What it is |
|---|---|
poker | No-Limit Texas Hold'em against styled bots at a 9-max, 6-max or heads-up table, in a pane beside your session. |
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.
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.
/poker topup when you're broke.A♠) or boxed card faces, amounts in chips or big blinds, a scrollable action log.| Key | Action |
|---|---|
9 / 6 / 2 | sit down at a 9-max / 6-max / heads-up table (in the lobby) |
f / c / r / a | fold / check or call / min raise / all in |
1–4 | raise 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) |
n | deal the next hand now (it deals itself after 3 seconds) |
b / q | rebuy / leave the table |
k / j | scroll the action log up / down (the mouse wheel works too) |
s | open or close the settings row |
l / v / u | in settings: switch language / card style / chips or big blinds |
Esc | back to the prompt |
Commands: /poker, /poker stats, /poker lang, /poker cards, /poker topup.
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.
在 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。
无限注现金局,盲注 5/10,牌桌用框线字符绘制。在大厅里可以选择 9 人桌、6 人桌或单挑。
/poker topup 领取筹码。A♠)和方框两种样式,金额可以切换成按大盲数(BB)显示,行动记录可以上下滚动。快捷键见上方英文部分的表格。
hooks/register.tsx 282 lines1import { 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}
282bots/styled.ts 118 lines1import 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)
118engine/game.ts 187 lines1import 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}
187engine/hand.ts 242 lines1import { 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}
242engine/table.ts 102 lines1import 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
102ui/view.tsx 272 lines1import 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}
272ui/art.tsx 428 lines1import 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}
428ui/i18n.ts 70 lines1import 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] ?? ''
70bots/equity.ts 34 lines1import { 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}
34bots/preflop.ts 47 lines1import 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
47engine/evaluate.ts 79 lines1import 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}
79ui/canvas.tsx 113 lines1import 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