456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
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);
|
||
|
||
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 affinity = readNumber(npcState?.affinity, 0);
|
||
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||
|
||
return [
|
||
buildNpcDialoguePromptBase(payload),
|
||
describeNpcConversationHistory(payload.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);
|
||
|
||
return [
|
||
buildNpcDialoguePromptBase(payload),
|
||
describeNpcConversationHistory(payload.conversationHistory, encounter.npcName),
|
||
`玩家刚刚说:${payload.playerMessage}`,
|
||
`NPC 刚刚回复:${npcReply}`,
|
||
`请围绕刚刚这轮对话,为玩家生成 3 条可以继续和 ${encounter.npcName} 聊下去的中文短句候选。`,
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n\n');
|
||
}
|