@sdd330dev/jy-skill 0.3.0 → 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 +41 -8
- package/README.md +54 -10
- package/SKILL.md +32 -15
- package/assets/game-config.json +63 -2
- package/assets/templates.json +168 -9
- package/package.json +11 -1
- package/references/agent-handbook.md +49 -10
- package/references/host-adapters.md +182 -0
- package/references/player-guide.md +5 -4
- package/save/.gitkeep +0 -0
- package/scripts/build-feishu-card.ts +53 -0
- package/scripts/choice-prompt.ts +179 -0
- package/scripts/config-loader.ts +182 -16
- package/scripts/event-engine.ts +205 -0
- package/scripts/game-engine.ts +683 -40
- package/scripts/game-logic.ts +5 -0
- package/scripts/game-types.ts +149 -0
- package/scripts/mcp-server.ts +161 -0
- package/scripts/persistence.ts +100 -31
package/scripts/game-logic.ts
CHANGED
|
@@ -105,6 +105,11 @@ export function calculateMovePoints(agility: number): number {
|
|
|
105
105
|
return Math.floor(agility / 15) + 3;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** 地点移动体力消耗:轻功越高消耗越低,最低 3 */
|
|
109
|
+
export function calculateMoveStaminaCost(agility: number): number {
|
|
110
|
+
return Math.max(3, 8 - Math.floor(agility / 15));
|
|
111
|
+
}
|
|
112
|
+
|
|
108
113
|
// ============================================================================
|
|
109
114
|
// 默认属性
|
|
110
115
|
// ============================================================================
|
package/scripts/game-types.ts
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import type { DEFAULT_ATTRIBUTES } from './game-logic';
|
|
6
6
|
|
|
7
|
+
export interface CharacterBuffs {
|
|
8
|
+
attack?: number;
|
|
9
|
+
agility?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
export interface Character {
|
|
8
13
|
name: string;
|
|
9
14
|
level: number;
|
|
@@ -19,6 +24,8 @@ export interface Character {
|
|
|
19
24
|
equipment: { weapon: string | null; armor: string | null };
|
|
20
25
|
skills: string[];
|
|
21
26
|
skillLevels: Record<string, number>;
|
|
27
|
+
skillExp?: Record<string, number>;
|
|
28
|
+
buffs?: CharacterBuffs;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
export interface Inventory {
|
|
@@ -44,3 +51,145 @@ export interface BattleEnemy {
|
|
|
44
51
|
attack: number;
|
|
45
52
|
defence: number;
|
|
46
53
|
}
|
|
54
|
+
|
|
55
|
+
export interface NpcPersonaDetail {
|
|
56
|
+
archetype?: string;
|
|
57
|
+
tone?: string;
|
|
58
|
+
likes?: string[];
|
|
59
|
+
dislikes?: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface NpcTeachCondition {
|
|
63
|
+
minLevel?: number;
|
|
64
|
+
minIQ?: number;
|
|
65
|
+
flag?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface NpcCard {
|
|
69
|
+
name: string;
|
|
70
|
+
title?: string;
|
|
71
|
+
persona: string | NpcPersonaDetail;
|
|
72
|
+
knowledge: string[];
|
|
73
|
+
canHelp?: string[];
|
|
74
|
+
isShop?: boolean;
|
|
75
|
+
canTeach?: string[];
|
|
76
|
+
canGive?: string[];
|
|
77
|
+
conditions?: {
|
|
78
|
+
teach?: NpcTeachCondition;
|
|
79
|
+
give?: NpcTeachCondition;
|
|
80
|
+
quest?: NpcTeachCondition;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface DialogChoice {
|
|
85
|
+
text: string;
|
|
86
|
+
nextId: string;
|
|
87
|
+
index: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface EventResult {
|
|
91
|
+
type: 'dialog' | 'setFlag' | 'addItem' | 'message' | 'battle' | 'heal';
|
|
92
|
+
message?: string;
|
|
93
|
+
dialogId?: string;
|
|
94
|
+
choices?: DialogChoice[];
|
|
95
|
+
flag?: string;
|
|
96
|
+
itemName?: string;
|
|
97
|
+
enemyName?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ActionOption {
|
|
101
|
+
id: string;
|
|
102
|
+
label: string;
|
|
103
|
+
category: 'talk' | 'move' | 'shop' | 'explore' | 'rest' | 'status' | 'interact';
|
|
104
|
+
hint?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface TalkResult {
|
|
108
|
+
success: boolean;
|
|
109
|
+
message: string;
|
|
110
|
+
npc?: NpcCard;
|
|
111
|
+
choices?: DialogChoice[];
|
|
112
|
+
events?: EventResult[];
|
|
113
|
+
dialogId?: string;
|
|
114
|
+
context?: NpcContext;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface LocationDetail {
|
|
118
|
+
name: string;
|
|
119
|
+
description: string;
|
|
120
|
+
atmosphere: string;
|
|
121
|
+
dangerLevel: 'safe' | 'cautious' | 'dangerous';
|
|
122
|
+
connections: string[];
|
|
123
|
+
npcs: Array<{ name: string; persona?: string }>;
|
|
124
|
+
shops: string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface NpcContext {
|
|
128
|
+
card: NpcCard;
|
|
129
|
+
playerRelation: {
|
|
130
|
+
level: number;
|
|
131
|
+
iq: number;
|
|
132
|
+
flags: Record<string, boolean | number>;
|
|
133
|
+
inventory: string[];
|
|
134
|
+
};
|
|
135
|
+
availableActions: Array<'teach' | 'give' | 'quest' | 'talk'>;
|
|
136
|
+
constraints: string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ResolveOptionResult {
|
|
140
|
+
action: string;
|
|
141
|
+
result:
|
|
142
|
+
| TalkResult
|
|
143
|
+
| {
|
|
144
|
+
success: boolean;
|
|
145
|
+
message: string;
|
|
146
|
+
encounter?: string;
|
|
147
|
+
events?: EventResult[];
|
|
148
|
+
locationDetail?: LocationDetail;
|
|
149
|
+
}
|
|
150
|
+
| { success: boolean; message: string }
|
|
151
|
+
| string
|
|
152
|
+
| { events: EventResult[] }
|
|
153
|
+
| PlayerChoicePrompt;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface PlayerChoiceItem {
|
|
157
|
+
value: string;
|
|
158
|
+
label: string;
|
|
159
|
+
description?: string;
|
|
160
|
+
category?: ActionOption['category'] | 'nav';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface PlayerChoicePrompt {
|
|
164
|
+
type: 'player_choice';
|
|
165
|
+
message: string;
|
|
166
|
+
choices: PlayerChoiceItem[];
|
|
167
|
+
dialogChoices?: DialogChoice[];
|
|
168
|
+
dialogId?: string;
|
|
169
|
+
page?: number;
|
|
170
|
+
hasMore?: boolean;
|
|
171
|
+
totalPages?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface McpElicitationParams {
|
|
175
|
+
mode: 'form';
|
|
176
|
+
message: string;
|
|
177
|
+
requestedSchema: {
|
|
178
|
+
type: 'object';
|
|
179
|
+
properties: {
|
|
180
|
+
action: {
|
|
181
|
+
type: 'string';
|
|
182
|
+
title: string;
|
|
183
|
+
description?: string;
|
|
184
|
+
oneOf: Array<{ const: string; title: string }>;
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
required: ['action'];
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface FeishuInteractiveCard {
|
|
192
|
+
config: { wide_screen_mode: boolean };
|
|
193
|
+
header?: { title: { tag: 'plain_text'; content: string } };
|
|
194
|
+
elements: Array<Record<string, unknown>>;
|
|
195
|
+
}
|
|
@@ -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
|
+
});
|
package/scripts/persistence.ts
CHANGED
|
@@ -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 {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import type { GameState } from './game-types';
|
|
16
|
-
import { DEFAULT_ATTRIBUTES, MAX_STAMINA } from './game-logic';
|
|
16
|
+
import { DEFAULT_ATTRIBUTES, MAX_STAMINA, MAX_LEVEL, MAX_EXP } from './game-logic';
|
|
17
17
|
import { getMap, getTemplates, initConfigs } from './config-loader';
|
|
18
18
|
|
|
19
19
|
export interface LoadGameResult {
|
|
@@ -23,20 +23,48 @@ 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
|
-
|
|
30
|
-
|
|
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`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isFiniteNumber(n: unknown): n is number {
|
|
55
|
+
return typeof n === 'number' && Number.isFinite(n);
|
|
31
56
|
}
|
|
32
57
|
|
|
33
58
|
function isValidGameState(raw: unknown): raw is GameState {
|
|
34
59
|
if (!raw || typeof raw !== 'object') return false;
|
|
35
60
|
const state = raw as GameState;
|
|
36
|
-
|
|
37
|
-
if (!
|
|
61
|
+
const c = state.character;
|
|
62
|
+
if (!c?.name || typeof state.location !== 'string') return false;
|
|
63
|
+
if (!state.inventory || !isFiniteNumber(state.inventory.silver)) return false;
|
|
38
64
|
if (!Array.isArray(state.inventory.items)) return false;
|
|
39
|
-
if (
|
|
65
|
+
if (!isFiniteNumber(state.week)) return false;
|
|
66
|
+
if (!isFiniteNumber(c.hp) || !isFiniteNumber(c.mp)) return false;
|
|
67
|
+
if (!isFiniteNumber(c.stamina)) return false;
|
|
40
68
|
return true;
|
|
41
69
|
}
|
|
42
70
|
|
|
@@ -44,12 +72,30 @@ function isValidGameState(raw: unknown): raw is GameState {
|
|
|
44
72
|
function migrateGameState(state: GameState): GameState {
|
|
45
73
|
initConfigs();
|
|
46
74
|
|
|
47
|
-
if (
|
|
75
|
+
if (typeof state.character.skills === 'string') {
|
|
76
|
+
state.character.skills = [state.character.skills];
|
|
77
|
+
} else if (!Array.isArray(state.character.skills)) {
|
|
48
78
|
state.character.skills = [...(getTemplates().defaultCharacter.skills ?? ['基本拳法'])];
|
|
49
79
|
}
|
|
50
80
|
if (!state.character.skillLevels) {
|
|
51
81
|
state.character.skillLevels = Object.fromEntries(state.character.skills.map((s) => [s, 0]));
|
|
52
82
|
}
|
|
83
|
+
for (const skill of state.character.skills) {
|
|
84
|
+
if (state.character.skillLevels[skill] == null) {
|
|
85
|
+
state.character.skillLevels[skill] = 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!state.character.skillExp) {
|
|
89
|
+
state.character.skillExp = Object.fromEntries(state.character.skills.map((s) => [s, 0]));
|
|
90
|
+
}
|
|
91
|
+
for (const skill of state.character.skills) {
|
|
92
|
+
if (state.character.skillExp![skill] == null) {
|
|
93
|
+
state.character.skillExp![skill] = 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!state.character.buffs) {
|
|
97
|
+
state.character.buffs = {};
|
|
98
|
+
}
|
|
53
99
|
if (!state.character.attributes || typeof state.character.attributes !== 'object') {
|
|
54
100
|
state.character.attributes = { ...DEFAULT_ATTRIBUTES };
|
|
55
101
|
} else {
|
|
@@ -74,11 +120,21 @@ function migrateGameState(state: GameState): GameState {
|
|
|
74
120
|
const c = state.character;
|
|
75
121
|
if (typeof c.maxHp !== 'number') c.maxHp = DEFAULT_ATTRIBUTES.maxHp;
|
|
76
122
|
if (typeof c.maxMp !== 'number') c.maxMp = DEFAULT_ATTRIBUTES.maxMp;
|
|
123
|
+
c.level = Math.max(1, Math.min(MAX_LEVEL, Math.floor(c.level ?? 1)));
|
|
124
|
+
c.exp = Math.max(0, Math.min(MAX_EXP, Math.floor(c.exp ?? 0)));
|
|
77
125
|
c.hp = Math.max(0, Math.min(c.maxHp, c.hp));
|
|
78
126
|
c.mp = Math.max(0, Math.min(c.maxMp, c.mp));
|
|
79
127
|
c.stamina = Math.max(0, Math.min(MAX_STAMINA, c.stamina));
|
|
80
128
|
c.poison = Math.max(0, c.poison);
|
|
81
129
|
c.hurt = Math.max(0, c.hurt);
|
|
130
|
+
c.attributes.level = c.level;
|
|
131
|
+
c.attributes.exp = c.exp;
|
|
132
|
+
|
|
133
|
+
state.inventory.silver = Math.max(0, Math.floor(state.inventory.silver));
|
|
134
|
+
for (const item of state.inventory.items) {
|
|
135
|
+
item.count = Math.max(0, Math.floor(item.count));
|
|
136
|
+
}
|
|
137
|
+
state.inventory.items = state.inventory.items.filter((i) => i.count > 0);
|
|
82
138
|
|
|
83
139
|
if (!getMap(state.location)) {
|
|
84
140
|
state.location = getTemplates().startLocation ?? '小村';
|
|
@@ -87,49 +143,62 @@ function migrateGameState(state: GameState): GameState {
|
|
|
87
143
|
return state;
|
|
88
144
|
}
|
|
89
145
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!existsSync(SAVE_FILE)) return null;
|
|
93
|
-
|
|
146
|
+
function loadGameStateFromPath(path: string): GameState | null {
|
|
147
|
+
if (!existsSync(path)) return null;
|
|
94
148
|
try {
|
|
95
|
-
const raw = readFileSync(
|
|
149
|
+
const raw = readFileSync(path, 'utf-8');
|
|
96
150
|
const parsed: unknown = JSON.parse(raw);
|
|
97
|
-
if (!isValidGameState(parsed))
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
151
|
+
if (!isValidGameState(parsed)) return null;
|
|
100
152
|
return migrateGameState(parsed);
|
|
101
153
|
} catch {
|
|
102
154
|
return null;
|
|
103
155
|
}
|
|
104
156
|
}
|
|
105
157
|
|
|
158
|
+
/** 读取存档;不存在或损坏时返回 null */
|
|
159
|
+
export function loadGameState(userId?: string | null): GameState | null {
|
|
160
|
+
return loadGameStateFromPath(getSavePath(userId));
|
|
161
|
+
}
|
|
162
|
+
|
|
106
163
|
/** 写入存档(原子替换,避免写入中断损坏) */
|
|
107
|
-
export function saveGameState(state: GameState): void {
|
|
108
|
-
|
|
109
|
-
|
|
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 });
|
|
110
169
|
}
|
|
111
|
-
|
|
112
|
-
|
|
170
|
+
const tmp = getSaveTmpPath(path);
|
|
171
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
|
|
172
|
+
renameSync(tmp, path);
|
|
113
173
|
}
|
|
114
174
|
|
|
115
175
|
/** 删除存档 */
|
|
116
|
-
export function deleteSave(): void {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (existsSync(
|
|
121
|
-
unlinkSync(SAVE_TMP);
|
|
122
|
-
}
|
|
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);
|
|
123
181
|
}
|
|
124
182
|
|
|
125
183
|
/** 开始或继续:有存档则加载,否则新建并落盘 */
|
|
126
184
|
export function loadOrCreateGame(
|
|
127
185
|
createNewGame: (name: string) => GameState,
|
|
128
186
|
name = '主角',
|
|
187
|
+
userId?: string | null,
|
|
129
188
|
): LoadGameResult {
|
|
130
|
-
const existing = loadGameState();
|
|
189
|
+
const existing = loadGameState(userId);
|
|
131
190
|
if (existing) return { state: existing, isNewGame: false };
|
|
132
191
|
const state = createNewGame(name);
|
|
133
|
-
saveGameState(state);
|
|
192
|
+
saveGameState(state, userId);
|
|
134
193
|
return { state, isNewGame: true };
|
|
135
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
|
+
}
|