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

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.
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>
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>
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. |
| 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
/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.
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.
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.
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.
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.
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.
Bets on what the agent will do, crash games, blackjack, roulette, leaderboards, anything over the network. And money, in any form.
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.
hooks/register.ts 734 lines1import 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}
734locales/index.ts 97 lines1// 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}
97hooks/game.ts 196 lines1// 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}
196hooks/reels.ts 58 lines1// 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}
58hooks/art.ts 157 lines1// 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}
157hooks/pixels.ts 337 lines1// 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}
337locales/en.ts 199 lines1import 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
199locales/ru.ts 204 lines1import 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