mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
Compare commits
147 Commits
feature/un
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
661b1e29e4 | ||
|
|
e38d45460e | ||
|
|
e0c8e9dafc | ||
|
|
047c85fcbf | ||
|
|
da6d06365d | ||
|
|
8613d558a8 | ||
|
|
017c251f78 | ||
|
|
d4223abc34 | ||
|
|
5125a159d2 | ||
|
|
d09f363414 | ||
|
|
9d35f98ec7 | ||
|
|
eb833da33b | ||
|
|
eadd32ae47 | ||
|
|
3c55a8c83f | ||
|
|
5582bb47ef | ||
|
|
95bb191977 | ||
|
|
03811f973b | ||
|
|
02ab1a0307 | ||
|
|
00c120f95c | ||
|
|
7b513103d9 | ||
|
|
d6374f02d6 | ||
|
|
b217836f5a | ||
|
|
4cf1a8353e | ||
|
|
a58a36c35a | ||
|
|
c5c7202348 | ||
|
|
192221eafc | ||
|
|
8c6be4b5d3 | ||
|
|
c37b274406 | ||
|
|
e16b5667a8 | ||
|
|
e9405e4a8a | ||
|
|
be64db70d4 | ||
|
|
6fc365cf73 | ||
|
|
6a89a5139a | ||
|
|
6ed8f5b870 | ||
|
|
bc17003301 | ||
|
|
dc13eb9c10 | ||
|
|
ec6a223b85 | ||
|
|
27e9857741 | ||
|
|
090e3515ae | ||
|
|
0572d5591b | ||
|
|
24922affd2 | ||
|
|
10b5f35140 | ||
|
|
b3fce1edb7 | ||
|
|
5e47489579 | ||
|
|
3210caddb0 | ||
|
|
51a3a83f07 | ||
|
|
c69e66d2cd | ||
|
|
cbda09d7ee | ||
|
|
c88943795f | ||
|
|
2a5b263641 | ||
|
|
f2dd5142b3 | ||
|
|
4dcbaf1e66 | ||
|
|
0b304730d8 | ||
|
|
7a0dd3057e | ||
|
|
ca1c87f460 | ||
|
|
fc7a85f5c7 | ||
|
|
5bc12b00b2 | ||
|
|
792777d68c | ||
|
|
047634afe6 | ||
|
|
a92af99448 | ||
|
|
cfe1552ec9 | ||
|
|
9624f880e0 | ||
|
|
85e5a8cffb | ||
|
|
299953b0ee | ||
|
|
7a3fdf6e67 | ||
|
|
b642977afe | ||
|
|
781188862e | ||
|
|
b966eef5a9 | ||
|
|
c3d63c8fe2 | ||
|
|
7d4c4278c0 | ||
|
|
93bfdabff1 | ||
|
|
1173a62301 | ||
|
|
7ea69ca279 | ||
|
|
ecf2dbde44 | ||
|
|
1a910ed639 | ||
|
|
dceaacdf4f | ||
|
|
4e82fb5974 | ||
|
|
f43350e600 | ||
|
|
23fcbf9004 | ||
|
|
23bb09d240 | ||
|
|
d208855f07 | ||
|
|
7881cc617c | ||
|
|
c7e1c50b86 | ||
|
|
2247026bd5 | ||
|
|
eec961352b | ||
|
|
fb41513b32 | ||
|
|
94c4b37eed | ||
|
|
6c5df395c3 | ||
|
|
be97a0b010 | ||
|
|
59f8675fa3 | ||
|
|
c4775fff58 | ||
|
|
31b2fdd97a | ||
|
|
1837df5f88 | ||
|
|
04c7ed4250 | ||
|
|
7813904264 | ||
|
|
02783e4f5d | ||
|
|
9930a53e51 | ||
|
|
2c15d9123d | ||
|
|
1217c453c4 | ||
|
|
77e8d15482 | ||
|
|
72cfb83de3 | ||
|
|
8bf645364f | ||
|
|
1b777a25ac | ||
|
|
af0a7054c7 | ||
|
|
ea0eee05d0 | ||
|
|
bd70971632 | ||
|
|
d8e33935db | ||
|
|
bfd14206a9 | ||
|
|
f22caf0e97 | ||
|
|
25067e78af | ||
|
|
70d8c0038c | ||
|
|
3c64113d77 | ||
|
|
0777e1a1f9 | ||
|
|
080bd93efc | ||
|
|
363ba39cad | ||
|
|
4b23bcd3eb | ||
|
|
4116ac9b5c | ||
|
|
39299f6e17 | ||
|
|
1bba087942 | ||
|
|
7c64199fc5 | ||
|
|
df61bf3852 | ||
|
|
98284a5908 | ||
|
|
fae96c3e7f | ||
|
|
661cc764fe | ||
|
|
391e0c233a | ||
|
|
74682b2a82 | ||
|
|
100b1589f2 | ||
|
|
fa8e45e933 | ||
|
|
96e6d33414 | ||
|
|
1dd36f3f6f | ||
|
|
e3570f8cdb | ||
|
|
f5a97011e8 | ||
|
|
a3fc348421 | ||
|
|
12cbb7c4c7 | ||
|
|
96f3e1b309 | ||
|
|
336159ee18 | ||
|
|
970fcd627f | ||
|
|
f74492617b | ||
|
|
b5525f63c6 | ||
|
|
722aa6c97a | ||
|
|
52a862e5b4 | ||
|
|
88ddba6c23 | ||
|
|
711927f01b | ||
|
|
956e98a445 | ||
|
|
cee62bc654 | ||
|
|
5fc7c8e13d | ||
|
|
300faa18d0 |
139
.claude/skills/fix-issue/SKILL.md
Normal file
139
.claude/skills/fix-issue/SKILL.md
Normal 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
|
||||
```
|
||||
|
||||
提取以下信息:
|
||||
- 问题标题和描述
|
||||
- Labels(bug、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. 静默完成
|
||||
@@ -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
59
.github/workflows/auto-issue-fix.yml
vendored
Normal 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 }}"
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
79
.github/workflows/publish-npm.yml
vendored
Normal 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') }}
|
||||
5
.github/workflows/update-contributors.yml
vendored
5
.github/workflows/update-contributors.yml
vendored
@@ -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
6
.gitignore
vendored
@@ -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
357
AGENTS.md
Normal 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 framework(forked/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 Server(Docker 部署,含 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 UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
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 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。
|
||||
58
CLAUDE.md
58
CLAUDE.md
@@ -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
|
||||
@@ -60,7 +60,6 @@ bun run check:unused
|
||||
|
||||
# Full check (typecheck + lint + test) — run after completing any task
|
||||
bun run test:all
|
||||
|
||||
bun run typecheck
|
||||
|
||||
# Remote Control Server
|
||||
@@ -87,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`
|
||||
@@ -118,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
|
||||
@@ -127,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)
|
||||
|
||||
@@ -176,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`。
|
||||
@@ -218,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)
|
||||
|
||||
@@ -250,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/)
|
||||
@@ -263,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 同一模块。
|
||||
@@ -272,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
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
@@ -293,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
|
||||
|
||||
116
README.md
116
README.md
@@ -12,30 +12,32 @@
|
||||
|
||||
牢 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 下载后, 直接使用
|
||||
@@ -45,7 +47,7 @@ npm i -g claude-code-best
|
||||
|
||||
# bun 安装比较多问题, 推荐 npm 装
|
||||
# bun i -g claude-code-best
|
||||
# bun pm -g trust claude-code-best
|
||||
# bun pm -g trust claude-code-best @claude-code-best/mcp-chrome-bridge
|
||||
|
||||
ccb # 以 nodejs 打开 claude code
|
||||
ccb-bun # 以 bun 形态打开
|
||||
@@ -60,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
|
||||
```
|
||||
|
||||
@@ -91,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
|
||||
@@ -121,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))
|
||||
@@ -157,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
|
||||
|
||||
|
||||
53
README_EN.md
53
README_EN.md
@@ -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
|
||||
```
|
||||
|
||||
|
||||
101
bun.lock
101
bun.lock
@@ -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
@@ -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-only(3 个)
|
||||
|
||||
| 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 |
|
||||
|
||||
### 第四类:纯 stub(1 个)
|
||||
|
||||
| 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
426
docs/features/ssh-remote.md
Normal 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
|
||||
# 方式 A:ssh-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
|
||||
```
|
||||
|
||||
#### Windows(OpenSSH 强制 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 Forbidden(SSH 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
1218
docs/ink-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.7.1",
|
||||
"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,12 +47,16 @@
|
||||
"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",
|
||||
@@ -63,7 +67,8 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -158,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:*",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {},
|
||||
|
||||
@@ -394,6 +394,7 @@ export const getAgentDefinitionsWithOverrides = memoize(
|
||||
|
||||
export function clearAgentDefinitionsCache(): void {
|
||||
getAgentDefinitionsWithOverrides.cache.clear?.()
|
||||
loadMarkdownFilesForSubdir.cache?.clear?.()
|
||||
clearPluginAgentCache()
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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:')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}".`,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -17,32 +17,21 @@
|
||||
* getSyntaxTheme always returns the default for the given Claude theme.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { diffArrays } from 'diff'
|
||||
import type * as hljsNamespace from 'highlight.js'
|
||||
import hljs from 'highlight.js'
|
||||
import { basename, extname } from 'path'
|
||||
|
||||
// createRequire works in both Bun and Node.js ESM contexts.
|
||||
// Needed because this package is "type": "module" but uses require() for
|
||||
// lazy loading — bare require is not available in Node.js ESM.
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
// 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
|
||||
const mod = nodeRequire('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!
|
||||
}
|
||||
@@ -441,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)
|
||||
@@ -525,7 +514,7 @@ function highlightLine(
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = hljs().highlight(code, {
|
||||
result = hljsApi().highlight(code, {
|
||||
language: state.lang,
|
||||
ignoreIllegals: true,
|
||||
})
|
||||
|
||||
112
packages/modifiers-napi/src/__tests__/index.test.ts
Normal file
112
packages/modifiers-napi/src/__tests__/index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
338
packages/pokemon/docs/battle-audit-report.md
Normal file
338
packages/pokemon/docs/battle-audit-report.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Pokémon Battle 实现审查报告
|
||||
|
||||
> 审查日期:2026-04-23
|
||||
> 审查范围:`packages/pokemon/` 全部源码(battle、core、dex、ui)
|
||||
> 对比基准:原版 Pokémon 核心系列游戏(Gen 9:Scarlet/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 Value(32 位 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 方法,阈值 < 16(Gen 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 计算孵化步数
|
||||
12
packages/pokemon/package.json
Normal file
12
packages/pokemon/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
133
packages/pokemon/scripts/fetch-pokedex-data.ts
Normal file
133
packages/pokemon/scripts/fetch-pokedex-data.ts
Normal 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)
|
||||
90
packages/pokemon/scripts/fetch-species-names.ts
Normal file
90
packages/pokemon/scripts/fetch-species-names.ts
Normal 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)
|
||||
124
packages/pokemon/scripts/fetch-sprites.ts
Normal file
124
packages/pokemon/scripts/fetch-sprites.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Script to pre-fetch all 10 MVP Pokémon sprites from GitHub.
|
||||
* Run: bun run packages/pokemon/scripts/fetch-sprites.ts
|
||||
*/
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons'
|
||||
|
||||
const COW_FILES: Record<string, string> = {
|
||||
bulbasaur: '001_bulbasaur',
|
||||
ivysaur: '002_ivysaur',
|
||||
venusaur: '003_venusaur',
|
||||
charmander: '004_charmander',
|
||||
charmeleon: '005_charmeleon',
|
||||
charizard: '006_charizard',
|
||||
squirtle: '007_squirtle',
|
||||
wartortle: '008_wartortle',
|
||||
blastoise: '009_blastoise',
|
||||
pikachu: '025_pikachu',
|
||||
}
|
||||
|
||||
const SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
|
||||
|
||||
function convertCowToLines(cowContent: string): string[] {
|
||||
const startMarker = '$the_cow =<<EOC;'
|
||||
const endMarker = 'EOC'
|
||||
|
||||
const startIdx = cowContent.indexOf(startMarker)
|
||||
if (startIdx === -1) return []
|
||||
|
||||
const contentStart = startIdx + startMarker.length
|
||||
const endIdx = cowContent.indexOf(endMarker, contentStart)
|
||||
if (endIdx === -1) return []
|
||||
|
||||
let content = cowContent.slice(contentStart, endIdx)
|
||||
|
||||
// Convert \N{U+XXXX} to actual Unicode characters
|
||||
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
|
||||
String.fromCodePoint(parseInt(hex, 16)),
|
||||
)
|
||||
|
||||
// Convert \e to actual escape character (for ANSI sequences)
|
||||
content = content.replace(/\\e/g, '\x1b')
|
||||
|
||||
// Split into lines
|
||||
let lines = content.split('\n')
|
||||
|
||||
// Strip leading/trailing empty lines
|
||||
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
|
||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
||||
|
||||
// Remove first 4 lines (cowsay thought bubble guide - $thoughts lines)
|
||||
if (lines.length > 4) {
|
||||
lines = lines.slice(4)
|
||||
}
|
||||
|
||||
// Trim trailing whitespace on each line
|
||||
lines = lines.map((line) => line.trimEnd())
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function stripAnsi(str: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Ensure output directory
|
||||
if (!existsSync(SPRITES_DIR)) {
|
||||
mkdirSync(SPRITES_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
for (const [speciesId, cowPrefix] of Object.entries(COW_FILES)) {
|
||||
const url = `${GITHUB_RAW_BASE}/${cowPrefix}.cow`
|
||||
console.log(`Fetching ${speciesId} from ${url}...`)
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
console.error(` FAILED: HTTP ${response.status}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const cowContent = await response.text()
|
||||
const lines = convertCowToLines(cowContent)
|
||||
|
||||
if (lines.length === 0) {
|
||||
console.error(` FAILED: No lines after conversion`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate visible width (strip ANSI for measurement)
|
||||
const widths = lines.map((l) => stripAnsi(l).length)
|
||||
|
||||
const sprite = {
|
||||
speciesId,
|
||||
lines,
|
||||
width: Math.max(...widths),
|
||||
height: lines.length,
|
||||
fetchedAt: Date.now(),
|
||||
}
|
||||
|
||||
const outPath = join(SPRITES_DIR, `${speciesId}.json`)
|
||||
writeFileSync(outPath, JSON.stringify(sprite, null, 2))
|
||||
|
||||
console.log(` OK: ${lines.length} lines, ${sprite.width} cols wide`)
|
||||
|
||||
// Also print first line for visual check
|
||||
console.log(` Preview line 1: ${stripAnsi(lines[0]!)}`)
|
||||
} catch (err) {
|
||||
console.error(` FAILED: ${err}`)
|
||||
}
|
||||
|
||||
// Small delay to be nice to GitHub
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
console.log('\nDone! Sprites cached to ~/.claude/buddy-sprites/')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
347
packages/pokemon/src/__tests__/battle-helper.ts
Normal file
347
packages/pokemon/src/__tests__/battle-helper.ts
Normal 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)
|
||||
}
|
||||
298
packages/pokemon/src/__tests__/battle-scenarios.test.ts
Normal file
298
packages/pokemon/src/__tests__/battle-scenarios.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
469
packages/pokemon/src/__tests__/battle.test.ts
Normal file
469
packages/pokemon/src/__tests__/battle.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
188
packages/pokemon/src/__tests__/creature.test.ts
Normal file
188
packages/pokemon/src/__tests__/creature.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
79
packages/pokemon/src/__tests__/effort.test.ts
Normal file
79
packages/pokemon/src/__tests__/effort.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { generateCreature } from '../core/creature'
|
||||
import { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from '../core/effort'
|
||||
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../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')
|
||||
})
|
||||
})
|
||||
160
packages/pokemon/src/__tests__/egg.test.ts
Normal file
160
packages/pokemon/src/__tests__/egg.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
39
packages/pokemon/src/__tests__/evMapping.test.ts
Normal file
39
packages/pokemon/src/__tests__/evMapping.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
126
packages/pokemon/src/__tests__/evolution.test.ts
Normal file
126
packages/pokemon/src/__tests__/evolution.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
153
packages/pokemon/src/__tests__/experience.test.ts
Normal file
153
packages/pokemon/src/__tests__/experience.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
33
packages/pokemon/src/__tests__/fallback.test.ts
Normal file
33
packages/pokemon/src/__tests__/fallback.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
51
packages/pokemon/src/__tests__/gender.test.ts
Normal file
51
packages/pokemon/src/__tests__/gender.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { determineGender, getGenderSymbol } from '../core/gender'
|
||||
import { 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('')
|
||||
})
|
||||
})
|
||||
59
packages/pokemon/src/__tests__/learnsets.test.ts
Normal file
59
packages/pokemon/src/__tests__/learnsets.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
51
packages/pokemon/src/__tests__/names.test.ts
Normal file
51
packages/pokemon/src/__tests__/names.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
53
packages/pokemon/src/__tests__/nature.test.ts
Normal file
53
packages/pokemon/src/__tests__/nature.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
46
packages/pokemon/src/__tests__/pkmn.test.ts
Normal file
46
packages/pokemon/src/__tests__/pkmn.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
103
packages/pokemon/src/__tests__/renderer.test.ts
Normal file
103
packages/pokemon/src/__tests__/renderer.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
95
packages/pokemon/src/__tests__/species.test.ts
Normal file
95
packages/pokemon/src/__tests__/species.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
29
packages/pokemon/src/__tests__/spriteCache.test.ts
Normal file
29
packages/pokemon/src/__tests__/spriteCache.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
388
packages/pokemon/src/__tests__/storage.test.ts
Normal file
388
packages/pokemon/src/__tests__/storage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
64
packages/pokemon/src/__tests__/xpTable.test.ts
Normal file
64
packages/pokemon/src/__tests__/xpTable.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
73
packages/pokemon/src/battle/ai.ts
Normal file
73
packages/pokemon/src/battle/ai.ts
Normal 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)]!
|
||||
}
|
||||
166
packages/pokemon/src/battle/capture.ts
Normal file
166
packages/pokemon/src/battle/capture.ts
Normal 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 }
|
||||
}
|
||||
838
packages/pokemon/src/battle/engine.ts
Normal file
838
packages/pokemon/src/battle/engine.ts
Normal 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
|
||||
}
|
||||
5
packages/pokemon/src/battle/index.ts
Normal file
5
packages/pokemon/src/battle/index.ts
Normal 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'
|
||||
189
packages/pokemon/src/battle/settlement.ts
Normal file
189
packages/pokemon/src/battle/settlement.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
93
packages/pokemon/src/battle/types.ts
Normal file
93
packages/pokemon/src/battle/types.ts
Normal 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
|
||||
}
|
||||
160
packages/pokemon/src/core/creature.ts
Normal file
160
packages/pokemon/src/core/creature.ts
Normal 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)
|
||||
}
|
||||
98
packages/pokemon/src/core/effort.ts
Normal file
98
packages/pokemon/src/core/effort.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Creature, StatName } from '../types'
|
||||
import { STAT_NAMES } from '../types'
|
||||
import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL, 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'
|
||||
}
|
||||
111
packages/pokemon/src/core/egg.ts
Normal file
111
packages/pokemon/src/core/egg.ts
Normal 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 }]
|
||||
}
|
||||
46
packages/pokemon/src/core/evolution.ts
Normal file
46
packages/pokemon/src/core/evolution.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Creature, EvolutionResult, SpeciesId } from '../types'
|
||||
import { 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
|
||||
}
|
||||
52
packages/pokemon/src/core/experience.ts
Normal file
52
packages/pokemon/src/core/experience.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Creature } from '../types'
|
||||
import { 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,
|
||||
}
|
||||
}
|
||||
29
packages/pokemon/src/core/gender.ts
Normal file
29
packages/pokemon/src/core/gender.ts
Normal 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 ''
|
||||
}
|
||||
}
|
||||
131
packages/pokemon/src/core/spriteCache.ts
Normal file
131
packages/pokemon/src/core/spriteCache.ts
Normal 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}`
|
||||
}
|
||||
420
packages/pokemon/src/core/storage.ts
Normal file
420
packages/pokemon/src/core/storage.ts
Normal 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
|
||||
}
|
||||
31
packages/pokemon/src/dex/evMapping.ts
Normal file
31
packages/pokemon/src/dex/evMapping.ts
Normal 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]
|
||||
}
|
||||
33
packages/pokemon/src/dex/evolution.ts
Normal file
33
packages/pokemon/src/dex/evolution.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
93
packages/pokemon/src/dex/learnsets.ts
Normal file
93
packages/pokemon/src/dex/learnsets.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
70
packages/pokemon/src/dex/names.ts
Normal file
70
packages/pokemon/src/dex/names.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { SpeciesId } from '../types'
|
||||
|
||||
/** Curated English names (Dex provides default names for all species) */
|
||||
export const SPECIES_NAMES: Partial<Record<string, string>> = {
|
||||
bulbasaur: 'Bulbasaur',
|
||||
ivysaur: 'Ivysaur',
|
||||
venusaur: 'Venusaur',
|
||||
charmander: 'Charmander',
|
||||
charmeleon: 'Charmeleon',
|
||||
charizard: 'Charizard',
|
||||
squirtle: 'Squirtle',
|
||||
wartortle: 'Wartortle',
|
||||
blastoise: 'Blastoise',
|
||||
pikachu: 'Pikachu',
|
||||
}
|
||||
|
||||
/** Curated multilingual names (falls back to English from Dex) */
|
||||
const CURATED_I18N: Partial<Record<string, Record<string, string>>> = {
|
||||
bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' },
|
||||
ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' },
|
||||
venusaur: { en: 'Venusaur', ja: 'フシギバナ', zh: '妙蛙花' },
|
||||
charmander: { en: 'Charmander', ja: 'ヒトカゲ', zh: '小火龙' },
|
||||
charmeleon: { en: 'Charmeleon', ja: 'リザード', zh: '火恐龙' },
|
||||
charizard: { en: 'Charizard', ja: 'リザードン', zh: '喷火龙' },
|
||||
squirtle: { en: 'Squirtle', ja: 'ゼニガメ', zh: '杰尼龟' },
|
||||
wartortle: { en: 'Wartortle', ja: 'カメール', zh: '卡咪龟' },
|
||||
blastoise: { en: 'Blastoise', ja: 'カメックス', zh: '水箭龟' },
|
||||
pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' },
|
||||
}
|
||||
|
||||
// Try loading auto-generated multilingual data (from fetch-species-names.ts)
|
||||
let generatedI18n: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require('./species-names.ts') as { SPECIES_I18N_DATA?: Record<string, { en: string; ja: string; zh: string }> }
|
||||
if (mod.SPECIES_I18N_DATA) {
|
||||
generatedI18n = mod.SPECIES_I18N_DATA
|
||||
}
|
||||
} catch {
|
||||
// species-names.ts not generated yet — use curated fallback
|
||||
}
|
||||
|
||||
/** Get multilingual name for a species. Falls back to Dex English name. */
|
||||
export function getSpeciesI18nName(speciesId: SpeciesId, lang: string): string {
|
||||
const generated = generatedI18n[speciesId]
|
||||
if (generated) return generated[lang] ?? generated.en ?? speciesId
|
||||
const curated = CURATED_I18N[speciesId]
|
||||
if (curated) return curated[lang] ?? curated.en ?? speciesId
|
||||
return speciesId
|
||||
}
|
||||
|
||||
/** All available multilingual names (curated + auto-generated) */
|
||||
export const SPECIES_I18N: Partial<Record<string, Record<string, string>>> = {
|
||||
...CURATED_I18N,
|
||||
...generatedI18n,
|
||||
}
|
||||
|
||||
/** Curated personality descriptions (falls back to empty string) */
|
||||
export const SPECIES_PERSONALITY: Partial<Record<string, string>> = {
|
||||
bulbasaur: 'Calm and collected, a reliable partner',
|
||||
ivysaur: 'Steady growth, patient and resilient',
|
||||
venusaur: 'Majestic and powerful, a natural leader',
|
||||
charmander: 'Energetic and curious, loves adventure',
|
||||
charmeleon: 'Fierce and determined, always pushing forward',
|
||||
charizard: 'Proud and strong-willed, a formidable ally',
|
||||
squirtle: 'Cheerful and playful, adapts easily',
|
||||
wartortle: 'Loyal and protective, wise beyond years',
|
||||
blastoise: 'Steadfast and powerful, a defensive fortress',
|
||||
pikachu: 'Friendly and energetic, always by your side',
|
||||
}
|
||||
39
packages/pokemon/src/dex/nature.ts
Normal file
39
packages/pokemon/src/dex/nature.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import { FROM_DEX_STAT } from './pkmn'
|
||||
import type { NatureName, NatureEffect, NatureStat } from '../types'
|
||||
|
||||
// All 25 canonical nature names (Dex.natures is not iterable, so we list them)
|
||||
const NATURE_IDS: NatureName[] = [
|
||||
'hardy', 'lonely', 'brave', 'adamant', 'naughty',
|
||||
'bold', 'docile', 'relaxed', 'impish', 'lax',
|
||||
'timid', 'hasty', 'serious', 'jolly', 'naive',
|
||||
'modest', 'mild', 'quiet', 'bashful', 'rash',
|
||||
'calm', 'gentle', 'sassy', 'careful', 'quirky',
|
||||
]
|
||||
|
||||
/** Get all nature names */
|
||||
export function getAllNatureNames(): NatureName[] {
|
||||
return NATURE_IDS.filter(name => Dex.natures.get(name)?.exists)
|
||||
}
|
||||
|
||||
/** Randomly assign a nature */
|
||||
export function randomNature(): NatureName {
|
||||
const names = getAllNatureNames()
|
||||
return names[Math.floor(Math.random() * names.length)]!
|
||||
}
|
||||
|
||||
/** Map Dex stat abbreviation (atk, spa, spe, etc.) to our NatureStat format */
|
||||
function mapDexStat(stat: string | undefined): NatureStat | null {
|
||||
if (!stat) return null
|
||||
return (FROM_DEX_STAT[stat] as NatureStat) ?? null
|
||||
}
|
||||
|
||||
/** Get nature effect (plus/minus stat, or null for neutral) — delegates to Dex.natures */
|
||||
export function getNatureEffect(nature: NatureName): NatureEffect {
|
||||
const n = Dex.natures.get(nature)
|
||||
if (!n?.exists) return { plus: null, minus: null }
|
||||
return {
|
||||
plus: mapDexStat(n.plus),
|
||||
minus: mapDexStat(n.minus),
|
||||
}
|
||||
}
|
||||
39
packages/pokemon/src/dex/pkmn.ts
Normal file
39
packages/pokemon/src/dex/pkmn.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import { Generations } from '@pkmn/data'
|
||||
import type { StatName } from '../types'
|
||||
|
||||
// Singleton Gen 9 data source
|
||||
const gens = new Generations(Dex as unknown as import('@pkmn/data').Dex)
|
||||
export const gen = gens.get(9)
|
||||
|
||||
// Stat name mapping: @pkmn/sim → our StatName
|
||||
export const FROM_DEX_STAT: Record<string, StatName> = {
|
||||
hp: 'hp', atk: 'attack', def: 'defense',
|
||||
spa: 'spAtk', spd: 'spDef', spe: 'speed',
|
||||
}
|
||||
|
||||
// Stat name mapping: our StatName → @pkmn/sim abbreviation
|
||||
export const TO_DEX_STAT: Record<StatName, string> = {
|
||||
hp: 'hp', attack: 'atk', defense: 'def',
|
||||
spAtk: 'spa', spDef: 'spd', speed: 'spe',
|
||||
}
|
||||
|
||||
/** Query species from Dex (uses Dex directly for full coverage) */
|
||||
export function getSpecies(id: string) {
|
||||
return Dex.species.get(id)
|
||||
}
|
||||
|
||||
/** Map Dex baseStats to our StatName format */
|
||||
export function mapBaseStats(dexStats: { hp: number; atk: number; def: number; spa: number; spd: number; spe: number }): Record<StatName, number> {
|
||||
const result = {} as Record<StatName, number>
|
||||
for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) {
|
||||
result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get gender rate from Dex genderRatio (M/F ratio → our genderRate 0-8) */
|
||||
export function mapGenderRatio(genderRatio?: { M: number; F: number } | string): number {
|
||||
if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless
|
||||
return Math.round(genderRatio.F * 8)
|
||||
}
|
||||
1093
packages/pokemon/src/dex/pokedex-data.ts
Normal file
1093
packages/pokemon/src/dex/pokedex-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user