mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
25 Commits
feature/po
...
v1.10.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc1785fc0 | ||
|
|
c80e593212 | ||
|
|
b47731a3f3 | ||
|
|
a65df4a102 | ||
|
|
52b61c2c06 | ||
|
|
3cb4828de6 | ||
|
|
f5c3ee5b5d | ||
|
|
c2ac9a74c1 | ||
|
|
fc438bd222 | ||
|
|
4591432a1d | ||
|
|
901628b4d9 | ||
|
|
cf33c06021 | ||
|
|
e0ca1d054c | ||
|
|
6585d0f67c | ||
|
|
e4403ff010 | ||
|
|
9e61e7a90d | ||
|
|
d03af7bd4e | ||
|
|
e8ef955ff9 | ||
|
|
a8ed0cdce5 | ||
|
|
1c3b280c6a | ||
|
|
7a3cc24a00 | ||
|
|
2e7fc428cd | ||
|
|
ad09f38fd1 | ||
|
|
b0a3ef90dc | ||
|
|
c07ad4c738 |
@@ -1,139 +0,0 @@
|
||||
---
|
||||
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. 静默完成
|
||||
59
.github/workflows/auto-issue-fix.yml
vendored
59
.github/workflows/auto-issue-fix.yml
vendored
@@ -1,59 +0,0 @@
|
||||
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 }}"
|
||||
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -6,18 +6,29 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
env:
|
||||
GIT_CONFIG_COUNT: 2
|
||||
GIT_CONFIG_KEY_0: init.defaultBranch
|
||||
GIT_CONFIG_VALUE_0: main
|
||||
GIT_CONFIG_KEY_1: advice.defaultBranchName
|
||||
GIT_CONFIG_VALUE_1: "false"
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
@@ -26,12 +37,17 @@ jobs:
|
||||
- name: Test with Coverage
|
||||
run: |
|
||||
set -o pipefail
|
||||
bun test --coverage --coverage-reporter=lcov 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
test -s coverage/lcov.info
|
||||
grep -q '^SF:' coverage/lcov.info
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, 2026-04-25
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
fail_ci_if_error: true
|
||||
files: ./coverage/lcov.info
|
||||
disable_search: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
|
||||
8
.github/workflows/publish-npm.yml
vendored
8
.github/workflows/publish-npm.yml
vendored
@@ -20,17 +20,17 @@ jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
ref: ${{ github.event.inputs.version || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6, 2026-04-25
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2, 2026-04-25
|
||||
with:
|
||||
name: ${{ github.event.inputs.version || github.ref_name }}
|
||||
body: |
|
||||
|
||||
8
.github/workflows/release-rcs.yml
vendored
8
.github/workflows/release-rcs.yml
vendored
@@ -17,17 +17,17 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3, 2026-04-25
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3, 2026-04-25
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5, 2026-04-25
|
||||
with:
|
||||
context: .
|
||||
file: packages/remote-control-server/Dockerfile
|
||||
|
||||
6
.github/workflows/update-contributors.yml
vendored
6
.github/workflows/update-contributors.yml
vendored
@@ -11,17 +11,17 @@ jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: jaywcjlove/github-action-contributors@main
|
||||
- uses: jaywcjlove/github-action-contributors@86707f6d4c2469ce6b46bc3367253ebd41ee242c # main, 2026-04-25
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
output: "contributors.svg"
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
- uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5, 2026-04-25
|
||||
with:
|
||||
commit_message: "docs: update contributors"
|
||||
file_pattern: "contributors.svg"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ data
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
teach-me
|
||||
credentials.json
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -171,8 +171,8 @@ bun run docs:dev
|
||||
| `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) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取) |
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
@@ -254,13 +254,13 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
| 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 |
|
||||
| `*-napi` packages | 全部已恢复/实现:`audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`(macOS FFI);`url-handler-napi`(环境变量+CLI) |
|
||||
| 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 |
|
||||
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
|
||||
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
|
||||
| MCP OAuth | Simplified |
|
||||
|
||||
### Key Type Files
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -76,7 +76,9 @@ bun run docs:dev
|
||||
### 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 都可运行)。
|
||||
- **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 都可运行)。构建时会将 `vendor/audio-capture/` 和 `src/utils/vendor/ripgrep/` 复制到 `dist/vendor/` 下。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,chunk 输出到 `dist/chunks/`。post-build 同样复制 vendor 文件到 `dist/vendor/`。
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/ripgrep.ts` 和 `packages/audio-capture-napi/src/index.ts` 均通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 定位 dist 根目录,再拼接 `vendor/` 子路径,确保不同构建产物层级下路径一致。
|
||||
- **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:*`。
|
||||
@@ -171,8 +173,8 @@ bun run docs:dev
|
||||
| `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) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取) |
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
@@ -254,13 +256,13 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
| 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 |
|
||||
| `*-napi` packages | 全部已恢复/实现:`audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`(macOS FFI);`url-handler-napi`(环境变量+CLI) |
|
||||
| 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 |
|
||||
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
|
||||
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
|
||||
| MCP OAuth | Simplified |
|
||||
|
||||
### Key Type Files
|
||||
@@ -327,7 +329,7 @@ bun run typecheck
|
||||
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
|
||||
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。**开发任何 TUI 组件前,必须先查阅 `docs/ink-guide.md`**,该文档涵盖了双层组件设计(Base vs Themed)、布局系统、主题色、快捷键、所有 Hooks 和设计系统组件的用法。日常使用 `Box`/`Text`(Themed 版),用 `useKeybindings` 代替直接 `useInput`。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
## Design Context
|
||||
|
||||
12
README.md
12
README.md
@@ -19,15 +19,15 @@
|
||||
|
||||
| 特性 | 说明 | 文档 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **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) |
|
||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/uds-inbox) / [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) |
|
||||
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 (`/login`) | [文档](https://ccb.agent-aura.top/docs/features/all-features-guide) |
|
||||
| Voice Mode | 语音输入,支持豆包语言输入(`/voice doubao`) | [文档](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) |
|
||||
@@ -55,6 +55,8 @@ ccb update # 更新到最新版本
|
||||
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
|
||||
```
|
||||
|
||||
> **安装/更新失败?** 先 `npm rm -g claude-code-best` 清理旧版本,再 `npm i -g claude-code-best@latest`。仍失败则指定版本号:`npm i -g claude-code-best@<版本号>`
|
||||
|
||||
## ⚡ 快速开始(源码版)
|
||||
|
||||
### ⚙️ 环境要求
|
||||
@@ -233,6 +235,10 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 致谢
|
||||
|
||||
- [doubaoime-asr](https://github.com/starccy/doubaoime-asr) — 豆包 ASR 语音识别 SDK,为 Voice Mode 提供无需 Anthropic OAuth 的语音输入方案
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目仅供学习研究用途。Claude Code 的所有权利归 [Anthropic](https://www.anthropic.com/) 所有。
|
||||
|
||||
@@ -188,7 +188,7 @@ The TUI (REPL) mode requires a real terminal and cannot be launched directly via
|
||||
## Documentation & Links
|
||||
|
||||
- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome
|
||||
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
|
||||
- **DeepWiki**: https://deepwiki.com/claude-code-best/claude-code
|
||||
|
||||
## Contributors
|
||||
|
||||
|
||||
12
build.ts
12
build.ts
@@ -75,10 +75,14 @@ console.log(
|
||||
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for import.meta.require, ${bunPatched} for Bun destructure)`,
|
||||
)
|
||||
|
||||
// Step 4: Copy native .node addon files (audio-capture)
|
||||
const vendorDir = join(outdir, 'vendor', 'audio-capture')
|
||||
await cp('vendor/audio-capture', vendorDir, { recursive: true })
|
||||
console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`)
|
||||
// Step 4: Copy native .node addon files (audio-capture) and vendored binaries (ripgrep)
|
||||
const audioCaptureDir = join(outdir, 'vendor', 'audio-capture')
|
||||
await cp('vendor/audio-capture', audioCaptureDir, { recursive: true })
|
||||
console.log(`Copied vendor/audio-capture/ → ${audioCaptureDir}/`)
|
||||
|
||||
const ripgrepDir = join(outdir, 'vendor', 'ripgrep')
|
||||
await cp('src/utils/vendor/ripgrep', ripgrepDir, { recursive: true })
|
||||
console.log(`Copied src/utils/vendor/ripgrep/ → ${ripgrepDir}/`)
|
||||
|
||||
// Step 5: Generate cli-bun and cli-node executable entry points
|
||||
const cliBun = join(outdir, 'cli-bun.js')
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.7 MiB |
@@ -99,12 +99,15 @@ ARGUMENTS
|
||||
|
||||
## 四、认证
|
||||
|
||||
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递:
|
||||
默认启动时自动生成随机 token。客户端连接时不要把 token 放在 URL 中:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
ws://localhost:9315/ws
|
||||
```
|
||||
|
||||
无法发送 `Authorization` header 的 WebSocket 客户端需要使用
|
||||
`rcs.auth.<base64url-token>` 子协议传递 token。
|
||||
|
||||
配置固定 token:
|
||||
|
||||
```bash
|
||||
@@ -135,6 +138,9 @@ acp-link ccb-bun -- --acp
|
||||
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
|
||||
2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId),替代完整 `register`
|
||||
|
||||
RCS 的 ACP WebSocket 连接不接受 URL query token。acp-link 会通过
|
||||
`rcs.auth.<base64url-token>` WebSocket 子协议发送 `ACP_RCS_TOKEN`。
|
||||
|
||||
```
|
||||
acp-link RCS
|
||||
│ │
|
||||
|
||||
@@ -145,8 +145,8 @@ M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开
|
||||
|
||||
```
|
||||
/pipes — 显示所有实例 + 切换选择面板
|
||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||
/pipes deselect <name> — 取消选中
|
||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||
/pipes deselect <name> — 取消选中
|
||||
/pipes all — 全选
|
||||
/pipes none — 全部取消
|
||||
```
|
||||
@@ -169,7 +169,7 @@ LAN Peers:
|
||||
Selected: cli-da029538
|
||||
```
|
||||
|
||||
### /attach <name>
|
||||
### /attach <name>
|
||||
|
||||
手动 attach 到一个实例,使其成为你的 slave。
|
||||
|
||||
@@ -179,7 +179,7 @@ Selected: cli-da029538
|
||||
|
||||
attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
|
||||
|
||||
### /detach <name>
|
||||
### /detach <name>
|
||||
|
||||
断开与某个 slave 的连接。
|
||||
|
||||
@@ -187,7 +187,7 @@ attach 后,对方变为 slave,你变为 master。可以向它发送 prompt
|
||||
/detach cli-04d67950
|
||||
```
|
||||
|
||||
### /send <name> <message>
|
||||
### /send <name> <message>
|
||||
|
||||
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。
|
||||
|
||||
|
||||
@@ -225,6 +225,11 @@ acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器
|
||||
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
|
||||
| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 |
|
||||
|
||||
ACP 的 agents、channel groups、relay 和 channel-group SSE 端点都要求有效
|
||||
API key。浏览器 `EventSource` 不能发送 `Authorization` header,外部订阅
|
||||
`/acp/channel-groups/:id/events` 时需要使用 `fetch` + `ReadableStream` 并带
|
||||
`Authorization: Bearer <api-key>`。
|
||||
|
||||
### acp-link 连接
|
||||
|
||||
详见 [acp-link 文档](./acp-link.md)。
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
# VOICE_MODE — 语音输入
|
||||
|
||||
> Feature Flag: `FEATURE_VOICE_MODE=1`
|
||||
> 实现状态:完整可用(需要 Anthropic OAuth)
|
||||
> 实现状态:完整可用(双后端:Anthropic OAuth / 豆包 ASR)
|
||||
> 引用数:46
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空格键录音,音频通过 WebSocket 流式传输到 Anthropic STT 端点(Nova 3),实时转录显示在终端中。
|
||||
VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空格键录音,音频流式传输到 STT 后端,实时转录显示在终端中。支持两个后端:
|
||||
|
||||
- **Anthropic STT(默认)**:通过 WebSocket 流式传输到 Nova 3 端点,需要 Anthropic OAuth
|
||||
- **豆包 ASR(Doubao)**:通过 `doubaoime-asr` 包的 AsyncGenerator 协议流式识别,使用独立凭证文件,无需 Anthropic OAuth
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **Push-to-Talk**:长按空格键录音,释放后自动发送
|
||||
- **流式转录**:录音过程中实时显示中间转录结果
|
||||
- **无缝集成**:转录文本直接作为用户消息提交到对话
|
||||
- **双后端切换**:通过 `/voice` 命令参数选择 STT 后端,持久化到 settings.json
|
||||
|
||||
## 二、用户交互
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 长按空格 | 开始录音,显示录音状态 |
|
||||
| 释放空格 | 停止录音,等待最终转录 |
|
||||
| 转录完成 | 自动插入到输入框并提交 |
|
||||
| `/voice` 命令 | 切换语音模式开关 |
|
||||
| 释放空格 | 停止录音,转录结果自动提交 |
|
||||
| `/voice` | 切换语音模式开关(默认使用 Anthropic 后端) |
|
||||
| `/voice doubao` | 启用语音模式并使用豆包 ASR 后端 |
|
||||
| `/voice anthropic` | 切换回 Anthropic STT 后端 |
|
||||
|
||||
### UI 反馈
|
||||
|
||||
@@ -35,26 +40,37 @@ VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空
|
||||
|
||||
文件:`src/voice/voiceModeEnabled.ts`
|
||||
|
||||
三层检查:
|
||||
两层检查函数:
|
||||
|
||||
```ts
|
||||
// Anthropic 后端(需要 OAuth)
|
||||
isVoiceModeEnabled() = hasVoiceAuth() && isVoiceGrowthBookEnabled()
|
||||
|
||||
// 豆包后端 / 通用可用性检查(不需要 OAuth)
|
||||
isVoiceAvailable() = isVoiceGrowthBookEnabled()
|
||||
```
|
||||
|
||||
1. **Feature Flag**:`feature('VOICE_MODE')` — 编译时/运行时开关
|
||||
2. **GrowthBook Kill-Switch**:`!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)` — 紧急关闭开关(默认 false = 未禁用)
|
||||
3. **Auth 检查**:`hasVoiceAuth()` — 需要 Anthropic OAuth token(非 API key)
|
||||
3. **Auth 检查(仅 Anthropic)**:`hasVoiceAuth()` — 需要 Anthropic OAuth token(非 API key)
|
||||
4. **Provider 检查**:`voiceProvider` 设置决定使用哪个后端,豆包后端跳过 OAuth 检查
|
||||
|
||||
### 3.2 核心模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | Feature flag + GrowthBook + Auth 三层门控 |
|
||||
| `src/hooks/useVoice.ts` | React hook 管理录音状态和 WebSocket 连接 |
|
||||
| `src/services/voiceStreamSTT.ts` | WebSocket 流式传输到 Anthropic STT |
|
||||
| `src/hooks/useVoice.ts` | React hook 管理录音状态和后端连接 |
|
||||
| `src/services/voiceStreamSTT.ts` | Anthropic WebSocket 流式 STT |
|
||||
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器(AsyncGenerator → VoiceStreamConnection) |
|
||||
| `src/commands/voice/voice.ts` | `/voice` 命令实现,处理后端选择和持久化 |
|
||||
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook,根据 provider 决定是否跳过 OAuth |
|
||||
| `src/utils/settings/types.ts` | `voiceProvider: 'anthropic' | 'doubao'` 设置类型定义 |
|
||||
|
||||
### 3.3 数据流
|
||||
|
||||
#### Anthropic 后端
|
||||
|
||||
```
|
||||
用户按下空格键
|
||||
│
|
||||
@@ -79,20 +95,108 @@ WebSocket 连接到 Anthropic STT 端点
|
||||
转录文本 → 插入输入框 → 自动提交
|
||||
```
|
||||
|
||||
#### 豆包 ASR 后端
|
||||
|
||||
```
|
||||
用户按下空格键
|
||||
│
|
||||
▼
|
||||
useVoice hook 激活(检测到 voiceProvider === 'doubao')
|
||||
│
|
||||
▼
|
||||
macOS 原生音频 / SoX 开始录音
|
||||
│
|
||||
▼
|
||||
connectDoubaoStream() 创建 AudioChunkQueue + VoiceStreamConnection
|
||||
│
|
||||
├──→ onReady 立即触发(无需等待握手)
|
||||
│
|
||||
▼
|
||||
音频数据通过 AudioChunkQueue 传入 transcribeRealtime()
|
||||
│
|
||||
├──→ INTERIM_RESULT → 实时显示中间转录
|
||||
├──→ FINAL_RESULT → 显示最终转录
|
||||
│
|
||||
▼
|
||||
用户释放空格键
|
||||
│
|
||||
▼
|
||||
finalize() 立即返回(豆包在录音过程中已返回结果,无需等待)
|
||||
│
|
||||
▼
|
||||
转录文本 → 插入输入框 → 自动提交
|
||||
```
|
||||
|
||||
### 3.4 音频录制
|
||||
|
||||
支持两种音频后端:
|
||||
支持两种音频后端(两个 STT 后端共享):
|
||||
- **macOS 原生音频**:优先使用,低延迟
|
||||
- **SoX(Sound eXchange)**:回退方案,跨平台
|
||||
|
||||
音频流通过 WebSocket 发送到 Anthropic 的 Nova 3 STT 模型。
|
||||
### 3.5 豆包 ASR 适配器设计
|
||||
|
||||
文件:`src/services/doubaoSTT.ts`
|
||||
|
||||
豆包后端使用适配器模式,将 `doubaoime-asr` 的 AsyncGenerator 协议桥接到 `VoiceStreamConnection` 接口:
|
||||
|
||||
**AudioChunkQueue** — push 式异步队列:
|
||||
- 实现 `AsyncIterable<Uint8Array>` 接口
|
||||
- `push(chunk)` 将音频数据入队,`push(null)` 发送结束信号
|
||||
- 内部维护等待者(waiting)和缓冲队列(chunks)两个状态
|
||||
|
||||
**connectDoubaoStream()** — 连接入口:
|
||||
- 动态导入 `doubaoime-asr`(optionalDependencies)
|
||||
- 从 `~/.claude/tts/doubao/credentials.json` 加载凭证
|
||||
- 创建 AudioChunkQueue 和 VoiceStreamConnection
|
||||
- 立即触发 `onReady`(避免与 useVoice 的音频缓冲死锁)
|
||||
- `finalize()` 立即返回(豆包在录音过程中已返回结果)
|
||||
- 后台 async IIFE 消费 `transcribeRealtime` generator,映射响应类型到回调
|
||||
|
||||
**响应类型映射**:
|
||||
|
||||
| doubaoime-asr ResponseType | 回调映射 |
|
||||
|----------------------------|----------|
|
||||
| SESSION_STARTED | 日志记录 |
|
||||
| VAD_START | 日志记录 |
|
||||
| INTERIM_RESULT | `onTranscript(text, false)` |
|
||||
| FINAL_RESULT | `onTranscript(text, true)` |
|
||||
| ERROR | `onError(errorMsg)` |
|
||||
| SESSION_FINISHED | 日志记录 |
|
||||
|
||||
### 3.6 后端选择逻辑
|
||||
|
||||
文件:`src/hooks/useVoice.ts`
|
||||
|
||||
```ts
|
||||
// 判断当前 provider
|
||||
isDoubaoProvider() → 读取 settings.voiceProvider
|
||||
|
||||
// handleKeyEvent 中的可用性检查
|
||||
const sttAvailable = isDoubaoProvider()
|
||||
? isDoubaoAvailableSync() // 乐观检查(首次返回 true)
|
||||
: isVoiceStreamAvailable() // Anthropic WebSocket 检查
|
||||
|
||||
// attemptConnect 中的连接函数选择
|
||||
const connectFn = isDoubaoProvider()
|
||||
? connectDoubaoStream
|
||||
: connectVoiceStream
|
||||
```
|
||||
|
||||
豆包后端的特殊处理:
|
||||
- 跳过 `getVoiceKeyterms()` 调用(豆包无需关键词提示)
|
||||
- 跳过 Focus Mode(`if (!enabled || !focusMode || isDoubaoProvider())`)
|
||||
|
||||
## 四、关键设计决策
|
||||
|
||||
1. **OAuth 独占**:语音模式使用 `voice_stream` 端点(claude.ai),仅 Anthropic OAuth 用户可用。API key、Bedrock、Vertex 用户无法使用
|
||||
2. **GrowthBook 负向门控**:`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用(无需等 GrowthBook 初始化)
|
||||
3. **Keychain 缓存**:`getClaudeAIOAuthTokens()` 首次调用访问 macOS keychain(~20-50ms),后续缓存命中
|
||||
4. **独立于主 feature flag**:`isVoiceGrowthBookEnabled()` 在 feature flag 关闭时短路返回 `false`,不触发任何模块加载
|
||||
1. **双后端共存**:豆包后端作为独立适配器与 Anthropic 后端并存,不替换原有流程,通过 `voiceProvider` 设置切换
|
||||
2. **设置持久化**:`voiceProvider` 存储在 `settings.json`,通过 `/voice` 命令修改,跨会话生效
|
||||
3. **OAuth 独占(Anthropic)**:Anthropic 后端使用 `voice_stream` 端点(claude.ai),仅 OAuth 用户可用
|
||||
4. **豆包无需 OAuth**:豆包后端使用独立凭证文件,不依赖 Anthropic 认证,通过 `isVoiceAvailable()` 放宽门控
|
||||
5. **GrowthBook 负向门控**:`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用
|
||||
6. **onReady 立即触发**:豆包后端在连接建立后立即触发 `onReady`,避免与 useVoice 音频缓冲的时序死锁(Anthropic 需要等待 WebSocket 握手)
|
||||
7. **finalize() 立即返回**:豆包在录音过程中已返回所有结果,用户抬手时无需等待处理
|
||||
8. **乐观可用性检查**:`isDoubaoAvailableSync()` 在首次调用时返回 `true`,实际导入错误在 `connectDoubaoStream` 中处理
|
||||
9. **optionalDependencies**:`doubaoime-asr` 作为可选依赖,安装失败不影响 Anthropic 后端
|
||||
|
||||
## 五、使用方式
|
||||
|
||||
@@ -100,26 +204,60 @@ WebSocket 连接到 Anthropic STT 端点
|
||||
# 启用 feature
|
||||
FEATURE_VOICE_MODE=1 bun run dev
|
||||
|
||||
# 在 REPL 中使用
|
||||
# 在 REPL 中使用 Anthropic 后端
|
||||
# 1. 确保已通过 OAuth 登录(claude.ai 订阅)
|
||||
# 2. 按住空格键说话
|
||||
# 3. 释放空格键等待转录
|
||||
# 4. 或使用 /voice 命令切换开关
|
||||
# 2. 输入 /voice 启用
|
||||
# 3. 按住空格键说话
|
||||
# 4. 释放空格键等待转录
|
||||
|
||||
# 在 REPL 中使用豆包 ASR 后端
|
||||
# 1. 确保 doubaoime-asr 已安装(bun add doubaoime-asr)
|
||||
# 2. 配置凭证文件:~/.claude/tts/doubao/credentials.json
|
||||
# 3. 输入 /voice doubao 启用
|
||||
# 4. 按住空格键说话
|
||||
# 5. 释放空格键,转录结果即刻显示
|
||||
|
||||
# 切换后端
|
||||
/voice doubao # 切换到豆包 ASR
|
||||
/voice anthropic # 切换回 Anthropic STT
|
||||
/voice # 关闭语音模式
|
||||
```
|
||||
|
||||
### 豆包凭证配置
|
||||
|
||||
凭证文件路径:`~/.claude/tts/doubao/credentials.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceId": "...",
|
||||
"installId": "...",
|
||||
"cdid": "...",
|
||||
"openudid": "...",
|
||||
"clientudid": "...",
|
||||
"token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## 六、外部依赖
|
||||
|
||||
| 依赖 | 说明 |
|
||||
|------|------|
|
||||
| Anthropic OAuth | claude.ai 订阅登录,非 API key |
|
||||
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 |
|
||||
| macOS 原生音频 或 SoX | 音频录制 |
|
||||
| Nova 3 STT | 语音转文本模型 |
|
||||
| 依赖 | 说明 | 适用后端 |
|
||||
|------|------|----------|
|
||||
| Anthropic OAuth | claude.ai 订阅登录,非 API key | Anthropic |
|
||||
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 | 通用 |
|
||||
| macOS 原生音频 或 SoX | 音频录制 | 通用 |
|
||||
| Nova 3 STT | Anthropic 语音转文本模型 | Anthropic |
|
||||
| doubaoime-asr | 豆包 ASR SDK(optionalDependencies) | 豆包 |
|
||||
| 凭证文件 | `~/.claude/tts/doubao/credentials.json` | 豆包 |
|
||||
|
||||
## 七、文件索引
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | 54 | 三层门控逻辑 |
|
||||
| `src/hooks/useVoice.ts` | — | React hook(录音状态 + WebSocket) |
|
||||
| `src/services/voiceStreamSTT.ts` | — | STT WebSocket 流式传输 |
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | 三层门控逻辑 + `isVoiceAvailable()` |
|
||||
| `src/hooks/useVoice.ts` | React hook(录音状态 + 后端选择 + 连接管理) |
|
||||
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook(按 provider 决定 OAuth 检查) |
|
||||
| `src/services/voiceStreamSTT.ts` | Anthropic STT WebSocket 流式传输 |
|
||||
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器(AudioChunkQueue + connectDoubaoStream) |
|
||||
| `src/commands/voice/voice.ts` | `/voice` 命令(开关 + 后端选择) |
|
||||
| `src/commands/voice/index.ts` | 命令注册(去除 availability 限制) |
|
||||
| `src/utils/settings/types.ts` | `voiceProvider` 类型定义 |
|
||||
|
||||
1218
docs/ink-guide.md
1218
docs/ink-guide.md
File diff suppressed because it is too large
Load Diff
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal file
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# Agent 通讯修复 Jira Task
|
||||
|
||||
- 版本:v1.0
|
||||
- 生成日期:2026-04-25
|
||||
- 来源:由按文件执行清单、Claude 交叉验证意见整理合并
|
||||
- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
|
||||
- 使用方式:这是唯一执行任务文档;每个 `JIRA-*` 小节可直接拆成一个 Jira issue,字段保持统一,便于复制或二次导入。
|
||||
|
||||
---
|
||||
|
||||
## 方案性质
|
||||
|
||||
本文档是目标状态式执行方案,不是临时补丁清单。每张 ticket 必须交付明确的代码终态、测试覆盖和回归边界;不得只用局部 workaround 掩盖问题。
|
||||
|
||||
---
|
||||
|
||||
## 执行总则
|
||||
|
||||
1. 先边界安全,后内部优化:先修 WS 入站大小与输入校验,避免线上风险扩大。
|
||||
2. 单文件可回滚:每个文件内修改保持内聚,便于回滚与 bisect。
|
||||
3. 不改协议语义,只修实现缺陷:除 `resource_link` 表达形式统一外,不改变主流程契约。
|
||||
4. 每个文件必须有验收输出:要么测试用例,要么日志/指标验证。
|
||||
5. 发布前必须确认协议层行为无回归:`stopReason` 决策与 `sessionUpdate` 发送顺序保持稳定。
|
||||
|
||||
---
|
||||
|
||||
## Epic
|
||||
|
||||
### JIRA-EPIC-001:提升 Agent 通讯链路稳定性与边界安全
|
||||
|
||||
- Issue Type:Epic
|
||||
- Priority:P0
|
||||
- Owner:核心通讯 / 后端网关 / QA
|
||||
- Scope:ACP Agent、ACP Bridge、Remote Control Server、REPL 初始化生命周期
|
||||
- Goal:修复长会话资源泄漏、补齐 WebSocket 入站边界、统一 prompt 转换、收敛类型风险,并补充关键回归测试。
|
||||
|
||||
#### Epic 验收标准
|
||||
|
||||
- `bun run typecheck` 0 error。
|
||||
- P0 WebSocket 超大消息拒绝逻辑已实现并覆盖测试。
|
||||
- ACP bridge abort listener 生命周期无累积。
|
||||
- prompt 转换实现单源化。
|
||||
- settings/defaultMode 能真实影响 ACP permission mode,且 `_meta.permissionMode` 保持最高优先级。
|
||||
- REPL 目标 hook suppress 清理完成,timer cleanup 完整。
|
||||
|
||||
---
|
||||
|
||||
## P0 Tickets
|
||||
|
||||
### JIRA-001:为 session ingress WebSocket 补齐消息大小限制
|
||||
|
||||
- Issue Type:Bug
|
||||
- Priority:P0
|
||||
- Story Points:3
|
||||
- Owner:后端/网关
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||
- 后续票:JIRA-008(同文件 P1 类型与 decode path 收尾)
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
|
||||
|
||||
#### 背景
|
||||
|
||||
`session-ingress` 当前缺少 WebSocket message size limit。ACP 路由已有类似限制,两个入口边界不一致,可能导致大包占用内存或绕过入口保护。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 新增 `MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024`,与 ACP 路由的 10MB 上限保持一致。
|
||||
- 在 `onMessage` decode 后优先检查 payload size。
|
||||
- 超限时执行 `ws.close(1009, "message too large")`。
|
||||
- 日志记录 `sessionId`、payload size、limit。
|
||||
- 对 `string`、`ArrayBuffer`、`Uint8Array` 进行统一 decode 分流。
|
||||
- 非支持类型直接拒绝并记录,不进入业务 handler。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 11MB payload 被 1009 close。
|
||||
- 1KB 合法 payload 仍正常进入 handler。
|
||||
- 非支持类型 payload 不进入 handler。
|
||||
- 不改变 URL、auth、session 解析逻辑。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- Remote Control Server session ingress WebSocket。
|
||||
- 正常会话消息转发。
|
||||
- WebSocket close code 行为。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。入口逻辑变更可能影响特殊客户端 payload 类型。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 在 `packages/remote-control-server/src/__tests__/routes.test.ts` 增加 session-ingress WebSocket 大包、小包、坏类型 payload 用例。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-002:修复 ACP bridge abort listener 生命周期泄漏
|
||||
|
||||
- Issue Type:Bug
|
||||
- Priority:P0
|
||||
- Story Points:3
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/bridge.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/bridge.ts:576-585`
|
||||
|
||||
#### 背景
|
||||
|
||||
ACP bridge 的 `Promise.race` abort 分支注册 listener 后缺少完整 cleanup。长会话或高频 next 场景可能出现 listener 累积。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 将 abort race 改为可清理监听器写法。
|
||||
- 注册 listener 后保留 handler 引用。
|
||||
- `sdkMessages.next()` 先返回时必须 `removeEventListener`。
|
||||
- abort、throw、return 等路径都在 `finally` 中清理。
|
||||
- 不改变 `stopReason` 决策逻辑。
|
||||
- 不改变 `sessionUpdate` 发送顺序。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 模拟 10k 次 next 且不 abort,listener 不增长。
|
||||
- abort 场景仍返回 `cancelled`。
|
||||
- 原有 streaming/session update 行为无回归。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP bridge streaming loop。
|
||||
- 用户取消请求。
|
||||
- SDK generator 异常路径。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。异步控制流变更需要覆盖取消与异常路径。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 listener cleanup 单元测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
## P1 Tickets
|
||||
|
||||
### JIRA-003:优化 ACP agent pending prompt 队列为 O(1) 出队
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/agent.ts:332-339`
|
||||
|
||||
#### 背景
|
||||
|
||||
当前 pending prompt 队列使用 `Map + sort` 获取下一项,排队量上升时会带来不必要的排序成本。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 改为 `queue: string[]` + `pendingMap: Map<string, PendingPrompt>` 组合。
|
||||
- 入队执行 `queue.push(id)` 与 `pendingMap.set(id, prompt)`。
|
||||
- 出队从队首惰性跳过已取消项。
|
||||
- 取消只从 `pendingMap` 删除,不做数组中间删除。
|
||||
- 保持现有取消语义和出队顺序。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 1000 pending prompt 场景下出队顺序正确。
|
||||
- 已取消 prompt 不会被 resolve。
|
||||
- 出队不再依赖全量 sort。
|
||||
- 1000 排队场景下出队耗时低于旧实现;测试记录旧实现复杂度风险和新实现 O(1) 出队路径。
|
||||
- 行为与旧实现兼容。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP prompt queue。
|
||||
- 并发 prompt 请求。
|
||||
- prompt cancel / resolve 边界。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。队列结构变更可能引入取消边界问题。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 queue 顺序与取消测试。
|
||||
- 对 1000 prompt 场景做性能断言或日志记录。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-004:接入真实 settings 读取并校验 ACP permission mode
|
||||
|
||||
- Issue Type:Bug
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/agent.ts:465-467`
|
||||
|
||||
#### 背景
|
||||
|
||||
`getSetting()` 当前未真正接入项目配置,导致默认 permission mode 配置无法按预期生效。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 接入项目现有 settings/config 读取逻辑。
|
||||
- 仅接受合法 permission mode 枚举值。
|
||||
- 非法值 fallback 到 `default`。
|
||||
- `_meta.permissionMode` 继续保持最高优先级。
|
||||
- 不改变外部协议字段。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- settings/defaultMode 能影响默认 permission mode。
|
||||
- `_meta.permissionMode` 能覆盖 settings。
|
||||
- 非法 settings 值不会传播到运行时。
|
||||
- 类型检查通过。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP agent session 初始化。
|
||||
- 权限模式同步。
|
||||
- 客户端 `_meta` 覆盖逻辑。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。配置优先级错误会影响权限行为。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 defaultMode / `_meta.permissionMode` 优先级测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-005:单源化 ACP prompt 转换逻辑
|
||||
|
||||
- Issue Type:Refactor
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
- `src/services/acp/bridge.ts`
|
||||
- `src/services/acp/promptConversion.ts`(新增)
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/agent.ts:754-758`
|
||||
- `src/services/acp/agent.ts:764-785`
|
||||
- `src/services/acp/bridge.ts:522-537`
|
||||
|
||||
#### 背景
|
||||
|
||||
ACP agent 与 bridge 存在重复 prompt 转换逻辑,`resource_link` 等 block 的输出策略容易分叉。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 新增共享转换模块 `src/services/acp/promptConversion.ts`。
|
||||
- `agent.ts` 与 `bridge.ts` 改为调用共享转换函数。
|
||||
- 删除 `bridge.ts` 中 `promptToQueryContent` 的真实实现;如导出仍需保留,则只允许保留调用共享函数的 wrapper。
|
||||
- `resource_link` 输出改为稳定纯文本元信息,禁止 markdown link。
|
||||
- 保持其他 block 转换语义不变。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 全仓库仅保留一个真实 prompt 转换实现。
|
||||
- 相同 input block 在 agent/bridge 输出一致。
|
||||
- `resource_link` 不再输出 `[name](uri)` 形式。
|
||||
- 相关测试覆盖转换一致性。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP prompt input。
|
||||
- bridge query content。
|
||||
- resource link prompt 表达。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。文本格式变化可能影响下游 prompt 快照或断言。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 shared conversion 单元测试。
|
||||
- 全仓库搜索重复转换函数。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-006:治理 REPL onInit effect 依赖并补齐 timer cleanup
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:终端 UI
|
||||
- Files:
|
||||
- `src/screens/REPL.tsx`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/screens/REPL.tsx:654-662`
|
||||
- `src/screens/REPL.tsx:4996-5005`
|
||||
|
||||
#### 背景
|
||||
|
||||
REPL 中目标初始化 effect 存在 hook dependency suppress,warm-up timer 也需要显式 cleanup,避免频繁挂载/卸载时留下悬挂任务。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 整理 `onInit` 生命周期,使用稳定引用或 effect 内联。
|
||||
- 移除目标段 `exhaustive-deps` suppress。
|
||||
- 保持 unmount cleanup 行为不变。
|
||||
- warm-up effect 中记录 timeout id。
|
||||
- cleanup 中执行 `clearTimeout(timeoutId)`。
|
||||
- 保留 `alive` 判定作为并发保护。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 目标段不再需要 hooks lint suppress。
|
||||
- 高频打开/关闭搜索栏无悬挂 timer 增长。
|
||||
- REPL 初始化行为无回归。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- REPL 初始化。
|
||||
- 搜索栏 warm-up。
|
||||
- 组件卸载 cleanup。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。React effect 依赖治理可能改变初始化时机。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行 lint/typecheck。
|
||||
- 手动或测试覆盖 REPL mount/unmount。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-007:收敛 ACP route WebSocket 事件 any 类型
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:2
|
||||
- Owner:后端/网关
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/routes/acp/index.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `packages/remote-control-server/src/routes/acp/index.ts:108-146`
|
||||
|
||||
#### 背景
|
||||
|
||||
ACP route 中 WebSocket 事件和 socket 参数存在 `any`,降低编译期保护。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 定义最小 WebSocket 事件类型:open/message/close/error。
|
||||
- 将 `_evt: any`、`evt: any`、`ws: any` 替换为窄类型。
|
||||
- 不改变 payload decode 与大小检查策略。
|
||||
- 不改变现有 handler 行为。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 编译期能捕获错误事件字段访问。
|
||||
- 现有 WebSocket 行为不变。
|
||||
- `bun run typecheck` 通过。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP WebSocket route。
|
||||
- message decode。
|
||||
- close/error handler。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 低。类型收敛为主。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行 `bun run typecheck`。
|
||||
- 保留现有测试通过。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-008:收敛 session ingress WebSocket 事件类型与 decode path
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:后端/网关
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||
- 前置依赖:JIRA-001 已合并
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
|
||||
|
||||
#### 背景
|
||||
|
||||
在完成 P0 size guard 后,session ingress 仍需要进一步收敛事件类型与 decode path,减少隐式类型风险。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 定义或复用最小 WebSocket message event 类型。
|
||||
- 将 message decode 分支集中到一个小函数。
|
||||
- 保持 P0 size guard 与 close code 语义。
|
||||
- 不改变 auth/session 解析。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- decode path 单一清晰。
|
||||
- 不支持 payload 类型有明确拒绝路径。
|
||||
- `bun run typecheck` 通过。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- Session ingress WebSocket message handling。
|
||||
- P0 大包拒绝逻辑。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 低到中。与 P0 同文件,注意避免重复改动冲突。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 与 JIRA-001 同批测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
## QA Tickets
|
||||
|
||||
### JIRA-009:补充 ACP 通讯回归测试
|
||||
|
||||
- Issue Type:Test
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:QA/核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
- `src/services/acp/bridge.ts`
|
||||
- `src/services/acp/promptConversion.ts`
|
||||
- `src/services/acp/__tests__/agent.test.ts`
|
||||
- `src/services/acp/__tests__/bridge.test.ts`
|
||||
- `src/services/acp/__tests__/promptConversion.test.ts`
|
||||
|
||||
#### 覆盖场景
|
||||
|
||||
- 长会话 10k turn,无 abort listener 累积。
|
||||
- prompt queue 1000 并发排队,取消/出队顺序正确。
|
||||
- settings/defaultMode 与 `_meta.permissionMode` 优先级正确。
|
||||
- `resource_link` 转换在 agent 与 bridge 输出一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 新增测试在本地稳定通过。
|
||||
- 不依赖真实网络或外部服务。
|
||||
- 测试 mock 遵守仓库规范,只 mock 有副作用链路。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP bridge。
|
||||
- ACP agent。
|
||||
- prompt conversion。
|
||||
- permission mode resolution。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。异步测试可能有稳定性问题,需要避免时间敏感断言。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行相关 `bun test`。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-010:补充 Remote Control Server WebSocket 入站回归测试
|
||||
|
||||
- Issue Type:Test
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:QA/后端
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/__tests__/routes.test.ts`
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||
|
||||
#### 覆盖场景
|
||||
|
||||
- 11MB session ingress payload 被 1009 close(与 10MB 上限对齐)。
|
||||
- 合法小 payload 正常进入 handler。
|
||||
- 非支持 payload 类型被拒绝。
|
||||
- 日志或可观测输出包含 sessionId、payload size、limit。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 11MB payload 被 1009 close(与 10MB 上限对齐)。
|
||||
- 新增测试稳定通过。
|
||||
- 不启动真实外部服务。
|
||||
- 不改变现有 route public contract。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- RCS session ingress route。
|
||||
- WebSocket message handling。
|
||||
- close code 行为。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。测试需要适配现有 WebSocket/mock 基础设施。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行 RCS package 相关测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
## 推荐执行顺序
|
||||
|
||||
执行节奏与原计划保持一致:先完成 P0 全部改动和冒烟验证,再启动 P1 改造;测试票可穿插执行,但不得绕过 P0 gate。
|
||||
|
||||
1. JIRA-001:先封入口大包风险。
|
||||
2. JIRA-002:修长会话 listener 生命周期。
|
||||
3. JIRA-010:补 RCS 入站测试,锁住 P0 行为。
|
||||
4. JIRA-003:优化 pending prompt queue。
|
||||
5. JIRA-004:接入 settings/defaultMode。
|
||||
6. JIRA-005:单源化 prompt 转换。
|
||||
7. JIRA-009:补 ACP 回归测试。
|
||||
8. JIRA-006:治理 REPL effect/timer。
|
||||
9. JIRA-007:收敛 ACP route 类型。
|
||||
10. JIRA-008:收敛 session ingress 类型与 decode path。
|
||||
|
||||
---
|
||||
|
||||
## Release Checklist
|
||||
|
||||
- [ ] `bun run typecheck` 0 error
|
||||
- [ ] P0 tickets 已合并并测试通过
|
||||
- [ ] ACP 回归测试通过
|
||||
- [ ] RCS WebSocket 入站测试通过
|
||||
- [ ] prompt conversion 单源化已通过代码搜索确认
|
||||
- [ ] permission mode 优先级测试通过
|
||||
- [ ] 协议层行为无回归(stopReason 决策、sessionUpdate 发送顺序)
|
||||
- [ ] REPL hook/timer 改动通过 lint/typecheck
|
||||
- [ ] 最终变更说明包含风险与未覆盖项
|
||||
74
docs/internals/agent-comm-fix-questions.md
Normal file
74
docs/internals/agent-comm-fix-questions.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Agent 通讯修复问题文档
|
||||
|
||||
- 版本:v1.0
|
||||
- 生成日期:2026-04-25
|
||||
- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
|
||||
- 配套执行文档:`docs/internals/agent-comm-fix-jira-tasks.md`
|
||||
- 目的:保留决策前要问的问题、交叉验证提示词和已确认结论;不要在这里写 Jira 执行步骤。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前已确认结论
|
||||
|
||||
- 只保留两份交付文档:本问题文档 + Jira Task 文档。
|
||||
- Jira Task 文档是唯一执行入口,包含 Owner、优先级、文件范围、验收标准、风险和验证建议。
|
||||
- Claude 交叉验证结论:整体通过,无 blocking findings;建议补充协议回归 gate、JIRA-001/008 依赖、代码参考位置和阈值一致性,这些建议已合并到 Jira Task 文档。
|
||||
- 本次已进入业务代码修复阶段,必须运行 `bun run typecheck` 和相关回归测试。
|
||||
|
||||
---
|
||||
|
||||
## 2. 执行前必须问清的问题
|
||||
|
||||
1. `session-ingress` 的 WebSocket 上限是否固定为 10MB,并与 ACP route 保持一致?
|
||||
2. 超限 close code 是否统一使用 `1009`,close reason 是否固定为 `message too large`?
|
||||
3. `resource_link` 的纯文本格式是否已有下游依赖,能否替代当前 markdown link 表达?
|
||||
4. ACP permission mode 的真实 settings key 是哪个,非法值 fallback 是否统一为 `default`?
|
||||
5. `_meta.permissionMode` 是否必须始终覆盖 settings/defaultMode?
|
||||
6. abort listener 测试中,是否能通过 mock signal 或计数器稳定证明 10k next 后无 listener 累积?
|
||||
7. pending prompt queue 的取消语义是否允许惰性清理,而不是立刻从数组中删除?
|
||||
8. REPL hook suppress 的清理范围是否只限目标段,不顺手改其他 decompiled React Compiler 结构?
|
||||
9. RCS WebSocket 测试应放在现有哪个 `__tests__` 布局下,是否已有 route/mock 基础设施可复用?
|
||||
10. 发布 gate 是否必须包含 `stopReason` 决策与 `sessionUpdate` 发送顺序不回归?
|
||||
|
||||
---
|
||||
|
||||
## 3. 给 Claude 或 Reviewer 的复核问题
|
||||
|
||||
```text
|
||||
请作为外部审查者,复核 docs/internals/agent-comm-fix-jira-tasks.md。
|
||||
|
||||
请检查:
|
||||
1. 是否仍满足“按文件分工的执行清单”和“Jira task 文档”要求。
|
||||
2. 是否存在遗漏的文件、验收标准、风险或前置依赖。
|
||||
3. 是否有重复、误导执行者、优先级不合理或测试不可落地的问题。
|
||||
4. 是否还有必须阻断实施的 finding。
|
||||
|
||||
请用中文输出:
|
||||
- Verdict
|
||||
- Blocking Findings
|
||||
- Non-blocking Findings
|
||||
- Suggested Edits
|
||||
- Final Recommendation
|
||||
|
||||
不要修改文件,只输出审查意见。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 已处理的复核建议
|
||||
|
||||
- Release Checklist 已补充协议层行为无回归 gate。
|
||||
- JIRA-001 与 JIRA-008 已明确同文件前后置关系。
|
||||
- JIRA-001 到 JIRA-008 已补充参考代码位置。
|
||||
- JIRA-003 已补回 1000 排队场景下的出队耗时验收。
|
||||
- JIRA-008 story points 已从 2 调整为 3。
|
||||
- JIRA-010 已明确 11MB payload 对齐 10MB 上限并触发 1009 close。
|
||||
- 推荐执行顺序已明确 P0 gate:P0 全部改动和冒烟验证完成后,再启动 P1 改造。
|
||||
|
||||
---
|
||||
|
||||
## 5. 不在本文档维护的内容
|
||||
|
||||
- 不维护 Jira ticket 正文;统一在 `docs/internals/agent-comm-fix-jira-tasks.md` 修改。
|
||||
- 不维护业务代码实现方案;实现时按具体 ticket 读取对应文件。
|
||||
- 不维护历史中间稿;旧执行清单已合并进 Jira Task 文档。
|
||||
@@ -200,9 +200,9 @@ LSP 服务器通过插件提供。插件的 `manifest.json` 中可以声明 LSP
|
||||
|------|------|------|------|
|
||||
| `command` | string | 是 | LSP 服务器可执行命令(不含空格) |
|
||||
| `args` | string[] | 否 | 命令行参数 |
|
||||
| `extensionToLanguage` | Record<string, string> | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||
| `extensionToLanguage` | `Record<string, string>` | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||
| `transport` | `"stdio"` \| `"socket"` | 否 | 通信方式,默认 `stdio` |
|
||||
| `env` | Record<string, string> | 否 | 启动服务器时设置的环境变量 |
|
||||
| `env` | `Record<string, string>` | 否 | 启动服务器时设置的环境变量 |
|
||||
| `initializationOptions` | unknown | 否 | 传给服务器的初始化选项 |
|
||||
| `settings` | unknown | 否 | 通过 `workspace/didChangeConfiguration` 传递的设置 |
|
||||
| `workspaceFolder` | string | 否 | 工作区目录路径 |
|
||||
|
||||
@@ -175,7 +175,7 @@ F. getCompletedResults() → 空
|
||||
|
||||
---
|
||||
|
||||
#### #8 stream_event (input_json_delta: '{"file_path":')
|
||||
#### #8 stream_event (input_json_delta: `'{"file_path":'`)
|
||||
|
||||
```
|
||||
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
||||
|
||||
55
package.json
55
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.9.4",
|
||||
"version": "1.10.5",
|
||||
"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>",
|
||||
@@ -78,19 +78,19 @@
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
"@ant/computer-use-mcp": "workspace:*",
|
||||
"@ant/computer-use-swift": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.29.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||
"@anthropic-ai/mcpb": "^2.1.2",
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"@aws-sdk/client-bedrock": "^3.1032.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
|
||||
"@aws-sdk/client-sts": "^3.1032.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.32",
|
||||
"@aws-sdk/credential-providers": "^3.1032.0",
|
||||
"@aws-sdk/client-bedrock": "^3.1037.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1037.0",
|
||||
"@aws-sdk/client-sts": "^3.1037.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.36",
|
||||
"@aws-sdk/credential-providers": "^3.1037.0",
|
||||
"@azure/identity": "^4.13.1",
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@claude-code-best/agent-tools": "workspace:*",
|
||||
@@ -103,20 +103,20 @@
|
||||
"@langfuse/tracing": "^5.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.214.0",
|
||||
"@opentelemetry/api-logs": "^0.215.0",
|
||||
"@opentelemetry/core": "^2.7.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/resources": "^2.7.0",
|
||||
"@opentelemetry/sdk-logs": "^0.214.0",
|
||||
"@opentelemetry/sdk-logs": "^0.215.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
@@ -144,7 +144,7 @@
|
||||
"asciichart": "^1.5.25",
|
||||
"audio-capture-napi": "workspace:*",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.15.0",
|
||||
"axios": "^1.15.2",
|
||||
"bidi-js": "^1.0.3",
|
||||
"cacache": "^20.0.4",
|
||||
"chalk": "^5.6.2",
|
||||
@@ -205,5 +205,16 @@
|
||||
"xss": "^1.0.15",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"doubaoime-asr": "^0.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@inquirer/prompts": "8.4.2",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"follow-redirects": "1.16.0",
|
||||
"hono": "4.12.15",
|
||||
"postcss": "8.5.10",
|
||||
"uuid": "14.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"./client": "./src/client/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"openai": "^6.33.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +80,17 @@ ARGUMENTS
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, a random token is auto-generated on startup. Pass it as a query parameter:
|
||||
By default, a random token is auto-generated on startup. Connect to the
|
||||
WebSocket endpoint without putting the token in the URL:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
ws://localhost:9315/ws
|
||||
```
|
||||
|
||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to
|
||||
disable (not recommended). Clients that cannot send an `Authorization` header
|
||||
must send the token in a WebSocket subprotocol named
|
||||
`rcs.auth.<base64url-token>`.
|
||||
|
||||
## RCS Upstream
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@hono/node-ws": "^1.0.5",
|
||||
"@stricli/auto-complete": "^1.2.4",
|
||||
"@stricli/core": "^1.2.4",
|
||||
"hono": "^4.7.0",
|
||||
"hono": "^4.12.15",
|
||||
"pino": "^10.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"selfsigned": "^5.5.0"
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import type { ServerConfig } from "../server.js";
|
||||
import { describe, test, expect, mock } from "bun:test";
|
||||
import {
|
||||
__testing,
|
||||
decodeClientWsMessage,
|
||||
MAX_CLIENT_WS_PAYLOAD_BYTES,
|
||||
resolveNewSessionPermissionMode,
|
||||
type ServerConfig,
|
||||
} from "../server.js";
|
||||
import {
|
||||
authTokensEqual,
|
||||
decodeWebSocketAuthProtocol,
|
||||
encodeWebSocketAuthProtocol,
|
||||
extractWebSocketAuthToken,
|
||||
} from "../ws-auth.js";
|
||||
import { buildRcsWsUrl } from "../rcs-upstream.js";
|
||||
|
||||
function makeTestWs(sent: unknown[]) {
|
||||
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0];
|
||||
|
||||
return {
|
||||
readyState: 1,
|
||||
send: mock((message: string) => {
|
||||
sent.push(JSON.parse(message));
|
||||
}),
|
||||
close: mock(() => {}),
|
||||
raw: null,
|
||||
isInner: false,
|
||||
url: "",
|
||||
origin: "",
|
||||
protocol: "",
|
||||
} as unknown as TestWs;
|
||||
}
|
||||
|
||||
describe("Server HTTP endpoints", () => {
|
||||
test("package.json has correct bin and main entries", async () => {
|
||||
@@ -60,6 +90,188 @@ describe("WebSocket message types", () => {
|
||||
expect(clientMessageTypes).toContain("connect");
|
||||
expect(clientMessageTypes).toContain("cancel");
|
||||
});
|
||||
|
||||
test("decodes supported client message payloads", () => {
|
||||
expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: "ping" });
|
||||
expect(
|
||||
decodeClientWsMessage(Buffer.from('{"type":"prompt","payload":{"content":[]}}')),
|
||||
).toEqual({ type: "prompt", payload: { content: [] } });
|
||||
expect(
|
||||
decodeClientWsMessage(new TextEncoder().encode('{"type":"cancel"}').buffer),
|
||||
).toEqual({ type: "cancel" });
|
||||
expect(
|
||||
decodeClientWsMessage([
|
||||
Buffer.from('{"type":"list_sessions","payload":{"cursor":"'),
|
||||
Buffer.from('next"}}'),
|
||||
]),
|
||||
).toEqual({ type: "list_sessions", payload: { cwd: undefined, cursor: "next" } });
|
||||
});
|
||||
|
||||
test("rejects malformed typed client payloads", () => {
|
||||
expect(() => decodeClientWsMessage('{"type":"prompt"}')).toThrow(
|
||||
"Invalid prompt payload",
|
||||
);
|
||||
expect(() =>
|
||||
decodeClientWsMessage('{"type":"load_session","payload":{}}'),
|
||||
).toThrow("Invalid load_session payload");
|
||||
expect(() => decodeClientWsMessage('{"type":"unknown"}')).toThrow(
|
||||
"Unknown message type",
|
||||
);
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":123}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":{}}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":null}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
});
|
||||
|
||||
test("rejects oversized client message payloads before decoding", () => {
|
||||
const payload = "x".repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1);
|
||||
expect(() => decodeClientWsMessage(payload)).toThrow("WebSocket message too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket auth protocol", () => {
|
||||
test("round-trips tokens through a WebSocket subprotocol token", () => {
|
||||
const protocol = encodeWebSocketAuthProtocol("secret/token+with=symbols");
|
||||
expect(protocol).toStartWith("rcs.auth.");
|
||||
expect(protocol).not.toContain("secret/token");
|
||||
expect(decodeWebSocketAuthProtocol(protocol)).toBe("secret/token+with=symbols");
|
||||
});
|
||||
|
||||
test("ignores query-token style inputs", () => {
|
||||
expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined();
|
||||
expect(decodeWebSocketAuthProtocol("token=secret")).toBeUndefined();
|
||||
expect(decodeWebSocketAuthProtocol("other, rcs.auth.")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prefers Authorization headers and supports protocol auth", () => {
|
||||
expect(
|
||||
extractWebSocketAuthToken({
|
||||
authorization: "Bearer header-token",
|
||||
protocol: encodeWebSocketAuthProtocol("protocol-token"),
|
||||
}),
|
||||
).toBe("header-token");
|
||||
expect(
|
||||
extractWebSocketAuthToken({
|
||||
protocol: encodeWebSocketAuthProtocol("protocol-token"),
|
||||
}),
|
||||
).toBe("protocol-token");
|
||||
});
|
||||
|
||||
test("compares auth tokens through the shared constant-time path", () => {
|
||||
expect(authTokensEqual("secret-token", "secret-token")).toBe(true);
|
||||
expect(authTokensEqual("secret-token", "wrong-token")).toBe(false);
|
||||
expect(authTokensEqual(undefined, "secret-token")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RCS upstream URL normalization", () => {
|
||||
test("removes legacy token query params from WebSocket URLs", () => {
|
||||
expect(
|
||||
buildRcsWsUrl("http://example.test/acp/ws?token=old-secret&x=1"),
|
||||
).toBe("ws://example.test/acp/ws?x=1");
|
||||
});
|
||||
|
||||
test("adds /acp/ws for base URLs", () => {
|
||||
expect(buildRcsWsUrl("https://example.test/")).toBe(
|
||||
"wss://example.test/acp/ws",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission mode resolution", () => {
|
||||
test("uses client requested non-bypass modes", () => {
|
||||
expect(resolveNewSessionPermissionMode("plan", "acceptEdits")).toBe("plan");
|
||||
});
|
||||
|
||||
test("uses local default when client does not request a mode", () => {
|
||||
expect(resolveNewSessionPermissionMode(undefined, "acceptEdits")).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("rejects client requested bypassPermissions without local default", () => {
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypassPermissions", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypass", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypasspermissions", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypassPermissions", undefined),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
});
|
||||
|
||||
test("rejects unknown client permission modes before forwarding", () => {
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("unknown-mode", "acceptEdits"),
|
||||
).toThrow("Invalid permissionMode: unknown-mode");
|
||||
});
|
||||
|
||||
test("allows bypassPermissions when local default already enables it", () => {
|
||||
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypassPermissions")).toBe("bypassPermissions");
|
||||
expect(resolveNewSessionPermissionMode("bypass", "bypassPermissions")).toBe("bypassPermissions");
|
||||
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypass")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
test("new_session rejects client bypass before forwarding to the agent", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const ws = makeTestWs(sent);
|
||||
const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS;
|
||||
process.env.ACP_LINK_TEST_INTERNALS = "1";
|
||||
let unregisterClient = () => {};
|
||||
let restoreMode = () => {};
|
||||
|
||||
try {
|
||||
const newSession = mock(async () => ({
|
||||
sessionId: "should-not-be-created",
|
||||
}));
|
||||
unregisterClient = __testing.registerClient(ws, {
|
||||
connection: { newSession },
|
||||
});
|
||||
restoreMode = __testing.setDefaultPermissionMode("acceptEdits");
|
||||
|
||||
await __testing.dispatchClientMessage(ws, {
|
||||
type: "new_session",
|
||||
payload: {
|
||||
cwd: "/tmp",
|
||||
permissionMode: "bypass",
|
||||
},
|
||||
});
|
||||
|
||||
expect(newSession).not.toHaveBeenCalled();
|
||||
expect(__testing.getClientSessionId(ws)).toBeNull();
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "error",
|
||||
payload: {
|
||||
message: expect.stringContaining(
|
||||
"bypassPermissions requires local ACP_PERMISSION_MODE",
|
||||
),
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
restoreMode();
|
||||
unregisterClient();
|
||||
if (originalTestInternals === undefined) {
|
||||
delete process.env.ACP_LINK_TEST_INTERNALS;
|
||||
} else {
|
||||
process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heartbeat constants", () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createLogger } from "./logger.js";
|
||||
import { decodeJsonWsMessage, WsPayloadTooLargeError } from "./ws-message.js";
|
||||
import { encodeWebSocketAuthProtocol } from "./ws-auth.js";
|
||||
|
||||
export interface RcsUpstreamConfig {
|
||||
rcsUrl: string; // e.g. "http://localhost:3000"
|
||||
@@ -9,6 +11,18 @@ export interface RcsUpstreamConfig {
|
||||
maxSessions?: number;
|
||||
}
|
||||
|
||||
export function buildRcsWsUrl(rcsUrl: string): string {
|
||||
let raw = rcsUrl;
|
||||
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/acp/ws";
|
||||
}
|
||||
url.searchParams.delete("token");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* RCS upstream client — connects acp-link to a Remote Control Server.
|
||||
*
|
||||
@@ -87,17 +101,7 @@ export class RcsUpstreamClient {
|
||||
|
||||
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
||||
private buildWsUrl(): string {
|
||||
let raw = this.config.rcsUrl;
|
||||
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/acp/ws";
|
||||
}
|
||||
if (this.config.apiToken) {
|
||||
url.searchParams.set("token", this.config.apiToken);
|
||||
}
|
||||
return url.toString();
|
||||
return buildRcsWsUrl(this.config.rcsUrl);
|
||||
}
|
||||
|
||||
/** Open connection to RCS: REST register → WS identify */
|
||||
@@ -121,7 +125,9 @@ export class RcsUpstreamClient {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.ws = new WebSocket(wsUrl, [
|
||||
encodeWebSocketAuthProtocol(this.config.apiToken),
|
||||
]);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
RcsUpstreamClient.log.debug("ws open — sending identify");
|
||||
@@ -136,8 +142,13 @@ export class RcsUpstreamClient {
|
||||
this.ws.onmessage = (event) => {
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
data = decodeJsonWsMessage(event.data);
|
||||
} catch (err) {
|
||||
if (err instanceof WsPayloadTooLargeError) {
|
||||
RcsUpstreamClient.log.warn({ error: err.message }, "server message too large");
|
||||
this.ws?.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
||||
return;
|
||||
}
|
||||
@@ -152,11 +163,7 @@ export class RcsUpstreamClient {
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
console.log();
|
||||
if (this.sessionId) {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
|
||||
} else {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
}
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
if (this.agentId) {
|
||||
console.log(` Agent ID: ${this.agentId}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ import type { WebSocket as RawWebSocket } from "ws";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
|
||||
import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.js";
|
||||
import {
|
||||
decodeJsonWsMessage,
|
||||
WsPayloadTooLargeError,
|
||||
} from "./ws-message.js";
|
||||
import { authTokensEqual, extractWebSocketAuthToken } from "./ws-auth.js";
|
||||
|
||||
export { MAX_CLIENT_WS_PAYLOAD_BYTES } from "./ws-message.js";
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
@@ -251,6 +258,7 @@ async function handleConnect(ws: WSContext): Promise<void> {
|
||||
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
||||
cwd: AGENT_CWD,
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: buildAgentEnv(),
|
||||
});
|
||||
|
||||
state.process = agentProcess;
|
||||
@@ -334,7 +342,16 @@ async function handleNewSession(
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
|
||||
let permissionMode: string | undefined;
|
||||
try {
|
||||
permissionMode = resolveNewSessionPermissionMode(
|
||||
params.permissionMode,
|
||||
DEFAULT_PERMISSION_MODE,
|
||||
);
|
||||
} catch (error) {
|
||||
send(ws, "error", { message: (error as Error).message });
|
||||
return;
|
||||
}
|
||||
const result = await state.connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
@@ -590,9 +607,326 @@ interface ContentBlock {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ProxyMessage {
|
||||
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
|
||||
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
|
||||
type PermissionResponsePayload = {
|
||||
requestId: string;
|
||||
outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string };
|
||||
};
|
||||
|
||||
type ProxyMessage =
|
||||
| { type: "connect" }
|
||||
| { type: "disconnect" }
|
||||
| { type: "new_session"; payload: { cwd?: string; permissionMode?: string } }
|
||||
| { type: "prompt"; payload: { content: ContentBlock[] } }
|
||||
| { type: "permission_response"; payload: PermissionResponsePayload }
|
||||
| { type: "cancel" }
|
||||
| { type: "set_session_model"; payload: { modelId: string } }
|
||||
| { type: "list_sessions"; payload: { cwd?: string; cursor?: string } }
|
||||
| { type: "load_session"; payload: { sessionId: string; cwd?: string } }
|
||||
| { type: "resume_session"; payload: { sessionId: string; cwd?: string } }
|
||||
| { type: "ping" };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalStringField(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
source: string,
|
||||
): string | undefined {
|
||||
if (!Object.hasOwn(payload, key)) return undefined;
|
||||
const value = payload[key];
|
||||
if (typeof value === "string") return value;
|
||||
throw new Error(`Invalid ${source}: expected a string`);
|
||||
}
|
||||
|
||||
function payloadRecord(value: unknown, type: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`Invalid ${type} payload`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalPayloadRecord(value: unknown, type: string): Record<string, unknown> {
|
||||
if (value === undefined) return {};
|
||||
return payloadRecord(value, type);
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function decodeContentBlocks(value: unknown): ContentBlock[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
!value.every(block => isRecord(block) && typeof block.type === "string")
|
||||
) {
|
||||
throw new Error("Invalid prompt payload");
|
||||
}
|
||||
return value as ContentBlock[];
|
||||
}
|
||||
|
||||
function decodePermissionResponsePayload(value: unknown): PermissionResponsePayload {
|
||||
const payload = payloadRecord(value, "permission_response");
|
||||
if (typeof payload.requestId !== "string" || !isRecord(payload.outcome)) {
|
||||
throw new Error("Invalid permission_response payload");
|
||||
}
|
||||
if (payload.outcome.outcome === "cancelled") {
|
||||
return { requestId: payload.requestId, outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
if (
|
||||
payload.outcome.outcome === "selected" &&
|
||||
typeof payload.outcome.optionId === "string"
|
||||
) {
|
||||
return {
|
||||
requestId: payload.requestId,
|
||||
outcome: { outcome: "selected", optionId: payload.outcome.optionId },
|
||||
};
|
||||
}
|
||||
throw new Error("Invalid permission_response payload");
|
||||
}
|
||||
|
||||
function decodeClientMessage(message: Record<string, unknown>): ProxyMessage {
|
||||
if (typeof message.type !== "string") {
|
||||
throw new Error("Invalid WebSocket message payload");
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "connect":
|
||||
case "disconnect":
|
||||
case "cancel":
|
||||
case "ping":
|
||||
return { type: message.type };
|
||||
case "new_session": {
|
||||
const payload = optionalPayloadRecord(message.payload, "new_session");
|
||||
return {
|
||||
type: "new_session",
|
||||
payload: {
|
||||
cwd: optionalStringField(payload, "cwd", "new_session.cwd"),
|
||||
permissionMode: optionalStringField(
|
||||
payload,
|
||||
"permissionMode",
|
||||
"new_session.permissionMode",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "prompt": {
|
||||
const payload = payloadRecord(message.payload, "prompt");
|
||||
return {
|
||||
type: "prompt",
|
||||
payload: { content: decodeContentBlocks(payload.content) },
|
||||
};
|
||||
}
|
||||
case "permission_response":
|
||||
return {
|
||||
type: "permission_response",
|
||||
payload: decodePermissionResponsePayload(message.payload),
|
||||
};
|
||||
case "set_session_model": {
|
||||
const payload = payloadRecord(message.payload, "set_session_model");
|
||||
if (typeof payload.modelId !== "string") {
|
||||
throw new Error("Invalid set_session_model payload");
|
||||
}
|
||||
return { type: "set_session_model", payload: { modelId: payload.modelId } };
|
||||
}
|
||||
case "list_sessions": {
|
||||
const payload = optionalRecord(message.payload);
|
||||
return {
|
||||
type: "list_sessions",
|
||||
payload: {
|
||||
cwd: optionalString(payload.cwd),
|
||||
cursor: optionalString(payload.cursor),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "load_session":
|
||||
case "resume_session": {
|
||||
const payload = payloadRecord(message.payload, message.type);
|
||||
if (typeof payload.sessionId !== "string") {
|
||||
throw new Error(`Invalid ${message.type} payload`);
|
||||
}
|
||||
return {
|
||||
type: message.type,
|
||||
payload: {
|
||||
sessionId: payload.sessionId,
|
||||
cwd: optionalString(payload.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown message type: ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeClientWsMessage(data: unknown): ProxyMessage {
|
||||
return decodeClientMessage(decodeJsonWsMessage(data));
|
||||
}
|
||||
|
||||
async function dispatchClientMessage(ws: WSContext, data: ProxyMessage): Promise<void> {
|
||||
switch (data.type) {
|
||||
case "connect":
|
||||
await handleConnect(ws);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(ws);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(ws, data.payload);
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(ws, data.payload);
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(ws, data.payload);
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(ws);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(ws, data.payload);
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(ws, data.payload);
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(ws, data.payload);
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(ws, data.payload);
|
||||
break;
|
||||
case "ping":
|
||||
send(ws, "pong");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
dispatchClientMessage(
|
||||
ws: WSContext,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
assertTestingInternalsEnabled();
|
||||
return dispatchClientMessage(ws, data as ProxyMessage);
|
||||
},
|
||||
registerClient(
|
||||
ws: WSContext,
|
||||
state: {
|
||||
connection?: unknown;
|
||||
process?: ChildProcess | null;
|
||||
sessionId?: string | null;
|
||||
},
|
||||
): () => void {
|
||||
assertTestingInternalsEnabled();
|
||||
clients.set(ws, {
|
||||
process: state.process ?? null,
|
||||
connection: (state.connection ?? null) as acp.ClientSideConnection | null,
|
||||
sessionId: state.sessionId ?? null,
|
||||
pendingPermissions: new Map(),
|
||||
agentCapabilities: null,
|
||||
promptCapabilities: null,
|
||||
modelState: null,
|
||||
isAlive: true,
|
||||
});
|
||||
return () => {
|
||||
clients.delete(ws);
|
||||
};
|
||||
},
|
||||
getClientSessionId(ws: WSContext): string | null | undefined {
|
||||
assertTestingInternalsEnabled();
|
||||
return clients.get(ws)?.sessionId;
|
||||
},
|
||||
setDefaultPermissionMode(mode: string | undefined): () => void {
|
||||
assertTestingInternalsEnabled();
|
||||
const previous = DEFAULT_PERMISSION_MODE;
|
||||
DEFAULT_PERMISSION_MODE = mode;
|
||||
return () => {
|
||||
DEFAULT_PERMISSION_MODE = previous;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function assertTestingInternalsEnabled(): void {
|
||||
if (process.env.ACP_LINK_TEST_INTERNALS === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"acp-link test internals are disabled outside test execution.",
|
||||
);
|
||||
}
|
||||
|
||||
const ACP_LINK_PERMISSION_MODE_ALIASES = {
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
acceptedits: "acceptEdits",
|
||||
dontask: "dontAsk",
|
||||
plan: "plan",
|
||||
bypasspermissions: "bypassPermissions",
|
||||
bypass: "bypassPermissions",
|
||||
} as const;
|
||||
|
||||
type AcpLinkPermissionMode =
|
||||
(typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES];
|
||||
|
||||
export function resolveNewSessionPermissionMode(
|
||||
requestedMode: string | undefined,
|
||||
defaultMode: string | undefined,
|
||||
): string | undefined {
|
||||
const requested = resolveAcpLinkPermissionMode(requestedMode);
|
||||
const localDefault = resolveAcpLinkPermissionMode(defaultMode);
|
||||
|
||||
if (!requested) {
|
||||
return localDefault;
|
||||
}
|
||||
|
||||
if (requested !== "bypassPermissions") {
|
||||
return requested;
|
||||
}
|
||||
|
||||
if (localDefault === "bypassPermissions") {
|
||||
return "bypassPermissions";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAcpLinkPermissionMode(
|
||||
mode: string | undefined,
|
||||
): AcpLinkPermissionMode | undefined {
|
||||
if (mode === undefined) return undefined;
|
||||
|
||||
const normalized = mode?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid permissionMode: expected a non-empty string.");
|
||||
}
|
||||
|
||||
const resolved =
|
||||
ACP_LINK_PERMISSION_MODE_ALIASES[
|
||||
normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES
|
||||
];
|
||||
if (!resolved) {
|
||||
throw new Error(`Invalid permissionMode: ${mode}.`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function buildAgentEnv(): NodeJS.ProcessEnv {
|
||||
if (!DEFAULT_PERMISSION_MODE) {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startServer(config: ServerConfig): Promise<void> {
|
||||
@@ -638,44 +972,9 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
|
||||
rcsUpstream.setMessageHandler(async (msg) => {
|
||||
try {
|
||||
logRelay.debug({ type: msg.type }, "processing");
|
||||
switch (msg.type) {
|
||||
case "connect":
|
||||
await handleConnect(relayWs);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(relayWs);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(relayWs, msg.payload as { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } });
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(relayWs);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(relayWs, msg.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(relayWs, (msg.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(relayWs, "pong");
|
||||
break;
|
||||
default:
|
||||
logRelay.warn({ type: msg.type }, "unknown message type");
|
||||
}
|
||||
const data = decodeClientMessage(msg);
|
||||
logRelay.debug({ type: data.type }, "processing");
|
||||
await dispatchClientMessage(relayWs, data);
|
||||
} catch (error) {
|
||||
logRelay.error({ error: (error as Error).message }, "handler error");
|
||||
}
|
||||
@@ -700,9 +999,11 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
"/ws",
|
||||
upgradeWebSocket((c) => {
|
||||
if (AUTH_TOKEN) {
|
||||
const url = new URL(c.req.url);
|
||||
const providedToken = url.searchParams.get("token");
|
||||
if (providedToken !== AUTH_TOKEN) {
|
||||
const providedToken = extractWebSocketAuthToken({
|
||||
authorization: c.req.header("Authorization"),
|
||||
protocol: c.req.header("Sec-WebSocket-Protocol"),
|
||||
});
|
||||
if (!authTokensEqual(providedToken, AUTH_TOKEN)) {
|
||||
logWs.warn("connection rejected: invalid token");
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
@@ -734,63 +1035,31 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
state.isAlive = true;
|
||||
});
|
||||
},
|
||||
async onMessage(event, ws) {
|
||||
try {
|
||||
const data = JSON.parse(event.data.toString());
|
||||
logWs.debug({ type: data.type }, "received");
|
||||
|
||||
switch (data.type) {
|
||||
case "connect":
|
||||
await handleConnect(ws);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(ws);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(ws, data.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(ws, data.payload);
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(ws);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(ws, data.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(ws, (data.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(ws, "pong");
|
||||
break;
|
||||
default:
|
||||
send(ws, "error", { message: `Unknown message type: ${data.type}` });
|
||||
async onMessage(event, ws) {
|
||||
try {
|
||||
const data = decodeClientWsMessage(event.data);
|
||||
logWs.debug({ type: data.type }, "received");
|
||||
await dispatchClientMessage(ws, data);
|
||||
} catch (error) {
|
||||
if (error instanceof WsPayloadTooLargeError) {
|
||||
logWs.warn({ error: error.message }, "message too large");
|
||||
ws.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
logWs.error({ error: (error as Error).message }, "message error");
|
||||
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logWs.error({ error: (error as Error).message }, "message error");
|
||||
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||
}
|
||||
},
|
||||
onClose(_event, ws) {
|
||||
logWs.info("client disconnected");
|
||||
const state = clients.get(ws);
|
||||
if (state) {
|
||||
cancelPendingPermissions(state);
|
||||
}
|
||||
handleDisconnect(ws);
|
||||
clients.delete(ws);
|
||||
},
|
||||
};
|
||||
},
|
||||
onClose(_event, ws) {
|
||||
logWs.info("client disconnected");
|
||||
const state = clients.get(ws);
|
||||
if (state) {
|
||||
cancelPendingPermissions(state);
|
||||
}
|
||||
handleDisconnect(ws);
|
||||
clients.delete(ws);
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -855,7 +1124,7 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
console.log(` URL: ${localWsUrl}`);
|
||||
}
|
||||
if (AUTH_TOKEN) {
|
||||
console.log(` Token: ${AUTH_TOKEN}`);
|
||||
console.log(` Token: configured`);
|
||||
}
|
||||
console.log();
|
||||
if (!AUTH_TOKEN) {
|
||||
|
||||
62
packages/acp-link/src/ws-auth.ts
Normal file
62
packages/acp-link/src/ws-auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
|
||||
|
||||
function sha256(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
export function encodeWebSocketAuthProtocol(token: string): string {
|
||||
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
|
||||
if (!protocolHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const protocol of protocolHeader.split(",")) {
|
||||
const trimmed = protocol.trim();
|
||||
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
return token.length > 0 ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractBearerToken(authorizationHeader: string | undefined): string | undefined {
|
||||
return authorizationHeader?.startsWith("Bearer ")
|
||||
? authorizationHeader.slice("Bearer ".length)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function extractWebSocketAuthToken(headers: {
|
||||
authorization?: string;
|
||||
protocol?: string;
|
||||
}): string | undefined {
|
||||
return extractBearerToken(headers.authorization) ??
|
||||
decodeWebSocketAuthProtocol(headers.protocol);
|
||||
}
|
||||
|
||||
export function authTokensEqual(
|
||||
providedToken: string | undefined,
|
||||
expectedToken: string | undefined,
|
||||
): boolean {
|
||||
if (!providedToken || !expectedToken) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(sha256(providedToken), sha256(expectedToken));
|
||||
}
|
||||
60
packages/acp-link/src/ws-message.ts
Normal file
60
packages/acp-link/src/ws-message.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export class WsPayloadTooLargeError extends Error {
|
||||
constructor(byteLength: number) {
|
||||
super(`WebSocket message too large: ${byteLength} bytes`);
|
||||
this.name = "WsPayloadTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonWsMessage {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function assertPayloadSize(byteLength: number): void {
|
||||
if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) {
|
||||
throw new WsPayloadTooLargeError(byteLength);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWsText(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
assertPayloadSize(Buffer.byteLength(data, "utf8"));
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data instanceof ArrayBuffer) {
|
||||
assertPayloadSize(data.byteLength);
|
||||
return new TextDecoder().decode(new Uint8Array(data));
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
assertPayloadSize(data.byteLength);
|
||||
return new TextDecoder().decode(
|
||||
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(data) && data.every(Buffer.isBuffer)) {
|
||||
const byteLength = data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
||||
assertPayloadSize(byteLength);
|
||||
return Buffer.concat(data, byteLength).toString("utf8");
|
||||
}
|
||||
|
||||
throw new Error("Unsupported WebSocket message payload");
|
||||
}
|
||||
|
||||
export function decodeJsonWsMessage(data: unknown): JsonWsMessage {
|
||||
const parsed = JSON.parse(decodeWsText(data)) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("type" in parsed) ||
|
||||
typeof parsed.type !== "string"
|
||||
) {
|
||||
throw new Error("Invalid WebSocket message payload");
|
||||
}
|
||||
return parsed as JsonWsMessage;
|
||||
}
|
||||
@@ -1,10 +1,33 @@
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
// createRequire works in both Bun and Node.js ESM contexts.
|
||||
// Needed because this package is "type": "module" but uses require() for
|
||||
// loading native .node addons — bare require is not available in Node.js ESM.
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Resolve the "vendor root" directory where native .node binaries live.
|
||||
*
|
||||
* - Dev mode: import.meta.url → packages/audio-capture-napi/src/index.ts
|
||||
* → vendor root = <project>/vendor/
|
||||
* - Bun build: import.meta.url → dist/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
* - Vite build: import.meta.url → dist/chunks/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
*/
|
||||
function getVendorRoot(): string {
|
||||
const filePath = fileURLToPath(import.meta.url)
|
||||
const dir = dirname(filePath)
|
||||
const parts = dir.split(sep)
|
||||
const distIdx = parts.lastIndexOf('dist')
|
||||
if (distIdx !== -1) {
|
||||
return parts.slice(0, distIdx + 1).join(sep) + sep + 'vendor'
|
||||
}
|
||||
// Dev mode — go up from packages/audio-capture-napi/src/ to project root
|
||||
return resolve(dir, '..', '..', '..', 'vendor')
|
||||
}
|
||||
|
||||
type AudioCaptureNapi = {
|
||||
startRecording(
|
||||
onData: (data: Buffer) => void,
|
||||
@@ -56,15 +79,18 @@ function loadModule(): AudioCaptureNapi | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Candidates 2-4: npm-install, dev/source, and workspace layouts.
|
||||
// In bundled output, require() resolves relative to cli.js at the package root.
|
||||
// In dev, it resolves relative to this file. When loaded from a workspace
|
||||
// package (packages/audio-capture-napi/src/), we need an absolute path fallback.
|
||||
// Candidates 2-5: resolved vendor path + relative fallbacks.
|
||||
// The primary candidate uses getVendorRoot() to find the correct dist root
|
||||
// regardless of chunk nesting depth. Relative fallbacks cover edge cases.
|
||||
const platformDir = `${process.arch}-${platform}`
|
||||
const binaryRel = `audio-capture/${platformDir}/audio-capture.node`
|
||||
const vendorRoot = getVendorRoot()
|
||||
const fallbacks = [
|
||||
`./vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
`../audio-capture/${platformDir}/audio-capture.node`,
|
||||
`${process.cwd()}/vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
resolve(vendorRoot, binaryRel),
|
||||
`./vendor/${binaryRel}`,
|
||||
`../vendor/${binaryRel}`,
|
||||
`../../vendor/${binaryRel}`,
|
||||
`${process.cwd()}/vendor/${binaryRel}`,
|
||||
]
|
||||
for (const p of fallbacks) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { Message } from 'src/types/message.js'
|
||||
import { filterIncompleteToolCalls } from '../filterIncompleteToolCalls.js'
|
||||
|
||||
describe('filterIncompleteToolCalls', () => {
|
||||
test('drops assistant tool uses that do not have matching results', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
message: { role: 'user', content: 'continue' },
|
||||
},
|
||||
] as unknown as Message[]
|
||||
|
||||
expect(
|
||||
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
|
||||
).toEqual(['u1'])
|
||||
})
|
||||
|
||||
test('preserves assistant text when dropping orphan tool uses', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'I will read the file.' },
|
||||
{ type: 'tool_use', id: 'missing', name: 'Read' },
|
||||
],
|
||||
},
|
||||
},
|
||||
] as unknown as Message[]
|
||||
|
||||
const filtered = filterIncompleteToolCalls(messages)
|
||||
expect(filtered).toHaveLength(1)
|
||||
const first = filtered[0]!
|
||||
const content = first.message!.content
|
||||
expect(
|
||||
Array.isArray(content) ? content.map(block => block.type) : [],
|
||||
).toEqual(['text'])
|
||||
})
|
||||
|
||||
test('keeps completed parallel tool calls when dropping an orphan', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'done', name: 'Read' },
|
||||
{ type: 'tool_use', id: 'missing', name: 'Grep' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
|
||||
},
|
||||
},
|
||||
] as unknown as Message[]
|
||||
|
||||
const filtered = filterIncompleteToolCalls(messages)
|
||||
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
|
||||
const first = filtered[0]!
|
||||
const content = first.message!.content
|
||||
expect(
|
||||
Array.isArray(content)
|
||||
? content.map(block =>
|
||||
block.type === 'tool_use' ? block.id : block.type,
|
||||
)
|
||||
: [],
|
||||
).toEqual(['done'])
|
||||
})
|
||||
|
||||
test('keeps assistant tool uses that have matching results', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'done', name: 'Read' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
|
||||
},
|
||||
},
|
||||
] as unknown as Message[]
|
||||
|
||||
expect(
|
||||
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
|
||||
).toEqual(['a1', 'u1'])
|
||||
})
|
||||
|
||||
test('drops orphan tool results when their tool use was removed', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
|
||||
],
|
||||
},
|
||||
},
|
||||
] as unknown as Message[]
|
||||
|
||||
expect(filterIncompleteToolCalls(messages)).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps user text while dropping orphan tool results', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a1',
|
||||
message: { role: 'assistant', content: 'done' },
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'keep this' },
|
||||
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
|
||||
],
|
||||
},
|
||||
},
|
||||
] as unknown as Message[]
|
||||
|
||||
const filtered = filterIncompleteToolCalls(messages)
|
||||
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
|
||||
const content = filtered[1]!.message!.content
|
||||
expect(Array.isArray(content) ? content : []).toEqual([
|
||||
{ type: 'text', text: 'keep this' },
|
||||
])
|
||||
})
|
||||
|
||||
test('drops malformed tool blocks without ids', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', name: 'Read' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', content: 'late' }],
|
||||
},
|
||||
},
|
||||
] as unknown as Message[]
|
||||
|
||||
expect(filterIncompleteToolCalls(messages)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Message,
|
||||
UserMessage,
|
||||
} from 'src/types/message.js'
|
||||
|
||||
/**
|
||||
* Removes invalid or orphaned tool_use/tool_result blocks while preserving
|
||||
* completed tool-call pairs. This is intentionally block-level, not
|
||||
* message-level, so completed parallel tool calls stay paired with results.
|
||||
*/
|
||||
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
|
||||
const toolUseIdsWithResults = new Set<string>()
|
||||
|
||||
for (const message of messages) {
|
||||
if (message?.type === 'user') {
|
||||
const userMessage = message as UserMessage
|
||||
const content = userMessage.message.content
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result' && block.tool_use_id) {
|
||||
toolUseIdsWithResults.add(block.tool_use_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retainedToolUseIds = new Set<string>()
|
||||
const withoutOrphanToolUses: Message[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (message?.type === 'assistant') {
|
||||
const assistantMessage = message as AssistantMessage
|
||||
const content = assistantMessage.message.content
|
||||
if (Array.isArray(content)) {
|
||||
let changed = false
|
||||
const filteredContent = content.filter(block => {
|
||||
if (block.type !== 'tool_use') return true
|
||||
if (!block.id) {
|
||||
changed = true
|
||||
return false
|
||||
}
|
||||
if (toolUseIdsWithResults.has(block.id)) {
|
||||
retainedToolUseIds.add(block.id)
|
||||
return true
|
||||
}
|
||||
changed = true
|
||||
return false
|
||||
})
|
||||
|
||||
if (!changed) {
|
||||
withoutOrphanToolUses.push(message)
|
||||
continue
|
||||
}
|
||||
if (filteredContent.length > 0) {
|
||||
withoutOrphanToolUses.push({
|
||||
...assistantMessage,
|
||||
message: {
|
||||
...assistantMessage.message,
|
||||
content: filteredContent,
|
||||
},
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
withoutOrphanToolUses.push(message)
|
||||
}
|
||||
|
||||
const filteredMessages: Message[] = []
|
||||
for (const message of withoutOrphanToolUses) {
|
||||
if (message?.type !== 'user') {
|
||||
filteredMessages.push(message)
|
||||
continue
|
||||
}
|
||||
const userMessage = message as UserMessage
|
||||
const content = userMessage.message.content
|
||||
if (!Array.isArray(content)) {
|
||||
filteredMessages.push(message)
|
||||
continue
|
||||
}
|
||||
let changed = false
|
||||
const filteredContent = content.filter(block => {
|
||||
if (block.type !== 'tool_result') return true
|
||||
if (!block.tool_use_id) {
|
||||
changed = true
|
||||
return false
|
||||
}
|
||||
if (retainedToolUseIds.has(block.tool_use_id)) return true
|
||||
changed = true
|
||||
return false
|
||||
})
|
||||
if (!changed) {
|
||||
filteredMessages.push(message)
|
||||
continue
|
||||
}
|
||||
if (filteredContent.length > 0) {
|
||||
filteredMessages.push({
|
||||
...userMessage,
|
||||
message: {
|
||||
...userMessage.message,
|
||||
content: filteredContent,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return filteredMessages
|
||||
}
|
||||
@@ -86,8 +86,11 @@ import {
|
||||
import type { ContentReplacementState } from 'src/utils/toolResultStorage.js'
|
||||
import { createAgentId } from 'src/utils/uuid.js'
|
||||
import { resolveAgentTools } from './agentToolUtils.js'
|
||||
import { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
|
||||
import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js'
|
||||
|
||||
export { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
|
||||
|
||||
/**
|
||||
* Initialize agent-specific MCP servers
|
||||
* Agents can define their own MCP servers in their frontmatter that are additive
|
||||
@@ -886,50 +889,6 @@ export async function* runAgent({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out assistant messages with incomplete tool calls (tool uses without results).
|
||||
* This prevents API errors when sending messages with orphaned tool calls.
|
||||
*/
|
||||
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
|
||||
// Build a set of tool use IDs that have results
|
||||
const toolUseIdsWithResults = new Set<string>()
|
||||
|
||||
for (const message of messages) {
|
||||
if (message?.type === 'user') {
|
||||
const userMessage = message as UserMessage
|
||||
const content = userMessage.message.content
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result' && block.tool_use_id) {
|
||||
toolUseIdsWithResults.add(block.tool_use_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out assistant messages that contain tool calls without results
|
||||
return messages.filter(message => {
|
||||
if (message?.type === 'assistant') {
|
||||
const assistantMessage = message as AssistantMessage
|
||||
const content = assistantMessage.message.content
|
||||
if (Array.isArray(content)) {
|
||||
// Check if this assistant message has any tool uses without results
|
||||
const hasIncompleteToolCall = content.some(
|
||||
block =>
|
||||
block.type === 'tool_use' &&
|
||||
block.id &&
|
||||
!toolUseIdsWithResults.has(block.id),
|
||||
)
|
||||
// Exclude messages with incomplete tool calls
|
||||
return !hasIncompleteToolCall
|
||||
}
|
||||
}
|
||||
// Keep all non-assistant messages and assistant messages without tool calls
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async function getAgentSystemPrompt(
|
||||
agentDefinition: AgentDefinition,
|
||||
toolUseContext: Pick<ToolUseContext, 'options'>,
|
||||
|
||||
@@ -106,6 +106,84 @@ describe("findActualString", () => {
|
||||
const result = findActualString("hello", "");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
// ── Tab/space normalization (Bug #2 reproduction) ──
|
||||
|
||||
test("finds match when search uses spaces but file uses tabs", () => {
|
||||
// File content uses Tab indentation
|
||||
const fileContent = "\tif (x) {\n\t\treturn 1;\n\t}";
|
||||
// User copies from Read output which renders tabs as spaces
|
||||
const searchWithSpaces = " if (x) {\n return 1;\n }";
|
||||
const result = findActualString(fileContent, searchWithSpaces);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBe(fileContent);
|
||||
});
|
||||
|
||||
test("finds match when search mixes tabs and spaces inconsistently", () => {
|
||||
const fileContent = "\tconst x = 1; // comment";
|
||||
const searchMixed = " const x = 1; // comment";
|
||||
const result = findActualString(fileContent, searchMixed);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("finds match for single-line tab-to-space mismatch", () => {
|
||||
const fileContent = "\t\torder_price = NormalizeDouble(ask, digits);";
|
||||
const searchSpaces = " order_price = NormalizeDouble(ask, digits);";
|
||||
const result = findActualString(fileContent, searchSpaces);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── CJK / UTF-8 characters (Bug #1 reproduction) ──
|
||||
|
||||
test("finds match with CJK characters in content", () => {
|
||||
const fileContent = "input int x = 620; // 止盈点数(点) — 32个pip=320点";
|
||||
const result = findActualString(fileContent, fileContent);
|
||||
expect(result).toBe(fileContent);
|
||||
});
|
||||
|
||||
test("finds match with CJK characters when tab/space differs", () => {
|
||||
const fileContent = "\t// 向上突破 → Sell Limit (逆方向做空)";
|
||||
const searchSpaces = " // 向上突破 → Sell Limit (逆方向做空)";
|
||||
const result = findActualString(fileContent, searchSpaces);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBe(fileContent);
|
||||
});
|
||||
|
||||
// ── Multiline with tabs + CJK (combined Bug #1 + #2) ──
|
||||
|
||||
test("finds multiline match with tabs and CJK characters", () => {
|
||||
const fileContent = "\tif(effective_dir == BREAKOUT_UP)\n\t\t{\n\t\t\t// 向上突破\n\t\t}";
|
||||
const searchSpaces = " if(effective_dir == BREAKOUT_UP)\n {\n // 向上突破\n }";
|
||||
const result = findActualString(fileContent, searchSpaces);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBe(fileContent);
|
||||
});
|
||||
|
||||
// ── Returned string must be a valid substring of fileContent ──
|
||||
|
||||
test("returned string from tab match is a real substring of fileContent", () => {
|
||||
const fileContent = "prefix\n\t\tindented code\nsuffix";
|
||||
const searchSpaces = "prefix\n indented code\nsuffix";
|
||||
const result = findActualString(fileContent, searchSpaces);
|
||||
expect(result).not.toBeNull();
|
||||
expect(fileContent.includes(result!)).toBe(true);
|
||||
});
|
||||
|
||||
test("returned string from partial tab match is a real substring", () => {
|
||||
const fileContent = "line1\n\tif (x) {\n\t\tdoStuff();\n\t}\nline5";
|
||||
const searchSpaces = " if (x) {\n doStuff();\n }";
|
||||
const result = findActualString(fileContent, searchSpaces);
|
||||
expect(result).not.toBeNull();
|
||||
expect(fileContent.includes(result!)).toBe(true);
|
||||
});
|
||||
|
||||
test("tab match with mixed indentation levels", () => {
|
||||
const fileContent = "class Foo {\n\t\tmethod1() {\n\t\t\treturn 42;\n\t\t}\n}";
|
||||
const searchSpaces = "class Foo {\n method1() {\n return 42;\n }\n}";
|
||||
const result = findActualString(fileContent, searchSpaces);
|
||||
expect(result).not.toBeNull();
|
||||
expect(fileContent.includes(result!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── preserveQuoteStyle ─────────────────────────────────────────────────
|
||||
|
||||
@@ -63,9 +63,26 @@ export function stripTrailingWhitespace(str: string): string {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes whitespace for fuzzy matching by converting tabs to spaces
|
||||
* and collapsing leading whitespace on each line to a canonical form.
|
||||
* This handles the case where Read tool output renders tabs as spaces,
|
||||
* so users copy spaces from the output but the file actually has tabs.
|
||||
*/
|
||||
function normalizeWhitespace(str: string): string {
|
||||
return str.replace(/\t/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the actual string in the file content that matches the search string,
|
||||
* accounting for quote normalization
|
||||
* accounting for quote normalization and tab/space differences.
|
||||
*
|
||||
* Matching cascade:
|
||||
* 1. Exact match
|
||||
* 2. Quote normalization (curly → straight quotes)
|
||||
* 3. Tab/space normalization (tabs ↔ spaces in leading whitespace)
|
||||
* 4. Quote + tab/space normalization combined
|
||||
*
|
||||
* @param fileContent The file content to search in
|
||||
* @param searchString The string to search for
|
||||
* @returns The actual string found in the file, or null if not found
|
||||
@@ -89,9 +106,92 @@ export function findActualString(
|
||||
return fileContent.substring(searchIndex, searchIndex + searchString.length)
|
||||
}
|
||||
|
||||
// Try with tab/space normalization — handles the case where Read output
|
||||
// renders tabs as spaces and the user copies the rendered version
|
||||
const wsNormalizedFile = normalizeWhitespace(fileContent)
|
||||
const wsNormalizedSearch = normalizeWhitespace(searchString)
|
||||
|
||||
const wsSearchIndex = wsNormalizedFile.indexOf(wsNormalizedSearch)
|
||||
if (wsSearchIndex !== -1) {
|
||||
// Map the match position back to the original file content.
|
||||
// We need to find the corresponding range in the original string.
|
||||
return mapNormalizedMatchBackToFile(fileContent, wsNormalizedFile, wsSearchIndex, wsNormalizedSearch.length)
|
||||
}
|
||||
|
||||
// Try combined: quote normalization + tab/space normalization
|
||||
const combinedFile = normalizeWhitespace(normalizedFile)
|
||||
const combinedSearch = normalizeWhitespace(normalizedSearch)
|
||||
|
||||
const combinedIndex = combinedFile.indexOf(combinedSearch)
|
||||
if (combinedIndex !== -1) {
|
||||
return mapNormalizedMatchBackToFile(fileContent, combinedFile, combinedIndex, combinedSearch.length)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a match found in a normalized version of fileContent, map the match
|
||||
* position back to the original fileContent and extract the corresponding
|
||||
* substring.
|
||||
*
|
||||
* Strategy: walk through both strings character by character, building a
|
||||
* mapping from normalized offset to original offset. When a tab is expanded
|
||||
* to 4 spaces in the normalized version, the normalized offset advances by 4
|
||||
* while the original offset advances by 1.
|
||||
*/
|
||||
function mapNormalizedMatchBackToFile(
|
||||
fileContent: string,
|
||||
normalizedFile: string,
|
||||
normalizedStart: number,
|
||||
normalizedLength: number,
|
||||
): string {
|
||||
// Build a sparse mapping from normalized position → original position.
|
||||
// We only need to map the range [normalizedStart, normalizedStart + normalizedLength].
|
||||
let normPos = 0
|
||||
let origPos = 0
|
||||
let origStart = -1
|
||||
let origEnd = -1
|
||||
|
||||
while (origPos < fileContent.length && normPos <= normalizedStart + normalizedLength) {
|
||||
if (normPos === normalizedStart) {
|
||||
origStart = origPos
|
||||
}
|
||||
if (normPos === normalizedStart + normalizedLength) {
|
||||
origEnd = origPos
|
||||
break
|
||||
}
|
||||
|
||||
const origChar = fileContent[origPos]!
|
||||
if (origChar === '\t') {
|
||||
// Tab expands to 4 spaces in normalized version
|
||||
const nextNormPos = normPos + 4
|
||||
// If normalizedStart falls within this expanded tab, snap to origPos
|
||||
if (normPos < normalizedStart && nextNormPos > normalizedStart && origStart === -1) {
|
||||
origStart = origPos
|
||||
}
|
||||
if (normPos < normalizedStart + normalizedLength && nextNormPos > normalizedStart + normalizedLength && origEnd === -1) {
|
||||
origEnd = origPos + 1
|
||||
}
|
||||
normPos = nextNormPos
|
||||
origPos++
|
||||
} else {
|
||||
normPos++
|
||||
origPos++
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we couldn't map precisely, use character-count heuristic
|
||||
if (origStart === -1) origStart = 0
|
||||
if (origEnd === -1) {
|
||||
// Approximate: use the ratio of original to normalized length
|
||||
const ratio = fileContent.length / normalizedFile.length
|
||||
origEnd = Math.round(origStart + normalizedLength * ratio)
|
||||
}
|
||||
|
||||
return fileContent.substring(origStart, origEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* When old_string matched via quote normalization (curly quotes in file,
|
||||
* straight quotes from model), apply the same curly quote style to new_string
|
||||
|
||||
@@ -84,22 +84,48 @@ Use this tool to discover messaging targets before sending cross-session message
|
||||
// UDS socket directory. The implementation scans for live sockets
|
||||
// and optionally includes Remote Control bridge peers.
|
||||
const peers: PeerInfo[] = []
|
||||
const seen = new Set<string>()
|
||||
const addPeer = (peer: PeerInfo): void => {
|
||||
if (seen.has(peer.address)) return
|
||||
seen.add(peer.address)
|
||||
peers.push(peer)
|
||||
}
|
||||
|
||||
// Discovery is handled by the UDS messaging subsystem initialized in setup.ts.
|
||||
// Return discovered peers from the app state.
|
||||
const appState = context.getAppState()
|
||||
const messagingSocketPath = (appState as Record<string, unknown>).messagingSocketPath as string | undefined
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const udsMessaging =
|
||||
require('src/utils/udsMessaging.js') as typeof import('src/utils/udsMessaging.js')
|
||||
const udsClient =
|
||||
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
|
||||
const bridgePeers =
|
||||
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
const messagingSocketPath = udsMessaging.getUdsMessagingSocketPath()
|
||||
if (messagingSocketPath) {
|
||||
// Self entry for reference
|
||||
if (_input.include_self) {
|
||||
peers.push({
|
||||
address: `uds:${messagingSocketPath}`,
|
||||
addPeer({
|
||||
address: udsMessaging.formatUdsAddress(messagingSocketPath),
|
||||
name: 'self',
|
||||
pid: process.pid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const peer of await udsClient.listPeers()) {
|
||||
if (!peer.messagingSocketPath) continue
|
||||
addPeer({
|
||||
address: udsMessaging.formatUdsAddress(peer.messagingSocketPath),
|
||||
name: peer.name ?? peer.kind,
|
||||
cwd: peer.cwd,
|
||||
pid: peer.pid,
|
||||
})
|
||||
}
|
||||
|
||||
for (const peer of await bridgePeers.listBridgePeers()) {
|
||||
addPeer(peer)
|
||||
}
|
||||
|
||||
return {
|
||||
data: { peers },
|
||||
}
|
||||
|
||||
@@ -421,7 +421,7 @@ export const PowerShellTool = buildTool({
|
||||
isSearch: boolean
|
||||
isRead: boolean
|
||||
} {
|
||||
if (!input.command) {
|
||||
if (!input?.command) {
|
||||
return { isSearch: false, isRead: false }
|
||||
}
|
||||
return isSearchOrReadPowerShellCommand(input.command)
|
||||
|
||||
@@ -130,6 +130,41 @@ export type SendMessageToolOutput =
|
||||
| RequestOutput
|
||||
| ResponseOutput
|
||||
|
||||
const UDS_INLINE_TOKEN_MARKER = '#token='
|
||||
|
||||
function stripInlineUdsToken(target: string): string {
|
||||
const markerIndex = target.indexOf(UDS_INLINE_TOKEN_MARKER)
|
||||
return markerIndex === -1 ? target : target.slice(0, markerIndex)
|
||||
}
|
||||
|
||||
function hasInlineUdsToken(to: string): boolean {
|
||||
const addr = parseAddress(to)
|
||||
// Empty-token markers are still inline-token attempts. Observable input
|
||||
// redaction preserves "#token=" so cloned inputs remain rejected.
|
||||
return (
|
||||
addr.scheme === 'uds' && addr.target.includes(UDS_INLINE_TOKEN_MARKER)
|
||||
)
|
||||
}
|
||||
|
||||
function recipientForDisplay(to: string): string {
|
||||
const addr = parseAddress(to)
|
||||
if (addr.scheme !== 'uds') return to
|
||||
return `uds:${stripInlineUdsToken(addr.target)}`
|
||||
}
|
||||
|
||||
function redactInlineUdsTokenForRejection(to: string): string {
|
||||
const addr = parseAddress(to)
|
||||
if (addr.scheme !== 'uds') return to
|
||||
const markerIndex = addr.target.indexOf(UDS_INLINE_TOKEN_MARKER)
|
||||
if (markerIndex === -1) return to
|
||||
return `uds:${addr.target.slice(0, markerIndex)}${UDS_INLINE_TOKEN_MARKER}`
|
||||
}
|
||||
|
||||
function redactObservableInlineUdsToken(input: { to: string }): void {
|
||||
if (!hasInlineUdsToken(input.to)) return
|
||||
input.to = redactInlineUdsTokenForRejection(input.to)
|
||||
}
|
||||
|
||||
function findTeammateColor(
|
||||
appState: {
|
||||
teamContext?: { teammates: { [id: string]: { color?: string } } }
|
||||
@@ -541,15 +576,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
},
|
||||
|
||||
backfillObservableInput(input) {
|
||||
if ('type' in input) return
|
||||
if (typeof input.to !== 'string') return
|
||||
|
||||
redactObservableInlineUdsToken(input as { to: string })
|
||||
if ('type' in input) return
|
||||
|
||||
if (input.to === '*') {
|
||||
input.type = 'broadcast'
|
||||
if (typeof input.message === 'string') input.content = input.message
|
||||
} else if (typeof input.message === 'string') {
|
||||
input.type = 'message'
|
||||
input.recipient = input.to
|
||||
input.recipient = recipientForDisplay(input.to)
|
||||
input.content = input.message
|
||||
} else if (typeof input.message === 'object' && input.message !== null) {
|
||||
const msg = input.message as {
|
||||
@@ -560,7 +597,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
feedback?: string
|
||||
}
|
||||
input.type = msg.type
|
||||
input.recipient = input.to
|
||||
input.recipient = recipientForDisplay(input.to)
|
||||
if (msg.request_id !== undefined) input.request_id = msg.request_id
|
||||
if (msg.approve !== undefined) input.approve = msg.approve
|
||||
const content = msg.reason ?? msg.feedback
|
||||
@@ -569,16 +606,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
},
|
||||
|
||||
toAutoClassifierInput(input) {
|
||||
const recipient = recipientForDisplay(input.to)
|
||||
if (typeof input.message === 'string') {
|
||||
return `to ${input.to}: ${input.message}`
|
||||
return `to ${recipient}: ${input.message}`
|
||||
}
|
||||
switch (input.message.type) {
|
||||
case 'shutdown_request':
|
||||
return `shutdown_request to ${input.to}`
|
||||
return `shutdown_request to ${recipient}`
|
||||
case 'shutdown_response':
|
||||
return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}`
|
||||
case 'plan_approval_response':
|
||||
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}`
|
||||
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${recipient}`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -630,6 +668,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
errorCode: 9,
|
||||
}
|
||||
}
|
||||
if (
|
||||
addr.scheme === 'uds' &&
|
||||
hasInlineUdsToken(input.to)
|
||||
) {
|
||||
return {
|
||||
result: false,
|
||||
message:
|
||||
'uds addresses must not include inline auth tokens; use the ListPeers address',
|
||||
errorCode: 9,
|
||||
}
|
||||
}
|
||||
if (input.to.includes('@')) {
|
||||
return {
|
||||
result: false,
|
||||
@@ -753,6 +802,19 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
},
|
||||
|
||||
async call(input, context, canUseTool, assistantMessage) {
|
||||
if (typeof input.message === 'string') {
|
||||
const addr = parseAddress(input.to)
|
||||
if (addr.scheme === 'uds' && hasInlineUdsToken(input.to)) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message:
|
||||
'uds addresses must not include inline auth tokens; use the ListPeers address',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (feature('UDS_INBOX') && typeof input.message === 'string') {
|
||||
const addr = parseAddress(input.to)
|
||||
if (addr.scheme === 'bridge') {
|
||||
@@ -772,10 +834,10 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
const { postInterClaudeMessage } =
|
||||
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const result = await postInterClaudeMessage(
|
||||
const result = (await postInterClaudeMessage(
|
||||
addr.target,
|
||||
input.message,
|
||||
) as { ok: boolean; error?: string }
|
||||
)) as { ok: boolean; error?: string }
|
||||
const preview = input.summary || truncate(input.message, 50)
|
||||
return {
|
||||
data: {
|
||||
@@ -787,6 +849,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
}
|
||||
}
|
||||
if (addr.scheme === 'uds') {
|
||||
const recipient = recipientForDisplay(input.to)
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { sendToUdsSocket } =
|
||||
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
|
||||
@@ -797,14 +860,14 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
message: `”${preview}” → ${input.to}`,
|
||||
message: `”${preview}” → ${recipient}`,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message: `Failed to send to ${input.to}: ${errorMessage(e)}`,
|
||||
message: `Failed to send to ${recipient}: ${errorMessage(e)}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { SendMessageTool } from '../SendMessageTool.js'
|
||||
|
||||
describe('SendMessageTool UDS recipient handling', () => {
|
||||
test('redacts inline UDS tokens before classifier and observable paths', async () => {
|
||||
const tokenAddress = 'uds:/tmp/peer.sock#token=secret-token'
|
||||
|
||||
const observableInput = {
|
||||
to: tokenAddress,
|
||||
message: 'hello',
|
||||
} as Record<string, unknown>
|
||||
SendMessageTool.backfillObservableInput!(observableInput)
|
||||
|
||||
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
|
||||
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
|
||||
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
|
||||
expect(
|
||||
SendMessageTool.toAutoClassifierInput({
|
||||
to: tokenAddress,
|
||||
message: 'hello',
|
||||
}),
|
||||
).toBe('to uds:/tmp/peer.sock: hello')
|
||||
})
|
||||
|
||||
test('keeps redacted UDS token rejection through observable backfill', async () => {
|
||||
const observableInput = {
|
||||
to: 'uds:/tmp/peer.sock#token=secret-token',
|
||||
message: {
|
||||
type: 'plan_approval_response',
|
||||
request_id: 'req-1',
|
||||
approve: false,
|
||||
reason: 'needs tests',
|
||||
},
|
||||
} as Record<string, unknown>
|
||||
|
||||
SendMessageTool.backfillObservableInput!(observableInput)
|
||||
|
||||
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
|
||||
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
|
||||
expect(observableInput.type).toBe('plan_approval_response')
|
||||
expect(observableInput.request_id).toBe('req-1')
|
||||
expect(observableInput.approve).toBe(false)
|
||||
expect(observableInput.content).toBe('needs tests')
|
||||
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
|
||||
|
||||
const result = await SendMessageTool.validateInput!(
|
||||
observableInput as never,
|
||||
{} as never,
|
||||
)
|
||||
|
||||
expect(result.result).toBe(false)
|
||||
if (result.result !== false) {
|
||||
throw new Error('expected validation to reject redacted inline UDS token')
|
||||
}
|
||||
expect(result.message).toContain('inline auth tokens')
|
||||
})
|
||||
|
||||
test('keeps inline-token rejection when observable input is cloned', async () => {
|
||||
const observableInput = {
|
||||
to: 'uds:/tmp/peer.sock#token=secret-token',
|
||||
message: 'hello',
|
||||
} as Record<string, unknown>
|
||||
|
||||
SendMessageTool.backfillObservableInput!(observableInput)
|
||||
const clonedInput = {
|
||||
to: observableInput.to,
|
||||
message: observableInput.message,
|
||||
summary: 'hello peer',
|
||||
}
|
||||
|
||||
const validation = await SendMessageTool.validateInput!(
|
||||
clonedInput as never,
|
||||
{} as never,
|
||||
)
|
||||
const result = await SendMessageTool.call(
|
||||
clonedInput as never,
|
||||
{} as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
)
|
||||
|
||||
expect(validation.result).toBe(false)
|
||||
expect(result.data.success).toBe(false)
|
||||
expect(JSON.stringify(clonedInput)).not.toContain('secret-token')
|
||||
expect(JSON.stringify(result)).not.toContain('secret-token')
|
||||
})
|
||||
|
||||
test('redacts UDS tokens in structured classifier text', async () => {
|
||||
const to = 'uds:/tmp/peer.sock#token=secret-token'
|
||||
|
||||
expect(
|
||||
SendMessageTool.toAutoClassifierInput({
|
||||
to,
|
||||
message: { type: 'shutdown_request' },
|
||||
}),
|
||||
).toBe('shutdown_request to uds:/tmp/peer.sock')
|
||||
expect(
|
||||
SendMessageTool.toAutoClassifierInput({
|
||||
to,
|
||||
message: {
|
||||
type: 'plan_approval_response',
|
||||
request_id: 'req-1',
|
||||
approve: true,
|
||||
},
|
||||
}),
|
||||
).toBe('plan_approval approve to uds:/tmp/peer.sock')
|
||||
expect(
|
||||
SendMessageTool.toAutoClassifierInput({
|
||||
to,
|
||||
message: {
|
||||
type: 'plan_approval_response',
|
||||
request_id: 'req-2',
|
||||
approve: false,
|
||||
},
|
||||
}),
|
||||
).toBe('plan_approval reject to uds:/tmp/peer.sock')
|
||||
expect(
|
||||
SendMessageTool.toAutoClassifierInput({
|
||||
to,
|
||||
message: {
|
||||
type: 'shutdown_response',
|
||||
request_id: 'shutdown-1',
|
||||
approve: false,
|
||||
},
|
||||
}),
|
||||
).toBe('shutdown_response reject shutdown-1')
|
||||
})
|
||||
|
||||
test('redacts from the first inline UDS token marker', async () => {
|
||||
const tokenAddress = 'uds:/tmp/peer.sock#token=first#token=second'
|
||||
|
||||
const observableInput = {
|
||||
to: tokenAddress,
|
||||
message: 'hello',
|
||||
} as Record<string, unknown>
|
||||
SendMessageTool.backfillObservableInput!(observableInput)
|
||||
|
||||
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
|
||||
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
|
||||
expect(JSON.stringify(observableInput)).not.toContain('first')
|
||||
expect(JSON.stringify(observableInput)).not.toContain('second')
|
||||
expect(
|
||||
SendMessageTool.toAutoClassifierInput({
|
||||
to: tokenAddress,
|
||||
message: 'hello',
|
||||
}),
|
||||
).toBe('to uds:/tmp/peer.sock: hello')
|
||||
})
|
||||
|
||||
test('rejects inline UDS tokens during validation', async () => {
|
||||
const result = await SendMessageTool.validateInput!(
|
||||
{
|
||||
to: 'uds:/tmp/peer.sock#token=secret-token',
|
||||
message: 'hello',
|
||||
},
|
||||
{} as never,
|
||||
)
|
||||
|
||||
expect(result.result).toBe(false)
|
||||
if (result.result !== false) {
|
||||
throw new Error('expected validation to reject inline UDS token')
|
||||
}
|
||||
expect(result.message).toContain('inline auth tokens')
|
||||
expect(JSON.stringify(result)).not.toContain('secret-token')
|
||||
})
|
||||
|
||||
test('rejects inline UDS tokens during execution without leaking them', async () => {
|
||||
const result = await SendMessageTool.call(
|
||||
{
|
||||
to: 'uds:/tmp/peer.sock#token=secret-token',
|
||||
message: 'hello',
|
||||
},
|
||||
{} as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
)
|
||||
|
||||
expect(result.data.success).toBe(false)
|
||||
expect(JSON.stringify(result)).not.toContain('secret-token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
|
||||
type MockAxiosResponse = {
|
||||
data: ArrayBuffer
|
||||
headers: Record<string, unknown>
|
||||
status: number
|
||||
statusText: string
|
||||
}
|
||||
|
||||
type MockAxiosError = Error & {
|
||||
isAxiosError: true
|
||||
response?: {
|
||||
headers: Record<string, unknown>
|
||||
status: number
|
||||
}
|
||||
}
|
||||
|
||||
let getMock: (url: string) => Promise<MockAxiosResponse>
|
||||
|
||||
mock.module('axios', () => {
|
||||
const axiosMock = {
|
||||
get: (url: string) => getMock(url),
|
||||
isAxiosError: (error: unknown): error is MockAxiosError =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { isAxiosError?: unknown }).isAxiosError === true,
|
||||
}
|
||||
|
||||
return { default: axiosMock }
|
||||
})
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/services/api/claude.js', () => ({
|
||||
queryHaiku: async () => ({ message: { content: [] } }),
|
||||
}))
|
||||
|
||||
mock.module('src/utils/http.js', () => ({
|
||||
getWebFetchUserAgent: () => 'TestAgent/1.0',
|
||||
}))
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
mock.module('src/utils/mcpOutputStorage.js', () => ({
|
||||
isBinaryContentType: (contentType: string) =>
|
||||
!contentType.toLowerCase().startsWith('text/'),
|
||||
persistBinaryContent: async () => ({
|
||||
filepath: '/tmp/webfetch-test.bin',
|
||||
size: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getInitialSettings: () => ({}),
|
||||
getSettings_DEPRECATED: () => ({ skipWebFetchPreflight: true }),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getMock = async () => ({
|
||||
data: new TextEncoder().encode('hello').buffer,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebFetch response headers', () => {
|
||||
test('reads redirect Location from AxiosHeaders-style get()', async () => {
|
||||
getMock = async () => {
|
||||
const error = new Error('redirect') as MockAxiosError
|
||||
error.isAxiosError = true
|
||||
error.response = {
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === 'location' ? '/next' : undefined,
|
||||
},
|
||||
status: 302,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const { getWithPermittedRedirects } = await import('../utils')
|
||||
const result = await getWithPermittedRedirects(
|
||||
'https://example.com/old',
|
||||
new AbortController().signal,
|
||||
() => false,
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'redirect',
|
||||
originalUrl: 'https://example.com/old',
|
||||
redirectUrl: 'https://example.com/next',
|
||||
statusCode: 302,
|
||||
})
|
||||
})
|
||||
|
||||
test('reads proxy block markers from normalized headers', async () => {
|
||||
getMock = async () => {
|
||||
const error = new Error('blocked') as MockAxiosError
|
||||
error.isAxiosError = true
|
||||
error.response = {
|
||||
headers: { 'x-proxy-error': 'blocked-by-allowlist' },
|
||||
status: 403,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const { getWithPermittedRedirects } = await import('../utils')
|
||||
|
||||
await expect(
|
||||
getWithPermittedRedirects(
|
||||
'https://blocked.example/path',
|
||||
new AbortController().signal,
|
||||
() => false,
|
||||
),
|
||||
).rejects.toThrow('EGRESS_BLOCKED')
|
||||
})
|
||||
|
||||
test('normalizes array content-type before cache and parsing', async () => {
|
||||
getMock = async () => ({
|
||||
data: new TextEncoder().encode('plain body').buffer,
|
||||
headers: { 'content-type': ['text/plain', 'charset=utf-8'] },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
|
||||
const { clearWebFetchCache, getURLMarkdownContent } = await import('../utils')
|
||||
clearWebFetchCache()
|
||||
|
||||
const result = await getURLMarkdownContent(
|
||||
'https://example.com/plain.txt',
|
||||
new AbortController(),
|
||||
)
|
||||
|
||||
expect('type' in result).toBe(false)
|
||||
if ('type' in result) {
|
||||
throw new Error('unexpected redirect result')
|
||||
}
|
||||
expect(result.content).toBe('plain body')
|
||||
expect(result.contentType).toBe('text/plain, charset=utf-8')
|
||||
})
|
||||
})
|
||||
@@ -82,6 +82,34 @@ export function clearWebFetchCache(): void {
|
||||
DOMAIN_CHECK_CACHE.clear()
|
||||
}
|
||||
|
||||
function responseHeaderToString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map(responseHeaderToString)
|
||||
.filter((part): part is string => part !== undefined)
|
||||
return parts.length > 0 ? parts.join(', ') : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getResponseHeader(
|
||||
headers: AxiosResponse<unknown>['headers'],
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const headersWithGet = headers as { get?: (headerName: string) => unknown }
|
||||
if (typeof headersWithGet.get === 'function') {
|
||||
const value = responseHeaderToString(headersWithGet.get(name))
|
||||
if (value !== undefined) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return responseHeaderToString(headers[name.toLowerCase()])
|
||||
}
|
||||
|
||||
// Lazy singleton — defers the turndown → @mixmark-io/domino import (~1.4MB
|
||||
// retained heap) until the first HTML fetch, and reuses one instance across
|
||||
// calls (construction builds 15 rule objects; .turndown() is stateless).
|
||||
@@ -286,7 +314,7 @@ export async function getWithPermittedRedirects(
|
||||
error.response &&
|
||||
[301, 302, 307, 308].includes(error.response.status)
|
||||
) {
|
||||
const redirectLocation = error.response.headers.location
|
||||
const redirectLocation = getResponseHeader(error.response.headers, 'location')
|
||||
if (!redirectLocation) {
|
||||
throw new Error('Redirect missing Location header')
|
||||
}
|
||||
@@ -318,7 +346,8 @@ export async function getWithPermittedRedirects(
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
error.response?.status === 403 &&
|
||||
error.response.headers['x-proxy-error'] === 'blocked-by-allowlist'
|
||||
getResponseHeader(error.response.headers, 'x-proxy-error') ===
|
||||
'blocked-by-allowlist'
|
||||
) {
|
||||
const hostname = new URL(url).hostname
|
||||
throw new EgressBlockedError(hostname)
|
||||
@@ -430,7 +459,7 @@ export async function getURLMarkdownContent(
|
||||
// This lets GC reclaim up to MAX_HTTP_CONTENT_LENGTH (10MB) before Turndown
|
||||
// builds its DOM tree (which can be 3-5x the HTML size).
|
||||
;(response as { data: unknown }).data = null
|
||||
const contentType = response.headers['content-type'] ?? ''
|
||||
const contentType = getResponseHeader(response.headers, 'content-type') ?? ''
|
||||
|
||||
// Binary content: save raw bytes to disk with a proper extension so Claude
|
||||
// can inspect the file later. We still fall through to the utf-8 decode +
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
# 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 计算孵化步数
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
@@ -1,347 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,469 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,188 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,160 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
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('')
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
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))
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,388 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
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)]!
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,838 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
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'
|
||||
@@ -1,189 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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'
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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 }]
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
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}`
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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]
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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 }
|
||||
})
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import { Generations } from '@pkmn/data'
|
||||
import type { StatName } from '../types'
|
||||
|
||||
// Singleton Gen 9 data source
|
||||
const gens = new Generations(Dex as unknown as import('@pkmn/data').Dex)
|
||||
export const gen = gens.get(9)
|
||||
|
||||
// Stat name mapping: @pkmn/sim → our StatName
|
||||
export const FROM_DEX_STAT: Record<string, StatName> = {
|
||||
hp: 'hp', atk: 'attack', def: 'defense',
|
||||
spa: 'spAtk', spd: 'spDef', spe: 'speed',
|
||||
}
|
||||
|
||||
// Stat name mapping: our StatName → @pkmn/sim abbreviation
|
||||
export const TO_DEX_STAT: Record<StatName, string> = {
|
||||
hp: 'hp', attack: 'atk', defense: 'def',
|
||||
spAtk: 'spa', spDef: 'spd', speed: 'spe',
|
||||
}
|
||||
|
||||
/** Query species from Dex (uses Dex directly for full coverage) */
|
||||
export function getSpecies(id: string) {
|
||||
return Dex.species.get(id)
|
||||
}
|
||||
|
||||
/** Map Dex baseStats to our StatName format */
|
||||
export function mapBaseStats(dexStats: { hp: number; atk: number; def: number; spa: number; spd: number; spe: number }): Record<StatName, number> {
|
||||
const result = {} as Record<StatName, number>
|
||||
for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) {
|
||||
result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get gender rate from Dex genderRatio (M/F ratio → our genderRate 0-8) */
|
||||
export function mapGenderRatio(genderRatio?: { M: number; F: number } | string): number {
|
||||
if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless
|
||||
return Math.round(genderRatio.F * 8)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,169 +0,0 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import type { SpeciesData, SpeciesId, GrowthRate } from '../types'
|
||||
import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn'
|
||||
import { getNextEvolution } from './evolution'
|
||||
import { SPECIES_PERSONALITY } from './names'
|
||||
import { getGrowthRate, getCaptureRate, getBaseHappiness } from './pokedex-data'
|
||||
|
||||
// ─── Dynamic species list from @pkmn/sim Dex ───
|
||||
|
||||
const _rawSpecies = Dex.data.Species as Record<string, { num: number; forme?: string }>
|
||||
const _ids: string[] = []
|
||||
for (const [id, s] of Object.entries(_rawSpecies)) {
|
||||
if (s.num > 0 && Number.isInteger(s.num) && !s.forme) {
|
||||
_ids.push(id)
|
||||
}
|
||||
}
|
||||
_ids.sort((a, b) => (_rawSpecies[a]?.num ?? 9999) - (_rawSpecies[b]?.num ?? 9999))
|
||||
|
||||
/** All base species IDs from @pkmn/sim Dex (sorted by dex number) */
|
||||
export const ALL_SPECIES_IDS: SpeciesId[] = _ids
|
||||
|
||||
// ─── Supplementary data (fields not provided by @pkmn/sim) ───
|
||||
// Only curated entries for species with known data; defaults used for others.
|
||||
|
||||
interface SupplementEntry {
|
||||
flavorText: string
|
||||
}
|
||||
|
||||
const SUPPLEMENT: Partial<Record<string, SupplementEntry>> = {
|
||||
bulbasaur: {
|
||||
flavorText: 'A strange seed was planted on its back at birth. The plant sprouts and grows with this Pokémon.',
|
||||
},
|
||||
ivysaur: {
|
||||
flavorText: 'When the bulb on its back grows large, it appears to lose the ability to stand on its hind legs.',
|
||||
},
|
||||
venusaur: {
|
||||
flavorText: 'The plant blooms when it is absorbing solar energy. It stays on the move to seek sunlight.',
|
||||
},
|
||||
charmander: {
|
||||
flavorText: 'Obviously prefers hot places. When it rains, steam is said to spout from the tip of its tail.',
|
||||
},
|
||||
charmeleon: {
|
||||
flavorText: 'Tough fights could excite this Pokémon. When excited, it may blow out bluish-white flames.',
|
||||
},
|
||||
charizard: {
|
||||
flavorText: 'Spits fire that is hot enough to melt boulders. Known to cause forest fires unintentionally.',
|
||||
},
|
||||
squirtle: {
|
||||
flavorText: 'After birth, its back swells and hardens into a shell. Powerfully sprays foam from its mouth.',
|
||||
},
|
||||
wartortle: {
|
||||
flavorText: 'Often hides in water to stalk unwary prey. For swimming fast, it moves its ears to maintain balance.',
|
||||
},
|
||||
blastoise: {
|
||||
flavorText: 'It crushes its foe under its heavy body to cause fainting. In a pinch, it will withdraw inside its shell.',
|
||||
},
|
||||
pikachu: {
|
||||
flavorText: 'When several of these Pokémon gather, their electricity can build and cause lightning storms.',
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Evolution chain builder (from Dex evos field) ───
|
||||
|
||||
function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'] {
|
||||
const evo = getNextEvolution(speciesId)
|
||||
if (!evo) return undefined
|
||||
return [{ trigger: evo.trigger, level: evo.minLevel, into: evo.to }]
|
||||
}
|
||||
|
||||
// ─── Build SpeciesData from Dex + supplement ───
|
||||
|
||||
function buildSpeciesData(id: SpeciesId): SpeciesData {
|
||||
const dex = getSpecies(id)
|
||||
const personality = SPECIES_PERSONALITY[id]
|
||||
|
||||
if (!dex) {
|
||||
throw new Error(`Species ${id} not found in @pkmn/sim Dex`)
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: dex.name,
|
||||
names: { en: dex.name },
|
||||
dexNumber: dex.num,
|
||||
genderRate: mapGenderRatio(dex.genderRatio as { M: number; F: number } | undefined),
|
||||
baseStats: mapBaseStats(dex.baseStats),
|
||||
types: dex.types.map((t: string) => t.toLowerCase()) as [string, string?],
|
||||
baseHappiness: getBaseHappiness(id),
|
||||
growthRate: getGrowthRate(id) as GrowthRate,
|
||||
captureRate: getCaptureRate(id),
|
||||
personality: personality ?? '',
|
||||
evolutionChain: buildEvolutionChain(id),
|
||||
shinyChance: 1 / 4096,
|
||||
flavorText: SUPPLEMENT[id]?.flavorText ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── In-memory cache (built once, immutable) ───
|
||||
|
||||
const speciesCache = new Map<SpeciesId, SpeciesData>()
|
||||
|
||||
function getCached(id: SpeciesId): SpeciesData {
|
||||
let data = speciesCache.get(id)
|
||||
if (!data) {
|
||||
data = buildSpeciesData(id)
|
||||
speciesCache.set(id, data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ─── Sync getters (used by all consumers) ───
|
||||
|
||||
/** Get species data by ID. */
|
||||
export function getSpeciesData(id: SpeciesId): SpeciesData {
|
||||
return getCached(id)
|
||||
}
|
||||
|
||||
/** Get all species data as a Record. */
|
||||
export function getAllSpeciesData(): Record<SpeciesId, SpeciesData> {
|
||||
const result = {} as Record<SpeciesId, SpeciesData>
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
result[id] = getCached(id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous getter that returns the full map.
|
||||
* @deprecated Use getSpeciesData / getAllSpeciesData
|
||||
*/
|
||||
export const SPECIES_DATA: Record<SpeciesId, SpeciesData> = new Proxy({} as Record<SpeciesId, SpeciesData>, {
|
||||
get(_, prop: string) {
|
||||
return getSpeciesData(prop as SpeciesId)
|
||||
},
|
||||
ownKeys() {
|
||||
return ALL_SPECIES_IDS as unknown as string[]
|
||||
},
|
||||
has(_, prop) {
|
||||
return ALL_SPECIES_IDS.includes(prop as SpeciesId)
|
||||
},
|
||||
getOwnPropertyDescriptor(_, prop) {
|
||||
if (ALL_SPECIES_IDS.includes(prop as SpeciesId)) {
|
||||
return { configurable: true, enumerable: true, value: getSpeciesData(prop as SpeciesId) }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
/** No-op — data is now built-in from @pkmn/sim */
|
||||
export function ensureSpeciesData(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** No-op — data is now built-in from @pkmn/sim */
|
||||
export async function refreshAllSpeciesData(): Promise<void> {
|
||||
// Clear cache to force rebuild
|
||||
speciesCache.clear()
|
||||
}
|
||||
|
||||
// ─── Dex number mapping (dynamic) ───
|
||||
|
||||
export const DEX_TO_SPECIES: Record<number, SpeciesId> = (() => {
|
||||
const map: Record<number, SpeciesId> = {}
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
const s = _rawSpecies[id]
|
||||
if (s) map[s.num] = id
|
||||
}
|
||||
return map
|
||||
})()
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { GrowthRate } from '../types'
|
||||
|
||||
/**
|
||||
* Calculate total XP required to reach a given level for a growth rate type.
|
||||
* Follows original Pokémon XP curve formulas.
|
||||
*/
|
||||
export function xpForLevel(level: number, growthRate: GrowthRate): number {
|
||||
if (level <= 1) return 0
|
||||
const n = level
|
||||
switch (growthRate) {
|
||||
case 'erratic':
|
||||
return xpErratic(n)
|
||||
case 'fast':
|
||||
return Math.floor((n * n * n * 4) / 5)
|
||||
case 'medium-fast':
|
||||
return n * n * n
|
||||
case 'medium-slow':
|
||||
return Math.floor((6 / 5) * n * n * n - 15 * n * n + 100 * n - 140)
|
||||
case 'slow':
|
||||
return Math.floor((5 * n * n * n) / 4)
|
||||
case 'fluctuating':
|
||||
return xpFluctuating(n)
|
||||
default:
|
||||
return n * n * n
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate level from total XP for a given growth rate.
|
||||
*/
|
||||
export function levelFromXp(totalXp: number, growthRate: GrowthRate): number {
|
||||
// Binary search for level
|
||||
let lo = 1
|
||||
let hi = 100
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2)
|
||||
if (xpForLevel(mid, growthRate) <= totalXp) {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
return Math.min(lo, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* XP needed to go from current level to next level.
|
||||
*/
|
||||
export function xpToNextLevel(currentLevel: number, totalXp: number, growthRate: GrowthRate): number {
|
||||
if (currentLevel >= 100) return 0
|
||||
const nextLevelXp = xpForLevel(currentLevel + 1, growthRate)
|
||||
return nextLevelXp - totalXp
|
||||
}
|
||||
|
||||
// Erratic growth rate (complex piecewise)
|
||||
function xpErratic(n: number): number {
|
||||
if (n <= 1) return 0
|
||||
if (n <= 50) {
|
||||
return Math.floor((n * n * n * (100 - n)) / 50)
|
||||
}
|
||||
if (n <= 68) {
|
||||
return Math.floor((n * n * n * (150 - n)) / 100)
|
||||
}
|
||||
if (n <= 98) {
|
||||
return Math.floor((n * n * n * Math.floor((1911 - 10 * n) / 3)) / 500)
|
||||
}
|
||||
// n 99-100
|
||||
return Math.floor((n * n * n * (160 - n)) / 100)
|
||||
}
|
||||
|
||||
// Fluctuating growth rate (complex piecewise)
|
||||
function xpFluctuating(n: number): number {
|
||||
if (n <= 1) return 0
|
||||
if (n <= 15) {
|
||||
return Math.floor((n * n * n * (Math.floor((n + 1) / 3) + 24)) / 50)
|
||||
}
|
||||
if (n <= 36) {
|
||||
return Math.floor((n * n * n * (n + 14)) / 50)
|
||||
}
|
||||
return Math.floor((n * n * n * (Math.floor(n / 2) + 32)) / 50)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// Types
|
||||
export type {
|
||||
StatName,
|
||||
NatureName,
|
||||
NatureStat,
|
||||
NatureEffect,
|
||||
MoveSlot,
|
||||
ItemId,
|
||||
PCBox,
|
||||
BagEntry,
|
||||
Bag,
|
||||
SpeciesId,
|
||||
Gender,
|
||||
EvolutionTrigger,
|
||||
EvolutionCondition,
|
||||
GrowthRate,
|
||||
SpeciesData,
|
||||
Creature,
|
||||
Egg,
|
||||
DexEntry,
|
||||
BuddyData,
|
||||
StatsResult,
|
||||
EvolutionResult,
|
||||
SpriteCache,
|
||||
AnimMode,
|
||||
} from './types'
|
||||
export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS, EMPTY_MOVE } from './types'
|
||||
|
||||
// Data
|
||||
export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './dex/species'
|
||||
export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './dex/evMapping'
|
||||
export { xpForLevel, levelFromXp, xpToNextLevel } from './dex/xpTable'
|
||||
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './dex/names'
|
||||
export { getAllNatureNames, randomNature, getNatureEffect } from './dex/nature'
|
||||
export { getNextEvolution } from './dex/evolution'
|
||||
export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './dex/learnsets'
|
||||
export { FROM_DEX_STAT, TO_DEX_STAT } from './dex/pkmn'
|
||||
|
||||
// Battle
|
||||
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './battle/types'
|
||||
export { createBattle, executeTurn, executeSwitch, type BattleInit } from './battle/engine'
|
||||
export { settleBattle, applyMoveLearn, applyEvolution } from './battle/settlement'
|
||||
export { chooseAIMove } from './battle/ai'
|
||||
|
||||
// Core
|
||||
export { generateCreature, calculateStats, getCreatureName, recalculateLevel, getActiveCreature, getTotalEV } from './core/creature'
|
||||
export { determineGender, getGenderSymbol } from './core/gender'
|
||||
export { awardXP, getXpProgress } from './core/experience'
|
||||
export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort'
|
||||
export { checkEvolution, evolve, canEvolveFurther } from './core/evolution'
|
||||
export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg'
|
||||
export {
|
||||
loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy,
|
||||
updateDailyStats, incrementTurns,
|
||||
addToParty, removeFromParty, swapPartySlots, setActivePartyMember, compactParty,
|
||||
depositToBox, withdrawFromBox, moveInBox, renameBox,
|
||||
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
|
||||
addItemToBag, removeItemFromBag, getItemCount,
|
||||
} from './core/storage'
|
||||
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
|
||||
|
||||
// Sprites
|
||||
export { renderAnimatedSprite, shrinkSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer'
|
||||
export { getFallbackSprite } from './sprites/fallback'
|
||||
export { SpeciesPicker } from './ui/SpeciesPicker'
|
||||
|
||||
// UI Components
|
||||
export { CompanionCard } from './ui/CompanionCard'
|
||||
export { PokedexView } from './ui/PokedexView'
|
||||
export { EggView } from './ui/EggView'
|
||||
export { EvolutionAnim } from './ui/EvolutionAnim'
|
||||
export { StatBar } from './ui/StatBar'
|
||||
export { SpeciesDetail } from './ui/SpeciesDetail'
|
||||
export { SpriteAnimator } from './ui/SpriteAnimator'
|
||||
export { BattleSprite } from './ui/BattleSprite'
|
||||
export { BattleField } from './ui/BattleField'
|
||||
export { BattleConfigPanel } from './ui/BattleConfigPanel'
|
||||
export { BattleScene } from './ui/BattleScene'
|
||||
export type { MenuPhase } from './ui/BattleScene'
|
||||
export { HpCard } from './ui/HpCard'
|
||||
export { BattleMenu } from './ui/BattleMenu'
|
||||
export { BattleLogPanel } from './ui/BattleLogPanel'
|
||||
export { SwitchPanel } from './ui/SwitchPanel'
|
||||
export { ItemPanel } from './ui/ItemPanel'
|
||||
export { BattleResultPanel } from './ui/BattleResultPanel'
|
||||
export { MoveLearnPanel } from './ui/MoveLearnPanel'
|
||||
export { BattleFlow } from './ui/BattleFlow'
|
||||
export type { BattleFlowHandle } from './ui/BattleFlow'
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { SpeciesId } from '../types'
|
||||
|
||||
/**
|
||||
* Fallback ASCII art for when sprites can't be fetched.
|
||||
* Curated sprites for original 10 species; generic fallback for all others.
|
||||
*/
|
||||
const FALLBACK_SPRITES: Partial<Record<string, string[]>> = {
|
||||
bulbasaur: [
|
||||
' _,,--.,,_ ',
|
||||
' ,\' `, ',
|
||||
' ; o o ; ',
|
||||
' ; ~~~~~~~~ ; ',
|
||||
' `--,,__,,--\' ',
|
||||
],
|
||||
ivysaur: [
|
||||
' _,--..,_ ',
|
||||
' ,\' (o)(o) `, ',
|
||||
' ; ~~~~~~ ; ',
|
||||
' ; \\====/ ; ',
|
||||
' `--,,__,,--\' ',
|
||||
],
|
||||
venusaur: [
|
||||
' _,,,---.,,_ ',
|
||||
' ,\' (o) (o) `, ',
|
||||
' ; ~~~~~~~~ ; ',
|
||||
' ; /========\\ ; ',
|
||||
' `-,,,____,,,-\' ',
|
||||
],
|
||||
charmander: [
|
||||
' ,^., ',
|
||||
' ( o o) ',
|
||||
' / ~~~ \\ ',
|
||||
' / \\___/ \\ ',
|
||||
' ^^^ ^^^ ',
|
||||
],
|
||||
charmeleon: [
|
||||
' ,--^. ',
|
||||
' ( o o) ',
|
||||
' / ~~~~~ \\ ',
|
||||
' / \\___/ \\ ',
|
||||
' ^^ ^^ ',
|
||||
],
|
||||
charizard: [
|
||||
' /\\ /\\ ',
|
||||
' / \\/ \\ ',
|
||||
' | o o | ',
|
||||
' | ~~~~~~ | ',
|
||||
' \\______/ ',
|
||||
],
|
||||
squirtle: [
|
||||
' _____ ',
|
||||
' ,\' `, ',
|
||||
' ; o o ; ',
|
||||
' ; ~~~~~~~ ; ',
|
||||
' `-.,__,\' ',
|
||||
],
|
||||
wartortle: [
|
||||
' _______ ',
|
||||
' ,\' `, ',
|
||||
' ; o o ; ',
|
||||
' ; ~~~~~~~~ ; ',
|
||||
' `-.,__,\' ',
|
||||
],
|
||||
blastoise: [
|
||||
' .________. ',
|
||||
' | o o | ',
|
||||
' | ~~~~~~~~ | ',
|
||||
' | [====] | ',
|
||||
' `-.,__,\' ',
|
||||
],
|
||||
pikachu: [
|
||||
' /\\ /\\ ',
|
||||
' ( o o ) ',
|
||||
' \\ ~~~ / ',
|
||||
' /`-...-\'\\ ',
|
||||
' ^^ ^^ ',
|
||||
],
|
||||
}
|
||||
|
||||
/** Generic fallback sprite for species without curated ASCII art */
|
||||
const GENERIC_SPRITE: string[] = [
|
||||
' .---. ',
|
||||
' / o o \\ ',
|
||||
' | --- | ',
|
||||
' \\ / ',
|
||||
' `---\' ',
|
||||
]
|
||||
|
||||
/**
|
||||
* Get fallback ASCII sprite lines for a species.
|
||||
*/
|
||||
export function getFallbackSprite(speciesId: SpeciesId): string[] {
|
||||
return FALLBACK_SPRITES[speciesId] ?? GENERIC_SPRITE
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from './renderer'
|
||||
export type { AnimMode } from '../types'
|
||||
export { getFallbackSprite } from './fallback'
|
||||
export { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
|
||||
@@ -1,358 +0,0 @@
|
||||
import type { AnimMode } from '../types'
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Pixel Grid Model — ANSI-safe animation foundation
|
||||
// ═══════════════════════════════════════════════════════
|
||||
//
|
||||
// Every sprite line is parsed into a Pixel[] row:
|
||||
// Pixel = { char: '▄', style: '\x1b[33m' }
|
||||
//
|
||||
// style = full accumulated ANSI state at that position,
|
||||
// so any transform (shift, reverse, slice) just moves Pixels
|
||||
// around without ever touching raw ANSI strings.
|
||||
//
|
||||
// After transform, render each row back: reset → style → char → reset
|
||||
|
||||
export interface Pixel {
|
||||
char: string
|
||||
/** Full ANSI state needed to render this pixel */
|
||||
style: string
|
||||
}
|
||||
|
||||
const EMPTY_PIXEL: Pixel = { char: ' ', style: '' }
|
||||
const EMPTY_ROW: Pixel[] = []
|
||||
export { EMPTY_PIXEL, EMPTY_ROW }
|
||||
|
||||
// ─── Parse / Render ───────────────────────────────────
|
||||
|
||||
/** Parse a raw ANSI string line into a Pixel row */
|
||||
function parseLine(line: string): Pixel[] {
|
||||
const pixels: Pixel[] = []
|
||||
let style = ''
|
||||
let i = 0
|
||||
while (i < line.length) {
|
||||
if (line[i] === '\x1b') {
|
||||
// Collect full ANSI escape sequence: \x1b[ ... m
|
||||
const start = i
|
||||
i++ // skip \x1b
|
||||
if (i < line.length && line[i] === '[') {
|
||||
i++ // skip [
|
||||
while (i < line.length && line[i] !== 'm') i++
|
||||
if (i < line.length) i++ // skip m
|
||||
}
|
||||
style += line.slice(start, i)
|
||||
} else {
|
||||
// Visible character (handle multi-byte Unicode)
|
||||
const cp = line.codePointAt(i)!
|
||||
const ch = String.fromCodePoint(cp)
|
||||
pixels.push({ char: ch, style })
|
||||
i += ch.length
|
||||
}
|
||||
}
|
||||
return pixels
|
||||
}
|
||||
|
||||
/** Render a Pixel row back to an ANSI string */
|
||||
function renderRow(pixels: Pixel[]): string {
|
||||
if (pixels.length === 0) return ''
|
||||
let out = ''
|
||||
let lastStyle: string | null = null
|
||||
for (const p of pixels) {
|
||||
if (p.style !== lastStyle) {
|
||||
out += '\x1b[0m' + p.style // reset then apply
|
||||
lastStyle = p.style
|
||||
}
|
||||
out += p.char
|
||||
}
|
||||
out += '\x1b[0m' // final reset
|
||||
return out
|
||||
}
|
||||
|
||||
export function parseSprite(lines: string[]): Pixel[][] {
|
||||
return lines.map(parseLine)
|
||||
}
|
||||
|
||||
export function renderSprite(grid: Pixel[][]): string[] {
|
||||
return grid.map(renderRow)
|
||||
}
|
||||
|
||||
// ─── Grid Transforms ──────────────────────────────────
|
||||
// All transforms operate on Pixel[][], never touch raw strings.
|
||||
|
||||
/** Horizontal shift — positive = right, negative = left */
|
||||
function shiftH(grid: Pixel[][], n: number): Pixel[][] {
|
||||
if (n > 0) return grid.map(row => [...Array(n).fill(EMPTY_PIXEL), ...row])
|
||||
if (n < 0) return grid.map(row => row.slice(Math.abs(n)))
|
||||
return grid
|
||||
}
|
||||
|
||||
/** Vertical shift up — removes rows from top, pads empty at bottom */
|
||||
function shiftUp(grid: Pixel[][], n: number): Pixel[][] {
|
||||
if (n <= 0) return grid
|
||||
const height = grid.length
|
||||
const shifted = grid.slice(n)
|
||||
while (shifted.length < height) shifted.push(EMPTY_ROW)
|
||||
return shifted
|
||||
}
|
||||
|
||||
/** Mirror map — characters that change when flipped horizontally */
|
||||
const MIRROR: Record<string, string> = {
|
||||
'/': '\\', '\\': '/',
|
||||
'(': ')', ')': '(',
|
||||
'<': '>', '>': '<',
|
||||
'{': '}', '}': '{',
|
||||
'[': ']', ']': '[',
|
||||
'╱': '╲', '╲': '╱',
|
||||
'▌': '▐', '▐': '▌',
|
||||
'▎': '▏', '▏': '▎',
|
||||
'◀': '▶', '▶': '◀',
|
||||
'◄': '►', '►': '◄',
|
||||
'→': '←', '←': '→',
|
||||
'↗': '↙', '↙': '↗',
|
||||
'↘': '↖', '↖': '↘',
|
||||
'`': "'", "'": '`',
|
||||
',': '´', '´': ',',
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal mirror — reverse each row.
|
||||
* When mirrorChars=true, also swap directional characters (correct mirror).
|
||||
* When mirrorChars=false, only reverse positions (more visible "flip" effect).
|
||||
*/
|
||||
function reverseH(grid: Pixel[][], mirrorChars = true): Pixel[][] {
|
||||
const width = Math.max(0, ...grid.map(row => row.length))
|
||||
return grid.map(row =>
|
||||
[...row, ...Array(width - row.length).fill(EMPTY_PIXEL)]
|
||||
.reverse()
|
||||
.map(p => ({
|
||||
...p,
|
||||
char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/** Replace eye-like characters with dash */
|
||||
function blinkEyes(grid: Pixel[][]): Pixel[][] {
|
||||
return grid.map(row =>
|
||||
row.map(p =>
|
||||
/[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Idle Sequence
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
const IDLE_SEQUENCE: AnimMode[] = [
|
||||
'idle', 'idle',
|
||||
'breathe', 'breathe',
|
||||
'idle',
|
||||
'blink',
|
||||
'idle',
|
||||
'bounce',
|
||||
'idle',
|
||||
'fidget', 'fidget',
|
||||
'idle',
|
||||
'breathe', 'breathe',
|
||||
'idle',
|
||||
'flip', 'flip', 'flip',
|
||||
'idle', 'idle',
|
||||
'bounce',
|
||||
'idle',
|
||||
'blink',
|
||||
'idle',
|
||||
'excited', 'excited',
|
||||
'idle',
|
||||
]
|
||||
|
||||
export function getIdleAnimMode(tick: number): AnimMode {
|
||||
return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Public API
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Flip sprite lines horizontally (mirror + swap directional chars).
|
||||
* For player Pokemon facing right towards the opponent.
|
||||
*/
|
||||
export function flipSpriteLines(lines: string[]): string[] {
|
||||
return renderSprite(reverseH(parseSprite(lines), true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply animation transform to sprite lines.
|
||||
* Internally: parse ANSI → Pixel grid → transform → render back.
|
||||
*/
|
||||
export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] {
|
||||
const grid = parseSprite(lines)
|
||||
|
||||
let result: Pixel[][] = grid
|
||||
|
||||
switch (mode) {
|
||||
case 'idle':
|
||||
break
|
||||
case 'breathe':
|
||||
// Right sway → center
|
||||
result = shiftH(result, tick % 4 < 2 ? 3 : 0)
|
||||
break
|
||||
case 'blink':
|
||||
result = blinkEyes(result)
|
||||
break
|
||||
case 'fidget':
|
||||
// Big right sway → center
|
||||
result = shiftH(result, tick % 2 === 0 ? 4 : 0)
|
||||
break
|
||||
case 'bounce': {
|
||||
const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0]
|
||||
const h = PATTERN[tick % PATTERN.length]
|
||||
result = shiftUp(result, h)
|
||||
break
|
||||
}
|
||||
case 'walkLeft':
|
||||
// Step right → center (mimics bounce-back from left step)
|
||||
result = shiftH(result, tick % 4 === 0 ? 0 : 3)
|
||||
break
|
||||
case 'walkRight':
|
||||
// Step right → further right → center
|
||||
result = shiftH(result, (tick % 4) * 2)
|
||||
break
|
||||
case 'flip':
|
||||
// Pure position reversal — do NOT mirror chars so / \ ( )
|
||||
// visibly swap, making the flip obvious.
|
||||
result = reverseH(result, false)
|
||||
break
|
||||
case 'excited':
|
||||
// Jitter right ↔ further right (never crop)
|
||||
result = shiftH(result, tick % 2 === 0 ? 1 : 4)
|
||||
break
|
||||
case 'pet':
|
||||
break // overlay handled by SpriteAnimator
|
||||
}
|
||||
|
||||
return renderSprite(result)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Sprite Shrink (nearest-neighbor / block sampling)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function pixelWeight(char: string): number {
|
||||
if (char === ' ') return 0
|
||||
if ('█▓'.includes(char)) return 4
|
||||
if ('▒■▀▄'.includes(char)) return 3
|
||||
if ('░▌▐/\\()<>'.includes(char)) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function pickDominantPixel(
|
||||
grid: Pixel[][],
|
||||
x0: number,
|
||||
x1: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): Pixel {
|
||||
let best: Pixel = EMPTY_PIXEL
|
||||
let bestScore = -1
|
||||
const cx = (x0 + x1 - 1) / 2
|
||||
const cy = (y0 + y1 - 1) / 2
|
||||
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const pixel = grid[y]?.[x] ?? EMPTY_PIXEL
|
||||
const weight = pixelWeight(pixel.char)
|
||||
if (weight === 0) continue
|
||||
|
||||
const dist = Math.abs(x - cx) + Math.abs(y - cy)
|
||||
const score = weight * 10 - dist
|
||||
if (score > bestScore) {
|
||||
best = pixel
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore >= 0 ? best : EMPTY_PIXEL
|
||||
}
|
||||
|
||||
function resampleGrid(grid: Pixel[][], targetWidth: number, targetHeight: number): Pixel[][] {
|
||||
const srcHeight = grid.length
|
||||
const srcWidth = Math.max(0, ...grid.map(row => row.length))
|
||||
|
||||
return Array.from({ length: targetHeight }, (_, y) => {
|
||||
const y0 = Math.floor((y * srcHeight) / targetHeight)
|
||||
const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * srcHeight) / targetHeight))
|
||||
|
||||
return Array.from({ length: targetWidth }, (_, x) => {
|
||||
const x0 = Math.floor((x * srcWidth) / targetWidth)
|
||||
const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * srcWidth) / targetWidth))
|
||||
return pickDominantPixel(grid, x0, x1, y0, y1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isEmptyRow(row: Pixel[]): boolean {
|
||||
return row.length === 0 || row.every(pixel => pixel.char === ' ')
|
||||
}
|
||||
|
||||
function trimEmptyMargin(grid: Pixel[][]): Pixel[][] {
|
||||
if (grid.length === 0) return grid
|
||||
|
||||
let top = 0
|
||||
let bottom = grid.length - 1
|
||||
while (top <= bottom && isEmptyRow(grid[top] ?? [])) top++
|
||||
while (bottom >= top && isEmptyRow(grid[bottom] ?? [])) bottom--
|
||||
|
||||
if (top > bottom) return []
|
||||
|
||||
const sliced = grid.slice(top, bottom + 1)
|
||||
const width = Math.max(0, ...sliced.map(row => row.length))
|
||||
|
||||
let left = 0
|
||||
let right = width - 1
|
||||
const isEmptyCol = (x: number) => sliced.every(row => (row[x]?.char ?? ' ') === ' ')
|
||||
|
||||
while (left <= right && isEmptyCol(left)) left++
|
||||
while (right >= left && isEmptyCol(right)) right--
|
||||
|
||||
return sliced.map(row => row.slice(left, right + 1))
|
||||
}
|
||||
|
||||
export function shrinkSprite(
|
||||
lines: string[],
|
||||
opts: { scale?: number; maxWidth?: number; maxHeight?: number },
|
||||
): string[] {
|
||||
const grid = trimEmptyMargin(parseSprite(lines))
|
||||
const srcHeight = grid.length
|
||||
const srcWidth = Math.max(0, ...grid.map(row => row.length))
|
||||
|
||||
if (srcWidth === 0 || srcHeight === 0) return lines
|
||||
|
||||
const baseScale = Math.min(opts.scale ?? 0.75, 1)
|
||||
const widthScale = opts.maxWidth ? opts.maxWidth / srcWidth : 1
|
||||
const heightScale = opts.maxHeight ? opts.maxHeight / srcHeight : 1
|
||||
const finalScale = Math.min(baseScale, widthScale, heightScale, 1)
|
||||
|
||||
if (finalScale >= 1) return lines
|
||||
|
||||
const targetWidth = Math.max(1, Math.floor(srcWidth * finalScale))
|
||||
const targetHeight = Math.max(1, Math.floor(srcHeight * finalScale))
|
||||
|
||||
return renderSprite(resampleGrid(grid, targetWidth, targetHeight))
|
||||
}
|
||||
|
||||
// ─── Heart overlay (kept for SpriteAnimator convenience) ──
|
||||
|
||||
const PET_HEARTS = [
|
||||
[' ♥ ', ' '],
|
||||
[' ♥ ♥ ', ' ♥ '],
|
||||
[' ♥ ♥ ', ' ♥ ♥ '],
|
||||
[' ♥ ♥ ', ' ♥ ♥ '],
|
||||
[' ♥ ', ' ♥ ♥ '],
|
||||
]
|
||||
|
||||
export function getPetOverlay(tick: number): string[] {
|
||||
return PET_HEARTS[tick % PET_HEARTS.length]
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
// 6 attributes (mapped to programming scenarios)
|
||||
export type StatName = 'hp' | 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed'
|
||||
export const STAT_NAMES: StatName[] = ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed']
|
||||
export const STAT_LABELS: Record<StatName, string> = {
|
||||
hp: 'HP',
|
||||
attack: 'ATK',
|
||||
defense: 'DEF',
|
||||
spAtk: 'SPA',
|
||||
spDef: 'SPD',
|
||||
speed: 'SPE',
|
||||
}
|
||||
|
||||
// Species IDs — dynamically populated from @pkmn/sim Dex (1025 species)
|
||||
export type SpeciesId = string
|
||||
|
||||
// Re-exported from dex/species.ts (computed from Dex.data at module load)
|
||||
export { ALL_SPECIES_IDS } from './dex/species'
|
||||
|
||||
// Nature (delegated to @pkmn/sim Dex.natures)
|
||||
export type NatureName = string
|
||||
export type NatureStat = 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed'
|
||||
export type NatureEffect = { plus: NatureStat | null; minus: NatureStat | null }
|
||||
|
||||
// Move slot
|
||||
export type MoveSlot = { id: string; pp: number; maxPp: number }
|
||||
export const EMPTY_MOVE: MoveSlot = { id: '', pp: 0, maxPp: 0 }
|
||||
|
||||
// Item ID (Showdown format string)
|
||||
export type ItemId = string
|
||||
|
||||
// PC box (fixed 30 slots)
|
||||
export type PCBox = { name: string; slots: (string | null)[] }
|
||||
|
||||
// Bag
|
||||
export type BagEntry = { id: ItemId; count: number }
|
||||
export type Bag = { items: BagEntry[] }
|
||||
|
||||
// Gender
|
||||
export type Gender = 'male' | 'female' | 'genderless'
|
||||
|
||||
// Evolution trigger types
|
||||
export type EvolutionTrigger = 'level_up' | 'item' | 'trade' | 'friendship'
|
||||
|
||||
export type EvolutionCondition = {
|
||||
trigger: EvolutionTrigger
|
||||
level?: number // Level evolution: target level
|
||||
minFriendship?: number // Friendship evolution
|
||||
item?: string // Item evolution
|
||||
into: SpeciesId // Evolves into
|
||||
}
|
||||
|
||||
// Growth rate types (from PokeAPI)
|
||||
export type GrowthRate = 'slow' | 'medium-slow' | 'medium-fast' | 'fast' | 'erratic' | 'fluctuating'
|
||||
|
||||
// Species base data
|
||||
export type SpeciesData = {
|
||||
id: SpeciesId
|
||||
name: string // English name
|
||||
names: Record<string, string> // Multilingual names { ja, en, zh }
|
||||
dexNumber: number // Pokédex number (1-10 MVP)
|
||||
genderRate: number // Female probability (0-8, -1 = genderless). femaleChance = genderRate / 8
|
||||
baseStats: Record<StatName, number>
|
||||
types: [string, string?] // Types (grass/poison, fire, water etc.)
|
||||
baseHappiness: number // Base friendship
|
||||
growthRate: GrowthRate
|
||||
captureRate: number
|
||||
personality: string // Default personality description
|
||||
evolutionChain?: EvolutionCondition[]
|
||||
shinyChance: number // Shiny probability (default 1/4096)
|
||||
flavorText?: string // Pokédex description
|
||||
}
|
||||
|
||||
// Instantiated creature (stored in buddy-data.json)
|
||||
export type Creature = {
|
||||
id: string // UUID
|
||||
speciesId: SpeciesId
|
||||
nickname?: string // User-defined name
|
||||
gender: Gender
|
||||
level: number
|
||||
xp: number // Current level progress XP
|
||||
totalXp: number // Total accumulated XP
|
||||
nature: NatureName // Character nature
|
||||
ev: Record<StatName, number> // Effort values
|
||||
iv: Record<StatName, number> // Individual values (0-31)
|
||||
moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // 4 move slots
|
||||
ability: string // Showdown ability ID
|
||||
heldItem: ItemId | null // Held item
|
||||
friendship: number // Friendship (0-255)
|
||||
isShiny: boolean
|
||||
hatchedAt: number // Timestamp when obtained
|
||||
pokeball: string // Pokeball type
|
||||
}
|
||||
|
||||
// Egg
|
||||
export type Egg = {
|
||||
id: string
|
||||
obtainedAt: number
|
||||
stepsRemaining: number // Remaining hatch steps
|
||||
totalSteps: number // Original total steps (for progress calc)
|
||||
speciesId: SpeciesId // Pre-determined species
|
||||
}
|
||||
|
||||
// Pokédex entry
|
||||
export type DexEntry = {
|
||||
speciesId: SpeciesId
|
||||
discoveredAt: number
|
||||
caughtCount: number // Number caught
|
||||
bestLevel: number // Highest level record
|
||||
}
|
||||
|
||||
// buddy-data.json complete structure
|
||||
export type BuddyData = {
|
||||
version: 2
|
||||
party: (string | null)[] // Always length 6, party[0] = active buddy
|
||||
boxes: PCBox[] // PC storage (default 8 boxes × 30 slots)
|
||||
creatures: Creature[]
|
||||
eggs: Egg[]
|
||||
dex: DexEntry[]
|
||||
bag: Bag
|
||||
stats: {
|
||||
totalTurns: number
|
||||
consecutiveDays: number
|
||||
lastActiveDate: string // ISO date
|
||||
totalEggsObtained: number
|
||||
totalEvolutions: number
|
||||
battlesWon: number
|
||||
battlesLost: number
|
||||
}
|
||||
}
|
||||
|
||||
// Calculated stats result
|
||||
export type StatsResult = Record<StatName, number>
|
||||
|
||||
// Evolution result
|
||||
export type EvolutionResult = {
|
||||
from: SpeciesId
|
||||
to: SpeciesId
|
||||
newLevel: number
|
||||
}
|
||||
|
||||
// Sprite cache entry
|
||||
export type SpriteCache = {
|
||||
speciesId: SpeciesId
|
||||
lines: string[]
|
||||
width: number
|
||||
height: number
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
// Animation mode
|
||||
export type AnimMode =
|
||||
| 'idle'
|
||||
| 'breathe'
|
||||
| 'blink'
|
||||
| 'fidget'
|
||||
| 'bounce'
|
||||
| 'walkLeft'
|
||||
| 'walkRight'
|
||||
| 'flip'
|
||||
| 'excited'
|
||||
| 'pet'
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Creature, SpeciesId } from '../types'
|
||||
import { getCreatureName } from '../core/creature'
|
||||
|
||||
interface BattleConfigPanelProps {
|
||||
party: (Creature | null)[]
|
||||
cursorIndex: number
|
||||
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const OPTIONS = [
|
||||
{ label: '随机遇战(等级自动匹配)', color: 'warning' as const },
|
||||
{ label: '指定对手', color: 'inactive' as const },
|
||||
]
|
||||
|
||||
export function BattleConfigPanel({ party, cursorIndex }: BattleConfigPanelProps) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="claude"
|
||||
borderText={{ content: ' 战斗配置 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
{/* Party display */}
|
||||
<Text bold color="claude">队伍</Text>
|
||||
{party.map((creature, i) => {
|
||||
if (!creature) return (
|
||||
<Box key={i}>
|
||||
<Text dimColor> [空]</Text>
|
||||
</Box>
|
||||
)
|
||||
const hpPercent = 100
|
||||
const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
|
||||
const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
|
||||
const isLead = i === 0
|
||||
return (
|
||||
<Box key={creature.id}>
|
||||
<Text color={isLead ? 'claude' : 'inactive'}>
|
||||
{isLead ? ' ▸ ' : ' '}
|
||||
</Text>
|
||||
<Text bold={isLead}>{getCreatureName(creature)}</Text>
|
||||
<Text> Lv.{creature.level} </Text>
|
||||
<Text color="success">{hpBar}</Text>
|
||||
<Text color="inactive">{hpEmpty}</Text>
|
||||
<Text> {hpPercent}%</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Options */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="claude">选择对手</Text>
|
||||
{OPTIONS.map((opt, i) => (
|
||||
<Box key={i}>
|
||||
<Text color={i === cursorIndex ? 'success' : 'inactive'}>
|
||||
{i === cursorIndex ? ' ▶ ' : ' '}
|
||||
</Text>
|
||||
<Text bold={i === cursorIndex} color={i === cursorIndex ? opt.color : 'inactive'}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] 选择 · [Enter] 确认 · [ESC] 取消</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { parseSprite, renderSprite, flipSpriteLines, EMPTY_PIXEL, EMPTY_ROW } from '../sprites/renderer'
|
||||
import type { Pixel } from '../sprites/renderer'
|
||||
|
||||
/**
|
||||
* Combined battle field — composites both sprites into one canvas.
|
||||
* Opponent (top-right) and player (bottom-left) share overlapping rows,
|
||||
* like the classic GBA Pokemon battle layout.
|
||||
*
|
||||
* Bounce: fast 0-1-2-1px vertical, staggered between the two.
|
||||
*/
|
||||
|
||||
const BOUNCE = [0, 1, 2, 1]
|
||||
/** How many rows the player sprite overlaps into opponent's area */
|
||||
const OVERLAP = 3
|
||||
|
||||
interface BattleFieldProps {
|
||||
opponentLines: string[]
|
||||
playerLines: string[]
|
||||
animEnabled?: boolean
|
||||
}
|
||||
|
||||
export function BattleField({ opponentLines, playerLines, animEnabled = true }: BattleFieldProps) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!animEnabled) return
|
||||
const timer = setInterval(() => setTick(t => t + 1), 120)
|
||||
return () => clearInterval(timer)
|
||||
}, [animEnabled])
|
||||
|
||||
// Parse & flip (cached)
|
||||
const oppGrid = useMemo(() => parseSprite(opponentLines), [opponentLines])
|
||||
const playerGrid = useMemo(() => parseSprite(flipSpriteLines(playerLines)), [playerLines])
|
||||
|
||||
// Composited canvas
|
||||
const canvas = useMemo(() => {
|
||||
const oppH = oppGrid.length
|
||||
const playerH = playerGrid.length
|
||||
const totalH = oppH + playerH - OVERLAP
|
||||
const canvasW = Math.max(
|
||||
widthOf(oppGrid),
|
||||
widthOf(playerGrid),
|
||||
)
|
||||
|
||||
// Build empty canvas
|
||||
const rows: Pixel[][] = Array.from({ length: totalH }, () =>
|
||||
Array.from({ length: canvasW }, () => EMPTY_PIXEL),
|
||||
)
|
||||
|
||||
// Bounce offsets
|
||||
const oppOffset = animEnabled ? BOUNCE[tick % BOUNCE.length]! : 0
|
||||
const playerOffset = animEnabled ? BOUNCE[(tick + 2) % BOUNCE.length]! : 0
|
||||
|
||||
// Blit opponent (top-right, shifted up by bounce)
|
||||
const oppY = -oppOffset // negative = shift up
|
||||
blit(rows, oppGrid, oppY, canvasW - widthOf(oppGrid))
|
||||
|
||||
// Blit player (bottom-left, shifted up by bounce)
|
||||
const playerStartRow = oppH - OVERLAP
|
||||
const playerY = playerStartRow - playerOffset
|
||||
blit(rows, playerGrid, playerY, 0)
|
||||
|
||||
return rows
|
||||
}, [oppGrid, playerGrid, animEnabled, tick])
|
||||
|
||||
const rendered = renderSprite(canvas)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{rendered.map((line, i) => (
|
||||
<Text key={i}>{line || ' '}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** Get width of a pixel grid */
|
||||
function widthOf(grid: Pixel[][]): number {
|
||||
return Math.max(0, ...grid.map(row => row.length))
|
||||
}
|
||||
|
||||
/** Blit source grid onto target at (startRow, startCol). Non-empty pixels overwrite. */
|
||||
function blit(target: Pixel[][], source: Pixel[][], startRow: number, startCol: number) {
|
||||
for (let sy = 0; sy < source.length; sy++) {
|
||||
const ty = startRow + sy
|
||||
if (ty < 0 || ty >= target.length) continue
|
||||
for (let sx = 0; sx < source[sy].length; sx++) {
|
||||
const tx = startCol + sx
|
||||
if (tx < 0 || tx >= target[ty].length) continue
|
||||
const pixel = source[sy][sx]
|
||||
if (pixel.char !== ' ') {
|
||||
target[ty][tx] = pixel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BuddyData, Creature, SpeciesId } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { saveBuddyData } from '../core/storage'
|
||||
import { createBattle, executeTurn, executeSwitch, type BattleInit } from '../battle/engine'
|
||||
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
|
||||
import { BattleConfigPanel } from './BattleConfigPanel'
|
||||
import { BattleScene, type MenuPhase } from './BattleScene'
|
||||
import { SpeciesPicker } from './SpeciesPicker'
|
||||
import { SwitchPanel } from './SwitchPanel'
|
||||
import { ItemPanel } from './ItemPanel'
|
||||
import { BattleResultPanel } from './BattleResultPanel'
|
||||
import { MoveLearnPanel } from './MoveLearnPanel'
|
||||
import type { BattleState, PlayerAction } from '../battle/types'
|
||||
|
||||
type Phase =
|
||||
| 'config'
|
||||
| 'configSelect'
|
||||
| 'battle'
|
||||
| 'result'
|
||||
| 'learnMoves'
|
||||
| 'evolution'
|
||||
| 'done'
|
||||
|
||||
export interface BattleFlowHandle {
|
||||
handleInput: (input: string, key: {
|
||||
escape?: boolean
|
||||
return?: boolean
|
||||
upArrow?: boolean
|
||||
downArrow?: boolean
|
||||
leftArrow?: boolean
|
||||
rightArrow?: boolean
|
||||
tab?: boolean
|
||||
backspace?: boolean
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
meta?: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
interface BattleFlowProps {
|
||||
buddyData: BuddyData
|
||||
onClose: () => void
|
||||
isActive?: boolean
|
||||
inputRef?: React.MutableRefObject<BattleFlowHandle | null>
|
||||
}
|
||||
|
||||
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
|
||||
const [phase, setPhase] = useState<Phase>('config')
|
||||
const [buddyData, setBuddyData] = useState(initialData)
|
||||
const [battleInit, setBattleInit] = useState<BattleInit | null>(null)
|
||||
const [battleState, setBattleState] = useState<BattleState | null>(null)
|
||||
const [opponentSpeciesId, setOpponentSpeciesId] = useState<SpeciesId>('pikachu')
|
||||
const [opponentLevel, setOpponentLevel] = useState(5)
|
||||
const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
|
||||
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
|
||||
const [replaceIndex, setReplaceIndex] = useState(0)
|
||||
const [configCursor, setConfigCursor] = useState(0)
|
||||
|
||||
// ─── Battle UI state ───
|
||||
const [menuPhase, setMenuPhase] = useState<MenuPhase>('main')
|
||||
const [cursorIndex, setCursorIndex] = useState(0)
|
||||
const [animEnabled, setAnimEnabled] = useState(true)
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function getActiveCreatureLevel(): number {
|
||||
const id = buddyData.party[0]
|
||||
if (!id) return 5
|
||||
const c = buddyData.creatures.find(cr => cr.id === id)
|
||||
return c?.level ?? 5
|
||||
}
|
||||
|
||||
function getPartyCreatures(): Creature[] {
|
||||
return buddyData.party
|
||||
.filter((id): id is string => id !== null)
|
||||
.map(id => buddyData.creatures.find(c => c.id === id))
|
||||
.filter((c): c is Creature => c !== undefined)
|
||||
}
|
||||
|
||||
/** Build battleHp map from battleState.playerParty */
|
||||
function getBattleHpMap(): Record<string, { hp: number; maxHp: number }> {
|
||||
if (!battleState) return {}
|
||||
const map: Record<string, { hp: number; maxHp: number }> = {}
|
||||
for (const p of battleState.playerParty) {
|
||||
map[p.id] = { hp: p.hp, maxHp: p.maxHp }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Get max cursor index for current sub-phase */
|
||||
function getMaxCursor(): number {
|
||||
if (!battleState) return 0
|
||||
switch (menuPhase) {
|
||||
case 'main': return 3
|
||||
case 'fight': return battleState.playerPokemon.moves.length - 1
|
||||
case 'bag': return battleState.usableItems.length - 1
|
||||
case 'pokemon': return getPartyCreatures().length - 1
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ───
|
||||
|
||||
const handleRandomBattle = useCallback(() => {
|
||||
const opponentLevel = getActiveCreatureLevel()
|
||||
const speciesList = ALL_SPECIES_IDS
|
||||
const randomSpecies = speciesList[Math.floor(Math.random() * speciesList.length)]!
|
||||
handleStartBattle(randomSpecies, opponentLevel)
|
||||
}, [buddyData])
|
||||
|
||||
const handleStartBattle = useCallback(async (speciesId: SpeciesId, level: number) => {
|
||||
setOpponentSpeciesId(speciesId)
|
||||
setOpponentLevel(level)
|
||||
|
||||
const creatures = buddyData.party
|
||||
.filter((id): id is string => id !== null)
|
||||
.map(id => buddyData.creatures.find(c => c.id === id))
|
||||
.filter((c): c is Creature => c !== undefined)
|
||||
|
||||
if (creatures.length === 0) return
|
||||
|
||||
const bagItems = buddyData.bag.items
|
||||
const init = await createBattle(creatures, speciesId, level, bagItems)
|
||||
setBattleInit(init)
|
||||
setBattleState(init.state)
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
setPhase('battle')
|
||||
}, [buddyData])
|
||||
|
||||
const handleAction = useCallback(async (action: PlayerAction) => {
|
||||
if (!battleInit) return
|
||||
|
||||
// Consume item from bag before executing turn
|
||||
if (action.type === 'item' && action.itemId) {
|
||||
const updated = {
|
||||
...buddyData,
|
||||
bag: {
|
||||
...buddyData.bag,
|
||||
items: buddyData.bag.items.map(entry =>
|
||||
entry.id === action.itemId
|
||||
? { ...entry, count: Math.max(0, entry.count - 1) }
|
||||
: entry
|
||||
).filter(entry => entry.count > 0),
|
||||
},
|
||||
}
|
||||
setBuddyData(updated)
|
||||
}
|
||||
|
||||
const state = await executeTurn(battleInit, action)
|
||||
setBattleState(state)
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
|
||||
// Escape successful — close battle without rewards
|
||||
if (state.escaped) {
|
||||
saveBuddyData(buddyData)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
// Pokémon fainted — show switch panel overlay
|
||||
if (state.needsSwitch && !state.finished) {
|
||||
setMenuPhase('pokemon')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.finished && state.result) {
|
||||
const participants = buddyData.party.filter((id): id is string => id !== null)
|
||||
const result = { ...state.result, participantIds: participants }
|
||||
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
|
||||
|
||||
setBuddyData(settled.data)
|
||||
setPendingMoves(settled.learnableMoves)
|
||||
setPendingEvos(settled.pendingEvolutions)
|
||||
setBattleState({ ...state, result })
|
||||
setPhase('result')
|
||||
}
|
||||
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
|
||||
|
||||
const handleResultContinue = useCallback(() => {
|
||||
if (pendingMoves.length > 0) {
|
||||
setPhase('learnMoves')
|
||||
} else if (pendingEvos.length > 0) {
|
||||
setPhase('evolution')
|
||||
} else {
|
||||
saveBuddyData(buddyData)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
}
|
||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||
|
||||
const handleMoveLearn = useCallback((idx: number) => {
|
||||
if (pendingMoves.length === 0) return
|
||||
const move = pendingMoves[0]!
|
||||
const updated = applyMoveLearn(buddyData, move.creatureId, move.moveId, idx)
|
||||
setBuddyData(updated)
|
||||
const remaining = pendingMoves.slice(1)
|
||||
setPendingMoves(remaining)
|
||||
if (remaining.length === 0) {
|
||||
if (pendingEvos.length > 0) {
|
||||
setPhase('evolution')
|
||||
} else {
|
||||
saveBuddyData(updated)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||
|
||||
const handleMoveSkip = useCallback(() => {
|
||||
const remaining = pendingMoves.slice(1)
|
||||
setPendingMoves(remaining)
|
||||
if (remaining.length === 0) {
|
||||
if (pendingEvos.length > 0) {
|
||||
setPhase('evolution')
|
||||
} else {
|
||||
saveBuddyData(buddyData)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||
|
||||
const handleEvolutionConfirm = useCallback(() => {
|
||||
if (pendingEvos.length === 0) return
|
||||
const evo = pendingEvos[0]!
|
||||
const updated = applyEvolution(buddyData, evo.creatureId, evo.to)
|
||||
setBuddyData(updated)
|
||||
const remaining = pendingEvos.slice(1)
|
||||
setPendingEvos(remaining)
|
||||
if (remaining.length === 0) {
|
||||
saveBuddyData(updated)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
}
|
||||
}, [pendingEvos, buddyData, onClose])
|
||||
|
||||
// Forced switch after faint
|
||||
const handleForcedSwitch = useCallback(async (partyIndex: number) => {
|
||||
if (!battleInit) return
|
||||
const state = await executeSwitch(battleInit, partyIndex)
|
||||
setBattleState(state)
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
|
||||
if (state.finished && state.result) {
|
||||
const participants = buddyData.party.filter((id): id is string => id !== null)
|
||||
const result = { ...state.result, participantIds: participants }
|
||||
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
|
||||
setBuddyData(settled.data)
|
||||
setPendingMoves(settled.learnableMoves)
|
||||
setPendingEvos(settled.pendingEvolutions)
|
||||
setBattleState({ ...state, result })
|
||||
setPhase('result')
|
||||
}
|
||||
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
|
||||
|
||||
// ─── Main menu cursor navigation (2x2 grid) ───
|
||||
|
||||
const moveMainCursor = useCallback((direction: 'up' | 'down' | 'left' | 'right') => {
|
||||
setCursorIndex(prev => {
|
||||
// Grid: 0=TL, 1=TR, 2=BL, 3=BR
|
||||
switch (direction) {
|
||||
case 'up': return prev >= 2 ? prev - 2 : prev + 2
|
||||
case 'down': return prev < 2 ? prev + 2 : prev - 2
|
||||
case 'left': return prev % 2 === 1 ? prev - 1 : prev + 1
|
||||
case 'right': return prev % 2 === 0 ? prev + 1 : prev - 1
|
||||
default: return prev
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ─── Input handler ───
|
||||
|
||||
const handleInput = useCallback((input: string, key: {
|
||||
escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean
|
||||
leftArrow?: boolean; rightArrow?: boolean
|
||||
}) => {
|
||||
if (!isActive) return
|
||||
|
||||
if (phase === 'config') {
|
||||
if (key.escape) {
|
||||
onClose()
|
||||
} else if (key.upArrow) {
|
||||
setConfigCursor(prev => (prev - 1 + 2) % 2)
|
||||
} else if (key.downArrow) {
|
||||
setConfigCursor(prev => (prev + 1) % 2)
|
||||
} else if (key.return) {
|
||||
if (configCursor === 0) {
|
||||
handleRandomBattle()
|
||||
} else {
|
||||
setPhase('configSelect')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'configSelect') {
|
||||
// SpeciesPicker handles its own input via FuzzyPicker/useInput
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'battle') {
|
||||
if (!battleState) return
|
||||
|
||||
// F key toggles animation
|
||||
if (input.toLowerCase() === 'f') {
|
||||
setAnimEnabled(prev => !prev)
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Main menu ───
|
||||
if (menuPhase === 'main') {
|
||||
if (key.escape) return
|
||||
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
|
||||
moveMainCursor(key.upArrow ? 'up' : key.downArrow ? 'down' : key.leftArrow ? 'left' : 'right')
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
switch (cursorIndex) {
|
||||
case 0: // 战斗 → move selection
|
||||
setMenuPhase('fight')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
case 1: // 背包
|
||||
setMenuPhase('bag')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
case 2: // 宝可梦
|
||||
setMenuPhase('pokemon')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
case 3: // 逃跑 — attempt escape
|
||||
handleAction({ type: 'run' })
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Fight (move selection) ───
|
||||
if (menuPhase === 'fight') {
|
||||
if (key.escape) {
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setCursorIndex(prev => Math.min(battleState.playerPokemon.moves.length - 1, prev + 1))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const move = battleState.playerPokemon.moves[cursorIndex]
|
||||
if (move && move.pp > 0 && !move.disabled) {
|
||||
handleAction({ type: 'move', moveIndex: cursorIndex })
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Bag (item selection) ───
|
||||
if (menuPhase === 'bag') {
|
||||
if (key.escape) {
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(1) // return to 背包
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setCursorIndex(prev => Math.min(battleState.usableItems.length - 1, prev + 1))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const item = battleState.usableItems[cursorIndex]
|
||||
if (item) {
|
||||
handleAction({ type: 'item', itemId: item.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Pokemon (switch selection) ───
|
||||
if (menuPhase === 'pokemon') {
|
||||
const isForced = battleState.needsSwitch
|
||||
if (key.escape && !isForced) {
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(2) // return to 宝可梦
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
const maxIdx = getPartyCreatures().length - 1
|
||||
setCursorIndex(prev => Math.min(maxIdx, prev + 1))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const party = getPartyCreatures()
|
||||
const creature = party[cursorIndex]
|
||||
const battleParty = battleState.playerParty
|
||||
const battleCreature = battleParty[cursorIndex]
|
||||
if (creature && battleCreature && battleCreature.hp > 0) {
|
||||
if (isForced) {
|
||||
handleForcedSwitch(cursorIndex)
|
||||
} else {
|
||||
handleAction({ type: 'switch', partyIndex: cursorIndex })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'result') {
|
||||
if (key.return) handleResultContinue()
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'learnMoves') {
|
||||
if (input.toLowerCase() === 's') {
|
||||
handleMoveSkip()
|
||||
} else if (key.upArrow) {
|
||||
setReplaceIndex(prev => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow) {
|
||||
setReplaceIndex(prev => Math.min(3, prev + 1))
|
||||
} else if (key.return) {
|
||||
handleMoveLearn(replaceIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'evolution') {
|
||||
if (key.return) handleEvolutionConfirm()
|
||||
return
|
||||
}
|
||||
}, [isActive, phase, menuPhase, cursorIndex, configCursor, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
|
||||
|
||||
// Expose handleInput via ref
|
||||
useEffect(() => {
|
||||
if (inputRef) inputRef.current = { handleInput }
|
||||
}, [handleInput, inputRef])
|
||||
|
||||
// ─── Build overlay content for sub-panels ───
|
||||
|
||||
function buildOverlay(): React.ReactNode | undefined {
|
||||
if (!battleState) return undefined
|
||||
|
||||
if (menuPhase === 'bag') {
|
||||
return (
|
||||
<ItemPanel
|
||||
items={battleState.usableItems}
|
||||
cursorIndex={cursorIndex}
|
||||
categoryIndex={0}
|
||||
phase="items"
|
||||
onSelect={() => {}}
|
||||
onCancel={() => { setMenuPhase('main'); setCursorIndex(1) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (menuPhase === 'pokemon') {
|
||||
return (
|
||||
<SwitchPanel
|
||||
party={getPartyCreatures()}
|
||||
activeId={battleState.playerPokemon.id}
|
||||
cursorIndex={cursorIndex}
|
||||
battleHp={getBattleHpMap()}
|
||||
onSelect={() => {}}
|
||||
onCancel={() => { setMenuPhase('main'); setCursorIndex(2) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ─── Render by phase ───
|
||||
|
||||
switch (phase) {
|
||||
case 'config':
|
||||
return (
|
||||
<BattleConfigPanel
|
||||
party={getPartyCreatures()}
|
||||
cursorIndex={configCursor}
|
||||
onSubmit={handleStartBattle}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'configSelect':
|
||||
return (
|
||||
<SpeciesPicker
|
||||
onSelect={(speciesId) => handleStartBattle(speciesId, getActiveCreatureLevel())}
|
||||
onCancel={() => setPhase('config')}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'battle': {
|
||||
if (!battleState) return null
|
||||
return (
|
||||
<BattleScene
|
||||
state={battleState}
|
||||
menuPhase={menuPhase}
|
||||
cursorIndex={cursorIndex}
|
||||
animEnabled={animEnabled}
|
||||
overlay={buildOverlay()}
|
||||
onMoveCursor={(dir) => {
|
||||
if (menuPhase === 'main') moveMainCursor(dir)
|
||||
else if (dir === 'up') setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
else if (dir === 'down') setCursorIndex(prev => Math.min(getMaxCursor(), prev + 1))
|
||||
}}
|
||||
onSelect={() => {}}
|
||||
onBack={() => { setMenuPhase('main'); setCursorIndex(0) }}
|
||||
onToggleAnim={() => setAnimEnabled(prev => !prev)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
if (!battleState?.result) return null
|
||||
return (
|
||||
<BattleResultPanel
|
||||
result={battleState.result}
|
||||
onContinue={handleResultContinue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'learnMoves': {
|
||||
if (pendingMoves.length === 0) return null
|
||||
const move = pendingMoves[0]!
|
||||
const creature = buddyData.creatures.find(c => c.id === move.creatureId)
|
||||
if (!creature) return null
|
||||
return (
|
||||
<MoveLearnPanel
|
||||
creature={creature}
|
||||
newMoveId={move.moveId}
|
||||
cursorIndex={replaceIndex}
|
||||
onLearn={handleMoveLearn}
|
||||
onSkip={handleMoveSkip}
|
||||
onSelectReplace={setReplaceIndex}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'evolution': {
|
||||
if (pendingEvos.length === 0) return null
|
||||
const evo = pendingEvos[0]!
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="warning"
|
||||
borderText={{ content: ' 进化 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold color="warning">{evo.from} 正在进化为 {evo.to}!</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="claude">[Enter] 继续</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case 'done':
|
||||
return null
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user