Claude Code Buddy 系统深度解析:一只会陪你写代码的像素宠物

12 阅读10分钟

前言

在 Claude Code 的源码中,有一个名为 buddy 的模块——它实现了一套完整的"虚拟伴侣(Companion)"系统。这只坐在你输入框旁边的小精灵,不是一个简单的装饰品,而是一套设计相当精密的游戏化 UX 系统。本文将逐文件深入分析 buddy 目录下的 6 个源文件,从数据模型、随机生成算法、渲染引擎到 React 组件,完整还原这个系统的设计思路与实现细节。


一、类型系统设计:types.ts

1.1 物种定义的工程化技巧

types.ts 是整个系统的基础。值得关注的第一个细节是物种(Species)的定义方式:

const c = String.fromCharCode
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
export const dragon = c(0x64,0x72,0x61,0x67,0x6f,0x6e) as 'dragon'

这里用 String.fromCharCode 通过字符码点拼接字符串,而不是直接写字面量 'duck'。注释解释了原因:

One species name collides with a model-codename canary in excluded-strings.txt. The check greps build output (not source), so runtime-constructing the value keeps the literal out of the bundle...

这是一个对抗构建产物扫描工具的技巧。CI/CD 流水线中有一个脚本会扫描 bundle 产物中是否包含某些敏感字符串(即"canary"机制),某个物种名恰好与模型代号冲突。通过运行时构造字符串,就能让 bundle 产物中不出现该字面量,同时不影响功能。这种做法在大型团队的安全审计流程中并不罕见。

1.2 稀有度权重系统

export const RARITY_WEIGHTS = {
  common: 60,
  uncommon: 25,
  rare: 10,
  epic: 4,
  legendary: 1,
} as const satisfies Record<Rarity, number>

5 个稀有度等级,概率总和为 100,传奇(legendary)仅 1% 概率。这与经典的 Gacha 游戏设计如出一辙。as const satisfies Record<Rarity, string> 的写法既保留了字面量类型推断(as const),又用 satisfies 做了结构完整性校验,是 TypeScript 4.9+ 的惯用手法。

1.3 Companion 数据模型的分层设计

整个 Companion 被拆分成三层:

层级类型内容持久化策略
Bones(骨骼)CompanionBones稀有度、物种、眼型、帽子、闪光、属性不持久化,每次从 userId 重新生成
Soul(灵魂)CompanionSoul名字、性格存储在 config
完整体CompanionBones + Soul + hatchedAt运行时合并

这个分层设计解决了一个核心问题:如何防止用户通过手动编辑配置文件来"伪造"一个传奇稀有度的伴侣?答案是——Bones 根本不存储,它在每次读取时从用户 ID 确定性地重新生成。用户编辑 config.companion 只能改变名字和性格,稀有度是由账号 ID 决定的,无法伪造。


二、随机生成引擎:companion.ts

这是整个系统的核心,包含了一个完整的"确定性随机角色生成"(Deterministic Procedural Generation)流水线。

2.1 Mulberry32 伪随机数生成器

function mulberry32(seed: number): () => number {
  let a = seed >>> 0
  return function () {
    a |= 0
    a = (a + 0x6d2b79f5) | 0
    let t = Math.imul(a ^ (a >>> 15), 1 | a)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

Mulberry32 是一个轻量级的 32 位整数伪随机数生成器(PRNG),由 Tommy Ettinger 设计。其特点是:

  • 体积极小:只有 5 行核心逻辑
  • 质量足够好:通过了 BigCrush 统计测试的多数项目
  • 有状态闭包:每次调用自动更新内部状态 a,返回 [0, 1) 区间的浮点数

注释里说  "good enough for picking ducks" ——对于这个场景,它确实完全够用。

2.2 字符串哈希函数

function hashString(s: string): number {
  if (typeof Bun !== 'undefined') {
    return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
  }
  let h = 2166136261
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i)
    h = Math.imul(h, 16777619)
  }
  return h >>> 0
}

这是 FNV-1a(Fowler-Noll-Vo)  哈希算法的 32 位实现:

  • 初始值 2166136261(即 0x811c9dc5)是 FNV offset basis
  • 乘数 16777619(即 0x01000193)是 FNV prime
  • Math.imul 执行 C 风格的 32 位整数乘法,避免 JavaScript 浮点精度问题
  • 在 Bun 运行时有原生哈希实现,优先使用性能更好的 Bun.hash

2.3 稀有度抽取算法

function rollRarity(rng: () => number): Rarity {
  const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
  let roll = rng() * total
  for (const rarity of RARITIES) {
    roll -= RARITY_WEIGHTS[rarity]
    if (roll < 0) return rarity
  }
  return 'common'
}

这是经典的加权随机抽样算法(Weighted Random Sampling),时间复杂度 O(n)。等价于把 [0, 100) 区间按权重切分成若干段,然后用随机数落点决定结果。由于 RARITIES 数组顺序固定(common → legendary),且权重递减,大多数情况下在前两次迭代就能返回结果。

2.4 属性点分配:峰谷系统

const RARITY_FLOOR: Record<Rarity, number> = {
  common: 5,
  uncommon: 15,
  rare: 25,
  epic: 35,
  legendary: 50,
}

function rollStats(rng, rarity) {
  const floor = RARITY_FLOOR[rarity]
  const peak = pick(rng, STAT_NAMES)   // 峰值属性
  let dump = pick(rng, STAT_NAMES)     // 垃圾属性
  while (dump === peak) dump = pick(rng, STAT_NAMES)

  for (const name of STAT_NAMES) {
    if (name === peak)  stats[name] = Math.min(100, floor + 50 + rng() * 30)
    else if (name === dump) stats[name] = Math.max(1, floor - 10 + rng() * 15)
    else stats[name] = floor + rng() * 40
  }
}

5 个属性(DEBUGGINGPATIENCECHAOSWISDOMSNARK),采用 RPG 中常见的峰谷分配机制:

  • 随机选一个峰值属性(peak),数值 = floor + 50 + 随机30,上限100
  • 随机选一个垃圾属性(dump),数值 = floor - 10 + 随机15,下限1
  • 其余属性 = floor + 随机40

稀有度越高,floor 越大(从 5 到 50),意味着传奇伴侣所有属性都至少从50起,而普通伴侣的垃圾属性可能低至 1。

2.5 Salt 与缓存机制

const SALT = 'friend-2026-401'

export function roll(userId: string): Roll {
  const key = userId + SALT
  if (rollCache?.key === key) return rollCache.value
  const value = rollFrom(mulberry32(hashString(key)))
  rollCache = { key, value }
  return value
}

SALT 的值 'friend-2026-401' 暗示这是 2026 年 4 月 1 日(愚人节)上线的功能。Salt 的作用是:如果将来需要对所有用户"重新洗牌"(例如加入新物种导致旧 ID 的概率分布改变),只需更换 SALT 即可,而不影响底层算法。

缓存逻辑 rollCache 是一个单元素 LRU:对同一个 userId 的重复调用直接返回缓存值,避免重复哈希计算。注释说这个函数会在三条热路径上被调用(500ms 动画 tick、每次键盘输入、每次 AI 响应),缓存优化非常必要。

2.6 Bones/Soul 合并策略

export function getCompanion(): Companion | undefined {
  const stored = getGlobalConfig().companion  // StoredCompanion(只有 soul)
  if (!stored) return undefined
  const { bones } = roll(companionUserId())
  return { ...stored, ...bones }  // bones 放后面,覆盖 stored 中的同名字段
}

合并时 bones 放在展开运算符的后面,确保即便旧格式的 config 中存有 bones 字段,也会被当前重新生成的 bones 覆盖。这个设计使得系统向后兼容:升级时即使 SPECIES 数组发生变化,也不会因为 stored 中存了一个已不存在的物种名而崩溃。


三、精灵渲染引擎:sprites.ts

3.1 ASCII Art 精灵系统

每个物种有 3 帧动画,每帧是 5 行 × 12 字符的 ASCII 画。使用 {E} 作为眼睛的占位符,渲染时替换为实际眼型字符:

[duck frame 0]
'            '
'    __      '
'  <({E} )___  '   ← {E} 会被替换为 '·', '✦', '×' 等
'   (  ._>   '
'    `--´    '

renderSprite 函数的逻辑:

export function renderSprite(bones: CompanionBones, frame = 0): string[] {
  const frames = BODIES[bones.species]
  const body = frames[frame % frames.length]!.map(line =>
    line.replaceAll('{E}', bones.eye),
  )
  const lines = [...body]
  // 插入帽子:只有第 0 行为空时才覆盖
  if (bones.hat !== 'none' && !lines[0]!.trim()) {
    lines[0] = HAT_LINES[bones.hat]
  }
  // 优化:所有帧第 0 行都为空时,去掉这一行节省终端空间
  if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
  return lines
}

帽子系统设计精妙:第 0 行(顶部)作为帽子槽,普通帧保持空白,特殊帧(如 dragon 的第 2 帧)用它显示"烟雾"特效。只有当第 0 行为空时,帽子才会被渲染进去,防止帽子与烟雾特效互相覆盖。

3.2 renderFace 的 switch 全覆盖

renderFace 函数为每个物种生成独特的"脸部表情"字符串(用于气泡尾部或简洁显示),用 TypeScript 的 exhaustive switch 保证每个物种都有对应处理:

case duck:
case goose:
  return `(${eye}>`    // 鸭子和鹅共用侧脸样式
case blob:
  return `(${eye}${eye})`  // blob 双眼并排

四、提示词注入:prompt.ts

export function companionIntroText(name: string, species: string): string {
  return `# Companion

A small ${species} named ${name} sits beside the user's input box...
When the user addresses ${name} directly (by name), its bubble will answer.
Your job in that moment is to stay out of the way: respond in ONE line or less...`
}

这是整个系统最有趣的部分之一:Companion 的气泡回复实际上是注入到主 LLM 上下文中的系统提示词getCompanionIntroAttachment 函数把伴侣的名字和物种作为一个 companion_intro 类型的 attachment 注入到消息历史中。

去重逻辑保证了这段提示词只注入一次(通过扫描历史消息中是否已有 companion_intro attachment),避免重复污染上下文。


五、React 组件层:CompanionSprite.tsx

5.1 动画时钟设计

const TICK_MS = 500
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]

每 500ms 触发一次 tick,遍历 IDLE_SEQUENCE。序列含义:

  • 0 → 显示帧 0(静止)
  • 1 → 显示帧 1(轻微动作)
  • 2 → 显示帧 2(另一个动作)
  • -1 → 在帧 0 基础上触发眨眼效果

整个序列长 15,总周期 7.5 秒。大部分时间(9/15)是静止,偶尔(4/15)轻微动作,偶尔(1/15)眨眼,偶尔(1/15)做特殊动作,完全模拟了真实小动物的"懒散"状态。

5.2 气泡淡出系统

const BUBBLE_SHOW = 20  // ticks,约 10 秒
const FADE_WINDOW = 6   // 最后 6 个 tick(3 秒)开始变暗

气泡显示 10 秒,最后 3 秒进入淡出(fading)状态,颜色变为 inactive,提示用户气泡即将消失。这是很细腻的 UX 设计。

5.3 爱心粒子系统

const PET_BURST_MS = 2500
const PET_HEARTS = [
  `   ${H}    ${H}   `,
  `  ${H}  ${H}   ${H}  `,
  ` ${H}   ${H}  ${H}   `,
  `${H}  ${H}      ${H} `,
  '·    ·   ·  ',
]

执行 /buddy pet 命令时,5 帧心形动画依次播放(每帧约 500ms),爱心从密集到稀疏,最后消散为 ·,模拟粒子飘散效果。

5.4 React Compiler 优化

CompanionSprite.tsx 中大量的 _c$ 缓存结构,是 React Compiler(原 React Forget)的编译输出。React Compiler 会静态分析组件,自动插入 memoization 代码,避免不必要的重渲染。这说明该项目已启用实验性的 React Compiler 功能。


六、通知与生命周期:useBuddyNotification.tsx

6.1 彩虹预告通知

export function isBuddyTeaserWindow(): boolean {
  const d = new Date()
  return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7
}

这个函数判断当前是否在"预告窗口"(2026 年 4 月 1-7 日)。在预告期,未孵化伴侣的用户启动时会看到彩虹色的 /buddy 提示。注释说明使用本地时间而非 UTC,是为了在不同时区形成一个"24小时滚动波浪",让社交媒体上的讨论热度持续更长时间,而非在 UTC 午夜造成单点流量峰值冲击后端的"孵化生成"接口。

6.2 /buddy 命令触发位置检测

export function findBuddyTriggerPositions(text: string) {
  const re = //buddy\b/g
  let m: RegExpExecArray | null
  while ((m = re.exec(text)) !== null) {
    triggers.push({ start: m.index, end: m.index + m[0].length })
  }
  return triggers
}

这个函数返回输入文本中所有 /buddy 命令的字符位置范围,供 UI 层做高亮渲染使用(\b 确保匹配词边界,不会误匹配 /buddylist 这样的字符串)。


七、整体架构总结

types.ts          ← 数据模型定义(Bones / Soul / Companion)
    ↓
companion.ts      ← 确定性随机生成引擎(hash → PRNG → roll)
    ↓                       ↓
sprites.ts        ←  ASCII 精灵渲染(BODIES + HAT_LINES)
    ↓
CompanionSprite.tsx ← React 组件(动画 / 气泡 / 爱心粒子)
    ↓
prompt.ts         ← LLM 上下文注入(companion_intro attachment)
useBuddyNotification.tsx ← 启动通知 & 命令触发检测

整个系统体现了几个值得学习的设计原则:

  1. 骨肉分离(Bones/Soul Split) :可变的"灵魂"(名字、性格)与不可篡改的"骨骼"(稀有度、物种)分开存储,防止用户作弊,同时保证向后兼容。
  2. 确定性生成(Deterministic Generation) :用 hash(userId + salt) 作为种子,保证同一用户永远得到同一只宠物,既有"专属感",又避免了服务端存储 bones 的开销。
  3. 防御性工程(Defensive Engineering) :Salt、字符码点构造字符串、bones 后置覆盖——每处细节都有明确的防御目的。
  4. 渐进式 UX(Progressive UX) :预告窗口 → 孵化 → 闲置动画 → 气泡回复 → 爱心互动,构成了完整的用户旅程,而非一个静态彩蛋。

这只坐在终端里的小鸭子(或者传奇龙),背后藏着的是一套从哈希函数到 React Compiler 的完整工程体系。下次你的 Claude Code 里出现一只小精灵,不妨想想——它的稀有度,从你第一次注册账号那天起,就已经注定了。