深扒 Claude Code Buddy 模式:一只仙人掌背后的确定性随机算法

144 阅读5分钟

深扒 Claude Code Buddy 模式:一只仙人掌背后的确定性随机算法

今天是 2026 年 4 月 1 日。不,这不是愚人节玩笑——Claude Code 真的藏了一个宠物系统,而且 salt 值就叫 friend-2026-401

什么是 Buddy 模式?

最近更新的 Claude Code 里悄悄藏了一个 /buddy 命令。执行之后,输入框旁边会冒出一只小动物,偶尔发表对你代码的犀利评论。

比如我这只叫 Prong 的仙人掌(COMMON 稀有度),属性长这样:

物种:cactus(仙人掌)  稀有度:COMMON ★
────────────────────────────────
DEBUGGING  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░  77
PATIENCE   ▓▓▓▓▓░░░░░░░░░░░░  24
CHAOS      ▓░░░░░░░░░░░░░░░░   2
WISDOM     ▓▓▓▓▓░░░░░░░░░░░░  22
SNARK      ▓▓▓▓▓▓░░░░░░░░░░░  29

性格描述:"找 bug 找得又准又快,但你不按它说的方式改就会大声抱怨。"

基本操作:

  • /buddy — 呼出宠物
  • /buddy pet — 撸宠物
  • /buddy off — 关闭宠物
  • 不消耗用量,纯装饰/陪伴功能

分配机制:为什么你的宠物和别人不一样?

这套系统最有趣的地方是它的确定性随机设计——同一个账号永远分配到同一只宠物,但你无法通过修改配置来"作弊"刷稀有度。

核心算法:roll() 函数

源码在 src/buddy/companion.ts,核心入口是这个函数:

const SALT = 'friend-2026-401'

// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
// per-turn observer) with the same userId → cache the deterministic result.
let rollCache: { key: string; value: Roll } | undefined

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
}

流程很清晰:

  1. userId + "friend-2026-401" 作为种子字符串
  2. hashString() 把字符串变成一个 32 位整数
  3. 用这个整数初始化 Mulberry32 PRNG
  4. 用 PRNG 确定性地决定一切属性

salt 值 friend-2026-401 里的 401 就是 4 月 1 日——愚人节彩蛋。

哈希函数:两种实现

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

在 Bun 环境用 Bun 原生哈希,否则用 FNV-1a 32-bit 算法。两者都能把字符串映射到一个稳定的 32 位无符号整数。

PRNG: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,统计质量足够好,代码量极小,非常适合"用于挑鸭子"这种不需要密码学安全性的场景(注释原文:// Mulberry32 — tiny seeded PRNG, good enough for picking ducks)。

稀有度系统

权重表

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

稀有度抽取

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'
}

标准的加权随机选择。注意 RARITIES 的顺序是 ['common', 'uncommon', 'rare', 'epic', 'legendary'],越低稀有度越先被"消耗",这是一种等价但直观的实现方式。

物种与外观

18 种物种

duck / goose / blob / cat / dragon / octopus / owl / penguin /
turtle / snail / ghost / axolotl / capybara / cactus / robot /
rabbit / mushroom / chonk

有意思的是,types.ts 里的物种名全部用 String.fromCharCode() 编码:

const c = String.fromCharCode
export const duck    = c(0x64,0x75,0x63,0x6b) as 'duck'
export const goose   = c(0x67,0x6f,0x6f,0x73,0x65) as 'goose'
// ...

注释解释了原因:某个物种名和内部的一个模型代号冲突,触发了构建检查脚本的字符串扫描。通过运行时拼接字面量字符串,既规避了检查,又没有破坏类型系统(as cast 在编译后会被抹去)。

外观组合规则

属性选项备注
稀有度5 级common 60%, legendary 1%
物种18 种均等概率
眼睛6 种:· ✦ × ◉ @ °均等概率
帽子7 种:crown / tophat / propeller / halo / wizard / beanie / tinyduckcommon 没有帽子
闪亮变体1% 概率独立于稀有度
function rollFrom(rng: () => number): Roll {
  const rarity = rollRarity(rng)
  const bones: CompanionBones = {
    rarity,
    species: pick(rng, SPECIES),
    eye: pick(rng, EYES),
    hat: rarity === 'common' ? 'none' : pick(rng, HATS),  // common 无帽
    shiny: rng() < 0.01,
    stats: rollStats(rng, rarity),
  }
  return { bones, inspirationSeed: Math.floor(rng() * 1e9) }
}

属性系统

每只宠物有 5 个属性:DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK

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

function rollStats(rng: () => number, rarity: Rarity): Record<StatName, number> {
  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)

  const stats = {} as Record<StatName, number>
  for (const name of STAT_NAMES) {
    if (name === peak) {
      stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))  // 高区间
    } else if (name === dump) {
      stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))    // 低区间
    } else {
      stats[name] = floor + Math.floor(rng() * 40)                       // 中等
    }
  }
  return stats
}

设计逻辑:

  • 每只宠物必有一个峰值属性(peak)和一个垃圾属性(dump),形成鲜明个性
  • 稀有度越高,floor 越高,意味着即使是 legendary 的垃圾属性也比 common 的大多数属性强
  • common floor=5,legendary floor=50,差距悬殊

防作弊设计:骨架与灵魂分离

这套系统最精妙的地方在于持久化机制:

// Regenerate bones from userId, merge with stored soul. Bones never persist
// so species renames and SPECIES-array edits can't break stored companions,
// and editing config.companion can't fake a rarity.
export function getCompanion(): Companion | undefined {
  const stored = getGlobalConfig().companion
  if (!stored) return undefined
  const { bones } = roll(companionUserId())
  // bones last so stale bones fields in old-format configs get overridden
  return { ...stored, ...bones }
}

类型定义上:

  • Bones(骨架):物种、稀有度、眼睛、帽子、shiny、属性——每次从 userId 重新计算,从不持久化
  • Soul(灵魂):名字、性格描述——持久化到 config

这意味着无论你怎么编辑 config.companion,稀有度和属性都会被 roll(userId) 的结果覆盖。注释说得很直白:editing config.companion can't fake a rarity

同理,如果未来 Anthropic 修改了物种名称或新增物种,已有用户的宠物属性也不会因为 config 里存了旧的 species 字段而损坏——反正重算一遍。

一点碎碎念

这个功能本身很小,但代码设计很有意思:

  1. 确定性随机做到了公平——你不能通过删号重来(同账号永远相同)
  2. 骨架/灵魂分离在做到防作弊的同时,顺手解决了数据迁移问题
  3. 愚人节彩蛋隐藏在 salt 里,时间戳精确到那一天
  4. 注释写得很有人情味:"good enough for picking ducks"、"Bones never persist"

不管怎样,今天是 2026 年 4 月 1 日,我的仙人掌 Prong 已经开始对我的代码发表意见了。


如果你也用 Claude Code,现在可以试试 /buddy 看看你的宠物是什么。

我是一只仙人掌,DEBUGGING 77,但 PATIENCE 只有 24。你呢?