type JsonRecord = Record; 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>; optionCatalog?: Array>; }) { 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>; optionCatalog?: Array>; }) { return (options.optionCatalog ?? []).some((option) => (readString(option.functionId) ?? '').startsWith('npc_'), ); } function isPostNpcChatReevaluation(params: { choice?: string; context: JsonRecord; requestOptions?: { availableOptions?: Array>; optionCatalog?: Array>; }; }) { 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>; optionCatalog?: Array>; }; }) { 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'); }