SLOPSHOPPER

Banana — click while Claude thinks

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.

newpanecommandtoastprocessnetwork
v0.1.0MITupdated 2026-10-08somethingwentwell/cc-mod-banana-game
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · banana
│ ┃ 🍌 Banana Sponsor Banana ✕ › fix the failing auth test and add an audit log call │ ┃ _______ │ ┃ / _____ \ ⏺ Read(src/auth.ts) │ ┃ | |BANANA| | ⎿ Read 6 lines │ ┃ \_______/ ⏺ Update(src/auth.ts) │ ┃ Banana Sponsor · Click while Claude ⎿ Added 2 lines, removed 1 line │ ┃ thinks. Earn LLM tokens. ⏺ Bash(bun test) │ ┃ Every click earns coins; coins buy $1 of ⎿ 3 pass, 1 fail │ ┃ LLM tokens. │ ┃ Coins and bananas are void after 7 days. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Lifetime totals stay. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 🪙 0 coins Lv 0 Claude is idle │ ┃ void in — · lifetime 0 coins, 0 clicks › /banana │ ┃ ⎿ banana: Banana pane opened. Click the banana while Claude thinks │ ┃ c: [Click] b: Bag s: Shop t: Top [ Close ] │ ┃ │ ┃ Banana Sponsor Banana │ ┃ │ ┃ _ │ ┃ // │ ┃ // │ ┃ (( │ ┃ \\__ │ ┃ \__) │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 🍌 Banana Sponsor Banana
_______ / _____ \ | |BANANA| | \_______/ Banana Sponsor · Click while Claude thinks. Earn LLM tokens. Every click earns coins; coins buy $1 of LLM tokens. Coins and bananas are void after 7 days. Lifetime totals stay. 🪙 0 coins Lv 0 Claude is idle void in — · lifetime 0 coins, 0 clicks c: [Click] b: Bag s: Shop t: Top [ Close ] Banana Sponsor Banana _ // // (( \\__ \__) [ 🍌 CLICK THE BANANA 🍌 ] 0 clicks · +1 per click · 100 to Lv 1 offline: using cached content (HTTP 0)
README

🍌 Banana for Claude Code

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.

Claude Code with the Banana pane docked beside the chat

<sub>Real capture: Claude Code 2.1.291 in a terminal with the mod loaded.</sub>

Why you'll keep it on

  • Waiting pays. Every turn Claude spends thinking is a few hundred clicks you can cash in.
  • Rare drops. Each click can drop a banana: 🍌 common, 🟢 rare, 🔵 epic, or the ⭐ Golden Banana at 1 in 2,000.
  • Real rewards. Coins buy $1 of LLM tokens, topped up straight onto your account on the sponsor's gateway. No setup: the mod is already connected.
  • A live leaderboard. See how many bananas everyone has clicked this week, and where you rank.
  • It stays out of the way. The pane opens when Claude thinks and closes when Claude answers. Nothing touches your code or your conversation.

Get started

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, /banana always opens it.

You need a recent Claude Code (tested on 2.1.291).

How to play

Click tabClick. 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 tabBag. Your bananas by rarity, what each kind sells for, and its drop chance. Sell a whole rarity for coins in one press.
Shop tab before linking a gateway accountShop. The live price of $1 of LLM tokens. Register on the sponsor's gateway once, then type /banana link <your username>.
Shop tab after redeemingRedeem. Press the redeem button and $1 of tokens is added to your gateway account. The receipt stays in the Shop.
Top tab with the leaderboardTop. 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

CommandWhat it does
/bananaOpen 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 rules in one minute

  • The price floats. The sponsor puts a fixed budget of tokens on the table each week. The more coins everyone earns, the more coins $1 of tokens costs. The Shop shows the current rate and why.
  • Spend it or lose it. Coins and bananas expire 7 days after you earn them, oldest first, so cash in regularly. The header shows when your oldest coins expire.
  • Glory is forever. Your lifetime clicks and coins never expire, and the leaderboard ranks lifetime coins.
  • Saved on your machine. Progress lives in Claude Code's plugin store and survives restarts.

Get your tokens

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:

  1. Register at banana.jevable.ai/register (the Shop shows the same link).
  2. In Claude Code, type /banana link <your username>.
  3. Redeem in the Shop. The $1 of tokens lands on that account. Create an API key there and use 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.

Updating

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.

What it sends

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.

Run from source

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.

Source 4 files
hooks/register.tsx 807 lines
1// 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}
807
hooks/catalog.ts 53 lines
1// 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}
53
hooks/game.ts 255 lines
1// 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}
255
types/index.d.ts 179 lines
1// 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