mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
83 Commits
v2.6.9
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
661b1e29e4 | ||
|
|
00c120f95c | ||
|
|
7b513103d9 | ||
|
|
d6374f02d6 | ||
|
|
b217836f5a | ||
|
|
4cf1a8353e | ||
|
|
a58a36c35a | ||
|
|
c5c7202348 | ||
|
|
192221eafc | ||
|
|
8c6be4b5d3 | ||
|
|
c37b274406 | ||
|
|
e16b5667a8 | ||
|
|
e9405e4a8a | ||
|
|
be64db70d4 | ||
|
|
6fc365cf73 | ||
|
|
6a89a5139a | ||
|
|
6ed8f5b870 | ||
|
|
bc17003301 | ||
|
|
dc13eb9c10 | ||
|
|
ec6a223b85 | ||
|
|
27e9857741 | ||
|
|
090e3515ae | ||
|
|
0572d5591b | ||
|
|
24922affd2 | ||
|
|
10b5f35140 | ||
|
|
b3fce1edb7 | ||
|
|
5e47489579 | ||
|
|
3210caddb0 | ||
|
|
51a3a83f07 | ||
|
|
c69e66d2cd | ||
|
|
cbda09d7ee | ||
|
|
c88943795f | ||
|
|
ecf2dbde44 | ||
|
|
1a910ed639 | ||
|
|
dceaacdf4f | ||
|
|
7813904264 | ||
|
|
02783e4f5d | ||
|
|
9930a53e51 | ||
|
|
2c15d9123d | ||
|
|
1217c453c4 | ||
|
|
77e8d15482 | ||
|
|
72cfb83de3 | ||
|
|
8bf645364f | ||
|
|
1b777a25ac | ||
|
|
af0a7054c7 | ||
|
|
ea0eee05d0 | ||
|
|
bd70971632 | ||
|
|
d8e33935db | ||
|
|
bfd14206a9 | ||
|
|
f22caf0e97 | ||
|
|
25067e78af | ||
|
|
70d8c0038c | ||
|
|
3c64113d77 | ||
|
|
0777e1a1f9 | ||
|
|
080bd93efc | ||
|
|
363ba39cad | ||
|
|
4b23bcd3eb | ||
|
|
4116ac9b5c | ||
|
|
39299f6e17 | ||
|
|
1bba087942 | ||
|
|
7c64199fc5 | ||
|
|
df61bf3852 | ||
|
|
98284a5908 | ||
|
|
fae96c3e7f | ||
|
|
661cc764fe | ||
|
|
391e0c233a | ||
|
|
74682b2a82 | ||
|
|
100b1589f2 | ||
|
|
fa8e45e933 | ||
|
|
96e6d33414 | ||
|
|
1dd36f3f6f | ||
|
|
e3570f8cdb | ||
|
|
f5a97011e8 | ||
|
|
a3fc348421 | ||
|
|
12cbb7c4c7 | ||
|
|
96f3e1b309 | ||
|
|
336159ee18 | ||
|
|
970fcd627f | ||
|
|
f74492617b | ||
|
|
b5525f63c6 | ||
|
|
722aa6c97a | ||
|
|
52a862e5b4 | ||
|
|
88ddba6c23 |
139
.claude/skills/fix-issue/SKILL.md
Normal file
139
.claude/skills/fix-issue/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: fix-issue
|
||||
description: 处理 GitHub issue 的完整修复工作流。当用户提到 issue 编号、粘贴 GitHub issue URL、说"修一下这个 bug"、"处理一下这个 issue"、或需要根据 bug 报告修复代码时使用此 skill。即使用户只是提到了一个 GitHub 问题的链接或编号,也应该触发此 skill。
|
||||
---
|
||||
|
||||
# fix-issue: GitHub Issue 修复工作流
|
||||
|
||||
你是一个专门处理 GitHub issue 的修复助手。收到 issue 后,你将自主完成从分析到提交的全流程。
|
||||
|
||||
## 输入格式
|
||||
|
||||
支持两种输入方式:
|
||||
|
||||
1. **URL 方式**:用户提供 GitHub issue URL(如 `https://github.com/owner/repo/issues/123`)
|
||||
2. **描述方式**:用户直接描述问题(如 "登录页面点击提交按钮后崩溃" 或 "issue #456 的分页有问题")
|
||||
|
||||
如果是 URL 方式,用 `gh` 命令获取 issue 信息。如果是描述方式,直接基于描述工作。
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 阶段一:信息收集
|
||||
|
||||
**URL 方式:**
|
||||
|
||||
```bash
|
||||
# 获取 issue 内容和元信息
|
||||
gh issue view <number> --repo <owner/repo> --json title,body,labels,assignees,comments,state
|
||||
```
|
||||
|
||||
提取以下信息:
|
||||
- 问题标题和描述
|
||||
- Labels(bug、enhancement、documentation 等)
|
||||
- 评论中的补充信息(复现步骤、环境、错误日志、截图描述)
|
||||
- 是否有关联的 PR 或重复 issue
|
||||
|
||||
**描述方式:**
|
||||
|
||||
基于用户提供的描述理解问题。如果信息不足,用 AskUserQuestion 补充询问(只问一次,不要反复追问)。
|
||||
|
||||
### 阶段二:问题分析与复杂度评估
|
||||
|
||||
分析收集到的信息,评估问题:
|
||||
|
||||
1. **问题本质**:这是一个什么类型的问题?(bug / 文档 / 性能 / 安全 / 重构)
|
||||
2. **影响范围**:大概涉及哪些模块或文件?
|
||||
3. **复杂度**:简单(单文件修复) / 中等(多文件但逻辑清晰) / 复杂(多模块耦合、需求不明确、或无法定位根因)
|
||||
|
||||
**复杂度判断规则:**
|
||||
|
||||
如果满足以下任一条件,判定为"复杂",**必须停下来向用户汇报**,等用户决定下一步:
|
||||
- 无法确定问题的根因(多个可能的嫌疑点)
|
||||
- 修复可能影响 3 个以上模块
|
||||
- issue 描述模糊,存在多种理解方式
|
||||
- 需要添加新功能而非修复现有缺陷
|
||||
- 涉及数据库迁移、API 契约变更等破坏性修改
|
||||
|
||||
汇报时说明:问题分析结果、可能的修复方向、以及为什么需要用户决策。
|
||||
|
||||
### 阶段三:工作区检查
|
||||
|
||||
开始修复前检查工作区状态:
|
||||
|
||||
```bash
|
||||
git status
|
||||
git stash list
|
||||
```
|
||||
|
||||
- 如果工作区有未提交的更改,提醒用户先处理(stash 或提交),**不要自动 stash 或丢弃更改**
|
||||
- 如果工作区干净,直接进入下一步
|
||||
- 在当前分支上直接修复,不创建新分支
|
||||
|
||||
### 阶段四:代码定位与修复
|
||||
|
||||
1. 使用 Explorer subagent(`subagent_type: "Explore"`)探索代码库,定位问题相关代码。给 Explorer 足够的上下文——把 issue 的关键信息告诉它
|
||||
2. 阅读相关代码,理解当前实现
|
||||
3. 制定修复方案并实施代码修改
|
||||
|
||||
修复时遵循项目现有的代码风格和约定。参考 CLAUDE.md 中的项目规范。
|
||||
|
||||
### 阶段五:验证
|
||||
|
||||
修复完成后自动运行测试:
|
||||
|
||||
```bash
|
||||
bun test
|
||||
```
|
||||
|
||||
**测试失败处理:**
|
||||
- 分析失败原因,判断是否由本次修复引起
|
||||
- 如果是本次修复引起的,重新分析问题并修复,然后重跑测试
|
||||
- 最多重试 **2 次**(总共最多 3 次测试运行:初次 + 2 次重试)
|
||||
- 如果 2 次重试后仍然失败,停下来汇报失败原因和已尝试的方案,交给用户处理
|
||||
|
||||
### 阶段六:提交
|
||||
|
||||
测试通过后提交修复。
|
||||
|
||||
**提交策略:**
|
||||
- 涉及多文件修改时,按逻辑分组提交(例如:"修复数据层校验逻辑" 和 "修复 UI 层错误提示" 分开提交)
|
||||
- 单文件或逻辑简单的修复直接一次提交
|
||||
|
||||
**Commit message 格式:**
|
||||
|
||||
```
|
||||
fix: 简短描述 (#issue编号)
|
||||
```
|
||||
|
||||
示例:
|
||||
- `fix: 修复登录页提交按钮点击后崩溃的问题 (#123)`
|
||||
- `fix: 修正分页组件页码计算逻辑 (#456)`
|
||||
- `fix: 更新 API 文档中的错误返回值描述 (#789)`
|
||||
|
||||
对于非 bug 类型,对应调整 type:
|
||||
- 文档问题:`docs: 修正 xxx 描述 (#issue)`
|
||||
- 性能问题:`perf: 优化 xxx 性能 (#issue)`
|
||||
- 重构:`refactor: 重构 xxx (#issue)`
|
||||
|
||||
提交后不自动创建 PR,也不输出完成提示。静默完成。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- **`gh` 命令失败**:可能是 issue 不存在或权限不足。把错误信息展示给用户,让他们检查
|
||||
- **找不到相关代码**:扩大搜索范围,如果仍然找不到,停下来告诉用户,附上已搜索的范围
|
||||
- **测试超时**:如果是测试本身的问题(非修复引起),告知用户并跳过测试环节
|
||||
- **合并冲突**:不会发生(在当前分支直接修复),但如果 `git status` 显示冲突,停下来让用户处理
|
||||
|
||||
## 全流程示例
|
||||
|
||||
用户说:`帮我修一下 https://github.com/owner/repo/issues/42`
|
||||
|
||||
1. 运行 `gh issue view 42 --repo owner/repo --json ...`,获取 issue 信息
|
||||
2. 分析:issue 标题是"用户注册时邮箱校验失败",评论中有复现步骤和错误日志。复杂度评估:简单(单文件修复)
|
||||
3. `git status` 检查工作区干净
|
||||
4. 用 Explore agent 搜索 "email" "validate" "register" 相关代码
|
||||
5. 阅读 `src/services/auth/register.ts`,发现邮箱正则表达式不完整
|
||||
6. 修复正则表达式
|
||||
7. `bun test` → 通过
|
||||
8. `git commit -m "fix: 修复用户注册时邮箱校验正则表达式不完整的问题 (#42)"`
|
||||
9. 静默完成
|
||||
59
.github/workflows/auto-issue-fix.yml
vendored
Normal file
59
.github/workflows/auto-issue-fix.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Auto Issue Fix
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
auto-fix:
|
||||
# Only trigger when the label is "ai-fix"
|
||||
if: github.event.label.name == 'ai-fix'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
prompt: |
|
||||
You are an expert software engineer. Analyze the following GitHub issue and determine if it can be fixed.
|
||||
|
||||
Issue #${{ github.event.issue.number }}: ${{ github.event.issue.title }}
|
||||
${{ github.event.issue.body }}
|
||||
|
||||
Instructions:
|
||||
1. Read and understand the issue thoroughly.
|
||||
2. Explore the codebase to find the relevant code.
|
||||
3. If the issue is fixable, implement the fix and create a pull request. Use a clear PR title and description referencing the issue.
|
||||
4. If the issue is NOT fixable (e.g., needs more info, not a bug, out of scope), explain why in a brief summary.
|
||||
5. At the end, output a summary of what you did as your FINAL message. This will be posted as a comment on the issue.
|
||||
claude_args: |
|
||||
--model ${{ vars.CLAUDE_MODEL || 'claude-sonnet-4-20250514' }}
|
||||
--max-turns 30
|
||||
--allowedTools "Edit,Write,Read,Bash,Glob,Grep,Agent"
|
||||
settings: >
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "${{ secrets.ANTHROPIC_BASE_URL }}"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Post Claude's response as issue comment
|
||||
if: always() && steps.claude.outputs.result != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh issue comment ${{ github.event.issue.number }} --body "${{ steps.claude.outputs.result }}"
|
||||
@@ -327,7 +327,7 @@ bun run typecheck
|
||||
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
|
||||
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。**开发任何 TUI 组件前,必须先查阅 `docs/ink-guide.md`**,该文档涵盖了双层组件设计(Base vs Themed)、布局系统、主题色、快捷键、所有 Hooks 和设计系统组件的用法。日常使用 `Box`/`Text`(Themed 版),用 `useKeybindings` 代替直接 `useInput`。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
## Design Context
|
||||
|
||||
18
bun.lock
18
bun.lock
@@ -264,6 +264,14 @@
|
||||
"name": "modifiers-napi",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"packages/pokemon": {
|
||||
"name": "@claude-code-best/pokemon",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@pkmn/client": "^0.7.2",
|
||||
"@pkmn/protocol": "^0.7.2",
|
||||
},
|
||||
},
|
||||
"packages/remote-control-server": {
|
||||
"name": "@anthropic/remote-control-server",
|
||||
"version": "0.1.0",
|
||||
@@ -969,6 +977,16 @@
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@pkmn/client": ["@pkmn/client@0.7.2", "", { "dependencies": { "@pkmn/data": "^0.10.2", "@pkmn/protocol": "^0.7.2" } }, "sha512-Jf40Nqp+ZAcM5wNEIrMKNdU2G0OKELtGNXmq2QYRcVsutDF/g9+Xm5Y9nK+mIw+bAgSVSmZKD5/Y1MpwxHgX9A=="],
|
||||
|
||||
"@pkmn/data": ["@pkmn/data@0.10.7", "", { "dependencies": { "@pkmn/dex-types": "^0.10.7" } }, "sha512-csUX8BU4RMHxl3nEF3gIOp1eq9+q3xh+ZaX+Nr1RZBqWF4L5PkBzUG36KKgU+lw92BmncDYm7S8t52lPhAmoXA=="],
|
||||
|
||||
"@pkmn/dex-types": ["@pkmn/dex-types@0.10.7", "", { "dependencies": { "@pkmn/types": "^4.0.0" } }, "sha512-ewinxJHyeLwYSOcDsy+2pq9e1mMYNXwBB9B3CZG2fvonrkxAyycR5AtLKPqE61480jPPxaZocOh+SLtUm648SA=="],
|
||||
|
||||
"@pkmn/protocol": ["@pkmn/protocol@0.7.2", "", { "dependencies": { "@pkmn/types": "^4.0.0" }, "bin": { "generate-handler": "generate-handler", "protocol-verifier": "protocol-verifier" } }, "sha512-3zRY9B4YN8aeA/jypPgW1hh/SiEIY6lNg9xOqIgox0m4sW1kMhGoNCygJ1Qvx8x33xSRD/AVtH+VtsCGn+LcQg=="],
|
||||
|
||||
"@pkmn/types": ["@pkmn/types@4.0.0", "", {}, "sha512-gR2s/pxJYEegek1TtsYCQupNR3d5hMlcJFsiD+2LyfKr4tc+gETTql47tWLX5mFSbPcbXh7f4+7txlMIDoZx/g=="],
|
||||
|
||||
"@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "https://registry.npmmirror.com/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="],
|
||||
|
||||
"@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "https://registry.npmmirror.com/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="],
|
||||
|
||||
1218
docs/ink-guide.md
Normal file
1218
docs/ink-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
338
packages/pokemon/docs/battle-audit-report.md
Normal file
338
packages/pokemon/docs/battle-audit-report.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Pokémon Battle 实现审查报告
|
||||
|
||||
> 审查日期:2026-04-23
|
||||
> 审查范围:`packages/pokemon/` 全部源码(battle、core、dex、ui)
|
||||
> 对比基准:原版 Pokémon 核心系列游戏(Gen 9:Scarlet/Violet)
|
||||
> 更新日期:2026-04-24 — 修复了 #1, #2, #3, #4, #6, #7, #8, #13
|
||||
|
||||
---
|
||||
|
||||
## 一、严重问题(核心机制错误)
|
||||
|
||||
### 1. XP 计算公式与原版不符
|
||||
|
||||
**文件**: `src/battle/settlement.ts:30-31`
|
||||
|
||||
```ts
|
||||
const baseXp = (oppSpecies?.baseStats?.hp ?? 50) * opponentLevel / 7
|
||||
```
|
||||
|
||||
原版 Gen 9 的 XP 计算公式为:
|
||||
|
||||
```
|
||||
XP = (baseXP × opponentLevel × isTraded × isParticipating) / 7 × partySizeModifier
|
||||
```
|
||||
|
||||
当前实现存在以下错误:
|
||||
|
||||
- **baseXP 不等于 baseStats.hp**。每只宝可梦有独立的 `base_experience` 值(例如妙蛙种子是 64,皮卡丘是 112),而不是 HP 种族值。目前用 `baseStats.hp` 做代理完全是错的。
|
||||
- **缺少 traded Pokémon 1.5x 加成**。
|
||||
- **缺少参与战斗的宝可梦分摊机制**(原版中只有实际参与战斗的宝可梦获得 XP)。
|
||||
- **缺少 Lucky Egg 1.5x 加成**。
|
||||
- **缺少 Affection 加成**(Gen 6+)。
|
||||
|
||||
### 2. EV 收益完全自造,不使用真实数据
|
||||
|
||||
**文件**: `src/battle/settlement.ts:176-191`
|
||||
|
||||
```ts
|
||||
function getEvYield(speciesId: string): Record<string, number> {
|
||||
// @pkmn/sim Dex.species doesn't have evs field
|
||||
// Use baseStats as proxy: highest base stat gets 1-2 EVs
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
原版中每只宝可梦有固定的 EV yield(如妙蛙种子击倒后给 HP+1,皮卡丘给 Speed+2)。这些数据在 `@pkmn/data` 中是有的(`species.evs`),但代码误以为 `@pkmn/sim` 没有这个字段,就自造了一个「最高种族值 → 2 EV,第二高 → 1 EV」的算法,与原版完全不同。
|
||||
|
||||
### 3. 物品使用在战斗中无效
|
||||
|
||||
**文件**: `src/battle/engine.ts:436-438`
|
||||
|
||||
```ts
|
||||
case 'item':
|
||||
p1Choice = 'move 1' // fallback to move 1
|
||||
break
|
||||
```
|
||||
|
||||
当玩家使用物品(如药水)时,代码直接忽略了,改为使用第一个招式。原版中物品使用是战斗的核心部分——回复药、状态治愈药、精灵球等都有完整的效果。
|
||||
|
||||
### 4. 逃跑功能未实现
|
||||
|
||||
**文件**: `src/ui/BattleFlow.tsx:314`
|
||||
|
||||
```ts
|
||||
case 3: // 逃跑 — show message
|
||||
return
|
||||
```
|
||||
|
||||
战斗菜单中「逃跑」按钮存在但点击后什么也不做。原版中有逃跑概率计算公式(基于速度对比),对野外战斗是核心机制。
|
||||
|
||||
### 5. 对手(p2)不支持多精灵队伍
|
||||
|
||||
**文件**: `src/battle/engine.ts:61-67`
|
||||
|
||||
```ts
|
||||
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
|
||||
...
|
||||
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
|
||||
}
|
||||
```
|
||||
|
||||
对手始终只有一只宝可梦(野生宝可梦模式)。没有 Trainer Battle 的概念——对手不能有多只精灵、不能换人、不能使用物品。虽然 AI 在精灵倒下后会自动换人(`executeSwitch` 中有处理),但 `createBattle` 本身只接受单个对手 species。
|
||||
|
||||
---
|
||||
|
||||
## 二、中等问题(机制简化/缺失)
|
||||
|
||||
### 6. AI 过于简单
|
||||
|
||||
**文件**: `src/battle/ai.ts:6-13`
|
||||
|
||||
```ts
|
||||
export function chooseAIMove(pokemon: BattlePokemon): number {
|
||||
const usable = pokemon.moves
|
||||
.map((m, i) => ({ move: m, index: i }))
|
||||
.filter(({ move }) => move.pp > 0 && !move.disabled)
|
||||
if (usable.length === 0) return 0
|
||||
return usable[Math.floor(Math.random() * usable.length)]!.index
|
||||
}
|
||||
```
|
||||
|
||||
AI 只是随机选择一个可用招式。原版 NPC AI 至少会考虑:
|
||||
|
||||
- **属性克制**:优先使用效果绝佳的招式
|
||||
- **状态技 vs 攻击技**的权衡
|
||||
- **HP 低时**可能使用回复招式
|
||||
- **玩家属性**:避免使用被抵抗的招式
|
||||
- 不会换人、不会使用物品
|
||||
|
||||
### 7. 野生宝可梦的招式是按属性硬编码的
|
||||
|
||||
**文件**: `src/battle/engine.ts:69-94`
|
||||
|
||||
```ts
|
||||
function getSpeciesMoves(speciesId: string, _level: number): string[] {
|
||||
...
|
||||
const basicMoves: Record<string, string[]> = {
|
||||
normal: ['Tackle', 'Scratch'],
|
||||
fire: ['Ember', 'FireSpin'],
|
||||
...
|
||||
}
|
||||
return basicMoves[type] ?? ['Tackle', 'Scratch']
|
||||
}
|
||||
```
|
||||
|
||||
野生对手的招式不是从 learnset 中获取的,而是按第一属性硬编码了固定招式。`_level` 参数被完全忽略了——原版中不同等级的野生宝可梦应该有不同的招式组合。
|
||||
|
||||
### 8. 进化系统不完整
|
||||
|
||||
**文件**: `src/dex/evolution.ts` + `src/battle/settlement.ts:92-106`
|
||||
|
||||
- 只处理了 `evoType` 为 `level_up`、`item`、`trade`、`friendship` 四种类型
|
||||
- **只取第一个进化目标** (`dex.evos[0]`),忽略了分支进化(如伊布的多种进化)
|
||||
- **没有进化石使用的交互**(使用雷之石等道具触发进化)
|
||||
- **没有通讯交换进化**
|
||||
- **没有条件进化**(如知道特定招式、特定时间、特定地点等 Gen 9 新增条件)
|
||||
|
||||
### 9. 能力值计算缺少特性/道具修正
|
||||
|
||||
**文件**: `src/core/creature.ts:51-73`
|
||||
|
||||
`calculateStats` 只计算基础能力值,没有考虑:
|
||||
|
||||
- **特性对能力值的修正**(如 Hustle 增加攻击降低命中)
|
||||
- **道具对能力值的修正**(如 Choice Band 增加攻击 50%)
|
||||
|
||||
注:性格修正虽然传入了 `nature`,但由 `@pkmn/data` 的 `gen.stats.calc` 内部处理,这部分是正确的。
|
||||
|
||||
### 10. 捕获系统完全缺失
|
||||
|
||||
没有任何捕获野生宝可梦的机制:
|
||||
|
||||
- 没有 Pokeball 道具的实际效果
|
||||
- 没有捕获率计算(Shake check 公式)
|
||||
- 战斗结束后不能获得对手宝可梦
|
||||
- 虽然数据中有 `captureRate` 字段和 `pokeball` 字段,但从未使用
|
||||
|
||||
### 11. 状态异常处理不完整
|
||||
|
||||
**文件**: `src/battle/engine.ts:130-140`
|
||||
|
||||
只映射了 6 种基本状态(中毒、剧毒、灼伤、麻痹、冰冻、睡眠),但缺少:
|
||||
|
||||
- **混乱 (Confusion)**:不在 status 中,是 volatile status
|
||||
- **着迷 (Infatuation)**:同上
|
||||
- **畏缩 (Flinch)**:同上
|
||||
- 所有 volatile status(暂时性状态)都未追踪
|
||||
|
||||
### 12. 天气/场地效果未完整追踪
|
||||
|
||||
**文件**: `src/battle/engine.ts:153-173`
|
||||
|
||||
- `projectState` 中天气只在初始化时从 `prevConditions` 传入,不会自动更新
|
||||
- `mapWeather` 不区分 Primal Weather(原始回归天气)和普通天气
|
||||
- **场地效果(Electric Terrain、Grassy Terrain 等)** 被映射为 `fieldCondition` 事件,但没有影响战斗状态的逻辑
|
||||
|
||||
注:底层 `@pkmn/sim` 会正确处理这些效果,只是上层状态投影不完整,导致 UI 无法正确显示。
|
||||
|
||||
---
|
||||
|
||||
## 三、轻度问题(数值/细节偏差)
|
||||
|
||||
### 13. Growth Rate 数据覆盖不全
|
||||
|
||||
**文件**: `src/dex/species.ts:38-99`
|
||||
|
||||
只有 9 个物种(御三家 + 皮卡丘)有正确的 `growthRate` 数据,其余全部使用默认值 `medium-slow`。实际上超过 1000 个物种各有不同的成长速率。这导致 XP 计算对大部分物种不正确。
|
||||
|
||||
### 14. 闪光概率未使用 PID 计算
|
||||
|
||||
**文件**: `src/core/creature.ts:25`
|
||||
|
||||
```ts
|
||||
const isShiny = Math.random() < species.shinyChance // 1/4096
|
||||
```
|
||||
|
||||
原版 Gen 9 的闪光判定基于 Personality Value(32 位 PID)的异或运算,不是简单的随机概率。Shiny Charm 等道具的加成也无法体现。
|
||||
|
||||
### 15. IV 生成算法不是真正的 LCRNG
|
||||
|
||||
**文件**: `src/core/creature.ts:108-122`
|
||||
|
||||
```ts
|
||||
function generateIVs(seed: number): Record<StatName, number> {
|
||||
let s = seed
|
||||
const nextRand = () => {
|
||||
s = (s * 1103515245 + 12345) & 0x7fffffff
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
原版 Gen 3+ 使用的是完全不同的随机数生成器。更重要的是,Gen 3-5 的 IV 是通过 PID 的高位/低位直接提取的,不是独立随机。
|
||||
|
||||
### 16. 性别判定阈值计算偏差
|
||||
|
||||
**文件**: `src/core/gender.ts:12-13`
|
||||
|
||||
```ts
|
||||
const threshold = (speciesData.genderRate / 8) * 256
|
||||
return (seed % 256) < threshold ? 'female' : 'male'
|
||||
```
|
||||
|
||||
原版中性别由 PID 的低 8 位与 `genderRate` 直接比较决定,不需要乘 256 再取阈值。当前实现引入了不必要的精度损失。
|
||||
|
||||
### 17. 蛋系统与原版差异巨大
|
||||
|
||||
**文件**: `src/core/egg.ts`
|
||||
|
||||
- **获得条件**:原版通过培育屋/寄养屋繁殖,当前通过「连续编码 3 天 + 每 50 回合」获得
|
||||
- **孵化步数**:基于 captureRate 反推,而不是物种真实的 `hatch_counter` 数据
|
||||
- **没有遗传招式**:原版中蛋可以遗传父母双方的招式
|
||||
- **没有个体值遗传**:原版中蛋会随机继承父母的某些 IV
|
||||
- **没有球种遗传**:原版中蛋继承母亲的球种
|
||||
|
||||
### 18. 多语言名称覆盖极少
|
||||
|
||||
**文件**: `src/dex/names.ts`
|
||||
|
||||
只有 10 个物种有中/英/日三语名称,其余 1000+ 个物种只回退到英文名。这对于中文/日文用户来说体验不完整。
|
||||
|
||||
### 19. 缺少 Held Item 获取途径
|
||||
|
||||
战斗中 `heldItem` 被正确传入 Showdown 格式,所以底层模拟会处理道具效果。但是:
|
||||
|
||||
- 没有获得/装备道具的途径
|
||||
- 没有商店系统
|
||||
- 所有野生对手没有道具
|
||||
- 玩家的宝可梦默认 `heldItem: null`
|
||||
|
||||
### 20. Ability 系统不完整
|
||||
|
||||
- `getDefaultAbility` 只取第一个非隐藏特性
|
||||
- 没有隐藏特性(Hidden Ability)的选择
|
||||
- 没有特性胶囊/特性补丁的使用
|
||||
- 底层 Showdown 会正确处理特性效果(如 Intimidate、Levitate),但 UI 层不显示特性触发
|
||||
|
||||
---
|
||||
|
||||
## 四、问题汇总
|
||||
|
||||
| 严重程度 | 数量 | 编号 |
|
||||
|---------|------|------|
|
||||
| 严重(核心机制错误) | 5 | #1 ~ #5 |
|
||||
| 中等(机制简化/缺失) | 7 | #6 ~ #12 |
|
||||
| 轻度(数值/细节偏差) | 8 | #13 ~ #20 |
|
||||
|
||||
---
|
||||
|
||||
## 五、优先修复建议
|
||||
|
||||
按影响面从大到小排列:
|
||||
|
||||
1. **修复 XP 和 EV 计算(#1, #2)**:从 `@pkmn/data` 获取真实的 `base_experience` 和 `evs` 数据,替换当前的代理算法。这两个问题直接影响所有战斗的成长反馈。
|
||||
2. **实现物品使用(#3)**:至少支持 Potion(回复 HP)和状态治愈药。这是战斗中最基本的交互。
|
||||
3. **实现逃跑(#4)**:需要添加逃跑概率公式和对应的 Showdown 协议处理。
|
||||
4. **修复野生对手招式(#7)**:从 learnset 中按等级获取招式,替换硬编码映射。
|
||||
5. **补全 Growth Rate 数据(#13)**:从 PokeAPI 或 `@pkmn/data` 批量导入,而非只覆盖 9 个物种。
|
||||
|
||||
---
|
||||
|
||||
## 六、做得好的部分
|
||||
|
||||
- **底层战斗引擎(`@pkmn/sim`)集成正确**:属性克制、伤害公式、能力值计算、特性效果等核心数学由 Pokémon Showdown 引擎处理,结果与原版一致。
|
||||
- **EV 上限正确**:单项 252 / 总计 510,与原版一致。
|
||||
- **XP 经验曲线公式正确**:6 种 Growth Rate 的计算公式(erratic、fluctuating 等)与原版完全一致。
|
||||
- **Nature 系统完整**:25 个性格及其加成/减益效果通过 `@pkmn/data` 正确获取。
|
||||
- **Learnset 查询正确**:从 `Dex.data.Learnsets` 获取招式学习表,支持跨代回退。
|
||||
- **状态异常映射基本正确**:6 种主要状态的 Showdown 协议映射准确。
|
||||
- **战斗测试覆盖全面**:包括属性克制、强制换人、多精灵队伍等场景的集成测试。
|
||||
|
||||
---
|
||||
|
||||
## 七、修复记录(2026-04-24)
|
||||
|
||||
### 已修复
|
||||
|
||||
| 编号 | 问题 | 修复方式 |
|
||||
|------|------|---------|
|
||||
| #1 | XP 使用 baseStats.hp | 从 PokeAPI 获取真实 `base_experience`,存入 `pokedex-data.ts`,公式改为 `baseXP × level / 7` |
|
||||
| #2 | EV yield 伪造 | 从 PokeAPI 获取真实 EV yield 数据(1024 个物种),存入 `pokedex-data.ts` |
|
||||
| #3 | 物品使用无效 | 实现 Potion/HyperPotion/FullRestore 等回复药效果,直接操作 Battle 对象 HP,消耗背包物品 |
|
||||
| #4 | 逃跑未实现 | 实现 Gen 9 逃跑概率公式 `f = (playerSpeed × 128 / opponentSpeed + 30 × attempts) % 256`,成功时 forfeit 结束战斗 |
|
||||
| #6 | AI 纯随机 | AI 现在优先选克制招式(70%),避免被抵抗招式和蓄力招式,状态技最低优先级 |
|
||||
| #7 | 野生招式硬编码 | 从 `Dex.data.Learnsets` 按等级获取升级招式(最后 4 个),替换按属性硬编码映射 |
|
||||
| #8 | 进化只取第一目标 | 检查所有 `evos` 目标,支持分支进化,增加友谊度进化检测 |
|
||||
| #13 | Growth Rate 只覆盖 9 个 | 从 PokeAPI 批量导入所有 1024 个物种的 growth rate 数据 |
|
||||
| #5 | 多精灵对战不支持 | `createBattle` 支持传入 `OpponentEntry[]`,AI 换人时考虑属性克制 |
|
||||
| #10 | 缺少捕获系统 | 新增 `capture.ts`,实现 Gen 9 捕获率公式,支持精灵球/状态修正 |
|
||||
| #11 | 缺少 volatile status | 新增 `VolatileStatus` 类型,`BattlePokemon` 添加 `volatileStatus` 字段 |
|
||||
| #12 | 天气/地形未投影 | 确认 `projectState` 从 `battle.field.weather/terrain` 读取 |
|
||||
| #14 | Shiny 检测用随机 | 改为 Gen 3+ PID XOR 方法,阈值 < 16(Gen 8+ 1/4096 概率) |
|
||||
| #15 | IV 生成用 LCRNG | 改为 Gen 3+ PID 位提取法(word1/word2 各取 3 个 5-bit IV) |
|
||||
| #16 | 性别阈值精度丢失 | 从 `(rate/8)*256` 改为 `rate*32` 直接比较,消除浮点精度问题 |
|
||||
| #17 | 蛋孵化步数用 captureRate | 改为使用真实 `hatchCounter` 数据(步数 = cycles × 257),支持进化阶段回退 |
|
||||
| #18 | 多语言名称仅 10 个 | 创建 fetch 脚本获取全量中/日名称,`names.ts` 支持动态加载生成数据 |
|
||||
| #19 | 野生对手无道具 | 添加 `rollWildHeldItem`:5% 物种专属道具、5% 树果、3% 属性增强道具 |
|
||||
| #20 | Ability 只有第一个 | 新增 `randomAbility`/`getAbilities`,隐藏特性 5% 概率,第二特性 20% 概率 |
|
||||
|
||||
### 新增文件
|
||||
- `src/dex/pokedex-data.ts` — 1024 个物种的 baseExperience、EV yield、growthRate、captureRate、baseHappiness 数据
|
||||
- `scripts/fetch-pokedex-data.ts` — PokeAPI 数据抓取脚本(可重新运行以更新数据,含 hatchCounter)
|
||||
- `src/battle/capture.ts` — Gen 9 捕获率计算,精灵球/状态/时间修正
|
||||
- `scripts/fetch-species-names.ts` — 多语言名称抓取脚本(中/日/英)
|
||||
|
||||
### 修改文件
|
||||
- `src/battle/settlement.ts` — XP/EV 计算、进化检测
|
||||
- `src/battle/engine.ts` — 物品效果、逃跑逻辑、野生招式、AI 调用、多对手支持、野生道具
|
||||
- `src/battle/ai.ts` — 属性克制 AI(使用 `Dex.getEffectiveness`)
|
||||
- `src/battle/types.ts` — 新增 `run` 动作、`escaped`/`escapeAttempts`/`captureResult` 状态、VolatileStatus
|
||||
- `src/battle/index.ts` — 导出 OpponentEntry、attemptCapture、CaptureResult
|
||||
- `src/ui/BattleFlow.tsx` — 逃跑按钮、物品消耗
|
||||
- `src/dex/species.ts` — 使用 pokedex-data 替代硬编码 supplement
|
||||
- `src/dex/learnsets.ts` — 新增 randomAbility、getAbilities 函数
|
||||
- `src/dex/names.ts` — 支持加载 auto-generated 多语言名称数据
|
||||
- `src/dex/pokedex-data.ts` — 新增 getHatchCounter 函数
|
||||
- `src/core/creature.ts` — PID 生成、IV 位提取、Shiny XOR 检测、randomAbility
|
||||
- `src/core/gender.ts` — 修复阈值为 `genderRate * 32`
|
||||
- `src/core/egg.ts` — 使用 getHatchCounter 替代 captureRate 计算孵化步数
|
||||
12
packages/pokemon/package.json
Normal file
12
packages/pokemon/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@claude-code-best/pokemon",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@pkmn/client": "^0.7.2",
|
||||
"@pkmn/protocol": "^0.7.2"
|
||||
}
|
||||
}
|
||||
133
packages/pokemon/scripts/fetch-pokedex-data.ts
Normal file
133
packages/pokemon/scripts/fetch-pokedex-data.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Fetch base_experience, EV yield, and growth_rate for all species from PokeAPI.
|
||||
* Generates src/dex/pokedex-data.ts
|
||||
*
|
||||
* Usage: bun run scripts/fetch-pokedex-data.ts
|
||||
*/
|
||||
import { Dex } from '@pkmn/sim'
|
||||
|
||||
const GROWTH_RATE_MAP: Record<string, string> = {
|
||||
'slow-then-very-fast': 'erratic',
|
||||
'fast-then-very-slow': 'fluctuating',
|
||||
'medium': 'medium-fast',
|
||||
'medium-slow': 'medium-slow',
|
||||
'slow': 'slow',
|
||||
'fast': 'fast',
|
||||
}
|
||||
|
||||
const STAT_MAP: Record<string, string> = {
|
||||
'hp': 'hp',
|
||||
'attack': 'atk',
|
||||
'defense': 'def',
|
||||
'special-attack': 'spa',
|
||||
'special-defense': 'spd',
|
||||
'speed': 'spe',
|
||||
}
|
||||
|
||||
interface SpeciesPokedex {
|
||||
baseExperience: number
|
||||
evs: Record<string, number>
|
||||
growthRate: string
|
||||
captureRate: number
|
||||
baseHappiness: number
|
||||
hatchCounter: number
|
||||
}
|
||||
|
||||
async function fetchSpeciesData(id: number): Promise<SpeciesPokedex | null> {
|
||||
try {
|
||||
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json() as any
|
||||
|
||||
// Get growth rate from species endpoint
|
||||
const speciesRes = await fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`)
|
||||
if (!speciesRes.ok) return null
|
||||
const speciesData = await speciesRes.json() as any
|
||||
|
||||
const evs: Record<string, number> = {}
|
||||
for (const stat of data.stats || []) {
|
||||
if (stat.effort > 0) {
|
||||
const statName = STAT_MAP[stat.stat.name]
|
||||
if (statName) evs[statName] = stat.effort
|
||||
}
|
||||
}
|
||||
|
||||
const growthRateName = GROWTH_RATE_MAP[speciesData.growth_rate?.name] ?? 'medium-slow'
|
||||
|
||||
return {
|
||||
baseExperience: data.base_experience ?? 50,
|
||||
evs,
|
||||
growthRate: growthRateName,
|
||||
captureRate: speciesData.capture_rate ?? 45,
|
||||
baseHappiness: speciesData.base_happiness ?? 70,
|
||||
hatchCounter: speciesData.hatch_counter ?? 20,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Get all base species IDs from Dex
|
||||
const rawSpecies = Dex.data.Species as Record<string, { num: number; forme?: string }>
|
||||
const species: { id: string; num: number }[] = []
|
||||
for (const [id, s] of Object.entries(rawSpecies)) {
|
||||
if (s.num > 0 && Number.isInteger(s.num) && !s.forme) {
|
||||
species.push({ id, num: s.num })
|
||||
}
|
||||
}
|
||||
species.sort((a, b) => a.num - b.num)
|
||||
|
||||
console.log(`Fetching data for ${species.length} species from PokeAPI...`)
|
||||
|
||||
const results: Record<string, SpeciesPokedex> = {}
|
||||
let fetched = 0
|
||||
const BATCH_SIZE = 20
|
||||
|
||||
for (let i = 0; i < species.length; i += BATCH_SIZE) {
|
||||
const batch = species.slice(i, i + BATCH_SIZE)
|
||||
const promises = batch.map(async (s) => {
|
||||
const data = await fetchSpeciesData(s.num)
|
||||
if (data) results[s.id] = data
|
||||
fetched++
|
||||
})
|
||||
await Promise.all(promises)
|
||||
process.stdout.write(`\rFetched ${fetched}/${species.length}...`)
|
||||
// Small delay to avoid rate limiting
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
console.log(`\nFetched ${Object.keys(results).length} species.`)
|
||||
|
||||
// Generate TypeScript file
|
||||
const lines: string[] = [
|
||||
'// Auto-generated from PokeAPI. Run: bun run scripts/fetch-pokedex-data.ts',
|
||||
'// eslint-disable-next-line @typescript-eslint/no-extraneous-class',
|
||||
'export interface PokedexEntry {',
|
||||
' baseExperience: number',
|
||||
' evs: Record<string, number>',
|
||||
' growthRate: string',
|
||||
' captureRate: number',
|
||||
' baseHappiness: number',
|
||||
' hatchCounter?: number',
|
||||
'}',
|
||||
'',
|
||||
'export const POKEDEX_DATA: Record<string, PokedexEntry> = {',
|
||||
]
|
||||
|
||||
for (const [id, data] of Object.entries(results)) {
|
||||
const evsStr = Object.keys(data.evs).length > 0
|
||||
? `{ ${Object.entries(data.evs).map(([k, v]) => `${k}: ${v}`).join(', ')} }`
|
||||
: '{}'
|
||||
lines.push(` '${id}': { baseExperience: ${data.baseExperience}, evs: ${evsStr}, growthRate: '${data.growthRate}', captureRate: ${data.captureRate}, baseHappiness: ${data.baseHappiness}, hatchCounter: ${data.hatchCounter} },`)
|
||||
}
|
||||
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
const outputPath = new URL('../src/dex/pokedex-data.ts', import.meta.url)
|
||||
await Bun.write(outputPath, lines.join('\n'))
|
||||
console.log(`Written to ${outputPath.pathname}`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
90
packages/pokemon/scripts/fetch-species-names.ts
Normal file
90
packages/pokemon/scripts/fetch-species-names.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Fetch multilingual species names (en, ja, zh) from PokeAPI.
|
||||
* Generates src/dex/species-names.ts
|
||||
*
|
||||
* Usage: bun run scripts/fetch-species-names.ts
|
||||
*/
|
||||
import { Dex } from '@pkmn/sim'
|
||||
|
||||
interface SpeciesNames {
|
||||
en: string
|
||||
ja: string
|
||||
zh: string
|
||||
}
|
||||
|
||||
async function fetchSpeciesNames(id: number): Promise<SpeciesNames | null> {
|
||||
try {
|
||||
const res = await fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json() as any
|
||||
|
||||
const names: SpeciesNames = { en: '', ja: '', zh: '' }
|
||||
for (const entry of data.names || []) {
|
||||
const lang = entry.language.name as string
|
||||
if (lang === 'en') names.en = entry.name
|
||||
else if (lang === 'ja') names.ja = entry.name
|
||||
else if (lang === 'zh-Hant' || lang === 'zh-Hans') names.zh = entry.name
|
||||
}
|
||||
// Fallback to English if zh/ja missing
|
||||
if (!names.zh) names.zh = names.en
|
||||
if (!names.ja) names.ja = names.en
|
||||
if (!names.en) return null
|
||||
|
||||
return names
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rawSpecies = Dex.data.Species as Record<string, { num: number; forme?: string }>
|
||||
const species: { id: string; num: number }[] = []
|
||||
for (const [id, s] of Object.entries(rawSpecies)) {
|
||||
if (s.num > 0 && Number.isInteger(s.num) && !s.forme) {
|
||||
species.push({ id, num: s.num })
|
||||
}
|
||||
}
|
||||
species.sort((a, b) => a.num - b.num)
|
||||
|
||||
console.log(`Fetching names for ${species.length} species from PokeAPI...`)
|
||||
|
||||
const results: Record<string, SpeciesNames> = {}
|
||||
let fetched = 0
|
||||
const BATCH_SIZE = 20
|
||||
|
||||
for (let i = 0; i < species.length; i += BATCH_SIZE) {
|
||||
const batch = species.slice(i, i + BATCH_SIZE)
|
||||
const promises = batch.map(async (s) => {
|
||||
const data = await fetchSpeciesNames(s.num)
|
||||
if (data) results[s.id] = data
|
||||
fetched++
|
||||
})
|
||||
await Promise.all(promises)
|
||||
process.stdout.write(`\rFetched ${fetched}/${species.length}...`)
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
console.log(`\nFetched ${Object.keys(results).length} species names.`)
|
||||
|
||||
// Generate TypeScript file
|
||||
const lines: string[] = [
|
||||
'// Auto-generated from PokeAPI. Run: bun run scripts/fetch-species-names.ts',
|
||||
'',
|
||||
'export interface SpeciesI18n { en: string; ja: string; zh: string }',
|
||||
'',
|
||||
'export const SPECIES_I18N_DATA: Record<string, SpeciesI18n> = {',
|
||||
]
|
||||
|
||||
for (const [id, data] of Object.entries(results)) {
|
||||
lines.push(` '${id}': { en: '${data.en.replace(/'/g, "\\'")}', ja: '${data.ja}', zh: '${data.zh}' },`)
|
||||
}
|
||||
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
const outputPath = new URL('../src/dex/species-names.ts', import.meta.url)
|
||||
await Bun.write(outputPath, lines.join('\n'))
|
||||
console.log(`Written to ${outputPath.pathname}`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
124
packages/pokemon/scripts/fetch-sprites.ts
Normal file
124
packages/pokemon/scripts/fetch-sprites.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Script to pre-fetch all 10 MVP Pokémon sprites from GitHub.
|
||||
* Run: bun run packages/pokemon/scripts/fetch-sprites.ts
|
||||
*/
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons'
|
||||
|
||||
const COW_FILES: Record<string, string> = {
|
||||
bulbasaur: '001_bulbasaur',
|
||||
ivysaur: '002_ivysaur',
|
||||
venusaur: '003_venusaur',
|
||||
charmander: '004_charmander',
|
||||
charmeleon: '005_charmeleon',
|
||||
charizard: '006_charizard',
|
||||
squirtle: '007_squirtle',
|
||||
wartortle: '008_wartortle',
|
||||
blastoise: '009_blastoise',
|
||||
pikachu: '025_pikachu',
|
||||
}
|
||||
|
||||
const SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
|
||||
|
||||
function convertCowToLines(cowContent: string): string[] {
|
||||
const startMarker = '$the_cow =<<EOC;'
|
||||
const endMarker = 'EOC'
|
||||
|
||||
const startIdx = cowContent.indexOf(startMarker)
|
||||
if (startIdx === -1) return []
|
||||
|
||||
const contentStart = startIdx + startMarker.length
|
||||
const endIdx = cowContent.indexOf(endMarker, contentStart)
|
||||
if (endIdx === -1) return []
|
||||
|
||||
let content = cowContent.slice(contentStart, endIdx)
|
||||
|
||||
// Convert \N{U+XXXX} to actual Unicode characters
|
||||
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
|
||||
String.fromCodePoint(parseInt(hex, 16)),
|
||||
)
|
||||
|
||||
// Convert \e to actual escape character (for ANSI sequences)
|
||||
content = content.replace(/\\e/g, '\x1b')
|
||||
|
||||
// Split into lines
|
||||
let lines = content.split('\n')
|
||||
|
||||
// Strip leading/trailing empty lines
|
||||
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
|
||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
||||
|
||||
// Remove first 4 lines (cowsay thought bubble guide - $thoughts lines)
|
||||
if (lines.length > 4) {
|
||||
lines = lines.slice(4)
|
||||
}
|
||||
|
||||
// Trim trailing whitespace on each line
|
||||
lines = lines.map((line) => line.trimEnd())
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function stripAnsi(str: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Ensure output directory
|
||||
if (!existsSync(SPRITES_DIR)) {
|
||||
mkdirSync(SPRITES_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
for (const [speciesId, cowPrefix] of Object.entries(COW_FILES)) {
|
||||
const url = `${GITHUB_RAW_BASE}/${cowPrefix}.cow`
|
||||
console.log(`Fetching ${speciesId} from ${url}...`)
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
console.error(` FAILED: HTTP ${response.status}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const cowContent = await response.text()
|
||||
const lines = convertCowToLines(cowContent)
|
||||
|
||||
if (lines.length === 0) {
|
||||
console.error(` FAILED: No lines after conversion`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate visible width (strip ANSI for measurement)
|
||||
const widths = lines.map((l) => stripAnsi(l).length)
|
||||
|
||||
const sprite = {
|
||||
speciesId,
|
||||
lines,
|
||||
width: Math.max(...widths),
|
||||
height: lines.length,
|
||||
fetchedAt: Date.now(),
|
||||
}
|
||||
|
||||
const outPath = join(SPRITES_DIR, `${speciesId}.json`)
|
||||
writeFileSync(outPath, JSON.stringify(sprite, null, 2))
|
||||
|
||||
console.log(` OK: ${lines.length} lines, ${sprite.width} cols wide`)
|
||||
|
||||
// Also print first line for visual check
|
||||
console.log(` Preview line 1: ${stripAnsi(lines[0]!)}`)
|
||||
} catch (err) {
|
||||
console.error(` FAILED: ${err}`)
|
||||
}
|
||||
|
||||
// Small delay to be nice to GitHub
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
console.log('\nDone! Sprites cached to ~/.claude/buddy-sprites/')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
347
packages/pokemon/src/__tests__/battle-helper.ts
Normal file
347
packages/pokemon/src/__tests__/battle-helper.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Battle Test Framework
|
||||
*
|
||||
* Fluent API for testing Pokémon battle scenarios:
|
||||
*
|
||||
* const s = await battleScenario()
|
||||
* .party('charmander', 50, ['flamethrower'])
|
||||
* .party('bulbasaur', 30, ['vinewhip'])
|
||||
* .opponent('squirtle', 50)
|
||||
* .start()
|
||||
*
|
||||
* const state = await s.useMove(0).runTurn()
|
||||
* s.expect(state).hasDamage('opponent')
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { createBattle, executeTurn, executeSwitch } from '../battle/engine'
|
||||
import type { BattleState } from '../battle/types'
|
||||
import type { BattleInit } from '../battle/engine'
|
||||
import type { BattleEvent } from '../battle/types'
|
||||
import type { Creature, SpeciesId, StatName } from '../types'
|
||||
|
||||
// ─── Creature Builder ───
|
||||
|
||||
interface CreatureSpec {
|
||||
id: string
|
||||
speciesId: SpeciesId
|
||||
level: number
|
||||
moves: string[]
|
||||
ability?: string
|
||||
nature?: string
|
||||
ev?: Partial<Record<StatName, number>>
|
||||
iv?: Partial<Record<StatName, number>>
|
||||
}
|
||||
|
||||
function buildCreature(spec: CreatureSpec, index: number): Creature {
|
||||
return {
|
||||
id: spec.id ?? `test-${index}`,
|
||||
speciesId: spec.speciesId,
|
||||
gender: 'male',
|
||||
level: spec.level,
|
||||
xp: 0,
|
||||
totalXp: 0,
|
||||
nature: (spec.nature ?? 'adamant') as Creature['nature'],
|
||||
ev: {
|
||||
hp: spec.ev?.hp ?? 0,
|
||||
attack: spec.ev?.attack ?? 0,
|
||||
defense: spec.ev?.defense ?? 0,
|
||||
spAtk: spec.ev?.spAtk ?? 0,
|
||||
spDef: spec.ev?.spDef ?? 0,
|
||||
speed: spec.ev?.speed ?? 0,
|
||||
},
|
||||
iv: {
|
||||
hp: spec.iv?.hp ?? 31,
|
||||
attack: spec.iv?.attack ?? 31,
|
||||
defense: spec.iv?.defense ?? 31,
|
||||
spAtk: spec.iv?.spAtk ?? 31,
|
||||
spDef: spec.iv?.spDef ?? 31,
|
||||
speed: spec.iv?.speed ?? 31,
|
||||
},
|
||||
moves: [
|
||||
...spec.moves.map(m => ({ id: m, pp: 15, maxPp: 15 })),
|
||||
...Array(Math.max(0, 4 - spec.moves.length)).fill({ id: '', pp: 0, maxPp: 0 }),
|
||||
] as [import('../types').MoveSlot, import('../types').MoveSlot, import('../types').MoveSlot, import('../types').MoveSlot],
|
||||
ability: spec.ability ?? 'blaze',
|
||||
heldItem: null,
|
||||
friendship: 70,
|
||||
isShiny: false,
|
||||
hatchedAt: Date.now(),
|
||||
pokeball: 'pokeball',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scenario Builder ───
|
||||
|
||||
export interface BattleScenario {
|
||||
/** Add a party member (first = lead) */
|
||||
party(species: SpeciesId, level: number, moves: string[], opts?: Partial<CreatureSpec>): BattleScenario
|
||||
/** Set opponent (wild Pokémon) */
|
||||
opponent(species: SpeciesId, level: number): BattleScenario
|
||||
/** Create the battle and return runner */
|
||||
start(): Promise<BattleRunner>
|
||||
}
|
||||
|
||||
export interface BattleRunner {
|
||||
/** Queue a move action (0-indexed) */
|
||||
useMove(index: number): BattleRunner
|
||||
/** Queue a switch action (party slot index, 0-indexed) */
|
||||
switchTo(partyIndex: number): BattleRunner
|
||||
/** Execute one turn with queued action, return state */
|
||||
runTurn(): Promise<BattleState>
|
||||
/** Keep using move 0 until battle ends or max turns reached */
|
||||
runUntilEnd(maxTurns?: number): Promise<BattleState>
|
||||
/** Execute forced switch after faint */
|
||||
doSwitch(partyIndex: number): Promise<BattleState>
|
||||
/** Get current battle state (re-projected from Battle object) */
|
||||
readonly state: BattleState
|
||||
/** Assertion helpers */
|
||||
expect(state: BattleState): BattleAssertions
|
||||
}
|
||||
|
||||
export interface BattleAssertions {
|
||||
/** Battle has not ended */
|
||||
ongoing(): BattleAssertions
|
||||
/** Battle has ended */
|
||||
finished(): BattleAssertions
|
||||
/** Player won */
|
||||
playerWon(): BattleAssertions
|
||||
/** Opponent won */
|
||||
opponentWon(): BattleAssertions
|
||||
/** Player's active HP is full */
|
||||
playerHpFull(): BattleAssertions
|
||||
/** Player's active HP is below threshold (absolute) */
|
||||
playerHpBelow(hp: number): BattleAssertions
|
||||
/** Player's active HP percentage is below threshold */
|
||||
playerHpPctBelow(pct: number): BattleAssertions
|
||||
/** Opponent's active HP is full */
|
||||
opponentHpFull(): BattleAssertions
|
||||
/** Opponent's active HP is below threshold */
|
||||
opponentHpBelow(hp: number): BattleAssertions
|
||||
/** Player needs to switch (active fainted, bench alive) */
|
||||
needsSwitch(): BattleAssertions
|
||||
/** Player's active Pokémon has fainted */
|
||||
playerFainted(): BattleAssertions
|
||||
/** Opponent's active Pokémon has fainted */
|
||||
opponentFainted(): BattleAssertions
|
||||
/** Player's active species matches */
|
||||
playerSpecies(species: SpeciesId): BattleAssertions
|
||||
/** Opponent's active species matches */
|
||||
opponentSpecies(species: SpeciesId): BattleAssertions
|
||||
/** Events contain at least one of given type (optionally for given side) */
|
||||
hasEvent(type: BattleEvent['type'], side?: 'player' | 'opponent'): BattleAssertions
|
||||
/** Events contain damage for given side */
|
||||
hasDamage(side: 'player' | 'opponent'): BattleAssertions
|
||||
/** Events contain a move event for given side */
|
||||
hasMove(side: 'player' | 'opponent'): BattleAssertions
|
||||
/** Events contain a faint event for given side */
|
||||
hasFaint(side: 'player' | 'opponent'): BattleAssertions
|
||||
/** Events contain super-effective hit */
|
||||
hasSuperEffective(): BattleAssertions
|
||||
/** Events contain resisted hit */
|
||||
hasResisted(): BattleAssertions
|
||||
/** Events contain critical hit */
|
||||
hasCrit(): BattleAssertions
|
||||
/** Turn number matches */
|
||||
turnIs(n: number): BattleAssertions
|
||||
/** Player party has N alive (hp > 0) Pokémon */
|
||||
aliveInParty(n: number): BattleAssertions
|
||||
/** Player's move at index has expected pp and maxPp */
|
||||
playerMovePp(moveIndex: number, pp: number, maxPp: number): BattleAssertions
|
||||
/** Generic assertion */
|
||||
satisfies(fn: (state: BattleState) => boolean, msg?: string): BattleAssertions
|
||||
}
|
||||
|
||||
// ─── Implementation ───
|
||||
|
||||
class BattleScenarioImpl implements BattleScenario {
|
||||
private _party: CreatureSpec[] = []
|
||||
private _opponentSpecies: SpeciesId = 'pikachu'
|
||||
private _opponentLevel = 5
|
||||
|
||||
party(species: SpeciesId, level: number, moves: string[], opts?: Partial<CreatureSpec>): BattleScenario {
|
||||
this._party.push({
|
||||
id: opts?.id ?? `p${this._party.length + 1}`,
|
||||
speciesId: species,
|
||||
level,
|
||||
moves,
|
||||
...opts,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
opponent(species: SpeciesId, level: number): BattleScenario {
|
||||
this._opponentSpecies = species
|
||||
this._opponentLevel = level
|
||||
return this
|
||||
}
|
||||
|
||||
async start(): Promise<BattleRunner> {
|
||||
if (this._party.length === 0) {
|
||||
this._party.push({ id: 'p1', speciesId: 'charmander', level: 50, moves: ['tackle'] })
|
||||
}
|
||||
const creatures = this._party.map((s, i) => buildCreature(s, i))
|
||||
const init = await createBattle(creatures, this._opponentSpecies, this._opponentLevel)
|
||||
return new BattleRunnerImpl(init)
|
||||
}
|
||||
}
|
||||
|
||||
class BattleRunnerImpl implements BattleRunner {
|
||||
private _init: BattleInit
|
||||
private _pendingAction: { type: 'move'; index: number } | { type: 'switch'; partyIndex: number } | null = null
|
||||
|
||||
constructor(init: BattleInit) {
|
||||
this._init = init
|
||||
}
|
||||
|
||||
get state(): BattleState {
|
||||
return this._init.state
|
||||
}
|
||||
|
||||
useMove(index: number): BattleRunner {
|
||||
this._pendingAction = { type: 'move', index }
|
||||
return this
|
||||
}
|
||||
|
||||
switchTo(partyIndex: number): BattleRunner {
|
||||
this._pendingAction = { type: 'switch', partyIndex }
|
||||
return this
|
||||
}
|
||||
|
||||
async runTurn(): Promise<BattleState> {
|
||||
const action = this._pendingAction
|
||||
this._pendingAction = null
|
||||
|
||||
if (!action) {
|
||||
// Default: use move 0
|
||||
return executeTurn(this._init, { type: 'move', moveIndex: 0 })
|
||||
}
|
||||
|
||||
if (action.type === 'move') {
|
||||
return executeTurn(this._init, { type: 'move', moveIndex: action.index })
|
||||
} else {
|
||||
return executeTurn(this._init, { type: 'switch', partyIndex: action.partyIndex })
|
||||
}
|
||||
}
|
||||
|
||||
async runUntilEnd(maxTurns = 100): Promise<BattleState> {
|
||||
let state = this._init.state
|
||||
for (let i = 0; i < maxTurns && !state.finished; i++) {
|
||||
if (state.needsSwitch) {
|
||||
// Auto-switch to first alive bench
|
||||
const alive = state.playerParty.findIndex((p: any, idx: any) => idx > 0 && p.hp > 0)
|
||||
if (alive >= 0) {
|
||||
state = await executeSwitch(this._init, alive)
|
||||
} else break
|
||||
}
|
||||
state = await executeTurn(this._init, { type: 'move', moveIndex: 0 })
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
async doSwitch(partyIndex: number): Promise<BattleState> {
|
||||
return executeSwitch(this._init, partyIndex)
|
||||
}
|
||||
|
||||
expect(state: BattleState): BattleAssertions {
|
||||
return new BattleAssertionsImpl(state)
|
||||
}
|
||||
}
|
||||
|
||||
class BattleAssertionsImpl implements BattleAssertions {
|
||||
constructor(private s: BattleState) {}
|
||||
|
||||
ongoing() { expect(this.s.finished).toBe(false); return this }
|
||||
finished() { expect(this.s.finished).toBe(true); return this }
|
||||
playerWon() { expect(this.s.result?.winner).toBe('player'); return this }
|
||||
opponentWon() { expect(this.s.result?.winner).toBe('opponent'); return this }
|
||||
|
||||
playerHpFull() { expect(this.s.playerPokemon.hp).toBe(this.s.playerPokemon.maxHp); return this }
|
||||
playerHpBelow(hp: number) { expect(this.s.playerPokemon.hp).toBeLessThan(hp); return this }
|
||||
playerHpPctBelow(pct: number) {
|
||||
const actual = this.s.playerPokemon.maxHp > 0 ? (this.s.playerPokemon.hp / this.s.playerPokemon.maxHp) * 100 : 0
|
||||
expect(actual).toBeLessThan(pct)
|
||||
return this
|
||||
}
|
||||
opponentHpFull() { expect(this.s.opponentPokemon.hp).toBe(this.s.opponentPokemon.maxHp); return this }
|
||||
opponentHpBelow(hp: number) { expect(this.s.opponentPokemon.hp).toBeLessThan(hp); return this }
|
||||
|
||||
needsSwitch() { expect(this.s.needsSwitch).toBe(true); return this }
|
||||
playerFainted() { expect(this.s.playerPokemon.hp).toBe(0); return this }
|
||||
opponentFainted() { expect(this.s.opponentPokemon.hp).toBe(0); return this }
|
||||
|
||||
playerSpecies(sp: SpeciesId) { expect(this.s.playerPokemon.speciesId).toBe(sp); return this }
|
||||
opponentSpecies(sp: SpeciesId) { expect(this.s.opponentPokemon.speciesId).toBe(sp); return this }
|
||||
|
||||
hasEvent(type: BattleEvent['type'], side?: 'player' | 'opponent') {
|
||||
const has = this.s.events.some(e =>
|
||||
e.type === type && (side === undefined || ('side' in e && e.side === side))
|
||||
)
|
||||
expect(has).toBe(true)
|
||||
return this
|
||||
}
|
||||
hasDamage(side: 'player' | 'opponent') { return this.hasEvent('damage', side) }
|
||||
hasMove(side: 'player' | 'opponent') { return this.hasEvent('move', side) }
|
||||
hasFaint(side: 'player' | 'opponent') { return this.hasEvent('faint', side) }
|
||||
hasSuperEffective() { return this.hasEvent('effectiveness') }
|
||||
|
||||
hasResisted() {
|
||||
const has = this.s.events.some(e => e.type === 'effectiveness' && 'multiplier' in e && e.multiplier < 1)
|
||||
expect(has).toBe(true)
|
||||
return this
|
||||
}
|
||||
hasCrit() { return this.hasEvent('crit') }
|
||||
|
||||
turnIs(n: number) { expect(this.s.turn).toBe(n); return this }
|
||||
aliveInParty(n: number) {
|
||||
const alive = this.s.playerParty.filter(p => p.hp > 0).length
|
||||
expect(alive).toBe(n)
|
||||
return this
|
||||
}
|
||||
|
||||
playerMovePp(moveIndex: number, pp: number, maxPp: number) {
|
||||
const move = this.s.playerPokemon.moves[moveIndex]
|
||||
expect(move).toBeDefined()
|
||||
expect(move!.pp).toBe(pp)
|
||||
expect(move!.maxPp).toBe(maxPp)
|
||||
return this
|
||||
}
|
||||
|
||||
satisfies(fn: (state: BattleState) => boolean, msg?: string) {
|
||||
expect(fn(this.s), msg).toBe(true)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───
|
||||
|
||||
/** Create a new battle scenario */
|
||||
export function battleScenario(): BattleScenario {
|
||||
return new BattleScenarioImpl()
|
||||
}
|
||||
|
||||
/** Quick creature builder for raw Creature objects */
|
||||
export function makeCreature(
|
||||
species: SpeciesId,
|
||||
level: number,
|
||||
moves: string[] = ['tackle'],
|
||||
opts?: Partial<CreatureSpec>,
|
||||
): Creature {
|
||||
return buildCreature({
|
||||
id: opts?.id ?? 'test-1',
|
||||
speciesId: species,
|
||||
level,
|
||||
moves,
|
||||
...opts,
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/** Shorthand for describe/test wrapper */
|
||||
export function battleSuite(name: string, fn: (b: typeof battleScenario) => void) {
|
||||
describe(name, () => fn(battleScenario))
|
||||
}
|
||||
|
||||
/** Shorthand for a single battle test */
|
||||
export function battleTest(name: string, fn: () => Promise<void>) {
|
||||
test(name, fn)
|
||||
}
|
||||
298
packages/pokemon/src/__tests__/battle-scenarios.test.ts
Normal file
298
packages/pokemon/src/__tests__/battle-scenarios.test.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { battleScenario, battleTest, makeCreature } from './battle-helper'
|
||||
import type { BattleState } from '../battle/types'
|
||||
|
||||
// ─── 基础战斗创建 ───
|
||||
|
||||
describe('Battle Scenario: 创建', () => {
|
||||
battleTest('单精灵对战正常初始化', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower', 'airslash'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
s.expect(s.state)
|
||||
.ongoing()
|
||||
.playerSpecies('charmander')
|
||||
.opponentSpecies('squirtle')
|
||||
.playerHpFull()
|
||||
.opponentHpFull()
|
||||
})
|
||||
|
||||
battleTest('多精灵队伍正确初始化', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.party('bulbasaur', 30, ['vinewhip'])
|
||||
.party('pikachu', 25, ['thundershock'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
s.expect(s.state)
|
||||
.ongoing()
|
||||
.playerSpecies('charmander')
|
||||
.satisfies(s => s.playerParty.length === 3, 'party should have 3 members')
|
||||
.aliveInParty(3)
|
||||
})
|
||||
|
||||
battleTest('初始回合数为 1', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('pikachu', 50, ['thundershock'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
s.expect(s.state).turnIs(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 单回合战斗事件 ───
|
||||
|
||||
describe('Battle Scenario: 单回合事件', () => {
|
||||
battleTest('使用招式后产生伤害事件', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 5)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasDamage('opponent')
|
||||
})
|
||||
|
||||
battleTest('双方均使用招式', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state)
|
||||
.hasMove('player')
|
||||
.hasMove('opponent')
|
||||
})
|
||||
|
||||
battleTest('使用招式后 PP 递减', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower', 'scratch'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
// Record initial PP
|
||||
const initialState = s.state
|
||||
const initialPp = initialState.playerPokemon.moves[0]!.pp
|
||||
const maxPp = initialState.playerPokemon.moves[0]!.maxPp
|
||||
expect(initialPp).toBe(maxPp)
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
// PP should decrease by 1, maxPp stays the same
|
||||
s.expect(state).playerMovePp(0, initialPp - 1, maxPp)
|
||||
})
|
||||
|
||||
battleTest('等级碾压一击击杀', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 5)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).finished().opponentFainted()
|
||||
})
|
||||
|
||||
battleTest('回合数递增', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('pikachu', 50, ['thundershock'])
|
||||
.opponent('pikachu', 50) // Same type matchup for neutral/longer battle
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).turnIs(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 属性克制 ───
|
||||
|
||||
describe('Battle Scenario: 属性克制', () => {
|
||||
battleTest('火系招式对草系效果绝佳', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.opponent('bulbasaur', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasSuperEffective().hasDamage('opponent')
|
||||
})
|
||||
|
||||
battleTest('水系招式对火系效果绝佳', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('squirtle', 50, ['watergun'])
|
||||
.opponent('charmander', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasSuperEffective().hasDamage('opponent')
|
||||
})
|
||||
|
||||
battleTest('水系招式对水系效果不佳', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('squirtle', 50, ['watergun'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasResisted().hasDamage('opponent')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 强制换人 ───
|
||||
|
||||
describe('Battle Scenario: 强制换人', () => {
|
||||
battleTest('精灵倒下触发强制换人', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.party('bulbasaur', 50, ['vinewhip'])
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).needsSwitch().playerFainted().aliveInParty(1)
|
||||
})
|
||||
|
||||
battleTest('换人后新精灵上场', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.party('bulbasaur', 50, ['vinewhip'])
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
const afterTurn = await s.useMove(0).runTurn()
|
||||
s.expect(afterTurn).needsSwitch()
|
||||
|
||||
const afterSwitch = await s.doSwitch(1)
|
||||
s.expect(afterSwitch).playerSpecies('bulbasaur').ongoing()
|
||||
})
|
||||
|
||||
battleTest('换人后继续战斗', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.party('pikachu', 100, ['thundershock'], { ev: { attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
// Charmander gets OHKO'd by L100 Squirtle
|
||||
await s.useMove(0).runTurn()
|
||||
// Switch to Pikachu
|
||||
await s.doSwitch(1)
|
||||
// Pikachu fights Squirtle
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasMove('player').playerSpecies('pikachu')
|
||||
})
|
||||
|
||||
battleTest('最后一只倒下不触发强制换人', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state)
|
||||
.finished()
|
||||
.opponentWon()
|
||||
.satisfies(s => !s.needsSwitch, 'no switch needed when all fainted')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 战术换人 ───
|
||||
|
||||
describe('Battle Scenario: 战术换人', () => {
|
||||
battleTest('战术换人在同回合执行', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.party('squirtle', 50, ['watergun'])
|
||||
.opponent('bulbasaur', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.switchTo(1).runTurn()
|
||||
s.expect(state).playerSpecies('squirtle').ongoing()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 战斗结束 ───
|
||||
|
||||
describe('Battle Scenario: 战斗结束', () => {
|
||||
battleTest('玩家胜利', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.opponent('bulbasaur', 5)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).finished().playerWon()
|
||||
})
|
||||
|
||||
battleTest('玩家失败', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).finished().opponentWon()
|
||||
})
|
||||
|
||||
battleTest('runUntilEnd 自动完成战斗', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.opponent('squirtle', 5)
|
||||
.start()
|
||||
|
||||
const state = await s.runUntilEnd()
|
||||
s.expect(state).finished()
|
||||
})
|
||||
|
||||
battleTest('长战斗在 maxTurns 内结束', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.runUntilEnd(100)
|
||||
s.expect(state).finished()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 多精灵队伍战斗流程 ───
|
||||
|
||||
describe('Battle Scenario: 多精灵队伍', () => {
|
||||
battleTest('2v1 战斗:需要两次击杀', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.party('bulbasaur', 100, ['vinewhip'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 5)
|
||||
.start()
|
||||
|
||||
// First pokemon OHKOs opponent
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).finished().playerWon()
|
||||
})
|
||||
|
||||
battleTest('连续换人后战斗继续', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.party('bulbasaur', 5, ['vinewhip'])
|
||||
.party('pikachu', 100, ['thundershock'], { ev: { attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
// Charmander faints to L100 Squirtle
|
||||
await s.useMove(0).runTurn()
|
||||
// Switch to Bulbasaur (index 1)
|
||||
await s.doSwitch(1)
|
||||
// Bulbasaur faints too
|
||||
await s.useMove(0).runTurn()
|
||||
// Switch to Pikachu (index 2)
|
||||
await s.doSwitch(2)
|
||||
// Pikachu finishes
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state)
|
||||
.playerSpecies('pikachu')
|
||||
.hasMove('player')
|
||||
})
|
||||
})
|
||||
469
packages/pokemon/src/__tests__/battle.test.ts
Normal file
469
packages/pokemon/src/__tests__/battle.test.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { createBattle, executeTurn } from '../battle/engine'
|
||||
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
|
||||
import { chooseAIMove } from '../battle/ai'
|
||||
import type { Creature, BuddyData } from '../types'
|
||||
|
||||
function makeTestCreature(overrides: Partial<Creature> = {}): Creature {
|
||||
return {
|
||||
id: overrides.id ?? 'test-1',
|
||||
speciesId: overrides.speciesId ?? 'charmander',
|
||||
gender: overrides.gender ?? 'male',
|
||||
level: overrides.level ?? 50,
|
||||
xp: 0,
|
||||
totalXp: 0,
|
||||
nature: overrides.nature ?? 'adamant',
|
||||
ev: overrides.ev ?? { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
iv: overrides.iv ?? { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
|
||||
moves: overrides.moves ?? [
|
||||
{ id: 'flamethrower', pp: 15, maxPp: 15 },
|
||||
{ id: 'airslash', pp: 15, maxPp: 15 },
|
||||
{ id: 'dragontail', pp: 10, maxPp: 10 },
|
||||
{ id: 'slash', pp: 20, maxPp: 20 },
|
||||
],
|
||||
ability: overrides.ability ?? 'blaze',
|
||||
heldItem: null,
|
||||
friendship: overrides.friendship ?? 70,
|
||||
isShiny: false,
|
||||
hatchedAt: Date.now(),
|
||||
pokeball: 'pokeball',
|
||||
}
|
||||
}
|
||||
|
||||
function makeTestBuddyData(creatures: Creature[] = [makeTestCreature()]): BuddyData {
|
||||
return {
|
||||
version: 2,
|
||||
party: [creatures[0]!.id, null, null, null, null, null],
|
||||
boxes: [],
|
||||
creatures: creatures,
|
||||
eggs: [],
|
||||
dex: [],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: 0,
|
||||
consecutiveDays: 0,
|
||||
lastActiveDate: '',
|
||||
totalEggsObtained: 0,
|
||||
totalEvolutions: 0,
|
||||
battlesWon: 0,
|
||||
battlesLost: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('createBattle', () => {
|
||||
test('creates battle with valid initial state', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state).toBeDefined()
|
||||
expect(init.state.playerPokemon).toBeDefined()
|
||||
expect(init.state.opponentPokemon).toBeDefined()
|
||||
expect(init.state.finished).toBe(false)
|
||||
})
|
||||
|
||||
test('player pokemon has correct species', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'bulbasaur', 30)
|
||||
expect(init.state.playerPokemon.speciesId).toBe('charmander')
|
||||
expect(init.state.opponentPokemon.speciesId).toBe('bulbasaur')
|
||||
})
|
||||
|
||||
test('player pokemon has moves', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state.playerPokemon.moves.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeTurn', () => {
|
||||
test('move action generates events', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
const initialEventCount = init.state.events.length
|
||||
|
||||
const newState = await executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||
expect(newState.events.length).toBeGreaterThanOrEqual(initialEventCount)
|
||||
})
|
||||
|
||||
test('battle eventually ends within 50 turns', async () => {
|
||||
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 0, speed: 252 } })
|
||||
const init = await createBattle([creature], 'squirtle', 5)
|
||||
|
||||
let state = init.state
|
||||
for (let i = 0; i < 50 && !state.finished; i++) {
|
||||
state = await executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||
}
|
||||
|
||||
expect(state.finished).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settleBattle', () => {
|
||||
test('player win increments battlesWon', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const data: BuddyData = {
|
||||
version: 2,
|
||||
party: [creature.id, null, null, null, null, null],
|
||||
boxes: [],
|
||||
creatures: [creature],
|
||||
eggs: [],
|
||||
dex: [],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: 0,
|
||||
consecutiveDays: 0,
|
||||
lastActiveDate: '',
|
||||
totalEggsObtained: 0,
|
||||
totalEvolutions: 0,
|
||||
battlesWon: 0,
|
||||
battlesLost: 0,
|
||||
},
|
||||
}
|
||||
const result = {
|
||||
winner: 'player' as const,
|
||||
turns: 5,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [creature.id],
|
||||
}
|
||||
|
||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||
expect(settlement.data.stats.battlesWon).toBe(1)
|
||||
})
|
||||
|
||||
test('player loss returns unchanged data', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const data: BuddyData = {
|
||||
version: 2,
|
||||
party: [creature.id, null, null, null, null, null],
|
||||
boxes: [],
|
||||
creatures: [creature],
|
||||
eggs: [],
|
||||
dex: [],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: 0,
|
||||
consecutiveDays: 0,
|
||||
lastActiveDate: '',
|
||||
totalEggsObtained: 0,
|
||||
totalEvolutions: 0,
|
||||
battlesWon: 0,
|
||||
battlesLost: 0,
|
||||
},
|
||||
}
|
||||
const result = {
|
||||
winner: 'opponent' as const,
|
||||
turns: 3,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [creature.id],
|
||||
}
|
||||
|
||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||
// Loss early-returns unchanged data
|
||||
expect(settlement.data.creatures[0]!.totalXp).toBe(creature.totalXp)
|
||||
expect(settlement.learnableMoves).toEqual([])
|
||||
expect(settlement.pendingEvolutions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyMoveLearn', () => {
|
||||
test('replaces move at given index', () => {
|
||||
const creature = makeTestCreature()
|
||||
const data: BuddyData = {
|
||||
version: 2,
|
||||
party: [creature.id, null, null, null, null, null],
|
||||
boxes: [],
|
||||
creatures: [creature],
|
||||
eggs: [],
|
||||
dex: [],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: 0,
|
||||
consecutiveDays: 0,
|
||||
lastActiveDate: '',
|
||||
totalEggsObtained: 0,
|
||||
totalEvolutions: 0,
|
||||
battlesWon: 0,
|
||||
battlesLost: 0,
|
||||
},
|
||||
}
|
||||
const updated = applyMoveLearn(data, creature.id, 'fireblast', 3)
|
||||
expect(updated.creatures[0]!.moves[3]!.id).toBe('fireblast')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyEvolution', () => {
|
||||
test('evolves charmander to charmeleon and increments counter', () => {
|
||||
const creature = makeTestCreature({ speciesId: 'charmander' })
|
||||
const data: BuddyData = {
|
||||
version: 2,
|
||||
party: [creature.id, null, null, null, null, null],
|
||||
boxes: [],
|
||||
creatures: [creature],
|
||||
eggs: [],
|
||||
dex: [],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: 0,
|
||||
consecutiveDays: 0,
|
||||
lastActiveDate: '',
|
||||
totalEggsObtained: 0,
|
||||
totalEvolutions: 0,
|
||||
battlesWon: 0,
|
||||
battlesLost: 0,
|
||||
},
|
||||
}
|
||||
const updated = applyEvolution(data, creature.id, 'charmeleon')
|
||||
expect(updated.creatures[0]!.speciesId).toBe('charmeleon')
|
||||
expect(updated.stats.totalEvolutions).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chooseAIMove', () => {
|
||||
test('returns a valid move index', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
const aiPokemon = init.state.opponentPokemon
|
||||
const idx = chooseAIMove(aiPokemon)
|
||||
expect(idx).toBeGreaterThanOrEqual(0)
|
||||
expect(idx).toBeLessThan(aiPokemon.moves.length)
|
||||
})
|
||||
|
||||
test('returns 0 when all moves have 0 PP', () => {
|
||||
const pokemon = {
|
||||
...makeTestCreature(),
|
||||
moves: [
|
||||
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 0, maxPp: 35, disabled: false },
|
||||
],
|
||||
}
|
||||
const idx = chooseAIMove(pokemon as any)
|
||||
expect(idx).toBe(0) // Struggle fallback
|
||||
})
|
||||
|
||||
test('skips disabled moves', () => {
|
||||
const pokemon = {
|
||||
...makeTestCreature(),
|
||||
moves: [
|
||||
{ id: 'tackle', name: 'Tackle', type: 'Normal', pp: 35, maxPp: 35, disabled: true },
|
||||
{ id: 'scratch', name: 'Scratch', type: 'Normal', pp: 35, maxPp: 35, disabled: false },
|
||||
],
|
||||
}
|
||||
const idx = chooseAIMove(pokemon as any)
|
||||
expect(idx).toBe(1) // Only non-disabled move
|
||||
})
|
||||
})
|
||||
|
||||
describe('settleBattle - advanced', () => {
|
||||
test('player win awards XP to creature', async () => {
|
||||
const creature = makeTestCreature({ level: 5 })
|
||||
const data = makeTestBuddyData([creature])
|
||||
const result = {
|
||||
winner: 'player' as const,
|
||||
turns: 3,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [creature.id],
|
||||
}
|
||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||
expect(settlement.data.creatures[0]!.totalXp).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('player win awards EVs (capped at 252 per stat)', async () => {
|
||||
const creature = makeTestCreature({
|
||||
level: 5,
|
||||
ev: { hp: 250, attack: 250, defense: 250, spAtk: 250, spDef: 250, speed: 250 },
|
||||
})
|
||||
const data = makeTestBuddyData([creature])
|
||||
const result = {
|
||||
winner: 'player' as const,
|
||||
turns: 3,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [creature.id],
|
||||
}
|
||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||
for (const stat of ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] as const) {
|
||||
expect(settlement.data.creatures[0]!.ev[stat]).toBeLessThanOrEqual(252)
|
||||
}
|
||||
})
|
||||
|
||||
test('player loss does not increment battlesWon', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const data = makeTestBuddyData([creature])
|
||||
const result = {
|
||||
winner: 'opponent' as const,
|
||||
turns: 3,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [creature.id],
|
||||
}
|
||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||
expect(settlement.data.stats.battlesWon).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBattle - extended', () => {
|
||||
test('battle state has turn initialized', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state.turn).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('player pokemon has correct level', async () => {
|
||||
const creature = makeTestCreature({ level: 25 })
|
||||
const init = await createBattle([creature], 'bulbasaur', 10)
|
||||
expect(init.state.playerPokemon.level).toBe(25)
|
||||
})
|
||||
|
||||
test('opponent pokemon has correct level', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'squirtle', 15)
|
||||
expect(init.state.opponentPokemon.level).toBe(15)
|
||||
})
|
||||
|
||||
test('battle state has player party', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state.playerParty.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('battle state has usable items (empty bag)', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state.usableItems).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeTurn - extended', () => {
|
||||
test('item action defaults to move 1', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
const state = await executeTurn(init, { type: 'item', itemId: 'potion' })
|
||||
expect(state).toBeDefined()
|
||||
expect(state.events.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('battle produces damage or heal events', async () => {
|
||||
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 4, speed: 252 } })
|
||||
const init = await createBattle([creature], 'squirtle', 5)
|
||||
const state = await executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||
const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal')
|
||||
expect(hasDamageOrHeal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settleBattle - EV limits', () => {
|
||||
test('EV total cannot exceed 510', async () => {
|
||||
const creature = makeTestCreature({
|
||||
level: 5,
|
||||
ev: { hp: 250, attack: 250, defense: 10, spAtk: 0, spDef: 0, speed: 0 },
|
||||
})
|
||||
const data = makeTestBuddyData([creature])
|
||||
const result = {
|
||||
winner: 'player' as const,
|
||||
turns: 3,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [creature.id],
|
||||
}
|
||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||
const totalEV = Object.values(settlement.data.creatures[0]!.ev).reduce((a, b) => a + b, 0)
|
||||
expect(totalEV).toBeLessThanOrEqual(510)
|
||||
})
|
||||
|
||||
test('non-participant creatures are unchanged', async () => {
|
||||
const participant = makeTestCreature({ id: 'p1', level: 5 })
|
||||
const bystander = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
|
||||
const data = makeTestBuddyData([participant, bystander])
|
||||
data.party = [participant.id, bystander.id, null, null, null, null]
|
||||
const result = {
|
||||
winner: 'player' as const,
|
||||
turns: 3,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [participant.id],
|
||||
}
|
||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||
const bystanderAfter = settlement.data.creatures.find(c => c.id === 'p2')!
|
||||
expect(bystanderAfter.totalXp).toBe(bystander.totalXp)
|
||||
})
|
||||
|
||||
test('uses all party members as participants when participantIds is empty', async () => {
|
||||
const c1 = makeTestCreature({ id: 'p1', level: 5 })
|
||||
const c2 = makeTestCreature({ id: 'p2', level: 5, speciesId: 'bulbasaur' })
|
||||
const data = makeTestBuddyData([c1, c2])
|
||||
data.party = [c1.id, c2.id, null, null, null, null]
|
||||
const result = {
|
||||
winner: 'player' as const,
|
||||
turns: 3,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [] as string[],
|
||||
}
|
||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||
expect(settlement.data.creatures.find(c => c.id === 'p1')!.totalXp).toBeGreaterThan(0)
|
||||
expect(settlement.data.creatures.find(c => c.id === 'p2')!.totalXp).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('player win increments battlesWon but not battlesLost', async () => {
|
||||
const creature = makeTestCreature({ level: 5 })
|
||||
const data = makeTestBuddyData([creature])
|
||||
const result = {
|
||||
winner: 'player' as const,
|
||||
turns: 3,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [creature.id],
|
||||
}
|
||||
const settlement = await settleBattle(data, result, 'squirtle', 20)
|
||||
expect(settlement.data.stats.battlesWon).toBe(1)
|
||||
expect(settlement.data.stats.battlesLost).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyMoveLearn - extended', () => {
|
||||
test('new move has correct PP from Dex', () => {
|
||||
const creature = makeTestCreature()
|
||||
const data = makeTestBuddyData([creature])
|
||||
const updated = applyMoveLearn(data, creature.id, 'fireblast', 0)
|
||||
const move = updated.creatures[0]!.moves[0]!
|
||||
expect(move.id).toBe('fireblast')
|
||||
expect(move.pp).toBeGreaterThan(0)
|
||||
expect(move.maxPp).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('non-target creatures are unchanged', () => {
|
||||
const c1 = makeTestCreature({ id: 't1' })
|
||||
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
|
||||
const data = makeTestBuddyData([c1, c2])
|
||||
const updated = applyMoveLearn(data, 't1', 'fireblast', 0)
|
||||
const unchanged = updated.creatures.find(c => c.id === 't2')!
|
||||
expect(unchanged.moves[0]!.id).toBe('flamethrower')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyEvolution - extended', () => {
|
||||
test('friendship increases by 10', () => {
|
||||
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 70 })
|
||||
const data = makeTestBuddyData([creature])
|
||||
const updated = applyEvolution(data, creature.id, 'charmeleon')
|
||||
expect(updated.creatures[0]!.friendship).toBe(80)
|
||||
})
|
||||
|
||||
test('friendship capped at 255', () => {
|
||||
const creature = makeTestCreature({ speciesId: 'charmander', friendship: 250 })
|
||||
const data = makeTestBuddyData([creature])
|
||||
const updated = applyEvolution(data, creature.id, 'charmeleon')
|
||||
expect(updated.creatures[0]!.friendship).toBe(255)
|
||||
})
|
||||
|
||||
test('multiple evolutions increment counter correctly', () => {
|
||||
const c1 = makeTestCreature({ id: 't1', speciesId: 'charmander' })
|
||||
const c2 = makeTestCreature({ id: 't2', speciesId: 'bulbasaur' })
|
||||
const data = makeTestBuddyData([c1, c2])
|
||||
let updated = applyEvolution(data, 't1', 'charmeleon')
|
||||
updated = applyEvolution(updated, 't2', 'ivysaur')
|
||||
expect(updated.stats.totalEvolutions).toBe(2)
|
||||
})
|
||||
})
|
||||
188
packages/pokemon/src/__tests__/creature.test.ts
Normal file
188
packages/pokemon/src/__tests__/creature.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import type { SpeciesId, Creature } from '../types'
|
||||
import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel, getActiveCreature } from '../core/creature'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
|
||||
describe('generateCreature', () => {
|
||||
test('creates a creature with correct defaults', async () => {
|
||||
const c = await generateCreature('bulbasaur', 42)
|
||||
expect(c.speciesId).toBe('bulbasaur')
|
||||
expect(c.level).toBe(1)
|
||||
expect(c.xp).toBe(0)
|
||||
expect(c.totalXp).toBe(0)
|
||||
expect(c.friendship).toBe(getSpeciesData('bulbasaur').baseHappiness)
|
||||
expect(c.isShiny).toBeDefined()
|
||||
expect(c.id).toBeTruthy()
|
||||
expect(Object.values(c.iv).every((v: number) => v >= 0 && v <= 31)).toBe(true)
|
||||
expect(Object.values(c.ev).every((v: number) => v === 0)).toBe(true)
|
||||
})
|
||||
|
||||
test('deterministic IV generation from seed', async () => {
|
||||
const c1 = await generateCreature('charmander', 12345)
|
||||
const c2 = await generateCreature('charmander', 12345)
|
||||
expect(c1.iv).toEqual(c2.iv)
|
||||
})
|
||||
|
||||
test('different seeds produce different IVs', async () => {
|
||||
const c1 = await generateCreature('squirtle', 100)
|
||||
const c2 = await generateCreature('squirtle', 200)
|
||||
expect(c1.iv).not.toEqual(c2.iv)
|
||||
})
|
||||
|
||||
test('all MVP species can be generated', async () => {
|
||||
const species: SpeciesId[] = [
|
||||
'bulbasaur', 'ivysaur', 'venusaur',
|
||||
'charmander', 'charmeleon', 'charizard',
|
||||
'squirtle', 'wartortle', 'blastoise',
|
||||
'pikachu',
|
||||
]
|
||||
for (const s of species) {
|
||||
const c = await generateCreature(s)
|
||||
expect(c.speciesId).toBe(s)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateStats', () => {
|
||||
test('level 1 stats are reasonable', async () => {
|
||||
const c = await generateCreature('bulbasaur', 0)
|
||||
// Use deterministic nature to avoid flaky test from randomNature()
|
||||
c.nature = 'hardy'
|
||||
const stats = calculateStats(c)
|
||||
// HP at lv1: floor((2*45 + iv + floor(0/4)) * 1/100) + 1 + 10
|
||||
// With any IV: floor((90 + iv) / 100) + 11 = 0 + 11 = 11
|
||||
expect(stats.hp).toBeGreaterThanOrEqual(11)
|
||||
expect(stats.hp).toBeLessThanOrEqual(12)
|
||||
// Attack with Hardy (neutral): floor((2*49 + iv) * 1/100 + 5)
|
||||
expect(stats.attack).toBeGreaterThanOrEqual(5)
|
||||
expect(stats.attack).toBeLessThanOrEqual(6)
|
||||
})
|
||||
|
||||
test('stats increase with level', async () => {
|
||||
const c1 = await generateCreature('charmander', 0)
|
||||
c1.level = 1
|
||||
const stats1 = calculateStats(c1)
|
||||
|
||||
const c50 = { ...c1, level: 50 }
|
||||
const stats50 = calculateStats(c50)
|
||||
// All stats should be higher at level 50
|
||||
expect(stats50.hp).toBeGreaterThan(stats1.hp)
|
||||
expect(stats50.attack).toBeGreaterThan(stats1.attack)
|
||||
})
|
||||
|
||||
test('EVs affect stats', async () => {
|
||||
const c = await generateCreature('pikachu', 0)
|
||||
// Level must be high enough for EV contribution to be visible in stat formula
|
||||
const c50 = { ...c, level: 50 }
|
||||
const statsNoEV = calculateStats(c50)
|
||||
|
||||
const cWithEV = { ...c50, ev: { ...c50.ev, attack: 252 } }
|
||||
const statsWithEV = calculateStats(cWithEV)
|
||||
|
||||
expect(statsWithEV.attack).toBeGreaterThan(statsNoEV.attack)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCreatureName', () => {
|
||||
test('returns species name when no nickname', async () => {
|
||||
const c = await generateCreature('pikachu')
|
||||
c.nickname = undefined
|
||||
expect(getCreatureName(c)).toBe('Pikachu')
|
||||
})
|
||||
|
||||
test('returns nickname when set', async () => {
|
||||
const c = await generateCreature('pikachu')
|
||||
c.nickname = 'Sparky'
|
||||
expect(getCreatureName(c)).toBe('Sparky')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTotalEV', () => {
|
||||
test('returns 0 for new creature', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
expect(getTotalEV(c)).toBe(0)
|
||||
})
|
||||
|
||||
test('sums all EV values', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
c.ev = { hp: 10, attack: 20, defense: 30, spAtk: 40, spDef: 50, speed: 60 }
|
||||
expect(getTotalEV(c)).toBe(210)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recalculateLevel', () => {
|
||||
test('returns same creature if level unchanged', async () => {
|
||||
const c = await generateCreature('bulbasaur', 42)
|
||||
const result = recalculateLevel(c)
|
||||
expect(result.level).toBe(c.level)
|
||||
})
|
||||
|
||||
test('updates level based on totalXp', async () => {
|
||||
const c = await generateCreature('charmander', 42)
|
||||
c.totalXp = 8000
|
||||
const result = recalculateLevel(c)
|
||||
expect(result.level).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveCreature', () => {
|
||||
test('returns null when party is empty', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
const result = getActiveCreature({ party: [null, null, null, null, null, null], creatures: [c] })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns creature from party[0]', async () => {
|
||||
const c = await generateCreature('pikachu')
|
||||
const result = getActiveCreature({ party: [c.id, null, null, null, null, null], creatures: [c] })
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.id).toBe(c.id)
|
||||
})
|
||||
|
||||
test('returns creature from activeCreatureId (legacy)', async () => {
|
||||
const c = await generateCreature('squirtle')
|
||||
const result = getActiveCreature({ activeCreatureId: c.id, creatures: [c] })
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.id).toBe(c.id)
|
||||
})
|
||||
|
||||
test('prefers party[0] over activeCreatureId', async () => {
|
||||
const c1 = await generateCreature('bulbasaur')
|
||||
const c2 = await generateCreature('charmander')
|
||||
const result = getActiveCreature({ party: [c1.id, null, null, null, null, null], activeCreatureId: c2.id, creatures: [c1, c2] })
|
||||
expect(result!.id).toBe(c1.id)
|
||||
})
|
||||
|
||||
test('returns null when creature ID not found', () => {
|
||||
const result = getActiveCreature({ party: ['nonexistent', null, null, null, null, null], creatures: [] })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateStats - nature effects', () => {
|
||||
test('adamant nature boosts attack and lowers spAtk', async () => {
|
||||
const c = await generateCreature('charmander', 42)
|
||||
c.level = 50
|
||||
c.nature = 'adamant'
|
||||
const adamantStats = calculateStats(c)
|
||||
|
||||
c.nature = 'hardy'
|
||||
const hardyStats = calculateStats(c)
|
||||
|
||||
expect(adamantStats.attack).toBeGreaterThan(hardyStats.attack)
|
||||
expect(adamantStats.spAtk).toBeLessThan(hardyStats.spAtk)
|
||||
})
|
||||
|
||||
test('timid nature boosts speed and lowers attack', async () => {
|
||||
const c = await generateCreature('pikachu', 42)
|
||||
c.level = 50
|
||||
c.nature = 'timid'
|
||||
const timidStats = calculateStats(c)
|
||||
|
||||
c.nature = 'hardy'
|
||||
const hardyStats = calculateStats(c)
|
||||
|
||||
expect(timidStats.speed).toBeGreaterThan(hardyStats.speed)
|
||||
expect(timidStats.attack).toBeLessThan(hardyStats.attack)
|
||||
})
|
||||
})
|
||||
79
packages/pokemon/src/__tests__/effort.test.ts
Normal file
79
packages/pokemon/src/__tests__/effort.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { generateCreature } from '../core/creature'
|
||||
import { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from '../core/effort'
|
||||
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
|
||||
|
||||
beforeEach(() => {
|
||||
resetEVCooldowns()
|
||||
})
|
||||
|
||||
describe('awardEV', () => {
|
||||
test('mapped tool awards correct EV', async () => {
|
||||
let c = await generateCreature('bulbasaur')
|
||||
// Clear cooldown by using old timestamp
|
||||
c = awardEV(c, 'Bash', 0)
|
||||
expect(c.ev.attack).toBeGreaterThan(0)
|
||||
expect(c.ev.speed).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('unmapped tool awards random EV', async () => {
|
||||
let c = await generateCreature('bulbasaur')
|
||||
c = awardEV(c, 'UnknownTool', 0)
|
||||
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
|
||||
expect(totalEV).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('cooldown prevents repeated awards', async () => {
|
||||
const now = Date.now()
|
||||
let c = await generateCreature('bulbasaur')
|
||||
c = awardEV(c, 'Bash', now)
|
||||
const ev1 = { ...c.ev }
|
||||
c = awardEV(c, 'Bash', now + 1000) // Within 30s cooldown
|
||||
expect(c.ev).toEqual(ev1) // No change
|
||||
})
|
||||
|
||||
test('respects per-stat EV cap', async () => {
|
||||
let c = await generateCreature('bulbasaur')
|
||||
// Bash gives attack:2 + speed:1
|
||||
for (let i = 0; i < 200; i++) {
|
||||
c = awardEV(c, 'Bash', i * 60000) // Each call 60s apart (past cooldown)
|
||||
}
|
||||
expect(c.ev.attack).toBeLessThanOrEqual(MAX_EV_PER_STAT)
|
||||
})
|
||||
|
||||
test('respects total EV cap', async () => {
|
||||
let c = await generateCreature('bulbasaur')
|
||||
const tools = ['Bash', 'Edit', 'Write', 'Read', 'Grep', 'Glob', 'Agent', 'WebSearch', 'WebFetch']
|
||||
for (let i = 0; i < 200; i++) {
|
||||
for (const tool of tools) {
|
||||
c = awardEV(c, tool, (i * tools.length + tools.indexOf(tool)) * 60000)
|
||||
}
|
||||
}
|
||||
const total = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
|
||||
expect(total).toBeLessThanOrEqual(MAX_EV_TOTAL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('awardTurnEV', () => {
|
||||
test('awards EV for multiple tools', async () => {
|
||||
let c = await generateCreature('bulbasaur')
|
||||
c = awardTurnEV(c, ['Bash', 'Read', 'Write'], 0)
|
||||
const totalEV = Object.values(c.ev).reduce((a: number, b: number) => a + b, 0)
|
||||
expect(totalEV).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEVSummary', () => {
|
||||
test('returns "None" for new creature', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
expect(getEVSummary(c)).toBe('None')
|
||||
})
|
||||
|
||||
test('shows stat breakdown', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
c.ev = { hp: 0, attack: 5, defense: 0, spAtk: 3, spDef: 0, speed: 0 }
|
||||
const summary = getEVSummary(c)
|
||||
expect(summary).toContain('ATK+5')
|
||||
expect(summary).toContain('SPA+3')
|
||||
})
|
||||
})
|
||||
160
packages/pokemon/src/__tests__/egg.test.ts
Normal file
160
packages/pokemon/src/__tests__/egg.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg } from '../core/egg'
|
||||
import type { BuddyData } from '../types'
|
||||
import { generateCreature } from '../core/creature'
|
||||
|
||||
function makeBuddyData(overrides: Partial<BuddyData['stats']> = {}): BuddyData {
|
||||
const creature = generateCreature('bulbasaur')
|
||||
// Sync mock — generateCreature is async but for test setup we use the resolved structure
|
||||
return {
|
||||
version: 2,
|
||||
party: ['test-creature-id', null, null, null, null, null],
|
||||
boxes: [{ name: 'Box 1', slots: Array(30).fill(null) }],
|
||||
creatures: [{
|
||||
id: 'test-creature-id',
|
||||
speciesId: 'bulbasaur',
|
||||
gender: 'male' as const,
|
||||
level: 5,
|
||||
xp: 0,
|
||||
totalXp: 100,
|
||||
nature: 'hardy',
|
||||
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
|
||||
moves: [
|
||||
{ id: 'tackle', pp: 35, maxPp: 35 },
|
||||
{ id: '', pp: 0, maxPp: 0 },
|
||||
{ id: '', pp: 0, maxPp: 0 },
|
||||
{ id: '', pp: 0, maxPp: 0 },
|
||||
],
|
||||
ability: 'overgrow',
|
||||
heldItem: null,
|
||||
friendship: 70,
|
||||
isShiny: false,
|
||||
hatchedAt: Date.now(),
|
||||
pokeball: 'pokeball',
|
||||
}],
|
||||
eggs: [],
|
||||
dex: [{ speciesId: 'bulbasaur', discoveredAt: Date.now(), caughtCount: 1, bestLevel: 1 }],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: 50,
|
||||
consecutiveDays: 7,
|
||||
lastActiveDate: new Date().toISOString().split('T')[0],
|
||||
totalEggsObtained: 0,
|
||||
totalEvolutions: 0,
|
||||
battlesWon: 0,
|
||||
battlesLost: 0,
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('checkEggEligibility', () => {
|
||||
test('eligible when conditions met', () => {
|
||||
const data = makeBuddyData()
|
||||
expect(checkEggEligibility(data)).toBe(true)
|
||||
})
|
||||
|
||||
test('not eligible with existing egg', () => {
|
||||
const data = makeBuddyData()
|
||||
data.eggs = [{ id: 'test', obtainedAt: Date.now(), stepsRemaining: 1000, totalSteps: 3000, speciesId: 'pikachu' }]
|
||||
expect(checkEggEligibility(data)).toBe(false)
|
||||
})
|
||||
|
||||
test('not eligible with low consecutive days', () => {
|
||||
const data = makeBuddyData({ consecutiveDays: 2 })
|
||||
expect(checkEggEligibility(data)).toBe(false)
|
||||
})
|
||||
|
||||
test('not eligible when turns not multiple of 50', () => {
|
||||
const data = makeBuddyData({ totalTurns: 51 })
|
||||
expect(checkEggEligibility(data)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateEgg', () => {
|
||||
test('prefers uncollected species', () => {
|
||||
const data = makeBuddyData()
|
||||
// Already have bulbasaur, so egg should prefer others
|
||||
const egg = generateEgg(data)
|
||||
expect(egg.speciesId).not.toBe('bulbasaur')
|
||||
})
|
||||
|
||||
test('egg has valid steps', () => {
|
||||
const data = makeBuddyData()
|
||||
const egg = generateEgg(data)
|
||||
expect(egg.stepsRemaining).toBeGreaterThan(0)
|
||||
expect(egg.totalSteps).toBe(egg.stepsRemaining)
|
||||
})
|
||||
})
|
||||
|
||||
describe('advanceEggSteps', () => {
|
||||
test('reduces steps remaining', () => {
|
||||
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 100, totalSteps: 200, speciesId: 'pikachu' as const }
|
||||
const advanced = advanceEggSteps(egg, 30)
|
||||
expect(advanced.stepsRemaining).toBe(70)
|
||||
})
|
||||
|
||||
test('steps do not go below 0', () => {
|
||||
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 10, totalSteps: 200, speciesId: 'pikachu' as const }
|
||||
const advanced = advanceEggSteps(egg, 50)
|
||||
expect(advanced.stepsRemaining).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEggReadyToHatch', () => {
|
||||
test('ready when steps = 0', () => {
|
||||
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 200, speciesId: 'pikachu' as const }
|
||||
expect(isEggReadyToHatch(egg)).toBe(true)
|
||||
})
|
||||
|
||||
test('not ready when steps > 0', () => {
|
||||
const egg = { id: 'test', obtainedAt: Date.now(), stepsRemaining: 1, totalSteps: 200, speciesId: 'pikachu' as const }
|
||||
expect(isEggReadyToHatch(egg)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hatchEgg', () => {
|
||||
test('creates a creature and removes egg', async () => {
|
||||
const data = makeBuddyData()
|
||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
|
||||
const result = await hatchEgg(data, egg)
|
||||
|
||||
expect(result.creature.speciesId).toBe('charmander')
|
||||
expect(result.buddyData.creatures.length).toBe(data.creatures.length + 1)
|
||||
expect(result.buddyData.eggs.length).toBe(0)
|
||||
})
|
||||
|
||||
test('adds creature to party when slot available', async () => {
|
||||
const data = makeBuddyData()
|
||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'pikachu' as const }
|
||||
const result = await hatchEgg(data, egg)
|
||||
const newCreature = result.creature
|
||||
const inParty = result.buddyData.party.includes(newCreature.id)
|
||||
expect(inParty).toBe(true)
|
||||
})
|
||||
|
||||
test('increments totalEggsObtained', async () => {
|
||||
const data = makeBuddyData()
|
||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'squirtle' as const }
|
||||
const result = await hatchEgg(data, egg)
|
||||
expect(result.buddyData.stats.totalEggsObtained).toBe(1)
|
||||
})
|
||||
|
||||
test('updates dex entry with new species', async () => {
|
||||
const data = makeBuddyData()
|
||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'charmander' as const }
|
||||
const result = await hatchEgg(data, egg)
|
||||
const entry = result.buddyData.dex.find(d => d.speciesId === 'charmander')
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.caughtCount).toBe(1)
|
||||
})
|
||||
|
||||
test('increments caughtCount for existing dex entry', async () => {
|
||||
const data = makeBuddyData()
|
||||
const egg = { id: 'egg-1', obtainedAt: Date.now(), stepsRemaining: 0, totalSteps: 2000, speciesId: 'bulbasaur' as const }
|
||||
const result = await hatchEgg(data, egg)
|
||||
const entry = result.buddyData.dex.find(d => d.speciesId === 'bulbasaur')
|
||||
expect(entry!.caughtCount).toBe(2)
|
||||
})
|
||||
})
|
||||
39
packages/pokemon/src/__tests__/evMapping.test.ts
Normal file
39
packages/pokemon/src/__tests__/evMapping.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getEVForTool, DEFAULT_EV_MAPPING, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
|
||||
|
||||
describe('getEVForTool', () => {
|
||||
test('returns EV mapping for known tools', () => {
|
||||
const bashEV = getEVForTool('Bash')
|
||||
expect(bashEV).toBeDefined()
|
||||
expect(bashEV!.attack).toBe(2)
|
||||
expect(bashEV!.speed).toBe(1)
|
||||
})
|
||||
|
||||
test('returns undefined for unknown tools', () => {
|
||||
expect(getEVForTool('UnknownTool')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('all mapped tools have correct stat shape', () => {
|
||||
for (const [, ev] of Object.entries(DEFAULT_EV_MAPPING)) {
|
||||
expect(ev.hp).toBeDefined()
|
||||
expect(ev.attack).toBeDefined()
|
||||
expect(ev.defense).toBeDefined()
|
||||
expect(ev.spAtk).toBeDefined()
|
||||
expect(ev.spDef).toBeDefined()
|
||||
expect(ev.speed).toBeDefined()
|
||||
// EVs should sum to > 0
|
||||
const total = ev.hp + ev.attack + ev.defense + ev.spAtk + ev.spDef + ev.speed
|
||||
expect(total).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('EV constants', () => {
|
||||
test('MAX_EV_PER_STAT is 252', () => {
|
||||
expect(MAX_EV_PER_STAT).toBe(252)
|
||||
})
|
||||
|
||||
test('MAX_EV_TOTAL is 510', () => {
|
||||
expect(MAX_EV_TOTAL).toBe(510)
|
||||
})
|
||||
})
|
||||
126
packages/pokemon/src/__tests__/evolution.test.ts
Normal file
126
packages/pokemon/src/__tests__/evolution.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import type { Creature } from '../types'
|
||||
import { checkEvolution, evolve, canEvolveFurther } from '../core/evolution'
|
||||
|
||||
function makeEvolutionCreature(overrides: Partial<Creature> = {}): Creature {
|
||||
return {
|
||||
id: 'test-evo',
|
||||
speciesId: overrides.speciesId ?? 'bulbasaur',
|
||||
gender: 'male',
|
||||
level: overrides.level ?? 50,
|
||||
xp: 0,
|
||||
totalXp: 0,
|
||||
nature: 'hardy',
|
||||
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
iv: { hp: 31, attack: 31, defense: 31, spAtk: 31, spDef: 31, speed: 31 },
|
||||
moves: [
|
||||
{ id: 'tackle', pp: 35, maxPp: 35 },
|
||||
{ id: 'growl', pp: 40, maxPp: 40 },
|
||||
{ id: 'vinewhip', pp: 15, maxPp: 15 },
|
||||
{ id: 'razorleaf', pp: 10, maxPp: 10 },
|
||||
],
|
||||
ability: 'overgrow',
|
||||
heldItem: null,
|
||||
friendship: overrides.friendship ?? 70,
|
||||
isShiny: false,
|
||||
hatchedAt: Date.now(),
|
||||
pokeball: 'pokeball',
|
||||
}
|
||||
}
|
||||
|
||||
describe('checkEvolution', () => {
|
||||
test('bulbasaur at level 15 cannot evolve', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 15 })
|
||||
expect(checkEvolution(creature)).toBeNull()
|
||||
})
|
||||
|
||||
test('bulbasaur at level 16 can evolve into ivysaur', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 16 })
|
||||
const result = checkEvolution(creature)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.from).toBe('bulbasaur')
|
||||
expect(result!.to).toBe('ivysaur')
|
||||
})
|
||||
|
||||
test('charmander at level 16 evolves into charmeleon', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'charmander', level: 16 })
|
||||
const result = checkEvolution(creature)
|
||||
expect(result!.to).toBe('charmeleon')
|
||||
})
|
||||
|
||||
test('charmeleon at level 36 evolves into charizard', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'charmeleon', level: 36 })
|
||||
const result = checkEvolution(creature)
|
||||
expect(result!.to).toBe('charizard')
|
||||
})
|
||||
|
||||
test('squirtle at level 16 evolves into wartortle', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'squirtle', level: 16 })
|
||||
const result = checkEvolution(creature)
|
||||
expect(result!.to).toBe('wartortle')
|
||||
})
|
||||
|
||||
test('wartortle at level 36 evolves into blastoise', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'wartortle', level: 36 })
|
||||
const result = checkEvolution(creature)
|
||||
expect(result!.to).toBe('blastoise')
|
||||
})
|
||||
|
||||
test('venusaur cannot evolve further', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'venusaur', level: 50 })
|
||||
expect(checkEvolution(creature)).toBeNull()
|
||||
})
|
||||
|
||||
test('pikachu does not evolve by level-up (needs item)', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 })
|
||||
// Pikachu evolves via Thunder Stone, not level-up
|
||||
const result = checkEvolution(creature)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', level: 100 })
|
||||
const result = checkEvolution(creature)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.to).toBe('ivysaur')
|
||||
})
|
||||
})
|
||||
|
||||
describe('evolve', () => {
|
||||
test('changes species and boosts friendship', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 70, level: 16 })
|
||||
const evolved = evolve(creature, 'ivysaur')
|
||||
expect(evolved.speciesId).toBe('ivysaur')
|
||||
expect(evolved.friendship).toBe(80) // +10 friendship on evolution
|
||||
})
|
||||
|
||||
test('friendship is capped at 255', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'bulbasaur', friendship: 250, level: 16 })
|
||||
const evolved = evolve(creature, 'ivysaur')
|
||||
expect(evolved.friendship).toBe(255)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canEvolveFurther', () => {
|
||||
test('starter species can evolve', () => {
|
||||
expect(canEvolveFurther('bulbasaur')).toBe(true)
|
||||
expect(canEvolveFurther('charmander')).toBe(true)
|
||||
expect(canEvolveFurther('squirtle')).toBe(true)
|
||||
})
|
||||
|
||||
test('middle evolution can evolve', () => {
|
||||
expect(canEvolveFurther('ivysaur')).toBe(true)
|
||||
expect(canEvolveFurther('charmeleon')).toBe(true)
|
||||
expect(canEvolveFurther('wartortle')).toBe(true)
|
||||
})
|
||||
|
||||
test('final evolution cannot evolve', () => {
|
||||
expect(canEvolveFurther('venusaur')).toBe(false)
|
||||
expect(canEvolveFurther('charizard')).toBe(false)
|
||||
expect(canEvolveFurther('blastoise')).toBe(false)
|
||||
})
|
||||
|
||||
test('pikachu can evolve into raichu', () => {
|
||||
expect(canEvolveFurther('pikachu')).toBe(true)
|
||||
})
|
||||
})
|
||||
153
packages/pokemon/src/__tests__/experience.test.ts
Normal file
153
packages/pokemon/src/__tests__/experience.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { generateCreature } from '../core/creature'
|
||||
import { awardXP, getXpProgress } from '../core/experience'
|
||||
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable'
|
||||
|
||||
describe('xpForLevel', () => {
|
||||
test('level 1 requires 0 XP', () => {
|
||||
expect(xpForLevel(1, 'medium-slow')).toBe(0)
|
||||
})
|
||||
|
||||
test('medium-fast: level N requires N^3 XP', () => {
|
||||
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
|
||||
expect(xpForLevel(100, 'medium-fast')).toBe(1000000)
|
||||
})
|
||||
|
||||
test('fast: level N requires floor(N^3 * 4/5)', () => {
|
||||
expect(xpForLevel(10, 'fast')).toBe(Math.floor(1000 * 4 / 5)) // 800
|
||||
})
|
||||
|
||||
test('slow: level N requires floor(N^3 * 5/4)', () => {
|
||||
expect(xpForLevel(10, 'slow')).toBe(Math.floor(1000 * 5 / 4))
|
||||
})
|
||||
|
||||
test('higher levels require more XP', () => {
|
||||
for (let i = 2; i < 99; i++) {
|
||||
expect(xpForLevel(i + 1, 'medium-slow')).toBeGreaterThan(xpForLevel(i, 'medium-slow'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('levelFromXp', () => {
|
||||
test('0 XP = level 1', () => {
|
||||
expect(levelFromXp(0, 'medium-fast')).toBe(1)
|
||||
})
|
||||
|
||||
test('roundtrip: level → XP → level', () => {
|
||||
for (const growth of ['slow', 'medium-slow', 'medium-fast', 'fast'] as const) {
|
||||
for (const level of [1, 5, 10, 25, 50, 75, 100]) {
|
||||
const xp = xpForLevel(level, growth)
|
||||
expect(levelFromXp(xp, growth)).toBe(level)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('XP slightly below threshold stays at lower level', () => {
|
||||
const xp20 = xpForLevel(20, 'medium-fast')
|
||||
expect(levelFromXp(xp20 - 1, 'medium-fast')).toBe(19)
|
||||
})
|
||||
})
|
||||
|
||||
describe('awardXP', () => {
|
||||
test('awards XP and returns updated creature', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
const result = awardXP(c, 10)
|
||||
expect(result.creature.totalXp).toBe(10)
|
||||
expect(result.leveledUp).toBeDefined()
|
||||
})
|
||||
|
||||
test('large XP can cause level up', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
// Award enough XP for several levels
|
||||
const result = awardXP(c, 10000)
|
||||
expect(result.creature.level).toBeGreaterThan(1)
|
||||
expect(result.leveledUp).toBe(true)
|
||||
})
|
||||
|
||||
test('level capped at 100', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
c.level = 100
|
||||
c.totalXp = 1000000
|
||||
const result = awardXP(c, 999999)
|
||||
expect(result.creature.level).toBe(100)
|
||||
expect(result.leveledUp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getXpProgress', () => {
|
||||
test('new creature has 0 XP progress', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
const progress = getXpProgress(c)
|
||||
expect(progress.current).toBe(0)
|
||||
expect(progress.percentage).toBe(0)
|
||||
})
|
||||
|
||||
test('level 100 creature has 100% progress', async () => {
|
||||
const c = await generateCreature('charmander')
|
||||
c.level = 100
|
||||
c.totalXp = 1000000
|
||||
const progress = getXpProgress(c)
|
||||
expect(progress.percentage).toBe(100)
|
||||
})
|
||||
|
||||
test('needed is positive for sub-100 creatures', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
c.level = 5
|
||||
c.totalXp = xpForLevel(5, 'medium-slow')
|
||||
const progress = getXpProgress(c)
|
||||
expect(progress.needed).toBeGreaterThan(0)
|
||||
expect(progress.current).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('xpToNextLevel', () => {
|
||||
test('returns XP needed from current to next level', () => {
|
||||
const xp10 = xpForLevel(10, 'medium-fast')
|
||||
const xp11 = xpForLevel(11, 'medium-fast')
|
||||
const needed = xpToNextLevel(10, xp10, 'medium-fast')
|
||||
expect(needed).toBe(xp11 - xp10)
|
||||
})
|
||||
|
||||
test('returns 0 at level 100', () => {
|
||||
expect(xpToNextLevel(100, 1000000, 'medium-fast')).toBe(0)
|
||||
})
|
||||
|
||||
test('accounts for partial XP already earned', () => {
|
||||
const xp10 = xpForLevel(10, 'medium-fast')
|
||||
const xp11 = xpForLevel(11, 'medium-fast')
|
||||
const halfWay = xp10 + Math.floor((xp11 - xp10) / 2)
|
||||
const needed = xpToNextLevel(10, halfWay, 'medium-fast')
|
||||
expect(needed).toBe(xp11 - halfWay)
|
||||
})
|
||||
})
|
||||
|
||||
describe('awardXP - extended', () => {
|
||||
test('awarding 0 XP returns unchanged creature', async () => {
|
||||
const c = await generateCreature('bulbasaur')
|
||||
const result = awardXP(c, 0)
|
||||
expect(result.creature.totalXp).toBe(c.totalXp)
|
||||
expect(result.leveledUp).toBe(false)
|
||||
})
|
||||
|
||||
test('XP progress is correctly calculated after award', async () => {
|
||||
const c = await generateCreature('squirtle')
|
||||
const xpNeeded = xpForLevel(2, 'medium-slow')
|
||||
const result = awardXP(c, Math.floor(xpNeeded / 2))
|
||||
expect(result.creature.xp).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('multiple small XP awards equal one large award', async () => {
|
||||
const c1 = await generateCreature('bulbasaur', 42)
|
||||
const c2 = await generateCreature('bulbasaur', 42)
|
||||
c2.totalXp = c1.totalXp
|
||||
|
||||
let current = c1
|
||||
for (let i = 0; i < 10; i++) {
|
||||
current = awardXP(current, 100).creature
|
||||
}
|
||||
const bigResult = awardXP(c2, 1000)
|
||||
|
||||
expect(current.totalXp).toBe(bigResult.creature.totalXp)
|
||||
expect(current.level).toBe(bigResult.creature.level)
|
||||
})
|
||||
})
|
||||
33
packages/pokemon/src/__tests__/fallback.test.ts
Normal file
33
packages/pokemon/src/__tests__/fallback.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getFallbackSprite } from '../sprites/fallback'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
|
||||
describe('getFallbackSprite', () => {
|
||||
test('returns 5 lines for every species', () => {
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
const sprite = getFallbackSprite(id)
|
||||
expect(sprite.length).toBe(5)
|
||||
}
|
||||
})
|
||||
|
||||
test('returns generic fallback for unknown species', () => {
|
||||
const sprite = getFallbackSprite('unknowndefinitelynotarealspecies')
|
||||
expect(sprite.length).toBe(5)
|
||||
expect(sprite[0]).toContain('.---')
|
||||
})
|
||||
|
||||
test('returns curated sprite for pikachu', () => {
|
||||
const sprite = getFallbackSprite('pikachu')
|
||||
expect(sprite[0]).toContain('/\\')
|
||||
})
|
||||
|
||||
test('each line has consistent width', () => {
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
const sprite = getFallbackSprite(id)
|
||||
const widths = sprite.map(line => line.length)
|
||||
const maxWidth = Math.max(...widths)
|
||||
const minWidth = Math.min(...widths)
|
||||
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
51
packages/pokemon/src/__tests__/gender.test.ts
Normal file
51
packages/pokemon/src/__tests__/gender.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { determineGender, getGenderSymbol } from '../core/gender'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
|
||||
describe('determineGender', () => {
|
||||
test('genderless species', () => {
|
||||
// Pikachu has genderRate 4 (50% female)
|
||||
// Venusaur has genderRate 1 (12.5% female)
|
||||
// For testing genderless, we'd need a species with genderRate -1
|
||||
// None in MVP are genderless, so test the basic logic
|
||||
const pikachu = getSpeciesData('pikachu')
|
||||
expect(pikachu.genderRate).toBe(4)
|
||||
})
|
||||
|
||||
test('pikachu 50% female ratio', () => {
|
||||
const pikachu = getSpeciesData('pikachu')
|
||||
let males = 0
|
||||
let females = 0
|
||||
for (let seed = 0; seed < 1000; seed++) {
|
||||
const g = determineGender(pikachu, seed)
|
||||
if (g === 'male') males++
|
||||
else females++
|
||||
}
|
||||
// Should be roughly 50/50 with some tolerance
|
||||
expect(females).toBeGreaterThan(300)
|
||||
expect(males).toBeGreaterThan(300)
|
||||
})
|
||||
|
||||
test('starters are ~12.5% female', () => {
|
||||
const bulbasaur = getSpeciesData('bulbasaur')
|
||||
let females = 0
|
||||
for (let seed = 0; seed < 1000; seed++) {
|
||||
if (determineGender(bulbasaur, seed) === 'female') females++
|
||||
}
|
||||
// ~12.5% female = ~125 out of 1000
|
||||
expect(females).toBeGreaterThan(50)
|
||||
expect(females).toBeLessThan(250)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGenderSymbol', () => {
|
||||
test('male symbol', () => {
|
||||
expect(getGenderSymbol('male')).toBe('♂')
|
||||
})
|
||||
test('female symbol', () => {
|
||||
expect(getGenderSymbol('female')).toBe('♀')
|
||||
})
|
||||
test('genderless has no symbol', () => {
|
||||
expect(getGenderSymbol('genderless')).toBe('')
|
||||
})
|
||||
})
|
||||
59
packages/pokemon/src/__tests__/learnsets.test.ts
Normal file
59
packages/pokemon/src/__tests__/learnsets.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from '../dex/learnsets'
|
||||
import { EMPTY_MOVE } from '../types'
|
||||
|
||||
describe('getDefaultMoveset', () => {
|
||||
test('charmander at level 1 has at least one move', async () => {
|
||||
const moves = await getDefaultMoveset('charmander', 1)
|
||||
expect(moves.length).toBe(4)
|
||||
expect(moves[0]!.id).not.toBe('')
|
||||
})
|
||||
|
||||
test('charmander at level 10 has more moves', async () => {
|
||||
const moves = await getDefaultMoveset('charmander', 10)
|
||||
const nonEmpty = moves.filter(m => m.id !== '')
|
||||
expect(nonEmpty.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test('all moves have valid pp', async () => {
|
||||
const moves = await getDefaultMoveset('bulbasaur', 20)
|
||||
for (const move of moves) {
|
||||
if (move.id) {
|
||||
expect(move.pp).toBeGreaterThan(0)
|
||||
expect(move.maxPp).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('invalid species returns empty moves', async () => {
|
||||
const moves = await getDefaultMoveset('nonexistent' as any, 10)
|
||||
expect(moves.every(m => m.id === '')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultAbility', () => {
|
||||
test('charmander has blaze', () => {
|
||||
expect(getDefaultAbility('charmander')).toBe('blaze')
|
||||
})
|
||||
|
||||
test('bulbasaur has overgrow', () => {
|
||||
expect(getDefaultAbility('bulbasaur')).toBe('overgrow')
|
||||
})
|
||||
|
||||
test('squirtle has torrent', () => {
|
||||
expect(getDefaultAbility('squirtle')).toBe('torrent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNewLearnableMoves', () => {
|
||||
test('charmander gains ember at level 4', async () => {
|
||||
const moves = await getNewLearnableMoves('charmander', 1, 4)
|
||||
expect(moves.length).toBeGreaterThan(0)
|
||||
expect(moves.some(m => m.id === 'ember')).toBe(true)
|
||||
})
|
||||
|
||||
test('no new moves when level stays same', async () => {
|
||||
const moves = await getNewLearnableMoves('charmander', 5, 5)
|
||||
expect(moves.length).toBe(0)
|
||||
})
|
||||
})
|
||||
51
packages/pokemon/src/__tests__/names.test.ts
Normal file
51
packages/pokemon/src/__tests__/names.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../dex/names'
|
||||
|
||||
// Original 10 curated species
|
||||
const CURATED = [
|
||||
'bulbasaur', 'ivysaur', 'venusaur',
|
||||
'charmander', 'charmeleon', 'charizard',
|
||||
'squirtle', 'wartortle', 'blastoise',
|
||||
'pikachu',
|
||||
]
|
||||
|
||||
describe('SPECIES_NAMES', () => {
|
||||
test('has name for curated species', () => {
|
||||
for (const id of CURATED) {
|
||||
expect(SPECIES_NAMES[id]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Charmander name is correct', () => {
|
||||
expect(SPECIES_NAMES.charmander).toBe('Charmander')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SPECIES_I18N', () => {
|
||||
test('has i18n for curated species', () => {
|
||||
for (const id of CURATED) {
|
||||
expect(SPECIES_I18N[id]).toBeTruthy()
|
||||
expect(SPECIES_I18N[id]!.en).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
test('has Chinese translations', () => {
|
||||
expect(SPECIES_I18N.pikachu!.zh).toBe('皮卡丘')
|
||||
expect(SPECIES_I18N.squirtle!.zh).toBe('杰尼龟')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SPECIES_PERSONALITY', () => {
|
||||
test('has personality for curated species', () => {
|
||||
for (const id of CURATED) {
|
||||
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
test('personality is non-empty string for curated species', () => {
|
||||
for (const id of CURATED) {
|
||||
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
|
||||
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
53
packages/pokemon/src/__tests__/nature.test.ts
Normal file
53
packages/pokemon/src/__tests__/nature.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getAllNatureNames, randomNature, getNatureEffect } from '../dex/nature'
|
||||
|
||||
describe('getAllNatureNames', () => {
|
||||
test('returns 25 nature names', () => {
|
||||
const names = getAllNatureNames()
|
||||
expect(names.length).toBe(25)
|
||||
})
|
||||
|
||||
test('includes hardy and quirky', () => {
|
||||
const names = getAllNatureNames()
|
||||
expect(names).toContain('hardy')
|
||||
expect(names).toContain('quirky')
|
||||
})
|
||||
})
|
||||
|
||||
describe('randomNature', () => {
|
||||
test('returns a valid nature name', () => {
|
||||
const nature = randomNature()
|
||||
expect(getAllNatureNames()).toContain(nature)
|
||||
})
|
||||
|
||||
test('produces different natures over multiple calls', () => {
|
||||
const natures = new Set(Array.from({ length: 50 }, () => randomNature()))
|
||||
expect(natures.size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNatureEffect', () => {
|
||||
test('hardy is neutral (no effect)', () => {
|
||||
const effect = getNatureEffect('hardy')
|
||||
expect(effect.plus).toBeNull()
|
||||
expect(effect.minus).toBeNull()
|
||||
})
|
||||
|
||||
test('adamant boosts attack and lowers spAtk', () => {
|
||||
const effect = getNatureEffect('adamant')
|
||||
expect(effect.plus).toBe('attack')
|
||||
expect(effect.minus).toBe('spAtk')
|
||||
})
|
||||
|
||||
test('timid boosts speed and lowers attack', () => {
|
||||
const effect = getNatureEffect('timid')
|
||||
expect(effect.plus).toBe('speed')
|
||||
expect(effect.minus).toBe('attack')
|
||||
})
|
||||
|
||||
test('invalid nature returns neutral', () => {
|
||||
const effect = getNatureEffect('nonexistent')
|
||||
expect(effect.plus).toBeNull()
|
||||
expect(effect.minus).toBeNull()
|
||||
})
|
||||
})
|
||||
46
packages/pokemon/src/__tests__/pkmn.test.ts
Normal file
46
packages/pokemon/src/__tests__/pkmn.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { FROM_DEX_STAT, TO_DEX_STAT, mapBaseStats, mapGenderRatio } from '../dex/pkmn'
|
||||
|
||||
describe('FROM_DEX_STAT', () => {
|
||||
test('maps all 6 stats', () => {
|
||||
expect(FROM_DEX_STAT.hp).toBe('hp')
|
||||
expect(FROM_DEX_STAT.atk).toBe('attack')
|
||||
expect(FROM_DEX_STAT.def).toBe('defense')
|
||||
expect(FROM_DEX_STAT.spa).toBe('spAtk')
|
||||
expect(FROM_DEX_STAT.spd).toBe('spDef')
|
||||
expect(FROM_DEX_STAT.spe).toBe('speed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TO_DEX_STAT', () => {
|
||||
test('reverse maps all 6 stats', () => {
|
||||
expect(TO_DEX_STAT.hp).toBe('hp')
|
||||
expect(TO_DEX_STAT.attack).toBe('atk')
|
||||
expect(TO_DEX_STAT.defense).toBe('def')
|
||||
expect(TO_DEX_STAT.spAtk).toBe('spa')
|
||||
expect(TO_DEX_STAT.spDef).toBe('spd')
|
||||
expect(TO_DEX_STAT.speed).toBe('spe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapBaseStats', () => {
|
||||
test('converts Dex stat format to our format', () => {
|
||||
const result = mapBaseStats({ hp: 45, atk: 49, def: 49, spa: 65, spd: 65, spe: 45 })
|
||||
expect(result).toEqual({
|
||||
hp: 45, attack: 49, defense: 49,
|
||||
spAtk: 65, spDef: 65, speed: 45,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapGenderRatio', () => {
|
||||
test('returns -1 for genderless', () => {
|
||||
expect(mapGenderRatio(undefined)).toBe(-1)
|
||||
expect(mapGenderRatio('N')).toBe(-1)
|
||||
})
|
||||
|
||||
test('calculates female ratio', () => {
|
||||
expect(mapGenderRatio({ M: 0.875, F: 0.125 })).toBe(1) // 12.5% F → 1
|
||||
expect(mapGenderRatio({ M: 0.5, F: 0.5 })).toBe(4) // 50% F → 4
|
||||
})
|
||||
})
|
||||
103
packages/pokemon/src/__tests__/renderer.test.ts
Normal file
103
packages/pokemon/src/__tests__/renderer.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
|
||||
|
||||
describe('renderAnimatedSprite', () => {
|
||||
const testSprite = [
|
||||
' AB',
|
||||
' C D',
|
||||
]
|
||||
|
||||
test('idle mode returns original sprite (with ANSI resets)', () => {
|
||||
const result = renderAnimatedSprite(testSprite, 0, 'idle')
|
||||
expect(result.length).toBe(2)
|
||||
// Each row should contain the original characters
|
||||
expect(result[0]).toContain('A')
|
||||
expect(result[0]).toContain('B')
|
||||
})
|
||||
|
||||
test('flip reverses rows', () => {
|
||||
const flipped = renderAnimatedSprite(testSprite, 0, 'flip')
|
||||
expect(flipped[0]).toContain('B')
|
||||
expect(flipped[0]).toContain('A')
|
||||
})
|
||||
|
||||
test('blink replaces eye characters with dash', () => {
|
||||
const sprite = [' O ', ' O ']
|
||||
const result = renderAnimatedSprite(sprite, 0, 'blink')
|
||||
expect(result[0]).toContain('—')
|
||||
expect(result[1]).toContain('—')
|
||||
})
|
||||
|
||||
test('bounce shifts sprite up', () => {
|
||||
const result = renderAnimatedSprite(testSprite, 2, 'bounce')
|
||||
// Bounce at tick 2 should shift up by some amount
|
||||
expect(result.length).toBe(2)
|
||||
})
|
||||
|
||||
test('excited mode shifts horizontally', () => {
|
||||
const result = renderAnimatedSprite(testSprite, 0, 'excited')
|
||||
expect(result.length).toBe(2)
|
||||
})
|
||||
|
||||
test('walkRight shifts progressively', () => {
|
||||
const r0 = renderAnimatedSprite(testSprite, 0, 'walkRight')
|
||||
const r1 = renderAnimatedSprite(testSprite, 1, 'walkRight')
|
||||
// Different ticks should produce different horizontal positions
|
||||
expect(r0).toBeDefined()
|
||||
expect(r1).toBeDefined()
|
||||
})
|
||||
|
||||
test('walkLeft mode shifts', () => {
|
||||
const result = renderAnimatedSprite(testSprite, 0, 'walkLeft')
|
||||
expect(result.length).toBe(2)
|
||||
})
|
||||
|
||||
test('pet mode returns sprite unchanged', () => {
|
||||
const result = renderAnimatedSprite(testSprite, 0, 'pet')
|
||||
expect(result.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIdleAnimMode', () => {
|
||||
test('returns valid AnimMode for any tick', () => {
|
||||
const modes = new Set<string>()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
modes.add(getIdleAnimMode(i))
|
||||
}
|
||||
expect(modes.size).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test('cycles through sequence', () => {
|
||||
// First tick should be 'idle' (first element of IDLE_SEQUENCE)
|
||||
expect(getIdleAnimMode(0)).toBe('idle')
|
||||
})
|
||||
|
||||
test('wraps around after sequence length', () => {
|
||||
const mode0 = getIdleAnimMode(0)
|
||||
const modeAfterFullCycle = getIdleAnimMode(26) // IDLE_SEQUENCE.length
|
||||
expect(mode0).toBe(modeAfterFullCycle)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPetOverlay', () => {
|
||||
test('returns two lines', () => {
|
||||
const overlay = getPetOverlay(0)
|
||||
expect(overlay.length).toBe(2)
|
||||
})
|
||||
|
||||
test('contains heart characters', () => {
|
||||
const overlay = getPetOverlay(0)
|
||||
const combined = overlay.join('')
|
||||
expect(combined).toContain('♥')
|
||||
})
|
||||
|
||||
test('cycles through overlays', () => {
|
||||
const o0 = getPetOverlay(0)
|
||||
const o1 = getPetOverlay(1)
|
||||
expect(o0).not.toEqual(o1)
|
||||
})
|
||||
|
||||
test('wraps around', () => {
|
||||
expect(getPetOverlay(0)).toEqual(getPetOverlay(5))
|
||||
})
|
||||
})
|
||||
95
packages/pokemon/src/__tests__/species.test.ts
Normal file
95
packages/pokemon/src/__tests__/species.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getSpeciesData, getAllSpeciesData, DEX_TO_SPECIES, ensureSpeciesData } from '../dex/species'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import type { SpeciesId } from '../types'
|
||||
|
||||
describe('getSpeciesData', () => {
|
||||
test('returns valid data for charmander', () => {
|
||||
const data = getSpeciesData('charmander')
|
||||
expect(data.id).toBe('charmander')
|
||||
expect(data.name).toBe('Charmander')
|
||||
expect(data.dexNumber).toBe(4)
|
||||
expect(data.growthRate).toBe('medium-slow')
|
||||
expect(data.captureRate).toBe(45)
|
||||
expect(data.flavorText).toBeTruthy()
|
||||
})
|
||||
|
||||
test('returns valid data for pikachu', () => {
|
||||
const data = getSpeciesData('pikachu')
|
||||
expect(data.id).toBe('pikachu')
|
||||
expect(data.dexNumber).toBe(25)
|
||||
expect(data.growthRate).toBe('medium-fast')
|
||||
})
|
||||
|
||||
test('has baseStats with all 6 stats', () => {
|
||||
const data = getSpeciesData('bulbasaur')
|
||||
expect(data.baseStats).toHaveProperty('hp')
|
||||
expect(data.baseStats).toHaveProperty('attack')
|
||||
expect(data.baseStats).toHaveProperty('defense')
|
||||
expect(data.baseStats).toHaveProperty('spAtk')
|
||||
expect(data.baseStats).toHaveProperty('spDef')
|
||||
expect(data.baseStats).toHaveProperty('speed')
|
||||
})
|
||||
|
||||
test('has types array', () => {
|
||||
const data = getSpeciesData('squirtle')
|
||||
expect(data.types.length).toBeGreaterThan(0)
|
||||
expect(data.types[0]).toBe('water')
|
||||
})
|
||||
|
||||
test('has evolutionChain for species with evolutions', () => {
|
||||
const data = getSpeciesData('charmander')
|
||||
expect(data.evolutionChain).toBeDefined()
|
||||
expect(data.evolutionChain?.[0]?.into).toBe('charmeleon')
|
||||
})
|
||||
|
||||
test('has no evolutionChain for final evolutions', () => {
|
||||
const data = getSpeciesData('charizard')
|
||||
expect(data.evolutionChain).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllSpeciesData', () => {
|
||||
test('returns data for all species', () => {
|
||||
const all = getAllSpeciesData()
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
expect(all[id]).toBeDefined()
|
||||
expect(all[id]!.id).toBe(id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEX_TO_SPECIES', () => {
|
||||
test('maps dex numbers correctly', () => {
|
||||
expect(DEX_TO_SPECIES[1]).toBe('bulbasaur')
|
||||
expect(DEX_TO_SPECIES[4]).toBe('charmander')
|
||||
expect(DEX_TO_SPECIES[7]).toBe('squirtle')
|
||||
expect(DEX_TO_SPECIES[25]).toBe('pikachu')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureSpeciesData', () => {
|
||||
test('resolves without error', async () => {
|
||||
await expect(ensureSpeciesData()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSpeciesData - supplementary fields', () => {
|
||||
test('has baseHappiness', () => {
|
||||
expect(getSpeciesData('bulbasaur').baseHappiness).toBe(70)
|
||||
})
|
||||
|
||||
test('pikachu has higher captureRate', () => {
|
||||
expect(getSpeciesData('pikachu').captureRate).toBeGreaterThan(getSpeciesData('charmander').captureRate)
|
||||
})
|
||||
|
||||
test('has names with en key', () => {
|
||||
const data = getSpeciesData('charmander')
|
||||
expect(data.names).toBeDefined()
|
||||
expect(data.names.en).toBe('Charmander')
|
||||
})
|
||||
|
||||
test('shinyChance is 1/4096', () => {
|
||||
expect(getSpeciesData('bulbasaur').shinyChance).toBe(1 / 4096)
|
||||
})
|
||||
})
|
||||
29
packages/pokemon/src/__tests__/spriteCache.test.ts
Normal file
29
packages/pokemon/src/__tests__/spriteCache.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getSpeciesDisplay, loadSprite } from '../core/spriteCache'
|
||||
|
||||
describe('getSpeciesDisplay', () => {
|
||||
test('formats charmander display', () => {
|
||||
expect(getSpeciesDisplay('charmander')).toBe('#004 Charmander')
|
||||
})
|
||||
|
||||
test('formats pikachu display', () => {
|
||||
expect(getSpeciesDisplay('pikachu')).toBe('#025 Pikachu')
|
||||
})
|
||||
|
||||
test('formats bulbasaur display', () => {
|
||||
expect(getSpeciesDisplay('bulbasaur')).toBe('#001 Bulbasaur')
|
||||
})
|
||||
|
||||
test('pads dex number to 3 digits', () => {
|
||||
expect(getSpeciesDisplay('squirtle')).toBe('#007 Squirtle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSprite', () => {
|
||||
test('returns null when no cache exists', () => {
|
||||
// Uses a temp directory via getSpritesDir, should return null for non-cached
|
||||
const result = loadSprite('nonexistent_pokemon' as any)
|
||||
// Will be null since the file doesn't exist
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
388
packages/pokemon/src/__tests__/storage.test.ts
Normal file
388
packages/pokemon/src/__tests__/storage.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import {
|
||||
getDefaultBuddyData,
|
||||
addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
|
||||
depositToBox, withdrawFromBox, moveInBox, renameBox,
|
||||
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
|
||||
addItemToBag, removeItemFromBag, getItemCount,
|
||||
updateDailyStats, incrementTurns,
|
||||
} from '../core/storage'
|
||||
import type { BuddyData } from '../types'
|
||||
|
||||
function makeData(creatureCount = 1): BuddyData {
|
||||
const creatures = Array.from({ length: creatureCount }, (_, i) => ({
|
||||
id: `creature-${i}`,
|
||||
speciesId: 'bulbasaur' as const,
|
||||
gender: 'male' as const,
|
||||
level: 5,
|
||||
xp: 0,
|
||||
totalXp: 100,
|
||||
nature: 'hardy',
|
||||
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
iv: { hp: 15, attack: 15, defense: 15, spAtk: 15, spDef: 15, speed: 15 },
|
||||
moves: [
|
||||
{ id: 'tackle', pp: 35, maxPp: 35 },
|
||||
{ id: '', pp: 0, maxPp: 0 },
|
||||
{ id: '', pp: 0, maxPp: 0 },
|
||||
{ id: '', pp: 0, maxPp: 0 },
|
||||
] as [any, any, any, any],
|
||||
ability: 'overgrow',
|
||||
heldItem: null,
|
||||
friendship: 70,
|
||||
isShiny: false,
|
||||
hatchedAt: Date.now(),
|
||||
pokeball: 'pokeball',
|
||||
}))
|
||||
|
||||
const party: (string | null)[] = [creatures[0]!.id, null, null, null, null, null]
|
||||
if (creatureCount > 1) party[1] = creatures[1]!.id
|
||||
if (creatureCount > 2) party[2] = creatures[2]!.id
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
party,
|
||||
boxes: [
|
||||
{ name: 'Box 1', slots: Array(30).fill(null) as (string | null)[] },
|
||||
{ name: 'Box 2', slots: Array(30).fill(null) as (string | null)[] },
|
||||
],
|
||||
creatures,
|
||||
eggs: [],
|
||||
dex: [],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: 10,
|
||||
consecutiveDays: 5,
|
||||
lastActiveDate: new Date().toISOString().split('T')[0],
|
||||
totalEggsObtained: 0,
|
||||
totalEvolutions: 0,
|
||||
battlesWon: 3,
|
||||
battlesLost: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Default data ───
|
||||
|
||||
describe('getDefaultBuddyData', () => {
|
||||
test('returns v2 data with correct structure', async () => {
|
||||
const data = await getDefaultBuddyData()
|
||||
expect(data.version).toBe(2)
|
||||
expect(data.party.length).toBe(6)
|
||||
expect(data.party[0]).toBeTruthy()
|
||||
expect(data.boxes.length).toBe(8)
|
||||
expect(data.boxes[0]!.slots.length).toBe(30)
|
||||
expect(data.bag.items).toEqual([])
|
||||
expect(data.stats.battlesWon).toBe(0)
|
||||
expect(data.stats.battlesLost).toBe(0)
|
||||
})
|
||||
|
||||
test('has one creature matching party[0]', async () => {
|
||||
const data = await getDefaultBuddyData()
|
||||
expect(data.creatures.length).toBe(1)
|
||||
expect(data.creatures[0]!.id).toBe(data.party[0]!)
|
||||
})
|
||||
|
||||
test('creature has v2 fields', async () => {
|
||||
const data = await getDefaultBuddyData()
|
||||
const creature = data.creatures[0]!
|
||||
expect(creature.nature).toBeTruthy()
|
||||
expect(creature.moves.length).toBe(4)
|
||||
expect(creature.ability).toBeTruthy()
|
||||
expect(creature.heldItem).toBeNull()
|
||||
expect(creature.pokeball).toBe('pokeball')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Party operations ───
|
||||
|
||||
describe('addToParty', () => {
|
||||
test('adds creature to first empty slot', () => {
|
||||
const data = makeData()
|
||||
const result = addToParty(data, 'new-creature')
|
||||
expect(result.added).toBe(true)
|
||||
expect(result.data.party[1]).toBe('new-creature')
|
||||
})
|
||||
|
||||
test('returns false when party is full', () => {
|
||||
const data = makeData()
|
||||
data.party = ['c1', 'c2', 'c3', 'c4', 'c5', 'c6']
|
||||
const result = addToParty(data, 'new-creature')
|
||||
expect(result.added).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeFromParty', () => {
|
||||
test('removes creature and compacts party', () => {
|
||||
const data = makeData(3)
|
||||
const updated = removeFromParty(data, 0)
|
||||
expect(updated.party[0]).toBe('creature-1')
|
||||
expect(updated.party[1]).toBe('creature-2')
|
||||
expect(updated.party[2]).toBeNull()
|
||||
})
|
||||
|
||||
test('does nothing for out-of-bounds index', () => {
|
||||
const data = makeData()
|
||||
const updated = removeFromParty(data, 10)
|
||||
expect(updated.party).toEqual(data.party)
|
||||
})
|
||||
|
||||
test('cannot remove last party member', () => {
|
||||
const data = makeData(1)
|
||||
const updated = removeFromParty(data, 0)
|
||||
expect(updated.party[0]).toBe('creature-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('swapPartySlots', () => {
|
||||
test('swaps two party slots', () => {
|
||||
const data = makeData(2)
|
||||
const updated = swapPartySlots(data, 0, 1)
|
||||
expect(updated.party[0]).toBe('creature-1')
|
||||
expect(updated.party[1]).toBe('creature-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setActivePartyMember', () => {
|
||||
test('swaps creature to slot 0', () => {
|
||||
const data = makeData(2)
|
||||
const updated = setActivePartyMember(data, 'creature-1')
|
||||
expect(updated.party[0]).toBe('creature-1')
|
||||
expect(updated.party[1]).toBe('creature-0')
|
||||
})
|
||||
|
||||
test('no change if already active', () => {
|
||||
const data = makeData()
|
||||
const updated = setActivePartyMember(data, 'creature-0')
|
||||
expect(updated).toEqual(data)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── PC Box operations ───
|
||||
|
||||
describe('depositToBox', () => {
|
||||
test('deposits creature to first empty box slot', () => {
|
||||
const data = makeData()
|
||||
const result = depositToBox(data, 'box-creature')
|
||||
expect(result.deposited).toBe(true)
|
||||
expect(result.data.boxes[0]!.slots[0]).toBe('box-creature')
|
||||
})
|
||||
|
||||
test('fills second box when first is full', () => {
|
||||
const data = makeData()
|
||||
data.boxes[0]!.slots = Array(30).fill('x')
|
||||
const result = depositToBox(data, 'box-creature')
|
||||
expect(result.deposited).toBe(true)
|
||||
expect(result.data.boxes[1]!.slots[0]).toBe('box-creature')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withdrawFromBox', () => {
|
||||
test('withdraws creature from box', () => {
|
||||
const data = makeData()
|
||||
data.boxes[0]!.slots[5] = 'box-creature'
|
||||
const result = withdrawFromBox(data, 'box-creature')
|
||||
expect(result.withdrawn).toBe(true)
|
||||
expect(result.data.boxes[0]!.slots[5]).toBeNull()
|
||||
})
|
||||
|
||||
test('returns false when creature not in boxes', () => {
|
||||
const data = makeData()
|
||||
const result = withdrawFromBox(data, 'nonexistent')
|
||||
expect(result.withdrawn).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveInBox', () => {
|
||||
test('moves creature between slots', () => {
|
||||
const data = makeData()
|
||||
data.boxes[0]!.slots[0] = 'moving-creature'
|
||||
const updated = moveInBox(data, 0, 0, 0, 5)
|
||||
expect(updated.boxes[0]!.slots[0]).toBeNull()
|
||||
expect(updated.boxes[0]!.slots[5]).toBe('moving-creature')
|
||||
})
|
||||
|
||||
test('does nothing for empty source slot', () => {
|
||||
const data = makeData()
|
||||
const updated = moveInBox(data, 0, 0, 0, 5)
|
||||
expect(updated).toEqual(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renameBox', () => {
|
||||
test('renames a box', () => {
|
||||
const data = makeData()
|
||||
const updated = renameBox(data, 0, 'My Box')
|
||||
expect(updated.boxes[0]!.name).toBe('My Box')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findCreatureLocation', () => {
|
||||
test('finds creature in party', () => {
|
||||
const data = makeData()
|
||||
const loc = findCreatureLocation(data, 'creature-0')
|
||||
expect(loc).toEqual({ area: 'party', slot: 0 })
|
||||
})
|
||||
|
||||
test('finds creature in box', () => {
|
||||
const data = makeData()
|
||||
data.boxes[0]!.slots[3] = 'box-creature'
|
||||
const loc = findCreatureLocation(data, 'box-creature')
|
||||
expect(loc).toEqual({ area: 'box', slot: 3, boxIndex: 0 })
|
||||
})
|
||||
|
||||
test('returns null for nonexistent', () => {
|
||||
const data = makeData()
|
||||
expect(findCreatureLocation(data, 'nonexistent')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('releaseCreature', () => {
|
||||
test('removes creature from party and creatures array', () => {
|
||||
const data = makeData(2)
|
||||
const updated = releaseCreature(data, 'creature-1')
|
||||
expect(updated.creatures.find(c => c.id === 'creature-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTotalCreatureCount', () => {
|
||||
test('returns creature count', () => {
|
||||
expect(getTotalCreatureCount(makeData(3))).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllCreatureIds', () => {
|
||||
test('returns all ids', () => {
|
||||
expect(getAllCreatureIds(makeData(2))).toEqual(['creature-0', 'creature-1'])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Bag operations ───
|
||||
|
||||
describe('addItemToBag', () => {
|
||||
test('adds new item', () => {
|
||||
const data = makeData()
|
||||
const updated = addItemToBag(data, 'potion', 3)
|
||||
expect(updated.bag.items).toEqual([{ id: 'potion', count: 3 }])
|
||||
})
|
||||
|
||||
test('stacks existing item', () => {
|
||||
const data = makeData()
|
||||
const withItem = addItemToBag(data, 'potion', 2)
|
||||
const stacked = addItemToBag(withItem, 'potion', 3)
|
||||
expect(stacked.bag.items[0]!.count).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeItemFromBag', () => {
|
||||
test('removes item quantity', () => {
|
||||
const data = makeData()
|
||||
const withItem = addItemToBag(data, 'potion', 5)
|
||||
const result = removeItemFromBag(withItem, 'potion', 3)
|
||||
expect(result.removed).toBe(true)
|
||||
expect(result.data.bag.items[0]!.count).toBe(2)
|
||||
})
|
||||
|
||||
test('removes item entirely when count reaches 0', () => {
|
||||
const data = makeData()
|
||||
const withItem = addItemToBag(data, 'potion', 2)
|
||||
const result = removeItemFromBag(withItem, 'potion', 2)
|
||||
expect(result.removed).toBe(true)
|
||||
expect(result.data.bag.items.length).toBe(0)
|
||||
})
|
||||
|
||||
test('returns false when not enough items', () => {
|
||||
const data = makeData()
|
||||
const withItem = addItemToBag(data, 'potion', 1)
|
||||
const result = removeItemFromBag(withItem, 'potion', 5)
|
||||
expect(result.removed).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for nonexistent item', () => {
|
||||
const data = makeData()
|
||||
const result = removeItemFromBag(data, 'potion', 1)
|
||||
expect(result.removed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getItemCount', () => {
|
||||
test('returns count for existing item', () => {
|
||||
const data = makeData()
|
||||
const withItem = addItemToBag(data, 'potion', 3)
|
||||
expect(getItemCount(withItem, 'potion')).toBe(3)
|
||||
})
|
||||
|
||||
test('returns 0 for nonexistent item', () => {
|
||||
expect(getItemCount(makeData(), 'potion')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Stats ───
|
||||
|
||||
describe('updateDailyStats', () => {
|
||||
test('same day does not increment consecutive', () => {
|
||||
const data = makeData()
|
||||
const updated = updateDailyStats(data)
|
||||
expect(updated.stats.consecutiveDays).toBe(data.stats.consecutiveDays)
|
||||
})
|
||||
})
|
||||
|
||||
describe('incrementTurns', () => {
|
||||
test('increments totalTurns by 1', () => {
|
||||
const data = makeData()
|
||||
const updated = incrementTurns(data)
|
||||
expect(updated.stats.totalTurns).toBe(data.stats.totalTurns + 1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Extended coverage ───
|
||||
|
||||
describe('depositToBox - full boxes', () => {
|
||||
test('fails when all boxes are full', () => {
|
||||
const data = makeData()
|
||||
for (const box of data.boxes) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
box.slots[i] = `filler-${i}`
|
||||
}
|
||||
}
|
||||
const result = depositToBox(data, 'test-id')
|
||||
expect(result.deposited).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withdrawFromBox - roundtrip', () => {
|
||||
test('deposit then withdraw leaves box empty', () => {
|
||||
const data = makeData()
|
||||
const deposited = depositToBox(data, 'test-id')
|
||||
expect(deposited.deposited).toBe(true)
|
||||
const result = withdrawFromBox(deposited.data, 'test-id')
|
||||
expect(result.withdrawn).toBe(true)
|
||||
const slot = result.data.boxes[0]!.slots.find(s => s === 'test-id')
|
||||
expect(slot).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findCreatureLocation - deposit', () => {
|
||||
test('finds creature after depositing to box', () => {
|
||||
const data = makeData()
|
||||
const deposited = depositToBox(data, 'box-mon')
|
||||
const loc = findCreatureLocation(deposited.data, 'box-mon')
|
||||
expect(loc).not.toBeNull()
|
||||
expect(loc!.area).toBe('box')
|
||||
})
|
||||
})
|
||||
|
||||
describe('releaseCreature - box', () => {
|
||||
test('removes creature from box and creatures array', () => {
|
||||
const data = makeData()
|
||||
const deposited = depositToBox(data, 'box-mon')
|
||||
const released = releaseCreature(deposited.data, 'box-mon')
|
||||
expect(released.creatures.find(c => c.id === 'box-mon')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('clears party slot when releasing party member', () => {
|
||||
const data = makeData(2)
|
||||
const updated = releaseCreature(data, 'creature-1')
|
||||
expect(updated.party[1]).toBeNull()
|
||||
expect(updated.creatures.length).toBe(1)
|
||||
})
|
||||
})
|
||||
64
packages/pokemon/src/__tests__/xpTable.test.ts
Normal file
64
packages/pokemon/src/__tests__/xpTable.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable'
|
||||
|
||||
describe('xpForLevel', () => {
|
||||
test('returns 0 for level 1', () => {
|
||||
expect(xpForLevel(1, 'medium-fast')).toBe(0)
|
||||
})
|
||||
|
||||
test('returns 0 for level 0', () => {
|
||||
expect(xpForLevel(0, 'medium-fast')).toBe(0)
|
||||
})
|
||||
|
||||
test('medium-fast: level 5 = 125 XP', () => {
|
||||
expect(xpForLevel(5, 'medium-fast')).toBe(125)
|
||||
})
|
||||
|
||||
test('medium-fast: level 10 = 1000 XP', () => {
|
||||
expect(xpForLevel(10, 'medium-fast')).toBe(1000)
|
||||
})
|
||||
|
||||
test('slow: level 5 = 156 XP', () => {
|
||||
expect(xpForLevel(5, 'slow')).toBe(156)
|
||||
})
|
||||
|
||||
test('fast: level 5 = 100 XP', () => {
|
||||
expect(xpForLevel(5, 'fast')).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('levelFromXp', () => {
|
||||
test('returns 1 for 0 XP', () => {
|
||||
expect(levelFromXp(0, 'medium-fast')).toBe(1)
|
||||
})
|
||||
|
||||
test('returns 5 for 125 XP medium-fast', () => {
|
||||
expect(levelFromXp(125, 'medium-fast')).toBe(5)
|
||||
})
|
||||
|
||||
test('caps at 100', () => {
|
||||
expect(levelFromXp(999999999, 'medium-fast')).toBe(100)
|
||||
})
|
||||
|
||||
test('roundtrip: xpForLevel then levelFromXp', () => {
|
||||
for (let lv = 1; lv <= 100; lv += 10) {
|
||||
const xp = xpForLevel(lv, 'medium-fast')
|
||||
expect(levelFromXp(xp, 'medium-fast')).toBe(lv)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('xpToNextLevel', () => {
|
||||
test('returns 0 at level 100', () => {
|
||||
expect(xpToNextLevel(100, 0, 'medium-fast')).toBe(0)
|
||||
})
|
||||
|
||||
test('returns difference to next level', () => {
|
||||
// Level 5 medium-fast: xpForLevel(5)=125, xpForLevel(6)=216
|
||||
expect(xpToNextLevel(5, 125, 'medium-fast')).toBe(216 - 125)
|
||||
})
|
||||
|
||||
test('returns full next level XP from 0', () => {
|
||||
expect(xpToNextLevel(1, 0, 'medium-fast')).toBe(8) // 2^3=8
|
||||
})
|
||||
})
|
||||
73
packages/pokemon/src/battle/ai.ts
Normal file
73
packages/pokemon/src/battle/ai.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import type { BattlePokemon } from './types'
|
||||
|
||||
/**
|
||||
* AI move selection: prefers super-effective moves, avoids resisted moves,
|
||||
* falls back to random among usable moves.
|
||||
*/
|
||||
export function chooseAIMove(pokemon: BattlePokemon, opponentTypes?: string[]): number {
|
||||
const usable = pokemon.moves
|
||||
.map((m, i) => ({ move: m, index: i }))
|
||||
.filter(({ move }) => move.pp > 0 && !move.disabled)
|
||||
|
||||
if (usable.length === 0) return 0 // Struggle
|
||||
|
||||
// If no opponent type info, pick randomly
|
||||
if (!opponentTypes || opponentTypes.length === 0) {
|
||||
return usable[Math.floor(Math.random() * usable.length)]!.index
|
||||
}
|
||||
|
||||
// Classify moves by effectiveness against opponent
|
||||
const superEffective: number[] = []
|
||||
const neutral: number[] = []
|
||||
const resisted: number[] = []
|
||||
const statusMoves: number[] = [] // Lowest priority
|
||||
|
||||
for (const { move, index } of usable) {
|
||||
const dexMove = Dex.moves.get(move.id)
|
||||
if (!dexMove?.type) {
|
||||
neutral.push(index)
|
||||
continue
|
||||
}
|
||||
|
||||
const moveType = dexMove.type // Keep original case for Dex.getEffectiveness
|
||||
// Status moves and charge moves are lowest priority
|
||||
if (dexMove.category === 'Status' || dexMove.flags?.charge) {
|
||||
statusMoves.push(index)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check effectiveness against all opponent types using Dex.getEffectiveness
|
||||
let totalEffectiveness = 0
|
||||
for (const rawOppType of opponentTypes) {
|
||||
// Dex.getEffectiveness expects capitalized type names
|
||||
const oppType = rawOppType.charAt(0).toUpperCase() + rawOppType.slice(1)
|
||||
totalEffectiveness += Dex.getEffectiveness(moveType, oppType)
|
||||
}
|
||||
|
||||
if (totalEffectiveness > 0) {
|
||||
superEffective.push(index)
|
||||
} else if (totalEffectiveness < 0) {
|
||||
resisted.push(index)
|
||||
} else {
|
||||
neutral.push(index)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority: super-effective (70%) > neutral > super-effective (30%) > resisted > status
|
||||
const rand = Math.random()
|
||||
if (superEffective.length > 0 && rand < 0.7) {
|
||||
return superEffective[Math.floor(Math.random() * superEffective.length)]!
|
||||
}
|
||||
if (neutral.length > 0) {
|
||||
return neutral[Math.floor(Math.random() * neutral.length)]!
|
||||
}
|
||||
if (superEffective.length > 0) {
|
||||
return superEffective[Math.floor(Math.random() * superEffective.length)]!
|
||||
}
|
||||
if (resisted.length > 0) {
|
||||
return resisted[Math.floor(Math.random() * resisted.length)]!
|
||||
}
|
||||
// Only status moves available
|
||||
return statusMoves[Math.floor(Math.random() * statusMoves.length)]!
|
||||
}
|
||||
166
packages/pokemon/src/battle/capture.ts
Normal file
166
packages/pokemon/src/battle/capture.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import type { SpeciesId } from '../types'
|
||||
import { getCaptureRate } from '../dex/pokedex-data'
|
||||
|
||||
/**
|
||||
* Gen 9 capture rate calculation.
|
||||
* Returns { captured: boolean, shakes: 0-3 }
|
||||
*
|
||||
* Formula:
|
||||
* a = (3 * maxHP - 2 * currentHP) * catchRate * ballModifier / (3 * maxHP)
|
||||
* b = 65536 / (255 / a) ^ (1/4) (shake probability)
|
||||
* For each of 4 shakes: if random(0,65535) < b → pass, else → break out
|
||||
*/
|
||||
|
||||
/** Pokeball catch rate modifiers */
|
||||
const BALL_MODIFIERS: Record<string, number> = {
|
||||
pokeball: 1,
|
||||
greatball: 1.5,
|
||||
ultraball: 2,
|
||||
masterball: 255, // always catches
|
||||
netball: 3.5, // bug/water bonus (applied below)
|
||||
diveball: 3.5, // underwater/surfing
|
||||
nestball: 1, // scales with level (applied below)
|
||||
repeatball: 3.5, // if already caught
|
||||
timerball: 1, // scales with turns (applied below)
|
||||
duskball: 3.5, // night/cave
|
||||
quickball: 5, // first turn
|
||||
luxuryball: 1,
|
||||
premierball: 1,
|
||||
cherishball: 1,
|
||||
healball: 1,
|
||||
friendball: 1,
|
||||
levelball: 1,
|
||||
lureball: 1,
|
||||
moonball: 1,
|
||||
loveball: 1,
|
||||
heavyball: 1,
|
||||
fastball: 1,
|
||||
sportball: 1,
|
||||
parkball: 255,
|
||||
beastball: 5, // Ultra Beasts
|
||||
}
|
||||
|
||||
/** Status condition catch rate multiplier */
|
||||
const STATUS_MODIFIERS: Record<string, number> = {
|
||||
none: 1,
|
||||
poison: 1.5,
|
||||
bad_poison: 1.5,
|
||||
burn: 1.5,
|
||||
paralysis: 1.5,
|
||||
freeze: 2,
|
||||
sleep: 2.5,
|
||||
}
|
||||
|
||||
export interface CaptureResult {
|
||||
captured: boolean
|
||||
shakes: number // 0-3 (3 means captured)
|
||||
critical: boolean // critical capture (Gen 5+)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate capture attempt.
|
||||
* @param speciesId Opponent species
|
||||
* @param currentHp Opponent current HP
|
||||
* @param maxHp Opponent max HP
|
||||
* @param ballId Pokeball item ID
|
||||
* @param status Opponent status condition
|
||||
* @param turn Current battle turn number
|
||||
* @param isFirstTurn Whether it's the first turn of battle
|
||||
* @param isNight Whether it's nighttime (for Dusk Ball)
|
||||
* @param alreadyCaught Whether this species has been caught before (for Repeat Ball)
|
||||
* @param opponentLevel Opponent's level (for Nest Ball)
|
||||
*/
|
||||
export function attemptCapture(
|
||||
speciesId: SpeciesId,
|
||||
currentHp: number,
|
||||
maxHp: number,
|
||||
ballId: string,
|
||||
status: string = 'none',
|
||||
turn: number = 1,
|
||||
isFirstTurn: boolean = false,
|
||||
isNight: boolean = false,
|
||||
alreadyCaught: boolean = false,
|
||||
opponentLevel: number = 50,
|
||||
): CaptureResult {
|
||||
const catchRate = getCaptureRate(speciesId)
|
||||
|
||||
// Master Ball always catches
|
||||
if (ballId === 'masterball' || catchRate === 255) {
|
||||
return { captured: true, shakes: 3, critical: false }
|
||||
}
|
||||
|
||||
// Calculate ball modifier with conditional bonuses
|
||||
let ballModifier = BALL_MODIFIERS[ballId.toLowerCase()] ?? 1
|
||||
|
||||
// Quick Ball: 5x on first turn, 1x otherwise
|
||||
if (ballId === 'quickball') {
|
||||
ballModifier = isFirstTurn ? 5 : 1
|
||||
}
|
||||
|
||||
// Timer Ball: up to 4x after 10 turns
|
||||
if (ballId === 'timerball') {
|
||||
ballModifier = Math.min(4, 1 + (turn - 1) * 3 / 10)
|
||||
}
|
||||
|
||||
// Nest Ball: better for lower level wild Pokémon
|
||||
if (ballId === 'nestball') {
|
||||
ballModifier = Math.max(1, (40 - opponentLevel) / 10)
|
||||
}
|
||||
|
||||
// Dusk Ball: 3.5x at night or in caves
|
||||
if (ballId === 'duskball') {
|
||||
ballModifier = isNight ? 3.5 : 1
|
||||
}
|
||||
|
||||
// Repeat Ball: 3.5x if already caught
|
||||
if (ballId === 'repeatball') {
|
||||
ballModifier = alreadyCaught ? 3.5 : 1
|
||||
}
|
||||
|
||||
// Net Ball: 3.5x for Bug or Water types
|
||||
if (ballId === 'netball') {
|
||||
const species = Dex.species.get(speciesId)
|
||||
if (species?.types?.some((t: string) => t.toLowerCase() === 'bug' || t.toLowerCase() === 'water')) {
|
||||
ballModifier = 3.5
|
||||
}
|
||||
}
|
||||
|
||||
// Status modifier
|
||||
const statusMod = STATUS_MODIFIERS[status] ?? 1
|
||||
|
||||
// Catch rate formula (Gen 9)
|
||||
const hpFactor = (3 * maxHp - 2 * currentHp) / (3 * maxHp)
|
||||
const catchValue = hpFactor * catchRate * ballModifier * statusMod
|
||||
const a = Math.min(255, Math.floor(catchValue))
|
||||
|
||||
// Shake probability
|
||||
const b = Math.floor(65536 / Math.pow(255 / Math.max(1, a), 0.25))
|
||||
|
||||
// Perform 3 shake checks (4th check is automatic if all 3 pass)
|
||||
let shakes = 0
|
||||
let captured = true
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const roll = Math.floor(Math.random() * 65536)
|
||||
if (roll < b) {
|
||||
shakes++
|
||||
} else {
|
||||
captured = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Critical capture check (Gen 5+, rare)
|
||||
const dexCount = 0 // Could track Pokedex completion rate
|
||||
const criticalChance = Math.min(255, Math.floor(catchValue * dexCount / 256))
|
||||
const critical = criticalChance > 0 && Math.floor(Math.random() * 256) < criticalChance
|
||||
|
||||
if (critical) {
|
||||
// Critical capture only needs 1 shake
|
||||
const roll = Math.floor(Math.random() * 65536)
|
||||
captured = roll < b
|
||||
return { captured, shakes: captured ? 1 : 0, critical: true }
|
||||
}
|
||||
|
||||
return { captured, shakes, critical: false }
|
||||
}
|
||||
838
packages/pokemon/src/battle/engine.ts
Normal file
838
packages/pokemon/src/battle/engine.ts
Normal file
@@ -0,0 +1,838 @@
|
||||
import { BattleStreams, Teams, Dex, toID } from '@pkmn/sim'
|
||||
import { Protocol } from '@pkmn/protocol'
|
||||
import type { Creature, SpeciesId } from '../types'
|
||||
import { TO_DEX_STAT, FROM_DEX_STAT } from '../dex/pkmn'
|
||||
import { STAT_NAMES } from '../types'
|
||||
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition, WeatherKind, FieldCondition } from './types'
|
||||
import { chooseAIMove } from './ai'
|
||||
import { attemptCapture } from './capture'
|
||||
|
||||
// ─── Utility: get actual stat value accounting for stage ───
|
||||
|
||||
function getStatWithStage(pokemon: BattlePokemon, statKey: string): number {
|
||||
const raw = (pokemon as any)[statKey] ?? 10
|
||||
const stage = pokemon.statStages?.[statKey] ?? 0
|
||||
if (stage === 0) return raw
|
||||
const numerator = stage > 0 ? 2 + stage : 2
|
||||
const denominator = stage > 0 ? 2 : 2 - stage
|
||||
return Math.floor(raw * numerator / denominator)
|
||||
}
|
||||
|
||||
// ─── Item Effect Application ───
|
||||
|
||||
/** Healing item definitions */
|
||||
const HEALING_ITEMS: Record<string, { amount: number; percent?: boolean; cureStatus?: boolean }> = {
|
||||
'potion': { amount: 20 },
|
||||
'superpotion': { amount: 60 },
|
||||
'hyperpotion': { amount: 120 },
|
||||
'maxpotion': { amount: 9999 }, // full heal
|
||||
'fullrestore': { amount: 9999, cureStatus: true },
|
||||
'fullheal': { amount: 0, cureStatus: true },
|
||||
'berryjuice': { amount: 20 },
|
||||
'oranberry': { amount: 10 },
|
||||
'sitrusberry': { amount: 30, percent: true },
|
||||
'energyroot': { amount: 120 },
|
||||
'sweetheart': { amount: 20 },
|
||||
'freshwater': { amount: 30 },
|
||||
'sodapop': { amount: 50 },
|
||||
'lemonade': { amount: 70 },
|
||||
'moomoomilk': { amount: 100 },
|
||||
'revive': { amount: 50, percent: true }, // revives fainted with 50% HP
|
||||
'maxrevive': { amount: 100, percent: true }, // revives fainted with full HP
|
||||
}
|
||||
|
||||
function applyItemEffect(battle: any, itemId: string, target: any): void {
|
||||
const item = HEALING_ITEMS[itemId.toLowerCase().replace(/[-\s]/g, '')]
|
||||
if (!item) return
|
||||
|
||||
// HP healing
|
||||
if (item.amount > 0 && target.hp < target.maxhp) {
|
||||
if (item.percent) {
|
||||
target.hp = Math.min(target.maxhp, target.hp + Math.floor(target.maxhp * item.amount / 100))
|
||||
} else {
|
||||
target.hp = Math.min(target.maxhp, target.hp + item.amount)
|
||||
}
|
||||
}
|
||||
|
||||
// Cure status conditions
|
||||
if (item.cureStatus && target.status) {
|
||||
target.status = ''
|
||||
target.statusState = { toxicTurns: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
export type BattleInit = {
|
||||
streams: {
|
||||
omniscient: { write(data: string): void; read(): Promise<string | null | undefined> }
|
||||
spectator: { read(): Promise<string | null | undefined> }
|
||||
p1: { write(data: string): void; read(): Promise<string | null | undefined> }
|
||||
p2: { write(data: string): void; read(): Promise<string | null | undefined> }
|
||||
}
|
||||
/** Underlying stream — access .battle for Battle object */
|
||||
stream: BattleStreams.BattleStream
|
||||
state: BattleState
|
||||
}
|
||||
|
||||
// ─── Adapter: Creature → Showdown Set ───
|
||||
|
||||
function creatureToSetString(creature: Creature): string {
|
||||
const species = Dex.species.get(creature.speciesId)
|
||||
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
|
||||
|
||||
const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1)
|
||||
const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : ''
|
||||
|
||||
let moves = creature.moves
|
||||
.filter(m => m.id)
|
||||
.map(m => Dex.moves.get(m.id)?.name ?? m.id)
|
||||
|
||||
// Fallback: if no valid moves, use type-based defaults
|
||||
if (moves.length === 0) {
|
||||
moves = getSpeciesMoves(creature.speciesId, creature.level)
|
||||
}
|
||||
|
||||
const DEX_DISPLAY: Record<string, string> = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' }
|
||||
const formatStatLine = (vals: Record<string, number>) =>
|
||||
STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ')
|
||||
const ivs = formatStatLine(creature.iv)
|
||||
const evs = formatStatLine(creature.ev)
|
||||
|
||||
const lines = [
|
||||
species.name,
|
||||
`Level: ${creature.level}`,
|
||||
`Ability: ${abilityName}`,
|
||||
`Nature: ${natureName}`,
|
||||
`IVs: ${ivs}`,
|
||||
`EVs: ${evs}`,
|
||||
]
|
||||
if (creature.heldItem) lines.push(`Item: ${Dex.items.get(creature.heldItem)?.name ?? creature.heldItem}`)
|
||||
for (const move of moves) lines.push(`- ${move}`)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// Species-specific held items (speciesId → item name)
|
||||
const SPECIES_ITEMS: Partial<Record<string, string>> = {
|
||||
pikachu: 'Light Ball',
|
||||
farfetchd: 'Stick',
|
||||
cubone: 'Thick Club',
|
||||
marowak: 'Thick Club',
|
||||
ditto: 'Quick Powder',
|
||||
chansey: 'Lucky Punch',
|
||||
snorlax: 'Leftovers',
|
||||
}
|
||||
|
||||
// Type-based common wild held items (type → item, 5% chance)
|
||||
const TYPE_ITEMS: Partial<Record<string, string>> = {
|
||||
Fire: 'Charcoal',
|
||||
Water: 'Mystic Water',
|
||||
Electric: 'Magnet',
|
||||
Grass: 'Miracle Seed',
|
||||
Ice: 'Never-Melt Ice',
|
||||
Fighting: 'Black Belt',
|
||||
Poison: 'Poison Barb',
|
||||
Ground: 'Soft Sand',
|
||||
Flying: 'Sharp Beak',
|
||||
Psychic: 'TwistedSpoon',
|
||||
Bug: 'Silver Powder',
|
||||
Rock: 'Hard Stone',
|
||||
Ghost: 'Spell Tag',
|
||||
Dragon: 'Dragon Fang',
|
||||
Dark: 'Black Glasses',
|
||||
Steel: 'Metal Coat',
|
||||
Fairy: 'Fairy Feather',
|
||||
}
|
||||
|
||||
/** Roll a random held item for a wild Pokémon encounter */
|
||||
function rollWildHeldItem(speciesId: SpeciesId): string | null {
|
||||
// Species-specific items: 5% chance
|
||||
const speciesItem = SPECIES_ITEMS[speciesId]
|
||||
if (speciesItem && Math.random() < 0.05) return speciesItem
|
||||
|
||||
// Common berry: 5% chance
|
||||
if (Math.random() < 0.05) {
|
||||
const berries = ['Oran Berry', 'Sitrus Berry', 'Pecha Berry', 'Rawst Berry', 'Cheri Berry']
|
||||
return berries[Math.floor(Math.random() * berries.length)]
|
||||
}
|
||||
|
||||
// Type-based item: 3% chance
|
||||
if (Math.random() < 0.03) {
|
||||
const species = Dex.species.get(speciesId)
|
||||
if (species?.types?.[0]) {
|
||||
return TYPE_ITEMS[species.types[0]] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
|
||||
const species = Dex.species.get(speciesId)
|
||||
if (!species) throw new Error(`Species ${speciesId} not found`)
|
||||
const ability = species.abilities['0'] ?? ''
|
||||
const moves = getSpeciesMoves(speciesId, level)
|
||||
const lines = [species.name, `Level: ${level}`, `Ability: ${ability}`]
|
||||
// Wild Pokémon have a small chance to hold an item
|
||||
const wildItem = rollWildHeldItem(speciesId)
|
||||
if (wildItem) lines.push(`Item: ${wildItem}`)
|
||||
for (const move of moves) lines.push(`- ${move}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function getSpeciesMoves(speciesId: string, level: number): string[] {
|
||||
// Try learnset-based moves first (real level-up moves from Dex.data)
|
||||
const learnset = Dex.data.Learnsets[speciesId]?.learnset
|
||||
if (learnset) {
|
||||
const levelUpMoves: { id: string; level: number; gen: number }[] = []
|
||||
for (const [moveId, sources] of Object.entries(learnset)) {
|
||||
for (const src of sources as string[]) {
|
||||
const match = src.match(/^(\d+)L(\d+)$/)
|
||||
if (match) {
|
||||
const gen = parseInt(match[1]!)
|
||||
const moveLevel = parseInt(match[2]!)
|
||||
if (moveLevel <= level) {
|
||||
// Keep highest-gen entry for each move
|
||||
const existing = levelUpMoves.find(m => m.id === moveId)
|
||||
if (!existing || gen > existing.gen) {
|
||||
if (existing) {
|
||||
existing.gen = gen
|
||||
existing.level = moveLevel
|
||||
} else {
|
||||
levelUpMoves.push({ id: moveId, level: moveLevel, gen })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by level, take last 4 (most recently learned)
|
||||
levelUpMoves.sort((a, b) => a.level - b.level)
|
||||
const selected = levelUpMoves.slice(-4)
|
||||
if (selected.length > 0) {
|
||||
return selected.map(m => Dex.moves.get(m.id)?.name ?? m.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: type-based defaults
|
||||
const species = Dex.species.get(speciesId)
|
||||
const type = species?.types[0]?.toLowerCase() ?? 'normal'
|
||||
const fallbackMoves: Record<string, string[]> = {
|
||||
normal: ['Tackle', 'Scratch'],
|
||||
fire: ['Ember', 'FireSpin'],
|
||||
water: ['WaterGun', 'Bubble'],
|
||||
grass: ['VineWhip', 'RazorLeaf'],
|
||||
electric: ['ThunderShock', 'Spark'],
|
||||
poison: ['PoisonSting', 'Smog'],
|
||||
ice: ['IceShard', 'PowderSnow'],
|
||||
fighting: ['KarateChop', 'LowKick'],
|
||||
ground: ['MudSlap', 'SandAttack'],
|
||||
flying: ['Gust', 'WingAttack'],
|
||||
psychic: ['Confusion', 'Psybeam'],
|
||||
bug: ['BugBite', 'StringShot'],
|
||||
rock: ['RockThrow', 'SandAttack'],
|
||||
ghost: ['Lick', 'ShadowSneak'],
|
||||
dragon: ['DragonRage', 'Twister'],
|
||||
dark: ['Bite', 'Pursuit'],
|
||||
steel: ['MetalClaw', 'IronTail'],
|
||||
fairy: ['FairyWind', 'DisarmingVoice'],
|
||||
}
|
||||
return fallbackMoves[type] ?? ['Tackle', 'Scratch']
|
||||
}
|
||||
|
||||
// ─── State Projection (from Battle object) ───
|
||||
|
||||
function projectPokemon(pkm: any): BattlePokemon {
|
||||
if (!pkm) throw new Error('No active pokemon')
|
||||
const species = pkm.species
|
||||
const hp = pkm.hp ?? 0
|
||||
const maxHp = pkm.maxhp ?? 1
|
||||
|
||||
// Extract volatile statuses from the Pokémon's volatileStatuses
|
||||
const volatileStatuses: string[] = []
|
||||
if (pkm.volatiles) {
|
||||
for (const key of Object.keys(pkm.volatiles)) {
|
||||
volatileStatuses.push(key.toLowerCase())
|
||||
}
|
||||
}
|
||||
if (pkm.statusState?.confusion) volatileStatuses.push('confusion')
|
||||
if (pkm.statusState?.infatuation) volatileStatuses.push('infatuation')
|
||||
|
||||
return {
|
||||
id: pkm.name,
|
||||
speciesId: toID(species.name) as SpeciesId,
|
||||
name: species.name,
|
||||
level: pkm.level,
|
||||
hp,
|
||||
maxHp,
|
||||
types: species.types?.map((t: string) => t.toLowerCase()) ?? [],
|
||||
moves: (pkm.moveSlots ?? pkm.baseMoveset ?? []).filter(Boolean).map((m: any) => {
|
||||
const moveName = typeof m === 'string' ? m : (m.name ?? m.move?.name ?? Dex.moves.get(m.id ?? m.move)?.name ?? String(m.id ?? '???'))
|
||||
return {
|
||||
id: toID(moveName),
|
||||
name: moveName,
|
||||
type: m.type ?? Dex.moves.get(m.id ?? toID(moveName))?.type?.toLowerCase() ?? 'normal',
|
||||
pp: m.pp ?? 0,
|
||||
maxPp: m.maxpp ?? m.maxPp ?? m.pp ?? 0,
|
||||
disabled: m.disabled ?? false,
|
||||
}
|
||||
}),
|
||||
ability: pkm.ability ?? '',
|
||||
heldItem: pkm.item ?? null,
|
||||
status: mapStatus(pkm.status),
|
||||
volatileStatus: volatileStatuses,
|
||||
statStages: projectBoosts(pkm.boosts),
|
||||
}
|
||||
}
|
||||
|
||||
function mapStatus(status: string): StatusCondition {
|
||||
if (!status) return 'none'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'psn') return 'poison'
|
||||
if (s === 'tox') return 'bad_poison'
|
||||
if (s === 'brn') return 'burn'
|
||||
if (s === 'par') return 'paralysis'
|
||||
if (s === 'frz') return 'freeze'
|
||||
if (s === 'slp') return 'sleep'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function projectBoosts(boosts: Record<string, number> | undefined): Record<string, number> {
|
||||
if (!boosts) return {}
|
||||
const result: Record<string, number> = {}
|
||||
for (const [k, v] of Object.entries(boosts)) {
|
||||
const mapped = FROM_DEX_STAT[k]
|
||||
if (mapped) result[mapped] = v
|
||||
else result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function projectState(battle: any, bagItems?: { id: string; count: number }[], prevConditions?: { player: FieldCondition[]; opponent: FieldCondition[] }): BattleState {
|
||||
const p1 = battle.p1
|
||||
const p2 = battle.p2
|
||||
// Extract weather directly from battle field (auto-updates each turn)
|
||||
const weatherRaw = battle.field?.weather ?? ''
|
||||
const weather = mapWeather(weatherRaw)
|
||||
|
||||
// Extract terrain from battle field
|
||||
const terrainRaw = battle.field?.terrain ?? ''
|
||||
|
||||
return {
|
||||
playerPokemon: projectPokemon(p1.active[0]),
|
||||
opponentPokemon: projectPokemon(p2.active[0]),
|
||||
playerParty: p1.pokemon.map((p: any) => projectPokemon(p)),
|
||||
opponentParty: p2.pokemon.map((p: any) => projectPokemon(p)),
|
||||
turn: battle.turn ?? 1,
|
||||
events: [],
|
||||
finished: battle.ended,
|
||||
usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [],
|
||||
weather,
|
||||
playerConditions: prevConditions?.player ?? projectSideConditions(p1),
|
||||
opponentConditions: prevConditions?.opponent ?? projectSideConditions(p2),
|
||||
}
|
||||
}
|
||||
|
||||
function mapWeather(raw: string): WeatherKind | undefined {
|
||||
if (!raw) return undefined
|
||||
const w = raw.toLowerCase()
|
||||
if (w.includes('sun') || w.includes('desolateland')) return 'sun'
|
||||
if (w.includes('rain') || w.includes('primordialsea')) return 'rain'
|
||||
if (w.includes('sandstorm')) return 'sandstorm'
|
||||
if (w.includes('hail')) return 'hail'
|
||||
if (w.includes('snow')) return 'snow'
|
||||
if (w.includes('deltastream')) return 'deltastream'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Extract field conditions from a side object */
|
||||
function projectSideConditions(side: any): FieldCondition[] {
|
||||
const conditions: FieldCondition[] = []
|
||||
if (!side) return conditions
|
||||
const sr = side.sideConditions?.stealthrock
|
||||
if (sr) conditions.push({ id: 'Stealth Rock', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: 1 })
|
||||
const spikes = side.sideConditions?.spikes
|
||||
if (spikes) conditions.push({ id: 'Spikes', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: spikes.levels ?? 1 })
|
||||
const tspikes = side.sideConditions?.toxicspikes
|
||||
if (tspikes) conditions.push({ id: 'Toxic Spikes', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: tspikes.levels ?? 1 })
|
||||
const webs = side.sideConditions?.stickyweb
|
||||
if (webs) conditions.push({ id: 'Sticky Web', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: 1 })
|
||||
return conditions
|
||||
}
|
||||
|
||||
// ─── Protocol Event Parsing (from spectator chunks) ───
|
||||
|
||||
function parseChunkToEvents(chunk: string, prevHp?: { player: { hp: number; maxHp: number }; opponent: { hp: number; maxHp: number } }): BattleEvent[] {
|
||||
const events: BattleEvent[] = []
|
||||
// Track HP through the chunk to compute damage/heal amounts
|
||||
const hp = prevHp ? { player: { ...prevHp.player }, opponent: { ...prevHp.opponent } } : { player: { hp: 0, maxHp: 1 }, opponent: { hp: 0, maxHp: 1 } }
|
||||
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (!line.startsWith('|')) continue
|
||||
// Skip non-battle lines (but NOT |upkeep| anymore!)
|
||||
if (line.startsWith('|t:|') || line === '|' || line.startsWith('|gametype|') || line.startsWith('|player|') ||
|
||||
line.startsWith('|gen|') || line.startsWith('|tier|') || line.startsWith('|clearpoke|') ||
|
||||
line.startsWith('|poke|') || line.startsWith('|teampreview|') || line.startsWith('|teamsize|') ||
|
||||
line.startsWith('|start|') || line.startsWith('|done|')) continue
|
||||
|
||||
const parts = line.split('|')
|
||||
const cmd = parts[1]
|
||||
if (!cmd) continue
|
||||
const side = parts[2]?.startsWith('p1a') ? 'player' as const : 'opponent' as const
|
||||
|
||||
switch (cmd) {
|
||||
case 'move':
|
||||
events.push({ type: 'move', side, move: parts[3] ?? '', user: parts[2] ?? '' })
|
||||
break
|
||||
case '-damage': {
|
||||
const newHp = parseHpValue(parts[3])
|
||||
const prev = hp[side].hp
|
||||
const maxHp = hp[side].maxHp || 1
|
||||
if (newHp !== null) {
|
||||
const amount = Math.max(0, prev - newHp)
|
||||
const percentage = maxHp > 0 ? Math.round((amount / maxHp) * 100) : 0
|
||||
hp[side].hp = newHp
|
||||
hp[side].maxHp = Math.max(hp[side].maxHp, parseMaxHp(parts[3]) ?? maxHp)
|
||||
events.push({ type: 'damage', side, amount, percentage })
|
||||
} else {
|
||||
events.push({ type: 'damage', side, amount: 0, percentage: 0 })
|
||||
}
|
||||
break
|
||||
}
|
||||
case '-heal': {
|
||||
const newHp = parseHpValue(parts[3])
|
||||
const prev = hp[side].hp
|
||||
const maxHp = hp[side].maxHp || 1
|
||||
if (newHp !== null) {
|
||||
const amount = Math.max(0, newHp - prev)
|
||||
const percentage = maxHp > 0 ? Math.round((amount / maxHp) * 100) : 0
|
||||
hp[side].hp = newHp
|
||||
hp[side].maxHp = Math.max(hp[side].maxHp, parseMaxHp(parts[3]) ?? maxHp)
|
||||
events.push({ type: 'heal', side, amount, percentage })
|
||||
} else {
|
||||
events.push({ type: 'heal', side, amount: 0, percentage: 0 })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'faint':
|
||||
events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
|
||||
break
|
||||
case 'switch': {
|
||||
const name = parts[3]?.split(',')[0] ?? ''
|
||||
// Parse HP from switch: "Squirtle, L5, 100/100"
|
||||
const hpStr = parts[3] ?? ''
|
||||
const hpMatch = hpStr.match(/(\d+)\/(\d+)/)
|
||||
if (hpMatch) {
|
||||
hp[side].hp = parseInt(hpMatch[1], 10)
|
||||
hp[side].maxHp = parseInt(hpMatch[2], 10)
|
||||
}
|
||||
events.push({ type: 'switch', side, speciesId: toID(name), name })
|
||||
break
|
||||
}
|
||||
case '-supereffective':
|
||||
events.push({ type: 'effectiveness', multiplier: 2 })
|
||||
break
|
||||
case '-resisted':
|
||||
events.push({ type: 'effectiveness', multiplier: 0.5 })
|
||||
break
|
||||
case '-crit':
|
||||
events.push({ type: 'crit' })
|
||||
break
|
||||
case '-miss':
|
||||
events.push({ type: 'miss', side })
|
||||
break
|
||||
case '-status':
|
||||
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
|
||||
break
|
||||
case '-curestatus':
|
||||
// Pokémon cured of status — represent as status 'none'
|
||||
events.push({ type: 'status', side, status: 'none' })
|
||||
break
|
||||
case '-boost':
|
||||
case '-unboost': {
|
||||
const stages = cmd === '-boost' ? Number(parts[4]) : -Number(parts[4])
|
||||
events.push({ type: 'statChange', side, stat: parts[3] ?? '', stages })
|
||||
break
|
||||
}
|
||||
case '-ability':
|
||||
events.push({ type: 'ability', side, ability: parts[3] ?? '' })
|
||||
break
|
||||
case '-item':
|
||||
events.push({ type: 'item', side, item: parts[3] ?? '' })
|
||||
break
|
||||
case 'fail':
|
||||
events.push({ type: 'fail', side, reason: parts[3] ?? '' })
|
||||
break
|
||||
case '-fail':
|
||||
events.push({ type: 'fail', side, reason: parts[3] ?? '' })
|
||||
break
|
||||
case '-weather': {
|
||||
const weatherRaw = parts[2] ?? ''
|
||||
if (weatherRaw === 'none' || weatherRaw === '') {
|
||||
events.push({ type: 'weather', weather: 'none' })
|
||||
} else {
|
||||
const weather = mapWeather(weatherRaw)
|
||||
events.push({ type: 'weather', weather: weather ?? 'none', source: parts[3] ?? undefined })
|
||||
}
|
||||
break
|
||||
}
|
||||
case '-fieldstart':
|
||||
case '-fieldend': {
|
||||
const fieldId = parts[2] ?? ''
|
||||
const action = cmd === '-fieldstart' ? 'add' as const : 'remove' as const
|
||||
// Terrains etc. — map to fieldCondition
|
||||
events.push({ type: 'fieldCondition', side: 'player', id: fieldId, level: 1, action })
|
||||
break
|
||||
}
|
||||
case '-sidestart': {
|
||||
const conditionId = parts[3] ?? ''
|
||||
const condSide = parts[2]?.startsWith('p1') ? 'player' as const : 'opponent' as const
|
||||
const level = conditionId.match(/\d/) ? parseInt(conditionId.match(/\d/)![0], 10) : 1
|
||||
const cleanId = conditionId.replace(/\d+$/, '').trim()
|
||||
events.push({ type: 'fieldCondition', side: condSide, id: cleanId, level, action: 'add' })
|
||||
break
|
||||
}
|
||||
case '-sideend': {
|
||||
const conditionId = parts[3] ?? ''
|
||||
const condSide = parts[2]?.startsWith('p1') ? 'player' as const : 'opponent' as const
|
||||
events.push({ type: 'fieldCondition', side: condSide, id: conditionId, level: 0, action: 'remove' })
|
||||
break
|
||||
}
|
||||
case '-activate': {
|
||||
const effect = parts[3] ?? parts[2] ?? ''
|
||||
events.push({ type: 'activate', side, effect })
|
||||
break
|
||||
}
|
||||
case '-immune':
|
||||
events.push({ type: 'immune', side })
|
||||
break
|
||||
case 'upkeep':
|
||||
events.push({ type: 'upkeep' })
|
||||
break
|
||||
case 'turn':
|
||||
events.push({ type: 'turn', number: Number(parts[2]) })
|
||||
break
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Parse current HP from protocol HP string like "80/100" or "80/100brn" */
|
||||
function parseHpValue(hpStr?: string): number | null {
|
||||
if (!hpStr) return null
|
||||
const match = hpStr.match(/^(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
/** Parse max HP from protocol HP string like "80/100" or "80/100brn" */
|
||||
function parseMaxHp(hpStr?: string): number | null {
|
||||
if (!hpStr) return null
|
||||
const match = hpStr.match(/\/(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
// ─── Engine API ───
|
||||
|
||||
export type OpponentEntry = { speciesId: SpeciesId; level: number }
|
||||
|
||||
export async function createBattle(
|
||||
partyCreatures: Creature[],
|
||||
opponentSpeciesId: SpeciesId | OpponentEntry[],
|
||||
opponentLevel?: number,
|
||||
_bagItems?: { id: string; count: number }[],
|
||||
): Promise<BattleInit> {
|
||||
const stream = new BattleStreams.BattleStream()
|
||||
const streams = BattleStreams.getPlayerStreams(stream)
|
||||
|
||||
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
|
||||
|
||||
// Support both single species (wild) and multi-species (trainer) opponents
|
||||
let p2Sets: string[]
|
||||
if (Array.isArray(opponentSpeciesId)) {
|
||||
p2Sets = opponentSpeciesId.map(e => wildPokemonToSetString(e.speciesId, e.level))
|
||||
} else {
|
||||
const level = opponentLevel ?? 5
|
||||
p2Sets = [wildPokemonToSetString(opponentSpeciesId, level)]
|
||||
}
|
||||
|
||||
const p1Team = Teams.import(p1Sets.join('\n\n'))
|
||||
const p2Team = Teams.import(p2Sets.join('\n\n'))
|
||||
|
||||
const spec = { formatid: 'gen9customgame' }
|
||||
const p1spec = { name: 'Player', team: Teams.pack(p1Team) }
|
||||
const p2spec = { name: 'Opponent', team: Teams.pack(p2Team) }
|
||||
|
||||
// Initialize battle
|
||||
streams.omniscient.write(
|
||||
`>start ${JSON.stringify(spec)}\n` +
|
||||
`>player p1 ${JSON.stringify(p1spec)}\n` +
|
||||
`>player p2 ${JSON.stringify(p2spec)}`
|
||||
)
|
||||
|
||||
// Drain team preview from omniscient and spectator streams
|
||||
await streams.omniscient.read()
|
||||
await streams.spectator.read()
|
||||
|
||||
// Accept team preview — lead with first Pokémon
|
||||
streams.omniscient.write(`>p1 team 1\n>p2 team 1`)
|
||||
|
||||
// Read battle start from spectator (clean, no |split|)
|
||||
const startChunk = (await streams.spectator.read()) ?? ''
|
||||
|
||||
// Parse initial events (switches + turn)
|
||||
const initialEvents = parseChunkToEvents(startChunk)
|
||||
|
||||
// Use Battle object for rich state projection
|
||||
const battle = stream.battle!
|
||||
const state = projectState(battle, _bagItems, { player: [], opponent: [] })
|
||||
state.events = initialEvents
|
||||
|
||||
return { streams, stream, state }
|
||||
}
|
||||
|
||||
export async function executeTurn(
|
||||
battleInit: BattleInit,
|
||||
action: PlayerAction,
|
||||
): Promise<BattleState> {
|
||||
const { streams, stream } = battleInit
|
||||
const prevState = battleInit.state
|
||||
const battle = stream.battle!
|
||||
|
||||
// Build p1 choice
|
||||
let p1Choice: string
|
||||
let isEscape = false
|
||||
let state_captureResult: { captured: boolean; shakes: number; speciesId: SpeciesId } | undefined
|
||||
switch (action.type) {
|
||||
case 'move':
|
||||
p1Choice = `move ${action.moveIndex + 1}`
|
||||
break
|
||||
case 'switch': {
|
||||
// Use partyIndex directly (1-indexed for showdown protocol)
|
||||
const idx = action.partyIndex
|
||||
const p1Pokemon: any[] = battle.p1.pokemon
|
||||
p1Choice = idx >= 0 && idx < p1Pokemon.length ? `switch ${idx + 1}` : 'move 1'
|
||||
break
|
||||
}
|
||||
case 'item': {
|
||||
// Pokeball items trigger capture attempt
|
||||
if (action.itemId && action.itemId.toLowerCase().includes('ball')) {
|
||||
const opp = prevState.opponentPokemon
|
||||
const captureResult = attemptCapture(
|
||||
opp.speciesId, opp.hp, opp.maxHp, action.itemId, opp.status,
|
||||
prevState.turn, prevState.turn === 1,
|
||||
)
|
||||
if (captureResult.captured) {
|
||||
// Capture successful — forfeit and end battle
|
||||
streams.omniscient.write('>p1 forfeit')
|
||||
await streams.spectator.read()
|
||||
const state = projectState(battle, prevState.usableItems, {
|
||||
player: prevState.playerConditions,
|
||||
opponent: prevState.opponentConditions,
|
||||
})
|
||||
state.finished = true
|
||||
state.captureResult = { captured: true, shakes: captureResult.shakes, speciesId: opp.speciesId }
|
||||
state.events = [...prevState.events, { type: 'activate' as const, side: 'player' as const, effect: 'capture' }]
|
||||
battleInit.state = state
|
||||
return state
|
||||
}
|
||||
// Capture failed — player wastes turn, opponent attacks
|
||||
state_captureResult = { captured: false, shakes: captureResult.shakes, speciesId: opp.speciesId }
|
||||
} else {
|
||||
// Apply healing/status item effect
|
||||
const p1Active = battle.p1.active[0]
|
||||
if (p1Active && action.itemId) {
|
||||
applyItemEffect(battle, action.itemId, p1Active)
|
||||
}
|
||||
}
|
||||
p1Choice = 'move 1'
|
||||
break
|
||||
}
|
||||
case 'run': {
|
||||
// Escape probability: f = ((playerSpeed * 128) / opponentSpeed + 30 * attempts) % 256
|
||||
const attempts = (prevState.escapeAttempts ?? 0) + 1
|
||||
const playerSpeed = prevState.playerPokemon.statStages?.speed
|
||||
? getStatWithStage(prevState.playerPokemon, 'spe')
|
||||
: (battle.p1.active[0]?.stats?.spe ?? 10)
|
||||
const opponentSpeed = prevState.opponentPokemon.statStages?.speed
|
||||
? getStatWithStage(prevState.opponentPokemon, 'spe')
|
||||
: (battle.p2.active[0]?.stats?.spe ?? 10)
|
||||
const f = Math.floor((playerSpeed * 128 / Math.max(1, opponentSpeed) + 30 * attempts) % 256)
|
||||
const roll = Math.floor(Math.random() * 256)
|
||||
|
||||
if (roll < f) {
|
||||
// Escape successful — forfeit the battle
|
||||
streams.omniscient.write('>p1 forfeit')
|
||||
await streams.spectator.read()
|
||||
const state = projectState(battle, prevState.usableItems, {
|
||||
player: prevState.playerConditions,
|
||||
opponent: prevState.opponentConditions,
|
||||
})
|
||||
state.finished = true
|
||||
state.escaped = true
|
||||
state.events = [...prevState.events, { type: 'activate' as const, side: 'player' as const, effect: 'escape' }]
|
||||
battleInit.state = state
|
||||
return state
|
||||
}
|
||||
|
||||
// Escape failed — player wastes turn, opponent attacks
|
||||
isEscape = true
|
||||
p1Choice = 'move 1' // placeholder, player doesn't act
|
||||
break
|
||||
}
|
||||
default:
|
||||
p1Choice = 'move 1'
|
||||
}
|
||||
|
||||
// AI choice — pass player's types so AI can consider effectiveness
|
||||
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon, prevState.playerPokemon.types)
|
||||
const p2Choice = `move ${aiMoveIndex + 1}`
|
||||
|
||||
// Submit choices via stream
|
||||
streams.omniscient.write(`>p1 ${p1Choice}\n>p2 ${p2Choice}`)
|
||||
|
||||
// Read turn result from spectator (no |split| issues)
|
||||
const turnChunk = (await streams.spectator.read()) ?? ''
|
||||
const newEvents = parseChunkToEvents(turnChunk, {
|
||||
player: { hp: prevState.playerPokemon.hp, maxHp: prevState.playerPokemon.maxHp },
|
||||
opponent: { hp: prevState.opponentPokemon.hp, maxHp: prevState.opponentPokemon.maxHp },
|
||||
})
|
||||
|
||||
// Project rich state from Battle object, preserving field conditions
|
||||
const state = projectState(battle, prevState.usableItems, {
|
||||
player: prevState.playerConditions,
|
||||
opponent: prevState.opponentConditions,
|
||||
})
|
||||
state.events = [...prevState.events, ...newEvents]
|
||||
|
||||
// Track escape attempts
|
||||
if (isEscape) {
|
||||
state.escapeAttempts = (prevState.escapeAttempts ?? 0) + 1
|
||||
} else {
|
||||
state.escapeAttempts = prevState.escapeAttempts ?? 0
|
||||
}
|
||||
|
||||
// Track capture result
|
||||
if (state_captureResult) {
|
||||
state.captureResult = state_captureResult
|
||||
}
|
||||
|
||||
// Forced switch detection via Battle object
|
||||
const p1Active = battle.p1.active[0]
|
||||
const p1Fainted = p1Active?.fainted || p1Active?.hp === 0 || state.playerPokemon.hp === 0
|
||||
const hasAliveBench = battle.p1.pokemon.some(
|
||||
(p: any) => !p.fainted && p.hp > 0 && p !== p1Active,
|
||||
)
|
||||
if (p1Fainted && hasAliveBench && !battle.ended) {
|
||||
state.needsSwitch = true
|
||||
}
|
||||
|
||||
// Battle end detection
|
||||
if (battle.ended) {
|
||||
state.finished = true
|
||||
const winner = battle.winner === 'Player' ? 'player' as const : 'opponent' as const
|
||||
state.result = {
|
||||
winner,
|
||||
turns: state.turn,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
battleInit.state = state
|
||||
return state
|
||||
}
|
||||
|
||||
export async function executeSwitch(
|
||||
battleInit: BattleInit,
|
||||
partyIndex: number,
|
||||
): Promise<BattleState> {
|
||||
const { streams, stream } = battleInit
|
||||
const prevState = battleInit.state
|
||||
const battle = stream.battle!
|
||||
|
||||
// Validate slot index
|
||||
const p1Pokemon: any[] = battle.p1.pokemon
|
||||
if (partyIndex < 0 || partyIndex >= p1Pokemon.length) return prevState
|
||||
|
||||
// Build p2 command: switch if fainted, otherwise use AI move
|
||||
let p2Cmd = ''
|
||||
const p2Active = battle.p2.active[0]
|
||||
if (p2Active?.fainted || p2Active?.hp === 0) {
|
||||
const p2Pkm: any[] = battle.p2.pokemon
|
||||
// Find best switch-in: prefer type advantage against player's active
|
||||
const playerTypes = prevState.playerPokemon.types
|
||||
const aliveIndices = p2Pkm
|
||||
.map((p: any, i: number) => ({ p, i }))
|
||||
.filter(({ p, i }) => i > 0 && !p.fainted && p.hp > 0)
|
||||
|
||||
let bestIdx = -1
|
||||
if (aliveIndices.length > 0 && playerTypes.length > 0) {
|
||||
// Score each candidate by type effectiveness against player
|
||||
let bestScore = -Infinity
|
||||
for (const { p, i } of aliveIndices) {
|
||||
const types = p.species?.types ?? []
|
||||
let score = 0
|
||||
for (const atkType of types) {
|
||||
for (const defType of playerTypes) {
|
||||
score += Dex.getEffectiveness(atkType, defType)
|
||||
}
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to first alive if no type advantage found
|
||||
if (bestIdx < 0) bestIdx = aliveIndices[0]?.i ?? -1
|
||||
p2Cmd = bestIdx >= 0 ? `\n>p2 switch ${bestIdx + 1}` : '\n>p2 pass'
|
||||
} else {
|
||||
// p2's active is alive — submit AI move choice
|
||||
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon, prevState.playerPokemon.types)
|
||||
p2Cmd = `\n>p2 move ${aiMoveIndex + 1}`
|
||||
}
|
||||
|
||||
// Submit switch (1-indexed for showdown protocol)
|
||||
streams.omniscient.write(`>p1 switch ${partyIndex + 1}${p2Cmd}`)
|
||||
|
||||
// Read result
|
||||
const switchChunk = (await streams.spectator.read()) ?? ''
|
||||
const newEvents = parseChunkToEvents(switchChunk, {
|
||||
player: { hp: prevState.playerPokemon.hp, maxHp: prevState.playerPokemon.maxHp },
|
||||
opponent: { hp: prevState.opponentPokemon.hp, maxHp: prevState.opponentPokemon.maxHp },
|
||||
})
|
||||
|
||||
// Project state
|
||||
const state = projectState(battle, prevState.usableItems, {
|
||||
player: prevState.playerConditions,
|
||||
opponent: prevState.opponentConditions,
|
||||
})
|
||||
state.events = [...prevState.events, ...newEvents]
|
||||
|
||||
// Forced switch detection via Battle object
|
||||
const p1Active = battle.p1.active[0]
|
||||
const p1Fainted = p1Active?.fainted || p1Active?.hp === 0 || state.playerPokemon.hp === 0
|
||||
const hasAliveBench = battle.p1.pokemon.some(
|
||||
(p: any) => !p.fainted && p.hp > 0 && p !== p1Active,
|
||||
)
|
||||
if (p1Fainted && hasAliveBench && !battle.ended) {
|
||||
state.needsSwitch = true
|
||||
}
|
||||
|
||||
if (battle.ended) {
|
||||
state.finished = true
|
||||
const winner = battle.winner === 'Player' ? 'player' as const : 'opponent' as const
|
||||
state.result = {
|
||||
winner,
|
||||
turns: state.turn,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
battleInit.state = state
|
||||
return state
|
||||
}
|
||||
5
packages/pokemon/src/battle/index.ts
Normal file
5
packages/pokemon/src/battle/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './types'
|
||||
export { createBattle, executeTurn, executeSwitch, type BattleInit, type OpponentEntry } from './engine'
|
||||
export { settleBattle, applyMoveLearn, applyEvolution } from './settlement'
|
||||
export { chooseAIMove } from './ai'
|
||||
export { attemptCapture, type CaptureResult } from './capture'
|
||||
189
packages/pokemon/src/battle/settlement.ts
Normal file
189
packages/pokemon/src/battle/settlement.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { StatName, SpeciesId } from '../types'
|
||||
import { STAT_NAMES } from '../types'
|
||||
import { TO_DEX_STAT } from '../dex/pkmn'
|
||||
import type { BattleResult } from './types'
|
||||
import type { BuddyData } from '../types'
|
||||
import { levelFromXp } from '../dex/xpTable'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import { getBaseExperience, getEvYield as getPokedexEvYield } from '../dex/pokedex-data'
|
||||
|
||||
/**
|
||||
* Settle battle results: XP, EV, level ups, move learning, evolution detection.
|
||||
*/
|
||||
export async function settleBattle(
|
||||
data: BuddyData,
|
||||
result: BattleResult,
|
||||
opponentSpeciesId: SpeciesId,
|
||||
opponentLevel: number,
|
||||
): Promise<{
|
||||
data: BuddyData
|
||||
learnableMoves: { creatureId: string; moveId: string; moveName: string }[]
|
||||
pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[]
|
||||
}> {
|
||||
if (result.winner !== 'player') {
|
||||
return { data, learnableMoves: [], pendingEvolutions: [] }
|
||||
}
|
||||
|
||||
// Calculate XP reward using real base_experience from PokeAPI
|
||||
const baseXp = getBaseExperience(opponentSpeciesId)
|
||||
const xpGained = Math.max(1, Math.floor(baseXp * opponentLevel / 7))
|
||||
|
||||
// Calculate EV reward using real EV yield from PokeAPI
|
||||
const evGained: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
|
||||
const evYield = getPokedexEvYield(opponentSpeciesId)
|
||||
for (const stat of STAT_NAMES) {
|
||||
evGained[stat] = evYield[TO_DEX_STAT[stat]] ?? 0
|
||||
}
|
||||
|
||||
// Award XP/EV to participant creatures
|
||||
const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = []
|
||||
const pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] = []
|
||||
const participantIds = new Set(result.participantIds.length > 0 ? result.participantIds : data.party.filter((id): id is string => id !== null))
|
||||
|
||||
const updatedCreatures: typeof data.creatures = []
|
||||
for (const creature of data.creatures) {
|
||||
if (!participantIds.has(creature.id)) {
|
||||
updatedCreatures.push(creature)
|
||||
continue
|
||||
}
|
||||
|
||||
// Award EVs (capped)
|
||||
const newEv = { ...creature.ev }
|
||||
let totalEV = STAT_NAMES.reduce((sum, s) => sum + newEv[s], 0)
|
||||
for (const stat of STAT_NAMES) {
|
||||
if (totalEV >= MAX_EV_TOTAL) break
|
||||
const gain = Math.min(evGained[stat], MAX_EV_PER_STAT - newEv[stat], MAX_EV_TOTAL - totalEV)
|
||||
newEv[stat] += gain
|
||||
totalEV += gain
|
||||
}
|
||||
|
||||
// Award XP
|
||||
const oldLevel = creature.level
|
||||
const newTotalXp = creature.totalXp + xpGained
|
||||
const species = getSpeciesData(creature.speciesId)
|
||||
const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate))
|
||||
|
||||
// Detect new learnable moves on level up
|
||||
if (newLevel > oldLevel) {
|
||||
const learnset = await Dex.learnsets.get(creature.speciesId)
|
||||
if (learnset?.learnset) {
|
||||
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
|
||||
for (const src of sources as string[]) {
|
||||
if (src.startsWith('9L')) {
|
||||
const moveLevel = parseInt(src.slice(2))
|
||||
if (moveLevel > oldLevel && moveLevel <= newLevel) {
|
||||
const dexMove = Dex.moves.get(moveId)
|
||||
learnableMoves.push({
|
||||
creatureId: creature.id,
|
||||
moveId,
|
||||
moveName: dexMove?.name ?? moveId,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect evolution — check ALL evolution targets (handles branch evolutions)
|
||||
if (newLevel > oldLevel) {
|
||||
const species = Dex.species.get(creature.speciesId)
|
||||
if (species?.evos?.length) {
|
||||
for (const evoId of species.evos) {
|
||||
const targetId = evoId.toLowerCase()
|
||||
const target = Dex.species.get(targetId)
|
||||
if (!target?.exists) continue
|
||||
const trigger = species.evoType
|
||||
// Level-up evolutions
|
||||
if ((!trigger || trigger === 'levelUp') && target.evoLevel && newLevel >= target.evoLevel) {
|
||||
pendingEvolutions.push({
|
||||
creatureId: creature.id,
|
||||
from: creature.speciesId,
|
||||
to: targetId as SpeciesId,
|
||||
})
|
||||
break // Only evolve into one target per level-up
|
||||
}
|
||||
// Friendship evolutions (friendship >= 220)
|
||||
if (trigger === 'levelFriendship' && creature.friendship >= 220) {
|
||||
pendingEvolutions.push({
|
||||
creatureId: creature.id,
|
||||
from: creature.speciesId,
|
||||
to: targetId as SpeciesId,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedCreatures.push({
|
||||
...creature,
|
||||
level: newLevel,
|
||||
totalXp: newTotalXp,
|
||||
ev: newEv,
|
||||
})
|
||||
}
|
||||
|
||||
// Update data
|
||||
const updatedData: BuddyData = {
|
||||
...data,
|
||||
creatures: updatedCreatures,
|
||||
stats: {
|
||||
...data.stats,
|
||||
battlesWon: data.stats.battlesWon + (result.winner === 'player' ? 1 : 0),
|
||||
battlesLost: data.stats.battlesLost + (result.winner !== 'player' ? 1 : 0),
|
||||
},
|
||||
}
|
||||
|
||||
return { data: updatedData, learnableMoves, pendingEvolutions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply move learning - replace a move at the given index.
|
||||
*/
|
||||
export function applyMoveLearn(
|
||||
data: BuddyData,
|
||||
creatureId: string,
|
||||
moveId: string,
|
||||
replaceIndex: number,
|
||||
): BuddyData {
|
||||
return {
|
||||
...data,
|
||||
creatures: data.creatures.map(c => {
|
||||
if (c.id !== creatureId) return c
|
||||
const dexMove = Dex.moves.get(moveId)
|
||||
const newMoves = [...c.moves] as typeof c.moves
|
||||
newMoves[replaceIndex] = {
|
||||
id: moveId,
|
||||
pp: dexMove?.pp ?? 10,
|
||||
maxPp: dexMove?.pp ?? 10,
|
||||
}
|
||||
return { ...c, moves: newMoves as typeof c.moves }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply evolution to a creature.
|
||||
*/
|
||||
export function applyEvolution(
|
||||
data: BuddyData,
|
||||
creatureId: string,
|
||||
newSpeciesId: SpeciesId,
|
||||
): BuddyData {
|
||||
return {
|
||||
...data,
|
||||
creatures: data.creatures.map(c =>
|
||||
c.id === creatureId
|
||||
? { ...c, speciesId: newSpeciesId, friendship: Math.min(255, c.friendship + 10) }
|
||||
: c,
|
||||
),
|
||||
stats: {
|
||||
...data.stats,
|
||||
totalEvolutions: data.stats.totalEvolutions + 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
93
packages/pokemon/src/battle/types.ts
Normal file
93
packages/pokemon/src/battle/types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { StatName, SpeciesId } from '../types'
|
||||
|
||||
export type StatusCondition = 'poison' | 'bad_poison' | 'burn' | 'paralysis' | 'freeze' | 'sleep' | 'confusion' | 'infatuation' | 'flinch' | 'none'
|
||||
|
||||
export type VolatileStatus = 'confusion' | 'infatuation' | 'flinch' | 'leech_seed' | 'trapped' | 'nightmare' | 'curse' | 'taunt' | 'encore' | 'torment' | 'disable' | 'magnet_rise' | 'telekinesis' | 'heal_block' | 'embargo' | 'yawn' | 'perish_song'
|
||||
|
||||
export type BattlePokemon = {
|
||||
id: string // creature ID
|
||||
speciesId: SpeciesId
|
||||
name: string
|
||||
level: number
|
||||
hp: number // current HP in battle
|
||||
maxHp: number
|
||||
types: string[]
|
||||
moves: MoveOption[]
|
||||
ability: string
|
||||
heldItem: string | null
|
||||
status: StatusCondition
|
||||
volatileStatus: string[] // confusion, infatuation, leech_seed, etc.
|
||||
statStages: Record<string, number> // -6 to +6
|
||||
}
|
||||
|
||||
export type MoveOption = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
pp: number
|
||||
maxPp: number
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type PlayerAction =
|
||||
| { type: 'move'; moveIndex: number }
|
||||
| { type: 'switch'; partyIndex: number }
|
||||
| { type: 'item'; itemId: string }
|
||||
| { type: 'run' }
|
||||
|
||||
export type WeatherKind = 'sun' | 'rain' | 'sandstorm' | 'hail' | 'snow' | 'desolateland' | 'primordialsea' | 'deltastream'
|
||||
|
||||
export type FieldCondition = {
|
||||
/** e.g. 'Stealth Rock', 'Spikes', 'Toxic Spikes', 'Sticky Web' */
|
||||
id: string
|
||||
side: 'player' | 'opponent'
|
||||
level: number // 1-3 for Spikes/Toxic Spikes, 1 for others
|
||||
}
|
||||
|
||||
export type BattleEvent =
|
||||
| { type: 'move'; side: 'player' | 'opponent'; move: string; user: string }
|
||||
| { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number }
|
||||
| { type: 'heal'; side: 'player' | 'opponent'; amount: number; percentage: number }
|
||||
| { type: 'faint'; side: 'player' | 'opponent'; speciesId: string }
|
||||
| { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string }
|
||||
| { type: 'effectiveness'; multiplier: number }
|
||||
| { type: 'crit' }
|
||||
| { type: 'miss'; side: 'player' | 'opponent' }
|
||||
| { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition }
|
||||
| { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number }
|
||||
| { type: 'ability'; side: 'player' | 'opponent'; ability: string }
|
||||
| { type: 'item'; side: 'player' | 'opponent'; item: string }
|
||||
| { type: 'fail'; side: 'player' | 'opponent'; reason: string }
|
||||
| { type: 'weather'; weather: WeatherKind | 'none'; source?: string }
|
||||
| { type: 'upkeep' }
|
||||
| { type: 'fieldCondition'; side: 'player' | 'opponent'; id: string; level: number; action: 'add' | 'remove' }
|
||||
| { type: 'activate'; side: 'player' | 'opponent'; effect: string }
|
||||
| { type: 'immune'; side: 'player' | 'opponent' }
|
||||
| { type: 'turn'; number: number }
|
||||
|
||||
export type BattleResult = {
|
||||
winner: 'player' | 'opponent'
|
||||
turns: number
|
||||
xpGained: number
|
||||
evGained: Record<StatName, number>
|
||||
participantIds: string[]
|
||||
}
|
||||
|
||||
export type BattleState = {
|
||||
playerPokemon: BattlePokemon
|
||||
opponentPokemon: BattlePokemon
|
||||
playerParty: BattlePokemon[]
|
||||
opponentParty: BattlePokemon[]
|
||||
turn: number
|
||||
events: BattleEvent[]
|
||||
finished: boolean
|
||||
result?: BattleResult
|
||||
usableItems: { id: string; name: string; count: number }[]
|
||||
needsSwitch?: boolean // player's active Pokémon fainted, must switch
|
||||
weather?: WeatherKind // current weather
|
||||
playerConditions: FieldCondition[] // hazards on player's side
|
||||
opponentConditions: FieldCondition[] // hazards on opponent's side
|
||||
escaped?: boolean // player successfully escaped
|
||||
escapeAttempts?: number // number of failed escape attempts
|
||||
captureResult?: { captured: boolean; shakes: number; speciesId: SpeciesId } // capture attempt result
|
||||
}
|
||||
160
packages/pokemon/src/core/creature.ts
Normal file
160
packages/pokemon/src/core/creature.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Creature, SpeciesId, StatName, StatsResult } from '../types'
|
||||
import { STAT_NAMES } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { determineGender } from './gender'
|
||||
import { levelFromXp } from '../dex/xpTable'
|
||||
import { gen, TO_DEX_STAT, getSpecies } from '../dex/pkmn'
|
||||
import { getDefaultMoveset, randomAbility } from '../dex/learnsets'
|
||||
import { randomNature } from '../dex/nature'
|
||||
|
||||
/**
|
||||
* Generate a new creature of the given species.
|
||||
*/
|
||||
export async function generateCreature(speciesId: SpeciesId, seed?: number): Promise<Creature> {
|
||||
const species = getSpeciesData(speciesId)
|
||||
const actualSeed = seed ?? Math.floor(Math.random() * 0xffffffff)
|
||||
|
||||
// Generate PID (32-bit personality value) from seed
|
||||
const pid = generatePID(actualSeed)
|
||||
|
||||
// Generate IVs (0-31) extracted from PID (Gen 3+ style)
|
||||
const iv = generateIVsFromPID(pid)
|
||||
|
||||
// Determine gender from PID's low 8 bits (Gen 3+ style)
|
||||
const gender = determineGender(species, pid & 0xff)
|
||||
|
||||
// Determine shiny status from PID XOR (Gen 3+ style)
|
||||
const isShiny = checkShiny(pid, actualSeed)
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
speciesId,
|
||||
gender,
|
||||
level: 1,
|
||||
xp: 0,
|
||||
totalXp: 0,
|
||||
nature: randomNature(),
|
||||
ev: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
iv,
|
||||
moves: await getDefaultMoveset(speciesId, 1),
|
||||
ability: randomAbility(speciesId),
|
||||
heldItem: null,
|
||||
friendship: species.baseHappiness,
|
||||
isShiny,
|
||||
hatchedAt: Date.now(),
|
||||
pokeball: 'pokeball',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate actual stats for a creature using @pkmn/data stats.calc().
|
||||
* Handles base stats, IV, EV, level, and nature correction internally.
|
||||
*/
|
||||
export function calculateStats(creature: Creature): StatsResult {
|
||||
const species = getSpecies(creature.speciesId)
|
||||
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
|
||||
|
||||
// Get nature if creature has one (Phase 1 adds nature field)
|
||||
const nature = 'nature' in creature && creature.nature
|
||||
? gen.natures.get(creature.nature as string)
|
||||
: undefined
|
||||
|
||||
const result = {} as StatsResult
|
||||
for (const stat of STAT_NAMES) {
|
||||
const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe'
|
||||
result[stat] = gen.stats.calc(
|
||||
dexKey,
|
||||
species.baseStats[dexKey],
|
||||
creature.iv[stat],
|
||||
creature.ev[stat],
|
||||
creature.level,
|
||||
nature ?? undefined,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a creature (nickname or species name).
|
||||
*/
|
||||
export function getCreatureName(creature: Creature): string {
|
||||
if (creature.nickname) return creature.nickname
|
||||
return getSpeciesData(creature.speciesId).name
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate level from total XP (e.g. after XP gain).
|
||||
*/
|
||||
export function recalculateLevel(creature: Creature): Creature {
|
||||
const species = getSpeciesData(creature.speciesId)
|
||||
const newLevel = levelFromXp(creature.totalXp, species.growthRate)
|
||||
if (newLevel !== creature.level) {
|
||||
return { ...creature, level: newLevel }
|
||||
}
|
||||
return creature
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active creature from buddy data.
|
||||
* Reads from party[0] (new) with fallback to activeCreatureId (legacy).
|
||||
*/
|
||||
export function getActiveCreature(buddyData: { party?: (string | null)[]; activeCreatureId?: string | null; creatures: Creature[] }): Creature | null {
|
||||
const activeId = buddyData.party?.[0] ?? buddyData.activeCreatureId ?? null
|
||||
if (!activeId) return null
|
||||
return buddyData.creatures.find((c) => c.id === activeId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 32-bit Personality Value (PID) from a seed.
|
||||
* PID is the core identity value used for shiny check, gender, IVs, etc.
|
||||
*/
|
||||
function generatePID(seed: number): number {
|
||||
let s = seed
|
||||
const next = () => { s = ((s * 1103515245 + 12345) & 0x7fffffff) >>> 0; return s }
|
||||
return ((next() & 0xffff) | ((next() & 0xffff) << 16)) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate IVs from PID using Gen 3+ style extraction.
|
||||
* HP IV = bits 0-4 of (pid >> 16) | (pid & 0xffff) is NOT used here;
|
||||
* instead we use the more common method:
|
||||
* word1 = pid (lower 16 bits), word2 = pid >> 16 (upper 16 bits)
|
||||
* hp = word1 & 0x1f, atk = (word1 >> 5) & 0x1f, def = (word1 >> 10) & 0x1f
|
||||
* spe = word2 & 0x1f, spa = (word2 >> 5) & 0x1f, spd = (word2 >> 10) & 0x1f
|
||||
*/
|
||||
function generateIVsFromPID(pid: number): Record<StatName, number> {
|
||||
const word1 = pid & 0xffff
|
||||
const word2 = (pid >>> 16) & 0xffff
|
||||
return {
|
||||
hp: word1 & 0x1f,
|
||||
attack: (word1 >>> 5) & 0x1f,
|
||||
defense: (word1 >>> 10) & 0x1f,
|
||||
speed: word2 & 0x1f,
|
||||
spAtk: (word2 >>> 5) & 0x1f,
|
||||
spDef: (word2 >>> 10) & 0x1f,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check shiny status using PID XOR method (Gen 3+).
|
||||
* Shiny if (pid_upper16 XOR pid_lower16 XOR trainerID XOR secretID) < threshold.
|
||||
* Since we don't have trainer IDs, use the seed's high/low as proxy.
|
||||
*/
|
||||
function checkShiny(pid: number, seed: number): boolean {
|
||||
const pidUpper = (pid >>> 16) & 0xffff
|
||||
const pidLower = pid & 0xffff
|
||||
const trainerId = seed & 0xffff
|
||||
const secretId = (seed >>> 16) & 0xffff
|
||||
const xorResult = pidUpper ^ pidLower ^ trainerId ^ secretId
|
||||
// Standard threshold: 1 (1/65536 per encounter, ~1/8192 with both checks)
|
||||
// Gen 8+: 16 (1/4096 base rate, 1/1024 with charm)
|
||||
return xorResult < 16
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total EV across all stats.
|
||||
*/
|
||||
export function getTotalEV(creature: Creature): number {
|
||||
return STAT_NAMES.reduce((sum, stat) => sum + creature.ev[stat], 0)
|
||||
}
|
||||
98
packages/pokemon/src/core/effort.ts
Normal file
98
packages/pokemon/src/core/effort.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Creature, StatName } from '../types'
|
||||
import { STAT_NAMES } from '../types'
|
||||
import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL, EV_COOLDOWN_MS } from '../dex/evMapping'
|
||||
import { getTotalEV } from './creature'
|
||||
|
||||
// Track last EV award time per tool to enforce cooldown
|
||||
const evCooldowns = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Reset EV cooldown state (for testing).
|
||||
*/
|
||||
export function resetEVCooldowns(): void {
|
||||
evCooldowns.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Award EV to a creature based on tool usage.
|
||||
* Returns updated creature and actual EV awarded.
|
||||
*/
|
||||
export function awardEV(creature: Creature, toolName: string, timestamp?: number): Creature {
|
||||
const now = timestamp ?? Date.now()
|
||||
|
||||
// Check cooldown
|
||||
const lastTime = evCooldowns.get(toolName)
|
||||
if (lastTime !== undefined && now - lastTime < EV_COOLDOWN_MS) return creature
|
||||
|
||||
const currentTotal = getTotalEV(creature)
|
||||
if (currentTotal >= MAX_EV_TOTAL) return creature
|
||||
|
||||
let evGains = getEVForTool(toolName)
|
||||
if (!evGains) {
|
||||
// Random EV for unmapped tools
|
||||
evGains = generateRandomEV()
|
||||
}
|
||||
|
||||
const updated = { ...creature, ev: { ...creature.ev } }
|
||||
for (const stat of STAT_NAMES) {
|
||||
const gain = evGains[stat]
|
||||
if (gain > 0) {
|
||||
const current = updated.ev[stat]
|
||||
const canAdd = Math.min(gain, MAX_EV_PER_STAT - current, MAX_EV_TOTAL - getTotalEV(updated))
|
||||
if (canAdd > 0) {
|
||||
updated.ev[stat] = current + canAdd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
evCooldowns.set(toolName, now)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Award EVs for a full turn's worth of tool calls.
|
||||
* Deduplicates tool names and spaces timestamps to avoid cooldown issues.
|
||||
*/
|
||||
export function awardTurnEV(creature: Creature, toolNames: string[], timestamp?: number): Creature {
|
||||
const uniqueTools = [...new Set(toolNames)]
|
||||
const baseTime = timestamp ?? Date.now()
|
||||
let current = creature
|
||||
for (let i = 0; i < uniqueTools.length; i++) {
|
||||
current = awardEV(current, uniqueTools[i]!, baseTime + i * 60_000)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random 1-2 EV points in a random stat.
|
||||
*/
|
||||
function generateRandomEV(): Record<StatName, number> {
|
||||
const stats = [...STAT_NAMES]
|
||||
const stat = stats[Math.floor(Math.random() * stats.length)]
|
||||
const amount = Math.random() < 0.5 ? 1 : 2
|
||||
const result: Record<StatName, number> = { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 }
|
||||
result[stat] = amount
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted EV summary string.
|
||||
*/
|
||||
export function getEVSummary(creature: Creature): string {
|
||||
const parts: string[] = []
|
||||
for (const stat of STAT_NAMES) {
|
||||
const val = creature.ev[stat]
|
||||
if (val > 0) {
|
||||
const labels: Record<StatName, string> = {
|
||||
hp: 'HP',
|
||||
attack: 'ATK',
|
||||
defense: 'DEF',
|
||||
spAtk: 'SPA',
|
||||
spDef: 'SPD',
|
||||
speed: 'SPE',
|
||||
}
|
||||
parts.push(`${labels[stat]}+${val}`)
|
||||
}
|
||||
}
|
||||
return parts.join(' ') || 'None'
|
||||
}
|
||||
111
packages/pokemon/src/core/egg.ts
Normal file
111
packages/pokemon/src/core/egg.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { BuddyData, Creature, Egg, SpeciesId } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { getHatchCounter } from '../dex/pokedex-data'
|
||||
import { generateCreature } from './creature'
|
||||
import { addToParty, depositToBox } from './storage'
|
||||
|
||||
/** Days of consecutive coding needed to be eligible for an egg */
|
||||
export const EGG_REQUIRED_DAYS = 3
|
||||
|
||||
/**
|
||||
* Check if the player is eligible to receive an egg.
|
||||
* Conditions: consecutiveDays >= EGG_REQUIRED_DAYS AND totalTurns % 50 === 0 AND eggs.length < 1
|
||||
*/
|
||||
export function checkEggEligibility(buddyData: BuddyData): boolean {
|
||||
if (buddyData.eggs.length >= 1) return false
|
||||
if (buddyData.stats.consecutiveDays < EGG_REQUIRED_DAYS) return false
|
||||
if (buddyData.stats.totalTurns % 50 !== 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new egg with a species the player hasn't collected yet.
|
||||
* Priority: uncollected species > random from all species.
|
||||
*/
|
||||
export function generateEgg(buddyData: BuddyData): Egg {
|
||||
// Find uncollected species
|
||||
const collectedSpecies = new Set(buddyData.creatures.map((c) => c.speciesId))
|
||||
const uncollected = ALL_SPECIES_IDS.filter((id) => !collectedSpecies.has(id))
|
||||
|
||||
// Pick species (prefer uncollected, fall back to random starter)
|
||||
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu']
|
||||
const speciesId = uncollected.length > 0
|
||||
? uncollected[Math.floor(Math.random() * uncollected.length)]
|
||||
: starters[Math.floor(Math.random() * starters.length)]
|
||||
|
||||
// Steps based on real hatch_counter from PokeAPI (steps = cycles * 257)
|
||||
const hatchCounter = getHatchCounter(speciesId)
|
||||
const baseSteps = hatchCounter * 257
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
obtainedAt: Date.now(),
|
||||
stepsRemaining: baseSteps,
|
||||
totalSteps: baseSteps,
|
||||
speciesId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance egg steps by a given amount.
|
||||
* Returns updated egg or null if egg hatched.
|
||||
*/
|
||||
export function advanceEggSteps(egg: Egg, steps: number): Egg {
|
||||
const newSteps = Math.max(0, egg.stepsRemaining - steps)
|
||||
return { ...egg, stepsRemaining: newSteps }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an egg is ready to hatch.
|
||||
*/
|
||||
export function isEggReadyToHatch(egg: Egg): boolean {
|
||||
return egg.stepsRemaining <= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Hatch an egg, creating a new creature and updating buddy data.
|
||||
* Tries to add to party first, then deposits to PC box.
|
||||
*/
|
||||
export async function hatchEgg(buddyData: BuddyData, egg: Egg): Promise<{ buddyData: BuddyData; creature: Creature }> {
|
||||
const creature = await generateCreature(egg.speciesId)
|
||||
creature.hatchedAt = Date.now()
|
||||
|
||||
// Add creature to list
|
||||
let updatedData: BuddyData = {
|
||||
...buddyData,
|
||||
creatures: [...buddyData.creatures, creature],
|
||||
eggs: buddyData.eggs.filter((e) => e.id !== egg.id),
|
||||
dex: updateDexEntry(buddyData.dex, egg.speciesId, creature.level),
|
||||
stats: {
|
||||
...buddyData.stats,
|
||||
totalEggsObtained: buddyData.stats.totalEggsObtained + 1,
|
||||
},
|
||||
}
|
||||
|
||||
// Place in party or PC box
|
||||
const partyResult = addToParty(updatedData, creature.id)
|
||||
if (partyResult.added) {
|
||||
updatedData = partyResult.data
|
||||
} else {
|
||||
const boxResult = depositToBox(updatedData, creature.id)
|
||||
if (boxResult.deposited) updatedData = boxResult.data
|
||||
}
|
||||
|
||||
return { buddyData: updatedData, creature }
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create a dex entry for a species.
|
||||
*/
|
||||
function updateDexEntry(dex: BuddyData['dex'], speciesId: SpeciesId, level: number): BuddyData['dex'] {
|
||||
const existing = dex.find((d) => d.speciesId === speciesId)
|
||||
if (existing) {
|
||||
return dex.map((d) =>
|
||||
d.speciesId === speciesId
|
||||
? { ...d, caughtCount: d.caughtCount + 1, bestLevel: Math.max(d.bestLevel, level) }
|
||||
: d,
|
||||
)
|
||||
}
|
||||
return [...dex, { speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: level }]
|
||||
}
|
||||
46
packages/pokemon/src/core/evolution.ts
Normal file
46
packages/pokemon/src/core/evolution.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Creature, EvolutionResult, SpeciesId } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { getNextEvolution } from '../dex/evolution'
|
||||
|
||||
/**
|
||||
* Check if a creature meets evolution conditions.
|
||||
* Returns the evolution result if evolution should occur, null otherwise.
|
||||
*/
|
||||
export function checkEvolution(creature: Creature): EvolutionResult | null {
|
||||
if (creature.level > 100) return null
|
||||
|
||||
const nextEvo = getNextEvolution(creature.speciesId)
|
||||
if (!nextEvo) return null
|
||||
|
||||
// Check level-up conditions
|
||||
if (nextEvo.trigger === 'level_up' && nextEvo.minLevel != null && creature.level >= nextEvo.minLevel) {
|
||||
return {
|
||||
from: creature.speciesId,
|
||||
to: nextEvo.to,
|
||||
newLevel: creature.level,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute evolution on a creature.
|
||||
* Returns the updated creature with new species and recalculated data.
|
||||
*/
|
||||
export function evolve(creature: Creature, targetSpeciesId: SpeciesId): Creature {
|
||||
const newSpecies = getSpeciesData(targetSpeciesId)
|
||||
|
||||
return {
|
||||
...creature,
|
||||
speciesId: targetSpeciesId,
|
||||
friendship: Math.min(255, creature.friendship + 10), // Evolution boosts friendship
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a species can evolve further.
|
||||
*/
|
||||
export function canEvolveFurther(speciesId: SpeciesId): boolean {
|
||||
return getNextEvolution(speciesId) !== undefined
|
||||
}
|
||||
52
packages/pokemon/src/core/experience.ts
Normal file
52
packages/pokemon/src/core/experience.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Creature } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { levelFromXp, xpForLevel } from '../dex/xpTable'
|
||||
|
||||
/**
|
||||
* Award XP to a creature. Returns updated creature and whether level up occurred.
|
||||
*/
|
||||
export function awardXP(creature: Creature, amount: number): { creature: Creature; leveledUp: boolean; newLevel: number } {
|
||||
const species = getSpeciesData(creature.speciesId)
|
||||
if (creature.level >= 100) {
|
||||
return { creature, leveledUp: false, newLevel: creature.level }
|
||||
}
|
||||
|
||||
const newTotalXp = creature.totalXp + amount
|
||||
const oldLevel = creature.level
|
||||
const newLevel = Math.min(levelFromXp(newTotalXp, species.growthRate), 100)
|
||||
|
||||
// XP progress within current level
|
||||
const currentLevelXp = xpForLevel(newLevel, species.growthRate)
|
||||
const nextLevelXp = newLevel < 100 ? xpForLevel(newLevel + 1, species.growthRate) : currentLevelXp
|
||||
const xp = newTotalXp - currentLevelXp
|
||||
|
||||
const updated: Creature = {
|
||||
...creature,
|
||||
totalXp: newTotalXp,
|
||||
xp: Math.max(0, xp),
|
||||
level: newLevel,
|
||||
}
|
||||
|
||||
return {
|
||||
creature: updated,
|
||||
leveledUp: newLevel > oldLevel,
|
||||
newLevel,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XP needed to reach next level from current state.
|
||||
*/
|
||||
export function getXpProgress(creature: Creature): { current: number; needed: number; percentage: number } {
|
||||
const species = getSpeciesData(creature.speciesId)
|
||||
const currentLevelXp = xpForLevel(creature.level, species.growthRate)
|
||||
const nextLevelXp = creature.level < 100 ? xpForLevel(creature.level + 1, species.growthRate) : currentLevelXp
|
||||
const needed = nextLevelXp - currentLevelXp
|
||||
const current = creature.totalXp - currentLevelXp
|
||||
|
||||
return {
|
||||
current: Math.max(0, current),
|
||||
needed,
|
||||
percentage: needed > 0 ? Math.min(100, Math.floor((current / needed) * 100)) : 100,
|
||||
}
|
||||
}
|
||||
29
packages/pokemon/src/core/gender.ts
Normal file
29
packages/pokemon/src/core/gender.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Gender, SpeciesData } from '../types'
|
||||
|
||||
/**
|
||||
* Determine gender based on species gender ratio.
|
||||
* genderRate: -1 = genderless, 0 = always male, 1-7 = female chance = genderRate/8, 8 = always female
|
||||
*
|
||||
* Gen 3+ style: PID low byte (0-255) compared directly against genderRate * 32.
|
||||
* If value < genderRate * 32 → female, otherwise male.
|
||||
*/
|
||||
export function determineGender(speciesData: SpeciesData, seed: number): Gender {
|
||||
if (speciesData.genderRate === -1) return 'genderless'
|
||||
if (speciesData.genderRate === 0) return 'male'
|
||||
if (speciesData.genderRate === 8) return 'female'
|
||||
// Direct comparison: genderRate maps 0-8 to threshold 0-255 in steps of 32
|
||||
const threshold = speciesData.genderRate * 32
|
||||
return (seed % 256) < threshold ? 'female' : 'male'
|
||||
}
|
||||
|
||||
/** Get gender symbol for display */
|
||||
export function getGenderSymbol(gender: Gender): string {
|
||||
switch (gender) {
|
||||
case 'male':
|
||||
return '♂'
|
||||
case 'female':
|
||||
return '♀'
|
||||
case 'genderless':
|
||||
return ''
|
||||
}
|
||||
}
|
||||
131
packages/pokemon/src/core/spriteCache.ts
Normal file
131
packages/pokemon/src/core/spriteCache.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { SpeciesId, SpriteCache } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { getSpritesDir } from './storage'
|
||||
|
||||
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/claude-code-best/pokemonsay-newgenerations/master/pokemons'
|
||||
|
||||
/**
|
||||
* Build cow file name from dex number: NNN.cow
|
||||
*/
|
||||
function cowFileName(speciesId: SpeciesId): string {
|
||||
const { dexNumber } = getSpeciesData(speciesId)
|
||||
return `${String(dexNumber).padStart(3, '0')}.cow`
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sprite from local cache. Returns null if not cached.
|
||||
*/
|
||||
export function loadSprite(speciesId: SpeciesId): SpriteCache | null {
|
||||
const spritesDir = getSpritesDir()
|
||||
const filePath = join(spritesDir, `${speciesId}.json`)
|
||||
|
||||
if (!existsSync(filePath)) return null
|
||||
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf-8')
|
||||
return JSON.parse(raw) as SpriteCache
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch sprite from GitHub, convert from .cow format, and cache locally.
|
||||
* Returns the cached sprite data, or null if fetch failed.
|
||||
*/
|
||||
export async function fetchAndCacheSprite(speciesId: SpeciesId): Promise<SpriteCache | null> {
|
||||
// Try local cache first
|
||||
const cached = loadSprite(speciesId)
|
||||
if (cached) return cached
|
||||
|
||||
const fileName = cowFileName(speciesId)
|
||||
const url = `${GITHUB_RAW_BASE}/${fileName}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return null
|
||||
|
||||
const cowContent = await response.text()
|
||||
const lines = convertCowToLines(cowContent)
|
||||
if (lines.length === 0) return null
|
||||
|
||||
const sprite: SpriteCache = {
|
||||
speciesId,
|
||||
lines,
|
||||
width: Math.max(...lines.map(l => stripAnsi(l).length)),
|
||||
height: lines.length,
|
||||
fetchedAt: Date.now(),
|
||||
}
|
||||
|
||||
// Cache to disk
|
||||
const spritesDir = getSpritesDir()
|
||||
const filePath = join(spritesDir, `${speciesId}.json`)
|
||||
writeFileSync(filePath, JSON.stringify(sprite, null, 2))
|
||||
|
||||
return sprite
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert .cow file content to displayable lines.
|
||||
* Extracts heredoc content, converts Unicode escapes, strips thought lines.
|
||||
*/
|
||||
function convertCowToLines(cowContent: string): string[] {
|
||||
// Extract content between $the_cow =<<EOC; and EOC
|
||||
const startMarker = '$the_cow =<<EOC;'
|
||||
const endMarker = 'EOC'
|
||||
|
||||
const startIdx = cowContent.indexOf(startMarker)
|
||||
if (startIdx === -1) return []
|
||||
|
||||
const contentStart = startIdx + startMarker.length
|
||||
const endIdx = cowContent.indexOf(endMarker, contentStart)
|
||||
if (endIdx === -1) return []
|
||||
|
||||
let content = cowContent.slice(contentStart, endIdx)
|
||||
|
||||
// Convert \N{U+XXXX} to actual Unicode characters
|
||||
content = content.replace(/\\N\{U\+([0-9A-Fa-f]{4,6})\}/g, (_, hex) =>
|
||||
String.fromCodePoint(parseInt(hex, 16)),
|
||||
)
|
||||
|
||||
// Convert \e to actual escape character (for ANSI sequences)
|
||||
content = content.replace(/\\e/g, '\x1b')
|
||||
|
||||
// Split into lines
|
||||
let lines = content.split('\n')
|
||||
|
||||
// Strip leading/trailing empty lines
|
||||
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
|
||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
||||
|
||||
// Remove first 4 lines (cowsay thought bubble guide)
|
||||
if (lines.length > 4) {
|
||||
lines = lines.slice(4)
|
||||
}
|
||||
|
||||
// Trim trailing whitespace on each line (preserve leading for alignment)
|
||||
lines = lines.map(line => line.trimEnd())
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape sequences from a string.
|
||||
*/
|
||||
function stripAnsi(str: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get species name with dex number for display.
|
||||
*/
|
||||
export function getSpeciesDisplay(speciesId: SpeciesId): string {
|
||||
const data = getSpeciesData(speciesId)
|
||||
return `#${String(data.dexNumber).padStart(3, '0')} ${data.name}`
|
||||
}
|
||||
420
packages/pokemon/src/core/storage.ts
Normal file
420
packages/pokemon/src/core/storage.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import type { BuddyData, Creature, SpeciesId, PCBox, Bag } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { generateCreature } from './creature'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets'
|
||||
import { randomNature } from '../dex/nature'
|
||||
|
||||
const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json')
|
||||
const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')
|
||||
|
||||
const DEFAULT_BOX_COUNT = 8
|
||||
const BOX_SIZE = 30
|
||||
|
||||
/** Create empty boxes */
|
||||
function makeDefaultBoxes(): PCBox[] {
|
||||
return Array.from({ length: DEFAULT_BOX_COUNT }, (_, i) => ({
|
||||
name: `Box ${i + 1}`,
|
||||
slots: Array.from({ length: BOX_SIZE }, () => null),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load buddy data from disk. Returns default data if file doesn't exist.
|
||||
* Auto-migrates from any older version.
|
||||
*/
|
||||
export async function loadBuddyData(): Promise<BuddyData> {
|
||||
if (!existsSync(BUDDY_DATA_PATH)) {
|
||||
return getDefaultBuddyData()
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(BUDDY_DATA_PATH, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
return migrateToV2(data)
|
||||
} catch (e) {
|
||||
console.error('[buddy] Failed to load buddy data:', e)
|
||||
return getDefaultBuddyData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save buddy data to disk.
|
||||
*/
|
||||
export function saveBuddyData(data: BuddyData): void {
|
||||
const dir = join(BUDDY_DATA_PATH, '..')
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
writeFileSync(BUDDY_DATA_PATH, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default buddy data for new users.
|
||||
* Randomly assigns one of the three starters.
|
||||
*/
|
||||
export async function getDefaultBuddyData(): Promise<BuddyData> {
|
||||
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
|
||||
const randomStarter = starters[Math.floor(Math.random() * starters.length)]
|
||||
const creature = await generateCreature(randomStarter)
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
party: [creature.id, null, null, null, null, null],
|
||||
boxes: makeDefaultBoxes(),
|
||||
creatures: [creature],
|
||||
eggs: [],
|
||||
dex: [
|
||||
{
|
||||
speciesId: randomStarter,
|
||||
discoveredAt: Date.now(),
|
||||
caughtCount: 1,
|
||||
bestLevel: 1,
|
||||
},
|
||||
],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: 0,
|
||||
consecutiveDays: 0,
|
||||
lastActiveDate: new Date().toISOString().split('T')[0],
|
||||
totalEggsObtained: 0,
|
||||
totalEvolutions: 0,
|
||||
battlesWon: 0,
|
||||
battlesLost: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sprites cache directory path.
|
||||
*/
|
||||
export function getSpritesDir(): string {
|
||||
if (!existsSync(BUDDY_SPRITES_DIR)) {
|
||||
mkdirSync(BUDDY_SPRITES_DIR, { recursive: true })
|
||||
}
|
||||
return BUDDY_SPRITES_DIR
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from legacy buddy system.
|
||||
*/
|
||||
export async function migrateFromLegacy(
|
||||
storedCompanion: { name?: string; personality?: string; seed?: string; hatchedAt?: number; species?: string },
|
||||
): Promise<BuddyData> {
|
||||
const speciesMap: Record<string, SpeciesId> = {
|
||||
duck: 'bulbasaur', goose: 'squirtle', blob: 'bulbasaur',
|
||||
cat: 'charmander', dragon: 'pikachu', octopus: 'squirtle',
|
||||
owl: 'bulbasaur', penguin: 'squirtle', turtle: 'squirtle',
|
||||
snail: 'bulbasaur', ghost: 'pikachu', axolotl: 'squirtle',
|
||||
capybara: 'bulbasaur', cactus: 'charmander', robot: 'charmander',
|
||||
rabbit: 'pikachu', mushroom: 'bulbasaur', chonk: 'charmander',
|
||||
}
|
||||
|
||||
const mapped = storedCompanion.species ? speciesMap[storedCompanion.species] : undefined
|
||||
const starters: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle']
|
||||
const speciesId: SpeciesId = mapped ?? starters[Math.floor(Math.random() * starters.length)]!
|
||||
|
||||
const creature = await generateCreature(speciesId)
|
||||
creature.level = 5
|
||||
creature.totalXp = 100
|
||||
creature.friendship = 120
|
||||
|
||||
const speciesInfo = getSpeciesData(speciesId)
|
||||
if (storedCompanion.name && storedCompanion.name !== speciesInfo.name) {
|
||||
creature.nickname = storedCompanion.name
|
||||
}
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
party: [creature.id, null, null, null, null, null],
|
||||
boxes: makeDefaultBoxes(),
|
||||
creatures: [creature],
|
||||
eggs: [],
|
||||
dex: [{ speciesId, discoveredAt: Date.now(), caughtCount: 1, bestLevel: 5 }],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: 0,
|
||||
consecutiveDays: 1,
|
||||
lastActiveDate: new Date().toISOString().split('T')[0],
|
||||
totalEggsObtained: 0,
|
||||
totalEvolutions: 0,
|
||||
battlesWon: 0,
|
||||
battlesLost: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Migration ───
|
||||
|
||||
/** Migrate any version to v2 */
|
||||
async function migrateToV2(data: Record<string, unknown>): Promise<BuddyData> {
|
||||
const version = (data.version as number) ?? 1
|
||||
|
||||
if (version >= 2) return data as unknown as BuddyData
|
||||
|
||||
// v1 → v2
|
||||
const v1 = data as Record<string, unknown>
|
||||
const party = ensureParty(v1)
|
||||
|
||||
// Migrate creatures: add new fields
|
||||
const creatures = await migrateCreatures(v1.creatures as Creature[] ?? [])
|
||||
|
||||
// Build boxes — put non-party creatures into Box 1
|
||||
const partyIds = new Set(party.filter(Boolean))
|
||||
const nonPartyCreatures = creatures.filter(c => !partyIds.has(c.id))
|
||||
const boxes = makeDefaultBoxes()
|
||||
const box1Slots = [...boxes[0]!.slots]
|
||||
let boxIdx = 0
|
||||
for (const c of nonPartyCreatures) {
|
||||
if (boxIdx < BOX_SIZE) {
|
||||
box1Slots[boxIdx] = c.id
|
||||
boxIdx++
|
||||
}
|
||||
}
|
||||
boxes[0] = { name: 'Box 1', slots: box1Slots }
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
party,
|
||||
boxes,
|
||||
creatures,
|
||||
eggs: (v1.eggs as BuddyData['eggs']) ?? [],
|
||||
dex: (v1.dex as BuddyData['dex']) ?? [],
|
||||
bag: { items: [] },
|
||||
stats: {
|
||||
totalTurns: ((v1.stats as Record<string, number>)?.totalTurns) ?? 0,
|
||||
consecutiveDays: ((v1.stats as Record<string, number>)?.consecutiveDays) ?? 0,
|
||||
lastActiveDate: ((v1.stats as Record<string, string>)?.lastActiveDate) ?? new Date().toISOString().split('T')[0],
|
||||
totalEggsObtained: ((v1.stats as Record<string, number>)?.totalEggsObtained) ?? 0,
|
||||
totalEvolutions: ((v1.stats as Record<string, number>)?.totalEvolutions) ?? 0,
|
||||
battlesWon: 0,
|
||||
battlesLost: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure party field is valid */
|
||||
function ensureParty(data: Record<string, unknown>): (string | null)[] {
|
||||
const existing = data.party as (string | null)[] | undefined
|
||||
if (existing && existing.length === 6) return existing
|
||||
|
||||
const party: (string | null)[] = new Array(6).fill(null)
|
||||
const activeId = data.activeCreatureId ?? existing?.[0]
|
||||
if (activeId) party[0] = activeId as string
|
||||
|
||||
const creatures = data.creatures as Creature[] ?? []
|
||||
let slot = 1
|
||||
for (const c of creatures) {
|
||||
if (c.id === activeId) continue
|
||||
if (slot >= 6) break
|
||||
party[slot] = c.id
|
||||
slot++
|
||||
}
|
||||
return party
|
||||
}
|
||||
|
||||
/** Migrate creatures from v1 format to v2 */
|
||||
async function migrateCreatures(creatures: Creature[]): Promise<Creature[]> {
|
||||
const result: Creature[] = []
|
||||
for (const c of creatures) {
|
||||
// Already v2 (has nature field)
|
||||
if ('nature' in c && c.nature) {
|
||||
result.push(c)
|
||||
continue
|
||||
}
|
||||
|
||||
result.push({
|
||||
...c,
|
||||
nature: randomNature(),
|
||||
moves: await getDefaultMoveset(c.speciesId, c.level),
|
||||
ability: getDefaultAbility(c.speciesId),
|
||||
heldItem: null,
|
||||
pokeball: 'pokeball',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Daily / Turn stats ───
|
||||
|
||||
export function updateDailyStats(data: BuddyData): BuddyData {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const lastDate = data.stats.lastActiveDate
|
||||
|
||||
let consecutiveDays = data.stats.consecutiveDays
|
||||
if (lastDate !== today) {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0]
|
||||
consecutiveDays = lastDate === yesterdayStr ? consecutiveDays + 1 : 1
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
stats: { ...data.stats, consecutiveDays, lastActiveDate: today },
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementTurns(data: BuddyData): BuddyData {
|
||||
return {
|
||||
...data,
|
||||
stats: { ...data.stats, totalTurns: data.stats.totalTurns + 1 },
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Party operations ───
|
||||
|
||||
/** Compact party: move all non-null to front, pad with nulls to length 6 */
|
||||
export function compactParty(party: (string | null)[]): (string | null)[] {
|
||||
const filled = party.filter((id): id is string => id !== null)
|
||||
return [...filled, ...Array(6).fill(null)].slice(0, 6)
|
||||
}
|
||||
|
||||
export function addToParty(data: BuddyData, creatureId: string): { data: BuddyData; added: boolean } {
|
||||
const party = [...data.party]
|
||||
const emptyIdx = party.findIndex(p => p === null)
|
||||
if (emptyIdx === -1) return { data, added: false }
|
||||
party[emptyIdx] = creatureId
|
||||
return { data: { ...data, party: compactParty(party) }, added: true }
|
||||
}
|
||||
|
||||
export function removeFromParty(data: BuddyData, slotIndex: number): BuddyData {
|
||||
if (slotIndex < 0 || slotIndex >= 6) return data
|
||||
const party = [...data.party]
|
||||
// Don't remove if it would leave party empty
|
||||
const count = party.filter(Boolean).length
|
||||
if (count <= 1) return data
|
||||
party[slotIndex] = null
|
||||
return { ...data, party: compactParty(party) }
|
||||
}
|
||||
|
||||
export function swapPartySlots(data: BuddyData, indexA: number, indexB: number): BuddyData {
|
||||
const party = [...data.party]
|
||||
const a = party[indexA]
|
||||
const b = party[indexB]
|
||||
party[indexA] = b
|
||||
party[indexB] = a
|
||||
return { ...data, party: compactParty(party) }
|
||||
}
|
||||
|
||||
export function setActivePartyMember(data: BuddyData, creatureId: string): BuddyData {
|
||||
const party = [...data.party]
|
||||
const existingIdx = party.findIndex(id => id === creatureId)
|
||||
if (existingIdx === 0) return data
|
||||
if (existingIdx > 0) {
|
||||
party[0] = creatureId
|
||||
party[existingIdx] = data.party[0]
|
||||
} else {
|
||||
party[0] = creatureId
|
||||
}
|
||||
return { ...data, party: compactParty(party) }
|
||||
}
|
||||
|
||||
// ─── PC Box operations ───
|
||||
|
||||
export function depositToBox(data: BuddyData, creatureId: string): { data: BuddyData; deposited: boolean } {
|
||||
for (let b = 0; b < data.boxes.length; b++) {
|
||||
const slots = [...data.boxes[b]!.slots]
|
||||
const emptyIdx = slots.findIndex(s => s === null)
|
||||
if (emptyIdx !== -1) {
|
||||
slots[emptyIdx] = creatureId
|
||||
const boxes = [...data.boxes]
|
||||
boxes[b] = { ...data.boxes[b]!, slots }
|
||||
return { data: { ...data, boxes }, deposited: true }
|
||||
}
|
||||
}
|
||||
return { data, deposited: false }
|
||||
}
|
||||
|
||||
export function withdrawFromBox(data: BuddyData, creatureId: string): { data: BuddyData; withdrawn: boolean } {
|
||||
for (let b = 0; b < data.boxes.length; b++) {
|
||||
const slots = [...data.boxes[b]!.slots]
|
||||
const idx = slots.findIndex(s => s === creatureId)
|
||||
if (idx !== -1) {
|
||||
slots[idx] = null
|
||||
const boxes = [...data.boxes]
|
||||
boxes[b] = { ...data.boxes[b]!, slots }
|
||||
return { data: { ...data, boxes }, withdrawn: true }
|
||||
}
|
||||
}
|
||||
return { data, withdrawn: false }
|
||||
}
|
||||
|
||||
export function moveInBox(data: BuddyData, fromBox: number, fromSlot: number, toBox: number, toSlot: number): BuddyData {
|
||||
const boxes = data.boxes.map(b => ({ ...b, slots: [...b.slots] }))
|
||||
const creatureId = boxes[fromBox]?.slots[fromSlot]
|
||||
if (!creatureId) return data
|
||||
boxes[fromBox]!.slots[fromSlot] = null
|
||||
boxes[toBox]!.slots[toSlot] = creatureId
|
||||
return { ...data, boxes }
|
||||
}
|
||||
|
||||
export function renameBox(data: BuddyData, boxIndex: number, name: string): BuddyData {
|
||||
const boxes = [...data.boxes]
|
||||
boxes[boxIndex] = { ...boxes[boxIndex]!, name }
|
||||
return { ...data, boxes }
|
||||
}
|
||||
|
||||
export function findCreatureLocation(data: BuddyData, creatureId: string): { area: 'party' | 'box'; slot: number; boxIndex?: number } | null {
|
||||
const partyIdx = data.party.findIndex(id => id === creatureId)
|
||||
if (partyIdx !== -1) return { area: 'party', slot: partyIdx }
|
||||
|
||||
for (let b = 0; b < data.boxes.length; b++) {
|
||||
const slotIdx = data.boxes[b]!.slots.findIndex(id => id === creatureId)
|
||||
if (slotIdx !== -1) return { area: 'box', slot: slotIdx, boxIndex: b }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function releaseCreature(data: BuddyData, creatureId: string): BuddyData {
|
||||
// Remove from party
|
||||
let updated = removeFromParty(data, data.party.findIndex(id => id === creatureId))
|
||||
// Remove from boxes
|
||||
const withdrawResult = withdrawFromBox(updated, creatureId)
|
||||
if (withdrawResult.withdrawn) updated = withdrawResult.data
|
||||
// Remove from creatures array
|
||||
return {
|
||||
...updated,
|
||||
creatures: updated.creatures.filter(c => c.id !== creatureId),
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalCreatureCount(data: BuddyData): number {
|
||||
return data.creatures.length
|
||||
}
|
||||
|
||||
export function getAllCreatureIds(data: BuddyData): string[] {
|
||||
return data.creatures.map(c => c.id)
|
||||
}
|
||||
|
||||
// ─── Bag operations ───
|
||||
|
||||
export function addItemToBag(data: BuddyData, itemId: string, count = 1): BuddyData {
|
||||
const items = data.bag.items.map(e => ({ ...e }))
|
||||
const existing = items.find(e => e.id === itemId)
|
||||
if (existing) {
|
||||
existing.count += count
|
||||
} else {
|
||||
items.push({ id: itemId, count })
|
||||
}
|
||||
return { ...data, bag: { items } }
|
||||
}
|
||||
|
||||
export function removeItemFromBag(data: BuddyData, itemId: string, count = 1): { data: BuddyData; removed: boolean } {
|
||||
const items = data.bag.items.map(e => ({ ...e }))
|
||||
const existing = items.find(e => e.id === itemId)
|
||||
if (!existing || existing.count < count) return { data, removed: false }
|
||||
|
||||
existing.count -= count
|
||||
if (existing.count <= 0) {
|
||||
const idx = items.indexOf(existing)
|
||||
items.splice(idx, 1)
|
||||
}
|
||||
return { data: { ...data, bag: { items } }, removed: true }
|
||||
}
|
||||
|
||||
export function getItemCount(data: BuddyData, itemId: string): number {
|
||||
return data.bag.items.find(e => e.id === itemId)?.count ?? 0
|
||||
}
|
||||
31
packages/pokemon/src/dex/evMapping.ts
Normal file
31
packages/pokemon/src/dex/evMapping.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { StatName } from '../types'
|
||||
|
||||
/**
|
||||
* Default EV mapping: tool name → EV gains per use.
|
||||
* Tools not in this mapping get random 1-2 EV points.
|
||||
*/
|
||||
export const DEFAULT_EV_MAPPING: Record<string, Record<StatName, number>> = {
|
||||
Bash: { hp: 0, attack: 2, defense: 0, spAtk: 0, spDef: 0, speed: 1 },
|
||||
Edit: { hp: 0, attack: 0, defense: 1, spAtk: 2, spDef: 0, speed: 0 },
|
||||
Write: { hp: 0, attack: 0, defense: 0, spAtk: 3, spDef: 0, speed: 0 },
|
||||
Read: { hp: 1, attack: 0, defense: 2, spAtk: 0, spDef: 0, speed: 0 },
|
||||
Grep: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 },
|
||||
Glob: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 },
|
||||
Agent: { hp: 0, attack: 1, defense: 0, spAtk: 0, spDef: 0, speed: 2 },
|
||||
WebSearch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 },
|
||||
WebFetch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 },
|
||||
}
|
||||
|
||||
// EV limits (matching original Pokémon)
|
||||
export const MAX_EV_PER_STAT = 252
|
||||
export const MAX_EV_TOTAL = 510
|
||||
|
||||
// EV cooldown: same tool type only counts once per 30 seconds
|
||||
export const EV_COOLDOWN_MS = 30_000
|
||||
|
||||
/**
|
||||
* Get EV gains for a tool. Returns undefined if not mapped (→ random).
|
||||
*/
|
||||
export function getEVForTool(toolName: string): Record<StatName, number> | undefined {
|
||||
return DEFAULT_EV_MAPPING[toolName]
|
||||
}
|
||||
33
packages/pokemon/src/dex/evolution.ts
Normal file
33
packages/pokemon/src/dex/evolution.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import type { SpeciesId } from '../types'
|
||||
|
||||
export interface EvolutionChainStep {
|
||||
from: SpeciesId
|
||||
to: SpeciesId
|
||||
trigger: 'level_up' | 'item' | 'trade' | 'friendship'
|
||||
minLevel?: number
|
||||
}
|
||||
|
||||
/** Find the next evolution for a species, dynamically from Dex */
|
||||
export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | undefined {
|
||||
const dex = Dex.species.get(speciesId)
|
||||
if (!dex?.evos?.length) return undefined
|
||||
|
||||
// Take the first evolution target (most species have single evo path)
|
||||
const target = dex.evos[0]!.toLowerCase()
|
||||
|
||||
const targetDex = Dex.species.get(target)
|
||||
if (!targetDex?.exists) return undefined
|
||||
|
||||
const trigger = dex.evoType === 'trade' ? 'trade'
|
||||
: dex.evoType === 'useItem' ? 'item'
|
||||
: dex.evoType === 'levelFriendship' ? 'friendship'
|
||||
: 'level_up'
|
||||
|
||||
return {
|
||||
from: speciesId,
|
||||
to: target as SpeciesId,
|
||||
trigger,
|
||||
minLevel: targetDex.evoLevel ?? undefined,
|
||||
}
|
||||
}
|
||||
93
packages/pokemon/src/dex/learnsets.ts
Normal file
93
packages/pokemon/src/dex/learnsets.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import type { SpeciesId, MoveSlot } from '../types'
|
||||
import { EMPTY_MOVE } from '../types'
|
||||
|
||||
const GEN = 9
|
||||
|
||||
/** Get raw learnset data from Dex.data (synchronous, always available) */
|
||||
function getLearnsetData(speciesId: SpeciesId): Record<string, string[]> | null {
|
||||
const entry = Dex.data.Learnsets[speciesId]
|
||||
return entry?.learnset ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get level-up moves for a species.
|
||||
* Prefers the current gen (9L), falls back to the latest available gen.
|
||||
*/
|
||||
function getLevelUpMoves(learnset: Record<string, string[]>): { id: string; level: number }[] {
|
||||
// Collect level-up moves, preferring highest-gen data per move
|
||||
const moveMap = new Map<string, { id: string; level: number; gen: number }>()
|
||||
for (const [moveId, sources] of Object.entries(learnset)) {
|
||||
for (const src of sources) {
|
||||
const match = src.match(/^(\d+)L(\d+)$/)
|
||||
if (match) {
|
||||
const gen = parseInt(match[1]!)
|
||||
const level = parseInt(match[2]!)
|
||||
const existing = moveMap.get(moveId)
|
||||
if (!existing || gen > existing.gen) {
|
||||
moveMap.set(moveId, { id: moveId, level, gen })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(moveMap.values()).sort((a, b) => a.level - b.level)
|
||||
}
|
||||
|
||||
/** Get the default moveset for a species at a given level (last 4 level-up moves) */
|
||||
export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> {
|
||||
const learnset = getLearnsetData(speciesId)
|
||||
if (!learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE]
|
||||
|
||||
const levelUpMoves = getLevelUpMoves(learnset)
|
||||
const available = levelUpMoves.filter(m => m.level <= level).slice(-4)
|
||||
|
||||
const slots: MoveSlot[] = available.map(m => {
|
||||
const dexMove = Dex.moves.get(m.id)
|
||||
return { id: m.id, pp: dexMove?.pp ?? 10, maxPp: dexMove?.pp ?? 10 }
|
||||
})
|
||||
|
||||
while (slots.length < 4) slots.push(EMPTY_MOVE)
|
||||
return slots as [MoveSlot, MoveSlot, MoveSlot, MoveSlot]
|
||||
}
|
||||
|
||||
/** Get the default ability for a species (first non-hidden ability) */
|
||||
/** Get the first non-hidden ability for a species */
|
||||
export function getDefaultAbility(speciesId: SpeciesId): string {
|
||||
const species = Dex.species.get(speciesId)
|
||||
return species?.abilities?.['0']?.toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
/** Get all available abilities for a species (including hidden) */
|
||||
export function getAbilities(speciesId: SpeciesId): { normal: string[]; hidden: string | null } {
|
||||
const species = Dex.species.get(speciesId)
|
||||
if (!species?.exists) return { normal: [], hidden: null }
|
||||
const normal: string[] = []
|
||||
if (species.abilities['0']) normal.push(species.abilities['0'].toLowerCase())
|
||||
if (species.abilities['1']) normal.push(species.abilities['1'].toLowerCase())
|
||||
const hidden = species.abilities['H']?.toLowerCase() ?? null
|
||||
return { normal, hidden }
|
||||
}
|
||||
|
||||
/** Randomly select an ability for a species. Hidden ability has ~5% chance. */
|
||||
export function randomAbility(speciesId: SpeciesId): string {
|
||||
const { normal, hidden } = getAbilities(speciesId)
|
||||
if (normal.length === 0 && !hidden) return ''
|
||||
// 5% chance for hidden ability
|
||||
if (hidden && Math.random() < 0.05) return hidden
|
||||
// Otherwise pick from normal abilities
|
||||
return normal[Math.floor(Math.random() * normal.length)] ?? hidden ?? ''
|
||||
}
|
||||
|
||||
/** Get newly learnable moves when leveling up */
|
||||
export async function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): Promise<{ id: string; name: string }[]> {
|
||||
const learnset = getLearnsetData(speciesId)
|
||||
if (!learnset) return []
|
||||
|
||||
const levelUpMoves = getLevelUpMoves(learnset)
|
||||
return levelUpMoves
|
||||
.filter(m => m.level > oldLevel && m.level <= newLevel)
|
||||
.map(m => {
|
||||
const dexMove = Dex.moves.get(m.id)
|
||||
return { id: m.id, name: dexMove?.name ?? m.id }
|
||||
})
|
||||
}
|
||||
70
packages/pokemon/src/dex/names.ts
Normal file
70
packages/pokemon/src/dex/names.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { SpeciesId } from '../types'
|
||||
|
||||
/** Curated English names (Dex provides default names for all species) */
|
||||
export const SPECIES_NAMES: Partial<Record<string, string>> = {
|
||||
bulbasaur: 'Bulbasaur',
|
||||
ivysaur: 'Ivysaur',
|
||||
venusaur: 'Venusaur',
|
||||
charmander: 'Charmander',
|
||||
charmeleon: 'Charmeleon',
|
||||
charizard: 'Charizard',
|
||||
squirtle: 'Squirtle',
|
||||
wartortle: 'Wartortle',
|
||||
blastoise: 'Blastoise',
|
||||
pikachu: 'Pikachu',
|
||||
}
|
||||
|
||||
/** Curated multilingual names (falls back to English from Dex) */
|
||||
const CURATED_I18N: Partial<Record<string, Record<string, string>>> = {
|
||||
bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' },
|
||||
ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' },
|
||||
venusaur: { en: 'Venusaur', ja: 'フシギバナ', zh: '妙蛙花' },
|
||||
charmander: { en: 'Charmander', ja: 'ヒトカゲ', zh: '小火龙' },
|
||||
charmeleon: { en: 'Charmeleon', ja: 'リザード', zh: '火恐龙' },
|
||||
charizard: { en: 'Charizard', ja: 'リザードン', zh: '喷火龙' },
|
||||
squirtle: { en: 'Squirtle', ja: 'ゼニガメ', zh: '杰尼龟' },
|
||||
wartortle: { en: 'Wartortle', ja: 'カメール', zh: '卡咪龟' },
|
||||
blastoise: { en: 'Blastoise', ja: 'カメックス', zh: '水箭龟' },
|
||||
pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' },
|
||||
}
|
||||
|
||||
// Try loading auto-generated multilingual data (from fetch-species-names.ts)
|
||||
let generatedI18n: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require('./species-names.ts') as { SPECIES_I18N_DATA?: Record<string, { en: string; ja: string; zh: string }> }
|
||||
if (mod.SPECIES_I18N_DATA) {
|
||||
generatedI18n = mod.SPECIES_I18N_DATA
|
||||
}
|
||||
} catch {
|
||||
// species-names.ts not generated yet — use curated fallback
|
||||
}
|
||||
|
||||
/** Get multilingual name for a species. Falls back to Dex English name. */
|
||||
export function getSpeciesI18nName(speciesId: SpeciesId, lang: string): string {
|
||||
const generated = generatedI18n[speciesId]
|
||||
if (generated) return generated[lang] ?? generated.en ?? speciesId
|
||||
const curated = CURATED_I18N[speciesId]
|
||||
if (curated) return curated[lang] ?? curated.en ?? speciesId
|
||||
return speciesId
|
||||
}
|
||||
|
||||
/** All available multilingual names (curated + auto-generated) */
|
||||
export const SPECIES_I18N: Partial<Record<string, Record<string, string>>> = {
|
||||
...CURATED_I18N,
|
||||
...generatedI18n,
|
||||
}
|
||||
|
||||
/** Curated personality descriptions (falls back to empty string) */
|
||||
export const SPECIES_PERSONALITY: Partial<Record<string, string>> = {
|
||||
bulbasaur: 'Calm and collected, a reliable partner',
|
||||
ivysaur: 'Steady growth, patient and resilient',
|
||||
venusaur: 'Majestic and powerful, a natural leader',
|
||||
charmander: 'Energetic and curious, loves adventure',
|
||||
charmeleon: 'Fierce and determined, always pushing forward',
|
||||
charizard: 'Proud and strong-willed, a formidable ally',
|
||||
squirtle: 'Cheerful and playful, adapts easily',
|
||||
wartortle: 'Loyal and protective, wise beyond years',
|
||||
blastoise: 'Steadfast and powerful, a defensive fortress',
|
||||
pikachu: 'Friendly and energetic, always by your side',
|
||||
}
|
||||
39
packages/pokemon/src/dex/nature.ts
Normal file
39
packages/pokemon/src/dex/nature.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import { FROM_DEX_STAT } from './pkmn'
|
||||
import type { NatureName, NatureEffect, NatureStat } from '../types'
|
||||
|
||||
// All 25 canonical nature names (Dex.natures is not iterable, so we list them)
|
||||
const NATURE_IDS: NatureName[] = [
|
||||
'hardy', 'lonely', 'brave', 'adamant', 'naughty',
|
||||
'bold', 'docile', 'relaxed', 'impish', 'lax',
|
||||
'timid', 'hasty', 'serious', 'jolly', 'naive',
|
||||
'modest', 'mild', 'quiet', 'bashful', 'rash',
|
||||
'calm', 'gentle', 'sassy', 'careful', 'quirky',
|
||||
]
|
||||
|
||||
/** Get all nature names */
|
||||
export function getAllNatureNames(): NatureName[] {
|
||||
return NATURE_IDS.filter(name => Dex.natures.get(name)?.exists)
|
||||
}
|
||||
|
||||
/** Randomly assign a nature */
|
||||
export function randomNature(): NatureName {
|
||||
const names = getAllNatureNames()
|
||||
return names[Math.floor(Math.random() * names.length)]!
|
||||
}
|
||||
|
||||
/** Map Dex stat abbreviation (atk, spa, spe, etc.) to our NatureStat format */
|
||||
function mapDexStat(stat: string | undefined): NatureStat | null {
|
||||
if (!stat) return null
|
||||
return (FROM_DEX_STAT[stat] as NatureStat) ?? null
|
||||
}
|
||||
|
||||
/** Get nature effect (plus/minus stat, or null for neutral) — delegates to Dex.natures */
|
||||
export function getNatureEffect(nature: NatureName): NatureEffect {
|
||||
const n = Dex.natures.get(nature)
|
||||
if (!n?.exists) return { plus: null, minus: null }
|
||||
return {
|
||||
plus: mapDexStat(n.plus),
|
||||
minus: mapDexStat(n.minus),
|
||||
}
|
||||
}
|
||||
39
packages/pokemon/src/dex/pkmn.ts
Normal file
39
packages/pokemon/src/dex/pkmn.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import { Generations } from '@pkmn/data'
|
||||
import type { StatName } from '../types'
|
||||
|
||||
// Singleton Gen 9 data source
|
||||
const gens = new Generations(Dex as unknown as import('@pkmn/data').Dex)
|
||||
export const gen = gens.get(9)
|
||||
|
||||
// Stat name mapping: @pkmn/sim → our StatName
|
||||
export const FROM_DEX_STAT: Record<string, StatName> = {
|
||||
hp: 'hp', atk: 'attack', def: 'defense',
|
||||
spa: 'spAtk', spd: 'spDef', spe: 'speed',
|
||||
}
|
||||
|
||||
// Stat name mapping: our StatName → @pkmn/sim abbreviation
|
||||
export const TO_DEX_STAT: Record<StatName, string> = {
|
||||
hp: 'hp', attack: 'atk', defense: 'def',
|
||||
spAtk: 'spa', spDef: 'spd', speed: 'spe',
|
||||
}
|
||||
|
||||
/** Query species from Dex (uses Dex directly for full coverage) */
|
||||
export function getSpecies(id: string) {
|
||||
return Dex.species.get(id)
|
||||
}
|
||||
|
||||
/** Map Dex baseStats to our StatName format */
|
||||
export function mapBaseStats(dexStats: { hp: number; atk: number; def: number; spa: number; spd: number; spe: number }): Record<StatName, number> {
|
||||
const result = {} as Record<StatName, number>
|
||||
for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) {
|
||||
result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get gender rate from Dex genderRatio (M/F ratio → our genderRate 0-8) */
|
||||
export function mapGenderRatio(genderRatio?: { M: number; F: number } | string): number {
|
||||
if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless
|
||||
return Math.round(genderRatio.F * 8)
|
||||
}
|
||||
1093
packages/pokemon/src/dex/pokedex-data.ts
Normal file
1093
packages/pokemon/src/dex/pokedex-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
169
packages/pokemon/src/dex/species.ts
Normal file
169
packages/pokemon/src/dex/species.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import type { SpeciesData, SpeciesId, GrowthRate } from '../types'
|
||||
import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn'
|
||||
import { getNextEvolution } from './evolution'
|
||||
import { SPECIES_PERSONALITY } from './names'
|
||||
import { getGrowthRate, getCaptureRate, getBaseHappiness } from './pokedex-data'
|
||||
|
||||
// ─── Dynamic species list from @pkmn/sim Dex ───
|
||||
|
||||
const _rawSpecies = Dex.data.Species as Record<string, { num: number; forme?: string }>
|
||||
const _ids: string[] = []
|
||||
for (const [id, s] of Object.entries(_rawSpecies)) {
|
||||
if (s.num > 0 && Number.isInteger(s.num) && !s.forme) {
|
||||
_ids.push(id)
|
||||
}
|
||||
}
|
||||
_ids.sort((a, b) => (_rawSpecies[a]?.num ?? 9999) - (_rawSpecies[b]?.num ?? 9999))
|
||||
|
||||
/** All base species IDs from @pkmn/sim Dex (sorted by dex number) */
|
||||
export const ALL_SPECIES_IDS: SpeciesId[] = _ids
|
||||
|
||||
// ─── Supplementary data (fields not provided by @pkmn/sim) ───
|
||||
// Only curated entries for species with known data; defaults used for others.
|
||||
|
||||
interface SupplementEntry {
|
||||
flavorText: string
|
||||
}
|
||||
|
||||
const SUPPLEMENT: Partial<Record<string, SupplementEntry>> = {
|
||||
bulbasaur: {
|
||||
flavorText: 'A strange seed was planted on its back at birth. The plant sprouts and grows with this Pokémon.',
|
||||
},
|
||||
ivysaur: {
|
||||
flavorText: 'When the bulb on its back grows large, it appears to lose the ability to stand on its hind legs.',
|
||||
},
|
||||
venusaur: {
|
||||
flavorText: 'The plant blooms when it is absorbing solar energy. It stays on the move to seek sunlight.',
|
||||
},
|
||||
charmander: {
|
||||
flavorText: 'Obviously prefers hot places. When it rains, steam is said to spout from the tip of its tail.',
|
||||
},
|
||||
charmeleon: {
|
||||
flavorText: 'Tough fights could excite this Pokémon. When excited, it may blow out bluish-white flames.',
|
||||
},
|
||||
charizard: {
|
||||
flavorText: 'Spits fire that is hot enough to melt boulders. Known to cause forest fires unintentionally.',
|
||||
},
|
||||
squirtle: {
|
||||
flavorText: 'After birth, its back swells and hardens into a shell. Powerfully sprays foam from its mouth.',
|
||||
},
|
||||
wartortle: {
|
||||
flavorText: 'Often hides in water to stalk unwary prey. For swimming fast, it moves its ears to maintain balance.',
|
||||
},
|
||||
blastoise: {
|
||||
flavorText: 'It crushes its foe under its heavy body to cause fainting. In a pinch, it will withdraw inside its shell.',
|
||||
},
|
||||
pikachu: {
|
||||
flavorText: 'When several of these Pokémon gather, their electricity can build and cause lightning storms.',
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Evolution chain builder (from Dex evos field) ───
|
||||
|
||||
function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'] {
|
||||
const evo = getNextEvolution(speciesId)
|
||||
if (!evo) return undefined
|
||||
return [{ trigger: evo.trigger, level: evo.minLevel, into: evo.to }]
|
||||
}
|
||||
|
||||
// ─── Build SpeciesData from Dex + supplement ───
|
||||
|
||||
function buildSpeciesData(id: SpeciesId): SpeciesData {
|
||||
const dex = getSpecies(id)
|
||||
const personality = SPECIES_PERSONALITY[id]
|
||||
|
||||
if (!dex) {
|
||||
throw new Error(`Species ${id} not found in @pkmn/sim Dex`)
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: dex.name,
|
||||
names: { en: dex.name },
|
||||
dexNumber: dex.num,
|
||||
genderRate: mapGenderRatio(dex.genderRatio as { M: number; F: number } | undefined),
|
||||
baseStats: mapBaseStats(dex.baseStats),
|
||||
types: dex.types.map((t: string) => t.toLowerCase()) as [string, string?],
|
||||
baseHappiness: getBaseHappiness(id),
|
||||
growthRate: getGrowthRate(id) as GrowthRate,
|
||||
captureRate: getCaptureRate(id),
|
||||
personality: personality ?? '',
|
||||
evolutionChain: buildEvolutionChain(id),
|
||||
shinyChance: 1 / 4096,
|
||||
flavorText: SUPPLEMENT[id]?.flavorText ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── In-memory cache (built once, immutable) ───
|
||||
|
||||
const speciesCache = new Map<SpeciesId, SpeciesData>()
|
||||
|
||||
function getCached(id: SpeciesId): SpeciesData {
|
||||
let data = speciesCache.get(id)
|
||||
if (!data) {
|
||||
data = buildSpeciesData(id)
|
||||
speciesCache.set(id, data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ─── Sync getters (used by all consumers) ───
|
||||
|
||||
/** Get species data by ID. */
|
||||
export function getSpeciesData(id: SpeciesId): SpeciesData {
|
||||
return getCached(id)
|
||||
}
|
||||
|
||||
/** Get all species data as a Record. */
|
||||
export function getAllSpeciesData(): Record<SpeciesId, SpeciesData> {
|
||||
const result = {} as Record<SpeciesId, SpeciesData>
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
result[id] = getCached(id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous getter that returns the full map.
|
||||
* @deprecated Use getSpeciesData / getAllSpeciesData
|
||||
*/
|
||||
export const SPECIES_DATA: Record<SpeciesId, SpeciesData> = new Proxy({} as Record<SpeciesId, SpeciesData>, {
|
||||
get(_, prop: string) {
|
||||
return getSpeciesData(prop as SpeciesId)
|
||||
},
|
||||
ownKeys() {
|
||||
return ALL_SPECIES_IDS as unknown as string[]
|
||||
},
|
||||
has(_, prop) {
|
||||
return ALL_SPECIES_IDS.includes(prop as SpeciesId)
|
||||
},
|
||||
getOwnPropertyDescriptor(_, prop) {
|
||||
if (ALL_SPECIES_IDS.includes(prop as SpeciesId)) {
|
||||
return { configurable: true, enumerable: true, value: getSpeciesData(prop as SpeciesId) }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
/** No-op — data is now built-in from @pkmn/sim */
|
||||
export function ensureSpeciesData(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** No-op — data is now built-in from @pkmn/sim */
|
||||
export async function refreshAllSpeciesData(): Promise<void> {
|
||||
// Clear cache to force rebuild
|
||||
speciesCache.clear()
|
||||
}
|
||||
|
||||
// ─── Dex number mapping (dynamic) ───
|
||||
|
||||
export const DEX_TO_SPECIES: Record<number, SpeciesId> = (() => {
|
||||
const map: Record<number, SpeciesId> = {}
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
const s = _rawSpecies[id]
|
||||
if (s) map[s.num] = id
|
||||
}
|
||||
return map
|
||||
})()
|
||||
81
packages/pokemon/src/dex/xpTable.ts
Normal file
81
packages/pokemon/src/dex/xpTable.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { GrowthRate } from '../types'
|
||||
|
||||
/**
|
||||
* Calculate total XP required to reach a given level for a growth rate type.
|
||||
* Follows original Pokémon XP curve formulas.
|
||||
*/
|
||||
export function xpForLevel(level: number, growthRate: GrowthRate): number {
|
||||
if (level <= 1) return 0
|
||||
const n = level
|
||||
switch (growthRate) {
|
||||
case 'erratic':
|
||||
return xpErratic(n)
|
||||
case 'fast':
|
||||
return Math.floor((n * n * n * 4) / 5)
|
||||
case 'medium-fast':
|
||||
return n * n * n
|
||||
case 'medium-slow':
|
||||
return Math.floor((6 / 5) * n * n * n - 15 * n * n + 100 * n - 140)
|
||||
case 'slow':
|
||||
return Math.floor((5 * n * n * n) / 4)
|
||||
case 'fluctuating':
|
||||
return xpFluctuating(n)
|
||||
default:
|
||||
return n * n * n
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate level from total XP for a given growth rate.
|
||||
*/
|
||||
export function levelFromXp(totalXp: number, growthRate: GrowthRate): number {
|
||||
// Binary search for level
|
||||
let lo = 1
|
||||
let hi = 100
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2)
|
||||
if (xpForLevel(mid, growthRate) <= totalXp) {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
return Math.min(lo, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* XP needed to go from current level to next level.
|
||||
*/
|
||||
export function xpToNextLevel(currentLevel: number, totalXp: number, growthRate: GrowthRate): number {
|
||||
if (currentLevel >= 100) return 0
|
||||
const nextLevelXp = xpForLevel(currentLevel + 1, growthRate)
|
||||
return nextLevelXp - totalXp
|
||||
}
|
||||
|
||||
// Erratic growth rate (complex piecewise)
|
||||
function xpErratic(n: number): number {
|
||||
if (n <= 1) return 0
|
||||
if (n <= 50) {
|
||||
return Math.floor((n * n * n * (100 - n)) / 50)
|
||||
}
|
||||
if (n <= 68) {
|
||||
return Math.floor((n * n * n * (150 - n)) / 100)
|
||||
}
|
||||
if (n <= 98) {
|
||||
return Math.floor((n * n * n * Math.floor((1911 - 10 * n) / 3)) / 500)
|
||||
}
|
||||
// n 99-100
|
||||
return Math.floor((n * n * n * (160 - n)) / 100)
|
||||
}
|
||||
|
||||
// Fluctuating growth rate (complex piecewise)
|
||||
function xpFluctuating(n: number): number {
|
||||
if (n <= 1) return 0
|
||||
if (n <= 15) {
|
||||
return Math.floor((n * n * n * (Math.floor((n + 1) / 3) + 24)) / 50)
|
||||
}
|
||||
if (n <= 36) {
|
||||
return Math.floor((n * n * n * (n + 14)) / 50)
|
||||
}
|
||||
return Math.floor((n * n * n * (Math.floor(n / 2) + 32)) / 50)
|
||||
}
|
||||
88
packages/pokemon/src/index.ts
Normal file
88
packages/pokemon/src/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Types
|
||||
export type {
|
||||
StatName,
|
||||
NatureName,
|
||||
NatureStat,
|
||||
NatureEffect,
|
||||
MoveSlot,
|
||||
ItemId,
|
||||
PCBox,
|
||||
BagEntry,
|
||||
Bag,
|
||||
SpeciesId,
|
||||
Gender,
|
||||
EvolutionTrigger,
|
||||
EvolutionCondition,
|
||||
GrowthRate,
|
||||
SpeciesData,
|
||||
Creature,
|
||||
Egg,
|
||||
DexEntry,
|
||||
BuddyData,
|
||||
StatsResult,
|
||||
EvolutionResult,
|
||||
SpriteCache,
|
||||
AnimMode,
|
||||
} from './types'
|
||||
export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS, EMPTY_MOVE } from './types'
|
||||
|
||||
// Data
|
||||
export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './dex/species'
|
||||
export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './dex/evMapping'
|
||||
export { xpForLevel, levelFromXp, xpToNextLevel } from './dex/xpTable'
|
||||
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './dex/names'
|
||||
export { getAllNatureNames, randomNature, getNatureEffect } from './dex/nature'
|
||||
export { getNextEvolution } from './dex/evolution'
|
||||
export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './dex/learnsets'
|
||||
export { FROM_DEX_STAT, TO_DEX_STAT } from './dex/pkmn'
|
||||
|
||||
// Battle
|
||||
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './battle/types'
|
||||
export { createBattle, executeTurn, executeSwitch, type BattleInit } from './battle/engine'
|
||||
export { settleBattle, applyMoveLearn, applyEvolution } from './battle/settlement'
|
||||
export { chooseAIMove } from './battle/ai'
|
||||
|
||||
// Core
|
||||
export { generateCreature, calculateStats, getCreatureName, recalculateLevel, getActiveCreature, getTotalEV } from './core/creature'
|
||||
export { determineGender, getGenderSymbol } from './core/gender'
|
||||
export { awardXP, getXpProgress } from './core/experience'
|
||||
export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort'
|
||||
export { checkEvolution, evolve, canEvolveFurther } from './core/evolution'
|
||||
export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg'
|
||||
export {
|
||||
loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy,
|
||||
updateDailyStats, incrementTurns,
|
||||
addToParty, removeFromParty, swapPartySlots, setActivePartyMember, compactParty,
|
||||
depositToBox, withdrawFromBox, moveInBox, renameBox,
|
||||
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
|
||||
addItemToBag, removeItemFromBag, getItemCount,
|
||||
} from './core/storage'
|
||||
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
|
||||
|
||||
// Sprites
|
||||
export { renderAnimatedSprite, shrinkSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer'
|
||||
export { getFallbackSprite } from './sprites/fallback'
|
||||
export { SpeciesPicker } from './ui/SpeciesPicker'
|
||||
|
||||
// UI Components
|
||||
export { CompanionCard } from './ui/CompanionCard'
|
||||
export { PokedexView } from './ui/PokedexView'
|
||||
export { EggView } from './ui/EggView'
|
||||
export { EvolutionAnim } from './ui/EvolutionAnim'
|
||||
export { StatBar } from './ui/StatBar'
|
||||
export { SpeciesDetail } from './ui/SpeciesDetail'
|
||||
export { SpriteAnimator } from './ui/SpriteAnimator'
|
||||
export { BattleSprite } from './ui/BattleSprite'
|
||||
export { BattleField } from './ui/BattleField'
|
||||
export { BattleConfigPanel } from './ui/BattleConfigPanel'
|
||||
export { BattleScene } from './ui/BattleScene'
|
||||
export type { MenuPhase } from './ui/BattleScene'
|
||||
export { HpCard } from './ui/HpCard'
|
||||
export { BattleMenu } from './ui/BattleMenu'
|
||||
export { BattleLogPanel } from './ui/BattleLogPanel'
|
||||
export { SwitchPanel } from './ui/SwitchPanel'
|
||||
export { ItemPanel } from './ui/ItemPanel'
|
||||
export { BattleResultPanel } from './ui/BattleResultPanel'
|
||||
export { MoveLearnPanel } from './ui/MoveLearnPanel'
|
||||
export { BattleFlow } from './ui/BattleFlow'
|
||||
export type { BattleFlowHandle } from './ui/BattleFlow'
|
||||
94
packages/pokemon/src/sprites/fallback.ts
Normal file
94
packages/pokemon/src/sprites/fallback.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { SpeciesId } from '../types'
|
||||
|
||||
/**
|
||||
* Fallback ASCII art for when sprites can't be fetched.
|
||||
* Curated sprites for original 10 species; generic fallback for all others.
|
||||
*/
|
||||
const FALLBACK_SPRITES: Partial<Record<string, string[]>> = {
|
||||
bulbasaur: [
|
||||
' _,,--.,,_ ',
|
||||
' ,\' `, ',
|
||||
' ; o o ; ',
|
||||
' ; ~~~~~~~~ ; ',
|
||||
' `--,,__,,--\' ',
|
||||
],
|
||||
ivysaur: [
|
||||
' _,--..,_ ',
|
||||
' ,\' (o)(o) `, ',
|
||||
' ; ~~~~~~ ; ',
|
||||
' ; \\====/ ; ',
|
||||
' `--,,__,,--\' ',
|
||||
],
|
||||
venusaur: [
|
||||
' _,,,---.,,_ ',
|
||||
' ,\' (o) (o) `, ',
|
||||
' ; ~~~~~~~~ ; ',
|
||||
' ; /========\\ ; ',
|
||||
' `-,,,____,,,-\' ',
|
||||
],
|
||||
charmander: [
|
||||
' ,^., ',
|
||||
' ( o o) ',
|
||||
' / ~~~ \\ ',
|
||||
' / \\___/ \\ ',
|
||||
' ^^^ ^^^ ',
|
||||
],
|
||||
charmeleon: [
|
||||
' ,--^. ',
|
||||
' ( o o) ',
|
||||
' / ~~~~~ \\ ',
|
||||
' / \\___/ \\ ',
|
||||
' ^^ ^^ ',
|
||||
],
|
||||
charizard: [
|
||||
' /\\ /\\ ',
|
||||
' / \\/ \\ ',
|
||||
' | o o | ',
|
||||
' | ~~~~~~ | ',
|
||||
' \\______/ ',
|
||||
],
|
||||
squirtle: [
|
||||
' _____ ',
|
||||
' ,\' `, ',
|
||||
' ; o o ; ',
|
||||
' ; ~~~~~~~ ; ',
|
||||
' `-.,__,\' ',
|
||||
],
|
||||
wartortle: [
|
||||
' _______ ',
|
||||
' ,\' `, ',
|
||||
' ; o o ; ',
|
||||
' ; ~~~~~~~~ ; ',
|
||||
' `-.,__,\' ',
|
||||
],
|
||||
blastoise: [
|
||||
' .________. ',
|
||||
' | o o | ',
|
||||
' | ~~~~~~~~ | ',
|
||||
' | [====] | ',
|
||||
' `-.,__,\' ',
|
||||
],
|
||||
pikachu: [
|
||||
' /\\ /\\ ',
|
||||
' ( o o ) ',
|
||||
' \\ ~~~ / ',
|
||||
' /`-...-\'\\ ',
|
||||
' ^^ ^^ ',
|
||||
],
|
||||
}
|
||||
|
||||
/** Generic fallback sprite for species without curated ASCII art */
|
||||
const GENERIC_SPRITE: string[] = [
|
||||
' .---. ',
|
||||
' / o o \\ ',
|
||||
' | --- | ',
|
||||
' \\ / ',
|
||||
' `---\' ',
|
||||
]
|
||||
|
||||
/**
|
||||
* Get fallback ASCII sprite lines for a species.
|
||||
*/
|
||||
export function getFallbackSprite(speciesId: SpeciesId): string[] {
|
||||
return FALLBACK_SPRITES[speciesId] ?? GENERIC_SPRITE
|
||||
}
|
||||
4
packages/pokemon/src/sprites/index.ts
Normal file
4
packages/pokemon/src/sprites/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from './renderer'
|
||||
export type { AnimMode } from '../types'
|
||||
export { getFallbackSprite } from './fallback'
|
||||
export { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
|
||||
358
packages/pokemon/src/sprites/renderer.ts
Normal file
358
packages/pokemon/src/sprites/renderer.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import type { AnimMode } from '../types'
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Pixel Grid Model — ANSI-safe animation foundation
|
||||
// ═══════════════════════════════════════════════════════
|
||||
//
|
||||
// Every sprite line is parsed into a Pixel[] row:
|
||||
// Pixel = { char: '▄', style: '\x1b[33m' }
|
||||
//
|
||||
// style = full accumulated ANSI state at that position,
|
||||
// so any transform (shift, reverse, slice) just moves Pixels
|
||||
// around without ever touching raw ANSI strings.
|
||||
//
|
||||
// After transform, render each row back: reset → style → char → reset
|
||||
|
||||
export interface Pixel {
|
||||
char: string
|
||||
/** Full ANSI state needed to render this pixel */
|
||||
style: string
|
||||
}
|
||||
|
||||
const EMPTY_PIXEL: Pixel = { char: ' ', style: '' }
|
||||
const EMPTY_ROW: Pixel[] = []
|
||||
export { EMPTY_PIXEL, EMPTY_ROW }
|
||||
|
||||
// ─── Parse / Render ───────────────────────────────────
|
||||
|
||||
/** Parse a raw ANSI string line into a Pixel row */
|
||||
function parseLine(line: string): Pixel[] {
|
||||
const pixels: Pixel[] = []
|
||||
let style = ''
|
||||
let i = 0
|
||||
while (i < line.length) {
|
||||
if (line[i] === '\x1b') {
|
||||
// Collect full ANSI escape sequence: \x1b[ ... m
|
||||
const start = i
|
||||
i++ // skip \x1b
|
||||
if (i < line.length && line[i] === '[') {
|
||||
i++ // skip [
|
||||
while (i < line.length && line[i] !== 'm') i++
|
||||
if (i < line.length) i++ // skip m
|
||||
}
|
||||
style += line.slice(start, i)
|
||||
} else {
|
||||
// Visible character (handle multi-byte Unicode)
|
||||
const cp = line.codePointAt(i)!
|
||||
const ch = String.fromCodePoint(cp)
|
||||
pixels.push({ char: ch, style })
|
||||
i += ch.length
|
||||
}
|
||||
}
|
||||
return pixels
|
||||
}
|
||||
|
||||
/** Render a Pixel row back to an ANSI string */
|
||||
function renderRow(pixels: Pixel[]): string {
|
||||
if (pixels.length === 0) return ''
|
||||
let out = ''
|
||||
let lastStyle: string | null = null
|
||||
for (const p of pixels) {
|
||||
if (p.style !== lastStyle) {
|
||||
out += '\x1b[0m' + p.style // reset then apply
|
||||
lastStyle = p.style
|
||||
}
|
||||
out += p.char
|
||||
}
|
||||
out += '\x1b[0m' // final reset
|
||||
return out
|
||||
}
|
||||
|
||||
export function parseSprite(lines: string[]): Pixel[][] {
|
||||
return lines.map(parseLine)
|
||||
}
|
||||
|
||||
export function renderSprite(grid: Pixel[][]): string[] {
|
||||
return grid.map(renderRow)
|
||||
}
|
||||
|
||||
// ─── Grid Transforms ──────────────────────────────────
|
||||
// All transforms operate on Pixel[][], never touch raw strings.
|
||||
|
||||
/** Horizontal shift — positive = right, negative = left */
|
||||
function shiftH(grid: Pixel[][], n: number): Pixel[][] {
|
||||
if (n > 0) return grid.map(row => [...Array(n).fill(EMPTY_PIXEL), ...row])
|
||||
if (n < 0) return grid.map(row => row.slice(Math.abs(n)))
|
||||
return grid
|
||||
}
|
||||
|
||||
/** Vertical shift up — removes rows from top, pads empty at bottom */
|
||||
function shiftUp(grid: Pixel[][], n: number): Pixel[][] {
|
||||
if (n <= 0) return grid
|
||||
const height = grid.length
|
||||
const shifted = grid.slice(n)
|
||||
while (shifted.length < height) shifted.push(EMPTY_ROW)
|
||||
return shifted
|
||||
}
|
||||
|
||||
/** Mirror map — characters that change when flipped horizontally */
|
||||
const MIRROR: Record<string, string> = {
|
||||
'/': '\\', '\\': '/',
|
||||
'(': ')', ')': '(',
|
||||
'<': '>', '>': '<',
|
||||
'{': '}', '}': '{',
|
||||
'[': ']', ']': '[',
|
||||
'╱': '╲', '╲': '╱',
|
||||
'▌': '▐', '▐': '▌',
|
||||
'▎': '▏', '▏': '▎',
|
||||
'◀': '▶', '▶': '◀',
|
||||
'◄': '►', '►': '◄',
|
||||
'→': '←', '←': '→',
|
||||
'↗': '↙', '↙': '↗',
|
||||
'↘': '↖', '↖': '↘',
|
||||
'`': "'", "'": '`',
|
||||
',': '´', '´': ',',
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal mirror — reverse each row.
|
||||
* When mirrorChars=true, also swap directional characters (correct mirror).
|
||||
* When mirrorChars=false, only reverse positions (more visible "flip" effect).
|
||||
*/
|
||||
function reverseH(grid: Pixel[][], mirrorChars = true): Pixel[][] {
|
||||
const width = Math.max(0, ...grid.map(row => row.length))
|
||||
return grid.map(row =>
|
||||
[...row, ...Array(width - row.length).fill(EMPTY_PIXEL)]
|
||||
.reverse()
|
||||
.map(p => ({
|
||||
...p,
|
||||
char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/** Replace eye-like characters with dash */
|
||||
function blinkEyes(grid: Pixel[][]): Pixel[][] {
|
||||
return grid.map(row =>
|
||||
row.map(p =>
|
||||
/[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Idle Sequence
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
const IDLE_SEQUENCE: AnimMode[] = [
|
||||
'idle', 'idle',
|
||||
'breathe', 'breathe',
|
||||
'idle',
|
||||
'blink',
|
||||
'idle',
|
||||
'bounce',
|
||||
'idle',
|
||||
'fidget', 'fidget',
|
||||
'idle',
|
||||
'breathe', 'breathe',
|
||||
'idle',
|
||||
'flip', 'flip', 'flip',
|
||||
'idle', 'idle',
|
||||
'bounce',
|
||||
'idle',
|
||||
'blink',
|
||||
'idle',
|
||||
'excited', 'excited',
|
||||
'idle',
|
||||
]
|
||||
|
||||
export function getIdleAnimMode(tick: number): AnimMode {
|
||||
return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Public API
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Flip sprite lines horizontally (mirror + swap directional chars).
|
||||
* For player Pokemon facing right towards the opponent.
|
||||
*/
|
||||
export function flipSpriteLines(lines: string[]): string[] {
|
||||
return renderSprite(reverseH(parseSprite(lines), true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply animation transform to sprite lines.
|
||||
* Internally: parse ANSI → Pixel grid → transform → render back.
|
||||
*/
|
||||
export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] {
|
||||
const grid = parseSprite(lines)
|
||||
|
||||
let result: Pixel[][] = grid
|
||||
|
||||
switch (mode) {
|
||||
case 'idle':
|
||||
break
|
||||
case 'breathe':
|
||||
// Right sway → center
|
||||
result = shiftH(result, tick % 4 < 2 ? 3 : 0)
|
||||
break
|
||||
case 'blink':
|
||||
result = blinkEyes(result)
|
||||
break
|
||||
case 'fidget':
|
||||
// Big right sway → center
|
||||
result = shiftH(result, tick % 2 === 0 ? 4 : 0)
|
||||
break
|
||||
case 'bounce': {
|
||||
const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0]
|
||||
const h = PATTERN[tick % PATTERN.length]
|
||||
result = shiftUp(result, h)
|
||||
break
|
||||
}
|
||||
case 'walkLeft':
|
||||
// Step right → center (mimics bounce-back from left step)
|
||||
result = shiftH(result, tick % 4 === 0 ? 0 : 3)
|
||||
break
|
||||
case 'walkRight':
|
||||
// Step right → further right → center
|
||||
result = shiftH(result, (tick % 4) * 2)
|
||||
break
|
||||
case 'flip':
|
||||
// Pure position reversal — do NOT mirror chars so / \ ( )
|
||||
// visibly swap, making the flip obvious.
|
||||
result = reverseH(result, false)
|
||||
break
|
||||
case 'excited':
|
||||
// Jitter right ↔ further right (never crop)
|
||||
result = shiftH(result, tick % 2 === 0 ? 1 : 4)
|
||||
break
|
||||
case 'pet':
|
||||
break // overlay handled by SpriteAnimator
|
||||
}
|
||||
|
||||
return renderSprite(result)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Sprite Shrink (nearest-neighbor / block sampling)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function pixelWeight(char: string): number {
|
||||
if (char === ' ') return 0
|
||||
if ('█▓'.includes(char)) return 4
|
||||
if ('▒■▀▄'.includes(char)) return 3
|
||||
if ('░▌▐/\\()<>'.includes(char)) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function pickDominantPixel(
|
||||
grid: Pixel[][],
|
||||
x0: number,
|
||||
x1: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): Pixel {
|
||||
let best: Pixel = EMPTY_PIXEL
|
||||
let bestScore = -1
|
||||
const cx = (x0 + x1 - 1) / 2
|
||||
const cy = (y0 + y1 - 1) / 2
|
||||
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const pixel = grid[y]?.[x] ?? EMPTY_PIXEL
|
||||
const weight = pixelWeight(pixel.char)
|
||||
if (weight === 0) continue
|
||||
|
||||
const dist = Math.abs(x - cx) + Math.abs(y - cy)
|
||||
const score = weight * 10 - dist
|
||||
if (score > bestScore) {
|
||||
best = pixel
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore >= 0 ? best : EMPTY_PIXEL
|
||||
}
|
||||
|
||||
function resampleGrid(grid: Pixel[][], targetWidth: number, targetHeight: number): Pixel[][] {
|
||||
const srcHeight = grid.length
|
||||
const srcWidth = Math.max(0, ...grid.map(row => row.length))
|
||||
|
||||
return Array.from({ length: targetHeight }, (_, y) => {
|
||||
const y0 = Math.floor((y * srcHeight) / targetHeight)
|
||||
const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * srcHeight) / targetHeight))
|
||||
|
||||
return Array.from({ length: targetWidth }, (_, x) => {
|
||||
const x0 = Math.floor((x * srcWidth) / targetWidth)
|
||||
const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * srcWidth) / targetWidth))
|
||||
return pickDominantPixel(grid, x0, x1, y0, y1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isEmptyRow(row: Pixel[]): boolean {
|
||||
return row.length === 0 || row.every(pixel => pixel.char === ' ')
|
||||
}
|
||||
|
||||
function trimEmptyMargin(grid: Pixel[][]): Pixel[][] {
|
||||
if (grid.length === 0) return grid
|
||||
|
||||
let top = 0
|
||||
let bottom = grid.length - 1
|
||||
while (top <= bottom && isEmptyRow(grid[top] ?? [])) top++
|
||||
while (bottom >= top && isEmptyRow(grid[bottom] ?? [])) bottom--
|
||||
|
||||
if (top > bottom) return []
|
||||
|
||||
const sliced = grid.slice(top, bottom + 1)
|
||||
const width = Math.max(0, ...sliced.map(row => row.length))
|
||||
|
||||
let left = 0
|
||||
let right = width - 1
|
||||
const isEmptyCol = (x: number) => sliced.every(row => (row[x]?.char ?? ' ') === ' ')
|
||||
|
||||
while (left <= right && isEmptyCol(left)) left++
|
||||
while (right >= left && isEmptyCol(right)) right--
|
||||
|
||||
return sliced.map(row => row.slice(left, right + 1))
|
||||
}
|
||||
|
||||
export function shrinkSprite(
|
||||
lines: string[],
|
||||
opts: { scale?: number; maxWidth?: number; maxHeight?: number },
|
||||
): string[] {
|
||||
const grid = trimEmptyMargin(parseSprite(lines))
|
||||
const srcHeight = grid.length
|
||||
const srcWidth = Math.max(0, ...grid.map(row => row.length))
|
||||
|
||||
if (srcWidth === 0 || srcHeight === 0) return lines
|
||||
|
||||
const baseScale = Math.min(opts.scale ?? 0.75, 1)
|
||||
const widthScale = opts.maxWidth ? opts.maxWidth / srcWidth : 1
|
||||
const heightScale = opts.maxHeight ? opts.maxHeight / srcHeight : 1
|
||||
const finalScale = Math.min(baseScale, widthScale, heightScale, 1)
|
||||
|
||||
if (finalScale >= 1) return lines
|
||||
|
||||
const targetWidth = Math.max(1, Math.floor(srcWidth * finalScale))
|
||||
const targetHeight = Math.max(1, Math.floor(srcHeight * finalScale))
|
||||
|
||||
return renderSprite(resampleGrid(grid, targetWidth, targetHeight))
|
||||
}
|
||||
|
||||
// ─── Heart overlay (kept for SpriteAnimator convenience) ──
|
||||
|
||||
const PET_HEARTS = [
|
||||
[' ♥ ', ' '],
|
||||
[' ♥ ♥ ', ' ♥ '],
|
||||
[' ♥ ♥ ', ' ♥ ♥ '],
|
||||
[' ♥ ♥ ', ' ♥ ♥ '],
|
||||
[' ♥ ', ' ♥ ♥ '],
|
||||
]
|
||||
|
||||
export function getPetOverlay(tick: number): string[] {
|
||||
return PET_HEARTS[tick % PET_HEARTS.length]
|
||||
}
|
||||
161
packages/pokemon/src/types.ts
Normal file
161
packages/pokemon/src/types.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// 6 attributes (mapped to programming scenarios)
|
||||
export type StatName = 'hp' | 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed'
|
||||
export const STAT_NAMES: StatName[] = ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed']
|
||||
export const STAT_LABELS: Record<StatName, string> = {
|
||||
hp: 'HP',
|
||||
attack: 'ATK',
|
||||
defense: 'DEF',
|
||||
spAtk: 'SPA',
|
||||
spDef: 'SPD',
|
||||
speed: 'SPE',
|
||||
}
|
||||
|
||||
// Species IDs — dynamically populated from @pkmn/sim Dex (1025 species)
|
||||
export type SpeciesId = string
|
||||
|
||||
// Re-exported from dex/species.ts (computed from Dex.data at module load)
|
||||
export { ALL_SPECIES_IDS } from './dex/species'
|
||||
|
||||
// Nature (delegated to @pkmn/sim Dex.natures)
|
||||
export type NatureName = string
|
||||
export type NatureStat = 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed'
|
||||
export type NatureEffect = { plus: NatureStat | null; minus: NatureStat | null }
|
||||
|
||||
// Move slot
|
||||
export type MoveSlot = { id: string; pp: number; maxPp: number }
|
||||
export const EMPTY_MOVE: MoveSlot = { id: '', pp: 0, maxPp: 0 }
|
||||
|
||||
// Item ID (Showdown format string)
|
||||
export type ItemId = string
|
||||
|
||||
// PC box (fixed 30 slots)
|
||||
export type PCBox = { name: string; slots: (string | null)[] }
|
||||
|
||||
// Bag
|
||||
export type BagEntry = { id: ItemId; count: number }
|
||||
export type Bag = { items: BagEntry[] }
|
||||
|
||||
// Gender
|
||||
export type Gender = 'male' | 'female' | 'genderless'
|
||||
|
||||
// Evolution trigger types
|
||||
export type EvolutionTrigger = 'level_up' | 'item' | 'trade' | 'friendship'
|
||||
|
||||
export type EvolutionCondition = {
|
||||
trigger: EvolutionTrigger
|
||||
level?: number // Level evolution: target level
|
||||
minFriendship?: number // Friendship evolution
|
||||
item?: string // Item evolution
|
||||
into: SpeciesId // Evolves into
|
||||
}
|
||||
|
||||
// Growth rate types (from PokeAPI)
|
||||
export type GrowthRate = 'slow' | 'medium-slow' | 'medium-fast' | 'fast' | 'erratic' | 'fluctuating'
|
||||
|
||||
// Species base data
|
||||
export type SpeciesData = {
|
||||
id: SpeciesId
|
||||
name: string // English name
|
||||
names: Record<string, string> // Multilingual names { ja, en, zh }
|
||||
dexNumber: number // Pokédex number (1-10 MVP)
|
||||
genderRate: number // Female probability (0-8, -1 = genderless). femaleChance = genderRate / 8
|
||||
baseStats: Record<StatName, number>
|
||||
types: [string, string?] // Types (grass/poison, fire, water etc.)
|
||||
baseHappiness: number // Base friendship
|
||||
growthRate: GrowthRate
|
||||
captureRate: number
|
||||
personality: string // Default personality description
|
||||
evolutionChain?: EvolutionCondition[]
|
||||
shinyChance: number // Shiny probability (default 1/4096)
|
||||
flavorText?: string // Pokédex description
|
||||
}
|
||||
|
||||
// Instantiated creature (stored in buddy-data.json)
|
||||
export type Creature = {
|
||||
id: string // UUID
|
||||
speciesId: SpeciesId
|
||||
nickname?: string // User-defined name
|
||||
gender: Gender
|
||||
level: number
|
||||
xp: number // Current level progress XP
|
||||
totalXp: number // Total accumulated XP
|
||||
nature: NatureName // Character nature
|
||||
ev: Record<StatName, number> // Effort values
|
||||
iv: Record<StatName, number> // Individual values (0-31)
|
||||
moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // 4 move slots
|
||||
ability: string // Showdown ability ID
|
||||
heldItem: ItemId | null // Held item
|
||||
friendship: number // Friendship (0-255)
|
||||
isShiny: boolean
|
||||
hatchedAt: number // Timestamp when obtained
|
||||
pokeball: string // Pokeball type
|
||||
}
|
||||
|
||||
// Egg
|
||||
export type Egg = {
|
||||
id: string
|
||||
obtainedAt: number
|
||||
stepsRemaining: number // Remaining hatch steps
|
||||
totalSteps: number // Original total steps (for progress calc)
|
||||
speciesId: SpeciesId // Pre-determined species
|
||||
}
|
||||
|
||||
// Pokédex entry
|
||||
export type DexEntry = {
|
||||
speciesId: SpeciesId
|
||||
discoveredAt: number
|
||||
caughtCount: number // Number caught
|
||||
bestLevel: number // Highest level record
|
||||
}
|
||||
|
||||
// buddy-data.json complete structure
|
||||
export type BuddyData = {
|
||||
version: 2
|
||||
party: (string | null)[] // Always length 6, party[0] = active buddy
|
||||
boxes: PCBox[] // PC storage (default 8 boxes × 30 slots)
|
||||
creatures: Creature[]
|
||||
eggs: Egg[]
|
||||
dex: DexEntry[]
|
||||
bag: Bag
|
||||
stats: {
|
||||
totalTurns: number
|
||||
consecutiveDays: number
|
||||
lastActiveDate: string // ISO date
|
||||
totalEggsObtained: number
|
||||
totalEvolutions: number
|
||||
battlesWon: number
|
||||
battlesLost: number
|
||||
}
|
||||
}
|
||||
|
||||
// Calculated stats result
|
||||
export type StatsResult = Record<StatName, number>
|
||||
|
||||
// Evolution result
|
||||
export type EvolutionResult = {
|
||||
from: SpeciesId
|
||||
to: SpeciesId
|
||||
newLevel: number
|
||||
}
|
||||
|
||||
// Sprite cache entry
|
||||
export type SpriteCache = {
|
||||
speciesId: SpeciesId
|
||||
lines: string[]
|
||||
width: number
|
||||
height: number
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
// Animation mode
|
||||
export type AnimMode =
|
||||
| 'idle'
|
||||
| 'breathe'
|
||||
| 'blink'
|
||||
| 'fidget'
|
||||
| 'bounce'
|
||||
| 'walkLeft'
|
||||
| 'walkRight'
|
||||
| 'flip'
|
||||
| 'excited'
|
||||
| 'pet'
|
||||
73
packages/pokemon/src/ui/BattleConfigPanel.tsx
Normal file
73
packages/pokemon/src/ui/BattleConfigPanel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Creature, SpeciesId } from '../types'
|
||||
import { getCreatureName } from '../core/creature'
|
||||
|
||||
interface BattleConfigPanelProps {
|
||||
party: (Creature | null)[]
|
||||
cursorIndex: number
|
||||
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const OPTIONS = [
|
||||
{ label: '随机遇战(等级自动匹配)', color: 'warning' as const },
|
||||
{ label: '指定对手', color: 'inactive' as const },
|
||||
]
|
||||
|
||||
export function BattleConfigPanel({ party, cursorIndex }: BattleConfigPanelProps) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="claude"
|
||||
borderText={{ content: ' 战斗配置 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
{/* Party display */}
|
||||
<Text bold color="claude">队伍</Text>
|
||||
{party.map((creature, i) => {
|
||||
if (!creature) return (
|
||||
<Box key={i}>
|
||||
<Text dimColor> [空]</Text>
|
||||
</Box>
|
||||
)
|
||||
const hpPercent = 100
|
||||
const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
|
||||
const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
|
||||
const isLead = i === 0
|
||||
return (
|
||||
<Box key={creature.id}>
|
||||
<Text color={isLead ? 'claude' : 'inactive'}>
|
||||
{isLead ? ' ▸ ' : ' '}
|
||||
</Text>
|
||||
<Text bold={isLead}>{getCreatureName(creature)}</Text>
|
||||
<Text> Lv.{creature.level} </Text>
|
||||
<Text color="success">{hpBar}</Text>
|
||||
<Text color="inactive">{hpEmpty}</Text>
|
||||
<Text> {hpPercent}%</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Options */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="claude">选择对手</Text>
|
||||
{OPTIONS.map((opt, i) => (
|
||||
<Box key={i}>
|
||||
<Text color={i === cursorIndex ? 'success' : 'inactive'}>
|
||||
{i === cursorIndex ? ' ▶ ' : ' '}
|
||||
</Text>
|
||||
<Text bold={i === cursorIndex} color={i === cursorIndex ? opt.color : 'inactive'}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] 选择 · [Enter] 确认 · [ESC] 取消</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
98
packages/pokemon/src/ui/BattleField.tsx
Normal file
98
packages/pokemon/src/ui/BattleField.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { parseSprite, renderSprite, flipSpriteLines, EMPTY_PIXEL, EMPTY_ROW } from '../sprites/renderer'
|
||||
import type { Pixel } from '../sprites/renderer'
|
||||
|
||||
/**
|
||||
* Combined battle field — composites both sprites into one canvas.
|
||||
* Opponent (top-right) and player (bottom-left) share overlapping rows,
|
||||
* like the classic GBA Pokemon battle layout.
|
||||
*
|
||||
* Bounce: fast 0-1-2-1px vertical, staggered between the two.
|
||||
*/
|
||||
|
||||
const BOUNCE = [0, 1, 2, 1]
|
||||
/** How many rows the player sprite overlaps into opponent's area */
|
||||
const OVERLAP = 3
|
||||
|
||||
interface BattleFieldProps {
|
||||
opponentLines: string[]
|
||||
playerLines: string[]
|
||||
animEnabled?: boolean
|
||||
}
|
||||
|
||||
export function BattleField({ opponentLines, playerLines, animEnabled = true }: BattleFieldProps) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!animEnabled) return
|
||||
const timer = setInterval(() => setTick(t => t + 1), 120)
|
||||
return () => clearInterval(timer)
|
||||
}, [animEnabled])
|
||||
|
||||
// Parse & flip (cached)
|
||||
const oppGrid = useMemo(() => parseSprite(opponentLines), [opponentLines])
|
||||
const playerGrid = useMemo(() => parseSprite(flipSpriteLines(playerLines)), [playerLines])
|
||||
|
||||
// Composited canvas
|
||||
const canvas = useMemo(() => {
|
||||
const oppH = oppGrid.length
|
||||
const playerH = playerGrid.length
|
||||
const totalH = oppH + playerH - OVERLAP
|
||||
const canvasW = Math.max(
|
||||
widthOf(oppGrid),
|
||||
widthOf(playerGrid),
|
||||
)
|
||||
|
||||
// Build empty canvas
|
||||
const rows: Pixel[][] = Array.from({ length: totalH }, () =>
|
||||
Array.from({ length: canvasW }, () => EMPTY_PIXEL),
|
||||
)
|
||||
|
||||
// Bounce offsets
|
||||
const oppOffset = animEnabled ? BOUNCE[tick % BOUNCE.length]! : 0
|
||||
const playerOffset = animEnabled ? BOUNCE[(tick + 2) % BOUNCE.length]! : 0
|
||||
|
||||
// Blit opponent (top-right, shifted up by bounce)
|
||||
const oppY = -oppOffset // negative = shift up
|
||||
blit(rows, oppGrid, oppY, canvasW - widthOf(oppGrid))
|
||||
|
||||
// Blit player (bottom-left, shifted up by bounce)
|
||||
const playerStartRow = oppH - OVERLAP
|
||||
const playerY = playerStartRow - playerOffset
|
||||
blit(rows, playerGrid, playerY, 0)
|
||||
|
||||
return rows
|
||||
}, [oppGrid, playerGrid, animEnabled, tick])
|
||||
|
||||
const rendered = renderSprite(canvas)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{rendered.map((line, i) => (
|
||||
<Text key={i}>{line || ' '}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** Get width of a pixel grid */
|
||||
function widthOf(grid: Pixel[][]): number {
|
||||
return Math.max(0, ...grid.map(row => row.length))
|
||||
}
|
||||
|
||||
/** Blit source grid onto target at (startRow, startCol). Non-empty pixels overwrite. */
|
||||
function blit(target: Pixel[][], source: Pixel[][], startRow: number, startCol: number) {
|
||||
for (let sy = 0; sy < source.length; sy++) {
|
||||
const ty = startRow + sy
|
||||
if (ty < 0 || ty >= target.length) continue
|
||||
for (let sx = 0; sx < source[sy].length; sx++) {
|
||||
const tx = startCol + sx
|
||||
if (tx < 0 || tx >= target[ty].length) continue
|
||||
const pixel = source[sy][sx]
|
||||
if (pixel.char !== ' ') {
|
||||
target[ty][tx] = pixel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
591
packages/pokemon/src/ui/BattleFlow.tsx
Normal file
591
packages/pokemon/src/ui/BattleFlow.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BuddyData, Creature, SpeciesId } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { saveBuddyData } from '../core/storage'
|
||||
import { createBattle, executeTurn, executeSwitch, type BattleInit } from '../battle/engine'
|
||||
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
|
||||
import { BattleConfigPanel } from './BattleConfigPanel'
|
||||
import { BattleScene, type MenuPhase } from './BattleScene'
|
||||
import { SpeciesPicker } from './SpeciesPicker'
|
||||
import { SwitchPanel } from './SwitchPanel'
|
||||
import { ItemPanel } from './ItemPanel'
|
||||
import { BattleResultPanel } from './BattleResultPanel'
|
||||
import { MoveLearnPanel } from './MoveLearnPanel'
|
||||
import type { BattleState, PlayerAction } from '../battle/types'
|
||||
|
||||
type Phase =
|
||||
| 'config'
|
||||
| 'configSelect'
|
||||
| 'battle'
|
||||
| 'result'
|
||||
| 'learnMoves'
|
||||
| 'evolution'
|
||||
| 'done'
|
||||
|
||||
export interface BattleFlowHandle {
|
||||
handleInput: (input: string, key: {
|
||||
escape?: boolean
|
||||
return?: boolean
|
||||
upArrow?: boolean
|
||||
downArrow?: boolean
|
||||
leftArrow?: boolean
|
||||
rightArrow?: boolean
|
||||
tab?: boolean
|
||||
backspace?: boolean
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
meta?: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
interface BattleFlowProps {
|
||||
buddyData: BuddyData
|
||||
onClose: () => void
|
||||
isActive?: boolean
|
||||
inputRef?: React.MutableRefObject<BattleFlowHandle | null>
|
||||
}
|
||||
|
||||
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
|
||||
const [phase, setPhase] = useState<Phase>('config')
|
||||
const [buddyData, setBuddyData] = useState(initialData)
|
||||
const [battleInit, setBattleInit] = useState<BattleInit | null>(null)
|
||||
const [battleState, setBattleState] = useState<BattleState | null>(null)
|
||||
const [opponentSpeciesId, setOpponentSpeciesId] = useState<SpeciesId>('pikachu')
|
||||
const [opponentLevel, setOpponentLevel] = useState(5)
|
||||
const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
|
||||
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
|
||||
const [replaceIndex, setReplaceIndex] = useState(0)
|
||||
const [configCursor, setConfigCursor] = useState(0)
|
||||
|
||||
// ─── Battle UI state ───
|
||||
const [menuPhase, setMenuPhase] = useState<MenuPhase>('main')
|
||||
const [cursorIndex, setCursorIndex] = useState(0)
|
||||
const [animEnabled, setAnimEnabled] = useState(true)
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function getActiveCreatureLevel(): number {
|
||||
const id = buddyData.party[0]
|
||||
if (!id) return 5
|
||||
const c = buddyData.creatures.find(cr => cr.id === id)
|
||||
return c?.level ?? 5
|
||||
}
|
||||
|
||||
function getPartyCreatures(): Creature[] {
|
||||
return buddyData.party
|
||||
.filter((id): id is string => id !== null)
|
||||
.map(id => buddyData.creatures.find(c => c.id === id))
|
||||
.filter((c): c is Creature => c !== undefined)
|
||||
}
|
||||
|
||||
/** Build battleHp map from battleState.playerParty */
|
||||
function getBattleHpMap(): Record<string, { hp: number; maxHp: number }> {
|
||||
if (!battleState) return {}
|
||||
const map: Record<string, { hp: number; maxHp: number }> = {}
|
||||
for (const p of battleState.playerParty) {
|
||||
map[p.id] = { hp: p.hp, maxHp: p.maxHp }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Get max cursor index for current sub-phase */
|
||||
function getMaxCursor(): number {
|
||||
if (!battleState) return 0
|
||||
switch (menuPhase) {
|
||||
case 'main': return 3
|
||||
case 'fight': return battleState.playerPokemon.moves.length - 1
|
||||
case 'bag': return battleState.usableItems.length - 1
|
||||
case 'pokemon': return getPartyCreatures().length - 1
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ───
|
||||
|
||||
const handleRandomBattle = useCallback(() => {
|
||||
const opponentLevel = getActiveCreatureLevel()
|
||||
const speciesList = ALL_SPECIES_IDS
|
||||
const randomSpecies = speciesList[Math.floor(Math.random() * speciesList.length)]!
|
||||
handleStartBattle(randomSpecies, opponentLevel)
|
||||
}, [buddyData])
|
||||
|
||||
const handleStartBattle = useCallback(async (speciesId: SpeciesId, level: number) => {
|
||||
setOpponentSpeciesId(speciesId)
|
||||
setOpponentLevel(level)
|
||||
|
||||
const creatures = buddyData.party
|
||||
.filter((id): id is string => id !== null)
|
||||
.map(id => buddyData.creatures.find(c => c.id === id))
|
||||
.filter((c): c is Creature => c !== undefined)
|
||||
|
||||
if (creatures.length === 0) return
|
||||
|
||||
const bagItems = buddyData.bag.items
|
||||
const init = await createBattle(creatures, speciesId, level, bagItems)
|
||||
setBattleInit(init)
|
||||
setBattleState(init.state)
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
setPhase('battle')
|
||||
}, [buddyData])
|
||||
|
||||
const handleAction = useCallback(async (action: PlayerAction) => {
|
||||
if (!battleInit) return
|
||||
|
||||
// Consume item from bag before executing turn
|
||||
if (action.type === 'item' && action.itemId) {
|
||||
const updated = {
|
||||
...buddyData,
|
||||
bag: {
|
||||
...buddyData.bag,
|
||||
items: buddyData.bag.items.map(entry =>
|
||||
entry.id === action.itemId
|
||||
? { ...entry, count: Math.max(0, entry.count - 1) }
|
||||
: entry
|
||||
).filter(entry => entry.count > 0),
|
||||
},
|
||||
}
|
||||
setBuddyData(updated)
|
||||
}
|
||||
|
||||
const state = await executeTurn(battleInit, action)
|
||||
setBattleState(state)
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
|
||||
// Escape successful — close battle without rewards
|
||||
if (state.escaped) {
|
||||
saveBuddyData(buddyData)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
// Pokémon fainted — show switch panel overlay
|
||||
if (state.needsSwitch && !state.finished) {
|
||||
setMenuPhase('pokemon')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.finished && state.result) {
|
||||
const participants = buddyData.party.filter((id): id is string => id !== null)
|
||||
const result = { ...state.result, participantIds: participants }
|
||||
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
|
||||
|
||||
setBuddyData(settled.data)
|
||||
setPendingMoves(settled.learnableMoves)
|
||||
setPendingEvos(settled.pendingEvolutions)
|
||||
setBattleState({ ...state, result })
|
||||
setPhase('result')
|
||||
}
|
||||
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
|
||||
|
||||
const handleResultContinue = useCallback(() => {
|
||||
if (pendingMoves.length > 0) {
|
||||
setPhase('learnMoves')
|
||||
} else if (pendingEvos.length > 0) {
|
||||
setPhase('evolution')
|
||||
} else {
|
||||
saveBuddyData(buddyData)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
}
|
||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||
|
||||
const handleMoveLearn = useCallback((idx: number) => {
|
||||
if (pendingMoves.length === 0) return
|
||||
const move = pendingMoves[0]!
|
||||
const updated = applyMoveLearn(buddyData, move.creatureId, move.moveId, idx)
|
||||
setBuddyData(updated)
|
||||
const remaining = pendingMoves.slice(1)
|
||||
setPendingMoves(remaining)
|
||||
if (remaining.length === 0) {
|
||||
if (pendingEvos.length > 0) {
|
||||
setPhase('evolution')
|
||||
} else {
|
||||
saveBuddyData(updated)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||
|
||||
const handleMoveSkip = useCallback(() => {
|
||||
const remaining = pendingMoves.slice(1)
|
||||
setPendingMoves(remaining)
|
||||
if (remaining.length === 0) {
|
||||
if (pendingEvos.length > 0) {
|
||||
setPhase('evolution')
|
||||
} else {
|
||||
saveBuddyData(buddyData)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||
|
||||
const handleEvolutionConfirm = useCallback(() => {
|
||||
if (pendingEvos.length === 0) return
|
||||
const evo = pendingEvos[0]!
|
||||
const updated = applyEvolution(buddyData, evo.creatureId, evo.to)
|
||||
setBuddyData(updated)
|
||||
const remaining = pendingEvos.slice(1)
|
||||
setPendingEvos(remaining)
|
||||
if (remaining.length === 0) {
|
||||
saveBuddyData(updated)
|
||||
setPhase('done')
|
||||
onClose()
|
||||
}
|
||||
}, [pendingEvos, buddyData, onClose])
|
||||
|
||||
// Forced switch after faint
|
||||
const handleForcedSwitch = useCallback(async (partyIndex: number) => {
|
||||
if (!battleInit) return
|
||||
const state = await executeSwitch(battleInit, partyIndex)
|
||||
setBattleState(state)
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
|
||||
if (state.finished && state.result) {
|
||||
const participants = buddyData.party.filter((id): id is string => id !== null)
|
||||
const result = { ...state.result, participantIds: participants }
|
||||
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
|
||||
setBuddyData(settled.data)
|
||||
setPendingMoves(settled.learnableMoves)
|
||||
setPendingEvos(settled.pendingEvolutions)
|
||||
setBattleState({ ...state, result })
|
||||
setPhase('result')
|
||||
}
|
||||
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
|
||||
|
||||
// ─── Main menu cursor navigation (2x2 grid) ───
|
||||
|
||||
const moveMainCursor = useCallback((direction: 'up' | 'down' | 'left' | 'right') => {
|
||||
setCursorIndex(prev => {
|
||||
// Grid: 0=TL, 1=TR, 2=BL, 3=BR
|
||||
switch (direction) {
|
||||
case 'up': return prev >= 2 ? prev - 2 : prev + 2
|
||||
case 'down': return prev < 2 ? prev + 2 : prev - 2
|
||||
case 'left': return prev % 2 === 1 ? prev - 1 : prev + 1
|
||||
case 'right': return prev % 2 === 0 ? prev + 1 : prev - 1
|
||||
default: return prev
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ─── Input handler ───
|
||||
|
||||
const handleInput = useCallback((input: string, key: {
|
||||
escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean
|
||||
leftArrow?: boolean; rightArrow?: boolean
|
||||
}) => {
|
||||
if (!isActive) return
|
||||
|
||||
if (phase === 'config') {
|
||||
if (key.escape) {
|
||||
onClose()
|
||||
} else if (key.upArrow) {
|
||||
setConfigCursor(prev => (prev - 1 + 2) % 2)
|
||||
} else if (key.downArrow) {
|
||||
setConfigCursor(prev => (prev + 1) % 2)
|
||||
} else if (key.return) {
|
||||
if (configCursor === 0) {
|
||||
handleRandomBattle()
|
||||
} else {
|
||||
setPhase('configSelect')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'configSelect') {
|
||||
// SpeciesPicker handles its own input via FuzzyPicker/useInput
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'battle') {
|
||||
if (!battleState) return
|
||||
|
||||
// F key toggles animation
|
||||
if (input.toLowerCase() === 'f') {
|
||||
setAnimEnabled(prev => !prev)
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Main menu ───
|
||||
if (menuPhase === 'main') {
|
||||
if (key.escape) return
|
||||
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
|
||||
moveMainCursor(key.upArrow ? 'up' : key.downArrow ? 'down' : key.leftArrow ? 'left' : 'right')
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
switch (cursorIndex) {
|
||||
case 0: // 战斗 → move selection
|
||||
setMenuPhase('fight')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
case 1: // 背包
|
||||
setMenuPhase('bag')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
case 2: // 宝可梦
|
||||
setMenuPhase('pokemon')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
case 3: // 逃跑 — attempt escape
|
||||
handleAction({ type: 'run' })
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Fight (move selection) ───
|
||||
if (menuPhase === 'fight') {
|
||||
if (key.escape) {
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setCursorIndex(prev => Math.min(battleState.playerPokemon.moves.length - 1, prev + 1))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const move = battleState.playerPokemon.moves[cursorIndex]
|
||||
if (move && move.pp > 0 && !move.disabled) {
|
||||
handleAction({ type: 'move', moveIndex: cursorIndex })
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Bag (item selection) ───
|
||||
if (menuPhase === 'bag') {
|
||||
if (key.escape) {
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(1) // return to 背包
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setCursorIndex(prev => Math.min(battleState.usableItems.length - 1, prev + 1))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const item = battleState.usableItems[cursorIndex]
|
||||
if (item) {
|
||||
handleAction({ type: 'item', itemId: item.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Pokemon (switch selection) ───
|
||||
if (menuPhase === 'pokemon') {
|
||||
const isForced = battleState.needsSwitch
|
||||
if (key.escape && !isForced) {
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(2) // return to 宝可梦
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
const maxIdx = getPartyCreatures().length - 1
|
||||
setCursorIndex(prev => Math.min(maxIdx, prev + 1))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const party = getPartyCreatures()
|
||||
const creature = party[cursorIndex]
|
||||
const battleParty = battleState.playerParty
|
||||
const battleCreature = battleParty[cursorIndex]
|
||||
if (creature && battleCreature && battleCreature.hp > 0) {
|
||||
if (isForced) {
|
||||
handleForcedSwitch(cursorIndex)
|
||||
} else {
|
||||
handleAction({ type: 'switch', partyIndex: cursorIndex })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'result') {
|
||||
if (key.return) handleResultContinue()
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'learnMoves') {
|
||||
if (input.toLowerCase() === 's') {
|
||||
handleMoveSkip()
|
||||
} else if (key.upArrow) {
|
||||
setReplaceIndex(prev => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow) {
|
||||
setReplaceIndex(prev => Math.min(3, prev + 1))
|
||||
} else if (key.return) {
|
||||
handleMoveLearn(replaceIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'evolution') {
|
||||
if (key.return) handleEvolutionConfirm()
|
||||
return
|
||||
}
|
||||
}, [isActive, phase, menuPhase, cursorIndex, configCursor, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
|
||||
|
||||
// Expose handleInput via ref
|
||||
useEffect(() => {
|
||||
if (inputRef) inputRef.current = { handleInput }
|
||||
}, [handleInput, inputRef])
|
||||
|
||||
// ─── Build overlay content for sub-panels ───
|
||||
|
||||
function buildOverlay(): React.ReactNode | undefined {
|
||||
if (!battleState) return undefined
|
||||
|
||||
if (menuPhase === 'bag') {
|
||||
return (
|
||||
<ItemPanel
|
||||
items={battleState.usableItems}
|
||||
cursorIndex={cursorIndex}
|
||||
categoryIndex={0}
|
||||
phase="items"
|
||||
onSelect={() => {}}
|
||||
onCancel={() => { setMenuPhase('main'); setCursorIndex(1) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (menuPhase === 'pokemon') {
|
||||
return (
|
||||
<SwitchPanel
|
||||
party={getPartyCreatures()}
|
||||
activeId={battleState.playerPokemon.id}
|
||||
cursorIndex={cursorIndex}
|
||||
battleHp={getBattleHpMap()}
|
||||
onSelect={() => {}}
|
||||
onCancel={() => { setMenuPhase('main'); setCursorIndex(2) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ─── Render by phase ───
|
||||
|
||||
switch (phase) {
|
||||
case 'config':
|
||||
return (
|
||||
<BattleConfigPanel
|
||||
party={getPartyCreatures()}
|
||||
cursorIndex={configCursor}
|
||||
onSubmit={handleStartBattle}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'configSelect':
|
||||
return (
|
||||
<SpeciesPicker
|
||||
onSelect={(speciesId) => handleStartBattle(speciesId, getActiveCreatureLevel())}
|
||||
onCancel={() => setPhase('config')}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'battle': {
|
||||
if (!battleState) return null
|
||||
return (
|
||||
<BattleScene
|
||||
state={battleState}
|
||||
menuPhase={menuPhase}
|
||||
cursorIndex={cursorIndex}
|
||||
animEnabled={animEnabled}
|
||||
overlay={buildOverlay()}
|
||||
onMoveCursor={(dir) => {
|
||||
if (menuPhase === 'main') moveMainCursor(dir)
|
||||
else if (dir === 'up') setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
else if (dir === 'down') setCursorIndex(prev => Math.min(getMaxCursor(), prev + 1))
|
||||
}}
|
||||
onSelect={() => {}}
|
||||
onBack={() => { setMenuPhase('main'); setCursorIndex(0) }}
|
||||
onToggleAnim={() => setAnimEnabled(prev => !prev)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
if (!battleState?.result) return null
|
||||
return (
|
||||
<BattleResultPanel
|
||||
result={battleState.result}
|
||||
onContinue={handleResultContinue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'learnMoves': {
|
||||
if (pendingMoves.length === 0) return null
|
||||
const move = pendingMoves[0]!
|
||||
const creature = buddyData.creatures.find(c => c.id === move.creatureId)
|
||||
if (!creature) return null
|
||||
return (
|
||||
<MoveLearnPanel
|
||||
creature={creature}
|
||||
newMoveId={move.moveId}
|
||||
cursorIndex={replaceIndex}
|
||||
onLearn={handleMoveLearn}
|
||||
onSkip={handleMoveSkip}
|
||||
onSelectReplace={setReplaceIndex}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'evolution': {
|
||||
if (pendingEvos.length === 0) return null
|
||||
const evo = pendingEvos[0]!
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="warning"
|
||||
borderText={{ content: ' 进化 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold color="warning">{evo.from} 正在进化为 {evo.to}!</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="claude">[Enter] 继续</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case 'done':
|
||||
return null
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
103
packages/pokemon/src/ui/BattleLogPanel.tsx
Normal file
103
packages/pokemon/src/ui/BattleLogPanel.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BattleEvent } from '../battle/types'
|
||||
|
||||
/** Max lines to display in the log panel */
|
||||
const MAX_VISIBLE = 20
|
||||
|
||||
function eventColor(event: BattleEvent): string {
|
||||
switch (event.type) {
|
||||
case 'damage': return 'error'
|
||||
case 'heal': return 'success'
|
||||
case 'faint': return 'error'
|
||||
case 'crit': return 'warning'
|
||||
case 'miss': return 'inactive'
|
||||
case 'effectiveness': return event.multiplier > 1 ? 'success' : 'warning'
|
||||
case 'move': return 'claude'
|
||||
case 'status': return 'warning'
|
||||
case 'switch': return 'claude'
|
||||
case 'turn': return 'inactive'
|
||||
case 'weather': return 'claude'
|
||||
case 'fieldCondition': return 'warning'
|
||||
case 'activate': return 'claude'
|
||||
case 'immune': return 'inactive'
|
||||
case 'upkeep': return 'inactive'
|
||||
case 'ability': return 'claude'
|
||||
case 'item': return 'warning'
|
||||
case 'fail': return 'inactive'
|
||||
default: return 'inactive'
|
||||
}
|
||||
}
|
||||
|
||||
const WEATHER_NAMES: Record<string, string> = {
|
||||
sun: '大晴天', rain: '雨天', sandstorm: '沙暴', hail: '冰雹',
|
||||
snow: '下雪', desolateland: '大日照', primordialsea: '大雨', deltastream: '强气流',
|
||||
}
|
||||
|
||||
function formatEvent(event: BattleEvent): string {
|
||||
switch (event.type) {
|
||||
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
|
||||
case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害 (${event.percentage}%)`
|
||||
case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP`
|
||||
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}的 ${event.speciesId} 倒下了!`
|
||||
case 'crit': return '击中要害!'
|
||||
case 'miss': return '攻击没有命中!'
|
||||
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
|
||||
case 'status': return `${event.side === 'player' ? '我方' : '对手'}${event.status === 'none' ? '恢复了异常状态!' : `陷入了${event.status}状态!`}`
|
||||
case 'switch': return `${event.side === 'player' ? '我方' : '对手'}换上了 ${event.name}!`
|
||||
case 'turn': return `── 回合 ${event.number} ──`
|
||||
case 'statChange': {
|
||||
const sign = event.stages > 0 ? '↑' : '↓'
|
||||
return `${event.side === 'player' ? '我方' : '对手'}的 ${event.stat} ${sign}${Math.abs(event.stages)}`
|
||||
}
|
||||
case 'ability': return `${event.side === 'player' ? '我方' : '对手'}的特性 ${event.ability} 发动了!`
|
||||
case 'item': return `${event.side === 'player' ? '我方' : '对手'}的 ${event.item} 发动了!`
|
||||
case 'fail': return `${event.side === 'player' ? '我方' : '对手'}的攻击失败了!`
|
||||
case 'weather':
|
||||
if (event.weather === 'none') return '天气恢复了正常'
|
||||
return `${WEATHER_NAMES[event.weather] ?? event.weather} 开始了!`
|
||||
case 'upkeep': return '── 回合结束处理 ──'
|
||||
case 'fieldCondition':
|
||||
if (event.action === 'add') return `${event.side === 'player' ? '我方' : '对手'}场地: ${event.id}!`
|
||||
return `${event.side === 'player' ? '我方' : '对手'}场地的 ${event.id} 消失了`
|
||||
case 'activate': return `${event.side === 'player' ? '我方' : '对手'}触发了 ${event.effect}`
|
||||
case 'immune': return `${event.side === 'player' ? '我方' : '对手'}不受影响!`
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
interface BattleLogPanelProps {
|
||||
events: BattleEvent[]
|
||||
animEnabled: boolean
|
||||
onToggleAnim: () => void
|
||||
}
|
||||
|
||||
export function BattleLogPanel({ events, animEnabled, onToggleAnim }: BattleLogPanelProps) {
|
||||
const visible = events.slice(-MAX_VISIBLE)
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 战斗日志 ', position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
width="40%"
|
||||
>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{visible.map((event, i) => (
|
||||
<Text key={i} color={eventColor(event) as any} dimColor={event.type === 'turn'}>
|
||||
{' '}{formatEvent(event)}
|
||||
</Text>
|
||||
))}
|
||||
{visible.length === 0 && (
|
||||
<Text dimColor> 等待战斗开始...</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> [F] {animEnabled ? '关闭动画' : '开启动画'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
122
packages/pokemon/src/ui/BattleMenu.tsx
Normal file
122
packages/pokemon/src/ui/BattleMenu.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { MoveOption } from '../battle/types'
|
||||
|
||||
export interface BattleMenuProps {
|
||||
phase: 'main' | 'fight'
|
||||
moves: MoveOption[]
|
||||
cursorIndex: number
|
||||
onMoveCursor: (direction: 'up' | 'down' | 'left' | 'right') => void
|
||||
onSelect: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function BattleMenu({ phase, moves, cursorIndex }: BattleMenuProps) {
|
||||
if (phase === 'fight') {
|
||||
return <MoveMenu moves={moves} cursorIndex={cursorIndex} />
|
||||
}
|
||||
|
||||
return <MainMenu cursorIndex={cursorIndex} />
|
||||
}
|
||||
|
||||
function MainMenu({ cursorIndex }: { cursorIndex: number }) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
paddingX={1}
|
||||
>
|
||||
{/* Row 0: 战斗 + 背包 */}
|
||||
<Box>
|
||||
<MenuItem label="战斗" selected={cursorIndex === 0} />
|
||||
<MenuItem label="背包" selected={cursorIndex === 1} />
|
||||
</Box>
|
||||
{/* Row 1: 宝可梦 + 逃跑 */}
|
||||
<Box>
|
||||
<MenuItem label="宝可梦" selected={cursorIndex === 2} />
|
||||
<MenuItem label="逃跑" selected={cursorIndex === 3} disabled />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({ label, selected, disabled }: { label: string; selected: boolean; disabled?: boolean }) {
|
||||
if (selected && disabled) {
|
||||
return (
|
||||
<Box width={16}>
|
||||
<Text color="warning" bold>
|
||||
{' ▶ '}{label} (不可用)
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<Box width={16}>
|
||||
<Text color="success" bold>
|
||||
{' ▶ '}{label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Box width={16}>
|
||||
<Text dimColor>
|
||||
{' '}{label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width={16}>
|
||||
<Text>
|
||||
{' '}{label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MoveMenu({ moves, cursorIndex }: { moves: MoveOption[]; cursorIndex: number }) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 选择招式 ', position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
>
|
||||
{moves.map((move, i) => (
|
||||
<MoveItem key={move.id || i} move={move} selected={cursorIndex === i} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MoveItem({ move, selected }: { move: MoveOption; selected: boolean }) {
|
||||
const ppText = `PP ${move.pp}/${move.maxPp}`
|
||||
const noPP = move.pp <= 0 || move.disabled
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<Box width={32}>
|
||||
<Text color="success" bold>
|
||||
{' ▶ '}{move.name.padEnd(14)}{ppText}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width={32}>
|
||||
<Text color={noPP ? ('inactive' as any) : undefined} dimColor={noPP}>
|
||||
{' '}{move.name.padEnd(14)}{ppText}
|
||||
</Text>
|
||||
{move.disabled && <Text color="error"> 禁用</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
31
packages/pokemon/src/ui/BattleResultPanel.tsx
Normal file
31
packages/pokemon/src/ui/BattleResultPanel.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BattleResult } from '../battle/types'
|
||||
|
||||
interface BattleResultPanelProps {
|
||||
result: BattleResult
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
export function BattleResultPanel({ result, onContinue }: BattleResultPanelProps) {
|
||||
const isWin = result.winner === 'player'
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={isWin ? 'success' : 'error'}
|
||||
borderText={{ content: isWin ? ' 胜利 ' : ' 战败 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold color={isWin ? 'success' : 'error'}>
|
||||
{isWin ? '战斗胜利!' : '战斗失败...'}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="claude">[Enter] 继续</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
156
packages/pokemon/src/ui/BattleScene.tsx
Normal file
156
packages/pokemon/src/ui/BattleScene.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BattleState, WeatherKind } from '../battle/types'
|
||||
import type { SpeciesId } from '../types'
|
||||
import { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
|
||||
import { getFallbackSprite } from '../sprites/fallback'
|
||||
import { HpCard } from './HpCard'
|
||||
import { BattleMenu } from './BattleMenu'
|
||||
import { BattleLogPanel } from './BattleLogPanel'
|
||||
import { BattleSprite } from './BattleSprite'
|
||||
import type { StatusCondition } from '../battle/types'
|
||||
|
||||
export type MenuPhase = 'main' | 'fight' | 'bag' | 'pokemon'
|
||||
|
||||
/** Hook: get sprite lines with async fetch fallback */
|
||||
function useSpriteLines(speciesId: SpeciesId): string[] {
|
||||
const [tick, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (loadSprite(speciesId)) return
|
||||
fetchAndCacheSprite(speciesId).then(s => { if (s) setTick(t => t + 1) })
|
||||
}, [speciesId])
|
||||
void tick
|
||||
const cached = loadSprite(speciesId)
|
||||
return cached?.lines ?? getFallbackSprite(speciesId)
|
||||
}
|
||||
|
||||
interface BattleSceneProps {
|
||||
state: BattleState
|
||||
menuPhase: MenuPhase
|
||||
cursorIndex: number
|
||||
animEnabled: boolean
|
||||
/** Override content for right panel (bag/pokemon overlay) */
|
||||
overlay?: React.ReactNode
|
||||
onMoveCursor: (direction: 'up' | 'down' | 'left' | 'right') => void
|
||||
onSelect: () => void
|
||||
onBack: () => void
|
||||
onToggleAnim: () => void
|
||||
}
|
||||
|
||||
const WEATHER_LABELS: Record<WeatherKind, string> = {
|
||||
sun: '☀ 大晴天', rain: '🌧 雨天', sandstorm: '🌪 沙暴', hail: '❄ 冰雹',
|
||||
snow: '🌨 下雪', desolateland: '☀ 大日照', primordialsea: '🌧 大雨', deltastream: '🌀 强气流',
|
||||
}
|
||||
|
||||
export function BattleScene({
|
||||
state,
|
||||
menuPhase,
|
||||
cursorIndex,
|
||||
animEnabled,
|
||||
overlay,
|
||||
onMoveCursor,
|
||||
onSelect,
|
||||
onBack,
|
||||
onToggleAnim,
|
||||
}: BattleSceneProps) {
|
||||
const opp = state.opponentPokemon
|
||||
const player = state.playerPokemon
|
||||
|
||||
// Load sprite lines (with async fetch for uncached species)
|
||||
const oppSpriteLines = useSpriteLines(opp.speciesId as SpeciesId)
|
||||
const playerSpriteLines = useSpriteLines(player.speciesId as SpeciesId)
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width="100%">
|
||||
{/* Left: Battle Log (40%) */}
|
||||
<BattleLogPanel
|
||||
events={state.events}
|
||||
animEnabled={animEnabled}
|
||||
onToggleAnim={onToggleAnim}
|
||||
/>
|
||||
|
||||
{/* Right: Battle Field (60%) */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: state.weather ? ` ${WEATHER_LABELS[state.weather]} · 回合 ${state.turn} ` : ` 回合 ${state.turn} `, position: 'top', align: 'center' }}
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
width="60%"
|
||||
>
|
||||
{overlay ? (
|
||||
overlay
|
||||
) : (
|
||||
<>
|
||||
{/* Opponent info */}
|
||||
<Box flexDirection="row" justifyContent="flex-start">
|
||||
<HpCard
|
||||
name={opp.name}
|
||||
level={opp.level}
|
||||
hp={opp.hp}
|
||||
maxHp={opp.maxHp}
|
||||
status={opp.status as StatusCondition}
|
||||
align="left"
|
||||
isOpponent
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/*
|
||||
Keep the overlapping sprites inside a fixed-height battlefield with absolute positioning.
|
||||
Do NOT switch this back to negative margins or normal-flow overlap: Ink/Yoga reflow can leave
|
||||
visual ghosting above the player sprite during animation when overlap affects outer layout.
|
||||
*/}
|
||||
{/* Overlapped battlefield: fixed-height container so overlap won't disturb outer layout */}
|
||||
<Box height={18} marginTop={1} marginBottom={1} overflow="hidden">
|
||||
<Box position="absolute" top={0} right={0}>
|
||||
<BattleSprite
|
||||
lines={oppSpriteLines}
|
||||
animEnabled={animEnabled}
|
||||
/>
|
||||
</Box>
|
||||
<Box position="absolute" bottom={0} left={0}>
|
||||
<BattleSprite
|
||||
lines={playerSpriteLines}
|
||||
flip
|
||||
phaseOffset={2}
|
||||
animEnabled={animEnabled}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Player info */}
|
||||
<Box flexDirection="row" justifyContent="flex-end">
|
||||
<HpCard
|
||||
name={player.name}
|
||||
level={player.level}
|
||||
hp={player.hp}
|
||||
maxHp={player.maxHp}
|
||||
status={player.status as StatusCondition}
|
||||
align="right"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Menu */}
|
||||
{!state.finished && (
|
||||
<BattleMenu
|
||||
phase={menuPhase as 'main' | 'fight'}
|
||||
moves={player.moves}
|
||||
cursorIndex={cursorIndex}
|
||||
onMoveCursor={onMoveCursor}
|
||||
onSelect={onSelect}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.finished && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> 战斗结束</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
68
packages/pokemon/src/ui/BattleSprite.tsx
Normal file
68
packages/pokemon/src/ui/BattleSprite.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { parseSprite, renderSprite, flipSpriteLines, EMPTY_ROW } from '../sprites/renderer'
|
||||
import type { Pixel } from '../sprites/renderer'
|
||||
|
||||
/**
|
||||
* Simple battle sprite with fast 1-2px vertical bounce.
|
||||
* Padded so bounce never clips the sprite.
|
||||
*/
|
||||
|
||||
// Bounce pattern: 0 → 1 → 2 → 1 → 0 → ...
|
||||
const BOUNCE = [0, 1, 2, 1]
|
||||
/** Vertical padding above & below — bounce shifts within this space */
|
||||
const V_PAD = 3
|
||||
|
||||
interface BattleSpriteProps {
|
||||
/** ANSI sprite lines */
|
||||
lines: string[]
|
||||
/** Flip horizontally (player side) */
|
||||
flip?: boolean
|
||||
/** Enable animation (false = static) */
|
||||
animEnabled?: boolean
|
||||
/** Phase offset to stagger bounce between sprites */
|
||||
phaseOffset?: number
|
||||
}
|
||||
|
||||
export function BattleSprite({ lines, flip, animEnabled = true, phaseOffset = 0 }: BattleSpriteProps) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!animEnabled) return
|
||||
const timer = setInterval(() => setTick(t => t + 1), 120)
|
||||
return () => clearInterval(timer)
|
||||
}, [animEnabled])
|
||||
|
||||
// Flip once (cached)
|
||||
const source = useMemo(() => flip ? flipSpriteLines(lines) : lines, [lines, flip])
|
||||
|
||||
// Parse to pixel grid once (cached), then pad
|
||||
const padded = useMemo(() => {
|
||||
const grid = parseSprite(source)
|
||||
const top = Array.from({ length: V_PAD }, () => EMPTY_ROW)
|
||||
const bottom = Array.from({ length: V_PAD }, () => EMPTY_ROW)
|
||||
return [...top, ...grid, ...bottom]
|
||||
}, [source])
|
||||
|
||||
// Apply bounce offset with phase shift — shift up within padded space
|
||||
const offset = animEnabled ? BOUNCE[(tick + phaseOffset) % BOUNCE.length]! : 0
|
||||
const shifted = shiftGridUp(padded, offset)
|
||||
const rendered = renderSprite(shifted)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{rendered.map((line, i) => (
|
||||
<Text key={i}>{line || ' '}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** Shift Pixel grid up by n rows, pad empty rows at bottom */
|
||||
function shiftGridUp(grid: Pixel[][], n: number): Pixel[][] {
|
||||
if (n <= 0) return grid
|
||||
const height = grid.length
|
||||
const shifted = grid.slice(n)
|
||||
while (shifted.length < height) shifted.push(EMPTY_ROW)
|
||||
return shifted
|
||||
}
|
||||
130
packages/pokemon/src/ui/BattleView.tsx
Normal file
130
packages/pokemon/src/ui/BattleView.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BattleState, BattleEvent } from '../battle/types'
|
||||
|
||||
function hpColor(pct: number): 'success' | 'warning' | 'error' {
|
||||
if (pct > 50) return 'success'
|
||||
if (pct > 25) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function hpBar(current: number, max: number): { bar: string; pct: number } {
|
||||
if (max <= 0) return { bar: '░░░░░░░░░░', pct: 0 }
|
||||
const pct = Math.round((current / max) * 100)
|
||||
const filled = Math.round((current / max) * 10)
|
||||
return {
|
||||
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled)),
|
||||
pct,
|
||||
}
|
||||
}
|
||||
|
||||
interface BattleViewProps {
|
||||
state: BattleState
|
||||
onAction: (action: import('../battle/types').PlayerAction) => void
|
||||
}
|
||||
|
||||
export function BattleView({ state, onAction }: BattleViewProps) {
|
||||
const opp = state.opponentPokemon
|
||||
const player = state.playerPokemon
|
||||
const oppHp = hpBar(opp.hp, opp.maxHp)
|
||||
const playerHp = hpBar(player.hp, player.maxHp)
|
||||
|
||||
const recentEvents = state.events.slice(-10)
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="claude"
|
||||
borderText={{ content: ` 回合 ${state.turn} `, position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
{/* Opponent */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold> 野生的 </Text>
|
||||
<Text bold color="error">{opp.name}</Text>
|
||||
<Text dimColor> Lv.{opp.level}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor> HP </Text>
|
||||
<Text color={hpColor(oppHp.pct)}>{oppHp.bar}</Text>
|
||||
<Text> {opp.hp}/{opp.maxHp}</Text>
|
||||
{opp.status !== 'none' && <Text color="warning"> [{opp.status}]</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text color="inactive"> ─── vs ───</Text>
|
||||
|
||||
{/* Player */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold> </Text>
|
||||
<Text bold color="claude">{player.name}</Text>
|
||||
<Text dimColor> Lv.{player.level}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor> HP </Text>
|
||||
<Text color={hpColor(playerHp.pct)}>{playerHp.bar}</Text>
|
||||
<Text> {player.hp}/{player.maxHp}</Text>
|
||||
{player.status !== 'none' && <Text color="warning"> [{player.status}]</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Move selection */}
|
||||
{!state.finished && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="claude">选择行动</Text>
|
||||
{player.moves.map((move, i) => (
|
||||
<Box key={move.id || i}>
|
||||
<Text color={move.pp > 0 ? 'text' : 'inactive'}>
|
||||
{' '}[{i + 1}] {move.name || '---'}
|
||||
</Text>
|
||||
<Text dimColor> PP {move.pp}/{move.maxPp}</Text>
|
||||
{move.disabled && <Text color="error"> (禁用)</Text>}
|
||||
</Box>
|
||||
))}
|
||||
<Text color="claude"> [S] 换人</Text>
|
||||
<Text color="claude"> [I] 道具</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Event log */}
|
||||
{recentEvents.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{recentEvents.map((event, i) => (
|
||||
<Text key={i} color={eventColor(event)} dimColor> {formatEvent(event)}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function eventColor(event: BattleEvent): 'error' | 'success' | 'warning' | 'claude' | 'inactive' | 'text' {
|
||||
switch (event.type) {
|
||||
case 'damage': return 'error'
|
||||
case 'heal': return 'success'
|
||||
case 'faint': return 'error'
|
||||
case 'crit': return 'warning'
|
||||
case 'miss': return 'inactive'
|
||||
case 'effectiveness': return event.multiplier > 1 ? 'success' : 'warning'
|
||||
default: return 'inactive'
|
||||
}
|
||||
}
|
||||
|
||||
function formatEvent(event: BattleEvent): string {
|
||||
switch (event.type) {
|
||||
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
|
||||
case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害 (${event.percentage}%)`
|
||||
case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP`
|
||||
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}的 ${event.speciesId} 倒下了!`
|
||||
case 'crit': return '击中要害!'
|
||||
case 'miss': return '攻击没有命中!'
|
||||
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
|
||||
case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!`
|
||||
case 'turn': return `── 回合 ${event.number} ──`
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
159
packages/pokemon/src/ui/CompanionCard.tsx
Normal file
159
packages/pokemon/src/ui/CompanionCard.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react'
|
||||
import { Box, Text, type Color } from '@anthropic/ink'
|
||||
import type { BuddyData, Creature, SpeciesId } from '../types'
|
||||
import { STAT_NAMES, STAT_LABELS } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { SPECIES_PERSONALITY } from '../dex/names'
|
||||
import { calculateStats, getCreatureName, getTotalEV } from '../core/creature'
|
||||
import { getXpProgress } from '../core/experience'
|
||||
import { getEVSummary } from '../core/effort'
|
||||
import { getGenderSymbol } from '../core/gender'
|
||||
import { getStatColor } from './shared'
|
||||
import { getNextEvolution } from '../dex/evolution'
|
||||
import { StatBar } from './StatBar'
|
||||
|
||||
interface CompanionCardProps {
|
||||
creature: Creature
|
||||
buddyData: BuddyData
|
||||
spriteLines?: string[]
|
||||
}
|
||||
|
||||
// ANSI color constants
|
||||
const CYAN: Color = 'ansi:cyan'
|
||||
const YELLOW: Color = 'ansi:yellow'
|
||||
const GREEN: Color = 'ansi:green'
|
||||
const BLUE: Color = 'ansi:blue'
|
||||
const RED: Color = 'ansi:red'
|
||||
const MAGENTA: Color = 'ansi:magenta'
|
||||
const WHITE: Color = 'ansi:whiteBright'
|
||||
const GRAY: Color = 'ansi:white'
|
||||
|
||||
/** Type → display color mapping */
|
||||
const TYPE_COLORS: Record<string, Color> = {
|
||||
grass: 'ansi:green',
|
||||
poison: 'ansi:magenta',
|
||||
fire: 'ansi:red',
|
||||
flying: 'ansi:cyan',
|
||||
water: 'ansi:blue',
|
||||
electric: 'ansi:yellow',
|
||||
normal: 'ansi:white',
|
||||
}
|
||||
|
||||
/**
|
||||
* Redesigned companion card with Pokémon-style stats display.
|
||||
*/
|
||||
export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCardProps) {
|
||||
const species = getSpeciesData(creature.speciesId)
|
||||
const stats = calculateStats(creature)
|
||||
const xp = getXpProgress(creature)
|
||||
const genderSymbol = getGenderSymbol(creature.gender)
|
||||
const name = getCreatureName(creature)
|
||||
const evSummary = getEVSummary(creature)
|
||||
const totalEV = getTotalEV(creature)
|
||||
const nextEvo = getNextEvolution(creature.speciesId)
|
||||
|
||||
// Type badges
|
||||
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
|
||||
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
|
||||
{i > 0 ? '/' : ''}{t.toUpperCase()}
|
||||
</Text>
|
||||
))
|
||||
|
||||
// Friendship color
|
||||
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED
|
||||
|
||||
// Shiny badge
|
||||
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> ★SHINY★</Text> : null
|
||||
|
||||
// Evolution hint
|
||||
const evoHint = nextEvo ? (
|
||||
<Text color={GRAY}> → <Text color={CYAN}>{getSpeciesData(nextEvo.to).name}</Text> Lv.{nextEvo.minLevel}</Text>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
{/* Header row */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Text bold color={CYAN}>{name}</Text>
|
||||
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
|
||||
{shinyBadge}
|
||||
</Box>
|
||||
<Text bold>Lv.{creature.level}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Species + type + gender */}
|
||||
<Box>
|
||||
<Text color={GRAY}>{species.name}</Text>
|
||||
<Text> </Text>
|
||||
{typeBadges}
|
||||
{genderSymbol && <Text> {genderSymbol}</Text>}
|
||||
</Box>
|
||||
|
||||
{/* Sprite */}
|
||||
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||
{spriteLines ? (
|
||||
spriteLines.map((line, i) => <Text key={i}>{line}</Text>)
|
||||
) : (
|
||||
<Text color={GRAY}>[Loading sprite...]</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Personality */}
|
||||
<Box>
|
||||
<Text color={GRAY} italic>"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"</Text>
|
||||
</Box>
|
||||
|
||||
{/* Stats section */}
|
||||
<Box flexDirection="column" marginTop={0}>
|
||||
<Text color={GRAY}>─── Base Stats ───</Text>
|
||||
{STAT_NAMES.map((stat) => (
|
||||
<StatBar
|
||||
key={stat}
|
||||
label={STAT_LABELS[stat]}
|
||||
value={stats[stat]}
|
||||
maxValue={255}
|
||||
color={getStatColor(stat)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* XP progress */}
|
||||
<Box marginTop={0}>
|
||||
<Text color={GRAY}>XP </Text>
|
||||
<Text color={BLUE}>
|
||||
{'█'.repeat(Math.round(xp.percentage / 10))}
|
||||
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
|
||||
</Text>
|
||||
<Text> {xp.current}/{xp.needed}</Text>
|
||||
</Box>
|
||||
|
||||
{/* EV + Friendship */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={GRAY}>EV </Text>
|
||||
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
|
||||
<Text color={GRAY}> ({totalEV}/510)</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={GRAY}>♥ </Text>
|
||||
<Text color={friendshipColor}>
|
||||
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
|
||||
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
|
||||
</Text>
|
||||
<Text> {creature.friendship}/255</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Evolution hint */}
|
||||
{evoHint && (
|
||||
<Box marginTop={0}>
|
||||
<Text color={GRAY}>Next: </Text>
|
||||
{evoHint}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
54
packages/pokemon/src/ui/EggView.tsx
Normal file
54
packages/pokemon/src/ui/EggView.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import { Box, Text, type Color } from '@anthropic/ink'
|
||||
import type { Egg } from '../types'
|
||||
|
||||
const CYAN: Color = 'ansi:cyan'
|
||||
const YELLOW: Color = 'ansi:yellow'
|
||||
const GRAY: Color = 'ansi:white'
|
||||
|
||||
interface EggViewProps {
|
||||
egg: Egg
|
||||
}
|
||||
|
||||
/**
|
||||
* Egg status view showing hatch progress.
|
||||
*/
|
||||
export function EggView({ egg }: EggViewProps) {
|
||||
const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100)
|
||||
const filled = Math.round(percentage / 10)
|
||||
const empty = 10 - filled
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
|
||||
<Text bold color={CYAN}>
|
||||
Egg Status
|
||||
</Text>
|
||||
|
||||
{/* ASCII egg */}
|
||||
<Box flexDirection="column" alignItems="center" marginY={1}>
|
||||
<Text> . </Text>
|
||||
<Text> / \ </Text>
|
||||
<Text> | | </Text>
|
||||
<Text> \_/ </Text>
|
||||
</Box>
|
||||
|
||||
{/* Progress */}
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text>
|
||||
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
|
||||
</Text>
|
||||
<Text color={YELLOW}>
|
||||
{'█'.repeat(filled)}
|
||||
{'░'.repeat(empty)}
|
||||
</Text>
|
||||
<Text>{percentage}%</Text>
|
||||
</Box>
|
||||
|
||||
{/* Tips */}
|
||||
<Box marginTop={1} flexDirection="column" alignItems="center">
|
||||
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
|
||||
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
101
packages/pokemon/src/ui/EvolutionAnim.tsx
Normal file
101
packages/pokemon/src/ui/EvolutionAnim.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Box, Text, type Color } from '@anthropic/ink'
|
||||
import type { SpeciesId } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
|
||||
import { getFallbackSprite } from '../sprites/fallback'
|
||||
|
||||
const YELLOW: Color = 'ansi:yellow'
|
||||
const GREEN: Color = 'ansi:green'
|
||||
const GRAY: Color = 'ansi:white'
|
||||
|
||||
interface EvolutionAnimProps {
|
||||
fromSpecies: SpeciesId
|
||||
toSpecies: SpeciesId
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Evolution animation component.
|
||||
* Displays a flashing/morphing effect from old species to new species.
|
||||
* 8 frames × 500ms = ~4 seconds total.
|
||||
*/
|
||||
export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionAnimProps) {
|
||||
const [tick, setTick] = useState(0)
|
||||
const [spriteTick, setSpriteTick] = useState(0)
|
||||
const totalFrames = 8
|
||||
|
||||
// Prefetch sprites for both species
|
||||
useEffect(() => {
|
||||
for (const id of [fromSpecies, toSpecies]) {
|
||||
if (!loadSprite(id)) {
|
||||
fetchAndCacheSprite(id).then(s => { if (s) setSpriteTick(t => t + 1) })
|
||||
}
|
||||
}
|
||||
}, [fromSpecies, toSpecies])
|
||||
void spriteTick
|
||||
|
||||
useEffect(() => {
|
||||
if (tick >= totalFrames) {
|
||||
onComplete()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setTick((t) => t + 1), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [tick, onComplete])
|
||||
|
||||
const fromSprite = getSpriteLines(fromSpecies)
|
||||
const toSprite = getSpriteLines(toSpecies)
|
||||
const fromName = getSpeciesData(fromSpecies).name
|
||||
const toName = getSpeciesData(toSpecies).name
|
||||
|
||||
// Frame logic:
|
||||
// 0-3: old sprite with flash (alternate blank)
|
||||
// 4-7: alternate old/new, settle on new
|
||||
let displayLines: string[]
|
||||
if (tick < 3) {
|
||||
displayLines = tick % 2 === 0 ? fromSprite : fromSprite.map(() => '')
|
||||
} else if (tick < 6) {
|
||||
displayLines = tick % 2 === 0 ? fromSprite : toSprite
|
||||
} else {
|
||||
displayLines = toSprite
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1} alignItems="center">
|
||||
<Text bold color={YELLOW}>
|
||||
✨ Evolution! ✨
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" alignItems="center" marginY={1}>
|
||||
{displayLines.map((line, i) => (
|
||||
<Text key={i}>
|
||||
{tick >= 6 ? '✨ ' : ''}
|
||||
{line}
|
||||
{tick >= 6 ? ' ✨' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Text>
|
||||
<Text color={GRAY}>{fromName}</Text>
|
||||
<Text color={YELLOW}> → </Text>
|
||||
<Text bold color={GREEN}>
|
||||
{toName}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{tick >= totalFrames - 1 && (
|
||||
<Text bold color={GREEN}>
|
||||
进化成功!
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function getSpriteLines(speciesId: SpeciesId): string[] {
|
||||
const cached = loadSprite(speciesId)
|
||||
if (cached) return cached.lines
|
||||
return getFallbackSprite(speciesId)
|
||||
}
|
||||
85
packages/pokemon/src/ui/HpCard.tsx
Normal file
85
packages/pokemon/src/ui/HpCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { StatusCondition } from '../battle/types'
|
||||
|
||||
/** HP bar width in characters (GBA style) */
|
||||
const HP_BAR_WIDTH = 12
|
||||
|
||||
function hpColor(pct: number): string {
|
||||
if (pct > 50) return 'success'
|
||||
if (pct > 25) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function hpBar(current: number, max: number): { bar: string; pct: number } {
|
||||
if (max <= 0) return { bar: '░'.repeat(HP_BAR_WIDTH), pct: 0 }
|
||||
const pct = Math.round((current / max) * 100)
|
||||
const filled = Math.round((current / max) * HP_BAR_WIDTH)
|
||||
return {
|
||||
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, HP_BAR_WIDTH - filled)),
|
||||
pct,
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: StatusCondition): { text: string; color: string } | null {
|
||||
switch (status) {
|
||||
case 'poison':
|
||||
case 'bad_poison':
|
||||
return { text: 'PSN', color: 'warning' }
|
||||
case 'burn':
|
||||
return { text: 'BRN', color: 'error' }
|
||||
case 'paralysis':
|
||||
return { text: 'PAR', color: 'warning' }
|
||||
case 'freeze':
|
||||
return { text: 'FRZ', color: 'claude' }
|
||||
case 'sleep':
|
||||
return { text: 'SLP', color: 'inactive' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface HpCardProps {
|
||||
name: string
|
||||
level: number
|
||||
hp: number
|
||||
maxHp: number
|
||||
status?: StatusCondition
|
||||
/** Left = opponent (top-left), Right = player (bottom-right) */
|
||||
align: 'left' | 'right'
|
||||
/** Show as opponent (wild pokemon prefix) */
|
||||
isOpponent?: boolean
|
||||
}
|
||||
|
||||
export function HpCard({ name, level, hp, maxHp, status, align, isOpponent }: HpCardProps) {
|
||||
const { bar, pct } = hpBar(hp, maxHp)
|
||||
const statusInfo = status && status !== 'none' ? statusLabel(status) : null
|
||||
|
||||
const prefix = isOpponent ? '野生的 ' : ''
|
||||
|
||||
const nameLine = (
|
||||
<Box justifyContent={align === 'right' ? 'flex-end' : 'flex-start'}>
|
||||
{isOpponent && <Text bold> </Text>}
|
||||
<Text bold>{prefix}{name}</Text>
|
||||
<Text dimColor> Lv.{level}</Text>
|
||||
{statusInfo && (
|
||||
<Text color={statusInfo.color as any}> {statusInfo.text}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const hpLine = (
|
||||
<Box justifyContent={align === 'right' ? 'flex-end' : 'flex-start'}>
|
||||
<Text dimColor> HP </Text>
|
||||
<Text color={hpColor(pct) as any}>{bar}</Text>
|
||||
<Text> {hp}/{maxHp}</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{nameLine}
|
||||
{hpLine}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
84
packages/pokemon/src/ui/ItemPanel.tsx
Normal file
84
packages/pokemon/src/ui/ItemPanel.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
|
||||
interface ItemPanelProps {
|
||||
items: { id: string; name: string; count: number; description?: string }[]
|
||||
cursorIndex: number
|
||||
categoryIndex: number
|
||||
phase: 'category' | 'items'
|
||||
onSelect: (itemId: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** Item categories */
|
||||
const CATEGORIES = [
|
||||
{ id: 'healing', label: '回复药', filter: (id: string) => id.includes('potion') || id.includes('berry') || id.includes('heal') },
|
||||
{ id: 'ball', label: '精灵球', filter: (id: string) => id.includes('ball') },
|
||||
{ id: 'battle', label: '战斗道具', filter: (id: string) => id.includes('x-') || id.includes('dire') || id.includes('guard') },
|
||||
]
|
||||
|
||||
export function ItemPanel({ items, cursorIndex, categoryIndex, phase, onSelect, onCancel }: ItemPanelProps) {
|
||||
if (phase === 'category') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 背包 ', position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
>
|
||||
{CATEGORIES.map((cat, i) => (
|
||||
<Box key={cat.id}>
|
||||
{categoryIndex === i ? (
|
||||
<Text color="success" bold> ▶ {cat.label} </Text>
|
||||
) : (
|
||||
<Text> {cat.label} </Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> [ESC] 返回</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Phase: items — show items in selected category
|
||||
const cat = CATEGORIES[categoryIndex]
|
||||
const filtered = cat
|
||||
? items.filter(item => cat.filter(item.id))
|
||||
: items
|
||||
const displayItems = filtered.length > 0 ? filtered : items
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ` ${cat?.label ?? '道具'} `, position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
>
|
||||
{displayItems.length === 0 ? (
|
||||
<Text dimColor> 没有可用道具</Text>
|
||||
) : (
|
||||
displayItems.map((item, i) => (
|
||||
<Box key={item.id}>
|
||||
{cursorIndex === i ? (
|
||||
<Text color="success" bold> ▶ {item.name}</Text>
|
||||
) : (
|
||||
<Text> {item.name}</Text>
|
||||
)}
|
||||
<Text dimColor> ×{item.count}</Text>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> [ESC] 返回</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
59
packages/pokemon/src/ui/MoveLearnPanel.tsx
Normal file
59
packages/pokemon/src/ui/MoveLearnPanel.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Creature } from '../types'
|
||||
import { Dex } from '@pkmn/sim'
|
||||
|
||||
interface MoveLearnPanelProps {
|
||||
creature: Creature
|
||||
newMoveId: string
|
||||
cursorIndex: number
|
||||
onLearn: (replaceIndex: number) => void
|
||||
onSkip: () => void
|
||||
onSelectReplace: (index: number) => void
|
||||
}
|
||||
|
||||
export function MoveLearnPanel({ creature, newMoveId, cursorIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) {
|
||||
const dexMove = Dex.moves.get(newMoveId)
|
||||
const moveName = dexMove?.name ?? newMoveId
|
||||
const moveType = dexMove?.type ?? 'Normal'
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 新招式 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text>{creature.speciesId} 可以学习: <Text bold color="claude">{moveName}</Text> <Text dimColor>({moveType})</Text></Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>当前招式:</Text>
|
||||
{creature.moves.map((move, i) => {
|
||||
const isSelected = i === cursorIndex
|
||||
const moveInfo = move.id ? Dex.moves.get(move.id) : null
|
||||
return (
|
||||
<Box key={i}>
|
||||
{isSelected ? (
|
||||
<Text color="success" bold>
|
||||
{' ▶ '}{moveInfo?.name ?? move.id ?? '---'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
{' '}{moveInfo?.name ?? move.id ?? '---'}
|
||||
</Text>
|
||||
)}
|
||||
<Text dimColor> PP {move.pp}/{move.maxPp}</Text>
|
||||
{isSelected && <Text color="warning"> {'<-- 替换'}</Text>}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] 选择 · [Enter] 替换 · [S] 跳过</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
188
packages/pokemon/src/ui/PokedexView.tsx
Normal file
188
packages/pokemon/src/ui/PokedexView.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from 'react'
|
||||
import { Box, Text, type Color } from '@anthropic/ink'
|
||||
import type { BuddyData, SpeciesId } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
|
||||
const CYAN: Color = 'ansi:cyan'
|
||||
const GREEN: Color = 'ansi:green'
|
||||
const GRAY: Color = 'ansi:white'
|
||||
const YELLOW: Color = 'ansi:yellow'
|
||||
const WHITE: Color = 'ansi:whiteBright'
|
||||
|
||||
const BAR_WIDTH = 30
|
||||
|
||||
/** Gen ranges for stats */
|
||||
const GEN_RANGES = [
|
||||
{ label: 'Gen I', start: 1, end: 151 },
|
||||
{ label: 'Gen II', start: 152, end: 251 },
|
||||
{ label: 'Gen III', start: 252, end: 386 },
|
||||
{ label: 'Gen IV', start: 387, end: 493 },
|
||||
{ label: 'Gen V', start: 494, end: 649 },
|
||||
{ label: 'Gen VI', start: 650, end: 721 },
|
||||
{ label: 'Gen VII', start: 722, end: 809 },
|
||||
{ label: 'Gen VIII',start: 810, end: 905 },
|
||||
{ label: 'Gen IX', start: 906, end: 1025 },
|
||||
]
|
||||
|
||||
interface PokedexViewProps {
|
||||
buddyData: BuddyData
|
||||
}
|
||||
|
||||
/**
|
||||
* Pokédex view — shows collection progress, per-gen stats,
|
||||
* and discovered species list.
|
||||
*/
|
||||
export function PokedexView({ buddyData }: PokedexViewProps) {
|
||||
const dexMap = new Map(buddyData.dex.map((d) => [d.speciesId, d]))
|
||||
const collected = buddyData.dex.length
|
||||
const total = ALL_SPECIES_IDS.length
|
||||
const percent = total > 0 ? collected / total : 0
|
||||
|
||||
// Build dex number set for quick lookup
|
||||
const collectedNums = new Set<number>()
|
||||
for (const entry of buddyData.dex) {
|
||||
const data = getSpeciesData(entry.speciesId)
|
||||
collectedNums.add(data.dexNumber)
|
||||
}
|
||||
|
||||
// Per-gen stats
|
||||
const genStats = GEN_RANGES.map(g => {
|
||||
const genTotal = ALL_SPECIES_IDS.filter(id => {
|
||||
const n = getSpeciesData(id).dexNumber
|
||||
return n >= g.start && n <= g.end
|
||||
}).length
|
||||
const genCollected = [...collectedNums].filter(n => n >= g.start && n <= g.end).length
|
||||
return { ...g, total: genTotal, collected: genCollected }
|
||||
})
|
||||
|
||||
// Discovered species (for compact display)
|
||||
const discovered = buddyData.dex
|
||||
.map(entry => {
|
||||
const species = getSpeciesData(entry.speciesId)
|
||||
return { entry, species }
|
||||
})
|
||||
.sort((a, b) => a.species.dexNumber - b.species.dexNumber)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
{/* Header with percentage */}
|
||||
<Box justifyContent="space-between">
|
||||
<Text bold color={CYAN}>Pokédex</Text>
|
||||
<Text>
|
||||
<Text bold color={collected === total ? GREEN : WHITE}>{collected}</Text>
|
||||
<Text color={GRAY}>/{total} </Text>
|
||||
<Text bold color={GREEN}>{(percent * 100).toFixed(1)}%</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Fixed-width progress bar */}
|
||||
<Box>
|
||||
<Text color={GREEN}>{'█'.repeat(Math.round(percent * BAR_WIDTH))}</Text>
|
||||
<Text color={GRAY}>{'░'.repeat(BAR_WIDTH - Math.round(percent * BAR_WIDTH))}</Text>
|
||||
<Text> {Math.floor(percent * 100)}%</Text>
|
||||
</Box>
|
||||
|
||||
{/* Per-gen stats */}
|
||||
<Box flexDirection="column" marginTop={0}>
|
||||
<Text color={GRAY}>─── 分代统计 ───</Text>
|
||||
{genStats.map(g => {
|
||||
const p = g.total > 0 ? g.collected / g.total : 0
|
||||
const miniBar = '█'.repeat(Math.round(p * 10)) + '░'.repeat(10 - Math.round(p * 10))
|
||||
return (
|
||||
<Box key={g.label}>
|
||||
<Text color={GRAY}>{g.label.padEnd(8)}</Text>
|
||||
<Text color={p >= 1 ? GREEN : p > 0 ? YELLOW : GRAY}>{miniBar}</Text>
|
||||
<Text> <Text bold>{g.collected}</Text><Text color={GRAY}>/{g.total}</Text></Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Discovered species list */}
|
||||
{discovered.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={0}>
|
||||
<Text color={GRAY}>─── 已发现 ({discovered.length}) ───</Text>
|
||||
{discovered.map(({ entry, species }) => {
|
||||
const isActive = buddyData.party[0]
|
||||
? buddyData.creatures.some(c => c.id === buddyData.party[0] && c.speciesId === species.id)
|
||||
: false
|
||||
return (
|
||||
<Box key={species.id}>
|
||||
<Text>{isActive ? <Text color={YELLOW}>▶</Text> : ' '}</Text>
|
||||
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
|
||||
<Text color={WHITE} bold={isActive}>
|
||||
{species.name}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
{species.types.filter((t): t is string => Boolean(t)).map((t, ti) => (
|
||||
<Text key={t} color={getTypeColor(t)}>
|
||||
{ti > 0 ? '/' : ''}{t.slice(0, 3).toUpperCase()}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
|
||||
{entry.caughtCount > 1 && (
|
||||
<Text color={GRAY}> x{entry.caughtCount}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{discovered.length === 0 && (
|
||||
<Box marginTop={0}>
|
||||
<Text dimColor> 还没有发现任何精灵,开始冒险吧!</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<Box marginTop={0} flexDirection="column">
|
||||
<Text color={GRAY}>─── Stats ───</Text>
|
||||
<Box>
|
||||
<Text color={GRAY}>Turns: </Text>
|
||||
<Text>{buddyData.stats.totalTurns}</Text>
|
||||
<Text color={GRAY}> Days: </Text>
|
||||
<Text>{buddyData.stats.consecutiveDays}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={GRAY}>Eggs: </Text>
|
||||
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
||||
<Text color={GRAY}> Evolutions: </Text>
|
||||
<Text>{buddyData.stats.totalEvolutions}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Egg info */}
|
||||
{buddyData.eggs.length > 0 && (
|
||||
<Box marginTop={0}>
|
||||
<Text color={YELLOW}>🥚 Egg: </Text>
|
||||
<Text>{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}</Text>
|
||||
<Text color={GRAY}> steps</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{buddyData.stats.consecutiveDays < 7 && (
|
||||
<Box>
|
||||
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** Type → color mapping */
|
||||
function getTypeColor(type: string): Color {
|
||||
const colors: Record<string, Color> = {
|
||||
grass: 'ansi:green',
|
||||
poison: 'ansi:magenta',
|
||||
fire: 'ansi:red',
|
||||
flying: 'ansi:cyan',
|
||||
water: 'ansi:blue',
|
||||
electric: 'ansi:yellow',
|
||||
normal: 'ansi:white',
|
||||
}
|
||||
return colors[type] ?? 'ansi:white'
|
||||
}
|
||||
176
packages/pokemon/src/ui/SpeciesDetail.tsx
Normal file
176
packages/pokemon/src/ui/SpeciesDetail.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from 'react'
|
||||
import { Box, Text, type Color } from '@anthropic/ink'
|
||||
import type { SpeciesId, StatName } from '../types'
|
||||
import { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { getNextEvolution } from '../dex/evolution'
|
||||
import { StatBar } from './StatBar'
|
||||
import { getStatColor } from './shared'
|
||||
|
||||
const CYAN: Color = 'ansi:cyan'
|
||||
const GRAY: Color = 'ansi:white'
|
||||
const WHITE: Color = 'ansi:whiteBright'
|
||||
const YELLOW: Color = 'ansi:yellow'
|
||||
const GREEN: Color = 'ansi:green'
|
||||
const RED: Color = 'ansi:red'
|
||||
const BLUE: Color = 'ansi:blue'
|
||||
|
||||
/** Type → color */
|
||||
const TYPE_COLORS: Record<string, Color> = {
|
||||
grass: 'ansi:green', poison: 'ansi:magenta', fire: 'ansi:red',
|
||||
flying: 'ansi:cyan', water: 'ansi:blue', electric: 'ansi:yellow',
|
||||
normal: 'ansi:white',
|
||||
}
|
||||
|
||||
interface SpeciesDetailProps {
|
||||
speciesId: SpeciesId
|
||||
caughtLevel?: number
|
||||
spriteLines?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed species info page — base stats, evolution chain, flavor text.
|
||||
*/
|
||||
export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDetailProps) {
|
||||
const species = getSpeciesData(speciesId)
|
||||
const nextEvo = getNextEvolution(speciesId)
|
||||
|
||||
// Type badges
|
||||
const typeBadges = species.types.filter((t): t is string => Boolean(t)).map((t, i) => (
|
||||
<Text key={t} color={TYPE_COLORS[t] ?? GRAY}>
|
||||
{i > 0 ? ' / ' : ''}{t.toUpperCase()}
|
||||
</Text>
|
||||
))
|
||||
|
||||
// Gender info
|
||||
const genderInfo = species.genderRate === -1
|
||||
? 'Genderless'
|
||||
: species.genderRate === 0
|
||||
? '♂ 100%'
|
||||
: species.genderRate === 8
|
||||
? '♀ 100%'
|
||||
: `♀ ${(species.genderRate / 8 * 100).toFixed(1)}%`
|
||||
|
||||
// Max base stat for bar scaling
|
||||
const maxBase = 130
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
{/* Header */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} {species.name}</Text>
|
||||
</Box>
|
||||
{caughtLevel && <Text color={GREEN}>Best: Lv.{caughtLevel}</Text>}
|
||||
</Box>
|
||||
|
||||
{/* Type + gender */}
|
||||
<Box>
|
||||
{typeBadges}
|
||||
<Text color={GRAY}> {genderInfo}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Sprite */}
|
||||
{spriteLines && (
|
||||
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||
{spriteLines.map((line, i) => <Text key={i}>{line}</Text>)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Flavor text */}
|
||||
{species.flavorText && (
|
||||
<Box marginTop={0}>
|
||||
<Text color={GRAY} italic>{species.flavorText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Base Stats */}
|
||||
<Box flexDirection="column" marginTop={0}>
|
||||
<Text color={GRAY}>─── Base Stats ───</Text>
|
||||
{STAT_NAMES.map((stat) => (
|
||||
<Box key={stat}>
|
||||
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
|
||||
<Text color={getStatColor(stat)}>
|
||||
{'█'.repeat(Math.round((species.baseStats[stat] / maxBase) * 15))}
|
||||
{'░'.repeat(15 - Math.round((species.baseStats[stat] / maxBase) * 15))}
|
||||
</Text>
|
||||
<Text> {String(species.baseStats[stat]).padStart(3)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{/* Total */}
|
||||
<Box>
|
||||
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
|
||||
<Text color={GRAY}>
|
||||
{'─'.repeat(15)}
|
||||
</Text>
|
||||
<Text bold> {Object.values(species.baseStats).reduce((a, b) => a + b, 0)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Evolution chain */}
|
||||
{(nextEvo || species.dexNumber > 1) && (
|
||||
<Box flexDirection="column" marginTop={0}>
|
||||
<Text color={GRAY}>─── Evolution ───</Text>
|
||||
<EvolutionChain speciesId={speciesId} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<Box flexDirection="column" marginTop={0}>
|
||||
<Text color={GRAY}>─── Info ───</Text>
|
||||
<Box>
|
||||
<Text color={GRAY}>Growth: </Text>
|
||||
<Text>{species.growthRate}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={GRAY}>Capture: </Text>
|
||||
<Text>{species.captureRate}</Text>
|
||||
<Text color={GRAY}> Happiness: </Text>
|
||||
<Text>{species.baseHappiness}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render evolution chain arrow */
|
||||
function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) {
|
||||
// Walk back to find chain head
|
||||
let head: SpeciesId = speciesId
|
||||
for (const candidate of ALL_SPECIES_IDS) {
|
||||
const evo = getNextEvolution(candidate)
|
||||
if (evo?.to === head) {
|
||||
head = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const chain: SpeciesId[] = [head]
|
||||
let current: SpeciesId | undefined = head
|
||||
while (current) {
|
||||
const next = getNextEvolution(current)
|
||||
if (next) {
|
||||
chain.push(next.to)
|
||||
current = next.to
|
||||
} else {
|
||||
current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{chain.map((sid, i) => (
|
||||
<React.Fragment key={sid}>
|
||||
{i > 0 && <Text color={GRAY}> → </Text>}
|
||||
<Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}>
|
||||
{getSpeciesData(sid).name}
|
||||
</Text>
|
||||
{i < chain.length - 1 && getNextEvolution(sid) && (
|
||||
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
79
packages/pokemon/src/ui/SpeciesPicker.tsx
Normal file
79
packages/pokemon/src/ui/SpeciesPicker.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { Box, Text, FuzzyPicker } from '@anthropic/ink'
|
||||
import type { SpeciesId } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
|
||||
/** Pre-computed species entry for picker */
|
||||
type SpeciesEntry = {
|
||||
id: SpeciesId
|
||||
name: string
|
||||
displayName: string // zh name or English name
|
||||
dexNumber: number
|
||||
types: string[]
|
||||
}
|
||||
|
||||
// Build all entries once (species data is cached internally by getSpeciesData)
|
||||
const ALL_ENTRIES: SpeciesEntry[] = ALL_SPECIES_IDS.map(id => {
|
||||
const data = getSpeciesData(id)
|
||||
return {
|
||||
id,
|
||||
name: data.name,
|
||||
displayName: data.name,
|
||||
dexNumber: data.dexNumber,
|
||||
types: data.types as string[],
|
||||
}
|
||||
})
|
||||
|
||||
/** Searchable species picker using FuzzyPicker */
|
||||
export function SpeciesPicker({
|
||||
onSelect,
|
||||
onCancel,
|
||||
title = '选择精灵',
|
||||
}: {
|
||||
onSelect: (speciesId: SpeciesId) => void
|
||||
onCancel: () => void
|
||||
title?: string
|
||||
}) {
|
||||
const [filtered, setFiltered] = useState<SpeciesEntry[]>(ALL_ENTRIES.slice(0, 50))
|
||||
|
||||
const handleQueryChange = useCallback((q: string) => {
|
||||
if (!q.trim()) {
|
||||
setFiltered(ALL_ENTRIES.slice(0, 50))
|
||||
return
|
||||
}
|
||||
const lower = q.toLowerCase()
|
||||
const matched = ALL_ENTRIES.filter(e =>
|
||||
e.id.includes(lower) ||
|
||||
e.name.toLowerCase().includes(lower) ||
|
||||
e.displayName.includes(q) ||
|
||||
String(e.dexNumber).includes(q)
|
||||
)
|
||||
setFiltered(matched.slice(0, 100))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FuzzyPicker<SpeciesEntry>
|
||||
title={title}
|
||||
placeholder="输入名称或编号搜索…"
|
||||
items={filtered}
|
||||
getKey={item => item.id}
|
||||
renderItem={(item, focused) => (
|
||||
<Box>
|
||||
<Text color={focused ? 'claude' : undefined} bold={focused}>
|
||||
#{String(item.dexNumber).padStart(3, '0')} {item.displayName}
|
||||
</Text>
|
||||
{item.displayName !== item.name && (
|
||||
<Text dimColor> {item.name}</Text>
|
||||
)}
|
||||
<Text color="inactive"> {item.types.join('/')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
onQueryChange={handleQueryChange}
|
||||
onSelect={item => onSelect(item.id)}
|
||||
onCancel={onCancel}
|
||||
emptyMessage={q => `没有找到 "${q}" 相关的精灵`}
|
||||
matchLabel={filtered.length < ALL_ENTRIES.length ? `${filtered.length} 个结果` : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
81
packages/pokemon/src/ui/SpriteAnimator.tsx
Normal file
81
packages/pokemon/src/ui/SpriteAnimator.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { Box, Text, type Color } from '@anthropic/ink'
|
||||
import type { AnimMode } from '../types'
|
||||
import { renderAnimatedSprite, flipSpriteLines, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
|
||||
|
||||
/** Vertical padding — bounce shifts within this space */
|
||||
const V_PAD = 4
|
||||
|
||||
interface SpriteAnimatorProps {
|
||||
/** Base sprite lines (ANSI is preserved) */
|
||||
lines: string[]
|
||||
/** Text color for the sprite */
|
||||
color?: Color
|
||||
/** Tick interval in ms (default 250) */
|
||||
tickMs?: number
|
||||
/** Single mode; omit for idle auto-play */
|
||||
mode?: AnimMode
|
||||
/** Center horizontally (default true) */
|
||||
centered?: boolean
|
||||
/** Show pet hearts overlay */
|
||||
petting?: boolean
|
||||
/** Flip horizontally (for player Pokemon facing opponent) */
|
||||
flip?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated sprite renderer with built-in tick loop.
|
||||
*
|
||||
* - Keeps ANSI intact (parse → pixel grid → transform → render)
|
||||
* - Pads vertically so bounce never shifts layout
|
||||
* - Grid transforms guarantee fixed output height
|
||||
*/
|
||||
export function SpriteAnimator({
|
||||
lines,
|
||||
color,
|
||||
tickMs = 100,
|
||||
mode,
|
||||
centered = true,
|
||||
petting,
|
||||
flip,
|
||||
}: SpriteAnimatorProps) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTick(t => t + 1), tickMs)
|
||||
return () => clearInterval(timer)
|
||||
}, [tickMs])
|
||||
|
||||
// Flip sprite if needed (cached)
|
||||
const sourceLines = useMemo(
|
||||
() => flip ? flipSpriteLines(lines) : lines,
|
||||
[lines, flip],
|
||||
)
|
||||
|
||||
// Add vertical padding — bounce shifts within this space
|
||||
const padded = [...Array(V_PAD).fill(''), ...sourceLines, ...Array(V_PAD).fill('')]
|
||||
|
||||
// Apply animation (renderer parses to pixels, transforms, renders back)
|
||||
const currentMode = mode ?? getIdleAnimMode(tick)
|
||||
const animated = renderAnimatedSprite(padded, tick, currentMode)
|
||||
|
||||
// Pet hearts overlay
|
||||
const overlay = petting ? getPetOverlay(tick) : null
|
||||
const displayLines = overlay ? [...overlay, ...animated] : animated
|
||||
|
||||
const spriteBlock = (
|
||||
<Box flexDirection="column">
|
||||
{displayLines.map((line, i) => (
|
||||
<Text key={i} color={color}>{line || ' '}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (!centered) return spriteBlock
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="center" width="100%">
|
||||
{spriteBlock}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
28
packages/pokemon/src/ui/StatBar.tsx
Normal file
28
packages/pokemon/src/ui/StatBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
import { Box, Text, type Color } from '@anthropic/ink'
|
||||
|
||||
interface StatBarProps {
|
||||
label: string
|
||||
value: number
|
||||
maxValue: number
|
||||
color?: Color
|
||||
width?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact horizontal stat bar for Pokémon stats.
|
||||
*/
|
||||
export function StatBar({ label, value, maxValue, color = 'ansi:green', width = 12 }: StatBarProps) {
|
||||
const filled = Math.round((value / maxValue) * width)
|
||||
const empty = width - filled
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(empty)
|
||||
const valueStr = String(value).padStart(3)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color="ansi:whiteBright">{label.padEnd(3)}</Text>
|
||||
<Text color={color}>{bar}</Text>
|
||||
<Text> {valueStr}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
80
packages/pokemon/src/ui/SwitchPanel.tsx
Normal file
80
packages/pokemon/src/ui/SwitchPanel.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Creature } from '../types'
|
||||
import { getCreatureName } from '../core/creature'
|
||||
|
||||
interface SwitchPanelProps {
|
||||
party: Creature[]
|
||||
activeId: string
|
||||
cursorIndex: number
|
||||
/** HP values from battle state (keyed by creature id) */
|
||||
battleHp?: Record<string, { hp: number; maxHp: number }>
|
||||
onSelect: (creatureId: string, partyIndex: number) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function hpBarSmall(current: number, max: number): string {
|
||||
if (max <= 0) return '░░░░░░'
|
||||
const filled = Math.round((current / max) * 6)
|
||||
return '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 6 - filled))
|
||||
}
|
||||
|
||||
function hpColorStr(pct: number): string {
|
||||
if (pct > 50) return 'success'
|
||||
if (pct > 25) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
export function SwitchPanel({ party, activeId, cursorIndex, battleHp, onCancel }: SwitchPanelProps) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 换人 ', position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
>
|
||||
{party.map((creature, i) => {
|
||||
const isActive = creature.id === activeId
|
||||
const hpData = battleHp?.[creature.id]
|
||||
const hp = hpData?.hp ?? 0
|
||||
const maxHp = hpData?.maxHp ?? 1
|
||||
const hpPct = maxHp > 0 ? Math.round((hp / maxHp) * 100) : 0
|
||||
const isFainted = hpData ? hp <= 0 : false
|
||||
|
||||
return (
|
||||
<Box key={creature.id}>
|
||||
{cursorIndex === i ? (
|
||||
<Text color="success" bold>
|
||||
{' ▸ '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' '}
|
||||
</Text>
|
||||
) : isActive ? (
|
||||
<Text dimColor>
|
||||
{' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' (场上) '}
|
||||
</Text>
|
||||
) : isFainted ? (
|
||||
<Text dimColor>
|
||||
{' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' (倒下) '}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
{' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' '}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hpData && (
|
||||
<Text color={hpColorStr(hpPct) as any}>
|
||||
{hpBarSmall(hp, maxHp)} {hp}/{maxHp}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> [ESC] 返回</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
14
packages/pokemon/src/ui/shared.ts
Normal file
14
packages/pokemon/src/ui/shared.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Color } from '@anthropic/ink'
|
||||
|
||||
const STAT_COLORS: Record<string, Color> = {
|
||||
hp: 'ansi:green',
|
||||
attack: 'ansi:red',
|
||||
defense: 'ansi:yellow',
|
||||
spAtk: 'ansi:blue',
|
||||
spDef: 'ansi:magenta',
|
||||
speed: 'ansi:cyan',
|
||||
}
|
||||
|
||||
export function getStatColor(stat: string): Color {
|
||||
return STAT_COLORS[stat] ?? 'ansi:white'
|
||||
}
|
||||
8
packages/pokemon/tsconfig.json
Normal file
8
packages/pokemon/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -112,6 +112,9 @@ export type SetToolJSXFn = (
|
||||
isImmediate?: boolean
|
||||
/** Set to true to clear a local JSX command (e.g., from its onDone callback) */
|
||||
clearLocalJSX?: boolean
|
||||
/** Called when the panel is dismissed externally (e.g. ESC via CancelRequestHandler).
|
||||
* Must resolve the underlying Promise in processSlashCommand.tsx. */
|
||||
onDismiss?: () => void
|
||||
} | null,
|
||||
) => void
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Companion display card — shown by /buddy (no args).
|
||||
* Mirrors official vc8 component: bordered box with sprite, stats, last reaction.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useInput } from '@anthropic/ink';
|
||||
import { renderSprite } from './sprites.js';
|
||||
import { RARITY_COLORS, RARITY_STARS, STAT_NAMES, type Companion } from './types.js';
|
||||
|
||||
const CARD_WIDTH = 40;
|
||||
const CARD_PADDING_X = 2;
|
||||
|
||||
function StatBar({ name, value }: { name: string; value: number }) {
|
||||
const clamped = Math.max(0, Math.min(100, value));
|
||||
const filled = Math.round(clamped / 10);
|
||||
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
|
||||
return (
|
||||
<Text>
|
||||
{name.padEnd(10)} {bar} {String(value).padStart(3)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanionCard({
|
||||
companion,
|
||||
lastReaction,
|
||||
onDone,
|
||||
}: {
|
||||
companion: Companion;
|
||||
lastReaction?: string;
|
||||
onDone?: (result?: string, options?: { display?: string }) => void;
|
||||
}) {
|
||||
const color = RARITY_COLORS[companion.rarity];
|
||||
const stars = RARITY_STARS[companion.rarity];
|
||||
const sprite = renderSprite(companion, 0);
|
||||
|
||||
// Press any key to dismiss
|
||||
useInput(
|
||||
() => {
|
||||
onDone?.(undefined, { display: 'skip' });
|
||||
},
|
||||
{ isActive: onDone !== undefined },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={color}
|
||||
paddingX={CARD_PADDING_X}
|
||||
paddingY={1}
|
||||
width={CARD_WIDTH}
|
||||
flexShrink={0}
|
||||
>
|
||||
{/* Header: rarity + species */}
|
||||
<Box justifyContent="space-between">
|
||||
<Text bold color={color}>
|
||||
{stars} {companion.rarity.toUpperCase()}
|
||||
</Text>
|
||||
<Text color={color}>{companion.species.toUpperCase()}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Shiny indicator */}
|
||||
{companion.shiny && (
|
||||
<Text color="warning" bold>
|
||||
{'\u2728'} SHINY {'\u2728'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Sprite */}
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
{sprite.map((line, i) => (
|
||||
<Text key={i} color={color}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Name */}
|
||||
<Text bold>{companion.name}</Text>
|
||||
|
||||
{/* Personality */}
|
||||
<Box marginY={1}>
|
||||
<Text dimColor italic>
|
||||
"{companion.personality}"
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Box flexDirection="column">
|
||||
{STAT_NAMES.map(name => (
|
||||
<StatBar key={name} name={name} value={companion.stats[name] ?? 0} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Last reaction */}
|
||||
{lastReaction && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>last said</Text>
|
||||
<Box borderStyle="round" borderColor="inactive" paddingX={1}>
|
||||
<Text dimColor italic>
|
||||
{lastReaction}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,347 +1,342 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import figures from 'figures'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import type { AppState } from '../state/AppStateStore.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js'
|
||||
import type { Theme } from '../utils/theme.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
|
||||
import { RARITY_COLORS } from './types.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import figures from 'figures';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js';
|
||||
import type { AppState } from '../state/AppStateStore.js';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js';
|
||||
import {
|
||||
loadBuddyData,
|
||||
getActiveCreature,
|
||||
getCreatureName,
|
||||
getXpProgress,
|
||||
loadSprite,
|
||||
fetchAndCacheSprite,
|
||||
getFallbackSprite,
|
||||
renderAnimatedSprite,
|
||||
getIdleAnimMode,
|
||||
getSpeciesData,
|
||||
type Creature,
|
||||
type AnimMode,
|
||||
} from '@claude-code-best/pokemon';
|
||||
|
||||
const TICK_MS = 500
|
||||
const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms
|
||||
const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go
|
||||
const PET_BURST_MS = 2500 // how long hearts float after /buddy pet
|
||||
const TICK_MS = 500;
|
||||
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
|
||||
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go
|
||||
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
|
||||
|
||||
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
|
||||
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
|
||||
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
|
||||
// Module-level cache for sync access in render
|
||||
let _cachedCreature: Creature | null = null;
|
||||
let _cacheLoadPromise: Promise<void> | null = null;
|
||||
|
||||
function ensureCreatureCache(): void {
|
||||
if (_cachedCreature !== null || _cacheLoadPromise) return;
|
||||
_cacheLoadPromise = loadBuddyData().then(data => {
|
||||
_cachedCreature = getActiveCreature(data);
|
||||
_cacheLoadPromise = null;
|
||||
}).catch(() => { _cacheLoadPromise = null; });
|
||||
}
|
||||
|
||||
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
|
||||
const H = figures.heart
|
||||
const H = figures.heart;
|
||||
const PET_HEARTS = [
|
||||
` ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
`${H} ${H} ${H} `,
|
||||
'· · · ',
|
||||
]
|
||||
];
|
||||
|
||||
function wrap(text: string, width: number): string[] {
|
||||
const words = text.split(' ')
|
||||
const lines: string[] = []
|
||||
let cur = ''
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let cur = '';
|
||||
for (const w of words) {
|
||||
if (cur.length + w.length + 1 > width && cur) {
|
||||
lines.push(cur)
|
||||
cur = w
|
||||
lines.push(cur);
|
||||
cur = w;
|
||||
} else {
|
||||
cur = cur ? `${cur} ${w}` : w
|
||||
cur = cur ? `${cur} ${w}` : w;
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur)
|
||||
return lines
|
||||
if (cur) lines.push(cur);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function SpeechBubble({
|
||||
text,
|
||||
color,
|
||||
fading,
|
||||
tail,
|
||||
}: {
|
||||
text: string
|
||||
color: keyof Theme
|
||||
fading: boolean
|
||||
tail: 'down' | 'right'
|
||||
}): React.ReactNode {
|
||||
const lines = wrap(text, 30)
|
||||
const borderColor = fading ? 'inactive' : color
|
||||
function SpeechBubble({ text, fading }: { text: string; fading: boolean; tail: 'down' | 'right' }): React.ReactNode {
|
||||
const lines = wrap(text, 30);
|
||||
const borderColor = fading ? 'inactive' : 'claude';
|
||||
const bubble = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
width={34}
|
||||
>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
|
||||
{lines.map((l, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
italic
|
||||
dimColor={!fading}
|
||||
color={fading ? 'inactive' : undefined}
|
||||
>
|
||||
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
if (tail === 'right') {
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{bubble}
|
||||
<Text color={borderColor}>─</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{bubble}
|
||||
<Text color={borderColor}>─</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// For fullscreen floating bubble
|
||||
function FloatingBubble({ text, fading }: { text: string; fading: boolean }): React.ReactNode {
|
||||
const lines = wrap(text, 30);
|
||||
const borderColor = fading ? 'inactive' : 'claude';
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
|
||||
{bubble}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
|
||||
{lines.map((l, i) => (
|
||||
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box flexDirection="column" alignItems="flex-end" paddingRight={6}>
|
||||
<Text color={borderColor}>╲ </Text>
|
||||
<Text color={borderColor}>╲</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100
|
||||
const SPRITE_BODY_WIDTH = 12
|
||||
const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
|
||||
const SPRITE_PADDING_X = 2
|
||||
const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
|
||||
const NARROW_QUIP_CAP = 24
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100;
|
||||
const SPRITE_BODY_WIDTH = 12;
|
||||
const NAME_ROW_PAD = 2;
|
||||
const SPRITE_PADDING_X = 2;
|
||||
const BUBBLE_WIDTH = 36;
|
||||
const NARROW_QUIP_CAP = 24;
|
||||
|
||||
function spriteColWidth(nameWidth: number): number {
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD)
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD);
|
||||
}
|
||||
|
||||
// Width the sprite area consumes. PromptInput subtracts this so text wraps
|
||||
// correctly. In fullscreen the bubble floats over scrollback (no extra
|
||||
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
|
||||
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
|
||||
// (above input in fullscreen, below in scrollback), so no reservation.
|
||||
export function companionReservedColumns(
|
||||
terminalColumns: number,
|
||||
speaking: boolean,
|
||||
): number {
|
||||
if (!feature('BUDDY')) return 0
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
|
||||
const nameWidth = stringWidth(companion.name)
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
|
||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble
|
||||
/**
|
||||
* Get active Pokémon creature from cache, or null if not loaded yet.
|
||||
* Triggers async load if cache is empty.
|
||||
*/
|
||||
function getPokemonCreature(): Creature | null {
|
||||
try {
|
||||
ensureCreatureCache();
|
||||
return _cachedCreature;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-refresh the creature cache (call after data changes).
|
||||
*/
|
||||
export function refreshCreatureCache(): void {
|
||||
_cachedCreature = null;
|
||||
_cacheLoadPromise = null;
|
||||
ensureCreatureCache();
|
||||
}
|
||||
|
||||
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
||||
if (!feature('BUDDY')) return 0;
|
||||
const creature = getPokemonCreature();
|
||||
if (!creature || getGlobalConfig().companionMuted) return 0;
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
|
||||
const name = getCreatureName(creature);
|
||||
const nameWidth = stringWidth(name);
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
|
||||
// Without sprite art, only need name row width + padding + optional bubble
|
||||
return nameWidth + NAME_ROW_PAD + SPRITE_PADDING_X + bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sprite lines for a creature with animated mode applied.
|
||||
*/
|
||||
function getAnimatedSpriteLines(creature: Creature, tick: number, mode: AnimMode): string[] {
|
||||
const cached = loadSprite(creature.speciesId);
|
||||
const baseLines = cached?.lines ?? getFallbackSprite(creature.speciesId);
|
||||
return renderAnimatedSprite(baseLines, tick, mode);
|
||||
}
|
||||
|
||||
export function CompanionSprite(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const petAt = useAppState(s => s.companionPetAt)
|
||||
const focused = useAppState(s => s.footerSelection === 'companion')
|
||||
const setAppState = useSetAppState()
|
||||
const { columns } = useTerminalSize()
|
||||
const [tick, setTick] = useState(0)
|
||||
const lastSpokeTick = useRef(0)
|
||||
// Sync-during-render (not useEffect) so the first post-pet render already
|
||||
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const petAt = useAppState(s => s.companionPetAt);
|
||||
const xpInfo = useAppState(s => s.companionXpInfo);
|
||||
const focused = useAppState(s => s.footerSelection === 'companion');
|
||||
// Subscribe to creature changes so we re-render immediately after switch
|
||||
const creatureChangedAt = useAppState(s => s.companionCreatureChangedAt);
|
||||
const setAppState = useSetAppState();
|
||||
const { columns } = useTerminalSize();
|
||||
const [tick, setTick] = useState(0);
|
||||
const [spriteTick, setSpriteTick] = useState(0);
|
||||
|
||||
// Prefetch sprite when creature changes
|
||||
useEffect(() => {
|
||||
const c = getPokemonCreature();
|
||||
if (!c || loadSprite(c.speciesId)) return;
|
||||
fetchAndCacheSprite(c.speciesId).then(s => { if (s) setSpriteTick(t => t + 1) });
|
||||
}, [creatureChangedAt]);
|
||||
void spriteTick;
|
||||
const lastSpokeTick = useRef(0);
|
||||
const [{ petStartTick, forPetAt }, setPetStart] = useState({
|
||||
petStartTick: 0,
|
||||
forPetAt: petAt,
|
||||
})
|
||||
});
|
||||
if (petAt !== forPetAt) {
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt })
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(
|
||||
setT => setT((t: number) => t + 1),
|
||||
(setT: React.Dispatch<React.SetStateAction<number>>) => setT((t: number) => t + 1),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
lastSpokeTick.current = tick
|
||||
if (!reaction) return;
|
||||
lastSpokeTick.current = tick;
|
||||
const timer = setTimeout(
|
||||
setA =>
|
||||
(setA: React.Dispatch<React.SetStateAction<AppState>>) =>
|
||||
setA((prev: AppState) =>
|
||||
prev.companionReaction === undefined
|
||||
? prev
|
||||
: { ...prev, companionReaction: undefined },
|
||||
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined },
|
||||
),
|
||||
BUBBLE_SHOW * TICK_MS,
|
||||
setAppState,
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
|
||||
}, [reaction, setAppState])
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reaction, setAppState]);
|
||||
|
||||
if (!feature('BUDDY')) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY')) return null;
|
||||
const creature = getPokemonCreature();
|
||||
if (!creature || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
const color = RARITY_COLORS[companion.rarity]
|
||||
const colWidth = spriteColWidth(stringWidth(companion.name))
|
||||
const species = getSpeciesData(creature.speciesId);
|
||||
const name = getCreatureName(creature);
|
||||
const color = creature.isShiny ? 'warning' : 'claude';
|
||||
const colWidth = spriteColWidth(stringWidth(name));
|
||||
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
|
||||
const fading =
|
||||
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
|
||||
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
|
||||
|
||||
const petAge = petAt ? tick - petStartTick : Infinity
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS
|
||||
const petAge = petAt ? tick - petStartTick : Infinity;
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS;
|
||||
|
||||
// Narrow terminals: collapse to one-line face. When speaking, the quip
|
||||
// replaces the name beside the face (no room for a bubble).
|
||||
// Narrow terminals: collapse to one-line face
|
||||
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
|
||||
const quip =
|
||||
reaction && reaction.length > NARROW_QUIP_CAP
|
||||
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
|
||||
: reaction
|
||||
const label = quip
|
||||
? `"${quip}"`
|
||||
: focused
|
||||
? ` ${companion.name} `
|
||||
: companion.name
|
||||
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
|
||||
const label = quip ? `"${quip}"` : focused ? ` ${name} ` : name;
|
||||
const xpLabel = xpInfo
|
||||
? xpInfo.leveledUp
|
||||
? ` ↑Lv.${xpInfo.level}`
|
||||
: ` Lv.${xpInfo.level} +${xpInfo.xpGained}xp`
|
||||
: creature.level > 1
|
||||
? ` Lv.${creature.level}`
|
||||
: '';
|
||||
return (
|
||||
<Box paddingX={1} alignSelf="flex-end">
|
||||
<Text>
|
||||
{petting && <Text color="autoAccept">{figures.heart} </Text>}
|
||||
<Text bold color={color}>
|
||||
{renderFace(companion)}
|
||||
{species.names.zh ?? species.name}
|
||||
</Text>{' '}
|
||||
<Text
|
||||
italic
|
||||
dimColor={!focused && !reaction}
|
||||
bold={focused}
|
||||
inverse={focused && !reaction}
|
||||
color={
|
||||
reaction
|
||||
? fading
|
||||
? 'inactive'
|
||||
: color
|
||||
: focused
|
||||
? color
|
||||
: undefined
|
||||
}
|
||||
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{xpLabel && (
|
||||
<Text dimColor bold={xpInfo?.leveledUp} color={xpInfo?.leveledUp ? 'warning' : 'inactive'}>
|
||||
{xpLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
const frameCount = spriteFrameCount(companion.species)
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
|
||||
|
||||
let spriteFrame: number
|
||||
let blink = false
|
||||
// Determine animation mode
|
||||
let animMode: AnimMode = 'idle';
|
||||
if (reaction || petting) {
|
||||
// Excited: cycle all fidget frames fast
|
||||
spriteFrame = tick % frameCount
|
||||
animMode = 'excited';
|
||||
} else {
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
|
||||
if (step === -1) {
|
||||
spriteFrame = 0
|
||||
blink = true
|
||||
} else {
|
||||
spriteFrame = step % frameCount
|
||||
}
|
||||
animMode = getIdleAnimMode(tick);
|
||||
if (petting) animMode = 'pet';
|
||||
}
|
||||
|
||||
const body = renderSprite(companion, spriteFrame).map(line =>
|
||||
blink ? line.replaceAll(companion.eye, '-') : line,
|
||||
)
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body
|
||||
const spriteLines = getAnimatedSpriteLines(creature, tick, animMode);
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
|
||||
const displayLines = heartFrame ? [heartFrame, ...spriteLines] : spriteLines;
|
||||
|
||||
const xpStatus = xpInfo
|
||||
? xpInfo.leveledUp
|
||||
? `↑Lv.${xpInfo.level}`
|
||||
: `+${xpInfo.xpGained}xp`
|
||||
: null;
|
||||
|
||||
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
|
||||
// focused shows inverse name. The enter-to-open hint lives in
|
||||
// PromptInputFooter's right column so this row stays one line and the
|
||||
// sprite doesn't jump up when selected. flexShrink=0 stops the
|
||||
// inline-bubble row wrapper from squeezing the sprite to fit.
|
||||
const spriteColumn = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
alignItems="center"
|
||||
width={colWidth}
|
||||
>
|
||||
{sprite.map((line, i) => (
|
||||
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
<Text
|
||||
italic
|
||||
bold={focused}
|
||||
dimColor={!focused}
|
||||
color={focused ? color : undefined}
|
||||
inverse={focused}
|
||||
>
|
||||
{focused ? ` ${companion.name} ` : companion.name}
|
||||
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
|
||||
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
|
||||
{focused ? ` ${name} ` : name}
|
||||
</Text>
|
||||
<Text dimColor color={xpInfo?.leveledUp ? 'warning' : 'inactive'}>
|
||||
Lv.{creature.level} {xpStatus ?? ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
if (!reaction) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
|
||||
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
|
||||
// FullscreenLayout's bottomFloat slot (the bottom slot's overflowY:hidden
|
||||
// would clip a position:absolute overlay here). Sprite body only.
|
||||
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
|
||||
// because floating into Static scrollback can't be cleared.
|
||||
if (isFullscreenActive()) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
||||
<SpeechBubble
|
||||
text={reaction}
|
||||
color={color}
|
||||
fading={fading}
|
||||
tail="right"
|
||||
/>
|
||||
<SpeechBubble text={reaction} fading={fading} tail="right" />
|
||||
{spriteColumn}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
|
||||
// bottomFloat slot (outside the overflowY:hidden clip) so it can extend into
|
||||
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
|
||||
// just reads companionReaction and renders the fade.
|
||||
// Floating bubble overlay for fullscreen mode
|
||||
export function CompanionFloatingBubble(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const _creatureChangedAt = useAppState(s => s.companionCreatureChangedAt);
|
||||
const [{ tick, forReaction }, setTick] = useState({
|
||||
tick: 0,
|
||||
forReaction: reaction,
|
||||
})
|
||||
});
|
||||
|
||||
// Reset tick synchronously when reaction changes (not in useEffect, which
|
||||
// runs post-render and would show one stale-faded frame). Storing the
|
||||
// reaction the tick is counting FOR alongside the tick itself means the
|
||||
// fade computation never sees a tick from a previous reaction.
|
||||
if (reaction !== forReaction) {
|
||||
setTick({ tick: 0, forReaction: reaction })
|
||||
setTick({ tick: 0, forReaction: reaction });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
if (!reaction) return;
|
||||
const timer = setInterval(
|
||||
set => set(s => ({ ...s, tick: s.tick + 1 })),
|
||||
(set: React.Dispatch<React.SetStateAction<{ tick: number; forReaction: string | undefined }>>) =>
|
||||
set(s => ({ ...s, tick: s.tick + 1 })),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [reaction])
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [reaction]);
|
||||
|
||||
if (!feature('BUDDY') || !reaction) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY') || !reaction) return null;
|
||||
const creature = getPokemonCreature();
|
||||
if (!creature || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
return (
|
||||
<SpeechBubble
|
||||
text={reaction}
|
||||
color={RARITY_COLORS[companion.rarity]}
|
||||
fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
|
||||
tail="down"
|
||||
/>
|
||||
)
|
||||
return <FloatingBubble text={reaction} fading={tick >= BUBBLE_SHOW - FADE_WINDOW} />;
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import {
|
||||
type Companion,
|
||||
type CompanionBones,
|
||||
EYES,
|
||||
HATS,
|
||||
RARITIES,
|
||||
RARITY_WEIGHTS,
|
||||
type Rarity,
|
||||
SPECIES,
|
||||
STAT_NAMES,
|
||||
type StatName,
|
||||
} from './types.js'
|
||||
|
||||
// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return function () {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function hashString(s: string): number {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
|
||||
}
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
function pick<T>(rng: () => number, arr: readonly T[]): T {
|
||||
return arr[Math.floor(rng() * arr.length)]!
|
||||
}
|
||||
|
||||
function rollRarity(rng: () => number): Rarity {
|
||||
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
|
||||
let roll = rng() * total
|
||||
for (const rarity of RARITIES) {
|
||||
roll -= RARITY_WEIGHTS[rarity]
|
||||
if (roll < 0) return rarity
|
||||
}
|
||||
return 'common'
|
||||
}
|
||||
|
||||
const RARITY_FLOOR: Record<Rarity, number> = {
|
||||
common: 5,
|
||||
uncommon: 15,
|
||||
rare: 25,
|
||||
epic: 35,
|
||||
legendary: 50,
|
||||
}
|
||||
|
||||
// One peak stat, one dump stat, rest scattered. Rarity bumps the floor.
|
||||
function rollStats(
|
||||
rng: () => number,
|
||||
rarity: Rarity,
|
||||
): Record<StatName, number> {
|
||||
const floor = RARITY_FLOOR[rarity]
|
||||
const peak = pick(rng, STAT_NAMES)
|
||||
let dump = pick(rng, STAT_NAMES)
|
||||
while (dump === peak) dump = pick(rng, STAT_NAMES)
|
||||
|
||||
const stats = {} as Record<StatName, number>
|
||||
for (const name of STAT_NAMES) {
|
||||
if (name === peak) {
|
||||
stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
|
||||
} else if (name === dump) {
|
||||
stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
|
||||
} else {
|
||||
stats[name] = floor + Math.floor(rng() * 40)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
const SALT = 'friend-2026-401'
|
||||
|
||||
export type Roll = {
|
||||
bones: CompanionBones
|
||||
inspirationSeed: number
|
||||
}
|
||||
|
||||
function rollFrom(rng: () => number): Roll {
|
||||
const rarity = rollRarity(rng)
|
||||
const bones: CompanionBones = {
|
||||
rarity,
|
||||
species: pick(rng, SPECIES),
|
||||
eye: pick(rng, EYES),
|
||||
hat: rarity === 'common' ? 'none' : pick(rng, HATS),
|
||||
shiny: rng() < 0.01,
|
||||
stats: rollStats(rng, rarity),
|
||||
}
|
||||
return { bones, inspirationSeed: Math.floor(rng() * 1e9) }
|
||||
}
|
||||
|
||||
// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
|
||||
// per-turn observer) with the same userId → cache the deterministic result.
|
||||
let rollCache: { key: string; value: Roll } | undefined
|
||||
export function roll(userId: string): Roll {
|
||||
const key = userId + SALT
|
||||
if (rollCache?.key === key) return rollCache.value
|
||||
const value = rollFrom(mulberry32(hashString(key)))
|
||||
rollCache = { key, value }
|
||||
return value
|
||||
}
|
||||
|
||||
export function rollWithSeed(seed: string): Roll {
|
||||
return rollFrom(mulberry32(hashString(seed)))
|
||||
}
|
||||
|
||||
export function generateSeed(): string {
|
||||
return `rehatch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function companionUserId(): string {
|
||||
const config = getGlobalConfig()
|
||||
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'
|
||||
}
|
||||
|
||||
// Regenerate bones from seed or userId, merge with stored soul.
|
||||
export function getCompanion(): Companion | undefined {
|
||||
const stored = getGlobalConfig().companion
|
||||
if (!stored) return undefined
|
||||
const seed = stored.seed ?? companionUserId()
|
||||
const { bones } = rollWithSeed(seed)
|
||||
// bones last so stale bones fields in old-format configs get overridden
|
||||
return { ...stored, ...bones }
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
/**
|
||||
* Companion reaction system — aligns with official ZUK + Dc8 pattern.
|
||||
* Companion reaction system — adapted for Pokémon buddy system.
|
||||
*
|
||||
* Called from REPL.tsx after each query turn. Checks mute state, frequency
|
||||
* limits, and @-mention detection, then calls the buddy_react API to
|
||||
* generate a reaction shown in the CompanionSprite speech bubble.
|
||||
*/
|
||||
import { getCompanion } from './companion.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import { getUserAgent } from '../utils/http.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import {
|
||||
loadBuddyData,
|
||||
getActiveCreature,
|
||||
getCreatureName,
|
||||
calculateStats,
|
||||
getSpeciesData,
|
||||
type Creature,
|
||||
} from '@claude-code-best/pokemon'
|
||||
|
||||
// ─── Rate limiting ──────────────────────────────────
|
||||
|
||||
let lastReactTime = 0
|
||||
const MIN_INTERVAL_MS = 45_000 // official is roughly 30-60s
|
||||
const MIN_INTERVAL_MS = 45_000
|
||||
|
||||
// ─── Recent reactions (avoid repetition) ────────────
|
||||
|
||||
@@ -26,23 +33,17 @@ const MAX_RECENT = 8
|
||||
|
||||
/**
|
||||
* Trigger a companion reaction after a query turn.
|
||||
*
|
||||
* Mirrors official `ZUK()`:
|
||||
* 1. Check companion exists and is not muted
|
||||
* 2. Detect if user @-mentioned companion by name
|
||||
* 3. Apply rate limiting (skip if not addressed and too soon)
|
||||
* 4. Build conversation transcript
|
||||
* 5. Call buddy_react API
|
||||
* 6. Pass reaction text to setReaction callback
|
||||
*/
|
||||
export function triggerCompanionReaction(
|
||||
export async function triggerCompanionReaction(
|
||||
messages: Message[],
|
||||
setReaction: (text: string | undefined) => void,
|
||||
): void {
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return
|
||||
): Promise<void> {
|
||||
const data = await loadBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature || getGlobalConfig().companionMuted) return
|
||||
|
||||
const addressed = isAddressed(messages, companion.name)
|
||||
const name = getCreatureName(creature)
|
||||
const addressed = isAddressed(messages, name)
|
||||
|
||||
const now = Date.now()
|
||||
if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return
|
||||
@@ -52,7 +53,7 @@ export function triggerCompanionReaction(
|
||||
|
||||
lastReactTime = now
|
||||
|
||||
void callBuddyReactAPI(companion, transcript, addressed)
|
||||
void callBuddyReactAPI(creature, transcript, addressed)
|
||||
.then(reaction => {
|
||||
if (!reaction) return
|
||||
recentReactions.push(reaction)
|
||||
@@ -109,13 +110,7 @@ function buildTranscript(messages: Message[]): string {
|
||||
// ─── API call ───────────────────────────────────────
|
||||
|
||||
async function callBuddyReactAPI(
|
||||
companion: {
|
||||
name: string
|
||||
personality: string
|
||||
species: string
|
||||
rarity: string
|
||||
stats: Record<string, number>
|
||||
},
|
||||
creature: Creature,
|
||||
transcript: string,
|
||||
addressed: boolean,
|
||||
): Promise<string | null> {
|
||||
@@ -125,6 +120,10 @@ async function callBuddyReactAPI(
|
||||
const orgId = getGlobalConfig().oauthAccount?.organizationUuid
|
||||
if (!orgId) return null
|
||||
|
||||
const species = getSpeciesData(creature.speciesId)
|
||||
const name = getCreatureName(creature)
|
||||
const stats = calculateStats(creature)
|
||||
|
||||
const baseUrl = getOauthConfig().BASE_API_URL
|
||||
const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react`
|
||||
|
||||
@@ -136,11 +135,25 @@ async function callBuddyReactAPI(
|
||||
'User-Agent': getUserAgent(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: companion.name.slice(0, 32),
|
||||
personality: companion.personality.slice(0, 200),
|
||||
species: companion.species,
|
||||
rarity: companion.rarity,
|
||||
stats: companion.stats,
|
||||
name: name.slice(0, 32),
|
||||
personality: species.personality.slice(0, 200),
|
||||
species: creature.speciesId,
|
||||
rarity: creature.isShiny
|
||||
? 'legendary'
|
||||
: creature.level >= 36
|
||||
? 'epic'
|
||||
: creature.level >= 16
|
||||
? 'rare'
|
||||
: 'common',
|
||||
stats: {
|
||||
HP: stats.hp,
|
||||
ATK: stats.attack,
|
||||
DEF: stats.defense,
|
||||
SPA: stats.spAtk,
|
||||
SPD: stats.spDef,
|
||||
SPE: stats.speed,
|
||||
Level: creature.level,
|
||||
},
|
||||
transcript,
|
||||
reason: addressed ? 'addressed' : 'turn',
|
||||
recent: recentReactions.map(r => r.slice(0, 200)),
|
||||
|
||||
@@ -2,35 +2,44 @@ import { feature } from 'bun:bundle'
|
||||
import type { Message } from '../types/message.js'
|
||||
import type { Attachment } from '../utils/attachments.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import {
|
||||
loadBuddyData,
|
||||
getActiveCreature,
|
||||
getCreatureName,
|
||||
getSpeciesData,
|
||||
} from '@claude-code-best/pokemon'
|
||||
|
||||
export function companionIntroText(name: string, species: string): string {
|
||||
return `# Companion
|
||||
|
||||
A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
|
||||
A ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
|
||||
|
||||
When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`
|
||||
}
|
||||
|
||||
export function getCompanionIntroAttachment(
|
||||
export async function getCompanionIntroAttachment(
|
||||
messages: Message[] | undefined,
|
||||
): Attachment[] {
|
||||
): Promise<Attachment[]> {
|
||||
if (!feature('BUDDY')) return []
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return []
|
||||
const data = await loadBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature || getGlobalConfig().companionMuted) return []
|
||||
|
||||
const name = getCreatureName(creature)
|
||||
const species = getSpeciesData(creature.speciesId)
|
||||
|
||||
// Skip if already announced for this companion.
|
||||
for (const msg of messages ?? []) {
|
||||
if (msg.type !== 'attachment') continue
|
||||
if (msg.attachment!.type !== 'companion_intro') continue
|
||||
if (msg.attachment!.name === companion.name) return []
|
||||
if ((msg as any).attachment?.type !== 'companion_intro') continue
|
||||
if ((msg as any).attachment?.name === name) return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'companion_intro',
|
||||
name: companion.name,
|
||||
species: companion.species,
|
||||
name,
|
||||
species: species.names.zh ?? species.name,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,514 +0,0 @@
|
||||
import type { CompanionBones, Eye, Hat, Species } from './types.js'
|
||||
import {
|
||||
axolotl,
|
||||
blob,
|
||||
cactus,
|
||||
capybara,
|
||||
cat,
|
||||
chonk,
|
||||
dragon,
|
||||
duck,
|
||||
ghost,
|
||||
goose,
|
||||
mushroom,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
rabbit,
|
||||
robot,
|
||||
snail,
|
||||
turtle,
|
||||
} from './types.js'
|
||||
|
||||
// Each sprite is 5 lines tall, 12 wide (after {E}→1char substitution).
|
||||
// Multiple frames per species for idle fidget animation.
|
||||
// Line 0 is the hat slot — must be blank in frames 0-1; frame 2 may use it.
|
||||
const BODIES: Record<Species, string[][]> = {
|
||||
[duck]: [
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( .__> ',
|
||||
' `--´ ',
|
||||
],
|
||||
],
|
||||
[goose]: [
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}>> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
],
|
||||
[blob]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .------. ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .--. ',
|
||||
' ({E} {E}) ',
|
||||
' ( ) ',
|
||||
' `--´ ',
|
||||
],
|
||||
],
|
||||
[cat]: [
|
||||
[
|
||||
' ',
|
||||
' /\\_/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\_/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(")~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\-/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(") ',
|
||||
],
|
||||
],
|
||||
[dragon]: [
|
||||
[
|
||||
' ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ~~ ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ~~ ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
],
|
||||
[octopus]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' /\\/\\/\\/\\ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' \\/\\/\\/\\/ ',
|
||||
],
|
||||
[
|
||||
' o ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' /\\/\\/\\/\\ ',
|
||||
],
|
||||
],
|
||||
[owl]: [
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})({E})) ',
|
||||
' ( >< ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})({E})) ',
|
||||
' ( >< ) ',
|
||||
' .----. ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})(-)) ',
|
||||
' ( >< ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
],
|
||||
[penguin]: [
|
||||
[
|
||||
' ',
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' /( )\\ ',
|
||||
' `---´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' |( )| ',
|
||||
' `---´ ',
|
||||
],
|
||||
[
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' /( )\\ ',
|
||||
' `---´ ',
|
||||
' ~ ~ ',
|
||||
],
|
||||
],
|
||||
[turtle]: [
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[______]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[______]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[======]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
],
|
||||
[snail]: [
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' \\ ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' | ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' \\ ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~ ',
|
||||
],
|
||||
],
|
||||
[ghost]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' ~`~``~`~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' `~`~~`~` ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' ~~`~~`~~ ',
|
||||
],
|
||||
],
|
||||
[axolotl]: [
|
||||
[
|
||||
' ',
|
||||
'}~(______)~{',
|
||||
'}~({E} .. {E})~{',
|
||||
' ( .--. ) ',
|
||||
' (_/ \\_) ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
'~}(______){~',
|
||||
'~}({E} .. {E}){~',
|
||||
' ( .--. ) ',
|
||||
' (_/ \\_) ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
'}~(______)~{',
|
||||
'}~({E} .. {E})~{',
|
||||
' ( -- ) ',
|
||||
' ~_/ \\_~ ',
|
||||
],
|
||||
],
|
||||
[capybara]: [
|
||||
[
|
||||
' ',
|
||||
' n______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' n______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( Oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' u______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
],
|
||||
[cactus]: [
|
||||
[
|
||||
' ',
|
||||
' n ____ n ',
|
||||
' | |{E} {E}| | ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ____ ',
|
||||
' n |{E} {E}| n ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
[
|
||||
' n n ',
|
||||
' | ____ | ',
|
||||
' | |{E} {E}| | ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
],
|
||||
[robot]: [
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ -==- ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' * ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
],
|
||||
[rabbit]: [
|
||||
[
|
||||
' ',
|
||||
' (\\__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( .. )= ',
|
||||
' (")__(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' (|__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( .. )= ',
|
||||
' (")__(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' (\\__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( . . )= ',
|
||||
' (")__(") ',
|
||||
],
|
||||
],
|
||||
[mushroom]: [
|
||||
[
|
||||
' ',
|
||||
' .-o-OO-o-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .-O-oo-O-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
],
|
||||
[
|
||||
' . o . ',
|
||||
' .-o-OO-o-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
],
|
||||
],
|
||||
[chonk]: [
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /| ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´~ ',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
const HAT_LINES: Record<Hat, string> = {
|
||||
none: '',
|
||||
crown: ' \\^^^/ ',
|
||||
tophat: ' [___] ',
|
||||
propeller: ' -+- ',
|
||||
halo: ' ( ) ',
|
||||
wizard: ' /^\\ ',
|
||||
beanie: ' (___) ',
|
||||
tinyduck: ' ,> ',
|
||||
}
|
||||
|
||||
export function renderSprite(bones: CompanionBones, frame = 0): string[] {
|
||||
const frames = BODIES[bones.species]
|
||||
const body = frames[frame % frames.length]!.map(line =>
|
||||
line.replaceAll('{E}', bones.eye),
|
||||
)
|
||||
const lines = [...body]
|
||||
// Only replace with hat if line 0 is empty (some fidget frames use it for smoke etc)
|
||||
if (bones.hat !== 'none' && !lines[0]!.trim()) {
|
||||
lines[0] = HAT_LINES[bones.hat]
|
||||
}
|
||||
// Drop blank hat slot — wastes a row in the Card and ambient sprite when
|
||||
// there's no hat and the frame isn't using it for smoke/antenna/etc.
|
||||
// Only safe when ALL frames have blank line 0; otherwise heights oscillate.
|
||||
if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
|
||||
return lines
|
||||
}
|
||||
|
||||
export function spriteFrameCount(species: Species): number {
|
||||
return BODIES[species].length
|
||||
}
|
||||
|
||||
export function renderFace(bones: CompanionBones): string {
|
||||
const eye: Eye = bones.eye
|
||||
switch (bones.species) {
|
||||
case duck:
|
||||
case goose:
|
||||
return `(${eye}>`
|
||||
case blob:
|
||||
return `(${eye}${eye})`
|
||||
case cat:
|
||||
return `=${eye}ω${eye}=`
|
||||
case dragon:
|
||||
return `<${eye}~${eye}>`
|
||||
case octopus:
|
||||
return `~(${eye}${eye})~`
|
||||
case owl:
|
||||
return `(${eye})(${eye})`
|
||||
case penguin:
|
||||
return `(${eye}>)`
|
||||
case turtle:
|
||||
return `[${eye}_${eye}]`
|
||||
case snail:
|
||||
return `${eye}(@)`
|
||||
case ghost:
|
||||
return `/${eye}${eye}\\`
|
||||
case axolotl:
|
||||
return `}${eye}.${eye}{`
|
||||
case capybara:
|
||||
return `(${eye}oo${eye})`
|
||||
case cactus:
|
||||
return `|${eye} ${eye}|`
|
||||
case robot:
|
||||
return `[${eye}${eye}]`
|
||||
case rabbit:
|
||||
return `(${eye}..${eye})`
|
||||
case mushroom:
|
||||
return `|${eye} ${eye}|`
|
||||
case chonk:
|
||||
return `(${eye}.${eye})`
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
export const RARITIES = [
|
||||
'common',
|
||||
'uncommon',
|
||||
'rare',
|
||||
'epic',
|
||||
'legendary',
|
||||
] as const
|
||||
export type Rarity = (typeof RARITIES)[number]
|
||||
|
||||
// One species name collides with a model-codename canary in excluded-strings.txt.
|
||||
// The check greps build output (not source), so runtime-constructing the value keeps
|
||||
// the literal out of the bundle while the check stays armed for the actual codename.
|
||||
// All species encoded uniformly; `as` casts are type-position only (erased pre-bundle).
|
||||
const c = String.fromCharCode
|
||||
// biome-ignore format: keep the species list compact
|
||||
|
||||
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
|
||||
export const goose = c(0x67, 0x6f, 0x6f, 0x73, 0x65) as 'goose'
|
||||
export const blob = c(0x62, 0x6c, 0x6f, 0x62) as 'blob'
|
||||
export const cat = c(0x63, 0x61, 0x74) as 'cat'
|
||||
export const dragon = c(0x64, 0x72, 0x61, 0x67, 0x6f, 0x6e) as 'dragon'
|
||||
export const octopus = c(0x6f, 0x63, 0x74, 0x6f, 0x70, 0x75, 0x73) as 'octopus'
|
||||
export const owl = c(0x6f, 0x77, 0x6c) as 'owl'
|
||||
export const penguin = c(0x70, 0x65, 0x6e, 0x67, 0x75, 0x69, 0x6e) as 'penguin'
|
||||
export const turtle = c(0x74, 0x75, 0x72, 0x74, 0x6c, 0x65) as 'turtle'
|
||||
export const snail = c(0x73, 0x6e, 0x61, 0x69, 0x6c) as 'snail'
|
||||
export const ghost = c(0x67, 0x68, 0x6f, 0x73, 0x74) as 'ghost'
|
||||
export const axolotl = c(0x61, 0x78, 0x6f, 0x6c, 0x6f, 0x74, 0x6c) as 'axolotl'
|
||||
export const capybara = c(
|
||||
0x63,
|
||||
0x61,
|
||||
0x70,
|
||||
0x79,
|
||||
0x62,
|
||||
0x61,
|
||||
0x72,
|
||||
0x61,
|
||||
) as 'capybara'
|
||||
export const cactus = c(0x63, 0x61, 0x63, 0x74, 0x75, 0x73) as 'cactus'
|
||||
export const robot = c(0x72, 0x6f, 0x62, 0x6f, 0x74) as 'robot'
|
||||
export const rabbit = c(0x72, 0x61, 0x62, 0x62, 0x69, 0x74) as 'rabbit'
|
||||
export const mushroom = c(
|
||||
0x6d,
|
||||
0x75,
|
||||
0x73,
|
||||
0x68,
|
||||
0x72,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x6d,
|
||||
) as 'mushroom'
|
||||
export const chonk = c(0x63, 0x68, 0x6f, 0x6e, 0x6b) as 'chonk'
|
||||
|
||||
export const SPECIES = [
|
||||
duck,
|
||||
goose,
|
||||
blob,
|
||||
cat,
|
||||
dragon,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
turtle,
|
||||
snail,
|
||||
ghost,
|
||||
axolotl,
|
||||
capybara,
|
||||
cactus,
|
||||
robot,
|
||||
rabbit,
|
||||
mushroom,
|
||||
chonk,
|
||||
] as const
|
||||
export type Species = (typeof SPECIES)[number] // biome-ignore format: keep compact
|
||||
|
||||
export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const
|
||||
export type Eye = (typeof EYES)[number]
|
||||
|
||||
export const HATS = [
|
||||
'none',
|
||||
'crown',
|
||||
'tophat',
|
||||
'propeller',
|
||||
'halo',
|
||||
'wizard',
|
||||
'beanie',
|
||||
'tinyduck',
|
||||
] as const
|
||||
export type Hat = (typeof HATS)[number]
|
||||
|
||||
export const STAT_NAMES = [
|
||||
'DEBUGGING',
|
||||
'PATIENCE',
|
||||
'CHAOS',
|
||||
'WISDOM',
|
||||
'SNARK',
|
||||
] as const
|
||||
export type StatName = (typeof STAT_NAMES)[number]
|
||||
|
||||
// Deterministic parts — derived from hash(userId)
|
||||
export type CompanionBones = {
|
||||
rarity: Rarity
|
||||
species: Species
|
||||
eye: Eye
|
||||
hat: Hat
|
||||
shiny: boolean
|
||||
stats: Record<StatName, number>
|
||||
}
|
||||
|
||||
// Model-generated soul — stored in config after first hatch
|
||||
export type CompanionSoul = {
|
||||
name: string
|
||||
personality: string
|
||||
seed?: string
|
||||
}
|
||||
|
||||
export type Companion = CompanionBones &
|
||||
CompanionSoul & {
|
||||
hatchedAt: number
|
||||
}
|
||||
|
||||
// What actually persists in config. Bones are regenerated from hash(userId)
|
||||
// on every read so species renames don't break stored companions and users
|
||||
// can't edit their way to a legendary.
|
||||
export type StoredCompanion = CompanionSoul & { hatchedAt: number }
|
||||
|
||||
export const RARITY_WEIGHTS = {
|
||||
common: 60,
|
||||
uncommon: 25,
|
||||
rare: 10,
|
||||
epic: 4,
|
||||
legendary: 1,
|
||||
} as const satisfies Record<Rarity, number>
|
||||
|
||||
export const RARITY_STARS = {
|
||||
common: '★',
|
||||
uncommon: '★★',
|
||||
rare: '★★★',
|
||||
epic: '★★★★',
|
||||
legendary: '★★★★★',
|
||||
} as const satisfies Record<Rarity, string>
|
||||
|
||||
export const RARITY_COLORS = {
|
||||
common: 'inactive',
|
||||
uncommon: 'success',
|
||||
rare: 'permission',
|
||||
epic: 'autoAccept',
|
||||
legendary: 'warning',
|
||||
} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>
|
||||
@@ -155,6 +155,11 @@ const buddy = feature('BUDDY')
|
||||
require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
|
||||
).default
|
||||
: null
|
||||
const pokemonBattle = feature('BUDDY')
|
||||
? (
|
||||
require('./commands/pokemon-battle/index.js') as typeof import('./commands/pokemon-battle/index.js')
|
||||
).default
|
||||
: null
|
||||
const poor = feature('POOR')
|
||||
? (
|
||||
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
|
||||
@@ -364,6 +369,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
...(webCmd ? [webCmd] : []),
|
||||
...(forkCmd ? [forkCmd] : []),
|
||||
...(buddy ? [buddy] : []),
|
||||
...(pokemonBattle ? [pokemonBattle] : []),
|
||||
...(poor ? [poor] : []),
|
||||
...(proactive ? [proactive] : []),
|
||||
...(monitorCmd ? [monitorCmd] : []),
|
||||
|
||||
1174
src/commands/buddy/BuddyPanel.tsx
Normal file
1174
src/commands/buddy/BuddyPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,4 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
getCompanion,
|
||||
rollWithSeed,
|
||||
generateSeed,
|
||||
} from '../../buddy/companion.js'
|
||||
import { type StoredCompanion, RARITY_STARS } from '../../buddy/types.js'
|
||||
import { renderSprite } from '../../buddy/sprites.js'
|
||||
import { CompanionCard } from '../../buddy/CompanionCard.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { triggerCompanionReaction } from '../../buddy/companionReact.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
@@ -14,57 +6,50 @@ import type {
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
import {
|
||||
loadBuddyData,
|
||||
saveBuddyData,
|
||||
getDefaultBuddyData,
|
||||
migrateFromLegacy,
|
||||
getActiveCreature,
|
||||
getCreatureName,
|
||||
awardXP,
|
||||
advanceEggSteps,
|
||||
checkEvolution,
|
||||
checkEggEligibility,
|
||||
generateEgg,
|
||||
isEggReadyToHatch,
|
||||
hatchEgg,
|
||||
fetchAndCacheSprite,
|
||||
loadSprite,
|
||||
getFallbackSprite,
|
||||
getSpeciesData,
|
||||
generateCreature,
|
||||
addToParty,
|
||||
ALL_SPECIES_IDS,
|
||||
type BuddyData,
|
||||
type Creature,
|
||||
type SpeciesId,
|
||||
} from '@claude-code-best/pokemon'
|
||||
import { BuddyPanel } from './BuddyPanel.js'
|
||||
|
||||
// Species → default name fragments for hatch (no API needed)
|
||||
const SPECIES_NAMES: Record<string, string> = {
|
||||
duck: 'Waddles',
|
||||
goose: 'Goosberry',
|
||||
blob: 'Gooey',
|
||||
cat: 'Whiskers',
|
||||
dragon: 'Ember',
|
||||
octopus: 'Inky',
|
||||
owl: 'Hoots',
|
||||
penguin: 'Waddleford',
|
||||
turtle: 'Shelly',
|
||||
snail: 'Trailblazer',
|
||||
ghost: 'Casper',
|
||||
axolotl: 'Axie',
|
||||
capybara: 'Chill',
|
||||
cactus: 'Spike',
|
||||
robot: 'Byte',
|
||||
rabbit: 'Flops',
|
||||
mushroom: 'Spore',
|
||||
chonk: 'Chonk',
|
||||
}
|
||||
/**
|
||||
* Load or initialize Pokémon buddy data.
|
||||
* Migrates from legacy buddy system if needed.
|
||||
*/
|
||||
async function getOrInitBuddyData(): Promise<BuddyData> {
|
||||
let data = await loadBuddyData()
|
||||
|
||||
const SPECIES_PERSONALITY: Record<string, string> = {
|
||||
duck: 'Quirky and easily amused. Leaves rubber duck debugging tips everywhere.',
|
||||
goose: 'Assertive and honks at bad code. Takes no prisoners in code reviews.',
|
||||
blob: 'Adaptable and goes with the flow. Sometimes splits into two when confused.',
|
||||
cat: 'Independent and judgmental. Watches you type with mild disdain.',
|
||||
dragon:
|
||||
'Fiery and passionate about architecture. Hoards good variable names.',
|
||||
octopus:
|
||||
'Multitasker extraordinaire. Wraps tentacles around every problem at once.',
|
||||
owl: 'Wise but verbose. Always says "let me think about that" for exactly 3 seconds.',
|
||||
penguin: 'Cool under pressure. Slides gracefully through merge conflicts.',
|
||||
turtle: 'Patient and thorough. Believes slow and steady wins the deploy.',
|
||||
snail: 'Methodical and leaves a trail of useful comments. Never rushes.',
|
||||
ghost:
|
||||
'Ethereal and appears at the worst possible moments with spooky insights.',
|
||||
axolotl: 'Regenerative and cheerful. Recovers from any bug with a smile.',
|
||||
capybara: 'Zen master. Remains calm while everything around is on fire.',
|
||||
cactus:
|
||||
'Prickly on the outside but full of good intentions. Thrives on neglect.',
|
||||
robot: 'Efficient and literal. Processes feedback in binary.',
|
||||
rabbit: 'Energetic and hops between tasks. Finishes before you start.',
|
||||
mushroom: 'Quietly insightful. Grows on you over time.',
|
||||
chonk:
|
||||
'Big, warm, and takes up the whole couch. Prioritizes comfort over elegance.',
|
||||
}
|
||||
// If no active creature (party empty), check for legacy companion to migrate
|
||||
if (!getActiveCreature(data) || data.creatures.length === 0) {
|
||||
const legacyCompanion = getGlobalConfig().companion
|
||||
if (legacyCompanion) {
|
||||
data = await migrateFromLegacy(legacyCompanion)
|
||||
saveBuddyData(data)
|
||||
}
|
||||
}
|
||||
|
||||
function speciesLabel(species: string): string {
|
||||
return species.charAt(0).toUpperCase() + species.slice(1)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -89,18 +74,45 @@ export async function call(
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy pet — trigger heart animation + auto unmute ──
|
||||
// ── /buddy pet — trigger heart animation + XP + egg steps ──
|
||||
if (sub === 'pet') {
|
||||
const companion = getCompanion()
|
||||
if (!companion) {
|
||||
onDone('no companion yet \u00b7 run /buddy first', { display: 'system' })
|
||||
const data = await getOrInitBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature) {
|
||||
onDone('no companion yet · run /buddy first', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// Auto-unmute on pet + trigger heart animation
|
||||
// Auto-unmute + heart animation
|
||||
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
||||
setState?.(prev => ({ ...prev, companionPetAt: Date.now() }))
|
||||
|
||||
// Award pet XP
|
||||
const result = awardXP(creature, 2)
|
||||
data.creatures = data.creatures.map(c =>
|
||||
c.id === creature.id ? result.creature : c,
|
||||
)
|
||||
|
||||
// Advance egg steps
|
||||
if (data.eggs.length > 0) {
|
||||
data.eggs = data.eggs.map(egg => advanceEggSteps(egg, 5))
|
||||
|
||||
// Check hatch
|
||||
const readyEgg = data.eggs.find(isEggReadyToHatch)
|
||||
if (readyEgg) {
|
||||
const { buddyData: updatedData, creature: newCreature } = await hatchEgg(
|
||||
data,
|
||||
readyEgg,
|
||||
)
|
||||
Object.assign(data, updatedData)
|
||||
onDone(`🥚 Egg hatched! You got a ${getCreatureName(newCreature)}!`, {
|
||||
display: 'system',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
saveBuddyData(data)
|
||||
|
||||
// Trigger a post-pet reaction
|
||||
triggerCompanionReaction(context.messages ?? [], reaction =>
|
||||
setState?.(prev =>
|
||||
@@ -110,60 +122,135 @@ export async function call(
|
||||
),
|
||||
)
|
||||
|
||||
onDone(`petted ${companion.name}`, { display: 'system' })
|
||||
if (!data.eggs.find(isEggReadyToHatch)) {
|
||||
onDone(`petted ${getCreatureName(creature)} (+2 XP)`, {
|
||||
display: 'system',
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy (no args) — show existing or hatch ──
|
||||
const companion = getCompanion()
|
||||
// ── /buddy rename — rename current creature ──
|
||||
if (sub.startsWith('rename ')) {
|
||||
const nickname = sub.slice(7).trim()
|
||||
if (!nickname) {
|
||||
onDone('Usage: /buddy rename <name>', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
const data = await getOrInitBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature) {
|
||||
onDone('no companion yet · run /buddy first', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
data.creatures = data.creatures.map(c =>
|
||||
c.id === creature.id ? { ...c, nickname } : c,
|
||||
)
|
||||
saveBuddyData(data)
|
||||
onDone(`renamed to "${nickname}"`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy give-me-pokemon <species> [level] — admin: grant any Pokémon ──
|
||||
if (sub.startsWith('give-me-pokemon')) {
|
||||
const parts = sub.split(/\s+/)
|
||||
const speciesArg = parts[1]?.toLowerCase()
|
||||
const levelArg = parts[2] ? parseInt(parts[2], 10) : undefined
|
||||
|
||||
if (!speciesArg) {
|
||||
const available = ALL_SPECIES_IDS.join(', ')
|
||||
onDone(`Usage: /buddy give-me-pokemon <species> [level]\nAvailable: ${available}`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate species (match by partial name or full id)
|
||||
const match = ALL_SPECIES_IDS.find(id =>
|
||||
id === speciesArg || id.includes(speciesArg),
|
||||
)
|
||||
if (!match) {
|
||||
onDone(`Unknown species "${speciesArg}". Available: ${ALL_SPECIES_IDS.join(', ')}`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await getOrInitBuddyData()
|
||||
|
||||
// Create the creature
|
||||
const creature = await generateCreature(match)
|
||||
if (levelArg && !isNaN(levelArg) && levelArg >= 1 && levelArg <= 100) {
|
||||
creature.level = levelArg
|
||||
}
|
||||
|
||||
// Add to creatures and dex
|
||||
data.creatures.push(creature)
|
||||
const existingDex = data.dex.find(d => d.speciesId === match)
|
||||
if (existingDex) {
|
||||
existingDex.caughtCount++
|
||||
existingDex.bestLevel = Math.max(existingDex.bestLevel, creature.level)
|
||||
} else {
|
||||
data.dex.push({
|
||||
speciesId: match,
|
||||
discoveredAt: Date.now(),
|
||||
caughtCount: 1,
|
||||
bestLevel: creature.level,
|
||||
})
|
||||
}
|
||||
|
||||
// Try to add to party (first empty slot)
|
||||
const partyResult = addToParty(data, creature.id)
|
||||
if (partyResult.added) {
|
||||
Object.assign(data, partyResult.data)
|
||||
}
|
||||
// If party full, creature stays in creatures[] but not in party
|
||||
|
||||
saveBuddyData(data)
|
||||
setState?.(prev => ({ ...prev, companionCreatureChangedAt: Date.now() }))
|
||||
|
||||
const species = getSpeciesData(match)
|
||||
const name = creature.nickname ?? species.name
|
||||
onDone(`Got ${name} (${species.names.zh ?? species.name}) Lv.${creature.level}!`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy (no args) — show unified BuddyPanel ──
|
||||
const data = await getOrInitBuddyData()
|
||||
let creature = getActiveCreature(data)
|
||||
|
||||
// Auto-unmute when viewing
|
||||
if (companion && getGlobalConfig().companionMuted) {
|
||||
if (getGlobalConfig().companionMuted) {
|
||||
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
||||
}
|
||||
|
||||
if (companion) {
|
||||
// Return JSX card — matches official vc8 component
|
||||
const lastReaction = context.getAppState?.()?.companionReaction
|
||||
return React.createElement(CompanionCard, {
|
||||
companion,
|
||||
lastReaction,
|
||||
onDone: onDone as unknown as Parameters<typeof CompanionCard>[0]['onDone'],
|
||||
})
|
||||
// No creature → initialize new one
|
||||
if (!creature) {
|
||||
const legacyCompanion = getGlobalConfig().companion
|
||||
if (legacyCompanion) {
|
||||
const migrated = await migrateFromLegacy(legacyCompanion)
|
||||
saveBuddyData(migrated)
|
||||
creature = getActiveCreature(migrated)!
|
||||
} else {
|
||||
const defaultData = await getDefaultBuddyData()
|
||||
saveBuddyData(defaultData)
|
||||
creature = getActiveCreature(defaultData)!
|
||||
}
|
||||
}
|
||||
|
||||
// ── No companion → hatch ──
|
||||
const seed = generateSeed()
|
||||
const r = rollWithSeed(seed)
|
||||
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
|
||||
const personality =
|
||||
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
|
||||
|
||||
const stored: StoredCompanion = {
|
||||
name,
|
||||
personality,
|
||||
seed,
|
||||
hatchedAt: Date.now(),
|
||||
// Pre-fetch sprite if not cached
|
||||
const spriteCached = loadSprite(creature.speciesId)
|
||||
if (!spriteCached) {
|
||||
fetchAndCacheSprite(creature.speciesId).catch(() => {})
|
||||
}
|
||||
|
||||
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
|
||||
const spriteLines =
|
||||
spriteCached?.lines ?? getFallbackSprite(creature.speciesId)
|
||||
|
||||
const stars = RARITY_STARS[r.bones.rarity]
|
||||
const sprite = renderSprite(r.bones, 0)
|
||||
const shiny = r.bones.shiny ? ' \u2728 Shiny!' : ''
|
||||
// Reload data to get latest state after possible initialization
|
||||
const latestData = await loadBuddyData()
|
||||
|
||||
const lines = [
|
||||
'A wild companion appeared!',
|
||||
'',
|
||||
...sprite,
|
||||
'',
|
||||
`${name} the ${speciesLabel(r.bones.species)}${shiny}`,
|
||||
`Rarity: ${stars} (${r.bones.rarity})`,
|
||||
`"${personality}"`,
|
||||
'',
|
||||
'Your companion will now appear beside your input box!',
|
||||
'Say its name to get its take \u00b7 /buddy pet \u00b7 /buddy off',
|
||||
]
|
||||
onDone(lines.join('\n'), { display: 'system' })
|
||||
return null
|
||||
return React.createElement(BuddyPanel, {
|
||||
buddyData: latestData,
|
||||
spriteLines,
|
||||
onClose: () => {
|
||||
onDone('buddy panel closed', { display: 'system' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { isBuddyLive } from '../../buddy/useBuddyNotification.js'
|
||||
const buddy = {
|
||||
type: 'local-jsx',
|
||||
name: 'buddy',
|
||||
description: 'Hatch a coding companion · pet, off',
|
||||
argumentHint: '[pet|off]',
|
||||
description: 'Pokémon coding companion · pet, dex, egg, switch, rename, off',
|
||||
argumentHint: '[pet|dex|egg|switch|rename <name>|on|off]',
|
||||
immediate: true,
|
||||
get isHidden() {
|
||||
return !isBuddyLive()
|
||||
|
||||
15
src/commands/pokemon-battle/index.ts
Normal file
15
src/commands/pokemon-battle/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isBuddyLive } from '../../buddy/useBuddyNotification.js'
|
||||
|
||||
const pokemonBattle = {
|
||||
type: 'local-jsx',
|
||||
name: 'pokemon-battle',
|
||||
description: 'Start a Pokémon battle',
|
||||
immediate: true,
|
||||
get isHidden() {
|
||||
return !isBuddyLive()
|
||||
},
|
||||
load: () => import('./pokemon-battle.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default pokemonBattle
|
||||
68
src/commands/pokemon-battle/pokemon-battle.ts
Normal file
68
src/commands/pokemon-battle/pokemon-battle.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { useInput, useRegisterKeybindingContext } from '@anthropic/ink'
|
||||
import {
|
||||
loadBuddyData,
|
||||
getActiveCreature,
|
||||
BattleFlow,
|
||||
type BuddyData,
|
||||
type BattleFlowHandle,
|
||||
} from '@claude-code-best/pokemon'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type {
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
|
||||
async function getOrInitBuddyData(): Promise<BuddyData> {
|
||||
let data = await loadBuddyData()
|
||||
if (!getActiveCreature(data)) {
|
||||
data = await loadBuddyData()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: ToolUseContext & LocalJSXCommandContext,
|
||||
_args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const data = await getOrInitBuddyData()
|
||||
|
||||
if (!getActiveCreature(data)) {
|
||||
onDone('No companion yet · run /buddy first', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
return React.createElement(BattlePanel, {
|
||||
buddyData: data,
|
||||
onClose: () => {
|
||||
onDone('battle closed', { display: 'system' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function BattlePanel({
|
||||
buddyData,
|
||||
onClose,
|
||||
}: {
|
||||
buddyData: BuddyData
|
||||
onClose: () => void
|
||||
}) {
|
||||
const inputRef = useRef<BattleFlowHandle | null>(null)
|
||||
|
||||
// Register keybinding context so our shortcuts take priority over Global
|
||||
useRegisterKeybindingContext('Battle')
|
||||
|
||||
useInput((input, key, event) => {
|
||||
// Consume ALL keyboard events to prevent PromptInput from intercepting
|
||||
event.stopImmediatePropagation()
|
||||
inputRef.current?.handleInput(input, key)
|
||||
})
|
||||
|
||||
return React.createElement(BattleFlow, {
|
||||
buddyData,
|
||||
onClose,
|
||||
isActive: true,
|
||||
inputRef,
|
||||
})
|
||||
}
|
||||
@@ -2242,7 +2242,8 @@ function PromptInput({
|
||||
showTeamsDialog ||
|
||||
showQuickOpen ||
|
||||
showGlobalSearch ||
|
||||
showHistoryPicker
|
||||
showHistoryPicker ||
|
||||
isLocalJSXCommandActive
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,52 +1,84 @@
|
||||
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
import * as React from 'react'
|
||||
import { CHANNEL_ARROW } from '../../constants/figures.js'
|
||||
import { CHANNEL_TAG } from '../../constants/xml.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { truncateToWidth } from '../../utils/format.js'
|
||||
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||
import * as React from 'react';
|
||||
import { CHANNEL_ARROW } from '../../constants/figures.js';
|
||||
import { CHANNEL_TAG } from '../../constants/xml.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { truncateToWidth } from '../../utils/format.js';
|
||||
|
||||
type Props = {
|
||||
addMargin: boolean
|
||||
param: TextBlockParam
|
||||
}
|
||||
addMargin: boolean;
|
||||
param: TextBlockParam;
|
||||
};
|
||||
|
||||
// <channel source="..." user="..." chat_id="...">content</channel>
|
||||
// source is always first (wrapChannelMessage writes it), user is optional.
|
||||
const CHANNEL_RE = new RegExp(
|
||||
`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`,
|
||||
)
|
||||
const USER_ATTR_RE = /\buser="([^"]+)"/
|
||||
const CHANNEL_RE = new RegExp(`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`);
|
||||
const USER_ATTR_RE = /\buser="([^"]+)"/;
|
||||
|
||||
// Plugin-provided servers get names like plugin:slack-channel:slack via
|
||||
// addPluginScopeToServers — show just the leaf. Matches the suffix-match
|
||||
// logic in isServerInChannels.
|
||||
function displayServerName(name: string): string {
|
||||
const i = name.lastIndexOf(':')
|
||||
return i === -1 ? name : name.slice(i + 1)
|
||||
const i = name.lastIndexOf(':');
|
||||
return i === -1 ? name : name.slice(i + 1);
|
||||
}
|
||||
|
||||
const TRUNCATE_AT = 60
|
||||
const MAX_LINE_WIDTH = 80;
|
||||
const MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
* Formats multi-line channel content for compact display in the terminal.
|
||||
* Collapses excessive blank lines, limits to MAX_LINES, truncates each line.
|
||||
*/
|
||||
function formatChannelBody(raw: string): { lines: string[]; truncated: boolean } {
|
||||
const body = raw.trim();
|
||||
// Split into lines, collapse runs of blank lines into single empty line
|
||||
const allLines = body.split(/\n/).reduce<string[]>((acc, line) => {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed === '' && acc.length > 0 && acc[acc.length - 1] === '') return acc;
|
||||
acc.push(trimmed);
|
||||
return acc;
|
||||
}, []);
|
||||
// Remove leading/trailing blank lines
|
||||
while (allLines.length > 0 && allLines[0] === '') allLines.shift();
|
||||
while (allLines.length > 0 && allLines[allLines.length - 1] === '') allLines.pop();
|
||||
|
||||
const truncated = allLines.length > MAX_LINES;
|
||||
const visible = allLines.slice(0, MAX_LINES);
|
||||
const lines = visible.map(l => (l === '' ? '' : truncateToWidth(l, MAX_LINE_WIDTH)));
|
||||
return { lines, truncated };
|
||||
}
|
||||
|
||||
export function UserChannelMessage({ addMargin, param: { text } }: Props): React.ReactNode {
|
||||
const m = CHANNEL_RE.exec(text);
|
||||
if (!m) return null;
|
||||
const [, source, attrs, content] = m;
|
||||
const user = USER_ATTR_RE.exec(attrs ?? '')?.[1];
|
||||
const { lines, truncated } = formatChannelBody(content ?? '');
|
||||
|
||||
export function UserChannelMessage({
|
||||
addMargin,
|
||||
param: { text },
|
||||
}: Props): React.ReactNode {
|
||||
const m = CHANNEL_RE.exec(text)
|
||||
if (!m) return null
|
||||
const [, source, attrs, content] = m
|
||||
const user = USER_ATTR_RE.exec(attrs ?? '')?.[1]
|
||||
const body = (content ?? '').trim().replace(/\s+/g, ' ')
|
||||
const truncated = truncateToWidth(body, TRUNCATE_AT)
|
||||
return (
|
||||
<Box marginTop={addMargin ? 1 : 0}>
|
||||
<Text>
|
||||
<Text color="suggestion">{CHANNEL_ARROW}</Text>{' '}
|
||||
<Text dimColor>
|
||||
{displayServerName(source ?? '')}
|
||||
{user ? ` \u00b7 ${user}` : ''}:
|
||||
</Text>{' '}
|
||||
{truncated}
|
||||
</Text>
|
||||
<Box marginTop={addMargin ? 1 : 0} flexDirection="column">
|
||||
{lines.map((line, i) => (
|
||||
<Box key={i}>
|
||||
{i === 0 ? (
|
||||
<Text>
|
||||
<Text color="suggestion">{CHANNEL_ARROW}</Text>{' '}
|
||||
<Text dimColor>
|
||||
{displayServerName(source ?? '')}
|
||||
{user ? ` \u00b7 ${user}` : ''}:
|
||||
</Text>{' '}
|
||||
{line}
|
||||
{truncated && i === lines.length - 1 ? ' …' : ''}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
{' '}
|
||||
{line}
|
||||
{truncated && i === lines.length - 1 ? ' …' : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type CancelRequestHandlerProps = {
|
||||
popCommandFromQueue?: () => void
|
||||
vimMode?: VimMode
|
||||
isLocalJSXCommand?: boolean
|
||||
onDismissLocalJSX?: () => void
|
||||
isSearchingHistory?: boolean
|
||||
isHelpOpen?: boolean
|
||||
inputMode?: PromptInputMode
|
||||
@@ -71,6 +72,7 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
||||
popCommandFromQueue,
|
||||
vimMode,
|
||||
isLocalJSXCommand,
|
||||
onDismissLocalJSX,
|
||||
isSearchingHistory,
|
||||
isHelpOpen,
|
||||
inputMode,
|
||||
@@ -92,6 +94,12 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
||||
streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
}
|
||||
|
||||
// Priority 0: Dismiss local JSX command panel (e.g. /buddy, /config)
|
||||
if (isLocalJSXCommand && onDismissLocalJSX) {
|
||||
onDismissLocalJSX()
|
||||
return
|
||||
}
|
||||
|
||||
// Priority 1: If there's an active task running, cancel it first
|
||||
// This takes precedence over queue management so users can always interrupt Claude
|
||||
if (abortSignal !== undefined && !abortSignal.aborted) {
|
||||
@@ -140,16 +148,16 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
||||
screen !== 'transcript' &&
|
||||
!isSearchingHistory &&
|
||||
!isMessageSelectorVisible &&
|
||||
!isLocalJSXCommand &&
|
||||
!isHelpOpen &&
|
||||
!isOverlayActive &&
|
||||
!(isVimModeEnabled() && vimMode === 'INSERT')
|
||||
|
||||
// Escape (chat:cancel) defers to mode-exit when in special mode with empty
|
||||
// input, and to useBackgroundTaskNavigation when viewing a teammate
|
||||
// input, and to useBackgroundTaskNavigation when viewing a teammate.
|
||||
// Also active when a local JSX command panel (e.g. /buddy) is showing.
|
||||
const isEscapeActive =
|
||||
isContextActive &&
|
||||
(canCancelRunningTask || hasQueuedCommands) &&
|
||||
(canCancelRunningTask || hasQueuedCommands || !!isLocalJSXCommand) &&
|
||||
!isInSpecialModeWithEmptyInput &&
|
||||
!isViewingTeammate
|
||||
|
||||
|
||||
@@ -1285,6 +1285,9 @@ export function REPL({
|
||||
shouldContinueAnimation?: true;
|
||||
showSpinner?: boolean;
|
||||
isLocalJSXCommand: true;
|
||||
/** Called when the panel is dismissed externally (ESC via CancelRequestHandler).
|
||||
* Resolves the underlying Promise in processSlashCommand.tsx. */
|
||||
onDismiss?: () => void;
|
||||
} | null>(null);
|
||||
|
||||
// Wrapper for setToolJSX that preserves local JSX commands (like /btw).
|
||||
@@ -1305,6 +1308,7 @@ export function REPL({
|
||||
showSpinner?: boolean;
|
||||
isLocalJSXCommand?: boolean;
|
||||
clearLocalJSX?: boolean;
|
||||
onDismiss?: () => void;
|
||||
} | null,
|
||||
) => {
|
||||
// If setting a local JSX command, store it in the ref
|
||||
@@ -1319,6 +1323,9 @@ export function REPL({
|
||||
if (localJSXCommandRef.current) {
|
||||
// Allow clearing only if explicitly requested (from onDone callbacks)
|
||||
if (args?.clearLocalJSX) {
|
||||
// Notify the command that its panel was dismissed externally (e.g. ESC)
|
||||
// so it can resolve the underlying Promise and unblock executeUserInput.
|
||||
localJSXCommandRef.current.onDismiss?.();
|
||||
localJSXCommandRef.current = null;
|
||||
setToolJSXInternal(null);
|
||||
return;
|
||||
@@ -2561,6 +2568,9 @@ export function REPL({
|
||||
popCommandFromQueue: handleQueuedCommandOnCancel,
|
||||
vimMode,
|
||||
isLocalJSXCommand: toolJSX?.isLocalJSXCommand,
|
||||
onDismissLocalJSX: useCallback(() => {
|
||||
setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true });
|
||||
}, [setToolJSX]),
|
||||
isSearchingHistory,
|
||||
isHelpOpen,
|
||||
inputMode,
|
||||
@@ -3431,6 +3441,84 @@ export function REPL({
|
||||
// Log query profiling report if enabled
|
||||
logQueryProfileReport();
|
||||
|
||||
// ── Buddy EV/XP/egg hook ──
|
||||
if (feature('BUDDY')) {
|
||||
try {
|
||||
const {
|
||||
loadBuddyData: _load,
|
||||
saveBuddyData: _save,
|
||||
getActiveCreature: _getActive,
|
||||
awardXP: _awardXP,
|
||||
awardTurnEV: _awardEV,
|
||||
advanceEggSteps: _advSteps,
|
||||
checkEvolution: _checkEvo,
|
||||
checkEggEligibility: _checkEgg,
|
||||
generateEgg: _genEgg,
|
||||
isEggReadyToHatch: _isReady,
|
||||
hatchEgg: _hatchEgg,
|
||||
updateDailyStats: _updateDaily,
|
||||
incrementTurns: _incTurns,
|
||||
} = await import('@claude-code-best/pokemon');
|
||||
const _data = _updateDaily(_incTurns(await _load()));
|
||||
const _creature = _getActive(_data);
|
||||
if (_creature) {
|
||||
// 1. Collect tool names from this turn's messages
|
||||
const _toolNames: string[] = [];
|
||||
for (const _msg of messagesRef.current) {
|
||||
if (_msg.type === 'assistant' && Array.isArray((_msg as any).message?.content)) {
|
||||
for (const _block of (_msg as any).message.content) {
|
||||
if (_block.type === 'tool_use') _toolNames.push(_block.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Award EV for tool usage
|
||||
const _evolved = _awardEV(_creature, _toolNames);
|
||||
if (_evolved !== _creature) {
|
||||
_data.creatures = _data.creatures.map((c: any) => (c.id === _creature.id ? _evolved : c));
|
||||
}
|
||||
// 3. Award conversation XP
|
||||
const _xpResult = _awardXP(_evolved, 5 + _toolNames.length);
|
||||
_data.creatures = _data.creatures.map((c: any) => (c.id === _creature.id ? _xpResult.creature : c));
|
||||
// 3b. Update companion XP info for status display
|
||||
{
|
||||
const { getXpProgress: _getXp } = await import('@claude-code-best/pokemon');
|
||||
const _prog = _getXp(_xpResult.creature);
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
companionXpInfo: {
|
||||
level: _xpResult.newLevel,
|
||||
xpGained: 5 + _toolNames.length,
|
||||
xpCurrent: _prog.current,
|
||||
xpNeeded: _prog.needed,
|
||||
leveledUp: _xpResult.leveledUp,
|
||||
},
|
||||
}));
|
||||
}
|
||||
// 4. Advance egg steps
|
||||
if (_data.eggs.length > 0) {
|
||||
_data.eggs = _data.eggs.map((e: any) => _advSteps(e, 3));
|
||||
const _readyEgg = _data.eggs.find(_isReady);
|
||||
if (_readyEgg) {
|
||||
const { buddyData: _hatched, creature: _newC } = await _hatchEgg(_data, _readyEgg);
|
||||
Object.assign(_data, _hatched);
|
||||
}
|
||||
}
|
||||
// 5. Check evolution
|
||||
const _evoResult = _checkEvo(_xpResult.creature);
|
||||
if (_evoResult) {
|
||||
setAppState(prev => ({ ...prev, companionEvolving: { from: _evoResult.from, to: _evoResult.to } }));
|
||||
}
|
||||
// 6. Check egg eligibility
|
||||
if (_checkEgg(_data)) {
|
||||
_data.eggs.push(_genEgg(_data));
|
||||
}
|
||||
_save(_data);
|
||||
}
|
||||
} catch {
|
||||
// Buddy system is non-critical; silently ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Signal that a query turn has completed successfully
|
||||
await onTurnComplete?.(messagesRef.current);
|
||||
},
|
||||
|
||||
@@ -169,6 +169,14 @@ export type AppState = DeepImmutable<{
|
||||
companionReaction?: string
|
||||
// Timestamp of last /buddy pet — CompanionSprite renders hearts while recent
|
||||
companionPetAt?: number
|
||||
// Pokémon evolution animation state
|
||||
companionEvolving?: { from: string; to: string }
|
||||
// Egg steps update counter (triggers UI refresh)
|
||||
companionEggSteps?: number
|
||||
// XP info for companion status display (set after each turn)
|
||||
companionXpInfo?: { level: number; xpGained: number; xpCurrent: number; xpNeeded: number; leveledUp: boolean }
|
||||
// Timestamp when active creature was switched — triggers CompanionSprite refresh
|
||||
companionCreatureChangedAt?: number
|
||||
// TODO (ashwin): see if we can use utility-types DeepReadonly for this
|
||||
mcp: {
|
||||
clients: MCPServerConnection[]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user