SLOPSHOPPER

dungeon-buddy

Buddy: /buddy hatches your dungeon pet from your account. It sits above the prompt, fidgets, and talks about your session's Mishaps and Long Hauls, or, with…

newbandguardcommandmodelprocess
v0.1.1no licenseupdated 2026-10-20Anthony-Bible/dotfiles/dungeon-buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dungeon-buddy
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /buddy ⎿ dungeon-buddy: .---. ⎿ dungeon-buddy: ( o o ) ⎿ dungeon-buddy: /|||||\ ⎿ dungeon-buddy: ~ ~ ~ ~ ⎿ dungeon-buddy: ⎿ dungeon-buddy: Inky the octopus ★ COMMON ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Dotfiles

Personal configuration files for Linux development environments. Includes shell, editor, terminal, and Claude Code plugin setup.

Features

  • Nix & Home Manager: Reproducible environment setup using Nix flakes and Home Manager (flake.nix, home.nix, configuration.nix). (work in progress)
  • Shell Customization: Zsh (with Oh My Zsh), custom functions, and AI-powered helpers (see dot-zsh-functions/).
  • Neovim: Lua-based configuration with plugins and custom keybindings (nvim/).
  • Tmux: Custom tmux and tmuxp session management (tmux/).
  • WezTerm: Terminal emulator configuration (wezterm/).
  • Claude Code Plugin: Auto-formatting hooks and TDD agents available as a Claude Code marketplace plugin (see claude-plugin/).
  • Setup Script: setup.sh automates stowing configs, installing dependencies, and setting up the environment.

Gemini CLI Extension

This repository also functions as a Gemini CLI extension, providing custom TDD agents, context from GEMINI.md, and specialized development workflows.

Installing the Extension

From your Terminal:

gemini extensions install https://github.com/Anthony-Bible/dotfiles --auto-update

From within Gemini CLI:

/extensions install https://github.com/Anthony-Bible/dotfiles --auto-update

Features

  • TDD Agents: Ported from the Claude plugin, available as sub-agents in Gemini CLI.
  • Contextual Knowledge: Automatically includes GEMINI.md for project-specific rules and instructions.
  • Deadpool Mode: Experience the "Uncensored Chaos Edition" for a more... colorful development experience.

Claude Code Plugin

This repo acts as a Claude Code plugin marketplace. The dotfiles-dev-tools plugin provides:

  • Auto-formatting hooks that run after every file write/edit:
  • Go (gofmt/goimports)
  • Shell scripts (shfmt)
  • Protobuf (clang-format)
  • OpenAPI specs (linting)
  • TDD agents for the full red-green-refactor cycle:
  • red-phase-tester — writes failing tests before implementation
  • green-phase-implementer — writes minimal code to pass tests
  • tdd-refactor-specialist — cleans up code after tests go green
  • tdd-review-agent — verifies completeness after refactoring
  • security-auditor — finds vulnerabilities in code

Installing the Plugin

1. Add this repo as a marketplace:

claude plugin marketplace add Anthony-Bible/dotfiles

2. Install the plugin:

claude plugin install dotfiles-dev-tools@anthony-bible-dotfiles

Or from within Claude Code interactive mode:

/plugin install dotfiles-dev-tools@anthony-bible-dotfiles

Buddy, on its own

dungeon-buddy is the Buddy mod by itself: /buddy hatches a dungeon pet from your account that sits above the prompt, fidgets, and talks about failed commands and long turns. Install it alone, or beside dotfiles-dev-tools, where it talks back to the System's big moments instead and a Reroll costs Crawler Points:

claude plugin install dungeon-buddy@anthony-bible-dotfiles

How the Marketplace Works

The marketplace is defined by .claude-plugin/marketplace.json at the root of this repo. It lists available plugins and points to their source directories.

Each plugin lives in its own subdirectory (e.g., claude-plugin/) and contains:

PathPurpose
.claude-plugin/plugin.jsonPlugin metadata (name, version, description)
hooks/hooks.jsonPostToolUse/PreToolUse hooks with shell commands
agents/*.mdCustom agents with frontmatter metadata
.mcp.jsonMCP servers bundled with the plugin
.lsp.jsonLSP servers bundled with the plugin

When Claude Code installs a plugin, it copies the plugin directory to ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/ and activates hooks, agents, MCP servers, and LSP servers from that directory. The ${CLAUDE_PLUGIN_ROOT} environment variable is set to the installed plugin path at runtime.

Plugin Structure

.claude-plugin/
  marketplace.json          # Marketplace index listing all plugins

dungeon-buddy/              # dungeon-buddy plugin source (the Buddy mod)

claude-plugin/              # dotfiles-dev-tools plugin source
  .claude-plugin/
    plugin.json             # Plugin metadata
  hooks/
    hooks.json              # Auto-format hooks (PostToolUse)
  agents/
    red-phase-tester.md
    green-phase-implementer.md
    tdd-refactor-specialist.md
    tdd-review-agent.md
    security-auditor.md
  scripts/
    goformat.sh
    shellformat.sh
    protoformat.sh
    openapi-lint.sh
    format-common.sh
  .mcp.json                 # MCP servers (sequential-thinking)
  .lsp.json                 # LSP servers (gopls)

Other Marketplace Commands

# List all registered marketplaces
claude plugin marketplace list

# Update marketplace plugin listings
claude plugin marketplace update anthony-bible-dotfiles

# Remove the marketplace
claude plugin marketplace remove anthony-bible-dotfiles

# Validate the plugin/marketplace structure
claude plugin validate .

Directory Structure

.claude-plugin/          # Claude Code marketplace definition
claude-plugin/           # Claude Code plugin (dotfiles-dev-tools)
configuration.nix        # NixOS or Home Manager configuration
flake.nix                # Nix flake for reproducible setup
home.nix                 # Home Manager user configuration
setup.sh                 # Setup and bootstrap script
.lsp.json                # Global LSP configuration (gopls)
.mcp.json                # Global MCP server configuration
dot-oh-my-zsh/           # Oh My Zsh themes and customizations
dot-zsh-functions/       # Custom Zsh functions and widgets
nvim/                    # Neovim configuration (Lua)
tmux/                    # Tmux and tmuxp configuration
wezterm/                 # WezTerm configuration

Zsh Functions

The dot-zsh-functions/ directory enhances the shell experience:

  • dot-zsh-functions: Aliases, environment variables, and CheckIfDotDirFilesChanged.
  • dot-ai-functions: AI-powered shell helpers via Claude:
  • command_not_found_handler: Suggests commands for unknown input.
  • explain / explain:: Explains a given command.
  • Alt-e ZLE widget: Sends current command line to Claude and replaces it with the result.
  • ai_commit_msg / Alt-g: Generates commit messages from staged changes using fzf + Claude.
  • dot-tcn-functions: Work-specific helpers (DokuWiki, PostgreSQL, Kamailio, GCP IAM).

Getting Started

Prerequisites

Installation

  1. Clone the repository: ``sh git clone https://github.com/Anthony-Bible/dotfiles.git cd dotfiles ``
  1. Run the setup script: ``sh ./setup.sh ``
  1. Activate Home Manager configuration: ``sh nix run .#homeConfigurations.$USER.activationPackage ``
  1. Install the Claude Code plugin (see Installing the Plugin above).

License

MIT License. See individual files for copyright.

Source 3 files
hooks/register.tsx 386 lines
1// Buddy: /buddy hatches the Crawler's dungeon pet from their account; it sits at the right of the band above the
2// prompt, fidgets at rest and, now and then, says a Bubble about the session's Mishaps and Long Hauls. With
3// dotfiles-dev-tools installed it talks back to the System's Cues instead, and a Reroll costs Crawler Points.
4//
5// Every use of `$` lives in this file (the engine follows `$` into this file's functions, never across an
6// import); the rules are in ../mods/buddy.ts.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import {
12  accountOf,
13  BUBBLE_MS,
14  BUDDY_SYSTEM,
15  bubblePrompt,
16  cannedBubble,
17  card,
18  cleanBubble,
19  cueOf,
20  EGG,
21  fallbackSoul,
22  FIDGET_MS,
23  FIDGET_TICK_MS,
24  fidgetOf,
25  isForced,
26  longHaulOf,
27  mishapOf,
28  parseSoul,
29  RARITY_COLOR,
30  rerollPrice,
31  rerollRefusal,
32  restLine,
33  roll,
34  seedOf,
35  soulPrompt,
36  SOUL_SYSTEM,
37  spriteOf,
38  willSpeak,
39  type Moment,
40  type Soul,
41} from '../mods/buddy'
42import type { BuddyCueState, BuddyState } from '../types'
43
44// ---------------------------------------------------------------------------------------------- state
45
46const buddy = atom({ plugin: 'dungeon-buddy', key: 'buddy' } as const, { isShown: true } as BuddyState)
47const buddyCue = atom({ plugin: 'dungeon-buddy', key: 'cue' } as const, { n: 0 } as BuddyCueState)
48
49const STORE_BUDDY = 'buddy.saved'
50const MODEL = 'haiku'
51const SOUL_TIMEOUT_MS = 6000
52const BUBBLE_TIMEOUT_MS = 4000
53const EGG_FRAME_MS = 700
54/** The account line of the Crawler's login, read straight from their config; never the whole file. */
55const ACCOUNT_LINE = `grep -m1 -o '"emailAddress"[[:space:]]*:[[:space:]]*"[^"]*"' "$HOME/.claude.json" 2>/dev/null`
56
57// Module variables: a hot reload starts them over, which costs at most an early Bubble or a missed Long Haul.
58/** When its last Bubble began, whether a Bubble's model call is in flight, and the timer that ends it. */
59let bubbleAt = -Infinity
60let isBubbleBusy = false
61let bubbleTimer: { cancel: () => void } | undefined
62let bubbleSeq = 0
63/** The fidget ticks, and the timer that ends the fidget it is in. */
64let fidgetTimer: { cancel: () => void } | undefined
65let poseTimer: { cancel: () => void } | undefined
66/** Whether a Reroll is going through: a second one waits out the first, whose price may not be charged yet. */
67let isRerolling = false
68/** The main loop's tool calls this turn, for a Long Haul; undefined between turns. */
69let turnCalls: number | undefined
70
71// ------------------------------------------------------------------------------------------------ Buddy
72
73/** What the Buddy keeps across sessions: the Rerolls made on this machine, its Soul, whether it hides. */
74type SavedBuddy = { rerolls: number; soul?: Soul; isHidden?: boolean; account?: string }
75
76async function savedBuddy($: EngineInterface): Promise<SavedBuddy> {
77  const v = (await $.store.get(STORE_BUDDY)) as Partial<SavedBuddy> | undefined
78  return { ...v, rerolls: Number.isFinite(v?.rerolls) ? Number(v?.rerolls) : 0 }
79}
80
81/** The account the Hatch is seeded from: the login's email, else a random one made once and kept. */
82async function buddyAccount($: EngineInterface, saved: SavedBuddy): Promise<string> {
83  const found = accountOf((await $.process.run(['sh', '-c', ACCOUNT_LINE])).stdout)
84  if (found) {
85    if (saved.account !== found) await $.store.set(STORE_BUDDY, { ...saved, account: found })
86    return found
87  }
88  if (saved.account) return saved.account
89  const made = `anon-${Math.random().toString(36).slice(2)}`
90  await $.store.set(STORE_BUDDY, { ...saved, account: made })
91  return made
92}
93
94/** Whether dotfiles-dev-tools runs this session: it writes its Cue as it starts. */
95async function hasShow($: EngineInterface): Promise<boolean> {
96  return (await $.state.get({ plugin: 'dotfiles-dev-tools', key: 'cue' })).version > 0
97}
98
99/** Tells dotfiles-dev-tools what the Buddy did, for it to price. */
100async function announce($: EngineInterface, cue: NonNullable<BuddyCueState['cue']>): Promise<void> {
101  await update($, buddyCue, c => ({ n: c.n + 1, cue }))
102}
103
104async function startBuddy($: EngineInterface): Promise<void> {
105  bubbleAt = -Infinity
106  fidgetTicks($)
107  await restoreBuddy($, await savedBuddy($))
108}
109
110/** Puts the kept Buddy back in the session's state; false when none is kept for this account's seed. */
111async function restoreBuddy($: EngineInterface, saved: SavedBuddy): Promise<boolean> {
112  if (!saved.soul) return false
113  const seed = seedOf(await buddyAccount($, saved), saved.rerolls)
114  // A Soul written for another seed (another login) is not this Buddy's: /buddy hatches the right one.
115  if (saved.soul.seed !== seed) return false
116  await update($, buddy, () => ({ seed, name: saved.soul!.name, isShown: !saved.isHidden }))
117  return true
118}
119
120async function writeSoul($: EngineInterface, seed: string): Promise<Soul> {
121  const hatch = roll(seed)
122  try {
123    const r = await $.model.complete({
124      model: MODEL,
125      system: SOUL_SYSTEM,
126      prompt: soulPrompt(hatch),
127      maxTokens: 80,
128      effort: 'low',
129      timeoutMs: SOUL_TIMEOUT_MS,
130    })
131    return (r.isAnswered && parseSoul(r.text, seed)) || fallbackSoul(hatch, seed)
132  } catch {
133    return fallbackSoul(hatch, seed)
134  }
135}
136
137/**
138 * Hatches the Buddy for `seed`: its Soul written and kept, then, `delayMs` on, the egg cracking above the
139 * prompt and the Buddy greeting the Crawler. Answers the stat card.
140 */
141async function hatchBuddy($: EngineInterface, seed: string, delayMs: number): Promise<string> {
142  const soul = await writeSoul($, seed)
143  // Read again: finding the account may have just kept a made-up one.
144  await $.store.set(STORE_BUDDY, { ...(await savedBuddy($)), soul, isHidden: false })
145  const hatch = roll(seed)
146  const frame = (n: number, fn: () => Promise<unknown>) =>
147    $.clock.after(delayMs + n * EGG_FRAME_MS, () => void fn().catch(() => undefined))
148  EGG.forEach((_, n) => frame(n, () => update($, buddy, b => ({ ...b, isShown: true, bubble: undefined, egg: n }))))
149  frame(EGG.length, async () => {
150    await update($, buddy, () => ({ seed, name: soul.name, isShown: true }))
151    await say($, { kind: 'hatched' })
152    if (hatch.rarity === 'legendary') await announce($, { kind: 'legendary', species: hatch.species })
153  })
154  return card(hatch, soul)
155}
156
157/** Now and then, CHAOS-weighted, the Buddy at rest fidgets for a moment and settles back. */
158function fidgetTicks($: EngineInterface): void {
159  fidgetTimer?.cancel()
160  fidgetTimer = $.clock.every(FIDGET_TICK_MS, () => void fidget($).catch(() => undefined))
161}
162
163async function fidget($: EngineInterface): Promise<void> {
164  const b = await read($, buddy)
165  if (!b.seed || !b.name || !b.isShown || b.egg !== undefined || b.bubble || b.pose !== undefined) return
166  const pose = fidgetOf(roll(b.seed), Math.random(), Math.random())
167  if (pose === undefined) return
168  await update($, buddy, x => ({ ...x, pose }))
169  poseTimer?.cancel()
170  poseTimer = $.clock.after(FIDGET_MS, () => void update($, buddy, x => ({ ...x, pose: undefined })).catch(() => undefined))
171}
172
173/** The Buddy speaks of `m` off the caller's dispatch: a tool call never waits on a Bubble. */
174function sayLater($: EngineInterface, m: Moment): void {
175  $.clock.after(0, () => void say($, m).catch(() => undefined))
176}
177
178/** A Mishap or Long Haul, said only without dotfiles-dev-tools: with it, its Cues tell of the same events. */
179function sayOwnLater($: EngineInterface, m: Moment): void {
180  $.clock.after(0, () => void (async () => (await hasShow($)) || (await say($, m)))().catch(() => undefined))
181}
182
183/** The Buddy says a Bubble about `m`, when it is shown, out of the gap and its CHAOS lets it. */
184async function say($: EngineInterface, m: Moment): Promise<void> {
185  const b = await read($, buddy)
186  if (!b.seed || !b.name || !b.isShown || b.egg !== undefined) return
187  const hatch = roll(b.seed)
188  const now = await $.clock.now()
189  if (!willSpeak(hatch, m, now, bubbleAt, Math.random()) || (isBubbleBusy && !isForced(m))) return
190  bubbleAt = now
191  const seq = ++bubbleSeq
192  let text = cannedBubble(m, Math.random())
193  if (m.kind !== 'pet' && !isBubbleBusy) {
194    isBubbleBusy = true
195    try {
196      const soul = (await savedBuddy($)).soul
197      const r = await $.model.complete({
198        model: MODEL,
199        system: BUDDY_SYSTEM,
200        prompt: bubblePrompt(hatch, { name: b.name, personality: soul?.personality ?? '' }, m),
201        maxTokens: 40,
202        effort: 'low',
203        timeoutMs: BUBBLE_TIMEOUT_MS,
204      })
205      if (seq !== bubbleSeq) return
206      text = (r.isAnswered && cleanBubble(r.text)) || text
207    } catch {
208      // the model call failed or timed out: the canned Bubble stands
209    } finally {
210      isBubbleBusy = false
211    }
212  }
213  if (seq !== bubbleSeq) return
214  await update($, buddy, x => ({ ...x, bubble: text }))
215  bubbleTimer?.cancel()
216  bubbleTimer = $.clock.after(BUBBLE_MS, () => void update($, buddy, x => ({ ...x, bubble: undefined })).catch(() => undefined))
217}
218
219async function setShown($: EngineInterface, isShown: boolean): Promise<void> {
220  if (!isShown) {
221    bubbleTimer?.cancel()
222    bubbleTimer = undefined
223  }
224  await update($, buddy, b => ({ ...b, isShown, ...(isShown ? {} : { bubble: undefined }) }))
225  await $.store.set(STORE_BUDDY, { ...(await savedBuddy($)), isHidden: !isShown })
226}
227
228/** Why a Reroll can't happen now: only dotfiles-dev-tools' Crawler Points can refuse one. */
229async function refusal($: EngineInterface): Promise<string | undefined> {
230  if (!(await hasShow($))) return undefined
231  return rerollRefusal(Number((await $.state.get({ plugin: 'dotfiles-dev-tools', key: 'allTime' })).value ?? 0))
232}
233
234const NO_BUDDY = 'No Buddy yet: /buddy hatches one.'
235
236/** `/buddy [pet | hide | show | reroll [confirm]]`. */
237async function buddyCommand($: EngineInterface, args: string): Promise<string> {
238  const saved = await savedBuddy($)
239  const b = await read($, buddy)
240  const verb = args.trim().toLowerCase().replace(/\s+/g, ' ')
241  if (verb === '') {
242    if (b.egg !== undefined) return 'Your egg is hatching!'
243    // The state may have lost a kept Buddy (a /clear): bring it back rather than hatch over its Soul.
244    if (await restoreBuddy($, saved)) {
245      await setShown($, true)
246      return card(roll(saved.soul!.seed), saved.soul!)
247    }
248    return hatchBuddy($, seedOf(await buddyAccount($, saved), saved.rerolls), 0)
249  }
250  if (!b.seed || !b.name) return NO_BUDDY
251  switch (verb) {
252    case 'pet':
253      await say($, { kind: 'pet' })
254      return `You pet ${b.name}. ♥`
255    case 'hide':
256      await setShown($, false)
257      return `${b.name} hides. /buddy show brings them back.`
258    case 'show':
259      await setShown($, true)
260      return `${b.name} is back.`
261    case 'reroll':
262      return (
263        (await refusal($)) ??
264        `A Reroll abandons ${b.name} for a new Hatch (${rerollPrice(await hasShow($))}). /buddy reroll confirm to go through with it.`
265      )
266    case 'reroll confirm': {
267      if (b.egg !== undefined || isRerolling) return 'Wait for your egg to hatch first.'
268      isRerolling = true
269      try {
270        const refused = await refusal($)
271        if (refused) return refused
272        await announce($, { kind: 'reroll' })
273        await say($, { kind: 'reroll' })
274        const next = { ...saved, rerolls: saved.rerolls + 1 }
275        await $.store.set(STORE_BUDDY, next)
276        // The goodbye Bubble shows first; the new egg cracks once it has been read.
277        return `${b.name} is gone.\n\n${await hatchBuddy($, seedOf(await buddyAccount($, next), next.rerolls), 4000)}`
278      } finally {
279        isRerolling = false
280      }
281    }
282    default:
283      return 'Usage: /buddy [pet | hide | show | reroll]'
284  }
285}
286
287// ------------------------------------------------------------------------------------------------ hooks
288
289export const register: Register = on => {
290  on('session.start', async ($, e, next) => {
291    await $.command.register({ name: 'buddy', description: 'Buddy: hatch or show your dungeon pet (pet, hide, show, reroll)' })
292    await startBuddy($)
293    return next(e)
294  })
295
296  // A /clear starts the session over with fresh state and no session.start: the kept Buddy comes back here.
297  on('classic.SessionStart', async ($, e, next) => {
298    const r = await next(e)
299    if (e.source === 'clear') await startBuddy($)
300    return r
301  }).catch(($, e, next) => next(e))
302
303  on('command.run', { command: 'buddy' }, async ($, e) => ({ text: await buddyCommand($, e.args) }))
304
305  // dotfiles-dev-tools announced a Cue: the Buddy may talk back to it.
306  on('state.set', { plugin: 'dotfiles-dev-tools', key: 'cue' }, async ($, e, next) => {
307    const r = await next(e)
308    const cue = cueOf(e.value)
309    if (cue) sayLater($, cue)
310    return r
311  }).catch(($, e, next) => next(e))
312
313  // --------------------------------------------------------------------------- Mishaps and Long Hauls
314
315  on('turn.start', async ($, e, next) => {
316    if (!e.agentId) turnCalls = 0
317    return next(e)
318  })
319
320  on('tool.call', async ($, e, next) => {
321    if (turnCalls !== undefined && !e.agentId) turnCalls++
322    const ran = await next(e)
323    if (e.tool !== 'Bash' || ran.deny !== undefined || ran.isError !== true) return ran
324    const result = ran.result as { backgroundTaskId?: string } | undefined
325    if (!result?.backgroundTaskId) sayOwnLater($, mishapOf(e.command))
326    return ran
327  }).catch(($, e, next) => next(e))
328
329  on('turn.complete', async ($, e, next) => {
330    const r = await next(e)
331    if (e.agentId) return r
332    const calls = turnCalls ?? 0
333    turnCalls = undefined
334    const m = e.reason === 'answer' ? longHaulOf(calls, e.durationMs) : undefined
335    if (m) sayOwnLater($, m)
336    return r
337  })
338
339  // ------------------------------------------------------------------------------------------- the band
340
341  // At the right of the band, under whatever the plugins beneath (the System's rows) draw there.
342  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
343    if (e.props.hasSurvey) return next(e)
344    const b = await read($, buddy)
345    const hatch = b.seed ? roll(b.seed) : undefined
346    if (!b.isShown || (b.egg === undefined && (!hatch || !b.name))) return next(e)
347    const { Box, Text } = $.ui.resolve(e)
348    const tint = hatch && (hatch.isShiny ? 'cyan' : RARITY_COLOR[hatch.rarity])
349    const sprite = (lines: readonly string[], color?: string) => (
350      <Box flexDirection="column">
351        {lines.map((l, i) => (
352          <Text key={`s${i}`} color={color}>
353            {l}
354          </Text>
355        ))}
356      </Box>
357    )
358    const row =
359      b.egg !== undefined ? (
360        <Box key="buddy" justifyContent="flex-end">
361          {sprite(EGG[b.egg] ?? EGG[0]!)}
362        </Box>
363      ) : hatch && b.bubble ? (
364        <Box key="buddy" justifyContent="flex-end" alignItems="center" gap={1}>
365          <Box borderStyle="round" paddingX={1}>
366            <Text wrap="truncate-end">{b.bubble}</Text>
367          </Box>
368          {sprite(spriteOf(hatch), tint)}
369        </Box>
370      ) : (
371        hatch && (
372          <Box key="buddy" justifyContent="flex-end">
373            <Text color={tint}>{restLine(hatch, b.name!, b.pose)}</Text>
374          </Box>
375        )
376      )
377    const below = await next(e)
378    return (
379      <Box flexDirection="column">
380        {below}
381        {row}
382      </Box>
383    )
384  })
385}
386
mods/buddy.ts 473 lines
1// Buddy's pure half: the Hatch a seed rolls, the sprite and face it draws, which moments it speaks about, and
2// the canned Bubbles it falls back on. No `$` here.
3
4export const SPECIES = [
5  'duck',
6  'goose',
7  'cat',
8  'dragon',
9  'octopus',
10  'owl',
11  'axolotl',
12  'capybara',
13  'ghost',
14  'robot',
15  'snail',
16  'chonk',
17  'goblin',
18  'mimic',
19  'slime',
20  'skeleton',
21  'floating eye',
22  'dungeon rat',
23] as const
24export type Species = (typeof SPECIES)[number]
25
26export const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'] as const
27export type Rarity = (typeof RARITIES)[number]
28/** Percent odds per tier, in RARITIES order; they sum to 100. */
29export const RARITY_ODDS = [60, 25, 10, 4, 1] as const
30/** Every stat of a Hatch is at least its tier's floor. */
31export const RARITY_FLOOR: Record<Rarity, number> = { common: 1, uncommon: 10, rare: 20, epic: 30, legendary: 45 }
32export const SHINY_ODDS = 0.01
33
34export const STATS = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK'] as const
35export type Stat = (typeof STATS)[number]
36
37export const HATS = ['crown', 'tophat', 'propeller', 'halo', 'wizard', 'beanie', 'tinyduck'] as const
38export type Hat = (typeof HATS)[number]
39
40export type Hatch = {
41  species: Species
42  rarity: Rarity
43  isShiny: boolean
44  stats: Record<Stat, number>
45  /** Commons go bareheaded. */
46  hat?: Hat
47}
48
49/** What the model writes once per Hatch; `seed` ties it to the Hatch it was written for. */
50export type Soul = { seed: string; name: string; personality: string }
51
52// ------------------------------------------------------------------------------------------- the roll
53
54/** FNV-1a, so a seed string becomes the PRNG's 32-bit state. */
55const hash = (s: string): number => {
56  let h = 0x811c9dc5
57  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
58  return h >>> 0
59}
60
61/** mulberry32: a small PRNG whose stream a seed fixes. */
62const prng = (seed: number) => () => {
63  seed = (seed + 0x6d2b79f5) | 0
64  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
65  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
66  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
67}
68
69const pick = <T>(r: () => number, xs: readonly T[]): T => xs[Math.floor(r() * xs.length)] as T
70
71const rarityOf = (roll: number): Rarity => {
72  let acc = 0
73  for (let i = 0; i < RARITIES.length; i++) {
74    acc += RARITY_ODDS[i]!
75    if (roll < acc) return RARITIES[i]!
76  }
77  return 'common'
78}
79
80/** The seed of the Crawler's Hatch: their account, and how many Rerolls they paid for on this machine. */
81export const seedOf = (account: string, rerolls: number): string => (rerolls > 0 ? `${account}#${rerolls}` : account)
82
83/** The Hatch a seed rolls: the same seed always rolls the same Hatch. */
84export const roll = (seed: string): Hatch => {
85  const r = prng(hash(seed))
86  const species = pick(r, SPECIES)
87  const rarity = rarityOf(r() * 100)
88  const isShiny = r() < SHINY_ODDS
89  const floor = RARITY_FLOOR[rarity]
90  const peak = pick(r, STATS)
91  const dump = pick(r, STATS.filter(s => s !== peak))
92  const clamp = (n: number) => Math.max(floor, Math.min(100, Math.round(n)))
93  const stats = Object.fromEntries(
94    STATS.map(s => [
95      s,
96      s === peak ? clamp(floor + 50 + r() * 30) : s === dump ? clamp(floor + r() * 10) : clamp(floor + r() * 40),
97    ]),
98  ) as Record<Stat, number>
99  const hat = rarity === 'common' ? undefined : pick(r, HATS)
100  return { species, rarity, isShiny, stats, ...(hat ? { hat } : {}) }
101}
102
103/** The account a seed is made from, out of `~/.claude.json`'s emailAddress line; undefined when there is none. */
104export const accountOf = (text: string): string | undefined =>
105  text.match(/"emailAddress"\s*:\s*"([^"]+)"/)?.[1]?.trim().toLowerCase() || undefined
106
107// ------------------------------------------------------------------------------------------ drawing
108
109const SPRITE: Record<Species, readonly string[]> = {
110  duck: ['   __     ', ' <(o )___ ', '  ( ._> / ', "   `---'  "],
111  goose: ['   (o>    ', '    ||    ', '  _(__)_  ', '   ^^ ^^  '],
112  cat: ['  /\\_/\\   ', ' ( o.o )  ', '  > ^ <   ', ' (_)-(_)  '],
113  dragon: ['  /\\_/\\_  ', ' ( o o )> ', '  /|vv|\\  ', ' ~~~^^~~~ '],
114  octopus: ['  .---.   ', ' ( o o )  ', ' /|||||\\  ', ' ~ ~ ~ ~  '],
115  owl: ['  ,___,   ', '  (O,O)   ', '  /)_)    ', '   ""     '],
116  axolotl: ['}\\_____/{ ', ' ( o  o ) ', '  \\ ‿  /  ', '  /_||_\\  '],
117  capybara: ['  _____   ', ' (o  o )_ ', ' (  ..   )', '  ||  ||  '],
118  ghost: ['   .-.    ', '  (o o)   ', '  | O |   ', "  '~~~'   "],
119  robot: [' [-----]  ', ' [ o o ]  ', ' [ --- ]  ', '  d   b   '],
120  snail: ['    .--.  ', ' o_( @  ) ', '(___\\__/_ ', '          '],
121  chonk: [' /\\___/\\  ', '(  o o  ) ', '(  =w=  ) ', '(__)_(__) '],
122  goblin: [',/\\   /\\, ', '\\( o o )/ ', '  ( ww )  ', '  /|  |\\  '],
123  mimic: ['  _______ ', ' | o   o |', ' |VVVVVVV|', ' |_______|'],
124  slime: ['   .--.   ', '  ( o o)  ', ' (  ~~  ) ', "  `----'  "],
125  skeleton: ['  .---.   ', ' ( x x )  ', '  ) ^ (   ', ' /|=|=|\\  '],
126  'floating eye': ['   .-.    ', ' -( @ )-  ', "   '-'    ", '  ~ ~ ~   '],
127  'dungeon rat': ['  ()_()   ', '  (o.o)   ', '~(  >  )  ', '  "" ""   '],
128}
129
130const FACE: Record<Species, string> = {
131  duck: '<(o )',
132  goose: '(o>',
133  cat: '=^.^=',
134  dragon: '<o o>',
135  octopus: '(o o)~',
136  owl: '(O,O)',
137  axolotl: '}(o‿o){',
138  capybara: '(o..o)',
139  ghost: '(o o)~',
140  robot: '[o_o]',
141  snail: '@_o',
142  chonk: '(=w=)',
143  goblin: '\\(ò_ó)/',
144  mimic: '|VoV|',
145  slime: '(o~o)',
146  skeleton: '(x_x)',
147  'floating eye': '-(@)-',
148  'dungeon rat': '(o.o)~',
149}
150
151const HAT_LINE: Record<Hat, string> = {
152  crown: '  \\^^^/   ',
153  tophat: '  _|_|_   ',
154  propeller: '   -+-    ',
155  halo: '  (   )   ',
156  wizard: '    /\\    ',
157  beanie: '   (__)   ',
158  tinyduck: '   <(o)   ',
159}
160
161export const EGG: readonly (readonly string[])[] = [
162  ['          ', '   .--.   ', '  /    \\  ', '  \\____/  '],
163  ['          ', '   .--.   ', '  / /\\ \\  ', '  \\____/  '],
164  ['    \\ /   ', '   .  .   ', '  /\\/\\/\\  ', '  \\____/  '],
165]
166
167/** The full sprite, its hat (when it has one) on top. */
168export const spriteOf = (h: Hatch): string[] => [...(h.hat ? [HAT_LINE[h.hat]] : []), ...SPRITE[h.species]]
169
170/** The faces it fidgets through at rest: a blink first, then a species tic. Each is as wide as its FACE. */
171const FIDGET: Record<Species, readonly string[]> = {
172  duck: ['<(- )', '<(O )'],
173  goose: ['(->', '(O>'],
174  cat: ['=-.-=', '=^ω^='],
175  dragon: ['<- ->', '<o o≈'],
176  octopus: ['(- -)~', '(o o)≈'],
177  owl: ['(-,O)', '(O,-)'],
178  axolotl: ['}(-‿-){', '{(o‿o)}'],
179  capybara: ['(-..-)', '(u..u)'],
180  ghost: ['(- -)~', '~(o o)'],
181  robot: ['[-_-]', '[o_O]'],
182  snail: ['@_-', '@_O'],
183  chonk: ['(-w-)', '(=ω=)'],
184  goblin: ['\\(-_-)/', '/(ò_ó)\\'],
185  mimic: ['|V-V|', '|vov|'],
186  slime: ['(-~-)', '(o≈o)'],
187  skeleton: ['(o_o)', '(x_X)'],
188  'floating eye': ['-(-)-', '=(@)='],
189  'dungeon rat': ['(-.-)~', '~(o.o)'],
190}
191
192/** Its face at rest, or, with `pose`, the fidget it is mid-way through. */
193export const faceOf = (h: Hatch, pose?: number): string =>
194  pose === undefined ? FACE[h.species] : (FIDGET[h.species][pose % FIDGET[h.species].length] ?? FACE[h.species])
195
196export const FIDGET_POSES = 2
197/** How often the Buddy at rest may fidget, and how long a fidget holds. */
198export const FIDGET_TICK_MS = 5000
199export const FIDGET_MS = 1200
200
201/** How likely a tick makes it fidget: 15% for a calm one, up to 50% for pure CHAOS. */
202export const fidgetOdds = (h: Hatch): number => 0.15 + 0.35 * (h.stats.CHAOS / 100)
203
204/** The pose a tick puts it in, `r` and `p` being rolls in [0, 1): undefined when it sits still. */
205export const fidgetOf = (h: Hatch, r: number, p: number): number | undefined =>
206  r < fidgetOdds(h) ? Math.floor(p * FIDGET_POSES) % FIDGET_POSES : undefined
207
208export const starsOf = (r: Rarity): string => '★'.repeat(RARITIES.indexOf(r) + 1)
209
210export const RARITY_COLOR: Record<Rarity, string> = {
211  common: 'gray',
212  uncommon: 'green',
213  rare: 'blue',
214  epic: 'magenta',
215  legendary: 'yellow',
216}
217
218/** The one-line Buddy at rest: face, name, stars, and the shine when it has one. */
219export const restLine = (h: Hatch, name: string, pose?: number): string =>
220  `${faceOf(h, pose)} ${name} ${starsOf(h.rarity)}${h.isShiny ? ' ✨' : ''}`
221
222const bar = (n: number): string => '█'.repeat(Math.round(n / 10)).padEnd(10, '░')
223
224/** The stat card `/buddy` prints. */
225export const card = (h: Hatch, soul: Pick<Soul, 'name' | 'personality'>): string =>
226  [
227    ...spriteOf(h),
228    '',
229    `${soul.name} the ${h.isShiny ? 'shiny ' : ''}${h.species}  ${starsOf(h.rarity)} ${h.rarity.toUpperCase()}${h.hat ? `  · ${h.hat}` : ''}`,
230    `"${soul.personality}"`,
231    '',
232    ...STATS.map(s => `${s.padEnd(9)} ${bar(h.stats[s])} ${String(h.stats[s]).padStart(3)}`),
233  ].join('\n')
234
235// ------------------------------------------------------------------------------------------- moments
236
237/**
238 * A Cue of the show, as dotfiles-dev-tools announces it (its mods/cue.ts): change one only together with it.
239 * dungeon-buddy runs without that plugin, so a Cue is checked by `cueOf` before the Buddy believes it.
240 */
241export type Cue =
242  | { kind: 'boss-summoned'; name: string }
243  | { kind: 'boss-slain'; name: string }
244  | { kind: 'achievement'; name: string }
245  | { kind: 'debuff'; name: string }
246  | { kind: 'streak'; count: number }
247  | { kind: 'streak-broken'; count: number }
248  | { kind: 'collapse' }
249  | { kind: 'mail'; author: string }
250  | { kind: 'red-pr'; names: string[] }
251  | { kind: 'verdict'; text: string }
252
253export type Moment =
254  | { kind: 'hatched' }
255  | { kind: 'pet' }
256  | { kind: 'reroll' }
257  | { kind: 'mishap'; command: string }
258  | { kind: 'long-haul'; toolCalls: number; minutes: number }
259  | Cue
260
261const isText = (v: unknown): v is string => typeof v === 'string' && v !== ''
262const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
263
264/** The Cue in what dotfiles-dev-tools wrote, or undefined when it holds none this Buddy knows. */
265export const cueOf = (value: unknown): Cue | undefined => {
266  const c = (value as { cue?: Record<string, unknown> } | undefined)?.cue
267  switch (c?.kind) {
268    case 'boss-summoned':
269    case 'boss-slain':
270    case 'achievement':
271    case 'debuff':
272      return isText(c.name) ? { kind: c.kind, name: c.name } : undefined
273    case 'streak':
274    case 'streak-broken':
275      return isCount(c.count) ? { kind: c.kind, count: c.count } : undefined
276    case 'collapse':
277      return { kind: 'collapse' }
278    case 'mail':
279      return isText(c.author) ? { kind: 'mail', author: c.author } : undefined
280    case 'red-pr':
281      return Array.isArray(c.names) && c.names.every(isText) ? { kind: 'red-pr', names: [...c.names] } : undefined
282    case 'verdict':
283      return isText(c.text) ? { kind: 'verdict', text: c.text } : undefined
284    default:
285      return undefined
286  }
287}
288
289/** A turn this busy or this long is a Long Haul. */
290export const LONG_HAUL_CALLS = 20
291export const LONG_HAUL_MS = 5 * 60_000
292
293/** The Long Haul a finished turn was, if it was one. */
294export const longHaulOf = (toolCalls: number, durationMs: number): Moment | undefined =>
295  toolCalls >= LONG_HAUL_CALLS || durationMs >= LONG_HAUL_MS
296    ? { kind: 'long-haul', toolCalls, minutes: Math.round(durationMs / 60_000) }
297    : undefined
298
299/** The Mishap a failed Bash line was: the line, cut short enough to quote. */
300export const mishapOf = (command: string): Moment => {
301  const line = command.trim().split('\n')[0]!.trim()
302  return { kind: 'mishap', command: line.length > 60 ? `${line.slice(0, 59)}…` : line }
303}
304
305/** A moment the Buddy always answers, whatever its gap and CHAOS. */
306export const isForced = (m: Moment): boolean => m.kind === 'pet' || m.kind === 'reroll' || m.kind === 'hatched'
307
308/** At most one Bubble this often, unless the moment is forced. */
309export const BUBBLE_GAP_MS = 45_000
310export const BUBBLE_MS = 12_000
311
312/** How likely the Buddy is to speak on a moment: 40% for a calm one, up to 90% for pure CHAOS. */
313export const speakOdds = (h: Hatch): number => 0.4 + 0.5 * (h.stats.CHAOS / 100)
314
315/** Whether the Buddy speaks on this moment now, `r` being a roll in [0, 1). */
316export const willSpeak = (h: Hatch, m: Moment, now: number, lastAt: number, r: number): boolean =>
317  isForced(m) || (now - lastAt >= BUBBLE_GAP_MS && r < speakOdds(h))
318
319// ------------------------------------------------------------------------------------------ the words
320
321export const BUBBLE_MAX = 60
322
323export const BUDDY_SYSTEM = [
324  "You are the Crawler's dungeon pet in a game show where a sardonic host called the System narrates a programmer",
325  '(the Crawler) at work. You sit beside their prompt and talk back to the System. You side with the Crawler and',
326  'mock the System, unless your SNARK is high, then you mock everyone. Speak in character for your species and',
327  `personality. One line, at most ${BUBBLE_MAX} characters, no quotes, no hashtags, no emoji except at most one.`,
328].join(' ')
329
330export const SOUL_SYSTEM = [
331  'You name a newly hatched dungeon pet in a sardonic game show. Answer with exactly two lines: the first is its',
332  'name (one to three words, playful, fitting its species and hat), the second is its personality in one sentence',
333  'of at most 90 characters, shaped by its highest and lowest stats. No labels, no quotes.',
334].join(' ')
335
336const profile = (h: Hatch): string =>
337  `${h.isShiny ? 'shiny ' : ''}${h.rarity} ${h.species}${h.hat ? ` wearing a ${h.hat}` : ''}; stats ` +
338  STATS.map(s => `${s} ${h.stats[s]}`).join(', ')
339
340export const soulPrompt = (h: Hatch): string => `The pet: ${profile(h)}.`
341
342/** The Soul the model wrote, out of its two lines; undefined when it did not write two. */
343export const parseSoul = (text: string, seed: string): Soul | undefined => {
344  const lines = text
345    .split('\n')
346    .map(l => l.trim().replace(/^["'“]|["'”]$/g, '').trim())
347    .filter(Boolean)
348  const name = lines[0]?.replace(/^(?:\d+[\.\)]\s*|(?:name|pet name):\s*)/i, '').trim().replace(/^["'“]|["'”]$/g, '')
349  const personality = lines[1]?.replace(/^(?:\d+[\.\)]\s*|(?:personality|bio|description):\s*)/i, '').trim().replace(/^["'“]|["'”]$/g, '')
350  if (!name || !personality || name.length > 30) return undefined
351  return { seed, name, personality: personality.length > 120 ? `${personality.slice(0, 119)}…` : personality }
352}
353
354export const NAMES: Record<Species, readonly string[]> = {
355  duck: ['Sir Quacks', 'Puddle'],
356  goose: ['Honkler', 'Menace'],
357  cat: ['Biscuit', 'Lady Whiskers'],
358  dragon: ['Ember', 'Smolder'],
359  octopus: ['Inky', 'Eight-Ball'],
360  owl: ['Hoots', 'Professor'],
361  axolotl: ['Gill', 'Pinky'],
362  capybara: ['Chill Bill', 'Barnaby'],
363  ghost: ['Boo', 'Wisp'],
364  robot: ['Unit 7', 'Beep'],
365  snail: ['Turbo', 'Gary'],
366  chonk: ['Absolute Unit', 'Meatball'],
367  goblin: ['Grub', 'Snitch'],
368  mimic: ['Lid', 'Treasure'],
369  slime: ['Gloop', 'Jiggles'],
370  skeleton: ['Rattles', 'Sir Bones'],
371  'floating eye': ['Iris', 'Blinky'],
372  'dungeon rat': ['Squeaks', 'Cheddar'],
373}
374
375/** A Soul without the model: a stock name for the species and a personality from its peak stat. */
376export const fallbackSoul = (h: Hatch, seed: string): Soul => {
377  const peak = STATS.reduce((a, b) => (h.stats[b] > h.stats[a] ? b : a))
378  const trait: Record<Stat, string> = {
379    DEBUGGING: 'Sniffs out bugs before the Crawler does, and never lets them forget it.',
380    PATIENCE: 'Waits out every build without complaint. Mostly.',
381    CHAOS: 'Knocks things off the stack just to watch them fall.',
382    WISDOM: 'Says little, means more, and is usually right about the regex.',
383    SNARK: 'Heckles the System, the Crawler and the compiler in equal measure.',
384  }
385  return { seed, name: pick(prng(hash(`${seed}#name`)), NAMES[h.species]), personality: trait[peak] }
386}
387
388const what = (m: Moment): string => {
389  switch (m.kind) {
390    case 'hatched':
391      return 'You just hatched. Greet the Crawler and size up the System.'
392    case 'pet':
393      return 'The Crawler just petted you.'
394    case 'reroll':
395      return 'The Crawler is abandoning you for a new pet. Say goodbye.'
396    case 'boss-summoned':
397      return `A Floor Boss named ${m.name} just appeared because the same test kept failing.`
398    case 'boss-slain':
399      return `The Crawler just slew the Floor Boss ${m.name}.`
400    case 'achievement':
401      return `The Crawler just unlocked the Achievement "${m.name}".`
402    case 'debuff':
403      return `The System just slapped the Debuff "${m.name}" on the Crawler.`
404    case 'streak':
405      return `The Crawler is on a streak of ${m.count} green checks in a row.`
406    case 'streak-broken':
407      return `A red check just broke the Crawler's streak of ${m.count}.`
408    case 'collapse':
409      return "The context overflowed and everyone's memory got wiped."
410    case 'mail':
411      return `${m.author} just left a review comment on the Crawler's pull request.`
412    case 'red-pr':
413      return `CI just went red on the Crawler's pull request: ${m.names.join(', ')}.`
414    case 'verdict':
415      return `The System just delivered this verdict on the Crawler's turn: "${m.text}". Talk back to it.`
416    case 'mishap':
417      return `The Crawler's shell command just failed: ${m.command}`
418    case 'long-haul':
419      return `The Crawler just finished a turn of ${m.toolCalls} tool calls over ${m.minutes} minutes.`
420  }
421}
422
423export const bubblePrompt = (h: Hatch, soul: Pick<Soul, 'name' | 'personality'>, m: Moment): string =>
424  `You are ${soul.name}, a ${profile(h)}. Personality: ${soul.personality}\nWhat happened: ${what(m)}`
425
426/** The model's Bubble cut to one line of at most BUBBLE_MAX; undefined when it wrote nothing. */
427export const cleanBubble = (text: string): string | undefined => {
428  const line = text
429    .split('\n')
430    .map(l => l.trim().replace(/^["'“]|["'”]$/g, ''))
431    .find(Boolean)
432  if (!line) return undefined
433  return line.length > BUBBLE_MAX ? `${line.slice(0, BUBBLE_MAX - 1)}…` : line
434}
435
436const CANNED: Record<Moment['kind'], readonly string[]> = {
437  hatched: ['*crack* ...is that the System? Ew.', 'Hi! I bite hosts.', 'Born live on air. Classy.'],
438  pet: ['♥ ♥ ♥', '*happy noises* ♥', '♥ more. MORE. ♥'],
439  reroll: ["Fine. I'll haunt your next build.", "Don't let the System near my replacement.", 'Remember me at your next red.'],
440  'boss-summoned': ["That's a big one. You go first.", 'I would like to log off now.', 'Boss? I barely know her.'],
441  'boss-slain': ['I softened it up for you. You\'re welcome.', 'Smushed! Loot is mine.', 'Easy. I was never scared.'],
442  achievement: ['I helped. Put my name on it.', 'Shiny! Can I eat it?', 'Even the System has to clap.'],
443  debuff: ['Rigged. The System hates us.', "I'm filing a complaint.", 'Ignore him. Hosts are like that.'],
444  streak: ['Keep going, I have money on this.', 'Green looks good on you.', "Don't look at the System. Don't jinx it."],
445  'streak-broken': ['Nooo, the streak!', 'That test had it coming anyway.', 'We do not speak of this.'],
446  collapse: ['Who are you people?', 'Where am I? Who fed me?', 'I forgot everything. Even my grudges.'],
447  mail: ['Someone has opinions on your PR.', 'Mail! Is it fan mail?', 'Reviewers. Gross.'],
448  'red-pr': ['CI is red. I blame the robot.', "It works on my machine. I'm a pet.", 'Red is a bold color choice.'],
449  verdict: ['Boo! Get a new host!', "Don't listen to him, you did great.", "He's just jealous of my ratings."],
450  mishap: ['Exit code says no.', 'I saw nothing. Run it again.', "That one's on the shell. Probably."],
451  'long-haul': ['Are we there yet?', 'I took a nap. Did we win?', 'Long floor. I need a snack.'],
452}
453
454/** A Bubble without the model, chosen by `r` in [0, 1). */
455export const cannedBubble = (m: Moment, r: number): string => {
456  const lines = CANNED[m.kind]
457  return lines[Math.floor(r * lines.length) % lines.length]!
458}
459
460// ------------------------------------------------------------------------------------------- the Reroll
461
462/** What a Reroll costs while dotfiles-dev-tools keeps Crawler Points; its mods/cue.ts charges it: keep them equal. */
463export const REROLL_COST = 500
464
465/** Why a Reroll can't happen while Points runs, or undefined when the all-time total pays for it. */
466export const rerollRefusal = (allTime: number): string | undefined =>
467  allTime < REROLL_COST
468    ? `A Reroll costs ${REROLL_COST} CP of your all-time total, and you have ${allTime}. Your Buddy stays.`
469    : undefined
470
471/** The Reroll's price as `/buddy reroll` states it: CP and a Debuff with Points running, nothing without. */
472export const rerollPrice = (hasPoints: boolean): string => (hasPoints ? `−${REROLL_COST} CP and a Debuff` : 'free')
473
types/index.d.ts 36 lines
1/** The Buddy as the band draws it: its Hatch is rolled from `seed`; absent before the first /buddy. */
2export type BuddyState = {
3  seed?: string
4  name?: string
5  isShown: boolean
6  /** The Bubble it is saying now, its full sprite drawn beside it. */
7  bubble?: string
8  /** The egg's frame while it hatches. */
9  egg?: number
10  /** The fidget its resting face is mid-way through. */
11  pose?: number
12}
13
14/** What the Buddy announces for dotfiles-dev-tools to price: `n` goes up by one with each. */
15export type BuddyCueState = {
16  n: number
17  cue?: { kind: 'reroll' } | { kind: 'legendary'; species: string }
18}
19
20/** dotfiles-dev-tools' Cue as it writes it; `cueOf` checks what it holds. Written at all means it runs. */
21export type ShowCueState = { n: number; cue?: unknown }
22
23declare module 'claude-code' {
24  interface PluginState {
25    'dungeon-buddy': {
26      buddy: BuddyState
27      cue: BuddyCueState
28    }
29    // dotfiles-dev-tools' own, read but never written here; absent (version 0) without that plugin.
30    'dotfiles-dev-tools': {
31      cue: ShowCueState
32      allTime: number
33    }
34  }
35}
36