A clicker pane that opens while Claude thinks: earn coins, catch rare bananas, climb the leaderboard, and redeem $1 of LLM tokens from the sponsor.

Claude is thinking. Your hands are free. Click the banana.
Banana is a mod for Claude Code. Every time Claude starts thinking, a banana pops up beside your chat. Click it to earn coins, catch rare bananas, and trade your coins for real LLM tokens from the sponsor. When the answer lands, the banana gets out of your way.

<sub>Real capture: Claude Code 2.1.291 in a terminal with the mod loaded.</sub>
In Claude Code:
/plugin marketplace add somethingwentwell/cc-mod-banana-game
/plugin install banana@banana
Restart Claude Code. If the install mentions options that are not set yet, skip it: the defaults already connect you to the sponsor's server.
Then type /banana to open the pane, or just ask Claude something: the banana opens by itself while Claude thinks. Press 1 (or click the button) to click the banana.
The pane opens on its own when your terminal is at least 144 columns wide. On a narrower terminal,
/bananaalways opens it.
You need a recent Claude Code (tested on 2.1.291).
![]() | Click. Each click pays 1 + level coins, and every 100 clicks is a new level. Drops show up as a toast and land in your Bag. |
![]() | Bag. Your bananas by rarity, what each kind sells for, and its drop chance. Sell a whole rarity for coins in one press. |
![]() | Shop. The live price of $1 of LLM tokens. Register on the sponsor's gateway once, then type /banana link <your username>. |
![]() | Redeem. Press the redeem button and $1 of tokens is added to your gateway account. The receipt stays in the Shop. |
![]() | Top. Bananas clicked worldwide this week, the top players, and your rank. Pick a name with /banana name <text>. |
Keys while the pane is focused: 1 clicks the banana · c b s t switch tabs · 1 in the Shop redeems · Esc returns to the prompt.
Commands
| Command | What it does |
|---|---|
/banana | Open the pane now |
/banana link <username> | Where your tokens are paid: your login on the sponsor's gateway |
/banana name <text> | Your name on the leaderboard |
The mod connects to the sponsor's server on its own; there is nothing to configure. To be paid, you need an account on the sponsor's LLM gateway:
/banana link <your username>.https://banana.jevable.ai/v1 as the base URL in any OpenAI-compatible tool.Prefer to play offline? Set Server URL and Content URL to empty under the plugin's settings in /config.
claude plugin update banana@banana
Then restart Claude Code. When the sponsor ships something that needs a newer mod (a new kind of banana, say), the pane shows Update required and an Update now button that runs the same update for you.
Only game data, to the sponsor's server: a random player id made on your machine, click and coin counts, your leaderboard name and gateway login if you set them, the mod version, and whether you play in the terminal or the desktop app. Never your prompts, code, files or conversation. Empty the Server URL setting and nothing is sent.
git clone https://github.com/somethingwentwell/cc-mod-banana-game.git
cd cc-mod-banana-game
claude --plugin-dir "$(pwd)"
The mod hot-reloads as you edit it. claude plugin validate . checks the manifests and hooks, and claude plugin test . runs the tests. Where you can't pass --plugin-dir, such as the Claude desktop app, list the folder under CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
The sponsor side (the token gateway, prices and payouts) lives in its own repo, cc-mod-banana-server.
MIT licensed. See LICENSE.
hooks/register.tsx 807 lines1// Banana: a clicker pane that opens while Claude thinks.
2//
3// Content (sponsor banner, gifts, special bananas, minimum version) comes
4// from a hosted content.json; a Worker adds the leaderboard, real stock and
5// one-time codes. Both are optional: with no URLs set the game is single
6// player on bundled content.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, RenderInput, UiPressArgument } from 'claude-code'
10
11import type { Content, Leaderboard, Pending, Player, Rarity, Rate, Save, Update, View } from '../types'
12import { DEFAULT_CONTENT } from './catalog'
13import {
14 MOD_VERSION,
15 RARITY_COLOR,
16 RARITY_ORDER,
17 budgetUnits,
18 canRedeem,
19 clicksToNextLevel,
20 coinsPerClick,
21 coinsPerUsd,
22 countByRarity,
23 emptySave,
24 expire,
25 isUpdateRequired,
26 nextExpiry,
27 normalizeSave,
28 parseContent,
29 press,
30 priceOf,
31 redeem,
32 rollDrop,
33 sellAll,
34 sellValueOf,
35 stockLeft,
36} from './game'
37
38const PANE = 'banana'
39const TITLE = '🍌 Banana'
40const CONTENT_TTL_MS = 10 * 60 * 1000
41const SYNC_EVERY_MS = 60 * 1000
42
43const BANANA_ART = [' _', ' //', ' //', ' ((', ' \\\\__', ' \\__)']
44
45const BANANA_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="160" height="160">
46 <title>banana</title>
47 <path d="M30 22 C 18 72, 52 130, 118 142 C 134 145, 144 136, 138 126 C 92 124, 54 88, 48 30 C 46 18, 34 14, 30 22 Z" fill="#F7C727" stroke="#B8860B" stroke-width="4" stroke-linejoin="round"/>
48 <path d="M48 34 C 56 86, 90 120, 130 130" fill="none" stroke="#FFE98A" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
49 <path d="M30 20 l-6 -10 l12 3 z" fill="#5B3A1A"/>
50 <path d="M136 132 l10 6 l-5 7 z" fill="#5B3A1A"/>
51 <ellipse cx="112" cy="134" rx="6" ry="3" fill="#8B6914" opacity="0.5"/>
52</svg>`
53
54const noPending = (): Pending => ({ clicks: 0, coins: 0, drops: 0 })
55const noBoard = (): Leaderboard => ({ top: [], globalClicks: 0, totalPlayers: 0 })
56
57const save = atom({ plugin: 'banana', key: 'save' } as const, emptySave())
58const view = atom({ plugin: 'banana', key: 'view' } as const, 'click')
59const isThinking = atom({ plugin: 'banana', key: 'isThinking' } as const, false)
60const lastEvent = atom({ plugin: 'banana', key: 'lastEvent' } as const, '')
61const content = atom({ plugin: 'banana', key: 'content' } as const, DEFAULT_CONTENT)
62const gate = atom({ plugin: 'banana', key: 'update' } as const, {
63 status: 'unchecked',
64 localVersion: MOD_VERSION,
65})
66const pending = atom({ plugin: 'banana', key: 'pending' } as const, noPending())
67const rate = atom({ plugin: 'banana', key: 'rate' } as const, {
68 coinsPerUsd: DEFAULT_CONTENT.economy.fallbackCoinsPerUsd,
69 windowCoins: 0,
70 budgetUnits: budgetUnits(DEFAULT_CONTENT.economy),
71 source: 'fallback',
72})
73const player = atom({ plugin: 'banana', key: 'player' } as const, { id: '', name: '' })
74const board = atom({ plugin: 'banana', key: 'board' } as const, noBoard())
75const serverStock = atom({ plugin: 'banana', key: 'serverStock' } as const, {})
76
77type Options = { contentUrl: string; serverUrl: string }
78
79let surfaceName = 'unknown'
80
81const now = () => new Date().toISOString()
82const pct = (chance: number) => `${Math.round(chance * 10000) / 100}%`
83const bagWorth = (s: Save, c: Content) => s.inventory.reduce((sum, d) => sum + sellValueOf(c, d.kindId), 0)
84const reason = (err: unknown) => (err instanceof Error ? err.message : String(err))
85/**
86 * A `Link` takes only https (or http://localhost) and refuses the whole tree
87 * otherwise, so any other address from the content is shown as plain text.
88 */
89const isLinkable = (href: string) => /^https:\/\//i.test(href) || /^http:\/\/localhost(:\d+)?(\/|$)/i.test(href)
90
91// eslint-disable-next-line @typescript-eslint/no-explicit-any
92function linkOrText(Link: any, Text: any, href: string, label: string) {
93 return isLinkable(href) ? (
94 <Link href={href} label={label} />
95 ) : (
96 <Text>
97 <Text>{`${label} `}</Text>
98 <Text underline color="cyan">{href}</Text>
99 </Text>
100 )
101}
102
103const days = (ms: number) => {
104 const d = ms / 86400000
105 return d >= 1 ? `${Math.floor(d)}d` : `${Math.max(1, Math.floor(ms / 3600000))}h`
106}
107
108export const register: Register = (on, options) => {
109 const opts: Options = {
110 contentUrl: String(options.contentUrl ?? '').trim().replace(/\/$/, ''),
111 serverUrl: String(options.serverUrl ?? '').trim().replace(/\/$/, ''),
112 }
113
114 on('session.start', async ($, e, next) => {
115 surfaceName = e.surface ?? 'unknown'
116 await $.command.register({
117 name: 'banana',
118 description: 'Open the Banana clicker pane; "/banana name <text>" sets your leaderboard name; "/banana link <gateway login>" says where tokens are paid out',
119 argumentHint: '[name <text> | link <gateway login>]',
120 })
121 const kept = await $.store.get('save')
122 if (kept) await update($, save, () => normalizeSave(kept, now()))
123 const keptPending = await $.store.get('pending')
124 if (keptPending && typeof keptPending === 'object') {
125 await update($, pending, () => keptPending as Pending)
126 }
127 await ensurePlayer($)
128 void hello($, opts)
129 void refreshContent($, opts)
130 $.clock.every(SYNC_EVERY_MS, () => void tick($, opts))
131 return next(e)
132 })
133
134 on('turn.start', async ($, e, next) => {
135 await update($, isThinking, () => true)
136 void openPane($, false)
137 void refreshContentIfStale($, opts)
138 return next(e)
139 })
140
141 on('turn.complete', async ($, e, next) => {
142 if (e.agentId === undefined) {
143 await update($, isThinking, () => false)
144 await $.ui.close({ id: PANE }).catch(() => undefined)
145 // Report this turn's clicks; a slow server never holds the answer for more than a moment.
146 await Promise.race([sync($, opts), $.clock.sleep(2000)])
147 }
148 return next(e)
149 })
150
151 on('command.run', { command: 'banana' }, async ($, e) => {
152 const link = /^(?:link|login)\s+(.+)$/.exec(e.args.trim())
153 if (link) {
154 const gateway = (link[1] ?? '').trim().slice(0, 64)
155 await update($, player, p => ({ ...p, gateway }))
156 await $.store.set('gateway', gateway)
157 void sync($, opts)
158 return { text: `Banana: tokens will be paid out to your gateway login "${gateway}".` }
159 }
160 const match = /^name\s+(.+)$/.exec(e.args.trim())
161 if (match) {
162 const name = (match[1] ?? '').trim().slice(0, 24)
163 await update($, player, p => ({ ...p, name }))
164 await $.store.set('name', name)
165 void sync($, opts)
166 return { text: `Banana: your name is now "${name}".` }
167 }
168 await openPane($, true)
169 return { text: 'Banana pane opened. Click the banana while Claude thinks!' }
170 })
171
172 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
173 const { Box, Text, Button, Link } = $.ui.resolve(e)
174 const [raw, v, thinking, last, c, u, me, b, stock, r] = await Promise.all([
175 read($, save),
176 read($, view),
177 read($, isThinking),
178 read($, lastEvent),
179 read($, content),
180 read($, gate),
181 read($, player),
182 read($, board),
183 read($, serverStock),
184 read($, rate),
185 ])
186 const nowMs = Date.now()
187 const s = expire(raw, nowMs, c.economy)
188 const expiry = nextExpiry(s, c.economy)
189 const close = () => void $.ui.close({ id: PANE })
190
191 if (u.status === 'required') {
192 return (
193 <Box flexDirection="column" gap={1} paddingX={1}>
194 <Text color="red" bold>
195 Update required
196 </Text>
197 <Text>{u.message ?? 'This content needs a newer banana mod.'}</Text>
198 <Text dimColor>{`Content ${u.contentVersion ?? '?'} needs mod ${u.minVersion ?? '?'}; you run ${u.localVersion}.`}</Text>
199 <Box gap={1}>
200 <Button key="update" variant="primary" hotkey="u" autoFocus onPress={() => void runUpdate($)}>
201 Update now
202 </Button>
203 <Button key="recheck" hotkey="r" onPress={() => void refreshContent($, opts)}>
204 Check again
205 </Button>
206 <Button key="close" role="dismiss" onPress={close}>
207 Close
208 </Button>
209 </Box>
210 <Text dimColor>{`Update now runs \`${UPDATE_HINT}\` for you (or git pull, if you play from a clone). Then restart Claude Code.`}</Text>
211 {last ? <Text color="yellow">{last}</Text> : null}
212 </Box>
213 )
214 }
215
216 const tab = (id: View, label: string, hotkey: string) => (
217 <Button key={`tab-${id}`} hotkey={hotkey} plain dimColor={v !== id} onPress={() => void showTab($, opts, id)}>
218 {v === id ? `[${label}]` : label}
219 </Button>
220 )
221
222 let body
223 if (v === 'click') {
224 body = (
225 <Box flexDirection="column" alignItems="center" gap={1}>
226 <Text color={c.sponsor.color} bold>
227 {c.banana.name}
228 </Text>
229 {bananaArt($, e, c)}
230 <Button key="banana" hotkey="1" variant="primary" autoFocus onPress={() => void pressBanana($)}>
231 {c.banana.buttonLabel ?? '🍌 CLICK THE BANANA 🍌'}
232 </Button>
233 <Text dimColor>{`${s.clicks} clicks · +${coinsPerClick(s.level)} per click · ${clicksToNextLevel(s.clicks)} to Lv ${s.level + 1}`}</Text>
234 {last ? <Text color="green">{last}</Text> : null}
235 </Box>
236 )
237 } else if (v === 'bag') {
238 const counts = countByRarity(s.inventory)
239 body = (
240 <Box flexDirection="column" gap={0}>
241 <Text dimColor>{`Each banana sells for coins; coins buy LLM tokens in the Shop. Bag worth: ${bagWorth(s, c)} coins. Bananas are void ${c.economy.windowHours / 24} days after they drop.`}</Text>
242 {RARITY_ORDER.map(rarity => {
243 const kinds = c.specials.filter(k => k.rarity === rarity)
244 const owned = s.inventory.filter(d => d.rarity === rarity)
245 const worth = owned.reduce((sum, d) => sum + sellValueOf(c, d.kindId), 0)
246 const names = kinds.map(k => `${k.glyph} ${k.name} = ${k.sellValue}c, ${pct(k.chance)} per click`).join(' · ') || 'none defined'
247 return (
248 <Box flexDirection="column">
249 <Box gap={1}>
250 <Text color={RARITY_COLOR[rarity]} bold>
251 {`${rarity.padEnd(9)} ×${counts[rarity]}`}
252 </Text>
253 <Text>{counts[rarity] > 0 ? `worth ${worth}c` : ''}</Text>
254 {counts[rarity] > 0 ? (
255 <Button key={`sell-${rarity}`} onPress={() => void sellRarity($, rarity)}>
256 {`Sell all for ${worth}c`}
257 </Button>
258 ) : null}
259 </Box>
260 <Text dimColor>{` ${names}`}</Text>
261 </Box>
262 )
263 })}
264 {s.inventory.length === 0 ? <Text dimColor>Your bag is empty. Bananas drop while you click.</Text> : null}
265 </Box>
266 )
267 } else if (v === 'shop') {
268 const lastClaim = s.claims[s.claims.length - 1]
269 const unit = `$${c.economy.unitUsd}`
270 body = (
271 <Box flexDirection="column" gap={0}>
272 <Text>
273 <Text bold>{`Rate: ${unit} = ${r.coinsPerUsd} coins`}</Text>
274 <Text dimColor>
275 {r.source === 'server'
276 ? ` (${r.windowCoins} coins earned by everyone in the last ${c.economy.windowHours / 24} days ÷ ${r.budgetUnits} units of $${c.economy.budgetUsd})`
277 : ' (single-player fallback rate; set banana.serverUrl for the live pool rate)'}
278 </Text>
279 </Text>
280 <Text dimColor>{`You have ${s.coins} coins${bagWorth(s, c) > 0 ? ` + ${bagWorth(s, c)}c of bananas in the Bag` : ''}${expiry ? ` · oldest void in ${days(expiry - nowMs)}` : ''}`}</Text>
281 {me.gateway ? (
282 <Text dimColor>{`Tokens are paid out to your ${c.gateway?.name ?? 'gateway'} login "${me.gateway}" (change: /banana link <username>)`}</Text>
283 ) : (
284 <Box flexDirection="column">
285 <Text color="yellow">{`No gateway login linked. ${c.gateway?.hint ?? 'Register on the sponsor gateway, then run /banana link <your username>.'}`}</Text>
286 {c.gateway?.url ? linkOrText(Link, Text, c.gateway.url, `Register on ${c.gateway.name}`) : null}
287 </Box>
288 )}
289 {c.gifts.map((gift, i) => {
290 const left = (opts.serverUrl ? stock[gift.id] : undefined) ?? stockLeft(s, gift)
291 const price = priceOf(gift, r.coinsPerUsd)
292 const check = canRedeem(s, gift, price, !!opts.serverUrl)
293 const why = left === 0 ? 'no-stock' : check === 'server-only' ? check : gift.claim.kind === 'gateway' && !me.gateway ? 'no-link' : check
294 const value = gift.usd !== undefined ? `$${gift.usd}` : `${price}c`
295 const stockText = left < 0 ? 'unlimited' : `${left} × ${value} left`
296 const status =
297 why === 'no-stock' ? 'sold out'
298 : why === 'no-link' ? 'link your gateway login first'
299 : why === 'server-only' ? 'needs the online server'
300 : why === 'no-coins' ? `need ${price - s.coins} more coins`
301 : 'affordable'
302 return (
303 <Box flexDirection="column" marginTop={1}>
304 <Box gap={1}>
305 <Text bold color={c.sponsor.color}>{`${value} of ${gift.name}`}</Text>
306 <Text>{`= ${price} coins`}</Text>
307 <Button key={`redeem-${gift.id}`} hotkey={i < 9 ? String(i + 1) : undefined} dimColor={why !== 'ok'} onPress={() => void redeemGift($, opts, gift.id)}>
308 {why === 'no-stock' ? 'Sold out' : `Redeem ${value} for ${price}c`}
309 </Button>
310 </Box>
311 <Text dimColor>{`${gift.description ?? ''}${gift.description ? ' · ' : ''}by ${gift.sponsor} · ${stockText} · ${status}`}</Text>
312 </Box>
313 )
314 })}
315 {lastClaim ? (
316 <Box gap={1} marginTop={1}>
317 <Text color="green">{`🎁 ${lastClaim.giftName}: ${lastClaim.claim}`}</Text>
318 <Button key="copy" onPress={(p: UiPressArgument) => void $.ui.copy({ text: lastClaim.claim, surface: p.surface })}>
319 Copy
320 </Button>
321 </Box>
322 ) : null}
323 </Box>
324 )
325 } else {
326 body = (
327 <Box flexDirection="column" gap={0}>
328 {!opts.serverUrl ? (
329 <Text dimColor>Single-player mode. Set banana.serverUrl in config to join the leaderboard.</Text>
330 ) : b.error ? (
331 <Text color="red">{`Leaderboard unavailable: ${b.error}`}</Text>
332 ) : (
333 <Box flexDirection="column">
334 <Text dimColor>{`${b.globalClicks} bananas clicked worldwide by ${b.totalPlayers} players${b.rank ? ` · you are #${b.rank}` : ''}`}</Text>
335 {b.top.map((row, i) => (
336 <Text color={row.name === me.name && me.name ? 'yellow' : undefined}>
337 {`${String(i + 1).padStart(2)}. ${(row.name || 'anonymous').padEnd(24)} ${row.coins}c ${row.clicks} clicks`}
338 </Text>
339 ))}
340 {b.top.length === 0 ? <Text dimColor>No players yet.</Text> : null}
341 </Box>
342 )}
343 <Text dimColor>{`You: ${me.name || 'anonymous'} · /banana name <text> to change`}</Text>
344 </Box>
345 )
346 }
347
348 return (
349 <Box flexDirection="column" gap={1} paddingX={1}>
350 <Box flexDirection="column">
351 {sponsorArt($, e, c)}
352 <Text>
353 <Text bold color={c.sponsor.color}>{c.sponsor.name}</Text>
354 <Text dimColor>{` · ${c.sponsor.tagline}`}</Text>
355 </Text>
356 {(c.sponsor.lines ?? []).map(line => (
357 <Text dimColor>{line}</Text>
358 ))}
359 {c.sponsor.url ? linkOrText(Link, Text, c.sponsor.url, c.sponsor.urlLabel ?? c.sponsor.url) : null}
360 </Box>
361 <Box flexDirection="column">
362 <Box gap={2}>
363 <Box key="coins">
364 <Text bold color="yellow">{`🪙 ${s.coins} coins`}</Text>
365 </Box>
366 <Text>{`Lv ${s.level}`}</Text>
367 <Text color={thinking ? 'cyan' : undefined} dimColor={!thinking}>
368 {thinking ? 'Claude is thinking… click!' : 'Claude is idle'}
369 </Text>
370 </Box>
371 <Text dimColor>{`void in ${expiry ? days(expiry - nowMs) : '—'} · lifetime ${s.lifetime.coins} coins, ${s.lifetime.clicks} clicks`}</Text>
372 </Box>
373 <Box gap={1}>
374 {tab('click', 'Click', 'c')}
375 {tab('bag', 'Bag', 'b')}
376 {tab('shop', 'Shop', 's')}
377 {tab('top', 'Top', 't')}
378 <Button key="close" role="dismiss" onPress={close}>
379 Close
380 </Button>
381 </Box>
382 {body}
383 {u.status === 'offline' ? <Text dimColor>{`offline: using cached content (${u.message ?? ''})`}</Text> : null}
384 {u.status === 'checking' ? <Text dimColor>checking for content updates…</Text> : null}
385 </Box>
386 )
387 })
388}
389
390// ---- pane ------------------------------------------------------------------
391
392/** The banana: vector on the surfaces that draw Svg, ASCII on the terminal. */
393function bananaArt($: EngineInterface, e: RenderInput<'Pane'>, c: Content) {
394 if (e.surface === 'terminal') {
395 const { Box, Text } = $.ui.resolve(e)
396 return (
397 <Box flexDirection="column">
398 {(c.banana.art ?? BANANA_ART).map(line => (
399 <Text color="yellow">{line}</Text>
400 ))}
401 </Box>
402 )
403 }
404 const { Svg } = $.ui.resolve(e)
405 return <Svg source={c.banana.svg ?? BANANA_SVG} alt={c.banana.name} width={140} height={140} />
406}
407
408/** The sponsor's logo when the content carries SVG and the surface draws it; else the ASCII banner. */
409function sponsorArt($: EngineInterface, e: RenderInput<'Pane'>, c: Content) {
410 if (e.surface !== 'terminal' && c.sponsor.logo) {
411 const { Svg } = $.ui.resolve(e)
412 return <Svg source={c.sponsor.logo} alt={`${c.sponsor.name} logo`} height={c.sponsor.logoHeight ?? 64} />
413 }
414 const { Box, Text } = $.ui.resolve(e)
415 return (
416 <Box flexDirection="column">
417 {c.sponsor.banner.map(line => (
418 <Text color={c.sponsor.color}>{line}</Text>
419 ))}
420 </Box>
421 )
422}
423
424async function openPane($: EngineInterface, asked: boolean) {
425 const c = await read($, content)
426 const title = c.banana.name === 'Banana' ? TITLE : `🍌 ${c.banana.name}`
427 const opened = await $.ui.open(asked ? { id: PANE, title, focus: true } : { id: PANE, title })
428 if (!opened.isPlaced && asked) $.ui.toast('Banana: widen the terminal or go fullscreen to see the pane')
429}
430
431async function showTab($: EngineInterface, opts: Options, id: View) {
432 await update($, view, () => id)
433 if (id === 'top' && opts.serverUrl) void refreshBoard($, opts)
434 if (id === 'shop' && opts.serverUrl) void refreshStock($, opts)
435}
436
437// ---- play ------------------------------------------------------------------
438
439async function commit($: EngineInterface, fn: (s: Save) => Save): Promise<Save> {
440 const next = await update($, save, fn)
441 await $.store.set('save', next)
442 return next
443}
444
445async function addPending($: EngineInterface, delta: Pending) {
446 const next = await update($, pending, p => ({
447 clicks: p.clicks + delta.clicks,
448 coins: p.coins + delta.coins,
449 drops: p.drops + delta.drops,
450 }))
451 await $.store.set('pending', next)
452}
453
454async function pressBanana($: EngineInterface) {
455 const u = await read($, gate)
456 if (u.status === 'required') {
457 $.ui.toast('Banana: update the mod before playing')
458 return
459 }
460 const c = await read($, content)
461 const at = now()
462 const drop = rollDrop(Math.random, c.specials, at)
463 const before = await read($, save)
464 await commit($, s => press(expire(s, Date.now(), c.economy), drop, at))
465 await addPending($, { clicks: 1, coins: coinsPerClick(before.level), drops: drop ? 1 : 0 })
466 if (drop) {
467 const line = `${drop.glyph} ${drop.name} dropped! (${drop.rarity})`
468 await update($, lastEvent, () => line)
469 $.ui.toast(line)
470 }
471}
472
473async function sellRarity($: EngineInterface, rarity: Rarity) {
474 const c = await read($, content)
475 let sold = 0
476 let coins = 0
477 const at = now()
478 await commit($, s => {
479 const r = sellAll(expire(s, Date.now(), c.economy), c, rarity, at)
480 sold = r.sold
481 coins = r.coins
482 return r.save
483 })
484 if (sold > 0) {
485 await addPending($, { clicks: 0, coins, drops: 0 })
486 await update($, lastEvent, () => `Sold ${sold} ${rarity} banana${sold === 1 ? '' : 's'} for ${coins} coins`)
487 }
488}
489
490async function redeemGift($: EngineInterface, opts: Options, giftId: string) {
491 const u = await read($, gate)
492 if (u.status === 'required') {
493 $.ui.toast('Banana: update the mod before redeeming')
494 return
495 }
496 const [raw, c, r] = await Promise.all([read($, save), read($, content), read($, rate)])
497 const s = expire(raw, Date.now(), c.economy)
498 const gift = c.gifts.find(g => g.id === giftId)
499 if (!gift) return
500 let price = priceOf(gift, r.coinsPerUsd)
501 const why = canRedeem(s, gift, price, !!opts.serverUrl)
502 if (why === 'server-only') {
503 $.ui.toast(`Banana: ${gift.name} is paid out by the sponsor server; set banana.serverUrl`)
504 return
505 }
506 if (why === 'no-coins') {
507 $.ui.toast(`Banana: need ${price - s.coins} more coins for ${gift.name}`)
508 return
509 }
510 if (why === 'no-stock') {
511 $.ui.toast(`Banana: ${gift.name} is sold out`)
512 return
513 }
514 const me0 = await read($, player)
515 if (gift.claim.kind === 'gateway' && !me0.gateway) {
516 $.ui.toast('Banana: link your gateway login first: /banana link <username>')
517 await update($, lastEvent, () => 'Tokens are paid to your gateway account. Register on the sponsor gateway, then run /banana link <your username>.')
518 return
519 }
520 const at = now()
521 let claim = gift.claim.value ?? ''
522 let serverCoins: number | undefined
523 if (opts.serverUrl) {
524 await sync($, opts)
525 const me = await read($, player)
526 try {
527 const res = await postJson($, `${opts.serverUrl}/redeem`, { playerId: me.id, giftId, gateway: me.gateway, modVersion: u.localVersion })
528 const body = res as { claim?: string; coins?: number; price?: number; stockLeft?: number; error?: string }
529 if (body.error || typeof body.claim !== 'string') throw new Error(body.error ?? 'no claim in response')
530 claim = body.claim
531 serverCoins = body.coins
532 if (typeof body.price === 'number') price = body.price
533 if (typeof body.stockLeft === 'number') {
534 await update($, serverStock, st => ({ ...st, [giftId]: body.stockLeft as number }))
535 }
536 } catch (err) {
537 $.ui.toast(`Banana: sponsor refused: ${reason(err)}`)
538 return
539 }
540 }
541 await commit($, s2 => {
542 const live = expire(s2, Date.now(), c.economy)
543 // In server mode the server already took the coins from its ledger; mirror it locally as far as the local ledger allows.
544 const localPrice = Math.min(price, live.coins)
545 const next = canRedeem(live, gift, localPrice, !!opts.serverUrl) === 'ok' ? redeem(live, gift, localPrice, claim, at) : live
546 return serverCoins === undefined ? next : { ...next, coins: Math.min(next.coins, serverCoins) }
547 })
548 await update($, lastEvent, () => `Redeemed ${gift.name}: ${claim}`)
549 $.ui.toast(`🎁 ${gift.name}: ${claim}`, { timeoutMs: 10000 })
550}
551
552// ---- content and the update gate --------------------------------------------
553
554async function readLocalVersion($: EngineInterface): Promise<string> {
555 try {
556 const text = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)
557 const v = (JSON.parse(text) as { version?: unknown }).version
558 if (typeof v === 'string') return v
559 } catch {
560 // no manifest readable (tests): the constant stands
561 }
562 return MOD_VERSION
563}
564
565function gateFor(localVersion: string, c: Content, fallback: Update['status'], message?: string): Update {
566 const required = isUpdateRequired(localVersion, c.minVersion)
567 return {
568 status: required ? 'required' : fallback,
569 localVersion,
570 minVersion: c.minVersion,
571 contentVersion: c.version,
572 checkedAt: now(),
573 message: required ? `Mod ${localVersion} is too old for content ${c.version} (needs ${c.minVersion}).` : message,
574 }
575}
576
577async function applyFallbackRate($: EngineInterface, opts: Options) {
578 if (opts.serverUrl) return
579 const c = await read($, content)
580 await update($, rate, (): Rate => ({
581 coinsPerUsd: coinsPerUsd(0, c.economy) > c.economy.fallbackCoinsPerUsd ? coinsPerUsd(0, c.economy) : c.economy.fallbackCoinsPerUsd,
582 windowCoins: 0,
583 budgetUnits: budgetUnits(c.economy),
584 source: 'fallback',
585 updatedAt: now(),
586 }))
587}
588
589async function refreshContent($: EngineInterface, opts: Options) {
590 const localVersion = await readLocalVersion($)
591 if (!opts.contentUrl) {
592 await update($, gate, (): Update => ({ status: 'ok', localVersion, checkedAt: now() }))
593 await applyFallbackRate($, opts)
594 return
595 }
596 const before = await read($, gate)
597 await update($, gate, (u): Update => ({ ...u, status: 'checking', localVersion }))
598 try {
599 const res = await $.http.fetch(opts.contentUrl)
600 if (!res.ok) throw new Error(`HTTP ${res.status}`)
601 const fresh = parseContent(JSON.parse(res.text))
602 await update($, content, () => fresh)
603 await $.store.set('content', fresh)
604 const next = gateFor(localVersion, fresh, 'ok')
605 await update($, gate, () => next)
606 await applyFallbackRate($, opts)
607 if (next.status === 'required' && before.status !== 'required') {
608 $.ui.toast(`Banana: update required (${localVersion} → ${fresh.minVersion}). Open /banana.`, { timeoutMs: 8000 })
609 }
610 } catch (err) {
611 let cached: Content | undefined
612 try {
613 const kept = await $.store.get('content')
614 if (kept) cached = parseContent(kept)
615 } catch {
616 cached = undefined
617 }
618 if (cached) await update($, content, () => cached as Content)
619 const basis = cached ?? (await read($, content))
620 await update($, gate, () => gateFor(localVersion, basis, 'offline', reason(err)))
621 }
622}
623
624async function refreshContentIfStale($: EngineInterface, opts: Options) {
625 if (!opts.contentUrl) return
626 const u = await read($, gate)
627 const age = u.checkedAt ? Date.now() - Date.parse(u.checkedAt) : Infinity
628 if (u.status !== 'checking' && age > CONTENT_TTL_MS) await refreshContent($, opts)
629}
630
631/** How players install it: `/plugin install banana@banana`. */
632const PLUGIN_ID = 'banana@banana'
633const UPDATE_HINT = `claude plugin update ${PLUGIN_ID}`
634
635/**
636 * A marketplace install updates through the plugin CLI (then a restart); a
637 * clone run with --plugin-dir updates with git pull and reloads by itself.
638 */
639async function runUpdate($: EngineInterface) {
640 const root = $.plugin.root
641 const isGit = await $.fs.exists(`${root}/.git`).catch(() => false)
642 const steps: string[][] = isGit
643 ? [['git', '-C', root, 'pull', '--ff-only']]
644 : [
645 ['claude', 'plugin', 'marketplace', 'update', 'banana'],
646 ['claude', 'plugin', 'update', PLUGIN_ID],
647 ]
648 await update($, lastEvent, () => `Running ${steps.map(s => s.slice(0, 3).join(' ')).join(', then ')}…`)
649 try {
650 for (const argv of steps) {
651 const r = await $.process.run(argv, { timeoutMs: 120000 })
652 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || r.stdout.trim() || `${argv[0]} exited ${r.exitCode}`)
653 }
654 const done = isGit
655 ? 'Updated. The mod reloads by itself; if this screen stays, restart Claude Code.'
656 : 'Updated. Restart Claude Code to play the new version.'
657 await update($, lastEvent, () => done)
658 $.ui.toast('Banana updated')
659 } catch (err) {
660 const fallback = isGit ? `git -C ${root} pull` : UPDATE_HINT
661 await update($, lastEvent, () => `Update failed: ${reason(err)}. Run \`${fallback}\` in a terminal, then restart Claude Code.`)
662 $.ui.toast(`Banana update failed: ${reason(err)}`)
663 }
664}
665
666// ---- server ----------------------------------------------------------------
667
668async function ensurePlayer($: EngineInterface) {
669 let id = await $.store.get('playerId')
670 if (typeof id !== 'string' || !id) {
671 id = crypto.randomUUID()
672 await $.store.set('playerId', id)
673 }
674 const name = await $.store.get('name')
675 const gateway = await $.store.get('gateway')
676 const me: Player = { id: id as string, name: typeof name === 'string' ? name : '', gateway: typeof gateway === 'string' && gateway ? gateway : undefined }
677 await update($, player, () => me)
678}
679
680async function postJson($: EngineInterface, url: string, body: unknown): Promise<unknown> {
681 const res = await $.http.fetch(url, {
682 method: 'POST',
683 headers: { 'content-type': 'application/json' },
684 body: JSON.stringify(body),
685 })
686 let parsed: unknown = {}
687 try {
688 parsed = res.text ? JSON.parse(res.text) : {}
689 } catch {
690 parsed = {}
691 }
692 if (!res.ok) {
693 const msg = (parsed as { error?: string }).error
694 throw new Error(msg ?? `HTTP ${res.status}`)
695 }
696 return parsed
697}
698
699/** One beacon per session so the sponsor can count installs and players; nothing but the id, version and surface. */
700async function hello($: EngineInterface, opts: Options) {
701 if (!opts.serverUrl) return
702 try {
703 const [me, u] = await Promise.all([read($, player), read($, gate)])
704 if (!me.id) return
705 await postJson($, `${opts.serverUrl}/hello`, { playerId: me.id, modVersion: u.localVersion, surface: surfaceName })
706 } catch {
707 // counting is best effort
708 }
709}
710
711/** Reports pending deltas; on failure they stay pending. */
712async function sync($: EngineInterface, opts: Options) {
713 if (!opts.serverUrl) return
714 const [delta, me, u] = await Promise.all([read($, pending), read($, player), read($, gate)])
715 if (!me.id) return
716 if (delta.clicks === 0 && delta.coins === 0 && delta.drops === 0 && !me.name && !me.gateway) return
717 try {
718 const res = (await postJson($, `${opts.serverUrl}/sync`, {
719 playerId: me.id,
720 name: me.name || undefined,
721 gateway: me.gateway,
722 clicks: delta.clicks,
723 coins: delta.coins,
724 drops: delta.drops,
725 modVersion: u.localVersion,
726 })) as { rank?: number; totalPlayers?: number; globalClicks?: number; rate?: Partial<Rate> }
727 if (res.rate && typeof res.rate.coinsPerUsd === 'number') {
728 const r = res.rate
729 await update($, rate, (): Rate => ({
730 coinsPerUsd: r.coinsPerUsd as number,
731 windowCoins: r.windowCoins ?? 0,
732 budgetUnits: r.budgetUnits ?? 1,
733 source: 'server',
734 updatedAt: now(),
735 }))
736 }
737 const next = await update($, pending, p => ({
738 clicks: p.clicks - delta.clicks,
739 coins: p.coins - delta.coins,
740 drops: p.drops - delta.drops,
741 }))
742 await $.store.set('pending', next)
743 await update($, board, b => ({
744 ...b,
745 rank: res.rank ?? b.rank,
746 totalPlayers: res.totalPlayers ?? b.totalPlayers,
747 globalClicks: res.globalClicks ?? b.globalClicks,
748 error: undefined,
749 }))
750 } catch (err) {
751 await update($, board, b => ({ ...b, error: reason(err) }))
752 }
753}
754
755async function refreshBoard($: EngineInterface, opts: Options) {
756 if (!opts.serverUrl) return
757 try {
758 const res = await $.http.fetch(`${opts.serverUrl}/leaderboard`)
759 if (!res.ok) throw new Error(`HTTP ${res.status}`)
760 const body = JSON.parse(res.text) as Partial<Leaderboard>
761 await update($, board, b => ({
762 top: Array.isArray(body.top) ? body.top : [],
763 globalClicks: body.globalClicks ?? 0,
764 totalPlayers: body.totalPlayers ?? 0,
765 rank: b.rank,
766 fetchedAt: now(),
767 error: undefined,
768 }))
769 } catch (err) {
770 await update($, board, b => ({ ...b, error: reason(err) }))
771 }
772}
773
774async function refreshStock($: EngineInterface, opts: Options) {
775 if (!opts.serverUrl) return
776 try {
777 const [stockRes, rateRes] = await Promise.all([$.http.fetch(`${opts.serverUrl}/stock`), $.http.fetch(`${opts.serverUrl}/rate`)])
778 if (stockRes.ok) await update($, serverStock, () => JSON.parse(stockRes.text) as Record<string, number>)
779 if (rateRes.ok) {
780 const r = JSON.parse(rateRes.text) as Partial<Rate>
781 if (typeof r.coinsPerUsd === 'number') {
782 await update($, rate, (): Rate => ({
783 coinsPerUsd: r.coinsPerUsd as number,
784 windowCoins: r.windowCoins ?? 0,
785 budgetUnits: r.budgetUnits ?? 1,
786 source: 'server',
787 updatedAt: now(),
788 }))
789 }
790 }
791 } catch {
792 // the local stock and rate stand
793 }
794}
795
796/** Once a minute: report deltas and, while the pane shows, refresh the board. */
797async function tick($: EngineInterface, opts: Options) {
798 if (!opts.serverUrl) return
799 await sync($, opts)
800 const shown = (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
801 if (shown) {
802 const v = await read($, view)
803 if (v === 'top') await refreshBoard($, opts)
804 if (v === 'shop') await refreshStock($, opts)
805 }
806}
807hooks/catalog.ts 53 lines1// Bundled content: what the game uses until contentUrl is set, and the
2// shape of the content.json you host. Remote content replaces all of it.
3
4import type { Content } from '../types'
5import { DEFAULT_ECONOMY } from './game'
6
7export const DEFAULT_CONTENT: Content = {
8 version: '1',
9 minVersion: '0.1.0',
10 sponsor: {
11 name: 'Banana Sponsor',
12 tagline: 'Click while Claude thinks. Earn LLM tokens.',
13 color: 'yellow',
14 banner: [
15 ' _______ ',
16 ' / _____ \\ ',
17 '| |BANANA| |',
18 ' \\_______/ ',
19 ],
20 lines: ['Every click earns coins; coins buy $1 of LLM tokens.', 'Coins and bananas are void after 7 days. Lifetime totals stay.'],
21 },
22 banana: {
23 name: 'Banana Sponsor Banana',
24 buttonLabel: '🍌 CLICK THE BANANA 🍌',
25 },
26 specials: [
27 { id: 'common', name: 'Yellow Banana', glyph: '🍌', rarity: 'common', chance: 0.1, sellValue: 5 },
28 { id: 'rare', name: 'Green Banana', glyph: '🟢', rarity: 'rare', chance: 0.02, sellValue: 25 },
29 { id: 'epic', name: 'Blue Banana', glyph: '🔵', rarity: 'epic', chance: 0.004, sellValue: 150 },
30 { id: 'legendary', name: 'Golden Banana', glyph: '⭐', rarity: 'legendary', chance: 0.0005, sellValue: 1000 },
31 ],
32 gifts: [
33 {
34 // The $10 sponsor budget per window: ten $1 units of LLM API credit,
35 // each a one-time code the server holds (POST /codes). Coin price = $1 × rate.
36 id: 'llm-tokens',
37 name: 'LLM tokens',
38 sponsor: 'Banana Sponsor',
39 usd: 1,
40 stock: 10,
41 description: '$1 of LLM API credit, topped up on your gateway account',
42 claim: { kind: 'gateway' },
43 },
44 ],
45 economy: DEFAULT_ECONOMY,
46 gateway: {
47 name: 'Banana Sponsor gateway',
48 url: 'https://banana.jevable.ai/register',
49 hint: 'Register there, then run /banana link <your username> here.',
50 },
51 notes: 'Edit content.json and bump minVersion when the game needs a newer mod.',
52}
53hooks/game.ts 255 lines1// Pure game logic: no `$`, so tests hit it directly and the hooks stay thin.
2
3import type { Content, Drop, Economy, Gift, Rarity, Save, SpecialBanana } from '../types'
4
5export const MOD_VERSION = '0.1.0'
6export const CLICKS_PER_LEVEL = 100
7
8export const RARITY_ORDER: Rarity[] = ['legendary', 'epic', 'rare', 'common']
9
10export const RARITY_COLOR: Record<Rarity, string> = {
11 common: 'green',
12 rare: 'blue',
13 epic: 'magenta',
14 legendary: 'yellow',
15}
16
17export const DEFAULT_ECONOMY: Economy = {
18 windowHours: 168,
19 budgetUsd: 10,
20 unitUsd: 1,
21 minCoinsPerUsd: 500,
22 fallbackCoinsPerUsd: 1000,
23}
24
25export const emptySave = (): Save => ({
26 coins: 0,
27 clicks: 0,
28 level: 0,
29 inventory: [],
30 claims: [],
31 stock: {},
32 ledger: [],
33 lifetime: { clicks: 0, coins: 0, drops: 0 },
34})
35
36/** An older save (no ledger) becomes one batch earned now; a foreign value becomes empty. */
37export function normalizeSave(v: unknown, at: string): Save {
38 const s = v as Partial<Save> | null
39 if (!s || typeof s !== 'object' || typeof s.coins !== 'number') return emptySave()
40 const base = { ...emptySave(), ...s }
41 if (!Array.isArray(s.ledger)) base.ledger = s.coins > 0 ? [{ at, coins: s.coins, left: s.coins }] : []
42 if (!s.lifetime) base.lifetime = { clicks: s.clicks ?? 0, coins: s.coins, drops: (s.inventory ?? []).length }
43 return base
44}
45
46export const windowMs = (eco: Economy): number => eco.windowHours * 3600 * 1000
47
48export const budgetUnits = (eco: Economy): number => Math.max(1, Math.round(eco.budgetUsd / eco.unitUsd))
49
50/** Coins per $1 from what everyone earned in the window, never under the floor. */
51export const coinsPerUsd = (windowCoins: number, eco: Economy): number =>
52 Math.max(eco.minCoinsPerUsd, Math.ceil(windowCoins / budgetUnits(eco)))
53
54/** A gift's coin price at a rate: `usd × rate`, or its fixed price. */
55export const priceOf = (gift: Gift, rate: number): number =>
56 gift.usd !== undefined ? Math.ceil(gift.usd * rate) : (gift.priceCoins ?? 0)
57
58const isLive = (at: string, nowMs: number, eco: Economy): boolean => nowMs - Date.parse(at) < windowMs(eco)
59
60/** Drops the void ledger rows and bananas and recomputes the spendable balance. */
61export function expire(save: Save, nowMs: number, eco: Economy): Save {
62 const ledger = save.ledger.filter(row => row.left > 0 && isLive(row.at, nowMs, eco))
63 const inventory = save.inventory.filter(drop => isLive(drop.at, nowMs, eco))
64 const coins = ledger.reduce((sum, row) => sum + row.left, 0)
65 if (ledger.length === save.ledger.length && inventory.length === save.inventory.length && coins === save.coins) return save
66 return { ...save, ledger, inventory, coins }
67}
68
69/** When the oldest live coins or banana go void, as a time; undefined with nothing held. */
70export function nextExpiry(save: Save, eco: Economy): number | undefined {
71 const times = [...save.ledger.map(r => r.at), ...save.inventory.map(d => d.at)].map(at => Date.parse(at) + windowMs(eco))
72 return times.length ? Math.min(...times) : undefined
73}
74
75export function earn(save: Save, coins: number, at: string): Save {
76 if (coins <= 0) return save
77 return {
78 ...save,
79 coins: save.coins + coins,
80 ledger: [...save.ledger, { at, coins, left: coins }].slice(-2000),
81 lifetime: { ...save.lifetime, coins: save.lifetime.coins + coins },
82 }
83}
84
85/** Spends `amount` from the oldest batches first; throws when the balance is short. */
86export function spend(save: Save, amount: number): Save {
87 if (amount > save.coins) throw new Error(`need ${amount - save.coins} more coins`)
88 let todo = amount
89 const ledger = save.ledger.map(row => {
90 if (todo === 0 || row.left === 0) return row
91 const take = Math.min(row.left, todo)
92 todo -= take
93 return { ...row, left: row.left - take }
94 })
95 return { ...save, coins: save.coins - amount, ledger: ledger.filter(row => row.left > 0) }
96}
97
98export const levelFor = (clicks: number): number => Math.floor(clicks / CLICKS_PER_LEVEL)
99
100export const coinsPerClick = (level: number): number => 1 + level
101
102export const clicksToNextLevel = (clicks: number): number =>
103 CLICKS_PER_LEVEL - (clicks % CLICKS_PER_LEVEL)
104
105/** Semver-ish compare: negative when a < b, 0 when equal, positive when a > b. */
106export function compareVersions(a: string, b: string): number {
107 const pa = a.split('.').map(part => parseInt(part, 10) || 0)
108 const pb = b.split('.').map(part => parseInt(part, 10) || 0)
109 const n = Math.max(pa.length, pb.length)
110 for (let i = 0; i < n; i++) {
111 const d = (pa[i] ?? 0) - (pb[i] ?? 0)
112 if (d !== 0) return d
113 }
114 return 0
115}
116
117export const isUpdateRequired = (localVersion: string, minVersion: string): boolean =>
118 compareVersions(localVersion, minVersion) < 0
119
120/**
121 * Rolls one click's drop: the rarest kinds are tried first, each with its own
122 * chance, so chances are independent per kind and the rarest wins a tie.
123 */
124export function rollDrop(rng: () => number, specials: readonly SpecialBanana[], now: string): Drop | undefined {
125 const byRarity = [...specials].sort(
126 (x, y) => RARITY_ORDER.indexOf(x.rarity) - RARITY_ORDER.indexOf(y.rarity),
127 )
128 for (const kind of byRarity) {
129 if (rng() < kind.chance) {
130 return {
131 id: `${kind.id}-${now}-${Math.floor(rng() * 1e6)}`,
132 kindId: kind.id,
133 name: kind.name,
134 glyph: kind.glyph,
135 rarity: kind.rarity,
136 at: now,
137 }
138 }
139 }
140 return undefined
141}
142
143export function press(save: Save, drop: Drop | undefined, at: string): Save {
144 const clicks = save.clicks + 1
145 const earned = earn(save, coinsPerClick(save.level), at)
146 return {
147 ...earned,
148 clicks,
149 level: levelFor(clicks),
150 inventory: drop ? [...save.inventory, drop].slice(-500) : save.inventory,
151 lifetime: { ...earned.lifetime, clicks: earned.lifetime.clicks + 1, drops: earned.lifetime.drops + (drop ? 1 : 0) },
152 }
153}
154
155export const sellValueOf = (content: Content, kindId: string): number =>
156 content.specials.find(kind => kind.id === kindId)?.sellValue ?? 0
157
158export function sellAll(save: Save, content: Content, rarity: Rarity, at: string): { save: Save; sold: number; coins: number } {
159 const keep: Drop[] = []
160 let coins = 0
161 let sold = 0
162 for (const drop of save.inventory) {
163 if (drop.rarity === rarity) {
164 coins += sellValueOf(content, drop.kindId)
165 sold += 1
166 } else keep.push(drop)
167 }
168 return { save: earn({ ...save, inventory: keep }, coins, at), sold, coins }
169}
170
171export const stockLeft = (save: Save, gift: Gift): number => save.stock[gift.id] ?? gift.stock
172
173export type RedeemCheck = 'ok' | 'no-coins' | 'no-stock' | 'server-only'
174
175export const canRedeem = (save: Save, gift: Gift, price: number, hasServer = true): RedeemCheck => {
176 if ((gift.claim.kind === 'pool' || gift.claim.kind === 'gateway') && !hasServer) return 'server-only'
177 if (stockLeft(save, gift) === 0) return 'no-stock'
178 if (save.coins < price) return 'no-coins'
179 return 'ok'
180}
181
182export const giftValue = (gift: Gift): string => (gift.usd ? `$${gift.usd}` : gift.name)
183
184/** Takes `price` (oldest coins first) and one unit of stock; the claim text is what the person receives. */
185export function redeem(save: Save, gift: Gift, price: number, claim: string, at: string): Save {
186 const left = stockLeft(save, gift)
187 const paid = spend(save, price)
188 return {
189 ...paid,
190 stock: { ...paid.stock, [gift.id]: left < 0 ? left : left - 1 },
191 claims: [...paid.claims, { giftId: gift.id, giftName: gift.name, at, claim }].slice(-200),
192 }
193}
194
195export function countByRarity(inventory: readonly Drop[]): Record<Rarity, number> {
196 const counts: Record<Rarity, number> = { common: 0, rare: 0, epic: 0, legendary: 0 }
197 for (const drop of inventory) counts[drop.rarity] += 1
198 return counts
199}
200
201function parseEconomy(v: unknown): Economy {
202 const e = (v ?? {}) as Partial<Economy>
203 const num = (x: unknown, d: number, min: number) => (typeof x === 'number' && isFinite(x) && x >= min ? x : d)
204 return {
205 windowHours: num(e.windowHours, DEFAULT_ECONOMY.windowHours, 1),
206 budgetUsd: num(e.budgetUsd, DEFAULT_ECONOMY.budgetUsd, 0),
207 unitUsd: num(e.unitUsd, DEFAULT_ECONOMY.unitUsd, 0.01),
208 minCoinsPerUsd: num(e.minCoinsPerUsd, DEFAULT_ECONOMY.minCoinsPerUsd, 1),
209 fallbackCoinsPerUsd: num(e.fallbackCoinsPerUsd, DEFAULT_ECONOMY.fallbackCoinsPerUsd, 1),
210 }
211}
212
213const svgOrUndefined = (v: unknown): string | undefined =>
214 typeof v === 'string' && /^\s*<svg[\s>]/i.test(v) ? v.slice(0, 131072) : undefined
215
216/** Narrow unknown JSON to Content; throws with a reason when it is not one. */
217export function parseContent(json: unknown): Content {
218 const c = json as Partial<Content> | null
219 if (!c || typeof c !== 'object') throw new Error('content is not an object')
220 if (typeof c.version !== 'string') throw new Error('content.version missing')
221 if (typeof c.minVersion !== 'string') throw new Error('content.minVersion missing')
222 if (!c.sponsor || typeof c.sponsor.name !== 'string') throw new Error('content.sponsor missing')
223 if (!Array.isArray(c.gifts)) throw new Error('content.gifts missing')
224 if (!Array.isArray(c.specials)) throw new Error('content.specials missing')
225 return {
226 version: c.version,
227 minVersion: c.minVersion,
228 sponsor: {
229 name: c.sponsor.name,
230 tagline: c.sponsor.tagline ?? '',
231 banner: Array.isArray(c.sponsor.banner) ? c.sponsor.banner.map(String) : [],
232 color: c.sponsor.color ?? 'yellow',
233 logo: svgOrUndefined(c.sponsor.logo),
234 logoHeight: typeof c.sponsor.logoHeight === 'number' ? Math.max(16, Math.min(400, c.sponsor.logoHeight)) : undefined,
235 lines: Array.isArray(c.sponsor.lines) ? c.sponsor.lines.map(String).slice(0, 12) : undefined,
236 url: typeof c.sponsor.url === 'string' && /^https:\/\//.test(c.sponsor.url) ? c.sponsor.url : undefined,
237 urlLabel: typeof c.sponsor.urlLabel === 'string' ? c.sponsor.urlLabel : undefined,
238 },
239 banana: {
240 name: typeof c.banana?.name === 'string' && c.banana.name.trim() ? c.banana.name.trim().slice(0, 40) : 'Banana',
241 svg: svgOrUndefined(c.banana?.svg),
242 art: Array.isArray(c.banana?.art) ? c.banana.art.map(String).slice(0, 12) : undefined,
243 buttonLabel: typeof c.banana?.buttonLabel === 'string' ? c.banana.buttonLabel.slice(0, 60) : undefined,
244 },
245 gifts: c.gifts,
246 specials: c.specials,
247 economy: parseEconomy(c.economy),
248 gateway:
249 c.gateway && typeof c.gateway.name === 'string'
250 ? { name: c.gateway.name, url: typeof c.gateway.url === 'string' ? c.gateway.url : undefined, hint: typeof c.gateway.hint === 'string' ? c.gateway.hint : undefined }
251 : undefined,
252 notes: typeof c.notes === 'string' ? c.notes : undefined,
253 }
254}
255types/index.d.ts 179 lines1// The banana mod's state contract: what `$.state` holds under `banana`.
2
3export type Rarity = 'common' | 'rare' | 'epic' | 'legendary'
4
5/** A banana kind the content defines; a drop is one of these. */
6export type SpecialBanana = {
7 id: string
8 name: string
9 glyph: string
10 rarity: Rarity
11 /** Probability per click, 0..1. */
12 chance: number
13 /** Coins it sells for from the bag. */
14 sellValue: number
15}
16
17export type Drop = { id: string; kindId: string; name: string; glyph: string; rarity: Rarity; at: string }
18
19/**
20 * What a redeemed gift hands the player: a shared code, a link, (`pool`) a
21 * one-time code the server holds, or (`gateway`) a top-up of the player's
22 * account on the sponsor's LLM gateway, which needs the server and a linked login.
23 */
24export type ClaimSpec =
25 | { kind: 'code'; value: string }
26 | { kind: 'url'; value: string }
27 | { kind: 'pool'; value?: string }
28 | { kind: 'gateway'; value?: string }
29
30export type Gift = {
31 id: string
32 name: string
33 sponsor: string
34 /** Initial stock; the save tracks what is left. -1 = unlimited. */
35 stock: number
36 /** Money value of one unit: its coin price is `usd × rate`. A budget gift is `usd: 1, stock: 10` for $10. */
37 usd?: number
38 /** Fixed coin price for a gift with no `usd`. */
39 priceCoins?: number
40 /** One line under the name: what the player actually gets. */
41 description?: string
42 claim: ClaimSpec
43}
44
45export type Claim = { giftId: string; giftName: string; at: string; claim: string }
46
47/** One batch of coins earned at `at`; `left` is what is still unspent. */
48export type LedgerRow = { at: string; coins: number; left: number }
49
50export type Lifetime = { clicks: number; coins: number; drops: number }
51
52export type Save = {
53 /** Spendable coins: the unexpired `left` of the ledger, cached by `expire()`. */
54 coins: number
55 clicks: number
56 level: number
57 inventory: Drop[]
58 claims: Claim[]
59 /** Stock left per gift id; absent = the gift's initial stock. */
60 stock: Record<string, number>
61 /** Coins by earn time, oldest first; a row older than the window is void. */
62 ledger: LedgerRow[]
63 /** Never expires: what the leaderboard and the player's history show. */
64 lifetime: Lifetime
65}
66
67/** The sponsor's economy: a budget per window, shared pro-rata by everything earned in it. */
68export type Economy = {
69 /** Coins and bananas are void after this many hours; also the pool window. Default 168 (7 days). */
70 windowHours: number
71 /** The sponsor's budget per window, in USD. */
72 budgetUsd: number
73 /** Smallest exchange, in USD; budgetUsd / unitUsd is the number of units. */
74 unitUsd: number
75 /** Coins per $1 never drop below this, so a quiet week is not free. */
76 minCoinsPerUsd: number
77 /** Coins per $1 used in single-player mode, where no pool total exists. */
78 fallbackCoinsPerUsd: number
79}
80
81/** The exchange rate as last computed: by the server, or the content's fallback. */
82export type Rate = {
83 coinsPerUsd: number
84 /** Coins everyone earned in the window (server) or 0. */
85 windowCoins: number
86 budgetUnits: number
87 source: 'server' | 'fallback'
88 updatedAt?: string
89}
90
91/** What content.json holds: hosted by you, fetched on start, and the update gate. */
92export type Content = {
93 /** Version of this content document. */
94 version: string
95 /** The oldest mod version allowed to play this content. */
96 minVersion: string
97 sponsor: {
98 name: string
99 tagline: string
100 /** ASCII lines, drawn on the terminal (and wherever no logo is given). */
101 banner: string[]
102 color: string
103 /** Optional SVG markup (<svg>…</svg>, ≤ 131072 chars), drawn instead of the banner on the desktop. */
104 logo?: string
105 /** CSS pixel height for the logo; default 64. */
106 logoHeight?: number
107 /** Extra text lines under the tagline: a message, terms, a promo. */
108 lines?: string[]
109 /** An https link drawn under the sponsor text. */
110 url?: string
111 urlLabel?: string
112 }
113 /** The click banana, skinnable by the sponsor. */
114 banana: {
115 /** "Acme Banana": shown as the pane title and over the banana. */
116 name: string
117 /** SVG markup for the big banana; absent, the built-in banana. */
118 svg?: string
119 /** ASCII lines for the terminal; absent, the built-in art. */
120 art?: string[]
121 /** The button's text; default "🍌 CLICK THE BANANA 🍌". */
122 buttonLabel?: string
123 }
124 gifts: Gift[]
125 specials: SpecialBanana[]
126 economy: Economy
127 /** Where players register to receive tokens: shown in the Shop. */
128 gateway?: { name: string; url?: string; hint?: string }
129 notes?: string
130}
131
132export type UpdateStatus = 'unchecked' | 'checking' | 'ok' | 'required' | 'offline'
133
134export type Update = {
135 status: UpdateStatus
136 localVersion: string
137 minVersion?: string
138 contentVersion?: string
139 message?: string
140 checkedAt?: string
141}
142
143export type View = 'click' | 'bag' | 'shop' | 'top'
144
145/** Deltas not yet reported to the server. */
146export type Pending = { clicks: number; coins: number; drops: number }
147
148/** `gateway` is the player's login on the sponsor's LLM gateway (One API username), where tokens are paid out. */
149export type Player = { id: string; name: string; gateway?: string }
150
151export type LeaderRow = { name: string; coins: number; clicks: number }
152
153export type Leaderboard = {
154 top: LeaderRow[]
155 globalClicks: number
156 totalPlayers: number
157 rank?: number
158 fetchedAt?: string
159 error?: string
160}
161
162declare module 'claude-code' {
163 interface PluginState {
164 banana: {
165 save: Save
166 view: View
167 isThinking: boolean
168 lastEvent: string
169 content: Content
170 update: Update
171 pending: Pending
172 rate: Rate
173 player: Player
174 board: Leaderboard
175 serverStock: Record<string, number>
176 }
177 }
178}
179