From 88ddba6c239f655f7752eab7a7943d7a19ce3244 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Tue, 21 Apr 2026 19:03:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=AC=E4=B8=80=E7=89=88=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=20pokemon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Issues.md | 5 + docs/plans/buddy-pokemon-plan.md | 713 ++++++++++++++++++ packages/pokemon/package.json | 9 + packages/pokemon/scripts/fetch-sprites.ts | 124 +++ .../pokemon/src/__tests__/creature.test.ts | 107 +++ packages/pokemon/src/__tests__/effort.test.ts | 79 ++ packages/pokemon/src/__tests__/egg.test.ts | 87 +++ .../pokemon/src/__tests__/evolution.test.ts | 91 +++ .../pokemon/src/__tests__/experience.test.ts | 84 +++ packages/pokemon/src/__tests__/gender.test.ts | 51 ++ packages/pokemon/src/core/creature.ts | 117 +++ packages/pokemon/src/core/effort.ts | 98 +++ packages/pokemon/src/core/egg.ts | 97 +++ packages/pokemon/src/core/evolution.ts | 46 ++ packages/pokemon/src/core/experience.ts | 52 ++ packages/pokemon/src/core/gender.ts | 26 + packages/pokemon/src/core/spriteCache.ts | 139 ++++ packages/pokemon/src/core/storage.ts | 206 +++++ packages/pokemon/src/index.ts | 48 ++ packages/pokemon/src/sprites/fallback.ts | 85 +++ packages/pokemon/src/sprites/index.ts | 4 + packages/pokemon/src/sprites/renderer.ts | 76 ++ packages/pokemon/src/types.ts | 143 ++++ packages/pokemon/src/ui/CompanionCard.tsx | 170 +++++ packages/pokemon/src/ui/EggView.tsx | 54 ++ packages/pokemon/src/ui/EvolutionAnim.tsx | 90 +++ packages/pokemon/src/ui/PokedexView.tsx | 163 ++++ packages/pokemon/src/ui/SpeciesDetail.tsx | 193 +++++ packages/pokemon/src/ui/StatBar.tsx | 28 + packages/pokemon/tsconfig.json | 8 + src/buddy/CompanionCard.tsx | 110 --- src/buddy/CompanionSprite.tsx | 393 ++++------ src/buddy/companion.ts | 136 ---- src/buddy/companionReact.ts | 67 +- src/buddy/prompt.ts | 25 +- src/buddy/sprites.ts | 514 ------------- src/buddy/types.ts | 149 ---- src/commands/buddy/BuddyPanel.tsx | 403 ++++++++++ src/commands/buddy/buddy.ts | 265 ++++--- src/commands/buddy/index.ts | 4 +- .../messages/UserChannelMessage.tsx | 104 ++- src/hooks/useCancelRequest.ts | 14 +- src/screens/REPL.tsx | 66 ++ src/state/AppStateStore.ts | 4 + src/utils/config.ts | 9 +- tsconfig.json | 4 +- 46 files changed, 4143 insertions(+), 1317 deletions(-) create mode 100644 Issues.md create mode 100644 docs/plans/buddy-pokemon-plan.md create mode 100644 packages/pokemon/package.json create mode 100644 packages/pokemon/scripts/fetch-sprites.ts create mode 100644 packages/pokemon/src/__tests__/creature.test.ts create mode 100644 packages/pokemon/src/__tests__/effort.test.ts create mode 100644 packages/pokemon/src/__tests__/egg.test.ts create mode 100644 packages/pokemon/src/__tests__/evolution.test.ts create mode 100644 packages/pokemon/src/__tests__/experience.test.ts create mode 100644 packages/pokemon/src/__tests__/gender.test.ts create mode 100644 packages/pokemon/src/core/creature.ts create mode 100644 packages/pokemon/src/core/effort.ts create mode 100644 packages/pokemon/src/core/egg.ts create mode 100644 packages/pokemon/src/core/evolution.ts create mode 100644 packages/pokemon/src/core/experience.ts create mode 100644 packages/pokemon/src/core/gender.ts create mode 100644 packages/pokemon/src/core/spriteCache.ts create mode 100644 packages/pokemon/src/core/storage.ts create mode 100644 packages/pokemon/src/index.ts create mode 100644 packages/pokemon/src/sprites/fallback.ts create mode 100644 packages/pokemon/src/sprites/index.ts create mode 100644 packages/pokemon/src/sprites/renderer.ts create mode 100644 packages/pokemon/src/types.ts create mode 100644 packages/pokemon/src/ui/CompanionCard.tsx create mode 100644 packages/pokemon/src/ui/EggView.tsx create mode 100644 packages/pokemon/src/ui/EvolutionAnim.tsx create mode 100644 packages/pokemon/src/ui/PokedexView.tsx create mode 100644 packages/pokemon/src/ui/SpeciesDetail.tsx create mode 100644 packages/pokemon/src/ui/StatBar.tsx create mode 100644 packages/pokemon/tsconfig.json delete mode 100644 src/buddy/CompanionCard.tsx delete mode 100644 src/buddy/companion.ts delete mode 100644 src/buddy/sprites.ts delete mode 100644 src/buddy/types.ts create mode 100644 src/commands/buddy/BuddyPanel.tsx diff --git a/Issues.md b/Issues.md new file mode 100644 index 000000000..a817b06b1 --- /dev/null +++ b/Issues.md @@ -0,0 +1,5 @@ +pokemon 面板不要 command 描述 + +pokemon dex 数据要从后端拿过来 + +pokemon 面板没法通过 esc 关闭 diff --git a/docs/plans/buddy-pokemon-plan.md b/docs/plans/buddy-pokemon-plan.md new file mode 100644 index 000000000..1e9d4eec8 --- /dev/null +++ b/docs/plans/buddy-pokemon-plan.md @@ -0,0 +1,713 @@ +# Buddy Pokémon 系统重构计划 + +## Context + +现有 buddy 系统(`src/buddy/`)是一个简单的终端宠物,18 种物种、固定属性、无成长机制。用户希望将其重构为 Pokémon 风格的收集养成系统,以独立包 `packages/pokemon/` 的形式实现,复用原始 151 版本的设计理念。MVP 先做 10 只精灵(御三家 3 条进化线 + 1 只吉祥物)。 + +--- + +## Phase 1: 包结构与数据模型 + +### 1.1 `packages/pokemon/` 目录结构 + +``` +packages/pokemon/ +├── package.json # name: "@claude-code-best/pokemon" +├── tsconfig.json +├── src/ +│ ├── index.ts # 统一导出 +│ ├── types.ts # 所有类型定义 +│ ├── data/ +│ │ ├── species.ts # 10 只物种定义(base stats, 进化链, 性别比, 性格) +│ │ ├── evolution.ts # 进化条件数据 +│ │ ├── evMapping.ts # 工具→EV 映射(可配置 JSON) +│ │ ├── xpTable.ts # 1-100 级经验表(指数曲线) +│ │ └── names.ts # 默认名、性格文案 +│ ├── sprites/ +│ │ ├── index.ts # 精灵渲染入口 +│ │ ├── renderer.ts # ASCII art 渲染器(抖动/眨眼/粒子动画) +│ │ └── fallback.ts # 网络失败时的简易 ASCII 占位符 +│ ├── core/ +│ │ ├── creature.ts # 精灵生成、属性计算、等级判定 +│ │ ├── experience.ts # XP 增减、升级检测、经验曲线 +│ │ ├── effort.ts # EV 计算、工具→EV 映射 +│ │ ├── evolution.ts # 进化条件检测、进化执行 +│ │ ├── egg.ts # 蛋获取条件判定、孵化步数 +│ │ ├── gender.ts # 性别判定(按原始 151 设计) +│ │ ├── spriteCache.ts # GitHub 拉取 cow → 本地 JSON 缓存 +│ │ └── storage.ts # ~/.claude/buddy-data.json 读写 +│ └── ui/ +│ ├── CompanionCard.tsx # 重设计:6 属性条 + 等级 + XP +│ ├── PokedexView.tsx # 图鉴视图 +│ ├── EggView.tsx # 蛋/孵化进度 +│ ├── EvolutionAnim.tsx # 进化闪烁变形动画 +│ └── StatBar.tsx # 属性条组件(复用现有样式) +``` + +### 1.2 核心类型定义 + +```typescript +// types.ts + +// 6 属性(映射到编程场景) +export type StatName = 'hp' | 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed' +export const STAT_NAMES: StatName[] = ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] +export const STAT_LABELS: Record = { + hp: 'HP', attack: 'ATK', defense: 'DEF', + spAtk: 'SPA', spDef: 'SPD', speed: 'SPE' +} + +// 物种 ID(MVP 10 只) +export type SpeciesId = + | 'bulbasaur' | 'ivysaur' | 'venusaur' // 御三家·草 + | 'charmander' | 'charmeleon' | 'charizard' // 御三家·火 + | 'squirtle' | 'wartortle' | 'blastoise' // 御三家·水 + | 'pikachu' // 吉祥物 + +// 性别 +export type Gender = 'male' | 'female' | 'genderless' + +// 进化触发类型 +export type EvolutionTrigger = 'level' | 'level_up' | 'item' | 'trade' | 'friendship' + +export type EvolutionCondition = { + trigger: EvolutionTrigger + level?: number // 等级进化:目标等级 + minFriendship?: number // 亲密度进化 + item?: string // 道具进化 + into: SpeciesId // 进化为 +} + +// 物种基础数据 +export type SpeciesData = { + id: SpeciesId + name: string // 英文名 + names: Record // 多语言名 { ja, en, zh } + dexNumber: number // 图鉴编号 (1-10 MVP) + genderRatio: number // 雌性概率 (0-1, -1=无性别) + baseStats: Record + types: [string, string?] // 属性 (grass/poison, fire, water 等) + personality: string // 默认性格描述 + evolutionChain?: EvolutionCondition[] + sprites: string[][] // ASCII art 帧 (每帧 5 行) + shinyChance: number // 闪光概率 +} + +// 实例化的精灵(存储在 buddy-data.json) +export type Creature = { + id: string // 唯一 ID (uuid) + speciesId: SpeciesId + nickname?: string // 用户自定义名 + gender: Gender + level: number + xp: number + ev: Record // 努力值 + iv: Record // 个体值 (0-31) + friendship: number // 亲密度 (0-255) + isShiny: boolean + hatchedAt: number // 获得时间戳 +} + +// 蛋 +export type Egg = { + id: string + obtainedAt: number + stepsRemaining: number // 剩余孵化步数 + speciesId: SpeciesId // 预决定的物种(保底不重复) +} + +// 图鉴条目 +export type DexEntry = { + speciesId: SpeciesId + discoveredAt: number + caughtCount: number // 捕获数量 + bestLevel: number // 最高等级记录 +} + +// buddy-data.json 完整结构 +export type BuddyData = { + version: 1 + activeCreatureId: string | null + creatures: Creature[] + eggs: Egg[] + dex: DexEntry[] + stats: { + totalTurns: number + consecutiveDays: number + lastActiveDate: string // ISO date + totalEggsObtained: number + totalEvolutions: number + } +} +``` + +### 1.3 工具→EV 映射(可配置) + +```typescript +// data/evMapping.ts +export const DEFAULT_EV_MAPPING: Record> = { + "Bash": { attack: 2, speed: 1 }, + "Edit": { spAtk: 2, defense: 1 }, + "Write": { spAtk: 3 }, + "Read": { defense: 2, hp: 1 }, + "Grep": { spDef: 2, speed: 1 }, + "Glob": { spDef: 2, speed: 1 }, + "Agent": { speed: 2, attack: 1 }, + "WebSearch": { spDef: 2, hp: 1 }, + "WebFetch": { spDef: 2, hp: 1 }, +} +// 不在映射中的工具 → 随机分配 1-2 点 EV +``` + +### 1.4 经验曲线 + +```typescript +// data/xpTable.ts +// 指数曲线: level N 需要 totalXP = floor(N^3 * 0.8) +// 等级 1→2: 1 XP, 5→6: ~100 XP, 16→17: ~3000 XP, 36→37: ~37000 XP +export function xpForLevel(level: number): number { + return Math.floor(Math.pow(level, 3) * 0.8) +} +``` + +--- + +## Phase 2: 数据源 + 核心逻辑 + +### 2.0 PokeAPI 数据源 + +**API**: https://pokeapi.co/ (免费,无需认证,速率限制:100 请求/分钟/IP) + +**关键端点**: + +| 端点 | 数据 | 示例 | +|------|------|------| +| `/api/v2/pokemon/{id}` | base stats, types, height, weight | `hp:45, atk:49, def:49, spa:65, spd:65, spe:45` | +| `/api/v2/pokemon-species/{id}` | gender_rate, base_happiness, growth_rate, evolution_chain URL, flavor_text | `gender_rate:1, growth_rate:"medium-slow"` | +| `/api/v2/evolution-chain/{id}` | 完整进化链 + 触发条件 | `bulbasaur → Lv16 → ivysaur → Lv32 → venusaur` | + +**gender_rate 含义**: -1=无性别, 0=全雄, 1=12.5%雌, 4=50%雌, 8=全雌。公式: `femaleChance = gender_rate / 8` + +**growth_rate 映射到 XP 曲线**: + +| growth_rate | 公式 | 100级总XP | +|-------------|------|-----------| +| erratic | 复杂分段 | 600,000 | +| fast | `n^3 * 0.8` | 800,000 | +| medium-fast | `n^3` | 1,000,000 | +| medium-slow | `1.2n^3 - 15n^2 + 100n - 140` | 1,059,860 | +| slow | `1.25n^3` | 1,250,000 | +| fluctuating | 复杂分段 | 1,640,000 | + +**MVP 10 只的 growth_rate**: +- Bulbasaur line: `medium-slow` (链 #1) +- Charmander line: `medium-slow` (链 #2) +- Squirtle line: `medium-slow` (链 #3) +- Pikachu: `medium-fast` (链 #10) + +**数据获取策略**: 构建时调用 PokeAPI 预拉取 10 只精灵数据,生成静态 `data/species.ts`,运行时无需网络请求。用脚本 `scripts/fetch-species.ts` 实现: + +```bash +# 构建时拉取,生成 species.ts +bun run packages/pokemon/scripts/fetch-species.ts +``` + +预拉取的数据项: +- `baseStats`: 6 项种族值(直接用于属性计算) +- `types`: 属性组合(grass/poison, fire, water 等) +- `genderRate`: 性别比例 +- `baseHappiness`: 基础亲密度(70) +- `growthRate`: 经验曲线类型 +- `evolutionChain`: 进化链 + 触发条件(level/friendship/item) +- `captureRate`: 捕获率(影响蛋的稀有度) +- `flavorText`: 图鉴描述文本 + +### 2.0.1 端点数据示例 + +**GET /api/v2/pokemon/1 (Bulbasaur)**: +```json +{ + "base_stats": {"hp":45, "attack":49, "defense":49, "special-attack":65, "special-defense":65, "speed":45}, + "types": ["grass", "poison"] +} +``` + +**GET /api/v2/pokemon-species/1**: +```json +{ + "gender_rate": 1, // 12.5% 雌性 + "base_happiness": 70, + "growth_rate": {"name": "medium-slow"}, + "capture_rate": 45, + "evolution_chain": {"url": "https://pokeapi.co/api/v2/evolution-chain/1/"} +} +``` + +**GET /api/v2/evolution-chain/1**: +```json +{ + "chain": { + "species": {"name": "bulbasaur"}, + "evolves_to": [{ + "species": {"name": "ivysaur"}, + "evolution_details": [{"min_level": 16, "trigger": {"name": "level-up"}}], + "evolves_to": [{ + "species": {"name": "venusaur"}, + "evolution_details": [{"min_level": 32, "trigger": {"name": "level-up"}}] + }] + }] + } +} +``` + +--- + +### 2.1 精灵生成 (`core/creature.ts`) + +- `generateCreature(speciesId, seed?)`: 创建新精灵 + - IV 随机生成 (0-31),用种子确定性 + - 性别按 speciesData.genderRatio 判定 + - 等级 1,XP 0,EV 全 0 + - 亲密度 70(基础值) +- `calculateStats(creature)`: 计算实际属性值 + - 公式参照宝可梦: `stat = floor((2*base + iv + floor(ev/4)) * level / 100) + 5` + - HP 特殊: `hp = floor((2*base + iv + floor(ev/4)) * level / 100) + level + 10` +- `getCreatureName(creature)`: 返回 nickname || species name + +### 2.2 经验系统 (`core/experience.ts`) + +- `awardXP(creature, amount)`: 增加经验,返回是否升级 +- `calculateLevel(xp)`: 根据 totalXP 计算当前等级 +- 来源: + - 对话轮次完成: +5 XP + - /buddy pet: +2 XP + - 工具使用(通过 EV 间接): +1 XP/tool + - 进化: +50 XP bonus + +### 2.3 努力值系统 (`core/effort.ts`) + +- `awardEV(creature, toolName, count)`: 根据工具名查映射表,加 EV +- `getEVForTool(toolName)`: 查映射表,未定义则随机 +- EV 上限: 每项 252,总计 510(跟宝可梦一致) +- 冷却: 每种工具类型 30 秒内只计算一次 EV + +### 2.4 进化系统 (`core/evolution.ts`) + +- `checkEvolution(creature)`: 检查是否满足进化条件 + - 等级进化: level >= condition.level + - 亲密度进化: friendship >= condition.minFriendship +- `evolve(creature)`: 执行进化 + - 物种 ID 变更为进化目标 + - 属性重新计算(base stats 变化) + - 返回进化动画数据(旧物种 → 新物种) + +MVP 进化链(参照原始 151): +``` +Bulbasaur(#1) → Lv16 → Ivysaur(#2) → Lv32 → Venusaur(#3) +Charmander(#4) → Lv16 → Charmeleon(#5) → Lv36 → Charizard(#6) +Squirtle(#7) → Lv16 → Wartortle(#8) → Lv36 → Blastoise(#9) +Pikachu(#10) — MVP 不进化(后续加 Raichu) +``` + +### 2.5 蛋系统 (`core/egg.ts`) + +- `checkEggEligibility(buddyData)`: 判断是否满足获蛋条件 + - consecutiveDays >= 7 && totalTurns % 50 === 0(每 50 轮检查一次) + - 持有蛋数 < 1(一次只能有一个蛋) +- `generateEgg(buddyData)`: 生成蛋 + - 物种从「未收集」列表随机选取(保底不重复) + - 步数 = 2000-5000(按稀有度) +- `advanceEggSteps(egg, steps)`: 推进步数 + - pet +5 步,对话轮次 +3 步,任意命令 +1 步 +- `tryHatch(egg)`: 检查步数是否归零,返回新 Creature + +### 2.6 数据持久化 (`core/storage.ts`) + +- `loadBuddyData()`: 从 `~/.claude/buddy-data.json` 读取 +- `saveBuddyData(data)`: 写入 +- `migrateFromLegacy()`: 迁移旧 buddy 数据 + - 现有 duck→Bulbasaur, cat→Charmander, turtle→Squirtle 等 + - 保留 nickname 和 personality + - 等级设为 5(奖励老用户) + +--- + +## Phase 3: ASCII Art 精灵 + +### 3.1 素材来源 + +**彩色像素精灵仓库**: https://github.com/HRKings/pokemonsay-newgenerations/tree/master/pokemons + +该仓库包含大量 Pokémon `.cow` 文件(Perl cowsay 格式),使用 256 色 ANSI 转义 + Unicode 半块字符(▄▀)渲染高分辨率彩色像素精灵。MVP 所需的 10 个文件: + +``` +001_bulbasaur.cow → 002_ivysaur.cow → 003_venusaur.cow +004_charmander.cow → 005_charmeleon.cow → 006_charizard.cow +007_squirtle.cow → 008_wartortle.cow → 009_blastoise.cow +025_pikachu.cow +``` + +**格式特征**: +- Perl heredoc: `$the_cow =< l.replace(/[·✦×◉@°]/g, '—')) + case 'excited': return shiftLines(lines, tick % 2 === 0 ? -1 : 1) + case 'pet': return [...PET_HEARTS[tick % 5], ...lines] + } +} +``` + +**IDLE_SEQUENCE**(复用现有设计): `[idle, idle, idle, idle, fidget, idle, idle, idle, blink, idle, idle, idle, idle]` + +### 3.2 渲染适配 + +复用现有 `renderSprite()` 的架构,但扩展支持 ANSI 彩色: +- **彩色模式**: 保留原始 ANSI 256 色序列,直接输出到终端(256 色兼容性 > 99%) +- **单色回退**: 剥离 ANSI 序列,用 Ink `` 代替(兼容 16 色终端) +- 眼睛替换:保留现有 `{E}` 占位符机制 +- 帽子 slot:第 0 行保留空白(可选装饰) +- 3 帧动画循环:500ms tick(与现有一致) + +### 3.3 进化动画帧 + +进化时使用闪烁变形效果: +- 帧 1-3: 旧形态 + 闪烁(间隔显示空白) +- 帧 4-6: 新旧形态交替 +- 帧 7-8: 新形态 + ✨ 粒子 +- 总时长 ~4 秒(8 帧 × 500ms) + +--- + +## Phase 4: UI 组件 + +### 4.1 CompanionCard 重设计 + +``` +┌──────────────────────────────────┐ +│ ★ CHARIZARD #6 Lv.36 │ +│ ✨ SHINY ✨ │ +│ │ +│ ASCII art here │ +│ │ +│ "Blaze" (nicknamed Ember) │ +│ Type: Fire/Flying Gender: ♂ │ +│ │ +│ HP ████████░░ 85 │ +│ ATK ██████░░░░ 62 │ +│ DEF █████░░░░░ 55 │ +│ SPA ███████░░░ 78 │ +│ SPD ████░░░░░░ 48 │ +│ SPE ██████░░░░ 65 │ +│ │ +│ XP [████████░░░░░░░] 14200/15680 │ +│ EV: ATK+42 SPA+28 SPE+18 │ +│ Friendship: ████████░░ 180/255 │ +│ │ +│ ── Commands ── │ +│ /buddy pet Pet for hearts │ +│ /buddy dex View Pokédex │ +│ /buddy egg Check egg progress │ +│ /buddy switch Change buddy │ +└──────────────────────────────────┘ +``` + +### 4.2 PokédexView (`/buddy dex`) + +``` +┌───── Pokédex ────────────────────┐ +│ Collected: 4/10 │ +│ │ +│ #001 Bulbasaur ████████ Lv.12 │ +│ #002 Ivysaur ████████ Lv.24 │ +│ #003 Venusaur ────── ??? │ +│ #004 Charmander ████████ Lv.8 │ +│ #005 Charmeleon ────── ??? │ +│ #006 Charizard ────── ??? │ +│ #007 Squirtle ████████ Lv.16 │ +│ #008 Wartortle ────── ??? │ +│ #009 Blastoise ────── ??? │ +│ #010 Pikachu ████████ Lv.5 │ +│ │ +│ 🥚 Egg: 1240/3000 steps │ +│ Next egg in: 3 days + 12 turns │ +└──────────────────────────────────┘ +``` + +### 4.3 EggView (`/buddy egg`) + +``` +┌───── Egg Status ─────────────────┐ +│ │ +│ . │ +│ / \ │ +│ | | │ +│ \_/ │ +│ │ +│ Steps: 1240 / 3000 │ +│ ████████░░░░░░░░ 41% │ +│ │ +│ Pet (+5) · Chat (+3) · Cmd (+1) │ +│ Hatch: ~588 more interactions │ +└──────────────────────────────────┘ +``` + +### 4.4 进化动画 (`EvolutionAnim.tsx`) + +在 REPL 面板右侧区域显示: +- 设置 `AppState.companionEvolving = true` +- 500ms tick 循环: + - tick 0-3: 旧精灵 + 闪烁(每隔一帧显示空白) + - tick 4-7: 新旧交替 + ✨ 粒子效果 + - tick 8: 新形态稳定显示 + "进化成功!" 文字 +- 用户按任意键结束动画 +- 更新 buddy-data.json 中的物种数据 + +--- + +## Phase 5: 集成点 + +### 5.1 REPL.tsx 钩子(关键修改文件) + +在 `src/screens/REPL.tsx` 约 3407 行(turn metrics 收集后): + +```typescript +// 现有代码 +const toolMs = getTurnToolDurationMs() +const toolCount = getTurnToolCount() + +// 新增: EV + XP 奖励 +if (feature('BUDDY')) { + const buddyData = loadBuddyData() + if (buddyData.activeCreatureId) { + // 1. 遍历本 turn 的工具调用,计算 EV + const evResult = awardTurnEV(buddyData, messages) + // 2. 奖励对话 XP + const xpResult = awardXP(buddyData, 5 + toolCount) + // 3. 推进蛋步数 + advanceEggSteps(buddyData, 3) + // 4. 检查进化 + const evoResult = checkEvolution(getActiveCreature(buddyData)) + if (evoResult) { + setAppState(prev => ({ ...prev, companionEvolving: evoResult })) + } + saveBuddyData(buddyData) + } +} +``` + +### 5.2 /buddy 命令重构 + +修改 `src/commands/buddy/buddy.ts`,子命令: + +| 命令 | 说明 | +|------|------| +| `/buddy` | 显示 CompanionCard(新版) | +| `/buddy status` | 详细属性 + EV 分布 | +| `/buddy pet` | 摸摸 (+5 蛋步数, +2 XP, 心形动画) | +| `/buddy dex` | 显示 PokédexView | +| `/buddy switch` | 列出已拥有精灵,选择首发 | +| `/buddy egg` | 显示 EggView | +| `/buddy rename ` | 重命名当前精灵 | +| `/buddy on/off` | 静音/取消静音 | + +### 5.3 AppState 扩展 + +`src/state/AppStateStore.ts` 新增: +```typescript +companionEvolving?: { from: SpeciesId; to: SpeciesId } // 进化动画状态 +companionEggSteps?: number // 蛋步数更新(触发 UI 刷新) +``` + +### 5.4 buddy-data.json 持久化 + +路径: `~/.claude/buddy-data.json` + +```typescript +// core/storage.ts +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' + +const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json') + +export function loadBuddyData(): BuddyData { + if (!existsSync(BUDDY_DATA_PATH)) return getDefaultBuddyData() + return JSON.parse(readFileSync(BUDDY_DATA_PATH, 'utf-8')) +} + +export function saveBuddyData(data: BuddyData): void { + writeFileSync(BUDDY_DATA_PATH, JSON.stringify(data, null, 2)) +} +``` + +--- + +## Phase 6: 迁移方案 + +### 6.1 物种映射表 + +| 旧物种 | 新物种 | 原因 | +|--------|--------|------| +| duck | Bulbasaur | 同为初始伙伴 | +| cat | Charmander | 独立/判断力 → 火 | +| turtle | Squirtle | 耐心/稳重 → 水 | +| dragon | Pikachu | 稀有度最高 → 吉祥物 | +| 其他 14 种 | 随机御三家之一 | 按稀有度映射 | + +### 6.2 迁移逻辑 + +```typescript +function migrateFromLegacy(storedCompanion: StoredCompanion): BuddyData { + const speciesMap = { duck: 'bulbasaur', cat: 'charmander', turtle: 'squirtle', dragon: 'pikachu', ... } + const speciesId = speciesMap[storedCompanion.species] ?? randomStarter() + const creature = generateCreature(speciesId) + creature.level = 5 // 奖励老用户 + creature.nickname = storedCompanion.name !== defaultName ? storedCompanion.name : undefined + creature.friendship = 120 // 已有伙伴基础亲密度 + return { + version: 1, + activeCreatureId: creature.id, + creatures: [creature], + eggs: [], + dex: [{ speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: 5 }], + stats: { totalTurns: 0, consecutiveDays: 0, lastActiveDate: new Date().toISOString(), totalEggsObtained: 0, totalEvolutions: 0 } + } +} +``` + +--- + +## Phase 7: 实施顺序 + +### Step 1: 包骨架 + 类型 +- 创建 `packages/pokemon/` 目录结构 +- 定义所有 TypeScript 类型 +- 配置 package.json、tsconfig.json +- 在根 `package.json` 添加 workspace 引用 + +### Step 2: 数据文件(PokeAPI 预拉取) +- 编写 `scripts/fetch-species.ts` — 调用 PokeAPI 拉取 10 只精灵数据 +- 运行脚本生成 `data/species.ts`(base stats, types, gender_rate, growth_rate, evolution_chain, capture_rate, flavor_text) +- 手动编写 EV 映射 (`data/evMapping.ts`) +- 编写 XP 经验表 (`data/xpTable.ts`),支持 6 种 growth_rate 曲线 + +### Step 3: ASCII Art 精灵(获取时拉取,永久缓存) +- 编写 `core/spriteCache.ts` — 获取精灵时从 GitHub 拉取 .cow → 解析 → 缓存到 `~/.claude/buddy-sprites/` +- `loadSprite(speciesId)` 纯读本地缓存,无网络调用 +- `fetchAndCacheSprite(speciesId)` 仅在获得新精灵/进化时触发 +- 编写 `sprites/fallback.ts` — 网络不可用时的简易占位 ASCII +- 动画由 `renderAnimatedSprite()` 运行时变换(抖动/眨眼/心形粒子),每物种只缓存 1 帧 + +### Step 4: 核心逻辑 +- `core/creature.ts` — 精灵生成、属性计算 +- `core/experience.ts` — XP/等级系统 +- `core/effort.ts` — EV 系统 +- `core/evolution.ts` — 进化检测与执行 +- `core/egg.ts` — 蛋系统 +- `core/storage.ts` — 数据持久化 +- `core/gender.ts` — 性别判定 + +### Step 5: UI 组件 +- 重写 `CompanionCard.tsx`(6 属性 + 等级 + XP) +- 新建 `PokedexView.tsx` +- 新建 `EggView.tsx` +- 新建 `EvolutionAnim.tsx` + +### Step 6: 集成 +- 修改 `src/commands/buddy/buddy.ts` — 新子命令 +- 修改 `src/screens/REPL.tsx` — EV/XP 钩子 +- 修改 `src/state/AppStateStore.ts` — 新状态字段 +- 修改 `src/buddy/CompanionSprite.tsx` — 使用新精灵系统 +- 迁移逻辑在首次加载时自动执行 + +### Step 7: 测试 +- `packages/pokemon/src/__tests__/` 单元测试 +- 覆盖: 属性计算、XP 曲线、EV 映射、进化条件、蛋系统、迁移 + +--- + +## 关键文件清单 + +### 新建文件 +- `packages/pokemon/` — 整个包(~20 个文件,不含精灵图) +- `~/.claude/buddy-data.json` — 运行时自动创建 +- `~/.claude/buddy-sprites/` — 运行时从 GitHub 拉取并缓存的精灵 JSON(每个物种 1 个文件) + +### 修改文件 +- `src/commands/buddy/buddy.ts` — 新子命令路由 +- `src/commands/buddy/index.ts` — 命令注册 +- `src/screens/REPL.tsx` — EV/XP 钩子(~20 行新增) +- `src/state/AppStateStore.ts` — 新状态字段(~3 行) +- `src/buddy/CompanionSprite.tsx` — 使用 packages/pokemon 的精灵渲染 +- `src/buddy/CompanionCard.tsx` — 可能直接替换为 packages/pokemon 的版本 + +### 不修改文件 +- `src/utils/config.ts` — 旧 companion 字段保留,向后兼容 +- `src/buddy/companionReact.ts` — API 调用层不变,只更新传入的数据结构 +- `src/buddy/prompt.ts` — 伙伴 intro 逻辑微调 + +--- + +## 验证方案 + +1. **类型检查**: `bun run typecheck` 零错误 +2. **单元测试**: `bun test packages/pokemon/` 覆盖核心逻辑 +3. **全量测试**: `bun test` 确保 0 失败 +4. **手动验证**: + - `bun run dev` 启动 → `/buddy` 显示新卡片 + - `/buddy dex` 显示图鉴(初始只有 1 只) + - `/buddy pet` 心形动画 + XP 增长 + - 模拟工具使用 → EV 增长 + - `/buddy egg` 显示蛋进度 + - 等级达到 16 → 进化动画触发 + - 旧用户 `~/.claude.json` 有 companion → 自动迁移 diff --git a/packages/pokemon/package.json b/packages/pokemon/package.json new file mode 100644 index 000000000..fb91826fe --- /dev/null +++ b/packages/pokemon/package.json @@ -0,0 +1,9 @@ +{ + "name": "@claude-code-best/pokemon", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": {} +} diff --git a/packages/pokemon/scripts/fetch-sprites.ts b/packages/pokemon/scripts/fetch-sprites.ts new file mode 100644 index 000000000..340f01adf --- /dev/null +++ b/packages/pokemon/scripts/fetch-sprites.ts @@ -0,0 +1,124 @@ +/** + * Script to pre-fetch all 10 MVP Pokémon sprites from GitHub. + * Run: bun run packages/pokemon/scripts/fetch-sprites.ts + */ +import { writeFileSync, mkdirSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' + +const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons' + +const COW_FILES: Record = { + bulbasaur: '001_bulbasaur', + ivysaur: '002_ivysaur', + venusaur: '003_venusaur', + charmander: '004_charmander', + charmeleon: '005_charmeleon', + charizard: '006_charizard', + squirtle: '007_squirtle', + wartortle: '008_wartortle', + blastoise: '009_blastoise', + pikachu: '025_pikachu', +} + +const SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites') + +function convertCowToLines(cowContent: string): string[] { + const startMarker = '$the_cow =< + String.fromCodePoint(parseInt(hex, 16)), + ) + + // Convert \e to actual escape character (for ANSI sequences) + content = content.replace(/\\e/g, '\x1b') + + // Split into lines + let lines = content.split('\n') + + // Strip leading/trailing empty lines + while (lines.length > 0 && lines[0].trim() === '') lines.shift() + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop() + + // Remove first 4 lines (cowsay thought bubble guide - $thoughts lines) + if (lines.length > 4) { + lines = lines.slice(4) + } + + // Trim trailing whitespace on each line + lines = lines.map((line) => line.trimEnd()) + + return lines +} + +function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, '') +} + +async function main() { + // Ensure output directory + if (!existsSync(SPRITES_DIR)) { + mkdirSync(SPRITES_DIR, { recursive: true }) + } + + for (const [speciesId, cowPrefix] of Object.entries(COW_FILES)) { + const url = `${GITHUB_RAW_BASE}/${cowPrefix}.cow` + console.log(`Fetching ${speciesId} from ${url}...`) + + try { + const response = await fetch(url) + if (!response.ok) { + console.error(` FAILED: HTTP ${response.status}`) + continue + } + + const cowContent = await response.text() + const lines = convertCowToLines(cowContent) + + if (lines.length === 0) { + console.error(` FAILED: No lines after conversion`) + continue + } + + // Calculate visible width (strip ANSI for measurement) + const widths = lines.map((l) => stripAnsi(l).length) + + const sprite = { + speciesId, + lines, + width: Math.max(...widths), + height: lines.length, + fetchedAt: Date.now(), + } + + const outPath = join(SPRITES_DIR, `${speciesId}.json`) + writeFileSync(outPath, JSON.stringify(sprite, null, 2)) + + console.log(` OK: ${lines.length} lines, ${sprite.width} cols wide`) + + // Also print first line for visual check + console.log(` Preview line 1: ${stripAnsi(lines[0]!)}`) + } catch (err) { + console.error(` FAILED: ${err}`) + } + + // Small delay to be nice to GitHub + await new Promise((r) => setTimeout(r, 200)) + } + + console.log('\nDone! Sprites cached to ~/.claude/buddy-sprites/') +} + +main().catch(console.error) diff --git a/packages/pokemon/src/__tests__/creature.test.ts b/packages/pokemon/src/__tests__/creature.test.ts new file mode 100644 index 000000000..0a82cee8b --- /dev/null +++ b/packages/pokemon/src/__tests__/creature.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect } from 'bun:test' +import type { SpeciesId, Creature } from '../types' +import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel } from '../core/creature' +import { SPECIES_DATA } from '../data/species' + +describe('generateCreature', () => { + test('creates a creature with correct defaults', () => { + const c = generateCreature('bulbasaur', 42) + expect(c.speciesId).toBe('bulbasaur') + expect(c.level).toBe(1) + expect(c.xp).toBe(0) + expect(c.totalXp).toBe(0) + expect(c.friendship).toBe(SPECIES_DATA.bulbasaur.baseHappiness) + expect(c.isShiny).toBeDefined() + expect(c.id).toBeTruthy() + expect(Object.values(c.iv).every((v) => v >= 0 && v <= 31)).toBe(true) + expect(Object.values(c.ev).every((v) => v === 0)).toBe(true) + }) + + test('deterministic IV generation from seed', () => { + const c1 = generateCreature('charmander', 12345) + const c2 = generateCreature('charmander', 12345) + expect(c1.iv).toEqual(c2.iv) + }) + + test('different seeds produce different IVs', () => { + const c1 = generateCreature('squirtle', 100) + const c2 = generateCreature('squirtle', 200) + expect(c1.iv).not.toEqual(c2.iv) + }) + + test('all MVP species can be generated', () => { + const species: SpeciesId[] = [ + 'bulbasaur', 'ivysaur', 'venusaur', + 'charmander', 'charmeleon', 'charizard', + 'squirtle', 'wartortle', 'blastoise', + 'pikachu', + ] + for (const s of species) { + const c = generateCreature(s) + expect(c.speciesId).toBe(s) + } + }) +}) + +describe('calculateStats', () => { + test('level 1 stats are reasonable', () => { + const c = generateCreature('bulbasaur', 0) + const stats = calculateStats(c) + // HP at lv1: floor((2*45 + iv + floor(0/4)) * 1/100) + 1 + 10 + // With any IV: floor((90 + iv) / 100) + 11 = 0 + 11 = 11 + expect(stats.hp).toBeGreaterThanOrEqual(11) + expect(stats.hp).toBeLessThanOrEqual(12) + // Attack: floor((2*49 + iv) * 1/100) + 5 = 0 + 5 = 5 + expect(stats.attack).toBeGreaterThanOrEqual(5) + expect(stats.attack).toBeLessThanOrEqual(6) + }) + + test('stats increase with level', () => { + const c1 = generateCreature('charmander', 0) + c1.level = 1 + const stats1 = calculateStats(c1) + + const c50 = { ...c1, level: 50 } + const stats50 = calculateStats(c50) + // All stats should be higher at level 50 + expect(stats50.hp).toBeGreaterThan(stats1.hp) + expect(stats50.attack).toBeGreaterThan(stats1.attack) + }) + + test('EVs affect stats', () => { + const c = generateCreature('pikachu', 0) + const statsNoEV = calculateStats(c) + + const cWithEV = { ...c, ev: { ...c.ev, attack: 252 } } + const statsWithEV = calculateStats(cWithEV) + + expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack) + }) +}) + +describe('getCreatureName', () => { + test('returns species name when no nickname', () => { + const c = generateCreature('pikachu') + c.nickname = undefined + expect(getCreatureName(c)).toBe('Pikachu') + }) + + test('returns nickname when set', () => { + const c = generateCreature('pikachu') + c.nickname = 'Sparky' + expect(getCreatureName(c)).toBe('Sparky') + }) +}) + +describe('getTotalEV', () => { + test('returns 0 for new creature', () => { + const c = generateCreature('bulbasaur') + expect(getTotalEV(c)).toBe(0) + }) + + test('sums all EV values', () => { + const c = generateCreature('bulbasaur') + c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 } + expect(getTotalEV(c)).toBe(210) + }) +}) diff --git a/packages/pokemon/src/__tests__/effort.test.ts b/packages/pokemon/src/__tests__/effort.test.ts new file mode 100644 index 000000000..1860ab870 --- /dev/null +++ b/packages/pokemon/src/__tests__/effort.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect, beforeEach } from 'bun:test' +import { generateCreature } from '../core/creature' +import { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from '../core/effort' +import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping' + +beforeEach(() => { + resetEVCooldowns() +}) + +describe('awardEV', () => { + test('mapped tool awards correct EV', () => { + let c = generateCreature('bulbasaur') + // Clear cooldown by using old timestamp + c = awardEV(c, 'Bash', 0) + expect(c.ev.attack).toBeGreaterThan(0) + expect(c.ev.speed).toBeGreaterThan(0) + }) + + test('unmapped tool awards random EV', () => { + let c = generateCreature('bulbasaur') + c = awardEV(c, 'UnknownTool', 0) + const totalEV = Object.values(c.ev).reduce((a, b) => a + b, 0) + expect(totalEV).toBeGreaterThan(0) + }) + + test('cooldown prevents repeated awards', () => { + const now = Date.now() + let c = generateCreature('bulbasaur') + c = awardEV(c, 'Bash', now) + const ev1 = { ...c.ev } + c = awardEV(c, 'Bash', now + 1000) // Within 30s cooldown + expect(c.ev).toEqual(ev1) // No change + }) + + test('respects per-stat EV cap', () => { + let c = generateCreature('bulbasaur') + // Bash gives attack:2 + speed:1 + for (let i = 0; i < 200; i++) { + c = awardEV(c, 'Bash', i * 60000) // Each call 60s apart (past cooldown) + } + expect(c.ev.attack).toBeLessThanOrEqual(MAX_EV_PER_STAT) + }) + + test('respects total EV cap', () => { + let c = generateCreature('bulbasaur') + const tools = ['Bash', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'Agent', 'WebSearch', 'WebFetch'] + for (let i = 0; i < 200; i++) { + for (const tool of tools) { + c = awardEV(c, tool, (i * tools.length + tools.indexOf(tool)) * 60000) + } + } + const total = Object.values(c.ev).reduce((a, b) => a + b, 0) + expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL) + }) +}) + +describe('awardTurnEV', () => { + test('awards EV for multiple tools', () => { + let c = generateCreature('bulbasaur') + c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0) + const totalEV = Object.values(c.ev).reduce((a, b) => a + b, 0) + expect(totalEV).toBeGreaterThan(0) + }) +}) + +describe('getEVSummary', () => { + test('returns "None" for new creature', () => { + const c = generateCreature('bulbasaur') + expect(getEVSummary(c)).toBe('None') + }) + + test('shows stat breakdown', () => { + const c = generateCreature('bulbasaur') + c.ev = { hp: 0, attack: 5, defense: 0, spAtk: 3, spDef: 0, speed: 0 } + const summary = getEVSummary(c) + expect(summary).toContain('ATK+5') + expect(summary).toContain('SPA+3') + }) +}) diff --git a/packages/pokemon/src/__tests__/egg.test.ts b/packages/pokemon/src/__tests__/egg.test.ts new file mode 100644 index 000000000..84fca74bd --- /dev/null +++ b/packages/pokemon/src/__tests__/egg.test.ts @@ -0,0 +1,87 @@ +import { describe, test, expect } from 'bun:test' +import { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch } from '../core/egg' +import type { BuddyData } from '../types' +import { generateCreature } from '../core/creature' + +function makeBuddyData(overrides: Partial = {}): BuddyData { + return { + version: 1, + activeCreatureId: 'test', + creatures: [generateCreature('bulbasaur')], + eggs: [], + dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }], + stats: { + totalTurns: 50, + consecutiveDays: 7, + lastActiveDate: new Date().toISOString().split('T')[0], + totalEggsObtained: 0, + totalEvolutions: 0, + ...overrides, + }, + } +} + +describe('checkEggEligibility', () => { + test('eligible when conditions met', () => { + const data = makeBuddyData() + expect(checkEggEligibility(data)).toBe(true) + }) + + test('not eligible with existing egg', () => { + const data = makeBuddyData() + data.eggs = [{ id: 'test', obtainedAt: Date.now(), stepsRemaining: 1000, totalSteps: 3000, speciesId: 'pikachu' }] + expect(checkEggEligibility(data)).toBe(false) + }) + + test('not eligible with low consecutive days', () => { + const data = makeBuddyData({ consecutiveDays: 3 }) + expect(checkEggEligibility(data)).toBe(false) + }) + + test('not eligible when turns not multiple of 50', () => { + const data = makeBuddyData({ totalTurns: 51 }) + expect(checkEggEligibility(data)).toBe(false) + }) +}) + +describe('generateEgg', () => { + test('prefers uncollected species', () => { + const data = makeBuddyData() + // Already have bulbasaur, so egg should prefer others + const egg = generateEgg(data) + expect(egg.speciesId).not.toBe('bulbasaur') + }) + + test('egg has valid steps', () => { + const data = makeBuddyData() + const egg = generateEgg(data) + expect(egg.stepsRemaining).toBeGreaterThan(0) + expect(egg.totalSteps).toBe(egg.stepsRemaining) + }) +}) + +describe('advanceEggSteps', () => { + test('reduces steps remaining', () => { + const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 100, totalSteps: 200, speciesId: 'pikachu' as const } + const advanced = advanceEggSteps(egg, 30) + expect(advanced.stepsRemaining).toBe(70) + }) + + test('steps do not go below 0', () => { + const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 10, totalSteps: 200, speciesId: 'pikachu' as const } + const advanced = advanceEggSteps(egg, 50) + expect(advanced.stepsRemaining).toBe(0) + }) +}) + +describe('isEggReadyToHatch', () => { + test('ready when steps = 0', () => { + const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 200, speciesId: 'pikachu' as const } + expect(isEggReadyToHatch(egg)).toBe(true) + }) + + test('not ready when steps > 0', () => { + const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 1, totalSteps: 200, speciesId: 'pikachu' as const } + expect(isEggReadyToHatch(egg)).toBe(false) + }) +}) diff --git a/packages/pokemon/src/__tests__/evolution.test.ts b/packages/pokemon/src/__tests__/evolution.test.ts new file mode 100644 index 000000000..6e7e61556 --- /dev/null +++ b/packages/pokemon/src/__tests__/evolution.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect } from 'bun:test' +import { checkEvolution, evolve, canEvolveFurther } from '../core/evolution' + +describe('checkEvolution', () => { + test('bulbasaur at level 15 cannot evolve', () => { + const creature = { speciesId: 'bulbasaur' as const, level: 15, friendship: 70 } as any + expect(checkEvolution(creature)).toBeNull() + }) + + test('bulbasaur at level 16 can evolve into ivysaur', () => { + const creature = { speciesId: 'bulbasaur' as const, level: 16, friendship: 70 } as any + const result = checkEvolution(creature) + expect(result).not.toBeNull() + expect(result!.from).toBe('bulbasaur') + expect(result!.to).toBe('ivysaur') + }) + + test('charmander at level 16 evolves into charmeleon', () => { + const creature = { speciesId: 'charmander' as const, level: 16, friendship: 70 } as any + const result = checkEvolution(creature) + expect(result!.to).toBe('charmeleon') + }) + + test('charmeleon at level 36 evolves into charizard', () => { + const creature = { speciesId: 'charmeleon' as const, level: 36, friendship: 70 } as any + const result = checkEvolution(creature) + expect(result!.to).toBe('charizard') + }) + + test('squirtle at level 16 evolves into wartortle', () => { + const creature = { speciesId: 'squirtle' as const, level: 16, friendship: 70 } as any + const result = checkEvolution(creature) + expect(result!.to).toBe('wartortle') + }) + + test('wartortle at level 36 evolves into blastoise', () => { + const creature = { speciesId: 'wartortle' as const, level: 36, friendship: 70 } as any + const result = checkEvolution(creature) + expect(result!.to).toBe('blastoise') + }) + + test('venusaur cannot evolve further', () => { + const creature = { speciesId: 'venusaur' as const, level: 50, friendship: 70 } as any + expect(checkEvolution(creature)).toBeNull() + }) + + test('pikachu cannot evolve in MVP', () => { + const creature = { speciesId: 'pikachu' as const, level: 50, friendship: 70 } as any + expect(checkEvolution(creature)).toBeNull() + }) + + test('level 100 bulbasaur can still evolve (level >= minLevel)', () => { + const creature = { speciesId: 'bulbasaur' as const, level: 100, friendship: 70 } as any + const result = checkEvolution(creature) + expect(result).not.toBeNull() + expect(result!.to).toBe('ivysaur') + }) +}) + +describe('evolve', () => { + test('changes species and boosts friendship', () => { + const creature = { speciesId: 'bulbasaur' as const, friendship: 70, level: 16 } as any + const evolved = evolve(creature, 'ivysaur') + expect(evolved.speciesId).toBe('ivysaur') + expect(evolved.friendship).toBe(80) // +10 friendship on evolution + }) +}) + +describe('canEvolveFurther', () => { + test('starter species can evolve', () => { + expect(canEvolveFurther('bulbasaur')).toBe(true) + expect(canEvolveFurther('charmander')).toBe(true) + expect(canEvolveFurther('squirtle')).toBe(true) + }) + + test('middle evolution can evolve', () => { + expect(canEvolveFurther('ivysaur')).toBe(true) + expect(canEvolveFurther('charmeleon')).toBe(true) + expect(canEvolveFurther('wartortle')).toBe(true) + }) + + test('final evolution cannot evolve', () => { + expect(canEvolveFurther('venusaur')).toBe(false) + expect(canEvolveFurther('charizard')).toBe(false) + expect(canEvolveFurther('blastoise')).toBe(false) + }) + + test('pikachu cannot evolve in MVP', () => { + expect(canEvolveFurther('pikachu')).toBe(false) + }) +}) diff --git a/packages/pokemon/src/__tests__/experience.test.ts b/packages/pokemon/src/__tests__/experience.test.ts new file mode 100644 index 000000000..be31a7d3f --- /dev/null +++ b/packages/pokemon/src/__tests__/experience.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect } from 'bun:test' +import { generateCreature } from '../core/creature' +import { awardXP, getXpProgress } from '../core/experience' +import { xpForLevel, levelFromXp } from '../data/xpTable' + +describe('xpForLevel', () => { + test('level 1 requires 0 XP', () => { + expect(xpForLevel(1, 'medium-slow')).toBe(0) + }) + + test('medium-fast: level N requires N^3 XP', () => { + expect(xpForLevel(10, 'medium-fast')).toBe(1000) + expect(xpForLevel(100, 'medium-fast')).toBe(1000000) + }) + + test('fast: level N requires floor(N^3 * 4/5)', () => { + expect(xpForLevel(10, 'fast')).toBe(Math.floor(1000 * 4 / 5)) // 800 + }) + + test('slow: level N requires floor(N^3 * 5/4)', () => { + expect(xpForLevel(10, 'slow')).toBe(Math.floor(1000 * 5 / 4)) + }) + + test('higher levels require more XP', () => { + for (let i = 2; i < 99; i++) { + expect(xpForLevel(i + 1, 'medium-slow')).toBeGreaterThan(xpForLevel(i, 'medium-slow')) + } + }) +}) + +describe('levelFromXp', () => { + test('0 XP = level 1', () => { + expect(levelFromXp(0, 'medium-fast')).toBe(1) + }) + + test('roundtrip: level → XP → level', () => { + for (const growth of ['slow', 'medium-slow', 'medium-fast', 'fast'] as const) { + for (const level of [1, 5, 10, 25, 50, 75, 100]) { + const xp = xpForLevel(level, growth) + expect(levelFromXp(xp, growth)).toBe(level) + } + } + }) + + test('XP slightly below threshold stays at lower level', () => { + const xp20 = xpForLevel(20, 'medium-fast') + expect(levelFromXp(xp20 - 1, 'medium-fast')).toBe(19) + }) +}) + +describe('awardXP', () => { + test('awards XP and returns updated creature', () => { + const c = generateCreature('bulbasaur') + const result = awardXP(c, 10) + expect(result.creature.totalXp).toBe(10) + expect(result.leveledUp).toBeDefined() + }) + + test('large XP can cause level up', () => { + const c = generateCreature('bulbasaur') + // Award enough XP for several levels + const result = awardXP(c, 10000) + expect(result.creature.level).toBeGreaterThan(1) + expect(result.leveledUp).toBe(true) + }) + + test('level capped at 100', () => { + const c = generateCreature('bulbasaur') + c.level = 100 + c.totalXp = 1000000 + const result = awardXP(c, 999999) + expect(result.creature.level).toBe(100) + expect(result.leveledUp).toBe(false) + }) +}) + +describe('getXpProgress', () => { + test('new creature has 0 XP progress', () => { + const c = generateCreature('bulbasaur') + const progress = getXpProgress(c) + expect(progress.current).toBe(0) + expect(progress.percentage).toBe(0) + }) +}) diff --git a/packages/pokemon/src/__tests__/gender.test.ts b/packages/pokemon/src/__tests__/gender.test.ts new file mode 100644 index 000000000..05d3a1206 --- /dev/null +++ b/packages/pokemon/src/__tests__/gender.test.ts @@ -0,0 +1,51 @@ +import { describe, test, expect } from 'bun:test' +import { determineGender, getGenderSymbol } from '../core/gender' +import { SPECIES_DATA } from '../data/species' + +describe('determineGender', () => { + test('genderless species', () => { + // Pikachu has genderRate 4 (50% female) + // Venusaur has genderRate 1 (12.5% female) + // For testing genderless, we'd need a species with genderRate -1 + // None in MVP are genderless, so test the basic logic + const pikachu = SPECIES_DATA.pikachu + expect(pikachu.genderRate).toBe(4) + }) + + test('pikachu 50% female ratio', () => { + const pikachu = SPECIES_DATA.pikachu + let males = 0 + let females = 0 + for (let seed = 0; seed < 1000; seed++) { + const g = determineGender(pikachu, seed) + if (g === 'male') males++ + else females++ + } + // Should be roughly 50/50 with some tolerance + expect(females).toBeGreaterThan(300) + expect(males).toBeGreaterThan(300) + }) + + test('starters are ~12.5% female', () => { + const bulbasaur = SPECIES_DATA.bulbasaur + let females = 0 + for (let seed = 0; seed < 1000; seed++) { + if (determineGender(bulbasaur, seed) === 'female') females++ + } + // ~12.5% female = ~125 out of 1000 + expect(females).toBeGreaterThan(50) + expect(females).toBeLessThan(250) + }) +}) + +describe('getGenderSymbol', () => { + test('male symbol', () => { + expect(getGenderSymbol('male')).toBe('♂') + }) + test('female symbol', () => { + expect(getGenderSymbol('female')).toBe('♀') + }) + test('genderless has no symbol', () => { + expect(getGenderSymbol('genderless')).toBe('') + }) +}) diff --git a/packages/pokemon/src/core/creature.ts b/packages/pokemon/src/core/creature.ts new file mode 100644 index 000000000..eeec4d250 --- /dev/null +++ b/packages/pokemon/src/core/creature.ts @@ -0,0 +1,117 @@ +import { randomUUID } from 'node:crypto' +import type { Creature, SpeciesId, StatName, StatsResult } from '../types' +import { STAT_NAMES } from '../types' +import { SPECIES_DATA } from '../data/species' +import { determineGender } from './gender' +import { levelFromXp } from '../data/xpTable' + +/** + * Generate a new creature of the given species. + */ +export function generateCreature(speciesId: SpeciesId, seed?: number): Creature { + const species = SPECIES_DATA[speciesId] + const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff) + + // Generate IVs (0-31) using simple hash from seed + const iv = generateIVs(actualSeed) + + // Determine gender + const gender = determineGender(species, actualSeed & 0xff) + + // Determine shiny status + const isShiny = Math.random() < species.shinyChance + + return { + id: randomUUID(), + speciesId, + gender, + level: 1, + xp: 0, + totalXp: 0, + ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }, + iv, + friendship: species.baseHappiness, + isShiny, + hatchedAt: Date.now(), + } +} + +/** + * Calculate actual stats for a creature using Pokémon stat formulas. + * HP: floor((2 * base + iv + floor(ev/4)) * level / 100) + level + 10 + * Other: floor((2 * base + iv + floor(ev/4)) * level / 100) + 5 + */ +export function calculateStats(creature: Creature): StatsResult { + const species = SPECIES_DATA[creature.speciesId] + const level = creature.level + const result: StatsResult = {} as StatsResult + + for (const stat of STAT_NAMES) { + const base = species.baseStats[stat] + const iv = creature.iv[stat] + const ev = creature.ev[stat] + const raw = Math.floor((2 * base + iv + Math.floor(ev / 4)) * level / 100) + + if (stat === 'hp') { + result[stat] = raw + level + 10 + } else { + result[stat] = raw + 5 + } + } + + return result +} + +/** + * Get display name for a creature (nickname or species name). + */ +export function getCreatureName(creature: Creature): string { + if (creature.nickname) return creature.nickname + return SPECIES_DATA[creature.speciesId].name +} + +/** + * Recalculate level from total XP (e.g. after XP gain). + */ +export function recalculateLevel(creature: Creature): Creature { + const species = SPECIES_DATA[creature.speciesId] + const newLevel = levelFromXp(creature.totalXp, species.growthRate) + if (newLevel !== creature.level) { + return { ...creature, level: newLevel } + } + return creature +} + +/** + * Get the active creature from buddy data. + */ +export function getActiveCreature(buddyData: { activeCreatureId: string | null; creatures: Creature[] }): Creature | null { + if (!buddyData.activeCreatureId) return null + return buddyData.creatures.find((c) => c.id === buddyData.activeCreatureId) ?? null +} + +/** + * Generate IVs from a seed value. Each stat gets 0-31. + */ +function generateIVs(seed: number): Record { + let s = seed + const nextRand = () => { + s = (s * 1103515245 + 12345) & 0x7fffffff + return s + } + return { + hp: nextRand() % 32, + attack: nextRand() % 32, + defense: nextRand() % 32, + spAtk: nextRand() % 32, + spDef: nextRand() % 32, + speed: nextRand() % 32, + } +} + +/** + * Get total EV across all stats. + */ +export function getTotalEV(creature: Creature): number { + return STAT_NAMES.reduce((sum, stat) => sum + creature.ev[stat], 0) +} diff --git a/packages/pokemon/src/core/effort.ts b/packages/pokemon/src/core/effort.ts new file mode 100644 index 000000000..7d166bcec --- /dev/null +++ b/packages/pokemon/src/core/effort.ts @@ -0,0 +1,98 @@ +import type { Creature, StatName } from '../types' +import { STAT_NAMES } from '../types' +import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping' +import { getTotalEV } from './creature' + +// Track last EV award time per tool to enforce cooldown +const evCooldowns = new Map() + +/** + * Reset EV cooldown state (for testing). + */ +export function resetEVCooldowns(): void { + evCooldowns.clear() +} + +/** + * Award EV to a creature based on tool usage. + * Returns updated creature and actual EV awarded. + */ +export function awardEV(creature: Creature, toolName: string, timestamp?: number): Creature { + const now = timestamp ?? Date.now() + + // Check cooldown + const lastTime = evCooldowns.get(toolName) + if (lastTime !== undefined && now - lastTime < 30_000) return creature + + const currentTotal = getTotalEV(creature) + if (currentTotal >= MAX_EV_TOTAL) return creature + + let evGains = getEVForTool(toolName) + if (!evGains) { + // Random EV for unmapped tools + evGains = generateRandomEV() + } + + const updated = { ...creature, ev: { ...creature.ev } } + for (const stat of STAT_NAMES) { + const gain = evGains[stat] + if (gain > 0) { + const current = updated.ev[stat] + const canAdd = Math.min(gain, MAX_EV_PER_STAT - current, MAX_EV_TOTAL - getTotalEV(updated)) + if (canAdd > 0) { + updated.ev[stat] = current + canAdd + } + } + } + + evCooldowns.set(toolName, now) + return updated +} + +/** + * Award EVs for a full turn's worth of tool calls. + * Deduplicates tool names and spaces timestamps to avoid cooldown issues. + */ +export function awardTurnEV(creature: Creature, toolNames: string[], timestamp?: number): Creature { + const uniqueTools = [...new Set(toolNames)] + const baseTime = timestamp ?? Date.now() + let current = creature + for (let i = 0; i < uniqueTools.length; i++) { + current = awardEV(current, uniqueTools[i]!, baseTime + i * 60_000) + } + return current +} + +/** + * Generate random 1-2 EV points in a random stat. + */ +function generateRandomEV(): Record { + const stats = [...STAT_NAMES] + const stat = stats[Math.floor(Math.random() * stats.length)] + const amount = Math.random() < 0.5 ? 1 : 2 + const result: Record = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 } + result[stat] = amount + return result +} + +/** + * Get formatted EV summary string. + */ +export function getEVSummary(creature: Creature): string { + const parts: string[] = [] + for (const stat of STAT_NAMES) { + const val = creature.ev[stat] + if (val > 0) { + const labels: Record = { + hp: 'HP', + attack: 'ATK', + defense: 'DEF', + spAtk: 'SPA', + spDef: 'SPD', + speed: 'SPE', + } + parts.push(`${labels[stat]}+${val}`) + } + } + return parts.join(' ') || 'None' +} diff --git a/packages/pokemon/src/core/egg.ts b/packages/pokemon/src/core/egg.ts new file mode 100644 index 000000000..48ba8f28e --- /dev/null +++ b/packages/pokemon/src/core/egg.ts @@ -0,0 +1,97 @@ +import { randomUUID } from 'node:crypto' +import type { BuddyData, Creature, Egg, SpeciesId } from '../types' +import { ALL_SPECIES_IDS } from '../types' +import { SPECIES_DATA } from '../data/species' +import { generateCreature } from './creature' + +/** + * Check if the player is eligible to receive an egg. + * Conditions: consecutiveDays >= 7 AND totalTurns % 50 === 0 AND eggs.length < 1 + */ +export function checkEggEligibility(buddyData: BuddyData): boolean { + if (buddyData.eggs.length >= 1) return false + if (buddyData.stats.consecutiveDays < 7) return false + if (buddyData.stats.totalTurns % 50 !== 0) return false + return true +} + +/** + * Generate a new egg with a species the player hasn't collected yet. + * Priority: uncollected species > random from all species. + */ +export function generateEgg(buddyData: BuddyData): Egg { + // Find uncollected species + const collectedSpecies = new Set(buddyData.creatures.map((c) => c.speciesId)) + const uncollected = ALL_SPECIES_IDS.filter((id) => !collectedSpecies.has(id)) + + // Pick species (prefer uncollected, fall back to random starter) + const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu'] + const speciesId = uncollected.length > 0 + ? uncollected[Math.floor(Math.random() * uncollected.length)] + : starters[Math.floor(Math.random() * starters.length)] + + // Steps based on rarity (capture rate: lower = rarer = more steps) + const species = SPECIES_DATA[speciesId] + const baseSteps = Math.floor(2000 + ((255 - species.captureRate) / 255) * 3000) + + return { + id: randomUUID(), + obtainedAt: Date.now(), + stepsRemaining: baseSteps, + totalSteps: baseSteps, + speciesId, + } +} + +/** + * Advance egg steps by a given amount. + * Returns updated egg or null if egg hatched. + */ +export function advanceEggSteps(egg: Egg, steps: number): Egg { + const newSteps = Math.max(0, egg.stepsRemaining - steps) + return { ...egg, stepsRemaining: newSteps } +} + +/** + * Check if an egg is ready to hatch. + */ +export function isEggReadyToHatch(egg: Egg): boolean { + return egg.stepsRemaining <= 0 +} + +/** + * Hatch an egg, creating a new creature and updating buddy data. + */ +export function hatchEgg(buddyData: BuddyData, egg: Egg): { buddyData: BuddyData; creature: Creature } { + const creature = generateCreature(egg.speciesId) + creature.hatchedAt = Date.now() + + // Update buddy data + const updatedData: BuddyData = { + ...buddyData, + creatures: [...buddyData.creatures, creature], + eggs: buddyData.eggs.filter((e) => e.id !== egg.id), + dex: updateDexEntry(buddyData.dex, egg.speciesId, creature.level), + stats: { + ...buddyData.stats, + totalEggsObtained: buddyData.stats.totalEggsObtained + 1, + }, + } + + return { buddyData: updatedData, creature } +} + +/** + * Update or create a dex entry for a species. + */ +function updateDexEntry(dex: BuddyData['dex'], speciesId: SpeciesId, level: number): BuddyData['dex'] { + const existing = dex.find((d) => d.speciesId === speciesId) + if (existing) { + return dex.map((d) => + d.speciesId === speciesId + ? { ...d, caughtCount: d.caughtCount + 1, bestLevel: Math.max(d.bestLevel, level) } + : d, + ) + } + return [...dex, { speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: level }] +} diff --git a/packages/pokemon/src/core/evolution.ts b/packages/pokemon/src/core/evolution.ts new file mode 100644 index 000000000..bd302b6ef --- /dev/null +++ b/packages/pokemon/src/core/evolution.ts @@ -0,0 +1,46 @@ +import type { Creature, EvolutionResult, SpeciesId } from '../types' +import { SPECIES_DATA } from '../data/species' +import { getNextEvolution } from '../data/evolution' + +/** + * Check if a creature meets evolution conditions. + * Returns the evolution result if evolution should occur, null otherwise. + */ +export function checkEvolution(creature: Creature): EvolutionResult | null { + if (creature.level > 100) return null + + const nextEvo = getNextEvolution(creature.speciesId) + if (!nextEvo) return null + + // Check level-up conditions + if (nextEvo.trigger === 'level_up' && creature.level >= nextEvo.minLevel) { + return { + from: creature.speciesId, + to: nextEvo.to, + newLevel: creature.level, + } + } + + return null +} + +/** + * Execute evolution on a creature. + * Returns the updated creature with new species and recalculated data. + */ +export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature { + const newSpecies = SPECIES_DATA[targetSpeciesId] + + return { + ...creature, + speciesId: targetSpeciesId, + friendship: Math.min(255, creature.friendship + 10), // Evolution boosts friendship + } +} + +/** + * Check if a species can evolve further. + */ +export function canEvolveFurther(speciesId: SpeciesId): boolean { + return getNextEvolution(speciesId) !== undefined +} diff --git a/packages/pokemon/src/core/experience.ts b/packages/pokemon/src/core/experience.ts new file mode 100644 index 000000000..e3ba3a7a4 --- /dev/null +++ b/packages/pokemon/src/core/experience.ts @@ -0,0 +1,52 @@ +import type { Creature } from '../types' +import { SPECIES_DATA } from '../data/species' +import { levelFromXp, xpForLevel } from '../data/xpTable' + +/** + * Award XP to a creature. Returns updated creature and whether level up occurred. + */ +export function awardXP(creature: Creature, amount: number): { creature: Creature; leveledUp: boolean; newLevel: number } { + const species = SPECIES_DATA[creature.speciesId] + if (creature.level >= 100) { + return { creature, leveledUp: false, newLevel: creature.level } + } + + const newTotalXp = creature.totalXp + amount + const oldLevel = creature.level + const newLevel = Math.min(levelFromXp(newTotalXp, species.growthRate), 100) + + // XP progress within current level + const currentLevelXp = xpForLevel(newLevel, species.growthRate) + const nextLevelXp = newLevel < 100 ? xpForLevel(newLevel + 1, species.growthRate) : currentLevelXp + const xp = newTotalXp - currentLevelXp + + const updated: Creature = { + ...creature, + totalXp: newTotalXp, + xp: Math.max(0, xp), + level: newLevel, + } + + return { + creature: updated, + leveledUp: newLevel > oldLevel, + newLevel, + } +} + +/** + * Get XP needed to reach next level from current state. + */ +export function getXpProgress(creature: Creature): { current: number; needed: number; percentage: number } { + const species = SPECIES_DATA[creature.speciesId] + const currentLevelXp = xpForLevel(creature.level, species.growthRate) + const nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp + const needed = nextLevelXp - currentLevelXp + const current = creature.totalXp - currentLevelXp + + return { + current: Math.max(0, current), + needed, + percentage: needed > 0 ? Math.min(100, Math.floor((current / needed) * 100)) : 100, + } +} diff --git a/packages/pokemon/src/core/gender.ts b/packages/pokemon/src/core/gender.ts new file mode 100644 index 000000000..eb7440833 --- /dev/null +++ b/packages/pokemon/src/core/gender.ts @@ -0,0 +1,26 @@ +import type { Gender, SpeciesData } from '../types' + +/** + * Determine gender based on species gender ratio. + * genderRate: -1 = genderless, 0 = always male, 1-7 = female chance = genderRate/8, 8 = always female + */ +export function determineGender(speciesData: SpeciesData, seed: number): Gender { + if (speciesData.genderRate === -1) return 'genderless' + if (speciesData.genderRate === 0) return 'male' + if (speciesData.genderRate === 8) return 'female' + // Use seed value (0-255) to determine gender + const threshold = (speciesData.genderRate / 8) * 256 + return (seed % 256) < threshold ? 'female' : 'male' +} + +/** Get gender symbol for display */ +export function getGenderSymbol(gender: Gender): string { + switch (gender) { + case 'male': + return '♂' + case 'female': + return '♀' + case 'genderless': + return '' + } +} diff --git a/packages/pokemon/src/core/spriteCache.ts b/packages/pokemon/src/core/spriteCache.ts new file mode 100644 index 000000000..2709dbadb --- /dev/null +++ b/packages/pokemon/src/core/spriteCache.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { SpeciesId, SpriteCache } from '../types' +import { SPECIES_DATA } from '../data/species' +import { getSpritesDir } from './storage' + +const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons' + +/** Mapping of speciesId to cow file prefix */ +const COW_FILE_MAP: Record = { + bulbasaur: '001_bulbasaur', + ivysaur: '002_ivysaur', + venusaur: '003_venusaur', + charmander: '004_charmander', + charmeleon: '005_charmeleon', + charizard: '006_charizard', + squirtle: '007_squirtle', + wartortle: '008_wartortle', + blastoise: '009_blastoise', + pikachu: '025_pikachu', +} + +/** + * Load sprite from local cache. Returns null if not cached. + */ +export function loadSprite(speciesId: SpeciesId): SpriteCache | null { + const spritesDir = getSpritesDir() + const filePath = join(spritesDir, `${speciesId}.json`) + + if (!existsSync(filePath)) return null + + try { + const raw = readFileSync(filePath, 'utf-8') + return JSON.parse(raw) as SpriteCache + } catch { + return null + } +} + +/** + * Fetch sprite from GitHub, convert from .cow format, and cache locally. + * Returns the cached sprite data, or null if fetch failed. + */ +export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise { + // Try local cache first + const cached = loadSprite(speciesId) + if (cached) return cached + + const cowFileName = COW_FILE_MAP[speciesId] + if (!cowFileName) return null + + const url = `${GITHUB_RAW_BASE}/${cowFileName}.cow` + + try { + const response = await fetch(url) + if (!response.ok) return null + + const cowContent = await response.text() + const lines = convertCowToLines(cowContent) + if (lines.length === 0) return null + + const sprite: SpriteCache = { + speciesId, + lines, + width: Math.max(...lines.map((l) => stripAnsi(l).length)), + height: lines.length, + fetchedAt: Date.now(), + } + + // Cache to disk + const spritesDir = getSpritesDir() + const filePath = join(spritesDir, `${speciesId}.json`) + writeFileSync(filePath, JSON.stringify(sprite, null, 2)) + + return sprite + } catch { + return null + } +} + +/** + * Convert .cow file content to displayable lines. + * Extracts heredoc content, converts Unicode escapes, strips thought lines. + */ +function convertCowToLines(cowContent: string): string[] { + // Extract content between $the_cow =< + String.fromCodePoint(parseInt(hex, 16)), + ) + + // Convert \e to actual escape character (for ANSI sequences) + content = content.replace(/\\e/g, '\x1b') + + // Split into lines + let lines = content.split('\n') + + // Strip leading/trailing empty lines + while (lines.length > 0 && lines[0].trim() === '') lines.shift() + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop() + + // Remove first 4 lines (cowsay thought bubble guide) + if (lines.length > 4) { + lines = lines.slice(4) + } + + // Trim trailing whitespace on each line (preserve leading for alignment) + lines = lines.map((line) => line.trimEnd()) + + return lines +} + +/** + * Strip ANSI escape sequences from a string. + */ +function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, '') +} + +/** + * Get species name with dex number for display. + */ +export function getSpeciesDisplay(speciesId: SpeciesId): string { + const data = SPECIES_DATA[speciesId] + return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}` +} diff --git a/packages/pokemon/src/core/storage.ts b/packages/pokemon/src/core/storage.ts new file mode 100644 index 000000000..f3c1a606e --- /dev/null +++ b/packages/pokemon/src/core/storage.ts @@ -0,0 +1,206 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import type { BuddyData, SpeciesId } from '../types' +import { ALL_SPECIES_IDS } from '../types' +import { generateCreature } from './creature' +import { SPECIES_DATA } from '../data/species' + +const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json') +const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites') + +/** + * Load buddy data from disk. Returns default data if file doesn't exist. + */ +export function loadBuddyData(): BuddyData { + if (!existsSync(BUDDY_DATA_PATH)) { + return getDefaultBuddyData() + } + try { + const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8') + const data = JSON.parse(raw) as BuddyData + if (data.version !== 1) { + return migrateData(data) + } + return data + } catch { + return getDefaultBuddyData() + } +} + +/** + * Save buddy data to disk. + */ +export function saveBuddyData(data: BuddyData): void { + // Ensure directory exists + const dir = join(BUDDY_DATA_PATH, '..') + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + writeFileSync(BUDDY_DATA_PATH, JSON.stringify(data, null, 2)) +} + +/** + * Get default buddy data for new users. + * Randomly assigns one of the three starters. + */ +export function getDefaultBuddyData(): BuddyData { + const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle'] + const randomStarter = starters[Math.floor(Math.random() * starters.length)] + const creature = generateCreature(randomStarter) + + return { + version: 1, + activeCreatureId: creature.id, + creatures: [creature], + eggs: [], + dex: [ + { + speciesId: randomStarter, + discoveredAt: Date.now(), + caughtCount: 1, + bestLevel: 1, + }, + ], + stats: { + totalTurns: 0, + consecutiveDays: 0, + lastActiveDate: new Date().toISOString().split('T')[0], + totalEggsObtained: 0, + totalEvolutions: 0, + }, + } +} + +/** + * Get the sprites cache directory path. + */ +export function getSpritesDir(): string { + if (!existsSync(BUDDY_SPRITES_DIR)) { + mkdirSync(BUDDY_SPRITES_DIR, { recursive: true }) + } + return BUDDY_SPRITES_DIR +} + +/** + * Migrate from legacy buddy system. + * Accepts legacy companion data and maps to new Pokémon species. + * If species cannot be determined, randomly assigns a starter. + */ +export function migrateFromLegacy( + storedCompanion: { name?: string; personality?: string; seed?: string; hatchedAt?: number; species?: string }, +): BuddyData { + const speciesMap: Record = { + duck: 'bulbasaur', + goose: 'squirtle', + blob: 'bulbasaur', + cat: 'charmander', + dragon: 'pikachu', + octopus: 'squirtle', + owl: 'bulbasaur', + penguin: 'squirtle', + turtle: 'squirtle', + snail: 'bulbasaur', + ghost: 'pikachu', + axolotl: 'squirtle', + capybara: 'bulbasaur', + cactus: 'charmander', + robot: 'charmander', + rabbit: 'pikachu', + mushroom: 'bulbasaur', + chonk: 'charmander', + } + + // If species is provided directly, use it; otherwise random starter + const mapped = storedCompanion.species ? speciesMap[storedCompanion.species] : undefined + const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle'] + const speciesId: SpeciesId = mapped ?? starters[Math.floor(Math.random() * starters.length)]! + + const creature = generateCreature(speciesId) + creature.level = 5 // Reward for existing users + creature.totalXp = 100 + creature.friendship = 120 // Existing partner bonus + + // Preserve nickname if it's not the default + const speciesInfo = SPECIES_DATA[speciesId] + if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) { + creature.nickname = storedCompanion.name + } + + return { + version: 1, + activeCreatureId: creature.id, + creatures: [creature], + eggs: [], + dex: [ + { + speciesId, + discoveredAt: Date.now(), + caughtCount: 1, + bestLevel: 5, + }, + ], + stats: { + totalTurns: 0, + consecutiveDays: 1, + lastActiveDate: new Date().toISOString().split('T')[0], + totalEggsObtained: 0, + totalEvolutions: 0, + }, + } +} + +/** + * Handle data migration between versions. + */ +function migrateData(data: BuddyData): BuddyData { + // Currently only version 1 exists + if (!data.version || data.version < 1) { + return getDefaultBuddyData() + } + return data +} + +/** + * Update daily stats (consecutive days, last active date). + */ +export function updateDailyStats(data: BuddyData): BuddyData { + const today = new Date().toISOString().split('T')[0] + const lastDate = data.stats.lastActiveDate + + let consecutiveDays = data.stats.consecutiveDays + if (lastDate !== today) { + // Check if yesterday + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + const yesterdayStr = yesterday.toISOString().split('T')[0] + + if (lastDate === yesterdayStr) { + consecutiveDays++ + } else { + consecutiveDays = 1 + } + } + + return { + ...data, + stats: { + ...data.stats, + consecutiveDays, + lastActiveDate: today, + }, + } +} + +/** + * Increment turn counter. + */ +export function incrementTurns(data: BuddyData): BuddyData { + return { + ...data, + stats: { + ...data.stats, + totalTurns: data.stats.totalTurns + 1, + }, + } +} diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts new file mode 100644 index 000000000..94eedab8a --- /dev/null +++ b/packages/pokemon/src/index.ts @@ -0,0 +1,48 @@ +// Types +export type { + StatName, + SpeciesId, + Gender, + EvolutionTrigger, + EvolutionCondition, + GrowthRate, + SpeciesData, + Creature, + Egg, + DexEntry, + BuddyData, + StatsResult, + EvolutionResult, + SpriteCache, + AnimMode, +} from './types' +export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from './types' + +// Data +export { SPECIES_DATA, DEX_TO_SPECIES } from './data/species' +export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './data/evMapping' +export { xpForLevel, levelFromXp, xpToNextLevel } from './data/xpTable' +export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './data/names' +export { getNextEvolution, EVOLUTION_CHAINS } from './data/evolution' + +// Core +export { generateCreature, calculateStats, getCreatureName, recalculateLevel, getActiveCreature, getTotalEV } from './core/creature' +export { determineGender, getGenderSymbol } from './core/gender' +export { awardXP, getXpProgress } from './core/experience' +export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort' +export { checkEvolution, evolve, canEvolveFurther } from './core/evolution' +export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg } from './core/egg' +export { loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, updateDailyStats, incrementTurns } from './core/storage' +export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache' + +// Sprites +export { renderAnimatedSprite, getIdleAnimMode } from './sprites/renderer' +export { getFallbackSprite } from './sprites/fallback' + +// UI Components +export { CompanionCard } from './ui/CompanionCard' +export { PokedexView } from './ui/PokedexView' +export { EggView } from './ui/EggView' +export { EvolutionAnim } from './ui/EvolutionAnim' +export { StatBar } from './ui/StatBar' +export { SpeciesDetail } from './ui/SpeciesDetail' diff --git a/packages/pokemon/src/sprites/fallback.ts b/packages/pokemon/src/sprites/fallback.ts new file mode 100644 index 000000000..4a612cb23 --- /dev/null +++ b/packages/pokemon/src/sprites/fallback.ts @@ -0,0 +1,85 @@ +import type { SpeciesId } from '../types' + +/** + * Fallback ASCII art for when sprites can't be fetched. + * Simple 5-line representations of each species. + */ +const FALLBACK_SPRITES: Record = { + bulbasaur: [ + ' _,,--.,,_ ', + ' ,\' `, ', + ' ; o o ; ', + ' ; ~~~~~~~~ ; ', + ' `--,,__,,--\' ', + ], + ivysaur: [ + ' _,--..,_ ', + ' ,\' (o)(o) `, ', + ' ; ~~~~~~ ; ', + ' ; \\====/ ; ', + ' `--,,__,,--\' ', + ], + venusaur: [ + ' _,,,---.,,_ ', + ' ,\' (o) (o) `, ', + ' ; ~~~~~~~~ ; ', + ' ; /========\\ ; ', + ' `-,,,____,,,-\' ', + ], + charmander: [ + ' ,^., ', + ' ( o o) ', + ' / ~~~ \\ ', + ' / \\___/ \\ ', + ' ^^^ ^^^ ', + ], + charmeleon: [ + ' ,--^. ', + ' ( o o) ', + ' / ~~~~~ \\ ', + ' / \\___/ \\ ', + ' ^^ ^^ ', + ], + charizard: [ + ' /\\ /\\ ', + ' / \\/ \\ ', + ' | o o | ', + ' | ~~~~~~ | ', + ' \\______/ ', + ], + squirtle: [ + ' _____ ', + ' ,\' `, ', + ' ; o o ; ', + ' ; ~~~~~~~ ; ', + ' `-.,__,\' ', + ], + wartortle: [ + ' _______ ', + ' ,\' `, ', + ' ; o o ; ', + ' ; ~~~~~~~~ ; ', + ' `-.,__,\' ', + ], + blastoise: [ + ' .________. ', + ' | o o | ', + ' | ~~~~~~~~ | ', + ' | [====] | ', + ' `-.,__,\' ', + ], + pikachu: [ + ' /\\ /\\ ', + ' ( o o ) ', + ' \\ ~~~ / ', + ' /`-...-\'\\ ', + ' ^^ ^^ ', + ], +} + +/** + * Get fallback ASCII sprite lines for a species. + */ +export function getFallbackSprite(speciesId: SpeciesId): string[] { + return FALLBACK_SPRITES[speciesId] ?? FALLBACK_SPRITES.pikachu +} diff --git a/packages/pokemon/src/sprites/index.ts b/packages/pokemon/src/sprites/index.ts new file mode 100644 index 000000000..209db884d --- /dev/null +++ b/packages/pokemon/src/sprites/index.ts @@ -0,0 +1,4 @@ +export { renderAnimatedSprite, getIdleAnimMode } from './renderer' +export type { AnimMode } from '../types' +export { getFallbackSprite } from './fallback' +export { loadSprite, fetchAndCacheSprite } from '../core/spriteCache' diff --git a/packages/pokemon/src/sprites/renderer.ts b/packages/pokemon/src/sprites/renderer.ts new file mode 100644 index 000000000..1ebdee02c --- /dev/null +++ b/packages/pokemon/src/sprites/renderer.ts @@ -0,0 +1,76 @@ +import type { AnimMode } from '../types' + +/** Heart particle frames for pet animation */ +const PET_HEARTS = [ + [' ♥ ', ' '], + [' ♥ ♥ ', ' ♥ '], + [' ♥ ♥ ', ' ♥ ♥ '], + [' ♥ ♥ ', ' ♥ ♥ '], + [' ♥ ', ' ♥ ♥ '], +] + +/** + * Render animated sprite by applying mode-specific transformations. + * All species share the same animation logic - only the base sprite differs. + */ +export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] { + switch (mode) { + case 'idle': + return lines + case 'fidget': + return shiftLines(lines, tick % 2 === 0 ? 0 : 1) + case 'blink': + return blinkEyes(lines) + case 'excited': + return shiftLines(lines, tick % 2 === 0 ? -1 : 1) + case 'pet': + return addPetParticles(lines, tick) + default: + return lines + } +} + +/** + * Shift all lines left or right by offset columns. + */ +function shiftLines(lines: string[], offset: number): string[] { + if (offset === 0) return lines + if (offset > 0) { + return lines.map((line) => ' '.repeat(offset) + line) + } + // Shift left: remove leading characters + const absOffset = Math.abs(offset) + return lines.map((line) => line.slice(absOffset)) +} + +/** + * Replace eye characters with blink indicator. + */ +function blinkEyes(lines: string[]): string[] { + // Eye characters that should be replaced for blink + return lines.map((line) => + line.replace(/[·✦×◉@°oO]/g, '—'), + ) +} + +/** + * Add heart particle frames above the sprite for pet animation. + */ +function addPetParticles(lines: string[], tick: number): string[] { + const hearts = PET_HEARTS[tick % PET_HEARTS.length] + return [...hearts, ...lines] +} + +/** + * Get the animation mode for a given tick in the idle sequence. + * IDLE_SEQUENCE replicates the original buddy design pattern. + */ +const IDLE_SEQUENCE: AnimMode[] = [ + 'idle', 'idle', 'idle', 'idle', + 'fidget', 'idle', 'idle', 'idle', + 'blink', 'idle', 'idle', 'idle', 'idle', +] + +export function getIdleAnimMode(tick: number): AnimMode { + return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length] +} diff --git a/packages/pokemon/src/types.ts b/packages/pokemon/src/types.ts new file mode 100644 index 000000000..9f6f96215 --- /dev/null +++ b/packages/pokemon/src/types.ts @@ -0,0 +1,143 @@ +// 6 attributes (mapped to programming scenarios) +export type StatName = 'hp' | 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed' +export const STAT_NAMES: StatName[] = ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] +export const STAT_LABELS: Record = { + hp: 'HP', + attack: 'ATK', + defense: 'DEF', + spAtk: 'SPA', + spDef: 'SPD', + speed: 'SPE', +} + +// Species IDs (MVP 10 species) +export type SpeciesId = + | 'bulbasaur' + | 'ivysaur' + | 'venusaur' + | 'charmander' + | 'charmeleon' + | 'charizard' + | 'squirtle' + | 'wartortle' + | 'blastoise' + | 'pikachu' + +export const ALL_SPECIES_IDS: SpeciesId[] = [ + 'bulbasaur', + 'ivysaur', + 'venusaur', + 'charmander', + 'charmeleon', + 'charizard', + 'squirtle', + 'wartortle', + 'blastoise', + 'pikachu', +] + +// Gender +export type Gender = 'male' | 'female' | 'genderless' + +// Evolution trigger types +export type EvolutionTrigger = 'level_up' | 'item' | 'trade' | 'friendship' + +export type EvolutionCondition = { + trigger: EvolutionTrigger + level?: number // Level evolution: target level + minFriendship?: number // Friendship evolution + item?: string // Item evolution + into: SpeciesId // Evolves into +} + +// Growth rate types (from PokeAPI) +export type GrowthRate = 'slow' | 'medium-slow' | 'medium-fast' | 'fast' | 'erratic' | 'fluctuating' + +// Species base data +export type SpeciesData = { + id: SpeciesId + name: string // English name + names: Record // Multilingual names { ja, en, zh } + dexNumber: number // Pokédex number (1-10 MVP) + genderRate: number // Female probability (0-8, -1 = genderless). femaleChance = genderRate / 8 + baseStats: Record + types: [string, string?] // Types (grass/poison, fire, water etc.) + baseHappiness: number // Base friendship + growthRate: GrowthRate + captureRate: number + personality: string // Default personality description + evolutionChain?: EvolutionCondition[] + shinyChance: number // Shiny probability (default 1/4096) + flavorText?: string // Pokédex description +} + +// Instantiated creature (stored in buddy-data.json) +export type Creature = { + id: string // UUID + speciesId: SpeciesId + nickname?: string // User-defined name + gender: Gender + level: number + xp: number // Current level progress XP + totalXp: number // Total accumulated XP + ev: Record // Effort values + iv: Record // Individual values (0-31) + friendship: number // Friendship (0-255) + isShiny: boolean + hatchedAt: number // Timestamp when obtained +} + +// Egg +export type Egg = { + id: string + obtainedAt: number + stepsRemaining: number // Remaining hatch steps + totalSteps: number // Original total steps (for progress calc) + speciesId: SpeciesId // Pre-determined species +} + +// Pokédex entry +export type DexEntry = { + speciesId: SpeciesId + discoveredAt: number + caughtCount: number // Number caught + bestLevel: number // Highest level record +} + +// buddy-data.json complete structure +export type BuddyData = { + version: 1 + activeCreatureId: string | null + creatures: Creature[] + eggs: Egg[] + dex: DexEntry[] + stats: { + totalTurns: number + consecutiveDays: number + lastActiveDate: string // ISO date + totalEggsObtained: number + totalEvolutions: number + } +} + +// Calculated stats result +export type StatsResult = Record + +// Evolution result +export type EvolutionResult = { + from: SpeciesId + to: SpeciesId + newLevel: number +} + +// Sprite cache entry +export type SpriteCache = { + speciesId: SpeciesId + lines: string[] + width: number + height: number + fetchedAt: number +} + +// Animation mode +export type AnimMode = 'idle' | 'fidget' | 'blink' | 'excited' | 'pet' diff --git a/packages/pokemon/src/ui/CompanionCard.tsx b/packages/pokemon/src/ui/CompanionCard.tsx new file mode 100644 index 000000000..e289d7be4 --- /dev/null +++ b/packages/pokemon/src/ui/CompanionCard.tsx @@ -0,0 +1,170 @@ +import React from 'react' +import { Box, Text, type Color } from '@anthropic/ink' +import type { BuddyData, Creature, SpeciesId } from '../types' +import { STAT_NAMES, STAT_LABELS } from '../types' +import { SPECIES_DATA } from '../data/species' +import { SPECIES_I18N, SPECIES_PERSONALITY } from '../data/names' +import { calculateStats, getCreatureName, getTotalEV } from '../core/creature' +import { getXpProgress } from '../core/experience' +import { getEVSummary } from '../core/effort' +import { getGenderSymbol } from '../core/gender' +import { getNextEvolution } from '../data/evolution' +import { StatBar } from './StatBar' + +interface CompanionCardProps { + creature: Creature + buddyData: BuddyData + spriteLines?: string[] +} + +// ANSI color constants +const CYAN: Color = 'ansi:cyan' +const YELLOW: Color = 'ansi:yellow' +const GREEN: Color = 'ansi:green' +const BLUE: Color = 'ansi:blue' +const RED: Color = 'ansi:red' +const MAGENTA: Color = 'ansi:magenta' +const WHITE: Color = 'ansi:whiteBright' +const GRAY: Color = 'ansi:white' + +/** Type → display color mapping */ +const TYPE_COLORS: Record = { + grass: 'ansi:green', + poison: 'ansi:magenta', + fire: 'ansi:red', + flying: 'ansi:cyan', + water: 'ansi:blue', + electric: 'ansi:yellow', + normal: 'ansi:white', +} + +/** + * Redesigned companion card with Pokémon-style stats display. + */ +export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) { + const species = SPECIES_DATA[creature.speciesId] + const stats = calculateStats(creature) + const xp = getXpProgress(creature) + const genderSymbol = getGenderSymbol(creature.gender) + const name = getCreatureName(creature) + const evSummary = getEVSummary(creature) + const totalEV = getTotalEV(creature) + const nextEvo = getNextEvolution(creature.speciesId) + + // Type badges + const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => ( + + {i > 0 ? '/' : ''}{t.toUpperCase()} + + )) + + // Friendship color + const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED + + // Shiny badge + const shinyBadge = creature.isShiny ? ★SHINY★ : null + + // Evolution hint + const evoHint = nextEvo ? ( + {SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name} Lv.{nextEvo.minLevel} + ) : null + + return ( + + {/* Header row */} + + + {name} + #{String(species.dexNumber).padStart(3, '0')} + {shinyBadge} + + Lv.{creature.level} + + + {/* Species + type + gender */} + + {species.names.zh ?? species.name} + + {typeBadges} + {genderSymbol && {genderSymbol}} + + + {/* Sprite */} + + {spriteLines ? ( + spriteLines.map((line, i) => {line}) + ) : ( + [Loading sprite...] + )} + + + {/* Personality */} + + "{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}" + + + {/* Stats section */} + + ─── Base Stats ─── + {STAT_NAMES.map((stat) => ( + + ))} + + + {/* XP progress */} + + XP + + {'█'.repeat(Math.round(xp.percentage / 10))} + {'░'.repeat(10 - Math.round(xp.percentage / 10))} + + {xp.current}/{xp.needed} + + + {/* EV + Friendship */} + + + EV + = 510 ? GREEN : GRAY}>{evSummary} + ({totalEV}/510) + + + + + {'█'.repeat(Math.round((creature.friendship / 255) * 10))} + {'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))} + + {creature.friendship}/255 + + + + {/* Evolution hint */} + {evoHint && ( + + Next: + {evoHint} + + )} + + + + ) +} + +function getStatColor(stat: string): Color { + const colors: Record = { + hp: 'ansi:green', + attack: 'ansi:red', + defense: 'ansi:yellow', + spAtk: 'ansi:blue', + spDef: 'ansi:magenta', + speed: 'ansi:cyan', + } + return colors[stat] ?? 'ansi:white' +} diff --git a/packages/pokemon/src/ui/EggView.tsx b/packages/pokemon/src/ui/EggView.tsx new file mode 100644 index 000000000..7c1888080 --- /dev/null +++ b/packages/pokemon/src/ui/EggView.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { Box, Text, type Color } from '@anthropic/ink' +import type { Egg } from '../types' + +const CYAN: Color = 'ansi:cyan' +const YELLOW: Color = 'ansi:yellow' +const GRAY: Color = 'ansi:white' + +interface EggViewProps { + egg: Egg +} + +/** + * Egg status view showing hatch progress. + */ +export function EggView({ egg }: EggViewProps) { + const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100) + const filled = Math.round(percentage / 10) + const empty = 10 - filled + + return ( + + + Egg Status + + + {/* ASCII egg */} + + . + / \ + | | + \_/ + + + {/* Progress */} + + + Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps} + + + {'█'.repeat(filled)} + {'░'.repeat(empty)} + + {percentage}% + + + {/* Tips */} + + Pet (+5) · Chat (+3) · Cmd (+1) + Hatch: ~{egg.stepsRemaining} more interactions + + + ) +} diff --git a/packages/pokemon/src/ui/EvolutionAnim.tsx b/packages/pokemon/src/ui/EvolutionAnim.tsx new file mode 100644 index 000000000..503811e63 --- /dev/null +++ b/packages/pokemon/src/ui/EvolutionAnim.tsx @@ -0,0 +1,90 @@ +import React, { useState, useEffect } from 'react' +import { Box, Text, type Color } from '@anthropic/ink' +import type { SpeciesId } from '../types' +import { SPECIES_DATA } from '../data/species' +import { loadSprite } from '../core/spriteCache' +import { getFallbackSprite } from '../sprites/fallback' + +const YELLOW: Color = 'ansi:yellow' +const GREEN: Color = 'ansi:green' +const GRAY: Color = 'ansi:white' + +interface EvolutionAnimProps { + fromSpecies: SpeciesId + toSpecies: SpeciesId + onComplete: () => void +} + +/** + * Evolution animation component. + * Displays a flashing/morphing effect from old species to new species. + * 8 frames × 500ms = ~4 seconds total. + */ +export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionAnimProps) { + const [tick, setTick] = useState(0) + const totalFrames = 8 + + useEffect(() => { + if (tick >= totalFrames) { + onComplete() + return + } + const timer = setTimeout(() => setTick((t) => t + 1), 500) + return () => clearTimeout(timer) + }, [tick, onComplete]) + + const fromSprite = getSpriteLines(fromSpecies) + const toSprite = getSpriteLines(toSpecies) + const fromName = SPECIES_DATA[fromSpecies].name + const toName = SPECIES_DATA[toSpecies].name + + // Frame logic: + // 0-3: old sprite with flash (alternate blank) + // 4-7: alternate old/new, settle on new + let displayLines: string[] + if (tick < 3) { + displayLines = tick % 2 === 0 ? fromSprite : fromSprite.map(() => '') + } else if (tick < 6) { + displayLines = tick % 2 === 0 ? fromSprite : toSprite + } else { + displayLines = toSprite + } + + return ( + + + ✨ Evolution! ✨ + + + + {displayLines.map((line, i) => ( + + {tick >= 6 ? '✨ ' : ''} + {line} + {tick >= 6 ? ' ✨' : ''} + + ))} + + + + {fromName} + + + {toName} + + + + {tick >= totalFrames - 1 && ( + + 进化成功! + + )} + + ) +} + +function getSpriteLines(speciesId: SpeciesId): string[] { + const cached = loadSprite(speciesId) + if (cached) return cached.lines + return getFallbackSprite(speciesId) +} diff --git a/packages/pokemon/src/ui/PokedexView.tsx b/packages/pokemon/src/ui/PokedexView.tsx new file mode 100644 index 000000000..e72080c2c --- /dev/null +++ b/packages/pokemon/src/ui/PokedexView.tsx @@ -0,0 +1,163 @@ +import React from 'react' +import { Box, Text, type Color } from '@anthropic/ink' +import type { BuddyData, SpeciesId } from '../types' +import { ALL_SPECIES_IDS } from '../types' +import { SPECIES_DATA } from '../data/species' +import { getNextEvolution } from '../data/evolution' + +const CYAN: Color = 'ansi:cyan' +const GREEN: Color = 'ansi:green' +const GRAY: Color = 'ansi:white' +const YELLOW: Color = 'ansi:yellow' +const WHITE: Color = 'ansi:whiteBright' +const RED: Color = 'ansi:red' +const BLUE: Color = 'ansi:blue' + +interface PokedexViewProps { + buddyData: BuddyData +} + +/** + * Pokédex view — shows all species with collection status, + * evolution chains, and active creature indicator. + */ +export function PokedexView({ buddyData }: PokedexViewProps) { + const dexMap = new Map(buddyData.dex.map((d) => [d.speciesId, d])) + const collected = buddyData.dex.length + const total = ALL_SPECIES_IDS.length + + // Group species by evolution chain + const chains = groupByChain() + + return ( + + {/* Header */} + + Pokédex + + {collected} + /{total} + collected + + + + {/* Progress bar */} + + {'█'.repeat(collected)} + {'░'.repeat(total - collected)} + {Math.floor((collected / total) * 100)}% + + + {/* Species list grouped by evolution chains */} + {chains.map((chain, ci) => ( + 0 ? 0 : 0}> + {chain.map((speciesId, si) => { + const species = SPECIES_DATA[speciesId] + const entry = dexMap.get(speciesId) + const discovered = !!entry + const isActive = buddyData.activeCreatureId + ? buddyData.creatures.some((c) => c.id === buddyData.activeCreatureId && c.speciesId === speciesId) + : false + const nextEvo = getNextEvolution(speciesId) + + return ( + + + {/* Chain connector */} + {si === 0 ? ' ' : '├'} + {/* Active indicator */} + {isActive ? : ' '} + {/* Dex number */} + #{String(species.dexNumber).padStart(3, '0')} + {/* Name */} + + {discovered + ? (species.names.zh ?? species.name) + : '???'} + + {/* Type badges */} + {discovered && ( + + {' '} + {species.types.filter((t): t is string => Boolean(t)).map((t, ti) => ( + + {ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()} + + ))} + + )} + {/* Level / unknown indicator */} + {discovered && entry ? ( + Lv.{entry.bestLevel} + ) : ( + ─── + )} + {/* Evolution arrow */} + {nextEvo && ( + Lv.{nextEvo.minLevel} + )} + + + ) + })} + + ))} + + {/* Stats row */} + + ─── Stats ─── + + Turns: + {buddyData.stats.totalTurns} + Days: + {buddyData.stats.consecutiveDays} + + + Eggs: + {buddyData.stats.totalEggsObtained} + Evolutions: + {buddyData.stats.totalEvolutions} + + + + {/* Egg info */} + {buddyData.eggs.length > 0 && ( + + 🥚 Egg: + {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} + steps + + )} + + {buddyData.stats.consecutiveDays < 7 && ( + + Next egg: {7 - buddyData.stats.consecutiveDays} more days + + )} + + ) +} + +/** Type → color mapping */ +function getTypeColor(type: string): Color { + const colors: Record = { + grass: 'ansi:green', + poison: 'ansi:magenta', + fire: 'ansi:red', + flying: 'ansi:cyan', + water: 'ansi:blue', + electric: 'ansi:yellow', + normal: 'ansi:white', + } + return colors[type] ?? 'ansi:white' +} + +/** Group species by evolution chain for visual display */ +function groupByChain(): SpeciesId[][] { + return [ + ['bulbasaur', 'ivysaur', 'venusaur'], + ['charmander', 'charmeleon', 'charizard'], + ['squirtle', 'wartortle', 'blastoise'], + ['pikachu'], + ] +} diff --git a/packages/pokemon/src/ui/SpeciesDetail.tsx b/packages/pokemon/src/ui/SpeciesDetail.tsx new file mode 100644 index 000000000..5b25c83e0 --- /dev/null +++ b/packages/pokemon/src/ui/SpeciesDetail.tsx @@ -0,0 +1,193 @@ +import React from 'react' +import { Box, Text, type Color } from '@anthropic/ink' +import type { SpeciesId, StatName } from '../types' +import { STAT_NAMES, STAT_LABELS } from '../types' +import { SPECIES_DATA } from '../data/species' +import { SPECIES_PERSONALITY } from '../data/names' +import { getNextEvolution } from '../data/evolution' +import { StatBar } from './StatBar' + +const CYAN: Color = 'ansi:cyan' +const GRAY: Color = 'ansi:white' +const WHITE: Color = 'ansi:whiteBright' +const YELLOW: Color = 'ansi:yellow' +const GREEN: Color = 'ansi:green' +const RED: Color = 'ansi:red' +const BLUE: Color = 'ansi:blue' + +/** Type → color */ +const TYPE_COLORS: Record = { + grass: 'ansi:green', poison: 'ansi:magenta', fire: 'ansi:red', + flying: 'ansi:cyan', water: 'ansi:blue', electric: 'ansi:yellow', + normal: 'ansi:white', +} + +interface SpeciesDetailProps { + speciesId: SpeciesId + caughtLevel?: number + spriteLines?: string[] +} + +/** + * Detailed species info page — base stats, evolution chain, flavor text. + */ +export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDetailProps) { + const species = SPECIES_DATA[speciesId] + const nextEvo = getNextEvolution(speciesId) + + // Type badges + const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => ( + + {i > 0 ? ' / ' : ''}{t.toUpperCase()} + + )) + + // Gender info + const genderInfo = species.genderRate === -1 + ? 'Genderless' + : species.genderRate === 0 + ? '♂ 100%' + : species.genderRate === 8 + ? '♀ 100%' + : `♀ ${(species.genderRate / 8 * 100).toFixed(1)}%` + + // Max base stat for bar scaling + const maxBase = 130 + + return ( + + {/* Header */} + + + #{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name} + + {caughtLevel && Best: Lv.{caughtLevel}} + + + {/* Type + gender */} + + {typeBadges} + {genderInfo} + + + {/* Sprite */} + {spriteLines && ( + + {spriteLines.map((line, i) => {line})} + + )} + + {/* Flavor text */} + {species.flavorText && ( + + {species.flavorText} + + )} + + {/* Base Stats */} + + ─── Base Stats ─── + {STAT_NAMES.map((stat) => ( + + {STAT_LABELS[stat].padEnd(3)} + + {'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))} + {'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))} + + {String(species.baseStats[stat]).padStart(3)} + + ))} + {/* Total */} + + {'Total'.padEnd(3)} + + {'─'.repeat(15)} + + {Object.values(species.baseStats).reduce((a, b) => a + b, 0)} + + + + {/* Evolution chain */} + {(nextEvo || species.dexNumber > 1) && ( + + ─── Evolution ─── + + + )} + + {/* Info */} + + ─── Info ─── + + Growth: + {species.growthRate} + + + Capture: + {species.captureRate} + Happiness: + {species.baseHappiness} + + + + ) +} + +/** Render evolution chain arrow */ +function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) { + // Find the chain head + const chainHeads: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu'] + let head: SpeciesId = speciesId + for (const starter of chainHeads) { + if (isInChain(speciesId, starter)) { + head = starter + break + } + } + + const chain: SpeciesId[] = [head] + let current: SpeciesId | undefined = head + while (current) { + const next = getNextEvolution(current) + if (next) { + chain.push(next.to) + current = next.to + } else { + current = undefined + } + } + + return ( + + {chain.map((sid, i) => ( + + {i > 0 && } + + {SPECIES_DATA[sid].names.zh ?? SPECIES_DATA[sid].name} + + {i < chain.length - 1 && getNextEvolution(sid) && ( + Lv.{getNextEvolution(sid)!.minLevel} + )} + + ))} + + ) +} + +function isInChain(target: SpeciesId, head: SpeciesId): boolean { + let current: SpeciesId | undefined = head + while (current) { + if (current === target) return true + const next = getNextEvolution(current) + current = next ? next.to : undefined + } + return false +} + +function getStatColor(stat: string): Color { + const colors: Record = { + hp: 'ansi:green', attack: 'ansi:red', defense: 'ansi:yellow', + spAtk: 'ansi:blue', spDef: 'ansi:magenta', speed: 'ansi:cyan', + } + return colors[stat] ?? 'ansi:white' +} diff --git a/packages/pokemon/src/ui/StatBar.tsx b/packages/pokemon/src/ui/StatBar.tsx new file mode 100644 index 000000000..35cd620e8 --- /dev/null +++ b/packages/pokemon/src/ui/StatBar.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { Box, Text, type Color } from '@anthropic/ink' + +interface StatBarProps { + label: string + value: number + maxValue: number + color?: Color + width?: number +} + +/** + * Compact horizontal stat bar for Pokémon stats. + */ +export function StatBar({ label, value, maxValue, color = 'ansi:green', width = 12 }: StatBarProps) { + const filled = Math.round((value / maxValue) * width) + const empty = width - filled + const bar = '█'.repeat(filled) + '░'.repeat(empty) + const valueStr = String(value).padStart(3) + + return ( + + {label.padEnd(3)} + {bar} + {valueStr} + + ) +} diff --git a/packages/pokemon/tsconfig.json b/packages/pokemon/tsconfig.json new file mode 100644 index 000000000..49e05cea1 --- /dev/null +++ b/packages/pokemon/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/src/buddy/CompanionCard.tsx b/src/buddy/CompanionCard.tsx deleted file mode 100644 index 1f571605c..000000000 --- a/src/buddy/CompanionCard.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Companion display card — shown by /buddy (no args). - * Mirrors official vc8 component: bordered box with sprite, stats, last reaction. - */ -import React from 'react'; -import { Box, Text } from '@anthropic/ink'; -import { useInput } from '@anthropic/ink'; -import { renderSprite } from './sprites.js'; -import { RARITY_COLORS, RARITY_STARS, STAT_NAMES, type Companion } from './types.js'; - -const CARD_WIDTH = 40; -const CARD_PADDING_X = 2; - -function StatBar({ name, value }: { name: string; value: number }) { - const clamped = Math.max(0, Math.min(100, value)); - const filled = Math.round(clamped / 10); - const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled); - return ( - - {name.padEnd(10)} {bar} {String(value).padStart(3)} - - ); -} - -export function CompanionCard({ - companion, - lastReaction, - onDone, -}: { - companion: Companion; - lastReaction?: string; - onDone?: (result?: string, options?: { display?: string }) => void; -}) { - const color = RARITY_COLORS[companion.rarity]; - const stars = RARITY_STARS[companion.rarity]; - const sprite = renderSprite(companion, 0); - - // Press any key to dismiss - useInput( - () => { - onDone?.(undefined, { display: 'skip' }); - }, - { isActive: onDone !== undefined }, - ); - - return ( - - {/* Header: rarity + species */} - - - {stars} {companion.rarity.toUpperCase()} - - {companion.species.toUpperCase()} - - - {/* Shiny indicator */} - {companion.shiny && ( - - {'\u2728'} SHINY {'\u2728'} - - )} - - {/* Sprite */} - - {sprite.map((line, i) => ( - - {line} - - ))} - - - {/* Name */} - {companion.name} - - {/* Personality */} - - - "{companion.personality}" - - - - {/* Stats */} - - {STAT_NAMES.map(name => ( - - ))} - - - {/* Last reaction */} - {lastReaction && ( - - last said - - - {lastReaction} - - - - )} - - ); -} diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx index 22dbeb643..dbfe5f2f4 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -1,347 +1,288 @@ -import { feature } from 'bun:bundle' -import figures from 'figures' -import React, { useEffect, useRef, useState } from 'react' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { useAppState, useSetAppState } from '../state/AppState.js' -import type { AppState } from '../state/AppStateStore.js' -import { getGlobalConfig } from '../utils/config.js' -import { isFullscreenActive } from '../utils/fullscreen.js' -import type { Theme } from '../utils/theme.js' -import { getCompanion } from './companion.js' -import { renderFace, renderSprite, spriteFrameCount } from './sprites.js' -import { RARITY_COLORS } from './types.js' +import { feature } from 'bun:bundle'; +import figures from 'figures'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import { useAppState, useSetAppState } from '../state/AppState.js'; +import type { AppState } from '../state/AppStateStore.js'; +import { getGlobalConfig } from '../utils/config.js'; +import { isFullscreenActive } from '../utils/fullscreen.js'; +import { + loadBuddyData, + getActiveCreature, + getCreatureName, + loadSprite, + getFallbackSprite, + renderAnimatedSprite, + getIdleAnimMode, + SPECIES_DATA, + type Creature, + type AnimMode, +} from '@claude-code-best/pokemon'; -const TICK_MS = 500 -const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms -const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go -const PET_BURST_MS = 2500 // how long hearts float after /buddy pet - -// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink. -// Sequence indices map to sprite frames; -1 means "blink on frame 0". -const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0] +const TICK_MS = 500; +const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms +const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go +const PET_BURST_MS = 2500; // how long hearts float after /buddy pet // Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite. -const H = figures.heart +const H = figures.heart; const PET_HEARTS = [ ` ${H} ${H} `, ` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `, `${H} ${H} ${H} `, '· · · ', -] +]; function wrap(text: string, width: number): string[] { - const words = text.split(' ') - const lines: string[] = [] - let cur = '' + const words = text.split(' '); + const lines: string[] = []; + let cur = ''; for (const w of words) { if (cur.length + w.length + 1 > width && cur) { - lines.push(cur) - cur = w + lines.push(cur); + cur = w; } else { - cur = cur ? `${cur} ${w}` : w + cur = cur ? `${cur} ${w}` : w; } } - if (cur) lines.push(cur) - return lines + if (cur) lines.push(cur); + return lines; } -function SpeechBubble({ - text, - color, - fading, - tail, -}: { - text: string - color: keyof Theme - fading: boolean - tail: 'down' | 'right' -}): React.ReactNode { - const lines = wrap(text, 30) - const borderColor = fading ? 'inactive' : color +function SpeechBubble({ text, fading }: { text: string; fading: boolean; tail: 'down' | 'right' }): React.ReactNode { + const lines = wrap(text, 30); + const borderColor = fading ? 'inactive' : 'claude'; const bubble = ( - + {lines.map((l, i) => ( - + {l} ))} - ) - if (tail === 'right') { - return ( - - {bubble} - - - ) - } + ); + return ( + + {bubble} + + + ); +} + +// For fullscreen floating bubble +function FloatingBubble({ text, fading }: { text: string; fading: boolean }): React.ReactNode { + const lines = wrap(text, 30); + const borderColor = fading ? 'inactive' : 'claude'; return ( - {bubble} + + {lines.map((l, i) => ( + + {l} + + ))} + - ) + ); } -export const MIN_COLS_FOR_FULL_SPRITE = 100 -const SPRITE_BODY_WIDTH = 12 -const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name ` -const SPRITE_PADDING_X = 2 -const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column -const NARROW_QUIP_CAP = 24 +export const MIN_COLS_FOR_FULL_SPRITE = 100; +const SPRITE_BODY_WIDTH = 12; +const NAME_ROW_PAD = 2; +const SPRITE_PADDING_X = 2; +const BUBBLE_WIDTH = 36; +const NARROW_QUIP_CAP = 24; function spriteColWidth(nameWidth: number): number { - return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD) + return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD); } -// Width the sprite area consumes. PromptInput subtracts this so text wraps -// correctly. In fullscreen the bubble floats over scrollback (no extra -// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more. -// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row -// (above input in fullscreen, below in scrollback), so no reservation. -export function companionReservedColumns( - terminalColumns: number, - speaking: boolean, -): number { - if (!feature('BUDDY')) return 0 - const companion = getCompanion() - if (!companion || getGlobalConfig().companionMuted) return 0 - if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0 - const nameWidth = stringWidth(companion.name) - const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0 - return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble +/** + * Get active Pokémon creature, or null if buddy system not initialized. + */ +function getPokemonCreature(): Creature | null { + try { + const data = loadBuddyData(); + return getActiveCreature(data); + } catch { + return null; + } +} + +export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { + if (!feature('BUDDY')) return 0; + const creature = getPokemonCreature(); + if (!creature || getGlobalConfig().companionMuted) return 0; + if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0; + const name = getCreatureName(creature); + const nameWidth = stringWidth(name); + const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0; + return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble; +} + +/** + * Get sprite lines for a creature with animated mode applied. + */ +function getAnimatedSpriteLines(creature: Creature, tick: number, mode: AnimMode): string[] { + const cached = loadSprite(creature.speciesId); + const baseLines = cached?.lines ?? getFallbackSprite(creature.speciesId); + return renderAnimatedSprite(baseLines, tick, mode); } export function CompanionSprite(): React.ReactNode { - const reaction = useAppState(s => s.companionReaction) - const petAt = useAppState(s => s.companionPetAt) - const focused = useAppState(s => s.footerSelection === 'companion') - const setAppState = useSetAppState() - const { columns } = useTerminalSize() - const [tick, setTick] = useState(0) - const lastSpokeTick = useRef(0) - // Sync-during-render (not useEffect) so the first post-pet render already - // has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped. + const reaction = useAppState(s => s.companionReaction); + const petAt = useAppState(s => s.companionPetAt); + const focused = useAppState(s => s.footerSelection === 'companion'); + const setAppState = useSetAppState(); + const { columns } = useTerminalSize(); + const [tick, setTick] = useState(0); + const lastSpokeTick = useRef(0); const [{ petStartTick, forPetAt }, setPetStart] = useState({ petStartTick: 0, forPetAt: petAt, - }) + }); if (petAt !== forPetAt) { - setPetStart({ petStartTick: tick, forPetAt: petAt }) + setPetStart({ petStartTick: tick, forPetAt: petAt }); } useEffect(() => { const timer = setInterval( - setT => setT((t: number) => t + 1), + (setT: React.Dispatch>) => setT((t: number) => t + 1), TICK_MS, setTick, - ) - return () => clearInterval(timer) - }, []) + ); + return () => clearInterval(timer); + }, []); useEffect(() => { - if (!reaction) return - lastSpokeTick.current = tick + if (!reaction) return; + lastSpokeTick.current = tick; const timer = setTimeout( - setA => + (setA: React.Dispatch>) => setA((prev: AppState) => - prev.companionReaction === undefined - ? prev - : { ...prev, companionReaction: undefined }, + prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined }, ), BUBBLE_SHOW * TICK_MS, setAppState, - ) - return () => clearTimeout(timer) - // eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked - }, [reaction, setAppState]) + ); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reaction, setAppState]); - if (!feature('BUDDY')) return null - const companion = getCompanion() - if (!companion || getGlobalConfig().companionMuted) return null + if (!feature('BUDDY')) return null; + const creature = getPokemonCreature(); + if (!creature || getGlobalConfig().companionMuted) return null; - const color = RARITY_COLORS[companion.rarity] - const colWidth = spriteColWidth(stringWidth(companion.name)) + const species = SPECIES_DATA[creature.speciesId]; + const name = getCreatureName(creature); + const color = creature.isShiny ? 'warning' : 'claude'; + const colWidth = spriteColWidth(stringWidth(name)); - const bubbleAge = reaction ? tick - lastSpokeTick.current : 0 - const fading = - reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW + const bubbleAge = reaction ? tick - lastSpokeTick.current : 0; + const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW; - const petAge = petAt ? tick - petStartTick : Infinity - const petting = petAge * TICK_MS < PET_BURST_MS + const petAge = petAt ? tick - petStartTick : Infinity; + const petting = petAge * TICK_MS < PET_BURST_MS; - // Narrow terminals: collapse to one-line face. When speaking, the quip - // replaces the name beside the face (no room for a bubble). + // Narrow terminals: collapse to one-line face if (columns < MIN_COLS_FOR_FULL_SPRITE) { const quip = - reaction && reaction.length > NARROW_QUIP_CAP - ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' - : reaction - const label = quip - ? `"${quip}"` - : focused - ? ` ${companion.name} ` - : companion.name + reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; + const label = quip ? `"${quip}"` : focused ? ` ${name} ` : name; return ( {petting && {figures.heart} } - {renderFace(companion)} + {species.names.zh ?? species.name} {' '} {label} - ) + ); } - const frameCount = spriteFrameCount(companion.species) - const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null - let spriteFrame: number - let blink = false + // Determine animation mode + let animMode: AnimMode = 'idle'; if (reaction || petting) { - // Excited: cycle all fidget frames fast - spriteFrame = tick % frameCount + animMode = 'excited'; } else { - const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]! - if (step === -1) { - spriteFrame = 0 - blink = true - } else { - spriteFrame = step % frameCount - } + animMode = getIdleAnimMode(tick); + if (petting) animMode = 'pet'; } - const body = renderSprite(companion, spriteFrame).map(line => - blink ? line.replaceAll(companion.eye, '-') : line, - ) - const sprite = heartFrame ? [heartFrame, ...body] : body + const spriteLines = getAnimatedSpriteLines(creature, tick, animMode); + const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null; + const displayLines = heartFrame ? [heartFrame, ...spriteLines] : spriteLines; - // Name row doubles as hint row — unfocused shows dim name + ↓ discovery, - // focused shows inverse name. The enter-to-open hint lives in - // PromptInputFooter's right column so this row stays one line and the - // sprite doesn't jump up when selected. flexShrink=0 stops the - // inline-bubble row wrapper from squeezing the sprite to fit. const spriteColumn = ( - - {sprite.map((line, i) => ( + + {displayLines.map((line, i) => ( {line} ))} - - {focused ? ` ${companion.name} ` : companion.name} + + {focused ? ` ${name} ` : name} - ) + ); if (!reaction) { - return {spriteColumn} + return {spriteColumn}; } - // Fullscreen: bubble renders separately via CompanionFloatingBubble in - // FullscreenLayout's bottomFloat slot (the bottom slot's overflowY:hidden - // would clip a position:absolute overlay here). Sprite body only. - // Non-fullscreen: bubble sits inline beside the sprite (input shrinks) - // because floating into Static scrollback can't be cleared. if (isFullscreenActive()) { - return {spriteColumn} + return {spriteColumn}; } return ( - + {spriteColumn} - ) + ); } -// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's -// bottomFloat slot (outside the overflowY:hidden clip) so it can extend into -// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this -// just reads companionReaction and renders the fade. +// Floating bubble overlay for fullscreen mode export function CompanionFloatingBubble(): React.ReactNode { - const reaction = useAppState(s => s.companionReaction) + const reaction = useAppState(s => s.companionReaction); const [{ tick, forReaction }, setTick] = useState({ tick: 0, forReaction: reaction, - }) + }); - // Reset tick synchronously when reaction changes (not in useEffect, which - // runs post-render and would show one stale-faded frame). Storing the - // reaction the tick is counting FOR alongside the tick itself means the - // fade computation never sees a tick from a previous reaction. if (reaction !== forReaction) { - setTick({ tick: 0, forReaction: reaction }) + setTick({ tick: 0, forReaction: reaction }); } useEffect(() => { - if (!reaction) return + if (!reaction) return; const timer = setInterval( - set => set(s => ({ ...s, tick: s.tick + 1 })), + (set: React.Dispatch>) => + set(s => ({ ...s, tick: s.tick + 1 })), TICK_MS, setTick, - ) - return () => clearInterval(timer) - }, [reaction]) + ); + return () => clearInterval(timer); + }, [reaction]); - if (!feature('BUDDY') || !reaction) return null - const companion = getCompanion() - if (!companion || getGlobalConfig().companionMuted) return null + if (!feature('BUDDY') || !reaction) return null; + const creature = getPokemonCreature(); + if (!creature || getGlobalConfig().companionMuted) return null; - return ( - = BUBBLE_SHOW - FADE_WINDOW} - tail="down" - /> - ) + return = BUBBLE_SHOW - FADE_WINDOW} />; } diff --git a/src/buddy/companion.ts b/src/buddy/companion.ts deleted file mode 100644 index a0fa798cd..000000000 --- a/src/buddy/companion.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { getGlobalConfig } from '../utils/config.js' -import { - type Companion, - type CompanionBones, - EYES, - HATS, - RARITIES, - RARITY_WEIGHTS, - type Rarity, - SPECIES, - STAT_NAMES, - type StatName, -} from './types.js' - -// 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 - } -} - -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 -} - -function pick(rng: () => number, arr: readonly T[]): T { - return arr[Math.floor(rng() * arr.length)]! -} - -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' -} - -const RARITY_FLOOR: Record = { - common: 5, - uncommon: 15, - rare: 25, - epic: 35, - legendary: 50, -} - -// One peak stat, one dump stat, rest scattered. Rarity bumps the floor. -function rollStats( - rng: () => number, - rarity: Rarity, -): Record { - 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 - 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 -} - -const SALT = 'friend-2026-401' - -export type Roll = { - bones: CompanionBones - inspirationSeed: number -} - -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), - shiny: rng() < 0.01, - stats: rollStats(rng, rarity), - } - return { bones, inspirationSeed: Math.floor(rng() * 1e9) } -} - -// 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 -} - -export function rollWithSeed(seed: string): Roll { - return rollFrom(mulberry32(hashString(seed))) -} - -export function generateSeed(): string { - return `rehatch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` -} - -export function companionUserId(): string { - const config = getGlobalConfig() - return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon' -} - -// Regenerate bones from seed or userId, merge with stored soul. -export function getCompanion(): Companion | undefined { - const stored = getGlobalConfig().companion - if (!stored) return undefined - const seed = stored.seed ?? companionUserId() - const { bones } = rollWithSeed(seed) - // bones last so stale bones fields in old-format configs get overridden - return { ...stored, ...bones } -} diff --git a/src/buddy/companionReact.ts b/src/buddy/companionReact.ts index 021167e0d..85fae9f12 100644 --- a/src/buddy/companionReact.ts +++ b/src/buddy/companionReact.ts @@ -1,21 +1,28 @@ /** - * Companion reaction system — aligns with official ZUK + Dc8 pattern. + * Companion reaction system — adapted for Pokémon buddy system. * * Called from REPL.tsx after each query turn. Checks mute state, frequency * limits, and @-mention detection, then calls the buddy_react API to * generate a reaction shown in the CompanionSprite speech bubble. */ -import { getCompanion } from './companion.js' import { getGlobalConfig } from '../utils/config.js' import { getClaudeAIOAuthTokens } from '../utils/auth.js' import { getOauthConfig } from '../constants/oauth.js' import { getUserAgent } from '../utils/http.js' import type { Message } from '../types/message.js' +import { + loadBuddyData, + getActiveCreature, + getCreatureName, + calculateStats, + SPECIES_DATA, + type Creature, +} from '@claude-code-best/pokemon' // ─── Rate limiting ────────────────────────────────── let lastReactTime = 0 -const MIN_INTERVAL_MS = 45_000 // official is roughly 30-60s +const MIN_INTERVAL_MS = 45_000 // ─── Recent reactions (avoid repetition) ──────────── @@ -26,23 +33,17 @@ const MAX_RECENT = 8 /** * Trigger a companion reaction after a query turn. - * - * Mirrors official `ZUK()`: - * 1. Check companion exists and is not muted - * 2. Detect if user @-mentioned companion by name - * 3. Apply rate limiting (skip if not addressed and too soon) - * 4. Build conversation transcript - * 5. Call buddy_react API - * 6. Pass reaction text to setReaction callback */ export function triggerCompanionReaction( messages: Message[], setReaction: (text: string | undefined) => void, ): void { - const companion = getCompanion() - if (!companion || getGlobalConfig().companionMuted) return + const data = loadBuddyData() + const creature = getActiveCreature(data) + if (!creature || getGlobalConfig().companionMuted) return - const addressed = isAddressed(messages, companion.name) + const name = getCreatureName(creature) + const addressed = isAddressed(messages, name) const now = Date.now() if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return @@ -52,7 +53,7 @@ export function triggerCompanionReaction( lastReactTime = now - void callBuddyReactAPI(companion, transcript, addressed) + void callBuddyReactAPI(creature, transcript, addressed) .then(reaction => { if (!reaction) return recentReactions.push(reaction) @@ -109,13 +110,7 @@ function buildTranscript(messages: Message[]): string { // ─── API call ─────────────────────────────────────── async function callBuddyReactAPI( - companion: { - name: string - personality: string - species: string - rarity: string - stats: Record - }, + creature: Creature, transcript: string, addressed: boolean, ): Promise { @@ -125,6 +120,10 @@ async function callBuddyReactAPI( const orgId = getGlobalConfig().oauthAccount?.organizationUuid if (!orgId) return null + const species = SPECIES_DATA[creature.speciesId] + const name = getCreatureName(creature) + const stats = calculateStats(creature) + const baseUrl = getOauthConfig().BASE_API_URL const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react` @@ -136,11 +135,25 @@ async function callBuddyReactAPI( 'User-Agent': getUserAgent(), }, body: JSON.stringify({ - name: companion.name.slice(0, 32), - personality: companion.personality.slice(0, 200), - species: companion.species, - rarity: companion.rarity, - stats: companion.stats, + name: name.slice(0, 32), + personality: species.personality.slice(0, 200), + species: creature.speciesId, + rarity: creature.isShiny + ? 'legendary' + : creature.level >= 36 + ? 'epic' + : creature.level >= 16 + ? 'rare' + : 'common', + stats: { + HP: stats.hp, + ATK: stats.attack, + DEF: stats.defense, + SPA: stats.spAtk, + SPD: stats.spDef, + SPE: stats.speed, + Level: creature.level, + }, transcript, reason: addressed ? 'addressed' : 'turn', recent: recentReactions.map(r => r.slice(0, 200)), diff --git a/src/buddy/prompt.ts b/src/buddy/prompt.ts index 2203702c9..ab49cf9e7 100644 --- a/src/buddy/prompt.ts +++ b/src/buddy/prompt.ts @@ -2,12 +2,17 @@ import { feature } from 'bun:bundle' import type { Message } from '../types/message.js' import type { Attachment } from '../utils/attachments.js' import { getGlobalConfig } from '../utils/config.js' -import { getCompanion } from './companion.js' +import { + loadBuddyData, + getActiveCreature, + getCreatureName, + SPECIES_DATA, +} from '@claude-code-best/pokemon' export function companionIntroText(name: string, species: string): string { return `# Companion -A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher. +A ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. 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: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.` } @@ -16,21 +21,25 @@ export function getCompanionIntroAttachment( messages: Message[] | undefined, ): Attachment[] { if (!feature('BUDDY')) return [] - const companion = getCompanion() - if (!companion || getGlobalConfig().companionMuted) return [] + const data = loadBuddyData() + const creature = getActiveCreature(data) + if (!creature || getGlobalConfig().companionMuted) return [] + + const name = getCreatureName(creature) + const species = SPECIES_DATA[creature.speciesId] // Skip if already announced for this companion. for (const msg of messages ?? []) { if (msg.type !== 'attachment') continue - if (msg.attachment!.type !== 'companion_intro') continue - if (msg.attachment!.name === companion.name) return [] + if ((msg as any).attachment?.type !== 'companion_intro') continue + if ((msg as any).attachment?.name === name) return [] } return [ { type: 'companion_intro', - name: companion.name, - species: companion.species, + name, + species: species.names.zh ?? species.name, }, ] } diff --git a/src/buddy/sprites.ts b/src/buddy/sprites.ts deleted file mode 100644 index 0150b8cf1..000000000 --- a/src/buddy/sprites.ts +++ /dev/null @@ -1,514 +0,0 @@ -import type { CompanionBones, Eye, Hat, Species } from './types.js' -import { - axolotl, - blob, - cactus, - capybara, - cat, - chonk, - dragon, - duck, - ghost, - goose, - mushroom, - octopus, - owl, - penguin, - rabbit, - robot, - snail, - turtle, -} from './types.js' - -// Each sprite is 5 lines tall, 12 wide (after {E}→1char substitution). -// Multiple frames per species for idle fidget animation. -// Line 0 is the hat slot — must be blank in frames 0-1; frame 2 may use it. -const BODIES: Record = { - [duck]: [ - [ - ' ', - ' __ ', - ' <({E} )___ ', - ' ( ._> ', - ' `--´ ', - ], - [ - ' ', - ' __ ', - ' <({E} )___ ', - ' ( ._> ', - ' `--´~ ', - ], - [ - ' ', - ' __ ', - ' <({E} )___ ', - ' ( .__> ', - ' `--´ ', - ], - ], - [goose]: [ - [ - ' ', - ' ({E}> ', - ' || ', - ' _(__)_ ', - ' ^^^^ ', - ], - [ - ' ', - ' ({E}> ', - ' || ', - ' _(__)_ ', - ' ^^^^ ', - ], - [ - ' ', - ' ({E}>> ', - ' || ', - ' _(__)_ ', - ' ^^^^ ', - ], - ], - [blob]: [ - [ - ' ', - ' .----. ', - ' ( {E} {E} ) ', - ' ( ) ', - ' `----´ ', - ], - [ - ' ', - ' .------. ', - ' ( {E} {E} ) ', - ' ( ) ', - ' `------´ ', - ], - [ - ' ', - ' .--. ', - ' ({E} {E}) ', - ' ( ) ', - ' `--´ ', - ], - ], - [cat]: [ - [ - ' ', - ' /\\_/\\ ', - ' ( {E} {E}) ', - ' ( ω ) ', - ' (")_(") ', - ], - [ - ' ', - ' /\\_/\\ ', - ' ( {E} {E}) ', - ' ( ω ) ', - ' (")_(")~ ', - ], - [ - ' ', - ' /\\-/\\ ', - ' ( {E} {E}) ', - ' ( ω ) ', - ' (")_(") ', - ], - ], - [dragon]: [ - [ - ' ', - ' /^\\ /^\\ ', - ' < {E} {E} > ', - ' ( ~~ ) ', - ' `-vvvv-´ ', - ], - [ - ' ', - ' /^\\ /^\\ ', - ' < {E} {E} > ', - ' ( ) ', - ' `-vvvv-´ ', - ], - [ - ' ~ ~ ', - ' /^\\ /^\\ ', - ' < {E} {E} > ', - ' ( ~~ ) ', - ' `-vvvv-´ ', - ], - ], - [octopus]: [ - [ - ' ', - ' .----. ', - ' ( {E} {E} ) ', - ' (______) ', - ' /\\/\\/\\/\\ ', - ], - [ - ' ', - ' .----. ', - ' ( {E} {E} ) ', - ' (______) ', - ' \\/\\/\\/\\/ ', - ], - [ - ' o ', - ' .----. ', - ' ( {E} {E} ) ', - ' (______) ', - ' /\\/\\/\\/\\ ', - ], - ], - [owl]: [ - [ - ' ', - ' /\\ /\\ ', - ' (({E})({E})) ', - ' ( >< ) ', - ' `----´ ', - ], - [ - ' ', - ' /\\ /\\ ', - ' (({E})({E})) ', - ' ( >< ) ', - ' .----. ', - ], - [ - ' ', - ' /\\ /\\ ', - ' (({E})(-)) ', - ' ( >< ) ', - ' `----´ ', - ], - ], - [penguin]: [ - [ - ' ', - ' .---. ', - ' ({E}>{E}) ', - ' /( )\\ ', - ' `---´ ', - ], - [ - ' ', - ' .---. ', - ' ({E}>{E}) ', - ' |( )| ', - ' `---´ ', - ], - [ - ' .---. ', - ' ({E}>{E}) ', - ' /( )\\ ', - ' `---´ ', - ' ~ ~ ', - ], - ], - [turtle]: [ - [ - ' ', - ' _,--._ ', - ' ( {E} {E} ) ', - ' /[______]\\ ', - ' `` `` ', - ], - [ - ' ', - ' _,--._ ', - ' ( {E} {E} ) ', - ' /[______]\\ ', - ' `` `` ', - ], - [ - ' ', - ' _,--._ ', - ' ( {E} {E} ) ', - ' /[======]\\ ', - ' `` `` ', - ], - ], - [snail]: [ - [ - ' ', - ' {E} .--. ', - ' \\ ( @ ) ', - ' \\_`--´ ', - ' ~~~~~~~ ', - ], - [ - ' ', - ' {E} .--. ', - ' | ( @ ) ', - ' \\_`--´ ', - ' ~~~~~~~ ', - ], - [ - ' ', - ' {E} .--. ', - ' \\ ( @ ) ', - ' \\_`--´ ', - ' ~~~~~~ ', - ], - ], - [ghost]: [ - [ - ' ', - ' .----. ', - ' / {E} {E} \\ ', - ' | | ', - ' ~`~``~`~ ', - ], - [ - ' ', - ' .----. ', - ' / {E} {E} \\ ', - ' | | ', - ' `~`~~`~` ', - ], - [ - ' ~ ~ ', - ' .----. ', - ' / {E} {E} \\ ', - ' | | ', - ' ~~`~~`~~ ', - ], - ], - [axolotl]: [ - [ - ' ', - '}~(______)~{', - '}~({E} .. {E})~{', - ' ( .--. ) ', - ' (_/ \\_) ', - ], - [ - ' ', - '~}(______){~', - '~}({E} .. {E}){~', - ' ( .--. ) ', - ' (_/ \\_) ', - ], - [ - ' ', - '}~(______)~{', - '}~({E} .. {E})~{', - ' ( -- ) ', - ' ~_/ \\_~ ', - ], - ], - [capybara]: [ - [ - ' ', - ' n______n ', - ' ( {E} {E} ) ', - ' ( oo ) ', - ' `------´ ', - ], - [ - ' ', - ' n______n ', - ' ( {E} {E} ) ', - ' ( Oo ) ', - ' `------´ ', - ], - [ - ' ~ ~ ', - ' u______n ', - ' ( {E} {E} ) ', - ' ( oo ) ', - ' `------´ ', - ], - ], - [cactus]: [ - [ - ' ', - ' n ____ n ', - ' | |{E} {E}| | ', - ' |_| |_| ', - ' | | ', - ], - [ - ' ', - ' ____ ', - ' n |{E} {E}| n ', - ' |_| |_| ', - ' | | ', - ], - [ - ' n n ', - ' | ____ | ', - ' | |{E} {E}| | ', - ' |_| |_| ', - ' | | ', - ], - ], - [robot]: [ - [ - ' ', - ' .[||]. ', - ' [ {E} {E} ] ', - ' [ ==== ] ', - ' `------´ ', - ], - [ - ' ', - ' .[||]. ', - ' [ {E} {E} ] ', - ' [ -==- ] ', - ' `------´ ', - ], - [ - ' * ', - ' .[||]. ', - ' [ {E} {E} ] ', - ' [ ==== ] ', - ' `------´ ', - ], - ], - [rabbit]: [ - [ - ' ', - ' (\\__/) ', - ' ( {E} {E} ) ', - ' =( .. )= ', - ' (")__(") ', - ], - [ - ' ', - ' (|__/) ', - ' ( {E} {E} ) ', - ' =( .. )= ', - ' (")__(") ', - ], - [ - ' ', - ' (\\__/) ', - ' ( {E} {E} ) ', - ' =( . . )= ', - ' (")__(") ', - ], - ], - [mushroom]: [ - [ - ' ', - ' .-o-OO-o-. ', - '(__________)', - ' |{E} {E}| ', - ' |____| ', - ], - [ - ' ', - ' .-O-oo-O-. ', - '(__________)', - ' |{E} {E}| ', - ' |____| ', - ], - [ - ' . o . ', - ' .-o-OO-o-. ', - '(__________)', - ' |{E} {E}| ', - ' |____| ', - ], - ], - [chonk]: [ - [ - ' ', - ' /\\ /\\ ', - ' ( {E} {E} ) ', - ' ( .. ) ', - ' `------´ ', - ], - [ - ' ', - ' /\\ /| ', - ' ( {E} {E} ) ', - ' ( .. ) ', - ' `------´ ', - ], - [ - ' ', - ' /\\ /\\ ', - ' ( {E} {E} ) ', - ' ( .. ) ', - ' `------´~ ', - ], - ], -} - -const HAT_LINES: Record = { - none: '', - crown: ' \\^^^/ ', - tophat: ' [___] ', - propeller: ' -+- ', - halo: ' ( ) ', - wizard: ' /^\\ ', - beanie: ' (___) ', - tinyduck: ' ,> ', -} - -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] - // Only replace with hat if line 0 is empty (some fidget frames use it for smoke etc) - if (bones.hat !== 'none' && !lines[0]!.trim()) { - lines[0] = HAT_LINES[bones.hat] - } - // Drop blank hat slot — wastes a row in the Card and ambient sprite when - // there's no hat and the frame isn't using it for smoke/antenna/etc. - // Only safe when ALL frames have blank line 0; otherwise heights oscillate. - if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift() - return lines -} - -export function spriteFrameCount(species: Species): number { - return BODIES[species].length -} - -export function renderFace(bones: CompanionBones): string { - const eye: Eye = bones.eye - switch (bones.species) { - case duck: - case goose: - return `(${eye}>` - case blob: - return `(${eye}${eye})` - case cat: - return `=${eye}ω${eye}=` - case dragon: - return `<${eye}~${eye}>` - case octopus: - return `~(${eye}${eye})~` - case owl: - return `(${eye})(${eye})` - case penguin: - return `(${eye}>)` - case turtle: - return `[${eye}_${eye}]` - case snail: - return `${eye}(@)` - case ghost: - return `/${eye}${eye}\\` - case axolotl: - return `}${eye}.${eye}{` - case capybara: - return `(${eye}oo${eye})` - case cactus: - return `|${eye} ${eye}|` - case robot: - return `[${eye}${eye}]` - case rabbit: - return `(${eye}..${eye})` - case mushroom: - return `|${eye} ${eye}|` - case chonk: - return `(${eye}.${eye})` - } -} diff --git a/src/buddy/types.ts b/src/buddy/types.ts deleted file mode 100644 index 46c53c767..000000000 --- a/src/buddy/types.ts +++ /dev/null @@ -1,149 +0,0 @@ -export const RARITIES = [ - 'common', - 'uncommon', - 'rare', - 'epic', - 'legendary', -] as const -export type Rarity = (typeof RARITIES)[number] - -// 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. -// All species encoded uniformly; `as` casts are type-position only (erased pre-bundle). -const c = String.fromCharCode -// biome-ignore format: keep the species list compact - -export const duck = c(0x64,0x75,0x63,0x6b) as 'duck' -export const goose = c(0x67, 0x6f, 0x6f, 0x73, 0x65) as 'goose' -export const blob = c(0x62, 0x6c, 0x6f, 0x62) as 'blob' -export const cat = c(0x63, 0x61, 0x74) as 'cat' -export const dragon = c(0x64, 0x72, 0x61, 0x67, 0x6f, 0x6e) as 'dragon' -export const octopus = c(0x6f, 0x63, 0x74, 0x6f, 0x70, 0x75, 0x73) as 'octopus' -export const owl = c(0x6f, 0x77, 0x6c) as 'owl' -export const penguin = c(0x70, 0x65, 0x6e, 0x67, 0x75, 0x69, 0x6e) as 'penguin' -export const turtle = c(0x74, 0x75, 0x72, 0x74, 0x6c, 0x65) as 'turtle' -export const snail = c(0x73, 0x6e, 0x61, 0x69, 0x6c) as 'snail' -export const ghost = c(0x67, 0x68, 0x6f, 0x73, 0x74) as 'ghost' -export const axolotl = c(0x61, 0x78, 0x6f, 0x6c, 0x6f, 0x74, 0x6c) as 'axolotl' -export const capybara = c( - 0x63, - 0x61, - 0x70, - 0x79, - 0x62, - 0x61, - 0x72, - 0x61, -) as 'capybara' -export const cactus = c(0x63, 0x61, 0x63, 0x74, 0x75, 0x73) as 'cactus' -export const robot = c(0x72, 0x6f, 0x62, 0x6f, 0x74) as 'robot' -export const rabbit = c(0x72, 0x61, 0x62, 0x62, 0x69, 0x74) as 'rabbit' -export const mushroom = c( - 0x6d, - 0x75, - 0x73, - 0x68, - 0x72, - 0x6f, - 0x6f, - 0x6d, -) as 'mushroom' -export const chonk = c(0x63, 0x68, 0x6f, 0x6e, 0x6b) as 'chonk' - -export const SPECIES = [ - duck, - goose, - blob, - cat, - dragon, - octopus, - owl, - penguin, - turtle, - snail, - ghost, - axolotl, - capybara, - cactus, - robot, - rabbit, - mushroom, - chonk, -] as const -export type Species = (typeof SPECIES)[number] // biome-ignore format: keep compact - -export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const -export type Eye = (typeof EYES)[number] - -export const HATS = [ - 'none', - 'crown', - 'tophat', - 'propeller', - 'halo', - 'wizard', - 'beanie', - 'tinyduck', -] as const -export type Hat = (typeof HATS)[number] - -export const STAT_NAMES = [ - 'DEBUGGING', - 'PATIENCE', - 'CHAOS', - 'WISDOM', - 'SNARK', -] as const -export type StatName = (typeof STAT_NAMES)[number] - -// Deterministic parts — derived from hash(userId) -export type CompanionBones = { - rarity: Rarity - species: Species - eye: Eye - hat: Hat - shiny: boolean - stats: Record -} - -// Model-generated soul — stored in config after first hatch -export type CompanionSoul = { - name: string - personality: string - seed?: string -} - -export type Companion = CompanionBones & - CompanionSoul & { - hatchedAt: number - } - -// What actually persists in config. Bones are regenerated from hash(userId) -// on every read so species renames don't break stored companions and users -// can't edit their way to a legendary. -export type StoredCompanion = CompanionSoul & { hatchedAt: number } - -export const RARITY_WEIGHTS = { - common: 60, - uncommon: 25, - rare: 10, - epic: 4, - legendary: 1, -} as const satisfies Record - -export const RARITY_STARS = { - common: '★', - uncommon: '★★', - rare: '★★★', - epic: '★★★★', - legendary: '★★★★★', -} as const satisfies Record - -export const RARITY_COLORS = { - common: 'inactive', - uncommon: 'success', - rare: 'permission', - epic: 'autoAccept', - legendary: 'warning', -} as const satisfies Record diff --git a/src/commands/buddy/BuddyPanel.tsx b/src/commands/buddy/BuddyPanel.tsx new file mode 100644 index 000000000..12454c04b --- /dev/null +++ b/src/commands/buddy/BuddyPanel.tsx @@ -0,0 +1,403 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { Box, Text, Pane, Tab, Tabs, type Color } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { + STAT_NAMES, + STAT_LABELS, + ALL_SPECIES_IDS, + type BuddyData, + type Creature, + type SpeciesId, +} from '@claude-code-best/pokemon'; +import { SPECIES_DATA } from '@claude-code-best/pokemon'; +import { SPECIES_PERSONALITY } from '@claude-code-best/pokemon'; +import { getNextEvolution } from '@claude-code-best/pokemon'; +import { calculateStats, getCreatureName, getTotalEV, getActiveCreature } from '@claude-code-best/pokemon'; +import { getXpProgress } from '@claude-code-best/pokemon'; +import { getEVSummary } from '@claude-code-best/pokemon'; +import { getGenderSymbol } from '@claude-code-best/pokemon'; +import { StatBar } from '@claude-code-best/pokemon'; +import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; + +const CYAN: Color = 'ansi:cyan'; +const YELLOW: Color = 'ansi:yellow'; +const GREEN: Color = 'ansi:green'; +const BLUE: Color = 'ansi:blue'; +const RED: Color = 'ansi:red'; +const MAGENTA: Color = 'ansi:magenta'; +const WHITE: Color = 'ansi:whiteBright'; +const GRAY: Color = 'ansi:white'; + +const TYPE_COLORS: Record = { + grass: 'ansi:green', + poison: 'ansi:magenta', + fire: 'ansi:red', + flying: 'ansi:cyan', + water: 'ansi:blue', + electric: 'ansi:yellow', + normal: 'ansi:white', +}; + +interface BuddyPanelProps { + buddyData: BuddyData; + spriteLines?: string[]; + onClose: LocalJSXCommandOnDone; +} + +/** + * Unified buddy panel with tabs — same pattern as Settings. + * ESC closes, ←/→ switch tabs, Ctrl+C/D double-press exits. + */ +export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) { + const [selectedTab, setSelectedTab] = useState('Buddy'); + + useExitOnCtrlCDWithKeybindings(); + + const handleEscape = () => { + onClose('buddy panel closed'); + }; + + useKeybinding('confirm:no', handleEscape, { + context: 'Settings', + isActive: true, + }); + + const creature = getActiveCreature(buddyData); + + const tabs = [ + + {creature ? ( + + ) : ( + No buddy yet. Keep coding! + )} + , + + + , + + + , + ]; + + return ( + + + {tabs} + + + ); +} + +// ─── Buddy Tab ──────────────────────────────────────── + +function BuddyTab({ + creature, + buddyData, + spriteLines, +}: { + creature: Creature; + buddyData: BuddyData; + spriteLines?: string[]; +}) { + const species = SPECIES_DATA[creature.speciesId]; + const stats = calculateStats(creature); + const xp = getXpProgress(creature); + const genderSymbol = getGenderSymbol(creature.gender); + const name = getCreatureName(creature); + const evSummary = getEVSummary(creature); + const totalEV = getTotalEV(creature); + const nextEvo = getNextEvolution(creature.speciesId); + + const typeBadges = species.types + .filter((t): t is string => Boolean(t)) + .map((t, i) => ( + + {i > 0 && /} + {t.toUpperCase()} + + )); + + const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED; + const shinyBadge = creature.isShiny ? ★SHINY★ : null; + const evoHint = nextEvo ? ( + + {' '} + → {SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name} Lv. + {nextEvo.minLevel} + + ) : null; + + return ( + + + + + {name} + + #{String(species.dexNumber).padStart(3, '0')} + {shinyBadge} + + Lv.{creature.level} + + + + {species.names.zh ?? species.name} + + {typeBadges} + {genderSymbol && {genderSymbol}} + + + {spriteLines && ( + + {spriteLines.map((line, i) => ( + {line} + ))} + + )} + + + + "{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}" + + + + + ─── Stats ─── + {STAT_NAMES.map(stat => ( + + ))} + + + + XP + + {'█'.repeat(Math.round(xp.percentage / 10))} + {'░'.repeat(10 - Math.round(xp.percentage / 10))} + + + {' '} + {xp.current}/{xp.needed} + + + + + + EV + = 510 ? GREEN : GRAY}>{evSummary} + ({totalEV}/510) + + + + + {'█'.repeat(Math.round((creature.friendship / 255) * 10))} + {'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))} + + {creature.friendship}/255 + + + + {evoHint && ( + + Next: + {evoHint} + + )} + + ); +} + +// ─── Dex Tab ────────────────────────────────────────── + +function DexTab({ buddyData }: { buddyData: BuddyData }) { + const dexMap = new Map(buddyData.dex.map(d => [d.speciesId, d])); + const collected = buddyData.dex.length; + const total = ALL_SPECIES_IDS.length; + const chains = groupByChain(); + + return ( + + + + Pokédex + + + + {collected} + + /{total} + + + + + {'█'.repeat(collected)} + {'░'.repeat(total - collected)} + {Math.floor((collected / total) * 100)}% + + + {chains.map((chain, ci) => ( + + {chain.map((speciesId, si) => { + const species = SPECIES_DATA[speciesId]; + const entry = dexMap.get(speciesId); + const discovered = !!entry; + const isActive = buddyData.activeCreatureId + ? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === speciesId) + : false; + const nextEvo = getNextEvolution(speciesId); + + return ( + + {si === 0 ? ' ' : '├'} + {isActive ? : ' '} + #{String(species.dexNumber).padStart(3, '0')} + + {discovered ? (species.names.zh ?? species.name) : '???'} + + {discovered && ( + + {' '} + {species.types + .filter((t): t is string => Boolean(t)) + .map((t, ti) => ( + + {ti > 0 && /} + {t.slice(0, 3).toUpperCase()} + + ))} + + )} + {discovered && entry ? ( + Lv.{entry.bestLevel} + ) : ( + ─── + )} + {nextEvo && ( + + {' '} + →Lv.{nextEvo.minLevel} + + )} + + ); + })} + + ))} + + + ─── Stats ─── + + Turns: + {buddyData.stats.totalTurns} + Days: + {buddyData.stats.consecutiveDays} + + + Eggs: + {buddyData.stats.totalEggsObtained} + Evolutions: + {buddyData.stats.totalEvolutions} + + + + {buddyData.eggs.length > 0 && ( + + 🥚 Egg: + + {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} + + steps + + )} + + ); +} + +// ─── Egg Tab ────────────────────────────────────────── + +function EggTab({ buddyData }: { buddyData: BuddyData }) { + const eggs = buddyData.eggs; + + if (eggs.length === 0) { + return ( + + + Egg + + No egg currently. Keep coding! + {buddyData.stats.consecutiveDays < 7 && ( + Next egg: {7 - buddyData.stats.consecutiveDays} more days + )} + + ); + } + + const egg = eggs[0]!; + const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100); + const filled = Math.round(percentage / 10); + const empty = 10 - filled; + + return ( + + + Egg Status + + + + . + / \ + | | + \_/ + + + + + Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps} + + + {'█'.repeat(filled)} + {'░'.repeat(empty)} + + {percentage}% + + + + Pet (+5) · Chat (+3) · Cmd (+1) + Hatch: ~{egg.stepsRemaining} more interactions + + + + ─── Egg Stats ─── + + Total eggs: + {buddyData.stats.totalEggsObtained} + + + + ); +} + +// ─── Helpers ────────────────────────────────────────── + +function getStatColor(stat: string): Color { + const colors: Record = { + hp: 'ansi:green', + attack: 'ansi:red', + defense: 'ansi:yellow', + spAtk: 'ansi:blue', + spDef: 'ansi:magenta', + speed: 'ansi:cyan', + }; + return colors[stat] ?? 'ansi:white'; +} + +function groupByChain(): SpeciesId[][] { + return [ + ['bulbasaur', 'ivysaur', 'venusaur'], + ['charmander', 'charmeleon', 'charizard'], + ['squirtle', 'wartortle', 'blastoise'], + ['pikachu'], + ]; +} diff --git a/src/commands/buddy/buddy.ts b/src/commands/buddy/buddy.ts index d54a92d93..7ad22ff70 100644 --- a/src/commands/buddy/buddy.ts +++ b/src/commands/buddy/buddy.ts @@ -1,12 +1,4 @@ import React from 'react' -import { - getCompanion, - rollWithSeed, - generateSeed, -} from '../../buddy/companion.js' -import { type StoredCompanion, RARITY_STARS } from '../../buddy/types.js' -import { renderSprite } from '../../buddy/sprites.js' -import { CompanionCard } from '../../buddy/CompanionCard.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { triggerCompanionReaction } from '../../buddy/companionReact.js' import type { ToolUseContext } from '../../Tool.js' @@ -14,57 +6,46 @@ import type { LocalJSXCommandContext, LocalJSXCommandOnDone, } from '../../types/command.js' +import { + loadBuddyData, + saveBuddyData, + getDefaultBuddyData, + migrateFromLegacy, + getActiveCreature, + getCreatureName, + awardXP, + advanceEggSteps, + checkEvolution, + checkEggEligibility, + generateEgg, + isEggReadyToHatch, + hatchEgg, + fetchAndCacheSprite, + loadSprite, + getFallbackSprite, + SPECIES_DATA, + type BuddyData, + type Creature, +} from '@claude-code-best/pokemon' +import { BuddyPanel } from './BuddyPanel.js' -// Species → default name fragments for hatch (no API needed) -const SPECIES_NAMES: Record = { - duck: 'Waddles', - goose: 'Goosberry', - blob: 'Gooey', - cat: 'Whiskers', - dragon: 'Ember', - octopus: 'Inky', - owl: 'Hoots', - penguin: 'Waddleford', - turtle: 'Shelly', - snail: 'Trailblazer', - ghost: 'Casper', - axolotl: 'Axie', - capybara: 'Chill', - cactus: 'Spike', - robot: 'Byte', - rabbit: 'Flops', - mushroom: 'Spore', - chonk: 'Chonk', -} +/** + * Load or initialize Pokémon buddy data. + * Migrates from legacy buddy system if needed. + */ +function getOrInitBuddyData(): BuddyData { + let data = loadBuddyData() -const SPECIES_PERSONALITY: Record = { - duck: 'Quirky and easily amused. Leaves rubber duck debugging tips everywhere.', - goose: 'Assertive and honks at bad code. Takes no prisoners in code reviews.', - blob: 'Adaptable and goes with the flow. Sometimes splits into two when confused.', - cat: 'Independent and judgmental. Watches you type with mild disdain.', - dragon: - 'Fiery and passionate about architecture. Hoards good variable names.', - octopus: - 'Multitasker extraordinaire. Wraps tentacles around every problem at once.', - owl: 'Wise but verbose. Always says "let me think about that" for exactly 3 seconds.', - penguin: 'Cool under pressure. Slides gracefully through merge conflicts.', - turtle: 'Patient and thorough. Believes slow and steady wins the deploy.', - snail: 'Methodical and leaves a trail of useful comments. Never rushes.', - ghost: - 'Ethereal and appears at the worst possible moments with spooky insights.', - axolotl: 'Regenerative and cheerful. Recovers from any bug with a smile.', - capybara: 'Zen master. Remains calm while everything around is on fire.', - cactus: - 'Prickly on the outside but full of good intentions. Thrives on neglect.', - robot: 'Efficient and literal. Processes feedback in binary.', - rabbit: 'Energetic and hops between tasks. Finishes before you start.', - mushroom: 'Quietly insightful. Grows on you over time.', - chonk: - 'Big, warm, and takes up the whole couch. Prioritizes comfort over elegance.', -} + // If no active creature, check for legacy companion to migrate + if (!data.activeCreatureId || data.creatures.length === 0) { + const legacyCompanion = getGlobalConfig().companion + if (legacyCompanion) { + data = migrateFromLegacy(legacyCompanion) + saveBuddyData(data) + } + } -function speciesLabel(species: string): string { - return species.charAt(0).toUpperCase() + species.slice(1) + return data } export async function call( @@ -89,18 +70,45 @@ export async function call( return null } - // ── /buddy pet — trigger heart animation + auto unmute ── + // ── /buddy pet — trigger heart animation + XP + egg steps ── if (sub === 'pet') { - const companion = getCompanion() - if (!companion) { - onDone('no companion yet \u00b7 run /buddy first', { display: 'system' }) + const data = getOrInitBuddyData() + const creature = getActiveCreature(data) + if (!creature) { + onDone('no companion yet · run /buddy first', { display: 'system' }) return null } - // Auto-unmute on pet + trigger heart animation + // Auto-unmute + heart animation saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false })) setState?.(prev => ({ ...prev, companionPetAt: Date.now() })) + // Award pet XP + const result = awardXP(creature, 2) + data.creatures = data.creatures.map(c => + c.id === creature.id ? result.creature : c, + ) + + // Advance egg steps + if (data.eggs.length > 0) { + data.eggs = data.eggs.map(egg => advanceEggSteps(egg, 5)) + + // Check hatch + const readyEgg = data.eggs.find(isEggReadyToHatch) + if (readyEgg) { + const { buddyData: updatedData, creature: newCreature } = hatchEgg( + data, + readyEgg, + ) + Object.assign(data, updatedData) + onDone(`🥚 Egg hatched! You got a ${getCreatureName(newCreature)}!`, { + display: 'system', + }) + } + } + + saveBuddyData(data) + // Trigger a post-pet reaction triggerCompanionReaction(context.messages ?? [], reaction => setState?.(prev => @@ -110,60 +118,111 @@ export async function call( ), ) - onDone(`petted ${companion.name}`, { display: 'system' }) + if (!data.eggs.find(isEggReadyToHatch)) { + onDone(`petted ${getCreatureName(creature)} (+2 XP)`, { + display: 'system', + }) + } return null } - // ── /buddy (no args) — show existing or hatch ── - const companion = getCompanion() + // ── /buddy rename — rename current creature ── + if (sub.startsWith('rename ')) { + const nickname = sub.slice(7).trim() + if (!nickname) { + onDone('Usage: /buddy rename ', { display: 'system' }) + return null + } + const data = getOrInitBuddyData() + const creature = getActiveCreature(data) + if (!creature) { + onDone('no companion yet · run /buddy first', { display: 'system' }) + return null + } + data.creatures = data.creatures.map(c => + c.id === creature.id ? { ...c, nickname } : c, + ) + saveBuddyData(data) + onDone(`renamed to "${nickname}"`, { display: 'system' }) + return null + } + + // ── /buddy switch — switch active creature ── + if (sub === 'switch') { + const data = getOrInitBuddyData() + if (data.creatures.length <= 1) { + onDone('You only have one buddy!', { display: 'system' }) + return null + } + const lines = data.creatures.map((c, i) => { + const name = getCreatureName(c) + const species = SPECIES_DATA[c.speciesId] + const active = c.id === data.activeCreatureId ? ' ← active' : '' + return `${i + 1}. ${name} (${species.names.zh ?? species.name}) Lv.${c.level}${active}` + }) + onDone( + ['Switch buddy:', ...lines, '', 'Use: /buddy switch '].join('\n'), + { display: 'system' }, + ) + return null + } + + if (sub.startsWith('switch ')) { + const num = parseInt(sub.slice(7).trim(), 10) + const data = getOrInitBuddyData() + if (isNaN(num) || num < 1 || num > data.creatures.length) { + onDone('Invalid number. Use /buddy switch to see list.', { + display: 'system', + }) + return null + } + const creature = data.creatures[num - 1]! + data.activeCreatureId = creature.id + saveBuddyData(data) + onDone(`Switched to ${getCreatureName(creature)}!`, { display: 'system' }) + return null + } + + // ── /buddy (no args) — show unified BuddyPanel ── + const data = getOrInitBuddyData() + let creature = getActiveCreature(data) // Auto-unmute when viewing - if (companion && getGlobalConfig().companionMuted) { + if (getGlobalConfig().companionMuted) { saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false })) } - if (companion) { - // Return JSX card — matches official vc8 component - const lastReaction = context.getAppState?.()?.companionReaction - return React.createElement(CompanionCard, { - companion, - lastReaction, - onDone: onDone as unknown as Parameters[0]['onDone'], - }) + // No creature → initialize new one + if (!creature) { + const legacyCompanion = getGlobalConfig().companion + if (legacyCompanion) { + const migrated = migrateFromLegacy(legacyCompanion) + saveBuddyData(migrated) + creature = getActiveCreature(migrated)! + } else { + const defaultData = getDefaultBuddyData() + saveBuddyData(defaultData) + creature = getActiveCreature(defaultData)! + } } - // ── No companion → hatch ── - const seed = generateSeed() - const r = rollWithSeed(seed) - const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy' - const personality = - SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.' - - const stored: StoredCompanion = { - name, - personality, - seed, - hatchedAt: Date.now(), + // Pre-fetch sprite if not cached + const spriteCached = loadSprite(creature.speciesId) + if (!spriteCached) { + fetchAndCacheSprite(creature.speciesId).catch(() => {}) } - saveGlobalConfig(cfg => ({ ...cfg, companion: stored })) + const spriteLines = + spriteCached?.lines ?? getFallbackSprite(creature.speciesId) - const stars = RARITY_STARS[r.bones.rarity] - const sprite = renderSprite(r.bones, 0) - const shiny = r.bones.shiny ? ' \u2728 Shiny!' : '' + // Reload data to get latest state after possible initialization + const latestData = loadBuddyData() - const lines = [ - 'A wild companion appeared!', - '', - ...sprite, - '', - `${name} the ${speciesLabel(r.bones.species)}${shiny}`, - `Rarity: ${stars} (${r.bones.rarity})`, - `"${personality}"`, - '', - 'Your companion will now appear beside your input box!', - 'Say its name to get its take \u00b7 /buddy pet \u00b7 /buddy off', - ] - onDone(lines.join('\n'), { display: 'system' }) - return null + return React.createElement(BuddyPanel, { + buddyData: latestData, + spriteLines, + onClose: () => { + onDone('buddy panel closed', { display: 'system' }) + }, + }) } diff --git a/src/commands/buddy/index.ts b/src/commands/buddy/index.ts index 8df683028..8ac34a4e1 100644 --- a/src/commands/buddy/index.ts +++ b/src/commands/buddy/index.ts @@ -4,8 +4,8 @@ import { isBuddyLive } from '../../buddy/useBuddyNotification.js' const buddy = { type: 'local-jsx', name: 'buddy', - description: 'Hatch a coding companion · pet, off', - argumentHint: '[pet|off]', + description: 'Pokémon coding companion · pet, dex, egg, switch, rename, off', + argumentHint: '[pet|dex|egg|switch|rename |on|off]', immediate: true, get isHidden() { return !isBuddyLive() diff --git a/src/components/messages/UserChannelMessage.tsx b/src/components/messages/UserChannelMessage.tsx index a37f05c8c..28112d91a 100644 --- a/src/components/messages/UserChannelMessage.tsx +++ b/src/components/messages/UserChannelMessage.tsx @@ -1,52 +1,84 @@ -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import * as React from 'react' -import { CHANNEL_ARROW } from '../../constants/figures.js' -import { CHANNEL_TAG } from '../../constants/xml.js' -import { Box, Text } from '@anthropic/ink' -import { truncateToWidth } from '../../utils/format.js' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { CHANNEL_ARROW } from '../../constants/figures.js'; +import { CHANNEL_TAG } from '../../constants/xml.js'; +import { Box, Text } from '@anthropic/ink'; +import { truncateToWidth } from '../../utils/format.js'; type Props = { - addMargin: boolean - param: TextBlockParam -} + addMargin: boolean; + param: TextBlockParam; +}; // content // source is always first (wrapChannelMessage writes it), user is optional. -const CHANNEL_RE = new RegExp( - `<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?`, -) -const USER_ATTR_RE = /\buser="([^"]+)"/ +const CHANNEL_RE = new RegExp(`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?`); +const USER_ATTR_RE = /\buser="([^"]+)"/; // Plugin-provided servers get names like plugin:slack-channel:slack via // addPluginScopeToServers — show just the leaf. Matches the suffix-match // logic in isServerInChannels. function displayServerName(name: string): string { - const i = name.lastIndexOf(':') - return i === -1 ? name : name.slice(i + 1) + const i = name.lastIndexOf(':'); + return i === -1 ? name : name.slice(i + 1); } -const TRUNCATE_AT = 60 +const MAX_LINE_WIDTH = 80; +const MAX_LINES = 3; + +/** + * Formats multi-line channel content for compact display in the terminal. + * Collapses excessive blank lines, limits to MAX_LINES, truncates each line. + */ +function formatChannelBody(raw: string): { lines: string[]; truncated: boolean } { + const body = raw.trim(); + // Split into lines, collapse runs of blank lines into single empty line + const allLines = body.split(/\n/).reduce((acc, line) => { + const trimmed = line.trimEnd(); + if (trimmed === '' && acc.length > 0 && acc[acc.length - 1] === '') return acc; + acc.push(trimmed); + return acc; + }, []); + // Remove leading/trailing blank lines + while (allLines.length > 0 && allLines[0] === '') allLines.shift(); + while (allLines.length > 0 && allLines[allLines.length - 1] === '') allLines.pop(); + + const truncated = allLines.length > MAX_LINES; + const visible = allLines.slice(0, MAX_LINES); + const lines = visible.map(l => (l === '' ? '' : truncateToWidth(l, MAX_LINE_WIDTH))); + return { lines, truncated }; +} + +export function UserChannelMessage({ addMargin, param: { text } }: Props): React.ReactNode { + const m = CHANNEL_RE.exec(text); + if (!m) return null; + const [, source, attrs, content] = m; + const user = USER_ATTR_RE.exec(attrs ?? '')?.[1]; + const { lines, truncated } = formatChannelBody(content ?? ''); -export function UserChannelMessage({ - addMargin, - param: { text }, -}: Props): React.ReactNode { - const m = CHANNEL_RE.exec(text) - if (!m) return null - const [, source, attrs, content] = m - const user = USER_ATTR_RE.exec(attrs ?? '')?.[1] - const body = (content ?? '').trim().replace(/\s+/g, ' ') - const truncated = truncateToWidth(body, TRUNCATE_AT) return ( - - - {CHANNEL_ARROW}{' '} - - {displayServerName(source ?? '')} - {user ? ` \u00b7 ${user}` : ''}: - {' '} - {truncated} - + + {lines.map((line, i) => ( + + {i === 0 ? ( + + {CHANNEL_ARROW}{' '} + + {displayServerName(source ?? '')} + {user ? ` \u00b7 ${user}` : ''}: + {' '} + {line} + {truncated && i === lines.length - 1 ? ' …' : ''} + + ) : ( + + {' '} + {line} + {truncated && i === lines.length - 1 ? ' …' : ''} + + )} + + ))} - ) + ); } diff --git a/src/hooks/useCancelRequest.ts b/src/hooks/useCancelRequest.ts index 4382e2762..b14807e33 100644 --- a/src/hooks/useCancelRequest.ts +++ b/src/hooks/useCancelRequest.ts @@ -49,6 +49,7 @@ type CancelRequestHandlerProps = { popCommandFromQueue?: () => void vimMode?: VimMode isLocalJSXCommand?: boolean + onDismissLocalJSX?: () => void isSearchingHistory?: boolean isHelpOpen?: boolean inputMode?: PromptInputMode @@ -71,6 +72,7 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null { popCommandFromQueue, vimMode, isLocalJSXCommand, + onDismissLocalJSX, isSearchingHistory, isHelpOpen, inputMode, @@ -92,6 +94,12 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null { streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } + // Priority 0: Dismiss local JSX command panel (e.g. /buddy, /config) + if (isLocalJSXCommand && onDismissLocalJSX) { + onDismissLocalJSX() + return + } + // Priority 1: If there's an active task running, cancel it first // This takes precedence over queue management so users can always interrupt Claude if (abortSignal !== undefined && !abortSignal.aborted) { @@ -140,16 +148,16 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null { screen !== 'transcript' && !isSearchingHistory && !isMessageSelectorVisible && - !isLocalJSXCommand && !isHelpOpen && !isOverlayActive && !(isVimModeEnabled() && vimMode === 'INSERT') // Escape (chat:cancel) defers to mode-exit when in special mode with empty - // input, and to useBackgroundTaskNavigation when viewing a teammate + // input, and to useBackgroundTaskNavigation when viewing a teammate. + // Also active when a local JSX command panel (e.g. /buddy) is showing. const isEscapeActive = isContextActive && - (canCancelRunningTask || hasQueuedCommands) && + (canCancelRunningTask || hasQueuedCommands || !!isLocalJSXCommand) && !isInSpecialModeWithEmptyInput && !isViewingTeammate diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 3547c4aed..990b661ac 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -2564,6 +2564,9 @@ export function REPL({ popCommandFromQueue: handleQueuedCommandOnCancel, vimMode, isLocalJSXCommand: toolJSX?.isLocalJSXCommand, + onDismissLocalJSX: useCallback(() => { + setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true }); + }, [setToolJSX]), isSearchingHistory, isHelpOpen, inputMode, @@ -3434,6 +3437,69 @@ export function REPL({ // Log query profiling report if enabled logQueryProfileReport(); + // ── Buddy EV/XP/egg hook ── + if (feature('BUDDY')) { + try { + const { + loadBuddyData: _load, + saveBuddyData: _save, + getActiveCreature: _getActive, + awardXP: _awardXP, + awardTurnEV: _awardEV, + advanceEggSteps: _advSteps, + checkEvolution: _checkEvo, + checkEggEligibility: _checkEgg, + generateEgg: _genEgg, + isEggReadyToHatch: _isReady, + hatchEgg: _hatchEgg, + updateDailyStats: _updateDaily, + incrementTurns: _incTurns, + } = await import('@claude-code-best/pokemon'); + const _data = _updateDaily(_incTurns(_load())); + const _creature = _getActive(_data); + if (_creature) { + // 1. Collect tool names from this turn's messages + const _toolNames: string[] = []; + for (const _msg of messagesRef.current) { + if (_msg.role === 'assistant' && Array.isArray(_msg.content)) { + for (const _block of _msg.content) { + if (_block.type === 'tool_use') _toolNames.push(_block.name); + } + } + } + // 2. Award EV for tool usage + const _evolved = _awardEV(_creature, _toolNames); + if (_evolved !== _creature) { + _data.creatures = _data.creatures.map((c: any) => (c.id === _creature.id ? _evolved : c)); + } + // 3. Award conversation XP + const _xpResult = _awardXP(_evolved, 5 + _toolNames.length); + _data.creatures = _data.creatures.map((c: any) => (c.id === _creature.id ? _xpResult.creature : c)); + // 4. Advance egg steps + if (_data.eggs.length > 0) { + _data.eggs = _data.eggs.map((e: any) => _advSteps(e, 3)); + const _readyEgg = _data.eggs.find(_isReady); + if (_readyEgg) { + const { buddyData: _hatched, creature: _newC } = _hatchEgg(_data, _readyEgg); + Object.assign(_data, _hatched); + } + } + // 5. Check evolution + const _evoResult = _checkEvo(_xpResult.creature); + if (_evoResult) { + setAppState(prev => ({ ...prev, companionEvolving: { from: _evoResult.from, to: _evoResult.to } })); + } + // 6. Check egg eligibility + if (_checkEgg(_data)) { + _data.eggs.push(_genEgg(_data)); + } + _save(_data); + } + } catch { + // Buddy system is non-critical; silently ignore errors + } + } + // Signal that a query turn has completed successfully await onTurnComplete?.(messagesRef.current); }, diff --git a/src/state/AppStateStore.ts b/src/state/AppStateStore.ts index e5f20efda..e5829d1c0 100644 --- a/src/state/AppStateStore.ts +++ b/src/state/AppStateStore.ts @@ -169,6 +169,10 @@ export type AppState = DeepImmutable<{ companionReaction?: string // Timestamp of last /buddy pet — CompanionSprite renders hearts while recent companionPetAt?: number + // Pokémon evolution animation state + companionEvolving?: { from: string; to: string } + // Egg steps update counter (triggers UI refresh) + companionEggSteps?: number // TODO (ashwin): see if we can use utility-types DeepReadonly for this mcp: { clients: MCPServerConnection[] diff --git a/src/utils/config.ts b/src/utils/config.ts index 4707feaaa..e75fc0a9d 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -266,8 +266,13 @@ export type GlobalConfig = { [tipId: string]: number // Key is tipId, value is the numStartups when tip was last shown } - // /buddy companion soul — bones regenerated from userId on read. See src/buddy/. - companion?: import('../buddy/types.js').StoredCompanion + // /buddy companion — legacy data (migrated to buddy-data.json by Pokémon system) + companion?: { + name: string + personality: string + seed?: string + hatchedAt: number + } companionMuted?: boolean // Feedback survey tracking diff --git a/tsconfig.json b/tsconfig.json index 9472b1e18..4996db449 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,9 @@ "@claude-code-best/agent-tools/*": ["./packages/agent-tools/src/*"], "@claude-code-best/agent-tools": ["./packages/agent-tools/src/index.ts"], "@claude-code-best/weixin/*": ["./packages/weixin/src/*"], - "@claude-code-best/weixin": ["./packages/weixin/src/index.ts"] + "@claude-code-best/weixin": ["./packages/weixin/src/index.ts"], + "@claude-code-best/pokemon/*": ["./packages/pokemon/src/*"], + "@claude-code-best/pokemon": ["./packages/pokemon/src/index.ts"] } }, "include": ["src/**/*.ts", "src/**/*.tsx", "packages/**/*.ts", "packages/**/*.tsx"],