@sdd330dev/jy-skill 0.3.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +41 -8
- package/README.md +54 -10
- package/SKILL.md +32 -15
- package/assets/game-config.json +63 -2
- package/assets/templates.json +168 -9
- package/package.json +11 -1
- package/references/agent-handbook.md +49 -10
- package/references/host-adapters.md +182 -0
- package/references/player-guide.md +5 -4
- package/save/.gitkeep +0 -0
- package/scripts/build-feishu-card.ts +53 -0
- package/scripts/choice-prompt.ts +179 -0
- package/scripts/config-loader.ts +182 -16
- package/scripts/event-engine.ts +205 -0
- package/scripts/game-engine.ts +683 -40
- package/scripts/game-logic.ts +5 -0
- package/scripts/game-types.ts +149 -0
- package/scripts/mcp-server.ts +161 -0
- package/scripts/persistence.ts +100 -31
package/scripts/game-engine.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
41
|
-
|
|
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: [...
|
|
70
|
-
skillLevels: Object.fromEntries(
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
): {
|
|
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 -
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
//
|
|
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);
|