SLOPSHOPPER

Gamble with Claude Code

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

newpanecommandprocesstimeraudio
★ 4v0.1.0MITupdated 2026-10-03szarkans/gamble-with-claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gamble-with-claude-code
│ ┃ gamble-with-claude-code ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./chip-client.tsx │ ┃ ╔═══════════════════════════════════════════ ⏺ Read(src/auth.ts) │ ┃ ║ █████… TOKENS ⎿ Read 6 lines │ ┃ ║ burned 0 ⏺ Update(src/auth.ts) │ ┃ ║ casino +0 ⎿ Added 2 lines, removed 1 line │ ┃ ║ ALL OUT. Cache miss. ⏺ Bash(bun test) │ ┃ ╚═══════════════════════════════════════════ ⎿ 3 pass, 1 fail │ ┃ Bet today's Claude Code burn. More work, mor │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ SLOT / AGENT APPROVED │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /casino │ ┃ ⎿ gamble-with-claude-code: Casino failed to open. Check Python 3 i │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · gamble-with-claude-code
▣ client module ./chip-client.tsx ╔══════════════════════════════════════════════════════╗ ║ █████… TOKENS ║ ║ burned 0 ║ ║ casino +0 ║ ║ ALL OUT. Cache miss. ║ ╚══════════════════════════════════════════════════════╝ Bet today's Claude Code burn. More work, more tokens. SLOT / AGENT APPROVED Casino API tripped. Tokens safe; retry /casino. ▣ client module ./chip-client.tsx z: SLOTSx: ROULETTEc: BLACKJACKq: EXIT1: 10%2: 25%3: 50%4: A
README

<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.

  • every token Claude Code burned since midnight is a token in your wallet.
  • lost it all? go back to work. the wallet refills with every prompt.
  • the wallet burns at midnight. like your context window, but daily.

[!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!

gamble-your-tokens


not affiliated with Anthropic. just a fan with a token problem.

Source 19 files
hooks/register.tsx 116 lines
1import { 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}
116
hooks/host.ts 25 lines
1import 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}
25
hooks/controller.ts 428 lines
1import 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}
428
hooks/panel.tsx 51 lines
1import 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}
51
hooks/chips.ts 163 lines
1import 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)
163
hooks/blackjack.ts 74 lines
1// Одна колода на руку. Дилер стоит и на мягких 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}
74
hooks/roulette.ts 42 lines
1// Европейское колесо: зеро проигрывает всем внешним ставкам.
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}
42
hooks/economy.ts 29 lines
1import 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}
29
hooks/effects.ts 87 lines
1import { 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
87
hooks/raster.ts 59 lines
1// Один кадр: глиф и два цвета на клетку. Цвета явные, тема терминала не мешает.
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}
59
hooks/debug.ts 34 lines
1import 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}
34
hooks/slot.ts 71 lines
1// Чистая логика слота: символы, выплаты, отрисовка в ячейки 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