@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.
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 金庸群侠传 MCP Server — 暴露游戏工具与 PlayerChoicePrompt 标准契约
4
+ *
5
+ * 用法: npx tsx scripts/mcp-server.ts
6
+ * Cursor / Claude Desktop 配置 stdio 启动此脚本即可。
7
+ */
8
+
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { z } from 'zod';
12
+ import {
13
+ loadOrCreateGameForUser,
14
+ getStatus,
15
+ getLocationDetail,
16
+ resolveOption,
17
+ buildChoicePrompt,
18
+ toMcpElicitationParams,
19
+ fromElicitationResponse,
20
+ setSaveUserId,
21
+ } from './game-engine.js';
22
+ import type { GameState } from './game-types.js';
23
+
24
+ const sessions = new Map<string, GameState>();
25
+
26
+ function getSession(playerId: string): GameState {
27
+ let state = sessions.get(playerId);
28
+ if (!state) {
29
+ setSaveUserId(playerId);
30
+ const { state: loaded } = loadOrCreateGameForUser(playerId, '主角');
31
+ state = loaded;
32
+ sessions.set(playerId, state);
33
+ }
34
+ return state;
35
+ }
36
+
37
+ function attachChoiceMeta(result: unknown, state: GameState) {
38
+ const prompt = buildChoicePrompt(state);
39
+ return {
40
+ result,
41
+ playerChoice: prompt,
42
+ mcpElicitation: toMcpElicitationParams(prompt),
43
+ status: getStatus(state),
44
+ };
45
+ }
46
+
47
+ const server = new McpServer({
48
+ name: 'jy-skill',
49
+ version: '0.6.0',
50
+ });
51
+
52
+ server.tool(
53
+ 'jy_load_game',
54
+ '加载或新建金庸群侠传存档',
55
+ {
56
+ playerId: z.string().describe('玩家唯一 ID,如飞书 open_id'),
57
+ name: z.string().optional().describe('新角色名,默认「主角」'),
58
+ },
59
+ async ({ playerId, name }) => {
60
+ setSaveUserId(playerId);
61
+ const { state, isNewGame } = loadOrCreateGameForUser(playerId, name ?? '主角');
62
+ sessions.set(playerId, state);
63
+ const prompt = buildChoicePrompt(state);
64
+ return {
65
+ content: [
66
+ {
67
+ type: 'text',
68
+ text: JSON.stringify(
69
+ {
70
+ isNewGame,
71
+ location: state.location,
72
+ status: getStatus(state),
73
+ locationDetail: getLocationDetail(state),
74
+ playerChoice: prompt,
75
+ mcpElicitation: toMcpElicitationParams(prompt),
76
+ },
77
+ null,
78
+ 2,
79
+ ),
80
+ },
81
+ ],
82
+ };
83
+ },
84
+ );
85
+
86
+ server.tool(
87
+ 'jy_resolve_action',
88
+ '执行玩家选中的行动(optionId 来自 playerChoice.choices[].value)',
89
+ {
90
+ playerId: z.string(),
91
+ optionId: z.string().describe('PlayerChoicePrompt choices 中的 value'),
92
+ },
93
+ async ({ playerId, optionId }) => {
94
+ const state = getSession(playerId);
95
+ const resolved = resolveOption(state, optionId);
96
+ const payload = attachChoiceMeta(resolved, state);
97
+ return {
98
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
99
+ };
100
+ },
101
+ );
102
+
103
+ server.tool(
104
+ 'jy_get_status',
105
+ '获取当前状态栏与可选行动',
106
+ { playerId: z.string() },
107
+ async ({ playerId }) => {
108
+ const state = getSession(playerId);
109
+ const prompt = buildChoicePrompt(state);
110
+ return {
111
+ content: [
112
+ {
113
+ type: 'text',
114
+ text: JSON.stringify(
115
+ {
116
+ status: getStatus(state),
117
+ playerChoice: prompt,
118
+ mcpElicitation: toMcpElicitationParams(prompt),
119
+ },
120
+ null,
121
+ 2,
122
+ ),
123
+ },
124
+ ],
125
+ };
126
+ },
127
+ );
128
+
129
+ server.tool(
130
+ 'jy_from_elicitation',
131
+ '将 MCP Elicitation 用户响应解析为 optionId 并执行',
132
+ {
133
+ playerId: z.string(),
134
+ elicitationContent: z.record(z.string(), z.unknown()).describe('elicitation 表单回传 JSON'),
135
+ },
136
+ async ({ playerId, elicitationContent }) => {
137
+ const optionId = fromElicitationResponse(elicitationContent);
138
+ if (!optionId) {
139
+ return {
140
+ content: [{ type: 'text', text: JSON.stringify({ error: '无效的 elicitation 响应' }) }],
141
+ isError: true,
142
+ };
143
+ }
144
+ const state = getSession(playerId);
145
+ const resolved = resolveOption(state, optionId);
146
+ const payload = attachChoiceMeta(resolved, state);
147
+ return {
148
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
149
+ };
150
+ },
151
+ );
152
+
153
+ async function main(): Promise<void> {
154
+ const transport = new StdioServerTransport();
155
+ await server.connect(transport);
156
+ }
157
+
158
+ main().catch((err) => {
159
+ console.error(err);
160
+ process.exit(1);
161
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 游戏存档持久化 — save/game-state.json
2
+ * 游戏存档持久化 — save/game-state.json 或 save/users/{userId}.json
3
3
  */
4
4
 
5
5
  import {
@@ -23,11 +23,32 @@ export interface LoadGameResult {
23
23
 
24
24
  const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
25
25
  const SAVE_DIR = join(ROOT_DIR, 'save');
26
+ const USERS_DIR = join(SAVE_DIR, 'users');
26
27
  const SAVE_FILE = join(SAVE_DIR, 'game-state.json');
27
- const SAVE_TMP = join(SAVE_DIR, 'game-state.json.tmp');
28
28
 
29
- export function getSavePath(): string {
30
- return SAVE_FILE;
29
+ let currentSaveUserId: string | null = null;
30
+
31
+ /** 设置当前会话使用的存档用户 ID(null = 默认单用户档) */
32
+ export function setSaveUserId(userId: string | null): void {
33
+ currentSaveUserId = userId;
34
+ }
35
+
36
+ export function getSaveUserId(): string | null {
37
+ return currentSaveUserId;
38
+ }
39
+
40
+ function sanitizeUserId(userId: string): string {
41
+ return userId.replace(/[^a-zA-Z0-9_-]/g, '_');
42
+ }
43
+
44
+ export function getSavePath(userId?: string | null): string {
45
+ const id = userId === undefined ? currentSaveUserId : userId;
46
+ if (!id) return SAVE_FILE;
47
+ return join(USERS_DIR, `${sanitizeUserId(id)}.json`);
48
+ }
49
+
50
+ function getSaveTmpPath(savePath: string): string {
51
+ return `${savePath}.tmp`;
31
52
  }
32
53
 
33
54
  function isFiniteNumber(n: unknown): n is number {
@@ -122,49 +143,62 @@ function migrateGameState(state: GameState): GameState {
122
143
  return state;
123
144
  }
124
145
 
125
- /** 读取存档;不存在或损坏时返回 null */
126
- export function loadGameState(): GameState | null {
127
- if (!existsSync(SAVE_FILE)) return null;
128
-
146
+ function loadGameStateFromPath(path: string): GameState | null {
147
+ if (!existsSync(path)) return null;
129
148
  try {
130
- const raw = readFileSync(SAVE_FILE, 'utf-8');
149
+ const raw = readFileSync(path, 'utf-8');
131
150
  const parsed: unknown = JSON.parse(raw);
132
- if (!isValidGameState(parsed)) {
133
- return null;
134
- }
151
+ if (!isValidGameState(parsed)) return null;
135
152
  return migrateGameState(parsed);
136
153
  } catch {
137
154
  return null;
138
155
  }
139
156
  }
140
157
 
158
+ /** 读取存档;不存在或损坏时返回 null */
159
+ export function loadGameState(userId?: string | null): GameState | null {
160
+ return loadGameStateFromPath(getSavePath(userId));
161
+ }
162
+
141
163
  /** 写入存档(原子替换,避免写入中断损坏) */
142
- export function saveGameState(state: GameState): void {
143
- if (!existsSync(SAVE_DIR)) {
144
- mkdirSync(SAVE_DIR, { recursive: true });
145
- }
146
- writeFileSync(SAVE_TMP, JSON.stringify(state, null, 2), 'utf-8');
147
- renameSync(SAVE_TMP, SAVE_FILE);
164
+ export function saveGameState(state: GameState, userId?: string | null): void {
165
+ const path = getSavePath(userId);
166
+ const dir = dirname(path);
167
+ if (!existsSync(dir)) {
168
+ mkdirSync(dir, { recursive: true });
169
+ }
170
+ const tmp = getSaveTmpPath(path);
171
+ writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
172
+ renameSync(tmp, path);
148
173
  }
149
174
 
150
175
  /** 删除存档 */
151
- export function deleteSave(): void {
152
- if (existsSync(SAVE_FILE)) {
153
- unlinkSync(SAVE_FILE);
154
- }
155
- if (existsSync(SAVE_TMP)) {
156
- unlinkSync(SAVE_TMP);
157
- }
176
+ export function deleteSave(userId?: string | null): void {
177
+ const path = getSavePath(userId);
178
+ const tmp = getSaveTmpPath(path);
179
+ if (existsSync(path)) unlinkSync(path);
180
+ if (existsSync(tmp)) unlinkSync(tmp);
158
181
  }
159
182
 
160
183
  /** 开始或继续:有存档则加载,否则新建并落盘 */
161
184
  export function loadOrCreateGame(
162
185
  createNewGame: (name: string) => GameState,
163
186
  name = '主角',
187
+ userId?: string | null,
164
188
  ): LoadGameResult {
165
- const existing = loadGameState();
189
+ const existing = loadGameState(userId);
166
190
  if (existing) return { state: existing, isNewGame: false };
167
191
  const state = createNewGame(name);
168
- saveGameState(state);
192
+ saveGameState(state, userId);
169
193
  return { state, isNewGame: true };
170
194
  }
195
+
196
+ /** 多用户场景:按 userId 加载或新建 */
197
+ export function loadOrCreateGameForUser(
198
+ userId: string,
199
+ createNewGame: (name: string) => GameState,
200
+ name = '主角',
201
+ ): LoadGameResult {
202
+ setSaveUserId(userId);
203
+ return loadOrCreateGame(createNewGame, name, userId);
204
+ }