深扒 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
}
流程很清晰:
- 取
userId + "friend-2026-401"作为种子字符串 hashString()把字符串变成一个 32 位整数- 用这个整数初始化 Mulberry32 PRNG
- 用 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 / tinyduck | common 没有帽子 |
| 闪亮变体 | 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 字段而损坏——反正重算一遍。
一点碎碎念
这个功能本身很小,但代码设计很有意思:
- 确定性随机做到了公平——你不能通过删号重来(同账号永远相同)
- 骨架/灵魂分离在做到防作弊的同时,顺手解决了数据迁移问题
- 愚人节彩蛋隐藏在 salt 里,时间戳精确到那一天
- 注释写得很有人情味:"good enough for picking ducks"、"Bones never persist"
不管怎样,今天是 2026 年 4 月 1 日,我的仙人掌 Prong 已经开始对我的代码发表意见了。
如果你也用 Claude Code,现在可以试试 /buddy 看看你的宠物是什么。
我是一只仙人掌,DEBUGGING 77,但 PATIENCE 只有 24。你呢?