SLOPSHOPPER

gamba

A slot machine you spin while your agent works. Chips come only from the tokens it burns. No real money: satire.

newpanebandcommandtoastprompt
★ 2v0.4.0no licenseupdated 2026-10-04salatmaster/claude-gamba
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gamba
│ ┃ gamba ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀ ▀▀ ▀▀ ⎿ Read 6 lines │ ┃ ▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀ ▀ ▀ ▀ ▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /gamba │ ┃ Go burn some tokens, come back │ ┃ │ ┃ Balance: 0 Bet 2: ◄ 1 2 3 5 1 │ ┃ │ ┃ 1: Spin 4: Copy stats 5: Lang: auto 6: So │ ┃ │ ┃ The diff won't LGTM itself │ ┃ │ ┃ Rank Tourist Biggest win — Miss streak 0 │ ┃ │ ┃ This week: Waited on agent 0.0h Spins 0 │ 🎰 Waiting on Claude. Test your luck? 1: Yes 2: No 3: Always open 4: Don't offer ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🎰 Waiting on Claude. Test your luck? 1: Yes 2: No 3: Always open 4: Don't offer ⟨Claude Code's own drawing⟩
Pane · gamba
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀ ▀▀ ▀▀ ▀▀▀ ▀▀ ▀ ▀ ▀ ▀ ▀ ▀▀ ▀ ▀▀ ▀ ▀ ▀ ▀▀▀ Go burn some tokens, come back Balance: 0 Bet 2: ◄ 1 2 3 5 10 15 20 25 3: ► 1: Spin 4: Copy stats 5: Lang: auto 6: Sound: on 7: Auto The diff won't LGTM itself Rank Tourist Biggest win — Miss streak 0 Payout — This week: Waited on agent 0.0h Spins 0 Tokens deposit
README

🎰 claude-gamba

A slot machine inside Claude Code.<br>You spin while your agent works. Chips come only from the tokens it burns.

No real money. No payments, no deposits, no casino links. This is satire:<br>the only thing you can lose here is tokens, and you were burning those anyway.

Claude Code 2.1.287+ dependencies: 0 real money: 0 payout: 74.5%

English · Русский

<img src="docs/demo.gif" width="100%" alt="The agent reads three files; meanwhile the slot is offered above the prompt and opened: a pixel-art cabinet in the sidebar. The bet goes up to 5, and the fourth spin lands three green checks for +250 under a rain of coins">

<sub>A real session on Opus 5.5. The reels in this take were told what to land on: nobody films a slot machine until it pays.</sub>

Install

In a Claude Code session:

/plugin install gamba --marketplace salatmaster/claude-gamba

Then give your agent something to do. A line above the prompt will ask whether you want to test your luck. You do.

<sub>Needs Claude Code 2.1.287 or later, where mods start. From a shell: claude plugin marketplace add salatmaster/claude-gamba && claude plugin install gamba@claude-gamba. From a clone, for one session: claude --plugin-dir ./claude-gamba.</sub>

The deal

You can't buy chips. You can't get them for free. Your agent burns tokens, the tokens become chips, the chips become nothing. The machine pays back 74%, so you will run out, and then you will have to write another prompt. Some call this a job.

| | | | :- | :- | | 🔥 Chips are burned tokens | One chip per 2,000 tokens your agent spends, paid out while it is still working. There is no other way in. | | 🗯 It has opinions | Over a hundred deadpan lines per language. English and Russian are written separately; neither is a translation. | | 📈 It keeps score on you | Hours waited on the agent, tokens deposited, longest losing streak, rank. One key copies it for the group chat. | | 👾 It is drawn, not typed | A cabinet in pixel art, right in the terminal: reels that roll and bounce, a sign with running lights, coins raining on a big win. |

What it says

| When | It says | | :- | :- | | The agent starts working | Waiting on Claude. Test your luck? | | You spin | Let's go gambling! | | You miss | Aw dangit | | You miss seven times running | 7 misses in a row. Statistically you're due (you're not) | | Three of a kind | I MADE IT BIG!!! 🤑🤑🤑 | | You run out of chips | 90% of gamblers quit right before they hit big | | The agent pays you | Your agent just funded your habit | | The agent finishes | It finished. You didn't | | 80% of your weekly limit is gone | Please gamble responsibly (lol) |

And the line 4 puts on your clipboard:

This week: waited 6.4h on my agent, 212 spins, 1,904,220 tokens deposited. Rank: Redepositor

How to play

/gamba opens the slot at any moment, also in the middle of a turn; /ludka is the same command under its Russian name. Everything else is a digit, so it works on any keyboard layout.

| Key | What it does | | :- | :- | | <kbd>1</kbd> or <kbd>Enter</kbd> | Spin | | <kbd>2</kbd> <kbd>3</kbd> | Bet down, bet up. A click on a number picks it outright | | <kbd>4</kbd> | Copy your stats as one line | | <kbd>5</kbd> | Language: auto, English, Russian | | <kbd>6</kbd> | Sound on or off | | <kbd>7</kbd> | Auto-open: ask, always, never | | <kbd>8</kbd> | Close | | <kbd>Esc</kbd> | Keyboard back to the prompt. The slot stays; /gamba takes the keys again |

You keep typing, the agent keeps working. The slot never takes the keyboard unless you asked for it.

The machine

Three reels. The row in the middle, the one the marks on the frame point at, is the one that counts, read from the left.

| Reels | Pays, per chip bet | | :- | :- | | 🚀 🚀 🚀 shipped | 200 | | ✅ ✅ ✅ tests green | 50 | | 🍕 🍕 🍕 pizza night | 25 | | 🐛 🐛 🐛 | 10 | | 🔥 🔥 🔥 prod is on fire, 💀 💀 💀 | 6 | | the first two match | 2 | | 💰 💰 anywhere on the line | 10 free spins | | 💰 💰 💰 | 25 free spins |

The bet is 1, 2, 3, 5, 10, 15, 20 or 25 chips, and a win is the payout times the bet. With fewer chips than the bet, the spin goes all in with what you have.

💰 is the bonus. It pays nothing on the line, but two or three of them anywhere on it, on a paid spin, grant free spins: no chips taken, the stake of the spin that won them, every win doubled. Free spins grant no more free spins. One spin in about 150 gets there, and a bonus brings back 13 bets on average.

One spin in five and a half pays anything. The return is 74.5%, free spins included, computed exactly over all 9,261 outcomes and checked on 300,000 spins in the tests. The house edge is not a bug.

Chips

One chip per 2,000 burned tokens, at most 20 chips per agent turn. Chips land after every model request, so a long turn pays out while it is still running. Subagents' tokens count.

"Burned" is input + output + cache-write tokens. Cache reads are left out: every tool call re-reads the same context, and counting it twenty times is how you get thousands of spins a turn. On about 1,400 real turns this rate gives a median of 8 chips per turn.

Nothing asks you for settings at install. The rate, the cap and the limit warning threshold are yours to change later: /gamba rate 500, /gamba cap 50, /gamba warn 90. /gamba config shows where they stand.

Stats

Kept between sessions in Claude Code's own plugin store on your machine. Nothing is sent anywhere: the mod makes no network calls, and claude plugin validate on a clone lists everything it touches.

| Stat | What is counted | Window | | :- | :- | :- | | Waited on agent | Wall-clock time of your agent's turns in the main conversation | This week | | Spins | Spins | This week | | Tokens deposited | Burned tokens, as defined above | This week | | Biggest win | The largest single win, in chips | All time | | Miss streak | The longest run of misses | All time | | Payout | Chips won over chips bet | All time | | Rank | By all-time spins: Tourist, Degen at 10, Redepositor at 100, High roller at 500, The house at 2,000 | All time |

The week turns over on Monday, 00:00 UTC. /gamba stats prints the same line as text, for places that draw no panes.

The fine print

While the agent works and the slot is shut, one line above the prompt asks whether to open it: 1 yes, 2 no, 3 always open, 4 don't offer. The digits work straight from an empty prompt. Ignore the line and it leaves with the turn.

"Always" opens the slot by itself every time the agent starts, without taking the keyboard, and closing it does not undo the choice. Claude Code seats a pane nobody asked for only in a terminal 110 columns or wider; in a narrower one you get a yes/no line instead. 7 in the slot changes the same setting, which is the way back from "don't offer".

In a terminal the machine is drawn in pixels, two to a character cell: colored half blocks, no image protocol. A sidebar 44 rows tall gets the whole cabinet from the recording: the sign, the reels, the balance display. A shorter one loses the sign; under 35 rows only the reels stay in pixels, with the balance as text beside the bet, and under 20 the stats step out too. Taller ones add the stats card and the paytable.

Above the prompt the pixel reels stand on the left and everything else in a column beside them. That takes a pane 104 columns wide, a terminal of about 120. In a narrower terminal the words go under the reels: about 20 rows in all, so it happens only where the terminal is 36 rows or taller.

Where the pixels do not fit (a sidebar under 17 rows, a pane under 56 columns, a narrow terminal that is also short) and in the Desktop app, which draws none, the slot is the same machine in text and emoji.

The sidebar needs Claude Code's fullscreen layout and a terminal 110 columns or wider. Otherwise the slot sits above the prompt.

English, unless your prompts are in Cyrillic: then Russian, and it stays Russian. 5 overrides.

A clack per reel, a coin for a pair, a fanfare for three and a sad one for an empty balance. It plays on macOS only: Claude Code has no player elsewhere. The clips are generated by sounds/make.mjs, no samples from anywhere. 6 mutes.

At 80% of your weekly plan limit you get one toast per week. It is not helpful.

Add a phrase or a language

Every phrase is a line in locales/en.ts or locales/ru.ts. A meme that died: delete its line. A new one: add a line.

A new language is one file. Copy locales/en.ts, write it natively (do not translate, the jokes will not survive), and add it to LOCALES in locales/index.ts. Give it a detect pattern if prompts in that language are recognisable by script.

House rules, enforced by the tests where a test can: 60 characters at most, deadpan, never explain the joke, never repeat twice in a row, no names of real people, no brands, no links.

Not in here

Bets on what the agent will do, crash games, blackjack, roulette, leaderboards, anything over the network. And money, in any form.

Develop

claude --plugin-dir .                             # load it, hot-reloads on save
claude plugin validate .claude-plugin/plugin.json # what Claude Code reads from it
claude plugin test .                              # the tests, no session needed

No dependencies and no build step. Written against Claude Code 2.1.287. The pixels were checked by eye in xterm.js, at several sizes, docked and above the prompt, wide and narrow; other terminals should draw the same cells but nobody has looked. The Desktop app gets the text version, checked only by the test kit. The sprites are rows of letters in hooks/pixels.ts: redraw one and it is on the reels. The mods API is early access and may move between releases.

If the tests answer hooks modules are turned off in this process, that is Anthropic's remote switch for mods, not this repo. It comes back on by itself.

Source 8 files
hooks/register.ts 734 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { FALLBACK, LOCALES, detect, fill, pick } from '../locales/index'
4import type { Locale, Phrases } from '../locales/index'
5import {
6  BETS,
7  FREE_MULT,
8  FREE_SPINS,
9  OFFERS,
10  PAIR_PAY,
11  SCATTER,
12  SYMBOLS,
13  WEEK_MS,
14  applySpin,
15  burned,
16  deposit,
17  fresh,
18  payout,
19  rank,
20  rollWeek,
21  spin,
22} from './game'
23import type { Offer, Reels, Save } from './game'
24import { BOUNCE_MS, FRAME_MS, plan, where } from './reels'
25import { WIDTH, big, bulbs, layout, reelWindow } from './art'
26import type { Parts } from './art'
27import { cells, rows, scene } from './pixels'
28
29const PANE = 'gamba'
30const CABINET = 'cabinet' // the Raster's key in the pane
31const GLOW_MS = [0, 500, 1200] // how long a miss, a pair and three of a kind celebrate
32const IDLE_MS = 500 // a step of the sign's lights while nothing spins
33const LIGHT_TICKS = 3 // and while the reels run: every third frame
34const STREAK_FROM = 5
35const DEP_MS = 5000 // how long the agent line shows a deposit
36// Prompts the person wrote themselves, the ones the language is read from.
37const TYPED = ['composer', 'bridge', 'sdk']
38
39// What /gamba <word> <number> may set, and the bounds of each.
40const SETTINGS = { rate: [1, 1_000_000], cap: [1, 1000], warn: [1, 100] } as const
41let save: Save = fresh() // the store's copy as of the last load
42let queue: Promise<unknown> = Promise.resolve()
43let turn = { chips: 0, tokens: 0 } // deposited by the running turn
44let at: number[] = fresh().rest // where on the strip each reel is, a fraction while it moves
45let hits = [false, false, false] // the reels that won the last spin
46let drawn: Parts | undefined // the pixel cabinet the pane drew last; absent, it drew text
47let isSpinning = false
48let held = 0 // the part of a win the balance has not shown yet
49let granted = 0 // free spins the reels have won but not shown yet
50let glow = 0 // the payline celebrates: 1 a pair, 2 three of a kind or free spins won
51let before: Save | undefined // the records as they stood before the spin, while its reels roll
52let isLit = false // the blink of the glow
53let phase = 0 // frames drawn so far: LIGHT_TICKS of them move the running lights one bulb
54let timer: Timer | undefined // the spin, or the idle lights
55let later: Timer | undefined // the agent line's second phrase, on its way
56let line = '' // what the slot says
57let news = '' // what is going on with the agent
58let offer = '' // the question above the prompt while the agent works; '' when not asking
59
60async function load($: EngineInterface): Promise<void> {
61  const stored = (await $.store.get('save')) as Partial<Save> | undefined
62  save = { ...fresh(), ...stored }
63  // A save from before bets: every spin in it staked one chip.
64  if (stored?.wagered === undefined) save.wagered = save.spins
65}
66
67// Every change goes through here: re-read the store (another session may
68// have written), apply, write back, redraw. Changes of this session queue.
69// ponytail: two sessions writing in the same instant can still lose one
70// write between get and set. Split the save into per-field keys if it bites.
71function mutate<T>($: EngineInterface, change: (s: Save) => T): Promise<T> {
72  const run = queue.then(async () => {
73    await load($)
74    rollWeek(save, await $.clock.now())
75    const out = change(save)
76    await $.store.set('save', save)
77    $.ui.invalidate('ui.render')
78    return out
79  })
80  queue = run.catch(() => {})
81  return run
82}
83
84function locale(): Locale {
85  return LOCALES[save.lang === 'auto' ? save.detected : save.lang] ?? LOCALES[FALLBACK]!
86}
87
88function say(key: keyof Phrases, vars: Record<string, number | string> = {}): string {
89  const t = locale()
90  return fill(t, pick(t[key], key), vars)
91}
92
93function hours(t: Locale): string {
94  return (save.week.waitedMs / 3_600_000).toFixed(1).replace('.', t.decimal)
95}
96
97function share(): string {
98  const t = locale()
99  return fill(t, t.share, {
100    hours: hours(t),
101    spins: save.week.spins,
102    tokens: save.week.tokens,
103    rank: t.ranks[rank(save.spins)]!,
104  })
105}
106
107function outcome(win: number, tier: number): string {
108  if (tier === 2) return say('big')
109  if (win > 0) return say('small')
110  if (save.chips < 1 && save.free < 1) return say('broke')
111  return save.missStreak >= STREAK_FROM ? say('streak', { n: save.missStreak }) : say('miss')
112}
113
114// `byHand`: the person asked for it, so it may take the keyboard.
115function show($: EngineInterface, byHand: boolean) {
116  // Above the prompt the pane is as tall as what it draws, up to these rows.
117  return $.ui.open({ id: PANE, title: 'gamba', rows: 23, ...(byHand ? { focus: true as const } : {}) })
118}
119
120function sfx($: EngineInterface, name: 'stop' | 'win' | 'big' | 'broke'): void {
121  // No player (Linux, Windows) or no file: the slot just stays quiet.
122  if (save.sound) $.audio.play({ asset: `sounds/${name}.wav` }).catch(() => {})
123}
124
125// The cabinet as the pane's Raster takes it.
126function picture(parts: Parts): string {
127  return cells(
128    scene(parts, {
129      title: locale().title,
130      at,
131      phase: Math.floor(phase / LIGHT_TICKS),
132      frame: phase,
133      glow,
134      isLit,
135      hits,
136      balance: save.chips - held,
137      isPaying: glow > 0, // not before the reels are down: the color would give the win away
138    }),
139  )
140}
141
142// The picture moved. Pixels are repainted in place; a pane drawn in text, or
143// one whose words changed too, is drawn again.
144// ponytail: one pane at a time. With the slot on two surfaces at once the one
145// drawn last animates and the other catches up when the words change.
146function redraw($: EngineInterface, isWords: boolean): void {
147  if (drawn === undefined || isWords) $.ui.invalidate('ui.render')
148  else $.ui.blit({ requestId: PANE, key: CABINET, cells: picture(drawn) }).catch(() => {})
149}
150
151// The sign's lights keep running while the slot stands idle on screen.
152function idle($: EngineInterface): void {
153  const mine = $.clock.every(IDLE_MS, async () => {
154    phase += LIGHT_TICKS
155    const blit = drawn?.hasSign && (await $.ui.blit({ requestId: PANE, key: CABINET, cells: picture(drawn) }))
156    // No sign on screen: nothing to light until the pane draws one again.
157    if (blit && blit.deny === undefined) return
158    mine.cancel()
159    if (timer === mine) timer = undefined
160  })
161  timer = mine
162}
163
164async function pull($: EngineInterface): Promise<void> {
165  if (isSpinning) return
166  isSpinning = true
167  const reels: Reels = spin()
168  // The spin is settled and saved before the reels move: closing the pane
169  // mid-animation changes nothing.
170  const planned = plan(reels)
171  const spun = await mutate($, s => {
172    if (s.chips < 1 && s.free < 1) return undefined
173    before = { ...s, week: { ...s.week } } // the records would give the result away
174    s.rest = planned.rest // a reopened pane shows the last real result, not a made-up one
175    const wasFree = s.free > 0
176    const win = applySpin(s, reels)
177    return { win, wasFree, free: s.free, freeWon: s.freeWon }
178  })
179  if (spun === undefined) {
180    isSpinning = false
181    line = say('broke')
182    sfx($, 'broke')
183    $.ui.invalidate('ui.render')
184    return
185  }
186  const { win, wasFree, free, freeWon } = spun
187  timer?.cancel() // the last win may still be celebrating
188  glow = 0
189  held = win
190  // A paid spin that ends with free spins left has just won them.
191  granted = wasFree ? 0 : free
192  line = say('spin')
193  const tier = payout(reels) > PAIR_PAY || granted > 0 ? 2 : win > 0 ? 1 : 0
194  const down = planned.stops[2]! // the last reel stops
195  const end = down + Math.max(GLOW_MS[tier]!, BOUNCE_MS)
196  let tick = 0
197  timer = $.clock.every(FRAME_MS, () => {
198    tick += 1
199    const ms = tick * FRAME_MS
200    const isNow = (moment: number) => ms >= moment && ms - FRAME_MS < moment
201    let isWords = false
202    phase += 1
203    at = where(planned, ms)
204    if (planned.stops.some(isNow)) sfx($, 'stop')
205    if (isNow(down)) {
206      // The reels are down. The next spin may start over the celebration.
207      isSpinning = false
208      before = undefined
209      glow = tier
210      hits = reels.map((symbol, i) =>
211        granted > 0 ? symbol === SCATTER : win > 0 && (i < 2 || symbol === reels[0]),
212      )
213      // The last free spin sums them all up, its own win included.
214      const isLast = wasFree && free === 0
215      const said =
216        granted > 0 ? say('bonus', { n: granted }) : isLast ? say('bonusEnd', { n: freeWon }) : outcome(win, tier)
217      line = win > 0 && !isLast ? `${said}  +${win}` : said
218      granted = 0
219      if (tier > 0) sfx($, tier === 2 ? 'big' : 'win')
220      else if (save.chips < 1 && save.free < 1) sfx($, 'broke')
221      isWords = true
222    }
223    if (ms >= down && tier > 0) {
224      isLit = Math.floor((ms - down) / 100) % 2 === 0
225      held = Math.max(0, held - Math.ceil((win * FRAME_MS) / GLOW_MS[tier]!)) // the balance counts up
226      // Counted up in text: drawn again every third frame, the pixels in place between.
227      if (drawn?.hasBank === false && tick % 3 === 0) isWords = true
228    }
229    if (ms >= end) {
230      timer?.cancel()
231      timer = undefined
232      glow = held = 0
233      isWords = true
234    }
235    redraw($, isWords)
236  })
237}
238
239// The settings as text, with how to change them.
240function settings(): string {
241  const t = locale()
242  const l = t.labels
243  const now = `${l.rate}: ${fill(t, '{n}', { n: save.rate })} · ${l.cap}: ${save.cap} · ${l.warn}: ${save.warn}%`
244  return `${now}\n/gamba rate N · /gamba cap N · /gamba warn N`
245}
246
247export const register: Register = on => {
248  on('session.start', async ($, e, next) => {
249    await load($)
250    at = save.rest
251    $.ui.invalidate('ui.render') // a pane left open across a reload
252    await $.command.register({
253      name: 'gamba',
254      description: 'Spin the slot while your agent works',
255      argumentHint: '[stats | config | rate N | cap N | warn N]',
256      immediate: true,
257    })
258    // The same command under its Russian name.
259    await $.command.register({
260      name: 'ludka',
261      description: 'Покрутить слот, пока агент работает',
262      argumentHint: '[stats | config | rate N | cap N | warn N]',
263      immediate: true,
264    })
265    return next(e)
266  })
267
268  on('command.run', { command: ['gamba', 'ludka'] }, async ($, e) => {
269    await load($)
270    const [word = '', value = ''] = e.args.trim().split(/\s+/)
271    if (word === 'stats') return { text: share() }
272    // Nothing is asked at install: the defaults stand until one of these changes them.
273    if (word === 'config') return { text: settings() }
274    if (word === 'rate' || word === 'cap' || word === 'warn') {
275      const [least, most] = SETTINGS[word]
276      const n = Number(value)
277      if (Number.isInteger(n) && n >= least && n <= most) {
278        await mutate($, s => {
279          s[word] = n
280        })
281      }
282      return { text: settings() }
283    }
284    offer = ''
285    if (!save.seen) {
286      line = say('first')
287      await mutate($, s => {
288        s.seen = true
289      })
290    } else if (save.chips < 1 && save.free < 1 && !isSpinning) {
291      line = say('broke')
292    }
293    const opened = await show($, true)
294    // Where nothing draws a pane, the stats are still worth a line.
295    return opened.isPlaced ? {} : { text: share() }
296  })
297
298  on('prompt.submit', async ($, e, next) => {
299    const code = TYPED.includes(e.origin.kind) ? detect(e.text) : undefined
300    if (code !== undefined && code !== save.detected) {
301      line = news = '' // said in the old language
302      await mutate($, s => {
303        s.detected = code
304      })
305    }
306    return next(e)
307  })
308
309  on('turn.start', async ($, e, next) => {
310    turn = { chips: 0, tokens: 0 }
311    later?.cancel()
312    news = say('agentStart')
313    // The agent is off working. If the slot is not on screen: open it, offer
314    // it above the prompt, or leave the person alone, as they chose.
315    const isUp = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
316    // A pane nobody asked for never takes the keyboard, and a narrow terminal
317    // does not seat it at all: then the offer stands in for it.
318    const isAsking = !isUp && (save.offer === 'ask' || (save.offer === 'always' && !(await show($, false)).isPlaced))
319    offer = isAsking ? say('offer') : ''
320    $.ui.invalidate('ui.render')
321    return next(e)
322  })
323
324  // Chips arrive request by request, so a long turn pays out while it runs.
325  on('turn.step', async function* ($, e, next) {
326    const result = yield* next(e)
327    if (result.usage) {
328      const tokens = burned(result.usage)
329      // The room under the cap is read inside the queue: subagents step in parallel.
330      await mutate($, s => {
331        turn.chips += deposit(s, tokens, s.rate, s.cap - turn.chips)
332        turn.tokens += tokens
333      })
334    }
335    return result
336  })
337
338  on('turn.complete', async ($, e, next) => {
339    // A subagent's turn: its tokens were deposited step by step already.
340    if (e.agentId !== undefined) return next(e)
341    offer = '' // nothing left to wait for
342    // The deposit is the agent's news, not the reels': under the reels a
343    // "+10" reads as a win. It gets a few seconds, then the nudge back to work.
344    later?.cancel()
345    if (turn.chips > 0) {
346      news = say('dep', turn)
347      later = $.clock.after(DEP_MS, () => {
348        news = say('agentDone')
349        $.ui.invalidate('ui.render')
350      })
351    } else {
352      news = say('agentDone')
353    }
354    await mutate($, s => {
355      s.week.waitedMs += e.durationMs
356    })
357    return next(e)
358  })
359
360  on('session.measure', async ($, e, next) => {
361    const weekly = e.rateLimits
362      .filter(limit => limit.kind.startsWith('seven_day'))
363      .sort((a, b) => b.percentUsed - a.percentUsed)[0]
364    await load($)
365    if (weekly !== undefined && weekly.percentUsed >= save.warn) {
366      const now = await $.clock.now()
367      if (now >= save.warnedUntil) {
368        news = say('limit', { pct: Math.floor(weekly.percentUsed) })
369        $.ui.toast(news)
370        // Quiet until this window resets, however often it is measured.
371        const until = Date.parse(weekly.resetsAt ?? '') || now + WEEK_MS
372        await mutate($, s => {
373          s.warnedUntil = until
374        })
375      }
376    }
377    return next(e)
378  })
379
380  // The offer: one line above the prompt. Its digits work from an empty
381  // prompt, so it takes nothing from the person who ignores it.
382  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
383    if (offer === '' || e.props.hasSurvey) return next(e)
384    const { Box, Text, Button } = $.ui.resolve(e)
385    const l = locale().labels
386    // "Always" is already chosen and the terminal just would not seat the
387    // pane unasked: then the question is only whether to open it now.
388    const isChosen = save.offer === 'always'
389    const answer = (mode: Offer | undefined, isOpening: boolean) => async () => {
390      offer = ''
391      if (mode !== undefined) {
392        await mutate($, s => {
393          s.offer = mode
394        })
395      }
396      $.ui.invalidate('ui.render')
397      if (isOpening) await show($, true)
398    }
399    return Box({
400      flexDirection: 'column',
401      children: [
402        Box({
403          flexDirection: 'row',
404          flexWrap: 'wrap',
405          columnGap: 2,
406          children: [
407            Text({ color: 'yellow', children: [`🎰 ${offer}`] }),
408            Button({ key: 'offer-yes', label: l.yes, hotkey: '1', plain: true, onPress: answer(undefined, true) }),
409            Button({ key: 'offer-no', label: l.no, hotkey: '2', plain: true, onPress: answer(undefined, false) }),
410            ...(isChosen
411              ? []
412              : [
413                  Button({ key: 'offer-always', label: l.always, hotkey: '3', plain: true, onPress: answer('always', true) }),
414                  Button({ key: 'offer-never', label: l.never, hotkey: '4', plain: true, onPress: answer('never', false) }),
415                ]),
416          ],
417        }),
418        await next(e),
419      ],
420    })
421  })
422
423  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
424    const elements = $.ui.resolve(e)
425    const { Box, Text, Button } = elements
426    const t = locale()
427    const l = t.labels
428    const columns = e.props.bodyColumns
429    const isDocked = e.props.placement === 'dock'
430    // Only the terminal draws a Raster; the other surfaces take one and show nothing.
431    const height = isDocked ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 0)
432    const view = layout(e.props.placement, columns, height, e.surface === 'terminal')
433    drawn = view.cabinet
434    if (timer === undefined && drawn?.hasSign) idle($)
435    // Beside the pixel reels everything stands in a column, flush left.
436    const isAside = view.isAside === true
437    const isRoomy = view.isWide
438    const isOn = glow > 0 && isLit
439    const blink = glow > 0 ? (isLit ? 'lit' : 'dark') : undefined
440    const gold = { color: 'yellow' } as const
441    const tint = isOn ? { color: glow === 2 ? 'yellow' : 'green' } : isRoomy ? gold : {}
442    const balance = save.chips - held
443    const center = { flexDirection: 'column', alignItems: 'center' } as const
444
445    const [top, above, payline, below, bottom] = reelWindow(at.map(Math.round), isRoomy)
446    const reels = [
447      Text({ dimColor: !isOn && !isRoomy, ...tint, children: [top!] }),
448      Text({ dimColor: true, children: [above!] }),
449      Text({ bold: true, inverse: isOn && glow === 2, ...tint, children: [payline!] }),
450      Text({ dimColor: true, children: [below!] }),
451      Text({ dimColor: !isOn && !isRoomy, ...tint, children: [bottom!] }),
452    ]
453    const said = Text({ bold: true, ...(glow === 2 ? gold : {}), children: [line || ' '] })
454    const agent = Text({ dimColor: true, children: [news || ' '] })
455
456    const flow = {
457      flexDirection: 'row',
458      flexWrap: 'wrap',
459      columnGap: 2,
460      ...(isRoomy && !isAside ? ({ justifyContent: 'center' } as const) : {}),
461    } as const
462    // The stake: 2 and 3 step left and right through the stakes, a click
463    // picks one outright. The chosen one is a gold chip, the rest are dim.
464    const step = (by: number) => () =>
465      mutate($, s => {
466        const next = Math.max(0, BETS.indexOf(s.bet)) + by
467        s.bet = BETS[Math.max(0, Math.min(BETS.length - 1, next))]!
468      })
469    const bets = Box({
470      ...flow,
471      children: [
472        Text({ dimColor: true, children: [l.bet] }),
473        Button({ key: 'bet-down', label: '◄', hotkey: '2', plain: true, onPress: step(-1) }),
474        Box({
475          flexDirection: 'row',
476          columnGap: 1,
477          children: BETS.map(bet =>
478            bet === save.bet
479              ? Text({ bold: true, inverse: true, ...gold, children: [` ${bet} `] })
480              : Button({
481                  key: `bet-${bet}`,
482                  label: String(bet),
483                  plain: true,
484                  dimColor: true,
485                  onPress: () =>
486                    mutate($, s => {
487                      s.bet = bet
488                    }),
489                }),
490          ),
491        }),
492        Button({ key: 'bet-up', label: '►', hotkey: '3', plain: true, onPress: step(1) }),
493      ],
494    })
495    // While free spins last the stake is fixed: the row counts them down instead.
496    const free = save.free - granted
497    const counter = Text({
498      bold: true,
499      ...gold,
500      children: [`${l.free}: ${free} · ${l.bet} ${save.freeBet} ×${FREE_MULT}`],
501    })
502    // Digits, not letters: they press the same on any keyboard layout. The
503    // game is on 1 to 3; the settings after them are drawn dim.
504    const buttons = Box({
505      ...flow,
506      children: [
507        Button({ key: 'spin', label: l.spin, hotkey: '1', plain: true, autoFocus: true, onPress: () => pull($) }),
508        Button({
509          key: 'copy',
510          label: l.copy,
511          hotkey: '4',
512          plain: true,
513          dimColor: true,
514          onPress: async press => {
515            const copy = await $.ui.copy({ text: share(), surface: press.surface })
516            news = copy.isCopied ? l.copied : l.copyFailed
517            $.ui.invalidate('ui.render')
518          },
519        }),
520        Button({
521          key: 'lang',
522          label: `${l.lang}: ${save.lang}`,
523          hotkey: '5',
524          plain: true,
525          dimColor: true,
526          onPress: () => {
527            line = news = ''
528            return mutate($, s => {
529              const order = ['auto', ...Object.keys(LOCALES)]
530              s.lang = order[(order.indexOf(s.lang) + 1) % order.length]!
531            })
532          },
533        }),
534        Button({
535          key: 'sound',
536          label: `${l.sound}: ${save.sound ? l.on : l.off}`,
537          hotkey: '6',
538          plain: true,
539          dimColor: true,
540          onPress: () =>
541            mutate($, s => {
542              s.sound = !s.sound
543            }),
544        }),
545        Button({
546          key: 'offer',
547          label: `${l.offer}: ${l.offers[save.offer]}`,
548          hotkey: '7',
549          plain: true,
550          dimColor: true,
551          onPress: () =>
552            mutate($, s => {
553              s.offer = OFFERS[(OFFERS.indexOf(s.offer) + 1) % OFFERS.length]!
554            }),
555        }),
556        Button({
557          key: 'close',
558          label: l.close,
559          hotkey: '8',
560          plain: true,
561          dimColor: true,
562          onPress: () => $.ui.close({ id: PANE }),
563        }),
564      ],
565    })
566
567    const shown = before ?? save
568    const records: [string, number | string][] = [
569      [l.rank, t.ranks[rank(shown.spins)]!],
570      [l.maxWin, shown.maxWin > 0 ? fill(t, '{n}', { n: shown.maxWin }) : '—'],
571      [l.streak, shown.maxMissStreak],
572      [l.payout, shown.wagered > 0 ? `${Math.round((shown.won / shown.wagered) * 100)}%` : '—'],
573    ]
574    const weekly: [string, number | string][] = [
575      [l.waited, fill(t, l.hours, { hours: hours(t) })],
576      [l.spins, fill(t, '{n}', { n: shown.week.spins })],
577      [l.tokens, fill(t, '{n}', { n: save.week.tokens })],
578    ]
579
580    const pair = (width: number) => ([label, value]: [string, number | string]) =>
581      Box({
582        flexDirection: 'row',
583        justifyContent: 'space-between',
584        width,
585        children: [Text({ dimColor: true, children: [label] }), Text({ bold: true, children: [String(value)] })],
586      })
587    const list = (width: number) => [
588      ...records.map(pair(width)),
589      Box({ justifyContent: 'center', width, children: [Text({ dimColor: true, children: [`· ${l.week} ·`] })] }),
590      ...weekly.map(pair(width)),
591    ]
592    // The same numbers as two wrapping lines, for a pane with no room for a card.
593    const words = ([label, value]: [string, number | string]) =>
594      Box({
595        flexDirection: 'row',
596        columnGap: 1,
597        children: [Text({ dimColor: true, children: [label] }), Text({ bold: true, children: [String(value)] })],
598      })
599    const wrap = {
600      flexDirection: 'row',
601      flexWrap: 'wrap',
602      columnGap: 3,
603      ...(isAside ? {} : ({ justifyContent: 'center' } as const)),
604    } as const
605    const weekLine = Box({
606      ...wrap,
607      children: [Text({ dimColor: true, children: [`${l.week}:`] }), ...weekly.map(words)],
608    })
609    const lines = [Box({ ...wrap, children: records.map(words) }), weekLine]
610
611    // The balance: three rows tall where there is room, green while a win is
612    // being counted in; one line of text where there is not.
613    const bank =
614      view.bank === 'line'
615        ? Text({ bold: true, ...gold, children: [`${l.balance}: ${balance}`] })
616        : Box({
617            ...center,
618            children: [
619              ...(isAside ? [] : [Text({ dimColor: true, children: [[...l.balance.toUpperCase()].join(' ')] })]),
620              ...big(String(balance)).map(row =>
621                Text({ bold: true, color: glow > 0 ? 'green' : 'yellow', children: [row] }),
622              ),
623            ],
624          })
625
626    const width = Math.min(43, columns)
627    const card = { flexDirection: 'column', borderStyle: 'round', borderDimColor: true, paddingX: 1, width } as const
628    const lights = (shift: number) =>
629      Text({ ...gold, children: [bulbs(32, Math.floor(phase / LIGHT_TICKS) + shift, blink)] })
630    const window =
631      drawn !== undefined && 'Raster' in elements
632        ? elements.Raster({ key: CABINET, columns: WIDTH, rows: rows(drawn), cells: picture(drawn) })
633        : Box({ ...center, children: reels })
634    const isBeside = view.bank === 'beside' && !isAside
635    // Under the pixel reels every row counts: a one-line balance shares the stake's.
636    const isPurse = drawn !== undefined && view.bank === 'line'
637    const stake = Box({ ...flow, columnGap: 3, children: [...(isPurse ? [bank] : []), free > 0 ? counter : bets] })
638    const stats = [
639      ...(view.stats === 'card' ? [Box({ ...card, children: list(width - 4) })] : []),
640      ...(view.stats === 'lines' ? lines : []),
641      ...(view.stats === 'split' ? [weekLine] : []),
642    ]
643
644    if (isAside) {
645      return Box({
646        flexDirection: 'row',
647        alignItems: 'center',
648        columnGap: 3,
649        children: [
650          window,
651          Box({ flexDirection: 'column', alignItems: 'flex-start', children: [bank, said, stake, buttons, agent, ...stats] }),
652        ],
653      })
654    }
655    return Box({
656      ...(isRoomy ? { ...center, width: columns } : { flexDirection: 'column' }),
657      rowGap: view.gap,
658      children: [
659        ...(view.hasTitle
660          ? [
661              Box({
662                ...center,
663                children: [
664                  lights(0),
665                  ...big(t.title).map(row => Text({ bold: true, color: 'red', children: [row] })),
666                  lights(1),
667                ],
668              }),
669            ]
670          : view.hasLights
671            ? [lights(0)]
672            : []),
673        Box({
674          ...center,
675          children: [
676            isBeside
677              ? Box({
678                  flexDirection: 'row',
679                  alignItems: 'center',
680                  columnGap: 3,
681                  children: [
682                    window,
683                    bank,
684                    ...(view.stats === 'split'
685                      ? [Box({ flexDirection: 'column', children: records.map(pair(22)) })]
686                      : []),
687                  ],
688                })
689              : window,
690            ...(drawn === undefined ? [said] : []),
691          ],
692        }),
693        // Under the cabinet the phrase is a section of its own: the picture ends in a hard edge.
694        ...(drawn === undefined ? [] : [said]),
695        ...(isBeside || isPurse || view.bank === 'drawn' ? [] : [bank]),
696        stake,
697        Box({ ...(isRoomy ? { width: isDocked ? Math.min(columns, 58) : columns, justifyContent: 'center' } : {}), children: [buttons] }),
698        agent,
699        ...(view.hasPays
700          ? [
701              Box({
702                ...card,
703                children: [
704                  Box({
705                    flexDirection: 'row',
706                    flexWrap: 'wrap',
707                    columnGap: 3,
708                    children: SYMBOLS.filter(symbol => symbol.pay > 0).map(symbol =>
709                      Box({
710                        flexDirection: 'row',
711                        justifyContent: 'space-between',
712                        width: 11,
713                        children: [
714                          Text({ children: [symbol.emoji.repeat(3)] }),
715                          Text({ bold: true, ...gold, children: [String(symbol.pay * save.bet)] }),
716                        ],
717                      }),
718                    ),
719                  }),
720                  pair(width - 4)([l.pair, PAIR_PAY * save.bet]),
721                  pair(width - 4)([
722                    `${l.free} ×${FREE_MULT}`,
723                    [2, 3].map(n => `${SYMBOLS[SCATTER]!.emoji.repeat(n)} ${FREE_SPINS[n]}`).join(' · '),
724                  ]),
725                ],
726              }),
727            ]
728          : []),
729        ...stats,
730      ],
731    })
732  })
733}
734
locales/index.ts 97 lines
1// Phrases live in one file per language. To add a language: copy en.ts,
2// rewrite it natively (do not translate), and list it in LOCALES below.
3// To retire a stale phrase: delete its line.
4
5import en from './en'
6import ru from './ru'
7
8export type Phrases = {
9  spin: string[] // the reels start
10  miss: string[]
11  small: string[] // a pair
12  big: string[] // three of a kind
13  broke: string[] // out of chips
14  dep: string[] // chips granted: {chips}, {tokens}
15  agentStart: string[]
16  agentDone: string[]
17  streak: string[] // 5+ misses in a row: {n}
18  limit: string[] // weekly limit warning: {pct}
19  first: string[] // first launch
20  offer: string[] // above the prompt, while the agent works: open the slot?
21  bonus: string[] // scatters won free spins: {n}
22  bonusEnd: string[] // the last free spin is down: {n} chips won in them
23}
24
25export type Locale = Phrases & {
26  // Prompts with three or more matches switch the language to this one.
27  detect?: RegExp
28  // Which of a `{n:one|few|many}` placeholder's forms goes with `n`.
29  plural: (n: number) => number
30  title: string // the sign over the reels, in letters the big font has
31  group: string // thousands separator
32  decimal: string
33  labels: {
34    spin: string
35    copy: string
36    lang: string
37    close: string
38    sound: string
39    on: string
40    off: string
41    yes: string // the offer's four answers
42    no: string
43    always: string
44    never: string
45    offer: string // the pane's button for the same setting, and its three values
46    offers: { ask: string; always: string; never: string }
47    balance: string
48    rate: string // the three settings, as /gamba config lists them
49    cap: string
50    warn: string
51    bet: string
52    pair: string // the paytable's row for two alike
53    free: string // free spins: the counter while they last, and the paytable's row
54    week: string
55    waited: string
56    hours: string
57    spins: string
58    tokens: string
59    maxWin: string
60    streak: string
61    payout: string
62    rank: string
63    copied: string
64    copyFailed: string
65  }
66  share: string // the line for chat: {hours}, {spins}, {tokens}, {rank}
67  ranks: [string, string, string, string, string] // ascending
68}
69
70export const LOCALES: Record<string, Locale> = { en, ru }
71export const FALLBACK = 'en'
72
73export function detect(text: string): string | undefined {
74  return Object.keys(LOCALES).find(code => {
75    const pattern = LOCALES[code]!.detect
76    return pattern !== undefined && (text.match(pattern)?.length ?? 0) >= 3
77  })
78}
79
80const last: Record<string, string> = {}
81
82// A random phrase of the list, never the one `key` got last time.
83export function pick(list: string[], key: string, rng: () => number = Math.random): string {
84  const pool = list.length > 1 ? list.filter(phrase => phrase !== last[key]) : list
85  return (last[key] = pool[Math.floor(rng() * pool.length)]!)
86}
87
88// `{name}` becomes the value; `{name:chip|chips}` adds the right plural form.
89export function fill(locale: Locale, template: string, vars: Record<string, number | string>): string {
90  return template.replace(/\{(\w+)(?::([^}]+))?\}/g, (_, name: string, forms?: string) => {
91    const value = vars[name]
92    if (typeof value !== 'number') return String(value ?? '')
93    const text = String(value).replace(/\B(?=(\d{3})+$)/g, locale.group)
94    return forms ? `${text} ${forms.split('|')[locale.plural(value)]}` : text
95  })
96}
97
hooks/game.ts 196 lines
1// The slot and the chip economy: plain functions, no engine calls.
2
3export type Sym = { emoji: string; weight: number; pay: number }
4
5// One strip, used by all three reels. `pay` is what three of a kind returns
6// on a 1-chip bet. Emoji are Unicode 6 with default emoji presentation and no
7// variation selector, so every terminal draws them two cells wide.
8export const SYMBOLS: readonly Sym[] = [
9  { emoji: '🚀', weight: 1, pay: 200 }, // shipped
10  { emoji: '✅', weight: 2, pay: 50 }, // tests green
11  { emoji: '🍕', weight: 3, pay: 25 }, // pizza night
12  { emoji: '🐛', weight: 4, pay: 10 }, // bug
13  { emoji: '🔥', weight: 5, pay: 6 }, // prod is on fire
14  { emoji: '💀', weight: 5, pay: 6 }, // segfault
15  { emoji: '💰', weight: 1, pay: 0 }, // the bonus: a scatter, pays in free spins
16]
17
18// The scatter counts anywhere on the payline and never pays on it.
19export const SCATTER = SYMBOLS.length - 1
20// Free spins granted by 0, 1, 2 and 3 scatters on a paid spin.
21export const FREE_SPINS = [0, 0, 10, 25]
22// What a free spin's payout is multiplied by, on top of the stake that won it.
23export const FREE_MULT = 2
24
25// Two matching symbols from the left.
26export const PAIR_PAY = 2
27
28const TOTAL_WEIGHT = SYMBOLS.reduce((sum, s) => sum + s.weight, 0)
29
30export type Reels = [number, number, number]
31
32function reel(rng: () => number): number {
33  let roll = rng() * TOTAL_WEIGHT
34  return SYMBOLS.findIndex(s => (roll -= s.weight) < 0)
35}
36
37export function spin(rng: () => number = Math.random): Reels {
38  return [reel(rng), reel(rng), reel(rng)]
39}
40
41export function payout([a, b, c]: Reels): number {
42  if (a !== b || a === SCATTER) return 0
43  return b === c ? SYMBOLS[a]!.pay : PAIR_PAY
44}
45
46export function scatters(reels: Reels): number {
47  return reels.filter(symbol => symbol === SCATTER).length
48}
49
50// Expected return per chip bet, exact: every combination times its chance,
51// plus the free spins it grants, each worth a paid spin times FREE_MULT.
52export function rtp(): number {
53  let line = 0
54  let free = 0 // free spins one paid spin grants, on average
55  SYMBOLS.forEach((x, a) =>
56    SYMBOLS.forEach((y, b) =>
57      SYMBOLS.forEach((z, c) => {
58        const chance = (x.weight * y.weight * z.weight) / TOTAL_WEIGHT ** 3
59        line += chance * payout([a, b, c])
60        free += chance * FREE_SPINS[scatters([a, b, c])]!
61      }),
62    ),
63  )
64  return line + free * FREE_MULT * line
65}
66
67// When the agent starts working: offer the slot above the prompt, open it
68// without asking, or stay out of the way.
69export type Offer = 'ask' | 'always' | 'never'
70export const OFFERS: readonly Offer[] = ['ask', 'always', 'never']
71
72// The stakes on offer, in chips.
73export const BETS = [1, 2, 3, 5, 10, 15, 20, 25]
74
75export type Save = {
76  chips: number
77  dust: number // burned tokens not yet turned into a chip
78  lang: string // 'auto' or a locale code
79  detected: string // locale the prompts were written in
80  seen: boolean // the first-launch line was shown
81  offer: Offer
82  sound: boolean
83  rest: number[] // where on the strip the reels stopped last
84  week: { id: number; waitedMs: number; spins: number; tokens: number }
85  bet: number // chips a spin stakes, one of BETS
86  free: number // free spins left
87  freeBet: number // the stake of the spin that granted them
88  freeWon: number // won in the free spins so far
89  spins: number
90  wagered: number
91  won: number
92  maxWin: number
93  missStreak: number
94  maxMissStreak: number
95  warnedUntil: number // no limit warning before this time
96  // The settings, changed with /gamba rate, cap and warn.
97  rate: number // burned tokens one chip costs
98  cap: number // chips one agent turn deposits at most
99  warn: number // percent of the weekly limit that sets off the warning
100}
101
102export function fresh(): Save {
103  return {
104    chips: 0,
105    dust: 0,
106    lang: 'auto',
107    detected: 'en',
108    seen: false,
109    offer: 'ask',
110    sound: true,
111    rest: [0, 2, 5],
112    week: { id: 0, waitedMs: 0, spins: 0, tokens: 0 },
113    bet: 1,
114    free: 0,
115    freeBet: 0,
116    freeWon: 0,
117    spins: 0,
118    wagered: 0,
119    won: 0,
120    maxWin: 0,
121    missStreak: 0,
122    maxMissStreak: 0,
123    warnedUntil: 0,
124    rate: 2000,
125    cap: 20,
126    warn: 80,
127  }
128}
129
130const DAY_MS = 86_400_000
131export const WEEK_MS = 7 * DAY_MS
132
133// ponytail: weeks turn over on Monday 00:00 UTC, not local time. Take an
134// offset from the locale if anyone minds.
135export function rollWeek(s: Save, now: number): void {
136  const id = Math.floor((now / DAY_MS + 3) / 7) // 1970-01-01 was a Thursday
137  if (s.week.id !== id) s.week = { id, waitedMs: 0, spins: 0, tokens: 0 }
138}
139
140type Usage = {
141  input_tokens: number
142  output_tokens: number
143  cache_creation_input_tokens: number
144}
145
146// Tokens one request burned. Cache reads are left out: every request of a
147// turn re-reads the same context, so they would count it once per tool call.
148export function burned(u: Usage): number {
149  return u.input_tokens + u.output_tokens + u.cache_creation_input_tokens
150}
151
152// Turns burned tokens into chips, at most `room` of them; what does not make
153// a whole chip waits in `dust` for the next deposit. Returns the chips added.
154export function deposit(s: Save, tokens: number, tokensPerChip: number, room: number): number {
155  s.week.tokens += tokens
156  s.dust += tokens
157  const chips = Math.max(0, Math.min(room, Math.floor(s.dust / tokensPerChip)))
158  s.dust %= tokensPerChip
159  s.chips += chips
160  return chips
161}
162
163// One spin with the reels already drawn. The stake is the chosen bet, or all
164// the chips there are when that is less; the win is the payout times it.
165// A free spin stakes nothing and pays FREE_MULT times on the stake that won it.
166// ponytail: scatters in free spins grant nothing; add a retrigger if anyone asks.
167export function applySpin(s: Save, reels: Reels): number {
168  const isFree = s.free > 0
169  const stake = isFree ? 0 : Math.min(s.bet, s.chips)
170  const win = payout(reels) * (isFree ? s.freeBet * FREE_MULT : stake)
171  if (isFree) {
172    s.free -= 1
173    s.freeWon += win
174  } else if (FREE_SPINS[scatters(reels)]! > 0) {
175    s.free = FREE_SPINS[scatters(reels)]!
176    s.freeBet = stake
177    s.freeWon = 0
178  }
179  s.chips += win - stake
180  s.spins += 1
181  s.week.spins += 1
182  s.wagered += stake
183  s.won += win
184  s.maxWin = Math.max(s.maxWin, win)
185  s.missStreak = win > 0 ? 0 : s.missStreak + 1
186  s.maxMissStreak = Math.max(s.maxMissStreak, s.missStreak)
187  return win
188}
189
190// All-time spins needed for each rank, lowest first; names live in locales.
191const RANK_AT = [0, 10, 100, 500, 2000]
192
193export function rank(spins: number): number {
194  return RANK_AT.findLastIndex(at => spins >= at)
195}
196
hooks/reels.ts 58 lines
1// How a spin looks: the strip the reels scroll and where each reel is at any
2// moment of one spin. Show only: the result is drawn and saved before any of
3// this runs.
4
5import type { Reels } from './game'
6
7// The strip every reel scrolls, as indexes into SYMBOLS: each symbol as often
8// as its weight, so what slides past is what the reels really hold.
9export const STRIP = [4, 5, 3, 2, 5, 4, 1, 3, 5, 4, 2, 5, 3, 4, 0, 5, 3, 2, 4, 1, 6]
10
11export const FRAME_MS = 33
12const SPEED = 20 // symbols a second, at full speed
13const CRAWL = 4 // and as a reel reaches its stop
14const STOPS = [700, 1100, 1500] // when each reel stops, ms into the spin
15const BRAKE_MS = 450 // how long a reel slows down for
16const TEASE_MS = 1200 // the first two reels match: the third takes this much longer
17export const BOUNCE_MS = 180 // a stopped reel runs past its symbol and springs back
18const BOUNCE = 0.16 // by this much of a symbol
19
20// The symbols a reel at strip position `at` shows: above the line, on it, below.
21export function faces(at: number): [number, number, number] {
22  const wrap = (n: number) => STRIP[((n % STRIP.length) + STRIP.length) % STRIP.length]!
23  return [wrap(at - 1), wrap(at), wrap(at + 1)]
24}
25
26export type Plan = {
27  rest: number[] // the strip position each reel ends on
28  stops: number[] // when each reel gets there, ms into the spin
29  brakes: number[] // how long before that it starts to slow down
30}
31
32// Reels stop left to right and rest on `result`.
33export function plan(result: Reels, rng: () => number = Math.random): Plan {
34  const tease = result[0] === result[1] ? TEASE_MS : 0
35  return {
36    rest: result.map(symbol => {
37      const spots = STRIP.flatMap((face, at) => (face === symbol ? [at] : []))
38      return spots[Math.floor(rng() * spots.length)]!
39    }),
40    stops: STOPS.map((stop, i) => stop + (i === 2 ? tease : 0)),
41    brakes: STOPS.map((_, i) => BRAKE_MS + (i === 2 ? tease : 0)),
42  }
43}
44
45// The strip position of each reel `ms` into the spin, a fraction while the
46// reel moves. Reels only ever run down the strip towards their rest.
47export function where({ rest, stops, brakes }: Plan, ms: number): number[] {
48  return rest.map((spot, i) => {
49    const left = stops[i]! - ms
50    if (left <= -BOUNCE_MS) return spot
51    if (left <= 0) return spot - BOUNCE * Math.sin((Math.PI * -left) / BOUNCE_MS)
52    // Full speed, then a steady slowdown to a crawl over the last `brakes[i]`.
53    const slowing = Math.min(left, brakes[i]!)
54    const slowed = CRAWL * slowing + ((SPEED - CRAWL) * slowing * slowing) / (2 * brakes[i]!)
55    return spot + (slowed + SPEED * (left - slowing)) / 1000
56  })
57}
58
hooks/art.ts 157 lines
1// Text art for the pane: big letters, the bulb row, the reel window, and
2// which layout a pane of a given size gets. Strings only, no engine calls.
3
4import { SYMBOLS } from './game'
5import { faces } from './reels'
6
7// Five pixel rows per glyph; `big` packs them into three rows of half blocks.
8export const FONT: Record<string, string[]> = {
9  '0': ['111', '101', '101', '101', '111'],
10  '1': ['010', '110', '010', '010', '111'],
11  '2': ['111', '001', '111', '100', '111'],
12  '3': ['111', '001', '111', '001', '111'],
13  '4': ['101', '101', '111', '001', '001'],
14  '5': ['111', '100', '111', '001', '111'],
15  '6': ['111', '100', '111', '101', '111'],
16  '7': ['111', '001', '001', '001', '001'],
17  '8': ['111', '101', '111', '101', '111'],
18  '9': ['111', '101', '111', '001', '111'],
19  G: ['111', '100', '101', '101', '111'],
20  A: ['111', '101', '111', '101', '101'],
21  M: ['10001', '11011', '10101', '10001', '10001'],
22  B: ['110', '101', '110', '101', '110'],
23  Л: ['00100', '01010', '01010', '10001', '10001'],
24  У: ['101', '101', '111', '001', '110'],
25  Д: ['01110', '01010', '01010', '11111', '10001'],
26  К: ['101', '101', '110', '101', '101'],
27  А: ['111', '101', '111', '101', '101'],
28}
29
30// `text` three rows tall. Characters the font lacks are skipped.
31export function big(text: string): [string, string, string] {
32  const glyphs = [...text].flatMap(char => (FONT[char] ? [FONT[char]] : []))
33  const row = (r: number) =>
34    glyphs
35      .map(glyph =>
36        [...glyph[0]!]
37          .map((_, x) => {
38            const top = glyph[r * 2]?.[x] === '1'
39            const bottom = glyph[r * 2 + 1]?.[x] === '1'
40            return top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '
41          })
42          .join(''),
43      )
44      .join(' ')
45  return [row(0), row(1), row(2)]
46}
47
48// A row of bulbs `width` cells wide: every third one lit, the lit ones
49// moving with `phase`. `blink` lights or darkens the whole row instead.
50export function bulbs(width: number, phase: number, blink?: 'lit' | 'dark'): string {
51  return Array.from({ length: Math.floor((width + 1) / 2) }, (_, i) =>
52    blink === 'lit' || (blink === undefined && (i + phase) % 3 === 0) ? '●' : '○',
53  ).join(' ')
54}
55
56// The reel window as lines of equal width: frame, three rows of the strip,
57// frame. The middle row is the payline and carries the markers.
58export function reelWindow(at: number[], isWide: boolean): string[] {
59  const pad = ' '.repeat(isWide ? 3 : 2)
60  const bar = (isWide ? '═' : '─').repeat(pad.length * 2 + 2)
61  const [left, right, side, cross] = isWide ? ['► ', ' ◄', '║', '║'] : ['>', '<', '│', '│']
62  const blank = ' '.repeat(left.length)
63  const frame = (a: string, b: string, c: string) => `${blank}${a}${[bar, bar, bar].join(b)}${c}${blank}`
64  const row = (r: 0 | 1 | 2) =>
65    `${side}${at.map(spot => `${pad}${SYMBOLS[faces(spot)[r]]!.emoji}${pad}`).join(cross)}${side}`
66  return [
67    isWide ? frame('╔', '╦', '╗') : frame('╭', '┬', '╮'),
68    `${blank}${row(0)}${blank}`,
69    `${left}${row(1)}${right}`,
70    `${blank}${row(2)}${blank}`,
71    isWide ? frame('╚', '╩', '╝') : frame('╰', '┴', '╯'),
72  ]
73}
74
75// The cabinet drawn in pixels is this many cells wide.
76export const WIDTH = 56
77
78// Which parts of the pixel cabinet a pane has room for.
79export type Parts = {
80  hasSign: boolean
81  hasBank: boolean // the balance display under the reels
82  sliver: number // pixels of the symbols above and below the line that show
83}
84
85// What a pane of a given size draws. Everything optional is dropped in turn
86// as the room shrinks, and the balance moves beside the reels before it
87// gives up its big digits.
88export type Layout = {
89  isWide: boolean // the double-framed reel window fits
90  // Big digits under the reels or next to them, one line of text, or the cabinet's own display.
91  bank: 'below' | 'beside' | 'line' | 'drawn'
92  // A framed card; the records next to the reels and the week on a line; two
93  // lines of text; or nothing, where every row is taken.
94  stats: 'card' | 'split' | 'lines' | 'none'
95  hasLights: boolean
96  hasTitle: boolean
97  hasPays: boolean
98  gap: 0 | 1 // blank rows between the sections
99  cabinet?: Parts // the reels in pixels, in place of the text window, the sign and the lights
100  isAside?: true // above the prompt: the words stand beside the pixel reels, not under them
101}
102
103// `rows`: the pane's in the sidebar, the whole terminal's above the prompt,
104// where the pane is as tall as what it draws.
105// `canPaint`: the surface draws pixels, so the cabinet goes wherever it fits.
106export function layout(placement: 'dock' | 'inline', columns: number, rows: number, canPaint = false): Layout {
107  const isWide = columns >= 34
108  const isBroad = columns >= 52 // the big balance fits beside the reels
109  const plain = { isWide, hasLights: false, hasTitle: false, hasPays: false, gap: 0 } as const
110  if (!isWide) return { ...plain, bank: 'line', stats: 'lines' }
111  if (canPaint && columns >= WIDTH) {
112    const reels = { hasSign: false, hasBank: false, sliver: 1 }
113    // Above the prompt: the reels, and everything else in a column beside
114    // them, which takes 45 cells to stay as short as the reels are.
115    if (placement === 'inline' && columns >= WIDTH + 48) {
116      return { ...plain, bank: 'beside', stats: 'lines', cabinet: reels, isAside: true }
117    }
118    // Narrower: the words go under the reels, some 20 rows in all, where the
119    // terminal is tall enough to keep a transcript above them.
120    if (placement === 'inline' && rows >= 36) return { ...plain, bank: 'line', stats: 'lines', cabinet: reels }
121    // The sidebar, by the rows the cabinet and the nine rows of words under it
122    // add up to: 35 with the sign, 26 without. What is spare goes to blank
123    // rows between the sections, then to the stats card, then to the paytable.
124    if (placement === 'dock' && rows >= 35) {
125      const hasSign = rows >= 44
126      const spare = rows - (hasSign ? 44 : 35)
127      return {
128        ...plain,
129        bank: 'drawn',
130        stats: spare >= 13 ? 'card' : 'lines',
131        hasPays: spare >= 20,
132        gap: spare >= 5 ? 1 : 0,
133        cabinet: { hasSign, hasBank: true, sliver: 7 },
134      }
135    }
136    // A short sidebar: the reels and five rows of words, the balance on the
137    // stake's row; the stats where three more rows are left for them.
138    if (placement === 'dock' && rows >= 17) {
139      return { ...plain, bank: 'line', stats: rows >= 20 ? 'lines' : 'none', gap: rows >= 26 ? 1 : 0, cabinet: reels }
140    }
141  }
142  // Above the prompt the height is short and the width is what there is to use.
143  if (placement === 'inline') {
144    if (columns >= 73) return { ...plain, bank: 'beside', stats: 'split' }
145    return { ...plain, bank: isBroad ? 'beside' : 'line', stats: 'lines' }
146  }
147  // The sidebar, by the rows each set of parts adds up to.
148  const lit = { ...plain, hasLights: true }
149  if (rows >= 31) {
150    return { ...lit, bank: 'below', stats: 'card', gap: 1, hasTitle: rows >= 35, hasPays: rows >= 41 && columns >= 45 }
151  }
152  if (rows >= 26) return { ...lit, bank: 'below', stats: 'card' }
153  if (rows >= 22 && isBroad) return { ...lit, bank: 'beside', stats: 'card' }
154  if (rows >= 18) return { ...lit, bank: isBroad ? 'beside' : 'below', stats: 'lines' }
155  return { ...plain, hasLights: rows >= 14, bank: isBroad ? 'beside' : 'line', stats: 'lines' }
156}
157
hooks/pixels.ts 337 lines
1// The cabinet in pixels, for a surface that draws a Raster: two pixels to a
2// cell, one above the other. Colours and numbers only, no engine calls.
3
4import { FONT, WIDTH } from './art'
5import type { Parts } from './art'
6import { faces } from './reels'
7
8const CELL = 16 // a symbol is this many pixels square
9const PITCH = 18 // from one symbol on a reel to the next
10const SIGN = 18 // how tall the sign is, and the balance display
11const BANK = 14
12const CLEAR = -1 // the terminal's own background
13
14// The letters the art below is drawn in.
15const INK: Record<string, number> = {
16  k: 0x1c1917, // outline
17  w: 0xffffff,
18  l: 0xd6d3d1,
19  g: 0x78716c,
20  r: 0xef4444,
21  R: 0x991b1b,
22  o: 0xfb923c,
23  y: 0xfacc15,
24  Y: 0xca8a04,
25  G: 0x22c55e,
26  D: 0x15803d,
27  b: 0x2563eb,
28  s: 0x93c5fd,
29  c: 0xe7b97a,
30  n: 0xa16207,
31  e: 0xfef08a,
32}
33
34// One per symbol, in the order of SYMBOLS.
35export const SPRITES = [
36  [
37    '.......kk.......',
38    '......krrk......',
39    '.....krrrrk.....',
40    '.....krrrrk.....',
41    '....kwwwwwlk....',
42    '....kwwbbwlk....',
43    '....kwbssblk....',
44    '....kwbssblk....',
45    '....kwwbbwlk....',
46    '....kwwwwwlk....',
47    '..kkkwwwwwlkkk..',
48    '.krrkwwwwwlkrRk.',
49    '.krrkkkkkkkkrRk.',
50    '.kkk.koyyok.kkk.',
51    '.....koyyok.....',
52    '......kook......',
53  ],
54  [
55    '................',
56    '...kkkkkkkkkk...',
57    '..kGGGGGGGGGGk..',
58    '.kGGGGGGGGGGwGk.',
59    '.kGGGGGGGGGwwGk.',
60    '.kGGGGGGGGwwwGk.',
61    '.kGGGGGGGwwwGGk.',
62    '.kGwGGGGwwwGGGk.',
63    '.kGwwGGwwwGGGGk.',
64    '.kGwwwwwwGGGGGk.',
65    '.kGGwwwwGGGGGGk.',
66    '.kGGGwwGGGGGGGk.',
67    '.kDDDDDDDDDDDDk.',
68    '..kDDDDDDDDDDk..',
69    '...kkkkkkkkkk...',
70    '................',
71  ],
72  [
73    '................',
74    '.kkkkkkkkkkkkkk.',
75    '.kcccccccccccck.',
76    '.knnnnnnnnnnnnk.',
77    '..kyyyyyyyyyyk..',
78    '..kyrryyyyyyok..',
79    '...krryyyyyok...',
80    '...kyyyyrryok...',
81    '....kyyyrrok....',
82    '....kyyyyyok....',
83    '.....kyrrok.....',
84    '.....kyrrok.....',
85    '......kyok......',
86    '......kyok......',
87    '.......kk.......',
88    '................',
89  ],
90  [
91    '....k......k....',
92    '.....k....k.....',
93    '.....kkkkkk.....',
94    '....kkwkkwkk....',
95    '....kkkkkkkk....',
96    '.k.kGGGkkGGGk.k.',
97    '..kkGGGkkGGDkk..',
98    '...kGDGkkGGDk...',
99    'kkkkGGGkkGGDkkkk',
100    '...kGGGkkGDDk...',
101    '...kGDGkkGGDk...',
102    '..kkGGGkkGGDkk..',
103    '.k.kGGGkkGDDk.k.',
104    '....kGGkkGDk....',
105    '.....kkkkkk.....',
106    '................',
107  ],
108  [
109    '.......k........',
110    '......krk.......',
111    '......krrk......',
112    '.....krrrk..k...',
113    '.....krorrkkrk..',
114    '....krroorkrrk..',
115    '...krrooorrrrk..',
116    '..krroooyoorrrk.',
117    '..kroooyyooorrk.',
118    '.krrooyyyyoorrk.',
119    '.krooyyyyyyoork.',
120    '.krooyyeeyyoork.',
121    '.krrooyeeyoorrk.',
122    '..krrooyyoorrk..',
123    '...kkrrrrrrkk...',
124    '.....kkkkkk.....',
125  ],
126  [
127    '................',
128    '....kkkkkkkk....',
129    '..kkwwwwwwwwkk..',
130    '.kwwwwwwwwwwwlk.',
131    '.kwwwwwwwwwwwlk.',
132    '.kwkkkwwwwkkklk.',
133    '.kwkkkkwwkkkklk.',
134    '.kwkkkkwwkkkklk.',
135    '.kwwkkwwwwkkwlk.',
136    '.kwwwwwkkwwwwlk.',
137    '..kwwwkkkkwwlk..',
138    '...kkwwwwwwkk...',
139    '....kwkwwkwk....',
140    '....kwwwwwlk....',
141    '.....kkkkkk.....',
142    '................',
143  ],
144  [
145    '................',
146    '....kk....kk....',
147    '...kcckkkkcck...',
148    '....kcccccck....',
149    '.....kYYYYk.....',
150    '....kccccccnk...',
151    '...kcccGGcccnk..',
152    '..kcccGGGGcccnk.',
153    '..kccGGccccccnk.',
154    '..kcccGGGcccnnk.',
155    '..kcccccGGccnnk.',
156    '..kccGGGGcccnnk.',
157    '..kcccGGcccnnnk.',
158    '...kcccccnnnnk..',
159    '....kkkkkkkkk...',
160    '................',
161  ],
162]
163
164const COIN = ['..yyyy..', '.ywwyyy.', 'ywyyyyYy', 'ywyyyyYy', 'yyyyyyYy', 'yyyyyYYy', '.yyYYYy.', '..yyyy..']
165
166export type Canvas = { w: number; h: number; px: Int32Array }
167
168function rect(c: Canvas, x: number, y: number, w: number, h: number, color: number): void {
169  for (let row = Math.max(0, y); row < Math.min(c.h, y + h); row++) {
170    c.px.fill(color, row * c.w + Math.max(0, x), row * c.w + Math.min(c.w, x + w))
171  }
172}
173
174// Draws `art` with its top left at (x, y), each letter `scale` pixels square,
175// on the canvas rows `from` to `to` only. `ink` colours a letter, or skips it.
176function stamp(
177  c: Canvas,
178  art: string[],
179  x: number,
180  y: number,
181  ink: (letter: string, row: number) => number | undefined,
182  scale = 1,
183  from = 0,
184  to = c.h,
185): void {
186  art.forEach((line, r) =>
187    [...line].forEach((letter, col) => {
188      const color = ink(letter, r)
189      if (color === undefined) return
190      for (let row = y + r * scale; row < y + (r + 1) * scale; row++) {
191        if (row >= from && row < to) rect(c, x + col * scale, row, scale, 1, color)
192      }
193    }),
194  )
195}
196
197// `text` in the big font, `scale` pixels to its pixel, ending at `right`.
198// Characters the font lacks are skipped.
199function write(c: Canvas, text: string, right: number, y: number, scale: number, ink: (row: number) => number): void {
200  const glyphs = [...text].flatMap(char => (FONT[char] ? [FONT[char]] : []))
201  let x = right - span(text, scale)
202  for (const glyph of glyphs) {
203    stamp(c, glyph, x, y, (letter, row) => (letter === '1' ? ink(row) : undefined), scale)
204    x += (glyph[0]!.length + 1) * scale
205  }
206}
207
208function span(text: string, scale: number): number {
209  return [...text].reduce((sum, char) => sum + (FONT[char] ? (FONT[char][0]!.length + 1) * scale : 0), -scale)
210}
211
212function dim(color: number, by: number): number {
213  return [16, 8, 0].reduce((sum, shift) => sum | (Math.round(((color >> shift) & 255) * by) << shift), 0)
214}
215
216export type Look = {
217  title: string
218  at: number[] // strip position of each reel, a fraction while it moves
219  phase: number // where the running lights are
220  frame: number // frames drawn so far: what falls, falls by it
221  glow: number // what the reels celebrate: 0 nothing, 1 a pair, 2 a big win
222  isLit: boolean // the blink of the celebration
223  hits: boolean[] // the reels that won
224  balance: number
225  isPaying: boolean // a win is being counted in
226}
227
228function pixels(parts: Parts): number {
229  return (parts.hasSign ? SIGN : 0) + PITCH + 2 * parts.sliver + 4 + (parts.hasBank ? 2 + BANK : 0)
230}
231
232// How many rows of cells the cabinet takes.
233export function rows(parts: Parts): number {
234  return pixels(parts) / 2
235}
236
237export function scene(parts: Parts, look: Look): Canvas {
238  const c: Canvas = { w: WIDTH, h: pixels(parts), px: new Int32Array(WIDTH * pixels(parts)).fill(CLEAR) }
239  const isOn = look.glow > 0 && look.isLit
240  let y = 0
241
242  if (parts.hasSign) {
243    rect(c, 1, 0, WIDTH - 2, SIGN, 0x7f1d1d)
244    rect(c, 0, 1, WIDTH, SIGN - 1, 0x7f1d1d)
245    // Every third bulb is lit and the lit ones run; a win blinks them all.
246    for (let i = 0; i * 4 + 2 < WIDTH; i++) {
247      const bulb = (shift: number) =>
248        (look.glow > 0 ? isOn : (i + look.phase + shift) % 3 === 0) ? 0xfef08a : 0xa16207
249      rect(c, i * 4 + 1, 1, 2, 2, bulb(0))
250      rect(c, i * 4 + 1, SIGN - 3, 2, 2, bulb(1))
251    }
252    const right = Math.round((WIDTH + span(look.title, 2)) / 2)
253    write(c, look.title, right + 1, 5, 2, () => 0x450a0a)
254    write(c, look.title, right, 4, 2, row => [0xfef9c3, 0xfde047, 0xfacc15, 0xf59e0b, 0xd97706][row]!)
255    y += SIGN
256  }
257
258  // The reels: a frame, three strips of paper, the symbols on them.
259  const inner = PITCH + 2 * parts.sliver
260  const top = y + 2
261  const mid = top + inner / 2
262  rect(c, 0, y, WIDTH, inner + 4, 0x92400e)
263  rect(c, 1, y + 1, WIDTH - 2, inner + 2, isOn ? 0xfef9c3 : 0xfbbf24)
264  rect(c, 2, top, WIDTH - 4, inner, 0x44260a)
265  look.at.forEach((at, i) => {
266    const x = 2 + i * (CELL + 2)
267    rect(c, x, top, CELL, inner, 0xf5efe0)
268    if (isOn && look.hits[i]) rect(c, x, mid - PITCH / 2, CELL, PITCH, look.glow === 2 ? 0xfde047 : 0xbbf7d0)
269    for (let spot = Math.floor(at) - 2; spot <= Math.floor(at) + 3; spot++) {
270      const above = Math.round(mid + (spot - at) * PITCH - CELL / 2)
271      stamp(c, SPRITES[faces(spot)[1]]!, x, above, letter => INK[letter], 1, top, top + inner)
272    }
273  })
274  // The reels are drums: what is off the line turns away from the light.
275  for (let row = top; row < top + inner; row++) {
276    const off = Math.abs(row + 0.5 - mid) - PITCH / 2
277    if (off < 0) continue
278    const by = 0.9 - (0.45 * off) / parts.sliver
279    for (let x = 2; x < WIDTH - 2; x++) c.px[row * WIDTH + x] = dim(c.px[row * WIDTH + x]!, by)
280  }
281  // The line that counts, marked on the frame.
282  rect(c, 0, mid - 2, 2, 4, 0xef4444)
283  rect(c, WIDTH - 2, mid - 2, 2, 4, 0xef4444)
284  y += inner + 4
285
286  if (parts.hasBank) {
287    y += 2
288    rect(c, 1, y, WIDTH - 2, BANK, 0xa16207)
289    rect(c, 0, y + 1, WIDTH, BANK - 2, 0xa16207)
290    rect(c, 1, y + 1, WIDTH - 2, BANK - 2, 0x0c0a09)
291    stamp(c, COIN, 4, y + 3, letter => INK[letter])
292    const digits = String(look.balance)
293    // Five big digits fit; a longer balance is written small.
294    const scale = digits.length > 5 ? 1 : 2
295    write(c, digits, WIDTH - 4, y + (scale === 2 ? 2 : 5), scale, () => (look.isPaying ? 0x4ade80 : 0xfacc15))
296  }
297
298  // A big win rains coins over all of it.
299  if (look.glow === 2) {
300    for (let i = 0; i < 12; i++) {
301      const x = (i * 37 + 11) % (WIDTH - 2)
302      const fall = (i * 53 + look.frame * (2 + (i % 3))) % c.h
303      rect(c, x, fall, 2, 2, 0xfde047)
304      rect(c, x, fall, 1, 1, 0xffffff)
305    }
306  }
307  return c
308}
309
310const DEFAULT = 0x01000000 // the color word for "the terminal's own"
311const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
312
313// The canvas as a Raster's `cells`: the upper half block, its foreground the
314// upper pixel and its background the lower one.
315export function cells(c: Canvas): string {
316  const words = new Uint32Array((c.w * c.h * 3) / 2)
317  for (let i = 0; i < words.length / 3; i++) {
318    const upper = c.px[Math.floor(i / c.w) * 2 * c.w + (i % c.w)]!
319    const lower = c.px[(Math.floor(i / c.w) * 2 + 1) * c.w + (i % c.w)]!
320    const cell =
321      upper === lower
322        ? [0x20, DEFAULT, upper === CLEAR ? DEFAULT : upper]
323        : upper === CLEAR
324          ? [0x2584, lower, DEFAULT]
325          : [0x2580, upper, lower === CLEAR ? DEFAULT : lower]
326    words.set(cell, i * 3)
327  }
328  // Twelve bytes a cell: always whole groups of three, so no padding.
329  const bytes = new Uint8Array(words.buffer)
330  let out = ''
331  for (let i = 0; i < bytes.length; i += 3) {
332    const group = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
333    out += BASE64[group >> 18]! + BASE64[(group >> 12) & 63]! + BASE64[(group >> 6) & 63]! + BASE64[group & 63]!
334  }
335  return out
336}
337
locales/en.ts 199 lines
1import type { Locale } from './index'
2
3const en: Locale = {
4  plural: n => (n === 1 ? 0 : 1),
5  title: 'GAMBA',
6  group: ',',
7  decimal: '.',
8
9  spin: [
10    "Let's go gambling!",
11    'One more spin',
12    'This is the one',
13    'Trust the process',
14    "I can't stop winning",
15    'Big money big money',
16    'Last one. For real this time',
17    "It's not gambling if you know you'll win",
18    "Scared money don't make money",
19    'All skill, no luck',
20    'Spin now, think later',
21    'Due for a hit',
22  ],
23  miss: [
24    'Aw dangit',
25    'So close',
26    'Rigged',
27    'Warm-up spin',
28    'no no no!!😳🤯',
29    "That one didn't count",
30    'The next one hits. Source: trust me',
31    'A loss is just a win that is still loading',
32    'Unlucky. Run it back',
33    "Skill issue (the machine's)",
34    'This is fine',
35  ],
36  small: [
37    'A win is a win',
38    'Basically profit',
39    "We're up (don't check)",
40    'We take those',
41    'Small W',
42    'Not life-changing. Yet',
43    'The machine blinked first',
44    'Up one. Retirement soon',
45    'Proof the system works',
46    'Momentum 🤑',
47  ],
48  big: [
49    'I MADE IT BIG!!! 🤑🤑🤑',
50    "WE'RE SO BACK",
51    'Screenshot this',
52    'Told you. Never quit.',
53    'Cash out? Never heard of it',
54    "I CAN'T STOP WINNING 🤑",
55    "JACKPOT. Quit your job (don't)",
56    'THIS IS WHY WE SPIN 🤯',
57    'Generational wealth (in chips)',
58    'Mom, get the camera',
59    'Skill. Pure skill.',
60    'Never doubted it for a second',
61  ],
62  broke: [
63    '90% of gamblers quit right before they hit big',
64    'Balance: 0. Write a prompt to deposit',
65    "You can't deposit. You can only work",
66    "It's so over",
67    'Go burn some tokens, come back',
68    'Broke. Prompt harder',
69    'The house always wins. The house is your agent',
70    'Out of chips. Touch keyboard',
71    'Down bad. Ship something',
72    'Zero chips. Infinite potential',
73  ],
74  dep: [
75    'Dep landed: +{chips:chip|chips} for {tokens:token|tokens}',
76    'Your agent just funded your habit',
77    '{tokens:token|tokens} burned. The casino thanks you',
78    "We're so back",
79    '+{chips}. Earned the honest way: prompting',
80    'Payday: +{chips}. Straight to the machine',
81    'Tokens in, chips out. The economy works',
82    'Fresh dep. Back in the red soon',
83    "+{chips}. Your agent doesn't know what it's for",
84    '{tokens:token|tokens} well spent (on this)',
85  ],
86  agentStart: [
87    "Agent's thinking. Let's go gambling!",
88    'It writes, you spin',
89    'Might as well',
90    "Agent's on the clock. So are you",
91    'Tokens are burning. Chips incoming',
92    'It works. You invest',
93    'Compiling. I mean gambling',
94    'Waiting is a job too',
95    'Shift started. Its shift',
96    "Agent's in the mines. You're at the casino",
97  ],
98  agentDone: [
99    "Agent's back. Cash out and review the diff",
100    "It finished. You didn't",
101    "Fun's over, there's a diff",
102    "Break's over. Go see what it did",
103    "It's waiting on you. The machine can wait",
104    "Diff's ready. One more spin first",
105    "The diff won't LGTM itself",
106    "Agent's done. You're just getting started",
107    'Reply or respin. Choose wisely',
108    'Your turn. At work, not here',
109  ],
110  streak: [
111    'Aw dangit. Aw dangit. Aw dangit.',
112    'This is called variance',
113    'Tilted? Good. Spin.',
114    "{n} misses in a row. Statistically you're due (you're not)",
115    'A quitter would stop here',
116    '{n} straight. The machine is building up. Probably',
117    "{n} in a row. That's not luck, that's commitment",
118    "You'll win it back. They all say that",
119    "{n} misses. Record's close, don't quit now",
120    'Rock bottom has a basement',
121  ],
122  limit: [
123    'GET IT TWISTED, GAMBLE. GAMBLING IS AN INVESTMENT ONLY.',
124    '{pct}% of weekly limit gone. You will profit, you will win',
125    'Please gamble responsibly (lol)',
126    'Limit almost gone. Next dep: Monday',
127    '{pct}% burned. The last tokens taste the best',
128    'Weekly limit on fumes. Keep spinning',
129    '{pct}% gone. A responsible person would stop 😳',
130    "Week's not over. Your limit almost is",
131    'You and the agent are both about to rest',
132    '{pct}% of the limit. The casino recommends: continue',
133  ],
134  offer: [
135    'Waiting on Claude. Test your luck?',
136    "Agent's busy. Spin a few?",
137    'It works. You gamble?',
138    'Tokens are burning. Open the slot?',
139    "Got a minute and some chips. Let's go gambling?",
140    'Waiting is boring. See if it pays today?',
141    'This will take a while. Hit the casino?',
142    'While it writes code, feeling lucky?',
143  ],
144  bonus: [
145    'BONUS. {n:free spin|free spins}, all wins ×2',
146    'Performance bonus approved: {n:free spin|free spins}',
147    'The house is buying. Spin',
148    'Free spins. The house made a mistake',
149    'Bonus round. Nobody earned this',
150  ],
151  bonusEnd: [
152    'Bonus over. Took home +{n}',
153    'Free ride is over: +{n}',
154    'Back to paying. You made {n}',
155    'Bonus spent: +{n}. Yours, for now',
156    'Free spins are done. So is the generosity',
157  ],
158  first: ['Welcome. No real money here. Just your tokens.', "You can't deposit. You can only work."],
159
160  labels: {
161    spin: 'Spin',
162    copy: 'Copy stats',
163    lang: 'Lang',
164    close: 'Close',
165    sound: 'Sound',
166    on: 'on',
167    off: 'off',
168    yes: 'Yes',
169    no: 'No',
170    always: 'Always open',
171    never: "Don't offer",
172    offer: 'Auto-open',
173    offers: { ask: 'ask', always: 'always', never: 'never' },
174    balance: 'Balance',
175    rate: 'Tokens per chip',
176    cap: 'Chips per turn, at most',
177    warn: 'Limit warning',
178    bet: 'Bet',
179    pair: 'Pair from the left',
180    free: 'Free spins',
181    week: 'This week',
182    waited: 'Waited on agent',
183    hours: '{hours}h',
184    spins: 'Spins',
185    tokens: 'Tokens deposited',
186    maxWin: 'Biggest win',
187    streak: 'Miss streak',
188    payout: 'Payout',
189    rank: 'Rank',
190    copied: 'Stats copied. Go flex',
191    copyFailed: 'Clipboard said no. Type it out',
192  },
193  share:
194    'This week: waited {hours}h on my agent, {spins:spin|spins}, {tokens:token|tokens} deposited. Rank: {rank}',
195  ranks: ['Tourist', 'Degen', 'Redepositor', 'High roller', 'The house'],
196}
197
198export default en
199
locales/ru.ts 204 lines
1import type { Locale } from './index'
2
3const ru: Locale = {
4  detect: /[а-яё]/gi,
5  plural: n => (n % 10 === 1 && n % 100 !== 11 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 1 : 2),
6  title: 'ЛУДКА',
7  group: ' ',
8  decimal: ',',
9
10  spin: [
11    'Крутим бурмалду',
12    'Катаем',
13    'Ну, с богом',
14    'Сейчас точно даст',
15    'Депчик пошёл',
16    'Этот — отыгровочный',
17    'Последний. Честно',
18    'Чую, заряжен',
19    'Жмём и молимся',
20    'Математика на нашей стороне',
21    'Ещё один и за работу',
22    'Аппарат прогрет',
23  ],
24  miss: [
25    'Плаки-плаки',
26    'Не дало',
27    'Мимо кассы',
28    'Это была разминка',
29    'Ещё чуть-чуть и даст',
30    'Казик подкручен',
31    'Аппарат копит на занос',
32    'Так и задумано',
33    'На дистанции отобьём',
34    'Почти',
35    'Бывает. Каждый раз',
36  ],
37  small: [
38    'Копеечка, а приятно',
39    'Отбил спин',
40    'Плюсик есть',
41    'Мелочь, но в плюс',
42    'На чай хватит',
43    'Казик дразнит',
44    'Это он заманивает',
45    'Не занос, но и не плаки',
46    'Разгон пошёл',
47    'Мелко, но честно',
48  ],
49  big: [
50    'ЗАНОС',
51    'Ам-ам-ам-ам',
52    'Дало. ДАЛО.',
53    'Иксы полетели',
54    'Скрин в чат',
55    'Выводи, пока не поздно',
56    'ЗАНОСИЩЕ',
57    'Мама, я в плюсе',
58    'Вот ради этого и терпели',
59    'Казик, держись',
60    'Увольняюсь. Шутка. Крутим дальше',
61    'Это не удача, это система',
62  ],
63  broke: [
64    'Нужен додеп — взял микрозайм и заложил часы бати в ломбард',
65    'Фантики кончились. Иди работай',
66    'Баланс: 0. Напиши промпт — будет деп',
67    'Здравствуй, бедность',
68    'Казик закрыт до следующего промпта',
69    'Крутить не на что. Агент сам себя не запромптит',
70    'Додеп лежит в терминале. Иди возьми',
71    'Пусто. Самое время вспомнить про работу',
72    'Ты не проиграл. Ты проинвестировал в опыт',
73    'Баланс ноль, зато дифф не проверен',
74  ],
75  dep: [
76    'Деп зашёл: +{chips:фантик|фантика|фантиков} за {tokens:токен|токена|токенов}',
77    'Агент наработал тебе на додеп',
78    'Сожжено {tokens:токен|токена|токенов}. Казик благодарит',
79    'Додеп приехал: +{chips}. Агент не в курсе, на что',
80    '+{chips}. Заработано честным промптом',
81    'Токены сгорели не зря: +{chips}',
82    'Зарплата пришла: +{chips}. Сразу в казик',
83    'Касса пополнена. Агент старался',
84    '{tokens:токен|токена|токенов} стали фантиками. Алхимия',
85    'Свежий деп. Можно снова в минус',
86  ],
87  agentStart: [
88    'Агент ушёл думать. Самое время покатать',
89    'Пошла возня',
90    'Пока он пишет — ты крутишь',
91    'Он работает. Ты — инвестируешь',
92    'Токены горят. Фантики на подходе',
93    'Смена началась. У него',
94    'Процесс пошёл. Оба процесса',
95    'Ждать — тоже работа',
96    'Агент в шахте. Ты в казике',
97    'Агент занят. Ты тоже при деле',
98  ],
99  agentDone: [
100    'Агент вернулся. Кэшаут и за работу',
101    'Он закончил. Ты — нет',
102    'Хватит катать, там дифф',
103    'Перерыв окончен. Иди читай, что он наделал',
104    'Агент всё. Ревью само себя не сделает',
105    'Он ждёт ответа. Аппарат подождёт',
106    'Дифф готов. Докручивай и иди',
107    'Смена сдана. Твой ход',
108    'Там ответ. Тут ещё один спин. Выбирай',
109    'Агент свободен. Ты — как посмотреть',
110  ],
111  streak: [
112    'Замазался',
113    'Тильт — это тоже стратегия',
114    'Отмазаться не получится, но попробуй',
115    'Лудик, остановись. Шутка, крути',
116    '{n} мимо подряд. По статистике следующий — тоже',
117    '{n} подряд. Аппарат копит. Наверное',
118    'Серия {n}. Это уже не случайность, это характер',
119    'Отыграешься. Все так говорят',
120    '{n} мимо. Рекорд рядом, не сдавайся',
121    'Дно пробито. Снизу постучали',
122  ],
123  limit: [
124    '⚠️ Агент не видит ваше сообщение. Напишете в понедельник',
125    'Депнуто {pct}% недельного лимита. Играй ответственно (нет)',
126    'Лимит на донышке. Додеп только в понедельник',
127    'Ты слил {pct}% лимита. Агент слил остальное',
128    '{pct}% лимита ушло. Последние токены — самые вкусные',
129    'Лимит почти всё. Дальше только на свои (их нет)',
130    'Неделя ещё не кончилась, а лимит — почти',
131    '{pct}% лимита. Казик рекомендует не останавливаться',
132    'Скоро отдыхать будете оба: и ты, и агент',
133    'Лимит тает. Зато какая была неделя',
134  ],
135  offer: [
136    'Пока ждёшь Клода — проверим удачу?',
137    'Агент ушёл думать. Крутанём?',
138    'Он работает. А ты в казик?',
139    'Токены горят. Открыть слот?',
140    'Есть минутка и фантики. Катнём?',
141    'Ждать скучно. Проверим, даёт ли сегодня?',
142    'Агент занят надолго. Заглянем в казик?',
143    'Пока он пишет код — испытаем удачу?',
144  ],
145  bonus: [
146    'ПРЕМИЯ. {n:фриспин|фриспина|фриспинов}, всё ×2',
147    'Начальство расщедрилось: {n:фриспин|фриспина|фриспинов}',
148    'Бонуска. Крутим за счёт заведения',
149    'Премию выписали. Спустить до вечера',
150    'Казик ошибся в твою пользу. Пока',
151    'Квартальная премия. Заслужил? Неважно',
152  ],
153  bonusEnd: [
154    'Премия кончилась. Итого +{n}',
155    'Фриспины всё: +{n}. Назад в бурмалду',
156    'Премия освоена: +{n}',
157    'Корпоратив окончен. Унёс {n}',
158    'Бесплатное кончилось. Дальше за свои',
159  ],
160  first: [
161    'Добро пожаловать в казик. Денег тут нет, только твои токены',
162    'Депать нельзя. Можно только работать',
163  ],
164
165  labels: {
166    spin: 'Крутить',
167    copy: 'Скопировать стату',
168    lang: 'Язык',
169    close: 'Закрыть',
170    sound: 'Звук',
171    on: 'вкл',
172    off: 'выкл',
173    yes: 'Да',
174    no: 'Нет',
175    always: 'Открывать всегда',
176    never: 'Не предлагать',
177    offer: 'Автооткрытие',
178    offers: { ask: 'спрашивать', always: 'всегда', never: 'никогда' },
179    balance: 'Баланс',
180    rate: 'Токенов за фантик',
181    cap: 'Фантиков за ход, не больше',
182    warn: 'Предупреждение о лимите',
183    bet: 'Ставка',
184    pair: 'Пара слева',
185    free: 'Фриспины',
186    week: 'За неделю',
187    waited: 'Прождал агента',
188    hours: '{hours} ч',
189    spins: 'Спинов',
190    tokens: 'Депнуто токенов',
191    maxWin: 'Макс. занос',
192    streak: 'Серия мимо',
193    payout: 'Отдача',
194    rank: 'Ранг',
195    copied: 'Стата скопирована. Неси в чат',
196    copyFailed: 'Буфер не дался. Перепиши руками',
197  },
198  share:
199    'За неделю: прождал агента {hours} ч, сделал {spins:спин|спина|спинов}, депнул {tokens:токен|токена|токенов}. Ранг: {rank}',
200  ranks: ['Бездеп', 'Лудик', 'Додепщик', 'Катала', 'Король бурмалды'],
201}
202
203export default ru
204