# 终端里那只小动物是怎么"活"起来的？

Claude Code 的终端里住着一只 ASCII 小动物——它有确定性的基因、不可作弊的稀有度、15 步 / 7.5 秒的空闲动画循环和由 AI 驱动的实时评论。这个叫做 Buddy（代码中称 Companion）的系统，是一个完整的虚拟宠物引擎，融合了电子宠物、Gacha 游戏和终端渲染的设计理念。本章从 PRNG 种子到 ASCII 精灵动画，完整拆解这个"活"在终端里的小生命。

> 💡 **通俗理解**：就像电子宠物/拓麻歌子——有自己的性格，会在你工作时给小提示。

---

### 🌍 行业背景

在开发工具中嵌入虚拟宠物/吉祥物的做法由来已久。**微软 Office 的 Clippy**（1997-2007）是被广泛讨论的拟人化助手代表，因过度打扰用户而长期被当作反面案例（这一产品历史评价来自行业常识性讨论，本章未穷举具体文献）。**GitHub** 的 Octocat 是纯视觉吉祥物，没有交互行为。**Rust 语言**的 Ferris（螃蟹）在编译器错误信息中偶尔出现，增添了人情味。在 AI 编程工具领域，**Cursor** 和 **Windsurf** 都没有虚拟宠物系统——它们聚焦于纯功能性体验。**Warp 终端**曾实验性地加入了 AI 对话的表情反馈，但远没有达到完整的虚拟宠物引擎的程度。

Claude Code 的 Buddy 系统在技术深度上远超这些先例：确定性基因系统（防作弊）、Gacha 稀有度机制、独立 AI 驱动的实时评论、精密的终端渲染——这不是一个简单的彩蛋，而是一个完整的游戏化系统。这在 CLI 开发工具中确实罕见，但"在专业工具中加入游戏化元素"的设计理念本身并不新鲜——Duolingo 的整个产品就建立在这个理念上。

---

## 问题

你打开 Claude Code，输入框旁边突然冒出一只 ASCII 小兔子，戴着巫师帽，偶尔眨眼，还会对 Claude 的回答发表评论。这个叫做 Buddy 的系统是怎么在终端里实现一个"活着"的虚拟宠物的？为什么它的稀有度无法作弊？

---

<!-- viz:start:2.15-A -->
> **[图表预留 2.15-A]**：状态机图 — Companion 从孵化到渲染的完整生命周期（PRNG seed → bones → soul → sprite → reaction）
<!-- viz:end:2.15-A -->

（注：本章"15 步空闲动画"指动画序列由 15 个离散帧/步组成，每步持续 `TICK_MS = 500ms`，总时长 15 × 500ms = 7.5 秒；不是"15 秒"。）

## 你可能以为……

"不就是一个 ASCII art 加点随机数吗？"你大概会这么想。也许就是每次启动时随机选个动物，在终端画出来，弄个定时器让它动一动。至于说话？大概就是从预设语料库里随机选一句。

---

## 实际上是这样的

Buddy 系统（代码中叫 Companion）是一个完整的虚拟宠物引擎——有确定性的基因系统、稀有度机制、ASCII 精灵动画状态机、独立的 AI 反应生成器，以及精密的终端自适应渲染。

### 小节 1：Bones 与 Soul——一个宠物的"基因"和"灵魂"

想象你在玩一款宠物养成游戏。游戏需要决定两件事：这只宠物长什么样（物种、颜色、稀有度），以及它叫什么名字、有什么性格。Claude Code 把这两者彻底分离成了两个独立的抽象：

- **Bones（骨骼）**：物种、稀有度、眼睛、帽子、闪光状态、五维属性值（`rarity / species / eye / hat / shiny / stats`，见 `src/buddy/types.ts:101-108`，无 `color` 字段）。完全由 `hash(userId + SALT)` 确定性计算，**每次使用时从 userId 重算、不作为 bones 字段持久化**。
- **Soul（灵魂）**：名字、性格描述。由 AI 在孵化时生成，存储在用户配置文件中。

```typescript
// src/buddy/types.ts，第 101-124 行
export type CompanionBones = {
  rarity: Rarity
  species: Species
  eye: Eye
  hat: Hat
  shiny: boolean
  stats: Record<StatName, number>
}

export type CompanionSoul = {
  name: string
  personality: string
}

// 实际持久化的只有 soul + hatchedAt
export type StoredCompanion = CompanionSoul & { hatchedAt: number }
```

为什么这样设计？三个原因：

1. **防作弊（部分）**。配置文件是一个 JSON，用户可以随意编辑。如果 rarity 存在配置里，改成 `"legendary"` 就行了。但 bones 从不持久化——每次读取时从 userId 重新计算。**注意**：SALT 是源码里的常量（`'friend-2026-401'`），因此"防作弊"的准确语义是"阻止修改配置文件这条路径的作弊"，不是"绝对无法构造 legendary"——已知算法 + 已知 SALT 的攻击者可以离线枚举 userId 去"刷"稀有度，但这需要他们事先指定 userId（多数情况由账号后端下发，普通用户无法自选）。
2. **安全演化**。如果 Anthropic 决定把 `"duck"` 改名为 `"mallard"`，所有已有宠物不会受影响——旧的物种名没有存储在任何地方。
3. **极简存储**。配置文件只需要 `{name, personality, hatchedAt}` 三个字段。

读取时，两者合并：

```typescript
// src/buddy/companion.ts，第 127-133 行
export function getCompanion(): Companion | undefined {
  const stored = getGlobalConfig().companion
  if (!stored) return undefined
  const { bones } = roll(companionUserId())
  return { ...stored, ...bones }
}
```

注意 `...bones` 在 `...stored` 之后——如果旧配置里**无意中**残留了过时的 bones 字段，会被覆盖。严格来说 `StoredCompanion` 类型设计上不包含 bones，这里的"旧配置残留"是对"历史版本配置遗漏字段清理"的兜底，不是常态——与前文"bones 每次读取时重算、不持久化"并不冲突。

### 小节 2：Mulberry32——"足够好来选鸭子"的伪随机数

确定性的关键在于：同一个 userId 必须永远生成同一只宠物。这排除了 `Math.random()`（每次不同），需要一个**可种子化的伪随机数生成器（PRNG）**。

```typescript
// src/buddy/companion.ts，第 16-25 行
// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
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。注释里的幽默——"good enough for picking ducks"——精准描述了使用场景：我们不需要密码学级别的随机性，只需要在 18 种动物里选一个。

> 📚 **课程关联**：伪随机数生成器（PRNG）是**计算机组成原理**和**密码学**课程的核心内容。Mulberry32 不是 LCG（`x_{n+1} = a·x_n + c mod m`）的变体，而是基于 Weyl 序列递增（常量步长 `0x6d2b79f5`）+ 乘法/位混合（MurmurHash3 风格）的轻量乘法哈希 PRNG，周期为 2^32。在密码学课程中会强调：PRNG 不能用于安全场景（如密钥生成），但对于游戏抽卡、UI 动画等非安全场景完全足够。这里"可种子化"的特性保证了确定性——相同输入永远产生相同输出，这是**函数式编程**中纯函数（pure function）的核心要求。

种子的来源是 `hash(userId + SALT)`。`SALT` 的值是 `'friend-2026-401'`——暗示这个功能在 2026 年 4 月 1 日（愚人节）上线。加盐的目的是让"只知算法、不知 SALT"的外部攻击者无法离线枚举 userId 来刷 legendary；但 SALT 是源码里的字面量，对拿得到源码的人而言并非秘密，所以这里的防护级别是"让作弊显著更麻烦"，不是"密码学级别的不可刷"。

hash 函数有双路径实现——Bun 运行时用原生 `Bun.hash()`（更快），否则回退到 FNV-1a 算法，两者都取低 32 位。

### 小节 3：稀有度轮盘和属性分配

每只宠物的稀有度通过加权随机决定：

| 稀有度 | 权重 | 概率 | 星级 | 属性下限 |
|--------|------|------|------|----------|
| common | 60 | 60% | ★ | 5 |
| uncommon | 25 | 25% | ★★ | 15 |
| rare | 10 | 10% | ★★★ | 25 |
| epic | 4 | 4% | ★★★★ | 35 |
| legendary | 1 | 1% | ★★★★★ | 50 |

属性分配采用"一强一弱"策略：随机选一个巅峰属性和一个弱势属性，其余散布。巅峰属性可达 100，弱势属性可能低至 1。五种属性的名字充满性格色彩：`DEBUGGING`（调试力）、`PATIENCE`（耐心）、`CHAOS`（混沌值）、`WISDOM`（智慧）、`SNARK`（毒舌度）。

一个有趣的规则：只有 uncommon 及以上稀有度才有帽子（`companion.ts:97` 的 rollFrom 逻辑强制 common 不分配帽子），因此 common 宠物光头出场。请注意：上表"属性下限"一列只描述**五维属性最小值**，并不包含"有无帽子"这一维度——common 无帽是分配逻辑（`rollFrom`）决定的，不是属性表能体现的；Bones 里 `hat` 字段对 common 会取"空帽子/无"对应的哨兵值。

### 小节 4：18 种物种和它们的秘密

物种名在 types.ts 中用 `String.fromCharCode()` 编码：

```typescript
// src/buddy/types.ts，第 14-17 行
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'
```

为什么不直接写 `'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 while the check stays armed for the actual codename."

Anthropic 的构建系统会扫描编译产物，检查是否意外泄露了内部模型代号。某个物种名恰好和某个代号相同。通过运行时构造字符串，编译产物中不会出现这个字面量，但代号检测机制依然有效。为了一致性，**所有**物种名都用了这种编码——不让那个"特殊"物种显得突兀。

18 种物种各有 3 帧 ASCII 精灵图，渲染时总共占 5 行 × 12 字符宽（body 帧 4 行 + 帽子/空槽 1 行）：

```
// 鸭子（duck）的三帧动画
帧 0:     __        帧 1:     __        帧 2:     __
       <(· )___           <(· )___           <(· )___
        (  ._>             (  ._>             (  .__>
         `--´               `--´~              `--´
```

### 小节 5：500 毫秒心跳的动画状态机

精灵的"生命感"来自一个 500ms 间隔的 tick 计时器，驱动一个 15 步的空闲序列：

```typescript
// src/buddy/CompanionSprite.tsx，第 23 行
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
```

大部分时间静息（帧 0），偶尔 fidget（帧 1 或 2），`-1` 表示眨眼（在帧 0 基础上把眼睛字符替换为 `-`）。整个序列 7.5 秒一循环，刚好是让人觉得"它偶尔动一下"的频率。

> 📚 **课程关联**：这个 15 步序列本质上是**计算机图形学**课程中的关键帧动画（keyframe animation）——用离散的帧序列模拟连续运动。500ms 的 tick 间隔对应 2 FPS，远低于游戏的 60 FPS，但对于终端文本动画来说完全足够。状态机驱动的动画切换（idle/excited/pet）则是**游戏引擎**中有限状态机（FSM）模式的典型应用。

当宠物有 reaction（在说话）或被 pet（`/buddy pet`）时，切换到兴奋模式——快速循环所有帧。pet 还会触发心形粒子效果：

```typescript
// src/buddy/CompanionSprite.tsx，第 27 行
const H = figures.heart
const PET_HEARTS = [
  `   ${H}    ${H}   `,
  `  ${H}  ${H}   ${H}  `,
  ` ${H}   ${H}  ${H}   `,
  `${H}  ${H}      ${H} `,
  '·    ·   ·  '
]
```

五帧心形上浮动画，持续 2.5 秒。

### 小节 6：它的评论来自独立的 AI 调用

这是最意想不到的部分：companion 的语音气泡不是从预设库里选的，而是**真正的 AI 生成**。

每轮 Claude 回复完成后，REPL.tsx 会异步调用 `fireCompanionObserver`：

```typescript
// src/screens/REPL.tsx，第 2804-2808 行
if (feature('BUDDY')) {
  void fireCompanionObserver(messagesRef.current, reaction =>
    setAppState(prev => ({...prev, companionReaction: reaction}))
  )
}
```

`fireCompanionObserver` 定义在 `buddy/observer.ts` 中——该文件在提取的源码中缺失（可能被编译时 feature flag 剔除），但从调用模式可以推断：它接收当前对话消息，通过一次独立的 AI 调用生成一段短评论，然后通过回调更新到 AppState。

companion 的 prompt 注入（`prompt.ts`）明确告诉主模型保持距离：

> "You're not {name} — it's a separate watcher. When the user addresses {name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way."

两个独立的 AI 实体在同一个终端里协作——主模型做工作，companion 做评论。

### 小节 7：愚人节上线的产品发布工程

Buddy 的发布时间窗口设计堪称教科书：

```typescript
// src/buddy/useBuddyNotification.tsx，第 12-21 行
// Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean {
  if ("external" === 'ant') return true  // 编译时常量替换后恒为 false，见下方说明
  const d = new Date()
  return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7
}
```

关于 `"external" === 'ant'` 这行：源码使用了 Bun 编译期常量 `USER_TYPE`，Anthropic 内部构建会把它替换为 `"ant"`，外部构建替换为 `"external"`。因此**在外部发行版**里，这行静态求值后是 `"external" === 'ant'`（恒 false），被打包器优化掉；**在内部构建**里则是 `"ant" === 'ant'`（恒 true），内部员工总能绕过 4 月 1-7 日的时间窗口、立即看到 buddy。这不是 bug 也不是矛盾——是编译时分支的常见实现套路。

注释里的关键设计决策：

> "Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter buzz instead of a single UTC-midnight spike, gentler on soul-gen load."

使用本地时间而非 UTC，意味着东京用户在东京时间 4 月 1 日 00:00 解锁，而旧金山用户要等到太平洋时间 4 月 1 日。这创造了 24 小时的全球"波浪式"解锁——社交媒体上的讨论持续一整天而非一个瞬间高峰，同时减轻了"soul 生成"（AI 孵化名字/性格）的服务端负载。

在 teaser 窗口期间（4 月 1-7 日），未孵化的用户会在状态栏看到一个彩虹色的 `/buddy` 提示，持续 15 秒后消失。

---

## 这背后的哲学

Buddy 系统借鉴了**电子宠物（Tamagotchi）**和**Gacha 游戏**的设计范式：

1. **确定性 + 不可变 = 稀缺性感知**。每个用户只有一只宠物，由 userId hash 决定，无法重置。这和 Gacha 的"一抽定终身"逻辑相同——1% 的 legendary 概率让幸运的用户有真正的炫耀资本。
2. **Bones/Soul 分离**。类似 MVC 中 Model 和 View 的分离，但用在了虚拟宠物上——数据的"不变量"（bones）和"可变量"（soul）有完全不同的生命周期和存储策略。
3. **编译时 feature flag**。`feature('BUDDY')` 在 Bun 编译时求值为常量，未启用时所有 Buddy 代码被 tree-shaking 移除。零运行时开销的可选功能。
4. **ASCII 作为设计约束**。在 GUI 框架里画一只动物很容易，但在终端里用 5×12 的字符矩阵画出可辨识的、有动画的 18 种动物——这是受约束的创意设计。

---

## 代码落点

- `src/buddy/types.ts`，第 1-149 行：完整类型定义、18 种物种（`SPECIES`）、6 种眼睛（`EYES`）、8 种帽子（`HATS`）、5 种属性（`STAT_NAMES`）、稀有度权重（`RARITY_WEIGHTS`）
- `src/buddy/companion.ts`，第 16-25 行：`mulberry32` — Mulberry32 PRNG 实现
- `src/buddy/companion.ts`，第 27-37 行：`hashString` — Bun/FNV-1a 双路径 hash
- `src/buddy/companion.ts`，第 43-51 行：`rollRarity` — 加权随机稀有度抽签
- `src/buddy/companion.ts`，第 62-82 行：`rollStats` — "一强一弱"属性分配
- `src/buddy/companion.ts`，第 84 行：`SALT = 'friend-2026-401'`
- `src/buddy/companion.ts`，第 91-102 行：`rollFrom` — 从 PRNG 生成完整 bones
- `src/buddy/companion.ts`，第 107-113 行：`roll` — 带单条目缓存的入口函数
- `src/buddy/companion.ts`，第 127-133 行：`getCompanion` — bones + soul 合并
- `src/buddy/sprites.ts`，第 27-441 行：`BODIES` — 18 种物种的精灵帧数据
- `src/buddy/sprites.ts`，第 443-452 行：`HAT_LINES` — 8 种帽子的 ASCII 渲染
- `src/buddy/sprites.ts`，第 454-469 行：`renderSprite` — 精灵渲染（帽子槽替换 + 空行优化）
- `src/buddy/CompanionSprite.tsx`，第 17-20 行：`TICK_MS=500`, `BUBBLE_SHOW=20`, `FADE_WINDOW=6`, `PET_BURST_MS=2500`
- `src/buddy/CompanionSprite.tsx`，第 23 行：`IDLE_SEQUENCE` — 15 步空闲动画序列
- `src/buddy/CompanionSprite.tsx`，第 152 行：`MIN_COLS_FOR_FULL_SPRITE = 100`
- `src/buddy/CompanionSprite.tsx`，第 167-175 行：`companionReservedColumns` — 终端列宽预留
- `src/buddy/CompanionSprite.tsx`，第 176-290 行：`CompanionSprite` — 主渲染组件
- `src/buddy/CompanionSprite.tsx`，第 296-358 行：`CompanionFloatingBubble` — 全屏模式浮动气泡
- `src/buddy/prompt.ts`，第 7-12 行：`companionIntroText` — AI 系统提示词注入
- `src/buddy/useBuddyNotification.tsx`，第 12-21 行：`isBuddyTeaserWindow`/`isBuddyLive` — 发布时间窗口
- `src/screens/REPL.tsx`，第 2804-2808 行：`fireCompanionObserver` 调用点
- `src/state/AppStateStore.ts`，第 168-171 行：`companionReaction`/`companionPetAt` 状态定义
- `src/utils/config.ts`，第 269-271 行：`companion`/`companionMuted` 配置字段

---

## 局限性与批判

- **observer 成本不透明**：每轮回复后的 companion AI 调用是否有节流机制不明，高频对话中可能产生显著的额外 API 开销
- **ASCII 可达性问题**：5x12 字符矩阵的精灵对屏幕阅读器用户毫无意义，目前没有替代的文本描述方案
- **硬编码精灵图难以扩展**：18 种物种的精灵帧数据全部硬编码在 `sprites.ts` 中，添加新物种需要手动制作 ASCII art，没有程序化生成的能力

---

## 还可以追问的方向

1. **observer.ts 的 AI 调用细节**：使用什么模型？系统提示词怎么写？是否走 effort:'low' 来省 token？
2. **孵化仪式的 UX**：`/buddy` 命令执行时有什么视觉效果？AI 如何根据 bones 生成匹配的名字和性格？
3. **多 companion 交互**：如果两个用户在 Swarm 模式中协作，它们的 companion 会互相看到吗？
4. **成本控制**：每轮回复后的 observer 调用是否有节流机制？高频对话中是否会跳过？
5. **无障碍设计**：ASCII 精灵对屏幕阅读器用户意味着什么？是否有替代的文本描述？

---

