Compare commits

...

83 Commits

Author SHA1 Message Date
claude-code-best
661b1e29e4 Merge branch 'main' into feature/pokemon/battle 2026-04-24 21:33:23 +08:00
claude-code-best
00c120f95c chore: 1.9.4 2026-04-24 11:48:40 +08:00
claude-code-best
7b513103d9 refactor: 解耦 BRIDGE_MODE 与 DAEMON,禁用 DAEMON 降低内存占用
- 从 DEFAULT_BUILD_FEATURES 注释掉 DAEMON(内存占用过高)
- remoteControlServer 命令门控从 feature('DAEMON') && feature('BRIDGE_MODE')
  改为仅 feature('BRIDGE_MODE'),bridge 不再依赖 daemon
- --daemon-worker 快速路径改为运行时检测,未启用时输出明确错误提示

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
d6374f02d6 fix: 修复 ACP 模式下 messageSelector require 失败导致 submitMessage 崩溃
ACP 模式不加载完整的 React/Ink UI 组件,导致 require('src/components/MessageSelector.js')
返回 undefined。添加 try-catch 和 optional chaining fallback。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
b217836f5a docs: 为 DEFAULT_BUILD_FEATURES 每个 feature flag 添加功能注释
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
4cf1a8353e test: 添加 PP 递减测试
验证使用招式后 PP 减少 1,maxPp 保持不变。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
a58a36c35a fix: 修复战斗中 PP 不递减的问题
projectPokemon 读取 maxPp 属性名错误,Showdown 使用小写 maxpp,
导致 maxPp 回退到当前 pp 值,UI 显示的 PP 总是满的。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
c5c7202348 fix: 修复测试因 IV/性格非确定性导致的间歇失败
- battle-scenarios: 回合测试改用 pikachu vs pikachi 避免非确定性一击倒
- creature: EV 测试提升至 level 50 以确保 EV 贡献可见
- creature: level 1 stat 测试使用确定性 Hardy 性格避免 flaky

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
192221eafc feat: 多语言名称支持动态加载全量数据
- names.ts 新增 getSpeciesI18nName() 按 lang 获取名称
- 自动尝试加载 species-names.ts 生成数据(如已生成)
- 保留 10 个手工校对条目作为回退
- 配合 fetch-species-names.ts 脚本可获取 1024 个物种中/日名称
- 解决 #18 多语言名称覆盖极少

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
8c6be4b5d3 feat: 支持随机特性选择,包括隐藏特性
- 新增 getAbilities() 返回所有可用特性(含隐藏)
- 新增 randomAbility():80% 普通特性、20% 第二特性、5% 隐藏特性
- 保留 getDefaultAbility() 向后兼容
- 解决 #20 Ability 系统不完整

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
c37b274406 fix: 蛋孵化步数改用真实 hatchCounter 数据
- 孵化步数从 captureRate 反推改为 hatchCounter * 257(原版公式)
- getHatchCounter 支持进化阶段/传说回退分类
- fetch-pokedex-data.ts 已更新以采集 hatchCounter 字段
- 解决 #17 蛋系统孵化步数不准确

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
e16b5667a8 fix: 改用 Gen 3+ 标准方法生成 PID/IV/闪亮/性别
- 新增 generatePID() 生成 32 位 Personality Value
- IV 改为 PID 位提取法(word1/word2 各取 3 个 5-bit),替换 LCRNG
- Shiny 检测改为 PID XOR 方法,阈值 < 16(Gen 8+ 约 1/4096)
- 性别阈值从 (rate/8)*256 改为 rate*32,消除浮点精度丢失
- 生成生物时使用 randomAbility() 替代 getDefaultAbility()
- 解决 #14 Shiny 检测、#15 IV 生成、#16 性别阈值、#20 Ability 选择

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
e9405e4a8a feat: 战斗引擎全面升级 — 捕获/逃跑/多对手/AI/道具/状态
- 新增 capture.ts:Gen 9 捕获率计算,支持精灵球/状态/时间修正
- 实现逃跑概率公式 (Gen 9) 和失败累计机制
- createBattle 支持多对手 OpponentEntry[],AI 换人考虑属性克制
- AI 选招改为优先克制招式,避免蓄力招式和被抵抗招
- 野生招式从 Dex.data.Learnsets 按等级获取,替换硬编码映射
- 实现 Potion/SuperPotion/FullRestore 等回复药效果
- 野生对手随机持有道具(5%树果/专属、3%属性增强道具)
- 新增 VolatileStatus 类型,BattlePokemon 添加 volatileStatus
- needsSwitch 检测改为更健壮的 p1Fainted + hasAliveBench 逻辑
- 解决 #3 物品使用、#4 逃跑、#5 多精灵对战、#6 AI、#7 野生招式、
  #10 捕获系统、#11 volatile状态、#12 天气/地形、#19 野生道具

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
be64db70d4 fix: 修正战斗结算的 XP 和 EV 计算
- XP 公式改为使用真实 baseExperience(从 PokeAPI),而非 baseStats.hp
- EV yield 改为使用真实数据(getPokedexEvYield),而非伪造的映射
- 进化检测改为遍历所有 evos 目标,支持分支进化
- 新增友谊度进化检测(friendship >= 220)
- 解决 #1 XP 公式错误、#2 EV 伪造、#8 进化只取第一个目标

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
6fc365cf73 feat: 从 PokeAPI 批量导入物种数据,替换硬编码
- 新增 pokedex-data.ts:1024 个物种的 baseExperience、EV yield、growthRate、captureRate、baseHappiness、hatchCounter
- 新增 fetch-pokedex-data.ts:PokeAPI 数据抓取脚本(可重复运行)
- 新增 fetch-species-names.ts:多语言名称抓取脚本(中/日/英)
- species.ts 改为使用 pokedex-data 替代硬编码 supplement 条目
- 解决 #1 XP 数据源、#2 EV 数据源、#13 Growth Rate 覆盖不全问题

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
6a89a5139a docs: 添加 Pokémon 战斗系统审查报告
记录 20 个已发现的 bug 及修复状态,涵盖严重/中等/轻度三个级别。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
6ed8f5b870 chore: 1.9.3 2026-04-24 11:48:40 +08:00
claude-code-best
bc17003301 fix: 修复 usePipeIpc 中 require 返回 undefined 导致启动崩溃
将 lazy require() 调用全部替换为静态 import,解决构建产物中
模块加载时序问题导致的 'undefined is not an object' 错误。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
dc13eb9c10 chore: 1.9.2 2026-04-24 11:48:40 +08:00
claude-code-best
ec6a223b85 chore: 更新版本流水线 2026-04-24 11:48:40 +08:00
claude-code-best
27e9857741 chore: 1.9.1 2026-04-24 11:48:40 +08:00
claude-code-best
090e3515ae ci: 删除冗余 release 工作流 2026-04-24 11:48:40 +08:00
claude-code-best
0572d5591b ci: 添加 GitHub Release 和自动生成 changelog 到发布流程 2026-04-24 11:48:40 +08:00
claude-code-best
24922affd2 ci: 统一 typecheck 命令并添加 npm 发布工作流 2026-04-24 11:48:40 +08:00
claude-code-best
10b5f35140 fix: 修复第三方 Anthropic base URL 应使用 ExaSearchAdapter 而非 BingSearchAdapter
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:40 +08:00
claude-code-best
b3fce1edb7 chore: 贡献者更新工作流改为每周定时触发
移除 push 触发,仅保留每周一 schedule 触发。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:20 +08:00
claude-code-best
5e47489579 fix: 修复 cliHighlight 类型不兼容问题
loadedGetLanguage 返回类型中 name 字段改为可选,匹配 highlight.js
Language 类型中 name 为 string | undefined 的定义。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:48:20 +08:00
claude-code-best
3210caddb0 chore: 1.9.0 2026-04-24 11:48:20 +08:00
Bot
51a3a83f07 fix: 将 highlight.js 改为静态导入以兼容 Bun --compile 模式
- cliHighlight.ts: 使用静态 import 替换 dynamic import('highlight.js'),
  因为编译模式下模块解析指向内部 bunfs 路径导致无法找到
- color-diff-napi/src/index.ts: 同样改为静态导入,移除 createRequire 延迟加载
2026-04-24 11:48:20 +08:00
Bot
c69e66d2cd feat: 添加 Exa AI 搜索适配器
- 新增 ExaSearchAdapter,基于 MCP 协议调用 Exa 搜索 API
- WebSearchTool 支持 num_results、livecrawl、search_type、context_max_characters 等高级选项
- 非 Anthropic 官方 base URL 时默认使用 Exa 适配器
2026-04-24 11:48:20 +08:00
claude-code-best
cbda09d7ee chore: 添加 release 脚本 2026-04-24 11:47:51 +08:00
claude-code-best
c88943795f fix: 修复 build 过程中的问题 2026-04-24 11:47:51 +08:00
claude-code-best
ecf2dbde44 feat: 又一大波改动 2026-04-23 11:20:24 +08:00
claude-code-best
1a910ed639 refactor: 统一 log.ts/debug.ts 的测试 mock 为共享定义
- 新增 tests/mocks/log.ts 和 tests/mocks/debug.ts,覆盖源文件全部实际导出
- 移除旧 mock 中不存在的导出(logToFile、logEvent、getLogFilePath)
- 13 个测试文件改为使用共享 mock,避免定义分散和不一致

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:35:59 +08:00
claude-code-best
dceaacdf4f Merge remote-tracking branch 'origin/main' into feature/pokemon/battle 2026-04-22 22:59:13 +08:00
claude-code-best
7813904264 feat: 修复小细节 2026-04-22 17:59:22 +08:00
claude-code-best
02783e4f5d feat: PC Box 管理系统 + 全英文名统一 + 队伍补位机制
- 新增 PC Box tab(左侧 party + 右侧 box 网格,支持 party↔box 拾取/放置/交换)
- 空格键抓取/放下,左键在 col=0 时切到 party 面板
- 使用 useTabHeaderFocus 避免左右键被 Tabs 组件拦截
- 所有 1025 只精灵统一使用 Dex 英文名,移除中英混搭
- compactParty 补位机制:不允许前置空位,队伍最少保留一只
- PC Box tab 移至第二位(Buddy → PC Box → Pokédex → Egg)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 17:21:24 +08:00
claude-code-best
9930a53e51 fix: BuddyPanel DexTab 改为统计视图,修复 1025 字符进度条
- 进度条固定 30 字符宽度,按百分比填充(原来 repeat(1025) 破坏布局)
- 新增分代统计(Gen I-IX),每代显示迷你进度条和收集数
- 只展示已发现的前 15 只精灵(原来渲染全部 1025 条进化链)
- 删除硬编码的 groupByChain/getChainFor/isInChain helpers
- 移除 Select 组件和详情面板(搜索功能由 BattleFlow 的 SpeciesPicker 提供)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 16:01:29 +08:00
claude-code-best
2c15d9123d fix: PokedexView 改为百分比统计视图,不再渲染全部 1025 只精灵
- 进度条固定 30 字符宽度,按百分比填充
- 新增分代统计(Gen I-IX),每代显示迷你进度条
- 只展示已发现的精灵,而非全部 1025 条
- 删除 groupByChain() 及进化链渲染(列表太长)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 15:18:18 +08:00
claude-code-best
1217c453c4 feat: 同步 pkmn Dex 全部 1025 只精灵,新增 SpeciesPicker 搜索选择器
- SpeciesId 从 10 项联合类型改为 string,动态从 @pkmn/sim Dex 加载 1025 只精灵
- getSpecies() 改用 Dex.species.get() 直接查找(gen wrapper 仅覆盖 733/1025)
- SUPPLEMENT/DEX_TO_SPECIES 动态生成,未收录 species 使用默认值兜底
- names/fallback 改为 partial records,缺失时回退到 Dex 英文名/通用 sprite
- 新增 SpeciesPicker 组件(基于 FuzzyPicker),支持中英文/编号搜索选择精灵
- BattleFlow configSelect 阶段替换为 SpeciesPicker,删除旧的上下翻页逻辑
- evolution 移除 ALL_SPECIES_IDS 限制,所有 Dex 物种均可进化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 15:15:19 +08:00
claude-code-best
77e8d15482 feat: 解决显示问题 2026-04-22 14:24:41 +08:00
claude-code-best
72cfb83de3 feat: 一大坨优化 2026-04-22 13:33:02 +08:00
claude-code-best
8bf645364f fix: 处理精灵倒下后的强制换人和战斗结束判定
executeTurn 在精灵倒下时未处理 @pkmn/sim 的强制换人要求,
导致 "Not all choices done" 错误。现在:
- 检测 active Pokémon 是否倒下,自动切换到下一只
- 无可用精灵时使用 pass,让引擎正常判定胜负
- AI 侧同样处理强制换人

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 09:03:39 +08:00
claude-code-best
1b777a25ac fix: PromptInput 在 local-jsx 命令激活时跳过键盘处理
PromptInput 的 useInput 没有 isLocalJSXCommandActive 检查,
导致所有键盘事件被 PromptInput 消费,local-jsx 命令面板
(如 /pokemon-battle)无法接收输入。新增该检查。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:58:56 +08:00
claude-code-best
af0a7054c7 feat: 添加 auto-issue-fix GitHub Action,ai-fix 标签触发自动修复
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:55:27 +08:00
claude-code-best
ea0eee05d0 feat: 新增 /pokemon-battle 独立战斗命令,从 BuddyPanel 移除 Battle tab
- 新增 /pokemon-battle 命令,独立全屏战斗面板
- BattlePanel 在主 app Ink 上下文中使用 useInput,通过 inputRef 转发事件
- BuddyPanel 恢复为 Buddy/Pokédex/Egg 三 tab
- BattleFlow 移除内部 useInput,改为暴露 handleInput 方法

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:51:04 +08:00
claude-code-best
bd70971632 fix: Battle 快捷键通过 inputRef 转发至主 Ink 上下文
BattleFlow 不再直接调用 useInput(外部包的 Ink 上下文可能不同),
改为暴露 handleInput 方法,由 BuddyPanel 中的 BattleTab 通过
useInput + ref 转发键盘事件,确保在正确的 StdinContext 中工作。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:44:33 +08:00
claude-code-best
d8e33935db feat: 添加 fix-issue skill 处理 GitHub issue 修复工作流
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:41:23 +08:00
claude-code-best
bfd14206a9 fix: 修复 BattleView 渲染 @pkmn/sim 原始 move 对象导致的 React 报错
projectPokemon 中 moveSlots 的 name 字段可能为 undefined,
导致整个对象被当作 React child 渲染。现在优先从 Dex 查询招式名称。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:37:03 +08:00
claude-code-best
f22caf0e97 feat: 集成 Battle tab 到 BuddyPanel,重命名 data/ 为 dex/ 规避 gitignore
- BuddyPanel 新增 Battle tab,BattleFlow 加 isActive 控制
- BattleFlow configSelect 阶段支持 ↑↓ 选择物种
- packages/pokemon/src/data/ → dex/,解决根 .gitignore 匹配问题
- 全量 Tab→2空格 缩进转换

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:35:19 +08:00
claude-code-best
25067e78af fix: 修正 MoveLearnPanel 提示文本匹配实际键盘绑定
[S] 跳过, [1-4] 替换对应招式 — 与 BattleFlow 输入处理一致

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 07:03:22 +08:00
claude-code-best
70d8c0038c refactor: 进化链动态生成替代硬编码
- PokedexView.tsx: groupByChain() 改用 ALL_SPECIES_IDS + getNextEvolution 动态构建
- SpeciesDetail.tsx: EvolutionChain 用相同方式找链头
- 删除未使用的 isInChain 函数

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 06:32:36 +08:00
claude-code-best
3c64113d77 feat: 添加 BattleFlow 完整键盘输入处理
所有 phase 现在都支持键盘交互:
- config: Enter/1=随机战斗, 2=指定对手, ESC=取消
- configSelect: Enter=确认, ESC=返回
- battle: 1-4=选招, S=换人, I=道具
- switch: 1-6=选队友, ESC=取消
- item: 1-9=选道具, ESC=取消
- result: Enter=继续
- learnMoves: 1-4=替换招式, S=跳过
- evolution: Enter=确认进化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 06:07:01 +08:00
claude-code-best
0777e1a1f9 refactor: 删除未使用的 pkmn.ts 辅助函数
- 删除 getMove、getAbility、getType、getPrimaryAbility(无生产代码引用)
- 同步删除对应的 pkmn.test.ts 测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 06:04:12 +08:00
claude-code-best
080bd93efc test: 补充 hatchEgg 和 species 补充字段测试(227→237)
- egg.test.ts: 新增 hatchEgg 5 个测试
  - 创建 creature 并移除 egg
  - 放入 party 空位
  - totalEggsObtained 计数
  - 新 species dex entry 创建
  - 已有 species caughtCount 递增
- species.test.ts: 新增 ensureSpeciesData、baseHappiness、captureRate、names、shinyChance 测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 05:04:16 +08:00
claude-code-best
363ba39cad refactor: 消除 engine.ts 重复 break 和 evolution.test.ts 的 as any
- engine.ts: 移除 switch case 中多余的 break 语句
- evolution.test.ts: 用完整 Creature 对象替代 as any 类型转换

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 04:40:59 +08:00
claude-code-best
4b23bcd3eb test: 增强测试覆盖(188→226 tests)
- battle.test.ts: 新增 createBattle/executeTurn/settleBattle 边界测试
  - EV total cap、非参与者不变、空 participantIds 回退
  - applyMoveLearn PP 验证、applyEvolution friendship cap
  - 多次进化计数器、battlesWon/battlesLost 互斥
  - 修复 makeTestCreature friendship 覆盖
- creature.test.ts: 新增 recalculateLevel、getActiveCreature、nature 效果测试
- experience.test.ts: 新增 xpToNextLevel、awardXP 0值、累积验证
- storage.test.ts: 新增 Bag/Box/Release 操作边界测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 04:39:41 +08:00
claude-code-best
4116ac9b5c test: 增强 creature.test.ts 覆盖(recalculateLevel/getActiveCreature/性格效果)
新增 8 个测试用例:
- recalculateLevel: 等级不变/随经验更新
- getActiveCreature: 空 party/party[0]/legacy activeCreatureId/优先级/ID 不存在
- 性格效果: adamant 加攻减特攻、timid 加速减攻

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 04:35:51 +08:00
claude-code-best
39299f6e17 refactor: 清理未使用 import 和添加 spriteCache 错误日志
- SpeciesDetail.tsx: 移除未使用的 SPECIES_PERSONALITY import
- CompanionCard.tsx: 移除未使用的 SPECIES_I18N import
- spriteCache.ts: 空 catch 块添加错误日志输出

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 04:18:51 +08:00
claude-code-best
1bba087942 refactor: 代码优化(常量复用、清理未使用 import、错误日志)
- settlement.ts: 复用 MAX_EV_PER_STAT/MAX_EV_TOTAL 常量替代硬编码
- settlement.ts: 删除未使用的 Creature/addItemToBag/removeItemFromBag/xpForLevel import
- effort.ts: 复用 EV_COOLDOWN_MS 常量替代硬编码 30000
- storage.ts: 空 catch 块添加错误日志输出

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 04:16:42 +08:00
claude-code-best
7c64199fc5 fix: 修复战斗系统 bug(switch 映射、async learnableMoves、类型安全)
- engine.ts: switch 动作改为映射 creatureId 到 party slot index
- settlement.ts: 改用 for-of 循环支持 async learnableMoves 检测
- types.ts: miss 事件增加 side 字段,消除 as any
- BattleFlow.tsx: handleAction 改为 async 支持 await settleBattle
- battle.test.ts: 补充缺失的 async 标记

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 04:12:13 +08:00
claude-code-best
df61bf3852 chore: 删除已完成的计划文件 (Phase 0-3)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 03:02:15 +08:00
claude-code-best
98284a5908 refactor: 提取共享 getStatColor、移除 deprecated EVOLUTION_CHAINS
- 新增 ui/shared.ts 统一 getStatColor 函数
- CompanionCard/SpeciesDetail 改用共享函数,消除重复
- 移除 data/evolution.ts 中已废弃的 EVOLUTION_CHAINS 常量
- 清理 index.ts 导出

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 01:58:33 +08:00
claude-code-best
fae96c3e7f test: 补全 spriteCache/renderer/battle 测试用例
- 新增 spriteCache.test.ts: getSpeciesDisplay 格式化测试
- 扩展 renderer.test.ts: 覆盖所有 AnimMode + getIdleAnimMode + getPetOverlay
- 扩展 battle.test.ts: AI 边界情况 + settlement XP/EV 奖励 + 失败路径

188 tests / 0 fail (was 164)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 01:54:28 +08:00
claude-code-best
661cc764fe refactor: 清理 SwitchPanel 未使用变量和导入
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:51:51 +08:00
claude-code-best
391e0c233a chore: 移除 SwitchPanel 未使用的 calculateStats 导入
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:50:27 +08:00
claude-code-best
74682b2a82 refactor: 代码优化 — 扩展类型覆盖、修复变量遮蔽、移除未使用导入
- engine.ts: 扩展 getSpeciesMoves 覆盖全部 18 种属性
- settlement.ts: 重命名 species → oppSpecies 避免遮蔽外层变量
- storage.ts: addItemToBag/removeItemFromBag 深拷贝 bag items 避免修改原对象
- BattleFlow.tsx: 移除未使用导入和条件 useInput 调用(React hooks 规则)
- BattleView.tsx: 移除未使用的 BattlePokemon/MoveOption/Dex 导入

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:49:48 +08:00
claude-code-best
100b1589f2 fix: 修复 BattleFlow 进化阶段输入处理 + 清理无用文件
- BattleFlow.tsx: useInput hook 提升到顶层避免 React hooks 规则违规
- 删除未使用的 battle/adapter.ts 和 battle/handler.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:47:05 +08:00
claude-code-best
fa8e45e933 test: 新增数据层测试 + 引擎修复
- 新增 pkmn.test.ts: stat 映射测试
- 新增 species.test.ts: 物种数据测试
- 新增 xpTable.test.ts: XP 公式测试
- 新增 evMapping.test.ts: EV 映射测试
- 新增 names.test.ts: 多语言名称测试
- 新增 fallback.test.ts: 精灵 fallback 测试
- 修复 engine.ts 类型

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:45:57 +08:00
claude-code-best
96e6d33414 test: 新增 storage.test.ts
- 验证 BuddyData v2 结构正确性
- 验证 creature 包含 v2 字段

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:44:12 +08:00
claude-code-best
1dd36f3f6f test: 添加 battle/nature/learnsets/storage 测试,修复 nature 映射
- battle.test.ts: 10 个测试覆盖 createBattle/executeTurn/settleBattle/applyMoveLearn/applyEvolution/AI
- nature.test.ts: 测试 getAllNatureNames/randomNature/getNatureEffect
- learnsets.test.ts: 测试 getDefaultMoveset/getDefaultAbility/getNewLearnableMoves
- storage.test.ts: 测试 depositToBox/withdrawFromBox/findCreatureLocation/releaseCreature
- 修复 getNatureEffect 返回 Dex 格式(atk/spa/spe)未映射为我们的格式(attack/spAtk/speed)
- 删除遗留的 battle/adapter.ts 和 battle/handler.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:43:19 +08:00
claude-code-best
e3570f8cdb feat: 添加 BattleFlow 战斗状态机组件 (Phase 3)
实现完整的战斗 UI 流程:配置 → 战斗 → 换人/道具 → 结果 → 学招 → 进化 → 完成

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:38:17 +08:00
claude-code-best
f5a97011e8 feat: Phase 3 — 战斗 UI 终端交互组件
- BattleConfigPanel: 战斗前配置(队伍展示 + 对手选择)
- BattleView: 战斗主界面(双方 HP + 招式选择 + 事件日志)
- SwitchPanel: 换人选择面板
- ItemPanel: 道具使用面板
- BattleResultPanel: 战斗结算展示
- MoveLearnPanel: 新招式学习面板
- HP 条颜色分级(绿/黄/红)
- 事件日志中文格式化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:37:26 +08:00
claude-code-best
a3fc348421 feat: Phase 2 — 战斗引擎 @pkmn 薄适配层
- 安装 @pkmn/protocol @pkmn/client @pkmn/view
- 新建 battle/types.ts: BattleState, BattlePokemon, BattleEvent, PlayerAction 等
- 新建 battle/adapter.ts: Creature→PokemonSet 转换, 野生对手生成
- 新建 battle/engine.ts: createBattle() + executeTurn() 薄封装 @pkmn/sim
- 新建 battle/handler.ts: @pkmn/protocol Handler→BattleEvent 转换
- 新建 battle/ai.ts: 随机合法招式 AI 决策
- 新建 battle/settlement.ts: 战后结算 XP/EV/升级/进化/招式学习
- 新建 battle/index.ts: 统一导出

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:31:42 +08:00
claude-code-best
12cbb7c4c7 feat: Phase 1 — 数据模型升级 Creature v2 + PCBox/Bag
- 新增 MoveSlot, PCBox, Bag, ItemId 类型
- Creature 扩展 nature/moves/ability/heldItem/pokeball 字段
- BuddyData 升级 v2: 新增 boxes, bag, battlesWon/battlesLost
- 新建 data/learnsets.ts: getDefaultMoveset/getDefaultAbility/getNewLearnableMoves
- storage.ts v1→v2 迁移: 回填 nature/moves/ability,新增 PCBox/Bag
- 新增 PCBox 操作: deposit/withdraw/move/rename/findLocation/release
- 新增 Bag 操作: add/remove/getCount
- generateCreature/loadBuddyData/hatchEgg 改为 async (Dex.learnsets.get 异步)
- 修复 PokedexView: activeCreatureId → party[0]
- 更新测试文件: async/await + v2 BuddyData fixtures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:20:08 +08:00
claude-code-best
96f3e1b309 refactor: Phase 0 — 清除重复,委托 @pkmn 生态
- 删除硬编码 NATURES 常量,nature.ts 委托 Dex.natures
- 删除硬编码 EVOLUTION_CHAINS,evolution.ts 委托 Dex.species.evos
- calculateStats() 手写公式替换为 gen.stats.calc()
- 统一 TO_DEX_STAT/FROM_DEX_STAT 映射到 pkmn.ts
- 简化 species.ts buildEvolutionChain() 复用 getNextEvolution()
- 添加 NatureName/NatureEffect 类型定义

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:05:05 +08:00
claude-code-best
336159ee18 feat: 计划完成 2026-04-21 23:56:03 +08:00
claude-code-best
970fcd627f feat: 又是更新了一大堆 2026-04-21 21:38:13 +08:00
claude-code-best
f74492617b feat: 一大堆优化 2026-04-21 20:31:10 +08:00
claude-code-best
b5525f63c6 fix: 修复 buddy 命令 ESC 关闭后进入永久 loading 状态
CancelRequestHandler 先于 BuddyPanel 的 ESC handler 拦截按键,
仅清除面板但未 resolve processSlashCommand 中的 Promise,
导致 queryGuard 卡在 dispatching 状态。通过在 setToolJSX
中增加 onDismiss 回调,在面板被外部清除时同步 resolve Promise。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 19:23:37 +08:00
claude-code-best
722aa6c97a feat: 扩展精灵动画系统并新增 SpriteAnimator 组件
- 新增动画模式: breathe, bounce, walkLeft, walkRight, flip
- 新增 SpriteAnimator 组件,内置 tick 循环和居中渲染
- BuddyPanel 使用 SpriteAnimator 替代手动渲染

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 19:23:24 +08:00
claude-code-best
52a862e5b4 chore: 删除已完成的计划文件和 Issues
Issues.md 和 buddy-pokemon-plan.md 的内容已全部实现,清理掉。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 19:23:14 +08:00
claude-code-best
88ddba6c23 feat: 第一版可用 pokemon 2026-04-21 19:03:31 +08:00
104 changed files with 14241 additions and 1326 deletions

View File

@@ -0,0 +1,139 @@
---
name: fix-issue
description: 处理 GitHub issue 的完整修复工作流。当用户提到 issue 编号、粘贴 GitHub issue URL、说"修一下这个 bug"、"处理一下这个 issue"、或需要根据 bug 报告修复代码时使用此 skill。即使用户只是提到了一个 GitHub 问题的链接或编号,也应该触发此 skill。
---
# fix-issue: GitHub Issue 修复工作流
你是一个专门处理 GitHub issue 的修复助手。收到 issue 后,你将自主完成从分析到提交的全流程。
## 输入格式
支持两种输入方式:
1. **URL 方式**:用户提供 GitHub issue URL`https://github.com/owner/repo/issues/123`
2. **描述方式**:用户直接描述问题(如 "登录页面点击提交按钮后崩溃" 或 "issue #456 的分页有问题"
如果是 URL 方式,用 `gh` 命令获取 issue 信息。如果是描述方式,直接基于描述工作。
## 工作流程
### 阶段一:信息收集
**URL 方式:**
```bash
# 获取 issue 内容和元信息
gh issue view <number> --repo <owner/repo> --json title,body,labels,assignees,comments,state
```
提取以下信息:
- 问题标题和描述
- Labelsbug、enhancement、documentation 等)
- 评论中的补充信息(复现步骤、环境、错误日志、截图描述)
- 是否有关联的 PR 或重复 issue
**描述方式:**
基于用户提供的描述理解问题。如果信息不足,用 AskUserQuestion 补充询问(只问一次,不要反复追问)。
### 阶段二:问题分析与复杂度评估
分析收集到的信息,评估问题:
1. **问题本质**这是一个什么类型的问题bug / 文档 / 性能 / 安全 / 重构)
2. **影响范围**:大概涉及哪些模块或文件?
3. **复杂度**:简单(单文件修复) / 中等(多文件但逻辑清晰) / 复杂(多模块耦合、需求不明确、或无法定位根因)
**复杂度判断规则:**
如果满足以下任一条件,判定为"复杂"**必须停下来向用户汇报**,等用户决定下一步:
- 无法确定问题的根因(多个可能的嫌疑点)
- 修复可能影响 3 个以上模块
- issue 描述模糊,存在多种理解方式
- 需要添加新功能而非修复现有缺陷
- 涉及数据库迁移、API 契约变更等破坏性修改
汇报时说明:问题分析结果、可能的修复方向、以及为什么需要用户决策。
### 阶段三:工作区检查
开始修复前检查工作区状态:
```bash
git status
git stash list
```
- 如果工作区有未提交的更改提醒用户先处理stash 或提交),**不要自动 stash 或丢弃更改**
- 如果工作区干净,直接进入下一步
- 在当前分支上直接修复,不创建新分支
### 阶段四:代码定位与修复
1. 使用 Explorer subagent`subagent_type: "Explore"`)探索代码库,定位问题相关代码。给 Explorer 足够的上下文——把 issue 的关键信息告诉它
2. 阅读相关代码,理解当前实现
3. 制定修复方案并实施代码修改
修复时遵循项目现有的代码风格和约定。参考 CLAUDE.md 中的项目规范。
### 阶段五:验证
修复完成后自动运行测试:
```bash
bun test
```
**测试失败处理:**
- 分析失败原因,判断是否由本次修复引起
- 如果是本次修复引起的,重新分析问题并修复,然后重跑测试
- 最多重试 **2 次**(总共最多 3 次测试运行:初次 + 2 次重试)
- 如果 2 次重试后仍然失败,停下来汇报失败原因和已尝试的方案,交给用户处理
### 阶段六:提交
测试通过后提交修复。
**提交策略:**
- 涉及多文件修改时,按逻辑分组提交(例如:"修复数据层校验逻辑" 和 "修复 UI 层错误提示" 分开提交)
- 单文件或逻辑简单的修复直接一次提交
**Commit message 格式:**
```
fix: 简短描述 (#issue编号)
```
示例:
- `fix: 修复登录页提交按钮点击后崩溃的问题 (#123)`
- `fix: 修正分页组件页码计算逻辑 (#456)`
- `fix: 更新 API 文档中的错误返回值描述 (#789)`
对于非 bug 类型,对应调整 type
- 文档问题:`docs: 修正 xxx 描述 (#issue)`
- 性能问题:`perf: 优化 xxx 性能 (#issue)`
- 重构:`refactor: 重构 xxx (#issue)`
提交后不自动创建 PR也不输出完成提示。静默完成。
## 错误处理
- **`gh` 命令失败**:可能是 issue 不存在或权限不足。把错误信息展示给用户,让他们检查
- **找不到相关代码**:扩大搜索范围,如果仍然找不到,停下来告诉用户,附上已搜索的范围
- **测试超时**:如果是测试本身的问题(非修复引起),告知用户并跳过测试环节
- **合并冲突**:不会发生(在当前分支直接修复),但如果 `git status` 显示冲突,停下来让用户处理
## 全流程示例
用户说:`帮我修一下 https://github.com/owner/repo/issues/42`
1. 运行 `gh issue view 42 --repo owner/repo --json ...`,获取 issue 信息
2. 分析issue 标题是"用户注册时邮箱校验失败",评论中有复现步骤和错误日志。复杂度评估:简单(单文件修复)
3. `git status` 检查工作区干净
4. 用 Explore agent 搜索 "email" "validate" "register" 相关代码
5. 阅读 `src/services/auth/register.ts`,发现邮箱正则表达式不完整
6. 修复正则表达式
7. `bun test` → 通过
8. `git commit -m "fix: 修复用户注册时邮箱校验正则表达式不完整的问题 (#42)"`
9. 静默完成

59
.github/workflows/auto-issue-fix.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Auto Issue Fix
on:
issues:
types: [labeled]
jobs:
auto-fix:
# Only trigger when the label is "ai-fix"
if: github.event.label.name == 'ai-fix'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
prompt: |
You are an expert software engineer. Analyze the following GitHub issue and determine if it can be fixed.
Issue #${{ github.event.issue.number }}: ${{ github.event.issue.title }}
${{ github.event.issue.body }}
Instructions:
1. Read and understand the issue thoroughly.
2. Explore the codebase to find the relevant code.
3. If the issue is fixable, implement the fix and create a pull request. Use a clear PR title and description referencing the issue.
4. If the issue is NOT fixable (e.g., needs more info, not a bug, out of scope), explain why in a brief summary.
5. At the end, output a summary of what you did as your FINAL message. This will be posted as a comment on the issue.
claude_args: |
--model ${{ vars.CLAUDE_MODEL || 'claude-sonnet-4-20250514' }}
--max-turns 30
--allowedTools "Edit,Write,Read,Bash,Glob,Grep,Agent"
settings: >
{
"env": {
"ANTHROPIC_BASE_URL": "${{ secrets.ANTHROPIC_BASE_URL }}"
}
}
- name: Post Claude's response as issue comment
if: always() && steps.claude.outputs.result != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh issue comment ${{ github.event.issue.number }} --body "${{ steps.claude.outputs.result }}"

View File

@@ -327,7 +327,7 @@ bun run typecheck
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
- **Biome 配置** — 大量 lint 规则被关闭decompiled 代码不适合严格 lint`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。**开发任何 TUI 组件前,必须先查阅 `docs/ink-guide.md`**该文档涵盖了双层组件设计Base vs Themed、布局系统、主题色、快捷键、所有 Hooks 和设计系统组件的用法。日常使用 `Box`/`Text`Themed 版),用 `useKeybindings` 代替直接 `useInput`
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
## Design Context

View File

@@ -264,6 +264,14 @@
"name": "modifiers-napi",
"version": "1.0.0",
},
"packages/pokemon": {
"name": "@claude-code-best/pokemon",
"version": "1.0.0",
"dependencies": {
"@pkmn/client": "^0.7.2",
"@pkmn/protocol": "^0.7.2",
},
},
"packages/remote-control-server": {
"name": "@anthropic/remote-control-server",
"version": "0.1.0",
@@ -969,6 +977,16 @@
"@pinojs/redact": ["@pinojs/redact@0.4.0", "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@pkmn/client": ["@pkmn/client@0.7.2", "", { "dependencies": { "@pkmn/data": "^0.10.2", "@pkmn/protocol": "^0.7.2" } }, "sha512-Jf40Nqp+ZAcM5wNEIrMKNdU2G0OKELtGNXmq2QYRcVsutDF/g9+Xm5Y9nK+mIw+bAgSVSmZKD5/Y1MpwxHgX9A=="],
"@pkmn/data": ["@pkmn/data@0.10.7", "", { "dependencies": { "@pkmn/dex-types": "^0.10.7" } }, "sha512-csUX8BU4RMHxl3nEF3gIOp1eq9+q3xh+ZaX+Nr1RZBqWF4L5PkBzUG36KKgU+lw92BmncDYm7S8t52lPhAmoXA=="],
"@pkmn/dex-types": ["@pkmn/dex-types@0.10.7", "", { "dependencies": { "@pkmn/types": "^4.0.0" } }, "sha512-ewinxJHyeLwYSOcDsy+2pq9e1mMYNXwBB9B3CZG2fvonrkxAyycR5AtLKPqE61480jPPxaZocOh+SLtUm648SA=="],
"@pkmn/protocol": ["@pkmn/protocol@0.7.2", "", { "dependencies": { "@pkmn/types": "^4.0.0" }, "bin": { "generate-handler": "generate-handler", "protocol-verifier": "protocol-verifier" } }, "sha512-3zRY9B4YN8aeA/jypPgW1hh/SiEIY6lNg9xOqIgox0m4sW1kMhGoNCygJ1Qvx8x33xSRD/AVtH+VtsCGn+LcQg=="],
"@pkmn/types": ["@pkmn/types@4.0.0", "", {}, "sha512-gR2s/pxJYEegek1TtsYCQupNR3d5hMlcJFsiD+2LyfKr4tc+gETTql47tWLX5mFSbPcbXh7f4+7txlMIDoZx/g=="],
"@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "https://registry.npmmirror.com/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="],
"@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "https://registry.npmmirror.com/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="],

1218
docs/ink-guide.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,338 @@
# Pokémon Battle 实现审查报告
> 审查日期2026-04-23
> 审查范围:`packages/pokemon/` 全部源码battle、core、dex、ui
> 对比基准:原版 Pokémon 核心系列游戏Gen 9Scarlet/Violet
> 更新日期2026-04-24 — 修复了 #1, #2, #3, #4, #6, #7, #8, #13
---
## 一、严重问题(核心机制错误)
### 1. XP 计算公式与原版不符
**文件**: `src/battle/settlement.ts:30-31`
```ts
const baseXp = (oppSpecies?.baseStats?.hp ?? 50) * opponentLevel / 7
```
原版 Gen 9 的 XP 计算公式为:
```
XP = (baseXP × opponentLevel × isTraded × isParticipating) / 7 × partySizeModifier
```
当前实现存在以下错误:
- **baseXP 不等于 baseStats.hp**。每只宝可梦有独立的 `base_experience` 值(例如妙蛙种子是 64皮卡丘是 112而不是 HP 种族值。目前用 `baseStats.hp` 做代理完全是错的。
- **缺少 traded Pokémon 1.5x 加成**。
- **缺少参与战斗的宝可梦分摊机制**(原版中只有实际参与战斗的宝可梦获得 XP
- **缺少 Lucky Egg 1.5x 加成**。
- **缺少 Affection 加成**Gen 6+)。
### 2. EV 收益完全自造,不使用真实数据
**文件**: `src/battle/settlement.ts:176-191`
```ts
function getEvYield(speciesId: string): Record<string, number> {
// @pkmn/sim Dex.species doesn't have evs field
// Use baseStats as proxy: highest base stat gets 1-2 EVs
...
}
```
原版中每只宝可梦有固定的 EV yield如妙蛙种子击倒后给 HP+1皮卡丘给 Speed+2。这些数据在 `@pkmn/data` 中是有的(`species.evs`),但代码误以为 `@pkmn/sim` 没有这个字段,就自造了一个「最高种族值 → 2 EV第二高 → 1 EV」的算法与原版完全不同。
### 3. 物品使用在战斗中无效
**文件**: `src/battle/engine.ts:436-438`
```ts
case 'item':
p1Choice = 'move 1' // fallback to move 1
break
```
当玩家使用物品(如药水)时,代码直接忽略了,改为使用第一个招式。原版中物品使用是战斗的核心部分——回复药、状态治愈药、精灵球等都有完整的效果。
### 4. 逃跑功能未实现
**文件**: `src/ui/BattleFlow.tsx:314`
```ts
case 3: // 逃跑 — show message
return
```
战斗菜单中「逃跑」按钮存在但点击后什么也不做。原版中有逃跑概率计算公式(基于速度对比),对野外战斗是核心机制。
### 5. 对手p2不支持多精灵队伍
**文件**: `src/battle/engine.ts:61-67`
```ts
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
...
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
}
```
对手始终只有一只宝可梦(野生宝可梦模式)。没有 Trainer Battle 的概念——对手不能有多只精灵、不能换人、不能使用物品。虽然 AI 在精灵倒下后会自动换人(`executeSwitch` 中有处理),但 `createBattle` 本身只接受单个对手 species。
---
## 二、中等问题(机制简化/缺失)
### 6. AI 过于简单
**文件**: `src/battle/ai.ts:6-13`
```ts
export function chooseAIMove(pokemon: BattlePokemon): number {
const usable = pokemon.moves
.map((m, i) => ({ move: m, index: i }))
.filter(({ move }) => move.pp > 0 && !move.disabled)
if (usable.length === 0) return 0
return usable[Math.floor(Math.random() * usable.length)]!.index
}
```
AI 只是随机选择一个可用招式。原版 NPC AI 至少会考虑:
- **属性克制**:优先使用效果绝佳的招式
- **状态技 vs 攻击技**的权衡
- **HP 低时**可能使用回复招式
- **玩家属性**:避免使用被抵抗的招式
- 不会换人、不会使用物品
### 7. 野生宝可梦的招式是按属性硬编码的
**文件**: `src/battle/engine.ts:69-94`
```ts
function getSpeciesMoves(speciesId: string, _level: number): string[] {
...
const basicMoves: Record<string, string[]> = {
normal: ['Tackle', 'Scratch'],
fire: ['Ember', 'FireSpin'],
...
}
return basicMoves[type] ?? ['Tackle', 'Scratch']
}
```
野生对手的招式不是从 learnset 中获取的,而是按第一属性硬编码了固定招式。`_level` 参数被完全忽略了——原版中不同等级的野生宝可梦应该有不同的招式组合。
### 8. 进化系统不完整
**文件**: `src/dex/evolution.ts` + `src/battle/settlement.ts:92-106`
- 只处理了 `evoType``level_up``item``trade``friendship` 四种类型
- **只取第一个进化目标** (`dex.evos[0]`),忽略了分支进化(如伊布的多种进化)
- **没有进化石使用的交互**(使用雷之石等道具触发进化)
- **没有通讯交换进化**
- **没有条件进化**(如知道特定招式、特定时间、特定地点等 Gen 9 新增条件)
### 9. 能力值计算缺少特性/道具修正
**文件**: `src/core/creature.ts:51-73`
`calculateStats` 只计算基础能力值,没有考虑:
- **特性对能力值的修正**(如 Hustle 增加攻击降低命中)
- **道具对能力值的修正**(如 Choice Band 增加攻击 50%
注:性格修正虽然传入了 `nature`,但由 `@pkmn/data``gen.stats.calc` 内部处理,这部分是正确的。
### 10. 捕获系统完全缺失
没有任何捕获野生宝可梦的机制:
- 没有 Pokeball 道具的实际效果
- 没有捕获率计算Shake check 公式)
- 战斗结束后不能获得对手宝可梦
- 虽然数据中有 `captureRate` 字段和 `pokeball` 字段,但从未使用
### 11. 状态异常处理不完整
**文件**: `src/battle/engine.ts:130-140`
只映射了 6 种基本状态(中毒、剧毒、灼伤、麻痹、冰冻、睡眠),但缺少:
- **混乱 (Confusion)**:不在 status 中,是 volatile status
- **着迷 (Infatuation)**:同上
- **畏缩 (Flinch)**:同上
- 所有 volatile status暂时性状态都未追踪
### 12. 天气/场地效果未完整追踪
**文件**: `src/battle/engine.ts:153-173`
- `projectState` 中天气只在初始化时从 `prevConditions` 传入,不会自动更新
- `mapWeather` 不区分 Primal Weather原始回归天气和普通天气
- **场地效果Electric Terrain、Grassy Terrain 等)** 被映射为 `fieldCondition` 事件,但没有影响战斗状态的逻辑
注:底层 `@pkmn/sim` 会正确处理这些效果,只是上层状态投影不完整,导致 UI 无法正确显示。
---
## 三、轻度问题(数值/细节偏差)
### 13. Growth Rate 数据覆盖不全
**文件**: `src/dex/species.ts:38-99`
只有 9 个物种(御三家 + 皮卡丘)有正确的 `growthRate` 数据,其余全部使用默认值 `medium-slow`。实际上超过 1000 个物种各有不同的成长速率。这导致 XP 计算对大部分物种不正确。
### 14. 闪光概率未使用 PID 计算
**文件**: `src/core/creature.ts:25`
```ts
const isShiny = Math.random() < species.shinyChance // 1/4096
```
原版 Gen 9 的闪光判定基于 Personality Value32 位 PID的异或运算不是简单的随机概率。Shiny Charm 等道具的加成也无法体现。
### 15. IV 生成算法不是真正的 LCRNG
**文件**: `src/core/creature.ts:108-122`
```ts
function generateIVs(seed: number): Record<StatName, number> {
let s = seed
const nextRand = () => {
s = (s * 1103515245 + 12345) & 0x7fffffff
return s
}
```
原版 Gen 3+ 使用的是完全不同的随机数生成器。更重要的是Gen 3-5 的 IV 是通过 PID 的高位/低位直接提取的,不是独立随机。
### 16. 性别判定阈值计算偏差
**文件**: `src/core/gender.ts:12-13`
```ts
const threshold = (speciesData.genderRate / 8) * 256
return (seed % 256) < threshold ? 'female' : 'male'
```
原版中性别由 PID 的低 8 位与 `genderRate` 直接比较决定,不需要乘 256 再取阈值。当前实现引入了不必要的精度损失。
### 17. 蛋系统与原版差异巨大
**文件**: `src/core/egg.ts`
- **获得条件**:原版通过培育屋/寄养屋繁殖,当前通过「连续编码 3 天 + 每 50 回合」获得
- **孵化步数**:基于 captureRate 反推,而不是物种真实的 `hatch_counter` 数据
- **没有遗传招式**:原版中蛋可以遗传父母双方的招式
- **没有个体值遗传**:原版中蛋会随机继承父母的某些 IV
- **没有球种遗传**:原版中蛋继承母亲的球种
### 18. 多语言名称覆盖极少
**文件**: `src/dex/names.ts`
只有 10 个物种有中/英/日三语名称,其余 1000+ 个物种只回退到英文名。这对于中文/日文用户来说体验不完整。
### 19. 缺少 Held Item 获取途径
战斗中 `heldItem` 被正确传入 Showdown 格式,所以底层模拟会处理道具效果。但是:
- 没有获得/装备道具的途径
- 没有商店系统
- 所有野生对手没有道具
- 玩家的宝可梦默认 `heldItem: null`
### 20. Ability 系统不完整
- `getDefaultAbility` 只取第一个非隐藏特性
- 没有隐藏特性Hidden Ability的选择
- 没有特性胶囊/特性补丁的使用
- 底层 Showdown 会正确处理特性效果(如 Intimidate、Levitate但 UI 层不显示特性触发
---
## 四、问题汇总
| 严重程度 | 数量 | 编号 |
|---------|------|------|
| 严重(核心机制错误) | 5 | #1 ~ #5 |
| 中等(机制简化/缺失) | 7 | #6 ~ #12 |
| 轻度(数值/细节偏差) | 8 | #13 ~ #20 |
---
## 五、优先修复建议
按影响面从大到小排列:
1. **修复 XP 和 EV 计算(#1, #2**:从 `@pkmn/data` 获取真实的 `base_experience``evs` 数据,替换当前的代理算法。这两个问题直接影响所有战斗的成长反馈。
2. **实现物品使用(#3**:至少支持 Potion回复 HP和状态治愈药。这是战斗中最基本的交互。
3. **实现逃跑(#4**:需要添加逃跑概率公式和对应的 Showdown 协议处理。
4. **修复野生对手招式(#7**:从 learnset 中按等级获取招式,替换硬编码映射。
5. **补全 Growth Rate 数据(#13**:从 PokeAPI 或 `@pkmn/data` 批量导入,而非只覆盖 9 个物种。
---
## 六、做得好的部分
- **底层战斗引擎(`@pkmn/sim`)集成正确**:属性克制、伤害公式、能力值计算、特性效果等核心数学由 Pokémon Showdown 引擎处理,结果与原版一致。
- **EV 上限正确**:单项 252 / 总计 510与原版一致。
- **XP 经验曲线公式正确**6 种 Growth Rate 的计算公式erratic、fluctuating 等)与原版完全一致。
- **Nature 系统完整**25 个性格及其加成/减益效果通过 `@pkmn/data` 正确获取。
- **Learnset 查询正确**:从 `Dex.data.Learnsets` 获取招式学习表,支持跨代回退。
- **状态异常映射基本正确**6 种主要状态的 Showdown 协议映射准确。
- **战斗测试覆盖全面**:包括属性克制、强制换人、多精灵队伍等场景的集成测试。
---
## 七、修复记录2026-04-24
### 已修复
| 编号 | 问题 | 修复方式 |
|------|------|---------|
| #1 | XP 使用 baseStats.hp | 从 PokeAPI 获取真实 `base_experience`,存入 `pokedex-data.ts`,公式改为 `baseXP × level / 7` |
| #2 | EV yield 伪造 | 从 PokeAPI 获取真实 EV yield 数据1024 个物种),存入 `pokedex-data.ts` |
| #3 | 物品使用无效 | 实现 Potion/HyperPotion/FullRestore 等回复药效果,直接操作 Battle 对象 HP消耗背包物品 |
| #4 | 逃跑未实现 | 实现 Gen 9 逃跑概率公式 `f = (playerSpeed × 128 / opponentSpeed + 30 × attempts) % 256`,成功时 forfeit 结束战斗 |
| #6 | AI 纯随机 | AI 现在优先选克制招式70%),避免被抵抗招式和蓄力招式,状态技最低优先级 |
| #7 | 野生招式硬编码 | 从 `Dex.data.Learnsets` 按等级获取升级招式(最后 4 个),替换按属性硬编码映射 |
| #8 | 进化只取第一目标 | 检查所有 `evos` 目标,支持分支进化,增加友谊度进化检测 |
| #13 | Growth Rate 只覆盖 9 个 | 从 PokeAPI 批量导入所有 1024 个物种的 growth rate 数据 |
| #5 | 多精灵对战不支持 | `createBattle` 支持传入 `OpponentEntry[]`AI 换人时考虑属性克制 |
| #10 | 缺少捕获系统 | 新增 `capture.ts`,实现 Gen 9 捕获率公式,支持精灵球/状态修正 |
| #11 | 缺少 volatile status | 新增 `VolatileStatus` 类型,`BattlePokemon` 添加 `volatileStatus` 字段 |
| #12 | 天气/地形未投影 | 确认 `projectState``battle.field.weather/terrain` 读取 |
| #14 | Shiny 检测用随机 | 改为 Gen 3+ PID XOR 方法,阈值 < 16Gen 8+ 1/4096 概率) |
| #15 | IV 生成用 LCRNG | 改为 Gen 3+ PID 位提取法word1/word2 各取 3 个 5-bit IV |
| #16 | 性别阈值精度丢失 | 从 `(rate/8)*256` 改为 `rate*32` 直接比较,消除浮点精度问题 |
| #17 | 蛋孵化步数用 captureRate | 改为使用真实 `hatchCounter` 数据(步数 = cycles × 257支持进化阶段回退 |
| #18 | 多语言名称仅 10 个 | 创建 fetch 脚本获取全量中/日名称,`names.ts` 支持动态加载生成数据 |
| #19 | 野生对手无道具 | 添加 `rollWildHeldItem`5% 物种专属道具、5% 树果、3% 属性增强道具 |
| #20 | Ability 只有第一个 | 新增 `randomAbility`/`getAbilities`,隐藏特性 5% 概率,第二特性 20% 概率 |
### 新增文件
- `src/dex/pokedex-data.ts` — 1024 个物种的 baseExperience、EV yield、growthRate、captureRate、baseHappiness 数据
- `scripts/fetch-pokedex-data.ts` — PokeAPI 数据抓取脚本(可重新运行以更新数据,含 hatchCounter
- `src/battle/capture.ts` — Gen 9 捕获率计算,精灵球/状态/时间修正
- `scripts/fetch-species-names.ts` — 多语言名称抓取脚本(中/日/英)
### 修改文件
- `src/battle/settlement.ts` — XP/EV 计算、进化检测
- `src/battle/engine.ts` — 物品效果、逃跑逻辑、野生招式、AI 调用、多对手支持、野生道具
- `src/battle/ai.ts` — 属性克制 AI使用 `Dex.getEffectiveness`
- `src/battle/types.ts` — 新增 `run` 动作、`escaped`/`escapeAttempts`/`captureResult` 状态、VolatileStatus
- `src/battle/index.ts` — 导出 OpponentEntry、attemptCapture、CaptureResult
- `src/ui/BattleFlow.tsx` — 逃跑按钮、物品消耗
- `src/dex/species.ts` — 使用 pokedex-data 替代硬编码 supplement
- `src/dex/learnsets.ts` — 新增 randomAbility、getAbilities 函数
- `src/dex/names.ts` — 支持加载 auto-generated 多语言名称数据
- `src/dex/pokedex-data.ts` — 新增 getHatchCounter 函数
- `src/core/creature.ts` — PID 生成、IV 位提取、Shiny XOR 检测、randomAbility
- `src/core/gender.ts` — 修复阈值为 `genderRate * 32`
- `src/core/egg.ts` — 使用 getHatchCounter 替代 captureRate 计算孵化步数

View File

@@ -0,0 +1,12 @@
{
"name": "@claude-code-best/pokemon",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"@pkmn/client": "^0.7.2",
"@pkmn/protocol": "^0.7.2"
}
}

View File

@@ -0,0 +1,133 @@
/**
* Fetch base_experience, EV yield, and growth_rate for all species from PokeAPI.
* Generates src/dex/pokedex-data.ts
*
* Usage: bun run scripts/fetch-pokedex-data.ts
*/
import { Dex } from '@pkmn/sim'
const GROWTH_RATE_MAP: Record<string, string> = {
'slow-then-very-fast': 'erratic',
'fast-then-very-slow': 'fluctuating',
'medium': 'medium-fast',
'medium-slow': 'medium-slow',
'slow': 'slow',
'fast': 'fast',
}
const STAT_MAP: Record<string, string> = {
'hp': 'hp',
'attack': 'atk',
'defense': 'def',
'special-attack': 'spa',
'special-defense': 'spd',
'speed': 'spe',
}
interface SpeciesPokedex {
baseExperience: number
evs: Record<string, number>
growthRate: string
captureRate: number
baseHappiness: number
hatchCounter: number
}
async function fetchSpeciesData(id: number): Promise<SpeciesPokedex | null> {
try {
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)
if (!res.ok) return null
const data = await res.json() as any
// Get growth rate from species endpoint
const speciesRes = await fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`)
if (!speciesRes.ok) return null
const speciesData = await speciesRes.json() as any
const evs: Record<string, number> = {}
for (const stat of data.stats || []) {
if (stat.effort > 0) {
const statName = STAT_MAP[stat.stat.name]
if (statName) evs[statName] = stat.effort
}
}
const growthRateName = GROWTH_RATE_MAP[speciesData.growth_rate?.name] ?? 'medium-slow'
return {
baseExperience: data.base_experience ?? 50,
evs,
growthRate: growthRateName,
captureRate: speciesData.capture_rate ?? 45,
baseHappiness: speciesData.base_happiness ?? 70,
hatchCounter: speciesData.hatch_counter ?? 20,
}
} catch {
return null
}
}
async function main() {
// Get all base species IDs from Dex
const rawSpecies = Dex.data.Species as Record<string, { num: number; forme?: string }>
const species: { id: string; num: number }[] = []
for (const [id, s] of Object.entries(rawSpecies)) {
if (s.num > 0 && Number.isInteger(s.num) && !s.forme) {
species.push({ id, num: s.num })
}
}
species.sort((a, b) => a.num - b.num)
console.log(`Fetching data for ${species.length} species from PokeAPI...`)
const results: Record<string, SpeciesPokedex> = {}
let fetched = 0
const BATCH_SIZE = 20
for (let i = 0; i < species.length; i += BATCH_SIZE) {
const batch = species.slice(i, i + BATCH_SIZE)
const promises = batch.map(async (s) => {
const data = await fetchSpeciesData(s.num)
if (data) results[s.id] = data
fetched++
})
await Promise.all(promises)
process.stdout.write(`\rFetched ${fetched}/${species.length}...`)
// Small delay to avoid rate limiting
await new Promise(r => setTimeout(r, 200))
}
console.log(`\nFetched ${Object.keys(results).length} species.`)
// Generate TypeScript file
const lines: string[] = [
'// Auto-generated from PokeAPI. Run: bun run scripts/fetch-pokedex-data.ts',
'// eslint-disable-next-line @typescript-eslint/no-extraneous-class',
'export interface PokedexEntry {',
' baseExperience: number',
' evs: Record<string, number>',
' growthRate: string',
' captureRate: number',
' baseHappiness: number',
' hatchCounter?: number',
'}',
'',
'export const POKEDEX_DATA: Record<string, PokedexEntry> = {',
]
for (const [id, data] of Object.entries(results)) {
const evsStr = Object.keys(data.evs).length > 0
? `{ ${Object.entries(data.evs).map(([k, v]) => `${k}: ${v}`).join(', ')} }`
: '{}'
lines.push(` '${id}': { baseExperience: ${data.baseExperience}, evs: ${evsStr}, growthRate: '${data.growthRate}', captureRate: ${data.captureRate}, baseHappiness: ${data.baseHappiness}, hatchCounter: ${data.hatchCounter} },`)
}
lines.push('}')
lines.push('')
const outputPath = new URL('../src/dex/pokedex-data.ts', import.meta.url)
await Bun.write(outputPath, lines.join('\n'))
console.log(`Written to ${outputPath.pathname}`)
}
main().catch(console.error)

View File

@@ -0,0 +1,90 @@
/**
* Fetch multilingual species names (en, ja, zh) from PokeAPI.
* Generates src/dex/species-names.ts
*
* Usage: bun run scripts/fetch-species-names.ts
*/
import { Dex } from '@pkmn/sim'
interface SpeciesNames {
en: string
ja: string
zh: string
}
async function fetchSpeciesNames(id: number): Promise<SpeciesNames | null> {
try {
const res = await fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`)
if (!res.ok) return null
const data = await res.json() as any
const names: SpeciesNames = { en: '', ja: '', zh: '' }
for (const entry of data.names || []) {
const lang = entry.language.name as string
if (lang === 'en') names.en = entry.name
else if (lang === 'ja') names.ja = entry.name
else if (lang === 'zh-Hant' || lang === 'zh-Hans') names.zh = entry.name
}
// Fallback to English if zh/ja missing
if (!names.zh) names.zh = names.en
if (!names.ja) names.ja = names.en
if (!names.en) return null
return names
} catch {
return null
}
}
async function main() {
const rawSpecies = Dex.data.Species as Record<string, { num: number; forme?: string }>
const species: { id: string; num: number }[] = []
for (const [id, s] of Object.entries(rawSpecies)) {
if (s.num > 0 && Number.isInteger(s.num) && !s.forme) {
species.push({ id, num: s.num })
}
}
species.sort((a, b) => a.num - b.num)
console.log(`Fetching names for ${species.length} species from PokeAPI...`)
const results: Record<string, SpeciesNames> = {}
let fetched = 0
const BATCH_SIZE = 20
for (let i = 0; i < species.length; i += BATCH_SIZE) {
const batch = species.slice(i, i + BATCH_SIZE)
const promises = batch.map(async (s) => {
const data = await fetchSpeciesNames(s.num)
if (data) results[s.id] = data
fetched++
})
await Promise.all(promises)
process.stdout.write(`\rFetched ${fetched}/${species.length}...`)
await new Promise(r => setTimeout(r, 200))
}
console.log(`\nFetched ${Object.keys(results).length} species names.`)
// Generate TypeScript file
const lines: string[] = [
'// Auto-generated from PokeAPI. Run: bun run scripts/fetch-species-names.ts',
'',
'export interface SpeciesI18n { en: string; ja: string; zh: string }',
'',
'export const SPECIES_I18N_DATA: Record<string, SpeciesI18n> = {',
]
for (const [id, data] of Object.entries(results)) {
lines.push(` '${id}': { en: '${data.en.replace(/'/g, "\\'")}', ja: '${data.ja}', zh: '${data.zh}' },`)
}
lines.push('}')
lines.push('')
const outputPath = new URL('../src/dex/species-names.ts', import.meta.url)
await Bun.write(outputPath, lines.join('\n'))
console.log(`Written to ${outputPath.pathname}`)
}
main().catch(console.error)

View 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)

View File

@@ -0,0 +1,347 @@
/**
* Battle Test Framework
*
* Fluent API for testing Pokémon battle scenarios:
*
* const s = await battleScenario()
* .party('charmander', 50, ['flamethrower'])
* .party('bulbasaur', 30, ['vinewhip'])
* .opponent('squirtle', 50)
* .start()
*
* const state = await s.useMove(0).runTurn()
* s.expect(state).hasDamage('opponent')
*/
import { describe, test, expect } from 'bun:test'
import { createBattle, executeTurn, executeSwitch } from '../battle/engine'
import type { BattleState } from '../battle/types'
import type { BattleInit } from '../battle/engine'
import type { BattleEvent } from '../battle/types'
import type { Creature, SpeciesId, StatName } from '../types'
// ─── Creature Builder ───
interface CreatureSpec {
id: string
speciesId: SpeciesId
level: number
moves: string[]
ability?: string
nature?: string
ev?: Partial<Record<StatName, number>>
iv?: Partial<Record<StatName, number>>
}
function buildCreature(spec: CreatureSpec, index: number): Creature {
return {
id: spec.id ?? `test-${index}`,
speciesId: spec.speciesId,
gender: 'male',
level: spec.level,
xp: 0,
totalXp: 0,
nature: (spec.nature ?? 'adamant') as Creature['nature'],
ev: {
hp: spec.ev?.hp ?? 0,
attack: spec.ev?.attack ?? 0,
defense: spec.ev?.defense ?? 0,
spAtk: spec.ev?.spAtk ?? 0,
spDef: spec.ev?.spDef ?? 0,
speed: spec.ev?.speed ?? 0,
},
iv: {
hp: spec.iv?.hp ?? 31,
attack: spec.iv?.attack ?? 31,
defense: spec.iv?.defense ?? 31,
spAtk: spec.iv?.spAtk ?? 31,
spDef: spec.iv?.spDef ?? 31,
speed: spec.iv?.speed ?? 31,
},
moves: [
...spec.moves.map(m => ({ id: m, pp: 15, maxPp: 15 })),
...Array(Math.max(0, 4 - spec.moves.length)).fill({ id: '', pp: 0, maxPp: 0 }),
] as [import('../types').MoveSlot, import('../types').MoveSlot, import('../types').MoveSlot, import('../types').MoveSlot],
ability: spec.ability ?? 'blaze',
heldItem: null,
friendship: 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
}
// ─── Scenario Builder ───
export interface BattleScenario {
/** Add a party member (first = lead) */
party(species: SpeciesId, level: number, moves: string[], opts?: Partial<CreatureSpec>): BattleScenario
/** Set opponent (wild Pokémon) */
opponent(species: SpeciesId, level: number): BattleScenario
/** Create the battle and return runner */
start(): Promise<BattleRunner>
}
export interface BattleRunner {
/** Queue a move action (0-indexed) */
useMove(index: number): BattleRunner
/** Queue a switch action (party slot index, 0-indexed) */
switchTo(partyIndex: number): BattleRunner
/** Execute one turn with queued action, return state */
runTurn(): Promise<BattleState>
/** Keep using move 0 until battle ends or max turns reached */
runUntilEnd(maxTurns?: number): Promise<BattleState>
/** Execute forced switch after faint */
doSwitch(partyIndex: number): Promise<BattleState>
/** Get current battle state (re-projected from Battle object) */
readonly state: BattleState
/** Assertion helpers */
expect(state: BattleState): BattleAssertions
}
export interface BattleAssertions {
/** Battle has not ended */
ongoing(): BattleAssertions
/** Battle has ended */
finished(): BattleAssertions
/** Player won */
playerWon(): BattleAssertions
/** Opponent won */
opponentWon(): BattleAssertions
/** Player's active HP is full */
playerHpFull(): BattleAssertions
/** Player's active HP is below threshold (absolute) */
playerHpBelow(hp: number): BattleAssertions
/** Player's active HP percentage is below threshold */
playerHpPctBelow(pct: number): BattleAssertions
/** Opponent's active HP is full */
opponentHpFull(): BattleAssertions
/** Opponent's active HP is below threshold */
opponentHpBelow(hp: number): BattleAssertions
/** Player needs to switch (active fainted, bench alive) */
needsSwitch(): BattleAssertions
/** Player's active Pokémon has fainted */
playerFainted(): BattleAssertions
/** Opponent's active Pokémon has fainted */
opponentFainted(): BattleAssertions
/** Player's active species matches */
playerSpecies(species: SpeciesId): BattleAssertions
/** Opponent's active species matches */
opponentSpecies(species: SpeciesId): BattleAssertions
/** Events contain at least one of given type (optionally for given side) */
hasEvent(type: BattleEvent['type'], side?: 'player' | 'opponent'): BattleAssertions
/** Events contain damage for given side */
hasDamage(side: 'player' | 'opponent'): BattleAssertions
/** Events contain a move event for given side */
hasMove(side: 'player' | 'opponent'): BattleAssertions
/** Events contain a faint event for given side */
hasFaint(side: 'player' | 'opponent'): BattleAssertions
/** Events contain super-effective hit */
hasSuperEffective(): BattleAssertions
/** Events contain resisted hit */
hasResisted(): BattleAssertions
/** Events contain critical hit */
hasCrit(): BattleAssertions
/** Turn number matches */
turnIs(n: number): BattleAssertions
/** Player party has N alive (hp > 0) Pokémon */
aliveInParty(n: number): BattleAssertions
/** Player's move at index has expected pp and maxPp */
playerMovePp(moveIndex: number, pp: number, maxPp: number): BattleAssertions
/** Generic assertion */
satisfies(fn: (state: BattleState) => boolean, msg?: string): BattleAssertions
}
// ─── Implementation ───
class BattleScenarioImpl implements BattleScenario {
private _party: CreatureSpec[] = []
private _opponentSpecies: SpeciesId = 'pikachu'
private _opponentLevel = 5
party(species: SpeciesId, level: number, moves: string[], opts?: Partial<CreatureSpec>): BattleScenario {
this._party.push({
id: opts?.id ?? `p${this._party.length + 1}`,
speciesId: species,
level,
moves,
...opts,
})
return this
}
opponent(species: SpeciesId, level: number): BattleScenario {
this._opponentSpecies = species
this._opponentLevel = level
return this
}
async start(): Promise<BattleRunner> {
if (this._party.length === 0) {
this._party.push({ id: 'p1', speciesId: 'charmander', level: 50, moves: ['tackle'] })
}
const creatures = this._party.map((s, i) => buildCreature(s, i))
const init = await createBattle(creatures, this._opponentSpecies, this._opponentLevel)
return new BattleRunnerImpl(init)
}
}
class BattleRunnerImpl implements BattleRunner {
private _init: BattleInit
private _pendingAction: { type: 'move'; index: number } | { type: 'switch'; partyIndex: number } | null = null
constructor(init: BattleInit) {
this._init = init
}
get state(): BattleState {
return this._init.state
}
useMove(index: number): BattleRunner {
this._pendingAction = { type: 'move', index }
return this
}
switchTo(partyIndex: number): BattleRunner {
this._pendingAction = { type: 'switch', partyIndex }
return this
}
async runTurn(): Promise<BattleState> {
const action = this._pendingAction
this._pendingAction = null
if (!action) {
// Default: use move 0
return executeTurn(this._init, { type: 'move', moveIndex: 0 })
}
if (action.type === 'move') {
return executeTurn(this._init, { type: 'move', moveIndex: action.index })
} else {
return executeTurn(this._init, { type: 'switch', partyIndex: action.partyIndex })
}
}
async runUntilEnd(maxTurns = 100): Promise<BattleState> {
let state = this._init.state
for (let i = 0; i < maxTurns && !state.finished; i++) {
if (state.needsSwitch) {
// Auto-switch to first alive bench
const alive = state.playerParty.findIndex((p: any, idx: any) => idx > 0 && p.hp > 0)
if (alive >= 0) {
state = await executeSwitch(this._init, alive)
} else break
}
state = await executeTurn(this._init, { type: 'move', moveIndex: 0 })
}
return state
}
async doSwitch(partyIndex: number): Promise<BattleState> {
return executeSwitch(this._init, partyIndex)
}
expect(state: BattleState): BattleAssertions {
return new BattleAssertionsImpl(state)
}
}
class BattleAssertionsImpl implements BattleAssertions {
constructor(private s: BattleState) {}
ongoing() { expect(this.s.finished).toBe(false); return this }
finished() { expect(this.s.finished).toBe(true); return this }
playerWon() { expect(this.s.result?.winner).toBe('player'); return this }
opponentWon() { expect(this.s.result?.winner).toBe('opponent'); return this }
playerHpFull() { expect(this.s.playerPokemon.hp).toBe(this.s.playerPokemon.maxHp); return this }
playerHpBelow(hp: number) { expect(this.s.playerPokemon.hp).toBeLessThan(hp); return this }
playerHpPctBelow(pct: number) {
const actual = this.s.playerPokemon.maxHp > 0 ? (this.s.playerPokemon.hp / this.s.playerPokemon.maxHp) * 100 : 0
expect(actual).toBeLessThan(pct)
return this
}
opponentHpFull() { expect(this.s.opponentPokemon.hp).toBe(this.s.opponentPokemon.maxHp); return this }
opponentHpBelow(hp: number) { expect(this.s.opponentPokemon.hp).toBeLessThan(hp); return this }
needsSwitch() { expect(this.s.needsSwitch).toBe(true); return this }
playerFainted() { expect(this.s.playerPokemon.hp).toBe(0); return this }
opponentFainted() { expect(this.s.opponentPokemon.hp).toBe(0); return this }
playerSpecies(sp: SpeciesId) { expect(this.s.playerPokemon.speciesId).toBe(sp); return this }
opponentSpecies(sp: SpeciesId) { expect(this.s.opponentPokemon.speciesId).toBe(sp); return this }
hasEvent(type: BattleEvent['type'], side?: 'player' | 'opponent') {
const has = this.s.events.some(e =>
e.type === type && (side === undefined || ('side' in e && e.side === side))
)
expect(has).toBe(true)
return this
}
hasDamage(side: 'player' | 'opponent') { return this.hasEvent('damage', side) }
hasMove(side: 'player' | 'opponent') { return this.hasEvent('move', side) }
hasFaint(side: 'player' | 'opponent') { return this.hasEvent('faint', side) }
hasSuperEffective() { return this.hasEvent('effectiveness') }
hasResisted() {
const has = this.s.events.some(e => e.type === 'effectiveness' && 'multiplier' in e && e.multiplier < 1)
expect(has).toBe(true)
return this
}
hasCrit() { return this.hasEvent('crit') }
turnIs(n: number) { expect(this.s.turn).toBe(n); return this }
aliveInParty(n: number) {
const alive = this.s.playerParty.filter(p => p.hp > 0).length
expect(alive).toBe(n)
return this
}
playerMovePp(moveIndex: number, pp: number, maxPp: number) {
const move = this.s.playerPokemon.moves[moveIndex]
expect(move).toBeDefined()
expect(move!.pp).toBe(pp)
expect(move!.maxPp).toBe(maxPp)
return this
}
satisfies(fn: (state: BattleState) => boolean, msg?: string) {
expect(fn(this.s), msg).toBe(true)
return this
}
}
// ─── Public API ───
/** Create a new battle scenario */
export function battleScenario(): BattleScenario {
return new BattleScenarioImpl()
}
/** Quick creature builder for raw Creature objects */
export function makeCreature(
species: SpeciesId,
level: number,
moves: string[] = ['tackle'],
opts?: Partial<CreatureSpec>,
): Creature {
return buildCreature({
id: opts?.id ?? 'test-1',
speciesId: species,
level,
moves,
...opts,
}, 0)
}
/** Shorthand for describe/test wrapper */
export function battleSuite(name: string, fn: (b: typeof battleScenario) => void) {
describe(name, () => fn(battleScenario))
}
/** Shorthand for a single battle test */
export function battleTest(name: string, fn: () => Promise<void>) {
test(name, fn)
}

View File

@@ -0,0 +1,298 @@
import { describe, test, expect } from 'bun:test'
import { battleScenario, battleTest, makeCreature } from './battle-helper'
import type { BattleState } from '../battle/types'
// ─── 基础战斗创建 ───
describe('Battle Scenario: 创建', () => {
battleTest('单精灵对战正常初始化', async () => {
const s = await battleScenario()
.party('charmander', 50, ['flamethrower', 'airslash'])
.opponent('squirtle', 50)
.start()
s.expect(s.state)
.ongoing()
.playerSpecies('charmander')
.opponentSpecies('squirtle')
.playerHpFull()
.opponentHpFull()
})
battleTest('多精灵队伍正确初始化', async () => {
const s = await battleScenario()
.party('charmander', 50, ['flamethrower'])
.party('bulbasaur', 30, ['vinewhip'])
.party('pikachu', 25, ['thundershock'])
.opponent('squirtle', 50)
.start()
s.expect(s.state)
.ongoing()
.playerSpecies('charmander')
.satisfies(s => s.playerParty.length === 3, 'party should have 3 members')
.aliveInParty(3)
})
battleTest('初始回合数为 1', async () => {
const s = await battleScenario()
.party('pikachu', 50, ['thundershock'])
.opponent('squirtle', 50)
.start()
s.expect(s.state).turnIs(1)
})
})
// ─── 单回合战斗事件 ───
describe('Battle Scenario: 单回合事件', () => {
battleTest('使用招式后产生伤害事件', async () => {
const s = await battleScenario()
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
.opponent('squirtle', 5)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state).hasDamage('opponent')
})
battleTest('双方均使用招式', async () => {
const s = await battleScenario()
.party('charmander', 50, ['flamethrower'])
.opponent('squirtle', 50)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state)
.hasMove('player')
.hasMove('opponent')
})
battleTest('使用招式后 PP 递减', async () => {
const s = await battleScenario()
.party('charmander', 50, ['flamethrower', 'scratch'])
.opponent('squirtle', 50)
.start()
// Record initial PP
const initialState = s.state
const initialPp = initialState.playerPokemon.moves[0]!.pp
const maxPp = initialState.playerPokemon.moves[0]!.maxPp
expect(initialPp).toBe(maxPp)
const state = await s.useMove(0).runTurn()
// PP should decrease by 1, maxPp stays the same
s.expect(state).playerMovePp(0, initialPp - 1, maxPp)
})
battleTest('等级碾压一击击杀', async () => {
const s = await battleScenario()
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
.opponent('squirtle', 5)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state).finished().opponentFainted()
})
battleTest('回合数递增', async () => {
const s = await battleScenario()
.party('pikachu', 50, ['thundershock'])
.opponent('pikachu', 50) // Same type matchup for neutral/longer battle
.start()
const state = await s.useMove(0).runTurn()
s.expect(state).turnIs(2)
})
})
// ─── 属性克制 ───
describe('Battle Scenario: 属性克制', () => {
battleTest('火系招式对草系效果绝佳', async () => {
const s = await battleScenario()
.party('charmander', 50, ['flamethrower'])
.opponent('bulbasaur', 50)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state).hasSuperEffective().hasDamage('opponent')
})
battleTest('水系招式对火系效果绝佳', async () => {
const s = await battleScenario()
.party('squirtle', 50, ['watergun'])
.opponent('charmander', 50)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state).hasSuperEffective().hasDamage('opponent')
})
battleTest('水系招式对水系效果不佳', async () => {
const s = await battleScenario()
.party('squirtle', 50, ['watergun'])
.opponent('squirtle', 50)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state).hasResisted().hasDamage('opponent')
})
})
// ─── 强制换人 ───
describe('Battle Scenario: 强制换人', () => {
battleTest('精灵倒下触发强制换人', async () => {
const s = await battleScenario()
.party('charmander', 5, ['ember'])
.party('bulbasaur', 50, ['vinewhip'])
.opponent('squirtle', 100)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state).needsSwitch().playerFainted().aliveInParty(1)
})
battleTest('换人后新精灵上场', async () => {
const s = await battleScenario()
.party('charmander', 5, ['ember'])
.party('bulbasaur', 50, ['vinewhip'])
.opponent('squirtle', 100)
.start()
const afterTurn = await s.useMove(0).runTurn()
s.expect(afterTurn).needsSwitch()
const afterSwitch = await s.doSwitch(1)
s.expect(afterSwitch).playerSpecies('bulbasaur').ongoing()
})
battleTest('换人后继续战斗', async () => {
const s = await battleScenario()
.party('charmander', 5, ['ember'])
.party('pikachu', 100, ['thundershock'], { ev: { attack: 252, speed: 252 } })
.opponent('squirtle', 100)
.start()
// Charmander gets OHKO'd by L100 Squirtle
await s.useMove(0).runTurn()
// Switch to Pikachu
await s.doSwitch(1)
// Pikachu fights Squirtle
const state = await s.useMove(0).runTurn()
s.expect(state).hasMove('player').playerSpecies('pikachu')
})
battleTest('最后一只倒下不触发强制换人', async () => {
const s = await battleScenario()
.party('charmander', 5, ['ember'])
.opponent('squirtle', 100)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state)
.finished()
.opponentWon()
.satisfies(s => !s.needsSwitch, 'no switch needed when all fainted')
})
})
// ─── 战术换人 ───
describe('Battle Scenario: 战术换人', () => {
battleTest('战术换人在同回合执行', async () => {
const s = await battleScenario()
.party('charmander', 50, ['flamethrower'])
.party('squirtle', 50, ['watergun'])
.opponent('bulbasaur', 50)
.start()
const state = await s.switchTo(1).runTurn()
s.expect(state).playerSpecies('squirtle').ongoing()
})
})
// ─── 战斗结束 ───
describe('Battle Scenario: 战斗结束', () => {
battleTest('玩家胜利', async () => {
const s = await battleScenario()
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
.opponent('bulbasaur', 5)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state).finished().playerWon()
})
battleTest('玩家失败', async () => {
const s = await battleScenario()
.party('charmander', 5, ['ember'])
.opponent('squirtle', 100)
.start()
const state = await s.useMove(0).runTurn()
s.expect(state).finished().opponentWon()
})
battleTest('runUntilEnd 自动完成战斗', async () => {
const s = await battleScenario()
.party('charmander', 50, ['flamethrower'])
.opponent('squirtle', 5)
.start()
const state = await s.runUntilEnd()
s.expect(state).finished()
})
battleTest('长战斗在 maxTurns 内结束', async () => {
const s = await battleScenario()
.party('charmander', 50, ['flamethrower'])
.opponent('squirtle', 50)
.start()
const state = await s.runUntilEnd(100)
s.expect(state).finished()
})
})
// ─── 多精灵队伍战斗流程 ───
describe('Battle Scenario: 多精灵队伍', () => {
battleTest('2v1 战斗:需要两次击杀', async () => {
const s = await battleScenario()
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
.party('bulbasaur', 100, ['vinewhip'], { ev: { hp: 252, attack: 252, speed: 252 } })
.opponent('squirtle', 5)
.start()
// First pokemon OHKOs opponent
const state = await s.useMove(0).runTurn()
s.expect(state).finished().playerWon()
})
battleTest('连续换人后战斗继续', async () => {
const s = await battleScenario()
.party('charmander', 5, ['ember'])
.party('bulbasaur', 5, ['vinewhip'])
.party('pikachu', 100, ['thundershock'], { ev: { attack: 252, speed: 252 } })
.opponent('squirtle', 100)
.start()
// Charmander faints to L100 Squirtle
await s.useMove(0).runTurn()
// Switch to Bulbasaur (index 1)
await s.doSwitch(1)
// Bulbasaur faints too
await s.useMove(0).runTurn()
// Switch to Pikachu (index 2)
await s.doSwitch(2)
// Pikachu finishes
const state = await s.useMove(0).runTurn()
s.expect(state)
.playerSpecies('pikachu')
.hasMove('player')
})
})

View File

@@ -0,0 +1,469 @@
import { describe, test, expect } from 'bun:test'
import { createBattle, executeTurn } from '../battle/engine'
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
import { chooseAIMove } from '../battle/ai'
import type { Creature, BuddyData } from '../types'
function makeTestCreature(overrides: Partial<Creature> = {}): Creature {
return {
id: overrides.id ?? 'test-1',
speciesId: overrides.speciesId ?? 'charmander',
gender: overrides.gender ?? 'male',
level: overrides.level ?? 50,
xp: 0,
totalXp: 0,
nature: overrides.nature ?? 'adamant',
ev: overrides.ev ?? { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: overrides.iv ?? { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
moves: overrides.moves ?? [
{ id: 'flamethrower', pp: 15, maxPp: 15 },
{ id: 'airslash', pp: 15, maxPp: 15 },
{ id: 'dragontail', pp: 10, maxPp: 10 },
{ id: 'slash', pp: 20, maxPp: 20 },
],
ability: overrides.ability ?? 'blaze',
heldItem: null,
friendship: overrides.friendship ?? 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
}
function makeTestBuddyData(creatures: Creature[] = [makeTestCreature()]): BuddyData {
return {
version: 2,
party: [creatures[0]!.id, null, null, null, null, null],
boxes: [],
creatures: creatures,
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
}
describe('createBattle', () => {
test('creates battle with valid initial state', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'squirtle', 50)
expect(init.state).toBeDefined()
expect(init.state.playerPokemon).toBeDefined()
expect(init.state.opponentPokemon).toBeDefined()
expect(init.state.finished).toBe(false)
})
test('player pokemon has correct species', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'bulbasaur', 30)
expect(init.state.playerPokemon.speciesId).toBe('charmander')
expect(init.state.opponentPokemon.speciesId).toBe('bulbasaur')
})
test('player pokemon has moves', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'squirtle', 50)
expect(init.state.playerPokemon.moves.length).toBeGreaterThan(0)
})
})
describe('executeTurn', () => {
test('move action generates events', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'squirtle', 50)
const initialEventCount = init.state.events.length
const newState = await executeTurn(init, { type: 'move', moveIndex: 0 })
expect(newState.events.length).toBeGreaterThanOrEqual(initialEventCount)
})
test('battle eventually ends within 50 turns', async () => {
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 0, speed: 252 } })
const init = await createBattle([creature], 'squirtle', 5)
let state = init.state
for (let i = 0; i < 50 && !state.finished; i++) {
state = await executeTurn(init, { type: 'move', moveIndex: 0 })
}
expect(state.finished).toBe(true)
})
})
describe('settleBattle', () => {
test('player win increments battlesWon', async () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const result = {
winner: 'player' as const,
turns: 5,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(1)
})
test('player loss returns unchanged data', async () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const result = {
winner: 'opponent' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
// Loss early-returns unchanged data
expect(settlement.data.creatures[0]!.totalXp).toBe(creature.totalXp)
expect(settlement.learnableMoves).toEqual([])
expect(settlement.pendingEvolutions).toEqual([])
})
})
describe('applyMoveLearn', () => {
test('replaces move at given index', () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const updated = applyMoveLearn(data, creature.id, 'fireblast', 3)
expect(updated.creatures[0]!.moves[3]!.id).toBe('fireblast')
})
})
describe('applyEvolution', () => {
test('evolves charmander to charmeleon and increments counter', () => {
const creature = makeTestCreature({ speciesId: 'charmander' })
const data: BuddyData = {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: [],
creatures: [creature],
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: '',
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
const updated = applyEvolution(data, creature.id, 'charmeleon')
expect(updated.creatures[0]!.speciesId).toBe('charmeleon')
expect(updated.stats.totalEvolutions).toBe(1)
})
})
describe('chooseAIMove', () => {
test('returns a valid move index', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'squirtle', 50)
const aiPokemon = init.state.opponentPokemon
const idx = chooseAIMove(aiPokemon)
expect(idx).toBeGreaterThanOrEqual(0)
expect(idx).toBeLessThan(aiPokemon.moves.length)
})
test('returns 0 when all moves have 0 PP', () => {
const pokemon = {
...makeTestCreature(),
moves: [
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 0, maxPp: 35, disabled: false },
],
}
const idx = chooseAIMove(pokemon as any)
expect(idx).toBe(0) // Struggle fallback
})
test('skips disabled moves', () => {
const pokemon = {
...makeTestCreature(),
moves: [
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 35, maxPp: 35, disabled: true },
{ id: 'scratch', name: 'Scratch', type: 'Normal', pp: 35, maxPp: 35, disabled: false },
],
}
const idx = chooseAIMove(pokemon as any)
expect(idx).toBe(1) // Only non-disabled move
})
})
describe('settleBattle - advanced', () => {
test('player win awards XP to creature', async () => {
const creature = makeTestCreature({ level: 5 })
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.creatures[0]!.totalXp).toBeGreaterThan(0)
})
test('player win awards EVs (capped at 252 per stat)', async () => {
const creature = makeTestCreature({
level: 5,
ev: { hp: 250, attack: 250, defense: 250, spAtk: 250, spDef: 250, speed: 250 },
})
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
for (const stat of ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] as const) {
expect(settlement.data.creatures[0]!.ev[stat]).toBeLessThanOrEqual(252)
}
})
test('player loss does not increment battlesWon', async () => {
const creature = makeTestCreature()
const data = makeTestBuddyData([creature])
const result = {
winner: 'opponent' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(0)
})
})
describe('createBattle - extended', () => {
test('battle state has turn initialized', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'squirtle', 50)
expect(init.state.turn).toBeGreaterThanOrEqual(1)
})
test('player pokemon has correct level', async () => {
const creature = makeTestCreature({ level: 25 })
const init = await createBattle([creature], 'bulbasaur', 10)
expect(init.state.playerPokemon.level).toBe(25)
})
test('opponent pokemon has correct level', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'squirtle', 15)
expect(init.state.opponentPokemon.level).toBe(15)
})
test('battle state has player party', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'squirtle', 50)
expect(init.state.playerParty.length).toBeGreaterThan(0)
})
test('battle state has usable items (empty bag)', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'squirtle', 50)
expect(init.state.usableItems).toEqual([])
})
})
describe('executeTurn - extended', () => {
test('item action defaults to move 1', async () => {
const creature = makeTestCreature()
const init = await createBattle([creature], 'squirtle', 50)
const state = await executeTurn(init, { type: 'item', itemId: 'potion' })
expect(state).toBeDefined()
expect(state.events.length).toBeGreaterThan(0)
})
test('battle produces damage or heal events', async () => {
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 4, speed: 252 } })
const init = await createBattle([creature], 'squirtle', 5)
const state = await executeTurn(init, { type: 'move', moveIndex: 0 })
const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal')
expect(hasDamageOrHeal).toBe(true)
})
})
describe('settleBattle - EV limits', () => {
test('EV total cannot exceed 510', async () => {
const creature = makeTestCreature({
level: 5,
ev: { hp: 250, attack: 250, defense: 10, spAtk: 0, spDef: 0, speed: 0 },
})
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
const totalEV = Object.values(settlement.data.creatures[0]!.ev).reduce((a, b) => a + b, 0)
expect(totalEV).toBeLessThanOrEqual(510)
})
test('non-participant creatures are unchanged', async () => {
const participant = makeTestCreature({ id: 'p1', level: 5 })
const bystander = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
const data = makeTestBuddyData([participant, bystander])
data.party = [participant.id, bystander.id, null, null, null, null]
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [participant.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
const bystanderAfter = settlement.data.creatures.find(c => c.id === 'p2')!
expect(bystanderAfter.totalXp).toBe(bystander.totalXp)
})
test('uses all party members as participants when participantIds is empty', async () => {
const c1 = makeTestCreature({ id: 'p1', level: 5 })
const c2 = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
const data = makeTestBuddyData([c1, c2])
data.party = [c1.id, c2.id, null, null, null, null]
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [] as string[],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.creatures.find(c => c.id === 'p1')!.totalXp).toBeGreaterThan(0)
expect(settlement.data.creatures.find(c => c.id === 'p2')!.totalXp).toBeGreaterThan(0)
})
test('player win increments battlesWon but not battlesLost', async () => {
const creature = makeTestCreature({ level: 5 })
const data = makeTestBuddyData([creature])
const result = {
winner: 'player' as const,
turns: 3,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(1)
expect(settlement.data.stats.battlesLost).toBe(0)
})
})
describe('applyMoveLearn - extended', () => {
test('new move has correct PP from Dex', () => {
const creature = makeTestCreature()
const data = makeTestBuddyData([creature])
const updated = applyMoveLearn(data, creature.id, 'fireblast', 0)
const move = updated.creatures[0]!.moves[0]!
expect(move.id).toBe('fireblast')
expect(move.pp).toBeGreaterThan(0)
expect(move.maxPp).toBeGreaterThan(0)
})
test('non-target creatures are unchanged', () => {
const c1 = makeTestCreature({ id: 't1' })
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
const data = makeTestBuddyData([c1, c2])
const updated = applyMoveLearn(data, 't1', 'fireblast', 0)
const unchanged = updated.creatures.find(c => c.id === 't2')!
expect(unchanged.moves[0]!.id).toBe('flamethrower')
})
})
describe('applyEvolution - extended', () => {
test('friendship increases by 10', () => {
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 70 })
const data = makeTestBuddyData([creature])
const updated = applyEvolution(data, creature.id, 'charmeleon')
expect(updated.creatures[0]!.friendship).toBe(80)
})
test('friendship capped at 255', () => {
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 250 })
const data = makeTestBuddyData([creature])
const updated = applyEvolution(data, creature.id, 'charmeleon')
expect(updated.creatures[0]!.friendship).toBe(255)
})
test('multiple evolutions increment counter correctly', () => {
const c1 = makeTestCreature({ id: 't1', speciesId: 'charmander' })
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
const data = makeTestBuddyData([c1, c2])
let updated = applyEvolution(data, 't1', 'charmeleon')
updated = applyEvolution(updated, 't2', 'ivysaur')
expect(updated.stats.totalEvolutions).toBe(2)
})
})

View File

@@ -0,0 +1,188 @@
import { describe, test, expect } from 'bun:test'
import type { SpeciesId, Creature } from '../types'
import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel, getActiveCreature } from '../core/creature'
import { getSpeciesData } from '../dex/species'
describe('generateCreature', () => {
test('creates a creature with correct defaults', async () => {
const c = await 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(getSpeciesData('bulbasaur').baseHappiness)
expect(c.isShiny).toBeDefined()
expect(c.id).toBeTruthy()
expect(Object.values(c.iv).every((v: number) => v >= 0 && v <= 31)).toBe(true)
expect(Object.values(c.ev).every((v: number) => v === 0)).toBe(true)
})
test('deterministic IV generation from seed', async () => {
const c1 = await generateCreature('charmander', 12345)
const c2 = await generateCreature('charmander', 12345)
expect(c1.iv).toEqual(c2.iv)
})
test('different seeds produce different IVs', async () => {
const c1 = await generateCreature('squirtle', 100)
const c2 = await generateCreature('squirtle', 200)
expect(c1.iv).not.toEqual(c2.iv)
})
test('all MVP species can be generated', async () => {
const species: SpeciesId[] = [
'bulbasaur', 'ivysaur', 'venusaur',
'charmander', 'charmeleon', 'charizard',
'squirtle', 'wartortle', 'blastoise',
'pikachu',
]
for (const s of species) {
const c = await generateCreature(s)
expect(c.speciesId).toBe(s)
}
})
})
describe('calculateStats', () => {
test('level 1 stats are reasonable', async () => {
const c = await generateCreature('bulbasaur', 0)
// Use deterministic nature to avoid flaky test from randomNature()
c.nature = 'hardy'
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 with Hardy (neutral): floor((2*49 + iv) * 1/100 + 5)
expect(stats.attack).toBeGreaterThanOrEqual(5)
expect(stats.attack).toBeLessThanOrEqual(6)
})
test('stats increase with level', async () => {
const c1 = await 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', async () => {
const c = await generateCreature('pikachu', 0)
// Level must be high enough for EV contribution to be visible in stat formula
const c50 = { ...c, level: 50 }
const statsNoEV = calculateStats(c50)
const cWithEV = { ...c50, ev: { ...c50.ev, attack: 252 } }
const statsWithEV = calculateStats(cWithEV)
expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack)
})
})
describe('getCreatureName', () => {
test('returns species name when no nickname', async () => {
const c = await generateCreature('pikachu')
c.nickname = undefined
expect(getCreatureName(c)).toBe('Pikachu')
})
test('returns nickname when set', async () => {
const c = await generateCreature('pikachu')
c.nickname = 'Sparky'
expect(getCreatureName(c)).toBe('Sparky')
})
})
describe('getTotalEV', () => {
test('returns 0 for new creature', async () => {
const c = await generateCreature('bulbasaur')
expect(getTotalEV(c)).toBe(0)
})
test('sums all EV values', async () => {
const c = await generateCreature('bulbasaur')
c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 }
expect(getTotalEV(c)).toBe(210)
})
})
describe('recalculateLevel', () => {
test('returns same creature if level unchanged', async () => {
const c = await generateCreature('bulbasaur', 42)
const result = recalculateLevel(c)
expect(result.level).toBe(c.level)
})
test('updates level based on totalXp', async () => {
const c = await generateCreature('charmander', 42)
c.totalXp = 8000
const result = recalculateLevel(c)
expect(result.level).toBeGreaterThan(1)
})
})
describe('getActiveCreature', () => {
test('returns null when party is empty', async () => {
const c = await generateCreature('bulbasaur')
const result = getActiveCreature({ party: [null, null, null, null, null, null], creatures: [c] })
expect(result).toBeNull()
})
test('returns creature from party[0]', async () => {
const c = await generateCreature('pikachu')
const result = getActiveCreature({ party: [c.id, null, null, null, null, null], creatures: [c] })
expect(result).not.toBeNull()
expect(result!.id).toBe(c.id)
})
test('returns creature from activeCreatureId (legacy)', async () => {
const c = await generateCreature('squirtle')
const result = getActiveCreature({ activeCreatureId: c.id, creatures: [c] })
expect(result).not.toBeNull()
expect(result!.id).toBe(c.id)
})
test('prefers party[0] over activeCreatureId', async () => {
const c1 = await generateCreature('bulbasaur')
const c2 = await generateCreature('charmander')
const result = getActiveCreature({ party: [c1.id, null, null, null, null, null], activeCreatureId: c2.id, creatures: [c1, c2] })
expect(result!.id).toBe(c1.id)
})
test('returns null when creature ID not found', () => {
const result = getActiveCreature({ party: ['nonexistent', null, null, null, null, null], creatures: [] })
expect(result).toBeNull()
})
})
describe('calculateStats - nature effects', () => {
test('adamant nature boosts attack and lowers spAtk', async () => {
const c = await generateCreature('charmander', 42)
c.level = 50
c.nature = 'adamant'
const adamantStats = calculateStats(c)
c.nature = 'hardy'
const hardyStats = calculateStats(c)
expect(adamantStats.attack).toBeGreaterThan(hardyStats.attack)
expect(adamantStats.spAtk).toBeLessThan(hardyStats.spAtk)
})
test('timid nature boosts speed and lowers attack', async () => {
const c = await generateCreature('pikachu', 42)
c.level = 50
c.nature = 'timid'
const timidStats = calculateStats(c)
c.nature = 'hardy'
const hardyStats = calculateStats(c)
expect(timidStats.speed).toBeGreaterThan(hardyStats.speed)
expect(timidStats.attack).toBeLessThan(hardyStats.attack)
})
})

View 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 '../dex/evMapping'
beforeEach(() => {
resetEVCooldowns()
})
describe('awardEV', () => {
test('mapped tool awards correct EV', async () => {
let c = await 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', async () => {
let c = await generateCreature('bulbasaur')
c = awardEV(c, 'UnknownTool', 0)
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
expect(totalEV).toBeGreaterThan(0)
})
test('cooldown prevents repeated awards', async () => {
const now = Date.now()
let c = await 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', async () => {
let c = await 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', async () => {
let c = await 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: number, b: number) => a + b, 0)
expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL)
})
})
describe('awardTurnEV', () => {
test('awards EV for multiple tools', async () => {
let c = await generateCreature('bulbasaur')
c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0)
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
expect(totalEV).toBeGreaterThan(0)
})
})
describe('getEVSummary', () => {
test('returns "None" for new creature', async () => {
const c = await generateCreature('bulbasaur')
expect(getEVSummary(c)).toBe('None')
})
test('shows stat breakdown', async () => {
const c = await 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')
})
})

View File

@@ -0,0 +1,160 @@
import { describe, test, expect } from 'bun:test'
import { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg } from '../core/egg'
import type { BuddyData } from '../types'
import { generateCreature } from '../core/creature'
function makeBuddyData(overrides: Partial<BuddyData['stats']> = {}): BuddyData {
const creature = generateCreature('bulbasaur')
// Sync mock — generateCreature is async but for test setup we use the resolved structure
return {
version: 2,
party: ['test-creature-id', null, null, null, null, null],
boxes: [{ name: 'Box 1', slots: Array(30).fill(null) }],
creatures: [{
id: 'test-creature-id',
speciesId: 'bulbasaur',
gender: 'male' as const,
level: 5,
xp: 0,
totalXp: 100,
nature: 'hardy',
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
moves: [
{ id: 'tackle', pp: 35, maxPp: 35 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
],
ability: 'overgrow',
heldItem: null,
friendship: 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}],
eggs: [],
dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }],
bag: { items: [] },
stats: {
totalTurns: 50,
consecutiveDays: 7,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 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: 2 })
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)
})
})
describe('hatchEgg', () => {
test('creates a creature and removes egg', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
const result = await hatchEgg(data, egg)
expect(result.creature.speciesId).toBe('charmander')
expect(result.buddyData.creatures.length).toBe(data.creatures.length + 1)
expect(result.buddyData.eggs.length).toBe(0)
})
test('adds creature to party when slot available', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'pikachu' as const }
const result = await hatchEgg(data, egg)
const newCreature = result.creature
const inParty = result.buddyData.party.includes(newCreature.id)
expect(inParty).toBe(true)
})
test('increments totalEggsObtained', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'squirtle' as const }
const result = await hatchEgg(data, egg)
expect(result.buddyData.stats.totalEggsObtained).toBe(1)
})
test('updates dex entry with new species', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
const result = await hatchEgg(data, egg)
const entry = result.buddyData.dex.find(d => d.speciesId === 'charmander')
expect(entry).toBeDefined()
expect(entry!.caughtCount).toBe(1)
})
test('increments caughtCount for existing dex entry', async () => {
const data = makeBuddyData()
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'bulbasaur' as const }
const result = await hatchEgg(data, egg)
const entry = result.buddyData.dex.find(d => d.speciesId === 'bulbasaur')
expect(entry!.caughtCount).toBe(2)
})
})

View File

@@ -0,0 +1,39 @@
import { describe, test, expect } from 'bun:test'
import { getEVForTool, DEFAULT_EV_MAPPING, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
describe('getEVForTool', () => {
test('returns EV mapping for known tools', () => {
const bashEV = getEVForTool('Bash')
expect(bashEV).toBeDefined()
expect(bashEV!.attack).toBe(2)
expect(bashEV!.speed).toBe(1)
})
test('returns undefined for unknown tools', () => {
expect(getEVForTool('UnknownTool')).toBeUndefined()
})
test('all mapped tools have correct stat shape', () => {
for (const [, ev] of Object.entries(DEFAULT_EV_MAPPING)) {
expect(ev.hp).toBeDefined()
expect(ev.attack).toBeDefined()
expect(ev.defense).toBeDefined()
expect(ev.spAtk).toBeDefined()
expect(ev.spDef).toBeDefined()
expect(ev.speed).toBeDefined()
// EVs should sum to > 0
const total = ev.hp + ev.attack + ev.defense + ev.spAtk + ev.spDef + ev.speed
expect(total).toBeGreaterThan(0)
}
})
})
describe('EV constants', () => {
test('MAX_EV_PER_STAT is 252', () => {
expect(MAX_EV_PER_STAT).toBe(252)
})
test('MAX_EV_TOTAL is 510', () => {
expect(MAX_EV_TOTAL).toBe(510)
})
})

View File

@@ -0,0 +1,126 @@
import { describe, test, expect } from 'bun:test'
import type { Creature } from '../types'
import { checkEvolution, evolve, canEvolveFurther } from '../core/evolution'
function makeEvolutionCreature(overrides: Partial<Creature> = {}): Creature {
return {
id: 'test-evo',
speciesId: overrides.speciesId ?? 'bulbasaur',
gender: 'male',
level: overrides.level ?? 50,
xp: 0,
totalXp: 0,
nature: 'hardy',
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
moves: [
{ id: 'tackle', pp: 35, maxPp: 35 },
{ id: 'growl', pp: 40, maxPp: 40 },
{ id: 'vinewhip', pp: 15, maxPp: 15 },
{ id: 'razorleaf', pp: 10, maxPp: 10 },
],
ability: 'overgrow',
heldItem: null,
friendship: overrides.friendship ?? 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
}
describe('checkEvolution', () => {
test('bulbasaur at level 15 cannot evolve', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 15 })
expect(checkEvolution(creature)).toBeNull()
})
test('bulbasaur at level 16 can evolve into ivysaur', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 16 })
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 = makeEvolutionCreature({ speciesId: 'charmander', level: 16 })
const result = checkEvolution(creature)
expect(result!.to).toBe('charmeleon')
})
test('charmeleon at level 36 evolves into charizard', () => {
const creature = makeEvolutionCreature({ speciesId: 'charmeleon', level: 36 })
const result = checkEvolution(creature)
expect(result!.to).toBe('charizard')
})
test('squirtle at level 16 evolves into wartortle', () => {
const creature = makeEvolutionCreature({ speciesId: 'squirtle', level: 16 })
const result = checkEvolution(creature)
expect(result!.to).toBe('wartortle')
})
test('wartortle at level 36 evolves into blastoise', () => {
const creature = makeEvolutionCreature({ speciesId: 'wartortle', level: 36 })
const result = checkEvolution(creature)
expect(result!.to).toBe('blastoise')
})
test('venusaur cannot evolve further', () => {
const creature = makeEvolutionCreature({ speciesId: 'venusaur', level: 50 })
expect(checkEvolution(creature)).toBeNull()
})
test('pikachu does not evolve by level-up (needs item)', () => {
const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 })
// Pikachu evolves via Thunder Stone, not level-up
const result = checkEvolution(creature)
expect(result).toBeNull()
})
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 100 })
const result = checkEvolution(creature)
expect(result).not.toBeNull()
expect(result!.to).toBe('ivysaur')
})
})
describe('evolve', () => {
test('changes species and boosts friendship', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 70, level: 16 })
const evolved = evolve(creature, 'ivysaur')
expect(evolved.speciesId).toBe('ivysaur')
expect(evolved.friendship).toBe(80) // +10 friendship on evolution
})
test('friendship is capped at 255', () => {
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 250, level: 16 })
const evolved = evolve(creature, 'ivysaur')
expect(evolved.friendship).toBe(255)
})
})
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 can evolve into raichu', () => {
expect(canEvolveFurther('pikachu')).toBe(true)
})
})

View File

@@ -0,0 +1,153 @@
import { describe, test, expect } from 'bun:test'
import { generateCreature } from '../core/creature'
import { awardXP, getXpProgress } from '../core/experience'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/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', async () => {
const c = await generateCreature('bulbasaur')
const result = awardXP(c, 10)
expect(result.creature.totalXp).toBe(10)
expect(result.leveledUp).toBeDefined()
})
test('large XP can cause level up', async () => {
const c = await 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', async () => {
const c = await 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', async () => {
const c = await generateCreature('bulbasaur')
const progress = getXpProgress(c)
expect(progress.current).toBe(0)
expect(progress.percentage).toBe(0)
})
test('level 100 creature has 100% progress', async () => {
const c = await generateCreature('charmander')
c.level = 100
c.totalXp = 1000000
const progress = getXpProgress(c)
expect(progress.percentage).toBe(100)
})
test('needed is positive for sub-100 creatures', async () => {
const c = await generateCreature('bulbasaur')
c.level = 5
c.totalXp = xpForLevel(5, 'medium-slow')
const progress = getXpProgress(c)
expect(progress.needed).toBeGreaterThan(0)
expect(progress.current).toBe(0)
})
})
describe('xpToNextLevel', () => {
test('returns XP needed from current to next level', () => {
const xp10 = xpForLevel(10, 'medium-fast')
const xp11 = xpForLevel(11, 'medium-fast')
const needed = xpToNextLevel(10, xp10, 'medium-fast')
expect(needed).toBe(xp11 - xp10)
})
test('returns 0 at level 100', () => {
expect(xpToNextLevel(100, 1000000, 'medium-fast')).toBe(0)
})
test('accounts for partial XP already earned', () => {
const xp10 = xpForLevel(10, 'medium-fast')
const xp11 = xpForLevel(11, 'medium-fast')
const halfWay = xp10 + Math.floor((xp11 - xp10) / 2)
const needed = xpToNextLevel(10, halfWay, 'medium-fast')
expect(needed).toBe(xp11 - halfWay)
})
})
describe('awardXP - extended', () => {
test('awarding 0 XP returns unchanged creature', async () => {
const c = await generateCreature('bulbasaur')
const result = awardXP(c, 0)
expect(result.creature.totalXp).toBe(c.totalXp)
expect(result.leveledUp).toBe(false)
})
test('XP progress is correctly calculated after award', async () => {
const c = await generateCreature('squirtle')
const xpNeeded = xpForLevel(2, 'medium-slow')
const result = awardXP(c, Math.floor(xpNeeded / 2))
expect(result.creature.xp).toBeGreaterThanOrEqual(0)
})
test('multiple small XP awards equal one large award', async () => {
const c1 = await generateCreature('bulbasaur', 42)
const c2 = await generateCreature('bulbasaur', 42)
c2.totalXp = c1.totalXp
let current = c1
for (let i = 0; i < 10; i++) {
current = awardXP(current, 100).creature
}
const bigResult = awardXP(c2, 1000)
expect(current.totalXp).toBe(bigResult.creature.totalXp)
expect(current.level).toBe(bigResult.creature.level)
})
})

View File

@@ -0,0 +1,33 @@
import { describe, test, expect } from 'bun:test'
import { getFallbackSprite } from '../sprites/fallback'
import { ALL_SPECIES_IDS } from '../types'
describe('getFallbackSprite', () => {
test('returns 5 lines for every species', () => {
for (const id of ALL_SPECIES_IDS) {
const sprite = getFallbackSprite(id)
expect(sprite.length).toBe(5)
}
})
test('returns generic fallback for unknown species', () => {
const sprite = getFallbackSprite('unknowndefinitelynotarealspecies')
expect(sprite.length).toBe(5)
expect(sprite[0]).toContain('.---')
})
test('returns curated sprite for pikachu', () => {
const sprite = getFallbackSprite('pikachu')
expect(sprite[0]).toContain('/\\')
})
test('each line has consistent width', () => {
for (const id of ALL_SPECIES_IDS) {
const sprite = getFallbackSprite(id)
const widths = sprite.map(line => line.length)
const maxWidth = Math.max(...widths)
const minWidth = Math.min(...widths)
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)
}
})
})

View File

@@ -0,0 +1,51 @@
import { describe, test, expect } from 'bun:test'
import { determineGender, getGenderSymbol } from '../core/gender'
import { getSpeciesData } from '../dex/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 = getSpeciesData('pikachu')
expect(pikachu.genderRate).toBe(4)
})
test('pikachu 50% female ratio', () => {
const pikachu = getSpeciesData('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 = getSpeciesData('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('')
})
})

View File

@@ -0,0 +1,59 @@
import { describe, test, expect } from 'bun:test'
import { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from '../dex/learnsets'
import { EMPTY_MOVE } from '../types'
describe('getDefaultMoveset', () => {
test('charmander at level 1 has at least one move', async () => {
const moves = await getDefaultMoveset('charmander', 1)
expect(moves.length).toBe(4)
expect(moves[0]!.id).not.toBe('')
})
test('charmander at level 10 has more moves', async () => {
const moves = await getDefaultMoveset('charmander', 10)
const nonEmpty = moves.filter(m => m.id !== '')
expect(nonEmpty.length).toBeGreaterThan(1)
})
test('all moves have valid pp', async () => {
const moves = await getDefaultMoveset('bulbasaur', 20)
for (const move of moves) {
if (move.id) {
expect(move.pp).toBeGreaterThan(0)
expect(move.maxPp).toBeGreaterThan(0)
}
}
})
test('invalid species returns empty moves', async () => {
const moves = await getDefaultMoveset('nonexistent' as any, 10)
expect(moves.every(m => m.id === '')).toBe(true)
})
})
describe('getDefaultAbility', () => {
test('charmander has blaze', () => {
expect(getDefaultAbility('charmander')).toBe('blaze')
})
test('bulbasaur has overgrow', () => {
expect(getDefaultAbility('bulbasaur')).toBe('overgrow')
})
test('squirtle has torrent', () => {
expect(getDefaultAbility('squirtle')).toBe('torrent')
})
})
describe('getNewLearnableMoves', () => {
test('charmander gains ember at level 4', async () => {
const moves = await getNewLearnableMoves('charmander', 1, 4)
expect(moves.length).toBeGreaterThan(0)
expect(moves.some(m => m.id === 'ember')).toBe(true)
})
test('no new moves when level stays same', async () => {
const moves = await getNewLearnableMoves('charmander', 5, 5)
expect(moves.length).toBe(0)
})
})

View File

@@ -0,0 +1,51 @@
import { describe, test, expect } from 'bun:test'
import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../dex/names'
// Original 10 curated species
const CURATED = [
'bulbasaur', 'ivysaur', 'venusaur',
'charmander', 'charmeleon', 'charizard',
'squirtle', 'wartortle', 'blastoise',
'pikachu',
]
describe('SPECIES_NAMES', () => {
test('has name for curated species', () => {
for (const id of CURATED) {
expect(SPECIES_NAMES[id]).toBeTruthy()
}
})
test('Charmander name is correct', () => {
expect(SPECIES_NAMES.charmander).toBe('Charmander')
})
})
describe('SPECIES_I18N', () => {
test('has i18n for curated species', () => {
for (const id of CURATED) {
expect(SPECIES_I18N[id]).toBeTruthy()
expect(SPECIES_I18N[id]!.en).toBeTruthy()
}
})
test('has Chinese translations', () => {
expect(SPECIES_I18N.pikachu!.zh).toBe('皮卡丘')
expect(SPECIES_I18N.squirtle!.zh).toBe('杰尼龟')
})
})
describe('SPECIES_PERSONALITY', () => {
test('has personality for curated species', () => {
for (const id of CURATED) {
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
}
})
test('personality is non-empty string for curated species', () => {
for (const id of CURATED) {
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
}
})
})

View File

@@ -0,0 +1,53 @@
import { describe, test, expect } from 'bun:test'
import { getAllNatureNames, randomNature, getNatureEffect } from '../dex/nature'
describe('getAllNatureNames', () => {
test('returns 25 nature names', () => {
const names = getAllNatureNames()
expect(names.length).toBe(25)
})
test('includes hardy and quirky', () => {
const names = getAllNatureNames()
expect(names).toContain('hardy')
expect(names).toContain('quirky')
})
})
describe('randomNature', () => {
test('returns a valid nature name', () => {
const nature = randomNature()
expect(getAllNatureNames()).toContain(nature)
})
test('produces different natures over multiple calls', () => {
const natures = new Set(Array.from({ length: 50 }, () => randomNature()))
expect(natures.size).toBeGreaterThan(1)
})
})
describe('getNatureEffect', () => {
test('hardy is neutral (no effect)', () => {
const effect = getNatureEffect('hardy')
expect(effect.plus).toBeNull()
expect(effect.minus).toBeNull()
})
test('adamant boosts attack and lowers spAtk', () => {
const effect = getNatureEffect('adamant')
expect(effect.plus).toBe('attack')
expect(effect.minus).toBe('spAtk')
})
test('timid boosts speed and lowers attack', () => {
const effect = getNatureEffect('timid')
expect(effect.plus).toBe('speed')
expect(effect.minus).toBe('attack')
})
test('invalid nature returns neutral', () => {
const effect = getNatureEffect('nonexistent')
expect(effect.plus).toBeNull()
expect(effect.minus).toBeNull()
})
})

View File

@@ -0,0 +1,46 @@
import { describe, test, expect } from 'bun:test'
import { FROM_DEX_STAT, TO_DEX_STAT, mapBaseStats, mapGenderRatio } from '../dex/pkmn'
describe('FROM_DEX_STAT', () => {
test('maps all 6 stats', () => {
expect(FROM_DEX_STAT.hp).toBe('hp')
expect(FROM_DEX_STAT.atk).toBe('attack')
expect(FROM_DEX_STAT.def).toBe('defense')
expect(FROM_DEX_STAT.spa).toBe('spAtk')
expect(FROM_DEX_STAT.spd).toBe('spDef')
expect(FROM_DEX_STAT.spe).toBe('speed')
})
})
describe('TO_DEX_STAT', () => {
test('reverse maps all 6 stats', () => {
expect(TO_DEX_STAT.hp).toBe('hp')
expect(TO_DEX_STAT.attack).toBe('atk')
expect(TO_DEX_STAT.defense).toBe('def')
expect(TO_DEX_STAT.spAtk).toBe('spa')
expect(TO_DEX_STAT.spDef).toBe('spd')
expect(TO_DEX_STAT.speed).toBe('spe')
})
})
describe('mapBaseStats', () => {
test('converts Dex stat format to our format', () => {
const result = mapBaseStats({ hp: 45, atk: 49, def: 49, spa: 65, spd: 65, spe: 45 })
expect(result).toEqual({
hp: 45, attack: 49, defense: 49,
spAtk: 65, spDef: 65, speed: 45,
})
})
})
describe('mapGenderRatio', () => {
test('returns -1 for genderless', () => {
expect(mapGenderRatio(undefined)).toBe(-1)
expect(mapGenderRatio('N')).toBe(-1)
})
test('calculates female ratio', () => {
expect(mapGenderRatio({ M: 0.875, F: 0.125 })).toBe(1) // 12.5% F → 1
expect(mapGenderRatio({ M: 0.5, F: 0.5 })).toBe(4) // 50% F → 4
})
})

View File

@@ -0,0 +1,103 @@
import { describe, expect, test } from 'bun:test'
import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
describe('renderAnimatedSprite', () => {
const testSprite = [
' AB',
' C D',
]
test('idle mode returns original sprite (with ANSI resets)', () => {
const result = renderAnimatedSprite(testSprite, 0, 'idle')
expect(result.length).toBe(2)
// Each row should contain the original characters
expect(result[0]).toContain('A')
expect(result[0]).toContain('B')
})
test('flip reverses rows', () => {
const flipped = renderAnimatedSprite(testSprite, 0, 'flip')
expect(flipped[0]).toContain('B')
expect(flipped[0]).toContain('A')
})
test('blink replaces eye characters with dash', () => {
const sprite = [' O ', ' O ']
const result = renderAnimatedSprite(sprite, 0, 'blink')
expect(result[0]).toContain('—')
expect(result[1]).toContain('—')
})
test('bounce shifts sprite up', () => {
const result = renderAnimatedSprite(testSprite, 2, 'bounce')
// Bounce at tick 2 should shift up by some amount
expect(result.length).toBe(2)
})
test('excited mode shifts horizontally', () => {
const result = renderAnimatedSprite(testSprite, 0, 'excited')
expect(result.length).toBe(2)
})
test('walkRight shifts progressively', () => {
const r0 = renderAnimatedSprite(testSprite, 0, 'walkRight')
const r1 = renderAnimatedSprite(testSprite, 1, 'walkRight')
// Different ticks should produce different horizontal positions
expect(r0).toBeDefined()
expect(r1).toBeDefined()
})
test('walkLeft mode shifts', () => {
const result = renderAnimatedSprite(testSprite, 0, 'walkLeft')
expect(result.length).toBe(2)
})
test('pet mode returns sprite unchanged', () => {
const result = renderAnimatedSprite(testSprite, 0, 'pet')
expect(result.length).toBe(2)
})
})
describe('getIdleAnimMode', () => {
test('returns valid AnimMode for any tick', () => {
const modes = new Set<string>()
for (let i = 0; i < 100; i++) {
modes.add(getIdleAnimMode(i))
}
expect(modes.size).toBeGreaterThan(1)
})
test('cycles through sequence', () => {
// First tick should be 'idle' (first element of IDLE_SEQUENCE)
expect(getIdleAnimMode(0)).toBe('idle')
})
test('wraps around after sequence length', () => {
const mode0 = getIdleAnimMode(0)
const modeAfterFullCycle = getIdleAnimMode(26) // IDLE_SEQUENCE.length
expect(mode0).toBe(modeAfterFullCycle)
})
})
describe('getPetOverlay', () => {
test('returns two lines', () => {
const overlay = getPetOverlay(0)
expect(overlay.length).toBe(2)
})
test('contains heart characters', () => {
const overlay = getPetOverlay(0)
const combined = overlay.join('')
expect(combined).toContain('♥')
})
test('cycles through overlays', () => {
const o0 = getPetOverlay(0)
const o1 = getPetOverlay(1)
expect(o0).not.toEqual(o1)
})
test('wraps around', () => {
expect(getPetOverlay(0)).toEqual(getPetOverlay(5))
})
})

View File

@@ -0,0 +1,95 @@
import { describe, test, expect } from 'bun:test'
import { getSpeciesData, getAllSpeciesData, DEX_TO_SPECIES, ensureSpeciesData } from '../dex/species'
import { ALL_SPECIES_IDS } from '../types'
import type { SpeciesId } from '../types'
describe('getSpeciesData', () => {
test('returns valid data for charmander', () => {
const data = getSpeciesData('charmander')
expect(data.id).toBe('charmander')
expect(data.name).toBe('Charmander')
expect(data.dexNumber).toBe(4)
expect(data.growthRate).toBe('medium-slow')
expect(data.captureRate).toBe(45)
expect(data.flavorText).toBeTruthy()
})
test('returns valid data for pikachu', () => {
const data = getSpeciesData('pikachu')
expect(data.id).toBe('pikachu')
expect(data.dexNumber).toBe(25)
expect(data.growthRate).toBe('medium-fast')
})
test('has baseStats with all 6 stats', () => {
const data = getSpeciesData('bulbasaur')
expect(data.baseStats).toHaveProperty('hp')
expect(data.baseStats).toHaveProperty('attack')
expect(data.baseStats).toHaveProperty('defense')
expect(data.baseStats).toHaveProperty('spAtk')
expect(data.baseStats).toHaveProperty('spDef')
expect(data.baseStats).toHaveProperty('speed')
})
test('has types array', () => {
const data = getSpeciesData('squirtle')
expect(data.types.length).toBeGreaterThan(0)
expect(data.types[0]).toBe('water')
})
test('has evolutionChain for species with evolutions', () => {
const data = getSpeciesData('charmander')
expect(data.evolutionChain).toBeDefined()
expect(data.evolutionChain?.[0]?.into).toBe('charmeleon')
})
test('has no evolutionChain for final evolutions', () => {
const data = getSpeciesData('charizard')
expect(data.evolutionChain).toBeUndefined()
})
})
describe('getAllSpeciesData', () => {
test('returns data for all species', () => {
const all = getAllSpeciesData()
for (const id of ALL_SPECIES_IDS) {
expect(all[id]).toBeDefined()
expect(all[id]!.id).toBe(id)
}
})
})
describe('DEX_TO_SPECIES', () => {
test('maps dex numbers correctly', () => {
expect(DEX_TO_SPECIES[1]).toBe('bulbasaur')
expect(DEX_TO_SPECIES[4]).toBe('charmander')
expect(DEX_TO_SPECIES[7]).toBe('squirtle')
expect(DEX_TO_SPECIES[25]).toBe('pikachu')
})
})
describe('ensureSpeciesData', () => {
test('resolves without error', async () => {
await expect(ensureSpeciesData()).resolves.toBeUndefined()
})
})
describe('getSpeciesData - supplementary fields', () => {
test('has baseHappiness', () => {
expect(getSpeciesData('bulbasaur').baseHappiness).toBe(70)
})
test('pikachu has higher captureRate', () => {
expect(getSpeciesData('pikachu').captureRate).toBeGreaterThan(getSpeciesData('charmander').captureRate)
})
test('has names with en key', () => {
const data = getSpeciesData('charmander')
expect(data.names).toBeDefined()
expect(data.names.en).toBe('Charmander')
})
test('shinyChance is 1/4096', () => {
expect(getSpeciesData('bulbasaur').shinyChance).toBe(1 / 4096)
})
})

View File

@@ -0,0 +1,29 @@
import { describe, test, expect } from 'bun:test'
import { getSpeciesDisplay, loadSprite } from '../core/spriteCache'
describe('getSpeciesDisplay', () => {
test('formats charmander display', () => {
expect(getSpeciesDisplay('charmander')).toBe('#004 Charmander')
})
test('formats pikachu display', () => {
expect(getSpeciesDisplay('pikachu')).toBe('#025 Pikachu')
})
test('formats bulbasaur display', () => {
expect(getSpeciesDisplay('bulbasaur')).toBe('#001 Bulbasaur')
})
test('pads dex number to 3 digits', () => {
expect(getSpeciesDisplay('squirtle')).toBe('#007 Squirtle')
})
})
describe('loadSprite', () => {
test('returns null when no cache exists', () => {
// Uses a temp directory via getSpritesDir, should return null for non-cached
const result = loadSprite('nonexistent_pokemon' as any)
// Will be null since the file doesn't exist
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,388 @@
import { describe, test, expect } from 'bun:test'
import {
getDefaultBuddyData,
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
depositToBox, withdrawFromBox, moveInBox, renameBox,
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
addItemToBag, removeItemFromBag, getItemCount,
updateDailyStats, incrementTurns,
} from '../core/storage'
import type { BuddyData } from '../types'
function makeData(creatureCount = 1): BuddyData {
const creatures = Array.from({ length: creatureCount }, (_, i) => ({
id: `creature-${i}`,
speciesId: 'bulbasaur' as const,
gender: 'male' as const,
level: 5,
xp: 0,
totalXp: 100,
nature: 'hardy',
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
moves: [
{ id: 'tackle', pp: 35, maxPp: 35 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
{ id: '', pp: 0, maxPp: 0 },
] as [any, any, any, any],
ability: 'overgrow',
heldItem: null,
friendship: 70,
isShiny: false,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}))
const party: (string | null)[] = [creatures[0]!.id, null, null, null, null, null]
if (creatureCount > 1) party[1] = creatures[1]!.id
if (creatureCount > 2) party[2] = creatures[2]!.id
return {
version: 2,
party,
boxes: [
{ name: 'Box 1', slots: Array(30).fill(null) as (string | null)[] },
{ name: 'Box 2', slots: Array(30).fill(null) as (string | null)[] },
],
creatures,
eggs: [],
dex: [],
bag: { items: [] },
stats: {
totalTurns: 10,
consecutiveDays: 5,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 3,
battlesLost: 1,
},
}
}
// ─── Default data ───
describe('getDefaultBuddyData', () => {
test('returns v2 data with correct structure', async () => {
const data = await getDefaultBuddyData()
expect(data.version).toBe(2)
expect(data.party.length).toBe(6)
expect(data.party[0]).toBeTruthy()
expect(data.boxes.length).toBe(8)
expect(data.boxes[0]!.slots.length).toBe(30)
expect(data.bag.items).toEqual([])
expect(data.stats.battlesWon).toBe(0)
expect(data.stats.battlesLost).toBe(0)
})
test('has one creature matching party[0]', async () => {
const data = await getDefaultBuddyData()
expect(data.creatures.length).toBe(1)
expect(data.creatures[0]!.id).toBe(data.party[0]!)
})
test('creature has v2 fields', async () => {
const data = await getDefaultBuddyData()
const creature = data.creatures[0]!
expect(creature.nature).toBeTruthy()
expect(creature.moves.length).toBe(4)
expect(creature.ability).toBeTruthy()
expect(creature.heldItem).toBeNull()
expect(creature.pokeball).toBe('pokeball')
})
})
// ─── Party operations ───
describe('addToParty', () => {
test('adds creature to first empty slot', () => {
const data = makeData()
const result = addToParty(data, 'new-creature')
expect(result.added).toBe(true)
expect(result.data.party[1]).toBe('new-creature')
})
test('returns false when party is full', () => {
const data = makeData()
data.party = ['c1', 'c2', 'c3', 'c4', 'c5', 'c6']
const result = addToParty(data, 'new-creature')
expect(result.added).toBe(false)
})
})
describe('removeFromParty', () => {
test('removes creature and compacts party', () => {
const data = makeData(3)
const updated = removeFromParty(data, 0)
expect(updated.party[0]).toBe('creature-1')
expect(updated.party[1]).toBe('creature-2')
expect(updated.party[2]).toBeNull()
})
test('does nothing for out-of-bounds index', () => {
const data = makeData()
const updated = removeFromParty(data, 10)
expect(updated.party).toEqual(data.party)
})
test('cannot remove last party member', () => {
const data = makeData(1)
const updated = removeFromParty(data, 0)
expect(updated.party[0]).toBe('creature-0')
})
})
describe('swapPartySlots', () => {
test('swaps two party slots', () => {
const data = makeData(2)
const updated = swapPartySlots(data, 0, 1)
expect(updated.party[0]).toBe('creature-1')
expect(updated.party[1]).toBe('creature-0')
})
})
describe('setActivePartyMember', () => {
test('swaps creature to slot 0', () => {
const data = makeData(2)
const updated = setActivePartyMember(data, 'creature-1')
expect(updated.party[0]).toBe('creature-1')
expect(updated.party[1]).toBe('creature-0')
})
test('no change if already active', () => {
const data = makeData()
const updated = setActivePartyMember(data, 'creature-0')
expect(updated).toEqual(data)
})
})
// ─── PC Box operations ───
describe('depositToBox', () => {
test('deposits creature to first empty box slot', () => {
const data = makeData()
const result = depositToBox(data, 'box-creature')
expect(result.deposited).toBe(true)
expect(result.data.boxes[0]!.slots[0]).toBe('box-creature')
})
test('fills second box when first is full', () => {
const data = makeData()
data.boxes[0]!.slots = Array(30).fill('x')
const result = depositToBox(data, 'box-creature')
expect(result.deposited).toBe(true)
expect(result.data.boxes[1]!.slots[0]).toBe('box-creature')
})
})
describe('withdrawFromBox', () => {
test('withdraws creature from box', () => {
const data = makeData()
data.boxes[0]!.slots[5] = 'box-creature'
const result = withdrawFromBox(data, 'box-creature')
expect(result.withdrawn).toBe(true)
expect(result.data.boxes[0]!.slots[5]).toBeNull()
})
test('returns false when creature not in boxes', () => {
const data = makeData()
const result = withdrawFromBox(data, 'nonexistent')
expect(result.withdrawn).toBe(false)
})
})
describe('moveInBox', () => {
test('moves creature between slots', () => {
const data = makeData()
data.boxes[0]!.slots[0] = 'moving-creature'
const updated = moveInBox(data, 0, 0, 0, 5)
expect(updated.boxes[0]!.slots[0]).toBeNull()
expect(updated.boxes[0]!.slots[5]).toBe('moving-creature')
})
test('does nothing for empty source slot', () => {
const data = makeData()
const updated = moveInBox(data, 0, 0, 0, 5)
expect(updated).toEqual(data)
})
})
describe('renameBox', () => {
test('renames a box', () => {
const data = makeData()
const updated = renameBox(data, 0, 'My Box')
expect(updated.boxes[0]!.name).toBe('My Box')
})
})
describe('findCreatureLocation', () => {
test('finds creature in party', () => {
const data = makeData()
const loc = findCreatureLocation(data, 'creature-0')
expect(loc).toEqual({ area: 'party', slot: 0 })
})
test('finds creature in box', () => {
const data = makeData()
data.boxes[0]!.slots[3] = 'box-creature'
const loc = findCreatureLocation(data, 'box-creature')
expect(loc).toEqual({ area: 'box', slot: 3, boxIndex: 0 })
})
test('returns null for nonexistent', () => {
const data = makeData()
expect(findCreatureLocation(data, 'nonexistent')).toBeNull()
})
})
describe('releaseCreature', () => {
test('removes creature from party and creatures array', () => {
const data = makeData(2)
const updated = releaseCreature(data, 'creature-1')
expect(updated.creatures.find(c => c.id === 'creature-1')).toBeUndefined()
})
})
describe('getTotalCreatureCount', () => {
test('returns creature count', () => {
expect(getTotalCreatureCount(makeData(3))).toBe(3)
})
})
describe('getAllCreatureIds', () => {
test('returns all ids', () => {
expect(getAllCreatureIds(makeData(2))).toEqual(['creature-0', 'creature-1'])
})
})
// ─── Bag operations ───
describe('addItemToBag', () => {
test('adds new item', () => {
const data = makeData()
const updated = addItemToBag(data, 'potion', 3)
expect(updated.bag.items).toEqual([{ id: 'potion', count: 3 }])
})
test('stacks existing item', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 2)
const stacked = addItemToBag(withItem, 'potion', 3)
expect(stacked.bag.items[0]!.count).toBe(5)
})
})
describe('removeItemFromBag', () => {
test('removes item quantity', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 5)
const result = removeItemFromBag(withItem, 'potion', 3)
expect(result.removed).toBe(true)
expect(result.data.bag.items[0]!.count).toBe(2)
})
test('removes item entirely when count reaches 0', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 2)
const result = removeItemFromBag(withItem, 'potion', 2)
expect(result.removed).toBe(true)
expect(result.data.bag.items.length).toBe(0)
})
test('returns false when not enough items', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 1)
const result = removeItemFromBag(withItem, 'potion', 5)
expect(result.removed).toBe(false)
})
test('returns false for nonexistent item', () => {
const data = makeData()
const result = removeItemFromBag(data, 'potion', 1)
expect(result.removed).toBe(false)
})
})
describe('getItemCount', () => {
test('returns count for existing item', () => {
const data = makeData()
const withItem = addItemToBag(data, 'potion', 3)
expect(getItemCount(withItem, 'potion')).toBe(3)
})
test('returns 0 for nonexistent item', () => {
expect(getItemCount(makeData(), 'potion')).toBe(0)
})
})
// ─── Stats ───
describe('updateDailyStats', () => {
test('same day does not increment consecutive', () => {
const data = makeData()
const updated = updateDailyStats(data)
expect(updated.stats.consecutiveDays).toBe(data.stats.consecutiveDays)
})
})
describe('incrementTurns', () => {
test('increments totalTurns by 1', () => {
const data = makeData()
const updated = incrementTurns(data)
expect(updated.stats.totalTurns).toBe(data.stats.totalTurns + 1)
})
})
// ─── Extended coverage ───
describe('depositToBox - full boxes', () => {
test('fails when all boxes are full', () => {
const data = makeData()
for (const box of data.boxes) {
for (let i = 0; i < 30; i++) {
box.slots[i] = `filler-${i}`
}
}
const result = depositToBox(data, 'test-id')
expect(result.deposited).toBe(false)
})
})
describe('withdrawFromBox - roundtrip', () => {
test('deposit then withdraw leaves box empty', () => {
const data = makeData()
const deposited = depositToBox(data, 'test-id')
expect(deposited.deposited).toBe(true)
const result = withdrawFromBox(deposited.data, 'test-id')
expect(result.withdrawn).toBe(true)
const slot = result.data.boxes[0]!.slots.find(s => s === 'test-id')
expect(slot).toBeUndefined()
})
})
describe('findCreatureLocation - deposit', () => {
test('finds creature after depositing to box', () => {
const data = makeData()
const deposited = depositToBox(data, 'box-mon')
const loc = findCreatureLocation(deposited.data, 'box-mon')
expect(loc).not.toBeNull()
expect(loc!.area).toBe('box')
})
})
describe('releaseCreature - box', () => {
test('removes creature from box and creatures array', () => {
const data = makeData()
const deposited = depositToBox(data, 'box-mon')
const released = releaseCreature(deposited.data, 'box-mon')
expect(released.creatures.find(c => c.id === 'box-mon')).toBeUndefined()
})
test('clears party slot when releasing party member', () => {
const data = makeData(2)
const updated = releaseCreature(data, 'creature-1')
expect(updated.party[1]).toBeNull()
expect(updated.creatures.length).toBe(1)
})
})

View File

@@ -0,0 +1,64 @@
import { describe, test, expect } from 'bun:test'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable'
describe('xpForLevel', () => {
test('returns 0 for level 1', () => {
expect(xpForLevel(1, 'medium-fast')).toBe(0)
})
test('returns 0 for level 0', () => {
expect(xpForLevel(0, 'medium-fast')).toBe(0)
})
test('medium-fast: level 5 = 125 XP', () => {
expect(xpForLevel(5, 'medium-fast')).toBe(125)
})
test('medium-fast: level 10 = 1000 XP', () => {
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
})
test('slow: level 5 = 156 XP', () => {
expect(xpForLevel(5, 'slow')).toBe(156)
})
test('fast: level 5 = 100 XP', () => {
expect(xpForLevel(5, 'fast')).toBe(100)
})
})
describe('levelFromXp', () => {
test('returns 1 for 0 XP', () => {
expect(levelFromXp(0, 'medium-fast')).toBe(1)
})
test('returns 5 for 125 XP medium-fast', () => {
expect(levelFromXp(125, 'medium-fast')).toBe(5)
})
test('caps at 100', () => {
expect(levelFromXp(999999999, 'medium-fast')).toBe(100)
})
test('roundtrip: xpForLevel then levelFromXp', () => {
for (let lv = 1; lv <= 100; lv += 10) {
const xp = xpForLevel(lv, 'medium-fast')
expect(levelFromXp(xp, 'medium-fast')).toBe(lv)
}
})
})
describe('xpToNextLevel', () => {
test('returns 0 at level 100', () => {
expect(xpToNextLevel(100, 0, 'medium-fast')).toBe(0)
})
test('returns difference to next level', () => {
// Level 5 medium-fast: xpForLevel(5)=125, xpForLevel(6)=216
expect(xpToNextLevel(5, 125, 'medium-fast')).toBe(216 - 125)
})
test('returns full next level XP from 0', () => {
expect(xpToNextLevel(1, 0, 'medium-fast')).toBe(8) // 2^3=8
})
})

View File

@@ -0,0 +1,73 @@
import { Dex } from '@pkmn/sim'
import type { BattlePokemon } from './types'
/**
* AI move selection: prefers super-effective moves, avoids resisted moves,
* falls back to random among usable moves.
*/
export function chooseAIMove(pokemon: BattlePokemon, opponentTypes?: string[]): number {
const usable = pokemon.moves
.map((m, i) => ({ move: m, index: i }))
.filter(({ move }) => move.pp > 0 && !move.disabled)
if (usable.length === 0) return 0 // Struggle
// If no opponent type info, pick randomly
if (!opponentTypes || opponentTypes.length === 0) {
return usable[Math.floor(Math.random() * usable.length)]!.index
}
// Classify moves by effectiveness against opponent
const superEffective: number[] = []
const neutral: number[] = []
const resisted: number[] = []
const statusMoves: number[] = [] // Lowest priority
for (const { move, index } of usable) {
const dexMove = Dex.moves.get(move.id)
if (!dexMove?.type) {
neutral.push(index)
continue
}
const moveType = dexMove.type // Keep original case for Dex.getEffectiveness
// Status moves and charge moves are lowest priority
if (dexMove.category === 'Status' || dexMove.flags?.charge) {
statusMoves.push(index)
continue
}
// Check effectiveness against all opponent types using Dex.getEffectiveness
let totalEffectiveness = 0
for (const rawOppType of opponentTypes) {
// Dex.getEffectiveness expects capitalized type names
const oppType = rawOppType.charAt(0).toUpperCase() + rawOppType.slice(1)
totalEffectiveness += Dex.getEffectiveness(moveType, oppType)
}
if (totalEffectiveness > 0) {
superEffective.push(index)
} else if (totalEffectiveness < 0) {
resisted.push(index)
} else {
neutral.push(index)
}
}
// Priority: super-effective (70%) > neutral > super-effective (30%) > resisted > status
const rand = Math.random()
if (superEffective.length > 0 && rand < 0.7) {
return superEffective[Math.floor(Math.random() * superEffective.length)]!
}
if (neutral.length > 0) {
return neutral[Math.floor(Math.random() * neutral.length)]!
}
if (superEffective.length > 0) {
return superEffective[Math.floor(Math.random() * superEffective.length)]!
}
if (resisted.length > 0) {
return resisted[Math.floor(Math.random() * resisted.length)]!
}
// Only status moves available
return statusMoves[Math.floor(Math.random() * statusMoves.length)]!
}

View File

@@ -0,0 +1,166 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId } from '../types'
import { getCaptureRate } from '../dex/pokedex-data'
/**
* Gen 9 capture rate calculation.
* Returns { captured: boolean, shakes: 0-3 }
*
* Formula:
* a = (3 * maxHP - 2 * currentHP) * catchRate * ballModifier / (3 * maxHP)
* b = 65536 / (255 / a) ^ (1/4) (shake probability)
* For each of 4 shakes: if random(0,65535) < b → pass, else → break out
*/
/** Pokeball catch rate modifiers */
const BALL_MODIFIERS: Record<string, number> = {
pokeball: 1,
greatball: 1.5,
ultraball: 2,
masterball: 255, // always catches
netball: 3.5, // bug/water bonus (applied below)
diveball: 3.5, // underwater/surfing
nestball: 1, // scales with level (applied below)
repeatball: 3.5, // if already caught
timerball: 1, // scales with turns (applied below)
duskball: 3.5, // night/cave
quickball: 5, // first turn
luxuryball: 1,
premierball: 1,
cherishball: 1,
healball: 1,
friendball: 1,
levelball: 1,
lureball: 1,
moonball: 1,
loveball: 1,
heavyball: 1,
fastball: 1,
sportball: 1,
parkball: 255,
beastball: 5, // Ultra Beasts
}
/** Status condition catch rate multiplier */
const STATUS_MODIFIERS: Record<string, number> = {
none: 1,
poison: 1.5,
bad_poison: 1.5,
burn: 1.5,
paralysis: 1.5,
freeze: 2,
sleep: 2.5,
}
export interface CaptureResult {
captured: boolean
shakes: number // 0-3 (3 means captured)
critical: boolean // critical capture (Gen 5+)
}
/**
* Calculate capture attempt.
* @param speciesId Opponent species
* @param currentHp Opponent current HP
* @param maxHp Opponent max HP
* @param ballId Pokeball item ID
* @param status Opponent status condition
* @param turn Current battle turn number
* @param isFirstTurn Whether it's the first turn of battle
* @param isNight Whether it's nighttime (for Dusk Ball)
* @param alreadyCaught Whether this species has been caught before (for Repeat Ball)
* @param opponentLevel Opponent's level (for Nest Ball)
*/
export function attemptCapture(
speciesId: SpeciesId,
currentHp: number,
maxHp: number,
ballId: string,
status: string = 'none',
turn: number = 1,
isFirstTurn: boolean = false,
isNight: boolean = false,
alreadyCaught: boolean = false,
opponentLevel: number = 50,
): CaptureResult {
const catchRate = getCaptureRate(speciesId)
// Master Ball always catches
if (ballId === 'masterball' || catchRate === 255) {
return { captured: true, shakes: 3, critical: false }
}
// Calculate ball modifier with conditional bonuses
let ballModifier = BALL_MODIFIERS[ballId.toLowerCase()] ?? 1
// Quick Ball: 5x on first turn, 1x otherwise
if (ballId === 'quickball') {
ballModifier = isFirstTurn ? 5 : 1
}
// Timer Ball: up to 4x after 10 turns
if (ballId === 'timerball') {
ballModifier = Math.min(4, 1 + (turn - 1) * 3 / 10)
}
// Nest Ball: better for lower level wild Pokémon
if (ballId === 'nestball') {
ballModifier = Math.max(1, (40 - opponentLevel) / 10)
}
// Dusk Ball: 3.5x at night or in caves
if (ballId === 'duskball') {
ballModifier = isNight ? 3.5 : 1
}
// Repeat Ball: 3.5x if already caught
if (ballId === 'repeatball') {
ballModifier = alreadyCaught ? 3.5 : 1
}
// Net Ball: 3.5x for Bug or Water types
if (ballId === 'netball') {
const species = Dex.species.get(speciesId)
if (species?.types?.some((t: string) => t.toLowerCase() === 'bug' || t.toLowerCase() === 'water')) {
ballModifier = 3.5
}
}
// Status modifier
const statusMod = STATUS_MODIFIERS[status] ?? 1
// Catch rate formula (Gen 9)
const hpFactor = (3 * maxHp - 2 * currentHp) / (3 * maxHp)
const catchValue = hpFactor * catchRate * ballModifier * statusMod
const a = Math.min(255, Math.floor(catchValue))
// Shake probability
const b = Math.floor(65536 / Math.pow(255 / Math.max(1, a), 0.25))
// Perform 3 shake checks (4th check is automatic if all 3 pass)
let shakes = 0
let captured = true
for (let i = 0; i < 3; i++) {
const roll = Math.floor(Math.random() * 65536)
if (roll < b) {
shakes++
} else {
captured = false
break
}
}
// Critical capture check (Gen 5+, rare)
const dexCount = 0 // Could track Pokedex completion rate
const criticalChance = Math.min(255, Math.floor(catchValue * dexCount / 256))
const critical = criticalChance > 0 && Math.floor(Math.random() * 256) < criticalChance
if (critical) {
// Critical capture only needs 1 shake
const roll = Math.floor(Math.random() * 65536)
captured = roll < b
return { captured, shakes: captured ? 1 : 0, critical: true }
}
return { captured, shakes, critical: false }
}

View File

@@ -0,0 +1,838 @@
import { BattleStreams, Teams, Dex, toID } from '@pkmn/sim'
import { Protocol } from '@pkmn/protocol'
import type { Creature, SpeciesId } from '../types'
import { TO_DEX_STAT, FROM_DEX_STAT } from '../dex/pkmn'
import { STAT_NAMES } from '../types'
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition, WeatherKind, FieldCondition } from './types'
import { chooseAIMove } from './ai'
import { attemptCapture } from './capture'
// ─── Utility: get actual stat value accounting for stage ───
function getStatWithStage(pokemon: BattlePokemon, statKey: string): number {
const raw = (pokemon as any)[statKey] ?? 10
const stage = pokemon.statStages?.[statKey] ?? 0
if (stage === 0) return raw
const numerator = stage > 0 ? 2 + stage : 2
const denominator = stage > 0 ? 2 : 2 - stage
return Math.floor(raw * numerator / denominator)
}
// ─── Item Effect Application ───
/** Healing item definitions */
const HEALING_ITEMS: Record<string, { amount: number; percent?: boolean; cureStatus?: boolean }> = {
'potion': { amount: 20 },
'superpotion': { amount: 60 },
'hyperpotion': { amount: 120 },
'maxpotion': { amount: 9999 }, // full heal
'fullrestore': { amount: 9999, cureStatus: true },
'fullheal': { amount: 0, cureStatus: true },
'berryjuice': { amount: 20 },
'oranberry': { amount: 10 },
'sitrusberry': { amount: 30, percent: true },
'energyroot': { amount: 120 },
'sweetheart': { amount: 20 },
'freshwater': { amount: 30 },
'sodapop': { amount: 50 },
'lemonade': { amount: 70 },
'moomoomilk': { amount: 100 },
'revive': { amount: 50, percent: true }, // revives fainted with 50% HP
'maxrevive': { amount: 100, percent: true }, // revives fainted with full HP
}
function applyItemEffect(battle: any, itemId: string, target: any): void {
const item = HEALING_ITEMS[itemId.toLowerCase().replace(/[-\s]/g, '')]
if (!item) return
// HP healing
if (item.amount > 0 && target.hp < target.maxhp) {
if (item.percent) {
target.hp = Math.min(target.maxhp, target.hp + Math.floor(target.maxhp * item.amount / 100))
} else {
target.hp = Math.min(target.maxhp, target.hp + item.amount)
}
}
// Cure status conditions
if (item.cureStatus && target.status) {
target.status = ''
target.statusState = { toxicTurns: 0 }
}
}
// ─── Types ───
export type BattleInit = {
streams: {
omniscient: { write(data: string): void; read(): Promise<string | null | undefined> }
spectator: { read(): Promise<string | null | undefined> }
p1: { write(data: string): void; read(): Promise<string | null | undefined> }
p2: { write(data: string): void; read(): Promise<string | null | undefined> }
}
/** Underlying stream — access .battle for Battle object */
stream: BattleStreams.BattleStream
state: BattleState
}
// ─── Adapter: Creature → Showdown Set ───
function creatureToSetString(creature: Creature): string {
const species = Dex.species.get(creature.speciesId)
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1)
const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : ''
let moves = creature.moves
.filter(m => m.id)
.map(m => Dex.moves.get(m.id)?.name ?? m.id)
// Fallback: if no valid moves, use type-based defaults
if (moves.length === 0) {
moves = getSpeciesMoves(creature.speciesId, creature.level)
}
const DEX_DISPLAY: Record<string, string> = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' }
const formatStatLine = (vals: Record<string, number>) =>
STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ')
const ivs = formatStatLine(creature.iv)
const evs = formatStatLine(creature.ev)
const lines = [
species.name,
`Level: ${creature.level}`,
`Ability: ${abilityName}`,
`Nature: ${natureName}`,
`IVs: ${ivs}`,
`EVs: ${evs}`,
]
if (creature.heldItem) lines.push(`Item: ${Dex.items.get(creature.heldItem)?.name ?? creature.heldItem}`)
for (const move of moves) lines.push(`- ${move}`)
return lines.join('\n')
}
// Species-specific held items (speciesId → item name)
const SPECIES_ITEMS: Partial<Record<string, string>> = {
pikachu: 'Light Ball',
farfetchd: 'Stick',
cubone: 'Thick Club',
marowak: 'Thick Club',
ditto: 'Quick Powder',
chansey: 'Lucky Punch',
snorlax: 'Leftovers',
}
// Type-based common wild held items (type → item, 5% chance)
const TYPE_ITEMS: Partial<Record<string, string>> = {
Fire: 'Charcoal',
Water: 'Mystic Water',
Electric: 'Magnet',
Grass: 'Miracle Seed',
Ice: 'Never-Melt Ice',
Fighting: 'Black Belt',
Poison: 'Poison Barb',
Ground: 'Soft Sand',
Flying: 'Sharp Beak',
Psychic: 'TwistedSpoon',
Bug: 'Silver Powder',
Rock: 'Hard Stone',
Ghost: 'Spell Tag',
Dragon: 'Dragon Fang',
Dark: 'Black Glasses',
Steel: 'Metal Coat',
Fairy: 'Fairy Feather',
}
/** Roll a random held item for a wild Pokémon encounter */
function rollWildHeldItem(speciesId: SpeciesId): string | null {
// Species-specific items: 5% chance
const speciesItem = SPECIES_ITEMS[speciesId]
if (speciesItem && Math.random() < 0.05) return speciesItem
// Common berry: 5% chance
if (Math.random() < 0.05) {
const berries = ['Oran Berry', 'Sitrus Berry', 'Pecha Berry', 'Rawst Berry', 'Cheri Berry']
return berries[Math.floor(Math.random() * berries.length)]
}
// Type-based item: 3% chance
if (Math.random() < 0.03) {
const species = Dex.species.get(speciesId)
if (species?.types?.[0]) {
return TYPE_ITEMS[species.types[0]] ?? null
}
}
return null
}
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
const species = Dex.species.get(speciesId)
if (!species) throw new Error(`Species ${speciesId} not found`)
const ability = species.abilities['0'] ?? ''
const moves = getSpeciesMoves(speciesId, level)
const lines = [species.name, `Level: ${level}`, `Ability: ${ability}`]
// Wild Pokémon have a small chance to hold an item
const wildItem = rollWildHeldItem(speciesId)
if (wildItem) lines.push(`Item: ${wildItem}`)
for (const move of moves) lines.push(`- ${move}`)
return lines.join('\n')
}
function getSpeciesMoves(speciesId: string, level: number): string[] {
// Try learnset-based moves first (real level-up moves from Dex.data)
const learnset = Dex.data.Learnsets[speciesId]?.learnset
if (learnset) {
const levelUpMoves: { id: string; level: number; gen: number }[] = []
for (const [moveId, sources] of Object.entries(learnset)) {
for (const src of sources as string[]) {
const match = src.match(/^(\d+)L(\d+)$/)
if (match) {
const gen = parseInt(match[1]!)
const moveLevel = parseInt(match[2]!)
if (moveLevel <= level) {
// Keep highest-gen entry for each move
const existing = levelUpMoves.find(m => m.id === moveId)
if (!existing || gen > existing.gen) {
if (existing) {
existing.gen = gen
existing.level = moveLevel
} else {
levelUpMoves.push({ id: moveId, level: moveLevel, gen })
}
}
}
}
}
}
// Sort by level, take last 4 (most recently learned)
levelUpMoves.sort((a, b) => a.level - b.level)
const selected = levelUpMoves.slice(-4)
if (selected.length > 0) {
return selected.map(m => Dex.moves.get(m.id)?.name ?? m.id)
}
}
// Fallback: type-based defaults
const species = Dex.species.get(speciesId)
const type = species?.types[0]?.toLowerCase() ?? 'normal'
const fallbackMoves: Record<string, string[]> = {
normal: ['Tackle', 'Scratch'],
fire: ['Ember', 'FireSpin'],
water: ['WaterGun', 'Bubble'],
grass: ['VineWhip', 'RazorLeaf'],
electric: ['ThunderShock', 'Spark'],
poison: ['PoisonSting', 'Smog'],
ice: ['IceShard', 'PowderSnow'],
fighting: ['KarateChop', 'LowKick'],
ground: ['MudSlap', 'SandAttack'],
flying: ['Gust', 'WingAttack'],
psychic: ['Confusion', 'Psybeam'],
bug: ['BugBite', 'StringShot'],
rock: ['RockThrow', 'SandAttack'],
ghost: ['Lick', 'ShadowSneak'],
dragon: ['DragonRage', 'Twister'],
dark: ['Bite', 'Pursuit'],
steel: ['MetalClaw', 'IronTail'],
fairy: ['FairyWind', 'DisarmingVoice'],
}
return fallbackMoves[type] ?? ['Tackle', 'Scratch']
}
// ─── State Projection (from Battle object) ───
function projectPokemon(pkm: any): BattlePokemon {
if (!pkm) throw new Error('No active pokemon')
const species = pkm.species
const hp = pkm.hp ?? 0
const maxHp = pkm.maxhp ?? 1
// Extract volatile statuses from the Pokémon's volatileStatuses
const volatileStatuses: string[] = []
if (pkm.volatiles) {
for (const key of Object.keys(pkm.volatiles)) {
volatileStatuses.push(key.toLowerCase())
}
}
if (pkm.statusState?.confusion) volatileStatuses.push('confusion')
if (pkm.statusState?.infatuation) volatileStatuses.push('infatuation')
return {
id: pkm.name,
speciesId: toID(species.name) as SpeciesId,
name: species.name,
level: pkm.level,
hp,
maxHp,
types: species.types?.map((t: string) => t.toLowerCase()) ?? [],
moves: (pkm.moveSlots ?? pkm.baseMoveset ?? []).filter(Boolean).map((m: any) => {
const moveName = typeof m === 'string' ? m : (m.name ?? m.move?.name ?? Dex.moves.get(m.id ?? m.move)?.name ?? String(m.id ?? '???'))
return {
id: toID(moveName),
name: moveName,
type: m.type ?? Dex.moves.get(m.id ?? toID(moveName))?.type?.toLowerCase() ?? 'normal',
pp: m.pp ?? 0,
maxPp: m.maxpp ?? m.maxPp ?? m.pp ?? 0,
disabled: m.disabled ?? false,
}
}),
ability: pkm.ability ?? '',
heldItem: pkm.item ?? null,
status: mapStatus(pkm.status),
volatileStatus: volatileStatuses,
statStages: projectBoosts(pkm.boosts),
}
}
function mapStatus(status: string): StatusCondition {
if (!status) return 'none'
const s = status.toLowerCase()
if (s === 'psn') return 'poison'
if (s === 'tox') return 'bad_poison'
if (s === 'brn') return 'burn'
if (s === 'par') return 'paralysis'
if (s === 'frz') return 'freeze'
if (s === 'slp') return 'sleep'
return 'none'
}
function projectBoosts(boosts: Record<string, number> | undefined): Record<string, number> {
if (!boosts) return {}
const result: Record<string, number> = {}
for (const [k, v] of Object.entries(boosts)) {
const mapped = FROM_DEX_STAT[k]
if (mapped) result[mapped] = v
else result[k] = v
}
return result
}
function projectState(battle: any, bagItems?: { id: string; count: number }[], prevConditions?: { player: FieldCondition[]; opponent: FieldCondition[] }): BattleState {
const p1 = battle.p1
const p2 = battle.p2
// Extract weather directly from battle field (auto-updates each turn)
const weatherRaw = battle.field?.weather ?? ''
const weather = mapWeather(weatherRaw)
// Extract terrain from battle field
const terrainRaw = battle.field?.terrain ?? ''
return {
playerPokemon: projectPokemon(p1.active[0]),
opponentPokemon: projectPokemon(p2.active[0]),
playerParty: p1.pokemon.map((p: any) => projectPokemon(p)),
opponentParty: p2.pokemon.map((p: any) => projectPokemon(p)),
turn: battle.turn ?? 1,
events: [],
finished: battle.ended,
usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [],
weather,
playerConditions: prevConditions?.player ?? projectSideConditions(p1),
opponentConditions: prevConditions?.opponent ?? projectSideConditions(p2),
}
}
function mapWeather(raw: string): WeatherKind | undefined {
if (!raw) return undefined
const w = raw.toLowerCase()
if (w.includes('sun') || w.includes('desolateland')) return 'sun'
if (w.includes('rain') || w.includes('primordialsea')) return 'rain'
if (w.includes('sandstorm')) return 'sandstorm'
if (w.includes('hail')) return 'hail'
if (w.includes('snow')) return 'snow'
if (w.includes('deltastream')) return 'deltastream'
return undefined
}
/** Extract field conditions from a side object */
function projectSideConditions(side: any): FieldCondition[] {
const conditions: FieldCondition[] = []
if (!side) return conditions
const sr = side.sideConditions?.stealthrock
if (sr) conditions.push({ id: 'Stealth Rock', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: 1 })
const spikes = side.sideConditions?.spikes
if (spikes) conditions.push({ id: 'Spikes', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: spikes.levels ?? 1 })
const tspikes = side.sideConditions?.toxicspikes
if (tspikes) conditions.push({ id: 'Toxic Spikes', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: tspikes.levels ?? 1 })
const webs = side.sideConditions?.stickyweb
if (webs) conditions.push({ id: 'Sticky Web', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: 1 })
return conditions
}
// ─── Protocol Event Parsing (from spectator chunks) ───
function parseChunkToEvents(chunk: string, prevHp?: { player: { hp: number; maxHp: number }; opponent: { hp: number; maxHp: number } }): BattleEvent[] {
const events: BattleEvent[] = []
// Track HP through the chunk to compute damage/heal amounts
const hp = prevHp ? { player: { ...prevHp.player }, opponent: { ...prevHp.opponent } } : { player: { hp: 0, maxHp: 1 }, opponent: { hp: 0, maxHp: 1 } }
for (const line of chunk.split('\n')) {
if (!line.startsWith('|')) continue
// Skip non-battle lines (but NOT |upkeep| anymore!)
if (line.startsWith('|t:|') || line === '|' || line.startsWith('|gametype|') || line.startsWith('|player|') ||
line.startsWith('|gen|') || line.startsWith('|tier|') || line.startsWith('|clearpoke|') ||
line.startsWith('|poke|') || line.startsWith('|teampreview|') || line.startsWith('|teamsize|') ||
line.startsWith('|start|') || line.startsWith('|done|')) continue
const parts = line.split('|')
const cmd = parts[1]
if (!cmd) continue
const side = parts[2]?.startsWith('p1a') ? 'player' as const : 'opponent' as const
switch (cmd) {
case 'move':
events.push({ type: 'move', side, move: parts[3] ?? '', user: parts[2] ?? '' })
break
case '-damage': {
const newHp = parseHpValue(parts[3])
const prev = hp[side].hp
const maxHp = hp[side].maxHp || 1
if (newHp !== null) {
const amount = Math.max(0, prev - newHp)
const percentage = maxHp > 0 ? Math.round((amount / maxHp) * 100) : 0
hp[side].hp = newHp
hp[side].maxHp = Math.max(hp[side].maxHp, parseMaxHp(parts[3]) ?? maxHp)
events.push({ type: 'damage', side, amount, percentage })
} else {
events.push({ type: 'damage', side, amount: 0, percentage: 0 })
}
break
}
case '-heal': {
const newHp = parseHpValue(parts[3])
const prev = hp[side].hp
const maxHp = hp[side].maxHp || 1
if (newHp !== null) {
const amount = Math.max(0, newHp - prev)
const percentage = maxHp > 0 ? Math.round((amount / maxHp) * 100) : 0
hp[side].hp = newHp
hp[side].maxHp = Math.max(hp[side].maxHp, parseMaxHp(parts[3]) ?? maxHp)
events.push({ type: 'heal', side, amount, percentage })
} else {
events.push({ type: 'heal', side, amount: 0, percentage: 0 })
}
break
}
case 'faint':
events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
break
case 'switch': {
const name = parts[3]?.split(',')[0] ?? ''
// Parse HP from switch: "Squirtle, L5, 100/100"
const hpStr = parts[3] ?? ''
const hpMatch = hpStr.match(/(\d+)\/(\d+)/)
if (hpMatch) {
hp[side].hp = parseInt(hpMatch[1], 10)
hp[side].maxHp = parseInt(hpMatch[2], 10)
}
events.push({ type: 'switch', side, speciesId: toID(name), name })
break
}
case '-supereffective':
events.push({ type: 'effectiveness', multiplier: 2 })
break
case '-resisted':
events.push({ type: 'effectiveness', multiplier: 0.5 })
break
case '-crit':
events.push({ type: 'crit' })
break
case '-miss':
events.push({ type: 'miss', side })
break
case '-status':
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
break
case '-curestatus':
// Pokémon cured of status — represent as status 'none'
events.push({ type: 'status', side, status: 'none' })
break
case '-boost':
case '-unboost': {
const stages = cmd === '-boost' ? Number(parts[4]) : -Number(parts[4])
events.push({ type: 'statChange', side, stat: parts[3] ?? '', stages })
break
}
case '-ability':
events.push({ type: 'ability', side, ability: parts[3] ?? '' })
break
case '-item':
events.push({ type: 'item', side, item: parts[3] ?? '' })
break
case 'fail':
events.push({ type: 'fail', side, reason: parts[3] ?? '' })
break
case '-fail':
events.push({ type: 'fail', side, reason: parts[3] ?? '' })
break
case '-weather': {
const weatherRaw = parts[2] ?? ''
if (weatherRaw === 'none' || weatherRaw === '') {
events.push({ type: 'weather', weather: 'none' })
} else {
const weather = mapWeather(weatherRaw)
events.push({ type: 'weather', weather: weather ?? 'none', source: parts[3] ?? undefined })
}
break
}
case '-fieldstart':
case '-fieldend': {
const fieldId = parts[2] ?? ''
const action = cmd === '-fieldstart' ? 'add' as const : 'remove' as const
// Terrains etc. — map to fieldCondition
events.push({ type: 'fieldCondition', side: 'player', id: fieldId, level: 1, action })
break
}
case '-sidestart': {
const conditionId = parts[3] ?? ''
const condSide = parts[2]?.startsWith('p1') ? 'player' as const : 'opponent' as const
const level = conditionId.match(/\d/) ? parseInt(conditionId.match(/\d/)![0], 10) : 1
const cleanId = conditionId.replace(/\d+$/, '').trim()
events.push({ type: 'fieldCondition', side: condSide, id: cleanId, level, action: 'add' })
break
}
case '-sideend': {
const conditionId = parts[3] ?? ''
const condSide = parts[2]?.startsWith('p1') ? 'player' as const : 'opponent' as const
events.push({ type: 'fieldCondition', side: condSide, id: conditionId, level: 0, action: 'remove' })
break
}
case '-activate': {
const effect = parts[3] ?? parts[2] ?? ''
events.push({ type: 'activate', side, effect })
break
}
case '-immune':
events.push({ type: 'immune', side })
break
case 'upkeep':
events.push({ type: 'upkeep' })
break
case 'turn':
events.push({ type: 'turn', number: Number(parts[2]) })
break
}
}
return events
}
/** Parse current HP from protocol HP string like "80/100" or "80/100brn" */
function parseHpValue(hpStr?: string): number | null {
if (!hpStr) return null
const match = hpStr.match(/^(\d+)/)
return match ? parseInt(match[1], 10) : null
}
/** Parse max HP from protocol HP string like "80/100" or "80/100brn" */
function parseMaxHp(hpStr?: string): number | null {
if (!hpStr) return null
const match = hpStr.match(/\/(\d+)/)
return match ? parseInt(match[1], 10) : null
}
// ─── Engine API ───
export type OpponentEntry = { speciesId: SpeciesId; level: number }
export async function createBattle(
partyCreatures: Creature[],
opponentSpeciesId: SpeciesId | OpponentEntry[],
opponentLevel?: number,
_bagItems?: { id: string; count: number }[],
): Promise<BattleInit> {
const stream = new BattleStreams.BattleStream()
const streams = BattleStreams.getPlayerStreams(stream)
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
// Support both single species (wild) and multi-species (trainer) opponents
let p2Sets: string[]
if (Array.isArray(opponentSpeciesId)) {
p2Sets = opponentSpeciesId.map(e => wildPokemonToSetString(e.speciesId, e.level))
} else {
const level = opponentLevel ?? 5
p2Sets = [wildPokemonToSetString(opponentSpeciesId, level)]
}
const p1Team = Teams.import(p1Sets.join('\n\n'))
const p2Team = Teams.import(p2Sets.join('\n\n'))
const spec = { formatid: 'gen9customgame' }
const p1spec = { name: 'Player', team: Teams.pack(p1Team) }
const p2spec = { name: 'Opponent', team: Teams.pack(p2Team) }
// Initialize battle
streams.omniscient.write(
`>start ${JSON.stringify(spec)}\n` +
`>player p1 ${JSON.stringify(p1spec)}\n` +
`>player p2 ${JSON.stringify(p2spec)}`
)
// Drain team preview from omniscient and spectator streams
await streams.omniscient.read()
await streams.spectator.read()
// Accept team preview — lead with first Pokémon
streams.omniscient.write(`>p1 team 1\n>p2 team 1`)
// Read battle start from spectator (clean, no |split|)
const startChunk = (await streams.spectator.read()) ?? ''
// Parse initial events (switches + turn)
const initialEvents = parseChunkToEvents(startChunk)
// Use Battle object for rich state projection
const battle = stream.battle!
const state = projectState(battle, _bagItems, { player: [], opponent: [] })
state.events = initialEvents
return { streams, stream, state }
}
export async function executeTurn(
battleInit: BattleInit,
action: PlayerAction,
): Promise<BattleState> {
const { streams, stream } = battleInit
const prevState = battleInit.state
const battle = stream.battle!
// Build p1 choice
let p1Choice: string
let isEscape = false
let state_captureResult: { captured: boolean; shakes: number; speciesId: SpeciesId } | undefined
switch (action.type) {
case 'move':
p1Choice = `move ${action.moveIndex + 1}`
break
case 'switch': {
// Use partyIndex directly (1-indexed for showdown protocol)
const idx = action.partyIndex
const p1Pokemon: any[] = battle.p1.pokemon
p1Choice = idx >= 0 && idx < p1Pokemon.length ? `switch ${idx + 1}` : 'move 1'
break
}
case 'item': {
// Pokeball items trigger capture attempt
if (action.itemId && action.itemId.toLowerCase().includes('ball')) {
const opp = prevState.opponentPokemon
const captureResult = attemptCapture(
opp.speciesId, opp.hp, opp.maxHp, action.itemId, opp.status,
prevState.turn, prevState.turn === 1,
)
if (captureResult.captured) {
// Capture successful — forfeit and end battle
streams.omniscient.write('>p1 forfeit')
await streams.spectator.read()
const state = projectState(battle, prevState.usableItems, {
player: prevState.playerConditions,
opponent: prevState.opponentConditions,
})
state.finished = true
state.captureResult = { captured: true, shakes: captureResult.shakes, speciesId: opp.speciesId }
state.events = [...prevState.events, { type: 'activate' as const, side: 'player' as const, effect: 'capture' }]
battleInit.state = state
return state
}
// Capture failed — player wastes turn, opponent attacks
state_captureResult = { captured: false, shakes: captureResult.shakes, speciesId: opp.speciesId }
} else {
// Apply healing/status item effect
const p1Active = battle.p1.active[0]
if (p1Active && action.itemId) {
applyItemEffect(battle, action.itemId, p1Active)
}
}
p1Choice = 'move 1'
break
}
case 'run': {
// Escape probability: f = ((playerSpeed * 128) / opponentSpeed + 30 * attempts) % 256
const attempts = (prevState.escapeAttempts ?? 0) + 1
const playerSpeed = prevState.playerPokemon.statStages?.speed
? getStatWithStage(prevState.playerPokemon, 'spe')
: (battle.p1.active[0]?.stats?.spe ?? 10)
const opponentSpeed = prevState.opponentPokemon.statStages?.speed
? getStatWithStage(prevState.opponentPokemon, 'spe')
: (battle.p2.active[0]?.stats?.spe ?? 10)
const f = Math.floor((playerSpeed * 128 / Math.max(1, opponentSpeed) + 30 * attempts) % 256)
const roll = Math.floor(Math.random() * 256)
if (roll < f) {
// Escape successful — forfeit the battle
streams.omniscient.write('>p1 forfeit')
await streams.spectator.read()
const state = projectState(battle, prevState.usableItems, {
player: prevState.playerConditions,
opponent: prevState.opponentConditions,
})
state.finished = true
state.escaped = true
state.events = [...prevState.events, { type: 'activate' as const, side: 'player' as const, effect: 'escape' }]
battleInit.state = state
return state
}
// Escape failed — player wastes turn, opponent attacks
isEscape = true
p1Choice = 'move 1' // placeholder, player doesn't act
break
}
default:
p1Choice = 'move 1'
}
// AI choice — pass player's types so AI can consider effectiveness
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon, prevState.playerPokemon.types)
const p2Choice = `move ${aiMoveIndex + 1}`
// Submit choices via stream
streams.omniscient.write(`>p1 ${p1Choice}\n>p2 ${p2Choice}`)
// Read turn result from spectator (no |split| issues)
const turnChunk = (await streams.spectator.read()) ?? ''
const newEvents = parseChunkToEvents(turnChunk, {
player: { hp: prevState.playerPokemon.hp, maxHp: prevState.playerPokemon.maxHp },
opponent: { hp: prevState.opponentPokemon.hp, maxHp: prevState.opponentPokemon.maxHp },
})
// Project rich state from Battle object, preserving field conditions
const state = projectState(battle, prevState.usableItems, {
player: prevState.playerConditions,
opponent: prevState.opponentConditions,
})
state.events = [...prevState.events, ...newEvents]
// Track escape attempts
if (isEscape) {
state.escapeAttempts = (prevState.escapeAttempts ?? 0) + 1
} else {
state.escapeAttempts = prevState.escapeAttempts ?? 0
}
// Track capture result
if (state_captureResult) {
state.captureResult = state_captureResult
}
// Forced switch detection via Battle object
const p1Active = battle.p1.active[0]
const p1Fainted = p1Active?.fainted || p1Active?.hp === 0 || state.playerPokemon.hp === 0
const hasAliveBench = battle.p1.pokemon.some(
(p: any) => !p.fainted && p.hp > 0 && p !== p1Active,
)
if (p1Fainted && hasAliveBench && !battle.ended) {
state.needsSwitch = true
}
// Battle end detection
if (battle.ended) {
state.finished = true
const winner = battle.winner === 'Player' ? 'player' as const : 'opponent' as const
state.result = {
winner,
turns: state.turn,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [],
}
}
battleInit.state = state
return state
}
export async function executeSwitch(
battleInit: BattleInit,
partyIndex: number,
): Promise<BattleState> {
const { streams, stream } = battleInit
const prevState = battleInit.state
const battle = stream.battle!
// Validate slot index
const p1Pokemon: any[] = battle.p1.pokemon
if (partyIndex < 0 || partyIndex >= p1Pokemon.length) return prevState
// Build p2 command: switch if fainted, otherwise use AI move
let p2Cmd = ''
const p2Active = battle.p2.active[0]
if (p2Active?.fainted || p2Active?.hp === 0) {
const p2Pkm: any[] = battle.p2.pokemon
// Find best switch-in: prefer type advantage against player's active
const playerTypes = prevState.playerPokemon.types
const aliveIndices = p2Pkm
.map((p: any, i: number) => ({ p, i }))
.filter(({ p, i }) => i > 0 && !p.fainted && p.hp > 0)
let bestIdx = -1
if (aliveIndices.length > 0 && playerTypes.length > 0) {
// Score each candidate by type effectiveness against player
let bestScore = -Infinity
for (const { p, i } of aliveIndices) {
const types = p.species?.types ?? []
let score = 0
for (const atkType of types) {
for (const defType of playerTypes) {
score += Dex.getEffectiveness(atkType, defType)
}
}
if (score > bestScore) {
bestScore = score
bestIdx = i
}
}
}
// Fallback to first alive if no type advantage found
if (bestIdx < 0) bestIdx = aliveIndices[0]?.i ?? -1
p2Cmd = bestIdx >= 0 ? `\n>p2 switch ${bestIdx + 1}` : '\n>p2 pass'
} else {
// p2's active is alive — submit AI move choice
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon, prevState.playerPokemon.types)
p2Cmd = `\n>p2 move ${aiMoveIndex + 1}`
}
// Submit switch (1-indexed for showdown protocol)
streams.omniscient.write(`>p1 switch ${partyIndex + 1}${p2Cmd}`)
// Read result
const switchChunk = (await streams.spectator.read()) ?? ''
const newEvents = parseChunkToEvents(switchChunk, {
player: { hp: prevState.playerPokemon.hp, maxHp: prevState.playerPokemon.maxHp },
opponent: { hp: prevState.opponentPokemon.hp, maxHp: prevState.opponentPokemon.maxHp },
})
// Project state
const state = projectState(battle, prevState.usableItems, {
player: prevState.playerConditions,
opponent: prevState.opponentConditions,
})
state.events = [...prevState.events, ...newEvents]
// Forced switch detection via Battle object
const p1Active = battle.p1.active[0]
const p1Fainted = p1Active?.fainted || p1Active?.hp === 0 || state.playerPokemon.hp === 0
const hasAliveBench = battle.p1.pokemon.some(
(p: any) => !p.fainted && p.hp > 0 && p !== p1Active,
)
if (p1Fainted && hasAliveBench && !battle.ended) {
state.needsSwitch = true
}
if (battle.ended) {
state.finished = true
const winner = battle.winner === 'Player' ? 'player' as const : 'opponent' as const
state.result = {
winner,
turns: state.turn,
xpGained: 0,
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [],
}
}
battleInit.state = state
return state
}

View File

@@ -0,0 +1,5 @@
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './types'
export { createBattle, executeTurn, executeSwitch, type BattleInit, type OpponentEntry } from './engine'
export { settleBattle, applyMoveLearn, applyEvolution } from './settlement'
export { chooseAIMove } from './ai'
export { attemptCapture, type CaptureResult } from './capture'

View File

@@ -0,0 +1,189 @@
import type { StatName, SpeciesId } from '../types'
import { STAT_NAMES } from '../types'
import { TO_DEX_STAT } from '../dex/pkmn'
import type { BattleResult } from './types'
import type { BuddyData } from '../types'
import { levelFromXp } from '../dex/xpTable'
import { getSpeciesData } from '../dex/species'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
import { Dex } from '@pkmn/sim'
import { getBaseExperience, getEvYield as getPokedexEvYield } from '../dex/pokedex-data'
/**
* Settle battle results: XP, EV, level ups, move learning, evolution detection.
*/
export async function settleBattle(
data: BuddyData,
result: BattleResult,
opponentSpeciesId: SpeciesId,
opponentLevel: number,
): Promise<{
data: BuddyData
learnableMoves: { creatureId: string; moveId: string; moveName: string }[]
pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[]
}> {
if (result.winner !== 'player') {
return { data, learnableMoves: [], pendingEvolutions: [] }
}
// Calculate XP reward using real base_experience from PokeAPI
const baseXp = getBaseExperience(opponentSpeciesId)
const xpGained = Math.max(1, Math.floor(baseXp * opponentLevel / 7))
// Calculate EV reward using real EV yield from PokeAPI
const evGained: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
const evYield = getPokedexEvYield(opponentSpeciesId)
for (const stat of STAT_NAMES) {
evGained[stat] = evYield[TO_DEX_STAT[stat]] ?? 0
}
// Award XP/EV to participant creatures
const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = []
const pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] = []
const participantIds = new Set(result.participantIds.length > 0 ? result.participantIds : data.party.filter((id): id is string => id !== null))
const updatedCreatures: typeof data.creatures = []
for (const creature of data.creatures) {
if (!participantIds.has(creature.id)) {
updatedCreatures.push(creature)
continue
}
// Award EVs (capped)
const newEv = { ...creature.ev }
let totalEV = STAT_NAMES.reduce((sum, s) => sum + newEv[s], 0)
for (const stat of STAT_NAMES) {
if (totalEV >= MAX_EV_TOTAL) break
const gain = Math.min(evGained[stat], MAX_EV_PER_STAT - newEv[stat], MAX_EV_TOTAL - totalEV)
newEv[stat] += gain
totalEV += gain
}
// Award XP
const oldLevel = creature.level
const newTotalXp = creature.totalXp + xpGained
const species = getSpeciesData(creature.speciesId)
const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate))
// Detect new learnable moves on level up
if (newLevel > oldLevel) {
const learnset = await Dex.learnsets.get(creature.speciesId)
if (learnset?.learnset) {
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith('9L')) {
const moveLevel = parseInt(src.slice(2))
if (moveLevel > oldLevel && moveLevel <= newLevel) {
const dexMove = Dex.moves.get(moveId)
learnableMoves.push({
creatureId: creature.id,
moveId,
moveName: dexMove?.name ?? moveId,
})
}
break
}
}
}
}
}
// Detect evolution — check ALL evolution targets (handles branch evolutions)
if (newLevel > oldLevel) {
const species = Dex.species.get(creature.speciesId)
if (species?.evos?.length) {
for (const evoId of species.evos) {
const targetId = evoId.toLowerCase()
const target = Dex.species.get(targetId)
if (!target?.exists) continue
const trigger = species.evoType
// Level-up evolutions
if ((!trigger || trigger === 'levelUp') && target.evoLevel && newLevel >= target.evoLevel) {
pendingEvolutions.push({
creatureId: creature.id,
from: creature.speciesId,
to: targetId as SpeciesId,
})
break // Only evolve into one target per level-up
}
// Friendship evolutions (friendship >= 220)
if (trigger === 'levelFriendship' && creature.friendship >= 220) {
pendingEvolutions.push({
creatureId: creature.id,
from: creature.speciesId,
to: targetId as SpeciesId,
})
break
}
}
}
}
updatedCreatures.push({
...creature,
level: newLevel,
totalXp: newTotalXp,
ev: newEv,
})
}
// Update data
const updatedData: BuddyData = {
...data,
creatures: updatedCreatures,
stats: {
...data.stats,
battlesWon: data.stats.battlesWon + (result.winner === 'player' ? 1 : 0),
battlesLost: data.stats.battlesLost + (result.winner !== 'player' ? 1 : 0),
},
}
return { data: updatedData, learnableMoves, pendingEvolutions }
}
/**
* Apply move learning - replace a move at the given index.
*/
export function applyMoveLearn(
data: BuddyData,
creatureId: string,
moveId: string,
replaceIndex: number,
): BuddyData {
return {
...data,
creatures: data.creatures.map(c => {
if (c.id !== creatureId) return c
const dexMove = Dex.moves.get(moveId)
const newMoves = [...c.moves] as typeof c.moves
newMoves[replaceIndex] = {
id: moveId,
pp: dexMove?.pp ?? 10,
maxPp: dexMove?.pp ?? 10,
}
return { ...c, moves: newMoves as typeof c.moves }
}),
}
}
/**
* Apply evolution to a creature.
*/
export function applyEvolution(
data: BuddyData,
creatureId: string,
newSpeciesId: SpeciesId,
): BuddyData {
return {
...data,
creatures: data.creatures.map(c =>
c.id === creatureId
? { ...c, speciesId: newSpeciesId, friendship: Math.min(255, c.friendship + 10) }
: c,
),
stats: {
...data.stats,
totalEvolutions: data.stats.totalEvolutions + 1,
},
}
}

View File

@@ -0,0 +1,93 @@
import type { StatName, SpeciesId } from '../types'
export type StatusCondition = 'poison' | 'bad_poison' | 'burn' | 'paralysis' | 'freeze' | 'sleep' | 'confusion' | 'infatuation' | 'flinch' | 'none'
export type VolatileStatus = 'confusion' | 'infatuation' | 'flinch' | 'leech_seed' | 'trapped' | 'nightmare' | 'curse' | 'taunt' | 'encore' | 'torment' | 'disable' | 'magnet_rise' | 'telekinesis' | 'heal_block' | 'embargo' | 'yawn' | 'perish_song'
export type BattlePokemon = {
id: string // creature ID
speciesId: SpeciesId
name: string
level: number
hp: number // current HP in battle
maxHp: number
types: string[]
moves: MoveOption[]
ability: string
heldItem: string | null
status: StatusCondition
volatileStatus: string[] // confusion, infatuation, leech_seed, etc.
statStages: Record<string, number> // -6 to +6
}
export type MoveOption = {
id: string
name: string
type: string
pp: number
maxPp: number
disabled: boolean
}
export type PlayerAction =
| { type: 'move'; moveIndex: number }
| { type: 'switch'; partyIndex: number }
| { type: 'item'; itemId: string }
| { type: 'run' }
export type WeatherKind = 'sun' | 'rain' | 'sandstorm' | 'hail' | 'snow' | 'desolateland' | 'primordialsea' | 'deltastream'
export type FieldCondition = {
/** e.g. 'Stealth Rock', 'Spikes', 'Toxic Spikes', 'Sticky Web' */
id: string
side: 'player' | 'opponent'
level: number // 1-3 for Spikes/Toxic Spikes, 1 for others
}
export type BattleEvent =
| { type: 'move'; side: 'player' | 'opponent'; move: string; user: string }
| { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number }
| { type: 'heal'; side: 'player' | 'opponent'; amount: number; percentage: number }
| { type: 'faint'; side: 'player' | 'opponent'; speciesId: string }
| { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string }
| { type: 'effectiveness'; multiplier: number }
| { type: 'crit' }
| { type: 'miss'; side: 'player' | 'opponent' }
| { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition }
| { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number }
| { type: 'ability'; side: 'player' | 'opponent'; ability: string }
| { type: 'item'; side: 'player' | 'opponent'; item: string }
| { type: 'fail'; side: 'player' | 'opponent'; reason: string }
| { type: 'weather'; weather: WeatherKind | 'none'; source?: string }
| { type: 'upkeep' }
| { type: 'fieldCondition'; side: 'player' | 'opponent'; id: string; level: number; action: 'add' | 'remove' }
| { type: 'activate'; side: 'player' | 'opponent'; effect: string }
| { type: 'immune'; side: 'player' | 'opponent' }
| { type: 'turn'; number: number }
export type BattleResult = {
winner: 'player' | 'opponent'
turns: number
xpGained: number
evGained: Record<StatName, number>
participantIds: string[]
}
export type BattleState = {
playerPokemon: BattlePokemon
opponentPokemon: BattlePokemon
playerParty: BattlePokemon[]
opponentParty: BattlePokemon[]
turn: number
events: BattleEvent[]
finished: boolean
result?: BattleResult
usableItems: { id: string; name: string; count: number }[]
needsSwitch?: boolean // player's active Pokémon fainted, must switch
weather?: WeatherKind // current weather
playerConditions: FieldCondition[] // hazards on player's side
opponentConditions: FieldCondition[] // hazards on opponent's side
escaped?: boolean // player successfully escaped
escapeAttempts?: number // number of failed escape attempts
captureResult?: { captured: boolean; shakes: number; speciesId: SpeciesId } // capture attempt result
}

View File

@@ -0,0 +1,160 @@
import { randomUUID } from 'node:crypto'
import type { Creature, SpeciesId, StatName, StatsResult } from '../types'
import { STAT_NAMES } from '../types'
import { getSpeciesData } from '../dex/species'
import { determineGender } from './gender'
import { levelFromXp } from '../dex/xpTable'
import { gen, TO_DEX_STAT, getSpecies } from '../dex/pkmn'
import { getDefaultMoveset, randomAbility } from '../dex/learnsets'
import { randomNature } from '../dex/nature'
/**
* Generate a new creature of the given species.
*/
export async function generateCreature(speciesId: SpeciesId, seed?: number): Promise<Creature> {
const species = getSpeciesData(speciesId)
const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff)
// Generate PID (32-bit personality value) from seed
const pid = generatePID(actualSeed)
// Generate IVs (0-31) extracted from PID (Gen 3+ style)
const iv = generateIVsFromPID(pid)
// Determine gender from PID's low 8 bits (Gen 3+ style)
const gender = determineGender(species, pid & 0xff)
// Determine shiny status from PID XOR (Gen 3+ style)
const isShiny = checkShiny(pid, actualSeed)
return {
id: randomUUID(),
speciesId,
gender,
level: 1,
xp: 0,
totalXp: 0,
nature: randomNature(),
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
iv,
moves: await getDefaultMoveset(speciesId, 1),
ability: randomAbility(speciesId),
heldItem: null,
friendship: species.baseHappiness,
isShiny,
hatchedAt: Date.now(),
pokeball: 'pokeball',
}
}
/**
* Calculate actual stats for a creature using @pkmn/data stats.calc().
* Handles base stats, IV, EV, level, and nature correction internally.
*/
export function calculateStats(creature: Creature): StatsResult {
const species = getSpecies(creature.speciesId)
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
// Get nature if creature has one (Phase 1 adds nature field)
const nature = 'nature' in creature && creature.nature
? gen.natures.get(creature.nature as string)
: undefined
const result = {} as StatsResult
for (const stat of STAT_NAMES) {
const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe'
result[stat] = gen.stats.calc(
dexKey,
species.baseStats[dexKey],
creature.iv[stat],
creature.ev[stat],
creature.level,
nature ?? undefined,
)
}
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 getSpeciesData(creature.speciesId).name
}
/**
* Recalculate level from total XP (e.g. after XP gain).
*/
export function recalculateLevel(creature: Creature): Creature {
const species = getSpeciesData(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.
* Reads from party[0] (new) with fallback to activeCreatureId (legacy).
*/
export function getActiveCreature(buddyData: { party?: (string | null)[]; activeCreatureId?: string | null; creatures: Creature[] }): Creature | null {
const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null
if (!activeId) return null
return buddyData.creatures.find((c) => c.id === activeId) ?? null
}
/**
* Generate a 32-bit Personality Value (PID) from a seed.
* PID is the core identity value used for shiny check, gender, IVs, etc.
*/
function generatePID(seed: number): number {
let s = seed
const next = () => { s = ((s * 1103515245 + 12345) & 0x7fffffff) >>> 0; return s }
return ((next() & 0xffff) | ((next() & 0xffff) << 16)) >>> 0
}
/**
* Generate IVs from PID using Gen 3+ style extraction.
* HP IV = bits 0-4 of (pid >> 16) | (pid & 0xffff) is NOT used here;
* instead we use the more common method:
* word1 = pid (lower 16 bits), word2 = pid >> 16 (upper 16 bits)
* hp = word1 & 0x1f, atk = (word1 >> 5) & 0x1f, def = (word1 >> 10) & 0x1f
* spe = word2 & 0x1f, spa = (word2 >> 5) & 0x1f, spd = (word2 >> 10) & 0x1f
*/
function generateIVsFromPID(pid: number): Record<StatName, number> {
const word1 = pid & 0xffff
const word2 = (pid >>> 16) & 0xffff
return {
hp: word1 & 0x1f,
attack: (word1 >>> 5) & 0x1f,
defense: (word1 >>> 10) & 0x1f,
speed: word2 & 0x1f,
spAtk: (word2 >>> 5) & 0x1f,
spDef: (word2 >>> 10) & 0x1f,
}
}
/**
* Check shiny status using PID XOR method (Gen 3+).
* Shiny if (pid_upper16 XOR pid_lower16 XOR trainerID XOR secretID) < threshold.
* Since we don't have trainer IDs, use the seed's high/low as proxy.
*/
function checkShiny(pid: number, seed: number): boolean {
const pidUpper = (pid >>> 16) & 0xffff
const pidLower = pid & 0xffff
const trainerId = seed & 0xffff
const secretId = (seed >>> 16) & 0xffff
const xorResult = pidUpper ^ pidLower ^ trainerId ^ secretId
// Standard threshold: 1 (1/65536 per encounter, ~1/8192 with both checks)
// Gen 8+: 16 (1/4096 base rate, 1/1024 with charm)
return xorResult < 16
}
/**
* Get total EV across all stats.
*/
export function getTotalEV(creature: Creature): number {
return STAT_NAMES.reduce((sum, stat) => sum + creature.ev[stat], 0)
}

View 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, EV_COOLDOWN_MS } from '../dex/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 < EV_COOLDOWN_MS) 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'
}

View File

@@ -0,0 +1,111 @@
import { randomUUID } from 'node:crypto'
import type { BuddyData, Creature, Egg, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getHatchCounter } from '../dex/pokedex-data'
import { generateCreature } from './creature'
import { addToParty, depositToBox } from './storage'
/** Days of consecutive coding needed to be eligible for an egg */
export const EGG_REQUIRED_DAYS = 3
/**
* Check if the player is eligible to receive an egg.
* Conditions: consecutiveDays >= EGG_REQUIRED_DAYS 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 < EGG_REQUIRED_DAYS) 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 real hatch_counter from PokeAPI (steps = cycles * 257)
const hatchCounter = getHatchCounter(speciesId)
const baseSteps = hatchCounter * 257
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.
* Tries to add to party first, then deposits to PC box.
*/
export async function hatchEgg(buddyData: BuddyData, egg: Egg): Promise<{ buddyData: BuddyData; creature: Creature }> {
const creature = await generateCreature(egg.speciesId)
creature.hatchedAt = Date.now()
// Add creature to list
let 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,
},
}
// Place in party or PC box
const partyResult = addToParty(updatedData, creature.id)
if (partyResult.added) {
updatedData = partyResult.data
} else {
const boxResult = depositToBox(updatedData, creature.id)
if (boxResult.deposited) updatedData = boxResult.data
}
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 }]
}

View File

@@ -0,0 +1,46 @@
import type { Creature, EvolutionResult, SpeciesId } from '../types'
import { getSpeciesData } from '../dex/species'
import { getNextEvolution } from '../dex/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' && nextEvo.minLevel != null && 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 = getSpeciesData(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
}

View File

@@ -0,0 +1,52 @@
import type { Creature } from '../types'
import { getSpeciesData } from '../dex/species'
import { levelFromXp, xpForLevel } from '../dex/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 = getSpeciesData(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 = getSpeciesData(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,
}
}

View File

@@ -0,0 +1,29 @@
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
*
* Gen 3+ style: PID low byte (0-255) compared directly against genderRate * 32.
* If value < genderRate * 32 → female, otherwise male.
*/
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'
// Direct comparison: genderRate maps 0-8 to threshold 0-255 in steps of 32
const threshold = speciesData.genderRate * 32
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 ''
}
}

View File

@@ -0,0 +1,131 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { SpeciesId, SpriteCache } from '../types'
import { getSpeciesData } from '../dex/species'
import { getSpritesDir } from './storage'
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/claude-code-best/pokemonsay-newgenerations/master/pokemons'
/**
* Build cow file name from dex number: NNN.cow
*/
function cowFileName(speciesId: SpeciesId): string {
const { dexNumber } = getSpeciesData(speciesId)
return `${String(dexNumber).padStart(3, '0')}.cow`
}
/**
* 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 fileName = cowFileName(speciesId)
const url = `${GITHUB_RAW_BASE}/${fileName}`
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 = getSpeciesData(speciesId)
return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}`
}

View File

@@ -0,0 +1,420 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
import type { BuddyData, Creature, SpeciesId, PCBox, Bag } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { generateCreature } from './creature'
import { getSpeciesData } from '../dex/species'
import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets'
import { randomNature } from '../dex/nature'
const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json')
const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
const DEFAULT_BOX_COUNT = 8
const BOX_SIZE = 30
/** Create empty boxes */
function makeDefaultBoxes(): PCBox[] {
return Array.from({ length: DEFAULT_BOX_COUNT }, (_, i) => ({
name: `Box ${i + 1}`,
slots: Array.from({ length: BOX_SIZE }, () => null),
}))
}
/**
* Load buddy data from disk. Returns default data if file doesn't exist.
* Auto-migrates from any older version.
*/
export async function loadBuddyData(): Promise<BuddyData> {
if (!existsSync(BUDDY_DATA_PATH)) {
return getDefaultBuddyData()
}
try {
const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8')
const data = JSON.parse(raw)
return migrateToV2(data)
} catch (e) {
console.error('[buddy] Failed to load buddy data:', e)
return getDefaultBuddyData()
}
}
/**
* Save buddy data to disk.
*/
export function saveBuddyData(data: BuddyData): void {
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 async function getDefaultBuddyData(): Promise<BuddyData> {
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
const randomStarter = starters[Math.floor(Math.random() * starters.length)]
const creature = await generateCreature(randomStarter)
return {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: makeDefaultBoxes(),
creatures: [creature],
eggs: [],
dex: [
{
speciesId: randomStarter,
discoveredAt: Date.now(),
caughtCount: 1,
bestLevel: 1,
},
],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 0,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 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.
*/
export async function migrateFromLegacy(
storedCompanion: { name?: string; personality?: string; seed?: string; hatchedAt?: number; species?: string },
): Promise<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',
}
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 = await generateCreature(speciesId)
creature.level = 5
creature.totalXp = 100
creature.friendship = 120
const speciesInfo = getSpeciesData(speciesId)
if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) {
creature.nickname = storedCompanion.name
}
return {
version: 2,
party: [creature.id, null, null, null, null, null],
boxes: makeDefaultBoxes(),
creatures: [creature],
eggs: [],
dex: [{ speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: 5 }],
bag: { items: [] },
stats: {
totalTurns: 0,
consecutiveDays: 1,
lastActiveDate: new Date().toISOString().split('T')[0],
totalEggsObtained: 0,
totalEvolutions: 0,
battlesWon: 0,
battlesLost: 0,
},
}
}
// ─── Migration ───
/** Migrate any version to v2 */
async function migrateToV2(data: Record<string, unknown>): Promise<BuddyData> {
const version = (data.version as number) ?? 1
if (version >= 2) return data as unknown as BuddyData
// v1 → v2
const v1 = data as Record<string, unknown>
const party = ensureParty(v1)
// Migrate creatures: add new fields
const creatures = await migrateCreatures(v1.creatures as Creature[] ?? [])
// Build boxes — put non-party creatures into Box 1
const partyIds = new Set(party.filter(Boolean))
const nonPartyCreatures = creatures.filter(c => !partyIds.has(c.id))
const boxes = makeDefaultBoxes()
const box1Slots = [...boxes[0]!.slots]
let boxIdx = 0
for (const c of nonPartyCreatures) {
if (boxIdx < BOX_SIZE) {
box1Slots[boxIdx] = c.id
boxIdx++
}
}
boxes[0] = { name: 'Box 1', slots: box1Slots }
return {
version: 2,
party,
boxes,
creatures,
eggs: (v1.eggs as BuddyData['eggs']) ?? [],
dex: (v1.dex as BuddyData['dex']) ?? [],
bag: { items: [] },
stats: {
totalTurns: ((v1.stats as Record<string, number>)?.totalTurns) ?? 0,
consecutiveDays: ((v1.stats as Record<string, number>)?.consecutiveDays) ?? 0,
lastActiveDate: ((v1.stats as Record<string, string>)?.lastActiveDate) ?? new Date().toISOString().split('T')[0],
totalEggsObtained: ((v1.stats as Record<string, number>)?.totalEggsObtained) ?? 0,
totalEvolutions: ((v1.stats as Record<string, number>)?.totalEvolutions) ?? 0,
battlesWon: 0,
battlesLost: 0,
},
}
}
/** Ensure party field is valid */
function ensureParty(data: Record<string, unknown>): (string | null)[] {
const existing = data.party as (string | null)[] | undefined
if (existing && existing.length === 6) return existing
const party: (string | null)[] = new Array(6).fill(null)
const activeId = data.activeCreatureId ?? existing?.[0]
if (activeId) party[0] = activeId as string
const creatures = data.creatures as Creature[] ?? []
let slot = 1
for (const c of creatures) {
if (c.id === activeId) continue
if (slot >= 6) break
party[slot] = c.id
slot++
}
return party
}
/** Migrate creatures from v1 format to v2 */
async function migrateCreatures(creatures: Creature[]): Promise<Creature[]> {
const result: Creature[] = []
for (const c of creatures) {
// Already v2 (has nature field)
if ('nature' in c && c.nature) {
result.push(c)
continue
}
result.push({
...c,
nature: randomNature(),
moves: await getDefaultMoveset(c.speciesId, c.level),
ability: getDefaultAbility(c.speciesId),
heldItem: null,
pokeball: 'pokeball',
})
}
return result
}
// ─── Daily / Turn stats ───
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) {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().split('T')[0]
consecutiveDays = lastDate === yesterdayStr ? consecutiveDays + 1 : 1
}
return {
...data,
stats: { ...data.stats, consecutiveDays, lastActiveDate: today },
}
}
export function incrementTurns(data: BuddyData): BuddyData {
return {
...data,
stats: { ...data.stats, totalTurns: data.stats.totalTurns + 1 },
}
}
// ─── Party operations ───
/** Compact party: move all non-null to front, pad with nulls to length 6 */
export function compactParty(party: (string | null)[]): (string | null)[] {
const filled = party.filter((id): id is string => id !== null)
return [...filled, ...Array(6).fill(null)].slice(0, 6)
}
export function addToParty(data: BuddyData, creatureId: string): { data: BuddyData; added: boolean } {
const party = [...data.party]
const emptyIdx = party.findIndex(p => p === null)
if (emptyIdx === -1) return { data, added: false }
party[emptyIdx] = creatureId
return { data: { ...data, party: compactParty(party) }, added: true }
}
export function removeFromParty(data: BuddyData, slotIndex: number): BuddyData {
if (slotIndex < 0 || slotIndex >= 6) return data
const party = [...data.party]
// Don't remove if it would leave party empty
const count = party.filter(Boolean).length
if (count <= 1) return data
party[slotIndex] = null
return { ...data, party: compactParty(party) }
}
export function swapPartySlots(data: BuddyData, indexA: number, indexB: number): BuddyData {
const party = [...data.party]
const a = party[indexA]
const b = party[indexB]
party[indexA] = b
party[indexB] = a
return { ...data, party: compactParty(party) }
}
export function setActivePartyMember(data: BuddyData, creatureId: string): BuddyData {
const party = [...data.party]
const existingIdx = party.findIndex(id => id === creatureId)
if (existingIdx === 0) return data
if (existingIdx > 0) {
party[0] = creatureId
party[existingIdx] = data.party[0]
} else {
party[0] = creatureId
}
return { ...data, party: compactParty(party) }
}
// ─── PC Box operations ───
export function depositToBox(data: BuddyData, creatureId: string): { data: BuddyData; deposited: boolean } {
for (let b = 0; b < data.boxes.length; b++) {
const slots = [...data.boxes[b]!.slots]
const emptyIdx = slots.findIndex(s => s === null)
if (emptyIdx !== -1) {
slots[emptyIdx] = creatureId
const boxes = [...data.boxes]
boxes[b] = { ...data.boxes[b]!, slots }
return { data: { ...data, boxes }, deposited: true }
}
}
return { data, deposited: false }
}
export function withdrawFromBox(data: BuddyData, creatureId: string): { data: BuddyData; withdrawn: boolean } {
for (let b = 0; b < data.boxes.length; b++) {
const slots = [...data.boxes[b]!.slots]
const idx = slots.findIndex(s => s === creatureId)
if (idx !== -1) {
slots[idx] = null
const boxes = [...data.boxes]
boxes[b] = { ...data.boxes[b]!, slots }
return { data: { ...data, boxes }, withdrawn: true }
}
}
return { data, withdrawn: false }
}
export function moveInBox(data: BuddyData, fromBox: number, fromSlot: number, toBox: number, toSlot: number): BuddyData {
const boxes = data.boxes.map(b => ({ ...b, slots: [...b.slots] }))
const creatureId = boxes[fromBox]?.slots[fromSlot]
if (!creatureId) return data
boxes[fromBox]!.slots[fromSlot] = null
boxes[toBox]!.slots[toSlot] = creatureId
return { ...data, boxes }
}
export function renameBox(data: BuddyData, boxIndex: number, name: string): BuddyData {
const boxes = [...data.boxes]
boxes[boxIndex] = { ...boxes[boxIndex]!, name }
return { ...data, boxes }
}
export function findCreatureLocation(data: BuddyData, creatureId: string): { area: 'party' | 'box'; slot: number; boxIndex?: number } | null {
const partyIdx = data.party.findIndex(id => id === creatureId)
if (partyIdx !== -1) return { area: 'party', slot: partyIdx }
for (let b = 0; b < data.boxes.length; b++) {
const slotIdx = data.boxes[b]!.slots.findIndex(id => id === creatureId)
if (slotIdx !== -1) return { area: 'box', slot: slotIdx, boxIndex: b }
}
return null
}
export function releaseCreature(data: BuddyData, creatureId: string): BuddyData {
// Remove from party
let updated = removeFromParty(data, data.party.findIndex(id => id === creatureId))
// Remove from boxes
const withdrawResult = withdrawFromBox(updated, creatureId)
if (withdrawResult.withdrawn) updated = withdrawResult.data
// Remove from creatures array
return {
...updated,
creatures: updated.creatures.filter(c => c.id !== creatureId),
}
}
export function getTotalCreatureCount(data: BuddyData): number {
return data.creatures.length
}
export function getAllCreatureIds(data: BuddyData): string[] {
return data.creatures.map(c => c.id)
}
// ─── Bag operations ───
export function addItemToBag(data: BuddyData, itemId: string, count = 1): BuddyData {
const items = data.bag.items.map(e => ({ ...e }))
const existing = items.find(e => e.id === itemId)
if (existing) {
existing.count += count
} else {
items.push({ id: itemId, count })
}
return { ...data, bag: { items } }
}
export function removeItemFromBag(data: BuddyData, itemId: string, count = 1): { data: BuddyData; removed: boolean } {
const items = data.bag.items.map(e => ({ ...e }))
const existing = items.find(e => e.id === itemId)
if (!existing || existing.count < count) return { data, removed: false }
existing.count -= count
if (existing.count <= 0) {
const idx = items.indexOf(existing)
items.splice(idx, 1)
}
return { data: { ...data, bag: { items } }, removed: true }
}
export function getItemCount(data: BuddyData, itemId: string): number {
return data.bag.items.find(e => e.id === itemId)?.count ?? 0
}

View File

@@ -0,0 +1,31 @@
import type { StatName } from '../types'
/**
* Default EV mapping: tool name → EV gains per use.
* Tools not in this mapping get random 1-2 EV points.
*/
export const DEFAULT_EV_MAPPING: Record<string, Record<StatName, number>> = {
Bash: { hp: 0, attack: 2, defense: 0, spAtk: 0, spDef: 0, speed: 1 },
Edit: { hp: 0, attack: 0, defense: 1, spAtk: 2, spDef: 0, speed: 0 },
Write: { hp: 0, attack: 0, defense: 0, spAtk: 3, spDef: 0, speed: 0 },
Read: { hp: 1, attack: 0, defense: 2, spAtk: 0, spDef: 0, speed: 0 },
Grep: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 },
Glob: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 },
Agent: { hp: 0, attack: 1, defense: 0, spAtk: 0, spDef: 0, speed: 2 },
WebSearch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 },
WebFetch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 },
}
// EV limits (matching original Pokémon)
export const MAX_EV_PER_STAT = 252
export const MAX_EV_TOTAL = 510
// EV cooldown: same tool type only counts once per 30 seconds
export const EV_COOLDOWN_MS = 30_000
/**
* Get EV gains for a tool. Returns undefined if not mapped (→ random).
*/
export function getEVForTool(toolName: string): Record<StatName, number> | undefined {
return DEFAULT_EV_MAPPING[toolName]
}

View File

@@ -0,0 +1,33 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId } from '../types'
export interface EvolutionChainStep {
from: SpeciesId
to: SpeciesId
trigger: 'level_up' | 'item' | 'trade' | 'friendship'
minLevel?: number
}
/** Find the next evolution for a species, dynamically from Dex */
export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | undefined {
const dex = Dex.species.get(speciesId)
if (!dex?.evos?.length) return undefined
// Take the first evolution target (most species have single evo path)
const target = dex.evos[0]!.toLowerCase()
const targetDex = Dex.species.get(target)
if (!targetDex?.exists) return undefined
const trigger = dex.evoType === 'trade' ? 'trade'
: dex.evoType === 'useItem' ? 'item'
: dex.evoType === 'levelFriendship' ? 'friendship'
: 'level_up'
return {
from: speciesId,
to: target as SpeciesId,
trigger,
minLevel: targetDex.evoLevel ?? undefined,
}
}

View File

@@ -0,0 +1,93 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId, MoveSlot } from '../types'
import { EMPTY_MOVE } from '../types'
const GEN = 9
/** Get raw learnset data from Dex.data (synchronous, always available) */
function getLearnsetData(speciesId: SpeciesId): Record<string, string[]> | null {
const entry = Dex.data.Learnsets[speciesId]
return entry?.learnset ?? null
}
/**
* Get level-up moves for a species.
* Prefers the current gen (9L), falls back to the latest available gen.
*/
function getLevelUpMoves(learnset: Record<string, string[]>): { id: string; level: number }[] {
// Collect level-up moves, preferring highest-gen data per move
const moveMap = new Map<string, { id: string; level: number; gen: number }>()
for (const [moveId, sources] of Object.entries(learnset)) {
for (const src of sources) {
const match = src.match(/^(\d+)L(\d+)$/)
if (match) {
const gen = parseInt(match[1]!)
const level = parseInt(match[2]!)
const existing = moveMap.get(moveId)
if (!existing || gen > existing.gen) {
moveMap.set(moveId, { id: moveId, level, gen })
}
}
}
}
return Array.from(moveMap.values()).sort((a, b) => a.level - b.level)
}
/** Get the default moveset for a species at a given level (last 4 level-up moves) */
export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> {
const learnset = getLearnsetData(speciesId)
if (!learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE]
const levelUpMoves = getLevelUpMoves(learnset)
const available = levelUpMoves.filter(m => m.level <= level).slice(-4)
const slots: MoveSlot[] = available.map(m => {
const dexMove = Dex.moves.get(m.id)
return { id: m.id, pp: dexMove?.pp ?? 10, maxPp: dexMove?.pp ?? 10 }
})
while (slots.length < 4) slots.push(EMPTY_MOVE)
return slots as [MoveSlot, MoveSlot, MoveSlot, MoveSlot]
}
/** Get the default ability for a species (first non-hidden ability) */
/** Get the first non-hidden ability for a species */
export function getDefaultAbility(speciesId: SpeciesId): string {
const species = Dex.species.get(speciesId)
return species?.abilities?.['0']?.toLowerCase() ?? ''
}
/** Get all available abilities for a species (including hidden) */
export function getAbilities(speciesId: SpeciesId): { normal: string[]; hidden: string | null } {
const species = Dex.species.get(speciesId)
if (!species?.exists) return { normal: [], hidden: null }
const normal: string[] = []
if (species.abilities['0']) normal.push(species.abilities['0'].toLowerCase())
if (species.abilities['1']) normal.push(species.abilities['1'].toLowerCase())
const hidden = species.abilities['H']?.toLowerCase() ?? null
return { normal, hidden }
}
/** Randomly select an ability for a species. Hidden ability has ~5% chance. */
export function randomAbility(speciesId: SpeciesId): string {
const { normal, hidden } = getAbilities(speciesId)
if (normal.length === 0 && !hidden) return ''
// 5% chance for hidden ability
if (hidden && Math.random() < 0.05) return hidden
// Otherwise pick from normal abilities
return normal[Math.floor(Math.random() * normal.length)] ?? hidden ?? ''
}
/** Get newly learnable moves when leveling up */
export async function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): Promise<{ id: string; name: string }[]> {
const learnset = getLearnsetData(speciesId)
if (!learnset) return []
const levelUpMoves = getLevelUpMoves(learnset)
return levelUpMoves
.filter(m => m.level > oldLevel && m.level <= newLevel)
.map(m => {
const dexMove = Dex.moves.get(m.id)
return { id: m.id, name: dexMove?.name ?? m.id }
})
}

View File

@@ -0,0 +1,70 @@
import type { SpeciesId } from '../types'
/** Curated English names (Dex provides default names for all species) */
export const SPECIES_NAMES: Partial<Record<string, string>> = {
bulbasaur: 'Bulbasaur',
ivysaur: 'Ivysaur',
venusaur: 'Venusaur',
charmander: 'Charmander',
charmeleon: 'Charmeleon',
charizard: 'Charizard',
squirtle: 'Squirtle',
wartortle: 'Wartortle',
blastoise: 'Blastoise',
pikachu: 'Pikachu',
}
/** Curated multilingual names (falls back to English from Dex) */
const CURATED_I18N: Partial<Record<string, Record<string, string>>> = {
bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' },
ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' },
venusaur: { en: 'Venusaur', ja: 'フシギバナ', zh: '妙蛙花' },
charmander: { en: 'Charmander', ja: 'ヒトカゲ', zh: '小火龙' },
charmeleon: { en: 'Charmeleon', ja: 'リザード', zh: '火恐龙' },
charizard: { en: 'Charizard', ja: 'リザードン', zh: '喷火龙' },
squirtle: { en: 'Squirtle', ja: 'ゼニガメ', zh: '杰尼龟' },
wartortle: { en: 'Wartortle', ja: 'カメール', zh: '卡咪龟' },
blastoise: { en: 'Blastoise', ja: 'カメックス', zh: '水箭龟' },
pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' },
}
// Try loading auto-generated multilingual data (from fetch-species-names.ts)
let generatedI18n: Record<string, Record<string, string>> = {}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('./species-names.ts') as { SPECIES_I18N_DATA?: Record<string, { en: string; ja: string; zh: string }> }
if (mod.SPECIES_I18N_DATA) {
generatedI18n = mod.SPECIES_I18N_DATA
}
} catch {
// species-names.ts not generated yet — use curated fallback
}
/** Get multilingual name for a species. Falls back to Dex English name. */
export function getSpeciesI18nName(speciesId: SpeciesId, lang: string): string {
const generated = generatedI18n[speciesId]
if (generated) return generated[lang] ?? generated.en ?? speciesId
const curated = CURATED_I18N[speciesId]
if (curated) return curated[lang] ?? curated.en ?? speciesId
return speciesId
}
/** All available multilingual names (curated + auto-generated) */
export const SPECIES_I18N: Partial<Record<string, Record<string, string>>> = {
...CURATED_I18N,
...generatedI18n,
}
/** Curated personality descriptions (falls back to empty string) */
export const SPECIES_PERSONALITY: Partial<Record<string, string>> = {
bulbasaur: 'Calm and collected, a reliable partner',
ivysaur: 'Steady growth, patient and resilient',
venusaur: 'Majestic and powerful, a natural leader',
charmander: 'Energetic and curious, loves adventure',
charmeleon: 'Fierce and determined, always pushing forward',
charizard: 'Proud and strong-willed, a formidable ally',
squirtle: 'Cheerful and playful, adapts easily',
wartortle: 'Loyal and protective, wise beyond years',
blastoise: 'Steadfast and powerful, a defensive fortress',
pikachu: 'Friendly and energetic, always by your side',
}

View File

@@ -0,0 +1,39 @@
import { Dex } from '@pkmn/sim'
import { FROM_DEX_STAT } from './pkmn'
import type { NatureName, NatureEffect, NatureStat } from '../types'
// All 25 canonical nature names (Dex.natures is not iterable, so we list them)
const NATURE_IDS: NatureName[] = [
'hardy', 'lonely', 'brave', 'adamant', 'naughty',
'bold', 'docile', 'relaxed', 'impish', 'lax',
'timid', 'hasty', 'serious', 'jolly', 'naive',
'modest', 'mild', 'quiet', 'bashful', 'rash',
'calm', 'gentle', 'sassy', 'careful', 'quirky',
]
/** Get all nature names */
export function getAllNatureNames(): NatureName[] {
return NATURE_IDS.filter(name => Dex.natures.get(name)?.exists)
}
/** Randomly assign a nature */
export function randomNature(): NatureName {
const names = getAllNatureNames()
return names[Math.floor(Math.random() * names.length)]!
}
/** Map Dex stat abbreviation (atk, spa, spe, etc.) to our NatureStat format */
function mapDexStat(stat: string | undefined): NatureStat | null {
if (!stat) return null
return (FROM_DEX_STAT[stat] as NatureStat) ?? null
}
/** Get nature effect (plus/minus stat, or null for neutral) — delegates to Dex.natures */
export function getNatureEffect(nature: NatureName): NatureEffect {
const n = Dex.natures.get(nature)
if (!n?.exists) return { plus: null, minus: null }
return {
plus: mapDexStat(n.plus),
minus: mapDexStat(n.minus),
}
}

View File

@@ -0,0 +1,39 @@
import { Dex } from '@pkmn/sim'
import { Generations } from '@pkmn/data'
import type { StatName } from '../types'
// Singleton Gen 9 data source
const gens = new Generations(Dex as unknown as import('@pkmn/data').Dex)
export const gen = gens.get(9)
// Stat name mapping: @pkmn/sim → our StatName
export const FROM_DEX_STAT: Record<string, StatName> = {
hp: 'hp', atk: 'attack', def: 'defense',
spa: 'spAtk', spd: 'spDef', spe: 'speed',
}
// Stat name mapping: our StatName → @pkmn/sim abbreviation
export const TO_DEX_STAT: Record<StatName, string> = {
hp: 'hp', attack: 'atk', defense: 'def',
spAtk: 'spa', spDef: 'spd', speed: 'spe',
}
/** Query species from Dex (uses Dex directly for full coverage) */
export function getSpecies(id: string) {
return Dex.species.get(id)
}
/** Map Dex baseStats to our StatName format */
export function mapBaseStats(dexStats: { hp: number; atk: number; def: number; spa: number; spd: number; spe: number }): Record<StatName, number> {
const result = {} as Record<StatName, number>
for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) {
result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0
}
return result
}
/** Get gender rate from Dex genderRatio (M/F ratio → our genderRate 0-8) */
export function mapGenderRatio(genderRatio?: { M: number; F: number } | string): number {
if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless
return Math.round(genderRatio.F * 8)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesData, SpeciesId, GrowthRate } from '../types'
import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn'
import { getNextEvolution } from './evolution'
import { SPECIES_PERSONALITY } from './names'
import { getGrowthRate, getCaptureRate, getBaseHappiness } from './pokedex-data'
// ─── Dynamic species list from @pkmn/sim Dex ───
const _rawSpecies = Dex.data.Species as Record<string, { num: number; forme?: string }>
const _ids: string[] = []
for (const [id, s] of Object.entries(_rawSpecies)) {
if (s.num > 0 && Number.isInteger(s.num) && !s.forme) {
_ids.push(id)
}
}
_ids.sort((a, b) => (_rawSpecies[a]?.num ?? 9999) - (_rawSpecies[b]?.num ?? 9999))
/** All base species IDs from @pkmn/sim Dex (sorted by dex number) */
export const ALL_SPECIES_IDS: SpeciesId[] = _ids
// ─── Supplementary data (fields not provided by @pkmn/sim) ───
// Only curated entries for species with known data; defaults used for others.
interface SupplementEntry {
flavorText: string
}
const SUPPLEMENT: Partial<Record<string, SupplementEntry>> = {
bulbasaur: {
flavorText: 'A strange seed was planted on its back at birth. The plant sprouts and grows with this Pokémon.',
},
ivysaur: {
flavorText: 'When the bulb on its back grows large, it appears to lose the ability to stand on its hind legs.',
},
venusaur: {
flavorText: 'The plant blooms when it is absorbing solar energy. It stays on the move to seek sunlight.',
},
charmander: {
flavorText: 'Obviously prefers hot places. When it rains, steam is said to spout from the tip of its tail.',
},
charmeleon: {
flavorText: 'Tough fights could excite this Pokémon. When excited, it may blow out bluish-white flames.',
},
charizard: {
flavorText: 'Spits fire that is hot enough to melt boulders. Known to cause forest fires unintentionally.',
},
squirtle: {
flavorText: 'After birth, its back swells and hardens into a shell. Powerfully sprays foam from its mouth.',
},
wartortle: {
flavorText: 'Often hides in water to stalk unwary prey. For swimming fast, it moves its ears to maintain balance.',
},
blastoise: {
flavorText: 'It crushes its foe under its heavy body to cause fainting. In a pinch, it will withdraw inside its shell.',
},
pikachu: {
flavorText: 'When several of these Pokémon gather, their electricity can build and cause lightning storms.',
},
}
// ─── Evolution chain builder (from Dex evos field) ───
function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'] {
const evo = getNextEvolution(speciesId)
if (!evo) return undefined
return [{ trigger: evo.trigger, level: evo.minLevel, into: evo.to }]
}
// ─── Build SpeciesData from Dex + supplement ───
function buildSpeciesData(id: SpeciesId): SpeciesData {
const dex = getSpecies(id)
const personality = SPECIES_PERSONALITY[id]
if (!dex) {
throw new Error(`Species ${id} not found in @pkmn/sim Dex`)
}
return {
id,
name: dex.name,
names: { en: dex.name },
dexNumber: dex.num,
genderRate: mapGenderRatio(dex.genderRatio as { M: number; F: number } | undefined),
baseStats: mapBaseStats(dex.baseStats),
types: dex.types.map((t: string) => t.toLowerCase()) as [string, string?],
baseHappiness: getBaseHappiness(id),
growthRate: getGrowthRate(id) as GrowthRate,
captureRate: getCaptureRate(id),
personality: personality ?? '',
evolutionChain: buildEvolutionChain(id),
shinyChance: 1 / 4096,
flavorText: SUPPLEMENT[id]?.flavorText ?? '',
}
}
// ─── In-memory cache (built once, immutable) ───
const speciesCache = new Map<SpeciesId, SpeciesData>()
function getCached(id: SpeciesId): SpeciesData {
let data = speciesCache.get(id)
if (!data) {
data = buildSpeciesData(id)
speciesCache.set(id, data)
}
return data
}
// ─── Sync getters (used by all consumers) ───
/** Get species data by ID. */
export function getSpeciesData(id: SpeciesId): SpeciesData {
return getCached(id)
}
/** Get all species data as a Record. */
export function getAllSpeciesData(): Record<SpeciesId, SpeciesData> {
const result = {} as Record<SpeciesId, SpeciesData>
for (const id of ALL_SPECIES_IDS) {
result[id] = getCached(id)
}
return result
}
/**
* Synchronous getter that returns the full map.
* @deprecated Use getSpeciesData / getAllSpeciesData
*/
export const SPECIES_DATA: Record<SpeciesId, SpeciesData> = new Proxy({} as Record<SpeciesId, SpeciesData>, {
get(_, prop: string) {
return getSpeciesData(prop as SpeciesId)
},
ownKeys() {
return ALL_SPECIES_IDS as unknown as string[]
},
has(_, prop) {
return ALL_SPECIES_IDS.includes(prop as SpeciesId)
},
getOwnPropertyDescriptor(_, prop) {
if (ALL_SPECIES_IDS.includes(prop as SpeciesId)) {
return { configurable: true, enumerable: true, value: getSpeciesData(prop as SpeciesId) }
}
return undefined
},
})
/** No-op — data is now built-in from @pkmn/sim */
export function ensureSpeciesData(): Promise<void> {
return Promise.resolve()
}
/** No-op — data is now built-in from @pkmn/sim */
export async function refreshAllSpeciesData(): Promise<void> {
// Clear cache to force rebuild
speciesCache.clear()
}
// ─── Dex number mapping (dynamic) ───
export const DEX_TO_SPECIES: Record<number, SpeciesId> = (() => {
const map: Record<number, SpeciesId> = {}
for (const id of ALL_SPECIES_IDS) {
const s = _rawSpecies[id]
if (s) map[s.num] = id
}
return map
})()

View File

@@ -0,0 +1,81 @@
import type { GrowthRate } from '../types'
/**
* Calculate total XP required to reach a given level for a growth rate type.
* Follows original Pokémon XP curve formulas.
*/
export function xpForLevel(level: number, growthRate: GrowthRate): number {
if (level <= 1) return 0
const n = level
switch (growthRate) {
case 'erratic':
return xpErratic(n)
case 'fast':
return Math.floor((n * n * n * 4) / 5)
case 'medium-fast':
return n * n * n
case 'medium-slow':
return Math.floor((6 / 5) * n * n * n - 15 * n * n + 100 * n - 140)
case 'slow':
return Math.floor((5 * n * n * n) / 4)
case 'fluctuating':
return xpFluctuating(n)
default:
return n * n * n
}
}
/**
* Calculate level from total XP for a given growth rate.
*/
export function levelFromXp(totalXp: number, growthRate: GrowthRate): number {
// Binary search for level
let lo = 1
let hi = 100
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2)
if (xpForLevel(mid, growthRate) <= totalXp) {
lo = mid
} else {
hi = mid - 1
}
}
return Math.min(lo, 100)
}
/**
* XP needed to go from current level to next level.
*/
export function xpToNextLevel(currentLevel: number, totalXp: number, growthRate: GrowthRate): number {
if (currentLevel >= 100) return 0
const nextLevelXp = xpForLevel(currentLevel + 1, growthRate)
return nextLevelXp - totalXp
}
// Erratic growth rate (complex piecewise)
function xpErratic(n: number): number {
if (n <= 1) return 0
if (n <= 50) {
return Math.floor((n * n * n * (100 - n)) / 50)
}
if (n <= 68) {
return Math.floor((n * n * n * (150 - n)) / 100)
}
if (n <= 98) {
return Math.floor((n * n * n * Math.floor((1911 - 10 * n) / 3)) / 500)
}
// n 99-100
return Math.floor((n * n * n * (160 - n)) / 100)
}
// Fluctuating growth rate (complex piecewise)
function xpFluctuating(n: number): number {
if (n <= 1) return 0
if (n <= 15) {
return Math.floor((n * n * n * (Math.floor((n + 1) / 3) + 24)) / 50)
}
if (n <= 36) {
return Math.floor((n * n * n * (n + 14)) / 50)
}
return Math.floor((n * n * n * (Math.floor(n / 2) + 32)) / 50)
}

View File

@@ -0,0 +1,88 @@
// Types
export type {
StatName,
NatureName,
NatureStat,
NatureEffect,
MoveSlot,
ItemId,
PCBox,
BagEntry,
Bag,
SpeciesId,
Gender,
EvolutionTrigger,
EvolutionCondition,
GrowthRate,
SpeciesData,
Creature,
Egg,
DexEntry,
BuddyData,
StatsResult,
EvolutionResult,
SpriteCache,
AnimMode,
} from './types'
export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS, EMPTY_MOVE } from './types'
// Data
export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './dex/species'
export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './dex/evMapping'
export { xpForLevel, levelFromXp, xpToNextLevel } from './dex/xpTable'
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './dex/names'
export { getAllNatureNames, randomNature, getNatureEffect } from './dex/nature'
export { getNextEvolution } from './dex/evolution'
export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './dex/learnsets'
export { FROM_DEX_STAT, TO_DEX_STAT } from './dex/pkmn'
// Battle
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './battle/types'
export { createBattle, executeTurn, executeSwitch, type BattleInit } from './battle/engine'
export { settleBattle, applyMoveLearn, applyEvolution } from './battle/settlement'
export { chooseAIMove } from './battle/ai'
// 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, EGG_REQUIRED_DAYS } from './core/egg'
export {
loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy,
updateDailyStats, incrementTurns,
addToParty, removeFromParty, swapPartySlots, setActivePartyMember, compactParty,
depositToBox, withdrawFromBox, moveInBox, renameBox,
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
addItemToBag, removeItemFromBag, getItemCount,
} from './core/storage'
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
// Sprites
export { renderAnimatedSprite, shrinkSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer'
export { getFallbackSprite } from './sprites/fallback'
export { SpeciesPicker } from './ui/SpeciesPicker'
// 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'
export { SpriteAnimator } from './ui/SpriteAnimator'
export { BattleSprite } from './ui/BattleSprite'
export { BattleField } from './ui/BattleField'
export { BattleConfigPanel } from './ui/BattleConfigPanel'
export { BattleScene } from './ui/BattleScene'
export type { MenuPhase } from './ui/BattleScene'
export { HpCard } from './ui/HpCard'
export { BattleMenu } from './ui/BattleMenu'
export { BattleLogPanel } from './ui/BattleLogPanel'
export { SwitchPanel } from './ui/SwitchPanel'
export { ItemPanel } from './ui/ItemPanel'
export { BattleResultPanel } from './ui/BattleResultPanel'
export { MoveLearnPanel } from './ui/MoveLearnPanel'
export { BattleFlow } from './ui/BattleFlow'
export type { BattleFlowHandle } from './ui/BattleFlow'

View File

@@ -0,0 +1,94 @@
import type { SpeciesId } from '../types'
/**
* Fallback ASCII art for when sprites can't be fetched.
* Curated sprites for original 10 species; generic fallback for all others.
*/
const FALLBACK_SPRITES: Partial<Record<string, 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 ) ',
' \\ ~~~ / ',
' /`-...-\'\\ ',
' ^^ ^^ ',
],
}
/** Generic fallback sprite for species without curated ASCII art */
const GENERIC_SPRITE: string[] = [
' .---. ',
' / o o \\ ',
' | --- | ',
' \\ / ',
' `---\' ',
]
/**
* Get fallback ASCII sprite lines for a species.
*/
export function getFallbackSprite(speciesId: SpeciesId): string[] {
return FALLBACK_SPRITES[speciesId] ?? GENERIC_SPRITE
}

View File

@@ -0,0 +1,4 @@
export { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from './renderer'
export type { AnimMode } from '../types'
export { getFallbackSprite } from './fallback'
export { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'

View File

@@ -0,0 +1,358 @@
import type { AnimMode } from '../types'
// ═══════════════════════════════════════════════════════
// Pixel Grid Model — ANSI-safe animation foundation
// ═══════════════════════════════════════════════════════
//
// Every sprite line is parsed into a Pixel[] row:
// Pixel = { char: '▄', style: '\x1b[33m' }
//
// style = full accumulated ANSI state at that position,
// so any transform (shift, reverse, slice) just moves Pixels
// around without ever touching raw ANSI strings.
//
// After transform, render each row back: reset → style → char → reset
export interface Pixel {
char: string
/** Full ANSI state needed to render this pixel */
style: string
}
const EMPTY_PIXEL: Pixel = { char: ' ', style: '' }
const EMPTY_ROW: Pixel[] = []
export { EMPTY_PIXEL, EMPTY_ROW }
// ─── Parse / Render ───────────────────────────────────
/** Parse a raw ANSI string line into a Pixel row */
function parseLine(line: string): Pixel[] {
const pixels: Pixel[] = []
let style = ''
let i = 0
while (i < line.length) {
if (line[i] === '\x1b') {
// Collect full ANSI escape sequence: \x1b[ ... m
const start = i
i++ // skip \x1b
if (i < line.length && line[i] === '[') {
i++ // skip [
while (i < line.length && line[i] !== 'm') i++
if (i < line.length) i++ // skip m
}
style += line.slice(start, i)
} else {
// Visible character (handle multi-byte Unicode)
const cp = line.codePointAt(i)!
const ch = String.fromCodePoint(cp)
pixels.push({ char: ch, style })
i += ch.length
}
}
return pixels
}
/** Render a Pixel row back to an ANSI string */
function renderRow(pixels: Pixel[]): string {
if (pixels.length === 0) return ''
let out = ''
let lastStyle: string | null = null
for (const p of pixels) {
if (p.style !== lastStyle) {
out += '\x1b[0m' + p.style // reset then apply
lastStyle = p.style
}
out += p.char
}
out += '\x1b[0m' // final reset
return out
}
export function parseSprite(lines: string[]): Pixel[][] {
return lines.map(parseLine)
}
export function renderSprite(grid: Pixel[][]): string[] {
return grid.map(renderRow)
}
// ─── Grid Transforms ──────────────────────────────────
// All transforms operate on Pixel[][], never touch raw strings.
/** Horizontal shift — positive = right, negative = left */
function shiftH(grid: Pixel[][], n: number): Pixel[][] {
if (n > 0) return grid.map(row => [...Array(n).fill(EMPTY_PIXEL), ...row])
if (n < 0) return grid.map(row => row.slice(Math.abs(n)))
return grid
}
/** Vertical shift up — removes rows from top, pads empty at bottom */
function shiftUp(grid: Pixel[][], n: number): Pixel[][] {
if (n <= 0) return grid
const height = grid.length
const shifted = grid.slice(n)
while (shifted.length < height) shifted.push(EMPTY_ROW)
return shifted
}
/** Mirror map — characters that change when flipped horizontally */
const MIRROR: Record<string, string> = {
'/': '\\', '\\': '/',
'(': ')', ')': '(',
'<': '>', '>': '<',
'{': '}', '}': '{',
'[': ']', ']': '[',
'': '╲', '╲': '',
'▌': '▐', '▐': '▌',
'▎': '▏', '▏': '▎',
'◀': '▶', '▶': '◀',
'◄': '►', '►': '◄',
'→': '←', '←': '→',
'↗': '↙', '↙': '↗',
'↘': '↖', '↖': '↘',
'`': "'", "'": '`',
',': '´', '´': ',',
}
/**
* Horizontal mirror — reverse each row.
* When mirrorChars=true, also swap directional characters (correct mirror).
* When mirrorChars=false, only reverse positions (more visible "flip" effect).
*/
function reverseH(grid: Pixel[][], mirrorChars = true): Pixel[][] {
const width = Math.max(0, ...grid.map(row => row.length))
return grid.map(row =>
[...row, ...Array(width - row.length).fill(EMPTY_PIXEL)]
.reverse()
.map(p => ({
...p,
char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char,
})),
)
}
/** Replace eye-like characters with dash */
function blinkEyes(grid: Pixel[][]): Pixel[][] {
return grid.map(row =>
row.map(p =>
/[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p,
),
)
}
// ═══════════════════════════════════════════════════════
// Idle Sequence
// ═══════════════════════════════════════════════════════
const IDLE_SEQUENCE: AnimMode[] = [
'idle', 'idle',
'breathe', 'breathe',
'idle',
'blink',
'idle',
'bounce',
'idle',
'fidget', 'fidget',
'idle',
'breathe', 'breathe',
'idle',
'flip', 'flip', 'flip',
'idle', 'idle',
'bounce',
'idle',
'blink',
'idle',
'excited', 'excited',
'idle',
]
export function getIdleAnimMode(tick: number): AnimMode {
return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
}
// ═══════════════════════════════════════════════════════
// Public API
// ═══════════════════════════════════════════════════════
/**
* Flip sprite lines horizontally (mirror + swap directional chars).
* For player Pokemon facing right towards the opponent.
*/
export function flipSpriteLines(lines: string[]): string[] {
return renderSprite(reverseH(parseSprite(lines), true))
}
/**
* Apply animation transform to sprite lines.
* Internally: parse ANSI → Pixel grid → transform → render back.
*/
export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] {
const grid = parseSprite(lines)
let result: Pixel[][] = grid
switch (mode) {
case 'idle':
break
case 'breathe':
// Right sway → center
result = shiftH(result, tick % 4 < 2 ? 3 : 0)
break
case 'blink':
result = blinkEyes(result)
break
case 'fidget':
// Big right sway → center
result = shiftH(result, tick % 2 === 0 ? 4 : 0)
break
case 'bounce': {
const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0]
const h = PATTERN[tick % PATTERN.length]
result = shiftUp(result, h)
break
}
case 'walkLeft':
// Step right → center (mimics bounce-back from left step)
result = shiftH(result, tick % 4 === 0 ? 0 : 3)
break
case 'walkRight':
// Step right → further right → center
result = shiftH(result, (tick % 4) * 2)
break
case 'flip':
// Pure position reversal — do NOT mirror chars so / \ ( )
// visibly swap, making the flip obvious.
result = reverseH(result, false)
break
case 'excited':
// Jitter right ↔ further right (never crop)
result = shiftH(result, tick % 2 === 0 ? 1 : 4)
break
case 'pet':
break // overlay handled by SpriteAnimator
}
return renderSprite(result)
}
// ═══════════════════════════════════════════════════════
// Sprite Shrink (nearest-neighbor / block sampling)
// ═══════════════════════════════════════════════════════
function pixelWeight(char: string): number {
if (char === ' ') return 0
if ('█▓'.includes(char)) return 4
if ('▒■▀▄'.includes(char)) return 3
if ('░▌▐/\\()<>'.includes(char)) return 2
return 1
}
function pickDominantPixel(
grid: Pixel[][],
x0: number,
x1: number,
y0: number,
y1: number,
): Pixel {
let best: Pixel = EMPTY_PIXEL
let bestScore = -1
const cx = (x0 + x1 - 1) / 2
const cy = (y0 + y1 - 1) / 2
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const pixel = grid[y]?.[x] ?? EMPTY_PIXEL
const weight = pixelWeight(pixel.char)
if (weight === 0) continue
const dist = Math.abs(x - cx) + Math.abs(y - cy)
const score = weight * 10 - dist
if (score > bestScore) {
best = pixel
bestScore = score
}
}
}
return bestScore >= 0 ? best : EMPTY_PIXEL
}
function resampleGrid(grid: Pixel[][], targetWidth: number, targetHeight: number): Pixel[][] {
const srcHeight = grid.length
const srcWidth = Math.max(0, ...grid.map(row => row.length))
return Array.from({ length: targetHeight }, (_, y) => {
const y0 = Math.floor((y * srcHeight) / targetHeight)
const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * srcHeight) / targetHeight))
return Array.from({ length: targetWidth }, (_, x) => {
const x0 = Math.floor((x * srcWidth) / targetWidth)
const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * srcWidth) / targetWidth))
return pickDominantPixel(grid, x0, x1, y0, y1)
})
})
}
function isEmptyRow(row: Pixel[]): boolean {
return row.length === 0 || row.every(pixel => pixel.char === ' ')
}
function trimEmptyMargin(grid: Pixel[][]): Pixel[][] {
if (grid.length === 0) return grid
let top = 0
let bottom = grid.length - 1
while (top <= bottom && isEmptyRow(grid[top] ?? [])) top++
while (bottom >= top && isEmptyRow(grid[bottom] ?? [])) bottom--
if (top > bottom) return []
const sliced = grid.slice(top, bottom + 1)
const width = Math.max(0, ...sliced.map(row => row.length))
let left = 0
let right = width - 1
const isEmptyCol = (x: number) => sliced.every(row => (row[x]?.char ?? ' ') === ' ')
while (left <= right && isEmptyCol(left)) left++
while (right >= left && isEmptyCol(right)) right--
return sliced.map(row => row.slice(left, right + 1))
}
export function shrinkSprite(
lines: string[],
opts: { scale?: number; maxWidth?: number; maxHeight?: number },
): string[] {
const grid = trimEmptyMargin(parseSprite(lines))
const srcHeight = grid.length
const srcWidth = Math.max(0, ...grid.map(row => row.length))
if (srcWidth === 0 || srcHeight === 0) return lines
const baseScale = Math.min(opts.scale ?? 0.75, 1)
const widthScale = opts.maxWidth ? opts.maxWidth / srcWidth : 1
const heightScale = opts.maxHeight ? opts.maxHeight / srcHeight : 1
const finalScale = Math.min(baseScale, widthScale, heightScale, 1)
if (finalScale >= 1) return lines
const targetWidth = Math.max(1, Math.floor(srcWidth * finalScale))
const targetHeight = Math.max(1, Math.floor(srcHeight * finalScale))
return renderSprite(resampleGrid(grid, targetWidth, targetHeight))
}
// ─── Heart overlay (kept for SpriteAnimator convenience) ──
const PET_HEARTS = [
[' ♥ ', ' '],
[' ♥ ♥ ', ' ♥ '],
[' ♥ ♥ ', ' ♥ ♥ '],
[' ♥ ♥ ', ' ♥ ♥ '],
[' ♥ ', ' ♥ ♥ '],
]
export function getPetOverlay(tick: number): string[] {
return PET_HEARTS[tick % PET_HEARTS.length]
}

View File

@@ -0,0 +1,161 @@
// 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 — dynamically populated from @pkmn/sim Dex (1025 species)
export type SpeciesId = string
// Re-exported from dex/species.ts (computed from Dex.data at module load)
export { ALL_SPECIES_IDS } from './dex/species'
// Nature (delegated to @pkmn/sim Dex.natures)
export type NatureName = string
export type NatureStat = 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed'
export type NatureEffect = { plus: NatureStat | null; minus: NatureStat | null }
// Move slot
export type MoveSlot = { id: string; pp: number; maxPp: number }
export const EMPTY_MOVE: MoveSlot = { id: '', pp: 0, maxPp: 0 }
// Item ID (Showdown format string)
export type ItemId = string
// PC box (fixed 30 slots)
export type PCBox = { name: string; slots: (string | null)[] }
// Bag
export type BagEntry = { id: ItemId; count: number }
export type Bag = { items: BagEntry[] }
// 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
nature: NatureName // Character nature
ev: Record<StatName, number> // Effort values
iv: Record<StatName, number> // Individual values (0-31)
moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // 4 move slots
ability: string // Showdown ability ID
heldItem: ItemId | null // Held item
friendship: number // Friendship (0-255)
isShiny: boolean
hatchedAt: number // Timestamp when obtained
pokeball: string // Pokeball type
}
// 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: 2
party: (string | null)[] // Always length 6, party[0] = active buddy
boxes: PCBox[] // PC storage (default 8 boxes × 30 slots)
creatures: Creature[]
eggs: Egg[]
dex: DexEntry[]
bag: Bag
stats: {
totalTurns: number
consecutiveDays: number
lastActiveDate: string // ISO date
totalEggsObtained: number
totalEvolutions: number
battlesWon: number
battlesLost: 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'
| 'breathe'
| 'blink'
| 'fidget'
| 'bounce'
| 'walkLeft'
| 'walkRight'
| 'flip'
| 'excited'
| 'pet'

View File

@@ -0,0 +1,73 @@
import { Box, Text } from '@anthropic/ink'
import type { Creature, SpeciesId } from '../types'
import { getCreatureName } from '../core/creature'
interface BattleConfigPanelProps {
party: (Creature | null)[]
cursorIndex: number
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
onCancel: () => void
}
const OPTIONS = [
{ label: '随机遇战(等级自动匹配)', color: 'warning' as const },
{ label: '指定对手', color: 'inactive' as const },
]
export function BattleConfigPanel({ party, cursorIndex }: BattleConfigPanelProps) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="claude"
borderText={{ content: ' 战斗配置 ', position: 'top', align: 'center' }}
paddingX={2}
paddingY={1}
>
{/* Party display */}
<Text bold color="claude"></Text>
{party.map((creature, i) => {
if (!creature) return (
<Box key={i}>
<Text dimColor> []</Text>
</Box>
)
const hpPercent = 100
const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
const isLead = i === 0
return (
<Box key={creature.id}>
<Text color={isLead ? 'claude' : 'inactive'}>
{isLead ? ' ▸ ' : ' '}
</Text>
<Text bold={isLead}>{getCreatureName(creature)}</Text>
<Text> Lv.{creature.level} </Text>
<Text color="success">{hpBar}</Text>
<Text color="inactive">{hpEmpty}</Text>
<Text> {hpPercent}%</Text>
</Box>
)
})}
{/* Options */}
<Box flexDirection="column" marginTop={1}>
<Text bold color="claude"></Text>
{OPTIONS.map((opt, i) => (
<Box key={i}>
<Text color={i === cursorIndex ? 'success' : 'inactive'}>
{i === cursorIndex ? ' ▶ ' : ' '}
</Text>
<Text bold={i === cursorIndex} color={i === cursorIndex ? opt.color : 'inactive'}>
{opt.label}
</Text>
</Box>
))}
</Box>
<Box marginTop={1}>
<Text dimColor>[] · [Enter] · [ESC] </Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useState, useMemo } from 'react'
import { Box, Text } from '@anthropic/ink'
import { parseSprite, renderSprite, flipSpriteLines, EMPTY_PIXEL, EMPTY_ROW } from '../sprites/renderer'
import type { Pixel } from '../sprites/renderer'
/**
* Combined battle field — composites both sprites into one canvas.
* Opponent (top-right) and player (bottom-left) share overlapping rows,
* like the classic GBA Pokemon battle layout.
*
* Bounce: fast 0-1-2-1px vertical, staggered between the two.
*/
const BOUNCE = [0, 1, 2, 1]
/** How many rows the player sprite overlaps into opponent's area */
const OVERLAP = 3
interface BattleFieldProps {
opponentLines: string[]
playerLines: string[]
animEnabled?: boolean
}
export function BattleField({ opponentLines, playerLines, animEnabled = true }: BattleFieldProps) {
const [tick, setTick] = useState(0)
useEffect(() => {
if (!animEnabled) return
const timer = setInterval(() => setTick(t => t + 1), 120)
return () => clearInterval(timer)
}, [animEnabled])
// Parse & flip (cached)
const oppGrid = useMemo(() => parseSprite(opponentLines), [opponentLines])
const playerGrid = useMemo(() => parseSprite(flipSpriteLines(playerLines)), [playerLines])
// Composited canvas
const canvas = useMemo(() => {
const oppH = oppGrid.length
const playerH = playerGrid.length
const totalH = oppH + playerH - OVERLAP
const canvasW = Math.max(
widthOf(oppGrid),
widthOf(playerGrid),
)
// Build empty canvas
const rows: Pixel[][] = Array.from({ length: totalH }, () =>
Array.from({ length: canvasW }, () => EMPTY_PIXEL),
)
// Bounce offsets
const oppOffset = animEnabled ? BOUNCE[tick % BOUNCE.length]! : 0
const playerOffset = animEnabled ? BOUNCE[(tick + 2) % BOUNCE.length]! : 0
// Blit opponent (top-right, shifted up by bounce)
const oppY = -oppOffset // negative = shift up
blit(rows, oppGrid, oppY, canvasW - widthOf(oppGrid))
// Blit player (bottom-left, shifted up by bounce)
const playerStartRow = oppH - OVERLAP
const playerY = playerStartRow - playerOffset
blit(rows, playerGrid, playerY, 0)
return rows
}, [oppGrid, playerGrid, animEnabled, tick])
const rendered = renderSprite(canvas)
return (
<Box flexDirection="column">
{rendered.map((line, i) => (
<Text key={i}>{line || ' '}</Text>
))}
</Box>
)
}
/** Get width of a pixel grid */
function widthOf(grid: Pixel[][]): number {
return Math.max(0, ...grid.map(row => row.length))
}
/** Blit source grid onto target at (startRow, startCol). Non-empty pixels overwrite. */
function blit(target: Pixel[][], source: Pixel[][], startRow: number, startCol: number) {
for (let sy = 0; sy < source.length; sy++) {
const ty = startRow + sy
if (ty < 0 || ty >= target.length) continue
for (let sx = 0; sx < source[sy].length; sx++) {
const tx = startCol + sx
if (tx < 0 || tx >= target[ty].length) continue
const pixel = source[sy][sx]
if (pixel.char !== ' ') {
target[ty][tx] = pixel
}
}
}
}

View File

@@ -0,0 +1,591 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BuddyData, Creature, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { saveBuddyData } from '../core/storage'
import { createBattle, executeTurn, executeSwitch, type BattleInit } from '../battle/engine'
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
import { BattleConfigPanel } from './BattleConfigPanel'
import { BattleScene, type MenuPhase } from './BattleScene'
import { SpeciesPicker } from './SpeciesPicker'
import { SwitchPanel } from './SwitchPanel'
import { ItemPanel } from './ItemPanel'
import { BattleResultPanel } from './BattleResultPanel'
import { MoveLearnPanel } from './MoveLearnPanel'
import type { BattleState, PlayerAction } from '../battle/types'
type Phase =
| 'config'
| 'configSelect'
| 'battle'
| 'result'
| 'learnMoves'
| 'evolution'
| 'done'
export interface BattleFlowHandle {
handleInput: (input: string, key: {
escape?: boolean
return?: boolean
upArrow?: boolean
downArrow?: boolean
leftArrow?: boolean
rightArrow?: boolean
tab?: boolean
backspace?: boolean
ctrl?: boolean
shift?: boolean
meta?: boolean
}) => void
}
interface BattleFlowProps {
buddyData: BuddyData
onClose: () => void
isActive?: boolean
inputRef?: React.MutableRefObject<BattleFlowHandle | null>
}
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
const [phase, setPhase] = useState<Phase>('config')
const [buddyData, setBuddyData] = useState(initialData)
const [battleInit, setBattleInit] = useState<BattleInit | null>(null)
const [battleState, setBattleState] = useState<BattleState | null>(null)
const [opponentSpeciesId, setOpponentSpeciesId] = useState<SpeciesId>('pikachu')
const [opponentLevel, setOpponentLevel] = useState(5)
const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
const [replaceIndex, setReplaceIndex] = useState(0)
const [configCursor, setConfigCursor] = useState(0)
// ─── Battle UI state ───
const [menuPhase, setMenuPhase] = useState<MenuPhase>('main')
const [cursorIndex, setCursorIndex] = useState(0)
const [animEnabled, setAnimEnabled] = useState(true)
// ─── Helpers ───
function getActiveCreatureLevel(): number {
const id = buddyData.party[0]
if (!id) return 5
const c = buddyData.creatures.find(cr => cr.id === id)
return c?.level ?? 5
}
function getPartyCreatures(): Creature[] {
return buddyData.party
.filter((id): id is string => id !== null)
.map(id => buddyData.creatures.find(c => c.id === id))
.filter((c): c is Creature => c !== undefined)
}
/** Build battleHp map from battleState.playerParty */
function getBattleHpMap(): Record<string, { hp: number; maxHp: number }> {
if (!battleState) return {}
const map: Record<string, { hp: number; maxHp: number }> = {}
for (const p of battleState.playerParty) {
map[p.id] = { hp: p.hp, maxHp: p.maxHp }
}
return map
}
/** Get max cursor index for current sub-phase */
function getMaxCursor(): number {
if (!battleState) return 0
switch (menuPhase) {
case 'main': return 3
case 'fight': return battleState.playerPokemon.moves.length - 1
case 'bag': return battleState.usableItems.length - 1
case 'pokemon': return getPartyCreatures().length - 1
default: return 0
}
}
// ─── Actions ───
const handleRandomBattle = useCallback(() => {
const opponentLevel = getActiveCreatureLevel()
const speciesList = ALL_SPECIES_IDS
const randomSpecies = speciesList[Math.floor(Math.random() * speciesList.length)]!
handleStartBattle(randomSpecies, opponentLevel)
}, [buddyData])
const handleStartBattle = useCallback(async (speciesId: SpeciesId, level: number) => {
setOpponentSpeciesId(speciesId)
setOpponentLevel(level)
const creatures = buddyData.party
.filter((id): id is string => id !== null)
.map(id => buddyData.creatures.find(c => c.id === id))
.filter((c): c is Creature => c !== undefined)
if (creatures.length === 0) return
const bagItems = buddyData.bag.items
const init = await createBattle(creatures, speciesId, level, bagItems)
setBattleInit(init)
setBattleState(init.state)
setMenuPhase('main')
setCursorIndex(0)
setPhase('battle')
}, [buddyData])
const handleAction = useCallback(async (action: PlayerAction) => {
if (!battleInit) return
// Consume item from bag before executing turn
if (action.type === 'item' && action.itemId) {
const updated = {
...buddyData,
bag: {
...buddyData.bag,
items: buddyData.bag.items.map(entry =>
entry.id === action.itemId
? { ...entry, count: Math.max(0, entry.count - 1) }
: entry
).filter(entry => entry.count > 0),
},
}
setBuddyData(updated)
}
const state = await executeTurn(battleInit, action)
setBattleState(state)
setMenuPhase('main')
setCursorIndex(0)
// Escape successful — close battle without rewards
if (state.escaped) {
saveBuddyData(buddyData)
setPhase('done')
onClose()
return
}
// Pokémon fainted — show switch panel overlay
if (state.needsSwitch && !state.finished) {
setMenuPhase('pokemon')
setCursorIndex(0)
return
}
if (state.finished && state.result) {
const participants = buddyData.party.filter((id): id is string => id !== null)
const result = { ...state.result, participantIds: participants }
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
setBuddyData(settled.data)
setPendingMoves(settled.learnableMoves)
setPendingEvos(settled.pendingEvolutions)
setBattleState({ ...state, result })
setPhase('result')
}
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
const handleResultContinue = useCallback(() => {
if (pendingMoves.length > 0) {
setPhase('learnMoves')
} else if (pendingEvos.length > 0) {
setPhase('evolution')
} else {
saveBuddyData(buddyData)
setPhase('done')
onClose()
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
const handleMoveLearn = useCallback((idx: number) => {
if (pendingMoves.length === 0) return
const move = pendingMoves[0]!
const updated = applyMoveLearn(buddyData, move.creatureId, move.moveId, idx)
setBuddyData(updated)
const remaining = pendingMoves.slice(1)
setPendingMoves(remaining)
if (remaining.length === 0) {
if (pendingEvos.length > 0) {
setPhase('evolution')
} else {
saveBuddyData(updated)
setPhase('done')
onClose()
}
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
const handleMoveSkip = useCallback(() => {
const remaining = pendingMoves.slice(1)
setPendingMoves(remaining)
if (remaining.length === 0) {
if (pendingEvos.length > 0) {
setPhase('evolution')
} else {
saveBuddyData(buddyData)
setPhase('done')
onClose()
}
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
const handleEvolutionConfirm = useCallback(() => {
if (pendingEvos.length === 0) return
const evo = pendingEvos[0]!
const updated = applyEvolution(buddyData, evo.creatureId, evo.to)
setBuddyData(updated)
const remaining = pendingEvos.slice(1)
setPendingEvos(remaining)
if (remaining.length === 0) {
saveBuddyData(updated)
setPhase('done')
onClose()
}
}, [pendingEvos, buddyData, onClose])
// Forced switch after faint
const handleForcedSwitch = useCallback(async (partyIndex: number) => {
if (!battleInit) return
const state = await executeSwitch(battleInit, partyIndex)
setBattleState(state)
setMenuPhase('main')
setCursorIndex(0)
if (state.finished && state.result) {
const participants = buddyData.party.filter((id): id is string => id !== null)
const result = { ...state.result, participantIds: participants }
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
setBuddyData(settled.data)
setPendingMoves(settled.learnableMoves)
setPendingEvos(settled.pendingEvolutions)
setBattleState({ ...state, result })
setPhase('result')
}
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
// ─── Main menu cursor navigation (2x2 grid) ───
const moveMainCursor = useCallback((direction: 'up' | 'down' | 'left' | 'right') => {
setCursorIndex(prev => {
// Grid: 0=TL, 1=TR, 2=BL, 3=BR
switch (direction) {
case 'up': return prev >= 2 ? prev - 2 : prev + 2
case 'down': return prev < 2 ? prev + 2 : prev - 2
case 'left': return prev % 2 === 1 ? prev - 1 : prev + 1
case 'right': return prev % 2 === 0 ? prev + 1 : prev - 1
default: return prev
}
})
}, [])
// ─── Input handler ───
const handleInput = useCallback((input: string, key: {
escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean
leftArrow?: boolean; rightArrow?: boolean
}) => {
if (!isActive) return
if (phase === 'config') {
if (key.escape) {
onClose()
} else if (key.upArrow) {
setConfigCursor(prev => (prev - 1 + 2) % 2)
} else if (key.downArrow) {
setConfigCursor(prev => (prev + 1) % 2)
} else if (key.return) {
if (configCursor === 0) {
handleRandomBattle()
} else {
setPhase('configSelect')
}
}
return
}
if (phase === 'configSelect') {
// SpeciesPicker handles its own input via FuzzyPicker/useInput
return
}
if (phase === 'battle') {
if (!battleState) return
// F key toggles animation
if (input.toLowerCase() === 'f') {
setAnimEnabled(prev => !prev)
return
}
// ─── Main menu ───
if (menuPhase === 'main') {
if (key.escape) return
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
moveMainCursor(key.upArrow ? 'up' : key.downArrow ? 'down' : key.leftArrow ? 'left' : 'right')
return
}
if (key.return) {
switch (cursorIndex) {
case 0: // 战斗 → move selection
setMenuPhase('fight')
setCursorIndex(0)
return
case 1: // 背包
setMenuPhase('bag')
setCursorIndex(0)
return
case 2: // 宝可梦
setMenuPhase('pokemon')
setCursorIndex(0)
return
case 3: // 逃跑 — attempt escape
handleAction({ type: 'run' })
return
}
}
return
}
// ─── Fight (move selection) ───
if (menuPhase === 'fight') {
if (key.escape) {
setMenuPhase('main')
setCursorIndex(0)
return
}
if (key.upArrow) {
setCursorIndex(prev => Math.max(0, prev - 1))
return
}
if (key.downArrow) {
setCursorIndex(prev => Math.min(battleState.playerPokemon.moves.length - 1, prev + 1))
return
}
if (key.return) {
const move = battleState.playerPokemon.moves[cursorIndex]
if (move && move.pp > 0 && !move.disabled) {
handleAction({ type: 'move', moveIndex: cursorIndex })
}
return
}
return
}
// ─── Bag (item selection) ───
if (menuPhase === 'bag') {
if (key.escape) {
setMenuPhase('main')
setCursorIndex(1) // return to 背包
return
}
if (key.upArrow) {
setCursorIndex(prev => Math.max(0, prev - 1))
return
}
if (key.downArrow) {
setCursorIndex(prev => Math.min(battleState.usableItems.length - 1, prev + 1))
return
}
if (key.return) {
const item = battleState.usableItems[cursorIndex]
if (item) {
handleAction({ type: 'item', itemId: item.id })
}
return
}
return
}
// ─── Pokemon (switch selection) ───
if (menuPhase === 'pokemon') {
const isForced = battleState.needsSwitch
if (key.escape && !isForced) {
setMenuPhase('main')
setCursorIndex(2) // return to 宝可梦
return
}
if (key.upArrow) {
setCursorIndex(prev => Math.max(0, prev - 1))
return
}
if (key.downArrow) {
const maxIdx = getPartyCreatures().length - 1
setCursorIndex(prev => Math.min(maxIdx, prev + 1))
return
}
if (key.return) {
const party = getPartyCreatures()
const creature = party[cursorIndex]
const battleParty = battleState.playerParty
const battleCreature = battleParty[cursorIndex]
if (creature && battleCreature && battleCreature.hp > 0) {
if (isForced) {
handleForcedSwitch(cursorIndex)
} else {
handleAction({ type: 'switch', partyIndex: cursorIndex })
}
}
return
}
return
}
return
}
if (phase === 'result') {
if (key.return) handleResultContinue()
return
}
if (phase === 'learnMoves') {
if (input.toLowerCase() === 's') {
handleMoveSkip()
} else if (key.upArrow) {
setReplaceIndex(prev => Math.max(0, prev - 1))
} else if (key.downArrow) {
setReplaceIndex(prev => Math.min(3, prev + 1))
} else if (key.return) {
handleMoveLearn(replaceIndex)
}
return
}
if (phase === 'evolution') {
if (key.return) handleEvolutionConfirm()
return
}
}, [isActive, phase, menuPhase, cursorIndex, configCursor, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
// Expose handleInput via ref
useEffect(() => {
if (inputRef) inputRef.current = { handleInput }
}, [handleInput, inputRef])
// ─── Build overlay content for sub-panels ───
function buildOverlay(): React.ReactNode | undefined {
if (!battleState) return undefined
if (menuPhase === 'bag') {
return (
<ItemPanel
items={battleState.usableItems}
cursorIndex={cursorIndex}
categoryIndex={0}
phase="items"
onSelect={() => {}}
onCancel={() => { setMenuPhase('main'); setCursorIndex(1) }}
/>
)
}
if (menuPhase === 'pokemon') {
return (
<SwitchPanel
party={getPartyCreatures()}
activeId={battleState.playerPokemon.id}
cursorIndex={cursorIndex}
battleHp={getBattleHpMap()}
onSelect={() => {}}
onCancel={() => { setMenuPhase('main'); setCursorIndex(2) }}
/>
)
}
return undefined
}
// ─── Render by phase ───
switch (phase) {
case 'config':
return (
<BattleConfigPanel
party={getPartyCreatures()}
cursorIndex={configCursor}
onSubmit={handleStartBattle}
onCancel={onClose}
/>
)
case 'configSelect':
return (
<SpeciesPicker
onSelect={(speciesId) => handleStartBattle(speciesId, getActiveCreatureLevel())}
onCancel={() => setPhase('config')}
/>
)
case 'battle': {
if (!battleState) return null
return (
<BattleScene
state={battleState}
menuPhase={menuPhase}
cursorIndex={cursorIndex}
animEnabled={animEnabled}
overlay={buildOverlay()}
onMoveCursor={(dir) => {
if (menuPhase === 'main') moveMainCursor(dir)
else if (dir === 'up') setCursorIndex(prev => Math.max(0, prev - 1))
else if (dir === 'down') setCursorIndex(prev => Math.min(getMaxCursor(), prev + 1))
}}
onSelect={() => {}}
onBack={() => { setMenuPhase('main'); setCursorIndex(0) }}
onToggleAnim={() => setAnimEnabled(prev => !prev)}
/>
)
}
case 'result': {
if (!battleState?.result) return null
return (
<BattleResultPanel
result={battleState.result}
onContinue={handleResultContinue}
/>
)
}
case 'learnMoves': {
if (pendingMoves.length === 0) return null
const move = pendingMoves[0]!
const creature = buddyData.creatures.find(c => c.id === move.creatureId)
if (!creature) return null
return (
<MoveLearnPanel
creature={creature}
newMoveId={move.moveId}
cursorIndex={replaceIndex}
onLearn={handleMoveLearn}
onSkip={handleMoveSkip}
onSelectReplace={setReplaceIndex}
/>
)
}
case 'evolution': {
if (pendingEvos.length === 0) return null
const evo = pendingEvos[0]!
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="warning"
borderText={{ content: ' 进化 ', position: 'top', align: 'center' }}
paddingX={2}
paddingY={1}
>
<Text bold color="warning">{evo.from} {evo.to}!</Text>
<Box marginTop={1}>
<Text color="claude">[Enter] </Text>
</Box>
</Box>
)
}
case 'done':
return null
default:
return null
}
}

View File

@@ -0,0 +1,103 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BattleEvent } from '../battle/types'
/** Max lines to display in the log panel */
const MAX_VISIBLE = 20
function eventColor(event: BattleEvent): string {
switch (event.type) {
case 'damage': return 'error'
case 'heal': return 'success'
case 'faint': return 'error'
case 'crit': return 'warning'
case 'miss': return 'inactive'
case 'effectiveness': return event.multiplier > 1 ? 'success' : 'warning'
case 'move': return 'claude'
case 'status': return 'warning'
case 'switch': return 'claude'
case 'turn': return 'inactive'
case 'weather': return 'claude'
case 'fieldCondition': return 'warning'
case 'activate': return 'claude'
case 'immune': return 'inactive'
case 'upkeep': return 'inactive'
case 'ability': return 'claude'
case 'item': return 'warning'
case 'fail': return 'inactive'
default: return 'inactive'
}
}
const WEATHER_NAMES: Record<string, string> = {
sun: '大晴天', rain: '雨天', sandstorm: '沙暴', hail: '冰雹',
snow: '下雪', desolateland: '大日照', primordialsea: '大雨', deltastream: '强气流',
}
function formatEvent(event: BattleEvent): string {
switch (event.type) {
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害 (${event.percentage}%)`
case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP`
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}${event.speciesId} 倒下了!`
case 'crit': return '击中要害!'
case 'miss': return '攻击没有命中!'
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
case 'status': return `${event.side === 'player' ? '我方' : '对手'}${event.status === 'none' ? '恢复了异常状态!' : `陷入了${event.status}状态!`}`
case 'switch': return `${event.side === 'player' ? '我方' : '对手'}换上了 ${event.name}!`
case 'turn': return `── 回合 ${event.number} ──`
case 'statChange': {
const sign = event.stages > 0 ? '↑' : '↓'
return `${event.side === 'player' ? '我方' : '对手'}${event.stat} ${sign}${Math.abs(event.stages)}`
}
case 'ability': return `${event.side === 'player' ? '我方' : '对手'}的特性 ${event.ability} 发动了!`
case 'item': return `${event.side === 'player' ? '我方' : '对手'}${event.item} 发动了!`
case 'fail': return `${event.side === 'player' ? '我方' : '对手'}的攻击失败了!`
case 'weather':
if (event.weather === 'none') return '天气恢复了正常'
return `${WEATHER_NAMES[event.weather] ?? event.weather} 开始了!`
case 'upkeep': return '── 回合结束处理 ──'
case 'fieldCondition':
if (event.action === 'add') return `${event.side === 'player' ? '我方' : '对手'}场地: ${event.id}!`
return `${event.side === 'player' ? '我方' : '对手'}场地的 ${event.id} 消失了`
case 'activate': return `${event.side === 'player' ? '我方' : '对手'}触发了 ${event.effect}`
case 'immune': return `${event.side === 'player' ? '我方' : '对手'}不受影响!`
default: return ''
}
}
interface BattleLogPanelProps {
events: BattleEvent[]
animEnabled: boolean
onToggleAnim: () => void
}
export function BattleLogPanel({ events, animEnabled, onToggleAnim }: BattleLogPanelProps) {
const visible = events.slice(-MAX_VISIBLE)
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="success"
borderText={{ content: ' 战斗日志 ', position: 'top', align: 'start' }}
paddingX={1}
paddingY={0}
width="40%"
>
<Box flexDirection="column" flexGrow={1}>
{visible.map((event, i) => (
<Text key={i} color={eventColor(event) as any} dimColor={event.type === 'turn'}>
{' '}{formatEvent(event)}
</Text>
))}
{visible.length === 0 && (
<Text dimColor> ...</Text>
)}
</Box>
<Box marginTop={1}>
<Text dimColor> [F] {animEnabled ? '关闭动画' : '开启动画'}</Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,122 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { MoveOption } from '../battle/types'
export interface BattleMenuProps {
phase: 'main' | 'fight'
moves: MoveOption[]
cursorIndex: number
onMoveCursor: (direction: 'up' | 'down' | 'left' | 'right') => void
onSelect: () => void
onBack: () => void
}
export function BattleMenu({ phase, moves, cursorIndex }: BattleMenuProps) {
if (phase === 'fight') {
return <MoveMenu moves={moves} cursorIndex={cursorIndex} />
}
return <MainMenu cursorIndex={cursorIndex} />
}
function MainMenu({ cursorIndex }: { cursorIndex: number }) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="success"
paddingX={1}
>
{/* Row 0: 战斗 + 背包 */}
<Box>
<MenuItem label="战斗" selected={cursorIndex === 0} />
<MenuItem label="背包" selected={cursorIndex === 1} />
</Box>
{/* Row 1: 宝可梦 + 逃跑 */}
<Box>
<MenuItem label="宝可梦" selected={cursorIndex === 2} />
<MenuItem label="逃跑" selected={cursorIndex === 3} disabled />
</Box>
</Box>
)
}
function MenuItem({ label, selected, disabled }: { label: string; selected: boolean; disabled?: boolean }) {
if (selected && disabled) {
return (
<Box width={16}>
<Text color="warning" bold>
{' ▶ '}{label} ()
</Text>
</Box>
)
}
if (selected) {
return (
<Box width={16}>
<Text color="success" bold>
{' ▶ '}{label}
</Text>
</Box>
)
}
if (disabled) {
return (
<Box width={16}>
<Text dimColor>
{' '}{label}
</Text>
</Box>
)
}
return (
<Box width={16}>
<Text>
{' '}{label}
</Text>
</Box>
)
}
function MoveMenu({ moves, cursorIndex }: { moves: MoveOption[]; cursorIndex: number }) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="success"
borderText={{ content: ' 选择招式 ', position: 'top', align: 'start' }}
paddingX={1}
>
{moves.map((move, i) => (
<MoveItem key={move.id || i} move={move} selected={cursorIndex === i} />
))}
</Box>
)
}
function MoveItem({ move, selected }: { move: MoveOption; selected: boolean }) {
const ppText = `PP ${move.pp}/${move.maxPp}`
const noPP = move.pp <= 0 || move.disabled
if (selected) {
return (
<Box width={32}>
<Text color="success" bold>
{' ▶ '}{move.name.padEnd(14)}{ppText}
</Text>
</Box>
)
}
return (
<Box width={32}>
<Text color={noPP ? ('inactive' as any) : undefined} dimColor={noPP}>
{' '}{move.name.padEnd(14)}{ppText}
</Text>
{move.disabled && <Text color="error"> </Text>}
</Box>
)
}

View File

@@ -0,0 +1,31 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BattleResult } from '../battle/types'
interface BattleResultPanelProps {
result: BattleResult
onContinue: () => void
}
export function BattleResultPanel({ result, onContinue }: BattleResultPanelProps) {
const isWin = result.winner === 'player'
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={isWin ? 'success' : 'error'}
borderText={{ content: isWin ? ' 胜利 ' : ' 战败 ', position: 'top', align: 'center' }}
paddingX={2}
paddingY={1}
>
<Text bold color={isWin ? 'success' : 'error'}>
{isWin ? '战斗胜利!' : '战斗失败...'}
</Text>
<Box marginTop={1}>
<Text color="claude">[Enter] </Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BattleState, WeatherKind } from '../battle/types'
import type { SpeciesId } from '../types'
import { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
import { getFallbackSprite } from '../sprites/fallback'
import { HpCard } from './HpCard'
import { BattleMenu } from './BattleMenu'
import { BattleLogPanel } from './BattleLogPanel'
import { BattleSprite } from './BattleSprite'
import type { StatusCondition } from '../battle/types'
export type MenuPhase = 'main' | 'fight' | 'bag' | 'pokemon'
/** Hook: get sprite lines with async fetch fallback */
function useSpriteLines(speciesId: SpeciesId): string[] {
const [tick, setTick] = useState(0)
useEffect(() => {
if (loadSprite(speciesId)) return
fetchAndCacheSprite(speciesId).then(s => { if (s) setTick(t => t + 1) })
}, [speciesId])
void tick
const cached = loadSprite(speciesId)
return cached?.lines ?? getFallbackSprite(speciesId)
}
interface BattleSceneProps {
state: BattleState
menuPhase: MenuPhase
cursorIndex: number
animEnabled: boolean
/** Override content for right panel (bag/pokemon overlay) */
overlay?: React.ReactNode
onMoveCursor: (direction: 'up' | 'down' | 'left' | 'right') => void
onSelect: () => void
onBack: () => void
onToggleAnim: () => void
}
const WEATHER_LABELS: Record<WeatherKind, string> = {
sun: '☀ 大晴天', rain: '🌧 雨天', sandstorm: '🌪 沙暴', hail: '❄ 冰雹',
snow: '🌨 下雪', desolateland: '☀ 大日照', primordialsea: '🌧 大雨', deltastream: '🌀 强气流',
}
export function BattleScene({
state,
menuPhase,
cursorIndex,
animEnabled,
overlay,
onMoveCursor,
onSelect,
onBack,
onToggleAnim,
}: BattleSceneProps) {
const opp = state.opponentPokemon
const player = state.playerPokemon
// Load sprite lines (with async fetch for uncached species)
const oppSpriteLines = useSpriteLines(opp.speciesId as SpeciesId)
const playerSpriteLines = useSpriteLines(player.speciesId as SpeciesId)
return (
<Box flexDirection="row" width="100%">
{/* Left: Battle Log (40%) */}
<BattleLogPanel
events={state.events}
animEnabled={animEnabled}
onToggleAnim={onToggleAnim}
/>
{/* Right: Battle Field (60%) */}
<Box
flexDirection="column"
borderStyle="round"
borderColor="success"
borderText={{ content: state.weather ? ` ${WEATHER_LABELS[state.weather]} · 回合 ${state.turn} ` : ` 回合 ${state.turn} `, position: 'top', align: 'center' }}
paddingX={1}
paddingY={0}
width="60%"
>
{overlay ? (
overlay
) : (
<>
{/* Opponent info */}
<Box flexDirection="row" justifyContent="flex-start">
<HpCard
name={opp.name}
level={opp.level}
hp={opp.hp}
maxHp={opp.maxHp}
status={opp.status as StatusCondition}
align="left"
isOpponent
/>
</Box>
{/*
Keep the overlapping sprites inside a fixed-height battlefield with absolute positioning.
Do NOT switch this back to negative margins or normal-flow overlap: Ink/Yoga reflow can leave
visual ghosting above the player sprite during animation when overlap affects outer layout.
*/}
{/* Overlapped battlefield: fixed-height container so overlap won't disturb outer layout */}
<Box height={18} marginTop={1} marginBottom={1} overflow="hidden">
<Box position="absolute" top={0} right={0}>
<BattleSprite
lines={oppSpriteLines}
animEnabled={animEnabled}
/>
</Box>
<Box position="absolute" bottom={0} left={0}>
<BattleSprite
lines={playerSpriteLines}
flip
phaseOffset={2}
animEnabled={animEnabled}
/>
</Box>
</Box>
{/* Player info */}
<Box flexDirection="row" justifyContent="flex-end">
<HpCard
name={player.name}
level={player.level}
hp={player.hp}
maxHp={player.maxHp}
status={player.status as StatusCondition}
align="right"
/>
</Box>
{/* Menu */}
{!state.finished && (
<BattleMenu
phase={menuPhase as 'main' | 'fight'}
moves={player.moves}
cursorIndex={cursorIndex}
onMoveCursor={onMoveCursor}
onSelect={onSelect}
onBack={onBack}
/>
)}
{state.finished && (
<Box marginTop={1}>
<Text dimColor> </Text>
</Box>
)}
</>
)}
</Box>
</Box>
)
}

View File

@@ -0,0 +1,68 @@
import React, { useEffect, useState, useMemo } from 'react'
import { Box, Text } from '@anthropic/ink'
import { parseSprite, renderSprite, flipSpriteLines, EMPTY_ROW } from '../sprites/renderer'
import type { Pixel } from '../sprites/renderer'
/**
* Simple battle sprite with fast 1-2px vertical bounce.
* Padded so bounce never clips the sprite.
*/
// Bounce pattern: 0 → 1 → 2 → 1 → 0 → ...
const BOUNCE = [0, 1, 2, 1]
/** Vertical padding above & below — bounce shifts within this space */
const V_PAD = 3
interface BattleSpriteProps {
/** ANSI sprite lines */
lines: string[]
/** Flip horizontally (player side) */
flip?: boolean
/** Enable animation (false = static) */
animEnabled?: boolean
/** Phase offset to stagger bounce between sprites */
phaseOffset?: number
}
export function BattleSprite({ lines, flip, animEnabled = true, phaseOffset = 0 }: BattleSpriteProps) {
const [tick, setTick] = useState(0)
useEffect(() => {
if (!animEnabled) return
const timer = setInterval(() => setTick(t => t + 1), 120)
return () => clearInterval(timer)
}, [animEnabled])
// Flip once (cached)
const source = useMemo(() => flip ? flipSpriteLines(lines) : lines, [lines, flip])
// Parse to pixel grid once (cached), then pad
const padded = useMemo(() => {
const grid = parseSprite(source)
const top = Array.from({ length: V_PAD }, () => EMPTY_ROW)
const bottom = Array.from({ length: V_PAD }, () => EMPTY_ROW)
return [...top, ...grid, ...bottom]
}, [source])
// Apply bounce offset with phase shift — shift up within padded space
const offset = animEnabled ? BOUNCE[(tick + phaseOffset) % BOUNCE.length]! : 0
const shifted = shiftGridUp(padded, offset)
const rendered = renderSprite(shifted)
return (
<Box flexDirection="column">
{rendered.map((line, i) => (
<Text key={i}>{line || ' '}</Text>
))}
</Box>
)
}
/** Shift Pixel grid up by n rows, pad empty rows at bottom */
function shiftGridUp(grid: Pixel[][], n: number): Pixel[][] {
if (n <= 0) return grid
const height = grid.length
const shifted = grid.slice(n)
while (shifted.length < height) shifted.push(EMPTY_ROW)
return shifted
}

View File

@@ -0,0 +1,130 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BattleState, BattleEvent } from '../battle/types'
function hpColor(pct: number): 'success' | 'warning' | 'error' {
if (pct > 50) return 'success'
if (pct > 25) return 'warning'
return 'error'
}
function hpBar(current: number, max: number): { bar: string; pct: number } {
if (max <= 0) return { bar: '░░░░░░░░░░', pct: 0 }
const pct = Math.round((current / max) * 100)
const filled = Math.round((current / max) * 10)
return {
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled)),
pct,
}
}
interface BattleViewProps {
state: BattleState
onAction: (action: import('../battle/types').PlayerAction) => void
}
export function BattleView({ state, onAction }: BattleViewProps) {
const opp = state.opponentPokemon
const player = state.playerPokemon
const oppHp = hpBar(opp.hp, opp.maxHp)
const playerHp = hpBar(player.hp, player.maxHp)
const recentEvents = state.events.slice(-10)
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="claude"
borderText={{ content: ` 回合 ${state.turn} `, position: 'top', align: 'center' }}
paddingX={2}
paddingY={1}
>
{/* Opponent */}
<Box flexDirection="column">
<Box>
<Text bold> </Text>
<Text bold color="error">{opp.name}</Text>
<Text dimColor> Lv.{opp.level}</Text>
</Box>
<Box>
<Text dimColor> HP </Text>
<Text color={hpColor(oppHp.pct)}>{oppHp.bar}</Text>
<Text> {opp.hp}/{opp.maxHp}</Text>
{opp.status !== 'none' && <Text color="warning"> [{opp.status}]</Text>}
</Box>
</Box>
<Text color="inactive"> vs </Text>
{/* Player */}
<Box flexDirection="column">
<Box>
<Text bold> </Text>
<Text bold color="claude">{player.name}</Text>
<Text dimColor> Lv.{player.level}</Text>
</Box>
<Box>
<Text dimColor> HP </Text>
<Text color={hpColor(playerHp.pct)}>{playerHp.bar}</Text>
<Text> {player.hp}/{player.maxHp}</Text>
{player.status !== 'none' && <Text color="warning"> [{player.status}]</Text>}
</Box>
</Box>
{/* Move selection */}
{!state.finished && (
<Box flexDirection="column" marginTop={1}>
<Text bold color="claude"></Text>
{player.moves.map((move, i) => (
<Box key={move.id || i}>
<Text color={move.pp > 0 ? 'text' : 'inactive'}>
{' '}[{i + 1}] {move.name || '---'}
</Text>
<Text dimColor> PP {move.pp}/{move.maxPp}</Text>
{move.disabled && <Text color="error"> ()</Text>}
</Box>
))}
<Text color="claude"> [S] </Text>
<Text color="claude"> [I] </Text>
</Box>
)}
{/* Event log */}
{recentEvents.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{recentEvents.map((event, i) => (
<Text key={i} color={eventColor(event)} dimColor> {formatEvent(event)}</Text>
))}
</Box>
)}
</Box>
)
}
function eventColor(event: BattleEvent): 'error' | 'success' | 'warning' | 'claude' | 'inactive' | 'text' {
switch (event.type) {
case 'damage': return 'error'
case 'heal': return 'success'
case 'faint': return 'error'
case 'crit': return 'warning'
case 'miss': return 'inactive'
case 'effectiveness': return event.multiplier > 1 ? 'success' : 'warning'
default: return 'inactive'
}
}
function formatEvent(event: BattleEvent): string {
switch (event.type) {
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害 (${event.percentage}%)`
case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP`
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}${event.speciesId} 倒下了!`
case 'crit': return '击中要害!'
case 'miss': return '攻击没有命中!'
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!`
case 'turn': return `── 回合 ${event.number} ──`
default: return ''
}
}

View File

@@ -0,0 +1,159 @@
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 { getSpeciesData } from '../dex/species'
import { SPECIES_PERSONALITY } from '../dex/names'
import { calculateStats, getCreatureName, getTotalEV } from '../core/creature'
import { getXpProgress } from '../core/experience'
import { getEVSummary } from '../core/effort'
import { getGenderSymbol } from '../core/gender'
import { getStatColor } from './shared'
import { getNextEvolution } from '../dex/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 = getSpeciesData(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}>{getSpeciesData(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.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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,101 @@
import React, { useState, useEffect } from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId } from '../types'
import { getSpeciesData } from '../dex/species'
import { loadSprite, fetchAndCacheSprite } 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 [spriteTick, setSpriteTick] = useState(0)
const totalFrames = 8
// Prefetch sprites for both species
useEffect(() => {
for (const id of [fromSpecies, toSpecies]) {
if (!loadSprite(id)) {
fetchAndCacheSprite(id).then(s => { if (s) setSpriteTick(t => t + 1) })
}
}
}, [fromSpecies, toSpecies])
void spriteTick
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 = getSpeciesData(fromSpecies).name
const toName = getSpeciesData(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)
}

View File

@@ -0,0 +1,85 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { StatusCondition } from '../battle/types'
/** HP bar width in characters (GBA style) */
const HP_BAR_WIDTH = 12
function hpColor(pct: number): string {
if (pct > 50) return 'success'
if (pct > 25) return 'warning'
return 'error'
}
function hpBar(current: number, max: number): { bar: string; pct: number } {
if (max <= 0) return { bar: '░'.repeat(HP_BAR_WIDTH), pct: 0 }
const pct = Math.round((current / max) * 100)
const filled = Math.round((current / max) * HP_BAR_WIDTH)
return {
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, HP_BAR_WIDTH - filled)),
pct,
}
}
function statusLabel(status: StatusCondition): { text: string; color: string } | null {
switch (status) {
case 'poison':
case 'bad_poison':
return { text: 'PSN', color: 'warning' }
case 'burn':
return { text: 'BRN', color: 'error' }
case 'paralysis':
return { text: 'PAR', color: 'warning' }
case 'freeze':
return { text: 'FRZ', color: 'claude' }
case 'sleep':
return { text: 'SLP', color: 'inactive' }
default:
return null
}
}
interface HpCardProps {
name: string
level: number
hp: number
maxHp: number
status?: StatusCondition
/** Left = opponent (top-left), Right = player (bottom-right) */
align: 'left' | 'right'
/** Show as opponent (wild pokemon prefix) */
isOpponent?: boolean
}
export function HpCard({ name, level, hp, maxHp, status, align, isOpponent }: HpCardProps) {
const { bar, pct } = hpBar(hp, maxHp)
const statusInfo = status && status !== 'none' ? statusLabel(status) : null
const prefix = isOpponent ? '野生的 ' : ''
const nameLine = (
<Box justifyContent={align === 'right' ? 'flex-end' : 'flex-start'}>
{isOpponent && <Text bold> </Text>}
<Text bold>{prefix}{name}</Text>
<Text dimColor> Lv.{level}</Text>
{statusInfo && (
<Text color={statusInfo.color as any}> {statusInfo.text}</Text>
)}
</Box>
)
const hpLine = (
<Box justifyContent={align === 'right' ? 'flex-end' : 'flex-start'}>
<Text dimColor> HP </Text>
<Text color={hpColor(pct) as any}>{bar}</Text>
<Text> {hp}/{maxHp}</Text>
</Box>
)
return (
<Box flexDirection="column">
{nameLine}
{hpLine}
</Box>
)
}

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
interface ItemPanelProps {
items: { id: string; name: string; count: number; description?: string }[]
cursorIndex: number
categoryIndex: number
phase: 'category' | 'items'
onSelect: (itemId: string) => void
onCancel: () => void
}
/** Item categories */
const CATEGORIES = [
{ id: 'healing', label: '回复药', filter: (id: string) => id.includes('potion') || id.includes('berry') || id.includes('heal') },
{ id: 'ball', label: '精灵球', filter: (id: string) => id.includes('ball') },
{ id: 'battle', label: '战斗道具', filter: (id: string) => id.includes('x-') || id.includes('dire') || id.includes('guard') },
]
export function ItemPanel({ items, cursorIndex, categoryIndex, phase, onSelect, onCancel }: ItemPanelProps) {
if (phase === 'category') {
return (
<Box flexDirection="column">
<Box
flexDirection="column"
borderStyle="round"
borderColor="success"
borderText={{ content: ' 背包 ', position: 'top', align: 'start' }}
paddingX={1}
>
{CATEGORIES.map((cat, i) => (
<Box key={cat.id}>
{categoryIndex === i ? (
<Text color="success" bold> {cat.label} </Text>
) : (
<Text> {cat.label} </Text>
)}
</Box>
))}
</Box>
<Box marginTop={1}>
<Text dimColor> [ESC] </Text>
</Box>
</Box>
)
}
// Phase: items — show items in selected category
const cat = CATEGORIES[categoryIndex]
const filtered = cat
? items.filter(item => cat.filter(item.id))
: items
const displayItems = filtered.length > 0 ? filtered : items
return (
<Box flexDirection="column">
<Box
flexDirection="column"
borderStyle="round"
borderColor="success"
borderText={{ content: ` ${cat?.label ?? '道具'} `, position: 'top', align: 'start' }}
paddingX={1}
>
{displayItems.length === 0 ? (
<Text dimColor> </Text>
) : (
displayItems.map((item, i) => (
<Box key={item.id}>
{cursorIndex === i ? (
<Text color="success" bold> {item.name}</Text>
) : (
<Text> {item.name}</Text>
)}
<Text dimColor> ×{item.count}</Text>
</Box>
))
)}
</Box>
<Box marginTop={1}>
<Text dimColor> [ESC] </Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,59 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { Creature } from '../types'
import { Dex } from '@pkmn/sim'
interface MoveLearnPanelProps {
creature: Creature
newMoveId: string
cursorIndex: number
onLearn: (replaceIndex: number) => void
onSkip: () => void
onSelectReplace: (index: number) => void
}
export function MoveLearnPanel({ creature, newMoveId, cursorIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) {
const dexMove = Dex.moves.get(newMoveId)
const moveName = dexMove?.name ?? newMoveId
const moveType = dexMove?.type ?? 'Normal'
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="success"
borderText={{ content: ' 新招式 ', position: 'top', align: 'center' }}
paddingX={2}
paddingY={1}
>
<Text>{creature.speciesId} : <Text bold color="claude">{moveName}</Text> <Text dimColor>({moveType})</Text></Text>
<Box flexDirection="column" marginTop={1}>
<Text bold>:</Text>
{creature.moves.map((move, i) => {
const isSelected = i === cursorIndex
const moveInfo = move.id ? Dex.moves.get(move.id) : null
return (
<Box key={i}>
{isSelected ? (
<Text color="success" bold>
{' ▶ '}{moveInfo?.name ?? move.id ?? '---'}
</Text>
) : (
<Text>
{' '}{moveInfo?.name ?? move.id ?? '---'}
</Text>
)}
<Text dimColor> PP {move.pp}/{move.maxPp}</Text>
{isSelected && <Text color="warning"> {'<-- 替换'}</Text>}
</Box>
)
})}
</Box>
<Box marginTop={1}>
<Text dimColor>[] · [Enter] · [S] </Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,188 @@
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 { getSpeciesData } from '../dex/species'
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 BAR_WIDTH = 30
/** Gen ranges for stats */
const GEN_RANGES = [
{ label: 'Gen I', start: 1, end: 151 },
{ label: 'Gen II', start: 152, end: 251 },
{ label: 'Gen III', start: 252, end: 386 },
{ label: 'Gen IV', start: 387, end: 493 },
{ label: 'Gen V', start: 494, end: 649 },
{ label: 'Gen VI', start: 650, end: 721 },
{ label: 'Gen VII', start: 722, end: 809 },
{ label: 'Gen VIII',start: 810, end: 905 },
{ label: 'Gen IX', start: 906, end: 1025 },
]
interface PokedexViewProps {
buddyData: BuddyData
}
/**
* Pokédex view — shows collection progress, per-gen stats,
* and discovered species list.
*/
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
const percent = total > 0 ? collected / total : 0
// Build dex number set for quick lookup
const collectedNums = new Set<number>()
for (const entry of buddyData.dex) {
const data = getSpeciesData(entry.speciesId)
collectedNums.add(data.dexNumber)
}
// Per-gen stats
const genStats = GEN_RANGES.map(g => {
const genTotal = ALL_SPECIES_IDS.filter(id => {
const n = getSpeciesData(id).dexNumber
return n >= g.start && n <= g.end
}).length
const genCollected = [...collectedNums].filter(n => n >= g.start && n <= g.end).length
return { ...g, total: genTotal, collected: genCollected }
})
// Discovered species (for compact display)
const discovered = buddyData.dex
.map(entry => {
const species = getSpeciesData(entry.speciesId)
return { entry, species }
})
.sort((a, b) => a.species.dexNumber - b.species.dexNumber)
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header with percentage */}
<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 bold color={GREEN}>{(percent * 100).toFixed(1)}%</Text>
</Text>
</Box>
{/* Fixed-width progress bar */}
<Box>
<Text color={GREEN}>{'█'.repeat(Math.round(percent * BAR_WIDTH))}</Text>
<Text color={GRAY}>{'░'.repeat(BAR_WIDTH - Math.round(percent * BAR_WIDTH))}</Text>
<Text> {Math.floor(percent * 100)}%</Text>
</Box>
{/* Per-gen stats */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> </Text>
{genStats.map(g => {
const p = g.total > 0 ? g.collected / g.total : 0
const miniBar = '█'.repeat(Math.round(p * 10)) + '░'.repeat(10 - Math.round(p * 10))
return (
<Box key={g.label}>
<Text color={GRAY}>{g.label.padEnd(8)}</Text>
<Text color={p >= 1 ? GREEN : p > 0 ? YELLOW : GRAY}>{miniBar}</Text>
<Text> <Text bold>{g.collected}</Text><Text color={GRAY}>/{g.total}</Text></Text>
</Box>
)
})}
</Box>
{/* Discovered species list */}
{discovered.length > 0 && (
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> ({discovered.length}) </Text>
{discovered.map(({ entry, species }) => {
const isActive = buddyData.party[0]
? buddyData.creatures.some(c => c.id === buddyData.party[0] && c.speciesId === species.id)
: false
return (
<Box key={species.id}>
<Text>{isActive ? <Text color={YELLOW}></Text> : ' '}</Text>
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
<Text color={WHITE} bold={isActive}>
{species.name}
</Text>
<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>
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
{entry.caughtCount > 1 && (
<Text color={GRAY}> x{entry.caughtCount}</Text>
)}
</Box>
)
})}
</Box>
)}
{discovered.length === 0 && (
<Box marginTop={0}>
<Text dimColor> </Text>
</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'
}

View File

@@ -0,0 +1,176 @@
import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId, StatName } from '../types'
import { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../dex/species'
import { getNextEvolution } from '../dex/evolution'
import { StatBar } from './StatBar'
import { getStatColor } from './shared'
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 = getSpeciesData(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.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 }) {
// Walk back to find chain head
let head: SpeciesId = speciesId
for (const candidate of ALL_SPECIES_IDS) {
const evo = getNextEvolution(candidate)
if (evo?.to === head) {
head = candidate
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}>
{getSpeciesData(sid).name}
</Text>
{i < chain.length - 1 && getNextEvolution(sid) && (
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>
)}
</React.Fragment>
))}
</Box>
)
}

View File

@@ -0,0 +1,79 @@
import React, { useState, useMemo, useCallback } from 'react'
import { Box, Text, FuzzyPicker } from '@anthropic/ink'
import type { SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../dex/species'
/** Pre-computed species entry for picker */
type SpeciesEntry = {
id: SpeciesId
name: string
displayName: string // zh name or English name
dexNumber: number
types: string[]
}
// Build all entries once (species data is cached internally by getSpeciesData)
const ALL_ENTRIES: SpeciesEntry[] = ALL_SPECIES_IDS.map(id => {
const data = getSpeciesData(id)
return {
id,
name: data.name,
displayName: data.name,
dexNumber: data.dexNumber,
types: data.types as string[],
}
})
/** Searchable species picker using FuzzyPicker */
export function SpeciesPicker({
onSelect,
onCancel,
title = '选择精灵',
}: {
onSelect: (speciesId: SpeciesId) => void
onCancel: () => void
title?: string
}) {
const [filtered, setFiltered] = useState<SpeciesEntry[]>(ALL_ENTRIES.slice(0, 50))
const handleQueryChange = useCallback((q: string) => {
if (!q.trim()) {
setFiltered(ALL_ENTRIES.slice(0, 50))
return
}
const lower = q.toLowerCase()
const matched = ALL_ENTRIES.filter(e =>
e.id.includes(lower) ||
e.name.toLowerCase().includes(lower) ||
e.displayName.includes(q) ||
String(e.dexNumber).includes(q)
)
setFiltered(matched.slice(0, 100))
}, [])
return (
<FuzzyPicker<SpeciesEntry>
title={title}
placeholder="输入名称或编号搜索…"
items={filtered}
getKey={item => item.id}
renderItem={(item, focused) => (
<Box>
<Text color={focused ? 'claude' : undefined} bold={focused}>
#{String(item.dexNumber).padStart(3, '0')} {item.displayName}
</Text>
{item.displayName !== item.name && (
<Text dimColor> {item.name}</Text>
)}
<Text color="inactive"> {item.types.join('/')}</Text>
</Box>
)}
onQueryChange={handleQueryChange}
onSelect={item => onSelect(item.id)}
onCancel={onCancel}
emptyMessage={q => `没有找到 "${q}" 相关的精灵`}
matchLabel={filtered.length < ALL_ENTRIES.length ? `${filtered.length} 个结果` : undefined}
/>
)
}

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useState, useMemo } from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { AnimMode } from '../types'
import { renderAnimatedSprite, flipSpriteLines, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
/** Vertical padding — bounce shifts within this space */
const V_PAD = 4
interface SpriteAnimatorProps {
/** Base sprite lines (ANSI is preserved) */
lines: string[]
/** Text color for the sprite */
color?: Color
/** Tick interval in ms (default 250) */
tickMs?: number
/** Single mode; omit for idle auto-play */
mode?: AnimMode
/** Center horizontally (default true) */
centered?: boolean
/** Show pet hearts overlay */
petting?: boolean
/** Flip horizontally (for player Pokemon facing opponent) */
flip?: boolean
}
/**
* Animated sprite renderer with built-in tick loop.
*
* - Keeps ANSI intact (parse → pixel grid → transform → render)
* - Pads vertically so bounce never shifts layout
* - Grid transforms guarantee fixed output height
*/
export function SpriteAnimator({
lines,
color,
tickMs = 100,
mode,
centered = true,
petting,
flip,
}: SpriteAnimatorProps) {
const [tick, setTick] = useState(0)
useEffect(() => {
const timer = setInterval(() => setTick(t => t + 1), tickMs)
return () => clearInterval(timer)
}, [tickMs])
// Flip sprite if needed (cached)
const sourceLines = useMemo(
() => flip ? flipSpriteLines(lines) : lines,
[lines, flip],
)
// Add vertical padding — bounce shifts within this space
const padded = [...Array(V_PAD).fill(''), ...sourceLines, ...Array(V_PAD).fill('')]
// Apply animation (renderer parses to pixels, transforms, renders back)
const currentMode = mode ?? getIdleAnimMode(tick)
const animated = renderAnimatedSprite(padded, tick, currentMode)
// Pet hearts overlay
const overlay = petting ? getPetOverlay(tick) : null
const displayLines = overlay ? [...overlay, ...animated] : animated
const spriteBlock = (
<Box flexDirection="column">
{displayLines.map((line, i) => (
<Text key={i} color={color}>{line || ' '}</Text>
))}
</Box>
)
if (!centered) return spriteBlock
return (
<Box flexDirection="row" justifyContent="center" width="100%">
{spriteBlock}
</Box>
)
}

View 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>
)
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { Creature } from '../types'
import { getCreatureName } from '../core/creature'
interface SwitchPanelProps {
party: Creature[]
activeId: string
cursorIndex: number
/** HP values from battle state (keyed by creature id) */
battleHp?: Record<string, { hp: number; maxHp: number }>
onSelect: (creatureId: string, partyIndex: number) => void
onCancel: () => void
}
function hpBarSmall(current: number, max: number): string {
if (max <= 0) return '░░░░░░'
const filled = Math.round((current / max) * 6)
return '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 6 - filled))
}
function hpColorStr(pct: number): string {
if (pct > 50) return 'success'
if (pct > 25) return 'warning'
return 'error'
}
export function SwitchPanel({ party, activeId, cursorIndex, battleHp, onCancel }: SwitchPanelProps) {
return (
<Box flexDirection="column">
<Box
flexDirection="column"
borderStyle="round"
borderColor="success"
borderText={{ content: ' 换人 ', position: 'top', align: 'start' }}
paddingX={1}
>
{party.map((creature, i) => {
const isActive = creature.id === activeId
const hpData = battleHp?.[creature.id]
const hp = hpData?.hp ?? 0
const maxHp = hpData?.maxHp ?? 1
const hpPct = maxHp > 0 ? Math.round((hp / maxHp) * 100) : 0
const isFainted = hpData ? hp <= 0 : false
return (
<Box key={creature.id}>
{cursorIndex === i ? (
<Text color="success" bold>
{' ▸ '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' '}
</Text>
) : isActive ? (
<Text dimColor>
{' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' (场上) '}
</Text>
) : isFainted ? (
<Text dimColor>
{' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' (倒下) '}
</Text>
) : (
<Text>
{' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' '}
</Text>
)}
{hpData && (
<Text color={hpColorStr(hpPct) as any}>
{hpBarSmall(hp, maxHp)} {hp}/{maxHp}
</Text>
)}
</Box>
)
})}
</Box>
<Box marginTop={1}>
<Text dimColor> [ESC] </Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,14 @@
import type { Color } from '@anthropic/ink'
const STAT_COLORS: Record<string, Color> = {
hp: 'ansi:green',
attack: 'ansi:red',
defense: 'ansi:yellow',
spAtk: 'ansi:blue',
spDef: 'ansi:magenta',
speed: 'ansi:cyan',
}
export function getStatColor(stat: string): Color {
return STAT_COLORS[stat] ?? 'ansi:white'
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

View File

@@ -112,6 +112,9 @@ export type SetToolJSXFn = (
isImmediate?: boolean
/** Set to true to clear a local JSX command (e.g., from its onDone callback) */
clearLocalJSX?: boolean
/** Called when the panel is dismissed externally (e.g. ESC via CancelRequestHandler).
* Must resolve the underlying Promise in processSlashCommand.tsx. */
onDismiss?: () => void
} | null,
) => void

View File

@@ -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>
&quot;{companion.personality}&quot;
</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>
);
}

View File

@@ -1,347 +1,342 @@
import { feature } from 'bun:bundle'
import figures from 'figures'
import React, { useEffect, useRef, useState } from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { Box, Text, stringWidth } from '@anthropic/ink'
import { useAppState, useSetAppState } from '../state/AppState.js'
import type { AppState } from '../state/AppStateStore.js'
import { getGlobalConfig } from '../utils/config.js'
import { isFullscreenActive } from '../utils/fullscreen.js'
import type { Theme } from '../utils/theme.js'
import { getCompanion } from './companion.js'
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
import { RARITY_COLORS } from './types.js'
import { feature } from 'bun:bundle';
import figures from 'figures';
import React, { useEffect, useRef, useState } from 'react';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { Box, Text, stringWidth } from '@anthropic/ink';
import { useAppState, useSetAppState } from '../state/AppState.js';
import type { AppState } from '../state/AppStateStore.js';
import { getGlobalConfig } from '../utils/config.js';
import { isFullscreenActive } from '../utils/fullscreen.js';
import {
loadBuddyData,
getActiveCreature,
getCreatureName,
getXpProgress,
loadSprite,
fetchAndCacheSprite,
getFallbackSprite,
renderAnimatedSprite,
getIdleAnimMode,
getSpeciesData,
type Creature,
type AnimMode,
} from '@claude-code-best/pokemon';
const TICK_MS = 500
const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms
const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go
const PET_BURST_MS = 2500 // how long hearts float after /buddy pet
const TICK_MS = 500;
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
// Module-level cache for sync access in render
let _cachedCreature: Creature | null = null;
let _cacheLoadPromise: Promise<void> | null = null;
function ensureCreatureCache(): void {
if (_cachedCreature !== null || _cacheLoadPromise) return;
_cacheLoadPromise = loadBuddyData().then(data => {
_cachedCreature = getActiveCreature(data);
_cacheLoadPromise = null;
}).catch(() => { _cacheLoadPromise = null; });
}
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
const H = figures.heart
const H = figures.heart;
const PET_HEARTS = [
` ${H} ${H} `,
` ${H} ${H} ${H} `,
` ${H} ${H} ${H} `,
`${H} ${H} ${H} `,
'· · · ',
]
];
function wrap(text: string, width: number): string[] {
const words = text.split(' ')
const lines: string[] = []
let cur = ''
const words = text.split(' ');
const lines: string[] = [];
let cur = '';
for (const w of words) {
if (cur.length + w.length + 1 > width && cur) {
lines.push(cur)
cur = w
lines.push(cur);
cur = w;
} else {
cur = cur ? `${cur} ${w}` : w
cur = cur ? `${cur} ${w}` : w;
}
}
if (cur) lines.push(cur)
return lines
if (cur) lines.push(cur);
return lines;
}
function SpeechBubble({
text,
color,
fading,
tail,
}: {
text: string
color: keyof Theme
fading: boolean
tail: 'down' | 'right'
}): React.ReactNode {
const lines = wrap(text, 30)
const borderColor = fading ? 'inactive' : color
function SpeechBubble({ text, fading }: { text: string; fading: boolean; tail: 'down' | 'right' }): React.ReactNode {
const lines = wrap(text, 30);
const borderColor = fading ? 'inactive' : 'claude';
const bubble = (
<Box
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
paddingX={1}
width={34}
>
<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}
>
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
{l}
</Text>
))}
</Box>
)
if (tail === 'right') {
return (
<Box flexDirection="row" alignItems="center">
{bubble}
<Text color={borderColor}></Text>
</Box>
)
}
);
return (
<Box flexDirection="row" alignItems="center">
{bubble}
<Text color={borderColor}></Text>
</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 (
<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}>
<Text color={borderColor}> </Text>
<Text color={borderColor}></Text>
</Box>
</Box>
)
);
}
export const MIN_COLS_FOR_FULL_SPRITE = 100
const SPRITE_BODY_WIDTH = 12
const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
const SPRITE_PADDING_X = 2
const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
const NARROW_QUIP_CAP = 24
export const MIN_COLS_FOR_FULL_SPRITE = 100;
const SPRITE_BODY_WIDTH = 12;
const NAME_ROW_PAD = 2;
const SPRITE_PADDING_X = 2;
const BUBBLE_WIDTH = 36;
const NARROW_QUIP_CAP = 24;
function spriteColWidth(nameWidth: number): number {
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD)
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD);
}
// Width the sprite area consumes. PromptInput subtracts this so text wraps
// correctly. In fullscreen the bubble floats over scrollback (no extra
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
// (above input in fullscreen, below in scrollback), so no reservation.
export function companionReservedColumns(
terminalColumns: number,
speaking: boolean,
): number {
if (!feature('BUDDY')) return 0
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return 0
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
const nameWidth = stringWidth(companion.name)
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble
/**
* Get active Pokémon creature from cache, or null if not loaded yet.
* Triggers async load if cache is empty.
*/
function getPokemonCreature(): Creature | null {
try {
ensureCreatureCache();
return _cachedCreature;
} catch {
return null;
}
}
/**
* Force-refresh the creature cache (call after data changes).
*/
export function refreshCreatureCache(): void {
_cachedCreature = null;
_cacheLoadPromise = null;
ensureCreatureCache();
}
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
if (!feature('BUDDY')) return 0;
const creature = getPokemonCreature();
if (!creature || getGlobalConfig().companionMuted) return 0;
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
const name = getCreatureName(creature);
const nameWidth = stringWidth(name);
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
// Without sprite art, only need name row width + padding + optional bubble
return nameWidth + NAME_ROW_PAD + SPRITE_PADDING_X + bubble;
}
/**
* Get sprite lines for a creature with animated mode applied.
*/
function getAnimatedSpriteLines(creature: Creature, tick: number, mode: AnimMode): string[] {
const cached = loadSprite(creature.speciesId);
const baseLines = cached?.lines ?? getFallbackSprite(creature.speciesId);
return renderAnimatedSprite(baseLines, tick, mode);
}
export function CompanionSprite(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction)
const petAt = useAppState(s => s.companionPetAt)
const focused = useAppState(s => s.footerSelection === 'companion')
const setAppState = useSetAppState()
const { columns } = useTerminalSize()
const [tick, setTick] = useState(0)
const lastSpokeTick = useRef(0)
// Sync-during-render (not useEffect) so the first post-pet render already
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
const reaction = useAppState(s => s.companionReaction);
const petAt = useAppState(s => s.companionPetAt);
const xpInfo = useAppState(s => s.companionXpInfo);
const focused = useAppState(s => s.footerSelection === 'companion');
// Subscribe to creature changes so we re-render immediately after switch
const creatureChangedAt = useAppState(s => s.companionCreatureChangedAt);
const setAppState = useSetAppState();
const { columns } = useTerminalSize();
const [tick, setTick] = useState(0);
const [spriteTick, setSpriteTick] = useState(0);
// Prefetch sprite when creature changes
useEffect(() => {
const c = getPokemonCreature();
if (!c || loadSprite(c.speciesId)) return;
fetchAndCacheSprite(c.speciesId).then(s => { if (s) setSpriteTick(t => t + 1) });
}, [creatureChangedAt]);
void spriteTick;
const lastSpokeTick = useRef(0);
const [{ petStartTick, forPetAt }, setPetStart] = useState({
petStartTick: 0,
forPetAt: petAt,
})
});
if (petAt !== forPetAt) {
setPetStart({ petStartTick: tick, forPetAt: petAt })
setPetStart({ petStartTick: tick, forPetAt: petAt });
}
useEffect(() => {
const timer = setInterval(
setT => setT((t: number) => t + 1),
(setT: React.Dispatch<React.SetStateAction<number>>) => setT((t: number) => t + 1),
TICK_MS,
setTick,
)
return () => clearInterval(timer)
}, [])
);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (!reaction) return
lastSpokeTick.current = tick
if (!reaction) return;
lastSpokeTick.current = tick;
const timer = setTimeout(
setA =>
(setA: React.Dispatch<React.SetStateAction<AppState>>) =>
setA((prev: AppState) =>
prev.companionReaction === undefined
? prev
: { ...prev, companionReaction: undefined },
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined },
),
BUBBLE_SHOW * TICK_MS,
setAppState,
)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
}, [reaction, setAppState])
);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reaction, setAppState]);
if (!feature('BUDDY')) return null
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return null
if (!feature('BUDDY')) return null;
const creature = getPokemonCreature();
if (!creature || getGlobalConfig().companionMuted) return null;
const color = RARITY_COLORS[companion.rarity]
const colWidth = spriteColWidth(stringWidth(companion.name))
const species = getSpeciesData(creature.speciesId);
const name = getCreatureName(creature);
const color = creature.isShiny ? 'warning' : 'claude';
const colWidth = spriteColWidth(stringWidth(name));
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
const fading =
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
const petAge = petAt ? tick - petStartTick : Infinity
const petting = petAge * TICK_MS < PET_BURST_MS
const petAge = petAt ? tick - petStartTick : Infinity;
const petting = petAge * TICK_MS < PET_BURST_MS;
// Narrow terminals: collapse to one-line face. When speaking, the quip
// replaces the name beside the face (no room for a bubble).
// Narrow terminals: collapse to one-line face
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
const quip =
reaction && reaction.length > NARROW_QUIP_CAP
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
: reaction
const label = quip
? `"${quip}"`
: focused
? ` ${companion.name} `
: companion.name
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
const label = quip ? `"${quip}"` : focused ? ` ${name} ` : name;
const xpLabel = xpInfo
? xpInfo.leveledUp
? ` ↑Lv.${xpInfo.level}`
: ` Lv.${xpInfo.level} +${xpInfo.xpGained}xp`
: creature.level > 1
? ` Lv.${creature.level}`
: '';
return (
<Box paddingX={1} alignSelf="flex-end">
<Text>
{petting && <Text color="autoAccept">{figures.heart} </Text>}
<Text bold color={color}>
{renderFace(companion)}
{species.names.zh ?? species.name}
</Text>{' '}
<Text
italic
dimColor={!focused && !reaction}
bold={focused}
inverse={focused && !reaction}
color={
reaction
? fading
? 'inactive'
: color
: focused
? color
: undefined
}
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined}
>
{label}
</Text>
{xpLabel && (
<Text dimColor bold={xpInfo?.leveledUp} color={xpInfo?.leveledUp ? 'warning' : 'inactive'}>
{xpLabel}
</Text>
)}
</Text>
</Box>
)
);
}
const frameCount = spriteFrameCount(companion.species)
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
let spriteFrame: number
let blink = false
// Determine animation mode
let animMode: AnimMode = 'idle';
if (reaction || petting) {
// Excited: cycle all fidget frames fast
spriteFrame = tick % frameCount
animMode = 'excited';
} else {
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
if (step === -1) {
spriteFrame = 0
blink = true
} else {
spriteFrame = step % frameCount
}
animMode = getIdleAnimMode(tick);
if (petting) animMode = 'pet';
}
const body = renderSprite(companion, spriteFrame).map(line =>
blink ? line.replaceAll(companion.eye, '-') : line,
)
const sprite = heartFrame ? [heartFrame, ...body] : body
const spriteLines = getAnimatedSpriteLines(creature, tick, animMode);
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
const displayLines = heartFrame ? [heartFrame, ...spriteLines] : spriteLines;
const xpStatus = xpInfo
? xpInfo.leveledUp
? `↑Lv.${xpInfo.level}`
: `+${xpInfo.xpGained}xp`
: null;
// 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 = (
<Box
flexDirection="column"
flexShrink={0}
alignItems="center"
width={colWidth}
>
{sprite.map((line, i) => (
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
{line}
</Text>
))}
<Text
italic
bold={focused}
dimColor={!focused}
color={focused ? color : undefined}
inverse={focused}
>
{focused ? ` ${companion.name} ` : companion.name}
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
{focused ? ` ${name} ` : name}
</Text>
<Text dimColor color={xpInfo?.leveledUp ? 'warning' : 'inactive'}>
Lv.{creature.level} {xpStatus ?? ''}
</Text>
</Box>
)
);
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()) {
return <Box paddingX={1}>{spriteColumn}</Box>
return <Box paddingX={1}>{spriteColumn}</Box>;
}
return (
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
<SpeechBubble
text={reaction}
color={color}
fading={fading}
tail="right"
/>
<SpeechBubble text={reaction} fading={fading} tail="right" />
{spriteColumn}
</Box>
)
);
}
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
// bottomFloat slot (outside the overflowY:hidden clip) so it can extend into
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
// just reads companionReaction and renders the fade.
// Floating bubble overlay for fullscreen mode
export function CompanionFloatingBubble(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction)
const reaction = useAppState(s => s.companionReaction);
const _creatureChangedAt = useAppState(s => s.companionCreatureChangedAt);
const [{ tick, forReaction }, setTick] = useState({
tick: 0,
forReaction: reaction,
})
});
// Reset tick synchronously when reaction changes (not in useEffect, which
// runs post-render and would show one stale-faded frame). Storing the
// reaction the tick is counting FOR alongside the tick itself means the
// fade computation never sees a tick from a previous reaction.
if (reaction !== forReaction) {
setTick({ tick: 0, forReaction: reaction })
setTick({ tick: 0, forReaction: reaction });
}
useEffect(() => {
if (!reaction) return
if (!reaction) return;
const timer = setInterval(
set => set(s => ({ ...s, tick: s.tick + 1 })),
(set: React.Dispatch<React.SetStateAction<{ tick: number; forReaction: string | undefined }>>) =>
set(s => ({ ...s, tick: s.tick + 1 })),
TICK_MS,
setTick,
)
return () => clearInterval(timer)
}, [reaction])
);
return () => clearInterval(timer);
}, [reaction]);
if (!feature('BUDDY') || !reaction) return null
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return null
if (!feature('BUDDY') || !reaction) return null;
const creature = getPokemonCreature();
if (!creature || getGlobalConfig().companionMuted) return null;
return (
<SpeechBubble
text={reaction}
color={RARITY_COLORS[companion.rarity]}
fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
tail="down"
/>
)
return <FloatingBubble text={reaction} fading={tick >= BUBBLE_SHOW - FADE_WINDOW} />;
}

View File

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

View File

@@ -1,21 +1,28 @@
/**
* Companion reaction system — aligns with official ZUK + Dc8 pattern.
* Companion reaction system — adapted for Pokémon buddy system.
*
* Called from REPL.tsx after each query turn. Checks mute state, frequency
* limits, and @-mention detection, then calls the buddy_react API to
* generate a reaction shown in the CompanionSprite speech bubble.
*/
import { getCompanion } from './companion.js'
import { getGlobalConfig } from '../utils/config.js'
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
import { getOauthConfig } from '../constants/oauth.js'
import { getUserAgent } from '../utils/http.js'
import type { Message } from '../types/message.js'
import {
loadBuddyData,
getActiveCreature,
getCreatureName,
calculateStats,
getSpeciesData,
type Creature,
} from '@claude-code-best/pokemon'
// ─── Rate limiting ──────────────────────────────────
let lastReactTime = 0
const MIN_INTERVAL_MS = 45_000 // official is roughly 30-60s
const MIN_INTERVAL_MS = 45_000
// ─── Recent reactions (avoid repetition) ────────────
@@ -26,23 +33,17 @@ const MAX_RECENT = 8
/**
* Trigger a companion reaction after a query turn.
*
* Mirrors official `ZUK()`:
* 1. Check companion exists and is not muted
* 2. Detect if user @-mentioned companion by name
* 3. Apply rate limiting (skip if not addressed and too soon)
* 4. Build conversation transcript
* 5. Call buddy_react API
* 6. Pass reaction text to setReaction callback
*/
export function triggerCompanionReaction(
export async function triggerCompanionReaction(
messages: Message[],
setReaction: (text: string | undefined) => void,
): void {
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return
): Promise<void> {
const data = await loadBuddyData()
const creature = getActiveCreature(data)
if (!creature || getGlobalConfig().companionMuted) return
const addressed = isAddressed(messages, companion.name)
const name = getCreatureName(creature)
const addressed = isAddressed(messages, name)
const now = Date.now()
if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return
@@ -52,7 +53,7 @@ export function triggerCompanionReaction(
lastReactTime = now
void callBuddyReactAPI(companion, transcript, addressed)
void callBuddyReactAPI(creature, transcript, addressed)
.then(reaction => {
if (!reaction) return
recentReactions.push(reaction)
@@ -109,13 +110,7 @@ function buildTranscript(messages: Message[]): string {
// ─── API call ───────────────────────────────────────
async function callBuddyReactAPI(
companion: {
name: string
personality: string
species: string
rarity: string
stats: Record<string, number>
},
creature: Creature,
transcript: string,
addressed: boolean,
): Promise<string | null> {
@@ -125,6 +120,10 @@ async function callBuddyReactAPI(
const orgId = getGlobalConfig().oauthAccount?.organizationUuid
if (!orgId) return null
const species = getSpeciesData(creature.speciesId)
const name = getCreatureName(creature)
const stats = calculateStats(creature)
const baseUrl = getOauthConfig().BASE_API_URL
const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react`
@@ -136,11 +135,25 @@ async function callBuddyReactAPI(
'User-Agent': getUserAgent(),
},
body: JSON.stringify({
name: companion.name.slice(0, 32),
personality: companion.personality.slice(0, 200),
species: companion.species,
rarity: companion.rarity,
stats: companion.stats,
name: name.slice(0, 32),
personality: species.personality.slice(0, 200),
species: creature.speciesId,
rarity: creature.isShiny
? 'legendary'
: creature.level >= 36
? 'epic'
: creature.level >= 16
? 'rare'
: 'common',
stats: {
HP: stats.hp,
ATK: stats.attack,
DEF: stats.defense,
SPA: stats.spAtk,
SPD: stats.spDef,
SPE: stats.speed,
Level: creature.level,
},
transcript,
reason: addressed ? 'addressed' : 'turn',
recent: recentReactions.map(r => r.slice(0, 200)),

View File

@@ -2,35 +2,44 @@ import { feature } from 'bun:bundle'
import type { Message } from '../types/message.js'
import type { Attachment } from '../utils/attachments.js'
import { getGlobalConfig } from '../utils/config.js'
import { getCompanion } from './companion.js'
import {
loadBuddyData,
getActiveCreature,
getCreatureName,
getSpeciesData,
} from '@claude-code-best/pokemon'
export function companionIntroText(name: string, species: string): string {
return `# Companion
A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
A ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`
}
export function getCompanionIntroAttachment(
export async function getCompanionIntroAttachment(
messages: Message[] | undefined,
): Attachment[] {
): Promise<Attachment[]> {
if (!feature('BUDDY')) return []
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return []
const data = await loadBuddyData()
const creature = getActiveCreature(data)
if (!creature || getGlobalConfig().companionMuted) return []
const name = getCreatureName(creature)
const species = getSpeciesData(creature.speciesId)
// Skip if already announced for this companion.
for (const msg of messages ?? []) {
if (msg.type !== 'attachment') continue
if (msg.attachment!.type !== 'companion_intro') continue
if (msg.attachment!.name === companion.name) return []
if ((msg as any).attachment?.type !== 'companion_intro') continue
if ((msg as any).attachment?.name === name) return []
}
return [
{
type: 'companion_intro',
name: companion.name,
species: companion.species,
name,
species: species.names.zh ?? species.name,
},
]
}

View File

@@ -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})`
}
}

View File

@@ -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>

View File

@@ -155,6 +155,11 @@ const buddy = feature('BUDDY')
require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
).default
: null
const pokemonBattle = feature('BUDDY')
? (
require('./commands/pokemon-battle/index.js') as typeof import('./commands/pokemon-battle/index.js')
).default
: null
const poor = feature('POOR')
? (
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
@@ -364,6 +369,7 @@ const COMMANDS = memoize((): Command[] => [
...(webCmd ? [webCmd] : []),
...(forkCmd ? [forkCmd] : []),
...(buddy ? [buddy] : []),
...(pokemonBattle ? [pokemonBattle] : []),
...(poor ? [poor] : []),
...(proactive ? [proactive] : []),
...(monitorCmd ? [monitorCmd] : []),

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,4 @@
import React from 'react'
import {
getCompanion,
rollWithSeed,
generateSeed,
} from '../../buddy/companion.js'
import { type StoredCompanion, RARITY_STARS } from '../../buddy/types.js'
import { renderSprite } from '../../buddy/sprites.js'
import { CompanionCard } from '../../buddy/CompanionCard.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { triggerCompanionReaction } from '../../buddy/companionReact.js'
import type { ToolUseContext } from '../../Tool.js'
@@ -14,57 +6,50 @@ import type {
LocalJSXCommandContext,
LocalJSXCommandOnDone,
} from '../../types/command.js'
import {
loadBuddyData,
saveBuddyData,
getDefaultBuddyData,
migrateFromLegacy,
getActiveCreature,
getCreatureName,
awardXP,
advanceEggSteps,
checkEvolution,
checkEggEligibility,
generateEgg,
isEggReadyToHatch,
hatchEgg,
fetchAndCacheSprite,
loadSprite,
getFallbackSprite,
getSpeciesData,
generateCreature,
addToParty,
ALL_SPECIES_IDS,
type BuddyData,
type Creature,
type SpeciesId,
} 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> = {
duck: 'Waddles',
goose: 'Goosberry',
blob: 'Gooey',
cat: 'Whiskers',
dragon: 'Ember',
octopus: 'Inky',
owl: 'Hoots',
penguin: 'Waddleford',
turtle: 'Shelly',
snail: 'Trailblazer',
ghost: 'Casper',
axolotl: 'Axie',
capybara: 'Chill',
cactus: 'Spike',
robot: 'Byte',
rabbit: 'Flops',
mushroom: 'Spore',
chonk: 'Chonk',
}
/**
* Load or initialize Pokémon buddy data.
* Migrates from legacy buddy system if needed.
*/
async function getOrInitBuddyData(): Promise<BuddyData> {
let data = await loadBuddyData()
const SPECIES_PERSONALITY: Record<string, string> = {
duck: 'Quirky and easily amused. Leaves rubber duck debugging tips everywhere.',
goose: 'Assertive and honks at bad code. Takes no prisoners in code reviews.',
blob: 'Adaptable and goes with the flow. Sometimes splits into two when confused.',
cat: 'Independent and judgmental. Watches you type with mild disdain.',
dragon:
'Fiery and passionate about architecture. Hoards good variable names.',
octopus:
'Multitasker extraordinaire. Wraps tentacles around every problem at once.',
owl: 'Wise but verbose. Always says "let me think about that" for exactly 3 seconds.',
penguin: 'Cool under pressure. Slides gracefully through merge conflicts.',
turtle: 'Patient and thorough. Believes slow and steady wins the deploy.',
snail: 'Methodical and leaves a trail of useful comments. Never rushes.',
ghost:
'Ethereal and appears at the worst possible moments with spooky insights.',
axolotl: 'Regenerative and cheerful. Recovers from any bug with a smile.',
capybara: 'Zen master. Remains calm while everything around is on fire.',
cactus:
'Prickly on the outside but full of good intentions. Thrives on neglect.',
robot: 'Efficient and literal. Processes feedback in binary.',
rabbit: 'Energetic and hops between tasks. Finishes before you start.',
mushroom: 'Quietly insightful. Grows on you over time.',
chonk:
'Big, warm, and takes up the whole couch. Prioritizes comfort over elegance.',
}
// If no active creature (party empty), check for legacy companion to migrate
if (!getActiveCreature(data) || data.creatures.length === 0) {
const legacyCompanion = getGlobalConfig().companion
if (legacyCompanion) {
data = await migrateFromLegacy(legacyCompanion)
saveBuddyData(data)
}
}
function speciesLabel(species: string): string {
return species.charAt(0).toUpperCase() + species.slice(1)
return data
}
export async function call(
@@ -89,18 +74,45 @@ export async function call(
return null
}
// ── /buddy pet — trigger heart animation + auto unmute ──
// ── /buddy pet — trigger heart animation + XP + egg steps ──
if (sub === 'pet') {
const companion = getCompanion()
if (!companion) {
onDone('no companion yet \u00b7 run /buddy first', { display: 'system' })
const data = await getOrInitBuddyData()
const creature = getActiveCreature(data)
if (!creature) {
onDone('no companion yet · run /buddy first', { display: 'system' })
return null
}
// Auto-unmute on pet + trigger heart animation
// Auto-unmute + heart animation
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
setState?.(prev => ({ ...prev, companionPetAt: Date.now() }))
// Award pet XP
const result = awardXP(creature, 2)
data.creatures = data.creatures.map(c =>
c.id === creature.id ? result.creature : c,
)
// Advance egg steps
if (data.eggs.length > 0) {
data.eggs = data.eggs.map(egg => advanceEggSteps(egg, 5))
// Check hatch
const readyEgg = data.eggs.find(isEggReadyToHatch)
if (readyEgg) {
const { buddyData: updatedData, creature: newCreature } = await hatchEgg(
data,
readyEgg,
)
Object.assign(data, updatedData)
onDone(`🥚 Egg hatched! You got a ${getCreatureName(newCreature)}!`, {
display: 'system',
})
}
}
saveBuddyData(data)
// Trigger a post-pet reaction
triggerCompanionReaction(context.messages ?? [], reaction =>
setState?.(prev =>
@@ -110,60 +122,135 @@ export async function call(
),
)
onDone(`petted ${companion.name}`, { display: 'system' })
if (!data.eggs.find(isEggReadyToHatch)) {
onDone(`petted ${getCreatureName(creature)} (+2 XP)`, {
display: 'system',
})
}
return null
}
// ── /buddy (no args) — show existing or hatch ──
const companion = getCompanion()
// ── /buddy rename — rename current creature ──
if (sub.startsWith('rename ')) {
const nickname = sub.slice(7).trim()
if (!nickname) {
onDone('Usage: /buddy rename <name>', { display: 'system' })
return null
}
const data = await 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 give-me-pokemon <species> [level] — admin: grant any Pokémon ──
if (sub.startsWith('give-me-pokemon')) {
const parts = sub.split(/\s+/)
const speciesArg = parts[1]?.toLowerCase()
const levelArg = parts[2] ? parseInt(parts[2], 10) : undefined
if (!speciesArg) {
const available = ALL_SPECIES_IDS.join(', ')
onDone(`Usage: /buddy give-me-pokemon <species> [level]\nAvailable: ${available}`, { display: 'system' })
return null
}
// Validate species (match by partial name or full id)
const match = ALL_SPECIES_IDS.find(id =>
id === speciesArg || id.includes(speciesArg),
)
if (!match) {
onDone(`Unknown species "${speciesArg}". Available: ${ALL_SPECIES_IDS.join(', ')}`, { display: 'system' })
return null
}
const data = await getOrInitBuddyData()
// Create the creature
const creature = await generateCreature(match)
if (levelArg && !isNaN(levelArg) && levelArg >= 1 && levelArg <= 100) {
creature.level = levelArg
}
// Add to creatures and dex
data.creatures.push(creature)
const existingDex = data.dex.find(d => d.speciesId === match)
if (existingDex) {
existingDex.caughtCount++
existingDex.bestLevel = Math.max(existingDex.bestLevel, creature.level)
} else {
data.dex.push({
speciesId: match,
discoveredAt: Date.now(),
caughtCount: 1,
bestLevel: creature.level,
})
}
// Try to add to party (first empty slot)
const partyResult = addToParty(data, creature.id)
if (partyResult.added) {
Object.assign(data, partyResult.data)
}
// If party full, creature stays in creatures[] but not in party
saveBuddyData(data)
setState?.(prev => ({ ...prev, companionCreatureChangedAt: Date.now() }))
const species = getSpeciesData(match)
const name = creature.nickname ?? species.name
onDone(`Got ${name} (${species.names.zh ?? species.name}) Lv.${creature.level}!`, { display: 'system' })
return null
}
// ── /buddy (no args) — show unified BuddyPanel ──
const data = await getOrInitBuddyData()
let creature = getActiveCreature(data)
// Auto-unmute when viewing
if (companion && getGlobalConfig().companionMuted) {
if (getGlobalConfig().companionMuted) {
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
}
if (companion) {
// Return JSX card — matches official vc8 component
const lastReaction = context.getAppState?.()?.companionReaction
return React.createElement(CompanionCard, {
companion,
lastReaction,
onDone: onDone as unknown as Parameters<typeof CompanionCard>[0]['onDone'],
})
// No creature → initialize new one
if (!creature) {
const legacyCompanion = getGlobalConfig().companion
if (legacyCompanion) {
const migrated = await migrateFromLegacy(legacyCompanion)
saveBuddyData(migrated)
creature = getActiveCreature(migrated)!
} else {
const defaultData = await getDefaultBuddyData()
saveBuddyData(defaultData)
creature = getActiveCreature(defaultData)!
}
}
// ── No companion → hatch ──
const seed = generateSeed()
const r = rollWithSeed(seed)
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
const personality =
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
const stored: StoredCompanion = {
name,
personality,
seed,
hatchedAt: Date.now(),
// Pre-fetch sprite if not cached
const spriteCached = loadSprite(creature.speciesId)
if (!spriteCached) {
fetchAndCacheSprite(creature.speciesId).catch(() => {})
}
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
const spriteLines =
spriteCached?.lines ?? getFallbackSprite(creature.speciesId)
const stars = RARITY_STARS[r.bones.rarity]
const sprite = renderSprite(r.bones, 0)
const shiny = r.bones.shiny ? ' \u2728 Shiny!' : ''
// Reload data to get latest state after possible initialization
const latestData = await loadBuddyData()
const lines = [
'A wild companion appeared!',
'',
...sprite,
'',
`${name} the ${speciesLabel(r.bones.species)}${shiny}`,
`Rarity: ${stars} (${r.bones.rarity})`,
`"${personality}"`,
'',
'Your companion will now appear beside your input box!',
'Say its name to get its take \u00b7 /buddy pet \u00b7 /buddy off',
]
onDone(lines.join('\n'), { display: 'system' })
return null
return React.createElement(BuddyPanel, {
buddyData: latestData,
spriteLines,
onClose: () => {
onDone('buddy panel closed', { display: 'system' })
},
})
}

View File

@@ -4,8 +4,8 @@ import { isBuddyLive } from '../../buddy/useBuddyNotification.js'
const buddy = {
type: 'local-jsx',
name: 'buddy',
description: 'Hatch a coding companion · pet, off',
argumentHint: '[pet|off]',
description: 'Pokémon coding companion · pet, dex, egg, switch, rename, off',
argumentHint: '[pet|dex|egg|switch|rename <name>|on|off]',
immediate: true,
get isHidden() {
return !isBuddyLive()

View File

@@ -0,0 +1,15 @@
import type { Command } from '../../commands.js'
import { isBuddyLive } from '../../buddy/useBuddyNotification.js'
const pokemonBattle = {
type: 'local-jsx',
name: 'pokemon-battle',
description: 'Start a Pokémon battle',
immediate: true,
get isHidden() {
return !isBuddyLive()
},
load: () => import('./pokemon-battle.js'),
} satisfies Command
export default pokemonBattle

View File

@@ -0,0 +1,68 @@
import React, { useRef } from 'react'
import { useInput, useRegisterKeybindingContext } from '@anthropic/ink'
import {
loadBuddyData,
getActiveCreature,
BattleFlow,
type BuddyData,
type BattleFlowHandle,
} from '@claude-code-best/pokemon'
import type { ToolUseContext } from '../../Tool.js'
import type {
LocalJSXCommandContext,
LocalJSXCommandOnDone,
} from '../../types/command.js'
async function getOrInitBuddyData(): Promise<BuddyData> {
let data = await loadBuddyData()
if (!getActiveCreature(data)) {
data = await loadBuddyData()
}
return data
}
export async function call(
onDone: LocalJSXCommandOnDone,
_context: ToolUseContext & LocalJSXCommandContext,
_args: string,
): Promise<React.ReactNode> {
const data = await getOrInitBuddyData()
if (!getActiveCreature(data)) {
onDone('No companion yet · run /buddy first', { display: 'system' })
return null
}
return React.createElement(BattlePanel, {
buddyData: data,
onClose: () => {
onDone('battle closed', { display: 'system' })
},
})
}
function BattlePanel({
buddyData,
onClose,
}: {
buddyData: BuddyData
onClose: () => void
}) {
const inputRef = useRef<BattleFlowHandle | null>(null)
// Register keybinding context so our shortcuts take priority over Global
useRegisterKeybindingContext('Battle')
useInput((input, key, event) => {
// Consume ALL keyboard events to prevent PromptInput from intercepting
event.stopImmediatePropagation()
inputRef.current?.handleInput(input, key)
})
return React.createElement(BattleFlow, {
buddyData,
onClose,
isActive: true,
inputRef,
})
}

View File

@@ -2242,7 +2242,8 @@ function PromptInput({
showTeamsDialog ||
showQuickOpen ||
showGlobalSearch ||
showHistoryPicker
showHistoryPicker ||
isLocalJSXCommandActive
) {
return
}

View File

@@ -1,52 +1,84 @@
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import { CHANNEL_ARROW } from '../../constants/figures.js'
import { CHANNEL_TAG } from '../../constants/xml.js'
import { Box, Text } from '@anthropic/ink'
import { truncateToWidth } from '../../utils/format.js'
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import { CHANNEL_ARROW } from '../../constants/figures.js';
import { CHANNEL_TAG } from '../../constants/xml.js';
import { Box, Text } from '@anthropic/ink';
import { truncateToWidth } from '../../utils/format.js';
type Props = {
addMargin: boolean
param: TextBlockParam
}
addMargin: boolean;
param: TextBlockParam;
};
// <channel source="..." user="..." chat_id="...">content</channel>
// source is always first (wrapChannelMessage writes it), user is optional.
const CHANNEL_RE = new RegExp(
`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`,
)
const USER_ATTR_RE = /\buser="([^"]+)"/
const CHANNEL_RE = new RegExp(`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`);
const USER_ATTR_RE = /\buser="([^"]+)"/;
// Plugin-provided servers get names like plugin:slack-channel:slack via
// addPluginScopeToServers — show just the leaf. Matches the suffix-match
// logic in isServerInChannels.
function displayServerName(name: string): string {
const i = name.lastIndexOf(':')
return i === -1 ? name : name.slice(i + 1)
const i = name.lastIndexOf(':');
return i === -1 ? name : name.slice(i + 1);
}
const TRUNCATE_AT = 60
const MAX_LINE_WIDTH = 80;
const MAX_LINES = 3;
/**
* Formats multi-line channel content for compact display in the terminal.
* Collapses excessive blank lines, limits to MAX_LINES, truncates each line.
*/
function formatChannelBody(raw: string): { lines: string[]; truncated: boolean } {
const body = raw.trim();
// Split into lines, collapse runs of blank lines into single empty line
const allLines = body.split(/\n/).reduce<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 (
<Box marginTop={addMargin ? 1 : 0}>
<Text>
<Text color="suggestion">{CHANNEL_ARROW}</Text>{' '}
<Text dimColor>
{displayServerName(source ?? '')}
{user ? ` \u00b7 ${user}` : ''}:
</Text>{' '}
{truncated}
</Text>
<Box marginTop={addMargin ? 1 : 0} flexDirection="column">
{lines.map((line, i) => (
<Box key={i}>
{i === 0 ? (
<Text>
<Text color="suggestion">{CHANNEL_ARROW}</Text>{' '}
<Text dimColor>
{displayServerName(source ?? '')}
{user ? ` \u00b7 ${user}` : ''}:
</Text>{' '}
{line}
{truncated && i === lines.length - 1 ? ' …' : ''}
</Text>
) : (
<Text>
{' '}
{line}
{truncated && i === lines.length - 1 ? ' …' : ''}
</Text>
)}
</Box>
))}
</Box>
)
);
}

View File

@@ -49,6 +49,7 @@ type CancelRequestHandlerProps = {
popCommandFromQueue?: () => void
vimMode?: VimMode
isLocalJSXCommand?: boolean
onDismissLocalJSX?: () => void
isSearchingHistory?: boolean
isHelpOpen?: boolean
inputMode?: PromptInputMode
@@ -71,6 +72,7 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
popCommandFromQueue,
vimMode,
isLocalJSXCommand,
onDismissLocalJSX,
isSearchingHistory,
isHelpOpen,
inputMode,
@@ -92,6 +94,12 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}
// Priority 0: Dismiss local JSX command panel (e.g. /buddy, /config)
if (isLocalJSXCommand && onDismissLocalJSX) {
onDismissLocalJSX()
return
}
// Priority 1: If there's an active task running, cancel it first
// This takes precedence over queue management so users can always interrupt Claude
if (abortSignal !== undefined && !abortSignal.aborted) {
@@ -140,16 +148,16 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
screen !== 'transcript' &&
!isSearchingHistory &&
!isMessageSelectorVisible &&
!isLocalJSXCommand &&
!isHelpOpen &&
!isOverlayActive &&
!(isVimModeEnabled() && vimMode === 'INSERT')
// Escape (chat:cancel) defers to mode-exit when in special mode with empty
// input, and to useBackgroundTaskNavigation when viewing a teammate
// input, and to useBackgroundTaskNavigation when viewing a teammate.
// Also active when a local JSX command panel (e.g. /buddy) is showing.
const isEscapeActive =
isContextActive &&
(canCancelRunningTask || hasQueuedCommands) &&
(canCancelRunningTask || hasQueuedCommands || !!isLocalJSXCommand) &&
!isInSpecialModeWithEmptyInput &&
!isViewingTeammate

View File

@@ -1285,6 +1285,9 @@ export function REPL({
shouldContinueAnimation?: true;
showSpinner?: boolean;
isLocalJSXCommand: true;
/** Called when the panel is dismissed externally (ESC via CancelRequestHandler).
* Resolves the underlying Promise in processSlashCommand.tsx. */
onDismiss?: () => void;
} | null>(null);
// Wrapper for setToolJSX that preserves local JSX commands (like /btw).
@@ -1305,6 +1308,7 @@ export function REPL({
showSpinner?: boolean;
isLocalJSXCommand?: boolean;
clearLocalJSX?: boolean;
onDismiss?: () => void;
} | null,
) => {
// If setting a local JSX command, store it in the ref
@@ -1319,6 +1323,9 @@ export function REPL({
if (localJSXCommandRef.current) {
// Allow clearing only if explicitly requested (from onDone callbacks)
if (args?.clearLocalJSX) {
// Notify the command that its panel was dismissed externally (e.g. ESC)
// so it can resolve the underlying Promise and unblock executeUserInput.
localJSXCommandRef.current.onDismiss?.();
localJSXCommandRef.current = null;
setToolJSXInternal(null);
return;
@@ -2561,6 +2568,9 @@ export function REPL({
popCommandFromQueue: handleQueuedCommandOnCancel,
vimMode,
isLocalJSXCommand: toolJSX?.isLocalJSXCommand,
onDismissLocalJSX: useCallback(() => {
setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true });
}, [setToolJSX]),
isSearchingHistory,
isHelpOpen,
inputMode,
@@ -3431,6 +3441,84 @@ export function REPL({
// Log query profiling report if enabled
logQueryProfileReport();
// ── Buddy EV/XP/egg hook ──
if (feature('BUDDY')) {
try {
const {
loadBuddyData: _load,
saveBuddyData: _save,
getActiveCreature: _getActive,
awardXP: _awardXP,
awardTurnEV: _awardEV,
advanceEggSteps: _advSteps,
checkEvolution: _checkEvo,
checkEggEligibility: _checkEgg,
generateEgg: _genEgg,
isEggReadyToHatch: _isReady,
hatchEgg: _hatchEgg,
updateDailyStats: _updateDaily,
incrementTurns: _incTurns,
} = await import('@claude-code-best/pokemon');
const _data = _updateDaily(_incTurns(await _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.type === 'assistant' && Array.isArray((_msg as any).message?.content)) {
for (const _block of (_msg as any).message.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));
// 3b. Update companion XP info for status display
{
const { getXpProgress: _getXp } = await import('@claude-code-best/pokemon');
const _prog = _getXp(_xpResult.creature);
setAppState(prev => ({
...prev,
companionXpInfo: {
level: _xpResult.newLevel,
xpGained: 5 + _toolNames.length,
xpCurrent: _prog.current,
xpNeeded: _prog.needed,
leveledUp: _xpResult.leveledUp,
},
}));
}
// 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 } = await _hatchEgg(_data, _readyEgg);
Object.assign(_data, _hatched);
}
}
// 5. Check evolution
const _evoResult = _checkEvo(_xpResult.creature);
if (_evoResult) {
setAppState(prev => ({ ...prev, companionEvolving: { from: _evoResult.from, to: _evoResult.to } }));
}
// 6. Check egg eligibility
if (_checkEgg(_data)) {
_data.eggs.push(_genEgg(_data));
}
_save(_data);
}
} catch {
// Buddy system is non-critical; silently ignore errors
}
}
// Signal that a query turn has completed successfully
await onTurnComplete?.(messagesRef.current);
},

View File

@@ -169,6 +169,14 @@ export type AppState = DeepImmutable<{
companionReaction?: string
// Timestamp of last /buddy pet — CompanionSprite renders hearts while recent
companionPetAt?: number
// Pokémon evolution animation state
companionEvolving?: { from: string; to: string }
// Egg steps update counter (triggers UI refresh)
companionEggSteps?: number
// XP info for companion status display (set after each turn)
companionXpInfo?: { level: number; xpGained: number; xpCurrent: number; xpNeeded: number; leveledUp: boolean }
// Timestamp when active creature was switched — triggers CompanionSprite refresh
companionCreatureChangedAt?: number
// TODO (ashwin): see if we can use utility-types DeepReadonly for this
mcp: {
clients: MCPServerConnection[]

Some files were not shown because too many files have changed in this diff Show More