Slots, European roulette and blackjack in your terminal. Bet the tokens Claude Code burned today. No real money, just questionable decisions.

<h1 align="center">gamble with claude code</h1>
a casino right inside Claude Code. slots, roulette and blackjack — and you bet the tokens Claude Code actually burned today.
<h2 align="center">what's this about?</h2>
you burn millions of tokens a day anyway. might as well gamble them.
[!IMPORTANT] no real money. not real tokens either: you don't spend them and you can't win them. your "gambling tokens" are just the tokens Claude burned working today.
<h2 align="center">install</h2>
/plugin marketplace add szarkans/gamble-with-claude-code
/plugin install gamble-with-claude-code@gamble-with-claude-code
or
claude plugin marketplace add szarkans/gamble-with-claude-code
claude plugin install gamble-with-claude-code@gamble-with-claude-code
then /reload-plugins (or restart Claude Code) and
/casino
requirements: Claude Code 2.1.287+ (the one with mods) and python3. make the terminal ~50 rows tall, or scroll the panel with PageDown.
want new games without lifting a finger? /plugin → Marketplaces → gamble-with-claude-code → Enable auto-update.
<h2 align="center">games</h2>
slots — three reels, 777 pays ×20, rm -rf ×3 pays ×50.
roulette — european, 0–36. red/black, even/odd, halves, dozens, or a single number
blackjack — dealer stands on 17, blackjack pays 3:2, double down. no split. yet.
<h2 align="center">controls</h2>
z x c — slots, roulette, blackjack 1–4 — bet 10%, 25%, 50%, all in s — spin / deal / stand h — hit, d — double esc or q — back to work
mouse clicks work too, where your terminal reports them.
<h2 align="center">how are tokens counted?</h2>
from Claude Code's own session logs on your machine (~/.claude/projects, or $CLAUDE_CONFIG_DIR). today's input + cache writes + output. cache reads don't count — re-reading the same context isn't work.
nothing leaves your machine. the mod makes zero network calls.
<h2 align="center">also</h2>
not claude code's user or don't like in-terminal view? check out gamble-your-tokens - same casino but in browser!

not affiliated with Anthropic. just a fan with a token problem.
hooks/register.tsx 116 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Earned, Game, Hand, Phase, RouletteBet } from '../types'
4import type { Host, Snapshot } from './host'
5import { chooseFraction, chooseGame, chooseNumber, chooseRoulette, close, debugRound, isDebug, newHand, open, playHand, restore, roll, spin } from './controller'
6import { PANEL_ROWS, renderPanel } from './panel'
7import { chips } from './chips'
8const PANE = 'gamble-with-claude-code'
9
10const earned = atom({ plugin: 'gamble-with-claude-code', key: 'earned' } as const, { total: 0, date: '', midnight: 0 } as Earned, { shape: 'v1' })
11const net = atom({ plugin: 'gamble-with-claude-code', key: 'net' } as const, 0, { shape: 'v1' })
12const frac = atom({ plugin: 'gamble-with-claude-code', key: 'frac' } as const, 0.1, { shape: 'v1' })
13const reels = atom({ plugin: 'gamble-with-claude-code', key: 'reels' } as const, [0, 1, 2], { shape: 'v1' })
14const phase = atom({ plugin: 'gamble-with-claude-code', key: 'phase' } as const, 'idle' as Phase, { shape: 'v1' })
15const msg = atom({ plugin: 'gamble-with-claude-code', key: 'msg' } as const, 'Gambling is bad. So is --dangerously-skip-permissions.', { shape: 'v1' })
16const game = atom({ plugin: 'gamble-with-claude-code', key: 'game' } as const, 'slot' as Game, { shape: 'v1' })
17const rouletteBet = atom({ plugin: 'gamble-with-claude-code', key: 'rouletteBet' } as const, { kind: 'red' } as RouletteBet, { shape: 'v1' })
18const rouletteNumber = atom({ plugin: 'gamble-with-claude-code', key: 'rouletteResult' } as const, 0, { shape: 'v1' })
19const roulettePick = atom({ plugin: 'gamble-with-claude-code', key: 'roulettePick' } as const, 0, { shape: 'v1' })
20const hand = atom({ plugin: 'gamble-with-claude-code', key: 'hand' } as const, null as Hand | null, { shape: 'v1' })
21const columns = atom({ plugin: 'gamble-with-claude-code', key: 'columns' } as const, 64, { shape: 'v1' })
22
23async function snapshot($: EngineInterface): Promise<Snapshot> {
24 return {
25 earned: await read($, earned), net: await read($, net), frac: await read($, frac), reels: await read($, reels),
26 phase: await read($, phase), msg: await read($, msg), game: await read($, game),
27 rouletteBet: await read($, rouletteBet), rouletteNumber: await read($, rouletteNumber), roulettePick: await read($, roulettePick), hand: await read($, hand),
28 }
29}
30
31async function activate($: EngineInterface, key: string) {
32 // Оба пути ввода проверяют свежий снимок; Client и клавиатурный мост могут отстать от него.
33 const control = chips(await snapshot($), isDebug()).flatMap(g => g.chips).find(c => c.key === key)
34 if (!control || control.disabled) return
35 const api = host($), intent = control.intent
36 switch (intent.kind) {
37 case 'game': await chooseGame(api, intent.value); break
38 case 'fraction': await chooseFraction(api, intent.value); break
39 case 'roulette': await chooseRoulette(api, intent.value); break
40 case 'number': await chooseNumber(api, intent.step); break
41 case 'debug': await debugRound(api, intent.value); break
42 case 'spin': await spin(api); break
43 case 'roll': await roll(api); break
44 case 'deal': await newHand(api); break
45 case 'hit': case 'stand': case 'double': await playHand(api, intent.kind); break
46 case 'close': await $.ui.close({ id: PANE }); break
47 }
48}
49
50// Все обращения к $ видны валидатору; логика и отрисовка живут отдельно.
51function host($: EngineInterface): Host {
52 return {
53 earned: { get: () => read($, earned), set: v => update($, earned, () => v) },
54 net: { get: () => read($, net), set: v => update($, net, () => v) },
55 frac: { get: () => read($, frac), set: v => update($, frac, () => v) },
56 reels: { get: () => read($, reels), set: v => update($, reels, () => v) },
57 phase: { get: () => read($, phase), set: v => update($, phase, () => v) },
58 msg: { get: () => read($, msg), set: v => update($, msg, () => v) },
59 game: { get: () => read($, game), set: v => update($, game, () => v) },
60 rouletteBet: { get: () => read($, rouletteBet), set: v => update($, rouletteBet, () => v) },
61 rouletteNumber: { get: () => read($, rouletteNumber), set: v => update($, rouletteNumber, () => v) },
62 roulettePick: { get: () => read($, roulettePick), set: v => update($, roulettePick, () => v) },
63 hand: { get: () => read($, hand), set: v => update($, hand, () => v) },
64 columns: { get: () => read($, columns), set: v => update($, columns, () => v) },
65 countTokens: async () => {
66 let failed
67 for (const command of [['python3'], ['python'], ['py', '-3']]) {
68 try {
69 const result = await $.process.run([...command, '-I', `${$.plugin.root}/tools/count_today.py`], { timeoutMs: 15000 })
70 if (result.exitCode === 0) return result
71 // Windows App Execution Alias и Python 2 могут запуститься, но Python 3 за ними нет.
72 if (result.exitCode === 9009 || /Python was not found|No installed Python|Unknown option: -I/i.test(result.stderr)) continue
73 failed = result
74 } catch { /* Не установлен или недоступен: пробуем следующий запуск. */ }
75 }
76 if (failed) return failed
77 throw new Error('Python 3 required in PATH: python3, python or py -3. Cannot count tokens.')
78 },
79 debugFlag: () => $.env.get('GWCC_DEBUG'),
80 storeGet: key => $.store.get(key), storeSet: (key, value) => $.store.set(key, value),
81 now: () => $.clock.now(), every: (ms, fn) => $.clock.every(ms, fn),
82 paneIsOpen: async () => (await $.ui.panes()).some(pane => pane.id === PANE),
83 play: name => $.audio.play({ asset: `sounds/${name}.wav` }),
84 blit: cells => $.ui.blit({ requestId: PANE, key: 'stage', cells }),
85 // Запрашиваем ещё две строки DEBUG; renderer отдаёт ей только свободное место.
86 openPane: () => $.ui.open({ id: PANE, title: 'GAMBLE WITH CLAUDE CODE', focus: true, closeOnEscape: true, holdToasts: true, columns: 88, rows: PANEL_ROWS + 2 }),
87 }
88}
89
90export const register: Register = on => {
91 on('session.start', async ($, e, next) => {
92 await $.command.register({ name: 'casino', description: 'Slots, roulette and blackjack on today\'s Claude Code tokens' })
93 await restore(host($))
94 return next(e)
95 })
96 on('command.run', { command: 'casino' }, async $ => {
97 const opened = await open(host($))
98 return { text: opened ? 'Casino open. Gambling is bad.' : 'Casino failed to open. Check Python 3 in PATH and local storage; retry /casino.' }
99 })
100 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
101 if (e.surface !== 'terminal') {
102 const { Text } = $.ui.resolve(e)
103 return <Text>Casino needs a regular Claude Code 2.1.287+ terminal. Run /casino there.</Text>
104 }
105 return renderPanel($.ui.resolve(e), await snapshot($), e.props.bodyColumns, key => activate($, key), e.props.scroll.bodyRows)
106 })
107 on('ui.message', { component: 'Pane', requestId: PANE, surface: 'terminal' }, async ($, e, next) => {
108 if (e.element !== 'controls' && e.element !== 'navigation') return next(e)
109 if (!e.data || typeof e.data !== 'object' || !('control' in e.data) || typeof e.data.control !== 'string') return {}
110 await activate($, e.data.control)
111 return {}
112 })
113 on('ui.close', { id: PANE }, ($, e, next) => { close(); return next(e) })
114 on('session.end', ($, e, next) => { close(); return next(e) })
115}
116hooks/host.ts 25 lines1import type { ProcessRunResult, Timer } from 'claude-code'
2import type { Earned, Game, Hand, Phase, RouletteBet } from '../types'
3
4export const busyPhase = (p: Phase) => p === 'spinning' || p === 'dealing' || p === 'effect'
5
6export type Snapshot = {
7 earned: Earned; net: number; frac: number; reels: number[]; phase: Phase; msg: string
8 game: Game; rouletteBet: RouletteBet; rouletteNumber: number; roulettePick: number; hand: Hand | null
9}
10type Value<T> = { get: () => Promise<T>; set: (value: T) => Promise<unknown> }
11// Валидатор API следует за $ только в одном файле. Здесь передаём узкие вызовы.
12export type Host = { [K in keyof Snapshot]: Value<Snapshot[K]> } & {
13 countTokens: () => Promise<ProcessRunResult>
14 debugFlag: () => Promise<string | undefined>
15 storeGet: (key: string) => Promise<unknown>
16 storeSet: (key: string, value: unknown) => Promise<void>
17 now: () => Promise<number>
18 every: (ms: number, fn: () => void) => Timer
19 columns: Value<number>
20 paneIsOpen: () => Promise<boolean>
21 play: (name: string) => Promise<void>
22 blit: (cells: string) => Promise<unknown>
23 openPane: () => Promise<unknown>
24}
25hooks/controller.ts 428 lines1import type { Timer } from 'claude-code'
2import type { Host } from './host'
3import { busyPhase } from './host'
4import type { Earned, Game, Phase } from '../types'
5import type { Hand } from './blackjack'
6import type { RouletteBet } from './roulette'
7import type { Day } from './economy'
8import type { Effect } from './effects'
9import type { Frame } from './raster'
10import { blackjackPayout, canDouble, deal, dealerStep, doubleDown, hit, natural, score, shuffledDeck } from './blackjack'
11import { balanceOf, dayValue, stake } from './economy'
12import { FRAME_MS, effectDuration, effectFrame } from './effects'
13import { encode } from './raster'
14import { ROULETTE_MS, roulettePayout, rouletteResult, wheelIndex, wheelPosition } from './roulette'
15import { debugDeck, debugEnabled, debugKey, debugRoulette, debugSlot } from './debug'
16import type { DebugOutcome } from './debug'
17import { fmt, payout, spinResult } from './slot'
18import { blackjackFrame } from './views/blackjack'
19import { rouletteFrame } from './views/roulette'
20import { slotFrame } from './views/slot'
21
22const WIN = ["You're absolutely right!", 'No tests. Just confidence.', 'It was the cache all along.', 'LGTM. Ship it.']
23const LOSE = ['I apologize for the confusion.', 'Task complete. Only the bugs remain.', 'Works on my machine.', 'Let me try one more fix…']
24const pick = (xs: string[]) => xs[Math.floor(Math.random() * xs.length)]!
25let columns: number | undefined, opened = false, locked = false
26let animation: Timer | undefined, poll: Timer | undefined
27let generation = 0, liveFrame: ((width: number) => Frame) | undefined
28let debugging = false
29let pendingGame: (() => Promise<unknown>) | undefined
30
31export const setColumns = (n: number) => { columns = Math.max(1, Math.min(512, Math.floor(n))) }
32export const currentFrame = (width: number) => liveFrame?.(width)
33export const isDebug = () => debugging
34const message = (api: Host, s: string) => api.msg.set(s)
35const setPhase = (api: Host, p: Phase) => api.phase.set(p)
36const sound = (api: Host, name: string) => { void api.play(name).catch(() => {}) }
37
38async function sync(api: Host) {
39 const er = await api.earned.get()
40 const day = dayValue(await api.storeGet(debugKey(er.date, debugging)))
41 await api.net.set(day.net)
42 const current = await api.hand.get()
43 await api.hand.set(day.hand ?? (current?.status === 'done' ? current : null))
44 return day
45}
46
47async function change(api: Host, date: string, fn: (day: Day) => Day | null, show = true) {
48 const key = debugKey(date, debugging)
49 const next = fn(dayValue(await api.storeGet(key)))
50 if (!next) return null
51 await api.storeSet(key, next)
52 if (show && (await api.earned.get()).date === date) {
53 await api.net.set(next.net)
54 await api.hand.set(next.hand)
55 }
56 return next
57}
58
59async function refresh(api: Host) {
60 const { exitCode, stdout } = await api.countTokens()
61 if (exitCode !== 0) throw new Error('Claude Code token counter unavailable')
62 const er = JSON.parse(stdout) as Earned
63 if (!Number.isSafeInteger(er.total) || er.total < 0 || !/^\d{4}-\d{2}-\d{2}$/.test(er.date) || !Number.isFinite(er.midnight)) {
64 throw new Error('Claude Code token counter returned invalid data')
65 }
66 if (debugging) er.total = 10000
67 const previous = await api.earned.get()
68 if (previous.date && er.date !== previous.date) await api.hand.set(null)
69 await api.earned.set(er)
70 const day = await sync(api)
71 if (previous.date && er.date !== previous.date) {
72 await message(api, 'Midnight. Yesterday\'s tokens expired. Fresh context, fresh regrets.')
73 if (!animation) await setPhase(api, 'idle')
74 }
75 return { day, er }
76}
77
78async function guarded(api: Host, fn: () => Promise<void>, passive = false) {
79 if (locked) return false
80 locked = true
81 try { await fn(); return true }
82 catch (error) {
83 if (!passive) {
84 await message(api, error instanceof Error && error.message.startsWith('Python 3 required') ? error.message :
85 'Casino API tripped. Tokens safe; retry /casino.')
86 if (!animation) await setPhase(api, 'idle')
87 }
88 return false
89 } finally {
90 locked = false
91 const pending = pendingGame
92 pendingGame = undefined
93 await pending?.()
94 }
95}
96
97async function animate(api: Host, duration: number, paint: (t: number, width: number) => Frame,
98 done: () => Promise<void>) {
99 animation?.cancel()
100 const id = ++generation, start = await api.now()
101 let elapsed = 0, drawing = false
102 liveFrame = width => paint(Math.min(elapsed, duration), width)
103 const timer = api.every(FRAME_MS, () => {
104 if (drawing) return
105 drawing = true
106 void (async () => {
107 elapsed = Math.max(0, (await api.now()) - start)
108 if (id !== generation) return
109 // Ширина переживает reload в state; render подставляет актуальную ширину панели.
110 const savedWidth = await api.columns.get(), width = columns ?? savedWidth
111 if (width !== savedWidth) await api.columns.set(width)
112 if (id !== generation) return
113 try {
114 if (opened) await api.blit(encode(paint(Math.min(elapsed, duration), width)))
115 } catch {
116 timer.cancel()
117 if (id !== generation) return
118 animation = undefined; liveFrame = undefined
119 await message(api, 'Dropped frame. Saved result waiting in /casino.')
120 await setPhase(api, 'idle')
121 return
122 }
123 if (id !== generation) return
124 if (elapsed >= duration) {
125 // Не теряем колбэк, если /casino или опрос удерживает блокировку: следующий кадр повторит попытку.
126 if (locked) return
127 timer.cancel()
128 animation = undefined
129 liveFrame = undefined
130 await guarded(api, done)
131 }
132 })().catch(async () => {
133 timer.cancel()
134 if (id !== generation) return
135 animation = undefined; liveFrame = undefined
136 await message(api, 'Casino API tripped. Tokens safe; retry /casino.')
137 await setPhase(api, 'idle')
138 }).finally(() => { drawing = false })
139 })
140 animation = timer
141}
142
143async function celebrate(api: Host, base: (width: number) => Frame, effect: Effect, date: string) {
144 if (effect.won === 0) {
145 await message(api, 'Bet returned. Zero diff. Great animation.')
146 await setPhase(api, 'idle')
147 return
148 }
149 await setPhase(api, 'effect')
150 const win = effect.won > 0
151 const expired = (await api.earned.get()).date !== date
152 const samples = Array.from({ length: 72 }, () => Math.random())
153 sound(api, win ? effect.banner === 'JACKPOT' || effect.banner === 'BLACKJACK' ? 'jackpot' : 'win' : 'lose')
154 await message(api, `${effect.won > 0 ? '+' : '−'}${fmt(Math.abs(effect.won))} tokens${expired ? ` for ${date} (expired)` : ''}. ${pick(win ? WIN : LOSE)}`)
155 await animate(api, effectDuration(effect), (t, width) => {
156 let i = 0
157 return effectFrame(base(width), effect, t, () => samples[i++ % samples.length]!)
158 },
159 async () => { await setPhase(api, win ? 'win' : 'lose') })
160}
161
162async function betInfo(api: Host, forced = false) {
163 if (busyPhase(await api.phase.get())) return null
164 const { day, er } = await refresh(api)
165 const h = day.hand
166 if (h && h.status !== 'done') {
167 await message(api, 'Finish your blackjack hand. The dealer blocked your merge.')
168 return null
169 }
170 // Любую отладочную сцену можно повторить даже после проигрыша ALL IN.
171 if (debugging && forced) await change(api, er.date, () => ({ v: 1, net: 0, hand: null }))
172 const bet = stake(balanceOf(er.total, debugging && forced ? 0 : day.net), await api.frac.get())
173 if (bet <= 0) {
174 await setPhase(api, 'broke')
175 await message(api, '429: out of tokens. More Claude Code work, more bad decisions.')
176 return null
177 }
178 return { bet, date: er.date }
179}
180
181async function startAutomatic(api: Host, date: string, bet: number, won: number, text: string) {
182 await setPhase(api, 'spinning')
183 const before = await api.net.get()
184 // Автоматические игры фиксируют результат до шоу: reload не теряет выигрыш.
185 await change(api, date, day => ({ ...day, net: day.net + won - bet }), false)
186 await api.net.set(before - bet)
187 await message(api, `Bet ${fmt(bet)} tokens. ${text}`)
188 sound(api, 'spin')
189}
190
191const automaticEffect = (won: number, bet: number): Effect => ({
192 banner: won / bet >= 10 ? 'JACKPOT' : won > 0 ? 'WIN' : 'LOSE', won: won - bet, bet,
193})
194
195export const spin = (api: Host, forced?: DebugOutcome) => guarded(api, async () => {
196 const info = await betInfo(api, !!forced)
197 if (!info) return
198 const { bet, date } = info, result = debugging && forced ? debugSlot(forced) : spinResult(Math.random), won = payout(result, bet)
199 await startAutomatic(api, date, bet, won, 'Generating result…')
200 await api.reels.set(result)
201 let stopped = 0
202 await animate(api, 1600, (t, width) => {
203 const spinning = [700, 1150, 1600].map(end => t < end)
204 const count = spinning.filter(x => !x).length
205 if (count > stopped) { stopped = count; sound(api, 'click') }
206 return slotFrame(width, result.map((n, i) => spinning[i] ? Math.floor(Math.random() * 5) : n), spinning)
207 }, async () => {
208 await sync(api)
209 await celebrate(api, w => slotFrame(w, result), automaticEffect(won, bet), date)
210 })
211})
212
213export const roll = (api: Host, forced?: DebugOutcome) => guarded(api, async () => {
214 const info = await betInfo(api, !!forced)
215 if (!info) return
216 const { bet, date } = info, choice = await api.rouletteBet.get()
217 const result = debugging && forced ? debugRoulette(choice, forced) : rouletteResult(Math.random)
218 const won = roulettePayout(result, choice, bet)
219 await startAutomatic(api, date, bet, won, 'Ball deployed to production…')
220 await api.rouletteNumber.set(result)
221 let last = -1
222 await animate(api, ROULETTE_MS, (t, width) => {
223 const index = wheelIndex(result, t)
224 if (index !== last) { last = index; sound(api, 'click') }
225 return rouletteFrame(width, result, choice, t < ROULETTE_MS ? wheelPosition(result, t) : undefined)
226 }, async () => {
227 await animate(api, 720, (t, width) => rouletteFrame(width, result, choice, undefined, t), async () => {
228 await sync(api)
229 await celebrate(api, w => rouletteFrame(w, result, choice), automaticEffect(won, bet), date)
230 })
231 })
232})
233
234async function saveHand(api: Host, date: string, h: Hand, extra = 0) {
235 // Только новая раздача создаёт руку. Устаревший колбэк не воскрешает рассчитанную.
236 return change(api, date, day => !day.hand || h.status === 'done' ? null :
237 ({ ...day, net: day.net - extra, hand: h }))
238}
239
240async function finishHand(api: Host, date: string, h: Hand) {
241 const done = { ...h, status: 'done' as const }, won = blackjackPayout(done)
242 const paid = await change(api, date, day => day.hand ? { ...day, net: day.net + won, hand: null } : null)
243 if (!paid) return
244 if ((await api.earned.get()).date === date) await api.hand.set(done)
245 if (won === h.bet) {
246 await message(api, 'PUSH. Even the house won\'t merge your PR.')
247 await setPhase(api, 'idle')
248 return
249 }
250 await celebrate(api, w => blackjackFrame(w, done), {
251 banner: score(h.player) > 21 ? 'BUST' : natural(h.player) ? 'BLACKJACK' : won > 0 ? 'WIN' : 'LOSE',
252 won: won - h.bet, bet: h.bet,
253 }, date)
254}
255
256async function dealer(api: Host, date: string, h: Hand, flip = true) {
257 if (h.status === 'done') return
258 h = { ...h, status: 'dealer' }
259 if (!await saveHand(api, date, h)) return
260 await setPhase(api, 'dealing')
261 sound(api, 'click')
262 await animate(api, flip ? 480 : 360, (t, width) => blackjackFrame(width, h,
263 { player: h.player.length, dealer: h.dealer.length, reveal: !flip || t >= 240,
264 flip: flip ? t / 480 : 1, dealerSlide: flip ? 0 : (1 - t / 360) * 8 }), async () => {
265 const next = dealerStep(h)
266 if (next.status === 'done') await finishHand(api, date, next)
267 else await dealer(api, date, next, false)
268 })
269}
270
271export const newHand = (api: Host, forced?: DebugOutcome) => guarded(api, async () => {
272 const info = await betInfo(api, !!forced)
273 if (!info) return
274 const scenario = debugging ? forced : undefined
275 const h = deal(info.bet, scenario ? debugDeck(scenario) : shuffledDeck(Math.random))
276 const reserved = await change(api, info.date, day => day.hand ? null :
277 ({ ...day, net: day.net - info.bet, hand: h }))
278 if (!reserved) return
279 await setPhase(api, 'dealing')
280 await message(api, `Bet ${fmt(info.bet)} tokens. No cards cast as any.`)
281 let dealt = 0
282 await animate(api, 1000, (t, width) => {
283 const count = Math.min(4, Math.floor(t / 200))
284 if (count > dealt) { dealt = count; sound(api, 'click') }
285 const slide = count > 0 && t < 1000 ? (1 - (t % 200) / 200) * 8 : 0
286 return blackjackFrame(width, h, {
287 player: Math.ceil(count / 2), dealer: Math.floor(count / 2), reveal: false,
288 playerSlide: count % 2 ? slide : 0, dealerSlide: count % 2 ? 0 : slide,
289 })
290 }, async () => {
291 if (natural(h.player) || natural(h.dealer) || scenario === 'win') await dealer(api, info.date, h)
292 else if (scenario === 'lose') {
293 await setPhase(api, 'playing')
294 await takeHandAction(api, 'hit')
295 }
296 else { await setPhase(api, 'playing'); await message(api, 'Dealer waiting. Your context window is finite.') }
297 })
298})
299
300async function takeHandAction(api: Host, action: 'hit' | 'stand' | 'double') {
301 if (busyPhase(await api.phase.get())) return
302 const { day, er } = await refresh(api), h = day.hand
303 if (!h || h.status !== 'player') return
304 const balance = balanceOf(er.total, day.net)
305 if (action === 'double' && !canDouble(h, balance)) {
306 await message(api, 'Double: first two cards, with tokens for one more bet.')
307 return
308 }
309 const next = action === 'hit' ? hit(h) : action === 'double' ? doubleDown(h, balance) : { ...h, status: 'dealer' as const }
310 if (!await saveHand(api, er.date, next, action === 'double' ? h.bet : 0)) return
311 if (action === 'stand') { await dealer(api, er.date, next); return }
312 await setPhase(api, 'dealing')
313 sound(api, 'click')
314 await animate(api, 360, (t, width) => blackjackFrame(width, next, {
315 player: t < 180 ? h.player.length : next.player.length, dealer: h.dealer.length, reveal: false,
316 playerSlide: t < 180 ? 0 : (1 - (t - 180) / 180) * 8,
317 }), async () => {
318 if (next.status === 'dealer') await dealer(api, er.date, next)
319 else await setPhase(api, 'playing')
320 })
321}
322export const playHand = (api: Host, action: 'hit' | 'stand' | 'double') => guarded(api, () => takeHandAction(api, action))
323
324export function chooseGame(api: Host, g: Game): Promise<unknown> {
325 // Колбэк расчёта держит locked во время await: сохраняем последний выбор вкладки.
326 if (locked) { pendingGame = () => chooseGame(api, g); return Promise.resolve(false) }
327 return guarded(api, async () => {
328 if (await api.game.get() === g) return
329 const day = await sync(api), h = day.hand
330 if (h && h.status !== 'done') { await message(api, 'Finish your hand. The dealer remembers your promises.'); return }
331 stopAnimation()
332 // Автоматический результат уже в store; меняем только показ, без повторной выплаты.
333 await api.game.set(g)
334 await setPhase(api, 'idle')
335 await message(api, {
336 slot: 'Three reels. Zero tests.',
337 roulette: 'The house always has root.',
338 blackjack: 'Dealer stands on 17. CI just hangs.',
339 }[g])
340 })
341}
342
343export const chooseFraction = (api: Host, f: number) => guarded(api, async () => {
344 if (busyPhase(await api.phase.get())) return
345 await api.frac.set(f)
346})
347export const chooseRoulette = (api: Host, bet: RouletteBet) => guarded(api, async () => {
348 if (busyPhase(await api.phase.get())) return
349 if (bet.kind === 'number') await api.roulettePick.set(bet.value)
350 await api.rouletteBet.set(bet)
351})
352
353export const chooseNumber = (api: Host, step: -1 | 1) => guarded(api, async () => {
354 if (busyPhase(await api.phase.get())) return
355 const bet = await api.rouletteBet.get()
356 const n = ((bet.kind === 'number' ? bet.value : await api.roulettePick.get()) + step + 37) % 37
357 await api.roulettePick.set(n)
358 if (bet.kind === 'number') await api.rouletteBet.set({ kind: 'number', value: n })
359})
360
361function stopAnimation() {
362 animation?.cancel(); animation = undefined; liveFrame = undefined; generation++
363}
364
365function stopTimers() {
366 stopAnimation()
367 poll?.cancel(); poll = undefined
368}
369
370function startPolling(api: Host) {
371 poll = api.every(5000, () => { if (!animation) void guarded(api, async () => { await refresh(api) }, true) })
372}
373
374async function continueHand(api: Host, day: Day, date: string) {
375 const h = day.hand
376 if (h && h.status !== 'done') await api.game.set('blackjack')
377 await setPhase(api, h?.status === 'player' ? 'playing' : 'idle')
378 if (h && (h.status === 'dealer' || h.status === 'player' && (natural(h.player) || natural(h.dealer)))) {
379 await dealer(api, date, h)
380 }
381}
382
383export const open = (api: Host) => guarded(api, async () => {
384 debugging = debugEnabled(await api.debugFlag())
385 stopTimers()
386 let current: Awaited<ReturnType<typeof refresh>>
387 try { current = await refresh(api) }
388 catch (error) {
389 if (error instanceof Error && error.message.startsWith('Python 3 required')) {
390 opened = true
391 await api.openPane()
392 startPolling(api)
393 }
394 throw error
395 }
396 opened = true
397 await setPhase(api, 'idle')
398 await api.openPane()
399 startPolling(api)
400 await continueHand(api, current.day, current.er.date)
401})
402
403// session.start повторяется при reload; таймеры старой среды движок уже отменил.
404export const restore = (api: Host) => guarded(api, async () => {
405 stopTimers()
406 columns = undefined
407 debugging = debugEnabled(await api.debugFlag())
408 opened = await api.paneIsOpen()
409 if (!opened) return
410 await setPhase(api, 'idle')
411 startPolling(api)
412 const { day, er } = await refresh(api)
413 await message(api, 'Casino reloaded. Your tokens survived the deploy.')
414 await continueHand(api, day, er.date)
415})
416
417export async function debugRound(api: Host, outcome: DebugOutcome) {
418 if (!debugging) return
419 const game = await api.game.get()
420 return game === 'slot' ? spin(api, outcome) : game === 'roulette' ? roll(api, outcome) : newHand(api, outcome)
421}
422
423export function close() {
424 opened = false
425 poll?.cancel(); poll = undefined
426 // Раунд заканчивается и без панели; незаконченная рука лежит в store.
427}
428hooks/panel.tsx 51 lines1import type { Elements } from 'claude-code'
2import type { Snapshot } from './host'
3import { currentFrame, isDebug, setColumns } from './controller'
4import { encode } from './raster'
5import { blackjackFrame } from './views/blackjack'
6import { rouletteFrame } from './views/roulette'
7import { slotFrame } from './views/slot'
8import { dashboard, dashboardLayout } from './dashboard'
9import { chipLayout, chips } from './chips'
10
11// В 200×50 Claude Code может оставить 37 строк тела; лимит задачи — 38.
12export const PANEL_ROWS = 37
13
14export function renderPanel(elements: Elements['terminal'], data: Snapshot, columns: number, activate: (key: string) => Promise<unknown>, availableRows = PANEL_ROWS) {
15 const { Box, Button, Client, Raster, Text } = elements
16 const { phase: ph, msg: m, game: g, reels,
17 rouletteBet: choice, rouletteNumber: number, hand } = data
18 const width = Math.max(1, Math.min(88, Math.floor(columns)))
19 const groups = chips(data, isDebug())
20 const shortcuts = groups.flatMap(g => g.chips)
21 const tabs = groups.filter(g => g.kind === 'tabs'), controls = groups.filter(g => g.kind !== 'tabs')
22 setColumns(width)
23 const picture = currentFrame(width) ?? (g === 'slot' ? slotFrame(width, ph === 'spinning' ? [0, 1, 2] : reels, [ph === 'spinning', ph === 'spinning', ph === 'spinning']) :
24 g === 'roulette' ? rouletteFrame(width, number, choice, ph === 'spinning' ? 0 : undefined) :
25 blackjackFrame(width, hand, ph === 'dealing' && hand ? { player: hand.player.length, dealer: hand.dealer.length, reveal: false } : undefined))
26 // Отладка добавляется после консоли и не отнимает место у обычной панели.
27 const controlsRows = chipLayout(controls.filter(g => g.kind !== 'debug'), width).height
28 const debugRows = chipLayout(controls, width).height - controlsRows
29 const extraRows = Math.min(debugRows, Math.max(0, availableRows - PANEL_ROWS))
30 const sceneRows = Math.max(picture.rows, PANEL_ROWS - chipLayout(tabs, width).height - dashboardLayout(data, width).rows - 2 - controlsRows)
31 return <Box flexDirection="column" width={width} flexShrink={0}>
32 <Client key="navigation" module="./chip-client.tsx" width={width}
33 props={{ width, groups: tabs, shortcuts }} />
34 {dashboard(elements, data, width)}
35 <Text dimColor wrap="truncate-end">
36 {width >= 53 ? "Bet today's Claude Code burn. More work, more tokens." : "Today's Claude Code burn"}
37 </Text>
38 <Box flexDirection="row" justifyContent="center" alignItems="center" width={width} height={sceneRows} flexShrink={0}>
39 <Raster key="stage" columns={picture.columns} rows={picture.rows} cells={encode(picture)} />
40 </Box>
41 <Text bold wrap="truncate-end">{m}</Text>
42 <Client key="controls" module="./chip-client.tsx" width={width} height={controlsRows + extraRows}
43 props={{ width, groups: controls, shortcuts }} />
44 {/* Client получает клавиши после клика; мост держит хоткеи самого диалога с открытия. */}
45 <Box key="hotkeys" width={0} height={0} overflow="hidden" flexShrink={0}>
46 {shortcuts.map(chip => <Button key={chip.key} plain label={chip.label} hotkey={chip.hotkey}
47 onPress={() => activate(chip.key)} />)}
48 </Box>
49 </Box>
50}
51hooks/chips.ts 163 lines1import type { Game, RouletteBet } from '../types'
2import type { Snapshot } from './host'
3import { busyPhase } from './host'
4import { balanceOf } from './economy'
5import { canDouble } from './blackjack'
6import { debugChoices } from './debug'
7import type { DebugOutcome } from './debug'
8
9export type Intent =
10 | { kind: 'game'; value: Game } | { kind: 'fraction'; value: number }
11 | { kind: 'roulette'; value: RouletteBet } | { kind: 'debug'; value: DebugOutcome }
12 | { kind: 'number'; step: -1 | 1 }
13 | { kind: 'spin' | 'roll' | 'deal' | 'hit' | 'stand' | 'double' | 'close' }
14export type Chip = {
15 key: string; label: string; hotkey: string; selected: boolean; disabled: boolean
16 primary: boolean; color: string; intent: Intent
17}
18export type ChipGroup = { kind: 'tabs' | 'console' | 'bet' | 'debug'; label?: string; chips: Chip[] }
19export type ChipProps = { width: number; groups: ChipGroup[]; shortcuts: Chip[] }
20export type PlacedChip = Chip & { x: number; y: number; width: number; lines: string[] }
21export type PlacedLabel = { key: string; text: string; x: number; y: number; width: number }
22
23export function chips(data: Snapshot, debug: boolean): ChipGroup[] {
24 const busy = busyPhase(data.phase), activeHand = !!data.hand && data.hand.status !== 'done'
25 const balance = balanceOf(data.earned.total, data.net)
26 const chip = (key: string, label: string, hotkey: string, intent: Intent,
27 disabled = busy, selected = false, primary = false, color = '#adc0d5'): Chip =>
28 ({ key, label, hotkey, intent, disabled, selected, primary, color })
29 const tabs = (['slot', 'roulette', 'blackjack'] as const).map((g, i) =>
30 chip(`tab-${g}`, ['SLOTS', 'ROULETTE', 'BLACKJACK'][i]!, ['z', 'x', 'c'][i]!, { kind: 'game', value: g },
31 activeHand, data.game === g))
32 tabs.push(chip('close', 'EXIT', 'q', { kind: 'close' }, false))
33 const stakes = [0.1, 0.25, 0.5, 1].map((f, i) =>
34 chip(`frac-${i + 1}`, ['10%', '25%', '50%', 'ALL'][i]!, String(i + 1), { kind: 'fraction', value: f },
35 busy || activeHand || balance === 0, data.frac === f))
36 const noBet = busy || activeHand || balance === 0
37 const playing = data.game === 'blackjack' && data.hand?.status === 'player'
38 const actions = playing ? [
39 chip('hit', 'HIT', 'h', { kind: 'hit' }, busy, false, true),
40 chip('stand', 'STAND', 's', { kind: 'stand' }, busy, false, true),
41 chip('double', 'DOUBLE', 'd', { kind: 'double' }, busy || !canDouble(data.hand!, balance), false, true),
42 ] : [data.game === 'slot' ? chip('spin', 'SPIN', 's', { kind: 'spin' }, noBet, false, true) :
43 data.game === 'roulette' ? chip('roll', 'SPIN', 's', { kind: 'roll' }, noBet, false, true) :
44 chip('deal', 'DEAL', 's', { kind: 'deal' }, noBet, false, true)]
45 const groups: ChipGroup[] = [{ kind: 'tabs', chips: tabs }]
46 if (data.game === 'roulette') {
47 const current = data.rouletteBet, n = current.kind === 'number' ? current.value : data.roulettePick
48 const choice = (key: string, label: string, hotkey: string, bet: RouletteBet, color?: string) =>
49 chip(key, label, hotkey, { kind: 'roulette', value: bet }, busy,
50 current.kind === bet.kind && (!('value' in bet) || 'value' in current && current.value === bet.value), false, color)
51 groups.push(
52 { kind: 'bet', label: 'COLOR', chips: [
53 choice('red', 'RED', 'r', { kind: 'red' }, '#ff718b'), choice('black', 'BLACK', 'b', { kind: 'black' }),
54 ] },
55 { kind: 'bet', label: 'PARITY', chips: [
56 choice('even', 'EVEN', 'e', { kind: 'even' }), choice('odd', 'ODD', 'o', { kind: 'odd' }),
57 ] },
58 { kind: 'bet', label: 'RANGE', chips: [
59 choice('low', '1–18', 'l', { kind: 'low' }), choice('high', '19–36', 'u', { kind: 'high' }),
60 ] },
61 { kind: 'bet', label: 'DOZEN', chips: [
62 choice('dozen1', '1–12', 'a', { kind: 'dozen', value: 1 }),
63 choice('dozen2', '13–24', 'd', { kind: 'dozen', value: 2 }),
64 choice('dozen3', '25–36', 'f', { kind: 'dozen', value: 3 }),
65 ] },
66 { kind: 'bet', label: 'NUMBER', chips: [
67 chip('number-prev', '−', 'j', { kind: 'number', step: -1 }),
68 choice('number', String(n), 'n', { kind: 'number', value: n }, '#67efac'),
69 chip('number-next', '+', 'k', { kind: 'number', step: 1 }),
70 ] },
71 )
72 }
73 groups.push({ kind: 'console', chips: [...stakes, ...actions] })
74 if (debug) groups.push({ kind: 'debug', label: 'DEBUG', chips: debugChoices(data.game).map(c =>
75 chip(`debug-${c.outcome}`, c.label.replace('DEBUG ', ''), c.hotkey, { kind: 'debug', value: c.outcome }, busy || activeHand)) })
76 return groups
77}
78
79function chipLines(chip: Chip, width: number): string[] {
80 if (width < 3) return [chip.hotkey.padEnd(width)]
81 const inner = width - 2, label = chip.label.slice(0, inner)
82 const center = (s: string, fill: string) => {
83 const left = Math.floor((inner - s.length) / 2)
84 return fill.repeat(left) + s + fill.repeat(inner - left - s.length)
85 }
86 const border = chip.selected ? ['┏', '━', '┓', '┃', '┗', '┛'] :
87 chip.primary ? ['╔', '═', '╗', '║', '╚', '╝'] : ['╭', '─', '╮', '│', '╰', '╯']
88 return [border[0]! + border[1]!.repeat(inner) + border[2]!,
89 border[3]! + center(label, ' ') + border[3]!,
90 border[4]! + center(chip.hotkey, border[1]!) + border[5]!]
91}
92
93export function chipLayout(groups: ChipGroup[], columns: number) {
94 const width = Math.max(1, Math.min(88, Math.floor(columns))), placed: PlacedChip[] = []
95 const labels: PlacedLabel[] = []
96 let y = 0
97 const sizeOf = (c: Chip, compact = false) => compact ? c.hotkey.length + c.label.length + 1 :
98 Math.max(c.primary ? (c.key === 'hit' || c.key === 'stand' || c.key === 'double' ? 12 : 18) : c.key === 'number' ? 8 : 5,
99 c.label.length + (c.primary ? 4 : 2))
100 const span = (cs: Chip[], compact = false) => cs.reduce((n, c) => n + sizeOf(c, compact), 0) + Math.max(0, cs.length - 1)
101 const label = (key: string, text: string, x: number, top: number, room: number) => {
102 labels.push({ key, text: text.slice(0, room), x, y: top, width: Math.min(room, text.length) })
103 }
104 const flow = (cs: Chip[], left: number, top: number, room: number, compact = false) => {
105 let x = 0, row = top, rowHeight = 0
106 for (const chip of cs) {
107 const size = Math.min(room, sizeOf(chip, compact))
108 const lines = compact ? [`${chip.hotkey} ${chip.label}`.slice(0, size).padEnd(size)] : chipLines(chip, size)
109 if (x && x + size > room) { row += rowHeight; x = 0; rowHeight = 0 }
110 placed.push({ ...chip, x: left + x, y: row, width: size, lines })
111 rowHeight = Math.max(rowHeight, lines.length)
112 x += size + 1
113 }
114 return row + rowHeight - top
115 }
116 const split = (left: Chip[], right: Chip[]) => {
117 const l = span(left), r = span(right)
118 if (l + 2 + r <= width) {
119 y += Math.max(flow(left, 0, y, l), flow(right, width - r, y, r))
120 } else {
121 y += flow(left, 0, y, width)
122 y += flow(right, Math.max(0, width - r), y, Math.min(width, r))
123 }
124 }
125 const gutter = width >= 64 ? 3 : 1
126 const betGroup = (g: ChipGroup, left: number, top: number, room: number) => {
127 const labelWidth = g.label!.length + 1
128 const beside = labelWidth + span(g.chips) <= room
129 label(`label-${g.label}`, g.label!, left, top + (beside ? 1 : 0), Math.min(room, labelWidth - 1))
130 return (beside ? 0 : 1) + flow(g.chips, left + (beside ? labelWidth : 0), top + (beside ? 0 : 1),
131 room - (beside ? labelWidth : 0))
132 }
133 for (let i = 0; i < groups.length; i++) {
134 const group = groups[i]!
135 if (group.kind === 'tabs') split(group.chips.filter(c => c.key !== 'close'), group.chips.filter(c => c.key === 'close'))
136 else if (group.kind === 'console') {
137 split(group.chips.filter(c => !c.primary), group.chips.filter(c => c.primary))
138 } else if (group.kind === 'bet') {
139 // Группа переносится целиком; пять ставок занимают два ряда от 60 колонок.
140 let x = 0, rowHeight = 0
141 do {
142 const bet = groups[i]!, room = Math.min(width, bet.label!.length + 1 + span(bet.chips))
143 if (x && x + room > width) { y += rowHeight; x = 0; rowHeight = 0 }
144 rowHeight = Math.max(rowHeight, betGroup(bet, x, y, room))
145 x += room + gutter
146 i++
147 } while (groups[i]?.kind === 'bet')
148 y += rowHeight
149 i--
150 } else {
151 y++
152 label('debug-label', group.label!, 0, y, width)
153 const left = width > 6 ? 6 : 0
154 if (!left) y++
155 y += flow(group.chips, left, y, width - left, true)
156 }
157 }
158 return { width, height: y, placed, labels }
159}
160
161export const chipAt = (placed: PlacedChip[], x: number, y: number) => placed.find(c =>
162 x >= c.x && x < c.x + c.width && y >= c.y && y < c.y + c.lines.length)
163hooks/blackjack.ts 74 lines1// Одна колода на руку. Дилер стоит и на мягких 17; split и страховки нет.
2export const SUITS = ['♠', '♥', '♦', '♣'] as const
3import type { Card, Hand } from '../types'
4export type { Card, Hand } from '../types'
5
6export function score(cards: readonly Card[]): number {
7 let total = 0, aces = 0
8 for (const card of cards) {
9 total += card.rank === 1 ? 11 : Math.min(card.rank, 10)
10 if (card.rank === 1) aces++
11 }
12 while (total > 21 && aces-- > 0) total -= 10
13 return total
14}
15
16export const natural = (cards: readonly Card[]) => cards.length === 2 && score(cards) === 21
17export const dealerShouldHit = (cards: readonly Card[]) => score(cards) < 17
18export const canDouble = (hand: Hand, balance: number) =>
19 hand.status === 'player' && hand.player.length === 2 && !hand.doubled && balance >= hand.bet
20
21export function shuffledDeck(rand: () => number): Card[] {
22 const deck: Card[] = []
23 for (const suit of SUITS) for (let rank = 1; rank <= 13; rank++) deck.push({ rank, suit })
24 for (let i = deck.length - 1; i > 0; i--) {
25 const j = Math.floor(rand() * (i + 1))
26 ;[deck[i], deck[j]] = [deck[j]!, deck[i]!]
27 }
28 return deck
29}
30
31export function deal(bet: number, deck: readonly Card[]): Hand {
32 if (deck.length < 4) throw new Error('Deck is empty')
33 return {
34 player: [deck[0]!, deck[2]!], dealer: [deck[1]!, deck[3]!], deck: deck.slice(4),
35 bet, doubled: false, status: 'player',
36 }
37}
38
39export function hit(hand: Hand): Hand {
40 if (hand.status !== 'player') return hand
41 const card = hand.deck[0]
42 if (!card) throw new Error('Deck is empty')
43 const player = [...hand.player, card]
44 return { ...hand, player, deck: hand.deck.slice(1), status: score(player) >= 21 ? 'dealer' : 'player' }
45}
46
47export function doubleDown(hand: Hand, balance: number): Hand {
48 if (!canDouble(hand, balance)) throw new Error('Double unavailable')
49 const drawn = hit(hand)
50 return { ...drawn, bet: hand.bet * 2, doubled: true, status: 'dealer' }
51}
52
53export function dealerStep(hand: Hand): Hand {
54 if (!dealerShouldHit(hand.dealer) || score(hand.player) > 21 || natural(hand.player) || natural(hand.dealer)) {
55 return { ...hand, status: 'done' }
56 }
57 const card = hand.deck[0]
58 if (!card) throw new Error('Deck is empty')
59 return { ...hand, dealer: [...hand.dealer, card], deck: hand.deck.slice(1), status: 'dealer' }
60}
61
62export function blackjackPayout(hand: Hand): number {
63 const p = score(hand.player), d = score(hand.dealer)
64 if (p > 21) return 0
65 if (natural(hand.dealer)) return natural(hand.player) ? hand.bet : 0
66 if (natural(hand.player) && !hand.doubled) return Math.floor(hand.bet * 2.5)
67 if (d > 21 || p > d) return hand.bet * 2
68 return p === d ? hand.bet : 0
69}
70
71export function rankLabel(card: Card): string {
72 return ({ 1: 'A', 11: 'J', 12: 'Q', 13: 'K' } as Record<number, string>)[card.rank] ?? String(card.rank)
73}
74hooks/roulette.ts 42 lines1// Европейское колесо: зеро проигрывает всем внешним ставкам.
2export const WHEEL = [0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26] as const
3const REDS = new Set([1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36])
4import type { RouletteBet } from '../types'
5export type { RouletteBet } from '../types'
6
7export function color(n: number): 'red' | 'black' | 'green' {
8 return n === 0 ? 'green' : REDS.has(n) ? 'red' : 'black'
9}
10
11export function roulettePayout(n: number, choice: RouletteBet, bet: number): number {
12 if (!Number.isInteger(n) || n < 0 || n > 36 || bet <= 0) return 0
13 if (choice.kind === 'number') return choice.value === n ? bet * 36 : 0
14 if (n === 0) return 0
15 if (choice.kind === 'dozen') return Math.ceil(n / 12) === choice.value ? bet * 3 : 0
16 const wins = {
17 red: color(n) === 'red', black: color(n) === 'black',
18 even: n % 2 === 0, odd: n % 2 === 1, low: n <= 18, high: n >= 19,
19 }
20 return wins[choice.kind] ? bet * 2 : 0
21}
22
23export function rouletteResult(rand: () => number): number {
24 return Math.floor(rand() * 37)
25}
26
27export const ROULETTE_MS = 2800
28// Непрерывная координата ленты: два оборота и кубическое торможение.
29export function wheelPosition(n: number, elapsed: number): number {
30 const p = Math.min(1, Math.max(0, elapsed / ROULETTE_MS))
31 const target = WHEEL.indexOf(n as typeof WHEEL[number])
32 return (74 + target) * (1 - (1 - p) ** 3)
33}
34
35export const wheelIndex = (n: number, elapsed: number) => Math.floor(wheelPosition(n, elapsed)) % 37
36
37export function betLabel(choice: RouletteBet): string {
38 if (choice.kind === 'number') return `Number ${choice.value} · 35:1`
39 if (choice.kind === 'dozen') return `${(choice.value - 1) * 12 + 1}–${choice.value * 12} · 2:1`
40 return { red: 'Red', black: 'Black', even: 'Even', odd: 'Odd', low: '1–18', high: '19–36' }[choice.kind] + ' · 1:1'
41}
42hooks/economy.ts 29 lines1import type { Card, Hand } from './blackjack'
2import { SUITS } from './blackjack'
3
4// Результат и незаконченная рука сохраняются одной записью; утром другой ключ.
5export type Day = { v: 1; net: number; hand: Hand | null }
6export const balanceOf = (total: number, net: number) => Math.max(0, Math.floor(total + net))
7export const stake = (balance: number, fraction: number) =>
8 balance <= 0 ? 0 : fraction >= 1 ? balance : Math.max(1, Math.floor(balance * fraction))
9
10export function dayValue(value: unknown): Day {
11 const empty: Day = { v: 1, net: 0, hand: null }
12 if (!value || typeof value !== 'object') return empty
13 const data = value as Partial<Day>
14 // Предыдущая форма отличается лишь отсутствием версии: миграция без потери кошелька.
15 if (data.v !== undefined && data.v !== 1 || !Number.isSafeInteger(data.net)) return empty
16 if (data.hand !== null && data.hand !== undefined && !validHand(data.hand)) return empty
17 return { v: 1, net: data.net!, hand: data.hand?.status === 'done' ? null : data.hand ?? null }
18}
19
20function validHand(value: unknown): value is Hand {
21 if (!value || typeof value !== 'object') return false
22 const h = value as Partial<Hand>
23 const cards = (value: unknown): value is Card[] => Array.isArray(value) && value.every(c =>
24 c && typeof c === 'object' && Number.isInteger(c.rank) && c.rank >= 1 && c.rank <= 13 && SUITS.includes(c.suit))
25 return cards(h.player) && h.player.length >= 2 && cards(h.dealer) && h.dealer.length >= 2 && cards(h.deck) &&
26 Number.isSafeInteger(h.bet) && h.bet! > 0 && typeof h.doubled === 'boolean' &&
27 (h.status === 'player' || h.status === 'dealer' || h.status === 'done')
28}
29hooks/effects.ts 87 lines1import { BG, FG, GOLD, RED, center, frame, put, stamp, text } from './raster'
2import type { Frame } from './raster'
3import { numberPixels, numberWidth } from './glyphs'
4
5// API принимает до 120 blit/с и показывает около 60. Нам хватает 25 кадров/с.
6export const FRAME_MS = 40
7export type Banner = 'WIN' | 'JACKPOT' | 'BLACKJACK' | 'BUST' | 'LOSE'
8export type Effect = { banner: Banner; won: number; bet: number }
9const FONT: Record<string, string[]> = {
10 W: ['# #', '# #', '# #', '###', '# #'], I: ['###', ' # ', ' # ', ' # ', '###'],
11 N: ['# #', '###', '###', '###', '# #'], J: [' #', ' #', ' #', '# #', '###'],
12 A: [' # ', '# #', '###', '# #', '# #'], C: ['###', '# ', '# ', '# ', '###'],
13 K: ['# #', '## ', '# ', '## ', '# #'], P: ['## ', '# #', '## ', '# ', '# '],
14 O: ['###', '# #', '# #', '# #', '###'], T: ['###', ' # ', ' # ', ' # ', ' # '],
15 B: ['## ', '# #', '## ', '# #', '## '], L: ['# ', '# ', '# ', '# ', '###'],
16 U: ['# #', '# #', '# #', '# #', '###'], S: ['###', '# ', '###', ' #', '###'],
17 E: ['###', '# ', '###', '# ', '###'],
18}
19
20export function banner(f: Frame, label: Banner, y: number, fg = GOLD, bg = BG) {
21 const width = label.length * 4 - 1
22 if (width > f.columns) { center(f, y + 2, label, fg, bg); return }
23 const left = Math.floor((f.columns - width) / 2)
24 Array.from(label).forEach((letter, i) => {
25 FONT[letter]!.forEach((row, dy) => Array.from(row).forEach((c, dx) => {
26 put(f, left + i * 4 + dx, y + dy, c === '#' ? '█' : ' ', fg, bg)
27 }))
28 })
29}
30
31export const strength = (e: Effect) => Math.min(3, Math.max(
32 e.banner === 'JACKPOT' ? 3 : e.banner === 'BLACKJACK' ? 2 : 1,
33 Math.ceil(Math.log2(1 + Math.max(0, e.won) / Math.max(1, e.bet)) / 2)))
34export const effectDuration = (e: Effect) => e.won <= 0 ? 720 : 2000 + strength(e) * 600
35export function effectCount(e: Effect, elapsed: number) {
36 const p = Math.min(1, Math.max(0, elapsed / (effectDuration(e) - (e.won > 0 ? 800 : 160))))
37 return Math.floor(Math.abs(e.won) * (1 - (1 - p) ** 3))
38}
39
40export function effectFrame(base: Frame, e: Effect, elapsed: number, rand: () => number): Frame {
41 if (elapsed >= effectDuration(e)) return base
42 const tick = Math.floor(elapsed / FRAME_MS), level = strength(e)
43 const win = e.won > 0
44 const pulse = win && (elapsed < 1000 ? tick % 9 < 4 : tick % 18 < 3)
45 const bg = pulse ? 0x4d421b : BG
46 const out = frame(base.columns, base.rows, bg)
47 const shake = elapsed < (win ? 800 + level * 160 : 200) ? (tick % 2 ? 1 : -1) * (level === 3 ? 3 : 2) : 0
48 stamp(out, base, shake, elapsed < 640 && win ? tick % 3 - 1 : 0)
49 if (win) {
50 // Цветов немного: палитра Raster ограничена 1024 парами.
51 for (let i = 2; i < out.words.length; i += 3) if (out.words[i] === BG || out.words[i] === 0x01000000) out.words[i] = bg
52 for (let i = 0; i < 32 * level; i++) {
53 const x = Math.floor(rand() * out.columns)
54 const origin = rand() * out.rows
55 const y = (origin + tick * (i % 2 ? 0.3 : -0.4) + out.rows * 8) % out.rows
56 put(out, x, y, ['*', '+', '·', '✦', '$'][i % 5]!, [GOLD, 0x67efac, 0x80dfff][i % 3]!)
57 }
58 } else {
59 for (let i = 1; i < out.words.length; i += 3) {
60 out.words[i] = MUTED_LOSS
61 out.words[i + 1] = 0x090d15
62 }
63 if (elapsed < 240) for (let i = 0; i < 14; i++) {
64 put(out, rand() * out.columns, rand() * out.rows, ['/', '#', '?'][i % 3]!, RED)
65 }
66 }
67 // Баннер и счётчик остаются читаемыми поверх салюта.
68 const clear = (top: number, rows: number, width: number) => {
69 const room = Math.min(out.columns, width + 4), x = Math.floor((out.columns - room) / 2)
70 for (let y = top; y < top + rows; y++) text(out, x, y, ' '.repeat(room), FG, bg)
71 }
72 const bannerWidth = e.banner.length * 4 - 1
73 clear(2, 6, bannerWidth <= out.columns ? bannerWidth : e.banner.length)
74 banner(out, e.banner, 2, win ? GOLD : RED, bg)
75 const count = effectCount(e, elapsed)
76 const label = `${win ? '+' : '−'}${count} tokens`
77 clear(8, 1, label.length)
78 center(out, 8, label, win ? GOLD : RED, bg)
79 if (win) {
80 const scale = numberWidth(count, 2) <= out.columns ? 2 : 1
81 clear(9, 5, numberWidth(count, scale))
82 if (numberWidth(count, scale) <= out.columns) numberPixels(out, Math.floor((out.columns - numberWidth(count, scale)) / 2), 9, count, GOLD, scale)
83 }
84 return out
85}
86const MUTED_LOSS = 0x7890ab
87hooks/raster.ts 59 lines1// Один кадр: глиф и два цвета на клетку. Цвета явные, тема терминала не мешает.
2export const BG = 0x101827
3export const FG = 0xf1f5f9
4export const MUTED = 0xadc0d5
5export const GOLD = 0xffd166
6export const RED = 0xff718b
7export const GREEN = 0x67efac
8export type Frame = { columns: number; rows: number; words: Uint32Array }
9
10export function frame(columns: number, rows = 18, bg = BG): Frame {
11 const words = new Uint32Array(columns * rows * 3)
12 for (let i = 0; i < words.length; i += 3) {
13 words[i] = 32
14 words[i + 1] = FG
15 words[i + 2] = bg
16 }
17 return { columns, rows, words }
18}
19
20export function put(f: Frame, x: number, y: number, glyph: string, fg = FG, bg?: number) {
21 x = Math.floor(x); y = Math.floor(y)
22 if (x < 0 || x >= f.columns || y < 0 || y >= f.rows) return
23 const i = (y * f.columns + x) * 3
24 f.words[i] = glyph.charCodeAt(0)
25 f.words[i + 1] = fg
26 if (bg !== undefined) f.words[i + 2] = bg
27}
28
29export function text(f: Frame, x: number, y: number, value: string, fg = FG, bg?: number) {
30 Array.from(value).forEach((c, i) => put(f, x + i, y, c, fg, bg))
31}
32
33export function center(f: Frame, y: number, value: string, fg = FG, bg?: number) {
34 text(f, Math.max(0, Math.floor((f.columns - value.length) / 2)), y, value, fg, bg)
35}
36
37export function stamp(target: Frame, source: Frame, x: number, y: number) {
38 for (let sy = 0; sy < source.rows; sy++) for (let sx = 0; sx < source.columns; sx++) {
39 const i = (sy * source.columns + sx) * 3
40 put(target, sx + x, sy + y, String.fromCharCode(source.words[i]!), source.words[i + 1]!, source.words[i + 2]!)
41 }
42}
43
44export function encode(f: Frame): string {
45 // Формат API — little-endian, независимо от порядка байтов машины.
46 const bytes = new Uint8Array(f.words.length * 4)
47 const view = new DataView(bytes.buffer)
48 f.words.forEach((word, i) => view.setUint32(i * 4, word, true))
49 return bytes.toBase64()
50}
51
52export function decode(columns: number, rows: number, cells: string): Frame {
53 const bytes = Uint8Array.fromBase64(cells)
54 const view = new DataView(bytes.buffer)
55 const f = frame(columns, rows)
56 f.words.forEach((_, i) => { f.words[i] = view.getUint32(i * 4, true) })
57 return f
58}
59hooks/debug.ts 34 lines1import type { Card, Game } from '../types'
2import type { RouletteBet } from './roulette'
3import { WHEEL, roulettePayout } from './roulette'
4import { shuffledDeck } from './blackjack'
5
6export type DebugOutcome = 'special' | 'win' | 'lose'
7export const debugEnabled = (value: string | undefined) => value === '1'
8export const debugKey = (date: string, enabled: boolean) => `${enabled ? 'debug:' : ''}day:${date}`
9export const debugSlot = (outcome: DebugOutcome) => outcome === 'special' ? [4, 4, 4] : outcome === 'win' ? [2, 2, 2] : [0, 1, 2]
10export function debugRoulette(choice: RouletteBet, outcome: DebugOutcome) {
11 return WHEEL.find(n => outcome === 'lose' ? roulettePayout(n, choice, 100) === 0 : roulettePayout(n, choice, 100) > 100)!
12}
13
14// Та же настоящая колода из 52 уникальных карт; меняется только порядок.
15export function debugDeck(outcome: DebugOutcome): Card[] {
16 const first: Card[] = outcome === 'special' ?
17 [{ rank: 1, suit: '♥' }, { rank: 9, suit: '♣' }, { rank: 13, suit: '♠' }, { rank: 8, suit: '♦' }] :
18 outcome === 'win' ?
19 [{ rank: 10, suit: '♥' }, { rank: 10, suit: '♣' }, { rank: 12, suit: '♠' }, { rank: 7, suit: '♦' }] :
20 [{ rank: 10, suit: '♥' }, { rank: 10, suit: '♣' }, { rank: 9, suit: '♠' }, { rank: 7, suit: '♦' }, { rank: 5, suit: '♣' }]
21 return [...first, ...shuffledDeck(() => 0.5).filter(c => !first.some(f => f.rank === c.rank && f.suit === c.suit))]
22}
23
24export function debugChoices(game: Game): { outcome: DebugOutcome; label: string; hotkey: string }[] {
25 if (game === 'roulette') return [
26 { outcome: 'win', label: 'DEBUG WIN', hotkey: 't' }, { outcome: 'lose', label: 'DEBUG LOSE', hotkey: 'v' },
27 ]
28 return [
29 { outcome: 'special', label: game === 'slot' ? 'DEBUG JACKPOT' : 'DEBUG BLACKJACK', hotkey: 't' },
30 { outcome: 'win', label: 'DEBUG WIN', hotkey: 'y' },
31 { outcome: 'lose', label: game === 'slot' ? 'DEBUG LOSE' : 'DEBUG BUST', hotkey: 'v' },
32 ]
33}
34hooks/slot.ts 71 lines1// Чистая логика слота: символы, выплаты, отрисовка в ячейки Raster.
2
3// Битмапы 5×5, каждый пиксель рисуется двумя клетками в ширину (клетки терминала высокие).
4export const SYMBOLS = [
5 { name: '7', fg: 0xff2d55, bg: 0x2a0012, bmp: ['#####', ' # ', ' # ', ' # ', ' # '] },
6 { name: '$', fg: 0x39ff88, bg: 0x002a14, bmp: [' ####', '# # ', ' ### ', ' # #', '#### '] },
7 { name: 'LGTM', fg: 0x33d6ff, bg: 0x001c2a, bmp: [' #', ' # ', '# # ', ' ## ', ' # '] },
8 { name: '429', fg: 0xffcc00, bg: 0x2a2200, bmp: [' # ', ' # ', ' # ', ' ', ' # '] },
9 { name: 'rm -rf', fg: 0xff7a00, bg: 0x2a1000, bmp: ['# #', ' # # ', ' # ', ' # # ', '# #'] },
10] as const
11
12// Три одинаковых: возвращается ставка × N. Соседняя пара: ×1.5.
13export const TRIPLE = [20, 10, 5, 3, 50]
14
15export function payout(reels: readonly number[], bet: number): number {
16 const [a, b, c] = reels
17 if (a === undefined || b === undefined || c === undefined || bet <= 0) return 0
18 if (a === b && b === c) return Math.floor(bet * (TRIPLE[a] ?? 0))
19 if (a === b || b === c) return Math.floor(bet * 1.5)
20 return 0
21}
22
23export function spinResult(rand: () => number): number[] {
24 return [0, 1, 2].map(() => Math.floor(rand() * SYMBOLS.length))
25}
26
27export const REEL_W = 12
28export const GAP = 2
29export const COLS = REEL_W * 3 + GAP * 2
30export const ROWS = 5
31const DEFAULT = 0x01000000
32
33function dim(c: number, k: number): number {
34 return (((c >> 16) & 255) * k) << 16 | (((c >> 8) & 255) * k) << 8 | ((c & 255) * k)
35}
36
37// reels[i] = индекс символа или -1 (крутится: shown[i] — что мелькает).
38// glow — цвет фона щелей на победной вспышке, или DEFAULT.
39export function cells(shown: readonly number[], spinning: readonly boolean[], glow = DEFAULT): string {
40 const words = new Uint32Array(COLS * ROWS * 3)
41 let i = 0
42 for (let y = 0; y < ROWS; y++) {
43 for (let x = 0; x < COLS; x++) {
44 const reel = Math.floor(x / (REEL_W + GAP))
45 const rx = x - reel * (REEL_W + GAP)
46 let cp = 0x20, fg = DEFAULT, bg = glow
47 if (rx < REEL_W) {
48 const s = SYMBOLS[shown[reel] ?? 0]!
49 const k = spinning[reel] ? 0.45 : 1
50 bg = spinning[reel] ? dim(s.bg, 1) : s.bg
51 const px = Math.floor((rx - 1) / 2)
52 if (rx >= 1 && px < 5 && s.bmp[y]![px] === '#') {
53 cp = 0x2588
54 fg = Math.round(k * 100) === 100 ? s.fg : dim(s.fg, k)
55 }
56 }
57 words[i++] = cp
58 words[i++] = fg
59 words[i++] = bg
60 }
61 }
62 return new Uint8Array(words.buffer).toBase64()
63}
64
65export function fmt(n: number): string {
66 if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
67 if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
68 if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
69 return String(Math.floor(n))
70}
71