@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.
@@ -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,25 +24,96 @@ import {
20
24
  getMap,
21
25
  getItem,
22
26
  getSkill,
27
+ getSkillById,
23
28
  getSkillAttackAtLevel,
24
29
  getDialog,
25
30
  getEnemyTemplate,
31
+ getNpcCard,
32
+ getLocationMeta,
26
33
  isWeapon,
27
34
  isArmor,
28
35
  isConsumable,
36
+ isSkillBook,
37
+ mapDamageTypeToStaminaCost,
29
38
  } from './config-loader';
30
- import type { GameState, BattleEnemy, Character } from './game-types';
39
+ import type {
40
+ GameState,
41
+ BattleEnemy,
42
+ Character,
43
+ TalkResult,
44
+ LocationDetail,
45
+ ActionOption,
46
+ ResolveOptionResult,
47
+ NpcCard,
48
+ NpcContext,
49
+ DialogChoice,
50
+ EventResult,
51
+ PlayerChoicePrompt,
52
+ McpElicitationParams,
53
+ FeishuInteractiveCard,
54
+ } from './game-types';
55
+ import {
56
+ runTriggeredEvents,
57
+ getInteractEventsForMap,
58
+ getInteractEventLabel,
59
+ getMapDangerHint,
60
+ processDialogChoiceActions,
61
+ evaluateConditions,
62
+ } from './event-engine';
31
63
  import {
32
64
  loadGameState as persistenceLoadGameState,
33
65
  saveGameState,
34
66
  deleteSave,
35
67
  loadOrCreateGame as persistenceLoadOrCreateGame,
68
+ loadOrCreateGameForUser as persistenceLoadOrCreateGameForUser,
36
69
  getSavePath,
70
+ setSaveUserId,
71
+ getSaveUserId,
37
72
  } from './persistence';
38
73
  import type { LoadGameResult } from './persistence';
39
-
40
- export type { GameState, BattleEnemy, Character, LoadGameResult };
41
- export { saveGameState, deleteSave, getSavePath };
74
+ import {
75
+ buildChoicePrompt as buildChoicePromptFromOptions,
76
+ toMcpElicitationParams,
77
+ fromElicitationResponse,
78
+ toFeishuInteractiveCard,
79
+ feishuCardToMessageContent,
80
+ parseFeishuButtonValue,
81
+ parseDialogChoiceValue,
82
+ parsePaginationValue,
83
+ isPaginationValue,
84
+ type BuildChoicePromptContext,
85
+ } from './choice-prompt';
86
+
87
+ export type {
88
+ GameState,
89
+ BattleEnemy,
90
+ Character,
91
+ LoadGameResult,
92
+ TalkResult,
93
+ LocationDetail,
94
+ ActionOption,
95
+ ResolveOptionResult,
96
+ NpcCard,
97
+ NpcContext,
98
+ DialogChoice,
99
+ EventResult,
100
+ PlayerChoicePrompt,
101
+ McpElicitationParams,
102
+ FeishuInteractiveCard,
103
+ };
104
+ export {
105
+ saveGameState,
106
+ deleteSave,
107
+ getSavePath,
108
+ setSaveUserId,
109
+ getSaveUserId,
110
+ toMcpElicitationParams,
111
+ fromElicitationResponse,
112
+ toFeishuInteractiveCard,
113
+ feishuCardToMessageContent,
114
+ parseFeishuButtonValue,
115
+ };
116
+ export type { BuildChoicePromptContext };
42
117
 
43
118
  initConfigs();
44
119
 
@@ -46,6 +121,42 @@ function autoSave(state: GameState): void {
46
121
  saveGameState(state);
47
122
  }
48
123
 
124
+ function assertAlive(state: GameState): { ok: true } | { ok: false; message: string } {
125
+ if (state.character.hp <= 0) {
126
+ return { ok: false, message: '你已昏迷,无法行动' };
127
+ }
128
+ return { ok: true };
129
+ }
130
+
131
+ function clearBuffs(state: GameState): void {
132
+ state.character.buffs = {};
133
+ }
134
+
135
+ function ensureSkillExp(state: GameState): void {
136
+ if (!state.character.skillExp) {
137
+ state.character.skillExp = {};
138
+ }
139
+ }
140
+
141
+ /** @internal 供单元测试覆盖防御性分支 */
142
+ export function consumeItemStack(state: GameState, itemName: string): void {
143
+ const inv = state.inventory.items.find((i) => i.name === itemName);
144
+ if (!inv) return;
145
+ inv.count--;
146
+ if (inv.count <= 0) {
147
+ state.inventory.items = state.inventory.items.filter((i) => i.name !== itemName);
148
+ }
149
+ }
150
+
151
+ function resolveEnemyTemplateName(enemyDisplayName: string): string {
152
+ const templates = getTemplates().enemies;
153
+ if (templates[enemyDisplayName]) return enemyDisplayName;
154
+ for (const key of Object.keys(templates)) {
155
+ if (enemyDisplayName.startsWith(key)) return key;
156
+ }
157
+ return enemyDisplayName;
158
+ }
159
+
49
160
  // ============================================================================
50
161
  // 初始化
51
162
  // ============================================================================
@@ -53,6 +164,7 @@ function autoSave(state: GameState): void {
53
164
  function buildCharacter(name: string): Character {
54
165
  const tpl = getTemplates();
55
166
  const attrs = { ...DEFAULT_ATTRIBUTES };
167
+ const defaultSkills = tpl.defaultCharacter.skills ?? ['基本拳法'];
56
168
  return {
57
169
  name,
58
170
  level: 1,
@@ -66,16 +178,16 @@ function buildCharacter(name: string): Character {
66
178
  hurt: 0,
67
179
  attributes: attrs,
68
180
  equipment: { weapon: null, armor: null },
69
- skills: [...(tpl.defaultCharacter.skills ?? ['基本拳法'])],
70
- skillLevels: Object.fromEntries(
71
- (tpl.defaultCharacter.skills ?? ['基本拳法']).map((s) => [s, 0]),
72
- ),
181
+ skills: [...defaultSkills],
182
+ skillLevels: Object.fromEntries(defaultSkills.map((s) => [s, 0])),
183
+ skillExp: Object.fromEntries(defaultSkills.map((s) => [s, 0])),
184
+ buffs: {},
73
185
  };
74
186
  }
75
187
 
76
188
  export function createNewGame(name: string): GameState {
77
189
  const tpl = getTemplates();
78
- return {
190
+ const state: GameState = {
79
191
  character: buildCharacter(name),
80
192
  team: [],
81
193
  inventory: {
@@ -88,6 +200,9 @@ export function createNewGame(name: string): GameState {
88
200
  visitedMaps: [tpl.startLocation ?? '小村'],
89
201
  completedQuests: [],
90
202
  };
203
+ runTriggeredEvents(state, 'auto', { mapName: state.location });
204
+ saveGameState(state);
205
+ return state;
91
206
  }
92
207
 
93
208
  export function loadGameState(): GameState | null {
@@ -123,17 +238,72 @@ export function getStatus(state: GameState): string {
123
238
  `❤️ ${c.hp}/${c.maxHp} | 💠 ${c.mp}/${c.maxMp} | ⚡ ${c.stamina}/${MAX_STAMINA}`,
124
239
  c.poison > 0 ? `🧪 中毒: ${c.poison}` : null,
125
240
  c.hurt > 0 ? `💊 受伤: ${c.hurt}` : null,
241
+ (c.buffs?.attack ?? 0) > 0 ? `⚔️ 攻加成: +${c.buffs!.attack}` : null,
242
+ (c.buffs?.agility ?? 0) > 0 ? `💨 轻功加成: +${c.buffs!.agility}` : null,
126
243
  `💰 ${state.inventory.silver} | 📍 ${state.location} | 📅 第${state.week}周`,
127
244
  ]
128
245
  .filter(Boolean)
129
246
  .join('\n');
130
247
  }
131
248
 
249
+ function personaToString(persona: NpcCard['persona']): string {
250
+ if (typeof persona === 'string') return persona;
251
+ const parts = [persona.archetype, persona.tone].filter(Boolean);
252
+ return parts.join(',') || '';
253
+ }
254
+
255
+ function buildNpcCard(location: string, npcName: string): NpcCard | undefined {
256
+ return getNpcCard(location, npcName);
257
+ }
258
+
259
+ function dialogToChoices(dialogId: string): DialogChoice[] {
260
+ const dialog = getDialog(dialogId);
261
+ if (!dialog?.choices?.length) return [];
262
+ return dialog.choices.map((c, index) => ({
263
+ text: c.text,
264
+ nextId: c.nextId,
265
+ index,
266
+ }));
267
+ }
268
+
269
+ export function getLocationDetail(state: GameState): LocationDetail {
270
+ const map = getMap(state.location);
271
+ const meta = getLocationMeta(state.location);
272
+ if (!map) {
273
+ return {
274
+ name: state.location,
275
+ description: meta.description,
276
+ atmosphere: meta.atmosphere,
277
+ dangerLevel: meta.dangerLevel,
278
+ connections: [],
279
+ npcs: [],
280
+ shops: [],
281
+ };
282
+ }
283
+
284
+ return {
285
+ name: state.location,
286
+ description: meta.description,
287
+ atmosphere: meta.atmosphere,
288
+ dangerLevel: meta.dangerLevel,
289
+ connections: [...map.connections],
290
+ npcs: map.npcs.map((name) => {
291
+ const card = getNpcCard(state.location, name);
292
+ return { name, persona: card ? personaToString(card.persona) : undefined };
293
+ }),
294
+ shops: [...map.shops],
295
+ };
296
+ }
297
+
132
298
  export function getLocationInfo(state: GameState): string {
133
299
  const map = getMap(state.location);
134
300
  if (!map) return '当前位置未知';
135
301
 
302
+ const detail = getLocationDetail(state);
136
303
  const lines = [`📍 ${state.location}`];
304
+ if (detail.description) {
305
+ lines.push(detail.description);
306
+ }
137
307
  if (map.connections.length > 0) {
138
308
  lines.push(`可前往: ${map.connections.join('、')}`);
139
309
  }
@@ -143,12 +313,224 @@ export function getLocationInfo(state: GameState): string {
143
313
  if (map.shops.length > 0) {
144
314
  lines.push(`可购: ${map.shops.join('、')}`);
145
315
  }
146
- if (map.encounterRate && map.encounterRate > 0) {
147
- lines.push('此地行路需当心,或有歹人埋伏');
316
+ const dangerHint = getMapDangerHint(state.location);
317
+ if (dangerHint) {
318
+ lines.push(dangerHint);
148
319
  }
149
320
  return lines.join('\n');
150
321
  }
151
322
 
323
+ export function getOptions(state: GameState): ActionOption[] {
324
+ const alive = assertAlive(state);
325
+ if (!alive.ok) {
326
+ return [{ id: 'status', label: '查看状态', category: 'status' }];
327
+ }
328
+
329
+ const map = getMap(state.location);
330
+ if (!map) {
331
+ return [{ id: 'status', label: '查看状态', category: 'status' }];
332
+ }
333
+
334
+ const options: ActionOption[] = [];
335
+
336
+ for (const npc of map.npcs) {
337
+ options.push({
338
+ id: `talk_${npc}`,
339
+ label: `和${npc}交谈`,
340
+ category: 'talk',
341
+ });
342
+ }
343
+
344
+ for (const conn of map.connections) {
345
+ options.push({
346
+ id: `move_${conn}`,
347
+ label: `前往${conn}`,
348
+ category: 'move',
349
+ });
350
+ }
351
+
352
+ for (const itemName of map.shops) {
353
+ const itemCfg = getItem(itemName);
354
+ const canAfford = state.inventory.silver >= (itemCfg?.price ?? Infinity);
355
+ options.push({
356
+ id: `buy_${itemName}`,
357
+ label: `购买${itemName}`,
358
+ category: 'shop',
359
+ hint: canAfford ? undefined : `(需${itemCfg?.price}两,现有${state.inventory.silver}两)`,
360
+ });
361
+ }
362
+
363
+ for (const event of getInteractEventsForMap(state.location)) {
364
+ if (evaluateConditions(state, event.conditions)) {
365
+ options.push({
366
+ id: `interact_${event.id}`,
367
+ label: getInteractEventLabel(event),
368
+ category: 'interact',
369
+ });
370
+ }
371
+ }
372
+
373
+ options.push(
374
+ { id: 'explore', label: '四处看看', category: 'explore' },
375
+ { id: 'rest', label: '休息恢复', category: 'rest' },
376
+ { id: 'status', label: '查看状态', category: 'status' },
377
+ );
378
+
379
+ return options;
380
+ }
381
+
382
+ export function resolveOption(state: GameState, optionId: string): ResolveOptionResult {
383
+ if (isPaginationValue(optionId)) {
384
+ const page = parsePaginationValue(optionId);
385
+ if (page != null) {
386
+ return {
387
+ action: 'paginate',
388
+ result: buildChoicePrompt(state, { page }),
389
+ };
390
+ }
391
+ }
392
+
393
+ const dialogRef = parseDialogChoiceValue(optionId);
394
+ if (dialogRef) {
395
+ return {
396
+ action: 'dialog',
397
+ result: chooseDialog(state, dialogRef.dialogId, dialogRef.index),
398
+ };
399
+ }
400
+
401
+ if (optionId.startsWith('talk_')) {
402
+ const npcName = optionId.slice('talk_'.length);
403
+ return { action: 'talk', result: talkTo(state, npcName) };
404
+ }
405
+ if (optionId.startsWith('move_')) {
406
+ const dest = optionId.slice('move_'.length);
407
+ return { action: 'move', result: moveTo(state, dest) };
408
+ }
409
+ if (optionId.startsWith('buy_')) {
410
+ const itemName = optionId.slice('buy_'.length);
411
+ return { action: 'buy', result: buyItem(state, itemName) };
412
+ }
413
+ if (optionId.startsWith('interact_')) {
414
+ const eventId = optionId.slice('interact_'.length);
415
+ const events = runTriggeredEvents(state, 'interact', { mapName: state.location, eventId });
416
+ autoSave(state);
417
+ const message = events
418
+ .map((e) => e.message)
419
+ .filter(Boolean)
420
+ .join('\n');
421
+ const battle = events.find((e) => e.type === 'battle');
422
+ return {
423
+ action: 'interact',
424
+ result: {
425
+ success: true,
426
+ message: message || '你探索了一番。',
427
+ events,
428
+ encounter: battle?.enemyName,
429
+ },
430
+ };
431
+ }
432
+ if (optionId === 'explore') {
433
+ return {
434
+ action: 'explore',
435
+ result: { success: true, message: getLocationInfo(state) },
436
+ };
437
+ }
438
+ if (optionId === 'rest') {
439
+ return { action: 'rest', result: rest(state) };
440
+ }
441
+ if (optionId === 'status') {
442
+ return { action: 'status', result: getStatus(state) };
443
+ }
444
+ return { action: 'unknown', result: { success: false, message: `未知选项:${optionId}` } };
445
+ }
446
+
447
+ export function buildChoicePrompt(
448
+ state: GameState,
449
+ ctx: BuildChoicePromptContext = {},
450
+ ): PlayerChoicePrompt {
451
+ return buildChoicePromptFromOptions(getOptions(state), ctx);
452
+ }
453
+
454
+ export function buildChoicePromptFromTalk(
455
+ state: GameState,
456
+ talkResult: TalkResult,
457
+ message?: string,
458
+ ): PlayerChoicePrompt {
459
+ if (talkResult.choices?.length && talkResult.dialogId) {
460
+ return buildChoicePromptFromOptions(getOptions(state), {
461
+ message: message ?? talkResult.message,
462
+ dialogChoices: talkResult.choices,
463
+ dialogId: talkResult.dialogId,
464
+ });
465
+ }
466
+ return buildChoicePrompt(state, { message: message ?? talkResult.message });
467
+ }
468
+
469
+ export function loadOrCreateGameForUser(userId: string, name = '主角'): LoadGameResult {
470
+ return persistenceLoadOrCreateGameForUser(userId, createNewGame, name);
471
+ }
472
+
473
+ export function getNpcContext(state: GameState, npcName: string): NpcContext | null {
474
+ const map = getMap(state.location);
475
+ if (!map || !map.npcs.includes(npcName)) return null;
476
+
477
+ const card = buildNpcCard(state.location, npcName);
478
+ if (!card) return null;
479
+
480
+ const char = state.character;
481
+ const availableActions: NpcContext['availableActions'] = ['talk'];
482
+ const constraints: string[] = [
483
+ `你是${card.name}${card.title ? `(${card.title})` : ''},必须保持角色性格一致。`,
484
+ '不可编造引擎未确认的物品给予、武功传授或任务完成。',
485
+ ];
486
+
487
+ if (typeof card.persona === 'object') {
488
+ if (card.persona.tone) constraints.push(`说话风格:${card.persona.tone}`);
489
+ if (card.persona.dislikes?.length) {
490
+ constraints.push(`厌恶:${card.persona.dislikes.join('、')}`);
491
+ }
492
+ } else {
493
+ constraints.push(`性格:${card.persona}`);
494
+ }
495
+
496
+ if (card.canTeach?.length) {
497
+ for (const skill of card.canTeach) {
498
+ const cond = card.conditions?.teach;
499
+ const levelOk = !cond?.minLevel || char.level >= cond.minLevel;
500
+ const iqOk = !cond?.minIQ || char.attributes.iq >= cond.minIQ;
501
+ if (levelOk && iqOk && !char.skills.includes(skill)) {
502
+ availableActions.push('teach');
503
+ constraints.push(`可传授${skill}(需调用 learnSkill 落实,不可口头编造)。`);
504
+ } else if (!levelOk || !iqOk) {
505
+ constraints.push(
506
+ `传授${skill}需等级${cond?.minLevel ?? 1}${cond?.minIQ ? `、资质${cond.minIQ}` : ''},当前未达标,不得声称已传授。`,
507
+ );
508
+ }
509
+ }
510
+ }
511
+
512
+ if (card.canGive?.length) {
513
+ availableActions.push('give');
514
+ constraints.push(`可给予物品:${card.canGive.join('、')}(须引擎确认)。`);
515
+ }
516
+
517
+ if (card.canHelp?.length || card.knowledge.length > 0) {
518
+ availableActions.push('quest');
519
+ }
520
+
521
+ return {
522
+ card,
523
+ playerRelation: {
524
+ level: char.level,
525
+ iq: char.attributes.iq,
526
+ flags: { ...state.flags },
527
+ inventory: state.inventory.items.map((i) => i.name),
528
+ },
529
+ availableActions: [...new Set(availableActions)],
530
+ constraints,
531
+ };
532
+ }
533
+
152
534
  export function getInventory(state: GameState): string {
153
535
  const items = state.inventory.items;
154
536
  if (items.length === 0) return `💰 银两: ${state.inventory.silver}\n\n📦 背包空空如也`;
@@ -160,7 +542,14 @@ export function getInventory(state: GameState): string {
160
542
  export function getSkills(state: GameState): string {
161
543
  const skills = state.character.skills;
162
544
  if (skills.length === 0) return '🥋 还没有学会任何武功';
163
- return `🥋 武功:\n${skills.map((s) => `- ${s}`).join('\n')}`;
545
+ ensureSkillExp(state);
546
+ return `🥋 武功:\n${skills
547
+ .map((s) => {
548
+ const lv = (state.character.skillLevels[s] ?? 0) + 1;
549
+ const exp = state.character.skillExp![s] ?? 0;
550
+ return `- ${s} Lv.${lv}(熟练 ${exp}/100)`;
551
+ })
552
+ .join('\n')}`;
164
553
  }
165
554
 
166
555
  // ============================================================================
@@ -170,7 +559,16 @@ export function getSkills(state: GameState): string {
170
559
  export function moveTo(
171
560
  state: GameState,
172
561
  destination: string,
173
- ): { success: boolean; message: string; encounter?: string } {
562
+ ): {
563
+ success: boolean;
564
+ message: string;
565
+ encounter?: string;
566
+ events?: EventResult[];
567
+ locationDetail?: LocationDetail;
568
+ } {
569
+ const alive = assertAlive(state);
570
+ if (!alive.ok) return { success: false, message: alive.message };
571
+
174
572
  const map = getMap(state.location);
175
573
  if (!map) return { success: false, message: '当前位置未知' };
176
574
 
@@ -185,9 +583,15 @@ export function moveTo(
185
583
  return { success: false, message: `从${state.location}无法直达${destination}` };
186
584
  }
187
585
 
586
+ const moveCost = calculateMoveStaminaCost(getEffectiveAgility(state));
587
+ if (state.character.stamina < moveCost) {
588
+ return { success: false, message: `体力不足,需要${moveCost}点体力才能前往${destination}` };
589
+ }
590
+
188
591
  state.location = destination;
189
- state.character.stamina = Math.max(0, state.character.stamina - 5);
592
+ state.character.stamina = Math.max(0, state.character.stamina - moveCost);
190
593
  state.week++;
594
+ clearBuffs(state);
191
595
  advanceWeekEffects(state);
192
596
 
193
597
  if (!state.visitedMaps.includes(destination)) {
@@ -207,20 +611,30 @@ export function moveTo(
207
611
  encounter = pool[Math.floor(Math.random() * pool.length)];
208
612
  }
209
613
 
614
+ const events = runTriggeredEvents(state, 'auto', { mapName: destination });
615
+ const locationDetail = getLocationDetail(state);
616
+
210
617
  autoSave(state);
211
618
 
212
- let message = `你来到了${destination}`;
619
+ const meta = getLocationMeta(destination);
620
+ let message = meta.description
621
+ ? `你来到了${destination}。${meta.description}`
622
+ : `你来到了${destination}`;
623
+ const eventMessages = events.map((e) => e.message).filter(Boolean);
624
+ if (eventMessages.length > 0) {
625
+ message += `\n${eventMessages.join('\n')}`;
626
+ }
213
627
  if (encounter) {
214
628
  message += `。暗处传来脚步声——似乎有${encounter}埋伏!`;
215
629
  }
216
- return { success: true, message, encounter };
630
+ return { success: true, message, encounter, events, locationDetail };
217
631
  }
218
632
 
219
633
  // ============================================================================
220
634
  // NPC 交互
221
635
  // ============================================================================
222
636
 
223
- export function talkTo(state: GameState, npcName: string): { success: boolean; message: string } {
637
+ export function talkTo(state: GameState, npcName: string): TalkResult {
224
638
  const map = getMap(state.location);
225
639
  if (!map) return { success: false, message: '当前位置未知' };
226
640
 
@@ -233,15 +647,80 @@ export function talkTo(state: GameState, npcName: string): { success: boolean; m
233
647
  return { success: false, message: `${state.location}没有${npcName}` };
234
648
  }
235
649
 
650
+ const npc = buildNpcCard(state.location, npcName);
651
+ const context = getNpcContext(state, npcName) ?? undefined;
652
+
653
+ const talkEvents = runTriggeredEvents(state, 'talk', {
654
+ mapName: state.location,
655
+ npcName,
656
+ });
657
+
236
658
  const dialogId = map.npcDialogs[npcName];
237
659
  if (dialogId) {
238
660
  const dialog = getDialog(dialogId);
239
661
  if (dialog) {
240
- return { success: true, message: `${dialog.speaker}:「${dialog.text}」` };
662
+ autoSave(state);
663
+ return {
664
+ success: true,
665
+ message: `${dialog.speaker}:「${dialog.text}」`,
666
+ npc,
667
+ context,
668
+ dialogId,
669
+ choices: dialogToChoices(dialogId),
670
+ events: talkEvents.length > 0 ? talkEvents : undefined,
671
+ };
672
+ }
673
+ }
674
+
675
+ return {
676
+ success: true,
677
+ message: `你和${npcName}聊了起来`,
678
+ npc,
679
+ context,
680
+ events: talkEvents.length > 0 ? talkEvents : undefined,
681
+ };
682
+ }
683
+
684
+ export function chooseDialog(state: GameState, dialogId: string, choiceIndex: number): TalkResult {
685
+ const dialog = getDialog(dialogId);
686
+ if (!dialog) {
687
+ return { success: false, message: '对话不存在' };
688
+ }
689
+
690
+ const choice = dialog.choices?.[choiceIndex];
691
+ if (!choice) {
692
+ return { success: false, message: '无效的选项' };
693
+ }
694
+
695
+ const actionResults: EventResult[] = [];
696
+ if (choice.actions?.length) {
697
+ actionResults.push(...processDialogChoiceActions(state, choice.actions));
698
+ }
699
+
700
+ if (choice.nextId) {
701
+ const nextDialog = getDialog(choice.nextId);
702
+ if (nextDialog) {
703
+ autoSave(state);
704
+ return {
705
+ success: true,
706
+ message: `${nextDialog.speaker}:「${nextDialog.text}」`,
707
+ dialogId: choice.nextId,
708
+ choices: dialogToChoices(choice.nextId),
709
+ events: actionResults.length > 0 ? actionResults : undefined,
710
+ };
241
711
  }
242
712
  }
243
713
 
244
- return { success: true, message: `你和${npcName}聊了起来` };
714
+ autoSave(state);
715
+ const actionMsg = actionResults
716
+ .map((r) => r.message)
717
+ .filter(Boolean)
718
+ .join('\n');
719
+ return {
720
+ success: true,
721
+ message: actionMsg || '你结束了对话。',
722
+ events: actionResults.length > 0 ? actionResults : undefined,
723
+ };
245
724
  }
246
725
 
247
726
  // ============================================================================
@@ -249,6 +728,9 @@ export function talkTo(state: GameState, npcName: string): { success: boolean; m
249
728
  // ============================================================================
250
729
 
251
730
  export function buyItem(state: GameState, itemName: string): { success: boolean; message: string } {
731
+ const alive = assertAlive(state);
732
+ if (!alive.ok) return { success: false, message: alive.message };
733
+
252
734
  const item = getItem(itemName);
253
735
  if (!item) return { success: false, message: `没有${itemName}出售` };
254
736
 
@@ -264,8 +746,13 @@ export function buyItem(state: GameState, itemName: string): { success: boolean;
264
746
  };
265
747
  }
266
748
 
267
- state.inventory.silver -= item.price;
749
+ const totalItems = state.inventory.items.reduce((sum, i) => sum + i.count, 0);
268
750
  const existing = state.inventory.items.find((i) => i.name === itemName);
751
+ if (!existing && totalItems >= MAX_INVENTORY_SIZE) {
752
+ return { success: false, message: `背包已满(最多${MAX_INVENTORY_SIZE}种物品)` };
753
+ }
754
+
755
+ state.inventory.silver -= item.price;
269
756
  if (existing) {
270
757
  existing.count++;
271
758
  } else {
@@ -285,6 +772,9 @@ export function buyItem(state: GameState, itemName: string): { success: boolean;
285
772
  // ============================================================================
286
773
 
287
774
  export function useItem(state: GameState, itemName: string): { success: boolean; message: string } {
775
+ const alive = assertAlive(state);
776
+ if (!alive.ok) return { success: false, message: alive.message };
777
+
288
778
  const inv = state.inventory.items.find((i) => i.name === itemName);
289
779
  if (!inv || inv.count <= 0) {
290
780
  return { success: false, message: `没有${itemName}` };
@@ -295,12 +785,40 @@ export function useItem(state: GameState, itemName: string): { success: boolean;
295
785
  return { success: false, message: `${itemName}无法使用` };
296
786
  }
297
787
 
788
+ // 武功秘籍
789
+ if (isSkillBook(item)) {
790
+ const skill = getSkillById(item.skillId!);
791
+ if (!skill) return { success: false, message: `${itemName}内容残缺,无法修习` };
792
+
793
+ const c = state.character;
794
+ if ((item.needIQ ?? 0) > c.attributes.iq) {
795
+ return { success: false, message: `资质不足,需要${item.needIQ}点资质才能研读${itemName}` };
796
+ }
797
+ if ((item.needExp ?? 0) > c.exp) {
798
+ return { success: false, message: `经验不足,需要${item.needExp}点经验才能研读${itemName}` };
799
+ }
800
+ if (c.skills.includes(skill.name)) {
801
+ return { success: false, message: `已经学会了${skill.name}` };
802
+ }
803
+
804
+ c.skills.push(skill.name);
805
+ c.skillLevels[skill.name] = 0;
806
+ ensureSkillExp(state);
807
+ state.character.skillExp![skill.name] = 0;
808
+ consumeItemStack(state, itemName);
809
+ autoSave(state);
810
+ return { success: true, message: `研读${itemName},学会了${skill.name}` };
811
+ }
812
+
298
813
  const c = state.character;
814
+ if (!c.buffs) c.buffs = {};
299
815
  const parts: string[] = [];
300
816
  let hpGain = 0;
301
817
  let mpGain = 0;
302
818
  let staminaGain = 0;
303
819
  let poisonReduced = 0;
820
+ let buffAttack = 0;
821
+ let buffAgility = 0;
304
822
 
305
823
  if (item.useAddHp > 0) {
306
824
  hpGain = Math.min(item.useAddHp, c.maxHp - c.hp);
@@ -318,8 +836,23 @@ export function useItem(state: GameState, itemName: string): { success: boolean;
318
836
  poisonReduced = Math.min(item.useDePoison, c.poison);
319
837
  parts.push('解除中毒');
320
838
  }
839
+ if ((item.useAddAttack ?? 0) > 0) {
840
+ if ((c.buffs.attack ?? 0) > 0) {
841
+ return { success: false, message: `${itemName}效果仍在,无需重复使用` };
842
+ }
843
+ buffAttack = item.useAddAttack!;
844
+ parts.push(`攻击力临时+${buffAttack}`);
845
+ }
846
+ if ((item.useAddAgility ?? 0) > 0) {
847
+ if ((c.buffs.agility ?? 0) > 0) {
848
+ return { success: false, message: `${itemName}效果仍在,无需重复使用` };
849
+ }
850
+ buffAgility = item.useAddAgility!;
851
+ parts.push(`轻功临时+${buffAgility}`);
852
+ }
321
853
 
322
- if (hpGain + mpGain + staminaGain + poisonReduced === 0) {
854
+ const totalGain = hpGain + mpGain + staminaGain + poisonReduced + buffAttack + buffAgility;
855
+ if (totalGain === 0) {
323
856
  if (item.useDePoison > 0 && c.poison <= 0) {
324
857
  return { success: false, message: '你没有中毒,无需使用解毒丸' };
325
858
  }
@@ -330,12 +863,10 @@ export function useItem(state: GameState, itemName: string): { success: boolean;
330
863
  c.mp += mpGain;
331
864
  c.stamina += staminaGain;
332
865
  c.poison -= poisonReduced;
866
+ if (buffAttack > 0) c.buffs.attack = buffAttack;
867
+ if (buffAgility > 0) c.buffs.agility = buffAgility;
333
868
 
334
- inv.count--;
335
- if (inv.count <= 0) {
336
- state.inventory.items = state.inventory.items.filter((i) => i.name !== itemName);
337
- }
338
-
869
+ consumeItemStack(state, itemName);
339
870
  autoSave(state);
340
871
  return { success: true, message: `使用${itemName},${parts.join(',')}` };
341
872
  }
@@ -348,6 +879,9 @@ export function equipItem(
348
879
  state: GameState,
349
880
  itemName: string,
350
881
  ): { success: boolean; message: string } {
882
+ const alive = assertAlive(state);
883
+ if (!alive.ok) return { success: false, message: alive.message };
884
+
351
885
  const inv = state.inventory.items.find((i) => i.name === itemName);
352
886
  if (!inv || inv.count <= 0) {
353
887
  return { success: false, message: `没有${itemName}` };
@@ -379,6 +913,9 @@ export function learnSkill(
379
913
  state: GameState,
380
914
  skillName: string,
381
915
  ): { success: boolean; message: string } {
916
+ const alive = assertAlive(state);
917
+ if (!alive.ok) return { success: false, message: alive.message };
918
+
382
919
  if (!getSkill(skillName)) {
383
920
  return { success: false, message: `江湖上没有${skillName}这门武功` };
384
921
  }
@@ -388,6 +925,8 @@ export function learnSkill(
388
925
 
389
926
  state.character.skills.push(skillName);
390
927
  state.character.skillLevels[skillName] = 0;
928
+ ensureSkillExp(state);
929
+ state.character.skillExp![skillName] = 0;
391
930
  autoSave(state);
392
931
  return { success: true, message: `学会了${skillName}` };
393
932
  }
@@ -397,11 +936,15 @@ export function learnSkill(
397
936
  // ============================================================================
398
937
 
399
938
  export function rest(state: GameState): { success: boolean; message: string } {
939
+ const alive = assertAlive(state);
940
+ if (!alive.ok) return { success: false, message: alive.message };
941
+
400
942
  state.character.hp = state.character.maxHp;
401
943
  state.character.mp = state.character.maxMp;
402
944
  state.character.stamina = MAX_STAMINA;
403
945
  state.character.poison = 0;
404
946
  state.character.hurt = 0;
947
+ clearBuffs(state);
405
948
 
406
949
  autoSave(state);
407
950
  return { success: true, message: '休息完毕,状态全满' };
@@ -415,6 +958,9 @@ export function startBattle(
415
958
  state: GameState,
416
959
  enemyName: string,
417
960
  ): { success: boolean; message: string; enemies?: BattleEnemy[] } {
961
+ const alive = assertAlive(state);
962
+ if (!alive.ok) return { success: false, message: alive.message };
963
+
418
964
  const entry = getTemplates().enemies[enemyName];
419
965
  const template = getEnemyTemplate(enemyName);
420
966
  if (!template) {
@@ -442,11 +988,19 @@ export function attackEnemy(
442
988
  enemies: BattleEnemy[],
443
989
  targetIndex: number,
444
990
  ): { message: string; enemyDefeated: boolean; playerDamage: number } {
991
+ const alive = assertAlive(state);
992
+ if (!alive.ok) return { message: alive.message, enemyDefeated: false, playerDamage: 0 };
993
+
445
994
  const target = enemies[targetIndex];
446
995
  if (!target || target.hp <= 0) {
447
996
  return { message: '目标无效', enemyDefeated: false, playerDamage: 0 };
448
997
  }
449
998
 
999
+ const staminaCost = calculateStaminaCost('normal');
1000
+ if (state.character.stamina < staminaCost) {
1001
+ return { message: '体力不足,无法攻击', enemyDefeated: false, playerDamage: 0 };
1002
+ }
1003
+
450
1004
  const damage = calcDamage(
451
1005
  getEffectiveAttack(state),
452
1006
  0,
@@ -456,7 +1010,7 @@ export function attackEnemy(
456
1010
  );
457
1011
 
458
1012
  target.hp = Math.max(0, target.hp - damage);
459
- state.character.stamina = Math.max(0, state.character.stamina - 3);
1013
+ state.character.stamina = Math.max(0, state.character.stamina - staminaCost);
460
1014
 
461
1015
  const defeated = target.hp <= 0;
462
1016
  if (defeated) {
@@ -477,6 +1031,9 @@ export function useSkillInBattle(
477
1031
  skillName: string,
478
1032
  targetIndex: number,
479
1033
  ): { success: boolean; message: string } {
1034
+ const alive = assertAlive(state);
1035
+ if (!alive.ok) return { success: false, message: alive.message };
1036
+
480
1037
  if (!state.character.skills.includes(skillName)) {
481
1038
  return { success: false, message: `没有学会${skillName}` };
482
1039
  }
@@ -484,17 +1041,52 @@ export function useSkillInBattle(
484
1041
  const skill = getSkill(skillName);
485
1042
  if (!skill) return { success: false, message: `未知武功${skillName}` };
486
1043
 
487
- const target = enemies[targetIndex];
488
- if (!target || target.hp <= 0) {
489
- return { success: false, message: '目标无效' };
490
- }
491
-
492
1044
  const levelIndex = state.character.skillLevels[skillName] ?? 0;
493
1045
  const mpCost = calculateMpCost(skill.mpCost, levelIndex);
494
1046
  if (state.character.mp < mpCost) {
495
1047
  return { success: false, message: `内力不足,需要${mpCost}点内力` };
496
1048
  }
497
1049
 
1050
+ const staminaCost = calculateStaminaCost(mapDamageTypeToStaminaCost(skill.damageType));
1051
+ if (state.character.stamina < staminaCost) {
1052
+ return { success: false, message: `体力不足,无法施展${skillName}` };
1053
+ }
1054
+
1055
+ state.character.mp = Math.max(0, state.character.mp - mpCost);
1056
+ state.character.stamina = Math.max(0, state.character.stamina - staminaCost);
1057
+
1058
+ // 自身目标类武功
1059
+ if (skill.damageType === 3) {
1060
+ const reduced = Math.min(state.character.poison, 50);
1061
+ state.character.poison = Math.max(0, state.character.poison - reduced);
1062
+ grantSkillExp(state, skillName);
1063
+ autoSave(state);
1064
+ return {
1065
+ success: true,
1066
+ message: reduced > 0 ? `使用${skillName},解除了部分毒素` : `使用${skillName},你并未中毒`,
1067
+ };
1068
+ }
1069
+
1070
+ if (skill.damageType === 4) {
1071
+ const skillAttack = getSkillAttackAtLevel(skillName, levelIndex);
1072
+ const healAmount = Math.min(skillAttack, state.character.maxHp - state.character.hp);
1073
+ state.character.hp += healAmount;
1074
+ grantSkillExp(state, skillName);
1075
+ autoSave(state);
1076
+ return {
1077
+ success: true,
1078
+ message:
1079
+ healAmount > 0
1080
+ ? `使用${skillName},恢复${healAmount}点生命`
1081
+ : `使用${skillName},气血已足,无需治疗`,
1082
+ };
1083
+ }
1084
+
1085
+ const target = enemies[targetIndex];
1086
+ if (!target || target.hp <= 0) {
1087
+ return { success: false, message: '目标无效' };
1088
+ }
1089
+
498
1090
  const skillAttack = getSkillAttackAtLevel(skillName, levelIndex);
499
1091
  const damage = calcDamage(
500
1092
  getEffectiveAttack(state),
@@ -505,18 +1097,27 @@ export function useSkillInBattle(
505
1097
  );
506
1098
 
507
1099
  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);
1100
+
1101
+ let extra = '';
1102
+ if (skill.damageType === 1) {
1103
+ const absorbed = Math.min(Math.floor(damage / 2), state.character.maxMp - state.character.mp);
1104
+ state.character.mp += absorbed;
1105
+ if (absorbed > 0) extra = `,吸取${absorbed}点内力`;
1106
+ }
1107
+ if (skill.damageType === 2) {
1108
+ extra = ',敌人身中剧毒';
1109
+ }
510
1110
 
511
1111
  const defeated = target.hp <= 0;
512
1112
  if (defeated) {
513
1113
  grantBattleExp(state, target.maxHp);
514
1114
  }
1115
+ grantSkillExp(state, skillName);
515
1116
 
516
1117
  autoSave(state);
517
1118
  return {
518
1119
  success: true,
519
- message: `使用${skillName}攻击${target.name},造成${damage}点伤害${defeated ? ',击败!' : ''}`,
1120
+ message: `使用${skillName}攻击${target.name},造成${damage}点伤害${extra}${defeated ? ',击败!' : ''}`,
520
1121
  };
521
1122
  }
522
1123
 
@@ -524,6 +1125,10 @@ export function enemyAttack(
524
1125
  state: GameState,
525
1126
  enemies: BattleEnemy[],
526
1127
  ): { message: string; playerDefeated: boolean } {
1128
+ if (state.character.hp <= 0) {
1129
+ return { message: '', playerDefeated: true };
1130
+ }
1131
+
527
1132
  const aliveEnemies = enemies.filter((e) => e.hp > 0);
528
1133
  if (aliveEnemies.length === 0) return { message: '', playerDefeated: false };
529
1134
 
@@ -532,19 +1137,33 @@ export function enemyAttack(
532
1137
 
533
1138
  state.character.hp = Math.max(0, state.character.hp - damage);
534
1139
 
1140
+ const templateName = resolveEnemyTemplateName(enemy.name);
1141
+ const template = getEnemyTemplate(templateName);
1142
+ if (template?.onHitPoison) {
1143
+ state.character.poison += template.onHitPoison;
1144
+ }
1145
+ if (template?.onHitHurt) {
1146
+ state.character.hurt += template.onHitHurt;
1147
+ }
1148
+
1149
+ let debuffMsg = '';
1150
+ if (template?.onHitPoison) debuffMsg += ',你感到一阵麻痹';
1151
+ if (template?.onHitHurt) debuffMsg += ',你受了内伤';
1152
+
535
1153
  autoSave(state);
536
1154
  return {
537
- message: `${enemy.name}攻击你,造成${damage}点伤害`,
1155
+ message: `${enemy.name}攻击你,造成${damage}点伤害${debuffMsg}`,
538
1156
  playerDefeated: state.character.hp <= 0,
539
1157
  };
540
1158
  }
541
1159
 
542
1160
  // ============================================================================
543
- // 状态辅助(原 game-state.ts 合并)
1161
+ // 状态辅助
544
1162
  // ============================================================================
545
1163
 
546
1164
  export function advanceWeek(state: GameState): void {
547
1165
  state.week++;
1166
+ clearBuffs(state);
548
1167
  advanceWeekEffects(state);
549
1168
  autoSave(state);
550
1169
  }
@@ -568,8 +1187,12 @@ export function isDead(state: GameState): boolean {
568
1187
  // 内部
569
1188
  // ============================================================================
570
1189
 
1190
+ function getEffectiveAgility(state: GameState): number {
1191
+ return state.character.attributes.agility + (state.character.buffs?.agility ?? 0);
1192
+ }
1193
+
571
1194
  function getEffectiveAttack(state: GameState): number {
572
- let attack = state.character.attributes.attack;
1195
+ let attack = state.character.attributes.attack + (state.character.buffs?.attack ?? 0);
573
1196
  const weapon = state.character.equipment.weapon;
574
1197
  if (weapon) {
575
1198
  const item = getItem(weapon);
@@ -589,10 +1212,28 @@ function getEffectiveDefence(state: GameState): number {
589
1212
  }
590
1213
 
591
1214
  function grantBattleExp(state: GameState, enemyMaxHp: number): void {
592
- state.character.exp += 10 + enemyMaxHp / 10;
1215
+ state.character.exp += Math.floor(10 + enemyMaxHp / 10);
593
1216
  checkLevelUp(state);
594
1217
  }
595
1218
 
1219
+ function grantSkillExp(state: GameState, skillName: string): void {
1220
+ ensureSkillExp(state);
1221
+ const levels = state.character.skillLevels;
1222
+ const levelIndex = levels[skillName] ?? 0;
1223
+ if (levelIndex >= MAX_SKILL_LEVEL - 1) return;
1224
+
1225
+ const gain = Math.floor(Math.random() * 3) + 1;
1226
+ state.character.skillExp![skillName] = (state.character.skillExp![skillName] ?? 0) + gain;
1227
+
1228
+ while (
1229
+ state.character.skillExp![skillName] >= 100 &&
1230
+ (levels[skillName] ?? 0) < MAX_SKILL_LEVEL - 1
1231
+ ) {
1232
+ state.character.skillExp![skillName] -= 100;
1233
+ levels[skillName] = (levels[skillName] ?? 0) + 1;
1234
+ }
1235
+ }
1236
+
596
1237
  function checkLevelUp(state: GameState): void {
597
1238
  const c = state.character;
598
1239
  let needed = getExpForLevel(c.level + 1);
@@ -605,11 +1246,13 @@ function checkLevelUp(state: GameState): void {
605
1246
  const attrGain = Math.floor(Math.random() * (Math.floor((iq - 10) / 20) + 2)) + 1;
606
1247
 
607
1248
  c.maxHp += (c.attributes.hpInc + Math.floor(Math.random() * 4)) * 3;
608
- c.maxMp += (9 - attrGain) * 4;
1249
+ c.maxMp += Math.max(0, (9 - attrGain) * 4);
609
1250
  c.attributes.attack += attrGain;
610
1251
  c.attributes.agility += attrGain;
611
1252
  c.attributes.defence += attrGain;
612
1253
 
1254
+ c.attributes.level = c.level;
1255
+ c.attributes.exp = c.exp;
613
1256
  c.hp = c.maxHp;
614
1257
  c.mp = c.maxMp;
615
1258
  needed = getExpForLevel(c.level + 1);