Files
Genarrative/src/prompts/storyPromptBuilders.ts
高物 1c72066bab
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 15:45:14 +08:00

1885 lines
76 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.
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 { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
import {
describeConversationStyle as describeNpcConversationStyle,
describeDisclosureStage,
describeWarmthStage,
} from '../data/npcInteractions';
import {
buildSceneEntityCatalogText,
getSceneHostileNpcPresetIds,
getScenePresetById,
} from '../data/scenePresets';
import {
buildFunctionCatalogText,
getFunctionById,
getFunctionPromptDescription,
} from '../data/stateFunctions';
import type { StoryGenerationContext } from '../services/aiTypes';
import { buildCustomWorldReferenceText } from '../services/customWorld';
import { sanitizePromptNarrativeText } from '../services/narrativeLanguage';
import { describeGoalStackForPrompt } from '../services/storyEngine/goalDirector';
import { buildStoryPromptHistory } from '../services/storyHistory';
import {
Character,
CharacterGender,
CustomWorldProfile,
FacingDirection,
SceneHostileNpc,
StoryMoment,
StoryOption,
WorldType,
} from '../types';
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
{
"storyText": "剧情文本",
"encounter": null,
"options": [
{
"functionId": "预定义功能ID",
"actionText": "选项显示文本"
}
]
}
只有当提示语明确要求你判断“主角继续推进后下一刻会遇到什么”时,才允许把 "encounter" 改成:
{
"kind": "npc|treasure|none",
"npcId": "仅当 kind=npc 时填写",
"treasureText": "仅当 kind=treasure 时填写"
}
严格规则:
- 所有文本必须是中文。
- 如果提示语给出了特定可选列表,你必须严格保留原有数量和 functionId你可以调整这些特定项的顺序但排序必须参考最近剧情、刚发生的结果、当前局面轻重缓急再重点优化 actionText下文会直接说明每个 function 的行为边界,不是让你发挥的剧本。
- 如果提示语中没有给特定可选列表,则必须输出至少 6 个选项。
- 除非提示语明确要求你判断下一刻遭遇,否则 encounter 必须保持为 null战斗结束后的续写、聊天续写、固定选项续写都不能生成新的 encounter。
- 每个选项只能包含 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 个字。`;
function describeConversationSituationLabel(
situation: StoryGenerationContext['conversationSituation'],
) {
switch (situation) {
case 'camp_first_contact':
return '营地初次试探';
case 'camp_followup':
return '营地顺势续谈';
case 'post_battle_breath':
return '战后缓气';
case 'shared_danger_coordination':
return '危险中协同';
case 'private_followup':
return '私下续谈';
case 'first_contact_cautious':
return '谨慎初见';
default:
return '当前对话';
}
}
function describeConversationPressureLabel(
pressure: StoryGenerationContext['conversationPressure'],
) {
switch (pressure) {
case 'high':
return '高压';
case 'medium':
return '中压';
case 'low':
return '低压';
default:
return '未知';
}
}
function describeRevealBudgetLabel(revealBudget: string | null | undefined) {
switch (revealBudget) {
case 'low':
return '低';
case 'medium':
return '中';
case 'high':
return '高';
default:
return '未设定';
}
}
function describeEmotionalCadenceLabel(cadence: string | null | undefined) {
switch (cadence) {
case 'tense':
return '紧绷';
case 'curious':
return '试探';
case 'hostile':
return '敌意';
case 'intimate':
return '亲近';
case 'tragic':
return '沉重';
case 'mysterious':
return '迷雾';
default:
return '未设定';
}
}
function describeCompanionReactionTypeLabel(reactionType: string) {
switch (reactionType) {
case 'approve':
return '认可';
case 'disapprove':
return '保留';
case 'concern':
return '担心';
case 'silence':
return '沉默';
case 'curious':
return '被勾起兴趣';
default:
return '反应';
}
}
function describeActStatusLabel(status: string | null | undefined) {
switch (status) {
case 'opening':
return '开场';
case 'midgame':
return '中段';
case 'late_game':
return '后段';
case 'finale':
return '收束';
case 'resolved':
return '已落定';
default:
return '进行中';
}
}
function describeBranchBudgetPressureLabel(
pressure: StoryGenerationContext['branchBudgetPressure'],
) {
switch (pressure) {
case 'low':
return '低';
case 'medium':
return '中';
case 'high':
return '高';
default:
return '未知';
}
}
function describePlayerStyleLabel(style: string | null | undefined) {
switch (style) {
case 'story_first':
return '剧情优先';
case 'explorer':
return '探索驱动';
case 'combat_driver':
return '战斗推进';
case 'companion_bond':
return '同伴关系';
case 'collector':
return '收集倾向';
default:
return '综合型';
}
}
function describeQaSeverityLabel(severity: string) {
switch (severity) {
case 'low':
return '低';
case 'medium':
return '中';
case 'high':
return '高';
default:
return '未知';
}
}
function describeQaCategoryLabel(category: string) {
switch (category) {
case 'consistency':
return '一致性';
case 'pacing':
return '节奏';
case 'payoff':
return '回收';
case 'branch_budget':
return '分支预算';
case 'reveal_leak':
return '信息泄露';
default:
return '叙事问题';
}
}
function describeReleaseGateStatusLabel(status: string | null | undefined) {
switch (status) {
case 'pass':
return '通过';
case 'warn':
return '警告';
case 'block':
return '阻塞';
default:
return '未知';
}
}
function describeChapterStageLabel(stage: string | null | undefined) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
}
}
function describeJourneyBeatLabel(beatType: string | null | undefined) {
switch (beatType) {
case 'approach':
return '接近';
case 'investigation':
return '调查';
case 'camp':
return '休整';
case 'conflict':
return '冲突';
case 'boss_prelude':
return '决战前奏';
case 'climax':
return '高潮';
case 'recovery':
return '恢复';
default:
return '旅程';
}
}
function describeCampEventTypeLabel(eventType: string | null | undefined) {
switch (eventType) {
case 'private_talk':
return '私下谈话';
case 'party_banter':
return '队伍闲谈';
case 'conflict':
return '冲突';
case 'comfort':
return '安抚';
case 'reveal':
return '揭露';
case 'decision':
return '抉择';
default:
return '营地事件';
}
}
function describeSetpieceTypeLabel(setpieceType: string | null | undefined) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '高光节点';
}
}
function describeWorldMutationTypeLabel(mutationType: string) {
switch (mutationType) {
case 'scene_text':
return '场景变化';
case 'npc_attitude':
return '人物态度变化';
case 'shop_style':
return '商铺风格变化';
case 'enemy_pressure':
return '敌方压力变化';
case 'route_lock':
return '路径封锁';
case 'route_unlock':
return '路径开启';
default:
return '世界变化';
}
}
function describeAnimationLabel(animation: string | null | undefined) {
switch (animation) {
case 'idle':
return '待机';
case 'acquire':
return '收取';
case 'attack':
return '攻击';
case 'run':
return '奔跑';
case 'jump':
return '跳跃';
case 'double jump':
return '二段跳';
case 'jump attack':
return '跳击';
case 'dash':
return '冲刺';
case 'hurt':
return '受击';
case 'die':
return '倒下';
case 'climb':
return '攀爬';
case 'skill1':
return '技能一';
case 'skill1 jump':
return '技能一起跳';
case 'skill1 bullet':
return '技能一弹道';
case 'skill1 bullet FX':
return '技能一特效';
case 'skill2':
return '技能二';
case 'skill2 jump':
return '技能二起跳';
case 'skill3':
return '技能三';
case 'skill3 jump':
return '技能三起跳';
case 'skill3 bullet':
return '技能三弹道';
case 'skill3 bullet FX':
return '技能三特效';
case 'skill4':
return '技能四';
case 'Wall Slide':
return '贴墙滑行';
case 'move':
return '逼近';
default:
return animation ?? '当前动作';
}
}
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(context: StoryGenerationContext) {
return context.customWorldProfile
? `自定义世界补充档案:\n${buildCustomWorldReferenceText(context.customWorldProfile, {
activeThreadIds: context.activeThreadIds,
highlightNpcNames: context.encounterName ? [context.encounterName] : [],
})}`
: 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;
}
const recentSharedEvent = sanitizePromptNarrativeText(
context.recentSharedEvent,
'你们刚共同经历了一段需要承接的局势变化。',
);
const talkPriority = sanitizePromptNarrativeText(
context.talkPriority,
'优先承接眼前局势与刚刚发生的变化。',
);
return [
'当前对话情景控制:',
context.conversationSituation
? `- 情景标签:${describeConversationSituationLabel(context.conversationSituation)}`
: null,
context.conversationPressure
? `- 当前压力:${describeConversationPressureLabel(context.conversationPressure)}`
: null,
recentSharedEvent ? `- 刚刚共同经历:${recentSharedEvent}` : null,
talkPriority ? `- 本轮优先说法:${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 hasVisibilityFact(
slice: StoryGenerationContext['visibilitySlice'],
factId: string,
) {
return Boolean(slice?.sayableFactIds.includes(factId));
}
function describeVisibilityFactLabel(factId: string) {
if (factId === 'publicMask') return '公开面';
if (factId === 'firstContactMask') return '首遇遮挡说辞';
if (factId === 'visibleLine') return '表层线';
if (factId === 'immediatePressure') return '当前压力';
if (factId === 'contradiction') return '说辞错位';
if (factId === 'hiddenLine') return '隐藏线';
if (factId === 'debtOrBurden') return '债务或负担';
if (factId === 'taboo') return '禁区';
if (factId.startsWith('thread:')) return '故事线程索引';
if (factId.startsWith('scar:')) return '旧痕索引';
if (factId.startsWith('chapter:')) return '已解锁背景摘要';
if (factId.startsWith('reaction:')) return '反应钩子';
return factId;
}
function describeVisibilitySliceSection(context: StoryGenerationContext) {
if (!context.visibilitySlice) {
return null;
}
const sayable = context.visibilitySlice.sayableFactIds
.map(describeVisibilityFactLabel)
.join('、');
const inferred = context.visibilitySlice.inferredFactIds
.map(describeVisibilityFactLabel)
.join('、');
const forbidden = context.visibilitySlice.forbiddenFactIds
.map(describeVisibilityFactLabel)
.join('、');
return [
'当前信息可见性切片:',
sayable ? `- 可直接进入本轮上下文:${sayable}` : null,
inferred ? `- 只能写成推测或缝隙:${inferred}` : null,
forbidden ? `- 禁止直接说破:${forbidden}` : null,
...(context.visibilitySlice.misdirectionHints ?? []).map(
(hint) => `- 误导/遮挡提示:${hint}`,
),
]
.filter(Boolean)
.join('\n');
}
function describeSceneNarrativeDirectiveSection(context: StoryGenerationContext) {
if (!context.sceneNarrativeDirective) {
return null;
}
const directive = context.sceneNarrativeDirective;
const primaryPressure = sanitizePromptNarrativeText(
directive.primaryPressure,
'当前场景仍有未被说透的压力。',
);
return [
'当前场景导演指令:',
primaryPressure ? `- 主压力:${primaryPressure}` : null,
directive.activeThreadIds.length > 0
? `- 当前激活故事线程数量:${directive.activeThreadIds.length}`
: null,
`- 揭示预算:${describeRevealBudgetLabel(directive.revealBudget)}`,
`- 情绪节奏:${describeEmotionalCadenceLabel(directive.emotionalCadence)}`,
].join('\n');
}
function describeRecentCompanionReactionsSection(context: StoryGenerationContext) {
if (!context.recentCompanionReactions?.length) {
return null;
}
return [
'最近一次同行反应:',
...context.recentCompanionReactions.slice(-3).map(
(reaction) => {
const safeReason = sanitizePromptNarrativeText(
reaction.reason,
'同行角色对你刚才那一步有了新的态度变化。',
);
const speaker =
getCharacterById(reaction.characterId)?.name ?? '同行角色';
return `- ${speaker} / ${describeCompanionReactionTypeLabel(reaction.reactionType)}${safeReason}`;
},
),
].join('\n');
}
function describeRecentCarrierEchoesSection(context: StoryGenerationContext) {
if (!context.recentCarrierEchoes?.length) {
return null;
}
return [
'最近叙事载体回响:',
...context.recentCarrierEchoes.slice(0, 4).map((echo) => `- ${echo}`),
].join('\n');
}
function describeCampaignSection(context: StoryGenerationContext) {
if (!context.campaignState && !context.actState) {
return null;
}
return [
'当前战役状态:',
context.campaignState
? `- 当前战役:${context.campaignState.title}(第 ${context.campaignState.currentActIndex + 1} 幕)`
: null,
context.actState
? `- 当前幕:${context.actState.title} / ${describeActStatusLabel(context.actState.status)} / ${context.actState.theme}`
: null,
].filter(Boolean).join('\n');
}
function describeConsequenceLedgerSection(context: StoryGenerationContext) {
if (!context.consequenceLedger?.length) {
return null;
}
return [
'关键后果账本:',
...context.consequenceLedger.slice(-5).map(
(record) => `- ${record.title}(权重 ${record.weight}${record.summary}`,
),
].join('\n');
}
function describeConstraintSection(context: StoryGenerationContext) {
if (!context.authorialConstraintPack) {
return null;
}
const pack = context.authorialConstraintPack;
return [
'作者性约束:',
`- 基调规则:${pack.toneRules.join('、') || '暂无'}`,
`- 禁止模式:${pack.noGoPatterns.join('、') || '暂无'}`,
`- 必须回收:${pack.requiredPayoffs.join('、') || '暂无'}`,
context.branchBudgetPressure
? `- 当前分支预算压力:${describeBranchBudgetPressureLabel(context.branchBudgetPressure)}`
: null,
].filter(Boolean).join('\n');
}
function describePackSection(context: StoryGenerationContext) {
if (!context.activeScenarioPack && !context.activeCampaignPack) {
return null;
}
return [
'当前内容包:',
context.activeScenarioPack
? `- 当前场景包:${context.activeScenarioPack.title} v${context.activeScenarioPack.version}`
: null,
context.activeCampaignPack
? `- 当前战役包:${context.activeCampaignPack.title} / ${context.activeCampaignPack.authoringStyle}`
: null,
].filter(Boolean).join('\n');
}
function describePlayerStyleSection(context: StoryGenerationContext) {
if (!context.playerStyleProfile) {
return null;
}
return [
'当前玩家画像:',
`- 风格:${describePlayerStyleLabel(context.playerStyleProfile.dominantStyle)}`,
`- 倾向:剧情 ${context.playerStyleProfile.preferenceWeights.story} / 探索 ${context.playerStyleProfile.preferenceWeights.exploration} / 战斗 ${context.playerStyleProfile.preferenceWeights.combat} / 同伴 ${context.playerStyleProfile.preferenceWeights.companion} / 收集 ${context.playerStyleProfile.preferenceWeights.collection}`,
].join('\n');
}
function describeNarrativeQaSection(context: StoryGenerationContext) {
if (!context.narrativeQaReport) {
return null;
}
return [
'当前叙事 QA',
`- 摘要:${context.narrativeQaReport.summary}`,
...context.narrativeQaReport.issues.slice(0, 4).map(
(issue) =>
`- ${describeQaSeverityLabel(issue.severity)} / ${describeQaCategoryLabel(issue.category)}${issue.summary}`,
),
context.releaseGateReport
? `- 发布门禁:${describeReleaseGateStatusLabel(context.releaseGateReport.status)} / ${context.releaseGateReport.summary}`
: null,
context.simulationRunResults?.length
? `- 模拟覆盖:${context.simulationRunResults.length}`
: null,
].join('\n');
}
function describeChapterSection(context: StoryGenerationContext) {
if (!context.chapterState) {
return null;
}
return [
'当前章节状态:',
`- 标题:${context.chapterState.title}`,
`- 阶段:${describeChapterStageLabel(context.chapterState.stage)}`,
`- 主题:${context.chapterState.theme}`,
`- 摘要:${context.chapterState.chapterSummary}`,
].join('\n');
}
function describeJourneyBeatSection(context: StoryGenerationContext) {
if (!context.journeyBeat) {
return null;
}
return [
'当前旅程段落:',
`- 类型:${describeJourneyBeatLabel(context.journeyBeat.beatType)}`,
`- 标题:${context.journeyBeat.title}`,
`- 情绪目标:${context.journeyBeat.emotionalGoal}`,
].join('\n');
}
function describeGoalStackSection(context: StoryGenerationContext) {
return describeGoalStackForPrompt(context.goalStack);
}
function describeCampEventSection(context: StoryGenerationContext) {
if (!context.currentCampEvent) {
return null;
}
return [
'当前可触发营地/旅途事件:',
`- 标题:${context.currentCampEvent.title}`,
`- 类型:${describeCampEventTypeLabel(context.currentCampEvent.eventType)}`,
`- 原因:${context.currentCampEvent.triggerReason}`,
].join('\n');
}
function describeSetpieceSection(context: StoryGenerationContext) {
if (!context.setpieceDirective) {
return null;
}
return [
'当前高光导演指令:',
`- 类型:${describeSetpieceTypeLabel(context.setpieceDirective.setpieceType)}`,
`- 标题:${context.setpieceDirective.title}`,
`- 核心问题:${context.setpieceDirective.dramaticQuestion}`,
].join('\n');
}
function describeWorldMutationSection(context: StoryGenerationContext) {
if (!context.recentWorldMutations?.length) {
return null;
}
return [
'最近世界变化:',
...context.recentWorldMutations.slice(-4).map(
(mutation) =>
`- ${describeWorldMutationTypeLabel(mutation.mutationType)}${mutation.reason}`,
),
].join('\n');
}
function describeFactionTensionSection(context: StoryGenerationContext) {
if (!context.recentFactionTensionStates?.length) {
return null;
}
return [
'当前阵营温度:',
...context.recentFactionTensionStates.slice(0, 4).map(
(tension) =>
`- 温度 ${tension.temperature}${tension.pressureSummary}`,
),
].join('\n');
}
function describeChronicleSection(context: StoryGenerationContext) {
const chronicleSummary = sanitizePromptNarrativeText(
context.recentChronicleSummary,
);
if (!chronicleSummary) {
return null;
}
return `近期旅程回顾:\n${chronicleSummary}`;
}
function buildCustomEncounterBackstoryLines(context: StoryGenerationContext) {
const encounterCustomProfile = context.encounterCustomProfile;
const narrativeProfile = context.encounterNarrativeProfile;
if (!encounterCustomProfile || !narrativeProfile) {
return ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
}
const lines: string[] = [];
if (hasVisibilityFact(context.visibilitySlice, 'publicMask')) {
lines.push(narrativeProfile.publicMask);
}
if (hasVisibilityFact(context.visibilitySlice, 'firstContactMask')) {
lines.push(narrativeProfile.firstContactMask);
}
if (hasVisibilityFact(context.visibilitySlice, 'visibleLine')) {
lines.push(narrativeProfile.visibleLine);
}
if (hasVisibilityFact(context.visibilitySlice, 'immediatePressure')) {
lines.push(narrativeProfile.immediatePressure);
}
(encounterCustomProfile.backstoryReveal?.chapters ?? []).forEach((chapter) => {
if (hasVisibilityFact(context.visibilitySlice, `chapter:${chapter.id}`)) {
const snippet =
chapter.contextSnippet || chapter.teaser || encounterCustomProfile.backstoryReveal?.publicSummary;
if (snippet) {
lines.push(snippet);
}
}
});
return lines.length > 0
? [...new Set(lines.filter(Boolean))]
: [encounterCustomProfile.backstoryReveal?.publicSummary ?? narrativeProfile.publicMask];
}
function describeBackstoryContext(label: string, snippets: string[]) {
const normalized = snippets
.map((snippet) =>
sanitizePromptNarrativeText(
snippet,
`${label === '主角背景' ? '主角' : '对方'}仍有自己的来路,但此刻不直接沿用非中文原句。`,
),
)
.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: SceneHostileNpc[],
) {
const schema = resolveAttributeSchema(world, context.customWorldProfile);
if (context.encounterName) {
const encounterCustomProfile = context.encounterCustomProfile;
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}`, [
encounterCustomProfile?.personality ||
inferEncounterPersonality(
context.encounterContext,
context.encounterDescription,
),
context.encounterNarrativeProfile?.publicMask ?? '',
context.encounterNarrativeProfile?.visibleLine ?? '',
context.encounterNarrativeProfile?.immediatePressure ?? '',
...(context.visibilitySlice?.sayableFactIds.includes('contradiction')
&& context.encounterNarrativeProfile?.contradiction
? [context.encounterNarrativeProfile.contradiction]
: []),
]);
const title =
encounterCharacter?.title ??
encounterCustomProfile?.title ??
context.encounterContext ??
'此地生灵';
const description =
encounterCharacter?.description ??
encounterCustomProfile?.description ??
context.encounterDescription ??
'对方站在你面前,等待你进一步表态。';
const personality =
encounterCharacter?.personality ??
encounterCustomProfile?.personality ??
inferEncounterPersonality(
context.encounterContext,
context.encounterDescription,
);
const backstoryLines = encounterCharacter
? context.isFirstMeaningfulContact
? [getCharacterPublicBackstorySummary(encounterCharacter, world)]
: buildCharacterBackstoryPromptContext(
encounterCharacter,
context.encounterAffinity ?? 0,
world,
)
: encounterCustomProfile
? buildCustomEncounterBackstoryLines(context)
: ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
const status = context.encounterKind === 'npc'
? context.isFirstMeaningfulContact
? '你们正在进行第一次真正接触,对方会先观察你的态度与来意。'
: '对你保持观察与戒备,正在等待你的回应'
: context.encounterKind === 'treasure'
? '静静停在前方,尚未被真正触碰'
: '状态未明';
return [
'当前面前实体:',
`- 名称:${context.encounterName}`,
`- 身份:${title}`,
`- 描述:${description}`,
...describeBackstoryContext('背景', backstoryLines).map(line => `- ${line}`),
`- 性格:${personality}`,
context.encounterNarrativeProfile?.firstContactMask
? `- 首遇遮挡说辞:${context.encounterNarrativeProfile.firstContactMask}`
: null,
context.encounterNarrativeProfile?.visibleLine
? `- 表层线:${context.encounterNarrativeProfile.visibleLine}`
: null,
context.encounterNarrativeProfile?.immediatePressure
? `- 当前压力:${context.encounterNarrativeProfile.immediatePressure}`
: null,
context.visibilitySlice?.inferredFactIds.includes('contradiction') &&
context.encounterNarrativeProfile?.contradiction
? `- 可写成推测的错位:${context.encounterNarrativeProfile.contradiction}`
: null,
!context.encounterNarrativeProfile && encounterCustomProfile?.motivation
? `- 当前动机:${encounterCustomProfile.motivation}`
: null,
!context.encounterNarrativeProfile && encounterCustomProfile?.combatStyle
? `- 战斗风格:${encounterCustomProfile.combatStyle}`
: null,
!context.encounterNarrativeProfile && encounterCustomProfile?.relationshipHooks?.length
? `- 关系切入口:${encounterCustomProfile.relationshipHooks.join('、')}`
: null,
!context.encounterNarrativeProfile && encounterCustomProfile?.tags?.length
? `- 标签:${encounterCustomProfile.tags.join('、')}`
: null,
!context.encounterNarrativeProfile && encounterCustomProfile?.skills?.length
? `- 自定义技能:${encounterCustomProfile.skills
.map((skill) => `${skill.name}(${skill.style})${skill.summary}`)
.join('')}`
: null,
!context.encounterNarrativeProfile && encounterCustomProfile?.initialItems?.length
? `- 随身物:${encounterCustomProfile.initialItems
.map(
(item) =>
`${item.name}x${item.quantity}(${item.category}/${item.rarity})`,
)
.join('')}`
: null,
`- 世界属性框架:${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,
sanitizePromptNarrativeText(context.encounterRelationshipSummary)
? `- 你与对方私下相处补充:${sanitizePromptNarrativeText(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)},当前动作 ${describeAnimationLabel(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}`,
sanitizePromptNarrativeText(context.lastObserveSignsReport)
? `最近一次观察结果:${sanitizePromptNarrativeText(context.lastObserveSignsReport)}`
: null,
sanitizePromptNarrativeText(context.recentActionResult)
? `刚刚结算结果:${sanitizePromptNarrativeText(context.recentActionResult)}`
: 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)},当前动作 ${describeAnimationLabel(context.playerAnimation)}`,
...describeAttributeProfileForPrompt('主角', world, context, attributeProfile),
].filter(Boolean).join('\n');
}
function describeMonsters(monsters: SceneHostileNpc[]) {
if (monsters.length === 0) {
return '当前没有可见敌对目标。';
}
return monsters
.map(monster => {
const hpRatio = monster.hp / Math.max(monster.maxHp, 1);
return `敌对目标 ${monster.name}:生命状态 ${describeHpBand(hpRatio)},当前动作 ${describeAnimationLabel(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);
const previousSummary = sanitizePromptNarrativeText(
promptHistory.previousSummary,
'更早的剧情已经推进过数轮,请只承接既有结果,不直接沿用其中的非中文原句。',
);
const recentOriginalRounds = promptHistory.recentOriginalRounds
.map((item) =>
sanitizePromptNarrativeText(
item,
'这一轮的原始文本里夹杂了非中文描述,续写时只承接已发生的结果与局势变化。',
),
)
.filter(Boolean);
if (!previousSummary && recentOriginalRounds.length === 0) {
return '最近剧情:暂无。';
}
return [
previousSummary
? `3轮以前的历史剧情总结\n${previousSummary}`
: '3轮以前的历史剧情总结暂无。',
recentOriginalRounds.length > 0
? `最近3轮剧情原文续写时优先承接\n${recentOriginalRounds
.map((item, index) => `- 第${index + 1}\n${item}`)
.join('\n')}`
: '最近3轮剧情原文暂无。',
'续写时必须先承接“最近3轮剧情原文”再与“3轮以前的历史剧情总结”保持一致不得跳过已经发生的结果、地点、关系变化或战斗状态。',
].join('\n');
}
function _buildResolvedUserPrompt(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
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(sanitizePromptNarrativeText(context.openingCampBackground))
&& Boolean(sanitizePromptNarrativeText(context.openingCampDialogue));
const partyRelationshipNotes = sanitizePromptNarrativeText(
context.partyRelationshipNotes,
);
const openingCampBackground = sanitizePromptNarrativeText(
context.openingCampBackground,
);
const openingCampDialogue = sanitizePromptNarrativeText(
context.openingCampDialogue,
);
const safeChoice = choice
? sanitizePromptNarrativeText(
choice,
'玩家刚刚做出了一个新的决定。',
)
: null;
const sceneMonsterIds = getSceneHostileNpcPresetIds(scene);
const battleCatalog = scene
? buildFunctionCatalogText({
...functionContext,
inBattle: true,
monsters: createSceneHostileNpcsFromIds(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),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
describePackSection(context),
describePlayerStyleSection(context),
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
describeConstraintSection(context),
describeCampEventSection(context),
describeSetpieceSection(context),
describeRecentCompanionReactionsSection(context),
describeRecentCarrierEchoesSection(context),
describeWorldMutationSection(context),
describeFactionTensionSection(context),
describeChronicleSection(context),
describeNarrativeQaSection(context),
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
partyRelationshipNotes ? `同行角色补充关系信息:\n${partyRelationshipNotes}` : null,
describeStoryHistory(history),
hasOpeningCampFollowupContext ? `营地开场背景:\n${openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null,
safeChoice ? `玩家刚刚选择:${safeChoice}` : '玩家刚进入当前局面。',
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。',
describeEncounterOutputRequirement(Boolean(pendingEncounter)),
'当敌人状态偏差时,攻击类 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 option.detailText
? `向面前角色送礼,以改善关系或表达诚意。当前礼物线索:${option.detailText}`
: '向面前角色送礼,以改善关系或表达诚意。';
}
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 describeEncounterOutputRequirement(pendingEncounter: boolean) {
return pendingEncounter
? '只有当前文明确要求你判断“主角继续推进后下一刻会遇到什么”时encounter 才能填写对象;如果这一刻什么都没遇到,请填写 kind=none。'
: '当前这一步不是遭遇生成流程。encounter 必须为 null保持为空不要生成新的 encounter尤其是战斗结束后的续写、聊天续写、固定选项续写时禁止新增场景实体。';
}
function buildCatalogAwareUserPrompt(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
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(sanitizePromptNarrativeText(context.openingCampBackground))
&& Boolean(sanitizePromptNarrativeText(context.openingCampDialogue));
const partyRelationshipNotes = sanitizePromptNarrativeText(
context.partyRelationshipNotes,
);
const openingCampBackground = sanitizePromptNarrativeText(
context.openingCampBackground,
);
const openingCampDialogue = sanitizePromptNarrativeText(
context.openingCampDialogue,
);
const safeChoice = choice
? sanitizePromptNarrativeText(
choice,
'玩家刚刚做出了一个新的决定。',
)
: null;
const battleCatalog = scene
? buildFunctionCatalogText({
...functionContext,
inBattle: true,
monsters: createSceneHostileNpcsFromIds(world, getSceneHostileNpcPresetIds(scene), 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),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
describePackSection(context),
describePlayerStyleSection(context),
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
describeConstraintSection(context),
describeCampEventSection(context),
describeSetpieceSection(context),
describeRecentCompanionReactionsSection(context),
describeRecentCarrierEchoesSection(context),
describeWorldMutationSection(context),
describeFactionTensionSection(context),
describeChronicleSection(context),
describeNarrativeQaSection(context),
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
partyRelationshipNotes ? `同行角色补充关系信息:\n${partyRelationshipNotes}` : null,
describeStoryHistory(history),
hasOpeningCampFollowupContext ? `营地开场背景:\n${openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null,
safeChoice ? `玩家刚刚选择:${safeChoice}` : '玩家刚进入当前局面。',
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。',
describeEncounterOutputRequirement(Boolean(pendingEncounter)),
'当敌人状态偏差时,攻击类 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: SceneHostileNpc[],
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: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
resultSummary: string,
) {
const openingCampBackground = sanitizePromptNarrativeText(
context.openingCampBackground,
);
const openingCampDialogue = sanitizePromptNarrativeText(
context.openingCampDialogue,
);
const safeTopic =
sanitizePromptNarrativeText(topic, '眼前刚刚谈到的话头') ?? topic;
const safeResultSummary =
sanitizePromptNarrativeText(
resultSummary,
'这段聊天刚让你们之间的气氛发生了新的变化。',
) ?? resultSummary;
return [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
describeCustomWorldSection(context),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
describePackSection(context),
describePlayerStyleSection(context),
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
describeConstraintSection(context),
describeCampEventSection(context),
describeSetpieceSection(context),
describeRecentCompanionReactionsSection(context),
describeRecentCarrierEchoesSection(context),
describeWorldMutationSection(context),
describeFactionTensionSection(context),
describeChronicleSection(context),
describeNarrativeQaSection(context),
`当前面前实体性别:${describeGender(getEncounterGender(context))}`,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeStoryHistory(history),
openingCampBackground ? `营地开场背景:\n${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null,
`当前交谈对象:${encounterName}`,
`聊天主题:${safeTopic}`,
`关系变化结果:${safeResultSummary}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
openingCampBackground && openingCampDialogue
? '这段 npc_chat 必须承接上面的营地开场背景和第一段对话,像同一段谈话自然往下推进,不要把语气和话题重置成初见模板。'
: null,
`请围绕“${safeTopic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounterName}:”开头。`,
].filter(Boolean).join('\n\n');
}
function buildNpcChatDialoguePrompt(
world: WorldType,
character: Character,
encounterName: string,
monsters: SceneHostileNpc[],
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: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
resultSummary: string,
) {
return [
buildNpcChatDialoguePrompt(world, character, encounter.npcName, monsters, history, context, topic, resultSummary),
'补充硬约束:这段内容只是聊天,不是做决定。',
'不要让对方在聊天里推进交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。',
'不要替玩家做选择,不要用建议句、命令句或诱导句把聊天写成别的行为入口。',
'低揭示阶段时,宁可留钩子、先谈眼前局势,也不要把完整来历和目标一次说完。',
context.isFirstMeaningfulContact
? '如果这是第一次真正接触,对方第一次开口必须先用一句自然招呼或开场判断起手,不能写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白。'
: null,
'如果当前情景是初见或刚打完一轮冲突,优先写短句、观察句和试探句,不要写成正式自我介绍。',
].filter(Boolean).join('\n\n');
}
export function buildNpcRecruitDialoguePrompt(
world: WorldType,
character: Character,
encounter: { npcName: string },
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
invitationText: string,
recruitSummary: string,
) {
const safeInvitationText =
sanitizePromptNarrativeText(invitationText, '我希望你能加入队伍,与我并肩同行。') ??
invitationText;
const safeRecruitSummary =
sanitizePromptNarrativeText(
recruitSummary,
'双方已经具备继续同行的条件。',
) ?? recruitSummary;
return [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
describeCustomWorldSection(context),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
describePackSection(context),
describePlayerStyleSection(context),
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
describeConstraintSection(context),
describeCampEventSection(context),
describeSetpieceSection(context),
describeRecentCompanionReactionsSection(context),
describeRecentCarrierEchoesSection(context),
describeWorldMutationSection(context),
describeFactionTensionSection(context),
describeChronicleSection(context),
describeNarrativeQaSection(context),
`当前招募对象性别:${describeGender(getEncounterGender(context))}`,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeStoryHistory(history),
`当前招募对象:${encounter.npcName}`,
`玩家邀请:${safeInvitationText}`,
`招募补充条件:${safeRecruitSummary}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
'对方可以谨慎确认,但对话末尾必须明确答应加入,不能把结论停在犹豫、保留或回避上。',
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
].join('\n\n');
}