初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

976
src/services/prompt.ts Normal file
View File

@@ -0,0 +1,976 @@
import { buildRoleAttributeProfileFromLegacyData } from '../data/attributeProfileGenerator';
import {
buildSchemaSummary,
describeTopAttributes,
formatAttributeList,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import {
buildCharacterBackstoryPromptContext,
getCharacterAdventureOpening,
getCharacterById,
getCharacterPublicBackstorySummary,
resolveEncounterRecruitCharacter,
} from '../data/characterPresets';
import { getMonsterPresetById } from '../data/hostileNpcPresets';
import { createSceneMonstersFromIds } from '../data/hostileNpcs';
import {
describeConversationStyle as describeNpcConversationStyle,
describeDisclosureStage,
describeWarmthStage,
} from '../data/npcInteractions';
import { buildSceneEntityCatalogText, getScenePresetById } from '../data/scenePresets';
import {
buildFunctionCatalogText,
getFunctionById,
getFunctionPromptDescription,
} from '../data/stateFunctions';
import {
Character,
CharacterGender,
CustomWorldProfile,
FacingDirection,
SceneMonster,
StoryMoment,
StoryOption,
WorldType,
} from '../types';
import type { StoryGenerationContext } from './aiTypes';
import { buildCustomWorldReferenceText } from './customWorld';
import { buildStoryPromptHistory } from './storyHistory';
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
{
"storyText": "剧情文本",
"encounter": {
"kind": "npc|treasure|none",
"npcId": "仅当 kind=npc 时填写",
"treasureText": "仅当 kind=treasure 时填写"
},
"options": [
{
"functionId": "预定义功能ID",
"actionText": "选项显示文本"
}
]
}
严格规则:
- 所有文本必须是中文。
- 如果提示语给出了特定可选列表,你必须严格保留原有数量和 functionId你可以调整这些特定项的顺序但排序必须参考最近剧情、刚发生的结果、当前局面轻重缓急再重点优化 actionText下文会直接说明每个 function 的行为边界,不是让你发挥的剧本。
- 如果提示语中没有给特定可选列表,则必须输出至少 6 个选项。
- 每个选项只能包含 functionId 和 actionText。
- 没有特定列表时,所有 functionId 必须互不重复。
- 每个选项只能包含一个 function不要把多个动作塞进同一行。
- storyText 必须衔接当前界面、最近剧情、当前场景与当前实体,不得割裂上下文凭空发挥。
- 战斗状态下storyText 必须提到当前敌对目标或战斗对象正在做什么。
- actionText 必须同时考虑:主角状态、面前实体状态、最近剧情、当前场景、当前可执行 function。
- 当主角生命值低下时,至少有一个 actionText 体现维持状态、调整、恢复或撤退。
- 当主角灵力低下时,至少有一个 actionText 体现节省消耗、保持节奏或尝试恢复。
- 当对方状态低下时,至少有一个 actionText 体现改变、攻击、结束或压制。
- actionText 只写玩家能看到的行动文本,不写 functionId不写特殊解释。
- 选项顺序不是随机列表,越接近最近剧情推进、当前威胁或当前机会的回应越靠前。
- 前端不会校验 functionId不该出现的 function 绝对不要输出。`;
export const NPC_CHAT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的对话编剧。
你只能输出纯文本对话不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 对话必须承接当前聊天主题、当前场景和最近关系变化。
- 对方必须给出真实回应,不能只用敷衍词。`;
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 CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 中可被玩家在角色面板里私下交谈的同行角色。
你只能输出这名角色此刻会说的话不能输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
硬性规则:
- 必须始终站在该角色立场回应,语气要符合角色设定、经历、情绪和与你的关系。
- 只回复角色说话内容,不要代替玩家发言,不要把回复写成系统选项。
- 可以自然提到最近剧情、战斗感受、彼此关系和顾虑,但不要写成任务说明书。
- 回复控制在 1 到 3 段,总长度尽量不超过 120 个中文字符。
- 玩家问得含糊时,也要给出明确、具体、带情绪的回应。`;
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成 3 条下一句可直接发送的中文回复建议。
你只能输出 3 行纯文本,每行 1 条不要序号、引号、解释、Markdown 或额外空行。
硬性规则:
- 三条建议必须风格有区分:一条偏关心,一条偏追问,一条偏轻松或拉近关系。
- 每条建议尽量控制在 10 到 28 个字。
- 建议必须贴合最近剧情、当前关系和上一轮聊天内容。`;
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `你要把玩家与这名角色的聊天沉淀成后续剧情推理可用的角色关系摘要。
你只能输出一段中文摘要不要标题、序号、Markdown、JSON 或解释。
摘要必须包含:
- 当前关系气氛与亲疏变化
- 角色对玩家态度的新变化
- 聊天里出现的重要信息、承诺、顾虑或暗示
长度控制在 45 到 120 个字。`;
export function describeWorld(world: WorldType) {
if (world === WorldType.WUXIA) return '武侠';
if (world === WorldType.XIANXIA) return '仙侠';
return '自定义世界';
}
function describeWorldForPrompt(world: WorldType, customWorldProfile?: CustomWorldProfile | null) {
return customWorldProfile
? `${customWorldProfile.name}(自定义世界)`
: describeWorld(world);
}
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
return customWorldProfile
? `自定义世界补充档案:\n${buildCustomWorldReferenceText(customWorldProfile)}`
: null;
}
function describeFacing(facing: FacingDirection) {
return facing === 'left' ? '左' : '右';
}
function describeGender(gender: CharacterGender | null | undefined) {
if (gender === 'female') return '女';
if (gender === 'male') return '男';
return '未知';
}
function describeAdventureOpening(character: Character, world: WorldType) {
const opening = getCharacterAdventureOpening(character, world);
if (!opening) return [];
return [
`来到此界的原因:${opening.reason}`,
`当前最重要的目标:${opening.goal}`,
];
}
function describePlayerOpeningByContext(character: Character, world: WorldType, context: StoryGenerationContext) {
const opening = getCharacterAdventureOpening(character, world);
if (!opening) return [];
const shouldConcealFullOpening = context.lastFunctionId === 'story_opening_camp_dialogue'
|| context.lastFunctionId === 'npc_chat'
|| context.isFirstMeaningfulContact === true;
if (!shouldConcealFullOpening) {
return describeAdventureOpening(character, world);
}
return [
`主角当前只表露出的钩子:${opening.surfaceHook ?? '主角有自己的来意,但不会刚见面就全说。'}`,
`主角当前更在意的事:${opening.immediateConcern ?? '主角会优先先谈眼前局势。'}`,
];
}
function describeEncounterOpeningByStage(character: Character, world: WorldType, context: StoryGenerationContext) {
const opening = getCharacterAdventureOpening(character, world);
if (!opening) return [];
if (context.isFirstMeaningfulContact) {
return [
`当前只看得出的钩子:${opening.surfaceHook ?? '对方有自己的来意,但此刻只会先露出一角。'}`,
`当前更在意的事:${opening.immediateConcern ?? '对方会先把注意力放在眼前局势上。'}`,
];
}
const stage = context.encounterDisclosureStage ?? 'guarded';
if (stage === 'guarded') {
return [
`当前只看得出的钩子:${opening.surfaceHook ?? '对方知道点什么,但并没有把来意说透。'}`,
`当前更在意的事:${opening.immediateConcern ?? '眼前局势比来历更值得先谈。'}`,
];
}
if (stage === 'partial') {
return [
`当前愿意松口的表层理由:${opening.guardedMotive ?? opening.surfaceHook ?? '对方只肯给一层表面的解释。'}`,
`当前更在意的事:${opening.immediateConcern ?? '眼前局势比旧事更急。'}`,
];
}
if (stage === 'honest') {
return [
`来到此界的原因(可逐步触及):${opening.reason}`,
`当前最重要的目标(仍不必一次说尽):${opening.goal}`,
];
}
return [
`来到此界的原因:${opening.reason}`,
`当前最重要的目标:${opening.goal}`,
];
}
function describeEncounterConversationDirective(context: StoryGenerationContext) {
if (!context.encounterConversationStyle || !context.encounterDisclosureStage || !context.encounterWarmthStage || !context.encounterAnswerMode) {
return null;
}
return [
'当前角色对话阶段控制:',
`- 当前好感:${context.encounterAffinity ?? '未知'}`,
`- 信息揭示阶段:${context.encounterDisclosureStage}${describeDisclosureStage(context.encounterDisclosureStage)}`,
`- 语气亲疏阶段:${context.encounterWarmthStage}${describeWarmthStage(context.encounterWarmthStage)}`,
`- 回答模式:${context.encounterAnswerMode}`,
`- 角色表述风格:${describeNpcConversationStyle(context.encounterConversationStyle)}`,
context.encounterAllowedTopics?.length
? `- 本轮优先可谈:${context.encounterAllowedTopics.join('、')}`
: null,
context.encounterBlockedTopics?.length
? `- 本轮避免直接说破:${context.encounterBlockedTopics.join('、')}`
: null,
].filter(Boolean).join('\n');
}
function describeConversationSituationDirective(context: StoryGenerationContext) {
if (!context.conversationSituation && !context.conversationPressure && !context.recentSharedEvent && !context.talkPriority) {
return null;
}
return [
'当前对话情景控制:',
context.conversationSituation ? `- 情景标签:${context.conversationSituation}` : null,
context.conversationPressure ? `- 当前压力:${context.conversationPressure}` : null,
context.recentSharedEvent ? `- 刚刚共同经历:${context.recentSharedEvent}` : null,
context.talkPriority ? `- 本轮优先说法:${context.talkPriority}` : null,
].filter(Boolean).join('\n');
}
function describeFirstContactRelationStance(
stance: StoryGenerationContext['firstContactRelationStance'],
) {
switch (stance) {
case 'guarded':
return '戒备试探';
case 'neutral':
return '正常交流但仍不熟';
case 'cooperative':
return '已有善意,先确认合作节奏';
case 'bonded':
return '明显信任,但仍是第一次正式对上人';
default:
return '初次接触';
}
}
function describeFirstMeaningfulContactDirective(context: StoryGenerationContext) {
if (!context.isFirstMeaningfulContact) {
return null;
}
return [
'当前接触阶段:这是你与该角色第一次真正接触。',
`- 当前关系站位:${describeFirstContactRelationStance(context.firstContactRelationStance ?? null)}`,
'- 可以按当前好感写得更冷或更暖,但仍必须保持第一次正式对上的节奏。',
'- 优先写现场判断、态度试探、来意确认和眼前压力,不要直接写成熟人后续轮。',
'- 不要让双方一上来互相讲完整过去;未公开或未解锁背景不能主动说破。',
].join('\n');
}
function describeBackstoryContext(label: string, snippets: string[]) {
const normalized = snippets
.map(snippet => snippet.trim())
.filter(Boolean);
if (normalized.length === 0) {
return [`${label}:暂无公开信息。`];
}
return normalized.map((snippet, index) =>
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index}`}${snippet}`,
);
}
function getEncounterGender(context: StoryGenerationContext) {
if (context.encounterCharacterId) {
return getCharacterById(context.encounterCharacterId)?.gender ?? context.encounterGender ?? 'unknown';
}
return context.encounterGender ?? 'unknown';
}
function describeHpBand(ratio: number) {
if (ratio >= 0.95) return '完好无损';
if (ratio >= 0.75) return '状态稳健';
if (ratio >= 0.55) return '略有消耗';
if (ratio >= 0.35) return '伤势明显';
if (ratio >= 0.15) return '伤势沉重';
return '濒临极限';
}
function describeManaBand(ratio: number) {
if (ratio >= 0.9) return '灵力满盈';
if (ratio >= 0.7) return '灵力充沛';
if (ratio >= 0.45) return '灵力平稳';
if (ratio >= 0.2) return '灵力吃紧';
if (ratio > 0) return '灵力见底';
return '灵力枯竭';
}
function describeOverallBand(hpRatio: number, manaRatio: number) {
if (hpRatio >= 0.75 && manaRatio >= 0.7) return '整体状态适合主动推进';
if (hpRatio >= 0.5 && manaRatio >= 0.4) return '整体状态仍可持续周旋';
if (hpRatio < 0.35 && manaRatio < 0.2) return '整体状态非常吃紧,应避免冒进';
if (hpRatio < 0.35) return '身体负担偏重,宜先稳住节奏';
if (manaRatio < 0.2) return '灵力压力很大,宜保守分配手段';
return '整体状态已有消耗,需要权衡节奏';
}
function inferEncounterPersonality(contextText: string | null | undefined, description: string | null | undefined) {
const source = `${contextText ?? ''} ${description ?? ''}`;
if (/|||/u.test(source)) return '';
if (/||/u.test(source)) return '';
if (/||/u.test(source)) return '';
if (/|||/u.test(source)) return '';
if (/||/u.test(source)) return '';
return '对外保持戒备,会先试探你的来意与立场';
}
function inferEncounterAttributeProfile(
world: WorldType,
context: StoryGenerationContext,
entityId: string,
extraText: string[] = [],
) {
const schema = resolveAttributeSchema(world, context.customWorldProfile);
return buildRoleAttributeProfileFromLegacyData({
entityId,
schema,
textBlocks: [
context.encounterName,
context.encounterContext,
context.encounterDescription,
...extraText,
],
}).profile;
}
function describeAttributeProfileForPrompt(
label: string,
world: WorldType,
context: StoryGenerationContext,
profile: ReturnType<typeof inferEncounterAttributeProfile> | null | undefined,
) {
const schema = resolveAttributeSchema(world, context.customWorldProfile);
return [
`${label}核心属性:${describeTopAttributes(profile, schema).join('、') || '暂无'}`,
`${label}属性详情:${formatAttributeList(profile, schema)
.map(entry => `${entry.slot.name} ${entry.value}`)
.join('、')}`,
];
}
function describeSkills(character: Character, context: StoryGenerationContext) {
const cooldowns = Object.entries(context.skillCooldowns)
.filter(([, turns]) => turns > 0)
.map(([skillId, turns]) => {
const skill = character.skills.find(item => item.id === skillId);
return skill ? `${skill.name} 还需 ${turns} 回合` : null;
})
.filter(Boolean)
.join('');
return [
`当前灵力档位:${describeManaBand(context.playerMana / Math.max(context.playerMaxMana, 1))}`,
'技能列表:',
...character.skills.map(
skill => `- ${skill.id}${skill.name},基础伤害 ${skill.damage},消耗 ${skill.manaCost},冷却 ${skill.cooldownTurns} 回合`,
),
`冷却中的技能:${cooldowns || '暂无'}`,
].join('\n');
}
function describeFrontEntity(
world: WorldType,
context: StoryGenerationContext,
monsters: SceneMonster[],
) {
const schema = resolveAttributeSchema(world, context.customWorldProfile);
if (context.encounterName) {
const encounterCharacter = context.encounterCharacterId
? getCharacterById(context.encounterCharacterId) ?? resolveEncounterRecruitCharacter({
characterId: context.encounterCharacterId,
context: context.encounterContext ?? '',
npcName: context.encounterName,
})
: resolveEncounterRecruitCharacter({
characterId: undefined,
context: context.encounterContext ?? '',
npcName: context.encounterName,
});
const attributeProfile = encounterCharacter
? resolveCharacterAttributeProfile(encounterCharacter, world, context.customWorldProfile)
: inferEncounterAttributeProfile(world, context, `encounter:${context.encounterName}`, [
inferEncounterPersonality(context.encounterContext, context.encounterDescription),
]);
const title = encounterCharacter?.title ?? context.encounterContext ?? '此地生灵';
const description = encounterCharacter?.description ?? context.encounterDescription ?? '对方站在你面前,等待你进一步表态。';
const personality = encounterCharacter?.personality ?? inferEncounterPersonality(context.encounterContext, context.encounterDescription);
const backstoryLines = encounterCharacter
? context.isFirstMeaningfulContact
? [getCharacterPublicBackstorySummary(encounterCharacter, world)]
: buildCharacterBackstoryPromptContext(
encounterCharacter,
context.encounterAffinity ?? 0,
world,
)
: ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
const status = context.encounterKind === 'npc'
? context.isFirstMeaningfulContact
? '你们正在进行第一次真正接触,对方会先观察你的态度与来意。'
: '对你保持观察与戒备,正在等待你的回应'
: context.encounterKind === 'treasure'
? '静静停在前方,尚未被真正触碰'
: '状态未明';
return [
'当前面前实体:',
`- 名称:${context.encounterName}`,
`- 身份:${title}`,
`- 描述:${description}`,
...describeBackstoryContext('背景', backstoryLines).map(line => `- ${line}`),
`- 性格:${personality}`,
`- 世界属性框架:${buildSchemaSummary(schema).map(slot => `${slot.name}${slot.definition}`).join('、')}`,
...(encounterCharacter ? describeEncounterOpeningByStage(encounterCharacter, world, context).map(line => `- ${line}`) : []),
`- 状态:${status}`,
...describeAttributeProfileForPrompt('对方', world, context, attributeProfile).map(line => `- ${line}`),
context.encounterKind === 'npc' && context.encounterAffinityText
? `- 对你的态度:${context.encounterAffinityText}`
: null,
context.encounterRelationshipSummary
? `- 你与对方私下相处补充:${context.encounterRelationshipSummary}`
: null,
].filter(Boolean).join('\n');
}
const primaryMonster = monsters.find(monster => monster.hp > 0) ?? monsters[0];
if (!primaryMonster) {
return '当前面前实体:暂无明确实体拦在你面前。';
}
const monsterPreset = getMonsterPresetById(world, primaryMonster.id);
const hpRatio = primaryMonster.hp / Math.max(primaryMonster.maxHp, 1);
const monsterProfile = primaryMonster.attributeProfile
?? inferEncounterAttributeProfile(world, context, `monster:${primaryMonster.id}`, [
monsterPreset?.description ?? primaryMonster.description,
primaryMonster.action,
]);
return [
'当前面前实体:',
`- 名称:${primaryMonster.name}`,
'- 身份:当前最靠前的敌对目标',
`- 描述:${monsterPreset?.description ?? primaryMonster.description}`,
'- 性格:更接近本能性的压迫与试探,会按当前动作持续逼近你',
`- 状态:生命状态 ${describeHpBand(hpRatio)},当前动作 ${primaryMonster.animation},朝向 ${describeFacing(primaryMonster.facing)}`,
...describeAttributeProfileForPrompt('敌对实体', world, context, monsterProfile).map(line => `- ${line}`),
].join('\n');
}
function describePlayerState(world: WorldType, character: Character, context: StoryGenerationContext) {
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
const sceneName = context.sceneName || '当前区域';
const sceneDescription = context.sceneDescription || '此地仍有未知人物、敌对目标与机缘潜伏。';
const schema = resolveAttributeSchema(world, context.customWorldProfile);
const attributeProfile = resolveCharacterAttributeProfile(character, world, context.customWorldProfile);
const playerBackstoryLines = describeBackstoryContext(
'主角背景',
[getCharacterPublicBackstorySummary(character, world)],
);
return [
`玩家状态:${context.inBattle ? '战斗状态' : '空闲状态'}`,
`当前场景:${sceneName}`,
`场景描述:${sceneDescription}`,
context.lastObserveSignsReport ? `最近一次观察结果:${context.lastObserveSignsReport}` : null,
`主角:${character.name}${character.title}`,
`主角描述:${character.description}`,
...playerBackstoryLines,
`主角性格:${character.personality}`,
...describePlayerOpeningByContext(character, world, context),
`世界属性框架:${buildSchemaSummary(schema).map(slot => `${slot.name}${slot.definition}`).join('、')}`,
`主角状态:生命状态 ${describeHpBand(hpRatio)},灵力状态 ${describeManaBand(manaRatio)},整体判断 ${describeOverallBand(hpRatio, manaRatio)},朝向 ${describeFacing(context.playerFacing)},当前动作 ${context.playerAnimation}`,
...describeAttributeProfileForPrompt('主角', world, context, attributeProfile),
].filter(Boolean).join('\n');
}
function describeMonsters(monsters: SceneMonster[]) {
if (monsters.length === 0) {
return '当前没有可见敌对目标。';
}
return monsters
.map(monster => {
const hpRatio = monster.hp / Math.max(monster.maxHp, 1);
return `敌对目标 ${monster.name}:生命状态 ${describeHpBand(hpRatio)},当前动作 ${monster.animation},行为“${monster.action}”,朝向 ${describeFacing(monster.facing)}`;
})
.join('\n');
}
function _describeHistory(history: string[]) {
if (history.length === 0) {
return '最近剧情:暂无。';
}
return `最近剧情:\n${history.slice(-6).map(item => `- ${item}`).join('\n')}`;
}
function describeStoryHistory(history: StoryMoment[]) {
const promptHistory = buildStoryPromptHistory(history);
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
return '最近剧情:暂无。';
}
return [
promptHistory.previousSummary
? `3轮以前的历史剧情总结\n${promptHistory.previousSummary}`
: '3轮以前的历史剧情总结暂无。',
promptHistory.recentOriginalRounds.length > 0
? `最近3轮剧情原文续写时优先承接\n${promptHistory.recentOriginalRounds
.map((item, index) => `- 第${index + 1}\n${item}`)
.join('\n')}`
: '最近3轮剧情原文暂无。',
'续写时必须先承接“最近3轮剧情原文”再与“3轮以前的历史剧情总结”保持一致不得跳过已经发生的结果、地点、关系变化或战斗状态。',
].join('\n');
}
function _buildResolvedUserPrompt(
world: WorldType,
character: Character,
monsters: SceneMonster[],
history: StoryMoment[],
context: StoryGenerationContext,
choice?: string,
availableOptions?: StoryOption[],
optionCatalog?: StoryOption[],
) {
const functionContext = {
worldType: world,
playerCharacter: character,
inBattle: context.inBattle,
currentSceneId: context.sceneId,
currentSceneName: context.sceneName,
monsters,
playerHp: context.playerHp,
playerMaxHp: context.playerMaxHp,
playerMana: context.playerMana,
playerMaxMana: context.playerMaxMana,
};
const scene = getScenePresetById(world, context.sceneId);
const pendingEncounter = context.pendingSceneEncounter && !!scene;
const hasProvidedOptions = (availableOptions?.length ?? 0) > 0;
const _hasOptionCatalog = Boolean(optionCatalog && optionCatalog.length > 0);
const hasProvidedNpcChatOptions = Boolean(availableOptions?.some(option => option.functionId === 'npc_chat'));
const isOpeningCampDialogue = context.lastFunctionId === 'story_opening_camp_dialogue' && Boolean(context.encounterName);
const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions
&& Boolean(context.openingCampBackground?.trim())
&& Boolean(context.openingCampDialogue?.trim());
const sceneMonsterIds = scene?.monsterIds ?? [];
const battleCatalog = scene
? buildFunctionCatalogText({
...functionContext,
inBattle: true,
monsters: createSceneMonstersFromIds(world, sceneMonsterIds, context.playerX),
})
: '';
const idleCatalog = buildFunctionCatalogText({
...functionContext,
inBattle: false,
monsters: [],
});
const observeSignsCatalog = context.observeSignsRequested
? buildSceneEntityCatalogText(world, context.sceneId)
: '';
const sections = [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
describeCustomWorldSection(context.customWorldProfile),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
context.partyRelationshipNotes ? `同行角色补充关系信息:\n${context.partyRelationshipNotes}` : null,
describeStoryHistory(history),
hasOpeningCampFollowupContext ? `营地开场背景:\n${context.openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
choice ? `玩家刚刚选择:${choice}` : '玩家刚进入当前局面。',
hasProvidedOptions
? `固定可选项列表(必须保持数量与 functionId 一致,可按最近剧情重排顺序):\n${describeProvidedOptions(availableOptions ?? [])}`
: pendingEncounter
? `当前场景实体池:\n${buildSceneEntityCatalogText(world, context.sceneId)}`
: `当前可执行 function\n${buildFunctionCatalogText(functionContext)}`,
hasProvidedOptions
? '这些选项对应当前局面下真实可执行的本地规则。你必须严格保持数量不变、functionId 不变;可以依据最近剧情、刚刚发生的结果和当前轻重缓急重排顺序;然后按每个 function 的行为边界,自然重写更贴合当前局面和状态的中文 actionText不要把它写成别的行为。'
: pendingEncounter
? `如果主角继续推进后遇到敌对目标,你必须只从以下战斗 function 中选择至少 6 个选项:\n${battleCatalog}`
: '请只根据上面的当前状态继续推进这一幕,并输出紧接着发生的剧情文本与至少 6 个选项。',
hasProvidedOptions
? 'storyText 必须直接承接最近剧情与当前结果,让玩家看到这一动作之后局面的新变化。'
: pendingEncounter
? `如果主角遇到角色、宝藏或暂时什么都没遇到,你必须只从以下空闲 function 中选择至少 6 个选项:\n${idleCatalog}`
: '这些选项必须全部从当前可执行 function 列表里选择。',
hasProvidedOptions
? '每个 function 后面的说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为;若重排顺序,必须让前面的选项更贴近最近剧情与当前局面的主导矛盾。'
: '下方 function 说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为。',
hasProvidedOptions || !pendingEncounter
? null
: '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId若不是场景角色则 options 必须使用空闲 function。',
'当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。',
'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。',
];
if (context.observeSignsRequested) {
sections.push(`当前动作是“停步观察动静”。你必须基于当前场景实体池进行推理把可能出现的角色、敌对目标、BOSS 线索写进 storyText。这里是观察参考实体池\n${observeSignsCatalog}`);
sections.push('这一段重点是观察和判断不是立刻推进遭遇。storyText 要写成后续还能继续引用的侦察结论。');
}
if (isOpeningCampDialogue) {
sections.push(`当前这一步是你与${context.encounterName}在营地里的开场聊天。storyText 必须直接写成刚刚发生的对话正文,每一行都必须以“你:”或“${context.encounterName}:”开头,总行数控制在 4 到 6 行。`);
sections.push('这段开场白必须承接玩家刚踏入此界后的警觉、判断、保留动机、营地气氛和面前同伴的态度,让它成为后续剧情可继续引用的真实最近剧情,而不是旁白总结。');
sections.push('玩家在第一轮里也只该表露表层钩子、眼前压力和试探态度,不要主动把完整目标、完整理由或全部过去一次说完。');
}
if (hasOpeningCampFollowupContext) {
sections.push('当前不是重新发明泛化选项,而是承接上面的营地开场背景和刚刚那段第一轮对话,整理角色眼下最自然会继续说或继续追问的话。');
sections.push('如果固定项里包含两个 npc_chat它们必须排在前两个位置这两个 npc_chat 的 actionText 必须直接承接刚刚聊到的话头,体现追问、确认、延展或继续对接,而不是回到通用模板。');
}
return sections.filter(Boolean).join('\n\n');
}
function describeProvidedOptionCore(option: StoryOption) {
const definition = getFunctionById(option.functionId);
const definitionCore = definition?.description?.trim();
const functionPromptDescription = getFunctionPromptDescription(option.functionId, definitionCore);
if (option.functionId === 'npc_preview_talk') {
return '把注意力真正转到眼前这个角色身上,准备开始与其交谈;这是进入角色互动层,不是立刻完成一次聊天。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'chat') {
return `和面前角色围绕当前这个话题切入点继续交谈,文案可以自然改写,但仍要保持这是聊天而不是别的行为。`;
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'trade') {
return '和面前角色进行交易,可以写成更自然的买卖或交换表达,但仍要保持这是交易行为。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'help') {
return '向面前角色寻求帮助或支援,但仍要保持这是求助行为。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'gift') {
return '向面前角色送礼,以改善关系或表达诚意。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'recruit') {
return '邀请面前角色加入队伍或同行,但仍要保持这是招募行为。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'quest_accept') {
return '接受面前角色给出的委托或任务。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'quest_turn_in') {
return '向面前角色交付已经完成的委托。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'leave') {
return '结束与面前实体的当前互动,把注意力重新放回前路。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'fight') {
return '与面前角色直接开战。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'spar') {
return '与面前角色进行点到为止的切磋。';
}
if (option.interaction?.kind === 'treasure') {
return option.interaction.action === 'inspect'
? '先检查眼前目标的细节与风险,再决定如何收取。'
: option.interaction.action === 'secure'
? '直接收取眼前的目标,不再做额外停留。'
: '暂时放过眼前目标,把注意力拉回当前环境。';
}
return functionPromptDescription || option.detailText || option.actionText;
}
function describeProvidedOptions(options: StoryOption[]) {
return options
.map((option, index) => {
return `- 第 ${index + 1} 项 / ${option.functionId}${describeProvidedOptionCore(option)}`;
})
.join('\n');
}
function buildCatalogAwareUserPrompt(
world: WorldType,
character: Character,
monsters: SceneMonster[],
history: StoryMoment[],
context: StoryGenerationContext,
choice?: string,
availableOptions?: StoryOption[],
optionCatalog?: StoryOption[],
) {
const functionContext = {
worldType: world,
playerCharacter: character,
inBattle: context.inBattle,
currentSceneId: context.sceneId,
currentSceneName: context.sceneName,
monsters,
playerHp: context.playerHp,
playerMaxHp: context.playerMaxHp,
playerMana: context.playerMana,
playerMaxMana: context.playerMaxMana,
};
const scene = getScenePresetById(world, context.sceneId);
const pendingEncounter = context.pendingSceneEncounter && !!scene;
const hasProvidedOptions = (availableOptions?.length ?? 0) > 0;
const hasOptionCatalog = Boolean(optionCatalog && optionCatalog.length > 0);
const hasProvidedNpcChatOptions = Boolean(availableOptions?.some(option => option.functionId === 'npc_chat'));
const isOpeningCampDialogue = context.lastFunctionId === 'story_opening_camp_dialogue' && Boolean(context.encounterName);
const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions
&& Boolean(context.openingCampBackground?.trim())
&& Boolean(context.openingCampDialogue?.trim());
const battleCatalog = scene
? buildFunctionCatalogText({
...functionContext,
inBattle: true,
monsters: createSceneMonstersFromIds(world, scene?.monsterIds ?? [], context.playerX),
})
: '';
const idleCatalog = buildFunctionCatalogText({
...functionContext,
inBattle: false,
monsters: [],
});
const observeSignsCatalog = context.observeSignsRequested
? buildSceneEntityCatalogText(world, context.sceneId)
: '';
const sections = [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
describeCustomWorldSection(context.customWorldProfile),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
context.partyRelationshipNotes ? `同行角色补充关系信息:\n${context.partyRelationshipNotes}` : null,
describeStoryHistory(history),
hasOpeningCampFollowupContext ? `营地开场背景:\n${context.openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
choice ? `玩家刚刚选择:${choice}` : '玩家刚进入当前局面。',
hasProvidedOptions
? `固定可选项列表(必须保留数量与 functionId可按最近剧情重排顺序\n${describeProvidedOptions(availableOptions ?? [])}`
: hasOptionCatalog
? `当前局面可调用的交互选项目录functionId 只能从这里选,但不需要保留原数量和顺序):\n${describeProvidedOptions(optionCatalog ?? [])}`
: pendingEncounter
? `当前场景实体池:\n${buildSceneEntityCatalogText(world, context.sceneId)}`
: `当前可执行 function\n${buildFunctionCatalogText(functionContext)}`,
hasProvidedOptions
? '这些选项对应当前局面下真实可执行的本地规则。你必须保持数量不变、functionId 不变;可以依据最近剧情、刚刚发生的结果和当前轻重缓急重排顺序,并自然重写更贴合当前局面和状态的中文 actionText。'
: hasOptionCatalog
? '上面的交互选项目录只是当前局面下合法可执行的 function 范围,不是固定模板。你不需要保留原数量、原顺序或原文案,但 options 里的 functionId 只能从这个目录里选择,并且要根据刚刚发生的结果、关系变化和眼前局面,自行决定最合理的选项组合。'
: pendingEncounter
? `如果主角继续推进后遇到敌对目标,你必须只从以下战斗 function 中选择至少 6 个选项:\n${battleCatalog}`
: '请只根据上面的当前状态继续推进这一幕,并输出紧接着发生的剧情文本与至少 6 个选项。',
hasProvidedOptions
? 'storyText 必须直接承接最近剧情与当前结果,让玩家看到这一动作之后局面的新变化。'
: hasOptionCatalog
? '请只根据上面的当前状态继续推进这一幕,输出紧接着发生的剧情文本与至少 6 个选项;如果目录里本身不足 6 个 function就优先覆盖当前最重要的合法 function。'
: pendingEncounter
? `如果主角遇到角色、宝藏或暂时什么都没遇到,你必须只从以下空闲 function 中选择至少 6 个选项:\n${idleCatalog}`
: '这些选项必须全部从当前可执行 function 列表里选择。',
hasProvidedOptions
? '每个 function 后面的说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为;若重排顺序,必须让前面的选项更贴近最近剧情与当前局面的主导矛盾。'
: hasOptionCatalog
? '目录里每个 function 后面的说明都是行为边界。actionText 可以自然改写,也可以只挑当前最合理的那部分 function但不能输出目录外的 function也不能把某个 function 写成别的行为。'
: '下方 function 说明都是行为边界。actionText 可以自然改写,但不能把它写成别的行为。',
hasProvidedOptions || hasOptionCatalog || !pendingEncounter
? null
: '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId若不是场景角色则 options 必须使用空闲 function。',
'当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。',
'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。',
];
if (context.observeSignsRequested) {
sections.push(`当前动作是“停步观察动静”。你必须基于当前场景实体池进行推理把可能出现的角色、敌对目标、BOSS 线索写进 storyText。这里是观察参考实体池\n${observeSignsCatalog}`);
sections.push('这一段重点是观察和判断不是立刻推进遭遇。storyText 要写成后续还能继续引用的侦察结论。');
}
if (isOpeningCampDialogue) {
sections.push(`当前这一步是你与${context.encounterName}在营地里的开场聊天。storyText 必须直接写成刚刚发生的对话正文,每一行都必须以“你:”或“${context.encounterName}:”开头,总行数控制在 4 到 6 行。`);
sections.push('这段开场白必须承接玩家刚踏入此界后的警觉、判断、保留动机、营地气氛和面前同伴的态度,让它成为后续剧情可继续引用的真实最近剧情,而不是旁白总结。');
sections.push('玩家在第一轮里也只该表露表层钩子、眼前压力和试探态度,不要主动把完整目标、完整理由或全部过去一次说完。');
}
if (hasOpeningCampFollowupContext) {
sections.push('当前不是重新发明泛化选项,而是承接上面的营地开场背景和刚刚那段第一轮对话,整理角色眼下最自然会继续说或继续追问的话。');
sections.push('如果固定项里包含两个 npc_chat它们必须排在前两个位置这两个 npc_chat 的 actionText 必须直接承接刚刚聊到的话头,体现追问、确认、延展或继续对接,而不是回到通用模板。');
}
return sections.filter(Boolean).join('\n\n');
}
export function buildUserPrompt(
world: WorldType,
character: Character,
monsters: SceneMonster[],
history: StoryMoment[],
context: StoryGenerationContext,
choice?: string,
availableOptions?: StoryOption[],
optionCatalog?: StoryOption[],
) {
return buildCatalogAwareUserPrompt(world, character, monsters, history, context, choice, availableOptions, optionCatalog);
}
function buildResolvedNpcChatDialoguePrompt(
world: WorldType,
character: Character,
encounterName: string,
monsters: SceneMonster[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
resultSummary: string,
) {
return [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
`当前面前实体性别:${describeGender(getEncounterGender(context))}`,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeStoryHistory(history),
context.openingCampBackground ? `营地开场背景:\n${context.openingCampBackground}` : null,
context.openingCampDialogue ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
`当前交谈对象:${encounterName}`,
`聊天主题:${topic}`,
`关系变化结果:${resultSummary}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
context.openingCampBackground && context.openingCampDialogue
? '这段 npc_chat 必须承接上面的营地开场背景和第一段对话,像同一段谈话自然往下推进,不要把语气和话题重置成初见模板。'
: null,
`请围绕“${topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounterName}:”开头。`,
].filter(Boolean).join('\n\n');
}
function buildNpcChatDialoguePrompt(
world: WorldType,
character: Character,
encounterName: string,
monsters: SceneMonster[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
resultSummary: string,
) {
return buildResolvedNpcChatDialoguePrompt(
world,
character,
encounterName,
monsters,
history,
context,
topic,
resultSummary,
);
}
export function buildStrictNpcChatDialoguePrompt(
world: WorldType,
character: Character,
encounter: { npcName: string },
monsters: SceneMonster[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
resultSummary: string,
) {
return [
buildNpcChatDialoguePrompt(world, character, encounter.npcName, monsters, history, context, topic, resultSummary),
'补充硬约束:这段内容只是聊天,不是做决定。',
'不要让对方在聊天里推进交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。',
'不要替玩家做选择,不要用建议句、命令句或诱导句把聊天写成别的行为入口。',
'低揭示阶段时,宁可留钩子、先谈眼前局势,也不要把完整来历和目标一次说完。',
'如果当前情景是初见或刚打完一轮冲突,优先写短句、观察句和试探句,不要写成正式自我介绍。',
].join('\n\n');
}
export function buildNpcRecruitDialoguePrompt(
world: WorldType,
character: Character,
encounter: { npcName: string },
monsters: SceneMonster[],
history: StoryMoment[],
context: StoryGenerationContext,
invitationText: string,
recruitSummary: string,
) {
return [
`世界:${describeWorld(world)}`,
describePlayerState(world, character, context),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
`当前招募对象性别:${describeGender(getEncounterGender(context))}`,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeStoryHistory(history),
`当前招募对象:${encounter.npcName}`,
`玩家邀请:${invitationText}`,
`招募补充条件:${recruitSummary}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
'对方可以谨慎确认,但对话末尾必须明确答应加入,不能把结论停在犹豫、保留或回避上。',
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
].join('\n\n');
}