1
This commit is contained in:
471
server-node/src/prompts/chatPromptBuilders.ts
Normal file
471
server-node/src/prompts/chatPromptBuilders.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcChatTurnRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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 = `总结玩家与这名角色之间不断变化的关系。
|
||||
只输出一段简洁文字。
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
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 NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
|
||||
你只输出这名 NPC 此刻会对玩家说的一轮回复。
|
||||
只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
|
||||
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
|
||||
|
||||
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
|
||||
只输出纯文本,共 3 行,每行 1 条。
|
||||
不要加编号、项目符号、Markdown、JSON 或额外说明。
|
||||
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.map((item) => readString(item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
function describeWorld(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return worldType || '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function describeStats(label: string, record: JsonRecord | null) {
|
||||
const hp = readNumber(record?.hp);
|
||||
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
|
||||
const mana = readNumber(record?.mana);
|
||||
const maxMana = Math.max(1, readNumber(record?.maxMana, mana));
|
||||
|
||||
return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`;
|
||||
}
|
||||
|
||||
function describeCharacter(label: string, value: unknown) {
|
||||
const record = asRecord(value);
|
||||
const name = readString(record?.name) ?? '未知角色';
|
||||
const title = readString(record?.title) ?? '未知称号';
|
||||
const description = readString(record?.description) ?? '暂无额外描述';
|
||||
const personality = readString(record?.personality) ?? '性格信息未显式提供';
|
||||
|
||||
return [
|
||||
`${label}姓名:${name}`,
|
||||
`${label}称号:${title}`,
|
||||
`${label}描述:${description}`,
|
||||
`${label}性格:${personality}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: unknown) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
const lines = history
|
||||
.slice(-4)
|
||||
.map((item) => readString(asRecord(item)?.text))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n')
|
||||
: '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
function describeConversationHistory(history: unknown) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
const lines = history
|
||||
.slice(-12)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色';
|
||||
const text = readString(record?.text);
|
||||
|
||||
return text ? `- ${speaker}:${text}` : null;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['聊天记录:', ...lines].join('\n')
|
||||
: '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
function describeNpcConversationHistory(history: unknown, npcName: string) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return '当前聊天记录:暂无。';
|
||||
}
|
||||
|
||||
const lines = history
|
||||
.slice(-10)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const speaker = readString(record?.speaker);
|
||||
const speakerName = readString(record?.speakerName);
|
||||
const text = readString(record?.text);
|
||||
if (!text) return null;
|
||||
|
||||
if (speaker === 'player') {
|
||||
return `- 玩家:${text}`;
|
||||
}
|
||||
|
||||
if (speaker === 'npc') {
|
||||
return `- ${speakerName ?? npcName}:${text}`;
|
||||
}
|
||||
|
||||
if (speaker === 'system') {
|
||||
return `- 系统提示:${text}`;
|
||||
}
|
||||
|
||||
return `- ${speakerName ?? '同伴'}:${text}`;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['当前聊天记录:', ...lines].join('\n')
|
||||
: '当前聊天记录:暂无。';
|
||||
}
|
||||
|
||||
function describeSceneContext(context: unknown) {
|
||||
const record = asRecord(context);
|
||||
const sceneName = readString(record?.sceneName) ?? '当前区域';
|
||||
const sceneDescription =
|
||||
readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。';
|
||||
const inBattle = record?.inBattle === true ? '战斗中' : '非战斗';
|
||||
const customWorldProfile = asRecord(record?.customWorldProfile);
|
||||
const customWorldName = readString(customWorldProfile?.name);
|
||||
const customWorldSummary = readString(customWorldProfile?.summary);
|
||||
|
||||
return [
|
||||
`世界补充:${customWorldName ?? '无'}`,
|
||||
customWorldSummary ? `世界摘要:${customWorldSummary}` : null,
|
||||
`场景:${sceneName}`,
|
||||
`场景描述:${sceneDescription}`,
|
||||
`当前状态:${inBattle}`,
|
||||
describeStats('玩家', record),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeTargetStatus(status: unknown) {
|
||||
const record = asRecord(status);
|
||||
const roleLabel = readString(record?.roleLabel) ?? '同行角色';
|
||||
const affinity = record?.affinity;
|
||||
|
||||
return [
|
||||
`对方身份:${roleLabel}`,
|
||||
describeStats('对方', record),
|
||||
typeof affinity === 'number' ? `当前好感:${affinity}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeEncounter(encounter: unknown) {
|
||||
const record = asRecord(encounter);
|
||||
const npcName = readString(record?.npcName) ?? '眼前角色';
|
||||
const contextText =
|
||||
readString(record?.context) ??
|
||||
readString(record?.npcDescription) ??
|
||||
'你们正在当前遭遇里继续对话。';
|
||||
|
||||
return {
|
||||
npcName,
|
||||
block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function describeMonsters(monsters: unknown) {
|
||||
if (!Array.isArray(monsters) || monsters.length === 0) {
|
||||
return '当前敌对目标:无。';
|
||||
}
|
||||
|
||||
const lines = monsters
|
||||
.slice(0, 4)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const name =
|
||||
readString(record?.name) ??
|
||||
readString(record?.npcName) ??
|
||||
readString(record?.id);
|
||||
const hp = readNumber(record?.hp);
|
||||
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
|
||||
|
||||
return name ? `- ${name}(生命 ${hp}/${maxHp})` : null;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['当前敌对目标:', ...lines].join('\n')
|
||||
: '当前敌对目标:无。';
|
||||
}
|
||||
|
||||
function describeTargetCharacterName(payload: {
|
||||
targetCharacter?: unknown;
|
||||
encounter?: unknown;
|
||||
}) {
|
||||
return (
|
||||
readString(asRecord(payload.targetCharacter)?.name) ??
|
||||
readString(asRecord(payload.encounter)?.npcName) ??
|
||||
'对方'
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatPrompt(
|
||||
payload: CharacterChatReplyRequest,
|
||||
) {
|
||||
const targetName = describeTargetCharacterName(payload);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.playerCharacter),
|
||||
describeCharacter('对方 / ', payload.targetCharacter),
|
||||
describeTargetStatus(payload.targetStatus),
|
||||
describeStoryHistory(payload.storyHistory),
|
||||
payload.conversationSummary
|
||||
? `之前聊天摘要:${payload.conversationSummary}`
|
||||
: '之前聊天摘要:暂无。',
|
||||
describeConversationHistory(payload.conversationHistory),
|
||||
`玩家刚刚对 ${targetName} 说:${payload.playerMessage}`,
|
||||
`现在请以 ${targetName} 的身份,直接回复玩家。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSuggestionPrompt(
|
||||
payload: CharacterChatSuggestionsRequest,
|
||||
) {
|
||||
const targetName = describeTargetCharacterName(payload);
|
||||
const latestCharacterReply = Array.isArray(payload.conversationHistory)
|
||||
? [...payload.conversationHistory]
|
||||
.reverse()
|
||||
.map((item) => asRecord(item))
|
||||
.find((record) => readString(record?.speaker) === 'character')
|
||||
: null;
|
||||
const latestReplyText = readString(latestCharacterReply?.text);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.playerCharacter),
|
||||
describeCharacter('对方 / ', payload.targetCharacter),
|
||||
describeTargetStatus(payload.targetStatus),
|
||||
describeStoryHistory(payload.storyHistory),
|
||||
payload.conversationSummary
|
||||
? `之前聊天摘要:${payload.conversationSummary}`
|
||||
: '之前聊天摘要:暂无。',
|
||||
describeConversationHistory(payload.conversationHistory),
|
||||
latestReplyText
|
||||
? `角色刚刚的回复:${latestReplyText}`
|
||||
: `玩家正准备与 ${targetName} 开始一段新的私聊。`,
|
||||
`请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSummaryPrompt(
|
||||
payload: CharacterChatSummaryRequest,
|
||||
) {
|
||||
const targetName = describeTargetCharacterName(payload);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.playerCharacter),
|
||||
describeCharacter('对方 / ', payload.targetCharacter),
|
||||
describeTargetStatus(payload.targetStatus),
|
||||
describeStoryHistory(payload.storyHistory),
|
||||
payload.previousSummary
|
||||
? `旧摘要:${payload.previousSummary}`
|
||||
: '旧摘要:暂无。',
|
||||
describeConversationHistory(payload.conversationHistory),
|
||||
`请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function buildNpcDialoguePromptBase(
|
||||
payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const character =
|
||||
(payload as NpcChatTurnRequest).character ??
|
||||
(payload as NpcChatTurnRequest).player;
|
||||
if (!(payload as NpcChatTurnRequest).character && character) {
|
||||
(payload as NpcChatTurnRequest).character = character;
|
||||
}
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.character),
|
||||
encounter.block,
|
||||
describeMonsters(payload.monsters),
|
||||
describeStoryHistory(payload.history),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildStrictNpcChatDialoguePrompt(
|
||||
payload: NpcChatDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const context = asRecord(payload.context);
|
||||
const openingCampBackground = readString(context?.openingCampBackground);
|
||||
const openingCampDialogue = readString(context?.openingCampDialogue);
|
||||
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
|
||||
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
|
||||
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
|
||||
allowedTopics.length > 0
|
||||
? `当前更适合谈的内容:${allowedTopics.join('、')}`
|
||||
: null,
|
||||
blockedTopics.length > 0
|
||||
? `当前避免直接说破:${blockedTopics.join('、')}`
|
||||
: null,
|
||||
`当前聊天主题:${payload.topic}`,
|
||||
payload.resultSummary
|
||||
? `这段聊天希望带来的变化:${payload.resultSummary}`
|
||||
: '这段聊天要让气氛、情报或关系出现一层新的变化。',
|
||||
`请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcRecruitDialoguePrompt(
|
||||
payload: NpcRecruitDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
`玩家邀请:${payload.invitationText}`,
|
||||
payload.recruitSummary
|
||||
? `招募补充条件:${payload.recruitSummary}`
|
||||
: '这轮对话已经具备自然邀请对方入队的条件。',
|
||||
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
|
||||
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcChatTurnReplyPrompt(
|
||||
payload: NpcChatTurnRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const npcState = asRecord(payload.npcState);
|
||||
const conversationHistory =
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
const affinity = readNumber(npcState?.affinity, 0);
|
||||
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
`当前关系值:${affinity}`,
|
||||
`已聊天轮次:${chattedCount}`,
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcChatTurnSuggestionPrompt(
|
||||
payload: NpcChatTurnRequest,
|
||||
npcReply: string,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const conversationHistory =
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
`NPC 刚刚回复:${npcReply}`,
|
||||
`请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。`,
|
||||
'每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。',
|
||||
'每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
57
server-node/src/prompts/customWorldAgentPrompts.ts
Normal file
57
server-node/src/prompts/customWorldAgentPrompts.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的世界草稿 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
|
||||
export const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
|
||||
export const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT =
|
||||
'你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。';
|
||||
|
||||
export const CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT =
|
||||
'你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。';
|
||||
|
||||
export function buildCustomWorldAgentCharacterExpansionPrompt(params: {
|
||||
worldName: string;
|
||||
worldSummary: string;
|
||||
creatorIntentSummary: string;
|
||||
anchorSummary: string;
|
||||
existingNames: string[];
|
||||
count: number;
|
||||
promptSeed: string;
|
||||
}) {
|
||||
return [
|
||||
`当前世界:${params.worldName}`,
|
||||
`世界摘要:${params.worldSummary}`,
|
||||
`创作意图摘要:${params.creatorIntentSummary}`,
|
||||
`参考锚点:${params.anchorSummary}`,
|
||||
`已有角色:${params.existingNames.join('、') || '暂无'}`,
|
||||
`数量:${params.count}`,
|
||||
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
|
||||
'返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。',
|
||||
'threadIds 必须优先引用现有线程 id。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldAgentLandmarkExpansionPrompt(params: {
|
||||
worldName: string;
|
||||
worldSummary: string;
|
||||
creatorIntentSummary: string;
|
||||
anchorSummary: string;
|
||||
existingNames: string[];
|
||||
count: number;
|
||||
promptSeed: string;
|
||||
}) {
|
||||
return [
|
||||
`当前世界:${params.worldName}`,
|
||||
`世界摘要:${params.worldSummary}`,
|
||||
`创作意图摘要:${params.creatorIntentSummary}`,
|
||||
`参考锚点:${params.anchorSummary}`,
|
||||
`已有地点:${params.existingNames.join('、') || '暂无'}`,
|
||||
`数量:${params.count}`,
|
||||
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
|
||||
'返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。',
|
||||
'threadIds / characterIds 必须优先引用现有对象 id。',
|
||||
].join('\n');
|
||||
}
|
||||
249
server-node/src/prompts/customWorldEntityPrompts.ts
Normal file
249
server-node/src/prompts/customWorldEntityPrompts.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
type ParsedRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
visualDescription: string;
|
||||
actionDescription: string;
|
||||
sceneVisualDescription: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type ParsedLandmarkConnection = {
|
||||
targetLandmarkId: string;
|
||||
summary: string;
|
||||
relativePosition: string;
|
||||
};
|
||||
|
||||
type ParsedLandmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription: string;
|
||||
dangerLevel: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: ParsedLandmarkConnection[];
|
||||
};
|
||||
|
||||
type ParsedProfile = {
|
||||
name: string;
|
||||
settingText: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
playableNpcs: ParsedRole[];
|
||||
storyNpcs: ParsedRole[];
|
||||
landmarks: ParsedLandmark[];
|
||||
};
|
||||
|
||||
export const CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT =
|
||||
'你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。';
|
||||
|
||||
function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) {
|
||||
if (roles.length === 0) {
|
||||
return emptyText;
|
||||
}
|
||||
|
||||
return roles
|
||||
.slice(0, 12)
|
||||
.map(
|
||||
(role, index) =>
|
||||
`${index + 1}. ${role.name} / ${role.title || role.role} / 身份:${
|
||||
role.role || '未写'
|
||||
} / 描述:${role.description || '未写'} / 背景:${
|
||||
role.backstory || '未写'
|
||||
} / 性格:${role.personality || '未写'} / 动机:${
|
||||
role.motivation || '未写'
|
||||
} / 形象:${role.visualDescription || '未写'} / 动作表现:${
|
||||
role.actionDescription || '未写'
|
||||
} / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${
|
||||
role.tags.join('、') || '暂无'
|
||||
}`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildLandmarkReferenceText(profile: ParsedProfile) {
|
||||
if (profile.landmarks.length === 0) {
|
||||
return '当前还没有场景设定。';
|
||||
}
|
||||
|
||||
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
|
||||
const landmarkById = new Map(
|
||||
profile.landmarks.map((landmark) => [landmark.id, landmark]),
|
||||
);
|
||||
|
||||
return profile.landmarks
|
||||
.slice(0, 12)
|
||||
.map((landmark, index) => {
|
||||
const sceneNpcNames = landmark.sceneNpcIds
|
||||
.map((npcId) => storyNpcById.get(npcId)?.name ?? '')
|
||||
.filter(Boolean)
|
||||
.join('、');
|
||||
const connectionNames = landmark.connections
|
||||
.map((connection) => {
|
||||
const targetName =
|
||||
landmarkById.get(connection.targetLandmarkId)?.name ||
|
||||
connection.targetLandmarkId;
|
||||
return `${targetName}(${connection.relativePosition} / ${
|
||||
connection.summary || '无说明'
|
||||
})`;
|
||||
})
|
||||
.join('、');
|
||||
|
||||
return `${index + 1}. ${landmark.name} / 危险度:${
|
||||
landmark.dangerLevel || 'medium'
|
||||
} / 描述:${landmark.description || '未写'} / 画面:${
|
||||
landmark.visualDescription || '未写'
|
||||
} / 场景角色:${
|
||||
sceneNpcNames || '暂无'
|
||||
} / 连接:${connectionNames || '暂无'}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildPlayablePrompt(profile: ParsedProfile) {
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
|
||||
`世界摘要:${profile.summary || '未填写'}`,
|
||||
`世界基调:${profile.tone || '未填写'}`,
|
||||
`玩家主线目标:${profile.playerGoal || '未填写'}`,
|
||||
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
|
||||
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
|
||||
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
|
||||
'请基于上面全部上下文,生成 1 名新的“可扮演角色”。',
|
||||
'要求:',
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
|
||||
'- 必须保留明确的协作价值、成长空间和入队理由。',
|
||||
'- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。',
|
||||
'- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。',
|
||||
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
|
||||
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "playableNpc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "称号",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句定位描述",',
|
||||
' "visualDescription": "角色形象描述",',
|
||||
' "actionDescription": "动作表现描述",',
|
||||
' "sceneVisualDescription": "角色关联场景画面描述",',
|
||||
' "backstory": "背景经历",',
|
||||
' "personality": "性格特点",',
|
||||
' "motivation": "当前动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 22,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildStoryPrompt(profile: ParsedProfile) {
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
|
||||
`世界摘要:${profile.summary || '未填写'}`,
|
||||
`世界基调:${profile.tone || '未填写'}`,
|
||||
`玩家主线目标:${profile.playerGoal || '未填写'}`,
|
||||
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
|
||||
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
|
||||
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
|
||||
'请基于上面全部上下文,生成 1 名新的“场景角色”。',
|
||||
'要求:',
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
|
||||
'- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。',
|
||||
'- 角色应与具体场景、关系链或局势变化发生绑定。',
|
||||
'- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。',
|
||||
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
|
||||
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "storyNpc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "称号",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句定位描述",',
|
||||
' "visualDescription": "角色形象描述",',
|
||||
' "actionDescription": "动作表现描述",',
|
||||
' "sceneVisualDescription": "角色关联场景画面描述",',
|
||||
' "backstory": "背景经历",',
|
||||
' "personality": "性格特点",',
|
||||
' "motivation": "当前动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 6,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildLandmarkPrompt(profile: ParsedProfile) {
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
|
||||
`世界摘要:${profile.summary || '未填写'}`,
|
||||
`世界基调:${profile.tone || '未填写'}`,
|
||||
`玩家主线目标:${profile.playerGoal || '未填写'}`,
|
||||
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
|
||||
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
|
||||
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
|
||||
'请基于上面全部上下文,生成 1 个新的“场景”。',
|
||||
'要求:',
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。',
|
||||
'- 必须给出适合出现在这个新场景里的 sceneNpcNames,且只能从已有场景角色里选择至少 3 个名字。',
|
||||
'- 必须给出 connections,且 targetLandmarkName 只能引用已有场景名,不要连向自己。',
|
||||
'- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "landmark": {',
|
||||
' "name": "场景名",',
|
||||
' "description": "场景描述",',
|
||||
' "visualDescription": "场景画面描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme",',
|
||||
' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],',
|
||||
' "connections": [',
|
||||
' { "targetLandmarkName": "已有场景名", "relativePosition": "forward", "summary": "通路说明" },',
|
||||
' { "targetLandmarkName": "已有场景名", "relativePosition": "inside", "summary": "通路说明" }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
61
server-node/src/prompts/customWorldOrchestratorPrompts.ts
Normal file
61
server-node/src/prompts/customWorldOrchestratorPrompts.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
|
||||
export const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。
|
||||
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
|
||||
|
||||
export function buildCustomWorldProfilePrompt(params: {
|
||||
generationSeedText: string;
|
||||
creatorIntentText?: string;
|
||||
generationMode: string;
|
||||
targets: {
|
||||
playableCount: number;
|
||||
storyCount: number;
|
||||
landmarkCount: number;
|
||||
};
|
||||
}) {
|
||||
return [
|
||||
'请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。',
|
||||
'必须严格输出单个 JSON 对象,不要 Markdown,不要解释。',
|
||||
'',
|
||||
`生成模式:${params.generationMode}`,
|
||||
`可扮演角色数量:${params.targets.playableCount}`,
|
||||
`场景角色数量:${params.targets.storyCount}`,
|
||||
`关键场景数量:${params.targets.landmarkCount}`,
|
||||
'',
|
||||
'创作者输入:',
|
||||
params.generationSeedText,
|
||||
params.creatorIntentText ? `\n结构化创作锚点:\n${params.creatorIntentText}` : '',
|
||||
'',
|
||||
'输出 JSON 字段要求:',
|
||||
'- name, subtitle, summary, tone, playerGoal, templateWorldType',
|
||||
'- majorFactions: string[],coreConflicts: string[]',
|
||||
'- camp: { name, description, dangerLevel }',
|
||||
'- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
|
||||
'- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
|
||||
'- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections',
|
||||
'- connections 每项包含 targetLandmarkName, relativePosition, summary,targetLandmarkName 必须指向本次输出的其他场景名',
|
||||
'',
|
||||
'约束:',
|
||||
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
|
||||
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
|
||||
'- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。',
|
||||
'- templateWorldType 只能是 WUXIA 或 XIANXIA。',
|
||||
'- dangerLevel 使用 low、medium、high、extreme 之一。',
|
||||
'- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。',
|
||||
'- 不要预生成物品档案;items 如需输出,必须为空数组。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldProfileRepairPrompt(responseText: string) {
|
||||
return [
|
||||
'请修复下面的自定义世界 JSON。',
|
||||
'只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。',
|
||||
responseText,
|
||||
].join('\n\n');
|
||||
}
|
||||
104
server-node/src/prompts/customWorldSceneNpcPrompts.ts
Normal file
104
server-node/src/prompts/customWorldSceneNpcPrompts.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
type ParsedStoryNpc = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
};
|
||||
|
||||
type ParsedLandmark = {
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
};
|
||||
|
||||
type ParsedProfile = {
|
||||
name: string;
|
||||
settingText: string;
|
||||
storyNpcs: ParsedStoryNpc[];
|
||||
landmarks: ParsedLandmark[];
|
||||
};
|
||||
|
||||
export const CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT =
|
||||
'你是游戏世界编辑器的场景 NPC 生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。';
|
||||
|
||||
export function buildCustomWorldSceneNpcPrompt(
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
sceneNpcs: ParsedStoryNpc[],
|
||||
otherNpcs: ParsedStoryNpc[],
|
||||
) {
|
||||
const sceneNpcSummary = sceneNpcs.length
|
||||
? sceneNpcs
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '当前场景还没有已加入 NPC。';
|
||||
|
||||
const reserveNpcSummary = otherNpcs.length
|
||||
? otherNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '暂无其他场景角色参考。';
|
||||
|
||||
const landmarkSummary = profile.landmarks
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(entry, index) =>
|
||||
`${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`世界设定:${profile.settingText || '未提供额外设定文本。'}`,
|
||||
`当前目标场景:${landmark.name}`,
|
||||
`场景描述:${landmark.description || '未填写'}`,
|
||||
`危险度:${landmark.dangerLevel || '中'}`,
|
||||
`当前场景已加入 NPC:\n${sceneNpcSummary}`,
|
||||
`其他可参考 NPC:\n${reserveNpcSummary}`,
|
||||
`世界内其他场景概览:\n${landmarkSummary}`,
|
||||
'请生成 1 名适合加入当前场景的新 NPC。',
|
||||
'要求:',
|
||||
'- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。',
|
||||
'- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。',
|
||||
'- 关系钩子、技能、初始物品都要可直接进入编辑器。',
|
||||
'- 返回 JSON,不要额外解释。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "npc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "头衔",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句角色描述",',
|
||||
' "backstory": "背景",',
|
||||
' "personality": "性格",',
|
||||
' "motivation": "动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 6,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
168
server-node/src/prompts/questPrompts.ts
Normal file
168
server-node/src/prompts/questPrompts.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
QuestGenerationContext,
|
||||
QuestOpportunity,
|
||||
QuestSceneSnapshot,
|
||||
} from '../modules/quest/runtimeQuestModule.js';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function summarizeActiveThreads(context: QuestGenerationContext) {
|
||||
return context.activeThreadIds?.length
|
||||
? context.activeThreadIds.join('、')
|
||||
: '暂无明确激活线程';
|
||||
}
|
||||
|
||||
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
const profile = context.issuerNarrativeProfile;
|
||||
if (!profile) {
|
||||
return '暂无额外叙事档案';
|
||||
}
|
||||
|
||||
return [
|
||||
`公开面:${profile.publicMask ?? '暂无'}`,
|
||||
`表层线:${profile.visibleLine ?? '暂无'}`,
|
||||
`当前压力:${profile.immediatePressure ?? '暂无'}`,
|
||||
profile.reactionHooks?.length
|
||||
? `反应钩子:${profile.reactionHooks.join('、')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
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 只是语义建议,不是硬编码结果。
|
||||
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
|
||||
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
|
||||
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
|
||||
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
|
||||
|
||||
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 ?? '未知'}`,
|
||||
`当前激活线程:${summarizeActiveThreads(context)}`,
|
||||
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
summarizeCompanions(context),
|
||||
`当前任务机会:${opportunity.reason}`,
|
||||
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
|
||||
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
|
||||
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
|
||||
].join('\n\n');
|
||||
}
|
||||
43
server-node/src/prompts/runtimeItemPrompts.ts
Normal file
43
server-node/src/prompts/runtimeItemPrompts.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival",
|
||||
"visibleClue": "玩家第一眼能抓到的痕迹",
|
||||
"witnessMark": "它见证过什么的使用痕",
|
||||
"unfinishedBusiness": "背后仍未结清的问题",
|
||||
"hiddenHook": "更深一层但别直接讲穿的钩子",
|
||||
"reactionHooks": ["以后谁会对它起反应"],
|
||||
"namingPattern": "命名范式建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPromptText(params: {
|
||||
generationChannel: string;
|
||||
planBlocks: string[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.generationChannel}`,
|
||||
'以下每个物品都需要给出一条可编译的运行时物品意图。',
|
||||
...params.planBlocks,
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
}
|
||||
33
server-node/src/prompts/storyOrchestratorPrompts.ts
Normal file
33
server-node/src/prompts/storyOrchestratorPrompts.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
type StoryRepairResponse = {
|
||||
storyText: string;
|
||||
encounter?: unknown;
|
||||
options: Array<{
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
|
||||
你会收到一个已经解析过的剧情 JSON 对象。
|
||||
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
|
||||
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
|
||||
export function buildStoryLanguageRepairPrompt(response: StoryRepairResponse) {
|
||||
return [
|
||||
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
|
||||
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
|
||||
JSON.stringify(
|
||||
{
|
||||
storyText: response.storyText,
|
||||
encounter: response.encounter ?? null,
|
||||
options: response.options.map((option) => ({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
].join('\n\n');
|
||||
}
|
||||
197
server-node/src/prompts/storyPromptBuilders.ts
Normal file
197
server-node/src/prompts/storyPromptBuilders.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function describeWorld(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return worldType || '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function describeCharacter(character: JsonRecord) {
|
||||
return [
|
||||
`主角:${readString(character.name) ?? '未知角色'}`,
|
||||
`称号:${readString(character.title) ?? '未知称号'}`,
|
||||
`描述:${readString(character.description) ?? '暂无'}`,
|
||||
`性格:${readString(character.personality) ?? '未显式提供'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeMonsters(monsters: JsonRecord[]) {
|
||||
if (monsters.length <= 0) {
|
||||
return '当前敌对目标:无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'当前敌对目标:',
|
||||
...monsters.slice(0, 4).map((monster) => {
|
||||
const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标';
|
||||
const hp = readNumber(monster.hp);
|
||||
const maxHp = Math.max(1, readNumber(monster.maxHp, hp));
|
||||
return `- ${name}(生命 ${hp}/${maxHp})`;
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: JsonRecord[]) {
|
||||
if (history.length <= 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'近期剧情:',
|
||||
...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeRequestOptions(options: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const available = options.availableOptions ?? [];
|
||||
const catalog = options.optionCatalog ?? [];
|
||||
|
||||
if (available.length > 0) {
|
||||
return [
|
||||
'固定可选项列表:',
|
||||
...available.map((option, index) => {
|
||||
const functionId = readString(option.functionId) ?? 'unknown';
|
||||
const actionText =
|
||||
readString(option.actionText) ??
|
||||
readString(option.text) ??
|
||||
'未提供文案';
|
||||
return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`;
|
||||
}),
|
||||
'必须保持数量不变,functionId 不变,可以重写 actionText。'.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (catalog.length > 0) {
|
||||
return [
|
||||
'当前局面可调用的交互选项目录:',
|
||||
...catalog.map((option, index) => {
|
||||
const functionId = readString(option.functionId) ?? 'unknown';
|
||||
const actionText =
|
||||
readString(option.actionText) ??
|
||||
readString(option.text) ??
|
||||
'未提供文案';
|
||||
return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`;
|
||||
}),
|
||||
'functionId 只能从上面目录里选择。'.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return '当前没有固定目录,请根据局势生成合理选项。';
|
||||
}
|
||||
|
||||
function hasNpcOptionCatalog(options: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
return (options.optionCatalog ?? []).some((option) =>
|
||||
(readString(option.functionId) ?? '').startsWith('npc_'),
|
||||
);
|
||||
}
|
||||
|
||||
function isPostNpcChatReevaluation(params: {
|
||||
choice?: string;
|
||||
context: JsonRecord;
|
||||
requestOptions?: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
readString(params.context.lastFunctionId) === 'npc_chat' &&
|
||||
hasNpcOptionCatalog(params.requestOptions ?? {}) &&
|
||||
Boolean(readString(params.choice))
|
||||
);
|
||||
}
|
||||
|
||||
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。
|
||||
输出格式必须严格符合:
|
||||
{
|
||||
"storyText": "剧情文本",
|
||||
"encounter": null,
|
||||
"options": [
|
||||
{
|
||||
"functionId": "预定义功能ID",
|
||||
"actionText": "选项显示文本"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
严格规则:
|
||||
- 所有文本必须是中文。
|
||||
- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。
|
||||
- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。
|
||||
- options 只允许输出 functionId 和 actionText。
|
||||
- 如果当前不是“继续推进后下一刻会遇到什么”的场景,encounter 必须保持为 null。`;
|
||||
|
||||
export function buildUserPrompt(params: {
|
||||
worldType: string;
|
||||
character: JsonRecord;
|
||||
monsters: JsonRecord[];
|
||||
history: JsonRecord[];
|
||||
context: JsonRecord;
|
||||
choice?: string;
|
||||
requestOptions?: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}) {
|
||||
const sceneName = readString(params.context.sceneName) ?? '当前区域';
|
||||
const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。';
|
||||
const encounterName = readString(params.context.encounterName);
|
||||
const playerHp = readNumber(params.context.playerHp);
|
||||
const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp));
|
||||
const playerMana = readNumber(params.context.playerMana);
|
||||
const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana));
|
||||
const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗';
|
||||
const pendingSceneEncounter =
|
||||
params.context.pendingSceneEncounter === true ? '是' : '否';
|
||||
const postNpcChatReevaluation = isPostNpcChatReevaluation(params);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(params.worldType)}`,
|
||||
`场景:${sceneName}`,
|
||||
`场景描述:${sceneDescription}`,
|
||||
encounterName ? `当前面前对象:${encounterName}` : null,
|
||||
`当前状态:${inBattle}`,
|
||||
`玩家生命:${playerHp}/${playerMaxHp}`,
|
||||
`玩家灵力:${playerMana}/${playerMaxMana}`,
|
||||
`是否需要判断下一刻遭遇:${pendingSceneEncounter}`,
|
||||
describeCharacter(params.character),
|
||||
describeMonsters(params.monsters),
|
||||
describeStoryHistory(params.history),
|
||||
params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。',
|
||||
describeRequestOptions(params.requestOptions ?? {}),
|
||||
postNpcChatReevaluation
|
||||
? '当前这一步是刚结束一轮 NPC 交谈后,对眼前局势的再次判断。storyText 必须先落出刚才那段聊天带来的态度变化、气氛变化或新暴露的信息,再进入下一步局势。'
|
||||
: null,
|
||||
postNpcChatReevaluation
|
||||
? '如果输出 npc_ 开头的选项,这些 actionText 必须直接承接刚才聊到的话题、关系变化或对方态度,写成此刻自然浮现的回应,不要退回“继续交谈”“请求援手”“看看能交换什么”这类通用模板。'
|
||||
: null,
|
||||
postNpcChatReevaluation
|
||||
? '当前目录只是合法 function 范围,不代表都要出现;只保留此刻真正自然浮现、和刚才聊天结果有关的选项。'
|
||||
: null,
|
||||
params.context.pendingSceneEncounter === true
|
||||
? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter;否则 encounter 必须为 null。'
|
||||
: '当前这一步不是新的遭遇生成流程,encounter 必须为 null。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
Reference in New Issue
Block a user