@sdd330dev/jy-skill 0.3.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,24 +28,92 @@ import {
28
28
  getSkillAttackAtLevel,
29
29
  getDialog,
30
30
  getEnemyTemplate,
31
+ getNpcCard,
32
+ getLocationMeta,
31
33
  isWeapon,
32
34
  isArmor,
33
35
  isConsumable,
34
36
  isSkillBook,
35
37
  mapDamageTypeToStaminaCost,
36
38
  } from './config-loader';
37
- 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';
38
63
  import {
39
64
  loadGameState as persistenceLoadGameState,
40
65
  saveGameState,
41
66
  deleteSave,
42
67
  loadOrCreateGame as persistenceLoadOrCreateGame,
68
+ loadOrCreateGameForUser as persistenceLoadOrCreateGameForUser,
43
69
  getSavePath,
70
+ setSaveUserId,
71
+ getSaveUserId,
44
72
  } from './persistence';
45
73
  import type { LoadGameResult } from './persistence';
46
-
47
- export type { GameState, BattleEnemy, Character, LoadGameResult };
48
- 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 };
49
117
 
50
118
  initConfigs();
51
119
 
@@ -119,7 +187,7 @@ function buildCharacter(name: string): Character {
119
187
 
120
188
  export function createNewGame(name: string): GameState {
121
189
  const tpl = getTemplates();
122
- return {
190
+ const state: GameState = {
123
191
  character: buildCharacter(name),
124
192
  team: [],
125
193
  inventory: {
@@ -132,6 +200,9 @@ export function createNewGame(name: string): GameState {
132
200
  visitedMaps: [tpl.startLocation ?? '小村'],
133
201
  completedQuests: [],
134
202
  };
203
+ runTriggeredEvents(state, 'auto', { mapName: state.location });
204
+ saveGameState(state);
205
+ return state;
135
206
  }
136
207
 
137
208
  export function loadGameState(): GameState | null {
@@ -175,11 +246,64 @@ export function getStatus(state: GameState): string {
175
246
  .join('\n');
176
247
  }
177
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
+
178
298
  export function getLocationInfo(state: GameState): string {
179
299
  const map = getMap(state.location);
180
300
  if (!map) return '当前位置未知';
181
301
 
302
+ const detail = getLocationDetail(state);
182
303
  const lines = [`📍 ${state.location}`];
304
+ if (detail.description) {
305
+ lines.push(detail.description);
306
+ }
183
307
  if (map.connections.length > 0) {
184
308
  lines.push(`可前往: ${map.connections.join('、')}`);
185
309
  }
@@ -189,12 +313,224 @@ export function getLocationInfo(state: GameState): string {
189
313
  if (map.shops.length > 0) {
190
314
  lines.push(`可购: ${map.shops.join('、')}`);
191
315
  }
192
- if (map.encounterRate && map.encounterRate > 0) {
193
- lines.push('此地行路需当心,或有歹人埋伏');
316
+ const dangerHint = getMapDangerHint(state.location);
317
+ if (dangerHint) {
318
+ lines.push(dangerHint);
194
319
  }
195
320
  return lines.join('\n');
196
321
  }
197
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
+
198
534
  export function getInventory(state: GameState): string {
199
535
  const items = state.inventory.items;
200
536
  if (items.length === 0) return `💰 银两: ${state.inventory.silver}\n\n📦 背包空空如也`;
@@ -223,7 +559,13 @@ export function getSkills(state: GameState): string {
223
559
  export function moveTo(
224
560
  state: GameState,
225
561
  destination: string,
226
- ): { success: boolean; message: string; encounter?: string } {
562
+ ): {
563
+ success: boolean;
564
+ message: string;
565
+ encounter?: string;
566
+ events?: EventResult[];
567
+ locationDetail?: LocationDetail;
568
+ } {
227
569
  const alive = assertAlive(state);
228
570
  if (!alive.ok) return { success: false, message: alive.message };
229
571
 
@@ -269,20 +611,30 @@ export function moveTo(
269
611
  encounter = pool[Math.floor(Math.random() * pool.length)];
270
612
  }
271
613
 
614
+ const events = runTriggeredEvents(state, 'auto', { mapName: destination });
615
+ const locationDetail = getLocationDetail(state);
616
+
272
617
  autoSave(state);
273
618
 
274
- 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
+ }
275
627
  if (encounter) {
276
628
  message += `。暗处传来脚步声——似乎有${encounter}埋伏!`;
277
629
  }
278
- return { success: true, message, encounter };
630
+ return { success: true, message, encounter, events, locationDetail };
279
631
  }
280
632
 
281
633
  // ============================================================================
282
634
  // NPC 交互
283
635
  // ============================================================================
284
636
 
285
- export function talkTo(state: GameState, npcName: string): { success: boolean; message: string } {
637
+ export function talkTo(state: GameState, npcName: string): TalkResult {
286
638
  const map = getMap(state.location);
287
639
  if (!map) return { success: false, message: '当前位置未知' };
288
640
 
@@ -295,15 +647,80 @@ export function talkTo(state: GameState, npcName: string): { success: boolean; m
295
647
  return { success: false, message: `${state.location}没有${npcName}` };
296
648
  }
297
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
+
298
658
  const dialogId = map.npcDialogs[npcName];
299
659
  if (dialogId) {
300
660
  const dialog = getDialog(dialogId);
301
661
  if (dialog) {
302
- 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
+ };
303
711
  }
304
712
  }
305
713
 
306
- 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
+ };
307
724
  }
308
725
 
309
726
  // ============================================================================
@@ -51,3 +51,145 @@ export interface BattleEnemy {
51
51
  attack: number;
52
52
  defence: number;
53
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
+ }