@sdd330dev/jy-skill 0.3.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +39 -8
- package/SKILL.md +32 -15
- package/assets/game-config.json +63 -2
- package/assets/templates.json +166 -7
- package/package.json +11 -1
- package/references/agent-handbook.md +47 -9
- package/references/host-adapters.md +182 -0
- package/scripts/build-feishu-card.ts +53 -0
- package/scripts/choice-prompt.ts +179 -0
- package/scripts/config-loader.ts +132 -14
- package/scripts/event-engine.ts +205 -0
- package/scripts/game-engine.ts +430 -13
- package/scripts/game-types.ts +142 -0
- package/scripts/mcp-server.ts +161 -0
- package/scripts/persistence.ts +61 -27
|
@@ -32,8 +32,16 @@ moveTo 若返回 encounter → 必须 startBattle 并打完或玩家死亡
|
|
|
32
32
|
战斗后 isDead(state) ? → 死亡结算 + restartGame()
|
|
33
33
|
↓
|
|
34
34
|
getStatus(state) 生成状态栏,附在回复末尾
|
|
35
|
+
↓
|
|
36
|
+
buildChoicePrompt(state) → Host 渲染点选 UI(MCP Elicitation / AskQuestion / 飞书卡片)
|
|
37
|
+
↓
|
|
38
|
+
玩家点选 → resolveOption(state, optionId)
|
|
35
39
|
```
|
|
36
40
|
|
|
41
|
+
**标准契约**:优先 `buildChoicePrompt` + Host UI;详见 [host-adapters.md](host-adapters.md)。
|
|
42
|
+
|
|
43
|
+
**数字 fallback**:无 UI 能力时,Agent 可展示编号列表;玩家输入 `1`–`9` → `resolveOption(state, getOptions(state)[n-1].id)`。
|
|
44
|
+
|
|
37
45
|
**复合指令**:按逻辑顺序逐步调用。例:「去平安镇买铁剑并装备」→ `moveTo` → 若 `encounter` 先战斗 → `buyItem` → `equipItem`。
|
|
38
46
|
|
|
39
47
|
**续玩 vs 新游戏**:
|
|
@@ -104,11 +112,29 @@ getStatus(state) 生成状态栏,附在回复末尾
|
|
|
104
112
|
### 3.7 商店与对话
|
|
105
113
|
|
|
106
114
|
- **`buyItem`**:物品须在**当前地点** `shops` 列表中,且银两 ≥ 价格。
|
|
107
|
-
- **`talkTo`**:NPC 名须与当前地点 `npcs`
|
|
115
|
+
- **`talkTo`**:NPC 名须与当前地点 `npcs` **完全一致**;返回 `npc` 角色卡、`choices?`、`context?`、`events?`。
|
|
108
116
|
- **`talkTo(state, 'random')`**:随机与在场 NPC 对话。
|
|
109
|
-
-
|
|
117
|
+
- **`chooseDialog(state, dialogId, choiceIndex)`**:推进对话分支,执行 choice actions(setFlag/addItem/battle/heal)。
|
|
118
|
+
- 有配置对话的 NPC 返回「说话者:「台词」」格式;Agent 基于 `npc.persona` 扩写,数值以引擎为准。
|
|
119
|
+
|
|
120
|
+
### 3.8 事件与任务(flags)
|
|
121
|
+
|
|
122
|
+
- `game-config.json` 中 `events` 已由 `event-engine.ts` 接入。
|
|
123
|
+
- **`auto` 事件**:`moveTo` 抵达、`createNewGame` 首次落点触发(如小村 `village_start`)。
|
|
124
|
+
- **`interact` 事件**:通过 `getOptions` 生成探索选项,`resolveOption` 触发(如山洞 `cave_treasure`)。
|
|
125
|
+
- **`talk` 事件**:`talkTo` 时按 NPC 名与条件触发(如 `cave_master_quest`)。
|
|
126
|
+
- `state.flags` 持久化;条件 `flag` / `level` / `item` 由引擎判定。
|
|
127
|
+
|
|
128
|
+
### 3.9 行动选项与 PlayerChoicePrompt
|
|
129
|
+
|
|
130
|
+
- **`buildChoicePrompt(state)`**:生成 Host 无关的 `PlayerChoicePrompt`(`choices[].value` = `ActionOption.id`)。
|
|
131
|
+
- **`toMcpElicitationParams(prompt)`** / **`fromElicitationResponse(content)`**:MCP Elicitation 映射。
|
|
132
|
+
- **`toFeishuInteractiveCard(prompt)`**:飞书交互卡片 JSON。
|
|
133
|
+
- **`getOptions(state)`**:内部行动列表;与 `buildChoicePrompt().choices[].value` 一一对应。
|
|
134
|
+
- **`resolveOption(state, optionId)`**:按 id 前缀分发到 talk/move/buy/interact/rest/status/explore/paginate/dialog。
|
|
135
|
+
- 每轮回复末尾 MUST 输出 `buildChoicePrompt`;Host 有 UI 时禁止让玩家打字选行动。
|
|
110
136
|
|
|
111
|
-
### 3.
|
|
137
|
+
### 3.10 武功
|
|
112
138
|
|
|
113
139
|
- 新角色默认:**基本拳法**(不耗内力)。
|
|
114
140
|
- **`learnSkill`**:武功须存在于 `assets/skills.json`;已学会则失败。
|
|
@@ -167,12 +193,14 @@ while (尚有敌人 hp > 0 && !isDead(state)) {
|
|
|
167
193
|
|
|
168
194
|
| 函数 | 返回 | 说明 |
|
|
169
195
|
|------|------|------|
|
|
170
|
-
| `loadOrCreateGame(createNewGame, name?)` | `{ state, isNewGame }` |
|
|
171
|
-
| `
|
|
196
|
+
| `loadOrCreateGame(createNewGame, name?)` | `{ state, isNewGame }` | 单用户默认档 |
|
|
197
|
+
| `loadOrCreateGameForUser(userId, createNewGame, name?)` | `{ state, isNewGame }` | 多用户 `save/users/{userId}.json` |
|
|
198
|
+
| `setSaveUserId(userId \| null)` | void | 切换当前会话存档目标 |
|
|
199
|
+
| `loadGameState(userId?)` | `GameState \| null` | 仅读取,不创建 |
|
|
172
200
|
| `saveGameState(state)` | void | 幂等手动存档 |
|
|
173
201
|
| `deleteSave()` | void | 仅删档;重开请用 `restartGame` |
|
|
174
202
|
| `restartGame(name?)` | `GameState` | 删档 + 新建 + 落盘 |
|
|
175
|
-
| `createNewGame(name)` | `GameState` |
|
|
203
|
+
| `createNewGame(name)` | `GameState` | 新建并触发 auto 事件、落盘 |
|
|
176
204
|
|
|
177
205
|
### 查询
|
|
178
206
|
|
|
@@ -181,14 +209,24 @@ while (尚有敌人 hp > 0 && !isDead(state)) {
|
|
|
181
209
|
| `getStatus(state)` | 状态栏文本(含经验、中毒/受伤行) |
|
|
182
210
|
| `getInventory(state)` | 银两 + 物品列表 |
|
|
183
211
|
| `getSkills(state)` | 已学武功 |
|
|
184
|
-
| `getLocationInfo(state)` |
|
|
212
|
+
| `getLocationInfo(state)` | 当前地点文本(含描述、险地提示) |
|
|
213
|
+
| `getLocationDetail(state)` | 结构化地点:description、atmosphere、dangerLevel、npcs |
|
|
214
|
+
| `getOptions(state)` | 当前可选行动列表(与 buildChoicePrompt 一致) |
|
|
215
|
+
| `buildChoicePrompt(state, ctx?)` | `PlayerChoicePrompt` 标准契约 |
|
|
216
|
+
| `buildChoicePromptFromTalk(talkResult, state)` | 对话分支选项契约 |
|
|
217
|
+
| `toMcpElicitationParams(prompt)` | MCP elicitation/create 参数 |
|
|
218
|
+
| `fromElicitationResponse(content)` | Elicitation 回传 → optionId |
|
|
219
|
+
| `toFeishuInteractiveCard(prompt)` | 飞书交互卡片 JSON |
|
|
220
|
+
| `getNpcContext(state, npc)` | LLM-NPC 约束包:card、constraints、availableActions |
|
|
185
221
|
|
|
186
222
|
### 探索与交互
|
|
187
223
|
|
|
188
224
|
| 函数 | 返回 | 失败常见原因 |
|
|
189
225
|
|------|------|--------------|
|
|
190
|
-
| `moveTo(state, dest)` | `{ success, message, encounter? }` | 不相连、无处可去 |
|
|
191
|
-
| `talkTo(state, npc)` | `
|
|
226
|
+
| `moveTo(state, dest)` | `{ success, message, encounter?, events?, locationDetail? }` | 不相连、无处可去 |
|
|
227
|
+
| `talkTo(state, npc)` | `TalkResult`(含 `npc?`, `choices?`, `context?`, `events?`) | NPC 不在场 |
|
|
228
|
+
| `chooseDialog(state, dialogId, index)` | `TalkResult` | 无效选项 |
|
|
229
|
+
| `resolveOption(state, optionId)` | `{ action, result }` | 未知 optionId |
|
|
192
230
|
| `rest(state)` | `{ success, message }` | 一般总成功 |
|
|
193
231
|
| `buyItem(state, item)` | `{ success, message }` | 非本店、无货、银两不足 |
|
|
194
232
|
| `useItem(state, item)` | `{ success, message }` | 无物品、非消耗品、无收益 |
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Host 选项适配指南
|
|
2
|
+
|
|
3
|
+
jy 引擎输出统一的 **PlayerChoicePrompt** 标准契约,各 Host 按自身能力渲染 UI,回传 `choices[].value` 后调用 `resolveOption(state, optionId)`。
|
|
4
|
+
|
|
5
|
+
## 标准契约
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
interface PlayerChoicePrompt {
|
|
9
|
+
type: 'player_choice';
|
|
10
|
+
message: string;
|
|
11
|
+
choices: Array<{
|
|
12
|
+
value: string; // 回传用,如 talk_村长、move_平安镇
|
|
13
|
+
label: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
category?: string;
|
|
16
|
+
}>;
|
|
17
|
+
dialogChoices?: DialogChoice[];
|
|
18
|
+
dialogId?: string;
|
|
19
|
+
page?: number;
|
|
20
|
+
hasMore?: boolean;
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
引擎 API:
|
|
25
|
+
|
|
26
|
+
| 函数 | 用途 |
|
|
27
|
+
|------|------|
|
|
28
|
+
| `buildChoicePrompt(state, ctx?)` | 从当前状态生成契约 |
|
|
29
|
+
| `buildChoicePromptFromTalk(state, talkResult)` | 对话含分支时生成契约 |
|
|
30
|
+
| `toMcpElicitationParams(prompt)` | 转为 MCP Elicitation schema |
|
|
31
|
+
| `fromElicitationResponse(content)` | 解析用户选择 → optionId |
|
|
32
|
+
| `toFeishuInteractiveCard(prompt)` | 转为飞书交互卡片 JSON |
|
|
33
|
+
| `resolveOption(state, optionId)` | 执行选中行动 |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Host 适配矩阵
|
|
38
|
+
|
|
39
|
+
| Host | 渲染机制 | 集成方式 |
|
|
40
|
+
|------|----------|----------|
|
|
41
|
+
| **MCP 客户端** | [MCP Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) `oneOf` 单选 | 运行 `scripts/mcp-server.ts`;工具返回 `mcpElicitation` |
|
|
42
|
+
| **Cursor Agent** | `AskQuestion`(语义同 Elicitation) | Skill 规则:每轮 `buildChoicePrompt` → AskQuestion 映射 value/label |
|
|
43
|
+
| **OpenClaw + 飞书** | 交互卡片按钮 | `build-feishu-card.ts` 或 `toFeishuInteractiveCard`;按钮 value 含 `optionId` |
|
|
44
|
+
| **纯文本 Agent** | 数字/文字 fallback | 仅当 Host 无 UI 能力时使用 1/2/3 |
|
|
45
|
+
|
|
46
|
+
**原则**:有 UI 能力则 **禁止** 让玩家打字选行动;无 UI 时才 fallback。
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## MCP 客户端
|
|
51
|
+
|
|
52
|
+
### 启动 Server
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx tsx scripts/mcp-server.ts
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Claude Desktop / Cursor MCP 配置示例:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"jy-skill": {
|
|
64
|
+
"command": "npx",
|
|
65
|
+
"args": ["tsx", "/path/to/jy/scripts/mcp-server.ts"],
|
|
66
|
+
"cwd": "/path/to/jy"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 工具
|
|
73
|
+
|
|
74
|
+
| 工具 | 说明 |
|
|
75
|
+
|------|------|
|
|
76
|
+
| `jy_load_game` | `playerId` 加载/新建(多用户存档) |
|
|
77
|
+
| `jy_resolve_action` | 执行 `optionId` |
|
|
78
|
+
| `jy_get_status` | 状态 + 最新 `playerChoice` |
|
|
79
|
+
| `jy_from_elicitation` | 解析 Elicitation 响应并执行 |
|
|
80
|
+
|
|
81
|
+
工具返回 JSON 含 `playerChoice` 与 `mcpElicitation`。Host 支持 Elicitation 时,用 `mcpElicitation` 渲染单选 UI;用户提交后调 `jy_from_elicitation` 或 `jy_resolve_action`。
|
|
82
|
+
|
|
83
|
+
### Elicitation 示例
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"mode": "form",
|
|
88
|
+
"message": "江湖路远,接下来做什么?",
|
|
89
|
+
"requestedSchema": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"properties": {
|
|
92
|
+
"action": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"title": "选择行动",
|
|
95
|
+
"oneOf": [
|
|
96
|
+
{ "const": "talk_村长", "title": "和村长交谈" },
|
|
97
|
+
{ "const": "move_平安镇", "title": "前往平安镇" }
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"required": ["action"]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
用户选择后回传 `{ "action": "talk_村长" }` → `resolveOption(state, "talk_村长")`。
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Cursor Agent
|
|
111
|
+
|
|
112
|
+
Cursor 的 `AskQuestion` 与 MCP Elicitation 语义相同。Agent 工作流:
|
|
113
|
+
|
|
114
|
+
1. `buildChoicePrompt(state)` 获取选项
|
|
115
|
+
2. 调用 AskQuestion:`options[].id` = `choices[].value`,`label` = `choices[].label`
|
|
116
|
+
3. 用户点选 → `resolveOption(state, selectedValue)`
|
|
117
|
+
4. 武侠叙述 + 状态栏 + 新一轮 AskQuestion
|
|
118
|
+
|
|
119
|
+
`talkTo` 返回 `choices` 时,用 `buildChoicePromptFromTalk` 或 AskQuestion 展示对话分支;选中后 `chooseDialog` 或 `resolveOption`(`dialog:dialogId:index` 格式)。
|
|
120
|
+
|
|
121
|
+
**Fallback**:AskQuestion 不可用时,可输出编号列表(1/2/3),但应优先尝试 AskQuestion。
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## OpenClaw + 飞书
|
|
126
|
+
|
|
127
|
+
### 前置
|
|
128
|
+
|
|
129
|
+
- 安装 [OpenClaw 飞书插件](https://docs.openclaw.ai/channels/feishu)(WebSocket 模式,免公网 Webhook)
|
|
130
|
+
- jy Skill 绑定到 Agent
|
|
131
|
+
- 多用户使用 `loadOrCreateGameForUser(open_id)` / MCP `playerId`
|
|
132
|
+
|
|
133
|
+
### 发卡片
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# 演示:从小村状态生成卡片
|
|
137
|
+
npx tsx scripts/build-feishu-card.ts --demo
|
|
138
|
+
|
|
139
|
+
# 从 PlayerChoicePrompt JSON 生成
|
|
140
|
+
echo '{"type":"player_choice",...}' | npx tsx scripts/build-feishu-card.ts
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
输出 `msg_type: interactive` 与 `content` JSON,通过飞书 IM API 或 OpenClaw 发送。
|
|
144
|
+
|
|
145
|
+
### 按钮回调
|
|
146
|
+
|
|
147
|
+
按钮 `value` 为 `{ "optionId": "talk_村长" }`。收到 `card.action.trigger` 后:
|
|
148
|
+
|
|
149
|
+
1. 解析 `optionId`
|
|
150
|
+
2. `resolveOption(state, optionId)`
|
|
151
|
+
3. 叙述结果 + 发送新卡片(`buildChoicePrompt` → `toFeishuInteractiveCard`)
|
|
152
|
+
|
|
153
|
+
也可安装社区 Skill `feishu-interactive-cards`,由 Agent 将 `PlayerChoicePrompt` 转为 confirmation/choice 卡片。
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 多用户存档
|
|
158
|
+
|
|
159
|
+
| 场景 | API |
|
|
160
|
+
|------|-----|
|
|
161
|
+
| 单用户(Cursor 默认) | `loadOrCreateGame` → `save/game-state.json` |
|
|
162
|
+
| 飞书/OpenClaw 多用户 | `loadOrCreateGameForUser(userId)` → `save/users/{userId}.json` |
|
|
163
|
+
| MCP Server | 工具参数 `playerId` 自动隔离 |
|
|
164
|
+
|
|
165
|
+
`setSaveUserId(userId)` 可切换当前会话存档目标。
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 分页
|
|
170
|
+
|
|
171
|
+
选项超过 8 条时,`buildChoicePrompt` 自动分页,含「上一页」「查看更多…」导航项(`__page_N`)。
|
|
172
|
+
`resolveOption(state, "__page_1")` 返回 `{ action: 'paginate', result: PlayerChoicePrompt }`,Host 重新渲染即可,不修改游戏状态。
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 对话分支 value 格式
|
|
177
|
+
|
|
178
|
+
| 类型 | value 格式 | 处理 |
|
|
179
|
+
|------|------------|------|
|
|
180
|
+
| 地图行动 | `talk_村长`、`move_平安镇`、`buy_铁剑` | `resolveOption` |
|
|
181
|
+
| 对话分支 | `dialog:village_elder:0` | `resolveOption` → `chooseDialog` |
|
|
182
|
+
| 分页 | `__page_0` | `resolveOption` → 新 prompt |
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 将 PlayerChoicePrompt 转为飞书交互卡片 JSON
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* echo '{"type":"player_choice","message":"...","choices":[...]}' | npx tsx scripts/build-feishu-card.ts
|
|
7
|
+
* npx tsx scripts/build-feishu-card.ts --demo
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { createNewGame, buildChoicePrompt } from './game-engine.js';
|
|
12
|
+
import { toFeishuInteractiveCard, feishuCardToMessageContent } from './choice-prompt.js';
|
|
13
|
+
import type { PlayerChoicePrompt } from './game-types.js';
|
|
14
|
+
|
|
15
|
+
function readStdin(): Promise<string> {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const chunks: Buffer[] = [];
|
|
18
|
+
process.stdin.on('data', (c: Buffer) => chunks.push(c));
|
|
19
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
20
|
+
process.stdin.on('error', reject);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main(): Promise<void> {
|
|
25
|
+
let prompt: PlayerChoicePrompt;
|
|
26
|
+
|
|
27
|
+
if (process.argv.includes('--demo')) {
|
|
28
|
+
const state = createNewGame('主角');
|
|
29
|
+
prompt = buildChoicePrompt(state);
|
|
30
|
+
} else if (process.argv[2] && !process.argv[2].startsWith('-')) {
|
|
31
|
+
prompt = JSON.parse(readFileSync(process.argv[2], 'utf-8')) as PlayerChoicePrompt;
|
|
32
|
+
} else {
|
|
33
|
+
const input = await readStdin();
|
|
34
|
+
if (!input.trim()) {
|
|
35
|
+
console.error('Usage: build-feishu-card.ts [prompt.json] | --demo | stdin JSON');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
prompt = JSON.parse(input) as PlayerChoicePrompt;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const card = toFeishuInteractiveCard(prompt);
|
|
42
|
+
const output = {
|
|
43
|
+
msg_type: 'interactive',
|
|
44
|
+
content: feishuCardToMessageContent(card),
|
|
45
|
+
card,
|
|
46
|
+
};
|
|
47
|
+
console.log(JSON.stringify(output, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
main().catch((err) => {
|
|
51
|
+
console.error(err);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 玩家选项标准契约 — Host 无关的选项导出与格式适配
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ActionOption,
|
|
7
|
+
DialogChoice,
|
|
8
|
+
PlayerChoiceItem,
|
|
9
|
+
PlayerChoicePrompt,
|
|
10
|
+
McpElicitationParams,
|
|
11
|
+
FeishuInteractiveCard,
|
|
12
|
+
} from './game-types';
|
|
13
|
+
|
|
14
|
+
export const CHOICES_PER_PAGE = 8;
|
|
15
|
+
|
|
16
|
+
export interface BuildChoicePromptContext {
|
|
17
|
+
message?: string;
|
|
18
|
+
dialogChoices?: DialogChoice[];
|
|
19
|
+
dialogId?: string;
|
|
20
|
+
page?: number;
|
|
21
|
+
options?: ActionOption[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function optionsToChoiceItems(options: ActionOption[]): PlayerChoiceItem[] {
|
|
25
|
+
return options.map((o) => ({
|
|
26
|
+
value: o.id,
|
|
27
|
+
label: o.label,
|
|
28
|
+
description: o.hint,
|
|
29
|
+
category: o.category,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function paginateChoiceItems(
|
|
34
|
+
items: PlayerChoiceItem[],
|
|
35
|
+
page = 0,
|
|
36
|
+
perPage = CHOICES_PER_PAGE,
|
|
37
|
+
): Pick<PlayerChoicePrompt, 'choices' | 'page' | 'hasMore' | 'totalPages'> {
|
|
38
|
+
const totalPages = Math.max(1, Math.ceil(items.length / perPage));
|
|
39
|
+
const safePage = Math.max(0, Math.min(page, totalPages - 1));
|
|
40
|
+
const start = safePage * perPage;
|
|
41
|
+
const slice = items.slice(start, start + perPage);
|
|
42
|
+
const hasMore = start + perPage < items.length;
|
|
43
|
+
|
|
44
|
+
const choices: PlayerChoiceItem[] = [...slice];
|
|
45
|
+
if (safePage > 0) {
|
|
46
|
+
choices.unshift({
|
|
47
|
+
value: `__page_${safePage - 1}`,
|
|
48
|
+
label: '上一页',
|
|
49
|
+
category: 'nav',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (hasMore) {
|
|
53
|
+
choices.push({
|
|
54
|
+
value: `__page_${safePage + 1}`,
|
|
55
|
+
label: '查看更多…',
|
|
56
|
+
category: 'nav',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { choices, page: safePage, hasMore, totalPages };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildChoicePrompt(
|
|
64
|
+
options: ActionOption[],
|
|
65
|
+
ctx: BuildChoicePromptContext = {},
|
|
66
|
+
): PlayerChoicePrompt {
|
|
67
|
+
if (ctx.dialogChoices?.length) {
|
|
68
|
+
return {
|
|
69
|
+
type: 'player_choice',
|
|
70
|
+
message: ctx.message ?? '请做出选择:',
|
|
71
|
+
choices: ctx.dialogChoices.map((c) => ({
|
|
72
|
+
value: dialogChoiceValue(ctx.dialogId ?? 'unknown', c.index),
|
|
73
|
+
label: c.text,
|
|
74
|
+
category: 'talk' as const,
|
|
75
|
+
})),
|
|
76
|
+
dialogChoices: ctx.dialogChoices,
|
|
77
|
+
dialogId: ctx.dialogId,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const allItems = optionsToChoiceItems(ctx.options ?? options);
|
|
82
|
+
const { choices, page, hasMore, totalPages } = paginateChoiceItems(allItems, ctx.page ?? 0);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
type: 'player_choice',
|
|
86
|
+
message: ctx.message ?? '江湖路远,接下来做什么?',
|
|
87
|
+
choices,
|
|
88
|
+
page,
|
|
89
|
+
hasMore,
|
|
90
|
+
totalPages,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function dialogChoiceValue(dialogId: string, choiceIndex: number): string {
|
|
95
|
+
return `dialog:${dialogId}:${choiceIndex}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function parseDialogChoiceValue(value: string): { dialogId: string; index: number } | null {
|
|
99
|
+
if (!value.startsWith('dialog:')) return null;
|
|
100
|
+
const parts = value.split(':');
|
|
101
|
+
if (parts.length !== 3) return null;
|
|
102
|
+
const index = Number.parseInt(parts[2]!, 10);
|
|
103
|
+
if (Number.isNaN(index)) return null;
|
|
104
|
+
return { dialogId: parts[1]!, index };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isPaginationValue(value: string): boolean {
|
|
108
|
+
return value.startsWith('__page_');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parsePaginationValue(value: string): number | null {
|
|
112
|
+
if (!isPaginationValue(value)) return null;
|
|
113
|
+
const page = Number.parseInt(value.slice('__page_'.length), 10);
|
|
114
|
+
return Number.isNaN(page) ? null : page;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function toMcpElicitationParams(prompt: PlayerChoicePrompt): McpElicitationParams {
|
|
118
|
+
const selectable = prompt.choices.filter((c) => c.category !== 'nav');
|
|
119
|
+
return {
|
|
120
|
+
mode: 'form',
|
|
121
|
+
message: prompt.message,
|
|
122
|
+
requestedSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
action: {
|
|
126
|
+
type: 'string',
|
|
127
|
+
title: '选择行动',
|
|
128
|
+
description: prompt.dialogId ? '对话分支' : undefined,
|
|
129
|
+
oneOf: selectable.map((c) => ({
|
|
130
|
+
const: c.value,
|
|
131
|
+
title: c.description ? `${c.label} ${c.description}` : c.label,
|
|
132
|
+
})),
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
required: ['action'],
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function fromElicitationResponse(content: Record<string, unknown>): string | null {
|
|
141
|
+
const action = content.action;
|
|
142
|
+
if (typeof action === 'string' && action.length > 0) return action;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function toFeishuInteractiveCard(prompt: PlayerChoicePrompt): FeishuInteractiveCard {
|
|
147
|
+
const actionButtons = prompt.choices.map((choice) => ({
|
|
148
|
+
tag: 'button',
|
|
149
|
+
text: { tag: 'plain_text', content: choice.label },
|
|
150
|
+
type: choice.category === 'nav' ? 'default' : 'primary',
|
|
151
|
+
value: { optionId: choice.value },
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
155
|
+
for (let i = 0; i < actionButtons.length; i += 3) {
|
|
156
|
+
rows.push({
|
|
157
|
+
tag: 'action',
|
|
158
|
+
actions: actionButtons.slice(i, i + 3),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
config: { wide_screen_mode: true },
|
|
164
|
+
header: {
|
|
165
|
+
title: { tag: 'plain_text', content: prompt.message },
|
|
166
|
+
},
|
|
167
|
+
elements: rows,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function feishuCardToMessageContent(card: FeishuInteractiveCard): string {
|
|
172
|
+
return JSON.stringify(card);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function parseFeishuButtonValue(payload: unknown): string | null {
|
|
176
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
177
|
+
const optionId = (payload as { optionId?: unknown }).optionId;
|
|
178
|
+
return typeof optionId === 'string' ? optionId : null;
|
|
179
|
+
}
|