Compare commits

...

155 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
e38d45460e fix: 修复 Windows Node.js 构建产物因 stdin.ref() 泄漏导致进程挂起 (#353)
startCapturingEarlyInput() 调用 stdin.ref() 后,如果 Ink 未能接管
(如 raw mode 不支持或 setup 阶段异常),unref() 永远不会被调用,
导致 Node.js 事件循环无法退出。修复包括:
- stopCapturingEarlyInput() 中补充 stdin.unref() 调用
- 新增 10s 安全阀定时器自动清理 leaked ref()
- Ink App.componentWillUnmount 兜底 unref() 非 TTY stdin

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 21:16:15 +08:00
claude-code-best
e0c8e9dafc chore: 添加学习文件夹 2026-04-24 20:33:43 +08:00
claude-code-best
047c85fcbf fix: 修复 DeepSeek V4 reasoning_content 回传导致的 400 错误
- 扩大模型名称检测范围,匹配所有 deepseek 模型(V4、R1 等)
- 始终保留 thinking blocks 为 reasoning_content 回传给 API
- 移除有 bug 的 turn boundary 剥离逻辑

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 20:33:43 +08:00
claude-code-best
da6d06365d fix: 修复 anthropic 煞笔的四个 bug (#352)
* fix: 移除文件编辑前必须先读取的限制

移除 FileEditTool 和 FileWriteTool 中的 "read before edit" 校验,
允许直接编辑未读取过的文件。保留文件修改过期检测。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: 更新 teach-me 自动写 note 笔记的功能

* fix: 修复 DeepSeek V4 reasoning_content 回传导致的 400 错误

- 扩大模型名称检测范围,匹配所有 deepseek 模型(V4、R1 等)
- 始终保留 thinking blocks 为 reasoning_content 回传给 API
- 移除有 bug 的 turn boundary 剥离逻辑

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: Opus 4.6/4.7 默认推理 effort 从 medium 改为 high

Pro 和 Max/Team 订阅者的 Opus 默认 effort 之前被降级为 medium,
导致用户感知模型「变笨」。恢复为 high。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 移除 thinkingClearLatched sticky-on 机制

空闲超过 1 小时后 thinkingClearLatched 会被触发且永不重置,
导致每轮 API 调用都清除 thinking 历史。完整移除该 latch 机制,
clearAllThinking 硬编码为 false。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 移除 numeric_length_anchors 系统指令

删除「工具调用间文字 ≤25 词、最终回复 ≤100 词」的硬性限制。
ablation 测试显示该约束使整体智能下降 3%。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 修复测试中 reasoning_content 类型断言

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 20:07:18 +08:00
claude-code-best
8613d558a8 Merge pull request #350 from YuanyuanMa03/fix-bun-install-readme
docs: clarify Bun setup without duplicate steps
2026-04-24 19:47:35 +08:00
YuanyuanMa03
017c251f78 docs: clarify bun setup without duplicate steps 2026-04-24 18:03:21 +08:00
YYMa
d4223abc34 Merge pull request #1 from YuanyuanMa03/fix-bun-install-readme
docs: correct Bun post-install instructions
2026-04-24 17:40:00 +08:00
YYMa
5125a159d2 docs: correct Bun post-install instructions 2026-04-24 17:36:57 +08:00
claude-code-best
d09f363414 Merge pull request #347 from amDosion/feat/ssh-remote-v2
feat: 启用 SKILL_LEARNING 编译开关
2026-04-24 16:07:10 +08:00
unraid
9d35f98ec7 feat: 启用 SKILL_LEARNING 编译开关
将 SKILL_LEARNING 加入 DEFAULT_BUILD_FEATURES,
构建产物中默认启用技能学习系统。
2026-04-24 15:18:26 +08:00
claude-code-best
eb833da33b fix: 创建 agent 后刷新 loadMarkdownFilesForSubdir 缓存
新建 agent 后 clearAgentDefinitionsCache 漏清底层
loadMarkdownFilesForSubdir 的 memoize 缓存,导致新
agent 不会立即出现在列表中,需要重启才能生效。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 15:05:05 +08:00
claude-code-best
eadd32ae47 docs: 同步 AGENTS.md 与 CLAUDE.md 2026-04-24 15:05:05 +08:00
claude-code-best
3c55a8c83f Merge pull request #344 from amDosion/feat/ssh-remote-v2
feat: SSH Remote — 本地 REPL + 远端工具执行
2026-04-24 14:36:11 +08:00
claude-code-best
5582bb47ef docs: 五一 lint 提示 2026-04-24 14:35:39 +08:00
claude-code-best
95bb191977 Merge pull request #341 from YuanyuanMa03/docs/bun-installation-guide
docs: 添加 Bun 安装详细说明
2026-04-24 14:29:39 +08:00
unraid
03811f973b feat: 实现 SSH Remote — 本地 REPL + 远端工具执行
SSH Remote 允许在本地运行交互式 REPL,同时将工具调用(Bash、文件读写等)
通过 SSH 隧道转发到远程主机执行。

核心模块:
- SSHSessionManager: NDJSON 双向通信、权限转发、指数退避重连
- SSHAuthProxy: 本地认证代理 + SSH -R 反向端口转发,nonce 验证
- SSHProbe: 远端主机平台/架构/已有二进制探测
- SSHDeploy: 远端二进制部署(scp)
- createSSHSession: 会话编排(probe → deploy → spawn → attach)

新增选项:
- --remote-bin: 跳过 probe/deploy,使用自定义远端二进制
- ANTHROPIC_AUTH_NONCE: API 请求认证 nonce header

包含 17 个单元测试和完整文档。
2026-04-24 14:25:56 +08:00
YuanyuanMa03
02ab1a0307 docs: 添加 Bun 安装详细说明
- 添加 Linux/macOS/Windows 各平台的安装命令
- 添加安装后的操作步骤(重启终端、验证安装、更新版本)
- 同步更新中英文 README
2026-04-24 12:07:18 +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
2a5b263641 chore: 1.9.4 2026-04-24 10:50:53 +08:00
claude-code-best
f2dd5142b3 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 10:01:05 +08:00
claude-code-best
4dcbaf1e66 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 09:59:23 +08:00
claude-code-best
0b304730d8 docs: 为 DEFAULT_BUILD_FEATURES 每个 feature flag 添加功能注释
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:26:59 +08:00
claude-code-best
7a0dd3057e chore: 1.9.3 2026-04-23 23:21:43 +08:00
claude-code-best
ca1c87f460 fix: 修复 usePipeIpc 中 require 返回 undefined 导致启动崩溃
将 lazy require() 调用全部替换为静态 import,解决构建产物中
模块加载时序问题导致的 'undefined is not an object' 错误。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 23:21:38 +08:00
claude-code-best
fc7a85f5c7 chore: 1.9.2 2026-04-23 23:04:18 +08:00
claude-code-best
5bc12b00b2 chore: 更新版本流水线 2026-04-23 22:55:27 +08:00
claude-code-best
792777d68c chore: 1.9.1 2026-04-23 22:46:51 +08:00
claude-code-best
047634afe6 ci: 删除冗余 release 工作流 2026-04-23 22:45:53 +08:00
claude-code-best
a92af99448 ci: 添加 GitHub Release 和自动生成 changelog 到发布流程 2026-04-23 22:44:02 +08:00
claude-code-best
cfe1552ec9 ci: 统一 typecheck 命令并添加 npm 发布工作流 2026-04-23 22:42:33 +08:00
claude-code-best
9624f880e0 fix: 修复第三方 Anthropic base URL 应使用 ExaSearchAdapter 而非 BingSearchAdapter
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:52:16 +08:00
claude-code-best
85e5a8cffb chore: 贡献者更新工作流改为每周定时触发
移除 push 触发,仅保留每周一 schedule 触发。

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:12:47 +08:00
claude-code-best
7a3fdf6e67 chore: 1.9.0 2026-04-23 20:10:29 +08:00
claude-code-best
b642977afe Merge pull request #335 from realorange1994/feature/cli-highlight
fix: 将 highlight.js 改为静态导入以兼容 Bun --compile 模式
2026-04-23 20:07:27 +08:00
claude-code-best
781188862e Merge pull request #333 from realorange1994/feature/exa-search
feat: 添加 Exa AI 搜索适配器
2026-04-23 20:06:53 +08:00
claude-code-best
b966eef5a9 Merge branch 'main' into feature/exa-search 2026-04-23 20:04:13 +08:00
claude-code-best
c3d63c8fe2 chore: 添加 release 脚本 2026-04-23 19:58:55 +08:00
Bot
7d4c4278c0 fix: 将 highlight.js 改为静态导入以兼容 Bun --compile 模式
- cliHighlight.ts: 使用静态 import 替换 dynamic import('highlight.js'),
  因为编译模式下模块解析指向内部 bunfs 路径导致无法找到
- color-diff-napi/src/index.ts: 同样改为静态导入,移除 createRequire 延迟加载
2026-04-23 18:47:31 +08:00
Bot
93bfdabff1 feat: 添加 Exa AI 搜索适配器
- 新增 ExaSearchAdapter,基于 MCP 协议调用 Exa 搜索 API
- WebSearchTool 支持 num_results、livecrawl、search_type、context_max_characters 等高级选项
- 非 Anthropic 官方 base URL 时默认使用 Exa 适配器
2026-04-23 18:43:41 +08:00
claude-code-best
1173a62301 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-23 11:39:53 +08:00
claude-code-best
7ea69ca279 fix: 修复 build 过程中的问题 2026-04-23 11:39:46 +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
4e82fb5974 Merge pull request #330 from claude-code-best/feature/improve-v2-final
feat: 整合功能恢复与技能学习闭环 v2 (重构版)
2026-04-22 22:55:20 +08:00
claude-code-best
f43350e600 fix: 修复 4 个测试失败(路径规范化、SDK 签名变更、空消息防护)
- projectContext.test.ts: 使用 realpathSync 处理 macOS /var→/private/var 符号链接
- bedrockClient.test.ts: 适配 Bedrock SDK v0.80 Bearer 认证(原 AWS4-HMAC-SHA256)
- bridge.ts: forwardSessionUpdates 添加 null guard 防止空消息导致 TypeError

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:52:37 +08:00
unraid
23fcbf9004 feat: 添加 UI 组件增强与测试覆盖
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:10 +08:00
unraid
23bb09d240 feat: 添加 model/provider 层改进
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:10 +08:00
unraid
d208855f07 feat: 添加 builtin-tools 增强与测试覆盖
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:10 +08:00
unraid
7881cc617c feat: 增强 ACP 桥接与权限处理
- 增强 ACP agent 测试覆盖
- 扩展 ACP bridge 测试用例
- 改进 ACP utils 权限管道

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:10 +08:00
unraid
c7e1c50b86 feat: 添加服务层增强与零散改进
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:10 +08:00
unraid
2247026bd5 chore: 添加脚本与构建配置更新
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:10 +08:00
unraid
eec961352b feat: 添加 napi 包测试覆盖与 stub 改进
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:10 +08:00
unraid
fb41513b32 feat: 添加工具类增强与状态管理改进
- 新增 workflowRuns、remoteTriggerAudit、pipeStatus 等工具
- 增强 permissionSetup: auto mode 和 bypass permissions 始终可用
- 新增多组测试覆盖 (modifiers, teamDiscovery, deepLink 等)
- 修复 parseInt 缺少 radix 参数
- 移除多余 biome-ignore 注释

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:10 +08:00
unraid
94c4b37eed feat: 添加 summary 命令 TypeScript 重写与其他命令增强
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:09 +08:00
unraid
6c5df395c3 feat: 添加 compact 缓存与上下文压缩增强
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:09 +08:00
unraid
be97a0b010 feat: 添加 Bedrock API 客户端及 API 层增强
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:09 +08:00
unraid
59f8675fa3 feat: 添加 Windows Terminal swarm 后端及 swarm 增强
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:09 +08:00
unraid
c4775fff58 feat: 添加 autonomy 自主模式命令系统
- 新增 autonomy CLI handler 和交互式面板
- 新增 autonomyCommandSpec 命令规范定义
- 新增 autonomyAuthority 权限控制
- 新增 autonomyStatus 状态管理
- 注册 CLI 子命令 (claude autonomy status/runs/flows/flow)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:09 +08:00
unraid
31b2fdd97a feat: 添加 provider usage 统计与余额查询
- 新增 providerUsage 服务(anthropic/bedrock/openai 适配器)
- 新增余额查询(deepseek/generic poller)
- StatusLine 保留原有 rateLimits 接口不变

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:09 +08:00
unraid
1837df5f88 feat: 添加 skill learning 技能学习闭环系统
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:09 +08:00
unraid
04c7ed4250 chore: 删除废弃文档和残留文件
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:08 +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
claude-code-best
711927f01b chore: 更新 lock 文件 2026-04-21 08:20:40 +00:00
claude-code-best
956e98a445 fix: 修复重复依赖声明 2026-04-21 16:16:38 +08:00
claude-code-best
cee62bc654 fix: 修复 model alias 导致无限递归栈溢出
当用户 settings 中配置 model = "opus[1m]" 等 alias 值时,
getDefaultOpusModel() → parseUserSpecifiedModel() → getDefaultOpusModel()
形成无限递归,导致启动时 RangeError: Maximum call stack size exceeded。

在 getDefaultOpusModel/Sonnet/Haiku 的 fallback 路径中增加
isAliasOrAliasWithSuffix 守卫,跳过 alias 值直接使用硬编码默认值。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 16:10:16 +08:00
claude-code-best
5fc7c8e13d chore: 添加 highlight.js 包 2026-04-21 12:42:10 +08:00
claude-code-best
300faa18d0 Merge branch 'feature/unknown-llm-feature-test' 2026-04-21 12:06:19 +08:00
claude-code-best
96ec96c720 feat: 添加 ccb update 命令,支持 npm/bun 自动更新
从 package.json 读取当前版本,查询 npm registry 最新版本,
自动检测安装方式(bun 或 npm)执行全局更新。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:35:57 +08:00
claude-code-best
13a0bfc479 fix: 修复构建产物 import 失效问题 2026-04-20 22:29:44 +08:00
claude-code-best
84f0271813 chore: 1.7.1 2026-04-20 22:13:31 +08:00
claude-code-best
ed4bdb9338 feat: 增强 auto mode 的易用性 (#312)
* feat: poor 模式降级 yolo 审阅模型

* feat: 为多模块添加 Langfuse tracing 支持

在 web search、agent creation、away summary、token estimation、
skill improvement 等模块中集成 Langfuse trace,并透传至
compact/apiQueryHook/execPromptHook 等调用链。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 让 auto mode 记录回主 trace

* fix: reopen auto mode prompt when classifier is unavailable

* fix: 修复 auto mode 情况下, llm 报错导致弹窗也不打开的问题

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 21:13:09 +08:00
claude-code-best
e4ce08fe39 Fixture/langfuse record auto mode data error (#308)
* fix: 修复状态栏 context 计数器在 loading 时闪现为 0 的问题

第三方 API(如智谱)在 message_start 中可能不返回完整 usage 数据,
导致 getCurrentUsage 返回全零 usage 对象,使 ctx 显示为 0%。

双重保护:
- getCurrentUsage: 跳过全零 usage,继续往前找有真实数据的 message
- calculateContextPercentages: totalInputTokens 为 0 时返回 null

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 外部化 ESM 包使用 createRequire 替代裸 require

color-diff-napi、image-processor-napi、audio-capture-napi 声明
"type": "module" 但使用裸 require(),Node.js ESM 中 require
不可用。改用 createRequire(import.meta.url) 或顶层 import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: getDefaultSonnetModel 优先使用用户配置的模型,修复第三方 provider 模型不存在错误

当用户通过 ANTHROPIC_MODEL 或 settings 配置了自定义 provider 支持的模型时,
getDefaultSonnetModel/Haiku/Opus 现在会优先使用该配置,而非硬编码 Anthropic 官方模型 ID。
同时改进 Langfuse 可观测性:sideQuery 失败时记录错误信息到 span,
optional 模式下标记 WARNING 而非 ERROR。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 将 auto_mode classifier 的 side-query span 绑定到父 trace

classifyYoloAction 及 classifyYoloActionXml 接收 parentSpan 参数,
透传给 sideQuery 调用,使 auto_mode 的 side-query span 嵌套在主 agent trace 下。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 穷鬼模式下跳过 memdir_relevance side-query

Poor mode 启用时不执行 findRelevantMemories 的预取调用,
避免额外的 API token 消耗。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 test:all 脚本用于完成任务后的全量检查

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Vite 构建补齐缺失的 feature flags,修复 auto mode 不可见

Vite 构建插件的 DEFAULT_BUILD_FEATURES 缺少 BUDDY、TRANSCRIPT_CLASSIFIER、
BRIDGE_MODE、ACP、BG_SESSIONS、TEMPLATES,导致 feature('TRANSCRIPT_CLASSIFIER')
被替换为 false,auto mode 从 Shift+Tab 循环中消失。与 build.ts 对齐。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 统一 feature flags 到 defines.ts,修复 Vite 构建缺失 auto mode

将 DEFAULT_BUILD_FEATURES 列表从 build.ts、dev.ts、vite-plugin-feature-flags.ts
三处内联定义统一到 scripts/defines.ts 单一导出。之前的 Vite 插件缺少
TRANSCRIPT_CLASSIFIER 等 feature flag,导致 auto mode 在 Vite 构建中不可见。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 13:30:05 +08:00
claude-code-best
92f8a92fbb feat: 正式启用 auto mode (#307)
* fix: 修复settings.json内存状态溢出的问题

* fix: 修复auto mode gate check未处理的promise rejection

在 bypassPermissionsKillswitch.ts 的 useKickOffCheckAndDisableAutoModeIfNeeded
中,void fire-and-forget 调用缺少 .catch() 处理,导致 verifyAutoModeGateAccess
失败时产生 unhandled promise rejection。同时移除 permissionSetup.ts 中冗余的
null check。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 开放 auto mode 和 bypass mode 给所有用户

通过 Shift+Tab 统一循环:default → acceptEdits → plan → auto → bypassPermissions → default

- 移除 USER_TYPE 分支判断,所有用户使用同一循环路径
- isBypassPermissionsModeAvailable 始终为 true
- isAutoModeAvailable 初始化直接为 true
- 移除 AutoModeOptInDialog 确认流程
- 简化 isAutoModeGateEnabled 仅保留快模式熔断器
- 简化 verifyAutoModeGateAccess 仅检查快模式
- 移除 GrowthBook/Statsig 远程门控
- bypass permissions killswitch 改为 no-op
- 新增 24 个测试覆盖循环逻辑和门控不变量

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 为sideQuery添加Langfuse追踪

sideQuery 绕过了 claude.ts 的主 API 路径,导致所有走 sideQuery 的调用
(auto mode classifier、permission explainer、session search 等)都没有
Langfuse 记录。现在为每次 sideQuery 调用创建独立 trace 并记录 LLM observation,
未配置 Langfuse 时全部 no-op。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: ACP availableModes 补齐 bypassPermissions 并修正测试 import 路径

- ACP agent availableModes 按条件包含 bypassPermissions(非 root/sandbox)
- 顺序对齐 REPL 循环:default → acceptEdits → plan → auto → bypassPermissions
- 新增 2 个测试验证 availableModes 包含 bypassPermissions 及模式切换
- 修正 getNextPermissionMode.test.ts 和 permissionSetup.test.ts 的 import 路径

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 10:20:27 +08:00
claude-code-best
a67e2d0e97 docs: 更新 npm 安装 2026-04-19 22:00:48 +08:00
claude-code-best
8c629858ab chore: 1.6.0 2026-04-19 21:37:35 +08:00
461 changed files with 41384 additions and 8464 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. 静默完成

View File

@@ -41,7 +41,8 @@ All teach-me data is stored under `.claude/skills/teach-me/records/`:
.claude/skills/teach-me/records/
├── learner-profile.md # Cross-topic notes (created on first session)
└── {topic-slug}/
── session.md # Learning state: concepts, status, notes
── session.md # Learning state: concepts, status, notes
└── {topic-slug}-notes.md # Learner-facing summary notes (generated at session end)
```
**Slug**: Topic in kebab-case, 2-5 words. Example: "Python decorators" → `python-decorators`
@@ -275,7 +276,8 @@ Update `session.md` after each round:
When all concepts mastered or user ends session:
1. Update `session.md` with final state.
2. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
2. **Generate learner-facing notes** — write `{topic-slug}-notes.md` in the topic directory. This is a standalone reference document the learner can review later. See "Notes Generation" below for format.
3. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
```markdown
# Learner Profile
@@ -293,7 +295,48 @@ Updated: {timestamp}
- Python decorators (8/10 concepts, 2025-01-15)
```
3. Give a brief text summary of what was covered, key insights, and areas for further study.
4. Give a brief text summary of what was covered, key insights, and areas for further study.
## Notes Generation
At session end, generate a learner-facing notes file at `{topic-slug}/{topic-slug}-notes.md`. This file is **written for the learner to review later**, not for the tutor. It should be self-contained and organized as a quick-reference.
### Notes Structure
```markdown
# {Topic} 核心笔记
## 1. {Section Name}
{Key concept, mechanism, or principle}
* **One-line summary**: {what it does / why it matters}
* **Detail**: {brief explanation, 2-4 sentences max}
* **Example** (if applicable): {code snippet, command, or concrete scenario}
---
## 2. {Section Name}
...
---
## n. 实战参数 / Cheat Sheet (if applicable)
{Practical commands, config, or quick-reference table}
| Parameter / Concept | What it does | Tuning tip |
|---------------------|-------------|------------|
| ... | ... | ... |
```
### Notes Writing Rules
1. **Start with "what & why"** before "how". Each section should answer: what is this, why does it exist, what problem does it solve.
2. **Use analogies sparingly but effectively**. Only include an analogy if it clarifies a non-obvious mechanism (e.g., "PagedAttention is like OS virtual memory paging").
3. **Include trade-offs**. Every optimization or design choice has a cost. Always state it (e.g., "TP improves throughput but increases communication latency").
4. **Code / command examples should be minimal**. Under 10 lines, self-contained, with comments explaining the key flags.
5. **Organize by concept dependency**, not by chronological teaching order. Foundation concepts first, advanced ones last.
6. **No quiz questions, no misconceptions, no tutor-side notes**. This is a clean reference document.
7. **Language matches the session**. If the session was in Chinese, notes are in Chinese (technical terms can stay in English).
8. **Keep it under 150 lines**. If it gets too long, the learner won't review it. Be ruthless about cutting fluff.
## Resuming Sessions

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

@@ -21,7 +21,7 @@ jobs:
run: bun install --frozen-lockfile
- name: Type check
run: bunx tsc --noEmit
run: bun run typecheck
- name: Test with Coverage
run: |

79
.github/workflows/publish-npm.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Publish to npm
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: '版本号 (例如: v1.9.0)'
required: true
type: string
permissions:
contents: write
packages: write
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.version || github.ref }}
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check
run: bun run typecheck
- name: Run tests
run: bun test
- name: Publish to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Generate changelog
id: changelog
run: |
VERSION="${{ github.event.inputs.version || github.ref_name }}"
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${VERSION#v}$" | head -1)
if [ -n "$PREV_TAG" ]; then
COMMITS=$(git log "${PREV_TAG}..${VERSION}" --pretty=format:"- %s (%h)" --no-merges)
else
COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges -20)
fi
{
echo "commits<<EOF"
echo "$COMMITS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.event.inputs.version || github.ref_name }}
body: |
## What's Changed
${{ steps.changelog.outputs.commits }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ github.event.inputs.version || github.ref_name }}^...${{ github.event.inputs.version || github.ref_name }}
draft: false
prerelease: ${{ contains(github.event.inputs.version || github.ref_name, 'rc') || contains(github.event.inputs.version || github.ref_name, 'beta') || contains(github.event.inputs.version || github.ref_name, 'alpha') }}

View File

@@ -1,11 +1,8 @@
name: Update Contributors
on:
push:
branches:
- main
schedule:
- cron: '0 0 * * *' # 每更新一次
- cron: '0 0 * * 1' # 每周一更新一次
permissions:
contents: write

6
.gitignore vendored
View File

@@ -19,6 +19,11 @@ src/utils/vendor/
/*.png
*.bmp
# Internal system prompt documents
Claude-Opus-*.txt
Claude-Sonnet-*.txt
Claude-Haiku-*.txt
# Agent / tool state dirs
.swarm/
.agents/__pycache__/
@@ -38,3 +43,4 @@ data
.codex/skills/.system/**
!.codex/prompts/
!.codex/prompts/**
teach-me

357
AGENTS.md Normal file
View File

@@ -0,0 +1,357 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
## Git Commit Message Convention
使用 **Conventional Commits** 规范:
```
<type>: <描述>
```
常见 type`feat``fix``docs``chore``refactor`
示例:
- `feat: 添加模型 1M 上下文切换`
- `fix: 修复初次登陆的校验问题`
- `chore: remove prefetchOfficialMcpUrls call on startup`
## Commands
```bash
# Install dependencies
bun install
# Dev mode (runs cli.tsx with MACRO defines injected via -d flags)
bun run dev
# Dev mode with debugger (set BUN_INSPECT=9229 to pick port)
bun run dev:inspect
# Pipe mode
echo "say hello" | bun run src/entrypoints/cli.tsx -p
# Build (code splitting, outputs dist/cli.js + chunk files)
bun run build
# Build with Vite (alternative build pipeline)
bun run build:vite
# Test
bun test # run all tests
bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
# Lint & Format (Biome)
bun run lint # check only
bun run lint:fix # auto-fix
bun run format # format all src/
# Health check
bun run health
# Check unused exports
bun run check:unused
# Full check (typecheck + lint + test) — run after completing any task
bun run test:all
bun run typecheck
# Remote Control Server
bun run rcs
# Docs dev server (Mintlify)
bun run docs:dev
```
详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`
## Architecture
### Runtime & Build
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
### Entry & Bootstrap
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
- `--version` / `-v` — 零模块加载
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
- `--claude-in-chrome-mcp` / `--chrome-native-host`
- `--computer-use-mcp` — 独立 MCP server 模式
- `--daemon-worker=<kind>` — feature-gated (DAEMON)
- `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE)
- `daemon` [subcommand] — feature-gated (DAEMON)
- `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS)
- `new` / `list` / `reply` — Template job commands
- `environment-runner` / `self-hosted-runner` — BYOC runner
- `--tmux` + `--worktree` 组合
- 默认路径:加载 `main.tsx` 启动完整 CLI
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands`mcp` (serve/add/remove/list...)、`server``ssh``open``auth``plugin``agents``auto-mode``doctor``update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
### Core Loop
- **`src/query.ts`** — The main API query function. Sends messages to Claude API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen.
- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts.
### API Layer
- **`src/services/api/claude.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
- Provider selection in `src/utils/model/providers.ts`。优先级modelType 参数 > 环境变量 > 默认 firstParty。
### Tool System
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
- **规划**: EnterPlanModeTool, ExitPlanModeV2Tool, VerifyPlanExecutionTool
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
### UI Layer (Ink)
- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection.
- **`packages/@ant/ink/`** — Custom Ink frameworkforked/internal包含 components、core、hooks、keybindings、theme、utils。注意不是 `src/ink/`
- **`src/components/`** — 149 个组件目录/文件,渲染于终端 Ink 环境中。关键组件:
- `App.tsx` — Root provider (AppState, Stats, FpsMetrics)
- `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering
- `PromptInput/` — User input handling
- `permissions/` — Tool permission approval UI
- `design-system/` — 复用 UI 组件Dialog, FuzzyPicker, ProgressBar, ThemeProvider 等)
- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout.
### State Management
- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc.
- **`src/state/AppStateStore.ts`** — Default state and store factory.
- **`src/state/store.ts`** — Zustand-style store for AppState (`createStore`).
- **`src/state/selectors.ts`** — State selectors.
- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode).
### Workspace Packages
| Package | 说明 |
|---------|------|
| `packages/@ant/ink/` | Forked Ink 框架components、hooks、keybindings、theme |
| `packages/@ant/computer-use-mcp/` | Computer Use MCP server截图/键鼠/剪贴板/应用管理) |
| `packages/@ant/computer-use-input/` | 键鼠模拟dispatcher + darwin/win32/linux backend |
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理dispatcher + per-platform backend |
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
| `packages/@ant/model-provider/` | Model provider 抽象层 |
| `packages/builtin-tools/` | 内置工具集60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
| `packages/agent-tools/` | Agent 工具集 |
| `packages/acp-link/` | ACP 代理服务器WebSocket → ACP agent 桥接) |
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
| `packages/mcp-client/` | MCP 客户端库 |
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
| `packages/remote-control-server/` | 自托管 Remote Control ServerDocker 部署,含 Web UI— Web UI 已重构为 React + Vite + Radix UI支持 ACP agent 接入 |
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
| `packages/shell/` | Shell 抽象(非 workspace 包) |
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
| `packages/color-diff-napi/` | 颜色差异计算完整实现11 tests |
| `packages/image-processor-napi/` | 图像处理(已恢复) |
| `packages/modifiers-napi/` | 键盘修饰键检测stub |
| `packages/url-handler-napi/` | URL scheme 处理stub |
### Bridge / Remote Control
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`packages/remote-control-server/`** — 自托管 RCS支持 Docker 部署,含 Web UI 控制面板React 19 + Vite + Radix UI。支持 ACP agent 通过 acp-link 接入ACP WebSocket handler、relay handler、SSE event stream。通过 `bun run rcs` 启动。
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`
- 详见 `docs/features/remote-control-self-hosting.md`
### ACP Protocol (Agent Client Protocol)
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`AcpAgent 类)、`bridge.ts`Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成REST 注册 + WS identify 两步流程、权限模式透传fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。
- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。
- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示PlanView 组件,含进度条/状态图标/优先级标签)。
### Daemon Mode
- **`src/daemon/`** — Daemon 模式(长驻 supervisor。feature-gated by `DAEMON`。包含 `main.ts`entry`workerRegistry.ts`worker 管理)。
### Context & System Prompt
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, CLAUDE.md contents, memory files).
- **`src/utils/claudemd.ts`** — Discovers and loads CLAUDE.md files from project hierarchy.
### Feature Flag System
Feature flags control which functionality is enabled at runtime. 代码中统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`
**Build 默认 features**19 个,见 `build.ts`:
- 基础: `BUDDY`, `TRANSCRIPT_CLASSIFIER`, `BRIDGE_MODE`, `AGENT_TRIGGERS_REMOTE`, `CHICAGO_MCP`, `VOICE_MODE`
- 统计/缓存: `SHOT_STATS`, `PROMPT_CACHE_BREAK_DETECTION`, `TOKEN_BUDGET`
- P0 本地: `AGENT_TRIGGERS`, `ULTRATHINK`, `BUILTIN_EXPLORE_PLAN_AGENTS`, `LODESTONE`
- P1 API 依赖: `EXTRACT_MEMORIES`, `VERIFICATION_AGENT`, `KAIROS_BRIEF`, `AWAY_SUMMARY`, `ULTRAPLAN`
- P2: `DAEMON`
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
**类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。
**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。
### Multi-API 兼容层
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。
#### OpenAI 兼容层
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI``OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL`
#### Gemini 兼容层
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
- **`src/services/api/gemini/`** — client、模型映射、类型定义
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
#### Grok 兼容层
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
- **`src/services/api/grok/`** — client、模型映射
详见各兼容层的 docs 文档。
### 穷鬼模式Budget Mode
- 通过 `/poor` 命令切换,持久化到 `settings.json`
- 启用后跳过 `extract_memories``prompt_suggestion``verification_agent`,显著减少 token 消耗。
- 实现在 `src/commands/poor/poorMode.ts`
### Stubbed/Deleted Modules
| Module | Status |
|--------|--------|
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux后端完整度不一 |
| `*-napi` packages | `audio-capture-napi``image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi``url-handler-napi` 仍为 stub |
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth |
| OpenAI/Gemini/Grok 兼容层 | Restored |
| Remote Control Server | Restored — 自托管 RCS + Web UI |
| Analytics / GrowthBook / Sentry | Empty implementations |
| Magic Docs / LSP Server | Removed |
| Plugins / Marketplace | Removed |
| MCP OAuth | Simplified |
### Key Type Files
- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers.
- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`.
- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.).
- **`src/types/permissions.ts`** — Permission mode and result types.
## Testing
- **框架**: `bun:test`(内置断言 + mock
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain
- **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests
### Mock 使用规范
**只 mock 有副作用的依赖链,不 mock 纯函数/纯数据模块。**
被迫 mock 的根源:`log.ts` / `debug.ts``bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts``debug.ts``bun:bundle``settings/settings.js``config.ts``auth.ts`、第三方网络库。
**`log.ts``debug.ts` 使用共享 mock**`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式:
```ts
import { logMock } from "../../../tests/mocks/log";
mock.module("src/utils/log.ts", logMock);
import { debugMock } from "../../../../tests/mocks/debug";
mock.module("src/utils/debug.ts", debugMock);
```
源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。
不要 mock纯函数模块`errors.ts``stringUtils.js`、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
### 类型检查
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bun run typecheck
```
**类型规范**
- 生产代码禁止 `as any`;测试文件中 mock 数据可用 `as any`
- 类型不匹配优先用 `as unknown as SpecificType` 双重断言,或补充 interface
- 未知结构对象用 `Record<string, unknown>` 替代 `any`
- 联合类型用类型守卫type guard收窄不要强转
- `msg.request` 属性访问:`const req = msg.request as Record<string, unknown>`
- Ink `color` prop`as keyof Theme` 而非 `as any`
## Working with This Codebase
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}``feature('X') ? a : b`
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
- **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 中。
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
## Design Context
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UIRCS 控制面板、文档站、着陆页)时必须参考该文件。
### 核心设计原则
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
### 品牌色
- 主色Claude Orange `#D77757`terra cotta
- 辅色Claude Blue `#5769F7`
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
### 目标用户
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
### 视觉参考
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。

View File

@@ -1,10 +1,10 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced**`bunx tsc --noEmit` must pass with zero errors**.
## Git Commit Message Convention
@@ -43,9 +43,9 @@ bun run build
bun run build:vite
# Test
bun test # run all tests (3175 tests / 207 files / 0 fail)
bun test # run all tests
bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
bun test --coverage # with coverage report
# Lint & Format (Biome)
bun run lint # check only
@@ -58,6 +58,8 @@ bun run health
# Check unused exports
bun run check:unused
# Full check (typecheck + lint + test) — run after completing any task
bun run test:all
bun run typecheck
# Remote Control Server
@@ -84,7 +86,7 @@ bun run docs:dev
### Entry & Bootstrap
1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
- `--version` / `-v` — 零模块加载
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
- `--claude-in-chrome-mcp` / `--chrome-native-host`
@@ -115,7 +117,7 @@ bun run docs:dev
### Tool System
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
- **`src/tools.ts`** (392 行) — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
@@ -124,6 +126,7 @@ bun run docs:dev
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
### UI Layer (Ink)
@@ -173,7 +176,7 @@ bun run docs:dev
### Bridge / Remote Control
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`packages/remote-control-server/`** — 自托管 RCS支持 Docker 部署,含 Web UI 控制面板React 19 + Vite + Radix UI。支持 ACP agent 通过 acp-link 接入ACP WebSocket handler、relay handler、SSE event stream。通过 `bun run rcs` 启动。
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`
- 详见 `docs/features/remote-control-self-hosting.md`
@@ -215,7 +218,30 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
### Multi-API 兼容层
支持 OpenAI、Gemini、Grok 三种第三方 API通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置
#### OpenAI 兼容层
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI``OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL`
#### Gemini 兼容层
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
- **`src/services/api/gemini/`** — client、模型映射、类型定义
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
#### Grok 兼容层
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
- **`src/services/api/grok/`** — client、模型映射
详见各兼容层的 docs 文档。
### 穷鬼模式Budget Mode
@@ -247,7 +273,6 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
## Testing
- **框架**: `bun:test`(内置断言 + mock
- **当前状态**: 3175 tests / 207 files / 0 fail
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain
- **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/
@@ -260,6 +285,18 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
被迫 mock 的根源:`log.ts` / `debug.ts``bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts``debug.ts``bun:bundle``settings/settings.js``config.ts``auth.ts`、第三方网络库。
**`log.ts``debug.ts` 使用共享 mock**`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式:
```ts
import { logMock } from "../../../tests/mocks/log";
mock.module("src/utils/log.ts", logMock);
import { debugMock } from "../../../../tests/mocks/debug";
mock.module("src/utils/debug.ts", debugMock);
```
源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。
不要 mock纯函数模块`errors.ts``stringUtils.js`、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
@@ -269,7 +306,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bun run typecheck # equivalent to bun run typecheck
bun run typecheck
```
**类型规范**
@@ -290,7 +327,7 @@ bun run typecheck # equivalent to 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

123
README.md
View File

@@ -12,39 +12,46 @@
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
> 我们将会在五一期间进行整个代码仓库的 lint 规范化, 这个期间提交的 PR 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
| 特性 | 说明 | 文档 |
|------|------|------|
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
| 特性 | 说明 | 文档 |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
- 🚀 [想要启动项目](#快速开始源码版)
- 🐛 [想要调试项目](#vs-code-调试)
- 📖 [想要学习项目](#teach-me-学习项目)
## ⚡ 快速开始(安装版)
不用克隆仓库, 从 NPM 下载后, 直接使用
```sh
bun i -g claude-code-best
bun pm -g trust claude-code-best
npm i -g claude-code-best
# bun 安装比较多问题, 推荐 npm 装
# bun i -g claude-code-best
# bun pm -g trust claude-code-best @claude-code-best/mcp-chrome-bridge
ccb # 以 nodejs 打开 claude code
ccb-bun # 以 bun 形态打开
ccb update # 更新到最新版本
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
```
@@ -55,11 +62,66 @@ CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDG
一定要最新版本的 bun 啊, 不然一堆奇奇怪怪的 BUG!!! bun upgrade!!!
- 📦 [Bun](https://bun.sh/) >= 1.3.11
**安装 Bun**
```bash
# Linux 和 macOS
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```
**安装后的操作:**
1. **让当前终端识别 `bun` 命令**
安装脚本会把 `~/.bun/bin` 写入对应的 shell 配置文件。macOS 默认 zsh 环境通常会看到:
```text
Added "~/.bun/bin" to $PATH in "~/.zshrc"
```
可以按安装脚本提示重启当前 shell
```bash
exec /bin/zsh
```
如果你使用 bash重新加载 bash 配置:
```bash
source ~/.bashrc
```
Windows PowerShell 用户关闭并重新打开 PowerShell 即可。
2. **验证 Bun 是否可用**
```bash
bun --help
bun --version
```
3. **如果已经安装过 Bun更新到最新版本**
```bash
bun upgrade
```
- ⚙️ 常规的配置 CC 的方式, 各大提供商都有自己的配置方式
### 📍 命令执行位置
- 安装或检查 Bun 的命令可以在任意目录执行:
`curl -fsSL https://bun.sh/install | bash`、`bun --help`、`bun --version`、`bun upgrade`
- 安装本项目依赖、启动开发模式、构建项目时,必须先进入本仓库根目录,也就是包含 `package.json` 的目录。
### 📥 安装
```bash
cd /path/to/claude-code
bun install
```
@@ -86,17 +148,17 @@ bun run build
需要填写的字段:
| 📌 字段 | 📝 说明 | 💡 示例 |
|------|------|------|
| Base URL | API 服务地址 | `https://api.example.com/v1` |
| API Key | 认证密钥 | `sk-xxx` |
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
| 📌 字段 | 📝 说明 | 💡 示例 |
| ------------ | ------------- | ---------------------------- |
| Base URL | API 服务地址 | `https://api.example.com/v1` |
| API Key | 认证密钥 | `sk-xxx` |
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
- ⌨️ **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存
> 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
## Feature Flags
@@ -116,16 +178,17 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
### 步骤
1. **终端启动 inspect 服务**
```bash
bun run dev:inspect
```
会输出类似 `ws://localhost:8888/xxxxxxxx` 的地址。
会输出类似 `ws://localhost:8888/xxxxxxxx` 的地址。
2. **VS Code 附着调试器**
- 在 `src/` 文件中打断点
- F5 → 选择 **"Attach to Bun (TUI debug)"**
## Teach Me 学习项目
我们新加了一个 teach-me skills, 通过问答式引导帮你理解这个项目的任何模块。(调整 [sigma skill 而来](https://github.com/sanyuan0704/sanyuan-skills))
@@ -152,7 +215,7 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
## 相关文档及网站
- **在线文档Mintlify**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — 文档源码位于 [`docs/`](docs/) 目录,欢迎投稿 PR
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
- **DeepWiki**: [https://deepwiki.com/claude-code-best/claude-code](https://deepwiki.com/claude-code-best/claude-code)
## Contributors

View File

@@ -48,11 +48,64 @@ Sponsor placeholder.
Make sure you're on the latest version of Bun, otherwise you'll run into all sorts of weird bugs. Run `bun upgrade`!
- [Bun](https://bun.sh/) >= 1.3.11
**Install Bun:**
```bash
# Linux and macOS
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```
**Post-installation steps:**
1. **Make `bun` available in the current terminal**
The installer adds `~/.bun/bin` to the matching shell configuration file. On macOS with the default zsh shell, you may see:
```text
Added "~/.bun/bin" to $PATH in "~/.zshrc"
```
Restart the current shell as the installer suggests:
```bash
exec /bin/zsh
```
If you use bash, reload the bash configuration:
```bash
source ~/.bashrc
```
Windows PowerShell users can close and reopen PowerShell.
2. **Verify that Bun is available:**
```bash
bun --help
bun --version
```
3. **Update to latest version (if already installed):**
```bash
bun upgrade
```
- Standard Claude Code configuration — each provider has its own setup method
### Command Execution Location
- Bun installation and checking commands can be run from any directory:
`curl -fsSL https://bun.sh/install | bash`, `bun --help`, `bun --version`, `bun upgrade`
- Project dependency installation, development mode, and builds must be run from this repository root, the directory containing `package.json`.
### Install
```bash
cd /path/to/claude-code
bun install
```

View File

@@ -1,6 +1,7 @@
import { readdir, readFile, writeFile, cp } from 'fs/promises'
import { join } from 'path'
import { getMacroDefines } from './scripts/defines.ts'
import { DEFAULT_BUILD_FEATURES } from './scripts/defines.ts'
const outdir = 'dist'
@@ -8,48 +9,6 @@ const outdir = 'dist'
const { rmSync } = await import('fs')
rmSync(outdir, { recursive: true, force: true })
// Default features that match the official CLI build.
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
const DEFAULT_BUILD_FEATURES = [
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
'AGENT_TRIGGERS_REMOTE',
'CHICAGO_MCP',
'VOICE_MODE',
'SHOT_STATS',
'PROMPT_CACHE_BREAK_DETECTION',
'TOKEN_BUDGET',
// P0: local features
'AGENT_TRIGGERS',
'ULTRATHINK',
'BUILTIN_EXPLORE_PLAN_AGENTS',
'LODESTONE',
// P1: API-dependent features
'EXTRACT_MEMORIES',
'VERIFICATION_AGENT',
'KAIROS_BRIEF',
'AWAY_SUMMARY',
'ULTRAPLAN',
// P2: daemon + remote control server
'DAEMON',
// ACP (Agent Client Protocol) agent mode
'ACP',
// PR-package restored features
'WORKFLOW_SCRIPTS',
'HISTORY_SNIP',
'CONTEXT_COLLAPSE',
'MONITOR_TOOL',
'FORK_SUBAGENT',
// 'UDS_INBOX',
'KAIROS',
'COORDINATOR_MODE',
'LAN_PIPES',
'BG_SESSIONS',
'TEMPLATES',
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion)
'POOR',
]
// Collect FEATURE_* env vars → Bun.build features
const envFeatures = Object.keys(process.env)
.filter(k => k.startsWith('FEATURE_'))

101
bun.lock
View File

@@ -6,7 +6,8 @@
"name": "claude-code-best",
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
"@claude-code-best/mcp-chrome-bridge": "^3.0.1",
"highlight.js": "^11.11.1",
"ws": "^8.20.0",
},
"devDependencies": {
@@ -101,7 +102,6 @@
"get-east-asian-width": "^1.5.0",
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
"highlight.js": "^11.11.1",
"https-proxy-agent": "^8.0.0",
"ignore": "^7.0.5",
"image-processor-napi": "workspace:*",
@@ -195,14 +195,13 @@
},
"packages/acp-link": {
"name": "acp-link",
"version": "1.1.0",
"version": "2.0.0",
"bin": {
"acp-link": "dist/cli/bin.js",
"acp-manager": "dist/manager/bin.js",
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
"@hono/node-server": "^1.13.8",
"@hono/node-server": "^2.0.0",
"@hono/node-ws": "^1.0.5",
"@stricli/auto-complete": "^1.2.4",
"@stricli/core": "^1.2.4",
@@ -265,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",
@@ -570,7 +577,7 @@
"@claude-code-best/builtin-tools": ["@claude-code-best/builtin-tools@workspace:packages/builtin-tools"],
"@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@2.0.8", "https://registry.npmmirror.com/@claude-code-best/mcp-chrome-bridge/-/mcp-chrome-bridge-2.0.8.tgz", { "dependencies": { "@fastify/cors": "^11.0.1", "@modelcontextprotocol/sdk": "^1.11.0", "chalk": "^5.4.1", "chrome-mcp-shared": "^1.0.2", "commander": "^13.1.0", "fastify": "^5.3.2", "is-admin": "^4.0.0", "pino": "^9.6.0", "uuid": "^11.1.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-f7J1e4PQ6qxXzdHwL7QRrMZ4lPfD/L1MWxWDbyHmHY7jaW2GL6WcArKpk/fApg3V/q0racqUWzXHQdpE/HJZqg=="],
"@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@3.0.1", "", { "dependencies": { "@hono/node-server": "^1.19.13", "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", "hono": "^4.12.12", "is-admin": "^4.0.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-ozeLHVOdckTUsWKJneJAL+CclrUlwVyBpfzFxgsrSL9f0LvjlJXE7+VcF5OmjDPwmZy6QNorvtg3/8NT2cIlzA=="],
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
@@ -636,22 +643,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "https://registry.npmmirror.com/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
"@fastify/cors": ["@fastify/cors@11.2.0", "https://registry.npmmirror.com/@fastify/cors/-/cors-11.2.0.tgz", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
"@fastify/error": ["@fastify/error@4.2.0", "https://registry.npmmirror.com/@fastify/error/-/error-4.2.0.tgz", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "https://registry.npmmirror.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "https://registry.npmmirror.com/@fastify/forwarded/-/forwarded-3.0.1.tgz", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "https://registry.npmmirror.com/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
"@fastify/otel": ["@fastify/otel@0.18.0", "https://registry.npmmirror.com/@fastify/otel/-/otel-0.18.0.tgz", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="],
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "https://registry.npmmirror.com/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@@ -666,7 +659,7 @@
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "https://registry.npmmirror.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
"@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
"@hono/node-server": ["@hono/node-server@2.0.0", "", { "peerDependencies": { "hono": "^4" } }, "sha512-n3GfHwwCvHCkGmOwKfxUPOlbfzuO64Sbc5XC4NGPIXxkuOnJrdgExdRKmHfF924r914WRJPT397GdqLvdYTeyQ=="],
"@hono/node-ws": ["@hono/node-ws@1.3.0", "https://registry.npmmirror.com/@hono/node-ws/-/node-ws-1.3.0.tgz", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
@@ -984,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=="],
@@ -1526,8 +1529,6 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
"abstract-logging": ["abstract-logging@2.0.1", "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -1568,8 +1569,6 @@
"auto-bind": ["auto-bind@5.0.1", "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"avvio": ["avvio@9.2.0", "https://registry.npmmirror.com/avvio/-/avvio-9.2.0.tgz", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="],
"axios": ["axios@1.15.0", "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
"bail": ["bail@2.0.2", "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
@@ -1636,8 +1635,6 @@
"chokidar": ["chokidar@5.0.0", "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"chrome-mcp-shared": ["chrome-mcp-shared@1.0.2", "https://registry.npmmirror.com/chrome-mcp-shared/-/chrome-mcp-shared-1.0.2.tgz", { "dependencies": { "@modelcontextprotocol/sdk": "^1.11.0", "zod": "^3.24.4" } }, "sha512-v+6HBmcgXrIfyVbkkrVgfFDzqOfDutI8yZM0yA8k7SiicqL1MfBoqnsOy5idYNvxyQymxCxXNuTmajn8xaGsgQ=="],
"cjs-module-lexer": ["cjs-module-lexer@2.2.0", "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
@@ -1868,16 +1865,10 @@
"fast-copy": ["fast-copy@4.0.3", "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.3.tgz", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "https://registry.npmmirror.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stringify": ["fast-json-stringify@6.3.0", "https://registry.npmmirror.com/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="],
"fast-querystring": ["fast-querystring@1.1.2", "https://registry.npmmirror.com/fast-querystring/-/fast-querystring-1.1.2.tgz", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
@@ -1886,10 +1877,6 @@
"fast-xml-parser": ["fast-xml-parser@5.5.8", "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"fastify": ["fastify@5.8.4", "https://registry.npmmirror.com/fastify/-/fastify-5.8.4.tgz", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="],
"fastify-plugin": ["fastify-plugin@5.1.0", "https://registry.npmmirror.com/fastify-plugin/-/fastify-plugin-5.1.0.tgz", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
"fastq": ["fastq@1.20.1", "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fd-package-json": ["fd-package-json@2.0.0", "https://registry.npmmirror.com/fd-package-json/-/fd-package-json-2.0.0.tgz", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
@@ -1906,8 +1893,6 @@
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-my-way": ["find-my-way@9.5.0", "https://registry.npmmirror.com/find-my-way/-/find-my-way-9.5.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
"find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"flora-colossus": ["flora-colossus@2.0.0", "https://registry.npmmirror.com/flora-colossus/-/flora-colossus-2.0.0.tgz", { "dependencies": { "debug": "^4.3.4", "fs-extra": "^10.1.0" } }, "sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA=="],
@@ -2106,8 +2091,6 @@
"json-schema": ["json-schema@0.4.0", "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "https://registry.npmmirror.com/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -2138,8 +2121,6 @@
"layout-base": ["layout-base@1.0.2", "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
"light-my-request": ["light-my-request@6.6.0", "https://registry.npmmirror.com/light-my-request/-/light-my-request-6.6.0.tgz", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -2564,14 +2545,10 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"ret": ["ret@0.5.0", "https://registry.npmmirror.com/ret/-/ret-0.5.0.tgz", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
@@ -2590,8 +2567,6 @@
"safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-regex2": ["safe-regex2@5.1.0", "https://registry.npmmirror.com/safe-regex2/-/safe-regex2-5.1.0.tgz", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@@ -2610,8 +2585,6 @@
"set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@@ -2702,8 +2675,6 @@
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toad-cache": ["toad-cache@3.7.0", "https://registry.npmmirror.com/toad-cache/-/toad-cache-3.7.0.tgz", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -3064,7 +3035,7 @@
"@claude-code-best/agent-tools/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@claude-code-best/mcp-chrome-bridge/pino": ["pino@9.14.0", "https://registry.npmmirror.com/pino/-/pino-9.14.0.tgz", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
"@claude-code-best/mcp-chrome-bridge/@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
"@claude-code-best/mcp-client/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -3076,16 +3047,18 @@
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"@hono/node-ws/@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
"@inquirer/core/@types/node": ["@types/node@22.19.17", "https://registry.npmmirror.com/@types/node/-/node-22.19.17.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
"@inquirer/core/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
"@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
@@ -3338,8 +3311,6 @@
"cacache/lru-cache": ["lru-cache@11.3.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.3.tgz", {}, "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ=="],
"chrome-mcp-shared/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"cli-highlight/chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cli-highlight/highlight.js": ["highlight.js@10.7.3", "https://registry.npmmirror.com/highlight.js/-/highlight.js-10.7.3.tgz", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
@@ -3362,8 +3333,6 @@
"external-editor/iconv-lite": ["iconv-lite@0.4.24", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"fastify/pino": ["pino@9.14.0", "https://registry.npmmirror.com/pino/-/pino-9.14.0.tgz", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
"form-data/mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -3382,10 +3351,6 @@
"katex/commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"light-my-request/cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"light-my-request/process-warning": ["process-warning@4.0.1", "https://registry.npmmirror.com/process-warning/-/process-warning-4.0.1.tgz", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mermaid/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
@@ -3634,10 +3599,6 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@claude-code-best/mcp-chrome-bridge/pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"@claude-code-best/mcp-chrome-bridge/pino/thread-stream": ["thread-stream@3.1.0", "https://registry.npmmirror.com/thread-stream/-/thread-stream-3.1.0.tgz", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
"@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "https://registry.npmmirror.com/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="],
"@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "https://registry.npmmirror.com/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="],
@@ -3720,10 +3681,6 @@
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"fastify/pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"fastify/pino/thread-stream": ["thread-stream@3.1.0", "https://registry.npmmirror.com/thread-stream/-/thread-stream-3.1.0.tgz", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],

File diff suppressed because it is too large Load Diff

View File

@@ -1,160 +0,0 @@
# Feature Flags 审查报告 — Codex 复核
> 审查日期: 2026-04-05
> 审查工具: Codex CLI v0.118.0 (本地, full-auto mode)
> 消耗 tokens: 240,306
> 审查范围: docs/feature-flags-audit-complete.md 中标记为 COMPLETE 的 22 个编译时 feature flag
---
## 审查背景
原始审计报告 (`docs/feature-flags-audit-complete.md`) 声称 22 个 feature flag 被标记为 "COMPLETE",只需在 `build.ts` / `scripts/dev.ts` 中启用即可工作。
Claude Code 团队通过 6 个并行子代理实际读取源码后初步发现大量误判,随后将分析结果传递给 Codex CLI 进行独立二次验证。
---
## Codex 发现摘要
### High 级发现
1. **`CONTEXT_COLLAPSE` 不是 COMPLETE**
- `src/services/contextCollapse/index.ts:43``isContextCollapseEnabled()` 硬编码为 `false`
- `src/services/contextCollapse/index.ts:47``applyCollapsesIfNeeded()` 只是原样返回消息
- `src/services/contextCollapse/index.ts:59``recoverFromOverflow()` 也是 no-op
- `src/services/contextCollapse/operations.ts:3``persist.ts:3` 同样是 stub
- 审计报告把 UI/命令文件算进去了,但真正被查询循环消费的是 stub 后端
2. **原分类"真正只需编译开关"的 7 个 flag只有 3 个准确**
-`SHOT_STATS` — 零额外门控compile-only
-`PROMPT_CACHE_BREAK_DETECTION` — 有 try-catch 兜底compile-only
-`TOKEN_BUDGET` — 纯本地计算compile-only
-`TEAMMEM` — 还要求 AutoMem + GrowthBook `tengu_herring_clock` + GitHub repo (`teamMemPaths.ts:73`, `watcher.ts:256`, `watcher.ts:259`)
-`AGENT_TRIGGERS` — 受 `isKairosCronEnabled()` GrowthBook 控制 (`useScheduledTasks.ts:61`, `useScheduledTasks.ts:119`)
-`EXTRACT_MEMORIES` — 受 `tengu_passport_quail` + AutoMem + 非 remote 限制 (`extractMemories.ts:536`, `:545`, `:550`)
-`KAIROS_BRIEF` — 受 `tengu_kairos_brief` + opt-in/kairosActive 限制 (`BriefTool.ts:95`, `:126`, `:132`)
### Medium 级发现
3. **`BG_SESSIONS``BASH_CLASSIFIER` 不适合简单归为"全 stub"**
- `BG_SESSIONS` — 会话注册/清理是真实现 (`concurrentSessions.ts:44`, `:55`),但任务摘要核心是 stub (`taskSummary.ts:2`)
- `BASH_CLASSIFIER` — 权限编排很大一块是真实现 (`bashPermissions.ts` 2621行),但分类后端 `bashClassifier.ts:24` 永远返回 disabled
4. **审计口径问题**
- 把"代码量/周边 UI 很多"误当成"可独立启用"
- `PROACTIVE``index.ts:3` 只有 state stub`commands.ts:64``REPL.tsx:415` 引用缺失文件
- `REACTIVE_COMPACT``reactiveCompact.ts:13` 整块是 stub
- `CACHED_MICROCOMPACT``cachedMicrocompact.ts:22` 全部 stub
---
## Codex 修正后的分类
### 第一类:真正 compile-only3 个)
| Flag | 说明 | Crash 风险 |
|------|------|-----------|
| **SHOT_STATS** | 纯本地 shot 分布统计ant-only 数据路径 | 低 |
| **PROMPT_CACHE_BREAK_DETECTION** | 本地 cache key 变化检测,写 diff 有兜底 | 低 |
| **TOKEN_BUDGET** | 本地 token 预算追踪,纯计算逻辑 | 低 |
### 第二类compile + 运行时条件7 个)
| Flag | 条件 | Crash 风险 |
|------|------|-----------|
| **TEAMMEM** | AutoMem + GrowthBook `tengu_herring_clock` + GitHub repo | 低 (clean no-op) |
| **AGENT_TRIGGERS** | GrowthBook `isKairosCronEnabled()` | 低 (clean no-op) |
| **EXTRACT_MEMORIES** | `tengu_passport_quail` + AutoMem + 非 remote | 低 (clean no-op) |
| **KAIROS_BRIEF** | `tengu_kairos_brief` + opt-in/kairosActive可用 `CLAUDE_CODE_BRIEF=1` 绕过 | 低 |
| **COORDINATOR_MODE** | 需 `CLAUDE_CODE_COORDINATOR_MODE=1``workerAgent.ts` 是 stub 但不阻塞 | 低 |
| **COMMIT_ATTRIBUTION** | 仅对 `isInternal=true` 的 repo 生效 | 低 |
| **VERIFICATION_AGENT** | 受 GrowthBook `tengu_hive_evidence` 双重门控 | 低 |
### 第三类:混合型 — 部分实现 + stub 核心5 个)
| Flag | 真实现部分 | Stub 核心 |
|------|-----------|----------|
| **BG_SESSIONS** | 会话注册/清理 (`concurrentSessions.ts`) | `bg.ts`/`taskSummary.ts`/`udsClient.ts` 全 stub + 依赖 tmux |
| **BASH_CLASSIFIER** | 权限编排 (`bashPermissions.ts` 2621行) | `bashClassifier.ts` 分类后端 stub + 需 API beta |
| **PROACTIVE** | REPL/命令注册框架 | `index.ts` stub + 3 文件缺失 |
| **REACTIVE_COMPACT** | 调用点已在主查询环路 | `reactiveCompact.ts` 22行全 no-op |
| **CACHED_MICROCOMPACT** | 调用点已布线 | `cachedMicrocompact.ts` 全 stub + 需未公开 API |
### 第四类:纯 stub1 个)
| Flag | 问题 |
|------|------|
| **CONTEXT_COLLAPSE** | 3 核心文件全 stub + CtxInspectTool 目录不存在 |
### 第五类依赖远程服务3 个)
| Flag | 依赖 |
|------|------|
| **ULTRAPLAN** | CCR 远程 agent 基础设施 + OAuth |
| **CCR_REMOTE_SETUP** | claude.ai OAuth + GitHub CLI + CCR 后端 |
| **BRIDGE_MODE** (build端) | claude.ai 订阅 + GrowthBook + WebSocket 后端 |
---
## 第三类恢复优先级建议
Codex 推荐的恢复顺序:
1. **REACTIVE_COMPACT** — 收益最直接,调用点在主查询环路,改完最容易立刻见效
2. **BG_SESSIONS** — 已有会话注册基础,补齐摘要和后台运行链路的 ROI 高
3. **PROACTIVE** — 产品面大,但缺文件比 stub 更严重,范围比前两项大
4. **CONTEXT_COLLAPSE** — collapse engine 全 stub恢复成本和设计不确定性都高
5. **BASH_CLASSIFIER** — 若无 API beta 能力不值得优先;若有则升到第 2
6. **CACHED_MICROCOMPACT** — 受未公开 API 约束,最后做
---
## 审计报告分类标准修正建议
Codex 建议将原来的单轴分类COMPLETE/PARTIAL/STUB改为**三轴**
| 轴 | 取值 | 说明 |
|----|------|------|
| **实现完整度** | `full` / `mixed` / `stub` | 活跃调用链上的核心模块是否有真实现 |
| **激活条件** | `compile-only` / `compile+env` / `compile+GrowthBook` / `compile+remote` / `compile+private API` | 启用需要什么 |
| **运行风险** | `safe no-op` / `background IO` / `startup critical` | 启用后条件不满足时的行为 |
**COMPLETE 的最低标准应满足:**
1. 活跃调用链上的核心模块不能是 stub
2. "可启用"不能只看编译 flag还要单列运行时 gate
按此标准,`CONTEXT_COLLAPSE``BG_SESSIONS``BASH_CLASSIFIER``PROACTIVE``REACTIVE_COMPACT``CACHED_MICROCOMPACT` 都应从 COMPLETE 降级。
---
## 已采取的行动
基于审查结果,已将以下 3 个确认安全的 flag 加入默认构建:
**build.ts:**
```typescript
const DEFAULT_BUILD_FEATURES = [
"AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE",
"SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET"
];
```
**scripts/dev.ts:**
```typescript
const DEFAULT_FEATURES = [
"BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE",
"AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE",
"SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET"
];
```
### 验证结果
| 项目 | 结果 |
|------|------|
| `bun run build` | ✅ 成功 (475 files) |
| `bun test` | ✅ 无新增失败 (23 fail 为已有问题) |
| SHOT_STATS 代码路径 | ✅ 完整 — stats 面板显示 shot 分布 |
| TOKEN_BUDGET 代码路径 | ✅ 完整 — 支持 `+500k` 语法,带进度条 |
| PROMPT_CACHE_BREAK_DETECTION 代码路径 | ✅ 完整 — 内部诊断debug 模式可见 |

426
docs/features/ssh-remote.md Normal file
View File

@@ -0,0 +1,426 @@
# SSH Remote — 远程主机运行 Claude Code
## 概述
SSH Remote 提供两种方式在远程 Linux 主机上运行 Claude Code
1. **SSH Remote 模块**`ccb ssh <host>`)— 本地 REPL + 远程工具执行,自动部署二进制 + 认证隧道
2. **直接 SSH 运行**`ssh <host> -t ccb`)— 远程已安装 ccb直接启动交互式会话
## 架构
### 方式一SSH Remote 模块(完整模式)
适用场景:远端没有 API 凭据或没有安装 ccb。
```
┌──────────────── 本地 Windows/Mac/Linux ───────────┐
│ │
│ ccb ssh <host> [dir] │
│ │ │
│ ├── 1. SSHProbe: 探测远端平台/架构/已有二进制 │
│ ├── 2. SSHDeploy: 部署 dist/ 到远端 │
│ ├── 3. SSHAuthProxy: 启动本地认证代理 │
│ │ ├─ Unix Socket (Linux/Mac) │
│ │ └─ TCP 127.0.0.1:<port> (Windows) │
│ │ │
│ └── 4. SSH -R 反向隧道 + 启动远端 CLI │
│ ssh -R <remote>:<local> <host> \ │
│ ANTHROPIC_BASE_URL=... \ │
│ ANTHROPIC_AUTH_NONCE=... \ │
│ ccb --output-format stream-json │
│ │
│ ┌─────── 本地 REPL (Ink TUI) ───────┐ │
│ │ 用户输入 → NDJSON → SSH stdin │ │
│ │ SSH stdout → NDJSON → 渲染消息 │ │
│ │ 工具权限请求 → 本地审批 → 回传 │ │
│ └────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
│ SSH 连接 (加密通道)
┌───────────────── 远端 Linux ──────────────────────┐
│ │
│ ccb (自动部署或已存在) │
│ ├── --output-format stream-json │
│ ├── --input-format stream-json │
│ ├── --verbose -p │
│ │ │
│ ├── API 请求 → ANTHROPIC_BASE_URL │
│ │ → SSH 反向隧道 → 本地 AuthProxy │
│ │ → 注入真实凭据 → api.anthropic.com │
│ │ │
│ └── 工具执行 (Bash/Read/Write/...) │
│ 直接在远端文件系统上操作 │
└────────────────────────────────────────────────────┘
```
### 方式二:直接 SSH 运行(简单模式)
适用场景:远端已安装 ccb 且已有 API 凭据(订阅或 API Key
```
┌─────── 本地终端 ───────┐ ┌──────── 远端 Linux ────────┐
│ │ SSH │ │
│ ssh <host> -t ccb │ ──────→ │ ccb (全局安装) │
│ │ │ ├── 使用远端自身凭据 │
│ 终端直接显示远端 TUI │ ←────── │ ├── 远端文件系统操作 │
│ │ TTY │ └── API 直连 Anthropic │
└─────────────────────────┘ └─────────────────────────────┘
```
### 适用场景对比
| | SSH Remote 模块 | 直接 SSH 运行 |
|---|---|---|
| 远端需要安装 ccb | 不需要(自动部署) | 需要 |
| 远端需要 API 凭据 | 不需要(本地隧道) | 需要 |
| 本地需要安装 ccb | 需要 | 不需要(任何终端) |
| 斜杠命令 | 本地处理 | 远端处理 |
| 网络延迟敏感 | 高NDJSON 双向) | 低(仅 TTY |
| 推荐场景 | 远端无凭据/无安装 | 远端已配置完整 |
---
## 前置准备SSH 密钥配置
两种方式都依赖 SSH 免密连接。以下是完整的密钥配置步骤。
### 1. 生成 SSH 密钥对(本地)
```bash
# 生成 Ed25519 密钥(推荐)
ssh-keygen -t ed25519 -C "your-email@example.com" -f ~/.ssh/id_remote
# 或 RSA 4096 位
ssh-keygen -t rsa -b 4096 -C "your-email@example.com" -f ~/.ssh/id_remote
```
生成两个文件:
- `~/.ssh/id_remote` — 私钥(不可泄露)
- `~/.ssh/id_remote.pub` — 公钥(部署到远端)
### 2. 将公钥部署到远端
```bash
# 方式 Assh-copy-id推荐
ssh-copy-id -i ~/.ssh/id_remote.pub user@remote-host
# 方式 B手动复制
cat ~/.ssh/id_remote.pub | ssh user@remote-host "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
```
### 3. 配置 SSH Config本地
编辑 `~/.ssh/config`(不存在则创建):
```
Host my-server
HostName 192.168.1.100 # 远端 IP 或域名
User root # 远端用户名
IdentityFile ~/.ssh/id_remote # 私钥路径
ServerAliveInterval 60 # 防止连接超时断开
ServerAliveCountMax 3
```
配置后可直接用别名连接:
```bash
ssh my-server # 等同于 ssh -i ~/.ssh/id_remote root@192.168.1.100
```
### 4. 文件权限设置
#### Linux / macOS
```bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/config
chmod 600 ~/.ssh/id_remote
chmod 644 ~/.ssh/id_remote.pub
```
#### WindowsOpenSSH 强制 ACL 检查)
```powershell
# 重置 .ssh 目录权限:仅允许当前用户 + SYSTEM
icacls "$env:USERPROFILE\.ssh" /inheritance:r /grant:r "$($env:USERNAME):(OI)(CI)F" /grant "SYSTEM:(OI)(CI)F"
# 修复 config 文件权限
icacls "$env:USERPROFILE\.ssh\config" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
# 修复私钥权限
icacls "$env:USERPROFILE\.ssh\id_remote" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
```
> **Windows 常见错误**:如果 `icacls` 显示 `UNKNOWN\UNKNOWN` ACL 条目,需要先移除再重新授权。权限错误会导致 SSH 拒绝使用密钥。
### 5. 验证免密连接
```bash
ssh my-server "echo 'SSH connection OK'"
# 应直接输出 "SSH connection OK",不要求输入密码
```
---
## 使用方式
### 方式一SSH Remote 模块
```bash
# 基本用法 — 自动探测、部署、启动
ccb ssh user@remote-host
# 使用 SSH Config 别名
ccb ssh my-server
# 指定远端工作目录
ccb ssh my-server /home/user/project
# 使用自定义远端二进制(跳过探测/部署)
ccb ssh my-server --remote-bin "bun /opt/ccb/dist/cli.js"
# 权限控制
ccb ssh my-server --permission-mode auto
ccb ssh my-server --dangerously-skip-permissions
# 恢复远端会话
ccb ssh my-server --continue
ccb ssh my-server --resume <session-uuid>
# 选择模型
ccb ssh my-server --model claude-sonnet-4-6-20250514
# 本地测试模式(不连接远端,测试 auth proxy 管道)
ccb ssh localhost --local
```
### 方式二:直接 SSH 运行
```bash
# 启动交互式会话
ssh my-server -t ccb
# 指定工作目录
ssh my-server -t "ccb --cwd /home/user/project"
# 使用特定模型
ssh my-server -t "ccb --model claude-sonnet-4-6-20250514"
```
---
## 构建与部署
### 构建产物
```bash
# 安装依赖
bun install
# 构建(输出到 dist/
bun run build
```
产物说明:
| 文件 | 说明 |
|------|------|
| `dist/cli.js` | Bun 入口(`#!/usr/bin/env bun` |
| `dist/cli-node.js` | Node.js 入口(`#!/usr/bin/env node``import ./cli.js` |
| `dist/cli-bun.js` | Bun 专用入口 |
| `dist/chunk-*.js` | 代码分割 chunk 文件(约 668 个) |
### 运行方式
```bash
# 方式 A通过 bun 直接运行(开发/调试)
bun run dev
# 方式 B运行构建产物bun 运行时)
bun dist/cli.js
# 方式 C运行构建产物node 运行时)
node dist/cli-node.js
# 方式 D全局安装后使用命令名
ccb
```
### 全局安装
在项目根目录执行:
```bash
# bun 全局安装(推荐)
bun install -g .
# 创建的命令:
# ccb → dist/cli-node.js
# ccb-bun → dist/cli-bun.js
# claude-code-best → dist/cli-node.js
# 安装位置:~/.bun/bin/ccb
```
或使用 npm
```bash
npm install -g .
```
验证:
```bash
ccb --version
# → x.x.x (Claude Code)
```
### 远端部署(全流程)
```bash
# 1. 登录远端
ssh my-server
# 2. 克隆或同步项目代码
git clone <repo-url> ~/ccb-project
cd ~/ccb-project
# 3. 安装运行时(如果没有 bun
curl -fsSL https://bun.sh/install | bash
source ~/.bashrc
# 4. 安装依赖 + 构建
bun install
bun run build
# 5. 全局安装
bun install -g .
# 6. 确保非交互式 SSH 可访问 ccb 命令
# bun install -g 安装到 ~/.bun/bin/,但非交互式 SSH 不加载 .bashrc
# 所以 PATH 中不包含 ~/.bun/bin/
# 解决方式(任选其一):
# 方式 A符号链接到系统 PATH推荐
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
# 方式 B添加到 /etc/profile.d/(所有用户生效)
echo 'export PATH="$HOME/.bun/bin:$PATH"' > /etc/profile.d/bun-path.sh
# 方式 C添加到 ~/.bash_profile当前用户ssh -t 时生效)
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bash_profile
# 7. 验证
ccb --version
# 8. 从本地测试
# (在本地终端)
ssh my-server -t ccb
```
### SSH Remote 自动部署
使用 `ccb ssh <host>` 时,模块自动处理:
1. **SSHProbe** 探测远端 `~/.local/bin/claude``command -v claude`
2. 若二进制不存在或版本不匹配,**SSHDeploy** 通过 `scp` 传输 `dist/` 目录
3. 在远端创建 wrapper 脚本(`~/.local/bin/claude`
4. 无需手动安装
---
## 模块结构
```
src/ssh/
├── createSSHSession.ts — 会话工厂:编排 probe → deploy → proxy → spawn
├── SSHSessionManager.ts — 双向 NDJSON 通信管理 + 权限转发 + 重连
├── SSHAuthProxy.ts — 本地认证代理API 凭据隧道)
├── SSHProbe.ts — 远端主机探测(平台/架构/已有二进制)
├── SSHDeploy.ts — 远端二进制部署scp + wrapper 脚本)
└── __tests__/
└── SSHSessionManager.test.ts — 17 个单元测试
```
## 关键技术细节
### 认证隧道
- **AuthProxy** 在本地监听Unix socket 或 TCP接收远端 CLI 的 API 请求
- 通过 SSH `-R` 反向端口转发隧道到远端
- AuthProxy 注入本地真实凭据API key 或 OAuth token转发到 `api.anthropic.com`
- `ANTHROPIC_AUTH_NONCE` header 防止未授权访问nonce 通过环境变量传递给远端 CLI远端 CLI 在每个 API 请求中携带此 header
### waitForInit vs 存活检查
- **标准模式**`waitForInit` 等待远端 CLI 发送 `{type:'system', subtype:'init'}` JSON 消息
- **`--remote-bin` 模式**:跳过 `waitForInit`print+stream-json 模式下 init 只在首次查询后发送),改用 3 秒进程存活检查
### 重连机制
- `SSHSessionManager` 检测 SSH 连接断开后自动重连
- 重连时在远端 CLI 命令中追加 `--continue` 恢复会话
- 指数退避重试(最多 5 次,间隔 1s → 2s → 4s → 8s → 16s
## Feature Flag
SSH Remote 功能受 `SSH_REMOTE` feature flag 控制:
- **Dev 模式**:默认启用
- **Build 模式**:需在 `build.ts``DEFAULT_BUILD_FEATURES` 中添加 `'SSH_REMOTE'`
- **运行时**`FEATURE_SSH_REMOTE=1` 环境变量
---
## 常见问题
### `ccb: command not found`SSH 远程执行时)
非交互式 SSH 不加载 `.bashrc``~/.bun/bin` 不在 PATH 中。
```bash
# 解决:创建符号链接
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
```
### SSH 密钥被拒绝
```
Permission denied (publickey)
```
1. 确认公钥已添加到远端 `~/.ssh/authorized_keys`
2. 确认本地私钥文件权限正确(`chmod 600`
3. 确认 `~/.ssh/config``IdentityFile` 路径正确
4. Windows 用户检查 ACL 权限(见上方 Windows 权限设置)
### SSH 连接超时
```
ssh: connect to host x.x.x.x port 22: Connection timed out
```
1. 确认远端 SSH 服务正在运行:`systemctl status sshd`
2. 确认防火墙允许 22 端口
3. 确认 IP 地址/域名正确
4.`~/.ssh/config` 中添加 `ConnectTimeout 10`
### 403 ForbiddenSSH Remote 模块)
AuthProxy 的 nonce 验证失败。确认:
1. 远端 CLI 版本包含 nonce header 注入修复
2. `ANTHROPIC_AUTH_NONCE` 环境变量正确传递到远端
3. `src/services/api/client.ts``x-auth-nonce` header 已启用
### 远端 CLI 启动后立即退出
```
Remote process exited immediately (code 1)
```
1. 确认远端 `bun` / `node` 运行时可用
2. 手动在远端执行 `ccb --version` 验证安装
3. 检查 `--remote-bin` 路径是否正确
4. 查看 stderr 输出获取详细错误信息

1218
docs/ink-guide.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "1.5.0",
"version": "1.9.4",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",
@@ -47,22 +47,28 @@
"build:bun": "bun run build.ts",
"dev": "bun run scripts/dev.ts",
"dev:inspect": "bun run scripts/dev-debug.ts",
"prepublishOnly": "bun run build",
"prepublishOnly": "bun run build:vite",
"lint": "biome lint src/",
"lint:fix": "biome lint --fix src/",
"format": "biome format --write src/",
"prepare": "git config core.hooksPath .githooks",
"test": "bun test",
"test:production": "bun run scripts/production-test.ts",
"test:production:offline": "bun run scripts/production-test.ts --offline",
"test:production:verbose": "bun run scripts/production-test.ts --verbose",
"test:production:bun": "bun run scripts/production-test.ts --bun",
"check:bundle": "bun run scripts/check-bundle-integrity.ts",
"check:unused": "knip-bun",
"health": "bun run scripts/health-check.ts",
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
"docs:dev": "npx mintlify dev",
"typecheck": "tsc --noEmit",
"test:all": "bun run typecheck && bun test",
"rcs": "bun run scripts/rcs.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
"@claude-code-best/mcp-chrome-bridge": "^3.0.1",
"highlight.js": "^11.11.1",
"ws": "^8.20.0"
},
"devDependencies": {
@@ -157,7 +163,6 @@
"get-east-asian-width": "^1.5.0",
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
"highlight.js": "^11.11.1",
"https-proxy-agent": "^8.0.0",
"ignore": "^7.0.5",
"image-processor-napi": "workspace:*",

View File

@@ -286,6 +286,15 @@ export default class App extends PureComponent<Props, State> {
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
this.handleSetRawMode(false)
} else {
// Even when raw mode was never enabled (e.g. non-TTY stdin on
// Windows Node.js), ensure stdin is unref'd so the process can
// exit. earlyInput may have called ref() before Ink mounted.
try {
this.props.stdin.unref()
} catch {
// stdin may already be destroyed
}
}
}

View File

@@ -21,26 +21,22 @@ function makeAssistantMsg(content: string | any[]): AssistantMessage {
describe('anthropicMessagesToOpenAI', () => {
test('converts system prompt to system message', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('hello')],
['You are helpful.'] as any,
)
const result = anthropicMessagesToOpenAI([makeUserMsg('hello')], [
'You are helpful.',
] as any)
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
})
test('joins multiple system prompt strings', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('hi')],
['Part 1', 'Part 2'] as any,
)
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [
'Part 1',
'Part 2',
] as any)
expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' })
})
test('skips empty system prompt', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('hi')],
[] as any,
)
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [] as any)
expect(result[0].role).toBe('user')
})
@@ -54,10 +50,12 @@ describe('anthropicMessagesToOpenAI', () => {
test('converts user message with content array', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{ type: 'text', text: 'line 1' },
{ type: 'text', text: 'line 2' },
])],
[
makeUserMsg([
{ type: 'text', text: 'line 1' },
{ type: 'text', text: 'line 2' },
]),
],
[] as any,
)
expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }])
@@ -73,55 +71,67 @@ describe('anthropicMessagesToOpenAI', () => {
test('converts assistant message with tool_use', () => {
const result = anthropicMessagesToOpenAI(
[makeAssistantMsg([
{ type: 'text', text: 'Let me help.' },
{
type: 'tool_use' as const,
id: 'toolu_123',
name: 'bash',
input: { command: 'ls' },
},
])],
[
makeAssistantMsg([
{ type: 'text', text: 'Let me help.' },
{
type: 'tool_use' as const,
id: 'toolu_123',
name: 'bash',
input: { command: 'ls' },
},
]),
],
[] as any,
)
expect(result).toEqual([{
role: 'assistant',
content: 'Let me help.',
tool_calls: [{
id: 'toolu_123',
type: 'function',
function: { name: 'bash', arguments: '{"command":"ls"}' },
}],
}])
expect(result).toEqual([
{
role: 'assistant',
content: 'Let me help.',
tool_calls: [
{
id: 'toolu_123',
type: 'function',
function: { name: 'bash', arguments: '{"command":"ls"}' },
},
],
},
])
})
test('converts tool_result to tool message', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'tool_result' as const,
tool_use_id: 'toolu_123',
content: 'file1.txt\nfile2.txt',
},
])],
[
makeUserMsg([
{
type: 'tool_result' as const,
tool_use_id: 'toolu_123',
content: 'file1.txt\nfile2.txt',
},
]),
],
[] as any,
)
expect(result).toEqual([{
role: 'tool',
tool_call_id: 'toolu_123',
content: 'file1.txt\nfile2.txt',
}])
expect(result).toEqual([
{
role: 'tool',
tool_call_id: 'toolu_123',
content: 'file1.txt\nfile2.txt',
},
])
})
test('strips thinking blocks', () => {
test('preserves thinking blocks as reasoning_content', () => {
const result = anthropicMessagesToOpenAI(
[makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
{ type: 'text', text: 'visible response' },
])],
[
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
{ type: 'text', text: 'visible response' },
]),
],
[] as any,
)
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
expect(result).toEqual([{ role: 'assistant', content: 'visible response', reasoning_content: 'internal thoughts...' }] as any)
})
test('handles full conversation with tools', () => {
@@ -157,91 +167,105 @@ describe('anthropicMessagesToOpenAI', () => {
test('converts base64 image to image_url', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{ type: 'text', text: 'what is this?' },
{
type: 'image' as const,
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
[
makeUserMsg([
{ type: 'text', text: 'what is this?' },
{
type: 'image' as const,
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
},
},
},
])],
]),
],
[] as any,
)
expect(result).toEqual([{
role: 'user',
content: [
{ type: 'text', text: 'what is this?' },
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
},
],
}])
expect(result).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'what is this?' },
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
},
],
},
])
})
test('converts url image to image_url', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'image' as const,
source: {
type: 'url',
url: 'https://example.com/img.png',
[
makeUserMsg([
{
type: 'image' as const,
source: {
type: 'url',
url: 'https://example.com/img.png',
},
},
},
])],
]),
],
[] as any,
)
expect(result).toEqual([{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'https://example.com/img.png' },
},
],
}])
expect(result).toEqual([
{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'https://example.com/img.png' },
},
],
},
])
})
test('converts image-only message without text', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'image' as const,
source: {
type: 'base64',
media_type: 'image/jpeg',
data: '/9j/4AAQ',
[
makeUserMsg([
{
type: 'image' as const,
source: {
type: 'base64',
media_type: 'image/jpeg',
data: '/9j/4AAQ',
},
},
},
])],
]),
],
[] as any,
)
expect(result).toEqual([{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
},
],
}])
expect(result).toEqual([
{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
},
],
},
])
})
test('defaults to image/png when media_type is missing', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'image' as const,
source: {
type: 'base64',
data: 'ABC123',
[
makeUserMsg([
{
type: 'image' as const,
source: {
type: 'base64',
data: 'ABC123',
},
},
},
])],
]),
],
[] as any,
)
expect((result[0].content as any[])[0].image_url.url).toBe(
@@ -253,10 +277,16 @@ describe('anthropicMessagesToOpenAI', () => {
describe('DeepSeek thinking mode (enableThinking)', () => {
test('preserves thinking block as reasoning_content when enabled', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('question'), makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Let me reason about this...' },
{ type: 'text', text: 'The answer is 42.' },
])],
[
makeUserMsg('question'),
makeAssistantMsg([
{
type: 'thinking' as const,
thinking: 'Let me reason about this...',
},
{ type: 'text', text: 'The answer is 42.' },
]),
],
[] as any,
{ enableThinking: true },
)
@@ -269,17 +299,19 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
expect(assistant.reasoning_content).toBe('Let me reason about this...')
})
test('drops thinking block when enableThinking is false (default)', () => {
test('preserves thinking block as reasoning_content even without enableThinking', () => {
const result = anthropicMessagesToOpenAI(
[makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
{ type: 'text', text: 'visible response' },
])],
[
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
{ type: 'text', text: 'visible response' },
]),
],
[] as any,
)
const assistant = result[0] as any
expect(assistant.content).toBe('visible response')
expect(assistant.reasoning_content).toBeUndefined()
expect(assistant.reasoning_content).toBe('internal thoughts...')
})
test('preserves reasoning_content with tool_calls in same turn', () => {
@@ -287,7 +319,10 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
[
makeUserMsg('what is the weather?'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'I need to call the weather tool.' },
{
type: 'thinking' as const,
thinking: 'I need to call the weather tool.',
},
{ type: 'text', text: '' },
{
type: 'tool_use' as const,
@@ -317,7 +352,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
expect(assistant.tool_calls[0].function.name).toBe('get_weather')
})
test('strips reasoning_content from previous turns', () => {
test('always preserves reasoning_content from all turns', () => {
const result = anthropicMessagesToOpenAI(
[
// Turn 1: user → assistant (with thinking)
@@ -326,7 +361,8 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
{ type: 'thinking' as const, thinking: 'Turn 1 reasoning...' },
{ type: 'text', text: 'Turn 1 answer' },
]),
// Turn 2: new user message → previous reasoning should be stripped
// Turn 2: new user message → reasoning should still be preserved
// (DeepSeek requires reasoning_content to be passed back when tool calls are involved)
makeUserMsg('question 2'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Turn 2 reasoning...' },
@@ -338,10 +374,9 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
)
const assistants = result.filter(m => m.role === 'assistant')
// Turn 1 assistant: reasoning should be stripped (previous turn)
expect((assistants[0] as any).reasoning_content).toBeUndefined()
// Both turns preserve reasoning_content (DeepSeek API requires it for tool calls)
expect((assistants[0] as any).reasoning_content).toBe('Turn 1 reasoning...')
expect((assistants[0] as any).content).toBe('Turn 1 answer')
// Turn 2 assistant: reasoning should be preserved (current turn)
expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...')
expect((assistants[1] as any).content).toBe('Turn 2 answer')
})
@@ -399,18 +434,27 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
const assistants = result.filter(m => m.role === 'assistant')
expect(assistants.length).toBe(3)
// All iterations within the same turn preserve reasoning
expect((assistants[0] as any).reasoning_content).toBe('I need the date first.')
expect((assistants[1] as any).reasoning_content).toBe('Now I can get the weather.')
expect((assistants[2] as any).reasoning_content).toBe('I have the info now.')
expect((assistants[0] as any).reasoning_content).toBe(
'I need the date first.',
)
expect((assistants[1] as any).reasoning_content).toBe(
'Now I can get the weather.',
)
expect((assistants[2] as any).reasoning_content).toBe(
'I have the info now.',
)
})
test('handles multiple thinking blocks in single assistant message', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('question'), makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'First thought.' },
{ type: 'thinking' as const, thinking: 'Second thought.' },
{ type: 'text', text: 'Final answer.' },
])],
[
makeUserMsg('question'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'First thought.' },
{ type: 'thinking' as const, thinking: 'Second thought.' },
{ type: 'text', text: 'Final answer.' },
]),
],
[] as any,
{ enableThinking: true },
)
@@ -420,10 +464,13 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
test('skips empty thinking blocks', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('question'), makeAssistantMsg([
{ type: 'thinking' as const, thinking: '' },
{ type: 'text', text: 'Answer.' },
])],
[
makeUserMsg('question'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: '' },
{ type: 'text', text: 'Answer.' },
]),
],
[] as any,
{ enableThinking: true },
)
@@ -481,15 +528,18 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
test('sets content to null when only thinking and tool_calls present', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('question'), makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
{
type: 'tool_use' as const,
id: 'toolu_001',
name: 'bash',
input: { command: 'ls' },
},
])],
[
makeUserMsg('question'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
{
type: 'tool_use' as const,
id: 'toolu_001',
name: 'bash',
input: { command: 'ls' },
},
]),
],
[] as any,
{ enableThinking: true },
)

View File

@@ -18,25 +18,29 @@ describe('anthropicToolsToOpenAI', () => {
const result = anthropicToolsToOpenAI(tools as any)
expect(result).toEqual([{
type: 'function',
function: {
name: 'bash',
description: 'Run a bash command',
parameters: {
type: 'object',
properties: { command: { type: 'string' } },
required: ['command'],
expect(result).toEqual([
{
type: 'function',
function: {
name: 'bash',
description: 'Run a bash command',
parameters: {
type: 'object',
properties: { command: { type: 'string' } },
required: ['command'],
},
},
},
}])
])
})
test('uses empty schema when input_schema missing', () => {
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
const result = anthropicToolsToOpenAI(tools as any)
expect((result[0] as { function: { parameters: unknown } }).function.parameters).toEqual({ type: 'object', properties: {} })
expect(
(result[0] as { function: { parameters: unknown } }).function.parameters,
).toEqual({ type: 'object', properties: {} })
})
test('strips Anthropic-specific fields', () => {
@@ -76,7 +80,8 @@ describe('anthropicToolsToOpenAI', () => {
},
]
const result = anthropicToolsToOpenAI(tools as any)
const props = (result[0] as { function: { parameters: any } }).function.parameters as any
const props = (result[0] as { function: { parameters: any } }).function
.parameters as any
expect(props.properties.mode).toEqual({ enum: ['read'] })
expect(props.properties.mode.const).toBeUndefined()
expect(props.properties.name).toEqual({ type: 'string' })
@@ -110,8 +115,11 @@ describe('anthropicToolsToOpenAI', () => {
},
]
const result = anthropicToolsToOpenAI(tools as any)
const params = (result[0] as { function: { parameters: any } }).function.parameters as any
expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] })
const params = (result[0] as { function: { parameters: any } }).function
.parameters as any
expect(params.properties.outer.properties.inner).toEqual({
enum: ['fixed'],
})
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
})
@@ -125,18 +133,17 @@ describe('anthropicToolsToOpenAI', () => {
type: 'object',
properties: {
val: {
anyOf: [
{ const: 'a' },
{ const: 'b' },
{ type: 'string' },
],
anyOf: [{ const: 'a' }, { const: 'b' }, { type: 'string' }],
},
},
},
},
]
const result = anthropicToolsToOpenAI(tools as any)
const anyOf = ((result[0] as { function: { parameters: any } }).function.parameters as any).properties.val.anyOf
const anyOf = (
(result[0] as { function: { parameters: any } }).function
.parameters as any
).properties.val.anyOf
expect(anyOf[0]).toEqual({ enum: ['a'] })
expect(anyOf[1]).toEqual({ enum: ['b'] })
expect(anyOf[2]).toEqual({ type: 'string' })

View File

@@ -26,16 +26,16 @@ export interface ConvertMessagesOptions {
* - system prompt → role: "system" message prepended
* - tool_use blocks → tool_calls[] on assistant message
* - tool_result blocks → role: "tool" messages
* - thinking blocks → silently dropped (or preserved as reasoning_content when enableThinking=true)
* - thinking blocks → preserved as reasoning_content (DeepSeek requires passing it back)
* - cache_control → stripped
*/
export function anthropicMessagesToOpenAI(
messages: (UserMessage | AssistantMessage)[],
systemPrompt: SystemPrompt,
options?: ConvertMessagesOptions,
// options retained for API compatibility; thinking blocks are now always preserved
_options?: ConvertMessagesOptions,
): ChatCompletionMessageParam[] {
const result: ChatCompletionMessageParam[] = []
const enableThinking = options?.enableThinking ?? false
// Prepend system prompt as system message
const systemText = systemPromptToText(systemPrompt)
@@ -46,50 +46,13 @@ export function anthropicMessagesToOpenAI(
} satisfies ChatCompletionSystemMessageParam)
}
// When thinking mode is on, detect turn boundaries so that reasoning_content
// from *previous* user turns is stripped (saves bandwidth; DeepSeek ignores it).
// A "new turn" starts when a user text message appears after at least one assistant response.
const turnBoundaries = new Set<number>()
if (enableThinking) {
let hasSeenAssistant = false
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.type === 'assistant') {
hasSeenAssistant = true
}
if (msg.type === 'user' && hasSeenAssistant) {
const content = msg.message.content
// A user message starts a new turn if it contains any non-tool_result content
// (text, image, or other media). Tool results alone do NOT start a new turn
// because they are continuations of the previous assistant tool call.
const startsNewUserTurn = typeof content === 'string'
? content.length > 0
: Array.isArray(content) && content.some(
(b: any) =>
typeof b === 'string' ||
(b &&
typeof b === 'object' &&
'type' in b &&
b.type !== 'tool_result'),
)
if (startsNewUserTurn) {
turnBoundaries.add(i)
}
}
}
}
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
for (const msg of messages) {
switch (msg.type) {
case 'user':
result.push(...convertInternalUserMessage(msg))
break
case 'assistant':
// Preserve reasoning_content unless we're before a turn boundary
// (i.e., from a previous user Q&A round)
const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
result.push(...convertInternalAssistantMessage(msg))
break
default:
break
@@ -101,20 +64,7 @@ export function anthropicMessagesToOpenAI(
function systemPromptToText(systemPrompt: SystemPrompt): string {
if (!systemPrompt || systemPrompt.length === 0) return ''
return systemPrompt
.filter(Boolean)
.join('\n\n')
}
/**
* Check if index `i` falls before any turn boundary (i.e. it belongs to a previous turn).
* A message at index i is "before" a boundary if there exists a boundary j where i < j.
*/
function isBeforeAnyTurnBoundary(i: number, boundaries: Set<number>): boolean {
for (const b of boundaries) {
if (i < b) return true
}
return false
return systemPrompt.filter(Boolean).join('\n\n')
}
function convertInternalUserMessage(
@@ -131,7 +81,8 @@ function convertInternalUserMessage(
} else if (Array.isArray(content)) {
const textParts: string[] = []
const toolResults: BetaToolResultBlockParam[] = []
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = []
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> =
[]
for (const block of content) {
if (typeof block === 'string') {
@@ -141,7 +92,9 @@ function convertInternalUserMessage(
} else if (block.type === 'tool_result') {
toolResults.push(block as BetaToolResultBlockParam)
} else if (block.type === 'image') {
const imagePart = convertImageBlockToOpenAI(block as unknown as Record<string, unknown>)
const imagePart = convertImageBlockToOpenAI(
block as unknown as Record<string, unknown>,
)
if (imagePart) {
imageParts.push(imagePart)
}
@@ -158,7 +111,10 @@ function convertInternalUserMessage(
// 如果有图片,构建多模态 content 数组
if (imageParts.length > 0) {
const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = []
const multiContent: Array<
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } }
> = []
if (textParts.length > 0) {
multiContent.push({ type: 'text', text: textParts.join('\n') })
}
@@ -206,7 +162,6 @@ function convertToolResult(
function convertInternalAssistantMessage(
msg: AssistantMessage,
preserveReasoning = false,
): ChatCompletionMessageParam[] {
const content = msg.message.content
@@ -229,7 +184,9 @@ function convertInternalAssistantMessage(
}
const textParts: string[] = []
const toolCalls: NonNullable<ChatCompletionAssistantMessageParam['tool_calls']> = []
const toolCalls: NonNullable<
ChatCompletionAssistantMessageParam['tool_calls']
> = []
const reasoningParts: string[] = []
for (const block of content) {
@@ -248,9 +205,12 @@ function convertInternalAssistantMessage(
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
},
})
} else if (block.type === 'thinking' && preserveReasoning) {
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
const thinkingText = (block as unknown as Record<string, unknown>).thinking
} else if (block.type === 'thinking') {
// DeepSeek thinking mode: always preserve reasoning_content.
// DeepSeek requires reasoning_content to be passed back in subsequent requests,
// especially when tool calls are involved (returns 400 if missing).
const thinkingText = (block as unknown as Record<string, unknown>)
.thinking
if (typeof thinkingText === 'string' && thinkingText) {
reasoningParts.push(thinkingText)
}
@@ -262,7 +222,9 @@ function convertInternalAssistantMessage(
role: 'assistant',
content: textParts.length > 0 ? textParts.join('\n') : null,
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }),
...(reasoningParts.length > 0 && {
reasoning_content: reasoningParts.join('\n'),
}),
}
return [result]

View File

@@ -16,21 +16,27 @@ export function anthropicToolsToOpenAI(
.filter(tool => {
// Only convert standard tools (skip server tools like computer_use, etc.)
const toolType = (tool as unknown as { type?: string }).type
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
return (
tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
)
})
.map(tool => {
// Handle the various tool shapes from Anthropic SDK
const anyTool = tool as unknown as Record<string, unknown>
const name = (anyTool.name as string) || ''
const description = (anyTool.description as string) || ''
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
const inputSchema = anyTool.input_schema as
| Record<string, unknown>
| undefined
return {
type: 'function' as const,
function: {
name,
description,
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
parameters: sanitizeJsonSchema(
inputSchema || { type: 'object', properties: {} },
),
},
} satisfies ChatCompletionTool
})
@@ -43,7 +49,9 @@ export function anthropicToolsToOpenAI(
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
* single-element array, which is semantically equivalent.
*/
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
function sanitizeJsonSchema(
schema: Record<string, unknown>,
): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema
const result = { ...schema }
@@ -55,20 +63,37 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
}
// Recursively process nested schemas
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
const objectKeys = [
'properties',
'definitions',
'$defs',
'patternProperties',
] as const
for (const key of objectKeys) {
const nested = result[key]
if (nested && typeof nested === 'object') {
const sanitized: Record<string, unknown> = {}
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
sanitized[k] =
v && typeof v === 'object'
? sanitizeJsonSchema(v as Record<string, unknown>)
: v
}
result[key] = sanitized
}
}
// Recursively process single-schema keys
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
const singleKeys = [
'items',
'additionalProperties',
'not',
'if',
'then',
'else',
'contains',
'propertyNames',
] as const
for (const key of singleKeys) {
const nested = result[key]
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
@@ -82,7 +107,9 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
const nested = result[key]
if (Array.isArray(nested)) {
result[key] = nested.map(item =>
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
item && typeof item === 'object'
? sanitizeJsonSchema(item as Record<string, unknown>)
: item,
)
}
}

View File

@@ -42,7 +42,10 @@ export async function* adaptOpenAIStreamToAnthropic(
let currentContentIndex = -1
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
const toolBlocks = new Map<number, { contentIndex: number; id: string; name: string; arguments: string }>()
const toolBlocks = new Map<
number,
{ contentIndex: number; id: string; name: string; arguments: string }
>()
// Track thinking block state
let thinkingBlockOpen = false
@@ -197,7 +200,8 @@ export async function* adaptOpenAIStreamToAnthropic(
// Start new tool_use block
currentContentIndex++
const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
const toolId =
tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
const toolName = tc.function?.name || ''
toolBlocks.set(tcIndex, {

View File

@@ -26,7 +26,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
"@hono/node-server": "^1.13.8",
"@hono/node-server": "^2.0.0",
"@hono/node-ws": "^1.0.5",
"@stricli/auto-complete": "^1.2.4",
"@stricli/core": "^1.2.4",

View File

@@ -1,3 +1,9 @@
import { createRequire } from 'node:module'
// createRequire works in both Bun and Node.js ESM contexts.
// Needed because this package is "type": "module" but uses require() for
// loading native .node addons — bare require is not available in Node.js ESM.
const nodeRequire = createRequire(import.meta.url)
type AudioCaptureNapi = {
startRecording(
@@ -41,7 +47,7 @@ function loadModule(): AudioCaptureNapi | null {
if (process.env.AUDIO_CAPTURE_NODE_PATH) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
cachedModule = require(
cachedModule = nodeRequire(
process.env.AUDIO_CAPTURE_NODE_PATH,
) as AudioCaptureNapi
return cachedModule
@@ -63,7 +69,7 @@ function loadModule(): AudioCaptureNapi | null {
for (const p of fallbacks) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
cachedModule = require(p) as AudioCaptureNapi
cachedModule = nodeRequire(p) as AudioCaptureNapi
return cachedModule
} catch {
// try next

View File

@@ -1,4 +1,5 @@
import { mock, describe, expect, test } from "bun:test";
import { debugMock } from "../../../../../../tests/mocks/debug";
// ─── Mocks for agentToolUtils.ts dependencies ───
// Only mock modules that are truly unavailable or cause side effects.
@@ -87,20 +88,7 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
updateProgressFromMessage: noop,
}));
mock.module("src/utils/debug.ts", () => ({
getMinDebugLogLevel: () => "warn",
isDebugMode: () => false,
enableDebugLogging: () => false,
getDebugFilter: () => null,
isDebugToStdErr: () => false,
getDebugFilePath: () => null,
setHasFormattedOutput: noop,
getHasFormattedOutput: () => false,
flushDebugLogs: async () => {},
logForDebugging: noop,
getDebugLogPath: () => "",
logAntError: noop,
}));
mock.module("src/utils/debug.ts", debugMock);
mock.module("src/utils/errors.js", () => ({
ClaudeError: class extends Error {},

View File

@@ -394,6 +394,7 @@ export const getAgentDefinitionsWithOverrides = memoize(
export function clearAgentDefinitionsCache(): void {
getAgentDefinitionsWithOverrides.cache.clear?.()
loadMarkdownFilesForSubdir.cache?.clear?.()
clearPluginAgentCache()
}

View File

@@ -2,6 +2,12 @@ import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { tokenCountWithEstimation } from 'src/utils/tokens.js'
import {
getStats,
isContextCollapseEnabled,
} from 'src/services/contextCollapse/index.js'
import { isSessionMemoryInitialized } from 'src/services/SessionMemory/sessionMemoryUtils.js'
const CTX_INSPECT_TOOL_NAME = 'CtxInspect'
@@ -19,6 +25,10 @@ type CtxInput = z.infer<InputSchema>
type CtxOutput = {
total_tokens: number
message_count: number
context_window_model: string
prompt_caching_enabled: boolean
session_memory_enabled: boolean
context_collapse_enabled: boolean
summary: string
}
@@ -67,13 +77,45 @@ Use this to understand your context budget before deciding whether to snip old m
}
},
async call() {
// Context inspection is wired into the context collapse system.
async call(input: CtxInput, context) {
const messages = context.messages ?? []
const model = context.options?.mainLoopModel ?? 'unknown'
const totalTokens = tokenCountWithEstimation(messages)
const collapseEnabled = isContextCollapseEnabled()
const collapseStats = getStats()
const focused = input.query?.trim()
const sessionMemoryEnabled = isSessionMemoryInitialized()
// Prompt caching is an API-level feature controlled by the provider, not
// a user-facing toggle. Report as enabled only for providers known to
// support Anthropic-style prompt caching (first-party, Bedrock, Vertex).
const promptCachingEnabled = !model.startsWith('openai/') &&
!model.startsWith('grok/') &&
!model.startsWith('gemini/')
const summaryParts = [
focused ? `Focus: ${focused}` : 'Overall context summary',
`Model context: ${model}`,
`Prompt caching: ${promptCachingEnabled ? 'enabled' : 'disabled'}`,
`Session memory: ${sessionMemoryEnabled ? 'enabled' : 'disabled'}`,
`Context collapse: ${collapseEnabled ? 'enabled' : 'disabled'}`,
]
if (collapseEnabled) {
summaryParts.push(
`Collapse spans: ${collapseStats.collapsedSpans} committed, ${collapseStats.stagedSpans} staged, ${collapseStats.collapsedMessages} messages summarized`,
)
}
return {
data: {
total_tokens: 0,
message_count: 0,
summary: 'Context inspection requires the CONTEXT_COLLAPSE runtime.',
total_tokens: totalTokens,
message_count: messages.length,
context_window_model: model,
prompt_caching_enabled: promptCachingEnabled,
session_memory_enabled: sessionMemoryEnabled,
context_collapse_enabled: collapseEnabled,
summary: summaryParts.join('\n'),
},
}
},

View File

@@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { logMock } from '../../../../../../tests/mocks/log'
mock.module('src/utils/log.ts', logMock)
mock.module('src/services/tokenEstimation.ts', () => ({
roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4),
roughTokenCountEstimationForMessages: (msgs: unknown[]) => msgs.length * 64,
roughTokenCountEstimationForMessage: () => 64,
roughTokenCountEstimationForFileType: () => 64,
bytesPerTokenForFileType: () => 4,
countTokensWithAPI: async () => 0,
countMessagesTokensWithAPI: async () => 0,
countTokensViaHaikuFallback: async () => 0,
}))
let sessionMemoryInitialized = false
mock.module('src/services/SessionMemory/sessionMemoryUtils.ts', () => ({
isSessionMemoryInitialized: () => sessionMemoryInitialized,
waitForSessionMemoryExtraction: async () => {},
getLastSummarizedMessageId: () => undefined,
getSessionMemoryContent: async () => null,
setLastSummarizedMessageId: () => {},
markExtractionStarted: () => {},
markExtractionCompleted: () => {},
setSessionMemoryConfig: () => {},
getSessionMemoryConfig: () => ({}),
recordExtractionTokenCount: () => {},
markSessionMemoryInitialized: () => {},
hasMetInitializationThreshold: () => false,
hasMetUpdateThreshold: () => false,
getToolCallsBetweenUpdates: () => 0,
resetSessionMemoryState: () => {},
DEFAULT_SESSION_MEMORY_CONFIG: {},
}))
mock.module('src/utils/slowOperations.ts', () => ({
jsonStringify: JSON.stringify,
jsonParse: JSON.parse,
slowLogging: { enabled: false },
clone: (value: unknown) => structuredClone(value),
cloneDeep: (value: unknown) => structuredClone(value),
callerFrame: () => '',
SLOW_OPERATION_THRESHOLD_MS: 100,
writeFileSync_DEPRECATED: () => {},
}))
const { initContextCollapse, resetContextCollapse } = await import(
'src/services/contextCollapse/index.js'
)
const { tokenCountWithEstimation } = await import('src/utils/tokens.js')
const { CtxInspectTool } = await import('../CtxInspectTool.js')
function makeUserMessage(text: string) {
return {
type: 'user' as const,
uuid: `user-${text}`,
message: { role: 'user' as const, content: text },
}
}
function makeAssistantMessage(text: string) {
return {
type: 'assistant' as const,
uuid: `assistant-${text}`,
message: {
role: 'assistant' as const,
content: [{ type: 'text' as const, text }],
},
}
}
function makeContext(messages: unknown[], mainLoopModel = 'claude-sonnet-4-6') {
return {
messages,
options: {
mainLoopModel,
},
getAppState: () => ({}),
} as any
}
const allowTool = async (input: Record<string, unknown>) => ({
behavior: 'allow' as const,
updatedInput: input,
})
const parentMessage = makeAssistantMessage('Parent tool call')
beforeEach(() => {
resetContextCollapse()
sessionMemoryInitialized = false
})
afterEach(() => {
resetContextCollapse()
sessionMemoryInitialized = false
})
describe('CtxInspectTool', () => {
test('tool exports and metadata remain stable', async () => {
expect(CtxInspectTool).toBeDefined()
expect(CtxInspectTool.name).toBe('CtxInspect')
expect(typeof CtxInspectTool.call).toBe('function')
expect(await CtxInspectTool.description()).toContain('context')
expect(CtxInspectTool.userFacingName()).toBe('CtxInspect')
expect(CtxInspectTool.isReadOnly()).toBe(true)
expect(CtxInspectTool.isConcurrencySafe()).toBe(true)
})
test('formats tool results for transcript rendering', () => {
const block = CtxInspectTool.mapToolResultToToolResultBlockParam(
{
total_tokens: 192,
message_count: 3,
context_window_model: 'claude-sonnet-4-6',
prompt_caching_enabled: true,
session_memory_enabled: true,
context_collapse_enabled: false,
summary: 'Context collapse: disabled',
},
'tool-use-id',
)
expect(block.tool_use_id).toBe('tool-use-id')
expect(block.content).toContain('192 tokens')
expect(block.content).toContain('3 messages')
expect(block.content).toContain('Context collapse: disabled')
})
test('returns live context counts and mechanism state', async () => {
const messages = [
makeUserMessage('Inspect the current context budget.'),
makeAssistantMessage('Looking at the current conversation state.'),
]
const context = makeContext(messages, 'claude-sonnet-4-6')
const result = await (CtxInspectTool as any).call(
{},
context,
allowTool,
parentMessage,
)
expect(Object.keys(result.data).sort()).toEqual([
'context_collapse_enabled',
'context_window_model',
'message_count',
'prompt_caching_enabled',
'session_memory_enabled',
'summary',
'total_tokens',
])
expect(result.data.message_count).toBe(messages.length)
expect(result.data.total_tokens).toBe(tokenCountWithEstimation(messages as any))
expect(result.data.context_window_model).toBe('claude-sonnet-4-6')
expect(result.data.prompt_caching_enabled).toBe(true)
expect(result.data.session_memory_enabled).toBe(false)
expect(result.data.context_collapse_enabled).toBe(false)
expect(result.data.summary).toContain('Overall context summary')
expect(result.data.summary).toContain('Session memory: disabled')
expect(result.data.summary).toContain('Context collapse: disabled')
})
test('query input focuses summary and collapse runtime changes the reported state', async () => {
const messages = [
makeUserMessage('Show me tool usage pressure in this thread.'),
makeAssistantMessage('Summarizing tool-heavy context now.'),
]
const context = makeContext(messages, 'claude-sonnet-4-6')
const disabledResult = await (CtxInspectTool as any).call(
{ query: 'tool usage' },
context,
allowTool,
parentMessage,
)
initContextCollapse()
const enabledResult = await (CtxInspectTool as any).call(
{ query: 'tool usage' },
context,
allowTool,
parentMessage,
)
expect(disabledResult.data.message_count).toBe(messages.length)
expect(enabledResult.data.message_count).toBe(messages.length)
expect(disabledResult.data.total_tokens).toBe(
tokenCountWithEstimation(messages as any),
)
expect(enabledResult.data.total_tokens).toBe(
tokenCountWithEstimation(messages as any),
)
expect(disabledResult.data.summary).toContain('Focus: tool usage')
expect(disabledResult.data.context_collapse_enabled).toBe(false)
expect(enabledResult.data.context_collapse_enabled).toBe(true)
expect(enabledResult.data.summary).toContain('Context collapse: enabled')
expect(enabledResult.data.summary).toContain('Collapse spans:')
})
})

View File

@@ -0,0 +1,107 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
DISCOVER_SKILLS_TOOL_NAME,
DESCRIPTION,
DISCOVER_SKILLS_PROMPT,
} from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
description: z
.string()
.describe(
'Description of what you want to do. Be specific — e.g. "deploy a Next.js app to Cloudflare Workers" rather than just "deploy".',
),
limit: z
.number()
.optional()
.describe('Maximum number of results to return (default: 5)'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type DiscoverInput = z.infer<InputSchema>
type DiscoverOutput = {
results: Array<{ name: string; description: string; score: number }>
count: number
}
export const DiscoverSkillsTool = buildTool({
name: DISCOVER_SKILLS_TOOL_NAME,
searchHint: 'find search discover skills commands tools capabilities',
maxResultSizeChars: 10_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return DESCRIPTION
},
async prompt() {
return DISCOVER_SKILLS_PROMPT
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'Discover Skills'
},
renderToolUseMessage(input: Partial<DiscoverInput>) {
return `Searching skills: ${input.description?.slice(0, 80) ?? '...'}`
},
mapToolResultToToolResultBlockParam(
content: DiscoverOutput,
toolUseID: string,
): ToolResultBlockParam {
if (content.count === 0) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: 'No matching skills found for that description.',
}
}
const lines = content.results.map(
(r, i) =>
`${i + 1}. **${r.name}** (score: ${r.score.toFixed(2)})\n ${r.description}`,
)
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Found ${content.count} relevant skill(s):\n\n${lines.join('\n\n')}`,
}
},
async call(input: DiscoverInput, context) {
const { getSkillIndex, searchSkills } = await import(
'src/services/skillSearch/localSearch.js'
)
const { getCwd } = await import('src/utils/cwd.js')
const cwd = getCwd()
const index = await getSkillIndex(cwd)
const results = searchSkills(input.description, index, input.limit ?? 5)
return {
data: {
results: results.map(r => ({
name: r.name,
description: r.description,
score: r.score,
})),
count: results.length,
},
}
},
})

View File

@@ -0,0 +1,54 @@
import { describe, test, expect } from 'bun:test'
import { DISCOVER_SKILLS_TOOL_NAME } from '../prompt.js'
describe('DiscoverSkillsTool', () => {
test('DISCOVER_SKILLS_TOOL_NAME is not empty', () => {
expect(DISCOVER_SKILLS_TOOL_NAME).toBe('DiscoverSkills')
expect(DISCOVER_SKILLS_TOOL_NAME.length).toBeGreaterThan(0)
})
test('tool exports are functions', async () => {
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
expect(DiscoverSkillsTool).toBeDefined()
expect(DiscoverSkillsTool.name).toBe('DiscoverSkills')
expect(typeof DiscoverSkillsTool.call).toBe('function')
})
test('tool has correct metadata', async () => {
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
expect(await DiscoverSkillsTool.description()).toContain('skill')
expect(DiscoverSkillsTool.userFacingName()).toBe('Discover Skills')
expect(DiscoverSkillsTool.isReadOnly()).toBe(true)
expect(DiscoverSkillsTool.isConcurrencySafe()).toBe(true)
})
test('renderToolUseMessage formats input', async () => {
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
const msg = DiscoverSkillsTool.renderToolUseMessage({
description: 'deploy to cloudflare',
})
expect(msg).toContain('deploy to cloudflare')
})
test('mapToolResultToToolResultBlockParam formats empty results', async () => {
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
{ results: [], count: 0 },
'test-id',
)
expect(result.content).toContain('No matching skills')
})
test('mapToolResultToToolResultBlockParam formats results', async () => {
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
{
results: [{ name: 'test-skill', description: 'A test skill', score: 0.85 }],
count: 1,
},
'test-id',
)
expect(result.content).toContain('test-skill')
expect(result.content).toContain('0.85')
})
})

View File

@@ -1,3 +1,13 @@
// Auto-generated stub — replace with real implementation
export {};
export const DISCOVER_SKILLS_TOOL_NAME: string = '';
export const DISCOVER_SKILLS_TOOL_NAME = 'DiscoverSkills'
export const DESCRIPTION =
'Search for relevant skills by describing what you want to do'
export const DISCOVER_SKILLS_PROMPT = `Search for skills relevant to a task description. Returns matching skills ranked by relevance.
Use this when:
- The auto-surfaced skills don't cover your current task
- You're pivoting to a different kind of work mid-conversation
- You want to find specialized skills for an unusual workflow
The search uses TF-IDF keyword matching against all registered skills (bundled, user-defined, and MCP-provided). Results include skill name, description, and relevance score.`

View File

@@ -273,18 +273,6 @@ export const FileEditTool = buildTool({
}
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
behavior: 'ask',
message:
'File has not been read yet. Read it first before writing to it.',
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 6,
}
}
// Check if file exists and get its last modified time
if (readTimestamp) {

View File

@@ -186,14 +186,6 @@ export function renderToolUseErrorMessage(
extractTag(result, 'tool_use_error')
) {
const errorMessage = extractTag(result, 'tool_use_error')
// Show a less scary message for intended behavior
if (errorMessage?.includes('File has not been read yet')) {
return (
<MessageResponse>
<Text dimColor>File must be read first</Text>
</MessageResponse>
)
}
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
return (
<MessageResponse>

View File

@@ -1,22 +1,8 @@
import { mock, describe, expect, test } from "bun:test";
import { logMock } from "../../../../../../tests/mocks/log";
// Mock log.ts to cut the heavy dependency chain
mock.module("src/utils/log.ts", () => ({
logError: () => {},
logToFile: () => {},
getLogDisplayTitle: () => "",
logEvent: () => {},
logMCPError: () => {},
logMCPDebug: () => {},
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
getLogFilePath: () => "/tmp/mock-log",
attachErrorLogSink: () => {},
getInMemoryErrors: () => [],
loadErrorLogs: async () => [],
getErrorLogByIndex: async () => null,
captureAPIRequest: () => {},
_resetErrorLogForTesting: () => {},
}));
mock.module("src/utils/log.ts", logMock);
const {
normalizeQuotes,

View File

@@ -196,25 +196,18 @@ export const FileWriteTool = buildTool({
}
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
message:
'File has not been read yet. Read it first before writing to it.',
errorCode: 2,
}
}
// Reuse mtime from the stat above — avoids a redundant statSync via
// getFileModificationTime. The readTimestamp guard above ensures this
// block is always reached when the file exists.
const lastWriteTime = Math.floor(fileMtimeMs)
if (lastWriteTime > readTimestamp.timestamp) {
return {
result: false,
message:
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
errorCode: 3,
// getFileModificationTime.
if (readTimestamp) {
const lastWriteTime = Math.floor(fileMtimeMs)
if (lastWriteTime > readTimestamp.timestamp) {
return {
result: false,
message:
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
errorCode: 3,
}
}
}

View File

@@ -1,9 +1,7 @@
import { mock, describe, expect, test } from "bun:test";
import { debugMock } from "../../../../../../tests/mocks/debug";
mock.module("src/utils/debug.ts", () => ({
logForDebugging: () => {},
isDebugMode: () => false,
}));
mock.module("src/utils/debug.ts", debugMock);
const {
formatGoToDefinitionResult,

View File

@@ -11,6 +11,7 @@ import {
getClaudeAIOAuthTokens,
} from 'src/utils/auth.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { appendRemoteTriggerAuditRecord } from 'src/utils/remoteTriggerAudit.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import { DESCRIPTION, PROMPT, REMOTE_TRIGGER_TOOL_NAME } from './prompt.js'
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
@@ -36,6 +37,7 @@ const outputSchema = lazySchema(() =>
z.object({
status: z.number(),
json: z.string(),
audit_id: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
@@ -76,77 +78,96 @@ export const RemoteTriggerTool = buildTool({
return PROMPT
},
async call(input: Input, context: ToolUseContext) {
await checkAndRefreshOAuthTokenIfNeeded()
const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
throw new Error(
'Not authenticated with a claude.ai account. Run /login and try again.',
)
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
throw new Error('Unable to resolve organization UUID.')
const auditBase = {
action: input.action,
...(input.trigger_id ? { triggerId: input.trigger_id } : {}),
}
try {
await checkAndRefreshOAuthTokenIfNeeded()
const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
throw new Error(
'Not authenticated with a claude.ai account. Run /login and try again.',
)
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
throw new Error('Unable to resolve organization UUID.')
}
const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-beta': TRIGGERS_BETA,
'x-organization-uuid': orgUUID,
}
const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-beta': TRIGGERS_BETA,
'x-organization-uuid': orgUUID,
}
const { action, trigger_id, body } = input
let method: 'GET' | 'POST'
let url: string
let data: unknown
switch (action) {
case 'list':
method = 'GET'
url = base
break
case 'get':
if (!trigger_id) throw new Error('get requires trigger_id')
method = 'GET'
url = `${base}/${trigger_id}`
break
case 'create':
if (!body) throw new Error('create requires body')
method = 'POST'
url = base
data = body
break
case 'update':
if (!trigger_id) throw new Error('update requires trigger_id')
if (!body) throw new Error('update requires body')
method = 'POST'
url = `${base}/${trigger_id}`
data = body
break
case 'run':
if (!trigger_id) throw new Error('run requires trigger_id')
method = 'POST'
url = `${base}/${trigger_id}/run`
data = {}
break
}
const { action, trigger_id, body } = input
let method: 'GET' | 'POST'
let url: string
let data: unknown
switch (action) {
case 'list':
method = 'GET'
url = base
break
case 'get':
if (!trigger_id) throw new Error('get requires trigger_id')
method = 'GET'
url = `${base}/${trigger_id}`
break
case 'create':
if (!body) throw new Error('create requires body')
method = 'POST'
url = base
data = body
break
case 'update':
if (!trigger_id) throw new Error('update requires trigger_id')
if (!body) throw new Error('update requires body')
method = 'POST'
url = `${base}/${trigger_id}`
data = body
break
case 'run':
if (!trigger_id) throw new Error('run requires trigger_id')
method = 'POST'
url = `${base}/${trigger_id}/run`
data = {}
break
}
const res = await axios.request({
method,
url,
headers,
data,
timeout: 20_000,
signal: context.abortController.signal,
validateStatus: () => true,
})
return {
data: {
const res = await axios.request({
method,
url,
headers,
data,
timeout: 20_000,
signal: context.abortController.signal,
validateStatus: () => true,
})
const audit = await appendRemoteTriggerAuditRecord({
...auditBase,
ok: res.status >= 200 && res.status < 300,
status: res.status,
json: jsonStringify(res.data),
},
})
return {
data: {
status: res.status,
json: jsonStringify(res.data),
audit_id: audit.auditId,
},
}
} catch (error) {
await appendRemoteTriggerAuditRecord({
...auditBase,
ok: false,
error: error instanceof Error ? error.message : String(error),
})
throw error
}
},
mapToolResultToToolResultBlockParam(output, toolUseID) {

View File

@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdir, readFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from 'src/bootstrap/state.js'
let requestStatus = 200
mock.module('axios', () => ({
default: {
request: async () => ({
status: requestStatus,
data: { ok: requestStatus >= 200 && requestStatus < 300 },
}),
},
}))
mock.module('src/utils/auth.js', () => ({
checkAndRefreshOAuthTokenIfNeeded: async () => {},
getClaudeAIOAuthTokens: () => ({ accessToken: 'token' }),
}))
mock.module('src/services/oauth/client.js', () => ({
getOrganizationUUID: async () => 'org',
}))
mock.module('src/constants/oauth.js', () => ({
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
}))
let cwd = ''
let previousCwd = ''
beforeEach(async () => {
requestStatus = 200
previousCwd = process.cwd()
cwd = join(tmpdir(), `remote-trigger-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
await mkdir(cwd, { recursive: true })
process.chdir(cwd)
resetStateForTests()
setOriginalCwd(cwd)
setProjectRoot(cwd)
})
afterEach(async () => {
resetStateForTests()
process.chdir(previousCwd)
await rm(cwd, { recursive: true, force: true })
})
describe('RemoteTriggerTool audit', () => {
test('writes an audit record for successful remote calls', async () => {
const { RemoteTriggerTool } = await import('../RemoteTriggerTool')
const result = await RemoteTriggerTool.call(
{ action: 'run', trigger_id: 'trigger-1' },
{ abortController: new AbortController() } as any,
)
expect(result.data.audit_id).toBeString()
const raw = await readFile(
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
'utf-8',
)
expect(raw).toContain('"action":"run"')
expect(raw).toContain('"triggerId":"trigger-1"')
expect(raw).toContain('"ok":true')
})
test('writes an audit record before rethrowing validation failures', async () => {
const { RemoteTriggerTool } = await import('../RemoteTriggerTool')
await expect(
RemoteTriggerTool.call(
{ action: 'run' },
{ abortController: new AbortController() } as any,
),
).rejects.toThrow('run requires trigger_id')
const raw = await readFile(
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
'utf-8',
)
expect(raw).toContain('"action":"run"')
expect(raw).toContain('"ok":false')
expect(raw).toContain('run requires trigger_id')
})
})

View File

@@ -14,11 +14,26 @@ import {
} from 'src/utils/swarm/teamHelpers.js'
import { clearTeammateColors } from 'src/utils/swarm/teammateLayoutManager.js'
import { clearLeaderTeamName } from 'src/utils/tasks.js'
import { ensureBackendsRegistered, getBackendByType, getInProcessBackend } from 'src/utils/swarm/backends/registry.js'
import { createPaneBackendExecutor } from 'src/utils/swarm/backends/PaneBackendExecutor.js'
import { isPaneBackend } from 'src/utils/swarm/backends/types.js'
import { sleep } from 'src/utils/sleep.js'
import { TEAM_DELETE_TOOL_NAME } from './constants.js'
import { getPrompt } from './prompt.js'
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
const inputSchema = lazySchema(() => z.strictObject({}))
const inputSchema = lazySchema(() =>
z.strictObject({
wait_ms: z
.number()
.min(0)
.max(30_000)
.optional()
.describe(
'Optional time to wait for active teammates to acknowledge shutdown before cleanup.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
export type Output = {
@@ -68,7 +83,7 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
}
},
async call(_input, context) {
async call(input, context) {
const { setAppState, getAppState } = context
const appState = getAppState()
const teamName = appState.teamContext?.teamName
@@ -87,13 +102,82 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
const activeMembers = nonLeadMembers.filter(m => m.isActive !== false)
if (activeMembers.length > 0) {
const memberNames = activeMembers.map(m => m.name).join(', ')
return {
data: {
success: false,
message: `Cannot cleanup team with ${activeMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`,
team_name: teamName,
},
const requested: string[] = []
for (const member of activeMembers) {
let sent = false
if (member.backendType === 'in-process') {
const executor = getInProcessBackend()
executor.setContext?.(context)
sent = await executor.terminate(
member.agentId,
'Team cleanup requested by team lead',
)
} else if (member.backendType && isPaneBackend(member.backendType)) {
await ensureBackendsRegistered()
const executor = createPaneBackendExecutor(
getBackendByType(member.backendType),
)
executor.setContext?.(context)
sent = await executor.terminate(
member.agentId,
'Team cleanup requested by team lead',
)
}
if (sent) {
requested.push(member.name)
}
}
const waitMs = input.wait_ms ?? 0
if (waitMs > 0 && requested.length > 0) {
const deadline = Date.now() + waitMs
while (Date.now() < deadline) {
await sleep(Math.min(250, Math.max(0, deadline - Date.now())))
const refreshed = readTeamFile(teamName)
const stillActive =
refreshed?.members.filter(
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
) ?? []
if (stillActive.length === 0) {
break
}
}
const refreshed = readTeamFile(teamName)
const stillActive =
refreshed?.members.filter(
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
) ?? []
if (stillActive.length === 0) {
// Fall through to cleanup with the refreshed team file state.
} else {
const memberNames = stillActive.map(m => m.name).join(', ')
return {
data: {
success: false,
message: `Shutdown requested for active teammate(s): ${requested.join(', ')}. Cleanup is still blocked after waiting ${waitMs}ms: ${memberNames}.`,
team_name: teamName,
},
}
}
}
const latestTeamFile = readTeamFile(teamName)
const latestActiveMembers =
latestTeamFile?.members.filter(
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
) ?? []
if (latestActiveMembers.length === 0) {
// Continue to cleanup below.
} else {
const memberNames = latestActiveMembers.map(m => m.name).join(', ')
return {
data: {
success: false,
message:
requested.length > 0
? `Shutdown requested for active teammate(s): ${requested.join(', ')}. Cleanup is blocked until they exit: ${memberNames}.`
: `Cannot cleanup team with ${latestActiveMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`,
team_name: teamName,
},
}
}
}
}

View File

@@ -9,19 +9,11 @@ const inputSchema = lazySchema(() =>
z.strictObject({
url: z
.string()
.describe('URL to navigate to in the browser.'),
.describe('URL to fetch and extract content from.'),
action: z
.enum(['navigate', 'screenshot', 'click', 'type', 'scroll'])
.enum(['navigate', 'screenshot'])
.optional()
.describe('Browser action to perform. Defaults to "navigate".'),
selector: z
.string()
.optional()
.describe('CSS selector for click/type actions.'),
text: z
.string()
.optional()
.describe('Text to type when action is "type".'),
.describe('Action to perform. "navigate" fetches page content (default). "screenshot" returns a text snapshot of the page.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
@@ -45,16 +37,24 @@ export const WebBrowserTool = buildTool({
},
async description() {
return 'Browse the web using an embedded browser'
return 'Fetch and read web page content via HTTP'
},
async prompt() {
return `Open and interact with web pages in an embedded browser. Supports navigation, screenshots, clicking, typing, and scrolling.
return `Fetch web pages via HTTP and extract their text content. This is a lightweight browser tool (HTTP fetch, not a full browser engine).
Supported actions:
- navigate: Fetch a URL and extract page title + text content
- screenshot: Same as navigate (returns text snapshot, not a visual screenshot)
Limitations:
- No JavaScript execution — only sees server-rendered HTML
- click/type/scroll require a full browser runtime (not available)
- For full browser interaction, use the Claude-in-Chrome MCP tools instead
Use this for:
- Viewing web pages and their content
- Taking screenshots of UI
- Interacting with web applications
- Testing web endpoints with full browser rendering`
- Reading web page content and documentation
- Checking API endpoints that return HTML
- Quick page title/content extraction`
},
isConcurrencySafe() {
@@ -85,12 +85,84 @@ Use this for:
},
async call(input: BrowserInput) {
// Browser integration requires the WEB_BROWSER_TOOL runtime (Bun WebView).
const action = input.action ?? 'navigate'
if (action === 'navigate' || action === 'screenshot') {
// Fetch the page content via HTTP
try {
const response = await fetch(input.url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
redirect: 'follow',
})
if (!response.ok) {
return {
data: {
title: `HTTP ${response.status}`,
url: input.url,
content: `Error: ${response.status} ${response.statusText}`,
},
}
}
const html = await response.text()
// Extract title
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i)
const title = titleMatch?.[1]?.trim() ?? ''
// Extract text content (strip HTML tags, scripts, styles)
let textContent = html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
// Truncate to reasonable size
if (textContent.length > 50_000) {
textContent = textContent.slice(0, 50_000) + '\n[truncated]'
}
if (action === 'screenshot') {
return {
data: {
title,
url: response.url,
content: `[Text snapshot — visual screenshots require Chrome browser tools]\n\n${textContent}`,
},
}
}
return {
data: {
title,
url: response.url,
content: textContent,
},
}
} catch (err) {
return {
data: {
title: 'Error',
url: input.url,
content: `Failed to fetch: ${err instanceof Error ? err.message : String(err)}`,
},
}
}
}
// Unreachable — schema only allows navigate/screenshot
return {
data: {
title: '',
url: input.url,
content: 'Web browser requires the WEB_BROWSER_TOOL runtime.',
content: `Unknown action "${action}".`,
},
}
},

View File

@@ -0,0 +1,94 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
// Mock fetch directly — avoids flaky dependency on external hosts AND
// pollution by other tests that call setGlobalDispatcher (proxy agents make
// localhost fetches return 500 in the full-suite run).
const realFetch = globalThis.fetch
beforeAll(() => {
globalThis.fetch = (async (
input: string | URL | Request,
_init?: RequestInit,
) => {
const url = typeof input === 'string' ? input : input.toString()
if (url === 'not-a-url' || !url.startsWith('http')) {
throw new TypeError('Failed to fetch')
}
const body =
'<!doctype html><html><head><title>Example Domain</title></head>' +
'<body><h1>Example Domain</h1><p>Sample content.</p></body></html>'
const res = new Response(body, {
status: 200,
headers: { 'content-type': 'text/html' },
})
// Make response.url match the request URL so tests can assert on it.
Object.defineProperty(res, 'url', { value: url, configurable: true })
return res
}) as typeof fetch
})
afterAll(() => {
globalThis.fetch = realFetch
})
describe('WebBrowserTool', () => {
test('tool exports and metadata', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
expect(WebBrowserTool).toBeDefined()
expect(WebBrowserTool.name).toBe('WebBrowser')
expect(typeof WebBrowserTool.call).toBe('function')
expect(WebBrowserTool.userFacingName()).toBe('Browser')
expect(WebBrowserTool.isReadOnly()).toBe(true)
})
test('description reflects browser-lite', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const desc = await WebBrowserTool.description()
expect(desc).toContain('HTTP')
expect(desc).not.toContain('embedded browser')
})
test('prompt mentions limitations', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const prompt = await WebBrowserTool.prompt()
expect(prompt).toContain('Limitations')
expect(prompt).toContain('No JavaScript')
expect(prompt).toContain('Claude-in-Chrome')
})
test('navigate fetches URL', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const result = await WebBrowserTool.call({
url: 'https://example.com',
} as any)
expect(result.data.title).toBe('Example Domain')
expect(result.data.url).toContain('example.com')
expect(result.data.content).toContain('Example Domain')
}, 15000)
test('screenshot returns text snapshot', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const result = await WebBrowserTool.call({
url: 'https://example.com',
action: 'screenshot',
} as any)
expect(result.data.content).toContain('Text snapshot')
expect(result.data.content).toContain('Example Domain')
}, 15000)
test('schema only allows navigate and screenshot', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const schema = WebBrowserTool.inputSchema
const parseResult = schema.safeParse({
url: 'https://example.com',
action: 'click',
})
expect(parseResult.success).toBe(false)
})
test('invalid URL returns error', async () => {
const { WebBrowserTool } = await import('../WebBrowserTool.js')
const result = await WebBrowserTool.call({ url: 'not-a-url' } as any)
expect(result.data.content).toContain('Failed to fetch')
})
})

View File

@@ -23,6 +23,26 @@ const inputSchema = lazySchema(() =>
.array(z.string())
.optional()
.describe('Never include search results from these domains'),
num_results: z
.number()
.optional()
.describe('Number of search results to return (default: 8)'),
livecrawl: z
.enum(['fallback', 'preferred'])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
search_type: z
.enum(['auto', 'fast', 'deep'])
.optional()
.describe(
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
),
context_max_characters: z
.number()
.optional()
.describe('Maximum characters for context string optimized for LLMs (default: 10000)'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
@@ -148,6 +168,10 @@ export const WebSearchTool = buildTool({
const adapterResults = await adapter.search(query, {
allowedDomains: input.allowed_domains,
blockedDomains: input.blocked_domains,
numResults: input.num_results,
livecrawl: input.livecrawl,
searchType: input.search_type,
contextMaxCharacters: input.context_max_characters,
signal: context.abortController.signal,
onProgress(progress) {
if (onProgress) {

View File

@@ -52,10 +52,10 @@ describe('createAdapter', () => {
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
})
test('selects the Bing adapter for third-party Anthropic base URLs', () => {
test('selects the Exa adapter for third-party Anthropic base URLs', () => {
delete process.env.WEB_SEARCH_ADAPTER
isFirstPartyBaseUrl = false
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
})
})

View File

@@ -0,0 +1,302 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
const _abortMock = () => ({
AbortError: class AbortError extends Error {
constructor(message?: string) { super(message); this.name = 'AbortError' }
},
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
})
mock.module('src/utils/errors.js', _abortMock)
mock.module('src/utils/errors', _abortMock)
describe('ExaSearchAdapter.search', () => {
const createAdapter = async () => {
const { ExaSearchAdapter } = await import('../adapters/exaAdapter')
return new ExaSearchAdapter()
}
// Exa MCP returns SSE lines like: data: {"result":{"content":[{"type":"text","text":"..."}]}}
const buildSseResponse = (text: string) => `data: ${JSON.stringify({ result: { content: [{ type: 'text', text }] } })}\n`
const STRUCTURED_TEXT = [
'Title: Example Result 1',
'URL: https://example.com/page1',
'Content: This is the content snippet for page 1.',
'',
'---',
'',
'Title: Example Result 2',
'URL: https://example.com/page2',
'Content: This is the content snippet for page 2.',
].join('\n')
afterEach(() => {
mock.restore()
})
test('parses structured Title/URL/Content blocks from SSE response', async () => {
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test query', {})
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
title: 'Example Result 1',
url: 'https://example.com/page1',
snippet: 'This is the content snippet for page 1.',
})
expect(results[1]).toEqual({
title: 'Example Result 2',
url: 'https://example.com/page2',
snippet: 'This is the content snippet for page 2.',
})
})
test('parses markdown link fallback when no structured blocks', async () => {
const markdownText = '- [React Docs](https://react.dev/docs)\n- [React Hooks](https://react.dev/hooks)'
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: buildSseResponse(markdownText) })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('react', {})
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
title: 'React Docs',
url: 'https://react.dev/docs',
snippet: undefined,
})
expect(results[1].url).toBe('https://react.dev/hooks')
})
test('parses plain URL fallback', async () => {
const plainUrlText = 'https://example.com/page1\nhttps://example.com/page2'
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: buildSseResponse(plainUrlText) })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {})
expect(results).toHaveLength(2)
expect(results[0].url).toBe('https://example.com/page1')
})
test('returns empty array for empty response', async () => {
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: '' })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {})
expect(results).toHaveLength(0)
})
test('parses direct JSON response (non-SSE fallback)', async () => {
const jsonResponse = JSON.stringify({
result: { content: [{ type: 'text', text: STRUCTURED_TEXT }] },
})
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: jsonResponse })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {})
expect(results).toHaveLength(2)
expect(results[0].url).toBe('https://example.com/page1')
})
test('calls onProgress with query_update and search_results_received', async () => {
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
isCancel: () => false,
},
}))
const progressCalls: any[] = []
const onProgress = (p: any) => progressCalls.push(p)
const adapter = await createAdapter()
await adapter.search('test', { onProgress })
expect(progressCalls).toHaveLength(2)
expect(progressCalls[0]).toEqual({ type: 'query_update', query: 'test' })
expect(progressCalls[1]).toEqual({
type: 'search_results_received',
resultCount: 2,
query: 'test',
})
})
test('filters results by allowedDomains', async () => {
const mixedText = [
'Title: Allowed',
'URL: https://allowed.com/a',
'---',
'Title: Blocked',
'URL: https://blocked.com/b',
].join('\n')
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', { allowedDomains: ['allowed.com'] })
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://allowed.com/a')
})
test('filters results by blockedDomains', async () => {
const mixedText = [
'Title: Good',
'URL: https://good.com/a',
'---',
'Title: Spam',
'URL: https://spam.com/b',
].join('\n')
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', { blockedDomains: ['spam.com'] })
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://good.com/a')
})
test('filters subdomains with allowedDomains', async () => {
const text = [
'Title: Subdomain',
'URL: https://docs.example.com/page',
'---',
'Title: Other',
'URL: https://other.com/page',
].join('\n')
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: buildSseResponse(text) })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', { allowedDomains: ['example.com'] })
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://docs.example.com/page')
})
test('throws AbortError when signal is already aborted', async () => {
mock.module('axios', () => ({
default: {
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const controller = new AbortController()
controller.abort()
const { AbortError } = await import('src/utils/errors')
await expect(
adapter.search('test', { signal: controller.signal }),
).rejects.toThrow(AbortError)
})
test('re-throws non-abort axios errors', async () => {
const networkError = new Error('Network error')
mock.module('axios', () => ({
default: {
post: mock(() => Promise.reject(networkError)),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
})
test('sends correct MCP request payload to Exa endpoint', async () => {
const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) }))
mock.module('axios', () => ({
default: {
post: axiosPost,
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await adapter.search('hello world', {})
expect(axiosPost.mock.calls).toHaveLength(1)
const [url, body, config] = (axiosPost.mock.calls as any[][])[0]
expect(url).toBe('https://mcp.exa.ai/mcp')
expect(body.jsonrpc).toBe('2.0')
expect(body.method).toBe('tools/call')
expect(body.params.name).toBe('web_search_exa')
expect(body.params.arguments.query).toBe('hello world')
expect(body.params.arguments.type).toBe('auto')
expect(body.params.arguments.numResults).toBe(8)
expect(body.params.arguments.livecrawl).toBe('fallback')
expect(body.params.arguments.contextMaxCharacters).toBe(10000)
expect(config.headers.Accept).toBe('application/json, text/event-stream')
})
test('passes custom search options to MCP request', async () => {
const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) }))
mock.module('axios', () => ({
default: {
post: axiosPost,
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await adapter.search('test', {
numResults: 15,
livecrawl: 'preferred',
searchType: 'deep',
contextMaxCharacters: 20000,
})
const [, body] = (axiosPost.mock.calls as any[][])[0]
expect(body.params.arguments.numResults).toBe(15)
expect(body.params.arguments.livecrawl).toBe('preferred')
expect(body.params.arguments.type).toBe('deep')
expect(body.params.arguments.contextMaxCharacters).toBe(20000)
})
})

View File

@@ -9,6 +9,9 @@ import type {
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { queryModelWithStreaming } from 'src/services/api/claude.js'
import { createTrace, endTrace, isLangfuseEnabled } from 'src/services/langfuse/index.js'
import { getSessionId } from 'src/bootstrap/state.js'
import { getAPIProvider } from 'src/utils/model/providers.js'
import { createUserMessage } from 'src/utils/messages.js'
import { getMainLoopModel, getSmallFastModel } from 'src/utils/model/model.js'
import { jsonParse } from 'src/utils/slowOperations.js'
@@ -38,6 +41,15 @@ export class ApiSearchAdapter implements WebSearchAdapter {
const toolSchema = makeToolSchema({ allowedDomains, blockedDomains })
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE('tengu_plum_vx3', false)
const model = useHaiku ? getSmallFastModel() : getMainLoopModel()
const langfuseTrace = isLangfuseEnabled()
? createTrace({
sessionId: getSessionId(),
model,
provider: getAPIProvider(),
name: 'web-search-tool',
})
: null
const queryStream = queryModelWithStreaming({
messages: [userMessage],
@@ -58,7 +70,7 @@ export class ApiSearchAdapter implements WebSearchAdapter {
alwaysAskRules: {},
isBypassPermissionsModeAvailable: false,
}),
model: useHaiku ? getSmallFastModel() : getMainLoopModel(),
model,
toolChoice: useHaiku ? { type: 'tool' as const, name: 'web_search' } : undefined,
isNonInteractiveSession: false,
hasAppendSystemPrompt: false,
@@ -68,6 +80,7 @@ export class ApiSearchAdapter implements WebSearchAdapter {
mcpTools: [],
agentId: undefined,
effortValue: undefined,
langfuseTrace,
},
})
@@ -148,6 +161,8 @@ export class ApiSearchAdapter implements WebSearchAdapter {
}
}
endTrace(langfuseTrace)
// Extract SearchResult[] from content blocks
return extractSearchResults(allContentBlocks)
}

View File

@@ -0,0 +1,200 @@
/**
* Exa AI-based search adapter — uses MCP protocol to call Exa's web search API.
*
* Ported from kilocode's production-validated implementation (mcp-exa.ts + websearch.ts).
* Key improvements over previous version:
* - Passes through numResults/livecrawl/type/contextMaxCharacters from options
* - Cleaner SSE parsing matching kilocode's approach
* - Proper content snippet extraction from Exa responses
*/
import axios from 'axios'
import { AbortError } from 'src/utils/errors.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
const FETCH_TIMEOUT_MS = 25_000
export class ExaSearchAdapter implements WebSearchAdapter {
async search(
query: string,
options: SearchOptions,
): Promise<SearchResult[]> {
const { signal, onProgress, allowedDomains, blockedDomains } = options
if (signal?.aborted) {
throw new AbortError()
}
onProgress?.({ type: 'query_update', query })
const abortController = new AbortController()
if (signal) {
signal.addEventListener('abort', () => abortController.abort(), { once: true })
}
// Use options to derive search params — matches kilocode websearch.ts defaults
const numResults = options.numResults ?? 8
const livecrawl = options.livecrawl ?? 'fallback'
const searchType = options.searchType ?? 'auto'
const contextMaxCharacters = options.contextMaxCharacters ?? 10000
let responseText: string
try {
const response = await axios.post(
EXA_MCP_URL,
{
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'web_search_exa',
arguments: {
query,
type: searchType,
numResults,
livecrawl,
contextMaxCharacters,
},
},
},
{
signal: abortController.signal,
timeout: FETCH_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
responseType: 'text',
},
)
responseText = response.data as string
} catch (e) {
if (axios.isCancel(e) || abortController.signal.aborted) {
throw new AbortError()
}
throw e
}
if (abortController.signal.aborted) {
throw new AbortError()
}
const searchText = this.parseSse(responseText)
if (abortController.signal.aborted) {
throw new AbortError()
}
// Parse the Exa results from the text response
const results = this.parseResults(searchText)
// Client-side domain filtering
const filteredResults = results.filter((r) => {
if (!r.url) return false
try {
const hostname = new URL(r.url).hostname
if (allowedDomains?.length && !allowedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) {
return false
}
if (blockedDomains?.length && blockedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) {
return false
}
} catch {
return false
}
return true
})
onProgress?.({
type: 'search_results_received',
resultCount: filteredResults.length,
query,
})
return filteredResults
}
private parseSse(body: string): string | undefined {
// SSE format: lines starting with "data: " containing JSON
// Matches kilocode mcp-exa.ts parseSse implementation
for (const line of body.split('\n')) {
if (!line.startsWith('data: ')) continue
const data = line.substring(6).trim()
if (!data || data === '[DONE]' || data === 'null') continue
try {
const parsed = JSON.parse(data)
const content = parsed?.result?.content
if (Array.isArray(content) && content[0]?.text) {
return content[0].text
}
} catch {
// Continue to next line
}
}
// Fallback: try parsing as direct JSON response (non-SSE)
try {
const parsed = JSON.parse(body)
const content = parsed?.result?.content
if (Array.isArray(content) && content[0]?.text) {
return content[0].text
}
} catch {
// Not JSON
}
return undefined
}
private parseResults(text: string | undefined): SearchResult[] {
if (!text) return []
const results: SearchResult[] = []
// Exa returns structured text with "Title:", "URL:", and "Content:" fields
// separated by "---" between entries
const blocks = text.split(/\n---\n/g)
for (const block of blocks) {
const titleMatch = block.match(/^Title:\s*(.+)$/m)
const urlMatch = block.match(/^URL:\s*(https?:\/\/[^\s]+)$/m)
const contentMatch = block.match(/^Content:\s*([\s\S]+?)(?=\n(?:Title:|URL:|---)|$)/m)
if (urlMatch) {
results.push({
title: titleMatch?.[1]?.trim() ?? urlMatch[1],
url: urlMatch[1].trim(),
snippet: contentMatch?.[1]?.trim().slice(0, 300),
})
}
}
// Fallback: markdown links
if (results.length === 0) {
const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g
let match: RegExpExecArray | null
while ((match = markdownLinkRegex.exec(text)) !== null) {
results.push({
title: match[1].trim(),
url: match[2].trim(),
})
}
}
// Fallback: plain URLs
if (results.length === 0) {
const urlRegex = /^https?:\/\/[^\s<>"\]]+/gm
let match: RegExpExecArray | null
while ((match = urlRegex.exec(text)) !== null) {
results.push({
title: match[0],
url: match[0],
})
}
}
return results
}
}

View File

@@ -7,6 +7,7 @@ import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js'
import { ApiSearchAdapter } from './apiAdapter.js'
import { BingSearchAdapter } from './bingAdapter.js'
import { BraveSearchAdapter } from './braveAdapter.js'
import { ExaSearchAdapter } from './exaAdapter.js'
import type { WebSearchAdapter } from './types.js'
export type {
@@ -16,17 +17,37 @@ export type {
WebSearchAdapter,
} from './types.js'
/**
* Check if the current session uses a third-party (non-Anthropic) API provider.
* These providers don't support Anthropic's server_tools (server-side web search),
* so they must fall back to the Bing scraper adapter.
*/
function isThirdPartyProvider(): boolean {
return !!(
process.env.CLAUDE_CODE_USE_OPENAI ||
process.env.CLAUDE_CODE_USE_GEMINI ||
process.env.CLAUDE_CODE_USE_GROK
)
}
let cachedAdapter: WebSearchAdapter | null = null
let cachedAdapterKey: 'api' | 'bing' | 'brave' | null = null
let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | null = null
export function createAdapter(): WebSearchAdapter {
const envAdapter = process.env.WEB_SEARCH_ADAPTER
// Priority:
// 1. Explicit env override (WEB_SEARCH_ADAPTER=api|bing|brave)
// 2. Third-party provider (OpenAI/Gemini/Grok) → bing (no server_tools support)
// 3. First-party Anthropic API → api (server-side web search + connector_text)
// 4. Fallback → bing
const adapterKey =
envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave'
envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave' || envAdapter === 'exa'
? envAdapter
: isFirstPartyAnthropicBaseUrl()
? 'api'
: 'bing'
: isThirdPartyProvider()
? 'bing'
: isFirstPartyAnthropicBaseUrl()
? 'api'
: 'exa'
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
@@ -36,9 +57,14 @@ export function createAdapter(): WebSearchAdapter {
return cachedAdapter
}
if (adapterKey === 'brave') {
cachedAdapter = new BraveSearchAdapter()
cachedAdapterKey = 'brave'
return cachedAdapter
cachedAdapter = new BraveSearchAdapter()
cachedAdapterKey = 'brave'
return cachedAdapter
}
if (adapterKey === 'exa') {
cachedAdapter = new ExaSearchAdapter()
cachedAdapterKey = 'exa'
return cachedAdapter
}
cachedAdapter = new BingSearchAdapter()

View File

@@ -9,6 +9,14 @@ export interface SearchOptions {
blockedDomains?: string[]
signal?: AbortSignal
onProgress?: (progress: SearchProgress) => void
/** Number of search results to return (default: 8) */
numResults?: number
/** Live crawl mode (default: 'fallback') */
livecrawl?: 'fallback' | 'preferred'
/** Search type (default: 'auto') */
searchType?: 'auto' | 'fast' | 'deep'
/** Maximum characters for context string (default: 10000) */
contextMaxCharacters?: number
}
export interface SearchProgress {

View File

@@ -1,18 +1,358 @@
import { randomUUID } from 'crypto'
import { mkdir, readdir, readFile, writeFile } from 'fs/promises'
import { join, parse } from 'path'
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { truncate } from 'src/utils/format.js'
import { WORKFLOW_TOOL_NAME } from './constants.js'
import { safeParseJSON } from 'src/utils/json.js'
import {
WORKFLOW_DIR_NAME,
WORKFLOW_FILE_EXTENSIONS,
WORKFLOW_TOOL_NAME,
} from './constants.js'
const WORKFLOW_RUNS_DIR = '.claude/workflow-runs'
const inputSchema = z.object({
workflow: z.string().describe('Name of the workflow to execute'),
args: z.string().optional().describe('Arguments to pass to the workflow'),
action: z
.enum(['start', 'status', 'advance', 'cancel', 'list'])
.optional()
.describe('Workflow action. Defaults to start.'),
run_id: z
.string()
.optional()
.describe('Workflow run id for status, advance, or cancel.'),
})
type Input = typeof inputSchema
type WorkflowInput = z.infer<Input>
type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'cancelled'
type WorkflowStep = {
name: string
prompt: string
status: WorkflowStepStatus
startedAt?: number
completedAt?: number
}
type WorkflowRun = {
runId: string
workflow: string
args?: string
status: 'running' | 'completed' | 'cancelled'
createdAt: number
updatedAt: number
currentStepIndex: number
steps: WorkflowStep[]
}
type WorkflowOutput = { output: string }
async function findWorkflowFile(
workflowDir: string,
workflow: string,
): Promise<{ path: string; content: string } | null> {
for (const ext of WORKFLOW_FILE_EXTENSIONS) {
const path = join(workflowDir, `${workflow}${ext}`)
try {
return { path, content: await readFile(path, 'utf-8') }
} catch {
// try next
}
}
return null
}
async function listAvailableWorkflows(workflowDir: string): Promise<string[]> {
try {
const files = await readdir(workflowDir)
return files
.filter(f => WORKFLOW_FILE_EXTENSIONS.includes(parse(f).ext.toLowerCase()))
.map(f => parse(f).name)
.sort()
} catch {
return []
}
}
function workflowRunPath(cwd: string, runId: string): string {
return join(cwd, WORKFLOW_RUNS_DIR, `${runId}.json`)
}
async function readWorkflowRun(
cwd: string,
runId: string,
): Promise<WorkflowRun | null> {
try {
const parsed = safeParseJSON(
await readFile(workflowRunPath(cwd, runId), 'utf-8'),
false,
) as Partial<WorkflowRun> | null
if (
!parsed ||
typeof parsed.runId !== 'string' ||
typeof parsed.workflow !== 'string' ||
!Array.isArray(parsed.steps)
) {
return null
}
return parsed as WorkflowRun
} catch {
return null
}
}
async function writeWorkflowRun(cwd: string, run: WorkflowRun): Promise<void> {
await mkdir(join(cwd, WORKFLOW_RUNS_DIR), { recursive: true })
await writeFile(
workflowRunPath(cwd, run.runId),
JSON.stringify(run, null, 2) + '\n',
'utf-8',
)
}
async function listWorkflowRuns(cwd: string): Promise<WorkflowRun[]> {
let files: string[]
try {
files = await readdir(join(cwd, WORKFLOW_RUNS_DIR))
} catch {
return []
}
const runs = await Promise.all(
files
.filter(f => f.endsWith('.json'))
.map(f => readWorkflowRun(cwd, f.slice(0, -'.json'.length))),
)
return runs
.filter((run): run is WorkflowRun => run !== null)
.sort((a, b) => b.updatedAt - a.updatedAt)
}
function parseMarkdownSteps(content: string): WorkflowStep[] {
const steps: WorkflowStep[] = []
for (const rawLine of content.split('\n')) {
const line = rawLine.trim()
const taskMatch = line.match(/^[-*]\s+\[[ xX]\]\s+(.+)$/)
const bulletMatch = line.match(/^[-*]\s+(.+)$/)
const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/)
const text = taskMatch?.[1] ?? bulletMatch?.[1] ?? numberedMatch?.[1]
if (!text) continue
steps.push({ name: text.slice(0, 80), prompt: text, status: 'pending' })
}
return steps
}
function parseYamlSteps(content: string): WorkflowStep[] {
const steps: WorkflowStep[] = []
let current: Partial<WorkflowStep> | null = null
const flush = () => {
if (!current) return
const prompt = current.prompt ?? current.name
if (current.name && prompt) {
steps.push({
name: current.name,
prompt,
status: 'pending',
})
}
current = null
}
for (const rawLine of content.split('\n')) {
const line = rawLine.trim()
const stepText = line.match(/^-\s+(.+)$/)?.[1]
if (stepText) {
flush()
const inlineName = stepText.match(/^name:\s*(.+)$/)?.[1]
current = {
name: inlineName ?? stepText,
prompt: inlineName ? undefined : stepText,
}
continue
}
const name = line.match(/^name:\s*(.+)$/)?.[1]
if (name) {
if (!current) current = {}
current.name = name
continue
}
const prompt = line.match(/^(prompt|run|command):\s*(.+)$/)?.[2]
if (prompt) {
if (!current) current = {}
current.prompt = prompt
}
}
flush()
return steps
}
function parseWorkflowSteps(filePath: string, content: string): WorkflowStep[] {
const ext = parse(filePath).ext.toLowerCase()
const steps =
ext === '.md' ? parseMarkdownSteps(content) : parseYamlSteps(content)
if (steps.length > 0) {
return steps
}
return [
{
name: 'Execute workflow',
prompt: content.trim(),
status: 'pending',
},
]
}
function formatStep(step: WorkflowStep, index: number): string {
return `Step ${index + 1}: ${step.name}\n${step.prompt}`
}
function formatRunStatus(run: WorkflowRun): string {
const lines = [
`Workflow run: ${run.runId}`,
`Workflow: ${run.workflow}`,
`Status: ${run.status}`,
`Current step: ${run.steps[run.currentStepIndex]?.name ?? 'none'}`,
`Steps: ${run.steps.length}`,
]
for (let i = 0; i < run.steps.length; i += 1) {
const step = run.steps[i]!
lines.push(` ${i + 1}. [${step.status}] ${step.name}`)
}
return lines.join('\n')
}
async function startWorkflow(
input: WorkflowInput,
cwd: string,
): Promise<WorkflowOutput> {
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
const found = await findWorkflowFile(workflowDir, input.workflow)
if (!found) {
const available = await listAvailableWorkflows(workflowDir)
const hint =
available.length > 0
? `\nAvailable workflows: ${available.join(', ')}`
: `\nNo workflows found in ${WORKFLOW_DIR_NAME}/. Create .md or .yaml files there.`
return { output: `Error: Workflow "${input.workflow}" not found.${hint}` }
}
const steps = parseWorkflowSteps(found.path, found.content)
const now = Date.now()
steps[0] = { ...steps[0]!, status: 'running', startedAt: now }
const run: WorkflowRun = {
runId: randomUUID(),
workflow: input.workflow,
...(input.args ? { args: input.args } : {}),
status: 'running',
createdAt: now,
updatedAt: now,
currentStepIndex: 0,
steps,
}
await writeWorkflowRun(cwd, run)
const argsSection = input.args ? `\n\nArguments:\n${input.args}` : ''
return {
output: [
`Workflow run started`,
`run_id: ${run.runId}`,
`workflow: ${run.workflow}`,
'',
formatStep(steps[0]!, 0),
argsSection,
'',
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
].join('\n'),
}
}
async function getRunOrError(
cwd: string,
runId: string | undefined,
): Promise<{ run?: WorkflowRun; output?: string }> {
if (!runId) return { output: 'Error: run_id is required for this action.' }
const run = await readWorkflowRun(cwd, runId)
if (!run) return { output: `Error: Workflow run "${runId}" not found.` }
return { run }
}
async function advanceWorkflow(
cwd: string,
runId: string | undefined,
): Promise<WorkflowOutput> {
const found = await getRunOrError(cwd, runId)
if (!found.run) return { output: found.output! }
const run = found.run
const now = Date.now()
const current = run.steps[run.currentStepIndex]
if (current && current.status === 'running') {
current.status = 'completed'
current.completedAt = now
}
const nextIndex = run.currentStepIndex + 1
if (nextIndex >= run.steps.length) {
run.status = 'completed'
run.updatedAt = now
await writeWorkflowRun(cwd, run)
return { output: `Workflow completed\nrun_id: ${run.runId}` }
}
run.currentStepIndex = nextIndex
run.steps[nextIndex] = {
...run.steps[nextIndex]!,
status: 'running',
startedAt: now,
}
run.updatedAt = now
await writeWorkflowRun(cwd, run)
return {
output: [
`Next workflow step`,
`run_id: ${run.runId}`,
'',
formatStep(run.steps[nextIndex]!, nextIndex),
'',
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
].join('\n'),
}
}
async function cancelWorkflow(
cwd: string,
runId: string | undefined,
): Promise<WorkflowOutput> {
const found = await getRunOrError(cwd, runId)
if (!found.run) return { output: found.output! }
const run = found.run
const now = Date.now()
run.status = 'cancelled'
run.updatedAt = now
for (const step of run.steps) {
if (step.status === 'pending' || step.status === 'running') {
step.status = 'cancelled'
}
}
await writeWorkflowRun(cwd, run)
return { output: `Workflow cancelled\nrun_id: ${run.runId}` }
}
async function listWorkflowRunsForOutput(cwd: string): Promise<WorkflowOutput> {
const runs = await listWorkflowRuns(cwd)
if (runs.length === 0) return { output: 'No workflow runs recorded.' }
return {
output: runs
.slice(0, 20)
.map(
run =>
`${run.runId} | ${run.workflow} | ${run.status} | step=${run.steps[run.currentStepIndex]?.name ?? 'none'} | updated=${new Date(run.updatedAt).toLocaleString()}`,
)
.join('\n'),
}
}
export const WorkflowTool = buildTool({
name: WORKFLOW_TOOL_NAME,
searchHint: 'execute user-defined workflow scripts',
@@ -22,21 +362,25 @@ export const WorkflowTool = buildTool({
inputSchema,
async description() {
return 'Execute a user-defined workflow script from .claude/workflows/'
return 'Execute and track a user-defined workflow from .claude/workflows/'
},
async prompt() {
return `Use the Workflow tool to execute user-defined workflow scripts located in .claude/workflows/. Workflows are YAML or Markdown files that define a sequence of steps for common development tasks.
return `Use the Workflow tool to run user-defined workflows located in .claude/workflows/. Workflows may be Markdown checklists/lists or YAML files with steps.
Guidelines:
- Specify the workflow name to execute (must match a file in .claude/workflows/)
- Optionally pass arguments that the workflow can use
- Workflows run in the context of the current project`
Actions:
- start (default): create a persisted workflow run and return the first step to execute
- advance: mark the current step complete and return the next step
- status: inspect a workflow run by run_id
- cancel: cancel a workflow run
- list: list recent workflow runs
Workflow run state is persisted in .claude/workflow-runs/.`
},
userFacingName() {
return 'Workflow'
},
isReadOnly() {
return false
isReadOnly(input) {
return input.action === 'status' || input.action === 'list'
},
isEnabled() {
return true
@@ -44,10 +388,10 @@ Guidelines:
renderToolUseMessage(input: Partial<WorkflowInput>) {
const name = input.workflow ?? 'unknown'
if (input.args) {
return `Workflow: ${name} ${input.args}`
}
return `Workflow: ${name}`
const action = input.action ?? 'start'
return input.args
? `Workflow: ${action} ${name} ${input.args}`
: `Workflow: ${action} ${name}`
},
mapToolResultToToolResultBlockParam(
@@ -61,14 +405,26 @@ Guidelines:
}
},
async call(_input: WorkflowInput, _context, _progress) {
// Workflow execution is wired by the WORKFLOW_SCRIPTS feature bootstrap.
// Without it, this tool is not functional.
return {
data: {
output:
'Error: Workflow execution requires the WORKFLOW_SCRIPTS runtime.',
},
async call(input: WorkflowInput) {
const cwd = process.cwd()
const action = input.action ?? 'start'
switch (action) {
case 'start':
return { data: await startWorkflow(input, cwd) }
case 'status': {
const found = await getRunOrError(cwd, input.run_id)
return {
data: {
output: found.run ? formatRunStatus(found.run) : found.output!,
},
}
}
case 'advance':
return { data: await advanceWorkflow(cwd, input.run_id) }
case 'cancel':
return { data: await cancelWorkflow(cwd, input.run_id) }
case 'list':
return { data: await listWorkflowRunsForOutput(cwd) }
}
},
})

View File

@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { WorkflowTool } from '../WorkflowTool'
let cwd: string
let previousCwd: string
beforeEach(async () => {
previousCwd = process.cwd()
cwd = join(tmpdir(), `workflow-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
await mkdir(join(cwd, '.claude', 'workflows'), { recursive: true })
process.chdir(cwd)
})
afterEach(async () => {
process.chdir(previousCwd)
await rm(cwd, { recursive: true, force: true })
})
describe('WorkflowTool', () => {
test('starts a workflow run and persists step state', async () => {
await writeFile(
join(cwd, '.claude', 'workflows', 'release.md'),
[
'# Release',
'',
'- [ ] Run tests',
'- [ ] Build package',
].join('\n'),
)
const result = await WorkflowTool.call({ workflow: 'release' })
expect(result.data.output).toContain('Workflow run started')
expect(result.data.output).toContain('Run tests')
const match = result.data.output.match(/run_id: ([a-f0-9-]+)/)
expect(match?.[1]).toBeString()
const raw = await readFile(
join(cwd, '.claude', 'workflow-runs', `${match![1]}.json`),
'utf-8',
)
const run = JSON.parse(raw)
expect(run.workflow).toBe('release')
expect(run.status).toBe('running')
expect(run.steps).toHaveLength(2)
expect(run.steps[0].status).toBe('running')
expect(run.steps[1].status).toBe('pending')
})
test('advances a workflow run through completion', async () => {
await writeFile(
join(cwd, '.claude', 'workflows', 'audit.yaml'),
[
'steps:',
' - name: Inspect',
' prompt: Inspect the code',
' - name: Verify',
' prompt: Run focused tests',
].join('\n'),
)
const started = await WorkflowTool.call({ workflow: 'audit' })
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
const next = await WorkflowTool.call(
{ workflow: 'audit', action: 'advance', run_id: runId },
)
expect(next.data.output).toContain('Next workflow step')
expect(next.data.output).toContain('Run focused tests')
const done = await WorkflowTool.call(
{ workflow: 'audit', action: 'advance', run_id: runId },
)
expect(done.data.output).toContain('Workflow completed')
})
test('lists and cancels workflow runs', async () => {
await writeFile(
join(cwd, '.claude', 'workflows', 'cleanup.md'),
'- Remove stale files',
)
const started = await WorkflowTool.call({ workflow: 'cleanup' })
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
const listed = await WorkflowTool.call(
{ workflow: 'cleanup', action: 'list' },
)
expect(listed.data.output).toContain(runId)
const cancelled = await WorkflowTool.call(
{ workflow: 'cleanup', action: 'cancel', run_id: runId },
)
expect(cancelled.data.output).toContain('Workflow cancelled')
})
})

View File

@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnTeammate } from '../spawnMultiAgent'
let tempHome: string
let previousConfigDir: string | undefined
beforeEach(() => {
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
tempHome = join(tmpdir(), `spawn-multi-agent-${Date.now()}-${Math.random().toString(16).slice(2)}`)
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterEach(() => {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
rmSync(tempHome, { recursive: true, force: true })
})
describe('spawnTeammate', () => {
test('fails before spawn side effects when the team file is missing', async () => {
let setAppStateCalled = false
const context = {
getAppState: () => ({
teamContext: undefined,
}),
setAppState: () => {
setAppStateCalled = true
},
options: {
agentDefinitions: {
activeAgents: [],
},
},
}
await expect(
spawnTeammate(
{
name: 'worker',
prompt: 'do work',
team_name: 'missing-team',
},
context as any,
),
).rejects.toThrow('Team "missing-team" does not exist')
expect(setAppStateCalled).toBe(false)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -18,26 +18,20 @@
*/
import { diffArrays } from 'diff'
import type * as hljsNamespace from 'highlight.js'
import hljs from 'highlight.js'
import { basename, extname } from 'path'
// Lazy: defers loading highlight.js until first render. The full bundle
// registers 190+ language grammars at require time (~50MB, 100-200ms on
// macOS, several× that on Windows). With a top-level import, any caller
// chunk that reaches this module — including test/preload.ts via
// StructuredDiff.tsx → colorDiff.ts — pays that cost at module-eval time
// and carries the heap for the rest of the process. On Windows CI this
// pushed later tests in the same shard into GC-pause territory and a
// beforeEach/afterEach hook timeout (officialRegistry.test.ts, PR #24150).
// Same lazy pattern the NAPI wrapper used for dlopen.
type HLJSApi = typeof hljsNamespace.default
// Static import — createRequire(import.meta.url) fails in Bun --compile mode
// because the resolved path points to the internal bunfs binary path where
// node_modules cannot be found. A top-level import ensures the module is
// bundled and accessible at runtime.
type HLJSApi = typeof hljs
let cachedHljs: HLJSApi | null = null
function hljs(): HLJSApi {
function hljsApi(): HLJSApi {
if (cachedHljs) return cachedHljs
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('highlight.js')
// highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
// in .default; under node CJS the module IS the API. Check at runtime.
const mod = hljs as HLJSApi & { default?: HLJSApi }
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
return cachedHljs!
}
@@ -436,9 +430,9 @@ function detectLanguage(
// Filename-based lookup (handles Dockerfile, Makefile, CMakeLists.txt, etc.)
const stem = base.split('.')[0] ?? ''
const byName = FILENAME_LANGS[base] ?? FILENAME_LANGS[stem]
if (byName && hljs().getLanguage(byName)) return byName
if (byName && hljsApi().getLanguage(byName)) return byName
if (ext) {
const lang = hljs().getLanguage(ext)
const lang = hljsApi().getLanguage(ext)
if (lang) return ext
}
// Shebang / first-line detection (strip UTF-8 BOM)
@@ -520,7 +514,7 @@ function highlightLine(
}
let result
try {
result = hljs().highlight(code, {
result = hljsApi().highlight(code, {
language: state.lang,
ignoreIllegals: true,
})

View File

@@ -1,3 +1,4 @@
import { readFileSync, unlinkSync } from 'node:fs'
import sharpModule from 'sharp'
export const sharp = sharpModule
@@ -62,13 +63,11 @@ return "${tmpPath}"
}
const file = Bun.file(tmpPath)
// Use synchronous read via Node compat
const fs = require('fs')
const buffer: Buffer = fs.readFileSync(tmpPath)
const buffer: Buffer = readFileSync(tmpPath)
// Clean up temp file
try {
fs.unlinkSync(tmpPath)
unlinkSync(tmpPath)
} catch {
// ignore cleanup errors
}

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
let ffiShouldThrow = false
let nativeFlags = 0
let dlopenCalls = 0
mock.module('bun:ffi', () => ({
FFIType: {
i32: 0,
u64: 0,
},
dlopen: () => {
dlopenCalls++
if (ffiShouldThrow) {
throw new Error('ffi load failed')
}
return {
symbols: {
CGEventSourceFlagsState: () => nativeFlags,
},
}
},
}))
const originalPlatform = process.platform
async function loadModule() {
return import(`../index.ts?case=${Math.random()}`)
}
beforeEach(() => {
ffiShouldThrow = false
nativeFlags = 0
dlopenCalls = 0
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
})
})
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
})
})
describe('modifiers-napi', () => {
test('returns false for non-darwin platforms', async () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
})
const mod = await loadModule()
await mod.prewarm()
expect(dlopenCalls).toBe(0)
expect(mod.isModifierPressed('shift')).toBe(false)
expect(mod.isModifierPressed('command')).toBe(false)
})
test('prewarm is idempotent on darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
})
const mod = await loadModule()
await mod.prewarm()
await mod.prewarm()
expect(dlopenCalls).toBe(1)
})
test('returns false when ffi loading fails on darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
})
ffiShouldThrow = true
const mod = await loadModule()
await mod.prewarm()
expect(mod.isModifierPressed('shift')).toBe(false)
})
test('returns false for unknown modifier names on darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
})
nativeFlags = 0x20000
const mod = await loadModule()
await mod.prewarm()
expect(mod.isModifierPressed('unknown')).toBe(false)
})
test('uses native flag bits for known modifiers on darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
})
nativeFlags = 0x20000 | 0x40000
const mod = await loadModule()
await mod.prewarm()
expect(mod.isModifierPressed('shift')).toBe(true)
expect(mod.isModifierPressed('control')).toBe(true)
expect(mod.isModifierPressed('option')).toBe(false)
})
})

View File

@@ -14,14 +14,16 @@ const modifierFlags: Record<string, number> = {
const kCGEventSourceStateCombinedSessionState = 0;
let cgEventSourceFlagsState: ((stateID: number) => number) | null = null;
let ffiLoadAttempted = false;
function loadFFI(): void {
if (cgEventSourceFlagsState !== null || process.platform !== "darwin") {
async function loadFFI(): Promise<void> {
if (ffiLoadAttempted || process.platform !== "darwin") {
return;
}
ffiLoadAttempted = true;
try {
const ffi = require("bun:ffi") as typeof import("bun:ffi");
const ffi = await import("bun:ffi");
const lib = ffi.dlopen(
`/System/Library/Frameworks/Carbon.framework/Carbon`,
{
@@ -35,13 +37,12 @@ function loadFFI(): void {
return Number(lib.symbols.CGEventSourceFlagsState(stateID));
};
} catch {
// If loading fails, keep the function null so isModifierPressed returns false
cgEventSourceFlagsState = null;
}
}
export function prewarm(): void {
loadFFI();
export async function prewarm(): Promise<void> {
await loadFFI();
}
export function isModifierPressed(modifier: string): boolean {
@@ -49,8 +50,6 @@ export function isModifierPressed(modifier: string): boolean {
return false;
}
loadFFI();
if (cgEventSourceFlagsState === null) {
return false;
}

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

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