Compare commits

..

20 Commits

Author SHA1 Message Date
claude-code-best
3cb4828de6 chore: 1.10.4 2026-04-26 21:33:00 +08:00
claude-code-best
f5c3ee5b5d fix: 修复长时间运行会话的内存泄漏问题
/clear 时释放 STATE 中保存的大块数据(API 请求/分类器请求/模型统计),
全屏模式增加 500 条消息上限防止无限增长,修复 progress 消息去重逻辑
避免交错消息导致重复累积(观察到 13k+ 条目/1GB+ 堆)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:14:00 +08:00
Dosion
c2ac9a74c1 fix: resolve dependency audit findings precisely (#361)
* fix: harden ACP communication boundaries

Harden ACP communication boundaries

Remote ACP sessions now cannot widen permission mode through untrusted
metadata or client payloads. WebSocket ACP ingress measures payloads by bytes
before binary decode, and prompt queue handoff keeps exactly one prompt active
while queued prompts are drained FIFO.

Constraint: ACP remote clients must not be able to open bypassPermissions without local launch intent
Constraint: WebSocket payload limits must be byte-based and checked before binary decode
Rejected: Keep promptToQueryContent wrapper | no production consumers remained after prompt conversion single-sourcing
Confidence: high
Scope-risk: moderate
Directive: Do not re-enable remote bypassPermissions from _meta unless a local launch gate is verified in both acp-link and agent
Tested: targeted ACP/RCS/acp-link prompt queue, bridge, permission, payload, and prompt conversion tests; bun run typecheck; bun run build
Not-tested: Manual live ACP/RCS session against an external client

* fix: restore repository verification gates

Keep the full repository test, typecheck, build, and Biome lint gates usable
after the ACP fix pass. This commit is intentionally separate from the ACP
behavior change: it fixes Windows-safe Langfuse home redaction, removes stale
lint suppressions, resolves Biome warning/info diagnostics, and keeps env
expansion tests explicit without template-placeholder lint noise.

Constraint: The project completion contract requires full typecheck, lint, test, and build evidence
Rejected: Leave warning/info diagnostics as historical noise | they obscure future gate regressions and weaken flow-impact claims
Confidence: high
Scope-risk: narrow
Directive: Keep repository gate cleanup separate from feature fixes when it is not part of the same runtime path
Tested: bunx biome lint src/; bunx tsc --noEmit; bun test src/services/mcp/__tests__/envExpansion.test.ts src/utils/__tests__/sliceAnsi.test.ts src/utils/__tests__/stringUtils.test.ts; bun test; bun run build
Not-tested: Manual Langfuse export against a real external Langfuse service

* fix: harden ACP failure boundaries after review

Deep review found several paths that made ACP communication failures look normal: prompt errors could finish as end_turn, permission pipeline exceptions could fall through to client approval, tool rawInput was deep-copied with JSON, and acp-link accepted unbounded or unvalidated WebSocket payloads. This keeps the behavior fail-closed, validates WS payloads before dispatch, caps payload size before JSON parse, and preserves cancellation intent with a generation counter.

Constraint: User explicitly rejected pseudo-fixes, fallback behavior, and unbounded payload handling

Rejected: Keep JSON stringify/parse rawInput copy | duplicates large payloads and silently drops non-JSON inputs

Rejected: Delegate permission pipeline errors to client approval | allows a broken local permission check to be bypassed

Confidence: high

Scope-risk: moderate

Directive: Do not convert ACP errors into normal end_turn responses without a protocol-level reason and regression tests

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bunx tsc --noEmit

Tested: bunx biome lint src/ packages/acp-link/src/

Tested: bun run test:all

Tested: bun run build

Not-tested: Manual end-to-end ACP client session over a real editor WebSocket

* fix: prevent ACP coverage runs from seeing partial mocks

GitHub Actions failed under bun test --coverage because permissions.test.ts replaced ../bridge.js with a partial mock that omitted forwardSessionUpdates. Coverage worker ordering on Linux let sibling tests observe that incomplete module.

This isolates ACP test mocks by snapshotting real exports, overriding only requested symbols, and restoring mocks in LIFO order. The shared helper also keeps the same behavior in agent.test.ts without duplicating mock infrastructure.

Constraint: bun:test mock.module is process-global inside a worker.

Rejected: Add fallback exports or production guards | the bridge export exists; the failure was test mock pollution.

Rejected: Keep per-file helper copies | duplication would let restore semantics drift again.

Confidence: high

Scope-risk: narrow

Directive: Prefer safeMockModule for partial mocks of real modules in ACP tests; plain mock.module is only appropriate for fully synthetic modules or isolated tests.

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov

Tested: bunx tsc --noEmit

Tested: bun run lint

Tested: git diff --check

Not-tested: Linux runner directly before push

* fix: normalize ACP bypass requests without warning noise

The previous CI repair removed the failing partial bridge mock, but it also added a shared safeMockModule helper and left the acp-link bypass normalization warning in the real new_session path.

This tightens the fix: acp-link now treats an unauthorized client bypass request as normal permission-mode normalization without emitting a warning, and the ACP permission test explicitly preserves the real bridge and permission exports instead of using a shared helper. The agent test keeps its local mock preservation but names it by behavior and restores mocks in LIFO order.

Constraint: CI output should not contain expected warning noise for covered policy branches.

Rejected: Silence the test only | the normal new_session path would still warn for an expected normalization branch.

Rejected: Keep the shared safeMockModule helper | the failing module was specific and should be fixed by preserving real exports at the mocking site.

Confidence: high

Scope-risk: narrow

Directive: Treat client-requested bypassPermissions as data to normalize unless the local default explicitly enables bypass.

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov with UPPER_WARN_COUNT=0

Tested: bun run test:all

Tested: bun run lint

Tested: bunx tsc --noEmit

Tested: git diff --check

* fix: harden ACP bypass and CI warning gates

ACP clients must not be able to enter bypassPermissions unless the local ACP gate and process environment both allow it. The same gate now controls session creation, explicit mode changes, and the ExitPlanMode option list, while session setup restores process.cwd so coverage and later work do not inherit ACP session state.

Constraint: CI must stay warning-clean without hiding real ACP permission failures

Rejected: Logging rejected bypass requests on the normal new_session path | it preserves audit text but reintroduces warning noise the runtime should not emit

Rejected: Broad CI=true postinstall skip | it hides explicit Chrome MCP setup checks outside the install path

Confidence: high

Scope-risk: moderate

Directive: Keep bypassPermissions gated through one ACP availability decision before exposing it to clients

Tested: bun test src/services/acp/__tests__/permissions.test.ts src/services/acp/__tests__/agent.test.ts packages/acp-link/src/__tests__/server.test.ts

Tested: bun run test:all

Tested: bun run lint

Tested: bun run build:vite with zero warning matches

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage produced non-empty lcov with SF records and zero filtered warning matches

Not-tested: GitHub Actions result after this push

* fix: remove remaining CI warning noise

The CI log still had three non-failing warnings after the ACP hardening commit: git init default-branch advice from checkout, a Node 20 action-runtime deprecation, and one additional known Vite dynamic-import diagnostic that only surfaced on Linux. The workflow now provides explicit git config and opts actions into Node 24, while Vite keeps a narrow allowlist for acknowledged optimizer diagnostics.

Constraint: Do not use shell log filtering to hide warnings after they happen

Rejected: Grep warning lines out of CI output | it would make future diagnostics harder to find

Confidence: high

Scope-risk: narrow

Directive: Add new Vite warning allowlist entries only after checking that they are existing optimizer diagnostics, not new application defects

Tested: bunx tsc --noEmit --pretty false

Tested: bunx biome lint .github/workflows/ci.yml vite.config.ts

Tested: bun run build:vite with zero warning matches

Not-tested: GitHub Actions result after this push

* fix: reject unauthorized ACP bypass and harden CI actions

ACP clients now fail closed when permissionMode is malformed, unknown, or requests bypass without a local bypass opt-in. acp-link validates new_session input before forwarding to the agent and returns client error frames for expected unauthorized requests without logging create-failed noise. The direct AcpAgent path independently rejects invalid _meta.permissionMode and unauthorized bypass instead of falling back to settings.

CI workflows and generated GitHub App templates now use Node 24-compatible actions pinned to immutable commit SHAs, and acp-link startup output no longer prints the auth token.

Constraint: Must not hide warnings with test isolation or log filtering

Rejected: Silent fallback to local permission mode | accepts invalid client intent and masks boundary behavior

Rejected: Broad dependency churn from bun update | audit remained failing while package and lockfile churn expanded scope

Confidence: high

Scope-risk: moderate

Directive: Client-provided permissionMode must stay fail-closed before reaching AcpAgent; only local settings.defaultMode may fall back to default on invalid local config

Tested: bun test packages/acp-link/src/__tests__/server.test.ts src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/permissions.test.ts src/services/skillLearning/__tests__/skillLifecycle.test.ts src/utils/settings/__tests__/config.test.ts

Tested: bunx tsc -p packages/acp-link/tsconfig.json --noEmit --pretty false

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

Tested: local CI equivalent install/typecheck/coverage/build with warning_scan=0

Not-tested: Pre-existing bun audit vulnerabilities require a separate dependency-hardening PR

* fix: resolve dependency audit findings precisely

Use dependency-native upgrades and lockfile resolution to close the audit findings without suppressions. Keep the chrome MCP setup aligned with the new dependency graph and add real integration coverage so the override behavior stays verified.

Constraint: no audit ignores or warning suppression
Rejected: broad google-auth/protobuf overrides | replaced with upstream-compatible resolution
Confidence: high
Scope-risk: moderate
Directive: keep dependency fixes upstream-compatible; do not reintroduce blanket overrides unless the audit surface changes materially
Tested: bun audit; bun audit --json; bun install --frozen-lockfile with CLAUDE_CODE_SKIP_CHROME_MCP_SETUP=1; bunx tsc --noEmit --pretty false; bun run lint; targeted tests; bun run test:all; bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run build:vite
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: keep ACP auth tokens out of URLs

Replace the ad hoc URL-token flow with crypto UUID-backed transport identifiers so the bearer token stays in structured request data instead of query strings. Keep the server, web client, and transport helpers aligned so the ACP/RCS handshake remains compatible after the API shape change.

Constraint: token must not be embedded in the URL
Rejected: token-as-uuid query fallback | leaked bearer tokens in URLs
Confidence: high
Scope-risk: moderate
Directive: preserve the structured auth path; do not reintroduce query-token fallback when adjusting ACP transport code
Tested: targeted ACP/RCS transport tests
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: normalize WebFetch request headers

Normalize WebFetch headers before dispatch so canonicalization preserves auth semantics and duplicate forms do not slip through. Keep the behavior locked with a focused header test instead of broadening the request pipeline.

Constraint: preserve header semantics without widening the fetch surface
Rejected: ad hoc caller-side normalization | too easy to bypass in future call sites
Confidence: high
Scope-risk: narrow
Directive: keep header normalization close to the WebFetch utility so future callers inherit the same behavior automatically
Tested: targeted WebFetch header tests
Not-tested: unrelated fetch backend behavior beyond header normalization

* fix: harden ACP remote auth surfaces

Tighten the remaining Claude security artifact items by requiring API keys on ACP global reads and relay upgrades, moving WebSocket tokens out of URLs, and replacing open web CORS with an explicit allowlist.

Constraint: Browser WebSocket clients cannot set arbitrary Authorization headers, so the token is carried in a selected subprotocol instead of a query string.
Rejected: Keep UUID auth for ACP channel groups | any caller can mint a UUID and read global ACP data.
Rejected: Preserve ?token= compatibility | secrets leak into logs, history, referrers, and intermediaries.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce query-string bearer tokens; use Authorization or rcs.auth.<base64url-token>.
Tested: bunx tsc --noEmit --pretty false
Tested: bun run typecheck in packages/remote-control-server
Tested: bun run build in packages/acp-link
Tested: bun run lint
Tested: bun audit
Tested: focused RCS/acp-link/web tests, 160 pass
Tested: Edge headless browser WebSocket subprotocol handshake
Tested: bun run test:all, 3669 pass
Tested: bun run build:vite
Tested: bun run build
Not-tested: Manual end-to-end relay with a live external ACP agent

* fix: resolve CI dependency override lookup

The CI runner does not expose @grpc/proto-loader as a root-resolvable package, and the test was relying on local hoisting rather than the real dependency owner. Resolve proto-loader through @opentelemetry/exporter-trace-otlp-grpc and @grpc/grpc-js so the smoke test follows the package graph it is validating.

Constraint: Do not add a new root dependency for a transitive smoke test.

Rejected: Skip or weaken the test | the test protects the protobuf 7 override path and should keep exercising loadSync.

Rejected: Add @grpc/proto-loader directly to root package.json | that hides the owning-package resolution issue and broadens dependency surface.

Confidence: high

Scope-risk: narrow

Directive: Dependency override smoke tests should resolve from the package that actually owns the dependency, not from incidental root hoisting.

Tested: bun test tests/integration/dependency-overrides.test.ts; bunx tsc --noEmit --pretty false; bun run lint; bun audit; bun run test:all; git diff --check

---------

Co-authored-by: unraid <local@unraid.local>
2026-04-26 19:49:54 +08:00
claude-code-best
fc438bd222 Feature/add auto mode settings and fix bug (#368)
* refactor: 将 convertMessagesToLangfuse 参数类型从 unknown 收窄为联合类型

将 readonly unknown[] 改为 readonly LangfuseInputMessage[],
其中 LangfuseInputMessage = UserMessage | AssistantMessage | ChatCompletionMessageParam,
让调用方获得编译期类型检查。

* fix: 修复 Config 面板第二次进入时左右键无反应的问题

将左右键枚举值切换从依赖 DOM 焦点的 onKeyDown 改为 useKeybindings 系统,
确保按键在任何焦点状态下都能正确响应。同时修复 isSearchMode 初始值和布局问题。

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

* fix: 修复 PowerShellTool.isSearchOrReadCommand 在 input 为 undefined 时崩溃的问题

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

* feat: 添加 RSS 内存指示器并解绑 auto 权限模式与 TRANSCRIPT_CLASSIFIER

- 在 REPL 底栏添加 RSS 内存使用显示,512MB 以下 dimColor,512MB-1GB warning 色,1GB 以上 error 色
- auto 权限模式不再依赖 TRANSCRIPT_CLASSIFIER feature flag,classifier 不可用时 fallback 到 prompting
- Config 面板 defaultPermissionMode 使用类型安全的 permissionModeFromString,显示改用 shortTitle
- bypassPermissions title 缩短为 Bypass 与 shortTitle 一致

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

* fix: 同步 permissionModeTitle 测试断言与 bypassPermissions 的新 title 值

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:43:25 +08:00
Eric Guo
4591432a1d Fix mintlify validate errors (#367) 2026-04-26 11:07:20 +08:00
WANG HONGXIANG
901628b4d9 fix: 修复 OpenAI provider (gpt-5.4/gpt-5.3-codex等模型)下 内建mcp__plugin_weixin_weixin__reply 微信工具不可见的问题 (#359)
* fix: 修复 OpenAI provider 下 MCP 工具不可见

* docs: 补充 OpenAI MCP 工具列表注释

* fix: 修正 OpenAI Langfuse 输入记录

* refactor: 使用类型守卫收窄 Langfuse role

* fix: 保留 Langfuse OpenAI 数组消息角色

* fix: 合并 Langfuse OpenAI tool_calls

* fix: 修复 OpenAI Langfuse 类型检查
2026-04-26 09:17:09 +08:00
HitMargin
cf33c06021 添加deepseek-v4-pro支持选择max思考深度 (#365)
Co-authored-by: HitMargin <hitmargin@qq.com>
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 09:00:43 +08:00
claude-code-best
e0ca1d054c chore: 1.10.2 2026-04-25 20:37:40 +08:00
claude-code-best
6585d0f67c fix: 禁用 COORDINATOR_MODE 和 TEAMMEM 解决内存溢出问题
COORDINATOR_MODE 的 AgentSummary 每 30s fork 完整消息历史是 GB 级内存泄露的主因,
TEAMMEM 依赖 COORDINATOR_MODE 且邮箱文件无限增长。同时恢复 DAEMON(非主因)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 20:29:52 +08:00
claude-code-best
e4403ff010 fix: 移除 RCS 按 machineName 复用 agent 记录的逻辑
多个同名 acp-link 实例注册到 RCS 时,REST 注册阶段按 machineName
去重导致不同实例共享同一条记录。改为每次注册都创建独立记录,
重连恢复由 WS identify 阶段按 environment_id 精确匹配。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 19:27:58 +08:00
claude-code-best
9e61e7a90d chore: 更新 biome 注释 2026-04-25 16:33:02 +08:00
claude-code-best
d03af7bd4e chore: 1.10.0 2026-04-25 14:48:15 +08:00
claude-code-best
e8ef955ff9 docs: 添加 /login 说明 2026-04-25 14:47:43 +08:00
claude-code-best
a8ed0cdce5 fix: 修复构建后 vendor 二进制路径解析错误(ripgrep/audio-capture)
构建后 chunk 文件位于 dist/chunks/(Vite)或 dist/(Bun),vendor 二进制在
dist/vendor/,但 ripgrep 和 audio-capture 的路径解析未考虑 chunks/ 层级,
导致 ENOENT。改用 import.meta.url 路径中 lastIndexOf('dist') 定位 dist 根,
并同步在 build.ts 和 post-build.ts 中添加 ripgrep vendor 文件复制。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:02 +08:00
claude-code-best
1c3b280c6a fix: 尝试修复多轮对话缓存失效 skill 提升的问题 2026-04-25 14:31:32 +08:00
claude-code-best
7a3cc24a00 fix: 尝试修复 nodejs windows 环境的问题 2026-04-25 14:07:45 +08:00
claude-code-best
2e7fc428cd feat: 集成豆包 ASR 语音识别后端,支持 /voice doubao 切换 (#357)
* feat: 集成豆包 ASR 语音识别后端,支持 /voice doubao 切换

- 新增 src/services/doubaoSTT.ts 适配模块,将 doubaoime-asr 的
  AsyncGenerator 协议适配为现有 VoiceStreamConnection 接口
- /voice doubao 启用豆包后端,/voice 使用默认 Anthropic 后端
- 后端选择持久化到 settings.json 的 voiceProvider 字段
- 豆包后端跳过 Anthropic OAuth 认证、语言限制和 Focus Mode
- 豆包后端松手即出结果,跳过 processing 状态
- 凭证文件存放在 ~/.claude/tts/doubao/credentials.json
- doubaoime-asr 作为 optionalDependencies 安装
- 移除 /voice 命令的 claude-ai 可用性限制,所有用户可用

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

* docs: 更新 Voice Mode 文档,添加豆包 ASR 后端说明和致谢

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 13:57:30 +08:00
claude-code-best
ad09f38fd1 fix: 修复在已有文本前输入斜杠命令无法触发自动补全,以及 Tab 补全覆盖后续文本的问题
当用户在已输入文本前插入 /command 时,光标后的文本包含空格,导致补全逻辑误判命令已有参数而跳过建议。
修复方式:只取光标前的文本(commandInput)进行命令解析和补全生成。

同时修复 Tab 补全斜杠命令时覆盖光标后文本的问题,改为在光标位置拼接补全结果。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 09:27:14 +08:00
claude-code-best
b0a3ef90dc chore: 1.9.5 2026-04-25 08:56:31 +08:00
claude-code-best
c07ad4c738 chore: 清理仓库审计问题——修正 CLAUDE.md、删除冗余 yoga-layout、清除 621 个未使用的类型 stub (#354)
- 修正 CLAUDE.md/AGENTS.md 六处过时陈述:modifiers-napi、url-handler-napi 已非 stub,
  Magic Docs/LSP Server/Plugins/Marketplace 已恢复
- 删除未使用的 src/native-ts/yoga-layout/ 冗余副本(2715 行),权威版本保留在 packages/@ant/ink
- 删除 src/ 下 621 个 Auto-generated type stub 文件(全部 export type X = any,无活跃引用)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 08:54:18 +08:00
908 changed files with 7315 additions and 20558 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -44,3 +44,4 @@ data
!.codex/prompts/
!.codex/prompts/**
teach-me
credentials.json

View File

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

View File

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

View File

@@ -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) |
@@ -233,6 +233,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/) 所有。

View File

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

View File

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

1077
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -145,8 +145,8 @@ M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开
```
/pipes — 显示所有实例 + 切换选择面板
/pipes select <name> — 选中某实例(消息会广播到它)
/pipes deselect <name> — 取消选中
/pipes select &lt;name&gt; — 选中某实例(消息会广播到它)
/pipes deselect &lt;name&gt; — 取消选中
/pipes all — 全选
/pipes none — 全部取消
```
@@ -169,7 +169,7 @@ LAN Peers:
Selected: cli-da029538
```
### /attach <name>
### /attach &lt;name&gt;
手动 attach 到一个实例,使其成为你的 slave。
@@ -179,7 +179,7 @@ Selected: cli-da029538
attach 后,对方变为 slave你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
### /detach <name>
### /detach &lt;name&gt;
断开与某个 slave 的连接。
@@ -187,7 +187,7 @@ attach 后,对方变为 slave你变为 master。可以向它发送 prompt
/detach cli-04d67950
```
### /send <name> <message>
### /send &lt;name&gt; &lt;message&gt;
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。

View File

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

View File

@@ -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
- **豆包 ASRDoubao**:通过 `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 原生音频**:优先使用,低延迟
- **SoXSound 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 SDKoptionalDependencies | 豆包 |
| 凭证文件 | `~/.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` 类型定义 |

File diff suppressed because it is too large Load Diff

View 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 TypeEpic
- PriorityP0
- Owner核心通讯 / 后端网关 / QA
- ScopeACP 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 TypeBug
- PriorityP0
- Story Points3
- 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 TypeBug
- PriorityP0
- Story Points3
- 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 且不 abortlistener 不增长。
- 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 TypeTask
- PriorityP1
- Story Points5
- 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 TypeBug
- PriorityP1
- Story Points3
- 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 TypeRefactor
- PriorityP1
- Story Points5
- 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 TypeTask
- PriorityP1
- Story Points3
- Owner终端 UI
- Files
- `src/screens/REPL.tsx`
#### 参考代码位置
- `src/screens/REPL.tsx:654-662`
- `src/screens/REPL.tsx:4996-5005`
#### 背景
REPL 中目标初始化 effect 存在 hook dependency suppresswarm-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 TypeTask
- PriorityP1
- Story Points2
- 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 TypeTask
- PriorityP1
- Story Points3
- 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 TypeTest
- PriorityP1
- Story Points5
- OwnerQA/核心通讯
- 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 TypeTest
- PriorityP1
- Story Points3
- OwnerQA/后端
- 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
- [ ] 最终变更说明包含风险与未覆盖项

View 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 gateP0 全部改动和冒烟验证完成后,再启动 P1 改造。
---
## 5. 不在本文档维护的内容
- 不维护 Jira ticket 正文;统一在 `docs/internals/agent-comm-fix-jira-tasks.md` 修改。
- 不维护业务代码实现方案;实现时按具体 ticket 读取对应文件。
- 不维护历史中间稿;旧执行清单已合并进 Jira Task 文档。

View File

@@ -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 | 否 | 工作区目录路径 |

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "1.9.4",
"version": "1.10.4",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",
@@ -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"
}
}

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('')
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)]!
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,159 +0,0 @@
import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { BuddyData, Creature, SpeciesId } from '../types'
import { STAT_NAMES, STAT_LABELS } from '../types'
import { getSpeciesData } from '../dex/species'
import { SPECIES_PERSONALITY } from '../dex/names'
import { calculateStats, getCreatureName, getTotalEV } from '../core/creature'
import { getXpProgress } from '../core/experience'
import { getEVSummary } from '../core/effort'
import { getGenderSymbol } from '../core/gender'
import { getStatColor } from './shared'
import { getNextEvolution } from '../dex/evolution'
import { StatBar } from './StatBar'
interface CompanionCardProps {
creature: Creature
buddyData: BuddyData
spriteLines?: string[]
}
// ANSI color constants
const CYAN: Color = 'ansi:cyan'
const YELLOW: Color = 'ansi:yellow'
const GREEN: Color = 'ansi:green'
const BLUE: Color = 'ansi:blue'
const RED: Color = 'ansi:red'
const MAGENTA: Color = 'ansi:magenta'
const WHITE: Color = 'ansi:whiteBright'
const GRAY: Color = 'ansi:white'
/** Type → display color mapping */
const TYPE_COLORS: Record<string, Color> = {
grass: 'ansi:green',
poison: 'ansi:magenta',
fire: 'ansi:red',
flying: 'ansi:cyan',
water: 'ansi:blue',
electric: 'ansi:yellow',
normal: 'ansi:white',
}
/**
* Redesigned companion card with Pokémon-style stats display.
*/
export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) {
const species = getSpeciesData(creature.speciesId)
const stats = calculateStats(creature)
const xp = getXpProgress(creature)
const genderSymbol = getGenderSymbol(creature.gender)
const name = getCreatureName(creature)
const evSummary = getEVSummary(creature)
const totalEV = getTotalEV(creature)
const nextEvo = getNextEvolution(creature.speciesId)
// Type badges
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
{i > 0 ? '/' : ''}{t.toUpperCase()}
</Text>
))
// Friendship color
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED
// Shiny badge
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> SHINY</Text> : null
// Evolution hint
const evoHint = nextEvo ? (
<Text color={GRAY}> <Text color={CYAN}>{getSpeciesData(nextEvo.to).name}</Text> Lv.{nextEvo.minLevel}</Text>
) : null
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Header row */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>{name}</Text>
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
{shinyBadge}
</Box>
<Text bold>Lv.{creature.level}</Text>
</Box>
{/* Species + type + gender */}
<Box>
<Text color={GRAY}>{species.name}</Text>
<Text> </Text>
{typeBadges}
{genderSymbol && <Text> {genderSymbol}</Text>}
</Box>
{/* Sprite */}
<Box flexDirection="column" alignItems="center" marginY={0}>
{spriteLines ? (
spriteLines.map((line, i) => <Text key={i}>{line}</Text>)
) : (
<Text color={GRAY}>[Loading sprite...]</Text>
)}
</Box>
{/* Personality */}
<Box>
<Text color={GRAY} italic>"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"</Text>
</Box>
{/* Stats section */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> Base Stats </Text>
{STAT_NAMES.map((stat) => (
<StatBar
key={stat}
label={STAT_LABELS[stat]}
value={stats[stat]}
maxValue={255}
color={getStatColor(stat)}
/>
))}
</Box>
{/* XP progress */}
<Box marginTop={0}>
<Text color={GRAY}>XP </Text>
<Text color={BLUE}>
{'█'.repeat(Math.round(xp.percentage / 10))}
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
</Text>
<Text> {xp.current}/{xp.needed}</Text>
</Box>
{/* EV + Friendship */}
<Box flexDirection="column">
<Box>
<Text color={GRAY}>EV </Text>
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
<Text color={GRAY}> ({totalEV}/510)</Text>
</Box>
<Box>
<Text color={GRAY}> </Text>
<Text color={friendshipColor}>
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
</Text>
<Text> {creature.friendship}/255</Text>
</Box>
</Box>
{/* Evolution hint */}
{evoHint && (
<Box marginTop={0}>
<Text color={GRAY}>Next: </Text>
{evoHint}
</Box>
)}
</Box>
)
}

View File

@@ -1,54 +0,0 @@
import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { Egg } from '../types'
const CYAN: Color = 'ansi:cyan'
const YELLOW: Color = 'ansi:yellow'
const GRAY: Color = 'ansi:white'
interface EggViewProps {
egg: Egg
}
/**
* Egg status view showing hatch progress.
*/
export function EggView({ egg }: EggViewProps) {
const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100)
const filled = Math.round(percentage / 10)
const empty = 10 - filled
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
<Text bold color={CYAN}>
Egg Status
</Text>
{/* ASCII egg */}
<Box flexDirection="column" alignItems="center" marginY={1}>
<Text> . </Text>
<Text> / \ </Text>
<Text> | | </Text>
<Text> \_/ </Text>
</Box>
{/* Progress */}
<Box flexDirection="column" alignItems="center">
<Text>
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
</Text>
<Text color={YELLOW}>
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</Text>
<Text>{percentage}%</Text>
</Box>
{/* Tips */}
<Box marginTop={1} flexDirection="column" alignItems="center">
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
</Box>
</Box>
)
}

View File

@@ -1,101 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId } from '../types'
import { getSpeciesData } from '../dex/species'
import { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
import { getFallbackSprite } from '../sprites/fallback'
const YELLOW: Color = 'ansi:yellow'
const GREEN: Color = 'ansi:green'
const GRAY: Color = 'ansi:white'
interface EvolutionAnimProps {
fromSpecies: SpeciesId
toSpecies: SpeciesId
onComplete: () => void
}
/**
* Evolution animation component.
* Displays a flashing/morphing effect from old species to new species.
* 8 frames × 500ms = ~4 seconds total.
*/
export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionAnimProps) {
const [tick, setTick] = useState(0)
const [spriteTick, setSpriteTick] = useState(0)
const totalFrames = 8
// Prefetch sprites for both species
useEffect(() => {
for (const id of [fromSpecies, toSpecies]) {
if (!loadSprite(id)) {
fetchAndCacheSprite(id).then(s => { if (s) setSpriteTick(t => t + 1) })
}
}
}, [fromSpecies, toSpecies])
void spriteTick
useEffect(() => {
if (tick >= totalFrames) {
onComplete()
return
}
const timer = setTimeout(() => setTick((t) => t + 1), 500)
return () => clearTimeout(timer)
}, [tick, onComplete])
const fromSprite = getSpriteLines(fromSpecies)
const toSprite = getSpriteLines(toSpecies)
const fromName = getSpeciesData(fromSpecies).name
const toName = getSpeciesData(toSpecies).name
// Frame logic:
// 0-3: old sprite with flash (alternate blank)
// 4-7: alternate old/new, settle on new
let displayLines: string[]
if (tick < 3) {
displayLines = tick % 2 === 0 ? fromSprite : fromSprite.map(() => '')
} else if (tick < 6) {
displayLines = tick % 2 === 0 ? fromSprite : toSprite
} else {
displayLines = toSprite
}
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
<Text bold color={YELLOW}>
Evolution!
</Text>
<Box flexDirection="column" alignItems="center" marginY={1}>
{displayLines.map((line, i) => (
<Text key={i}>
{tick >= 6 ? '✨ ' : ''}
{line}
{tick >= 6 ? ' ✨' : ''}
</Text>
))}
</Box>
<Text>
<Text color={GRAY}>{fromName}</Text>
<Text color={YELLOW}> </Text>
<Text bold color={GREEN}>
{toName}
</Text>
</Text>
{tick >= totalFrames - 1 && (
<Text bold color={GREEN}>
</Text>
)}
</Box>
)
}
function getSpriteLines(speciesId: SpeciesId): string[] {
const cached = loadSprite(speciesId)
if (cached) return cached.lines
return getFallbackSprite(speciesId)
}

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