mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 08:45:50 +00:00
feat: 第一版可用 pokemon
This commit is contained in:
5
Issues.md
Normal file
5
Issues.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pokemon 面板不要 command 描述
|
||||||
|
|
||||||
|
pokemon dex 数据要从后端拿过来
|
||||||
|
|
||||||
|
pokemon 面板没法通过 esc 关闭
|
||||||
713
docs/plans/buddy-pokemon-plan.md
Normal file
713
docs/plans/buddy-pokemon-plan.md
Normal file
@@ -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<StatName, string> = {
|
||||||
|
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<string, string> // 多语言名 { ja, en, zh }
|
||||||
|
dexNumber: number // 图鉴编号 (1-10 MVP)
|
||||||
|
genderRatio: number // 雌性概率 (0-1, -1=无性别)
|
||||||
|
baseStats: Record<StatName, number>
|
||||||
|
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<StatName, number> // 努力值
|
||||||
|
iv: Record<StatName, number> // 个体值 (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<string, Record<StatName, number>> = {
|
||||||
|
"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 =<<EOC;` ... `EOC`
|
||||||
|
- ANSI 256 色: `\e[38;5;Nm`(前景色)、`\e[48;5;Nm`(背景色)
|
||||||
|
- Unicode 半块字符: `\N{U+2584}`(▄)、`\N{U+2580}`(▀)、普通空格
|
||||||
|
- 每行含多个色块,组合出像素画效果
|
||||||
|
|
||||||
|
**转换引擎**: 不在代码中硬编码精灵图。运行时从 GitHub 拉取 .cow 文件,转换后缓存到本地。
|
||||||
|
|
||||||
|
**存储路径**: `~/.claude/buddy-sprites/{speciesId}.json`
|
||||||
|
|
||||||
|
**JSON 格式**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"speciesId": "bulbasaur",
|
||||||
|
"lines": [" \e[49m ...", "...", "...", "...", "..."],
|
||||||
|
"width": 36,
|
||||||
|
"height": 10,
|
||||||
|
"fetchedAt": 1745260800000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**拉取 + 转换流程** (`core/spriteCache.ts`):
|
||||||
|
1. 获取精灵时(蛋孵化 / 首次获得 / 进化)触发 `fetchAndCacheSprite(speciesId)`
|
||||||
|
2. `fetch("https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons/{NNN}_{name}.cow")`
|
||||||
|
3. 解析 .cow → 转换 → 写入 `~/.claude/buddy-sprites/{speciesId}.json`
|
||||||
|
4. 之后所有渲染从本地缓存读取,不再需要网络
|
||||||
|
5. `loadSprite(speciesId)` — 直接读本地缓存,无网络调用
|
||||||
|
6. 首次获取无网络 → 使用内置 fallback 简易 ASCII,下次有网时补拉
|
||||||
|
|
||||||
|
**转换步骤**:
|
||||||
|
1. 提取 heredoc 内容(`$the_cow =<<EOC;` 和 `EOC` 之间)
|
||||||
|
2. `\N{U+XXXX}` → 实际 Unicode 字符(`String.fromCharCode(0xXXXX)`)
|
||||||
|
3. 保留 ANSI 序列(`\e[38;5;Nm` / `\e[48;5;Nm`)— 终端直接渲染彩色
|
||||||
|
4. 去除前 4 行 `$thoughts` 占位行(cowsay 对话气泡引导线)
|
||||||
|
5. 按目标宽度缩放(CompanionCard 宽度约 36 列)
|
||||||
|
6. 写入本地 JSON 缓存
|
||||||
|
|
||||||
|
**动画策略**: 每物种只存 1 帧基础图,运行时通过变换生成动画:
|
||||||
|
- **idle**: 原图静态显示(占大部分时间)
|
||||||
|
- **fidget**: 整体右移 1 列 → 回原位,循环 1 次(500ms/帧,共 1 秒)
|
||||||
|
- **blink**: 将眼睛字符替换为 `—`(1 帧 500ms)
|
||||||
|
- **excited**: 快速左右抖动(每 250ms 交替 ±1 列偏移)
|
||||||
|
- **pet**: 在基础图上方叠加心形粒子帧(复用现有 PET_HEARTS)
|
||||||
|
|
||||||
|
不需要为每个物种单独设计帧。动画循环统一由 `sprites/renderer.ts` 的 `renderAnimatedSprite(sprite, tick, mode)` 处理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AnimMode = 'idle' | 'fidget' | 'blink' | 'excited' | 'pet'
|
||||||
|
|
||||||
|
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 lines.map(l => 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 `<Text color>` 代替(兼容 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 <name>` | 重命名当前精灵 |
|
||||||
|
| `/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 → 自动迁移
|
||||||
9
packages/pokemon/package.json
Normal file
9
packages/pokemon/package.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
124
packages/pokemon/scripts/fetch-sprites.ts
Normal file
124
packages/pokemon/scripts/fetch-sprites.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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 =<<EOC;'
|
||||||
|
const endMarker = 'EOC'
|
||||||
|
|
||||||
|
const startIdx = cowContent.indexOf(startMarker)
|
||||||
|
if (startIdx === -1) return []
|
||||||
|
|
||||||
|
const contentStart = startIdx + startMarker.length
|
||||||
|
const endIdx = cowContent.indexOf(endMarker, contentStart)
|
||||||
|
if (endIdx === -1) return []
|
||||||
|
|
||||||
|
let content = cowContent.slice(contentStart, endIdx)
|
||||||
|
|
||||||
|
// Convert \N{U+XXXX} to actual Unicode characters
|
||||||
|
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
|
||||||
|
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)
|
||||||
107
packages/pokemon/src/__tests__/creature.test.ts
Normal file
107
packages/pokemon/src/__tests__/creature.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
79
packages/pokemon/src/__tests__/effort.test.ts
Normal file
79
packages/pokemon/src/__tests__/effort.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
87
packages/pokemon/src/__tests__/egg.test.ts
Normal file
87
packages/pokemon/src/__tests__/egg.test.ts
Normal file
@@ -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['stats']> = {}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
91
packages/pokemon/src/__tests__/evolution.test.ts
Normal file
91
packages/pokemon/src/__tests__/evolution.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
84
packages/pokemon/src/__tests__/experience.test.ts
Normal file
84
packages/pokemon/src/__tests__/experience.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
51
packages/pokemon/src/__tests__/gender.test.ts
Normal file
51
packages/pokemon/src/__tests__/gender.test.ts
Normal file
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
117
packages/pokemon/src/core/creature.ts
Normal file
117
packages/pokemon/src/core/creature.ts
Normal file
@@ -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<StatName, number> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
98
packages/pokemon/src/core/effort.ts
Normal file
98
packages/pokemon/src/core/effort.ts
Normal file
@@ -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<string, number>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<StatName, number> {
|
||||||
|
const stats = [...STAT_NAMES]
|
||||||
|
const stat = stats[Math.floor(Math.random() * stats.length)]
|
||||||
|
const amount = Math.random() < 0.5 ? 1 : 2
|
||||||
|
const result: Record<StatName, number> = { 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<StatName, string> = {
|
||||||
|
hp: 'HP',
|
||||||
|
attack: 'ATK',
|
||||||
|
defense: 'DEF',
|
||||||
|
spAtk: 'SPA',
|
||||||
|
spDef: 'SPD',
|
||||||
|
speed: 'SPE',
|
||||||
|
}
|
||||||
|
parts.push(`${labels[stat]}+${val}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(' ') || 'None'
|
||||||
|
}
|
||||||
97
packages/pokemon/src/core/egg.ts
Normal file
97
packages/pokemon/src/core/egg.ts
Normal file
@@ -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 }]
|
||||||
|
}
|
||||||
46
packages/pokemon/src/core/evolution.ts
Normal file
46
packages/pokemon/src/core/evolution.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
52
packages/pokemon/src/core/experience.ts
Normal file
52
packages/pokemon/src/core/experience.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/pokemon/src/core/gender.ts
Normal file
26
packages/pokemon/src/core/gender.ts
Normal file
@@ -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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
139
packages/pokemon/src/core/spriteCache.ts
Normal file
139
packages/pokemon/src/core/spriteCache.ts
Normal file
@@ -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<SpeciesId, string> = {
|
||||||
|
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<SpriteCache | null> {
|
||||||
|
// 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 =<<EOC; and EOC
|
||||||
|
const startMarker = '$the_cow =<<EOC;'
|
||||||
|
const endMarker = 'EOC'
|
||||||
|
|
||||||
|
const startIdx = cowContent.indexOf(startMarker)
|
||||||
|
if (startIdx === -1) return []
|
||||||
|
|
||||||
|
const contentStart = startIdx + startMarker.length
|
||||||
|
const endIdx = cowContent.indexOf(endMarker, contentStart)
|
||||||
|
if (endIdx === -1) return []
|
||||||
|
|
||||||
|
let content = cowContent.slice(contentStart, endIdx)
|
||||||
|
|
||||||
|
// Convert \N{U+XXXX} to actual Unicode characters
|
||||||
|
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
|
||||||
|
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}`
|
||||||
|
}
|
||||||
206
packages/pokemon/src/core/storage.ts
Normal file
206
packages/pokemon/src/core/storage.ts
Normal file
@@ -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<string, SpeciesId> = {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
48
packages/pokemon/src/index.ts
Normal file
48
packages/pokemon/src/index.ts
Normal file
@@ -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'
|
||||||
85
packages/pokemon/src/sprites/fallback.ts
Normal file
85
packages/pokemon/src/sprites/fallback.ts
Normal file
@@ -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<SpeciesId, string[]> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
4
packages/pokemon/src/sprites/index.ts
Normal file
4
packages/pokemon/src/sprites/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { renderAnimatedSprite, getIdleAnimMode } from './renderer'
|
||||||
|
export type { AnimMode } from '../types'
|
||||||
|
export { getFallbackSprite } from './fallback'
|
||||||
|
export { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
|
||||||
76
packages/pokemon/src/sprites/renderer.ts
Normal file
76
packages/pokemon/src/sprites/renderer.ts
Normal file
@@ -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]
|
||||||
|
}
|
||||||
143
packages/pokemon/src/types.ts
Normal file
143
packages/pokemon/src/types.ts
Normal file
@@ -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<StatName, string> = {
|
||||||
|
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<string, string> // 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<StatName, number>
|
||||||
|
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<StatName, number> // Effort values
|
||||||
|
iv: Record<StatName, number> // 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<StatName, number>
|
||||||
|
|
||||||
|
// 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'
|
||||||
170
packages/pokemon/src/ui/CompanionCard.tsx
Normal file
170
packages/pokemon/src/ui/CompanionCard.tsx
Normal file
@@ -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<string, Color> = {
|
||||||
|
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) => (
|
||||||
|
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
|
||||||
|
{i > 0 ? '/' : ''}{t.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
|
||||||
|
// Friendship color
|
||||||
|
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED
|
||||||
|
|
||||||
|
// Shiny badge
|
||||||
|
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> ★SHINY★</Text> : null
|
||||||
|
|
||||||
|
// Evolution hint
|
||||||
|
const evoHint = nextEvo ? (
|
||||||
|
<Text color={GRAY}> → <Text color={CYAN}>{SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name}</Text> Lv.{nextEvo.minLevel}</Text>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
|
{/* Header row */}
|
||||||
|
<Box justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text bold color={CYAN}>{name}</Text>
|
||||||
|
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
|
||||||
|
{shinyBadge}
|
||||||
|
</Box>
|
||||||
|
<Text bold>Lv.{creature.level}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Species + type + gender */}
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
|
||||||
|
<Text> </Text>
|
||||||
|
{typeBadges}
|
||||||
|
{genderSymbol && <Text> {genderSymbol}</Text>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sprite */}
|
||||||
|
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||||
|
{spriteLines ? (
|
||||||
|
spriteLines.map((line, i) => <Text key={i}>{line}</Text>)
|
||||||
|
) : (
|
||||||
|
<Text color={GRAY}>[Loading sprite...]</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Personality */}
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY} italic>"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Stats section */}
|
||||||
|
<Box flexDirection="column" marginTop={0}>
|
||||||
|
<Text color={GRAY}>─── Base Stats ───</Text>
|
||||||
|
{STAT_NAMES.map((stat) => (
|
||||||
|
<StatBar
|
||||||
|
key={stat}
|
||||||
|
label={STAT_LABELS[stat]}
|
||||||
|
value={stats[stat]}
|
||||||
|
maxValue={255}
|
||||||
|
color={getStatColor(stat)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* XP progress */}
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={GRAY}>XP </Text>
|
||||||
|
<Text color={BLUE}>
|
||||||
|
{'█'.repeat(Math.round(xp.percentage / 10))}
|
||||||
|
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
|
||||||
|
</Text>
|
||||||
|
<Text> {xp.current}/{xp.needed}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* EV + Friendship */}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>EV </Text>
|
||||||
|
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
|
||||||
|
<Text color={GRAY}> ({totalEV}/510)</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>♥ </Text>
|
||||||
|
<Text color={friendshipColor}>
|
||||||
|
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
|
||||||
|
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
|
||||||
|
</Text>
|
||||||
|
<Text> {creature.friendship}/255</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Evolution hint */}
|
||||||
|
{evoHint && (
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={GRAY}>Next: </Text>
|
||||||
|
{evoHint}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatColor(stat: string): Color {
|
||||||
|
const colors: Record<string, Color> = {
|
||||||
|
hp: 'ansi:green',
|
||||||
|
attack: 'ansi:red',
|
||||||
|
defense: 'ansi:yellow',
|
||||||
|
spAtk: 'ansi:blue',
|
||||||
|
spDef: 'ansi:magenta',
|
||||||
|
speed: 'ansi:cyan',
|
||||||
|
}
|
||||||
|
return colors[stat] ?? 'ansi:white'
|
||||||
|
}
|
||||||
54
packages/pokemon/src/ui/EggView.tsx
Normal file
54
packages/pokemon/src/ui/EggView.tsx
Normal file
@@ -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 (
|
||||||
|
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
|
||||||
|
<Text bold color={CYAN}>
|
||||||
|
Egg Status
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* ASCII egg */}
|
||||||
|
<Box flexDirection="column" alignItems="center" marginY={1}>
|
||||||
|
<Text> . </Text>
|
||||||
|
<Text> / \ </Text>
|
||||||
|
<Text> | | </Text>
|
||||||
|
<Text> \_/ </Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<Box flexDirection="column" alignItems="center">
|
||||||
|
<Text>
|
||||||
|
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
|
||||||
|
</Text>
|
||||||
|
<Text color={YELLOW}>
|
||||||
|
{'█'.repeat(filled)}
|
||||||
|
{'░'.repeat(empty)}
|
||||||
|
</Text>
|
||||||
|
<Text>{percentage}%</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<Box marginTop={1} flexDirection="column" alignItems="center">
|
||||||
|
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
|
||||||
|
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
packages/pokemon/src/ui/EvolutionAnim.tsx
Normal file
90
packages/pokemon/src/ui/EvolutionAnim.tsx
Normal file
@@ -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 (
|
||||||
|
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
|
||||||
|
<Text bold color={YELLOW}>
|
||||||
|
✨ Evolution! ✨
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box flexDirection="column" alignItems="center" marginY={1}>
|
||||||
|
{displayLines.map((line, i) => (
|
||||||
|
<Text key={i}>
|
||||||
|
{tick >= 6 ? '✨ ' : ''}
|
||||||
|
{line}
|
||||||
|
{tick >= 6 ? ' ✨' : ''}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
<Text color={GRAY}>{fromName}</Text>
|
||||||
|
<Text color={YELLOW}> → </Text>
|
||||||
|
<Text bold color={GREEN}>
|
||||||
|
{toName}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{tick >= totalFrames - 1 && (
|
||||||
|
<Text bold color={GREEN}>
|
||||||
|
进化成功!
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpriteLines(speciesId: SpeciesId): string[] {
|
||||||
|
const cached = loadSprite(speciesId)
|
||||||
|
if (cached) return cached.lines
|
||||||
|
return getFallbackSprite(speciesId)
|
||||||
|
}
|
||||||
163
packages/pokemon/src/ui/PokedexView.tsx
Normal file
163
packages/pokemon/src/ui/PokedexView.tsx
Normal file
@@ -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 (
|
||||||
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box justifyContent="space-between" marginBottom={0}>
|
||||||
|
<Text bold color={CYAN}>Pokédex</Text>
|
||||||
|
<Text>
|
||||||
|
<Text bold color={collected === total ? GREEN : WHITE}>{collected}</Text>
|
||||||
|
<Text color={GRAY}>/{total} </Text>
|
||||||
|
<Text color={GRAY}>collected</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<Box>
|
||||||
|
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
|
||||||
|
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
|
||||||
|
<Text> {Math.floor((collected / total) * 100)}%</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Species list grouped by evolution chains */}
|
||||||
|
{chains.map((chain, ci) => (
|
||||||
|
<Box key={ci} flexDirection="column" marginTop={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 (
|
||||||
|
<Box key={speciesId} flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
{/* Chain connector */}
|
||||||
|
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
|
||||||
|
{/* Active indicator */}
|
||||||
|
<Text>{isActive ? <Text color={YELLOW}>▶</Text> : ' '}</Text>
|
||||||
|
{/* Dex number */}
|
||||||
|
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
|
||||||
|
{/* Name */}
|
||||||
|
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
|
||||||
|
{discovered
|
||||||
|
? (species.names.zh ?? species.name)
|
||||||
|
: '???'}
|
||||||
|
</Text>
|
||||||
|
{/* Type badges */}
|
||||||
|
{discovered && (
|
||||||
|
<Text>
|
||||||
|
{' '}
|
||||||
|
{species.types.filter((t): t is string => Boolean(t)).map((t, ti) => (
|
||||||
|
<Text key={t} color={getTypeColor(t)}>
|
||||||
|
{ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{/* Level / unknown indicator */}
|
||||||
|
{discovered && entry ? (
|
||||||
|
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={GRAY}> ───</Text>
|
||||||
|
)}
|
||||||
|
{/* Evolution arrow */}
|
||||||
|
{nextEvo && (
|
||||||
|
<Text color={GRAY}> →<Text color={CYAN}>Lv.{nextEvo.minLevel}</Text></Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<Box marginTop={0} flexDirection="column">
|
||||||
|
<Text color={GRAY}>─── Stats ───</Text>
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>Turns: </Text>
|
||||||
|
<Text>{buddyData.stats.totalTurns}</Text>
|
||||||
|
<Text color={GRAY}> Days: </Text>
|
||||||
|
<Text>{buddyData.stats.consecutiveDays}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>Eggs: </Text>
|
||||||
|
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
||||||
|
<Text color={GRAY}> Evolutions: </Text>
|
||||||
|
<Text>{buddyData.stats.totalEvolutions}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Egg info */}
|
||||||
|
{buddyData.eggs.length > 0 && (
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={YELLOW}>🥚 Egg: </Text>
|
||||||
|
<Text>{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}</Text>
|
||||||
|
<Text color={GRAY}> steps</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{buddyData.stats.consecutiveDays < 7 && (
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type → color mapping */
|
||||||
|
function getTypeColor(type: string): Color {
|
||||||
|
const colors: Record<string, Color> = {
|
||||||
|
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'],
|
||||||
|
]
|
||||||
|
}
|
||||||
193
packages/pokemon/src/ui/SpeciesDetail.tsx
Normal file
193
packages/pokemon/src/ui/SpeciesDetail.tsx
Normal file
@@ -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<string, Color> = {
|
||||||
|
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) => (
|
||||||
|
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
|
||||||
|
{i > 0 ? ' / ' : ''}{t.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name}</Text>
|
||||||
|
</Box>
|
||||||
|
{caughtLevel && <Text color={GREEN}>Best: Lv.{caughtLevel}</Text>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Type + gender */}
|
||||||
|
<Box>
|
||||||
|
{typeBadges}
|
||||||
|
<Text color={GRAY}> {genderInfo}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sprite */}
|
||||||
|
{spriteLines && (
|
||||||
|
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||||
|
{spriteLines.map((line, i) => <Text key={i}>{line}</Text>)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flavor text */}
|
||||||
|
{species.flavorText && (
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={GRAY} italic>{species.flavorText}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Base Stats */}
|
||||||
|
<Box flexDirection="column" marginTop={0}>
|
||||||
|
<Text color={GRAY}>─── Base Stats ───</Text>
|
||||||
|
{STAT_NAMES.map((stat) => (
|
||||||
|
<Box key={stat}>
|
||||||
|
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
|
||||||
|
<Text color={getStatColor(stat)}>
|
||||||
|
{'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))}
|
||||||
|
{'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))}
|
||||||
|
</Text>
|
||||||
|
<Text> {String(species.baseStats[stat]).padStart(3)}</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{/* Total */}
|
||||||
|
<Box>
|
||||||
|
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
|
||||||
|
<Text color={GRAY}>
|
||||||
|
{'─'.repeat(15)}
|
||||||
|
</Text>
|
||||||
|
<Text bold> {Object.values(species.baseStats).reduce((a, b) => a + b, 0)}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Evolution chain */}
|
||||||
|
{(nextEvo || species.dexNumber > 1) && (
|
||||||
|
<Box flexDirection="column" marginTop={0}>
|
||||||
|
<Text color={GRAY}>─── Evolution ───</Text>
|
||||||
|
<EvolutionChain speciesId={speciesId} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<Box flexDirection="column" marginTop={0}>
|
||||||
|
<Text color={GRAY}>─── Info ───</Text>
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>Growth: </Text>
|
||||||
|
<Text>{species.growthRate}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>Capture: </Text>
|
||||||
|
<Text>{species.captureRate}</Text>
|
||||||
|
<Text color={GRAY}> Happiness: </Text>
|
||||||
|
<Text>{species.baseHappiness}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<Box>
|
||||||
|
{chain.map((sid, i) => (
|
||||||
|
<React.Fragment key={sid}>
|
||||||
|
{i > 0 && <Text color={GRAY}> → </Text>}
|
||||||
|
<Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}>
|
||||||
|
{SPECIES_DATA[sid].names.zh ?? SPECIES_DATA[sid].name}
|
||||||
|
</Text>
|
||||||
|
{i < chain.length - 1 && getNextEvolution(sid) && (
|
||||||
|
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, Color> = {
|
||||||
|
hp: 'ansi:green', attack: 'ansi:red', defense: 'ansi:yellow',
|
||||||
|
spAtk: 'ansi:blue', spDef: 'ansi:magenta', speed: 'ansi:cyan',
|
||||||
|
}
|
||||||
|
return colors[stat] ?? 'ansi:white'
|
||||||
|
}
|
||||||
28
packages/pokemon/src/ui/StatBar.tsx
Normal file
28
packages/pokemon/src/ui/StatBar.tsx
Normal file
@@ -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 (
|
||||||
|
<Box>
|
||||||
|
<Text color="ansi:whiteBright">{label.padEnd(3)}</Text>
|
||||||
|
<Text color={color}>{bar}</Text>
|
||||||
|
<Text> {valueStr}</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
packages/pokemon/tsconfig.json
Normal file
8
packages/pokemon/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<Text>
|
|
||||||
{name.padEnd(10)} {bar} {String(value).padStart(3)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Box
|
|
||||||
flexDirection="column"
|
|
||||||
borderStyle="round"
|
|
||||||
borderColor={color}
|
|
||||||
paddingX={CARD_PADDING_X}
|
|
||||||
paddingY={1}
|
|
||||||
width={CARD_WIDTH}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{/* Header: rarity + species */}
|
|
||||||
<Box justifyContent="space-between">
|
|
||||||
<Text bold color={color}>
|
|
||||||
{stars} {companion.rarity.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
<Text color={color}>{companion.species.toUpperCase()}</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Shiny indicator */}
|
|
||||||
{companion.shiny && (
|
|
||||||
<Text color="warning" bold>
|
|
||||||
{'\u2728'} SHINY {'\u2728'}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sprite */}
|
|
||||||
<Box flexDirection="column" marginY={1}>
|
|
||||||
{sprite.map((line, i) => (
|
|
||||||
<Text key={i} color={color}>
|
|
||||||
{line}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<Text bold>{companion.name}</Text>
|
|
||||||
|
|
||||||
{/* Personality */}
|
|
||||||
<Box marginY={1}>
|
|
||||||
<Text dimColor italic>
|
|
||||||
"{companion.personality}"
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<Box flexDirection="column">
|
|
||||||
{STAT_NAMES.map(name => (
|
|
||||||
<StatBar key={name} name={name} value={companion.stats[name] ?? 0} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Last reaction */}
|
|
||||||
{lastReaction && (
|
|
||||||
<Box flexDirection="column" marginTop={1}>
|
|
||||||
<Text dimColor>last said</Text>
|
|
||||||
<Box borderStyle="round" borderColor="inactive" paddingX={1}>
|
|
||||||
<Text dimColor italic>
|
|
||||||
{lastReaction}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,347 +1,288 @@
|
|||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle';
|
||||||
import figures from 'figures'
|
import figures from 'figures';
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import { Box, Text, stringWidth } from '@anthropic/ink'
|
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
import { useAppState, useSetAppState } from '../state/AppState.js';
|
||||||
import type { AppState } from '../state/AppStateStore.js'
|
import type { AppState } from '../state/AppStateStore.js';
|
||||||
import { getGlobalConfig } from '../utils/config.js'
|
import { getGlobalConfig } from '../utils/config.js';
|
||||||
import { isFullscreenActive } from '../utils/fullscreen.js'
|
import { isFullscreenActive } from '../utils/fullscreen.js';
|
||||||
import type { Theme } from '../utils/theme.js'
|
import {
|
||||||
import { getCompanion } from './companion.js'
|
loadBuddyData,
|
||||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
|
getActiveCreature,
|
||||||
import { RARITY_COLORS } from './types.js'
|
getCreatureName,
|
||||||
|
loadSprite,
|
||||||
|
getFallbackSprite,
|
||||||
|
renderAnimatedSprite,
|
||||||
|
getIdleAnimMode,
|
||||||
|
SPECIES_DATA,
|
||||||
|
type Creature,
|
||||||
|
type AnimMode,
|
||||||
|
} from '@claude-code-best/pokemon';
|
||||||
|
|
||||||
const TICK_MS = 500
|
const TICK_MS = 500;
|
||||||
const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms
|
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 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
|
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]
|
|
||||||
|
|
||||||
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
|
// 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 = [
|
const PET_HEARTS = [
|
||||||
` ${H} ${H} `,
|
` ${H} ${H} `,
|
||||||
` ${H} ${H} ${H} `,
|
` ${H} ${H} ${H} `,
|
||||||
` ${H} ${H} ${H} `,
|
` ${H} ${H} ${H} `,
|
||||||
`${H} ${H} ${H} `,
|
`${H} ${H} ${H} `,
|
||||||
'· · · ',
|
'· · · ',
|
||||||
]
|
];
|
||||||
|
|
||||||
function wrap(text: string, width: number): string[] {
|
function wrap(text: string, width: number): string[] {
|
||||||
const words = text.split(' ')
|
const words = text.split(' ');
|
||||||
const lines: string[] = []
|
const lines: string[] = [];
|
||||||
let cur = ''
|
let cur = '';
|
||||||
for (const w of words) {
|
for (const w of words) {
|
||||||
if (cur.length + w.length + 1 > width && cur) {
|
if (cur.length + w.length + 1 > width && cur) {
|
||||||
lines.push(cur)
|
lines.push(cur);
|
||||||
cur = w
|
cur = w;
|
||||||
} else {
|
} else {
|
||||||
cur = cur ? `${cur} ${w}` : w
|
cur = cur ? `${cur} ${w}` : w;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cur) lines.push(cur)
|
if (cur) lines.push(cur);
|
||||||
return lines
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpeechBubble({
|
function SpeechBubble({ text, fading }: { text: string; fading: boolean; tail: 'down' | 'right' }): React.ReactNode {
|
||||||
text,
|
const lines = wrap(text, 30);
|
||||||
color,
|
const borderColor = fading ? 'inactive' : 'claude';
|
||||||
fading,
|
|
||||||
tail,
|
|
||||||
}: {
|
|
||||||
text: string
|
|
||||||
color: keyof Theme
|
|
||||||
fading: boolean
|
|
||||||
tail: 'down' | 'right'
|
|
||||||
}): React.ReactNode {
|
|
||||||
const lines = wrap(text, 30)
|
|
||||||
const borderColor = fading ? 'inactive' : color
|
|
||||||
const bubble = (
|
const bubble = (
|
||||||
<Box
|
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
|
||||||
flexDirection="column"
|
|
||||||
borderStyle="round"
|
|
||||||
borderColor={borderColor}
|
|
||||||
paddingX={1}
|
|
||||||
width={34}
|
|
||||||
>
|
|
||||||
{lines.map((l, i) => (
|
{lines.map((l, i) => (
|
||||||
<Text
|
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
|
||||||
key={i}
|
|
||||||
italic
|
|
||||||
dimColor={!fading}
|
|
||||||
color={fading ? 'inactive' : undefined}
|
|
||||||
>
|
|
||||||
{l}
|
{l}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
if (tail === 'right') {
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" alignItems="center">
|
<Box flexDirection="row" alignItems="center">
|
||||||
{bubble}
|
{bubble}
|
||||||
<Text color={borderColor}>─</Text>
|
<Text color={borderColor}>─</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
|
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
|
||||||
{bubble}
|
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
|
||||||
|
{l}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
<Box flexDirection="column" alignItems="flex-end" paddingRight={6}>
|
<Box flexDirection="column" alignItems="flex-end" paddingRight={6}>
|
||||||
<Text color={borderColor}>╲ </Text>
|
<Text color={borderColor}>╲ </Text>
|
||||||
<Text color={borderColor}>╲</Text>
|
<Text color={borderColor}>╲</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MIN_COLS_FOR_FULL_SPRITE = 100
|
export const MIN_COLS_FOR_FULL_SPRITE = 100;
|
||||||
const SPRITE_BODY_WIDTH = 12
|
const SPRITE_BODY_WIDTH = 12;
|
||||||
const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
|
const NAME_ROW_PAD = 2;
|
||||||
const SPRITE_PADDING_X = 2
|
const SPRITE_PADDING_X = 2;
|
||||||
const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
|
const BUBBLE_WIDTH = 36;
|
||||||
const NARROW_QUIP_CAP = 24
|
const NARROW_QUIP_CAP = 24;
|
||||||
|
|
||||||
function spriteColWidth(nameWidth: number): number {
|
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
|
* Get active Pokémon creature, or null if buddy system not initialized.
|
||||||
// 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
|
function getPokemonCreature(): Creature | null {
|
||||||
// (above input in fullscreen, below in scrollback), so no reservation.
|
try {
|
||||||
export function companionReservedColumns(
|
const data = loadBuddyData();
|
||||||
terminalColumns: number,
|
return getActiveCreature(data);
|
||||||
speaking: boolean,
|
} catch {
|
||||||
): number {
|
return null;
|
||||||
if (!feature('BUDDY')) return 0
|
}
|
||||||
const companion = getCompanion()
|
}
|
||||||
if (!companion || getGlobalConfig().companionMuted) return 0
|
|
||||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
|
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
||||||
const nameWidth = stringWidth(companion.name)
|
if (!feature('BUDDY')) return 0;
|
||||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
|
const creature = getPokemonCreature();
|
||||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble
|
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 {
|
export function CompanionSprite(): React.ReactNode {
|
||||||
const reaction = useAppState(s => s.companionReaction)
|
const reaction = useAppState(s => s.companionReaction);
|
||||||
const petAt = useAppState(s => s.companionPetAt)
|
const petAt = useAppState(s => s.companionPetAt);
|
||||||
const focused = useAppState(s => s.footerSelection === 'companion')
|
const focused = useAppState(s => s.footerSelection === 'companion');
|
||||||
const setAppState = useSetAppState()
|
const setAppState = useSetAppState();
|
||||||
const { columns } = useTerminalSize()
|
const { columns } = useTerminalSize();
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0);
|
||||||
const lastSpokeTick = useRef(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 [{ petStartTick, forPetAt }, setPetStart] = useState({
|
const [{ petStartTick, forPetAt }, setPetStart] = useState({
|
||||||
petStartTick: 0,
|
petStartTick: 0,
|
||||||
forPetAt: petAt,
|
forPetAt: petAt,
|
||||||
})
|
});
|
||||||
if (petAt !== forPetAt) {
|
if (petAt !== forPetAt) {
|
||||||
setPetStart({ petStartTick: tick, forPetAt: petAt })
|
setPetStart({ petStartTick: tick, forPetAt: petAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(
|
const timer = setInterval(
|
||||||
setT => setT((t: number) => t + 1),
|
(setT: React.Dispatch<React.SetStateAction<number>>) => setT((t: number) => t + 1),
|
||||||
TICK_MS,
|
TICK_MS,
|
||||||
setTick,
|
setTick,
|
||||||
)
|
);
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reaction) return
|
if (!reaction) return;
|
||||||
lastSpokeTick.current = tick
|
lastSpokeTick.current = tick;
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(
|
||||||
setA =>
|
(setA: React.Dispatch<React.SetStateAction<AppState>>) =>
|
||||||
setA((prev: AppState) =>
|
setA((prev: AppState) =>
|
||||||
prev.companionReaction === undefined
|
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined },
|
||||||
? prev
|
|
||||||
: { ...prev, companionReaction: undefined },
|
|
||||||
),
|
),
|
||||||
BUBBLE_SHOW * TICK_MS,
|
BUBBLE_SHOW * TICK_MS,
|
||||||
setAppState,
|
setAppState,
|
||||||
)
|
);
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [reaction, setAppState])
|
}, [reaction, setAppState]);
|
||||||
|
|
||||||
if (!feature('BUDDY')) return null
|
if (!feature('BUDDY')) return null;
|
||||||
const companion = getCompanion()
|
const creature = getPokemonCreature();
|
||||||
if (!companion || getGlobalConfig().companionMuted) return null
|
if (!creature || getGlobalConfig().companionMuted) return null;
|
||||||
|
|
||||||
const color = RARITY_COLORS[companion.rarity]
|
const species = SPECIES_DATA[creature.speciesId];
|
||||||
const colWidth = spriteColWidth(stringWidth(companion.name))
|
const name = getCreatureName(creature);
|
||||||
|
const color = creature.isShiny ? 'warning' : 'claude';
|
||||||
|
const colWidth = spriteColWidth(stringWidth(name));
|
||||||
|
|
||||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
|
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
|
||||||
const fading =
|
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
|
||||||
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
|
|
||||||
|
|
||||||
const petAge = petAt ? tick - petStartTick : Infinity
|
const petAge = petAt ? tick - petStartTick : Infinity;
|
||||||
const petting = petAge * TICK_MS < PET_BURST_MS
|
const petting = petAge * TICK_MS < PET_BURST_MS;
|
||||||
|
|
||||||
// Narrow terminals: collapse to one-line face. When speaking, the quip
|
// Narrow terminals: collapse to one-line face
|
||||||
// replaces the name beside the face (no room for a bubble).
|
|
||||||
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
|
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
|
||||||
const quip =
|
const quip =
|
||||||
reaction && reaction.length > NARROW_QUIP_CAP
|
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
|
||||||
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
|
const label = quip ? `"${quip}"` : focused ? ` ${name} ` : name;
|
||||||
: reaction
|
|
||||||
const label = quip
|
|
||||||
? `"${quip}"`
|
|
||||||
: focused
|
|
||||||
? ` ${companion.name} `
|
|
||||||
: companion.name
|
|
||||||
return (
|
return (
|
||||||
<Box paddingX={1} alignSelf="flex-end">
|
<Box paddingX={1} alignSelf="flex-end">
|
||||||
<Text>
|
<Text>
|
||||||
{petting && <Text color="autoAccept">{figures.heart} </Text>}
|
{petting && <Text color="autoAccept">{figures.heart} </Text>}
|
||||||
<Text bold color={color}>
|
<Text bold color={color}>
|
||||||
{renderFace(companion)}
|
{species.names.zh ?? species.name}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
<Text
|
<Text
|
||||||
italic
|
italic
|
||||||
dimColor={!focused && !reaction}
|
dimColor={!focused && !reaction}
|
||||||
bold={focused}
|
bold={focused}
|
||||||
inverse={focused && !reaction}
|
inverse={focused && !reaction}
|
||||||
color={
|
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined}
|
||||||
reaction
|
|
||||||
? fading
|
|
||||||
? 'inactive'
|
|
||||||
: color
|
|
||||||
: focused
|
|
||||||
? color
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
const frameCount = spriteFrameCount(companion.species)
|
|
||||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
|
|
||||||
|
|
||||||
let spriteFrame: number
|
// Determine animation mode
|
||||||
let blink = false
|
let animMode: AnimMode = 'idle';
|
||||||
if (reaction || petting) {
|
if (reaction || petting) {
|
||||||
// Excited: cycle all fidget frames fast
|
animMode = 'excited';
|
||||||
spriteFrame = tick % frameCount
|
|
||||||
} else {
|
} else {
|
||||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
|
animMode = getIdleAnimMode(tick);
|
||||||
if (step === -1) {
|
if (petting) animMode = 'pet';
|
||||||
spriteFrame = 0
|
|
||||||
blink = true
|
|
||||||
} else {
|
|
||||||
spriteFrame = step % frameCount
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = renderSprite(companion, spriteFrame).map(line =>
|
const spriteLines = getAnimatedSpriteLines(creature, tick, animMode);
|
||||||
blink ? line.replaceAll(companion.eye, '-') : line,
|
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
|
||||||
)
|
const displayLines = heartFrame ? [heartFrame, ...spriteLines] : spriteLines;
|
||||||
const sprite = heartFrame ? [heartFrame, ...body] : body
|
|
||||||
|
|
||||||
// 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 = (
|
const spriteColumn = (
|
||||||
<Box
|
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
|
||||||
flexDirection="column"
|
{displayLines.map((line, i) => (
|
||||||
flexShrink={0}
|
|
||||||
alignItems="center"
|
|
||||||
width={colWidth}
|
|
||||||
>
|
|
||||||
{sprite.map((line, i) => (
|
|
||||||
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
||||||
{line}
|
{line}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
<Text
|
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
|
||||||
italic
|
{focused ? ` ${name} ` : name}
|
||||||
bold={focused}
|
|
||||||
dimColor={!focused}
|
|
||||||
color={focused ? color : undefined}
|
|
||||||
inverse={focused}
|
|
||||||
>
|
|
||||||
{focused ? ` ${companion.name} ` : companion.name}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!reaction) {
|
if (!reaction) {
|
||||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()) {
|
if (isFullscreenActive()) {
|
||||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
||||||
<SpeechBubble
|
<SpeechBubble text={reaction} fading={fading} tail="right" />
|
||||||
text={reaction}
|
|
||||||
color={color}
|
|
||||||
fading={fading}
|
|
||||||
tail="right"
|
|
||||||
/>
|
|
||||||
{spriteColumn}
|
{spriteColumn}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
|
// Floating bubble overlay for fullscreen mode
|
||||||
// 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.
|
|
||||||
export function CompanionFloatingBubble(): React.ReactNode {
|
export function CompanionFloatingBubble(): React.ReactNode {
|
||||||
const reaction = useAppState(s => s.companionReaction)
|
const reaction = useAppState(s => s.companionReaction);
|
||||||
const [{ tick, forReaction }, setTick] = useState({
|
const [{ tick, forReaction }, setTick] = useState({
|
||||||
tick: 0,
|
tick: 0,
|
||||||
forReaction: reaction,
|
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) {
|
if (reaction !== forReaction) {
|
||||||
setTick({ tick: 0, forReaction: reaction })
|
setTick({ tick: 0, forReaction: reaction });
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reaction) return
|
if (!reaction) return;
|
||||||
const timer = setInterval(
|
const timer = setInterval(
|
||||||
set => set(s => ({ ...s, tick: s.tick + 1 })),
|
(set: React.Dispatch<React.SetStateAction<{ tick: number; forReaction: string | undefined }>>) =>
|
||||||
|
set(s => ({ ...s, tick: s.tick + 1 })),
|
||||||
TICK_MS,
|
TICK_MS,
|
||||||
setTick,
|
setTick,
|
||||||
)
|
);
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer);
|
||||||
}, [reaction])
|
}, [reaction]);
|
||||||
|
|
||||||
if (!feature('BUDDY') || !reaction) return null
|
if (!feature('BUDDY') || !reaction) return null;
|
||||||
const companion = getCompanion()
|
const creature = getPokemonCreature();
|
||||||
if (!companion || getGlobalConfig().companionMuted) return null
|
if (!creature || getGlobalConfig().companionMuted) return null;
|
||||||
|
|
||||||
return (
|
return <FloatingBubble text={reaction} fading={tick >= BUBBLE_SHOW - FADE_WINDOW} />;
|
||||||
<SpeechBubble
|
|
||||||
text={reaction}
|
|
||||||
color={RARITY_COLORS[companion.rarity]}
|
|
||||||
fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
|
|
||||||
tail="down"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T>(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<Rarity, number> = {
|
|
||||||
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<StatName, number> {
|
|
||||||
const floor = RARITY_FLOOR[rarity]
|
|
||||||
const peak = pick(rng, STAT_NAMES)
|
|
||||||
let dump = pick(rng, STAT_NAMES)
|
|
||||||
while (dump === peak) dump = pick(rng, STAT_NAMES)
|
|
||||||
|
|
||||||
const stats = {} as Record<StatName, number>
|
|
||||||
for (const name of STAT_NAMES) {
|
|
||||||
if (name === peak) {
|
|
||||||
stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
|
|
||||||
} else if (name === dump) {
|
|
||||||
stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
|
|
||||||
} else {
|
|
||||||
stats[name] = floor + Math.floor(rng() * 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -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
|
* Called from REPL.tsx after each query turn. Checks mute state, frequency
|
||||||
* limits, and @-mention detection, then calls the buddy_react API to
|
* limits, and @-mention detection, then calls the buddy_react API to
|
||||||
* generate a reaction shown in the CompanionSprite speech bubble.
|
* generate a reaction shown in the CompanionSprite speech bubble.
|
||||||
*/
|
*/
|
||||||
import { getCompanion } from './companion.js'
|
|
||||||
import { getGlobalConfig } from '../utils/config.js'
|
import { getGlobalConfig } from '../utils/config.js'
|
||||||
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
||||||
import { getOauthConfig } from '../constants/oauth.js'
|
import { getOauthConfig } from '../constants/oauth.js'
|
||||||
import { getUserAgent } from '../utils/http.js'
|
import { getUserAgent } from '../utils/http.js'
|
||||||
import type { Message } from '../types/message.js'
|
import type { Message } from '../types/message.js'
|
||||||
|
import {
|
||||||
|
loadBuddyData,
|
||||||
|
getActiveCreature,
|
||||||
|
getCreatureName,
|
||||||
|
calculateStats,
|
||||||
|
SPECIES_DATA,
|
||||||
|
type Creature,
|
||||||
|
} from '@claude-code-best/pokemon'
|
||||||
|
|
||||||
// ─── Rate limiting ──────────────────────────────────
|
// ─── Rate limiting ──────────────────────────────────
|
||||||
|
|
||||||
let lastReactTime = 0
|
let lastReactTime = 0
|
||||||
const MIN_INTERVAL_MS = 45_000 // official is roughly 30-60s
|
const MIN_INTERVAL_MS = 45_000
|
||||||
|
|
||||||
// ─── Recent reactions (avoid repetition) ────────────
|
// ─── Recent reactions (avoid repetition) ────────────
|
||||||
|
|
||||||
@@ -26,23 +33,17 @@ const MAX_RECENT = 8
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger a companion reaction after a query turn.
|
* 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(
|
export function triggerCompanionReaction(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
setReaction: (text: string | undefined) => void,
|
setReaction: (text: string | undefined) => void,
|
||||||
): void {
|
): void {
|
||||||
const companion = getCompanion()
|
const data = loadBuddyData()
|
||||||
if (!companion || getGlobalConfig().companionMuted) return
|
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()
|
const now = Date.now()
|
||||||
if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return
|
if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return
|
||||||
@@ -52,7 +53,7 @@ export function triggerCompanionReaction(
|
|||||||
|
|
||||||
lastReactTime = now
|
lastReactTime = now
|
||||||
|
|
||||||
void callBuddyReactAPI(companion, transcript, addressed)
|
void callBuddyReactAPI(creature, transcript, addressed)
|
||||||
.then(reaction => {
|
.then(reaction => {
|
||||||
if (!reaction) return
|
if (!reaction) return
|
||||||
recentReactions.push(reaction)
|
recentReactions.push(reaction)
|
||||||
@@ -109,13 +110,7 @@ function buildTranscript(messages: Message[]): string {
|
|||||||
// ─── API call ───────────────────────────────────────
|
// ─── API call ───────────────────────────────────────
|
||||||
|
|
||||||
async function callBuddyReactAPI(
|
async function callBuddyReactAPI(
|
||||||
companion: {
|
creature: Creature,
|
||||||
name: string
|
|
||||||
personality: string
|
|
||||||
species: string
|
|
||||||
rarity: string
|
|
||||||
stats: Record<string, number>
|
|
||||||
},
|
|
||||||
transcript: string,
|
transcript: string,
|
||||||
addressed: boolean,
|
addressed: boolean,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
@@ -125,6 +120,10 @@ async function callBuddyReactAPI(
|
|||||||
const orgId = getGlobalConfig().oauthAccount?.organizationUuid
|
const orgId = getGlobalConfig().oauthAccount?.organizationUuid
|
||||||
if (!orgId) return null
|
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 baseUrl = getOauthConfig().BASE_API_URL
|
||||||
const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react`
|
const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react`
|
||||||
|
|
||||||
@@ -136,11 +135,25 @@ async function callBuddyReactAPI(
|
|||||||
'User-Agent': getUserAgent(),
|
'User-Agent': getUserAgent(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: companion.name.slice(0, 32),
|
name: name.slice(0, 32),
|
||||||
personality: companion.personality.slice(0, 200),
|
personality: species.personality.slice(0, 200),
|
||||||
species: companion.species,
|
species: creature.speciesId,
|
||||||
rarity: companion.rarity,
|
rarity: creature.isShiny
|
||||||
stats: companion.stats,
|
? '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,
|
transcript,
|
||||||
reason: addressed ? 'addressed' : 'turn',
|
reason: addressed ? 'addressed' : 'turn',
|
||||||
recent: recentReactions.map(r => r.slice(0, 200)),
|
recent: recentReactions.map(r => r.slice(0, 200)),
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import { feature } from 'bun:bundle'
|
|||||||
import type { Message } from '../types/message.js'
|
import type { Message } from '../types/message.js'
|
||||||
import type { Attachment } from '../utils/attachments.js'
|
import type { Attachment } from '../utils/attachments.js'
|
||||||
import { getGlobalConfig } from '../utils/config.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 {
|
export function companionIntroText(name: string, species: string): string {
|
||||||
return `# Companion
|
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.`
|
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,
|
messages: Message[] | undefined,
|
||||||
): Attachment[] {
|
): Attachment[] {
|
||||||
if (!feature('BUDDY')) return []
|
if (!feature('BUDDY')) return []
|
||||||
const companion = getCompanion()
|
const data = loadBuddyData()
|
||||||
if (!companion || getGlobalConfig().companionMuted) return []
|
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.
|
// Skip if already announced for this companion.
|
||||||
for (const msg of messages ?? []) {
|
for (const msg of messages ?? []) {
|
||||||
if (msg.type !== 'attachment') continue
|
if (msg.type !== 'attachment') continue
|
||||||
if (msg.attachment!.type !== 'companion_intro') continue
|
if ((msg as any).attachment?.type !== 'companion_intro') continue
|
||||||
if (msg.attachment!.name === companion.name) return []
|
if ((msg as any).attachment?.name === name) return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: 'companion_intro',
|
type: 'companion_intro',
|
||||||
name: companion.name,
|
name,
|
||||||
species: companion.species,
|
species: species.names.zh ?? species.name,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Species, string[][]> = {
|
|
||||||
[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<Hat, string> = {
|
|
||||||
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})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<StatName, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<Rarity, number>
|
|
||||||
|
|
||||||
export const RARITY_STARS = {
|
|
||||||
common: '★',
|
|
||||||
uncommon: '★★',
|
|
||||||
rare: '★★★',
|
|
||||||
epic: '★★★★',
|
|
||||||
legendary: '★★★★★',
|
|
||||||
} as const satisfies Record<Rarity, string>
|
|
||||||
|
|
||||||
export const RARITY_COLORS = {
|
|
||||||
common: 'inactive',
|
|
||||||
uncommon: 'success',
|
|
||||||
rare: 'permission',
|
|
||||||
epic: 'autoAccept',
|
|
||||||
legendary: 'warning',
|
|
||||||
} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>
|
|
||||||
403
src/commands/buddy/BuddyPanel.tsx
Normal file
403
src/commands/buddy/BuddyPanel.tsx
Normal file
@@ -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<string, Color> = {
|
||||||
|
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 = [
|
||||||
|
<Tab key="buddy" title="Buddy">
|
||||||
|
{creature ? (
|
||||||
|
<BuddyTab creature={creature} buddyData={buddyData} spriteLines={spriteLines} />
|
||||||
|
) : (
|
||||||
|
<Text color={GRAY}>No buddy yet. Keep coding!</Text>
|
||||||
|
)}
|
||||||
|
</Tab>,
|
||||||
|
<Tab key="dex" title="Pokédex">
|
||||||
|
<DexTab buddyData={buddyData} />
|
||||||
|
</Tab>,
|
||||||
|
<Tab key="egg" title="Egg">
|
||||||
|
<EggTab buddyData={buddyData} />
|
||||||
|
</Tab>,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pane color="permission">
|
||||||
|
<Tabs color="permission" selectedTab={selectedTab} onTabChange={setSelectedTab} initialHeaderFocused={true}>
|
||||||
|
{tabs}
|
||||||
|
</Tabs>
|
||||||
|
</Pane>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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) => (
|
||||||
|
<React.Fragment key={t}>
|
||||||
|
{i > 0 && <Text color={GRAY}>/</Text>}
|
||||||
|
<Text color={TYPE_COLORS[t] ?? GRAY}>{t.toUpperCase()}</Text>
|
||||||
|
</React.Fragment>
|
||||||
|
));
|
||||||
|
|
||||||
|
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED;
|
||||||
|
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> ★SHINY★</Text> : null;
|
||||||
|
const evoHint = nextEvo ? (
|
||||||
|
<Text color={GRAY}>
|
||||||
|
{' '}
|
||||||
|
→ <Text color={CYAN}>{SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name}</Text> Lv.
|
||||||
|
{nextEvo.minLevel}
|
||||||
|
</Text>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text bold color={CYAN}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
|
||||||
|
{shinyBadge}
|
||||||
|
</Box>
|
||||||
|
<Text bold>Lv.{creature.level}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
|
||||||
|
<Text> </Text>
|
||||||
|
{typeBadges}
|
||||||
|
{genderSymbol && <Text> {genderSymbol}</Text>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{spriteLines && (
|
||||||
|
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||||
|
{spriteLines.map((line, i) => (
|
||||||
|
<Text key={i}>{line}</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY} italic>
|
||||||
|
"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column" marginTop={0}>
|
||||||
|
<Text color={GRAY}>─── Stats ───</Text>
|
||||||
|
{STAT_NAMES.map(stat => (
|
||||||
|
<StatBar key={stat} label={STAT_LABELS[stat]} value={stats[stat]} maxValue={255} color={getStatColor(stat)} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={GRAY}>XP </Text>
|
||||||
|
<Text color={BLUE}>
|
||||||
|
{'█'.repeat(Math.round(xp.percentage / 10))}
|
||||||
|
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{' '}
|
||||||
|
{xp.current}/{xp.needed}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>EV </Text>
|
||||||
|
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
|
||||||
|
<Text color={GRAY}> ({totalEV}/510)</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>♥ </Text>
|
||||||
|
<Text color={friendshipColor}>
|
||||||
|
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
|
||||||
|
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
|
||||||
|
</Text>
|
||||||
|
<Text> {creature.friendship}/255</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{evoHint && (
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={GRAY}>Next: </Text>
|
||||||
|
{evoHint}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box justifyContent="space-between">
|
||||||
|
<Text bold color={CYAN}>
|
||||||
|
Pokédex
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text bold color={collected === total ? GREEN : WHITE}>
|
||||||
|
{collected}
|
||||||
|
</Text>
|
||||||
|
<Text color={GRAY}>/{total}</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
|
||||||
|
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
|
||||||
|
<Text> {Math.floor((collected / total) * 100)}%</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{chains.map((chain, ci) => (
|
||||||
|
<Box key={ci} flexDirection="column">
|
||||||
|
{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 (
|
||||||
|
<Box key={speciesId}>
|
||||||
|
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
|
||||||
|
<Text>{isActive ? <Text color={YELLOW}>▶</Text> : ' '}</Text>
|
||||||
|
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
|
||||||
|
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
|
||||||
|
{discovered ? (species.names.zh ?? species.name) : '???'}
|
||||||
|
</Text>
|
||||||
|
{discovered && (
|
||||||
|
<Text>
|
||||||
|
{' '}
|
||||||
|
{species.types
|
||||||
|
.filter((t): t is string => Boolean(t))
|
||||||
|
.map((t, ti) => (
|
||||||
|
<React.Fragment key={t}>
|
||||||
|
{ti > 0 && <Text color={GRAY}>/</Text>}
|
||||||
|
<Text color={TYPE_COLORS[t] ?? GRAY}>{t.slice(0, 3).toUpperCase()}</Text>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{discovered && entry ? (
|
||||||
|
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={GRAY}> ───</Text>
|
||||||
|
)}
|
||||||
|
{nextEvo && (
|
||||||
|
<Text color={GRAY}>
|
||||||
|
{' '}
|
||||||
|
→<Text color={CYAN}>Lv.{nextEvo.minLevel}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Box marginTop={0} flexDirection="column">
|
||||||
|
<Text color={GRAY}>─── Stats ───</Text>
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>Turns: </Text>
|
||||||
|
<Text>{buddyData.stats.totalTurns}</Text>
|
||||||
|
<Text color={GRAY}> Days: </Text>
|
||||||
|
<Text>{buddyData.stats.consecutiveDays}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>Eggs: </Text>
|
||||||
|
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
||||||
|
<Text color={GRAY}> Evolutions: </Text>
|
||||||
|
<Text>{buddyData.stats.totalEvolutions}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{buddyData.eggs.length > 0 && (
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={YELLOW}>🥚 Egg: </Text>
|
||||||
|
<Text>
|
||||||
|
{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}
|
||||||
|
</Text>
|
||||||
|
<Text color={GRAY}> steps</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Egg Tab ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function EggTab({ buddyData }: { buddyData: BuddyData }) {
|
||||||
|
const eggs = buddyData.eggs;
|
||||||
|
|
||||||
|
if (eggs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text bold color={CYAN}>
|
||||||
|
Egg
|
||||||
|
</Text>
|
||||||
|
<Text color={GRAY}>No egg currently. Keep coding!</Text>
|
||||||
|
{buddyData.stats.consecutiveDays < 7 && (
|
||||||
|
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text bold color={CYAN}>
|
||||||
|
Egg Status
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||||
|
<Text> . </Text>
|
||||||
|
<Text> / \ </Text>
|
||||||
|
<Text> | | </Text>
|
||||||
|
<Text> \_/ </Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column" alignItems="center">
|
||||||
|
<Text>
|
||||||
|
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
|
||||||
|
</Text>
|
||||||
|
<Text color={YELLOW}>
|
||||||
|
{'█'.repeat(filled)}
|
||||||
|
{'░'.repeat(empty)}
|
||||||
|
</Text>
|
||||||
|
<Text>{percentage}%</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={0} flexDirection="column" alignItems="center">
|
||||||
|
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
|
||||||
|
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={0} flexDirection="column">
|
||||||
|
<Text color={GRAY}>─── Egg Stats ───</Text>
|
||||||
|
<Box>
|
||||||
|
<Text color={GRAY}>Total eggs: </Text>
|
||||||
|
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function getStatColor(stat: string): Color {
|
||||||
|
const colors: Record<string, Color> = {
|
||||||
|
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
import React from 'react'
|
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 { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||||
import { triggerCompanionReaction } from '../../buddy/companionReact.js'
|
import { triggerCompanionReaction } from '../../buddy/companionReact.js'
|
||||||
import type { ToolUseContext } from '../../Tool.js'
|
import type { ToolUseContext } from '../../Tool.js'
|
||||||
@@ -14,57 +6,46 @@ import type {
|
|||||||
LocalJSXCommandContext,
|
LocalJSXCommandContext,
|
||||||
LocalJSXCommandOnDone,
|
LocalJSXCommandOnDone,
|
||||||
} from '../../types/command.js'
|
} 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<string, string> = {
|
* Load or initialize Pokémon buddy data.
|
||||||
duck: 'Waddles',
|
* Migrates from legacy buddy system if needed.
|
||||||
goose: 'Goosberry',
|
*/
|
||||||
blob: 'Gooey',
|
function getOrInitBuddyData(): BuddyData {
|
||||||
cat: 'Whiskers',
|
let data = loadBuddyData()
|
||||||
dragon: 'Ember',
|
|
||||||
octopus: 'Inky',
|
// If no active creature, check for legacy companion to migrate
|
||||||
owl: 'Hoots',
|
if (!data.activeCreatureId || data.creatures.length === 0) {
|
||||||
penguin: 'Waddleford',
|
const legacyCompanion = getGlobalConfig().companion
|
||||||
turtle: 'Shelly',
|
if (legacyCompanion) {
|
||||||
snail: 'Trailblazer',
|
data = migrateFromLegacy(legacyCompanion)
|
||||||
ghost: 'Casper',
|
saveBuddyData(data)
|
||||||
axolotl: 'Axie',
|
}
|
||||||
capybara: 'Chill',
|
|
||||||
cactus: 'Spike',
|
|
||||||
robot: 'Byte',
|
|
||||||
rabbit: 'Flops',
|
|
||||||
mushroom: 'Spore',
|
|
||||||
chonk: 'Chonk',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPECIES_PERSONALITY: Record<string, string> = {
|
return data
|
||||||
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.',
|
|
||||||
}
|
|
||||||
|
|
||||||
function speciesLabel(species: string): string {
|
|
||||||
return species.charAt(0).toUpperCase() + species.slice(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function call(
|
export async function call(
|
||||||
@@ -89,18 +70,45 @@ export async function call(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── /buddy pet — trigger heart animation + auto unmute ──
|
// ── /buddy pet — trigger heart animation + XP + egg steps ──
|
||||||
if (sub === 'pet') {
|
if (sub === 'pet') {
|
||||||
const companion = getCompanion()
|
const data = getOrInitBuddyData()
|
||||||
if (!companion) {
|
const creature = getActiveCreature(data)
|
||||||
onDone('no companion yet \u00b7 run /buddy first', { display: 'system' })
|
if (!creature) {
|
||||||
|
onDone('no companion yet · run /buddy first', { display: 'system' })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-unmute on pet + trigger heart animation
|
// Auto-unmute + heart animation
|
||||||
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
||||||
setState?.(prev => ({ ...prev, companionPetAt: Date.now() }))
|
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
|
// Trigger a post-pet reaction
|
||||||
triggerCompanionReaction(context.messages ?? [], reaction =>
|
triggerCompanionReaction(context.messages ?? [], reaction =>
|
||||||
setState?.(prev =>
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── /buddy (no args) — show existing or hatch ──
|
// ── /buddy rename — rename current creature ──
|
||||||
const companion = getCompanion()
|
if (sub.startsWith('rename ')) {
|
||||||
|
const nickname = sub.slice(7).trim()
|
||||||
|
if (!nickname) {
|
||||||
|
onDone('Usage: /buddy rename <name>', { 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 <number>'].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
|
// Auto-unmute when viewing
|
||||||
if (companion && getGlobalConfig().companionMuted) {
|
if (getGlobalConfig().companionMuted) {
|
||||||
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (companion) {
|
// No creature → initialize new one
|
||||||
// Return JSX card — matches official vc8 component
|
if (!creature) {
|
||||||
const lastReaction = context.getAppState?.()?.companionReaction
|
const legacyCompanion = getGlobalConfig().companion
|
||||||
return React.createElement(CompanionCard, {
|
if (legacyCompanion) {
|
||||||
companion,
|
const migrated = migrateFromLegacy(legacyCompanion)
|
||||||
lastReaction,
|
saveBuddyData(migrated)
|
||||||
onDone: onDone as unknown as Parameters<typeof CompanionCard>[0]['onDone'],
|
creature = getActiveCreature(migrated)!
|
||||||
|
} else {
|
||||||
|
const defaultData = getDefaultBuddyData()
|
||||||
|
saveBuddyData(defaultData)
|
||||||
|
creature = getActiveCreature(defaultData)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fetch sprite if not cached
|
||||||
|
const spriteCached = loadSprite(creature.speciesId)
|
||||||
|
if (!spriteCached) {
|
||||||
|
fetchAndCacheSprite(creature.speciesId).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const spriteLines =
|
||||||
|
spriteCached?.lines ?? getFallbackSprite(creature.speciesId)
|
||||||
|
|
||||||
|
// Reload data to get latest state after possible initialization
|
||||||
|
const latestData = loadBuddyData()
|
||||||
|
|
||||||
|
return React.createElement(BuddyPanel, {
|
||||||
|
buddyData: latestData,
|
||||||
|
spriteLines,
|
||||||
|
onClose: () => {
|
||||||
|
onDone('buddy panel closed', { display: 'system' })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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(),
|
|
||||||
}
|
|
||||||
|
|
||||||
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
|
|
||||||
|
|
||||||
const stars = RARITY_STARS[r.bones.rarity]
|
|
||||||
const sprite = renderSprite(r.bones, 0)
|
|
||||||
const shiny = r.bones.shiny ? ' \u2728 Shiny!' : ''
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { isBuddyLive } from '../../buddy/useBuddyNotification.js'
|
|||||||
const buddy = {
|
const buddy = {
|
||||||
type: 'local-jsx',
|
type: 'local-jsx',
|
||||||
name: 'buddy',
|
name: 'buddy',
|
||||||
description: 'Hatch a coding companion · pet, off',
|
description: 'Pokémon coding companion · pet, dex, egg, switch, rename, off',
|
||||||
argumentHint: '[pet|off]',
|
argumentHint: '[pet|dex|egg|switch|rename <name>|on|off]',
|
||||||
immediate: true,
|
immediate: true,
|
||||||
get isHidden() {
|
get isHidden() {
|
||||||
return !isBuddyLive()
|
return !isBuddyLive()
|
||||||
|
|||||||
@@ -1,52 +1,84 @@
|
|||||||
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
|
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { CHANNEL_ARROW } from '../../constants/figures.js'
|
import { CHANNEL_ARROW } from '../../constants/figures.js';
|
||||||
import { CHANNEL_TAG } from '../../constants/xml.js'
|
import { CHANNEL_TAG } from '../../constants/xml.js';
|
||||||
import { Box, Text } from '@anthropic/ink'
|
import { Box, Text } from '@anthropic/ink';
|
||||||
import { truncateToWidth } from '../../utils/format.js'
|
import { truncateToWidth } from '../../utils/format.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
addMargin: boolean
|
addMargin: boolean;
|
||||||
param: TextBlockParam
|
param: TextBlockParam;
|
||||||
}
|
};
|
||||||
|
|
||||||
// <channel source="..." user="..." chat_id="...">content</channel>
|
// <channel source="..." user="..." chat_id="...">content</channel>
|
||||||
// source is always first (wrapChannelMessage writes it), user is optional.
|
// source is always first (wrapChannelMessage writes it), user is optional.
|
||||||
const CHANNEL_RE = new RegExp(
|
const CHANNEL_RE = new RegExp(`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`);
|
||||||
`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`,
|
const USER_ATTR_RE = /\buser="([^"]+)"/;
|
||||||
)
|
|
||||||
const USER_ATTR_RE = /\buser="([^"]+)"/
|
|
||||||
|
|
||||||
// Plugin-provided servers get names like plugin:slack-channel:slack via
|
// Plugin-provided servers get names like plugin:slack-channel:slack via
|
||||||
// addPluginScopeToServers — show just the leaf. Matches the suffix-match
|
// addPluginScopeToServers — show just the leaf. Matches the suffix-match
|
||||||
// logic in isServerInChannels.
|
// logic in isServerInChannels.
|
||||||
function displayServerName(name: string): string {
|
function displayServerName(name: string): string {
|
||||||
const i = name.lastIndexOf(':')
|
const i = name.lastIndexOf(':');
|
||||||
return i === -1 ? name : name.slice(i + 1)
|
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<string[]>((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 (
|
return (
|
||||||
<Box marginTop={addMargin ? 1 : 0}>
|
<Box marginTop={addMargin ? 1 : 0} flexDirection="column">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
{i === 0 ? (
|
||||||
<Text>
|
<Text>
|
||||||
<Text color="suggestion">{CHANNEL_ARROW}</Text>{' '}
|
<Text color="suggestion">{CHANNEL_ARROW}</Text>{' '}
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
{displayServerName(source ?? '')}
|
{displayServerName(source ?? '')}
|
||||||
{user ? ` \u00b7 ${user}` : ''}:
|
{user ? ` \u00b7 ${user}` : ''}:
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
{truncated}
|
{line}
|
||||||
|
{truncated && i === lines.length - 1 ? ' …' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text>
|
||||||
|
{' '}
|
||||||
|
{line}
|
||||||
|
{truncated && i === lines.length - 1 ? ' …' : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type CancelRequestHandlerProps = {
|
|||||||
popCommandFromQueue?: () => void
|
popCommandFromQueue?: () => void
|
||||||
vimMode?: VimMode
|
vimMode?: VimMode
|
||||||
isLocalJSXCommand?: boolean
|
isLocalJSXCommand?: boolean
|
||||||
|
onDismissLocalJSX?: () => void
|
||||||
isSearchingHistory?: boolean
|
isSearchingHistory?: boolean
|
||||||
isHelpOpen?: boolean
|
isHelpOpen?: boolean
|
||||||
inputMode?: PromptInputMode
|
inputMode?: PromptInputMode
|
||||||
@@ -71,6 +72,7 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
|||||||
popCommandFromQueue,
|
popCommandFromQueue,
|
||||||
vimMode,
|
vimMode,
|
||||||
isLocalJSXCommand,
|
isLocalJSXCommand,
|
||||||
|
onDismissLocalJSX,
|
||||||
isSearchingHistory,
|
isSearchingHistory,
|
||||||
isHelpOpen,
|
isHelpOpen,
|
||||||
inputMode,
|
inputMode,
|
||||||
@@ -92,6 +94,12 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
|||||||
streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
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
|
// Priority 1: If there's an active task running, cancel it first
|
||||||
// This takes precedence over queue management so users can always interrupt Claude
|
// This takes precedence over queue management so users can always interrupt Claude
|
||||||
if (abortSignal !== undefined && !abortSignal.aborted) {
|
if (abortSignal !== undefined && !abortSignal.aborted) {
|
||||||
@@ -140,16 +148,16 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
|||||||
screen !== 'transcript' &&
|
screen !== 'transcript' &&
|
||||||
!isSearchingHistory &&
|
!isSearchingHistory &&
|
||||||
!isMessageSelectorVisible &&
|
!isMessageSelectorVisible &&
|
||||||
!isLocalJSXCommand &&
|
|
||||||
!isHelpOpen &&
|
!isHelpOpen &&
|
||||||
!isOverlayActive &&
|
!isOverlayActive &&
|
||||||
!(isVimModeEnabled() && vimMode === 'INSERT')
|
!(isVimModeEnabled() && vimMode === 'INSERT')
|
||||||
|
|
||||||
// Escape (chat:cancel) defers to mode-exit when in special mode with empty
|
// 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 =
|
const isEscapeActive =
|
||||||
isContextActive &&
|
isContextActive &&
|
||||||
(canCancelRunningTask || hasQueuedCommands) &&
|
(canCancelRunningTask || hasQueuedCommands || !!isLocalJSXCommand) &&
|
||||||
!isInSpecialModeWithEmptyInput &&
|
!isInSpecialModeWithEmptyInput &&
|
||||||
!isViewingTeammate
|
!isViewingTeammate
|
||||||
|
|
||||||
|
|||||||
@@ -2564,6 +2564,9 @@ export function REPL({
|
|||||||
popCommandFromQueue: handleQueuedCommandOnCancel,
|
popCommandFromQueue: handleQueuedCommandOnCancel,
|
||||||
vimMode,
|
vimMode,
|
||||||
isLocalJSXCommand: toolJSX?.isLocalJSXCommand,
|
isLocalJSXCommand: toolJSX?.isLocalJSXCommand,
|
||||||
|
onDismissLocalJSX: useCallback(() => {
|
||||||
|
setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true });
|
||||||
|
}, [setToolJSX]),
|
||||||
isSearchingHistory,
|
isSearchingHistory,
|
||||||
isHelpOpen,
|
isHelpOpen,
|
||||||
inputMode,
|
inputMode,
|
||||||
@@ -3434,6 +3437,69 @@ export function REPL({
|
|||||||
// Log query profiling report if enabled
|
// Log query profiling report if enabled
|
||||||
logQueryProfileReport();
|
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
|
// Signal that a query turn has completed successfully
|
||||||
await onTurnComplete?.(messagesRef.current);
|
await onTurnComplete?.(messagesRef.current);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ export type AppState = DeepImmutable<{
|
|||||||
companionReaction?: string
|
companionReaction?: string
|
||||||
// Timestamp of last /buddy pet — CompanionSprite renders hearts while recent
|
// Timestamp of last /buddy pet — CompanionSprite renders hearts while recent
|
||||||
companionPetAt?: number
|
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
|
// TODO (ashwin): see if we can use utility-types DeepReadonly for this
|
||||||
mcp: {
|
mcp: {
|
||||||
clients: MCPServerConnection[]
|
clients: MCPServerConnection[]
|
||||||
|
|||||||
@@ -266,8 +266,13 @@ export type GlobalConfig = {
|
|||||||
[tipId: string]: number // Key is tipId, value is the numStartups when tip was last shown
|
[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/.
|
// /buddy companion — legacy data (migrated to buddy-data.json by Pokémon system)
|
||||||
companion?: import('../buddy/types.js').StoredCompanion
|
companion?: {
|
||||||
|
name: string
|
||||||
|
personality: string
|
||||||
|
seed?: string
|
||||||
|
hatchedAt: number
|
||||||
|
}
|
||||||
companionMuted?: boolean
|
companionMuted?: boolean
|
||||||
|
|
||||||
// Feedback survey tracking
|
// Feedback survey tracking
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
"@claude-code-best/agent-tools/*": ["./packages/agent-tools/src/*"],
|
"@claude-code-best/agent-tools/*": ["./packages/agent-tools/src/*"],
|
||||||
"@claude-code-best/agent-tools": ["./packages/agent-tools/src/index.ts"],
|
"@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/*"],
|
||||||
"@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"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "packages/**/*.ts", "packages/**/*.tsx"],
|
||||||
|
|||||||
Reference in New Issue
Block a user