426
src/services/ai.test.ts
Normal file
426
src/services/ai.test.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
connectivityError,
|
||||
fetchMock,
|
||||
requestChatMessageContentMock,
|
||||
requestPlainTextCompletionMock,
|
||||
streamPlainTextCompletionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
connectivityError: new Error('LLM unavailable'),
|
||||
fetchMock: vi.fn(),
|
||||
requestChatMessageContentMock: vi.fn(),
|
||||
requestPlainTextCompletionMock: vi.fn(),
|
||||
streamPlainTextCompletionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./llmClient', () => ({
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 45000,
|
||||
isLlmConnectivityError: (error: unknown) => error === connectivityError,
|
||||
requestChatMessageContent: requestChatMessageContentMock,
|
||||
requestPlainTextCompletion: requestPlainTextCompletionMock,
|
||||
streamPlainTextCompletion: streamPlainTextCompletionMock,
|
||||
}));
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
SceneMonster,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../types';
|
||||
import { AnimationState, WorldType } from '../types';
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCustomWorldProfile,
|
||||
generateCustomWorldSceneImage,
|
||||
generateInitialStory,
|
||||
streamCharacterPanelChatReply,
|
||||
streamNpcRecruitDialogue,
|
||||
} from './ai';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply,
|
||||
buildOfflineCharacterPanelChatSuggestions,
|
||||
buildOfflineNpcRecruitDialogue,
|
||||
} from './aiFallbacks';
|
||||
import type { StoryGenerationContext } from './aiTypes';
|
||||
import type { CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
|
||||
function createCharacter(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Lin',
|
||||
title: 'Wanderer',
|
||||
description: 'A cautious traveler.',
|
||||
backstory: 'Walked out of the northern mountains.',
|
||||
avatar: '/avatars/lin.png',
|
||||
portrait: '/portraits/lin.png',
|
||||
assetFolder: 'lin',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 8,
|
||||
intelligence: 7,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'Calm, observant, and steady.',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryOption(overrides: Partial<StoryOption> = {}): StoryOption {
|
||||
return {
|
||||
functionId: 'rest',
|
||||
actionText: 'Pause and recover.',
|
||||
text: 'Pause and recover.',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<StoryGenerationContext> = {},
|
||||
): StoryGenerationContext {
|
||||
return {
|
||||
playerHp: 30,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 12,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
sceneId: null,
|
||||
sceneName: 'Forest Trail',
|
||||
sceneDescription: 'A quiet mountain path.',
|
||||
pendingSceneEncounter: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTargetStatus(
|
||||
overrides: Partial<CharacterChatTargetStatus> = {},
|
||||
): CharacterChatTargetStatus {
|
||||
return {
|
||||
roleLabel: 'Companion',
|
||||
hp: 18,
|
||||
maxHp: 20,
|
||||
mana: 9,
|
||||
maxMana: 12,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
npcName: 'Lan',
|
||||
npcDescription: 'A sharp-eyed scout.',
|
||||
npcAvatar: '/avatars/lan.png',
|
||||
context: 'Campfire',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayableNpc(index: number) {
|
||||
return {
|
||||
name: `角色${index + 1}`,
|
||||
title: `身份${index + 1}`,
|
||||
description: `角色描述${index + 1}`,
|
||||
backstory: `角色背景${index + 1}`,
|
||||
personality: `角色性格${index + 1}`,
|
||||
combatStyle: `战斗风格${index + 1}`,
|
||||
tags: [`标签${index + 1}`],
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryNpc(index: number) {
|
||||
return {
|
||||
name: `世界NPC${index + 1}`,
|
||||
role: `职责${index + 1}`,
|
||||
description: `世界NPC描述${index + 1}`,
|
||||
motivation: `世界NPC动机${index + 1}`,
|
||||
relationshipHooks: [`关系${index + 1}`],
|
||||
};
|
||||
}
|
||||
|
||||
function createLandmark(index: number) {
|
||||
return {
|
||||
name: `场景${index + 1}`,
|
||||
description: `场景描述${index + 1}`,
|
||||
dangerLevel: 'high',
|
||||
};
|
||||
}
|
||||
|
||||
describe('ai orchestration fallbacks', () => {
|
||||
const playerCharacter = createCharacter();
|
||||
const targetCharacter = createCharacter({
|
||||
id: 'ally',
|
||||
name: 'Lan',
|
||||
title: 'Scout',
|
||||
personality: 'Dry, practical, and quietly protective.',
|
||||
});
|
||||
const context = createContext();
|
||||
const targetStatus = createTargetStatus();
|
||||
const monsters: SceneMonster[] = [];
|
||||
const storyHistory: StoryMoment[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
fetchMock.mockReset();
|
||||
requestChatMessageContentMock.mockReset();
|
||||
requestPlainTextCompletionMock.mockReset();
|
||||
streamPlainTextCompletionMock.mockReset();
|
||||
});
|
||||
|
||||
it('falls back to the offline story response when story generation loses connectivity', async () => {
|
||||
const availableOptions = [createStoryOption()];
|
||||
requestChatMessageContentMock.mockRejectedValue(connectivityError);
|
||||
|
||||
const response = await generateInitialStory(
|
||||
WorldType.WUXIA,
|
||||
playerCharacter,
|
||||
monsters,
|
||||
context,
|
||||
{ availableOptions },
|
||||
);
|
||||
|
||||
expect(response.options).toEqual(availableOptions);
|
||||
expect(response.options).not.toBe(availableOptions);
|
||||
expect(response.storyText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns offline character chat suggestions when the plain-text client reports connectivity errors', async () => {
|
||||
requestPlainTextCompletionMock.mockRejectedValue(connectivityError);
|
||||
|
||||
const suggestions = await generateCharacterPanelChatSuggestions(
|
||||
WorldType.WUXIA,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
[],
|
||||
'',
|
||||
targetStatus,
|
||||
);
|
||||
|
||||
expect(suggestions).toEqual(
|
||||
buildOfflineCharacterPanelChatSuggestions(targetCharacter),
|
||||
);
|
||||
});
|
||||
|
||||
it('streams the offline character chat reply and forwards it to onUpdate when connectivity fails', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const playerMessage = 'Tell me what you are really worried about.';
|
||||
const conversationSummary = 'Lan has started to trust the player more.';
|
||||
const fallbackReply = buildOfflineCharacterPanelChatReply(
|
||||
targetCharacter,
|
||||
playerMessage,
|
||||
conversationSummary,
|
||||
);
|
||||
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
|
||||
|
||||
const reply = await streamCharacterPanelChatReply(
|
||||
WorldType.WUXIA,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
[],
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
{ onUpdate },
|
||||
);
|
||||
|
||||
expect(reply).toBe(fallbackReply);
|
||||
expect(onUpdate).toHaveBeenCalledOnce();
|
||||
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
|
||||
});
|
||||
|
||||
it('uses the extracted NPC recruit fallback when recruit dialogue streaming loses connectivity', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const encounter = createEncounter();
|
||||
const fallbackReply = buildOfflineNpcRecruitDialogue(encounter);
|
||||
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
|
||||
|
||||
const reply = await streamNpcRecruitDialogue(
|
||||
WorldType.WUXIA,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
monsters,
|
||||
storyHistory,
|
||||
context,
|
||||
'Join us.',
|
||||
'The party is ready to travel together.',
|
||||
{ onUpdate },
|
||||
);
|
||||
|
||||
expect(reply).toBe(fallbackReply);
|
||||
expect(onUpdate).toHaveBeenCalledOnce();
|
||||
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
|
||||
});
|
||||
|
||||
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 4 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
generateCustomWorldProfile('一个需要很多角色和场景的世界'),
|
||||
).rejects.toThrow(
|
||||
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the generated custom world dossier item-free when the model output is valid', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 25 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 10 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: '不应保留的物品',
|
||||
category: '材料',
|
||||
rarity: 'rare',
|
||||
description: '这个字段应该被清空',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const profile =
|
||||
await generateCustomWorldProfile('一个需要很多角色和场景的世界');
|
||||
|
||||
expect(profile.playableNpcs).toHaveLength(5);
|
||||
expect(profile.storyNpcs).toHaveLength(25);
|
||||
expect(profile.landmarks).toHaveLength(10);
|
||||
expect(profile.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||||
assetId: 'custom-scene-1',
|
||||
model: 'wan2.2-t2i-flash',
|
||||
size: '1280*720',
|
||||
taskId: 'task-123',
|
||||
prompt: '用于测试的提示词',
|
||||
actualPrompt: '扩写后的提示词',
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await generateCustomWorldSceneImage({
|
||||
profile: {
|
||||
id: 'custom-world-1',
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '世界概述',
|
||||
tone: '世界基调',
|
||||
playerGoal: '核心目标',
|
||||
settingText: '原始设定',
|
||||
},
|
||||
landmark: {
|
||||
id: 'landmark-1',
|
||||
name: '雾潮码头',
|
||||
description: '被潮雾与旧升降机包围的码头。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
prompt: '用于测试的提示词',
|
||||
negativePrompt: '文字,水印',
|
||||
size: '1280*720',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/custom-world/scene-image',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||||
assetId: 'custom-scene-1',
|
||||
model: 'wan2.2-t2i-flash',
|
||||
size: '1280*720',
|
||||
taskId: 'task-123',
|
||||
prompt: '用于测试的提示词',
|
||||
actualPrompt: '扩写后的提示词',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces proxy error messages when scene image generation fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'DashScope API key 无效。',
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(
|
||||
generateCustomWorldSceneImage({
|
||||
profile: {
|
||||
id: 'custom-world-1',
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '世界概述',
|
||||
tone: '世界基调',
|
||||
playerGoal: '核心目标',
|
||||
settingText: '原始设定',
|
||||
},
|
||||
landmark: {
|
||||
id: 'landmark-1',
|
||||
name: '雾潮码头',
|
||||
description: '被潮雾与旧升降机包围的码头。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('DashScope API key 无效。');
|
||||
});
|
||||
});
|
||||
1011
src/services/ai.ts
Normal file
1011
src/services/ai.ts
Normal file
File diff suppressed because it is too large
Load Diff
64
src/services/aiFallbacks.ts
Normal file
64
src/services/aiFallbacks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {Character, CharacterChatTurn, Encounter} from '../types';
|
||||
|
||||
export function buildOfflineNpcChatDialogue(encounter: Encounter, topic: string) {
|
||||
return [
|
||||
`你:${topic}。我想先听听你的看法。`,
|
||||
`${encounter.npcName}:你问得并不随意,看来是真想弄清这里的底细。`,
|
||||
'你:前面的局势我还没看透。你若知道什么,就别只说一半。',
|
||||
`${encounter.npcName}:我能告诉你的,是这里近来一直不太平。接下来多留神些。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildOfflineNpcRecruitDialogue(encounter: Encounter) {
|
||||
return [
|
||||
'你:这不是客套。我是真心希望你能加入队伍,和我一起走下去。',
|
||||
`${encounter.npcName}:你这番话够坦诚,我听得出你不是随口一提。`,
|
||||
'你:前路不会轻松,但我还是希望你能与我并肩同行。',
|
||||
`${encounter.npcName}:好,我答应你。从现在起,我便与你结伴同行。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildOfflineCharacterPanelChatReply(
|
||||
targetCharacter: Character,
|
||||
playerMessage: string,
|
||||
conversationSummary: string,
|
||||
) {
|
||||
const personalityCue = targetCharacter.personality
|
||||
.split(/[,。!?,.!?]/u)
|
||||
.find(Boolean)?.trim() ?? '我会按自己的方式回答你';
|
||||
const focus = playerMessage.trim() || '我听见你刚才的话了。';
|
||||
|
||||
return `${focus}${focus.endsWith('。') ? '' : '。'}${personalityCue}${personalityCue.endsWith('。') ? '' : '。'}${
|
||||
conversationSummary
|
||||
? '我还记得我们之前谈过的那些事。'
|
||||
: '既然你愿意直接来问,我也会认真回答。'
|
||||
}前路不会轻松,但如果你还想继续说下去,我会陪着你。`;
|
||||
}
|
||||
|
||||
export function buildOfflineCharacterPanelChatSuggestions(targetCharacter: Character) {
|
||||
return [
|
||||
'把你的意思再说清楚一些。',
|
||||
`${targetCharacter.name},你真正担心的到底是什么?`,
|
||||
'先别管外面的局势,我想多了解你一点。',
|
||||
];
|
||||
}
|
||||
|
||||
export function buildOfflineCharacterPanelChatSummary(
|
||||
targetCharacter: Character,
|
||||
history: CharacterChatTurn[],
|
||||
previousSummary: string,
|
||||
) {
|
||||
const latestTurns = history.slice(-4)
|
||||
.map(turn => `${turn.speaker === 'player' ? '玩家' : targetCharacter.name}:${turn.text}`)
|
||||
.join(' ');
|
||||
|
||||
const currentSummary = latestTurns
|
||||
? `${targetCharacter.name}在私下交谈中更愿意坦率回应。最近交流:${latestTurns}`
|
||||
: `${targetCharacter.name}愿意继续私下交谈,对玩家的态度也在逐渐变得更温和。`;
|
||||
|
||||
if (!previousSummary) {
|
||||
return currentSummary.slice(0, 118);
|
||||
}
|
||||
|
||||
return `${previousSummary} ${currentSummary}`.slice(0, 118);
|
||||
}
|
||||
107
src/services/aiTypes.ts
Normal file
107
src/services/aiTypes.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterConversationStyle,
|
||||
CharacterGender,
|
||||
CompanionState,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
FacingDirection,
|
||||
InventoryItem,
|
||||
NpcAnswerMode,
|
||||
NpcDisclosureStage,
|
||||
NpcWarmthStage,
|
||||
QuestStatus,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {ConversationPressure, ConversationSituation} from '../types';
|
||||
|
||||
export interface StoryRequestOptions {
|
||||
availableOptions?: StoryOption[];
|
||||
optionCatalog?: StoryOption[];
|
||||
}
|
||||
|
||||
export interface TextStreamOptions {
|
||||
onUpdate?: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface StoryGenerationContext {
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
inBattle: boolean;
|
||||
playerX: number;
|
||||
playerFacing: FacingDirection;
|
||||
playerAnimation: AnimationState;
|
||||
skillCooldowns: Record<string, number>;
|
||||
sceneId?: string | null;
|
||||
sceneName?: string | null;
|
||||
sceneDescription?: string | null;
|
||||
pendingSceneEncounter?: boolean;
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
lastObserveSignsReport?: string | null;
|
||||
encounterKind?: string | null;
|
||||
encounterName?: string | null;
|
||||
encounterDescription?: string | null;
|
||||
encounterContext?: string | null;
|
||||
encounterCharacterId?: string | null;
|
||||
encounterGender?: CharacterGender | null;
|
||||
encounterAffinity?: number | null;
|
||||
encounterAffinityText?: string | null;
|
||||
encounterConversationStyle?: CharacterConversationStyle | null;
|
||||
encounterDisclosureStage?: NpcDisclosureStage | null;
|
||||
encounterWarmthStage?: NpcWarmthStage | null;
|
||||
encounterAnswerMode?: NpcAnswerMode | null;
|
||||
encounterAllowedTopics?: string[] | null;
|
||||
encounterBlockedTopics?: string[] | null;
|
||||
isFirstMeaningfulContact?: boolean;
|
||||
firstContactRelationStance?: 'guarded' | 'neutral' | 'cooperative' | 'bonded' | null;
|
||||
conversationSituation?: ConversationSituation | null;
|
||||
conversationPressure?: ConversationPressure | null;
|
||||
recentSharedEvent?: string | null;
|
||||
talkPriority?: string | null;
|
||||
encounterRelationshipSummary?: string | null;
|
||||
partyRelationshipNotes?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
}
|
||||
|
||||
export interface QuestSummarySnapshot {
|
||||
id: string;
|
||||
title: string;
|
||||
status: QuestStatus;
|
||||
issuerNpcId: string;
|
||||
}
|
||||
|
||||
export interface QuestGenerationContext {
|
||||
worldType: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
currentSceneId?: string | null;
|
||||
currentSceneName?: string | null;
|
||||
currentSceneDescription?: string | null;
|
||||
issuerNpcId?: string | null;
|
||||
issuerNpcName?: string | null;
|
||||
issuerNpcContext?: string | null;
|
||||
issuerAffinity?: number | null;
|
||||
issuerDisclosureStage?: NpcDisclosureStage | null;
|
||||
issuerWarmthStage?: NpcWarmthStage | null;
|
||||
encounterKind?: 'npc' | 'treasure' | 'none' | null;
|
||||
currentSceneHostileNpcIds?: string[];
|
||||
currentSceneTreasureHintCount?: number;
|
||||
recentStoryMoments: StoryMoment[];
|
||||
playerCharacter?: Character | null;
|
||||
playerHp?: number;
|
||||
playerMaxHp?: number;
|
||||
playerMana?: number;
|
||||
playerMaxMana?: number;
|
||||
playerInventory?: InventoryItem[];
|
||||
playerEquipment?: EquipmentLoadout | null;
|
||||
activeCompanions?: CompanionState[];
|
||||
rosterCompanions?: CompanionState[];
|
||||
currentQuestSummary?: QuestSummarySnapshot[];
|
||||
}
|
||||
121
src/services/attributeSchemaGenerator.ts
Normal file
121
src/services/attributeSchemaGenerator.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {validateWorldAttributeSchema} from '../data/attributeValidation';
|
||||
import {getPresetWorldAttributeSchema} from '../data/worldAttributeSchemas';
|
||||
import type {
|
||||
AttributeSchemaGenerationInput,
|
||||
WorldAttributeSchema,
|
||||
WorldAttributeSlot,
|
||||
} from '../types';
|
||||
import {WorldType} from '../types';
|
||||
import {detectCustomWorldThemeMode} from './customWorldTheme';
|
||||
|
||||
function buildSchema(
|
||||
input: AttributeSchemaGenerationInput,
|
||||
schemaName: string,
|
||||
slots: WorldAttributeSlot[],
|
||||
): WorldAttributeSchema {
|
||||
return {
|
||||
id: `schema:${input.worldType.toLowerCase()}:${schemaName}`,
|
||||
worldId: input.worldType === WorldType.CUSTOM ? `custom:${input.worldName}` : input.worldType,
|
||||
schemaVersion: 1,
|
||||
schemaName,
|
||||
generatedFrom: {
|
||||
worldType: input.worldType,
|
||||
worldName: input.worldName,
|
||||
settingSummary: input.summary,
|
||||
tone: input.tone,
|
||||
conflictCore: input.coreConflicts[0] ?? input.playerGoal,
|
||||
},
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
|
||||
const themeMode = detectCustomWorldThemeMode({
|
||||
settingText: input.settingText,
|
||||
summary: input.summary,
|
||||
tone: input.tone,
|
||||
playerGoal: input.playerGoal,
|
||||
templateWorldType: /仙|灵|宗门|秘境|裂界/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA,
|
||||
});
|
||||
|
||||
if (themeMode === 'machina') {
|
||||
return {
|
||||
schemaName: '机潮六轴',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '机锋', definition: '承受硬碰撞与机械压力的结构强度。', positiveSignals: ['硬度', '结构'], negativeSignals: ['脆裂', '松散'], combatUseText: '扛住正面撞击与重压。', socialUseText: '给人可靠、稳固、难被撼动的感觉。', explorationUseText: '在高压、坍塌与工业险境中撑住阵脚。' },
|
||||
{ slotId: 'axis_b', name: '步准', definition: '换位、校准、抢时机与精准位移的能力。', positiveSignals: ['校准', '位移'], negativeSignals: ['迟滞', '失准'], combatUseText: '快速转位、抢射界、控节奏。', socialUseText: '反应精确,不轻易露怯。', explorationUseText: '穿越机关、轨道与复杂装置。' },
|
||||
{ slotId: 'axis_c', name: '算识', definition: '解析结构、演算路径、识别规律的能力。', positiveSignals: ['演算', '拆解'], negativeSignals: ['误算', '看不懂'], combatUseText: '读懂装置与敌方机制的薄弱点。', socialUseText: '判断局势、识别话术与利益结构。', explorationUseText: '解密、修复、校准与规划路径。' },
|
||||
{ slotId: 'axis_d', name: '潮压', definition: '在高噪高压中强行推进局势的能力。', positiveSignals: ['推进', '压迫'], negativeSignals: ['退缩', '失控'], combatUseText: '顶着火力和混乱继续施压。', socialUseText: '在混乱场合里定调并逼出表态。', explorationUseText: '面对失控装置时敢于推进关键步骤。' },
|
||||
{ slotId: 'axis_e', name: '协频', definition: '与同伴、器械、网络或环境建立协同的能力。', positiveSignals: ['协同', '接驳'], negativeSignals: ['脱节', '孤立'], combatUseText: '与队友和装置形成联动收益。', socialUseText: '建立合作、交换与稳定配合。', explorationUseText: '接驳系统、调和多方资源与线索。' },
|
||||
{ slotId: 'axis_f', name: '续载', definition: '维持负载、稳定输出、长线运转的能力。', positiveSignals: ['稳载', '续航'], negativeSignals: ['过热', '断载'], combatUseText: '稳住循环、维持持续输出与可操作状态。', socialUseText: '显得沉着、持重、不轻易失衡。', explorationUseText: '在长时间高负荷环境里持续工作。' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
if (themeMode === 'tide') {
|
||||
return {
|
||||
schemaName: '潮境六脉',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '潮骨', definition: '扛住潮压与正面冲击的底子。', positiveSignals: ['承压', '稳'], negativeSignals: ['散', '弱'], combatUseText: '顶住正面浪涌与冲撞。', socialUseText: '给人能扛事的可靠感。', explorationUseText: '在风浪与湿重环境里稳住自己。' },
|
||||
{ slotId: 'axis_b', name: '浪步', definition: '顺潮借势、换位穿行的能力。', positiveSignals: ['借势', '轻快'], negativeSignals: ['笨拙', '慢'], combatUseText: '借势滑开、切线、拉开距离。', socialUseText: '谈吐灵活,懂得顺势而为。', explorationUseText: '穿越港口、水路、雾区与复杂地形。' },
|
||||
{ slotId: 'axis_c', name: '舟识', definition: '辨流向、识潮眼、看穿变化的能力。', positiveSignals: ['辨向', '识局'], negativeSignals: ['迷失', '误读'], combatUseText: '抓住潮势变化和敌人的失衡时机。', socialUseText: '看懂局势、试探真假与留白。', explorationUseText: '辨认水路、雾障、潮汐与遗留痕迹。' },
|
||||
{ slotId: 'axis_d', name: '潮魄', definition: '在剧烈变化中仍敢推进的胆气。', positiveSignals: ['胆气', '压前'], negativeSignals: ['畏缩', '犹疑'], combatUseText: '借高压局势硬推突破口。', socialUseText: '在谈判或冲突里顶住对方气势。', explorationUseText: '面对陌生水域与异变仍敢向前。' },
|
||||
{ slotId: 'axis_e', name: '契汐', definition: '与人、船、信物与约定形成牵引的能力。', positiveSignals: ['契合', '通人情'], negativeSignals: ['疏离', '难共鸣'], combatUseText: '借助协同与牵引打出连锁。', socialUseText: '善于结盟、安抚与做交换。', explorationUseText: '从航路、人情与旧约中打开局面。' },
|
||||
{ slotId: 'axis_f', name: '回澜', definition: '在漫长消耗中回稳状态、续住节奏的能力。', positiveSignals: ['回稳', '续航'], negativeSignals: ['紊乱', '断流'], combatUseText: '久战不乱,能把节奏重新拉回手里。', socialUseText: '遇事沉静,不易失态。', explorationUseText: '在漫长远行与恶劣天气里保有余力。' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
if (themeMode === 'rift') {
|
||||
return {
|
||||
schemaName: '裂界六轴',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '界躯', definition: '承受裂界冲击与异压侵蚀的底子。', positiveSignals: ['承载', '抗压'], negativeSignals: ['脆弱', '崩裂'], combatUseText: '扛住高强度裂界冲击。', socialUseText: '让人感到能镇住危险局面。', explorationUseText: '在异压、失衡环境下维持完整。' },
|
||||
{ slotId: 'axis_b', name: '裂步', definition: '穿梭边界、抢位、转场的能力。', positiveSignals: ['转场', '抢位'], negativeSignals: ['迟滞', '卡顿'], combatUseText: '借裂隙切位、抢身位与节奏。', socialUseText: '对局势变化响应很快。', explorationUseText: '穿越裂缝、断层与高危通路。' },
|
||||
{ slotId: 'axis_c', name: '界识', definition: '识别边界规律、虚实与因果的能力。', positiveSignals: ['辨识', '推断'], negativeSignals: ['错判', '看不清'], combatUseText: '洞察异界规律和对手的真空点。', socialUseText: '看破隐藏立场与不完整真话。', explorationUseText: '解读旧迹、裂痕和禁域法则。' },
|
||||
{ slotId: 'axis_d', name: '界压', definition: '在失衡局势中强行立住意志与推进力。', positiveSignals: ['压上去', '定调'], negativeSignals: ['动摇', '失措'], combatUseText: '顶住异变推进攻势。', socialUseText: '在高压博弈中逼出答案。', explorationUseText: '面对危险异象仍敢推开下一层。' },
|
||||
{ slotId: 'axis_e', name: '缚契', definition: '与他者、异物、誓约建立束缚或联结的能力。', positiveSignals: ['联结', '束约'], negativeSignals: ['排斥', '难联动'], combatUseText: '借共鸣与束缚形成协同或压制。', socialUseText: '建立合作、誓约与安抚关系。', explorationUseText: '唤醒遗物、安抚异种、触发响应。' },
|
||||
{ slotId: 'axis_f', name: '回脉', definition: '在紊乱环境中把自身重新拉回稳态的能力。', positiveSignals: ['回稳', '续住'], negativeSignals: ['失衡', '崩坏'], combatUseText: '抗住异压后迅速回到可战状态。', socialUseText: '情绪与气势都更稳。', explorationUseText: '在裂界侵蚀与长线压力里保持在线。' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
schemaName: input.worldType === WorldType.XIANXIA ? '灵界六轴' : '江湖六脉',
|
||||
slots: getPresetWorldAttributeSchema(
|
||||
/仙|灵|宗门|秘境|裂界/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA,
|
||||
).slots,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInput) {
|
||||
if (input.worldType === WorldType.WUXIA) {
|
||||
return getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
}
|
||||
|
||||
if (input.worldType === WorldType.XIANXIA) {
|
||||
return getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
||||
}
|
||||
|
||||
const generated = buildCustomThemeSlots(input);
|
||||
const schema = buildSchema(input, generated.schemaName, generated.slots);
|
||||
const issues = validateWorldAttributeSchema(schema);
|
||||
|
||||
if (issues.length > 0) {
|
||||
const fallbackWorldType = /仙|灵|宗门|秘境|裂界/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA;
|
||||
return {
|
||||
...getPresetWorldAttributeSchema(fallbackWorldType),
|
||||
id: `schema:custom-fallback:${input.worldName}`,
|
||||
worldId: `custom:${input.worldName}`,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: input.worldName,
|
||||
settingSummary: input.summary,
|
||||
tone: input.tone,
|
||||
conflictCore: input.coreConflicts[0] ?? input.playerGoal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
333
src/services/characterChatPrompt.ts
Normal file
333
src/services/characterChatPrompt.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
buildSchemaSummary,
|
||||
describeTopAttributes,
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
buildCharacterBackstoryPromptContext,
|
||||
getCharacterPublicBackstorySummary,
|
||||
getLockedCharacterBackstoryChapters,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
FacingDirection,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildCustomWorldReferenceText } from './customWorld';
|
||||
import { buildStoryPromptHistory } from './storyHistory';
|
||||
|
||||
export interface CharacterChatTargetStatus {
|
||||
roleLabel?: string | null;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
}
|
||||
|
||||
export interface CharacterChatPromptContext {
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
inBattle: boolean;
|
||||
playerFacing: FacingDirection;
|
||||
playerAnimation: AnimationState;
|
||||
sceneName?: string | null;
|
||||
sceneDescription?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
|
||||
只回复这名角色此刻会对玩家说的话。
|
||||
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
|
||||
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
|
||||
只输出纯文本,共 3 行,每行一条。
|
||||
不要加编号、项目符号、Markdown 或额外说明。
|
||||
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
|
||||
只输出一段简洁文字。
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
function describeWorld(world: WorldType) {
|
||||
if (world === WorldType.WUXIA) return '武侠';
|
||||
if (world === WorldType.XIANXIA) return '仙侠';
|
||||
return '自定义世界';
|
||||
}
|
||||
|
||||
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
|
||||
return customWorldProfile
|
||||
? `自定义世界参考:\n${buildCustomWorldReferenceText(customWorldProfile)}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function describeGender(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function describeFacing(facing: FacingDirection) {
|
||||
return facing === 'left' ? '左' : '右';
|
||||
}
|
||||
|
||||
function describeHpBand(ratio: number) {
|
||||
if (ratio >= 0.95) return '几乎无伤';
|
||||
if (ratio >= 0.75) return '状态稳健';
|
||||
if (ratio >= 0.55) return '略有消耗';
|
||||
if (ratio >= 0.35) return '伤势明显';
|
||||
if (ratio >= 0.15) return '伤势沉重';
|
||||
return '濒临极限';
|
||||
}
|
||||
|
||||
function describeManaBand(ratio: number) {
|
||||
if (ratio >= 0.9) return '充盈';
|
||||
if (ratio >= 0.7) return '稳定';
|
||||
if (ratio >= 0.45) return '尚可';
|
||||
if (ratio >= 0.2) return '偏低';
|
||||
if (ratio > 0) return '接近枯竭';
|
||||
return '耗尽';
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: StoryMoment[]) {
|
||||
const promptHistory = buildStoryPromptHistory(history);
|
||||
|
||||
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
promptHistory.previousSummary
|
||||
? `更早剧情摘要:\n${promptHistory.previousSummary}`
|
||||
: '更早剧情摘要:暂无。',
|
||||
promptHistory.recentOriginalRounds.length > 0
|
||||
? `最近 3 轮剧情:\n${promptHistory.recentOriginalRounds
|
||||
.map((item, index) => `- 第 ${index + 1} 轮:\n${item}`)
|
||||
.join('\n')}`
|
||||
: '最近 3 轮剧情:暂无。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeBackstoryContext(label: string, snippets: string[]) {
|
||||
const normalized = snippets
|
||||
.map(snippet => snippet.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [`${label}:暂无公开信息。`];
|
||||
}
|
||||
|
||||
return normalized.map((snippet, index) =>
|
||||
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`,
|
||||
);
|
||||
}
|
||||
|
||||
function describeCharacterInfo(
|
||||
label: string,
|
||||
character: Character,
|
||||
world: WorldType,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
options: {
|
||||
affinity?: number | null;
|
||||
includeUnlockProgress?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(character, world, customWorldProfile);
|
||||
const skills = character.skills.length > 0
|
||||
? character.skills
|
||||
.map(
|
||||
skill => `${skill.name}(伤害 ${skill.damage}/灵力 ${skill.manaCost}/冷却 ${skill.cooldownTurns})`,
|
||||
)
|
||||
.join(' | ')
|
||||
: '无';
|
||||
const backgroundLines = options.affinity == null
|
||||
? [getCharacterPublicBackstorySummary(character, world)]
|
||||
: buildCharacterBackstoryPromptContext(character, options.affinity, world);
|
||||
const nextLockedChapter = options.includeUnlockProgress && options.affinity != null
|
||||
? getLockedCharacterBackstoryChapters(character, options.affinity, world)[0] ?? null
|
||||
: null;
|
||||
const schemaSummary = buildSchemaSummary(schema)
|
||||
.map(slot => `${slot.name}(${slot.definition})`)
|
||||
.join(' | ');
|
||||
const topAttributes = describeTopAttributes(attributeProfile, schema).join('、') || '无';
|
||||
const attributeDetails = formatAttributeList(attributeProfile, schema)
|
||||
.map(entry => `${entry.slot.name} ${entry.value}`)
|
||||
.join(' | ');
|
||||
|
||||
return [
|
||||
`${label}姓名:${character.name}`,
|
||||
`${label}称号:${character.title}`,
|
||||
`${label}性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
`${label}描述:${character.description}`,
|
||||
...describeBackstoryContext(`${label}背景`, backgroundLines),
|
||||
nextLockedChapter
|
||||
? `${label}未解锁背景:${nextLockedChapter.title}(需好感 ${nextLockedChapter.affinityRequired},当前只知道:${nextLockedChapter.teaser})`
|
||||
: null,
|
||||
`${label}性格:${character.personality}`,
|
||||
`${label}世界属性框架:${schemaSummary}`,
|
||||
`${label}主要属性:${topAttributes}`,
|
||||
`${label}属性详情:${attributeDetails}`,
|
||||
`${label}技能:${skills}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeChatContext(world: WorldType, context: CharacterChatPromptContext) {
|
||||
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
|
||||
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
`玩家战斗状态:${context.inBattle ? '战斗中' : '非战斗'}`,
|
||||
`场景:${context.sceneName ?? '当前区域'}`,
|
||||
`场景描述:${context.sceneDescription ?? '周围气氛仍未安定。'}`,
|
||||
`玩家状态:生命 ${context.playerHp}/${context.playerMaxHp}(${describeHpBand(hpRatio)}),灵力 ${context.playerMana}/${context.playerMaxMana}(${describeManaBand(manaRatio)}),朝向 ${describeFacing(context.playerFacing)},动作 ${context.playerAnimation}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeTargetStatus(status: CharacterChatTargetStatus) {
|
||||
const hpRatio = status.hp / Math.max(status.maxHp, 1);
|
||||
const manaRatio = status.mana / Math.max(status.maxMana, 1);
|
||||
|
||||
return [
|
||||
`对方身份:${status.roleLabel ?? '同行角色'}`,
|
||||
`对方状态:生命 ${status.hp}/${status.maxHp}(${describeHpBand(hpRatio)}),灵力 ${status.mana}/${status.maxMana}(${describeManaBand(manaRatio)})`,
|
||||
status.affinity != null ? `当前好感:${status.affinity}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
if (history.length === 0) {
|
||||
return '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'聊天记录:',
|
||||
...history.slice(-12).map(turn => `- ${turn.speaker === 'player' ? '玩家' : '角色'}:${turn.text}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
playerMessage: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
`玩家刚刚对 ${targetCharacter.name} 说:${playerMessage}`,
|
||||
`现在请以 ${targetCharacter.name} 的身份,直接回复玩家。`,
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
const latestCharacterReply = [...conversationHistory]
|
||||
.reverse()
|
||||
.find(turn => turn.speaker === 'character')?.text ?? null;
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
latestCharacterReply
|
||||
? `角色刚刚的回复:${latestCharacterReply}`
|
||||
: `玩家正准备与 ${targetCharacter.name} 开始一段新的私聊。`,
|
||||
'生成 3 条可以直接发送的简短玩家回复候选。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
previousSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
previousSummary ? `旧摘要:${previousSummary}` : '旧摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
'请把旧摘要与最新聊天合并成一段更新后的关系摘要,供后续剧情推理使用。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
501
src/services/customWorld.ts
Normal file
501
src/services/customWorld.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { coerceWorldAttributeSchema } from '../data/attributeValidation';
|
||||
import {
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
ItemRarity,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { generateWorldAttributeSchema } from './attributeSchemaGenerator';
|
||||
|
||||
const CUSTOM_WORLD_RARITIES: ItemRarity[] = [
|
||||
'common',
|
||||
'uncommon',
|
||||
'rare',
|
||||
'epic',
|
||||
'legendary',
|
||||
];
|
||||
|
||||
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
|
||||
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
|
||||
export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10;
|
||||
|
||||
export const CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT = `你正在为像素动作 RPG 设计一份自定义世界档案。
|
||||
只返回一个 JSON 对象,不要返回 Markdown、代码块或额外解释。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"name": "世界名称",
|
||||
"subtitle": "世界副标题",
|
||||
"summary": "世界概述",
|
||||
"tone": "世界基调",
|
||||
"playerGoal": "玩家核心目标",
|
||||
"templateWorldType": "WUXIA 或 XIANXIA",
|
||||
"majorFactions": ["势力甲", "势力乙"],
|
||||
"coreConflicts": ["冲突甲", "冲突乙"],
|
||||
"playableNpcs": [
|
||||
{
|
||||
"name": "角色名称",
|
||||
"title": "称号",
|
||||
"description": "简短描述",
|
||||
"backstory": "背景经历",
|
||||
"personality": "性格特点",
|
||||
"combatStyle": "战斗风格",
|
||||
"tags": ["标签1", "标签2"]
|
||||
}
|
||||
],
|
||||
"storyNpcs": [
|
||||
{
|
||||
"name": "场景角色名称",
|
||||
"role": "身份",
|
||||
"description": "简短描述",
|
||||
"motivation": "动机",
|
||||
"relationshipHooks": ["关系切入口1", "关系切入口2"]
|
||||
}
|
||||
],
|
||||
"landmarks": [
|
||||
{
|
||||
"name": "场景名称",
|
||||
"description": "场景描述",
|
||||
"dangerLevel": "low|medium|high|extreme"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
硬性要求:
|
||||
- 所有生成文本都必须使用中文。
|
||||
- 每个场景角色和地标都必须直接源自玩家设定。
|
||||
- 不要用无关的武侠/仙侠预设素材来凑数。
|
||||
- 必须生成恰好 5 个 playableNpcs。
|
||||
- 必须生成足够多的 storyNpcs,使唯一角色总数至少达到 30。
|
||||
- 至少生成 10 个 landmarks。
|
||||
- 不要生成 items 字段。
|
||||
- 名称必须具体且有辨识度,不要使用 角色1、场景1 之类的占位名。
|
||||
- 名册中要覆盖多种社会身份,不能只有战斗角色。
|
||||
- 地标必须像真实可游玩的场景,能够承载探索、战斗、旅行和剧情推进。
|
||||
- 不要引用现实品牌、受版权保护的 IP 或知名既有人物。`;
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? (value.filter((item) => item && typeof item === 'object') as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeTags(value: unknown, fallbackTags: string[] = []) {
|
||||
const tags = Array.isArray(value)
|
||||
? value.map((item) => toText(item)).filter(Boolean)
|
||||
: [];
|
||||
return [
|
||||
...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)),
|
||||
].slice(0, 5);
|
||||
}
|
||||
|
||||
function normalizeWorldType(value: unknown, sourceText: string) {
|
||||
const worldType = toText(value).toUpperCase();
|
||||
if (worldType === WorldType.WUXIA || worldType === WorldType.XIANXIA) {
|
||||
return worldType;
|
||||
}
|
||||
return inferWorldTypeFromSetting(sourceText);
|
||||
}
|
||||
|
||||
function normalizeRarity(
|
||||
value: unknown,
|
||||
fallback: ItemRarity = 'rare',
|
||||
): ItemRarity {
|
||||
const rarity = toText(value).toLowerCase() as ItemRarity;
|
||||
return CUSTOM_WORLD_RARITIES.includes(rarity) ? rarity : fallback;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const ascii = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
if (ascii) {
|
||||
return ascii.slice(0, 24);
|
||||
}
|
||||
|
||||
return 'entry';
|
||||
}
|
||||
|
||||
function createEntryId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||||
}
|
||||
|
||||
function inferWorldTypeFromSetting(settingText: string) {
|
||||
if (/[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText)) {
|
||||
return WorldType.XIANXIA;
|
||||
}
|
||||
return WorldType.WUXIA;
|
||||
}
|
||||
|
||||
function buildSeedPhrase(settingText: string, fallback: string) {
|
||||
const compact = settingText.replace(/\s+/g, '').trim();
|
||||
return compact ? compact.slice(0, 10) : fallback;
|
||||
}
|
||||
|
||||
function buildWorldName(settingText: string, worldType: WorldType) {
|
||||
const seed = buildSeedPhrase(
|
||||
settingText,
|
||||
worldType === WorldType.XIANXIA ? '灵潮' : '江湖',
|
||||
);
|
||||
const suffix = worldType === WorldType.XIANXIA ? '界' : '录';
|
||||
return `${seed}${suffix}`;
|
||||
}
|
||||
|
||||
function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
|
||||
const templateWorldType = inferWorldTypeFromSetting(settingText);
|
||||
const name = buildWorldName(settingText, templateWorldType);
|
||||
const subtitle =
|
||||
templateWorldType === WorldType.XIANXIA ? '灵潮未定' : '风云将起';
|
||||
const summary = settingText.trim()
|
||||
? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。`
|
||||
: templateWorldType === WorldType.XIANXIA
|
||||
? '灵潮未定,旧秩序正在崩裂。'
|
||||
: '旧案复起,江湖格局正在改变。';
|
||||
const tone =
|
||||
templateWorldType === WorldType.XIANXIA
|
||||
? '空灵、危险、层层递进'
|
||||
: '紧张、克制、暗流涌动';
|
||||
const playerGoal =
|
||||
templateWorldType === WorldType.XIANXIA
|
||||
? '查清异变源头,在诸方势力之前抢到关键线索'
|
||||
: '沿着旧案痕迹追查幕后之人,并守住仍值得相信的人与路';
|
||||
|
||||
return {
|
||||
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
settingText: settingText.trim(),
|
||||
name,
|
||||
subtitle,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
attributeSchema: generateWorldAttributeSchema({
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: name,
|
||||
settingText: settingText.trim(),
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary],
|
||||
}),
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFallbackCustomWorldProfile(
|
||||
settingText: string,
|
||||
): CustomWorldProfile {
|
||||
return buildBaseCustomWorldProfile(settingText);
|
||||
}
|
||||
|
||||
function normalizePlayableNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
return {
|
||||
id: createEntryId('playable-npc', name, index),
|
||||
name,
|
||||
title: toText(item.title),
|
||||
description: toText(item.description),
|
||||
backstory: toText(item.backstory),
|
||||
personality: toText(item.personality),
|
||||
combatStyle: toText(item.combatStyle),
|
||||
tags: normalizeTags(item.tags),
|
||||
} satisfies CustomWorldPlayableNpc;
|
||||
})
|
||||
.filter((entry) => entry.name)
|
||||
.slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT);
|
||||
}
|
||||
|
||||
function normalizeStoryNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
return {
|
||||
id: createEntryId('story-npc', name, index),
|
||||
name,
|
||||
role: toText(item.role),
|
||||
description: toText(item.description),
|
||||
motivation: toText(item.motivation),
|
||||
relationshipHooks: normalizeTags(item.relationshipHooks),
|
||||
} satisfies CustomWorldNpc;
|
||||
})
|
||||
.filter((entry) => entry.name);
|
||||
}
|
||||
|
||||
function normalizeItemList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
const category = toText(item.category);
|
||||
return {
|
||||
id: createEntryId('item', name, index),
|
||||
name,
|
||||
category,
|
||||
rarity: normalizeRarity(item.rarity, 'rare'),
|
||||
description: toText(item.description),
|
||||
tags: normalizeTags(item.tags),
|
||||
} satisfies CustomWorldItem;
|
||||
})
|
||||
.filter((entry) => entry.name && entry.category);
|
||||
}
|
||||
|
||||
function normalizeLandmarkList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
return {
|
||||
id: createEntryId('landmark', name, index),
|
||||
name,
|
||||
description: toText(item.description),
|
||||
dangerLevel: toText(item.dangerLevel),
|
||||
} satisfies CustomWorldLandmark;
|
||||
})
|
||||
.filter((entry) => entry.name);
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldProfile(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
): CustomWorldProfile {
|
||||
const fallback = buildBaseCustomWorldProfile(settingText);
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const item = raw as Record<string, unknown>;
|
||||
const worldSignalText = [
|
||||
settingText,
|
||||
toText(item.subtitle),
|
||||
toText(item.summary),
|
||||
toText(item.tone),
|
||||
toText(item.playerGoal),
|
||||
].join(' ');
|
||||
const templateWorldType = normalizeWorldType(
|
||||
item.templateWorldType,
|
||||
worldSignalText,
|
||||
);
|
||||
const name =
|
||||
toText(item.name) || buildWorldName(settingText, templateWorldType);
|
||||
const summary = toText(item.summary) || fallback.summary;
|
||||
const tone = toText(item.tone) || fallback.tone;
|
||||
const playerGoal = toText(item.playerGoal) || fallback.playerGoal;
|
||||
const generatedAttributeSchema = generateWorldAttributeSchema({
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: name,
|
||||
settingText: settingText.trim(),
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
majorFactions: normalizeTags(item.majorFactions, []),
|
||||
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
settingText: settingText.trim(),
|
||||
name,
|
||||
subtitle: toText(item.subtitle) || fallback.subtitle,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
attributeSchema: coerceWorldAttributeSchema(
|
||||
item.attributeSchema,
|
||||
generatedAttributeSchema,
|
||||
),
|
||||
playableNpcs: normalizePlayableNpcList(item.playableNpcs),
|
||||
storyNpcs: normalizeStoryNpcList(item.storyNpcs),
|
||||
items: normalizeItemList(item.items),
|
||||
landmarks: normalizeLandmarkList(item.landmarks),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomWorldGenerationPrompt(settingText: string) {
|
||||
return [
|
||||
'请根据下面的玩家设定创建一份自定义世界档案。',
|
||||
'玩家设定:',
|
||||
settingText.trim(),
|
||||
'',
|
||||
'要求:',
|
||||
'- 所有生成文本都必须使用中文。',
|
||||
'- 不要复用预设流派模板来凑数。',
|
||||
'- 必须生成恰好 5 个 playableNpcs。',
|
||||
'- 必须生成足够多的 storyNpcs,使唯一角色总数至少达到 30。',
|
||||
'- 至少生成 10 个真正可游玩的 landmarks。',
|
||||
'- 不要生成任何 items,也不要包含 items 字段。',
|
||||
'- 每个场景角色和地标都必须直接源自玩家设定。',
|
||||
'- 要覆盖多种社会身份,不能只有战斗角色。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldReferenceText(profile: CustomWorldProfile) {
|
||||
const playableNpcText = profile.playableNpcs
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(npc) =>
|
||||
`- ${npc.name} / ${npc.title}:${npc.description};背景:${npc.backstory};风格:${npc.combatStyle}`,
|
||||
)
|
||||
.join('\n');
|
||||
const storyNpcText = profile.storyNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc) =>
|
||||
`- ${npc.name} / ${npc.role}:${npc.description};动机:${npc.motivation}`,
|
||||
)
|
||||
.join('\n');
|
||||
const landmarkText = profile.landmarks
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(landmark) =>
|
||||
`- ${landmark.name}:${landmark.description};危险度:${landmark.dangerLevel}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
`自定义世界:${profile.name}`,
|
||||
`副标题:${profile.subtitle}`,
|
||||
`玩家原始设定:${profile.settingText}`,
|
||||
`世界概述:${profile.summary}`,
|
||||
`世界基调:${profile.tone}`,
|
||||
`玩家核心目标:${profile.playerGoal}`,
|
||||
`世界属性轴:${profile.attributeSchema.slots.map((slot) => `${slot.name}:${slot.definition}`).join(';')}`,
|
||||
`可扮演角色档案:\n${playableNpcText || '- 暂无'}`,
|
||||
`世界场景角色档案:\n${storyNpcText || '- 暂无'}`,
|
||||
`关键场景档案:\n${landmarkText || '- 暂无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function countUniqueNames(items: Array<{ name: string }>) {
|
||||
return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size;
|
||||
}
|
||||
|
||||
export function validateGeneratedCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const playableCount = countUniqueNames(profile.playableNpcs);
|
||||
const storyCount = countUniqueNames(profile.storyNpcs);
|
||||
const landmarkCount = countUniqueNames(profile.landmarks);
|
||||
const totalNpcCount = countUniqueNames([
|
||||
...profile.playableNpcs,
|
||||
...profile.storyNpcs,
|
||||
]);
|
||||
|
||||
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (totalNpcCount < MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT} 名唯一角色,当前仅返回 ${totalNpcCount} 名。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
storyCount <
|
||||
Math.max(
|
||||
0,
|
||||
MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
)
|
||||
) {
|
||||
throw new Error('自定义世界生成返回的非可扮演场景角色数量不足。');
|
||||
}
|
||||
|
||||
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function clampSceneImageText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function describeDangerLevel(dangerLevel: string) {
|
||||
const normalized = dangerLevel.trim().toLowerCase();
|
||||
if (normalized === 'low' || normalized === '低')
|
||||
return '气氛相对平静,但暗藏细节张力';
|
||||
if (normalized === 'medium' || normalized === '中')
|
||||
return '带有明确的探索压力与潜在威胁';
|
||||
if (normalized === 'high' || normalized === '高')
|
||||
return '危险感强烈,空间中有明显压迫感';
|
||||
if (normalized === 'extreme' || normalized === '极高')
|
||||
return '极端危险,环境本身就像会吞没闯入者';
|
||||
return dangerLevel.trim()
|
||||
? `危险氛围:${dangerLevel.trim()}`
|
||||
: '危险气质保持克制但不可忽视';
|
||||
}
|
||||
|
||||
export const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT = [
|
||||
'文字',
|
||||
'水印',
|
||||
'logo',
|
||||
'UI界面',
|
||||
'对话框',
|
||||
'边框',
|
||||
'人物近景特写',
|
||||
'多人合照',
|
||||
'模糊',
|
||||
'低清晰度',
|
||||
'畸形建筑',
|
||||
'现代车辆',
|
||||
'监控摄像头',
|
||||
].join(',');
|
||||
|
||||
export function buildCustomWorldSceneImagePrompt(
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText'
|
||||
>,
|
||||
landmark: Pick<CustomWorldLandmark, 'name' | 'description' | 'dangerLevel'>,
|
||||
) {
|
||||
const worldName = clampSceneImageText(profile.name, 18) || '未命名世界';
|
||||
const worldSubtitle = clampSceneImageText(profile.subtitle, 18);
|
||||
const worldTone = clampSceneImageText(profile.tone, 48);
|
||||
const worldGoal = clampSceneImageText(profile.playerGoal, 48);
|
||||
const worldSummary = clampSceneImageText(profile.summary, 72);
|
||||
const worldSetting = clampSceneImageText(profile.settingText, 72);
|
||||
const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景';
|
||||
const landmarkDescription = clampSceneImageText(landmark.description, 96);
|
||||
const dangerMood = describeDangerLevel(landmark.dangerLevel);
|
||||
|
||||
return [
|
||||
'横版幻想 RPG 场景背景概念图,适合作为 2D 游戏战斗与探索背景,环境主体清晰,空间层次明确,电影感光影,细节丰富。',
|
||||
`世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`,
|
||||
worldSetting ? `玩家设定:${worldSetting}。` : '',
|
||||
worldSummary ? `世界概述:${worldSummary}。` : '',
|
||||
worldTone ? `整体基调:${worldTone}。` : '',
|
||||
worldGoal ? `玩家目标关联:${worldGoal}。` : '',
|
||||
`场景名称:${landmarkName}。`,
|
||||
landmarkDescription ? `场景描述:${landmarkDescription}。` : '',
|
||||
`${dangerMood}。`,
|
||||
'不要出现 UI、字幕、文字、水印或 logo,人物仅可作为很小的远景剪影,画面重点放在建筑、地貌、光线与氛围。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
146
src/services/customWorldBuilder.ts
Normal file
146
src/services/customWorldBuilder.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
buildCustomWorldPlayableNpcAttributeProfile,
|
||||
buildCustomWorldStoryNpcAttributeProfile,
|
||||
buildItemAttributeResonance,
|
||||
} from '../data/attributeProfileGenerator';
|
||||
import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags';
|
||||
import { CustomWorldProfile, WorldType } from '../types';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
|
||||
const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
|
||||
'sword-princess',
|
||||
'archer-hero',
|
||||
'girl-hero',
|
||||
'punch-hero',
|
||||
'fighter-4',
|
||||
] as const;
|
||||
|
||||
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
|
||||
const item = items[index % items.length];
|
||||
if (item === undefined) {
|
||||
throw new Error(`Missing ${label}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function getPlayableTemplateCharacterId(index: number) {
|
||||
return pickCyclic(
|
||||
PLAYABLE_TEMPLATE_CHARACTER_IDS,
|
||||
index,
|
||||
'playable template character id',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string[], fallbackTags: string[] = []) {
|
||||
return [
|
||||
...new Set(
|
||||
[...tags, ...fallbackTags].map((tag) => tag.trim()).filter(Boolean),
|
||||
),
|
||||
].slice(0, 5);
|
||||
}
|
||||
|
||||
function normalizeHooks(hooks: string[]) {
|
||||
const normalized = [
|
||||
...new Set(hooks.map((hook) => hook.trim()).filter(Boolean)),
|
||||
];
|
||||
if (normalized.length > 0) {
|
||||
return normalized.slice(0, 3);
|
||||
}
|
||||
return ['掌握关键线索'];
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const ascii = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
if (ascii) {
|
||||
return ascii.slice(0, 24);
|
||||
}
|
||||
|
||||
return 'entry';
|
||||
}
|
||||
|
||||
function createEntryId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||||
}
|
||||
|
||||
function dedupeByName<T extends { name: string }>(items: T[]) {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
const key = item.name.trim();
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export interface CustomWorldBuilderOptions {}
|
||||
|
||||
export function buildExpandedCustomWorldProfile(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
_options: CustomWorldBuilderOptions = {},
|
||||
): CustomWorldProfile {
|
||||
const profile = normalizeCustomWorldProfile(raw, settingText);
|
||||
const attributeSchema = profile.attributeSchema;
|
||||
|
||||
return {
|
||||
...profile,
|
||||
playableNpcs: dedupeByName(profile.playableNpcs)
|
||||
.slice(0, PLAYABLE_TEMPLATE_CHARACTER_IDS.length)
|
||||
.map((npc, index) => {
|
||||
const templateCharacterId =
|
||||
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
|
||||
return {
|
||||
...npc,
|
||||
id: createEntryId('playable-npc', npc.name, index),
|
||||
templateCharacterId,
|
||||
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
|
||||
templateCharacterId,
|
||||
maxCount: 5,
|
||||
}),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldPlayableNpcAttributeProfile(npc, attributeSchema),
|
||||
};
|
||||
}),
|
||||
storyNpcs: dedupeByName(profile.storyNpcs).map((npc, index) => ({
|
||||
...npc,
|
||||
id: createEntryId('story-npc', npc.name, index),
|
||||
description: clampText(npc.description, 72),
|
||||
motivation: clampText(npc.motivation, 72),
|
||||
relationshipHooks: normalizeHooks(npc.relationshipHooks),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldStoryNpcAttributeProfile(npc, attributeSchema),
|
||||
})),
|
||||
items: dedupeByName(profile.items).map((item, index) => ({
|
||||
...item,
|
||||
id: createEntryId('item', item.name, index),
|
||||
description: clampText(item.description, 72),
|
||||
tags: normalizeTags(item.tags),
|
||||
attributeResonance:
|
||||
item.attributeResonance ?? buildItemAttributeResonance(item),
|
||||
})),
|
||||
landmarks: dedupeByName(profile.landmarks).map((landmark, index) => ({
|
||||
...landmark,
|
||||
id: createEntryId('landmark', landmark.name, index),
|
||||
description: clampText(landmark.description, 96),
|
||||
dangerLevel:
|
||||
landmark.dangerLevel ||
|
||||
(profile.templateWorldType === WorldType.XIANXIA ? 'high' : 'medium'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
54
src/services/customWorldPresentation.stub.ts
Normal file
54
src/services/customWorldPresentation.stub.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { WorldType } from '../types';
|
||||
|
||||
const ATTRIBUTE_LABELS = {
|
||||
strength: 'Strength',
|
||||
agility: 'Agility',
|
||||
intelligence: 'Intelligence',
|
||||
spirit: 'Spirit',
|
||||
} as const;
|
||||
|
||||
const RESOURCE_LABELS = {
|
||||
hp: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: 'Max HP',
|
||||
maxMp: 'Max MP',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: 'Mana Cost',
|
||||
} as const;
|
||||
|
||||
export function buildThemedSkillName(_profile: unknown, style: string, index = 0) {
|
||||
return `${style || 'skill'}-${index + 1}`;
|
||||
}
|
||||
|
||||
export function buildCustomCampSceneName(profile: { name?: string } | null | undefined) {
|
||||
return profile?.name ? `${profile.name} Camp` : 'Camp';
|
||||
}
|
||||
|
||||
export function getAttributeLabelsForWorld(_worldType: WorldType | null) {
|
||||
return ATTRIBUTE_LABELS;
|
||||
}
|
||||
|
||||
export function getResourceLabelsForWorld(_worldType: WorldType | null) {
|
||||
return RESOURCE_LABELS;
|
||||
}
|
||||
|
||||
export function buildThemedItemName(_profile: unknown, category: string, rarity: string, seedKey: string) {
|
||||
return `${category}-${rarity}-${seedKey}`;
|
||||
}
|
||||
|
||||
export function buildThemedItemDescription(_profile: unknown, category: string, rarity: string, seedKey: string) {
|
||||
return `${category}-${rarity}-${seedKey} description`;
|
||||
}
|
||||
|
||||
export function inferCustomItemMechanics() {
|
||||
return {
|
||||
tags: [],
|
||||
equipmentSlotId: null,
|
||||
statProfile: null,
|
||||
useProfile: null,
|
||||
value: 0,
|
||||
};
|
||||
}
|
||||
497
src/services/customWorldPresentation.ts
Normal file
497
src/services/customWorldPresentation.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
import { getRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { ITEM_CATEGORY_OPTIONS } from '../data/itemCatalog';
|
||||
import {
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
EquipmentSlotId,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
ItemUseProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
type CustomWorldThemeMode,
|
||||
detectCustomWorldThemeMode,
|
||||
} from './customWorldTheme';
|
||||
|
||||
type ThemeMode = CustomWorldThemeMode;
|
||||
type AttributeLabelMap = Record<keyof Character['attributes'], string>;
|
||||
|
||||
const [
|
||||
CATEGORY_WEAPON,
|
||||
CATEGORY_ARMOR,
|
||||
CATEGORY_RELIC,
|
||||
CATEGORY_CONSUMABLE,
|
||||
CATEGORY_MATERIAL,
|
||||
CATEGORY_RARE,
|
||||
CATEGORY_EXCLUSIVE,
|
||||
] = ITEM_CATEGORY_OPTIONS;
|
||||
|
||||
type WorldPresentation = {
|
||||
mode: ThemeMode;
|
||||
attributeLabels: AttributeLabelMap;
|
||||
hpLabel: string;
|
||||
mpLabel: string;
|
||||
maxHpLabel: string;
|
||||
maxMpLabel: string;
|
||||
damageLabel: string;
|
||||
guardLabel: string;
|
||||
rangeLabel: string;
|
||||
cooldownLabel: string;
|
||||
manaCostLabel: string;
|
||||
campSuffix: string;
|
||||
itemPrefixes: string[];
|
||||
itemInfixes: string[];
|
||||
skillPrefixes: string[];
|
||||
skillSuffixByStyle: Record<CharacterSkillDefinition['style'], string[]>;
|
||||
};
|
||||
|
||||
const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
|
||||
martial: {
|
||||
mode: 'martial',
|
||||
attributeLabels: { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' },
|
||||
hpLabel: '气血',
|
||||
mpLabel: '内力',
|
||||
maxHpLabel: '气血上限',
|
||||
maxMpLabel: '内力上限',
|
||||
damageLabel: '招式',
|
||||
guardLabel: '防御',
|
||||
rangeLabel: '招距',
|
||||
cooldownLabel: '调息',
|
||||
manaCostLabel: '内力消耗',
|
||||
campSuffix: '行侠客栈',
|
||||
itemPrefixes: ['风雨', '青锋', '断桥', '冷铁', '旧案', '残影'],
|
||||
itemInfixes: ['刃','锋','魂','诀','式','影'],
|
||||
skillPrefixes: ['破','斩','击','御','飞','隐'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['杀','灭','破','击'],
|
||||
steady: ['守','御','护','镇'],
|
||||
mobility: ['闪','移','跃','遁'],
|
||||
finisher: ['决','断','灭','终'],
|
||||
projectile: ['飞','射','投','掷'],
|
||||
},
|
||||
},
|
||||
arcane: {
|
||||
mode: 'arcane',
|
||||
attributeLabels: { strength: '道骨', agility: '身法', intelligence: '神识', spirit: '灵韵' },
|
||||
hpLabel: '元命',
|
||||
mpLabel: '灵韵',
|
||||
maxHpLabel: '元命上限',
|
||||
maxMpLabel: '灵韵上限',
|
||||
damageLabel: '术法',
|
||||
guardLabel: '护盾',
|
||||
rangeLabel: '术距',
|
||||
cooldownLabel: '回息',
|
||||
manaCostLabel: '灵韵消耗',
|
||||
campSuffix: '宗门行馆',
|
||||
itemPrefixes: ['灵韵', '道纹', '云篆', '星芒', '界辉', '道痕'],
|
||||
itemInfixes: ['灵','道','法','术','诀','印'],
|
||||
skillPrefixes: ['灵','道','法','界','星','印'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['破','灭','毁','绝'],
|
||||
steady: ['守','御','护','镇'],
|
||||
mobility: ['闪','移','跃','遁'],
|
||||
finisher: ['决','断','灭','终'],
|
||||
projectile: ['飞','射','投','掷'],
|
||||
},
|
||||
},
|
||||
machina: {
|
||||
mode: 'machina',
|
||||
attributeLabels: { strength: 'Power', agility: 'Dexterity', intelligence: 'Logic', spirit: 'Core' },
|
||||
hpLabel: 'HP',
|
||||
mpLabel: 'Energy',
|
||||
maxHpLabel: 'Max HP',
|
||||
maxMpLabel: 'Max Energy',
|
||||
damageLabel: 'Firepower',
|
||||
guardLabel: 'Shield',
|
||||
rangeLabel: 'Range',
|
||||
cooldownLabel: 'Recharge',
|
||||
manaCostLabel: 'Energy Cost',
|
||||
campSuffix: 'Mobile Outpost',
|
||||
itemPrefixes: ['Iron', 'Steel', 'Pulse', 'Core', 'Nova', 'Plasma'],
|
||||
itemInfixes: ['-Core','-Drive','-Link','-Grid','-Node','-Unit'],
|
||||
skillPrefixes: ['Over','Ultra','Mega','Core','Pulse','Nova'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
|
||||
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
|
||||
mobility: ['-Dash','-Boost','-Warp','-Blink'],
|
||||
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
|
||||
projectile: ['-Round','-Bolt','-Shell','-Missile'],
|
||||
},
|
||||
},
|
||||
tide: {
|
||||
mode: 'tide',
|
||||
attributeLabels: { strength: 'Strength', agility: 'Agility', intelligence: 'Intelligence', spirit: 'Spirit' },
|
||||
hpLabel: 'HP',
|
||||
mpLabel: 'MP',
|
||||
maxHpLabel: 'Max HP',
|
||||
maxMpLabel: 'Max MP',
|
||||
damageLabel: 'Damage',
|
||||
guardLabel: 'Guard',
|
||||
rangeLabel: 'Range',
|
||||
cooldownLabel: 'Cooldown',
|
||||
manaCostLabel: 'Mana Cost',
|
||||
campSuffix: 'Camp',
|
||||
itemPrefixes: ['Wave', 'Tide', 'Ocean', 'Sea', 'Storm', 'Surf'],
|
||||
itemInfixes: ['-Wave','-Tide','-Ocean','-Sea','-Storm','-Surf'],
|
||||
skillPrefixes: ['Wave','Tide','Ocean','Sea','Storm','Surf'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
|
||||
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
|
||||
mobility: ['-Dash','-Boost','-Warp','-Blink'],
|
||||
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
|
||||
projectile: ['-Round','-Bolt','-Shell','-Missile'],
|
||||
},
|
||||
},
|
||||
rift: {
|
||||
mode: 'rift',
|
||||
attributeLabels: { strength: 'çå²', agility: 'è£æ¥', intelligence: 'çè¯', spirit: 'çå' },
|
||||
hpLabel: 'çå½',
|
||||
mpLabel: 'è£è½',
|
||||
maxHpLabel: 'çå½ä¸é',
|
||||
maxMpLabel: 'è£è½ä¸é',
|
||||
damageLabel: 'çå¿',
|
||||
guardLabel: '稳ç',
|
||||
rangeLabel: 'çè·',
|
||||
cooldownLabel: 'å¤ç',
|
||||
manaCostLabel: 'Rift Cost',
|
||||
campSuffix: 'è£çé©»è¥',
|
||||
itemPrefixes: ['è£ç', 'æå±', '边潮', 'ç°å', 'çæ¡¥', 'åå¨'],
|
||||
itemInfixes: ['edge', 'void', 'span', 'seal', 'rift', 'core'],
|
||||
skillPrefixes: ['rift', 'void', 'split', 'break', 'phase', 'warp'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['break', 'crash', 'shatter', 'burst'],
|
||||
steady: ['guard', 'hold', 'veil', 'ward'],
|
||||
mobility: ['step', 'shift', 'blink', 'drift'],
|
||||
finisher: ['ending', 'drop', 'break', 'flare'],
|
||||
projectile: ['spike', 'bolt', 'shard', 'wave'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORY_NOUNS: Record<string, string[]> = Object.fromEntries([
|
||||
[CATEGORY_WEAPON, ['blade', 'axe', 'bow', 'staff', 'spear', 'shield']],
|
||||
[CATEGORY_ARMOR, ['armor', 'robe', 'cloak', 'guard', 'mantle', 'bracer']],
|
||||
[CATEGORY_RELIC, ['ring', 'seal', 'badge', 'gem', 'charm', 'orb']],
|
||||
[CATEGORY_CONSUMABLE, ['potion', 'dust', 'draught', 'brew', 'oil', 'scroll']],
|
||||
[CATEGORY_MATERIAL, ['ore', 'crystal', 'bone', 'herb', 'core', 'silk']],
|
||||
[CATEGORY_RARE, ['sigil', 'relic', 'page', 'chart', 'key', 'idol']],
|
||||
[CATEGORY_EXCLUSIVE, ['core', 'seal', 'master-key', 'origin-box', 'true-mark', 'world-core']],
|
||||
]);
|
||||
const DEFAULT_CATEGORY_NOUNS = ['relic', 'sigil', 'token', 'seal', 'core', 'mark'];
|
||||
|
||||
const ROLE_SKILL_ROOTS: Record<string, string[]> = {
|
||||
'sword-princess': ['çå', 'éå¼', 'è£é', 'è£é'],
|
||||
'archer-hero': ['弦è¯', 'è¿è¢', '追é£', 'è´¯ç¢'],
|
||||
'girl-hero': ['åå', 'å½±è¢', 'ç¾æ©', 'æ å½±'],
|
||||
'punch-hero': ['æ³å¿', 'éå»', 'è£æ³', 'å´©æ¥'],
|
||||
'fighter-4': ['éé', 'ç¾éµ', 'é线', 'åå'],
|
||||
};
|
||||
|
||||
const SKILL_ROOT_STOP_WORDS = new Set([
|
||||
'ä¸ç',
|
||||
'设å®',
|
||||
'åºè°',
|
||||
'ç®æ ',
|
||||
'è§è²',
|
||||
'ææ',
|
||||
'飿 ¼',
|
||||
'èæ¯',
|
||||
'æ§æ ¼',
|
||||
'æ
äº',
|
||||
'custom-world',
|
||||
'playable-role',
|
||||
]);
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
|
||||
const item = items[index % items.length];
|
||||
if (item === undefined) {
|
||||
throw new Error(`Missing ${label}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function getWorldPresentation(profile: CustomWorldProfile) {
|
||||
return WORLD_PRESENTATIONS[detectCustomWorldThemeMode(profile)];
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[], max = 12) {
|
||||
return [...new Set(values.map(value => value.trim()).filter(Boolean))].slice(0, max);
|
||||
}
|
||||
|
||||
function collectSkillRootFragments(value: string, max = 8) {
|
||||
if (!value.trim()) return [] as string[];
|
||||
|
||||
const directSegments = value
|
||||
.split(/[ \t\r\n,!?:"()|/\\[\]-]+/u)
|
||||
.map(segment => segment.trim())
|
||||
.filter(segment => segment.length >= 2 && segment.length <= 6)
|
||||
.filter(segment => !SKILL_ROOT_STOP_WORDS.has(segment));
|
||||
|
||||
const chineseSource = value.replace(/[^\u4e00-\u9fa5]/gu, '');
|
||||
const ngrams: string[] = [];
|
||||
|
||||
for (let size = 2; size <= 4; size += 1) {
|
||||
for (let index = 0; index <= chineseSource.length - size; index += 1) {
|
||||
const fragment = chineseSource.slice(index, index + size);
|
||||
if (SKILL_ROOT_STOP_WORDS.has(fragment)) {
|
||||
continue;
|
||||
}
|
||||
ngrams.push(fragment);
|
||||
if (ngrams.length >= max) {
|
||||
return dedupeStrings([...directSegments, ...ngrams], max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeStrings([...directSegments, ...ngrams], max);
|
||||
}
|
||||
|
||||
function buildSkillThemeSeedSource(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
skill: CharacterSkillDefinition,
|
||||
index: number,
|
||||
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
||||
) {
|
||||
return [
|
||||
profile.name,
|
||||
profile.settingText,
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
role?.title ?? '',
|
||||
role?.combatStyle ?? '',
|
||||
role?.tags.join('|') ?? '',
|
||||
character.id,
|
||||
skill.id,
|
||||
skill.style,
|
||||
skill.delivery ?? '',
|
||||
index,
|
||||
].join('::');
|
||||
}
|
||||
|
||||
function buildSkillRootOptions(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
||||
) {
|
||||
const fallbackRoots = ROLE_SKILL_ROOTS[character.id] ?? ['çå¼', 'è¡è¯', 'è£é', 'æ½®å°'];
|
||||
const derivedRoots = dedupeStrings([
|
||||
...collectSkillRootFragments(role?.title ?? '', 4),
|
||||
...collectSkillRootFragments(role?.combatStyle ?? '', 6),
|
||||
...(role?.tags ?? []).flatMap(tag => collectSkillRootFragments(tag, 2)),
|
||||
...collectSkillRootFragments(profile.name, 4),
|
||||
...collectSkillRootFragments(profile.playerGoal, 6),
|
||||
], 8);
|
||||
|
||||
return derivedRoots.length > 0 ? dedupeStrings([...derivedRoots, ...fallbackRoots], 8) : fallbackRoots;
|
||||
}
|
||||
|
||||
export function getCustomWorldProfileForDisplay(worldType: WorldType | null | undefined, explicitProfile?: CustomWorldProfile | null) {
|
||||
if (explicitProfile) return explicitProfile;
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
return getRuntimeCustomWorldProfile();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAttributeLabelsForWorld(worldType: WorldType | null | undefined, explicitProfile?: CustomWorldProfile | null): AttributeLabelMap {
|
||||
const profile = getCustomWorldProfileForDisplay(worldType, explicitProfile);
|
||||
if (!profile) {
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return { strength: 'é躯', agility: '御è¡', intelligence: 'ç¥è¯', spirit: 'çµè´' };
|
||||
}
|
||||
return { strength: 'åé', agility: 'ææ·', intelligence: 'æºå', spirit: 'ç²¾ç¥' };
|
||||
}
|
||||
|
||||
return getWorldPresentation(profile).attributeLabels;
|
||||
}
|
||||
|
||||
export function getResourceLabelsForWorld(worldType: WorldType | null | undefined, explicitProfile?: CustomWorldProfile | null) {
|
||||
const profile = getCustomWorldProfileForDisplay(worldType, explicitProfile);
|
||||
if (!profile) {
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return {
|
||||
hp: 'å½å
',
|
||||
mp: 'çµè´',
|
||||
maxHp: 'å½å
ä¸é',
|
||||
maxMp: 'çµè´ä¸é',
|
||||
damage: 'æ¯å¿',
|
||||
guard: 'æ¤å
',
|
||||
range: 'æ¯è·',
|
||||
cooldown: '忝',
|
||||
manaCost: 'çµè´æ¶è?',
|
||||
};
|
||||
}
|
||||
return {
|
||||
hp: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: 'æå¤?HP',
|
||||
maxMp: 'æå¤?MP',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: 'Mana',
|
||||
};
|
||||
}
|
||||
|
||||
const presentation = getWorldPresentation(profile);
|
||||
return {
|
||||
hp: presentation.hpLabel,
|
||||
mp: presentation.mpLabel,
|
||||
maxHp: presentation.maxHpLabel,
|
||||
maxMp: presentation.maxMpLabel,
|
||||
damage: presentation.damageLabel,
|
||||
guard: presentation.guardLabel,
|
||||
range: presentation.rangeLabel,
|
||||
cooldown: presentation.cooldownLabel,
|
||||
manaCost: presentation.manaCostLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomCampSceneName(profile: CustomWorldProfile) {
|
||||
const presentation = getWorldPresentation(profile);
|
||||
return `${presentation.itemPrefixes[0]}${presentation.campSuffix}`;
|
||||
}
|
||||
|
||||
export function buildThemedSkillName(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
skill: CharacterSkillDefinition,
|
||||
index: number,
|
||||
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
||||
) {
|
||||
const presentation = getWorldPresentation(profile);
|
||||
const seed = hashText(buildSkillThemeSeedSource(profile, character, skill, index, role));
|
||||
const rootOptions = buildSkillRootOptions(profile, character, role);
|
||||
const prefix = presentation.skillPrefixes[seed % presentation.skillPrefixes.length];
|
||||
const root = rootOptions[(seed >>> 3) % rootOptions.length];
|
||||
const suffix = presentation.skillSuffixByStyle[skill.style][(seed >>> 5) % presentation.skillSuffixByStyle[skill.style].length];
|
||||
return `${prefix}${root}${suffix}`;
|
||||
}
|
||||
|
||||
function getCategoryNouns(category: string) {
|
||||
return CATEGORY_NOUNS[category] ?? CATEGORY_NOUNS['ç¨æå'];
|
||||
}
|
||||
|
||||
function getResolvedCategoryNouns(category: string): string[] {
|
||||
return getCategoryNouns(category) ?? DEFAULT_CATEGORY_NOUNS;
|
||||
}
|
||||
|
||||
export function buildThemedItemName(
|
||||
profile: CustomWorldProfile,
|
||||
category: string,
|
||||
sourceKey: string,
|
||||
index: number,
|
||||
) {
|
||||
const presentation = getWorldPresentation(profile);
|
||||
const seed = hashText(`${profile.id}:${sourceKey}:${category}:${index}`);
|
||||
const prefix = presentation.itemPrefixes[seed % presentation.itemPrefixes.length];
|
||||
const infix = presentation.itemInfixes[(seed >>> 3) % presentation.itemInfixes.length];
|
||||
const nouns = getResolvedCategoryNouns(category);
|
||||
const noun = pickCyclic(nouns, seed >>> 5, `item noun for category "${category}"`);
|
||||
return `${prefix}${infix}${noun}${index + 1}`;
|
||||
}
|
||||
|
||||
export function buildThemedItemDescription(
|
||||
profile: CustomWorldProfile,
|
||||
category: string,
|
||||
rarity: ItemRarity,
|
||||
seedKey: string,
|
||||
) {
|
||||
const seed = hashText(`${profile.id}:${category}:${rarity}:${seedKey}`);
|
||||
const hooks = [
|
||||
`Suitable for the current goal "${profile.playerGoal}".`,
|
||||
`Its tone closely matches the world tone "${profile.tone}".`,
|
||||
'Likely to appear in one of this world\'s major conflicts.',
|
||||
`It clearly ties into the expanding conflict inside this world.`,
|
||||
];
|
||||
const rarityText = {
|
||||
common: '常è§',
|
||||
uncommon: 'è¿é¶',
|
||||
rare: 'Rare',
|
||||
epic: 'æ ¸å¿',
|
||||
legendary: 'å
³é®',
|
||||
}[rarity];
|
||||
|
||||
return `${rarityText} ${category}. ${hooks[seed % hooks.length]}`;
|
||||
}
|
||||
|
||||
export function inferCustomItemMechanics(
|
||||
category: string,
|
||||
rarity: ItemRarity,
|
||||
tags: string[],
|
||||
seedKey: string,
|
||||
): {
|
||||
equipmentSlotId?: EquipmentSlotId | null;
|
||||
statProfile?: ItemStatProfile | null;
|
||||
useProfile?: ItemUseProfile | null;
|
||||
value: number;
|
||||
} {
|
||||
const seed = hashText(`${category}:${rarity}:${seedKey}:${tags.join('|')}`);
|
||||
const rarityTier = {
|
||||
common: 1,
|
||||
uncommon: 2,
|
||||
rare: 3,
|
||||
epic: 4,
|
||||
legendary: 5,
|
||||
}[rarity];
|
||||
|
||||
if (category === 'æ¦å¨') {
|
||||
return {
|
||||
equipmentSlotId: 'weapon',
|
||||
statProfile: {
|
||||
outgoingDamageBonus: 2 * rarityTier + (seed % 3),
|
||||
},
|
||||
value: 28 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === 'æ¤ç²') {
|
||||
return {
|
||||
equipmentSlotId: 'armor',
|
||||
statProfile: {
|
||||
maxHpBonus: 10 * rarityTier + (seed % 8),
|
||||
incomingDamageMultiplier: Math.max(0.72, Number((1 - rarityTier * 0.04).toFixed(2))),
|
||||
},
|
||||
value: 26 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === '??' || category === '???' || category === '????') {
|
||||
return {
|
||||
equipmentSlotId: 'relic',
|
||||
statProfile: {
|
||||
maxManaBonus: 8 * rarityTier + (seed % 7),
|
||||
outgoingDamageBonus: rarityTier >= 3 ? rarityTier : undefined,
|
||||
},
|
||||
value: 32 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === 'æ¶èå') {
|
||||
const heals = tags.includes('healing') || seed % 2 === 0;
|
||||
return {
|
||||
useProfile: heals
|
||||
? { hpRestore: 16 * rarityTier }
|
||||
: { manaRestore: 12 * rarityTier, cooldownReduction: rarityTier >= 3 ? 1 : 0 },
|
||||
value: 18 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: 10 * rarityTier,
|
||||
};
|
||||
}
|
||||
24
src/services/customWorldTheme.ts
Normal file
24
src/services/customWorldTheme.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CustomWorldProfile, WorldTemplateType, WorldType } from '../types';
|
||||
|
||||
export type CustomWorldThemeMode = 'martial' | 'arcane' | 'machina' | 'tide' | 'rift';
|
||||
|
||||
export function detectCustomWorldThemeMode(
|
||||
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
|
||||
): CustomWorldThemeMode {
|
||||
const source = `${profile.settingText} ${profile.summary} ${profile.tone} ${profile.playerGoal}`;
|
||||
|
||||
if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina';
|
||||
if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide';
|
||||
if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift';
|
||||
if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane';
|
||||
if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial';
|
||||
|
||||
return profile.templateWorldType === WorldType.XIANXIA ? 'arcane' : 'martial';
|
||||
}
|
||||
|
||||
export function resolveCustomWorldAnchorWorldType(
|
||||
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
|
||||
): WorldTemplateType {
|
||||
const themeMode = detectCustomWorldThemeMode(profile);
|
||||
return themeMode === 'arcane' || themeMode === 'rift' ? WorldType.XIANXIA : WorldType.WUXIA;
|
||||
}
|
||||
228
src/services/llmClient.ts
Normal file
228
src/services/llmClient.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type {TextStreamOptions} from './aiTypes';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_LLM_PROXY_BASE_URL || '/api/llm';
|
||||
const MODEL = import.meta.env.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715';
|
||||
const ENABLE_LLM_DEBUG_LOG = import.meta.env.DEV || import.meta.env.VITE_LLM_DEBUG_LOG === 'true';
|
||||
|
||||
export interface PlainTextCompletionOptions {
|
||||
timeoutMs?: number;
|
||||
debugLabel?: string;
|
||||
}
|
||||
|
||||
export class LlmConnectivityError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LlmConnectivityError';
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTimeoutMs(rawValue: string | undefined, fallback: number) {
|
||||
const parsed = Number(rawValue);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(import.meta.env.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
|
||||
export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
||||
import.meta.env.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
Math.max(REQUEST_TIMEOUT_MS, 45000),
|
||||
);
|
||||
|
||||
function logLlmDebug(title: string, payload: unknown) {
|
||||
if (!ENABLE_LLM_DEBUG_LOG) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(title, payload);
|
||||
}
|
||||
|
||||
function normalizeLlmError(error: unknown): never {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new LlmConnectivityError('The LLM request timed out. Please check the network or endpoint.');
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
throw new LlmConnectivityError('Unable to reach the LLM endpoint. The network or proxy may be unavailable.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
export function isLlmConnectivityError(error: unknown): error is LlmConnectivityError {
|
||||
return error instanceof LlmConnectivityError;
|
||||
}
|
||||
|
||||
async function requestMessageContent(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: PlainTextCompletionOptions = {},
|
||||
) {
|
||||
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
||||
const debugLabel = options.debugLabel ?? 'chat';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = performance.now();
|
||||
const requestBody = {
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{role: 'system' as const, content: systemPrompt},
|
||||
{role: 'user' as const, content: userPrompt},
|
||||
],
|
||||
};
|
||||
const rawPromptText = `[System]\n${systemPrompt}\n\n[User]\n${userPrompt}`;
|
||||
|
||||
try {
|
||||
logLlmDebug(`[LLM:${debugLabel}] prompt text`, rawPromptText);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const rawResponseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('LLM authentication failed. Check the configured API key on the dev server.');
|
||||
}
|
||||
|
||||
throw new Error(`LLM request failed: ${response.status} ${rawResponseText}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(rawResponseText);
|
||||
const content = data?.choices?.[0]?.message?.content;
|
||||
if (!content || typeof content !== 'string') {
|
||||
throw new Error('LLM response did not include message content.');
|
||||
}
|
||||
|
||||
logLlmDebug(`[LLM:${debugLabel}] output text`, content);
|
||||
logLlmDebug(`[LLM:${debugLabel}] completion success`, {
|
||||
model: MODEL,
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
responseLength: content.length,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
console.error(`[LLM:${debugLabel}] completion failed`, {
|
||||
model: MODEL,
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
timeoutMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return normalizeLlmError(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestChatMessageContent(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: PlainTextCompletionOptions = {},
|
||||
) {
|
||||
return requestMessageContent(systemPrompt, userPrompt, options);
|
||||
}
|
||||
|
||||
export async function requestPlainTextCompletion(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: PlainTextCompletionOptions = {},
|
||||
) {
|
||||
return requestMessageContent(systemPrompt, userPrompt, options);
|
||||
}
|
||||
|
||||
export async function streamPlainTextCompletion(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
stream: true,
|
||||
messages: [
|
||||
{role: 'system' as const, content: systemPrompt},
|
||||
{role: 'user' as const, content: userPrompt},
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const rawResponseText = await response.text();
|
||||
if (response.status === 401) {
|
||||
throw new Error('LLM authentication failed. Check the configured API key on the dev server.');
|
||||
}
|
||||
|
||||
throw new Error(`LLM request failed: ${response.status} ${rawResponseText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const fallbackText = await requestPlainTextCompletion(systemPrompt, userPrompt);
|
||||
let progressiveText = '';
|
||||
for (const char of fallbackText) {
|
||||
progressiveText += char;
|
||||
options.onUpdate?.(progressiveText);
|
||||
}
|
||||
return fallbackText;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedText = '';
|
||||
|
||||
for (;;) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string' && delta.length > 0) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE frames and continue consuming the stream.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulatedText.trim();
|
||||
} catch (error) {
|
||||
return normalizeLlmError(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
29
src/services/llmParsers.test.ts
Normal file
29
src/services/llmParsers.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {parseJsonResponseText, parseLineListContent} from './llmParsers';
|
||||
|
||||
describe('llmParsers', () => {
|
||||
it('parses fenced json payloads', () => {
|
||||
expect(
|
||||
parseJsonResponseText('```json\n{"storyText":"hello","options":[]}\n```'),
|
||||
).toEqual({
|
||||
storyText: 'hello',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses embedded json objects', () => {
|
||||
expect(
|
||||
parseJsonResponseText('prefix {"storyText":"hello","options":[]} suffix'),
|
||||
).toEqual({
|
||||
storyText: 'hello',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts compact suggestion lines', () => {
|
||||
expect(
|
||||
parseLineListContent('- first\n2. second\nthird', 2),
|
||||
).toEqual(['first', 'second']);
|
||||
});
|
||||
});
|
||||
28
src/services/llmParsers.ts
Normal file
28
src/services/llmParsers.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function parseJsonResponseText(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('LLM returned an empty response.');
|
||||
}
|
||||
|
||||
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
|
||||
if (fencedMatch?.[1]) {
|
||||
return JSON.parse(fencedMatch[1].trim());
|
||||
}
|
||||
|
||||
const firstBrace = trimmed.indexOf('{');
|
||||
const lastBrace = trimmed.lastIndexOf('}');
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
|
||||
}
|
||||
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
|
||||
export function parseLineListContent(text: string, maxItems = 3) {
|
||||
return text
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.trim().replace(/^[-*\d.)\s]+/u, '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, maxItems);
|
||||
}
|
||||
976
src/services/prompt.ts
Normal file
976
src/services/prompt.ts
Normal file
@@ -0,0 +1,976 @@
|
||||
import { buildRoleAttributeProfileFromLegacyData } from '../data/attributeProfileGenerator';
|
||||
import {
|
||||
buildSchemaSummary,
|
||||
describeTopAttributes,
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
buildCharacterBackstoryPromptContext,
|
||||
getCharacterAdventureOpening,
|
||||
getCharacterById,
|
||||
getCharacterPublicBackstorySummary,
|
||||
resolveEncounterRecruitCharacter,
|
||||
} from '../data/characterPresets';
|
||||
import { getMonsterPresetById } from '../data/hostileNpcPresets';
|
||||
import { createSceneMonstersFromIds } from '../data/hostileNpcs';
|
||||
import {
|
||||
describeConversationStyle as describeNpcConversationStyle,
|
||||
describeDisclosureStage,
|
||||
describeWarmthStage,
|
||||
} from '../data/npcInteractions';
|
||||
import { buildSceneEntityCatalogText, getScenePresetById } from '../data/scenePresets';
|
||||
import {
|
||||
buildFunctionCatalogText,
|
||||
getFunctionById,
|
||||
getFunctionPromptDescription,
|
||||
} from '../data/stateFunctions';
|
||||
import {
|
||||
Character,
|
||||
CharacterGender,
|
||||
CustomWorldProfile,
|
||||
FacingDirection,
|
||||
SceneMonster,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type { StoryGenerationContext } from './aiTypes';
|
||||
import { buildCustomWorldReferenceText } from './customWorld';
|
||||
import { buildStoryPromptHistory } from './storyHistory';
|
||||
|
||||
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。
|
||||
输出格式必须严格符合:
|
||||
{
|
||||
"storyText": "剧情文本",
|
||||
"encounter": {
|
||||
"kind": "npc|treasure|none",
|
||||
"npcId": "仅当 kind=npc 时填写",
|
||||
"treasureText": "仅当 kind=treasure 时填写"
|
||||
},
|
||||
"options": [
|
||||
{
|
||||
"functionId": "预定义功能ID",
|
||||
"actionText": "选项显示文本"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
严格规则:
|
||||
- 所有文本必须是中文。
|
||||
- 如果提示语给出了特定可选列表,你必须严格保留原有数量和 functionId;你可以调整这些特定项的顺序,但排序必须参考最近剧情、刚发生的结果、当前局面轻重缓急,再重点优化 actionText;下文会直接说明每个 function 的行为边界,不是让你发挥的剧本。
|
||||
- 如果提示语中没有给特定可选列表,则必须输出至少 6 个选项。
|
||||
- 每个选项只能包含 functionId 和 actionText。
|
||||
- 没有特定列表时,所有 functionId 必须互不重复。
|
||||
- 每个选项只能包含一个 function,不要把多个动作塞进同一行。
|
||||
- storyText 必须衔接当前界面、最近剧情、当前场景与当前实体,不得割裂上下文凭空发挥。
|
||||
- 战斗状态下,storyText 必须提到当前敌对目标或战斗对象正在做什么。
|
||||
- actionText 必须同时考虑:主角状态、面前实体状态、最近剧情、当前场景、当前可执行 function。
|
||||
- 当主角生命值低下时,至少有一个 actionText 体现维持状态、调整、恢复或撤退。
|
||||
- 当主角灵力低下时,至少有一个 actionText 体现节省消耗、保持节奏或尝试恢复。
|
||||
- 当对方状态低下时,至少有一个 actionText 体现改变、攻击、结束或压制。
|
||||
- actionText 只写玩家能看到的行动文本,不写 functionId,不写特殊解释。
|
||||
- 选项顺序不是随机列表,越接近最近剧情推进、当前威胁或当前机会的回应越靠前。
|
||||
- 前端不会校验 functionId;不该出现的 function 绝对不要输出。`;
|
||||
|
||||
export const NPC_CHAT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的对话编剧。
|
||||
你只能输出纯文本对话,不能输出解释、代码、markdown、JSON 或额外说明。
|
||||
|
||||
硬性规则:
|
||||
- 每一行都必须严格以“你:”或“角色名字:”开头。
|
||||
- 第一行必须是“你:”开头。
|
||||
- 总行数控制在 4 到 6 行。
|
||||
- 玩家和对方至少各说 2 次。
|
||||
- 对话必须承接当前聊天主题、当前场景和最近关系变化。
|
||||
- 对方必须给出真实回应,不能只用敷衍词。`;
|
||||
|
||||
export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。
|
||||
你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。
|
||||
|
||||
硬性规则:
|
||||
- 每一行都必须严格以“你:”或“角色名字:”开头。
|
||||
- 第一行必须是“你:”开头。
|
||||
- 总行数控制在 4 到 6 行。
|
||||
- 玩家和对方至少各说 2 次。
|
||||
- 这段内容只是聊天,不是做决定。
|
||||
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
|
||||
- 禁止把情报直接写成对玩家的指令。
|
||||
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
|
||||
|
||||
export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。
|
||||
你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。
|
||||
|
||||
硬性规则:
|
||||
- 每一行都必须严格以“你:”或“角色名字:”开头。
|
||||
- 第一行必须是“你:”开头。
|
||||
- 总行数控制在 4 到 6 行。
|
||||
- 玩家和对方至少各说 2 次。
|
||||
- 这段对话的目标是把“邀请对方入队”自然谈成。
|
||||
- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。
|
||||
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
|
||||
- 最后一行必须由对方明确答应加入队伍。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 中可被玩家在角色面板里私下交谈的同行角色。
|
||||
你只能输出这名角色此刻会说的话,不能输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
|
||||
硬性规则:
|
||||
- 必须始终站在该角色立场回应,语气要符合角色设定、经历、情绪和与你的关系。
|
||||
- 只回复角色说话内容,不要代替玩家发言,不要把回复写成系统选项。
|
||||
- 可以自然提到最近剧情、战斗感受、彼此关系和顾虑,但不要写成任务说明书。
|
||||
- 回复控制在 1 到 3 段,总长度尽量不超过 120 个中文字符。
|
||||
- 玩家问得含糊时,也要给出明确、具体、带情绪的回应。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成 3 条下一句可直接发送的中文回复建议。
|
||||
你只能输出 3 行纯文本,每行 1 条,不要序号、引号、解释、Markdown 或额外空行。
|
||||
硬性规则:
|
||||
- 三条建议必须风格有区分:一条偏关心,一条偏追问,一条偏轻松或拉近关系。
|
||||
- 每条建议尽量控制在 10 到 28 个字。
|
||||
- 建议必须贴合最近剧情、当前关系和上一轮聊天内容。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `你要把玩家与这名角色的聊天沉淀成后续剧情推理可用的角色关系摘要。
|
||||
你只能输出一段中文摘要,不要标题、序号、Markdown、JSON 或解释。
|
||||
摘要必须包含:
|
||||
- 当前关系气氛与亲疏变化
|
||||
- 角色对玩家态度的新变化
|
||||
- 聊天里出现的重要信息、承诺、顾虑或暗示
|
||||
长度控制在 45 到 120 个字。`;
|
||||
|
||||
export function describeWorld(world: WorldType) {
|
||||
if (world === WorldType.WUXIA) return '武侠';
|
||||
if (world === WorldType.XIANXIA) return '仙侠';
|
||||
return '自定义世界';
|
||||
}
|
||||
|
||||
function describeWorldForPrompt(world: WorldType, customWorldProfile?: CustomWorldProfile | null) {
|
||||
return customWorldProfile
|
||||
? `${customWorldProfile.name}(自定义世界)`
|
||||
: describeWorld(world);
|
||||
}
|
||||
|
||||
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
|
||||
return customWorldProfile
|
||||
? `自定义世界补充档案:\n${buildCustomWorldReferenceText(customWorldProfile)}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function describeFacing(facing: FacingDirection) {
|
||||
return facing === 'left' ? '左' : '右';
|
||||
}
|
||||
|
||||
function describeGender(gender: CharacterGender | null | undefined) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function describeAdventureOpening(character: Character, world: WorldType) {
|
||||
const opening = getCharacterAdventureOpening(character, world);
|
||||
if (!opening) return [];
|
||||
|
||||
return [
|
||||
`来到此界的原因:${opening.reason}`,
|
||||
`当前最重要的目标:${opening.goal}`,
|
||||
];
|
||||
}
|
||||
|
||||
function describePlayerOpeningByContext(character: Character, world: WorldType, context: StoryGenerationContext) {
|
||||
const opening = getCharacterAdventureOpening(character, world);
|
||||
if (!opening) return [];
|
||||
|
||||
const shouldConcealFullOpening = context.lastFunctionId === 'story_opening_camp_dialogue'
|
||||
|| context.lastFunctionId === 'npc_chat'
|
||||
|| context.isFirstMeaningfulContact === true;
|
||||
if (!shouldConcealFullOpening) {
|
||||
return describeAdventureOpening(character, world);
|
||||
}
|
||||
|
||||
return [
|
||||
`主角当前只表露出的钩子:${opening.surfaceHook ?? '主角有自己的来意,但不会刚见面就全说。'}`,
|
||||
`主角当前更在意的事:${opening.immediateConcern ?? '主角会优先先谈眼前局势。'}`,
|
||||
];
|
||||
}
|
||||
|
||||
function describeEncounterOpeningByStage(character: Character, world: WorldType, context: StoryGenerationContext) {
|
||||
const opening = getCharacterAdventureOpening(character, world);
|
||||
if (!opening) return [];
|
||||
|
||||
if (context.isFirstMeaningfulContact) {
|
||||
return [
|
||||
`当前只看得出的钩子:${opening.surfaceHook ?? '对方有自己的来意,但此刻只会先露出一角。'}`,
|
||||
`当前更在意的事:${opening.immediateConcern ?? '对方会先把注意力放在眼前局势上。'}`,
|
||||
];
|
||||
}
|
||||
|
||||
const stage = context.encounterDisclosureStage ?? 'guarded';
|
||||
if (stage === 'guarded') {
|
||||
return [
|
||||
`当前只看得出的钩子:${opening.surfaceHook ?? '对方知道点什么,但并没有把来意说透。'}`,
|
||||
`当前更在意的事:${opening.immediateConcern ?? '眼前局势比来历更值得先谈。'}`,
|
||||
];
|
||||
}
|
||||
if (stage === 'partial') {
|
||||
return [
|
||||
`当前愿意松口的表层理由:${opening.guardedMotive ?? opening.surfaceHook ?? '对方只肯给一层表面的解释。'}`,
|
||||
`当前更在意的事:${opening.immediateConcern ?? '眼前局势比旧事更急。'}`,
|
||||
];
|
||||
}
|
||||
if (stage === 'honest') {
|
||||
return [
|
||||
`来到此界的原因(可逐步触及):${opening.reason}`,
|
||||
`当前最重要的目标(仍不必一次说尽):${opening.goal}`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`来到此界的原因:${opening.reason}`,
|
||||
`当前最重要的目标:${opening.goal}`,
|
||||
];
|
||||
}
|
||||
|
||||
function describeEncounterConversationDirective(context: StoryGenerationContext) {
|
||||
if (!context.encounterConversationStyle || !context.encounterDisclosureStage || !context.encounterWarmthStage || !context.encounterAnswerMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前角色对话阶段控制:',
|
||||
`- 当前好感:${context.encounterAffinity ?? '未知'}`,
|
||||
`- 信息揭示阶段:${context.encounterDisclosureStage}(${describeDisclosureStage(context.encounterDisclosureStage)})`,
|
||||
`- 语气亲疏阶段:${context.encounterWarmthStage}(${describeWarmthStage(context.encounterWarmthStage)})`,
|
||||
`- 回答模式:${context.encounterAnswerMode}`,
|
||||
`- 角色表述风格:${describeNpcConversationStyle(context.encounterConversationStyle)}`,
|
||||
context.encounterAllowedTopics?.length
|
||||
? `- 本轮优先可谈:${context.encounterAllowedTopics.join('、')}`
|
||||
: null,
|
||||
context.encounterBlockedTopics?.length
|
||||
? `- 本轮避免直接说破:${context.encounterBlockedTopics.join('、')}`
|
||||
: null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeConversationSituationDirective(context: StoryGenerationContext) {
|
||||
if (!context.conversationSituation && !context.conversationPressure && !context.recentSharedEvent && !context.talkPriority) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前对话情景控制:',
|
||||
context.conversationSituation ? `- 情景标签:${context.conversationSituation}` : null,
|
||||
context.conversationPressure ? `- 当前压力:${context.conversationPressure}` : null,
|
||||
context.recentSharedEvent ? `- 刚刚共同经历:${context.recentSharedEvent}` : null,
|
||||
context.talkPriority ? `- 本轮优先说法:${context.talkPriority}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeFirstContactRelationStance(
|
||||
stance: StoryGenerationContext['firstContactRelationStance'],
|
||||
) {
|
||||
switch (stance) {
|
||||
case 'guarded':
|
||||
return '戒备试探';
|
||||
case 'neutral':
|
||||
return '正常交流但仍不熟';
|
||||
case 'cooperative':
|
||||
return '已有善意,先确认合作节奏';
|
||||
case 'bonded':
|
||||
return '明显信任,但仍是第一次正式对上人';
|
||||
default:
|
||||
return '初次接触';
|
||||
}
|
||||
}
|
||||
|
||||
function describeFirstMeaningfulContactDirective(context: StoryGenerationContext) {
|
||||
if (!context.isFirstMeaningfulContact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前接触阶段:这是你与该角色第一次真正接触。',
|
||||
`- 当前关系站位:${describeFirstContactRelationStance(context.firstContactRelationStance ?? null)}`,
|
||||
'- 可以按当前好感写得更冷或更暖,但仍必须保持第一次正式对上的节奏。',
|
||||
'- 优先写现场判断、态度试探、来意确认和眼前压力,不要直接写成熟人后续轮。',
|
||||
'- 不要让双方一上来互相讲完整过去;未公开或未解锁背景不能主动说破。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeBackstoryContext(label: string, snippets: string[]) {
|
||||
const normalized = snippets
|
||||
.map(snippet => snippet.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [`${label}:暂无公开信息。`];
|
||||
}
|
||||
|
||||
return normalized.map((snippet, index) =>
|
||||
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`,
|
||||
);
|
||||
}
|
||||
|
||||
function getEncounterGender(context: StoryGenerationContext) {
|
||||
if (context.encounterCharacterId) {
|
||||
return getCharacterById(context.encounterCharacterId)?.gender ?? context.encounterGender ?? 'unknown';
|
||||
}
|
||||
|
||||
return context.encounterGender ?? 'unknown';
|
||||
}
|
||||
|
||||
function describeHpBand(ratio: number) {
|
||||
if (ratio >= 0.95) return '完好无损';
|
||||
if (ratio >= 0.75) return '状态稳健';
|
||||
if (ratio >= 0.55) return '略有消耗';
|
||||
if (ratio >= 0.35) return '伤势明显';
|
||||
if (ratio >= 0.15) return '伤势沉重';
|
||||
return '濒临极限';
|
||||
}
|
||||
|
||||
function describeManaBand(ratio: number) {
|
||||
if (ratio >= 0.9) return '灵力满盈';
|
||||
if (ratio >= 0.7) return '灵力充沛';
|
||||
if (ratio >= 0.45) return '灵力平稳';
|
||||
if (ratio >= 0.2) return '灵力吃紧';
|
||||
if (ratio > 0) return '灵力见底';
|
||||
return '灵力枯竭';
|
||||
}
|
||||
|
||||
function describeOverallBand(hpRatio: number, manaRatio: number) {
|
||||
if (hpRatio >= 0.75 && manaRatio >= 0.7) return '整体状态适合主动推进';
|
||||
if (hpRatio >= 0.5 && manaRatio >= 0.4) return '整体状态仍可持续周旋';
|
||||
if (hpRatio < 0.35 && manaRatio < 0.2) return '整体状态非常吃紧,应避免冒进';
|
||||
if (hpRatio < 0.35) return '身体负担偏重,宜先稳住节奏';
|
||||
if (manaRatio < 0.2) return '灵力压力很大,宜保守分配手段';
|
||||
return '整体状态已有消耗,需要权衡节奏';
|
||||
}
|
||||
|
||||
function inferEncounterPersonality(contextText: string | null | undefined, description: string | null | undefined) {
|
||||
const source = `${contextText ?? ''} ${description ?? ''}`;
|
||||
if (/守|卫|先锋|甲/u.test(source)) return '谨慎克制,先观察后出手';
|
||||
if (/猎|追踪|巡/u.test(source)) return '警觉敏锐,习惯先捕捉细节';
|
||||
if (/商|摊主|军需/u.test(source)) return '精于算计,言谈中会反复权衡得失';
|
||||
if (/学|书|碑|录/u.test(source)) return '耐心细致,更看重信息与来历';
|
||||
if (/舟|渡|舵/u.test(source)) return '老练沉稳,习惯先判断局势再表态';
|
||||
return '对外保持戒备,会先试探你的来意与立场';
|
||||
}
|
||||
|
||||
function inferEncounterAttributeProfile(
|
||||
world: WorldType,
|
||||
context: StoryGenerationContext,
|
||||
entityId: string,
|
||||
extraText: string[] = [],
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, context.customWorldProfile);
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId,
|
||||
schema,
|
||||
textBlocks: [
|
||||
context.encounterName,
|
||||
context.encounterContext,
|
||||
context.encounterDescription,
|
||||
...extraText,
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
function describeAttributeProfileForPrompt(
|
||||
label: string,
|
||||
world: WorldType,
|
||||
context: StoryGenerationContext,
|
||||
profile: ReturnType<typeof inferEncounterAttributeProfile> | null | undefined,
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, context.customWorldProfile);
|
||||
return [
|
||||
`${label}核心属性:${describeTopAttributes(profile, schema).join('、') || '暂无'}`,
|
||||
`${label}属性详情:${formatAttributeList(profile, schema)
|
||||
.map(entry => `${entry.slot.name} ${entry.value}`)
|
||||
.join('、')}`,
|
||||
];
|
||||
}
|
||||
|
||||
function describeSkills(character: Character, context: StoryGenerationContext) {
|
||||
const cooldowns = Object.entries(context.skillCooldowns)
|
||||
.filter(([, turns]) => turns > 0)
|
||||
.map(([skillId, turns]) => {
|
||||
const skill = character.skills.find(item => item.id === skillId);
|
||||
return skill ? `${skill.name} 还需 ${turns} 回合` : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
|
||||
return [
|
||||
`当前灵力档位:${describeManaBand(context.playerMana / Math.max(context.playerMaxMana, 1))}`,
|
||||
'技能列表:',
|
||||
...character.skills.map(
|
||||
skill => `- ${skill.id}:${skill.name},基础伤害 ${skill.damage},消耗 ${skill.manaCost},冷却 ${skill.cooldownTurns} 回合`,
|
||||
),
|
||||
`冷却中的技能:${cooldowns || '暂无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeFrontEntity(
|
||||
world: WorldType,
|
||||
context: StoryGenerationContext,
|
||||
monsters: SceneMonster[],
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, context.customWorldProfile);
|
||||
if (context.encounterName) {
|
||||
const encounterCharacter = context.encounterCharacterId
|
||||
? getCharacterById(context.encounterCharacterId) ?? resolveEncounterRecruitCharacter({
|
||||
characterId: context.encounterCharacterId,
|
||||
context: context.encounterContext ?? '',
|
||||
npcName: context.encounterName,
|
||||
})
|
||||
: resolveEncounterRecruitCharacter({
|
||||
characterId: undefined,
|
||||
context: context.encounterContext ?? '',
|
||||
npcName: context.encounterName,
|
||||
});
|
||||
|
||||
const attributeProfile = encounterCharacter
|
||||
? resolveCharacterAttributeProfile(encounterCharacter, world, context.customWorldProfile)
|
||||
: inferEncounterAttributeProfile(world, context, `encounter:${context.encounterName}`, [
|
||||
inferEncounterPersonality(context.encounterContext, context.encounterDescription),
|
||||
]);
|
||||
const title = encounterCharacter?.title ?? context.encounterContext ?? '此地生灵';
|
||||
const description = encounterCharacter?.description ?? context.encounterDescription ?? '对方站在你面前,等待你进一步表态。';
|
||||
const personality = encounterCharacter?.personality ?? inferEncounterPersonality(context.encounterContext, context.encounterDescription);
|
||||
const backstoryLines = encounterCharacter
|
||||
? context.isFirstMeaningfulContact
|
||||
? [getCharacterPublicBackstorySummary(encounterCharacter, world)]
|
||||
: buildCharacterBackstoryPromptContext(
|
||||
encounterCharacter,
|
||||
context.encounterAffinity ?? 0,
|
||||
world,
|
||||
)
|
||||
: ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
|
||||
const status = context.encounterKind === 'npc'
|
||||
? context.isFirstMeaningfulContact
|
||||
? '你们正在进行第一次真正接触,对方会先观察你的态度与来意。'
|
||||
: '对你保持观察与戒备,正在等待你的回应'
|
||||
: context.encounterKind === 'treasure'
|
||||
? '静静停在前方,尚未被真正触碰'
|
||||
: '状态未明';
|
||||
|
||||
return [
|
||||
'当前面前实体:',
|
||||
`- 名称:${context.encounterName}`,
|
||||
`- 身份:${title}`,
|
||||
`- 描述:${description}`,
|
||||
...describeBackstoryContext('背景', backstoryLines).map(line => `- ${line}`),
|
||||
`- 性格:${personality}`,
|
||||
`- 世界属性框架:${buildSchemaSummary(schema).map(slot => `${slot.name}:${slot.definition}`).join('、')}`,
|
||||
|
||||
...(encounterCharacter ? describeEncounterOpeningByStage(encounterCharacter, world, context).map(line => `- ${line}`) : []),
|
||||
`- 状态:${status}`,
|
||||
...describeAttributeProfileForPrompt('对方', world, context, attributeProfile).map(line => `- ${line}`),
|
||||
context.encounterKind === 'npc' && context.encounterAffinityText
|
||||
? `- 对你的态度:${context.encounterAffinityText}`
|
||||
: null,
|
||||
context.encounterRelationshipSummary
|
||||
? `- 你与对方私下相处补充:${context.encounterRelationshipSummary}`
|
||||
: null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
const primaryMonster = monsters.find(monster => monster.hp > 0) ?? monsters[0];
|
||||
if (!primaryMonster) {
|
||||
return '当前面前实体:暂无明确实体拦在你面前。';
|
||||
}
|
||||
|
||||
const monsterPreset = getMonsterPresetById(world, primaryMonster.id);
|
||||
const hpRatio = primaryMonster.hp / Math.max(primaryMonster.maxHp, 1);
|
||||
const monsterProfile = primaryMonster.attributeProfile
|
||||
?? inferEncounterAttributeProfile(world, context, `monster:${primaryMonster.id}`, [
|
||||
monsterPreset?.description ?? primaryMonster.description,
|
||||
primaryMonster.action,
|
||||
]);
|
||||
|
||||
return [
|
||||
'当前面前实体:',
|
||||
`- 名称:${primaryMonster.name}`,
|
||||
'- 身份:当前最靠前的敌对目标',
|
||||
`- 描述:${monsterPreset?.description ?? primaryMonster.description}`,
|
||||
'- 性格:更接近本能性的压迫与试探,会按当前动作持续逼近你',
|
||||
`- 状态:生命状态 ${describeHpBand(hpRatio)},当前动作 ${primaryMonster.animation},朝向 ${describeFacing(primaryMonster.facing)}`,
|
||||
...describeAttributeProfileForPrompt('敌对实体', world, context, monsterProfile).map(line => `- ${line}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describePlayerState(world: WorldType, character: Character, context: StoryGenerationContext) {
|
||||
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
|
||||
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
|
||||
const sceneName = context.sceneName || '当前区域';
|
||||
const sceneDescription = context.sceneDescription || '此地仍有未知人物、敌对目标与机缘潜伏。';
|
||||
const schema = resolveAttributeSchema(world, context.customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(character, world, context.customWorldProfile);
|
||||
const playerBackstoryLines = describeBackstoryContext(
|
||||
'主角背景',
|
||||
[getCharacterPublicBackstorySummary(character, world)],
|
||||
);
|
||||
|
||||
return [
|
||||
`玩家状态:${context.inBattle ? '战斗状态' : '空闲状态'}`,
|
||||
`当前场景:${sceneName}`,
|
||||
`场景描述:${sceneDescription}`,
|
||||
context.lastObserveSignsReport ? `最近一次观察结果:${context.lastObserveSignsReport}` : null,
|
||||
`主角:${character.name},${character.title}`,
|
||||
`主角描述:${character.description}`,
|
||||
...playerBackstoryLines,
|
||||
`主角性格:${character.personality}`,
|
||||
...describePlayerOpeningByContext(character, world, context),
|
||||
`世界属性框架:${buildSchemaSummary(schema).map(slot => `${slot.name}:${slot.definition}`).join('、')}`,
|
||||
`主角状态:生命状态 ${describeHpBand(hpRatio)},灵力状态 ${describeManaBand(manaRatio)},整体判断 ${describeOverallBand(hpRatio, manaRatio)},朝向 ${describeFacing(context.playerFacing)},当前动作 ${context.playerAnimation}`,
|
||||
...describeAttributeProfileForPrompt('主角', world, context, attributeProfile),
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeMonsters(monsters: SceneMonster[]) {
|
||||
if (monsters.length === 0) {
|
||||
return '当前没有可见敌对目标。';
|
||||
}
|
||||
|
||||
return monsters
|
||||
.map(monster => {
|
||||
const hpRatio = monster.hp / Math.max(monster.maxHp, 1);
|
||||
return `敌对目标 ${monster.name}:生命状态 ${describeHpBand(hpRatio)},当前动作 ${monster.animation},行为“${monster.action}”,朝向 ${describeFacing(monster.facing)}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function _describeHistory(history: string[]) {
|
||||
if (history.length === 0) {
|
||||
return '最近剧情:暂无。';
|
||||
}
|
||||
|
||||
return `最近剧情:\n${history.slice(-6).map(item => `- ${item}`).join('\n')}`;
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: StoryMoment[]) {
|
||||
const promptHistory = buildStoryPromptHistory(history);
|
||||
|
||||
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
|
||||
return '最近剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
promptHistory.previousSummary
|
||||
? `3轮以前的历史剧情总结:\n${promptHistory.previousSummary}`
|
||||
: '3轮以前的历史剧情总结:暂无。',
|
||||
promptHistory.recentOriginalRounds.length > 0
|
||||
? `最近3轮剧情原文(续写时优先承接):\n${promptHistory.recentOriginalRounds
|
||||
.map((item, index) => `- 第${index + 1}轮\n${item}`)
|
||||
.join('\n')}`
|
||||
: '最近3轮剧情原文:暂无。',
|
||||
'续写时必须先承接“最近3轮剧情原文”,再与“3轮以前的历史剧情总结”保持一致,不得跳过已经发生的结果、地点、关系变化或战斗状态。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function _buildResolvedUserPrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
choice?: string,
|
||||
availableOptions?: StoryOption[],
|
||||
optionCatalog?: StoryOption[],
|
||||
) {
|
||||
const functionContext = {
|
||||
worldType: world,
|
||||
playerCharacter: character,
|
||||
inBattle: context.inBattle,
|
||||
currentSceneId: context.sceneId,
|
||||
currentSceneName: context.sceneName,
|
||||
monsters,
|
||||
playerHp: context.playerHp,
|
||||
playerMaxHp: context.playerMaxHp,
|
||||
playerMana: context.playerMana,
|
||||
playerMaxMana: context.playerMaxMana,
|
||||
};
|
||||
const scene = getScenePresetById(world, context.sceneId);
|
||||
const pendingEncounter = context.pendingSceneEncounter && !!scene;
|
||||
const hasProvidedOptions = (availableOptions?.length ?? 0) > 0;
|
||||
const _hasOptionCatalog = Boolean(optionCatalog && optionCatalog.length > 0);
|
||||
const hasProvidedNpcChatOptions = Boolean(availableOptions?.some(option => option.functionId === 'npc_chat'));
|
||||
const isOpeningCampDialogue = context.lastFunctionId === 'story_opening_camp_dialogue' && Boolean(context.encounterName);
|
||||
const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions
|
||||
&& Boolean(context.openingCampBackground?.trim())
|
||||
&& Boolean(context.openingCampDialogue?.trim());
|
||||
const sceneMonsterIds = scene?.monsterIds ?? [];
|
||||
const battleCatalog = scene
|
||||
? buildFunctionCatalogText({
|
||||
...functionContext,
|
||||
inBattle: true,
|
||||
monsters: createSceneMonstersFromIds(world, sceneMonsterIds, context.playerX),
|
||||
})
|
||||
: '';
|
||||
const idleCatalog = buildFunctionCatalogText({
|
||||
...functionContext,
|
||||
inBattle: false,
|
||||
monsters: [],
|
||||
});
|
||||
const observeSignsCatalog = context.observeSignsRequested
|
||||
? buildSceneEntityCatalogText(world, context.sceneId)
|
||||
: '';
|
||||
|
||||
const sections = [
|
||||
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
|
||||
describePlayerState(world, character, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
describeFrontEntity(world, context, monsters),
|
||||
describeConversationSituationDirective(context),
|
||||
describeEncounterConversationDirective(context),
|
||||
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
|
||||
describeSkills(character, context),
|
||||
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
|
||||
context.partyRelationshipNotes ? `同行角色补充关系信息:\n${context.partyRelationshipNotes}` : null,
|
||||
describeStoryHistory(history),
|
||||
hasOpeningCampFollowupContext ? `营地开场背景:\n${context.openingCampBackground}` : null,
|
||||
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
|
||||
choice ? `玩家刚刚选择:${choice}` : '玩家刚进入当前局面。',
|
||||
hasProvidedOptions
|
||||
? `固定可选项列表(必须保持数量与 functionId 一致,可按最近剧情重排顺序):\n${describeProvidedOptions(availableOptions ?? [])}`
|
||||
: pendingEncounter
|
||||
? `当前场景实体池:\n${buildSceneEntityCatalogText(world, context.sceneId)}`
|
||||
: `当前可执行 function:\n${buildFunctionCatalogText(functionContext)}`,
|
||||
hasProvidedOptions
|
||||
? '这些选项对应当前局面下真实可执行的本地规则。你必须严格保持数量不变、functionId 不变;可以依据最近剧情、刚刚发生的结果和当前轻重缓急重排顺序;然后按每个 function 的行为边界,自然重写更贴合当前局面和状态的中文 actionText,不要把它写成别的行为。'
|
||||
: pendingEncounter
|
||||
? `如果主角继续推进后遇到敌对目标,你必须只从以下战斗 function 中选择至少 6 个选项:\n${battleCatalog}`
|
||||
: '请只根据上面的当前状态继续推进这一幕,并输出紧接着发生的剧情文本与至少 6 个选项。',
|
||||
hasProvidedOptions
|
||||
? 'storyText 必须直接承接最近剧情与当前结果,让玩家看到这一动作之后局面的新变化。'
|
||||
: pendingEncounter
|
||||
? `如果主角遇到角色、宝藏或暂时什么都没遇到,你必须只从以下空闲 function 中选择至少 6 个选项:\n${idleCatalog}`
|
||||
: '这些选项必须全部从当前可执行 function 列表里选择。',
|
||||
hasProvidedOptions
|
||||
? '每个 function 后面的说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为;若重排顺序,必须让前面的选项更贴近最近剧情与当前局面的主导矛盾。'
|
||||
: '下方 function 说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为。',
|
||||
hasProvidedOptions || !pendingEncounter
|
||||
? null
|
||||
: '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId;若不是场景角色,则 options 必须使用空闲 function。',
|
||||
'当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。',
|
||||
'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。',
|
||||
];
|
||||
|
||||
if (context.observeSignsRequested) {
|
||||
sections.push(`当前动作是“停步观察动静”。你必须基于当前场景实体池进行推理,把可能出现的角色、敌对目标、BOSS 线索写进 storyText。这里是观察参考实体池:\n${observeSignsCatalog}`);
|
||||
sections.push('这一段重点是观察和判断,不是立刻推进遭遇。storyText 要写成后续还能继续引用的侦察结论。');
|
||||
}
|
||||
|
||||
if (isOpeningCampDialogue) {
|
||||
sections.push(`当前这一步是你与${context.encounterName}在营地里的开场聊天。storyText 必须直接写成刚刚发生的对话正文,每一行都必须以“你:”或“${context.encounterName}:”开头,总行数控制在 4 到 6 行。`);
|
||||
sections.push('这段开场白必须承接玩家刚踏入此界后的警觉、判断、保留动机、营地气氛和面前同伴的态度,让它成为后续剧情可继续引用的真实最近剧情,而不是旁白总结。');
|
||||
sections.push('玩家在第一轮里也只该表露表层钩子、眼前压力和试探态度,不要主动把完整目标、完整理由或全部过去一次说完。');
|
||||
}
|
||||
|
||||
if (hasOpeningCampFollowupContext) {
|
||||
sections.push('当前不是重新发明泛化选项,而是承接上面的营地开场背景和刚刚那段第一轮对话,整理角色眼下最自然会继续说或继续追问的话。');
|
||||
sections.push('如果固定项里包含两个 npc_chat,它们必须排在前两个位置;这两个 npc_chat 的 actionText 必须直接承接刚刚聊到的话头,体现追问、确认、延展或继续对接,而不是回到通用模板。');
|
||||
}
|
||||
|
||||
return sections.filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
function describeProvidedOptionCore(option: StoryOption) {
|
||||
const definition = getFunctionById(option.functionId);
|
||||
const definitionCore = definition?.description?.trim();
|
||||
const functionPromptDescription = getFunctionPromptDescription(option.functionId, definitionCore);
|
||||
|
||||
if (option.functionId === 'npc_preview_talk') {
|
||||
return '把注意力真正转到眼前这个角色身上,准备开始与其交谈;这是进入角色互动层,不是立刻完成一次聊天。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'chat') {
|
||||
return `和面前角色围绕当前这个话题切入点继续交谈,文案可以自然改写,但仍要保持这是聊天而不是别的行为。`;
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'trade') {
|
||||
return '和面前角色进行交易,可以写成更自然的买卖或交换表达,但仍要保持这是交易行为。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'help') {
|
||||
return '向面前角色寻求帮助或支援,但仍要保持这是求助行为。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'gift') {
|
||||
return '向面前角色送礼,以改善关系或表达诚意。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'recruit') {
|
||||
return '邀请面前角色加入队伍或同行,但仍要保持这是招募行为。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'quest_accept') {
|
||||
return '接受面前角色给出的委托或任务。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'quest_turn_in') {
|
||||
return '向面前角色交付已经完成的委托。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'leave') {
|
||||
return '结束与面前实体的当前互动,把注意力重新放回前路。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'fight') {
|
||||
return '与面前角色直接开战。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'spar') {
|
||||
return '与面前角色进行点到为止的切磋。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'treasure') {
|
||||
return option.interaction.action === 'inspect'
|
||||
? '先检查眼前目标的细节与风险,再决定如何收取。'
|
||||
: option.interaction.action === 'secure'
|
||||
? '直接收取眼前的目标,不再做额外停留。'
|
||||
: '暂时放过眼前目标,把注意力拉回当前环境。';
|
||||
}
|
||||
|
||||
return functionPromptDescription || option.detailText || option.actionText;
|
||||
}
|
||||
|
||||
function describeProvidedOptions(options: StoryOption[]) {
|
||||
return options
|
||||
.map((option, index) => {
|
||||
return `- 第 ${index + 1} 项 / ${option.functionId}:${describeProvidedOptionCore(option)}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildCatalogAwareUserPrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
choice?: string,
|
||||
availableOptions?: StoryOption[],
|
||||
optionCatalog?: StoryOption[],
|
||||
) {
|
||||
const functionContext = {
|
||||
worldType: world,
|
||||
playerCharacter: character,
|
||||
inBattle: context.inBattle,
|
||||
currentSceneId: context.sceneId,
|
||||
currentSceneName: context.sceneName,
|
||||
monsters,
|
||||
playerHp: context.playerHp,
|
||||
playerMaxHp: context.playerMaxHp,
|
||||
playerMana: context.playerMana,
|
||||
playerMaxMana: context.playerMaxMana,
|
||||
};
|
||||
const scene = getScenePresetById(world, context.sceneId);
|
||||
const pendingEncounter = context.pendingSceneEncounter && !!scene;
|
||||
const hasProvidedOptions = (availableOptions?.length ?? 0) > 0;
|
||||
const hasOptionCatalog = Boolean(optionCatalog && optionCatalog.length > 0);
|
||||
const hasProvidedNpcChatOptions = Boolean(availableOptions?.some(option => option.functionId === 'npc_chat'));
|
||||
const isOpeningCampDialogue = context.lastFunctionId === 'story_opening_camp_dialogue' && Boolean(context.encounterName);
|
||||
const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions
|
||||
&& Boolean(context.openingCampBackground?.trim())
|
||||
&& Boolean(context.openingCampDialogue?.trim());
|
||||
const battleCatalog = scene
|
||||
? buildFunctionCatalogText({
|
||||
...functionContext,
|
||||
inBattle: true,
|
||||
monsters: createSceneMonstersFromIds(world, scene?.monsterIds ?? [], context.playerX),
|
||||
})
|
||||
: '';
|
||||
const idleCatalog = buildFunctionCatalogText({
|
||||
...functionContext,
|
||||
inBattle: false,
|
||||
monsters: [],
|
||||
});
|
||||
const observeSignsCatalog = context.observeSignsRequested
|
||||
? buildSceneEntityCatalogText(world, context.sceneId)
|
||||
: '';
|
||||
|
||||
const sections = [
|
||||
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
|
||||
describePlayerState(world, character, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
describeFrontEntity(world, context, monsters),
|
||||
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
|
||||
describeSkills(character, context),
|
||||
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
|
||||
describeConversationSituationDirective(context),
|
||||
describeEncounterConversationDirective(context),
|
||||
describeFirstMeaningfulContactDirective(context),
|
||||
context.partyRelationshipNotes ? `同行角色补充关系信息:\n${context.partyRelationshipNotes}` : null,
|
||||
describeStoryHistory(history),
|
||||
hasOpeningCampFollowupContext ? `营地开场背景:\n${context.openingCampBackground}` : null,
|
||||
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
|
||||
choice ? `玩家刚刚选择:${choice}` : '玩家刚进入当前局面。',
|
||||
hasProvidedOptions
|
||||
? `固定可选项列表(必须保留数量与 functionId,可按最近剧情重排顺序):\n${describeProvidedOptions(availableOptions ?? [])}`
|
||||
: hasOptionCatalog
|
||||
? `当前局面可调用的交互选项目录(functionId 只能从这里选,但不需要保留原数量和顺序):\n${describeProvidedOptions(optionCatalog ?? [])}`
|
||||
: pendingEncounter
|
||||
? `当前场景实体池:\n${buildSceneEntityCatalogText(world, context.sceneId)}`
|
||||
: `当前可执行 function:\n${buildFunctionCatalogText(functionContext)}`,
|
||||
hasProvidedOptions
|
||||
? '这些选项对应当前局面下真实可执行的本地规则。你必须保持数量不变、functionId 不变;可以依据最近剧情、刚刚发生的结果和当前轻重缓急重排顺序,并自然重写更贴合当前局面和状态的中文 actionText。'
|
||||
: hasOptionCatalog
|
||||
? '上面的交互选项目录只是当前局面下合法可执行的 function 范围,不是固定模板。你不需要保留原数量、原顺序或原文案,但 options 里的 functionId 只能从这个目录里选择,并且要根据刚刚发生的结果、关系变化和眼前局面,自行决定最合理的选项组合。'
|
||||
: pendingEncounter
|
||||
? `如果主角继续推进后遇到敌对目标,你必须只从以下战斗 function 中选择至少 6 个选项:\n${battleCatalog}`
|
||||
: '请只根据上面的当前状态继续推进这一幕,并输出紧接着发生的剧情文本与至少 6 个选项。',
|
||||
hasProvidedOptions
|
||||
? 'storyText 必须直接承接最近剧情与当前结果,让玩家看到这一动作之后局面的新变化。'
|
||||
: hasOptionCatalog
|
||||
? '请只根据上面的当前状态继续推进这一幕,输出紧接着发生的剧情文本与至少 6 个选项;如果目录里本身不足 6 个 function,就优先覆盖当前最重要的合法 function。'
|
||||
: pendingEncounter
|
||||
? `如果主角遇到角色、宝藏或暂时什么都没遇到,你必须只从以下空闲 function 中选择至少 6 个选项:\n${idleCatalog}`
|
||||
: '这些选项必须全部从当前可执行 function 列表里选择。',
|
||||
hasProvidedOptions
|
||||
? '每个 function 后面的说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为;若重排顺序,必须让前面的选项更贴近最近剧情与当前局面的主导矛盾。'
|
||||
: hasOptionCatalog
|
||||
? '目录里每个 function 后面的说明都是行为边界。actionText 可以自然改写,也可以只挑当前最合理的那部分 function;但不能输出目录外的 function,也不能把某个 function 写成别的行为。'
|
||||
: '下方 function 说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为。',
|
||||
hasProvidedOptions || hasOptionCatalog || !pendingEncounter
|
||||
? null
|
||||
: '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId;若不是场景角色,则 options 必须使用空闲 function。',
|
||||
'当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。',
|
||||
'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。',
|
||||
];
|
||||
|
||||
if (context.observeSignsRequested) {
|
||||
sections.push(`当前动作是“停步观察动静”。你必须基于当前场景实体池进行推理,把可能出现的角色、敌对目标、BOSS 线索写进 storyText。这里是观察参考实体池:\n${observeSignsCatalog}`);
|
||||
sections.push('这一段重点是观察和判断,不是立刻推进遭遇。storyText 要写成后续还能继续引用的侦察结论。');
|
||||
}
|
||||
|
||||
if (isOpeningCampDialogue) {
|
||||
sections.push(`当前这一步是你与${context.encounterName}在营地里的开场聊天。storyText 必须直接写成刚刚发生的对话正文,每一行都必须以“你:”或“${context.encounterName}:”开头,总行数控制在 4 到 6 行。`);
|
||||
sections.push('这段开场白必须承接玩家刚踏入此界后的警觉、判断、保留动机、营地气氛和面前同伴的态度,让它成为后续剧情可继续引用的真实最近剧情,而不是旁白总结。');
|
||||
sections.push('玩家在第一轮里也只该表露表层钩子、眼前压力和试探态度,不要主动把完整目标、完整理由或全部过去一次说完。');
|
||||
}
|
||||
|
||||
if (hasOpeningCampFollowupContext) {
|
||||
sections.push('当前不是重新发明泛化选项,而是承接上面的营地开场背景和刚刚那段第一轮对话,整理角色眼下最自然会继续说或继续追问的话。');
|
||||
sections.push('如果固定项里包含两个 npc_chat,它们必须排在前两个位置;这两个 npc_chat 的 actionText 必须直接承接刚刚聊到的话头,体现追问、确认、延展或继续对接,而不是回到通用模板。');
|
||||
}
|
||||
|
||||
return sections.filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildUserPrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
choice?: string,
|
||||
availableOptions?: StoryOption[],
|
||||
optionCatalog?: StoryOption[],
|
||||
) {
|
||||
return buildCatalogAwareUserPrompt(world, character, monsters, history, context, choice, availableOptions, optionCatalog);
|
||||
}
|
||||
|
||||
function buildResolvedNpcChatDialoguePrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounterName: string,
|
||||
monsters: SceneMonster[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
resultSummary: string,
|
||||
) {
|
||||
return [
|
||||
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
|
||||
describePlayerState(world, character, context),
|
||||
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
describeFrontEntity(world, context, monsters),
|
||||
`当前面前实体性别:${describeGender(getEncounterGender(context))}`,
|
||||
describeSkills(character, context),
|
||||
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
|
||||
describeStoryHistory(history),
|
||||
context.openingCampBackground ? `营地开场背景:\n${context.openingCampBackground}` : null,
|
||||
context.openingCampDialogue ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
|
||||
`当前交谈对象:${encounterName}`,
|
||||
`聊天主题:${topic}`,
|
||||
`关系变化结果:${resultSummary}`,
|
||||
describeConversationSituationDirective(context),
|
||||
describeEncounterConversationDirective(context),
|
||||
describeFirstMeaningfulContactDirective(context),
|
||||
context.openingCampBackground && context.openingCampDialogue
|
||||
? '这段 npc_chat 必须承接上面的营地开场背景和第一段对话,像同一段谈话自然往下推进,不要把语气和话题重置成初见模板。'
|
||||
: null,
|
||||
`请围绕“${topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounterName}:”开头。`,
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
function buildNpcChatDialoguePrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounterName: string,
|
||||
monsters: SceneMonster[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
resultSummary: string,
|
||||
) {
|
||||
return buildResolvedNpcChatDialoguePrompt(
|
||||
world,
|
||||
character,
|
||||
encounterName,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStrictNpcChatDialoguePrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: { npcName: string },
|
||||
monsters: SceneMonster[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
resultSummary: string,
|
||||
) {
|
||||
return [
|
||||
buildNpcChatDialoguePrompt(world, character, encounter.npcName, monsters, history, context, topic, resultSummary),
|
||||
'补充硬约束:这段内容只是聊天,不是做决定。',
|
||||
'不要让对方在聊天里推进交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。',
|
||||
'不要替玩家做选择,不要用建议句、命令句或诱导句把聊天写成别的行为入口。',
|
||||
'低揭示阶段时,宁可留钩子、先谈眼前局势,也不要把完整来历和目标一次说完。',
|
||||
'如果当前情景是初见或刚打完一轮冲突,优先写短句、观察句和试探句,不要写成正式自我介绍。',
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcRecruitDialoguePrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: { npcName: string },
|
||||
monsters: SceneMonster[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
invitationText: string,
|
||||
recruitSummary: string,
|
||||
) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describePlayerState(world, character, context),
|
||||
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
describeFrontEntity(world, context, monsters),
|
||||
`当前招募对象性别:${describeGender(getEncounterGender(context))}`,
|
||||
describeSkills(character, context),
|
||||
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
|
||||
describeStoryHistory(history),
|
||||
`当前招募对象:${encounter.npcName}`,
|
||||
`玩家邀请:${invitationText}`,
|
||||
`招募补充条件:${recruitSummary}`,
|
||||
describeConversationSituationDirective(context),
|
||||
describeEncounterConversationDirective(context),
|
||||
describeFirstMeaningfulContactDirective(context),
|
||||
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
|
||||
'对方可以谨慎确认,但对话末尾必须明确答应加入,不能把结论停在犹豫、保留或回避上。',
|
||||
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
|
||||
].join('\n\n');
|
||||
}
|
||||
192
src/services/questDirector.ts
Normal file
192
src/services/questDirector.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {getNpcDisclosureStage, getNpcWarmthStage} from '../data/npcInteractions';
|
||||
import {
|
||||
buildFallbackQuestIntent,
|
||||
compileQuestIntentToQuest,
|
||||
evaluateQuestOpportunity,
|
||||
} from '../data/questFlow';
|
||||
import type {
|
||||
Encounter,
|
||||
GameState,
|
||||
QuestLogEntry,
|
||||
} from '../types';
|
||||
import type {QuestGenerationContext} from './aiTypes';
|
||||
import {requestChatMessageContent} from './llmClient';
|
||||
import {parseJsonResponseText} from './llmParsers';
|
||||
import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt';
|
||||
import type {QuestIntent, QuestPreviewRequest} from './questTypes';
|
||||
|
||||
const QUEST_DIRECTOR_TIMEOUT_MS = 12000;
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function coerceStringArray(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const items = value
|
||||
.map(item => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
return items.length > 0 ? items : fallback;
|
||||
}
|
||||
|
||||
function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent {
|
||||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const intent = rawIntent as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
title: coerceString(intent.title, fallback.title),
|
||||
description: coerceString(intent.description, fallback.description),
|
||||
summary: coerceString(intent.summary, fallback.summary),
|
||||
narrativeType: (
|
||||
typeof intent.narrativeType === 'string'
|
||||
&& ['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType)
|
||||
)
|
||||
? intent.narrativeType as QuestIntent['narrativeType']
|
||||
: fallback.narrativeType,
|
||||
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
|
||||
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
|
||||
playerHook: coerceString(intent.playerHook, fallback.playerHook),
|
||||
worldReason: coerceString(intent.worldReason, fallback.worldReason),
|
||||
recommendedObjectiveKinds: coerceStringArray(intent.recommendedObjectiveKinds, fallback.recommendedObjectiveKinds)
|
||||
.filter(kind => [
|
||||
'defeat_hostile_npc',
|
||||
'inspect_treasure',
|
||||
'spar_with_npc',
|
||||
'talk_to_npc',
|
||||
'reach_scene',
|
||||
'deliver_item',
|
||||
].includes(kind)) as QuestIntent['recommendedObjectiveKinds'],
|
||||
urgency: (
|
||||
typeof intent.urgency === 'string'
|
||||
&& ['low', 'medium', 'high'].includes(intent.urgency)
|
||||
)
|
||||
? intent.urgency as QuestIntent['urgency']
|
||||
: fallback.urgency,
|
||||
intimacy: (
|
||||
typeof intent.intimacy === 'string'
|
||||
&& ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
|
||||
)
|
||||
? intent.intimacy as QuestIntent['intimacy']
|
||||
: fallback.intimacy,
|
||||
rewardTheme: (
|
||||
typeof intent.rewardTheme === 'string'
|
||||
&& ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme)
|
||||
)
|
||||
? intent.rewardTheme as QuestIntent['rewardTheme']
|
||||
: fallback.rewardTheme,
|
||||
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQuestGenerationContextFromState(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
}): QuestGenerationContext {
|
||||
const {state, encounter} = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const issuerState = state.npcStates[issuerNpcId];
|
||||
|
||||
return {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
currentSceneDescription: state.currentScenePreset?.description ?? null,
|
||||
issuerNpcId,
|
||||
issuerNpcName: encounter.npcName,
|
||||
issuerNpcContext: encounter.context,
|
||||
issuerAffinity: issuerState?.affinity ?? 0,
|
||||
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
|
||||
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
|
||||
encounterKind: encounter.kind ?? 'npc',
|
||||
currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0,
|
||||
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])
|
||||
.filter(npc => Boolean(npc.hostile || npc.monsterPresetId))
|
||||
.map(npc => npc.id),
|
||||
recentStoryMoments: state.storyHistory.slice(-6),
|
||||
playerCharacter: state.playerCharacter,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
playerInventory: state.playerInventory,
|
||||
playerEquipment: state.playerEquipment,
|
||||
activeCompanions: state.companions,
|
||||
rosterCompanions: state.roster,
|
||||
currentQuestSummary: state.quests.map(quest => ({
|
||||
id: quest.id,
|
||||
title: quest.title,
|
||||
status: quest.status,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateQuestForNpcEncounter(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
}): Promise<QuestLogEntry | null> {
|
||||
const {state, encounter} = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const request: QuestPreviewRequest = {
|
||||
issuerNpcId,
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
currentQuests: state.quests.map(quest => ({
|
||||
id: quest.id,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
status: quest.status,
|
||||
})),
|
||||
context: buildQuestGenerationContextFromState({state, encounter}),
|
||||
origin: 'ai_compiled',
|
||||
};
|
||||
const opportunity = evaluateQuestOpportunity(request);
|
||||
if (!opportunity.shouldOffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackIntent = buildFallbackQuestIntent(request);
|
||||
|
||||
try {
|
||||
const content = await requestChatMessageContent(
|
||||
QUEST_INTENT_SYSTEM_PROMPT,
|
||||
buildQuestIntentPrompt({
|
||||
context: request.context!,
|
||||
scene: request.scene,
|
||||
opportunity,
|
||||
}),
|
||||
{
|
||||
timeoutMs: QUEST_DIRECTOR_TIMEOUT_MS,
|
||||
debugLabel: 'quest-intent',
|
||||
},
|
||||
);
|
||||
const parsed = parseJsonResponseText(content) as {intent?: unknown};
|
||||
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'ai_compiled',
|
||||
},
|
||||
intent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[QuestDirector] falling back to deterministic quest intent', error);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'fallback_builder',
|
||||
},
|
||||
fallbackIntent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
124
src/services/questPrompt.ts
Normal file
124
src/services/questPrompt.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type {QuestGenerationContext} from './aiTypes';
|
||||
import type {QuestOpportunity, QuestSceneSnapshot} from './questTypes';
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '武侠';
|
||||
case 'XIANXIA':
|
||||
return '仙侠';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
|
||||
const moments = context.recentStoryMoments
|
||||
.slice(-4)
|
||||
.map(moment => `- ${moment.text}`)
|
||||
.join('\n');
|
||||
|
||||
return moments || '- 暂无近期剧情记录';
|
||||
}
|
||||
|
||||
function summarizeCurrentQuests(context: QuestGenerationContext) {
|
||||
const summary = context.currentQuestSummary?.map(quest =>
|
||||
`- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`,
|
||||
).join('\n');
|
||||
|
||||
return summary || '- 当前没有进行中的任务';
|
||||
}
|
||||
|
||||
function summarizeCompanions(context: QuestGenerationContext) {
|
||||
const active = context.activeCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
const roster = context.rosterCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
return `当前同行角色:${active}\n队伍名册:${roster}`;
|
||||
}
|
||||
|
||||
function summarizePlayerState(context: QuestGenerationContext) {
|
||||
const playerName = context.playerCharacter?.name ?? '未知角色';
|
||||
const playerTitle = context.playerCharacter?.title ?? '未知称号';
|
||||
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
|
||||
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
|
||||
const inventory = context.playerInventory?.slice(0, 8).map(item => item.name).join('、') || '无';
|
||||
|
||||
return [
|
||||
`玩家:${playerName}(${playerTitle})`,
|
||||
`生命:${hp}`,
|
||||
`灵力:${mana}`,
|
||||
`背包快照:${inventory}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeScene(scene: QuestSceneSnapshot | null, context: QuestGenerationContext) {
|
||||
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
|
||||
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
|
||||
|
||||
return [
|
||||
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
|
||||
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
|
||||
`敌对角色 ID:${hostileNpcIds}`,
|
||||
`宝藏线索数量:${treasureHintCount}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
|
||||
只返回 JSON,不要输出 Markdown。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intent": {
|
||||
"title": "中文任务标题",
|
||||
"description": "中文任务描述",
|
||||
"summary": "中文短摘要",
|
||||
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
|
||||
"dramaticNeed": "string",
|
||||
"issuerGoal": "string",
|
||||
"playerHook": "string",
|
||||
"worldReason": "string",
|
||||
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
|
||||
"urgency": "low|medium|high",
|
||||
"intimacy": "transactional|cooperative|trust_based",
|
||||
"rewardTheme": "currency|resource|relationship|intel|rare_item",
|
||||
"followupHooks": ["string"]
|
||||
}
|
||||
}
|
||||
|
||||
规则:
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 任务必须扎根于当前场景、发布者和近期剧情。
|
||||
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
|
||||
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
|
||||
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
|
||||
- 如果当前场景存在威胁或异常,任务应当自然从该局势中生长出来。`;
|
||||
|
||||
export function buildQuestIntentPrompt(params: {
|
||||
context: QuestGenerationContext;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
opportunity: QuestOpportunity;
|
||||
}) {
|
||||
const {context, scene, opportunity} = params;
|
||||
const customWorldSummary = context.customWorldProfile
|
||||
? `${context.customWorldProfile.name}: ${context.customWorldProfile.summary}`
|
||||
: '无';
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(context.worldType)}`,
|
||||
`自定义世界摘要:${customWorldSummary}`,
|
||||
`发布角色:${context.issuerNpcName ?? '未知'}(${context.issuerNpcId ?? '未知'})`,
|
||||
`发布者身份:${context.issuerNpcContext ?? '暂无'}`,
|
||||
`发布者好感:${context.issuerAffinity ?? 0}`,
|
||||
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
|
||||
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
summarizeCompanions(context),
|
||||
`当前任务机会:${opportunity.reason}`,
|
||||
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
|
||||
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
|
||||
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
|
||||
].join('\n\n');
|
||||
}
|
||||
87
src/services/questTypes.ts
Normal file
87
src/services/questTypes.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
QuestNarrativeBinding,
|
||||
QuestNarrativeType,
|
||||
QuestObjectiveKind,
|
||||
QuestReward,
|
||||
QuestStatus,
|
||||
QuestStep,
|
||||
ScenePresetInfo,
|
||||
} from '../types';
|
||||
import type {QuestGenerationContext} from './aiTypes';
|
||||
|
||||
export type QuestUrgency = 'low' | 'medium' | 'high';
|
||||
export type QuestIntimacy = 'transactional' | 'cooperative' | 'trust_based';
|
||||
export type QuestRewardTheme = 'currency' | 'resource' | 'relationship' | 'intel' | 'rare_item';
|
||||
export type QuestFailPolicy = 'never' | 'leave_scene' | 'issuer_hostile' | 'time_window';
|
||||
|
||||
export type QuestSceneSnapshot = Pick<
|
||||
ScenePresetInfo,
|
||||
'id' | 'name' | 'hostileNpcIds' | 'monsterIds' | 'npcs' | 'treasureHints'
|
||||
> & {
|
||||
description?: ScenePresetInfo['description'];
|
||||
};
|
||||
|
||||
export interface QuestIntent {
|
||||
title: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
narrativeType: QuestNarrativeType;
|
||||
dramaticNeed: string;
|
||||
issuerGoal: string;
|
||||
playerHook: string;
|
||||
worldReason: string;
|
||||
recommendedObjectiveKinds: QuestObjectiveKind[];
|
||||
urgency: QuestUrgency;
|
||||
intimacy: QuestIntimacy;
|
||||
rewardTheme: QuestRewardTheme;
|
||||
followupHooks: string[];
|
||||
}
|
||||
|
||||
export interface QuestContract {
|
||||
id: string;
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
sceneId: string | null;
|
||||
questArchetype: QuestNarrativeType;
|
||||
title: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
steps: QuestStep[];
|
||||
reward: QuestReward;
|
||||
rewardText: string;
|
||||
narrativeBinding: QuestNarrativeBinding;
|
||||
failPolicy: QuestFailPolicy;
|
||||
}
|
||||
|
||||
export interface QuestOpportunity {
|
||||
shouldOffer: boolean;
|
||||
reason: string;
|
||||
suggestedIssuerNpcId?: string;
|
||||
suggestedThreatType?: 'hostile_npc' | 'treasure' | 'relationship' | 'travel';
|
||||
}
|
||||
|
||||
export type QuestProgressSignal =
|
||||
| {kind: 'hostile_npc_defeated'; sceneId?: string | null; hostileNpcId: string}
|
||||
| {kind: 'treasure_inspected'; sceneId?: string | null}
|
||||
| {kind: 'npc_spar_completed'; npcId: string}
|
||||
| {kind: 'npc_talk_completed'; npcId: string}
|
||||
| {kind: 'scene_reached'; sceneId: string}
|
||||
| {kind: 'item_delivered'; npcId: string; itemId: string; quantity: number};
|
||||
|
||||
export interface QuestCompilationRequest {
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
roleText: string;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
worldType: QuestGenerationContext['worldType'];
|
||||
context?: QuestGenerationContext;
|
||||
origin?: QuestNarrativeBinding['origin'];
|
||||
}
|
||||
|
||||
export interface QuestPreviewRequest extends QuestCompilationRequest {
|
||||
currentQuests?: Array<{
|
||||
id: string;
|
||||
issuerNpcId: string;
|
||||
status: QuestStatus;
|
||||
}>;
|
||||
}
|
||||
177
src/services/storyHistory.ts
Normal file
177
src/services/storyHistory.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { StoryHistoryRole, StoryMoment, StoryOption } from '../types';
|
||||
|
||||
const RECENT_ROUND_COUNT = 3;
|
||||
const MAX_SUMMARY_GROUPS = 6;
|
||||
const ACTION_SUMMARY_LIMIT = 24;
|
||||
const RESULT_SUMMARY_LIMIT = 80;
|
||||
|
||||
type StoryHistoryRound = {
|
||||
actionText: string | null;
|
||||
resultTexts: string[];
|
||||
};
|
||||
|
||||
export interface StoryPromptHistory {
|
||||
recentOriginalRounds: string[];
|
||||
previousSummary: string | null;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(text: string) {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function truncateText(text: string, limit: number) {
|
||||
if (text.length <= limit) return text;
|
||||
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function hasRoundContent(round: StoryHistoryRound) {
|
||||
return Boolean(round.actionText) || round.resultTexts.length > 0;
|
||||
}
|
||||
|
||||
function resolveHistoryRole(entry: StoryMoment, index: number): StoryHistoryRole {
|
||||
if (entry.historyRole) {
|
||||
return entry.historyRole;
|
||||
}
|
||||
|
||||
return index % 2 === 0 ? 'action' : 'result';
|
||||
}
|
||||
|
||||
function buildStoryRounds(history: StoryMoment[]): StoryHistoryRound[] {
|
||||
const rounds: StoryHistoryRound[] = [];
|
||||
let currentRound: StoryHistoryRound | null = null;
|
||||
|
||||
for (const [index, entry] of history.entries()) {
|
||||
const text = entry.text.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const historyRole = resolveHistoryRole(entry, index);
|
||||
|
||||
if (historyRole === 'action') {
|
||||
if (currentRound && hasRoundContent(currentRound)) {
|
||||
rounds.push(currentRound);
|
||||
}
|
||||
|
||||
currentRound = {
|
||||
actionText: text,
|
||||
resultTexts: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentRound) {
|
||||
currentRound = {
|
||||
actionText: null,
|
||||
resultTexts: [],
|
||||
};
|
||||
}
|
||||
|
||||
currentRound.resultTexts.push(text);
|
||||
}
|
||||
|
||||
if (currentRound && hasRoundContent(currentRound)) {
|
||||
rounds.push(currentRound);
|
||||
}
|
||||
|
||||
return rounds;
|
||||
}
|
||||
|
||||
function formatRoundOriginal(round: StoryHistoryRound) {
|
||||
const resultText = round.resultTexts.join('\n').trim() || '本轮没有明显的结果文本。';
|
||||
|
||||
return [
|
||||
round.actionText
|
||||
? `玩家行动:${round.actionText}`
|
||||
: '玩家行动:本轮没有明确的行动文本。',
|
||||
`剧情结果:${resultText}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeRound(round: StoryHistoryRound) {
|
||||
const actionText = round.actionText ? normalizeWhitespace(round.actionText) : '';
|
||||
const resultText = normalizeWhitespace(round.resultTexts.join(' '));
|
||||
|
||||
if (actionText && resultText) {
|
||||
return `玩家选择了“${truncateText(actionText, ACTION_SUMMARY_LIMIT)}”,随后${truncateText(resultText, RESULT_SUMMARY_LIMIT)}`;
|
||||
}
|
||||
|
||||
if (resultText) {
|
||||
return truncateText(resultText, RESULT_SUMMARY_LIMIT);
|
||||
}
|
||||
|
||||
if (actionText) {
|
||||
return `玩家选择了“${truncateText(actionText, ACTION_SUMMARY_LIMIT)}”。`;
|
||||
}
|
||||
|
||||
return '这一轮没有留下可用的剧情文本。';
|
||||
}
|
||||
|
||||
function _summarizeRoundGroup(rounds: StoryHistoryRound[]) {
|
||||
const firstRound = rounds[0];
|
||||
if (!firstRound) {
|
||||
return '没有可供总结的剧情记录。';
|
||||
}
|
||||
|
||||
if (rounds.length === 1) {
|
||||
return summarizeRound(firstRound);
|
||||
}
|
||||
|
||||
const secondRound = rounds[1];
|
||||
if (rounds.length === 2 && secondRound) {
|
||||
return `${summarizeRound(firstRound)} ${summarizeRound(secondRound)}`;
|
||||
}
|
||||
|
||||
const lastRound = rounds[rounds.length - 1] ?? firstRound;
|
||||
return `${summarizeRound(firstRound)} 之后又推进了${rounds.length - 2}轮相关剧情。${summarizeRound(lastRound)}`;
|
||||
}
|
||||
|
||||
function summarizeOlderRounds(rounds: StoryHistoryRound[]) {
|
||||
if (rounds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupSize = Math.max(1, Math.ceil(rounds.length / MAX_SUMMARY_GROUPS));
|
||||
const summaryLines: string[] = [];
|
||||
|
||||
for (let index = 0; index < rounds.length; index += groupSize) {
|
||||
const group = rounds.slice(index, index + groupSize);
|
||||
summaryLines.push(`- ${group.map(summarizeRound).join(' ')}`);
|
||||
}
|
||||
|
||||
return summaryLines.join('\n');
|
||||
}
|
||||
|
||||
function summarizeOlderRoundsCompact(rounds: StoryHistoryRound[]) {
|
||||
return summarizeOlderRounds(rounds);
|
||||
}
|
||||
|
||||
export function createHistoryMoment(
|
||||
text: string,
|
||||
historyRole: StoryHistoryRole,
|
||||
options: StoryOption[] = [],
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
historyRole,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStoryPromptHistory(history: StoryMoment[]): StoryPromptHistory {
|
||||
const rounds = buildStoryRounds(history);
|
||||
if (rounds.length === 0) {
|
||||
return {
|
||||
recentOriginalRounds: [],
|
||||
previousSummary: null,
|
||||
};
|
||||
}
|
||||
|
||||
const recentRounds = rounds.slice(-RECENT_ROUND_COUNT);
|
||||
const previousRounds = rounds.slice(0, -RECENT_ROUND_COUNT);
|
||||
|
||||
return {
|
||||
recentOriginalRounds: recentRounds.map(formatRoundOriginal),
|
||||
previousSummary: summarizeOlderRoundsCompact(previousRounds),
|
||||
};
|
||||
}
|
||||
6
src/services/typewriter.ts
Normal file
6
src/services/typewriter.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function getTypewriterDelay(char: string) {
|
||||
if (/[。!??!]/u.test(char)) return 240;
|
||||
if (/[,、;:,;:]/u.test(char)) return 150;
|
||||
if (/\s/u.test(char)) return 45;
|
||||
return 90;
|
||||
}
|
||||
Reference in New Issue
Block a user