@sdd330dev/jy-skill 0.3.0 → 0.3.1

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 CHANGED
@@ -98,6 +98,8 @@ saveGameState(state) // 可选
98
98
  2. 显示结算(等级、存活周数、银两)
99
99
  3. 调用 `restartGame()`(勿只删档不落盘)
100
100
 
101
+ **昏迷期间**不可休息、移动、购物或战斗;休息不能复活。
102
+
101
103
  玩家主动「重新开始」时同样使用 `restartGame()`。
102
104
 
103
105
  ## 叙述要点
package/README.md CHANGED
@@ -34,7 +34,9 @@
34
34
 
35
35
  ## 安装
36
36
 
37
- ### npm(推荐)
37
+ 包已发布至 npm:**[@sdd330dev/jy-skill](https://www.npmjs.com/package/@sdd330dev/jy-skill)**
38
+
39
+ ### 项目内安装(推荐)
38
40
 
39
41
  在 Cursor 项目根目录执行:
40
42
 
@@ -43,32 +45,74 @@ npm install @sdd330dev/jy-skill --save-dev
43
45
  npx jy-skill install
44
46
  ```
45
47
 
46
- 安装到**全局** Cursor skills(所有项目可用):
48
+ 使用 pnpm 或 yarn:
49
+
50
+ ```bash
51
+ pnpm add -D @sdd330dev/jy-skill && pnpm exec jy-skill install
52
+ # 或
53
+ yarn add -D @sdd330dev/jy-skill && yarn jy-skill install
54
+ ```
55
+
56
+ 安装完成后,目录结构如下:
57
+
58
+ ```
59
+ 你的项目/
60
+ └── .cursor/skills/jy/ ← SKILL.md、assets/、scripts/ 等
61
+ ```
62
+
63
+ 在 Cursor 中打开该项目,对话里说 **`jy`** 或 **「开始游戏」** 即可。
64
+
65
+ ### 全局安装(所有项目可用)
47
66
 
48
67
  ```bash
49
68
  npm install -g @sdd330dev/jy-skill
50
69
  jy-skill install --global
51
70
  ```
52
71
 
53
- CLI 会将 skill 安装到 `.cursor/skills/jy` 或 `~/.cursor/skills/jy`(与 SKILL 名称 `jy` 一致)。已存在时需加 `--force` 覆盖;Windows 下可加 `--copy` 强制复制。
72
+ skill 将安装到 `~/.cursor/skills/jy`,任意 Cursor 项目均可加载。
73
+
74
+ ### CLI 选项
75
+
76
+ | 选项 | 说明 |
77
+ |------|------|
78
+ | `--global` | 安装到 `~/.cursor/skills/jy` 而非当前项目 |
79
+ | `--force` | 覆盖已存在的 skill 目录 |
80
+ | `--copy` | 强制复制文件(默认优先 symlink;Windows 无权限时自动 fallback) |
81
+
82
+ 示例:`npx jy-skill install --force --copy`
83
+
84
+ > **注意**:`npm install` 只把包放入 `node_modules`,不会自动写入 `.cursor/`。必须再执行 **`jy-skill install`**(无 postinstall,避免 monorepo/CI 误写配置)。
85
+
86
+ ### 其他安装方式
87
+
88
+ **GitHub Release(离线 / 无 npm)**
89
+
90
+ 从 [Releases](https://github.com/sdd330/jy-skill/releases) 下载 `jy-skill.zip`,解压后:
91
+
92
+ ```bash
93
+ mkdir -p .cursor/skills
94
+ unzip jy-skill.zip -d .cursor/skills/jy
95
+ ```
54
96
 
55
- ### 从源码开发
97
+ **从源码开发**
56
98
 
57
- 克隆本仓库后在本目录用 pnpm 开发与测试(见下方「开发与测试」)。也可手动复制:
99
+ 克隆本仓库,在本目录用 pnpm 开发与测试(见下方「开发与测试」)。本地调试也可手动链接:
58
100
 
59
101
  ```bash
60
- mkdir -p .cursor/skills && cp -R /path/to/jy .cursor/skills/jy
102
+ mkdir -p .cursor/skills
103
+ ln -s "$(pwd)" .cursor/skills/jy # macOS / Linux
104
+ # Windows 或 symlink 失败时:cp -R . .cursor/skills/jy
61
105
  ```
62
106
 
63
- ### GitHub Release 与 npm
107
+ ### 发布与版本
64
108
 
65
- Git tag(`v*`)触发 [Release 工作流](.github/workflows/release.yml),生成 `jy-skill.zip` 并同步发布到 [npm @sdd330dev/jy-skill](https://www.npmjs.com/package/@sdd330dev/jy-skill)(需配置 `NPM_TOKEN`)。
109
+ Git tag(`v*`)触发 [Release 工作流](.github/workflows/release.yml),生成 `jy-skill.zip` 并同步发布到 [npm @sdd330dev/jy-skill](https://www.npmjs.com/package/@sdd330dev/jy-skill)(GitHub Secrets 需配置 `NPM_TOKEN`)。
66
110
 
67
- ### 维护者:本机发布到 npm
111
+ **维护者:本机发布到 npm**
68
112
 
69
113
  ```bash
70
114
  pnpm run ci
71
- npm login
115
+ # 使用 Granular Access Token(Publish + Bypass 2FA),或 npm login 后带 OTP:
72
116
  npm publish --access public # 或 pnpm run publish:npm
73
117
  ```
74
118
 
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: >-
5
5
  Agent 须读 references/agent-handbook.md 掌握完整玩法与 API;玩家说 jy/开始游戏/帮助/金庸群侠传时使用。
6
6
  license: MIT
7
7
  metadata:
8
- version: "0.3.0"
8
+ version: "0.3.1"
9
9
  disable-model-invocation: false
10
10
  ---
11
11
 
@@ -52,8 +52,8 @@
52
52
  "山贼": { "characterId": 200 },
53
53
  "强盗": { "characterId": 201 },
54
54
  "武林高手": { "characterId": 202 },
55
- "老虎": { "hp": 150, "attack": 35, "defence": 20, "solo": true },
56
- "毒蛇": { "hp": 60, "attack": 10, "defence": 5, "solo": true }
55
+ "老虎": { "hp": 150, "attack": 35, "defence": 20, "solo": true, "onHitHurt": 10 },
56
+ "毒蛇": { "hp": 60, "attack": 10, "defence": 5, "solo": true, "onHitPoison": 15 }
57
57
  },
58
58
  "defaultInventory": {
59
59
  "silver": 100,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdd330dev/jy-skill",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "金庸群侠传 Cursor Agent Skill — 对话式武侠 RPG,自然语言驱动移动、战斗与存档",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -113,7 +113,8 @@ getStatus(state) 生成状态栏,附在回复末尾
113
113
  - 新角色默认:**基本拳法**(不耗内力)。
114
114
  - **`learnSkill`**:武功须存在于 `assets/skills.json`;已学会则失败。
115
115
  - **`useSkillInBattle`**:须已学会且内力 ≥ 消耗;消耗公式见 `game-logic.ts`。
116
- - **已知限制**:战斗中 `skillLevels` 暂不增长(等级恒为 0 索引)。
116
+ - 武功 `skillLevels` 0–9 对应 Lv.1–10;每次战斗使用 +1~3 熟练度,满 100 升一级
117
+ - 死亡后(hp≤0)不可休息、移动、战斗;须 `restartGame()`
117
118
 
118
119
  ---
119
120
 
@@ -118,10 +118,11 @@
118
118
 
119
119
  ## 生存技巧
120
120
 
121
- - **移动**每次消耗 5 点体力,并推进 1 周;规划路线,避免体力耗尽困在途中。
122
- - **干粮**恢复体力,**金创药**恢复生命,**小还丹**恢复内力;**解毒丸**可解除中毒。生命/内力/体力已满时不会浪费药品。
123
- - **休息**一次满血满内力满体力,并清除中毒、受伤(在任意地点可用)。
124
- - 中毒、受伤状态下每移动一周会持续掉血,宜尽快治疗或休息。
121
+ - **移动**消耗体力(轻功越高消耗越低,约 3–7 点),并推进 1 周;体力不足时无法移动。
122
+ - **干粮**恢复体力,**金创药**恢复生命,**小还丹**恢复内力;**解毒丸**可解除中毒。**大力丸**/**疾风丸**临时提升攻击/轻功(移动或休息后失效)。
123
+ - **武功秘籍**(如九阴真经、独孤九剑剑谱)研读后可学会对应武功,需满足资质与经验要求。
124
+ - **休息**一次满血满内力满体力,并清除中毒、受伤与临时增益(在任意地点可用);**昏迷时无法休息**。
125
+ - 中毒、受伤状态下每移动一周会持续掉血;**毒蛇**等敌人攻击可能使你中毒。
125
126
  - 死亡后当前存档清除,需从头开始;重要进度请多探索、少鲁莽涉险。
126
127
 
127
128
  ---
package/save/.gitkeep ADDED
File without changes
@@ -35,6 +35,13 @@ export interface ItemConfig {
35
35
  useAddMp: number;
36
36
  useAddStamina: number;
37
37
  useDePoison: number;
38
+ useAddAttack?: number;
39
+ useAddAgility?: number;
40
+ useAddDefence?: number;
41
+ useAddPoison?: number;
42
+ skillId?: number;
43
+ needIQ?: number;
44
+ needExp?: number;
38
45
  }
39
46
 
40
47
  export interface SkillConfig {
@@ -43,6 +50,7 @@ export interface SkillConfig {
43
50
  desc: string;
44
51
  mpCost: number;
45
52
  damageType: number;
53
+ poison?: number;
46
54
  levels: number[][];
47
55
  }
48
56
 
@@ -67,6 +75,8 @@ export interface EnemyTemplate {
67
75
  attack: number;
68
76
  defence: number;
69
77
  solo?: boolean;
78
+ onHitPoison?: number;
79
+ onHitHurt?: number;
70
80
  }
71
81
 
72
82
  export interface GameTemplates {
@@ -114,6 +124,7 @@ const mapsByName = new Map<string, MapInfo>();
114
124
  const itemsByName = new Map<string, ItemConfig>();
115
125
  const itemsById = new Map<number, ItemConfig>();
116
126
  const skillsByName = new Map<string, SkillConfig>();
127
+ const skillsById = new Map<number, SkillConfig>();
117
128
  const charactersById = new Map<number, CharacterConfig>();
118
129
  const charactersByName = new Map<string, CharacterConfig>();
119
130
  const dialogsById = new Map<string, DialogConfig>();
@@ -185,7 +196,7 @@ function buildMapsFromConfig(): void {
185
196
  .filter((name): name is string => Boolean(name));
186
197
 
187
198
  const shops =
188
- templateMap && templateMap.shops.length > 0 ? [...templateMap.shops] : [...shopNames];
199
+ templateMap && templateMap.shops?.length > 0 ? [...templateMap.shops] : [...shopNames];
189
200
 
190
201
  const encounterConfig = parseEncounters(templateMap?.encounters);
191
202
 
@@ -241,6 +252,7 @@ function loadSkills(): void {
241
252
  const data = loadJson<{ skills: SkillConfig[] }>('skills.json');
242
253
  for (const skill of data.skills) {
243
254
  skillsByName.set(skill.name, skill);
255
+ skillsById.set(skill.id, skill);
244
256
  }
245
257
  }
246
258
 
@@ -277,6 +289,7 @@ export function resetConfigsForTest(): void {
277
289
  itemsByName.clear();
278
290
  itemsById.clear();
279
291
  skillsByName.clear();
292
+ skillsById.clear();
280
293
  charactersById.clear();
281
294
  charactersByName.clear();
282
295
  dialogsById.clear();
@@ -311,6 +324,27 @@ export function getSkill(name: string): SkillConfig | undefined {
311
324
  return skillsByName.get(name);
312
325
  }
313
326
 
327
+ export function getSkillById(id: number): SkillConfig | undefined {
328
+ initConfigs();
329
+ return skillsById.get(id);
330
+ }
331
+
332
+ /** damageType 数字 → calculateStaminaCost 字符串键 */
333
+ export function mapDamageTypeToStaminaCost(damageType: number): string {
334
+ switch (damageType) {
335
+ case 1:
336
+ return 'absorbMp';
337
+ case 2:
338
+ return 'poison';
339
+ case 3:
340
+ return 'depoison';
341
+ case 4:
342
+ return 'heal';
343
+ default:
344
+ return 'normal';
345
+ }
346
+ }
347
+
314
348
  export function getSkillAttackAtLevel(skillName: string, levelIndex = 0): number {
315
349
  const skill = getSkill(skillName);
316
350
  if (!skill || !skill.levels.length) return 0;
@@ -344,7 +378,21 @@ export function isArmor(item: ItemConfig): boolean {
344
378
  }
345
379
 
346
380
  export function isConsumable(item: ItemConfig): boolean {
347
- return item.useAddHp > 0 || item.useAddMp > 0 || item.useAddStamina > 0 || item.useDePoison > 0;
381
+ return (
382
+ item.useAddHp > 0 ||
383
+ item.useAddMp > 0 ||
384
+ item.useAddStamina > 0 ||
385
+ item.useDePoison > 0 ||
386
+ (item.useAddAttack ?? 0) > 0 ||
387
+ (item.useAddAgility ?? 0) > 0 ||
388
+ (item.useAddDefence ?? 0) > 0 ||
389
+ (item.useAddPoison ?? 0) > 0 ||
390
+ isSkillBook(item)
391
+ );
392
+ }
393
+
394
+ export function isSkillBook(item: ItemConfig): boolean {
395
+ return item.type === 2 && (item.skillId ?? 0) > 0;
348
396
  }
349
397
 
350
398
  /** 校验 assets 完整性(CLI / CI 用) */
@@ -8,11 +8,15 @@ import {
8
8
  calculateDamage as calcDamage,
9
9
  getExpForLevel,
10
10
  calculateMpCost,
11
+ calculateStaminaCost,
11
12
  calculatePoisonDamage,
12
13
  calculateHurtDamage,
14
+ calculateMoveStaminaCost,
13
15
  DEFAULT_ATTRIBUTES,
14
16
  MAX_STAMINA,
15
17
  MAX_LEVEL,
18
+ MAX_SKILL_LEVEL,
19
+ MAX_INVENTORY_SIZE,
16
20
  } from './game-logic';
17
21
  import {
18
22
  initConfigs,
@@ -20,12 +24,15 @@ import {
20
24
  getMap,
21
25
  getItem,
22
26
  getSkill,
27
+ getSkillById,
23
28
  getSkillAttackAtLevel,
24
29
  getDialog,
25
30
  getEnemyTemplate,
26
31
  isWeapon,
27
32
  isArmor,
28
33
  isConsumable,
34
+ isSkillBook,
35
+ mapDamageTypeToStaminaCost,
29
36
  } from './config-loader';
30
37
  import type { GameState, BattleEnemy, Character } from './game-types';
31
38
  import {
@@ -46,6 +53,42 @@ function autoSave(state: GameState): void {
46
53
  saveGameState(state);
47
54
  }
48
55
 
56
+ function assertAlive(state: GameState): { ok: true } | { ok: false; message: string } {
57
+ if (state.character.hp <= 0) {
58
+ return { ok: false, message: '你已昏迷,无法行动' };
59
+ }
60
+ return { ok: true };
61
+ }
62
+
63
+ function clearBuffs(state: GameState): void {
64
+ state.character.buffs = {};
65
+ }
66
+
67
+ function ensureSkillExp(state: GameState): void {
68
+ if (!state.character.skillExp) {
69
+ state.character.skillExp = {};
70
+ }
71
+ }
72
+
73
+ /** @internal 供单元测试覆盖防御性分支 */
74
+ export function consumeItemStack(state: GameState, itemName: string): void {
75
+ const inv = state.inventory.items.find((i) => i.name === itemName);
76
+ if (!inv) return;
77
+ inv.count--;
78
+ if (inv.count <= 0) {
79
+ state.inventory.items = state.inventory.items.filter((i) => i.name !== itemName);
80
+ }
81
+ }
82
+
83
+ function resolveEnemyTemplateName(enemyDisplayName: string): string {
84
+ const templates = getTemplates().enemies;
85
+ if (templates[enemyDisplayName]) return enemyDisplayName;
86
+ for (const key of Object.keys(templates)) {
87
+ if (enemyDisplayName.startsWith(key)) return key;
88
+ }
89
+ return enemyDisplayName;
90
+ }
91
+
49
92
  // ============================================================================
50
93
  // 初始化
51
94
  // ============================================================================
@@ -53,6 +96,7 @@ function autoSave(state: GameState): void {
53
96
  function buildCharacter(name: string): Character {
54
97
  const tpl = getTemplates();
55
98
  const attrs = { ...DEFAULT_ATTRIBUTES };
99
+ const defaultSkills = tpl.defaultCharacter.skills ?? ['基本拳法'];
56
100
  return {
57
101
  name,
58
102
  level: 1,
@@ -66,10 +110,10 @@ function buildCharacter(name: string): Character {
66
110
  hurt: 0,
67
111
  attributes: attrs,
68
112
  equipment: { weapon: null, armor: null },
69
- skills: [...(tpl.defaultCharacter.skills ?? ['基本拳法'])],
70
- skillLevels: Object.fromEntries(
71
- (tpl.defaultCharacter.skills ?? ['基本拳法']).map((s) => [s, 0]),
72
- ),
113
+ skills: [...defaultSkills],
114
+ skillLevels: Object.fromEntries(defaultSkills.map((s) => [s, 0])),
115
+ skillExp: Object.fromEntries(defaultSkills.map((s) => [s, 0])),
116
+ buffs: {},
73
117
  };
74
118
  }
75
119
 
@@ -123,6 +167,8 @@ export function getStatus(state: GameState): string {
123
167
  `❤️ ${c.hp}/${c.maxHp} | 💠 ${c.mp}/${c.maxMp} | ⚡ ${c.stamina}/${MAX_STAMINA}`,
124
168
  c.poison > 0 ? `🧪 中毒: ${c.poison}` : null,
125
169
  c.hurt > 0 ? `💊 受伤: ${c.hurt}` : null,
170
+ (c.buffs?.attack ?? 0) > 0 ? `⚔️ 攻加成: +${c.buffs!.attack}` : null,
171
+ (c.buffs?.agility ?? 0) > 0 ? `💨 轻功加成: +${c.buffs!.agility}` : null,
126
172
  `💰 ${state.inventory.silver} | 📍 ${state.location} | 📅 第${state.week}周`,
127
173
  ]
128
174
  .filter(Boolean)
@@ -160,7 +206,14 @@ export function getInventory(state: GameState): string {
160
206
  export function getSkills(state: GameState): string {
161
207
  const skills = state.character.skills;
162
208
  if (skills.length === 0) return '🥋 还没有学会任何武功';
163
- return `🥋 武功:\n${skills.map((s) => `- ${s}`).join('\n')}`;
209
+ ensureSkillExp(state);
210
+ return `🥋 武功:\n${skills
211
+ .map((s) => {
212
+ const lv = (state.character.skillLevels[s] ?? 0) + 1;
213
+ const exp = state.character.skillExp![s] ?? 0;
214
+ return `- ${s} Lv.${lv}(熟练 ${exp}/100)`;
215
+ })
216
+ .join('\n')}`;
164
217
  }
165
218
 
166
219
  // ============================================================================
@@ -171,6 +224,9 @@ export function moveTo(
171
224
  state: GameState,
172
225
  destination: string,
173
226
  ): { success: boolean; message: string; encounter?: string } {
227
+ const alive = assertAlive(state);
228
+ if (!alive.ok) return { success: false, message: alive.message };
229
+
174
230
  const map = getMap(state.location);
175
231
  if (!map) return { success: false, message: '当前位置未知' };
176
232
 
@@ -185,9 +241,15 @@ export function moveTo(
185
241
  return { success: false, message: `从${state.location}无法直达${destination}` };
186
242
  }
187
243
 
244
+ const moveCost = calculateMoveStaminaCost(getEffectiveAgility(state));
245
+ if (state.character.stamina < moveCost) {
246
+ return { success: false, message: `体力不足,需要${moveCost}点体力才能前往${destination}` };
247
+ }
248
+
188
249
  state.location = destination;
189
- state.character.stamina = Math.max(0, state.character.stamina - 5);
250
+ state.character.stamina = Math.max(0, state.character.stamina - moveCost);
190
251
  state.week++;
252
+ clearBuffs(state);
191
253
  advanceWeekEffects(state);
192
254
 
193
255
  if (!state.visitedMaps.includes(destination)) {
@@ -249,6 +311,9 @@ export function talkTo(state: GameState, npcName: string): { success: boolean; m
249
311
  // ============================================================================
250
312
 
251
313
  export function buyItem(state: GameState, itemName: string): { success: boolean; message: string } {
314
+ const alive = assertAlive(state);
315
+ if (!alive.ok) return { success: false, message: alive.message };
316
+
252
317
  const item = getItem(itemName);
253
318
  if (!item) return { success: false, message: `没有${itemName}出售` };
254
319
 
@@ -264,8 +329,13 @@ export function buyItem(state: GameState, itemName: string): { success: boolean;
264
329
  };
265
330
  }
266
331
 
267
- state.inventory.silver -= item.price;
332
+ const totalItems = state.inventory.items.reduce((sum, i) => sum + i.count, 0);
268
333
  const existing = state.inventory.items.find((i) => i.name === itemName);
334
+ if (!existing && totalItems >= MAX_INVENTORY_SIZE) {
335
+ return { success: false, message: `背包已满(最多${MAX_INVENTORY_SIZE}种物品)` };
336
+ }
337
+
338
+ state.inventory.silver -= item.price;
269
339
  if (existing) {
270
340
  existing.count++;
271
341
  } else {
@@ -285,6 +355,9 @@ export function buyItem(state: GameState, itemName: string): { success: boolean;
285
355
  // ============================================================================
286
356
 
287
357
  export function useItem(state: GameState, itemName: string): { success: boolean; message: string } {
358
+ const alive = assertAlive(state);
359
+ if (!alive.ok) return { success: false, message: alive.message };
360
+
288
361
  const inv = state.inventory.items.find((i) => i.name === itemName);
289
362
  if (!inv || inv.count <= 0) {
290
363
  return { success: false, message: `没有${itemName}` };
@@ -295,12 +368,40 @@ export function useItem(state: GameState, itemName: string): { success: boolean;
295
368
  return { success: false, message: `${itemName}无法使用` };
296
369
  }
297
370
 
371
+ // 武功秘籍
372
+ if (isSkillBook(item)) {
373
+ const skill = getSkillById(item.skillId!);
374
+ if (!skill) return { success: false, message: `${itemName}内容残缺,无法修习` };
375
+
376
+ const c = state.character;
377
+ if ((item.needIQ ?? 0) > c.attributes.iq) {
378
+ return { success: false, message: `资质不足,需要${item.needIQ}点资质才能研读${itemName}` };
379
+ }
380
+ if ((item.needExp ?? 0) > c.exp) {
381
+ return { success: false, message: `经验不足,需要${item.needExp}点经验才能研读${itemName}` };
382
+ }
383
+ if (c.skills.includes(skill.name)) {
384
+ return { success: false, message: `已经学会了${skill.name}` };
385
+ }
386
+
387
+ c.skills.push(skill.name);
388
+ c.skillLevels[skill.name] = 0;
389
+ ensureSkillExp(state);
390
+ state.character.skillExp![skill.name] = 0;
391
+ consumeItemStack(state, itemName);
392
+ autoSave(state);
393
+ return { success: true, message: `研读${itemName},学会了${skill.name}` };
394
+ }
395
+
298
396
  const c = state.character;
397
+ if (!c.buffs) c.buffs = {};
299
398
  const parts: string[] = [];
300
399
  let hpGain = 0;
301
400
  let mpGain = 0;
302
401
  let staminaGain = 0;
303
402
  let poisonReduced = 0;
403
+ let buffAttack = 0;
404
+ let buffAgility = 0;
304
405
 
305
406
  if (item.useAddHp > 0) {
306
407
  hpGain = Math.min(item.useAddHp, c.maxHp - c.hp);
@@ -318,8 +419,23 @@ export function useItem(state: GameState, itemName: string): { success: boolean;
318
419
  poisonReduced = Math.min(item.useDePoison, c.poison);
319
420
  parts.push('解除中毒');
320
421
  }
422
+ if ((item.useAddAttack ?? 0) > 0) {
423
+ if ((c.buffs.attack ?? 0) > 0) {
424
+ return { success: false, message: `${itemName}效果仍在,无需重复使用` };
425
+ }
426
+ buffAttack = item.useAddAttack!;
427
+ parts.push(`攻击力临时+${buffAttack}`);
428
+ }
429
+ if ((item.useAddAgility ?? 0) > 0) {
430
+ if ((c.buffs.agility ?? 0) > 0) {
431
+ return { success: false, message: `${itemName}效果仍在,无需重复使用` };
432
+ }
433
+ buffAgility = item.useAddAgility!;
434
+ parts.push(`轻功临时+${buffAgility}`);
435
+ }
321
436
 
322
- if (hpGain + mpGain + staminaGain + poisonReduced === 0) {
437
+ const totalGain = hpGain + mpGain + staminaGain + poisonReduced + buffAttack + buffAgility;
438
+ if (totalGain === 0) {
323
439
  if (item.useDePoison > 0 && c.poison <= 0) {
324
440
  return { success: false, message: '你没有中毒,无需使用解毒丸' };
325
441
  }
@@ -330,12 +446,10 @@ export function useItem(state: GameState, itemName: string): { success: boolean;
330
446
  c.mp += mpGain;
331
447
  c.stamina += staminaGain;
332
448
  c.poison -= poisonReduced;
449
+ if (buffAttack > 0) c.buffs.attack = buffAttack;
450
+ if (buffAgility > 0) c.buffs.agility = buffAgility;
333
451
 
334
- inv.count--;
335
- if (inv.count <= 0) {
336
- state.inventory.items = state.inventory.items.filter((i) => i.name !== itemName);
337
- }
338
-
452
+ consumeItemStack(state, itemName);
339
453
  autoSave(state);
340
454
  return { success: true, message: `使用${itemName},${parts.join(',')}` };
341
455
  }
@@ -348,6 +462,9 @@ export function equipItem(
348
462
  state: GameState,
349
463
  itemName: string,
350
464
  ): { success: boolean; message: string } {
465
+ const alive = assertAlive(state);
466
+ if (!alive.ok) return { success: false, message: alive.message };
467
+
351
468
  const inv = state.inventory.items.find((i) => i.name === itemName);
352
469
  if (!inv || inv.count <= 0) {
353
470
  return { success: false, message: `没有${itemName}` };
@@ -379,6 +496,9 @@ export function learnSkill(
379
496
  state: GameState,
380
497
  skillName: string,
381
498
  ): { success: boolean; message: string } {
499
+ const alive = assertAlive(state);
500
+ if (!alive.ok) return { success: false, message: alive.message };
501
+
382
502
  if (!getSkill(skillName)) {
383
503
  return { success: false, message: `江湖上没有${skillName}这门武功` };
384
504
  }
@@ -388,6 +508,8 @@ export function learnSkill(
388
508
 
389
509
  state.character.skills.push(skillName);
390
510
  state.character.skillLevels[skillName] = 0;
511
+ ensureSkillExp(state);
512
+ state.character.skillExp![skillName] = 0;
391
513
  autoSave(state);
392
514
  return { success: true, message: `学会了${skillName}` };
393
515
  }
@@ -397,11 +519,15 @@ export function learnSkill(
397
519
  // ============================================================================
398
520
 
399
521
  export function rest(state: GameState): { success: boolean; message: string } {
522
+ const alive = assertAlive(state);
523
+ if (!alive.ok) return { success: false, message: alive.message };
524
+
400
525
  state.character.hp = state.character.maxHp;
401
526
  state.character.mp = state.character.maxMp;
402
527
  state.character.stamina = MAX_STAMINA;
403
528
  state.character.poison = 0;
404
529
  state.character.hurt = 0;
530
+ clearBuffs(state);
405
531
 
406
532
  autoSave(state);
407
533
  return { success: true, message: '休息完毕,状态全满' };
@@ -415,6 +541,9 @@ export function startBattle(
415
541
  state: GameState,
416
542
  enemyName: string,
417
543
  ): { success: boolean; message: string; enemies?: BattleEnemy[] } {
544
+ const alive = assertAlive(state);
545
+ if (!alive.ok) return { success: false, message: alive.message };
546
+
418
547
  const entry = getTemplates().enemies[enemyName];
419
548
  const template = getEnemyTemplate(enemyName);
420
549
  if (!template) {
@@ -442,11 +571,19 @@ export function attackEnemy(
442
571
  enemies: BattleEnemy[],
443
572
  targetIndex: number,
444
573
  ): { message: string; enemyDefeated: boolean; playerDamage: number } {
574
+ const alive = assertAlive(state);
575
+ if (!alive.ok) return { message: alive.message, enemyDefeated: false, playerDamage: 0 };
576
+
445
577
  const target = enemies[targetIndex];
446
578
  if (!target || target.hp <= 0) {
447
579
  return { message: '目标无效', enemyDefeated: false, playerDamage: 0 };
448
580
  }
449
581
 
582
+ const staminaCost = calculateStaminaCost('normal');
583
+ if (state.character.stamina < staminaCost) {
584
+ return { message: '体力不足,无法攻击', enemyDefeated: false, playerDamage: 0 };
585
+ }
586
+
450
587
  const damage = calcDamage(
451
588
  getEffectiveAttack(state),
452
589
  0,
@@ -456,7 +593,7 @@ export function attackEnemy(
456
593
  );
457
594
 
458
595
  target.hp = Math.max(0, target.hp - damage);
459
- state.character.stamina = Math.max(0, state.character.stamina - 3);
596
+ state.character.stamina = Math.max(0, state.character.stamina - staminaCost);
460
597
 
461
598
  const defeated = target.hp <= 0;
462
599
  if (defeated) {
@@ -477,6 +614,9 @@ export function useSkillInBattle(
477
614
  skillName: string,
478
615
  targetIndex: number,
479
616
  ): { success: boolean; message: string } {
617
+ const alive = assertAlive(state);
618
+ if (!alive.ok) return { success: false, message: alive.message };
619
+
480
620
  if (!state.character.skills.includes(skillName)) {
481
621
  return { success: false, message: `没有学会${skillName}` };
482
622
  }
@@ -484,17 +624,52 @@ export function useSkillInBattle(
484
624
  const skill = getSkill(skillName);
485
625
  if (!skill) return { success: false, message: `未知武功${skillName}` };
486
626
 
487
- const target = enemies[targetIndex];
488
- if (!target || target.hp <= 0) {
489
- return { success: false, message: '目标无效' };
490
- }
491
-
492
627
  const levelIndex = state.character.skillLevels[skillName] ?? 0;
493
628
  const mpCost = calculateMpCost(skill.mpCost, levelIndex);
494
629
  if (state.character.mp < mpCost) {
495
630
  return { success: false, message: `内力不足,需要${mpCost}点内力` };
496
631
  }
497
632
 
633
+ const staminaCost = calculateStaminaCost(mapDamageTypeToStaminaCost(skill.damageType));
634
+ if (state.character.stamina < staminaCost) {
635
+ return { success: false, message: `体力不足,无法施展${skillName}` };
636
+ }
637
+
638
+ state.character.mp = Math.max(0, state.character.mp - mpCost);
639
+ state.character.stamina = Math.max(0, state.character.stamina - staminaCost);
640
+
641
+ // 自身目标类武功
642
+ if (skill.damageType === 3) {
643
+ const reduced = Math.min(state.character.poison, 50);
644
+ state.character.poison = Math.max(0, state.character.poison - reduced);
645
+ grantSkillExp(state, skillName);
646
+ autoSave(state);
647
+ return {
648
+ success: true,
649
+ message: reduced > 0 ? `使用${skillName},解除了部分毒素` : `使用${skillName},你并未中毒`,
650
+ };
651
+ }
652
+
653
+ if (skill.damageType === 4) {
654
+ const skillAttack = getSkillAttackAtLevel(skillName, levelIndex);
655
+ const healAmount = Math.min(skillAttack, state.character.maxHp - state.character.hp);
656
+ state.character.hp += healAmount;
657
+ grantSkillExp(state, skillName);
658
+ autoSave(state);
659
+ return {
660
+ success: true,
661
+ message:
662
+ healAmount > 0
663
+ ? `使用${skillName},恢复${healAmount}点生命`
664
+ : `使用${skillName},气血已足,无需治疗`,
665
+ };
666
+ }
667
+
668
+ const target = enemies[targetIndex];
669
+ if (!target || target.hp <= 0) {
670
+ return { success: false, message: '目标无效' };
671
+ }
672
+
498
673
  const skillAttack = getSkillAttackAtLevel(skillName, levelIndex);
499
674
  const damage = calcDamage(
500
675
  getEffectiveAttack(state),
@@ -505,18 +680,27 @@ export function useSkillInBattle(
505
680
  );
506
681
 
507
682
  target.hp = Math.max(0, target.hp - damage);
508
- state.character.mp = Math.max(0, state.character.mp - mpCost);
509
- state.character.stamina = Math.max(0, state.character.stamina - 3);
683
+
684
+ let extra = '';
685
+ if (skill.damageType === 1) {
686
+ const absorbed = Math.min(Math.floor(damage / 2), state.character.maxMp - state.character.mp);
687
+ state.character.mp += absorbed;
688
+ if (absorbed > 0) extra = `,吸取${absorbed}点内力`;
689
+ }
690
+ if (skill.damageType === 2) {
691
+ extra = ',敌人身中剧毒';
692
+ }
510
693
 
511
694
  const defeated = target.hp <= 0;
512
695
  if (defeated) {
513
696
  grantBattleExp(state, target.maxHp);
514
697
  }
698
+ grantSkillExp(state, skillName);
515
699
 
516
700
  autoSave(state);
517
701
  return {
518
702
  success: true,
519
- message: `使用${skillName}攻击${target.name},造成${damage}点伤害${defeated ? ',击败!' : ''}`,
703
+ message: `使用${skillName}攻击${target.name},造成${damage}点伤害${extra}${defeated ? ',击败!' : ''}`,
520
704
  };
521
705
  }
522
706
 
@@ -524,6 +708,10 @@ export function enemyAttack(
524
708
  state: GameState,
525
709
  enemies: BattleEnemy[],
526
710
  ): { message: string; playerDefeated: boolean } {
711
+ if (state.character.hp <= 0) {
712
+ return { message: '', playerDefeated: true };
713
+ }
714
+
527
715
  const aliveEnemies = enemies.filter((e) => e.hp > 0);
528
716
  if (aliveEnemies.length === 0) return { message: '', playerDefeated: false };
529
717
 
@@ -532,19 +720,33 @@ export function enemyAttack(
532
720
 
533
721
  state.character.hp = Math.max(0, state.character.hp - damage);
534
722
 
723
+ const templateName = resolveEnemyTemplateName(enemy.name);
724
+ const template = getEnemyTemplate(templateName);
725
+ if (template?.onHitPoison) {
726
+ state.character.poison += template.onHitPoison;
727
+ }
728
+ if (template?.onHitHurt) {
729
+ state.character.hurt += template.onHitHurt;
730
+ }
731
+
732
+ let debuffMsg = '';
733
+ if (template?.onHitPoison) debuffMsg += ',你感到一阵麻痹';
734
+ if (template?.onHitHurt) debuffMsg += ',你受了内伤';
735
+
535
736
  autoSave(state);
536
737
  return {
537
- message: `${enemy.name}攻击你,造成${damage}点伤害`,
738
+ message: `${enemy.name}攻击你,造成${damage}点伤害${debuffMsg}`,
538
739
  playerDefeated: state.character.hp <= 0,
539
740
  };
540
741
  }
541
742
 
542
743
  // ============================================================================
543
- // 状态辅助(原 game-state.ts 合并)
744
+ // 状态辅助
544
745
  // ============================================================================
545
746
 
546
747
  export function advanceWeek(state: GameState): void {
547
748
  state.week++;
749
+ clearBuffs(state);
548
750
  advanceWeekEffects(state);
549
751
  autoSave(state);
550
752
  }
@@ -568,8 +770,12 @@ export function isDead(state: GameState): boolean {
568
770
  // 内部
569
771
  // ============================================================================
570
772
 
773
+ function getEffectiveAgility(state: GameState): number {
774
+ return state.character.attributes.agility + (state.character.buffs?.agility ?? 0);
775
+ }
776
+
571
777
  function getEffectiveAttack(state: GameState): number {
572
- let attack = state.character.attributes.attack;
778
+ let attack = state.character.attributes.attack + (state.character.buffs?.attack ?? 0);
573
779
  const weapon = state.character.equipment.weapon;
574
780
  if (weapon) {
575
781
  const item = getItem(weapon);
@@ -589,10 +795,28 @@ function getEffectiveDefence(state: GameState): number {
589
795
  }
590
796
 
591
797
  function grantBattleExp(state: GameState, enemyMaxHp: number): void {
592
- state.character.exp += 10 + enemyMaxHp / 10;
798
+ state.character.exp += Math.floor(10 + enemyMaxHp / 10);
593
799
  checkLevelUp(state);
594
800
  }
595
801
 
802
+ function grantSkillExp(state: GameState, skillName: string): void {
803
+ ensureSkillExp(state);
804
+ const levels = state.character.skillLevels;
805
+ const levelIndex = levels[skillName] ?? 0;
806
+ if (levelIndex >= MAX_SKILL_LEVEL - 1) return;
807
+
808
+ const gain = Math.floor(Math.random() * 3) + 1;
809
+ state.character.skillExp![skillName] = (state.character.skillExp![skillName] ?? 0) + gain;
810
+
811
+ while (
812
+ state.character.skillExp![skillName] >= 100 &&
813
+ (levels[skillName] ?? 0) < MAX_SKILL_LEVEL - 1
814
+ ) {
815
+ state.character.skillExp![skillName] -= 100;
816
+ levels[skillName] = (levels[skillName] ?? 0) + 1;
817
+ }
818
+ }
819
+
596
820
  function checkLevelUp(state: GameState): void {
597
821
  const c = state.character;
598
822
  let needed = getExpForLevel(c.level + 1);
@@ -605,11 +829,13 @@ function checkLevelUp(state: GameState): void {
605
829
  const attrGain = Math.floor(Math.random() * (Math.floor((iq - 10) / 20) + 2)) + 1;
606
830
 
607
831
  c.maxHp += (c.attributes.hpInc + Math.floor(Math.random() * 4)) * 3;
608
- c.maxMp += (9 - attrGain) * 4;
832
+ c.maxMp += Math.max(0, (9 - attrGain) * 4);
609
833
  c.attributes.attack += attrGain;
610
834
  c.attributes.agility += attrGain;
611
835
  c.attributes.defence += attrGain;
612
836
 
837
+ c.attributes.level = c.level;
838
+ c.attributes.exp = c.exp;
613
839
  c.hp = c.maxHp;
614
840
  c.mp = c.maxMp;
615
841
  needed = getExpForLevel(c.level + 1);
@@ -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
  // ============================================================================
@@ -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 {
@@ -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 {
@@ -30,13 +30,20 @@ export function getSavePath(): string {
30
30
  return SAVE_FILE;
31
31
  }
32
32
 
33
+ function isFiniteNumber(n: unknown): n is number {
34
+ return typeof n === 'number' && Number.isFinite(n);
35
+ }
36
+
33
37
  function isValidGameState(raw: unknown): raw is GameState {
34
38
  if (!raw || typeof raw !== 'object') return false;
35
39
  const state = raw as GameState;
36
- if (!state.character?.name || typeof state.location !== 'string') return false;
37
- if (!state.inventory || typeof state.inventory.silver !== 'number') return false;
40
+ const c = state.character;
41
+ if (!c?.name || typeof state.location !== 'string') return false;
42
+ if (!state.inventory || !isFiniteNumber(state.inventory.silver)) return false;
38
43
  if (!Array.isArray(state.inventory.items)) return false;
39
- if (typeof state.week !== 'number') return false;
44
+ if (!isFiniteNumber(state.week)) return false;
45
+ if (!isFiniteNumber(c.hp) || !isFiniteNumber(c.mp)) return false;
46
+ if (!isFiniteNumber(c.stamina)) return false;
40
47
  return true;
41
48
  }
42
49
 
@@ -44,12 +51,30 @@ function isValidGameState(raw: unknown): raw is GameState {
44
51
  function migrateGameState(state: GameState): GameState {
45
52
  initConfigs();
46
53
 
47
- if (!Array.isArray(state.character.skills)) {
54
+ if (typeof state.character.skills === 'string') {
55
+ state.character.skills = [state.character.skills];
56
+ } else if (!Array.isArray(state.character.skills)) {
48
57
  state.character.skills = [...(getTemplates().defaultCharacter.skills ?? ['基本拳法'])];
49
58
  }
50
59
  if (!state.character.skillLevels) {
51
60
  state.character.skillLevels = Object.fromEntries(state.character.skills.map((s) => [s, 0]));
52
61
  }
62
+ for (const skill of state.character.skills) {
63
+ if (state.character.skillLevels[skill] == null) {
64
+ state.character.skillLevels[skill] = 0;
65
+ }
66
+ }
67
+ if (!state.character.skillExp) {
68
+ state.character.skillExp = Object.fromEntries(state.character.skills.map((s) => [s, 0]));
69
+ }
70
+ for (const skill of state.character.skills) {
71
+ if (state.character.skillExp![skill] == null) {
72
+ state.character.skillExp![skill] = 0;
73
+ }
74
+ }
75
+ if (!state.character.buffs) {
76
+ state.character.buffs = {};
77
+ }
53
78
  if (!state.character.attributes || typeof state.character.attributes !== 'object') {
54
79
  state.character.attributes = { ...DEFAULT_ATTRIBUTES };
55
80
  } else {
@@ -74,11 +99,21 @@ function migrateGameState(state: GameState): GameState {
74
99
  const c = state.character;
75
100
  if (typeof c.maxHp !== 'number') c.maxHp = DEFAULT_ATTRIBUTES.maxHp;
76
101
  if (typeof c.maxMp !== 'number') c.maxMp = DEFAULT_ATTRIBUTES.maxMp;
102
+ c.level = Math.max(1, Math.min(MAX_LEVEL, Math.floor(c.level ?? 1)));
103
+ c.exp = Math.max(0, Math.min(MAX_EXP, Math.floor(c.exp ?? 0)));
77
104
  c.hp = Math.max(0, Math.min(c.maxHp, c.hp));
78
105
  c.mp = Math.max(0, Math.min(c.maxMp, c.mp));
79
106
  c.stamina = Math.max(0, Math.min(MAX_STAMINA, c.stamina));
80
107
  c.poison = Math.max(0, c.poison);
81
108
  c.hurt = Math.max(0, c.hurt);
109
+ c.attributes.level = c.level;
110
+ c.attributes.exp = c.exp;
111
+
112
+ state.inventory.silver = Math.max(0, Math.floor(state.inventory.silver));
113
+ for (const item of state.inventory.items) {
114
+ item.count = Math.max(0, Math.floor(item.count));
115
+ }
116
+ state.inventory.items = state.inventory.items.filter((i) => i.count > 0);
82
117
 
83
118
  if (!getMap(state.location)) {
84
119
  state.location = getTemplates().startLocation ?? '小村';