Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -42,17 +42,15 @@ import {
} from '../types';
import type { StoryGenerationContext } from './aiTypes';
import { buildCustomWorldReferenceText } from './customWorld';
import { sanitizePromptNarrativeText } from './narrativeLanguage';
import { describeGoalStackForPrompt } from './storyEngine/goalDirector';
import { buildStoryPromptHistory } from './storyHistory';
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
{
"storyText": "剧情文本",
"encounter": {
"kind": "npc|treasure|none",
"npcId": "仅当 kind=npc 时填写",
"treasureText": "仅当 kind=treasure 时填写"
},
"encounter": null,
"options": [
{
"functionId": "预定义功能ID",
@@ -61,10 +59,18 @@ export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能
]
}
只有当提示语明确要求你判断“主角继续推进后下一刻会遇到什么”时,才允许把 "encounter" 改成:
{
"kind": "npc|treasure|none",
"npcId": "仅当 kind=npc 时填写",
"treasureText": "仅当 kind=treasure 时填写"
}
严格规则:
- 所有文本必须是中文。
- 如果提示语给出了特定可选列表,你必须严格保留原有数量和 functionId你可以调整这些特定项的顺序但排序必须参考最近剧情、刚发生的结果、当前局面轻重缓急再重点优化 actionText下文会直接说明每个 function 的行为边界,不是让你发挥的剧本。
- 如果提示语中没有给特定可选列表,则必须输出至少 6 个选项。
- 除非提示语明确要求你判断下一刻遭遇,否则 encounter 必须保持为 null战斗结束后的续写、聊天续写、固定选项续写都不能生成新的 encounter。
- 每个选项只能包含 functionId 和 actionText。
- 没有特定列表时,所有 functionId 必须互不重复。
- 每个选项只能包含一个 function不要把多个动作塞进同一行。
@@ -139,6 +145,329 @@ export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `你要把玩家与这
- 聊天里出现的重要信息、承诺、顾虑或暗示
长度控制在 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 '仙侠';
@@ -259,12 +588,25 @@ function describeConversationSituationDirective(context: StoryGenerationContext)
return null;
}
const recentSharedEvent = sanitizePromptNarrativeText(
context.recentSharedEvent,
'你们刚共同经历了一段需要承接的局势变化。',
);
const talkPriority = sanitizePromptNarrativeText(
context.talkPriority,
'优先承接眼前局势与刚刚发生的变化。',
);
return [
'当前对话情景控制:',
context.conversationSituation ? `- 情景标签:${context.conversationSituation}` : null,
context.conversationPressure ? `- 当前压力:${context.conversationPressure}` : null,
context.recentSharedEvent ? `- 刚刚共同经历:${context.recentSharedEvent}` : null,
context.talkPriority ? `- 本轮优先说法:${context.talkPriority}` : null,
context.conversationSituation
? `- 情景标签:${describeConversationSituationLabel(context.conversationSituation)}`
: null,
context.conversationPressure
? `- 当前压力:${describeConversationPressureLabel(context.conversationPressure)}`
: null,
recentSharedEvent ? `- 刚刚共同经历:${recentSharedEvent}` : null,
talkPriority ? `- 本轮优先说法:${talkPriority}` : null,
].filter(Boolean).join('\n');
}
@@ -356,12 +698,18 @@ function describeSceneNarrativeDirectiveSection(context: StoryGenerationContext)
}
const directive = context.sceneNarrativeDirective;
const primaryPressure = sanitizePromptNarrativeText(
directive.primaryPressure,
'当前场景仍有未被说透的压力。',
);
return [
'当前场景导演指令:',
`- 主压力:${directive.primaryPressure}`,
`- 激活线程:${directive.activeThreadIds.join('、') || '暂无'}`,
`- 揭示预算:${directive.revealBudget}`,
`- 情绪节奏:${directive.emotionalCadence}`,
primaryPressure ? `- 主压力:${primaryPressure}` : null,
directive.activeThreadIds.length > 0
? `- 当前激活故事线程数量:${directive.activeThreadIds.length}`
: null,
`- 揭示预算:${describeRevealBudgetLabel(directive.revealBudget)}`,
`- 情绪节奏:${describeEmotionalCadenceLabel(directive.emotionalCadence)}`,
].join('\n');
}
@@ -373,8 +721,15 @@ function describeRecentCompanionReactionsSection(context: StoryGenerationContext
return [
'最近一次同行反应:',
...context.recentCompanionReactions.slice(-3).map(
(reaction) =>
`- ${reaction.characterId} / ${reaction.reactionType}${reaction.reason}`,
(reaction) => {
const safeReason = sanitizePromptNarrativeText(
reaction.reason,
'同行角色对你刚才那一步有了新的态度变化。',
);
const speaker =
getCharacterById(reaction.characterId)?.name ?? '同行角色';
return `- ${speaker} / ${describeCompanionReactionTypeLabel(reaction.reactionType)}${safeReason}`;
},
),
].join('\n');
}
@@ -398,10 +753,10 @@ function describeCampaignSection(context: StoryGenerationContext) {
return [
'当前战役状态:',
context.campaignState
? `- Campaign${context.campaignState.title}Act ${context.campaignState.currentActIndex + 1}`
? `- 当前战役${context.campaignState.title} ${context.campaignState.currentActIndex + 1}`
: null,
context.actState
? `- 当前 Act${context.actState.title} / ${context.actState.status} / ${context.actState.theme}`
? `- 当前${context.actState.title} / ${describeActStatusLabel(context.actState.status)} / ${context.actState.theme}`
: null,
].filter(Boolean).join('\n');
}
@@ -431,7 +786,7 @@ function describeConstraintSection(context: StoryGenerationContext) {
`- 禁止模式:${pack.noGoPatterns.join('、') || '暂无'}`,
`- 必须回收:${pack.requiredPayoffs.join('、') || '暂无'}`,
context.branchBudgetPressure
? `- 当前分支预算压力:${context.branchBudgetPressure}`
? `- 当前分支预算压力:${describeBranchBudgetPressureLabel(context.branchBudgetPressure)}`
: null,
].filter(Boolean).join('\n');
}
@@ -444,10 +799,10 @@ function describePackSection(context: StoryGenerationContext) {
return [
'当前内容包:',
context.activeScenarioPack
? `- Scenario Pack${context.activeScenarioPack.title} v${context.activeScenarioPack.version}`
? `- 当前场景包${context.activeScenarioPack.title} v${context.activeScenarioPack.version}`
: null,
context.activeCampaignPack
? `- Campaign Pack${context.activeCampaignPack.title} / ${context.activeCampaignPack.authoringStyle}`
? `- 当前战役包${context.activeCampaignPack.title} / ${context.activeCampaignPack.authoringStyle}`
: null,
].filter(Boolean).join('\n');
}
@@ -459,7 +814,7 @@ function describePlayerStyleSection(context: StoryGenerationContext) {
return [
'当前玩家画像:',
`- 风格:${context.playerStyleProfile.dominantStyle}`,
`- 风格:${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');
}
@@ -473,13 +828,14 @@ function describeNarrativeQaSection(context: StoryGenerationContext) {
'当前叙事 QA',
`- 摘要:${context.narrativeQaReport.summary}`,
...context.narrativeQaReport.issues.slice(0, 4).map(
(issue) => `- ${issue.severity}/${issue.category}${issue.summary}`,
(issue) =>
`- ${describeQaSeverityLabel(issue.severity)} / ${describeQaCategoryLabel(issue.category)}${issue.summary}`,
),
context.releaseGateReport
? `- Release Gate${context.releaseGateReport.status} / ${context.releaseGateReport.summary}`
? `- 发布门禁:${describeReleaseGateStatusLabel(context.releaseGateReport.status)} / ${context.releaseGateReport.summary}`
: null,
context.simulationRunResults?.length
? `- Simulation 覆盖:${context.simulationRunResults.length}`
? `- 模拟覆盖:${context.simulationRunResults.length}`
: null,
].join('\n');
}
@@ -492,7 +848,7 @@ function describeChapterSection(context: StoryGenerationContext) {
return [
'当前章节状态:',
`- 标题:${context.chapterState.title}`,
`- 阶段:${context.chapterState.stage}`,
`- 阶段:${describeChapterStageLabel(context.chapterState.stage)}`,
`- 主题:${context.chapterState.theme}`,
`- 摘要:${context.chapterState.chapterSummary}`,
].join('\n');
@@ -505,12 +861,16 @@ function describeJourneyBeatSection(context: StoryGenerationContext) {
return [
'当前旅程段落:',
`- 类型:${context.journeyBeat.beatType}`,
`- 类型:${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;
@@ -519,7 +879,7 @@ function describeCampEventSection(context: StoryGenerationContext) {
return [
'当前可触发营地/旅途事件:',
`- 标题:${context.currentCampEvent.title}`,
`- 类型:${context.currentCampEvent.eventType}`,
`- 类型:${describeCampEventTypeLabel(context.currentCampEvent.eventType)}`,
`- 原因:${context.currentCampEvent.triggerReason}`,
].join('\n');
}
@@ -531,7 +891,7 @@ function describeSetpieceSection(context: StoryGenerationContext) {
return [
'当前高光导演指令:',
`- 类型:${context.setpieceDirective.setpieceType}`,
`- 类型:${describeSetpieceTypeLabel(context.setpieceDirective.setpieceType)}`,
`- 标题:${context.setpieceDirective.title}`,
`- 核心问题:${context.setpieceDirective.dramaticQuestion}`,
].join('\n');
@@ -546,7 +906,7 @@ function describeWorldMutationSection(context: StoryGenerationContext) {
'最近世界变化:',
...context.recentWorldMutations.slice(-4).map(
(mutation) =>
`- ${mutation.mutationType} / ${mutation.targetId}${mutation.reason}`,
`- ${describeWorldMutationTypeLabel(mutation.mutationType)}${mutation.reason}`,
),
].join('\n');
}
@@ -560,17 +920,20 @@ function describeFactionTensionSection(context: StoryGenerationContext) {
'当前阵营温度:',
...context.recentFactionTensionStates.slice(0, 4).map(
(tension) =>
`- ${tension.factionId} / 温度 ${tension.temperature}${tension.pressureSummary}`,
`- 温度 ${tension.temperature}${tension.pressureSummary}`,
),
].join('\n');
}
function describeChronicleSection(context: StoryGenerationContext) {
if (!context.recentChronicleSummary?.trim()) {
const chronicleSummary = sanitizePromptNarrativeText(
context.recentChronicleSummary,
);
if (!chronicleSummary) {
return null;
}
return `近期旅程回顾:\n${context.recentChronicleSummary}`;
return `近期旅程回顾:\n${chronicleSummary}`;
}
function buildCustomEncounterBackstoryLines(context: StoryGenerationContext) {
@@ -611,7 +974,12 @@ function buildCustomEncounterBackstoryLines(context: StoryGenerationContext) {
function describeBackstoryContext(label: string, snippets: string[]) {
const normalized = snippets
.map(snippet => snippet.trim())
.map((snippet) =>
sanitizePromptNarrativeText(
snippet,
`${label === '主角背景' ? '主角' : '对方'}仍有自己的来路,但此刻不直接沿用非中文原句。`,
),
)
.filter(Boolean);
if (normalized.length === 0) {
@@ -847,8 +1215,8 @@ function describeFrontEntity(
context.encounterKind === 'npc' && context.encounterAffinityText
? `- 对你的态度:${context.encounterAffinityText}`
: null,
context.encounterRelationshipSummary
? `- 你与对方私下相处补充:${context.encounterRelationshipSummary}`
sanitizePromptNarrativeText(context.encounterRelationshipSummary)
? `- 你与对方私下相处补充:${sanitizePromptNarrativeText(context.encounterRelationshipSummary)}`
: null,
].filter(Boolean).join('\n');
}
@@ -872,7 +1240,7 @@ function describeFrontEntity(
'- 身份:当前最靠前的敌对目标',
`- 描述:${monsterPreset?.description ?? primaryMonster.description}`,
'- 性格:更接近本能性的压迫与试探,会按当前动作持续逼近你',
`- 状态:生命状态 ${describeHpBand(hpRatio)},当前动作 ${primaryMonster.animation},朝向 ${describeFacing(primaryMonster.facing)}`,
`- 状态:生命状态 ${describeHpBand(hpRatio)},当前动作 ${describeAnimationLabel(primaryMonster.animation)},朝向 ${describeFacing(primaryMonster.facing)}`,
...describeAttributeProfileForPrompt('敌对实体', world, context, monsterProfile).map(line => `- ${line}`),
].join('\n');
}
@@ -893,14 +1261,19 @@ function describePlayerState(world: WorldType, character: Character, context: St
`玩家状态:${context.inBattle ? '战斗状态' : '空闲状态'}`,
`当前场景:${sceneName}`,
`场景描述:${sceneDescription}`,
context.lastObserveSignsReport ? `最近一次观察结果:${context.lastObserveSignsReport}` : null,
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)},当前动作 ${context.playerAnimation}`,
`主角状态:生命状态 ${describeHpBand(hpRatio)},灵力状态 ${describeManaBand(manaRatio)},整体判断 ${describeOverallBand(hpRatio, manaRatio)},朝向 ${describeFacing(context.playerFacing)},当前动作 ${describeAnimationLabel(context.playerAnimation)}`,
...describeAttributeProfileForPrompt('主角', world, context, attributeProfile),
].filter(Boolean).join('\n');
}
@@ -913,7 +1286,7 @@ function describeMonsters(monsters: SceneHostileNpc[]) {
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)}`;
return `敌对目标 ${monster.name}:生命状态 ${describeHpBand(hpRatio)},当前动作 ${describeAnimationLabel(monster.animation)},行为“${monster.action}”,朝向 ${describeFacing(monster.facing)}`;
})
.join('\n');
}
@@ -928,17 +1301,29 @@ function _describeHistory(history: string[]) {
function describeStoryHistory(history: StoryMoment[]) {
const promptHistory = buildStoryPromptHistory(history);
const previousSummary = sanitizePromptNarrativeText(
promptHistory.previousSummary,
'更早的剧情已经推进过数轮,请只承接既有结果,不直接沿用其中的非中文原句。',
);
const recentOriginalRounds = promptHistory.recentOriginalRounds
.map((item) =>
sanitizePromptNarrativeText(
item,
'这一轮的原始文本里夹杂了非中文描述,续写时只承接已发生的结果与局势变化。',
),
)
.filter(Boolean);
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
if (!previousSummary && recentOriginalRounds.length === 0) {
return '最近剧情:暂无。';
}
return [
promptHistory.previousSummary
? `3轮以前的历史剧情总结\n${promptHistory.previousSummary}`
previousSummary
? `3轮以前的历史剧情总结\n${previousSummary}`
: '3轮以前的历史剧情总结暂无。',
promptHistory.recentOriginalRounds.length > 0
? `最近3轮剧情原文续写时优先承接\n${promptHistory.recentOriginalRounds
recentOriginalRounds.length > 0
? `最近3轮剧情原文续写时优先承接\n${recentOriginalRounds
.map((item, index) => `- 第${index + 1}\n${item}`)
.join('\n')}`
: '最近3轮剧情原文暂无。',
@@ -975,8 +1360,23 @@ function _buildResolvedUserPrompt(
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());
&& 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({
@@ -1005,6 +1405,7 @@ function _buildResolvedUserPrompt(
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
@@ -1022,11 +1423,11 @@ function _buildResolvedUserPrompt(
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
context.partyRelationshipNotes ? `同行角色补充关系信息:\n${context.partyRelationshipNotes}` : null,
partyRelationshipNotes ? `同行角色补充关系信息:\n${partyRelationshipNotes}` : null,
describeStoryHistory(history),
hasOpeningCampFollowupContext ? `营地开场背景:\n${context.openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
choice ? `玩家刚刚选择:${choice}` : '玩家刚进入当前局面。',
hasOpeningCampFollowupContext ? `营地开场背景:\n${openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null,
safeChoice ? `玩家刚刚选择:${safeChoice}` : '玩家刚进入当前局面。',
hasProvidedOptions
? `固定可选项列表(必须保持数量与 functionId 一致,可按最近剧情重排顺序):\n${describeProvidedOptions(availableOptions ?? [])}`
: pendingEncounter
@@ -1048,6 +1449,7 @@ function _buildResolvedUserPrompt(
hasProvidedOptions || !pendingEncounter
? null
: '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId若不是场景角色则 options 必须使用空闲 function。',
describeEncounterOutputRequirement(Boolean(pendingEncounter)),
'当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。',
'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。',
];
@@ -1141,6 +1543,12 @@ function describeProvidedOptions(options: StoryOption[]) {
.join('\n');
}
function describeEncounterOutputRequirement(pendingEncounter: boolean) {
return pendingEncounter
? '只有当前文明确要求你判断“主角继续推进后下一刻会遇到什么”时encounter 才能填写对象;如果这一刻什么都没遇到,请填写 kind=none。'
: '当前这一步不是遭遇生成流程。encounter 必须为 null保持为空不要生成新的 encounter尤其是战斗结束后的续写、聊天续写、固定选项续写时禁止新增场景实体。';
}
function buildCatalogAwareUserPrompt(
world: WorldType,
character: Character,
@@ -1170,8 +1578,23 @@ function buildCatalogAwareUserPrompt(
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());
&& 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,
@@ -1199,6 +1622,7 @@ function buildCatalogAwareUserPrompt(
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
@@ -1217,11 +1641,11 @@ function buildCatalogAwareUserPrompt(
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
context.partyRelationshipNotes ? `同行角色补充关系信息:\n${context.partyRelationshipNotes}` : null,
partyRelationshipNotes ? `同行角色补充关系信息:\n${partyRelationshipNotes}` : null,
describeStoryHistory(history),
hasOpeningCampFollowupContext ? `营地开场背景:\n${context.openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
choice ? `玩家刚刚选择:${choice}` : '玩家刚进入当前局面。',
hasOpeningCampFollowupContext ? `营地开场背景:\n${openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null,
safeChoice ? `玩家刚刚选择:${safeChoice}` : '玩家刚进入当前局面。',
hasProvidedOptions
? `固定可选项列表(必须保留数量与 functionId可按最近剧情重排顺序\n${describeProvidedOptions(availableOptions ?? [])}`
: hasOptionCatalog
@@ -1251,6 +1675,7 @@ function buildCatalogAwareUserPrompt(
hasProvidedOptions || hasOptionCatalog || !pendingEncounter
? null
: '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId若不是场景角色则 options 必须使用空闲 function。',
describeEncounterOutputRequirement(Boolean(pendingEncounter)),
'当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。',
'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。',
];
@@ -1297,6 +1722,20 @@ function buildResolvedNpcChatDialoguePrompt(
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),
@@ -1308,6 +1747,7 @@ function buildResolvedNpcChatDialoguePrompt(
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
@@ -1324,18 +1764,18 @@ function buildResolvedNpcChatDialoguePrompt(
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeStoryHistory(history),
context.openingCampBackground ? `营地开场背景:\n${context.openingCampBackground}` : null,
context.openingCampDialogue ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
openingCampBackground ? `营地开场背景:\n${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null,
`当前交谈对象:${encounterName}`,
`聊天主题:${topic}`,
`关系变化结果:${resultSummary}`,
`聊天主题:${safeTopic}`,
`关系变化结果:${safeResultSummary}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
context.openingCampBackground && context.openingCampDialogue
openingCampBackground && openingCampDialogue
? '这段 npc_chat 必须承接上面的营地开场背景和第一段对话,像同一段谈话自然往下推进,不要把语气和话题重置成初见模板。'
: null,
`请围绕“${topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounterName}:”开头。`,
`请围绕“${safeTopic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounterName}:”开头。`,
].filter(Boolean).join('\n\n');
}
@@ -1391,6 +1831,15 @@ export function buildNpcRecruitDialoguePrompt(
invitationText: string,
recruitSummary: string,
) {
const safeInvitationText =
sanitizePromptNarrativeText(invitationText, '我希望你能加入队伍,与我并肩同行。') ??
invitationText;
const safeRecruitSummary =
sanitizePromptNarrativeText(
recruitSummary,
'双方已经具备继续同行的条件。',
) ?? recruitSummary;
return [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
@@ -1402,6 +1851,7 @@ export function buildNpcRecruitDialoguePrompt(
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
@@ -1419,8 +1869,8 @@ export function buildNpcRecruitDialoguePrompt(
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeStoryHistory(history),
`当前招募对象:${encounter.npcName}`,
`玩家邀请:${invitationText}`,
`招募补充条件:${recruitSummary}`,
`玩家邀请:${safeInvitationText}`,
`招募补充条件:${safeRecruitSummary}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),