Files
Genarrative/server-node/src/prompts/storyPromptBuilders.ts
2026-04-19 20:33:18 +08:00

198 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}