198 lines
7.2 KiB
TypeScript
198 lines
7.2 KiB
TypeScript
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');
|
||
}
|