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

@@ -1,6 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import { getScenePresetsByWorld } from '../data/scenePresets';
import type {
Character,
Encounter,
SceneHostileNpc,
StoryMoment,
StoryOption,
} from '../types';
import { AnimationState, WorldType } from '../types';
const {
connectivityError,
@@ -27,19 +36,12 @@ vi.mock('./llmClient', () => ({
streamPlainTextCompletion: streamPlainTextCompletionMock,
}));
import type {
Character,
Encounter,
SceneHostileNpc,
StoryMoment,
StoryOption,
} from '../types';
import { AnimationState, WorldType } from '../types';
import {
generateCharacterPanelChatSuggestions,
generateCustomWorldProfile,
generateCustomWorldSceneImage,
generateInitialStory,
generateNextStep,
streamCharacterPanelChatReply,
streamNpcRecruitDialogue,
} from './ai';
@@ -393,6 +395,123 @@ describe('ai orchestration fallbacks', () => {
expect(response.storyText.length).toBeGreaterThan(0);
});
it('repairs mixed-language story text before returning the story response', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
actionText: '继续沿山道探路。',
text: '继续沿山道探路。',
}),
];
requestChatMessageContentMock
.mockResolvedValueOnce(
JSON.stringify({
storyText: 'The forest is quiet. 你听见远处的风声。',
encounter: null,
options: [
{
functionId: 'idle_explore_forward',
actionText: 'Move forward carefully.',
},
],
}),
)
.mockResolvedValueOnce(
JSON.stringify({
storyText: '林间重新安静下来,你听见远处的风声。',
encounter: null,
options: [
{
functionId: 'idle_explore_forward',
actionText: '继续沿山道探路。',
},
],
}),
);
const response = await generateInitialStory(
WorldType.WUXIA,
playerCharacter,
monsters,
context,
{ availableOptions },
);
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
expect(response.options[0]?.actionText).toBe('继续沿山道探路。');
expect(requestChatMessageContentMock).toHaveBeenCalledTimes(2);
expect(requestChatMessageContentMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({
debugLabel: 'story-language-repair',
}),
);
});
it('ignores generated encounter payloads during post-battle continuations when no new scene encounter is pending', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
actionText: '先稳住呼吸,再看看前面的动静。',
text: '先稳住呼吸,再看看前面的动静。',
}),
];
const sceneWithNpc = getScenePresetsByWorld(WorldType.WUXIA).find(
(scene) => (scene.npcs?.length ?? 0) > 0,
);
const targetNpcId = sceneWithNpc?.npcs?.[0]?.id;
if (!sceneWithNpc || !targetNpcId) {
throw new Error('Expected a wuxia scene with at least one npc preset.');
}
requestChatMessageContentMock.mockResolvedValue(
JSON.stringify({
storyText: '山道总算安静下来,你收住气息,重新判断前路。',
encounter: {
kind: 'npc',
npcId: targetNpcId,
},
options: [
{
functionId: 'idle_explore_forward',
actionText: '先稳住呼吸,再看看前面的动静。',
},
],
}),
);
const response = await generateNextStep(
WorldType.WUXIA,
playerCharacter,
[],
[
{
text: '挥刀抢攻',
options: [],
historyRole: 'action',
},
{
text: '山道客已经败下阵来。',
options: [],
historyRole: 'result',
},
],
'挥刀抢攻',
createContext({
sceneId: sceneWithNpc.id,
sceneName: sceneWithNpc.name,
sceneDescription: sceneWithNpc.description,
pendingSceneEncounter: false,
}),
{ availableOptions },
);
expect(response.encounter).toBeUndefined();
expect(response.options).toEqual(availableOptions);
const userPrompt = requestChatMessageContentMock.mock.calls.at(-1)?.[1];
expect(userPrompt).toContain('encounter 必须为 null');
expect(userPrompt).toContain('战斗结束后的续写');
});
it('returns offline character chat suggestions when the plain-text client reports connectivity errors', async () => {
requestPlainTextCompletionMock.mockRejectedValue(connectivityError);

View File

@@ -101,6 +101,7 @@ import {
parseJsonResponseText as parseJsonResponseTextFromParser,
parseLineListContent as parseLineListContentFromParser,
} from './llmParsers';
import { hasMixedNarrativeLanguage } from './narrativeLanguage';
import {
buildNpcRecruitDialoguePrompt,
buildStrictNpcChatDialoguePrompt,
@@ -146,6 +147,11 @@ const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
你会收到一个已经解析过的剧情 JSON 对象。
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
const CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE = 5;
@@ -1447,6 +1453,15 @@ function buildEncounterDrivenResolution(
};
}
function resolveSafeGeneratedActionText(actionText: string | undefined) {
const trimmed = actionText?.trim();
if (!trimmed || hasMixedNarrativeLanguage(trimmed)) {
return undefined;
}
return trimmed;
}
function resolveOptionsFromFunctionIds(
items: RawOptionItem[],
worldType: WorldType,
@@ -1463,7 +1478,11 @@ function resolveOptionsFromFunctionIds(
return items
.map((item) =>
resolveFunctionOption(item.functionId, functionContext, item.actionText),
resolveFunctionOption(
item.functionId,
functionContext,
resolveSafeGeneratedActionText(item.actionText),
),
)
.filter(Boolean) as StoryOption[];
}
@@ -1525,7 +1544,7 @@ function resolveOptionsFromProvidedOptions(
if (!matchedOption) return;
consumedOptions.add(matchedOption);
const rewrittenText = item.actionText?.trim();
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
resolved.push({
...cloneStoryOption(matchedOption),
actionText: rewrittenText || matchedOption.actionText,
@@ -1566,7 +1585,7 @@ function resolveOptionsFromOptionCatalog(
const matchedOption = bucket?.shift();
if (!matchedOption) return;
const rewrittenText = item.actionText?.trim();
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
resolved.push({
...cloneStoryOption(matchedOption),
actionText: rewrittenText || matchedOption.actionText,
@@ -1662,6 +1681,112 @@ function buildOfflineResponse(
};
}
function buildStoryLanguageRepairPrompt(response: AIResponse) {
return [
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
'如果英文只是角色名或地点名,可以保留名字本体;其余英文句子、英文解释和中英混杂表达都必须改成中文。',
JSON.stringify(
{
storyText: response.storyText,
encounter: response.encounter ?? null,
options: response.options.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
},
null,
2,
),
].join('\n\n');
}
function needsStoryLanguageRepair(response: AIResponse) {
return hasMixedNarrativeLanguage(response.storyText);
}
function buildStoryLanguageFallbackText(
context: StoryGenerationContext,
inBattle: boolean,
) {
if (inBattle) {
return '敌意仍压在眼前,战斗局势还没有真正松开。';
}
if (context.encounterName) {
return `${context.encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`;
}
return `${context.sceneName || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`;
}
function finalizeStoryNarrativeLanguage(
response: AIResponse,
context: StoryGenerationContext,
inBattle: boolean,
): AIResponse {
if (!needsStoryLanguageRepair(response)) {
return response;
}
return {
...response,
storyText: buildStoryLanguageFallbackText(context, inBattle),
};
}
async function repairStoryNarrativeLanguage(
response: AIResponse,
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions,
) {
const responseBattleState = buildEncounterDrivenResolution(
worldType,
monsters,
context,
response.encounter,
).inBattle;
if (!needsStoryLanguageRepair(response)) {
return finalizeStoryNarrativeLanguage(response, context, responseBattleState);
}
try {
const repairedContent = await requestChatMessageContent(
STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
buildStoryLanguageRepairPrompt(response),
{
debugLabel: 'story-language-repair',
},
);
const repairedResponse = normalizeResponse(
parseJsonResponseTextFromParser(repairedContent),
worldType,
character,
monsters,
context,
requestOptions,
);
const repairedBattleState = buildEncounterDrivenResolution(
worldType,
monsters,
context,
repairedResponse.encounter,
).inBattle;
return finalizeStoryNarrativeLanguage(
repairedResponse,
context,
repairedBattleState,
);
} catch (error) {
console.warn('Failed to repair mixed-language story response:', error);
return finalizeStoryNarrativeLanguage(response, context, responseBattleState);
}
}
function normalizeResponse(
raw: unknown,
worldType: WorldType,
@@ -1766,7 +1891,7 @@ async function requestCompletion(
debugLabel: 'story-completion',
});
return normalizeResponse(
const response = normalizeResponse(
parseJsonResponseTextFromParser(content),
worldType,
character,
@@ -1774,6 +1899,15 @@ async function requestCompletion(
context,
requestOptions,
);
return repairStoryNarrativeLanguage(
response,
worldType,
character,
monsters,
context,
requestOptions,
);
}
export async function generateCustomWorldSceneImage({
@@ -1895,7 +2029,7 @@ export async function generateCustomWorldProfile(
landmarks: [],
} satisfies CustomWorldGenerationFramework;
reporter.complete('framework', {
phaseDetail: `世界框架已确定,基础模板锚定为${frameworkBase.templateWorldType === WorldType.WUXIA ? '武侠' : '仙侠'}`,
phaseDetail: '世界框架已确定,开始围绕你的设定继续编译题材层与关键对象。',
});
reporter.begin('theme-pack', {
phaseDetail: '正在提炼题材适配层词汇与命名范式。',

View File

@@ -21,6 +21,7 @@ import type {
EquipmentLoadout,
FacingDirection,
FactionTensionState,
GoalStackState,
InventoryItem,
JourneyBeat,
KnowledgeFact,
@@ -69,6 +70,7 @@ export interface StoryGenerationContext {
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
lastObserveSignsReport?: string | null;
recentActionResult?: string | null;
encounterKind?: string | null;
encounterName?: string | null;
encounterDescription?: string | null;
@@ -117,6 +119,7 @@ export interface StoryGenerationContext {
actState?: ActState | null;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
goalStack?: GoalStackState | null;
currentCampEvent?: CampEvent | null;
setpieceDirective?: SetpieceDirective | null;
encounterNarrativeProfile?: ActorNarrativeProfile | null;

View File

@@ -38,6 +38,20 @@ function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
templateWorldType: /||||/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA,
});
if (themeMode === 'mythic') {
return {
schemaName: '叙境六维',
slots: [
{ slotId: 'axis_a', name: '体魄', definition: '承受正面压力与长期消耗的底子。', positiveSignals: ['稳固', '抗压'], negativeSignals: ['脆弱', '虚浮'], combatUseText: '扛住冲击、保持站位。', socialUseText: '给人可靠、能顶事的感觉。', explorationUseText: '在漫长旅途中维持可行动状态。' },
{ slotId: 'axis_b', name: '身法', definition: '换位、腾挪、抢时机与穿行环境的能力。', positiveSignals: ['灵动', '迅捷'], negativeSignals: ['迟滞', '笨拙'], combatUseText: '变线、闪避、抢位和追击。', socialUseText: '反应快,懂得顺势调整说法。', explorationUseText: '穿越复杂地形与危险通路。' },
{ slotId: 'axis_c', name: '识见', definition: '看清局势、拆解线索与判断轻重缓急的能力。', positiveSignals: ['洞察', '判断'], negativeSignals: ['误判', '迟钝'], combatUseText: '看穿敌方破绽与局势变化。', socialUseText: '识别真假、试探与隐藏立场。', explorationUseText: '整理线索、辨认路径与推断风险。' },
{ slotId: 'axis_d', name: '胆魄', definition: '在高压局势里依然敢于推进和拍板的力量。', positiveSignals: ['果断', '压场'], negativeSignals: ['退缩', '犹疑'], combatUseText: '顶着压力推进战局。', socialUseText: '在僵局里定调并逼出回应。', explorationUseText: '面对未知异象仍敢继续前探。' },
{ slotId: 'axis_e', name: '牵引', definition: '与人、物、线索和环境建立联动的能力。', positiveSignals: ['协同', '共鸣'], negativeSignals: ['脱节', '孤立'], combatUseText: '借协同和牵制形成连锁。', socialUseText: '建立合作、说服和互信。', explorationUseText: '从人情、物件和场景之间串起通路。' },
{ slotId: 'axis_f', name: '定力', definition: '在变化与消耗中稳住节奏、拉回状态的能力。', positiveSignals: ['稳定', '续航'], negativeSignals: ['失衡', '崩乱'], combatUseText: '久战不乱,能重新控住节奏。', socialUseText: '情绪稳定,不轻易被带偏。', explorationUseText: '在长线推进中持续保持判断和行动力。' },
] satisfies WorldAttributeSlot[],
};
}
if (themeMode === 'machina') {
return {
schemaName: '机潮六轴',
@@ -81,10 +95,8 @@ function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
}
return {
schemaName: input.worldType === WorldType.XIANXIA ? '灵界六轴' : '江湖六脉',
slots: getPresetWorldAttributeSchema(
/||||/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA,
).slots,
schemaName: '叙境六维',
slots: getPresetWorldAttributeSchema(WorldType.WUXIA).slots,
};
}

View File

@@ -14,6 +14,7 @@ import {
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
CustomWorldCampScene,
CustomWorldItem,
CustomWorldLandmark,
CustomWorldNpc,
@@ -27,6 +28,7 @@ import {
WorldType,
} from '../types';
import { generateWorldAttributeSchema } from './attributeSchemaGenerator';
import { buildFallbackCustomWorldCampScene } from './customWorldCamp';
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
@@ -104,6 +106,12 @@ export interface CustomWorldGenerationLandmarkOutline {
connections: CustomWorldGenerationLandmarkConnectionOutline[];
}
export interface CustomWorldGenerationCampOutline {
name: string;
description: string;
dangerLevel: string;
}
export interface CustomWorldGenerationFramework {
settingText: string;
name: string;
@@ -114,6 +122,7 @@ export interface CustomWorldGenerationFramework {
templateWorldType: WorldType;
majorFactions: string[];
coreConflicts: string[];
camp: CustomWorldGenerationCampOutline;
playableNpcs: CustomWorldGenerationRoleOutline[];
storyNpcs: CustomWorldGenerationRoleOutline[];
landmarks: CustomWorldGenerationLandmarkOutline[];
@@ -508,32 +517,28 @@ function buildSeedPhrase(settingText: string, fallback: string) {
}
function buildWorldName(settingText: string, worldType: WorldType) {
const seed = buildSeedPhrase(
settingText,
worldType === WorldType.XIANXIA ? '灵潮' : '江湖',
);
const suffix = worldType === WorldType.XIANXIA ? '界' : '录';
const seed = buildSeedPhrase(settingText, '新旅');
const suffix = worldType === WorldType.XIANXIA ? '境' : '域';
return `${seed}${suffix}`;
}
function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
const templateWorldType = inferWorldTypeFromSetting(settingText);
const name = buildWorldName(settingText, templateWorldType);
const subtitle =
templateWorldType === WorldType.XIANXIA ? '灵潮未定' : '风云将起';
const subtitle = '前路未明';
const summary = settingText.trim()
? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。`
: templateWorldType === WorldType.XIANXIA
? '灵潮未定,旧秩序正在崩裂。'
: '旧案复起,江湖格局正在改变。';
const tone =
templateWorldType === WorldType.XIANXIA
? '空灵、危险、层层递进'
: '紧张、克制、暗流涌动';
const playerGoal =
templateWorldType === WorldType.XIANXIA
? '查清异变源头,在诸方势力之前抢到关键线索'
: '沿着旧案痕迹追查幕后之人,并守住仍值得相信的人与路';
: '一个仍待展开的独立世界正在成形。';
const tone = '未知、紧绷、仍在展开';
const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事';
const camp = buildFallbackCustomWorldCampScene({
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
templateWorldType,
});
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
@@ -559,6 +564,7 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
playableNpcs: [],
storyNpcs: [],
items: [],
camp,
landmarks: [],
themePack: null,
storyGraph: null,
@@ -592,6 +598,11 @@ export function normalizeCustomWorldGenerationFramework(
templateWorldType: fallback.templateWorldType,
majorFactions: [],
coreConflicts: [fallback.summary],
camp: {
name: fallback.camp?.name ?? '归舍',
description: fallback.camp?.description ?? '',
dangerLevel: fallback.camp?.dangerLevel ?? 'low',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
@@ -623,6 +634,14 @@ export function normalizeCustomWorldGenerationFramework(
templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
camp: normalizeCampOutline(item.camp, {
name,
summary: toText(item.summary) || fallback.summary,
tone: toText(item.tone) || fallback.tone,
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
settingText: settingText.trim(),
templateWorldType,
}),
playableNpcs: normalizeRoleOutlineList(item.playableNpcs, {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
@@ -649,6 +668,11 @@ export function buildCustomWorldRawProfileFromFramework(
templateWorldType: framework.templateWorldType,
majorFactions: framework.majorFactions,
coreConflicts: framework.coreConflicts,
camp: {
name: framework.camp.name,
description: framework.camp.description,
dangerLevel: framework.camp.dangerLevel,
},
playableNpcs: framework.playableNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
@@ -818,6 +842,24 @@ function normalizeRoleOutlineList(
return normalized;
}
function normalizeCampOutline(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
>,
): CustomWorldGenerationCampOutline {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
};
}
function normalizeLandmarkOutlineList(value: unknown) {
return toRecordArray(value)
.map((item) => {
@@ -910,6 +952,25 @@ function normalizeLandmarkDraftList(value: unknown) {
.filter((entry) => entry.name);
}
function normalizeCampScene(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
>,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
};
}
export function normalizeCustomWorldProfile(
raw: unknown,
settingText: string,
@@ -949,6 +1010,14 @@ export function normalizeCustomWorldProfile(
const playableNpcs = normalizePlayableNpcList(item.playableNpcs);
const storyNpcs = normalizeStoryNpcList(item.storyNpcs);
const landmarkDrafts = normalizeLandmarkDraftList(item.landmarks);
const camp = normalizeCampScene(item.camp, {
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
templateWorldType,
});
return {
id:
@@ -970,6 +1039,7 @@ export function normalizeCustomWorldProfile(
playableNpcs,
storyNpcs,
items: normalizeItemList(item.items),
camp,
landmarks: normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts,
storyNpcs,
@@ -1033,6 +1103,7 @@ function buildFrameworkSummaryText(
framework.coreConflicts.length > 0
? `核心冲突:${framework.coreConflicts.join('、')}`
: '',
`开局归处:${framework.camp.name}${framework.camp.description}`,
landmarkText ? `关键场景:${landmarkText}` : '',
]
.filter(Boolean)
@@ -1113,13 +1184,17 @@ export function validateCustomWorldGenerationFramework(
`自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅有 ${landmarkCount} 个。`,
);
}
if (!framework.camp.name.trim() || !framework.camp.description.trim()) {
throw new Error('自定义世界框架必须包含一个有效的开局归处场景。');
}
}
export function buildCustomWorldFrameworkPrompt(settingText: string) {
return [
'请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。',
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
'这一步只保留世界顶层信息,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。',
'这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。',
'玩家设定:',
settingText.trim(),
'',
@@ -1132,16 +1207,24 @@ export function buildCustomWorldFrameworkPrompt(settingText: string) {
' "playerGoal": "玩家核心目标",',
' "templateWorldType": "WUXIA|XIANXIA",',
' "majorFactions": ["势力甲", "势力乙"],',
' "coreConflicts": ["冲突甲", "冲突乙"]',
' "coreConflicts": ["冲突甲", "冲突乙"],',
' "camp": {',
' "name": "开局归处名称",',
' "description": "这是玩家进入世界后的第一处落脚点描述",',
' "dangerLevel": "low|medium|high|extreme"',
' }',
'}',
'',
'要求:',
'- 所有生成文本都必须使用中文。',
'- 这一步只输出顶层 8 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts。',
'- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和场景细节。',
'- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。',
'- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。',
'- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。',
'- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。',
'- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。',
'- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。',
'- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。',
'- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内。',
'- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。',
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
].join('\n');
}
@@ -1392,9 +1475,10 @@ export function buildCustomWorldFrameworkJsonRepairPrompt(
return [
'下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
'请只输出修复后的 JSON 对象。',
'顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts。',
'顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。',
'不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。',
'majorFactions 与 coreConflicts 必须是字符串数组。',
'camp 必须是对象且包含name、description、dangerLevel。',
'原始文本:',
responseText.trim(),
].join('\n');
@@ -1437,6 +1521,7 @@ export function buildCustomWorldRoleOutlineBatchPrompt(params: {
'',
'要求:',
`- 必须生成恰好 ${batchCount}${label}`,
'- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。',
'- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。',
'- 只保留name、title、role、description、initialAffinity、relationshipHooks、tags。',
'- relationshipHooks 最多 1 条tags 保持 1 到 2 个。',
@@ -1509,6 +1594,7 @@ export function buildCustomWorldLandmarkSeedBatchPrompt(params: {
'',
'要求:',
`- 必须生成恰好 ${batchCount} 个 landmarks。`,
'- 这是一个完全独立的自定义世界;不要在场景描述里直接写“武侠世界”“仙侠世界”等现成世界名。',
'- 这一步只保留name、description、dangerLevel。',
'- 不要输出 sceneNpcNames、connections、imageSrc 或其他字段。',
'- 名称必须具体且互不重复,不要使用 场景1、地点1 之类的占位名。',
@@ -1590,6 +1676,7 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: {
'',
'要求:',
`- 只输出这批 ${landmarkBatch.length} 个场景,不要输出其他场景。`,
'- 这是一个完全独立的自定义世界summary 不要带入“武侠”“仙侠”等现成世界名称。',
'- 名称必须与本批次场景骨架完全一致,不得改名。',
'- 每个场景必须提供恰好 3 个唯一 sceneNpcNames且只能从可用场景角色名里选择。',
`- 每个场景必须提供恰好 2 条 connectionsrelativePosition 只能使用:${relativePositionValues}`,
@@ -1662,6 +1749,7 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'',
'要求:',
`- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`,
'- 这是一个完全独立的自定义世界;不要在角色背景、性格、动机或战斗风格里直接写“武侠世界”“仙侠世界”等现成世界名。',
`- ${key} 的数量必须与本批次名单完全一致。`,
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。',
@@ -1717,6 +1805,7 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'',
'要求:',
`- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`,
'- 这是一个完全独立的自定义世界;不要在公开背景、技能名、物品名或说明里直接带入“武侠”“仙侠”等现成世界名。',
`- ${key} 的数量必须与本批次名单完全一致。`,
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 这一阶段只补全 backstoryReveal、skills、initialItems不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。',
@@ -1791,6 +1880,11 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
' "playerGoal": "玩家核心目标",',
' "majorFactions": ["势力甲", "势力乙"],',
' "coreConflicts": ["冲突甲", "冲突乙"],',
' "camp": {',
' "name": "开局归处名称",',
' "description": "玩家进入世界后的第一处落脚点描述",',
' "dangerLevel": "low|medium|high|extreme"',
' },',
' "playableNpcs": [',
' {',
' "name": "角色名称",',
@@ -1878,6 +1972,7 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
'',
'要求:',
'- 所有生成文本都必须使用中文。',
'- camp 必须存在,代表玩家开局时的落脚处;名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。',
'- 必须生成恰好 5 个 playableNpcs。',
'- 至少生成 25 个 storyNpcs并保证 playableNpcs + storyNpcs 的唯一名称总数不少于 30。',
'- 至少生成 10 个真正可游玩的 landmarks。',
@@ -1982,6 +2077,7 @@ export function buildCustomWorldReferenceText(
`世界概述:${profile.summary}`,
`世界基调:${profile.tone}`,
`玩家核心目标:${profile.playerGoal}`,
`开局归处:${profile.camp?.name ?? '未设定'}${profile.camp?.description ? `${profile.camp.description}` : ''}`,
`题材适配层:${themePack.displayName};制度词汇 ${themePack.institutionLexicon.slice(0, 4).join('、')};禁忌词 ${themePack.tabooLexicon.slice(0, 4).join('、')};载体类型 ${themePack.artifactClasses.slice(0, 4).join('、')}`,
`当前激活线程:\n${activeThreads.map((thread) => `- ${thread.title}${thread.summary}`).join('\n') || '- 暂无'}`,
`世界属性轴:${profile.attributeSchema.slots.map((slot) => `${slot.name}${slot.definition}`).join('')}`,

View File

@@ -0,0 +1,102 @@
import {
type CustomWorldCampScene,
type CustomWorldProfile,
} from '../types';
import { detectCustomWorldThemeMode } from './customWorldTheme';
type CampProfileSeed = Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
> & {
camp?: Pick<
CustomWorldCampScene,
'name' | 'description' | 'dangerLevel' | 'imageSrc'
> | null;
};
function clampText(value: string, maxLength: number) {
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function sanitizeCampSeed(name: string) {
const normalized = name.trim().replace(/\s+/g, '');
if (!normalized) {
return '';
}
const stripped = normalized.replace(
/(|||||||||)$/u,
'',
);
const seed = stripped || normalized;
return seed.slice(0, Math.min(seed.length, 4));
}
function buildFallbackCampName(profile: CampProfileSeed) {
const seed =
sanitizeCampSeed(profile.name) ||
'归途';
const themeMode = detectCustomWorldThemeMode(profile);
const suffixByMode = {
mythic: '归舍',
martial: '归舍',
arcane: '栖居',
machina: '整备居',
tide: '潮居',
rift: '界隙居所',
} as const;
return `${seed}${suffixByMode[themeMode]}`;
}
function buildFallbackCampDescription(profile: CampProfileSeed, campName: string) {
const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗';
const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索';
const themeMode = detectCustomWorldThemeMode(profile);
const descriptionByMode = {
mythic: `${campName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`,
martial: `${campName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`,
arcane: `${campName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`,
machina: `${campName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`,
tide: `${campName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`,
rift: `${campName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`,
} as const;
return descriptionByMode[themeMode];
}
export function buildFallbackCustomWorldCampScene(
profile: CampProfileSeed,
): CustomWorldCampScene {
const fallbackName = buildFallbackCampName(profile);
return {
name: fallbackName,
description: buildFallbackCampDescription(profile, fallbackName),
dangerLevel: 'low',
};
}
export function resolveCustomWorldCampScene(
profile: CampProfileSeed,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(profile);
const camp = profile.camp;
return {
name: camp?.name?.trim() || fallback.name,
description: camp?.description?.trim() || fallback.description,
dangerLevel: camp?.dangerLevel?.trim() || fallback.dangerLevel,
imageSrc: camp?.imageSrc?.trim() || undefined,
};
}

View File

@@ -23,8 +23,8 @@ export function buildThemedSkillName(_profile: unknown, style: string, index = 0
return `${style || 'skill'}-${index + 1}`;
}
export function buildCustomCampSceneName(profile: { name?: string } | null | undefined) {
return profile?.name ? `${profile.name} Camp` : 'Camp';
export function buildCustomCampSceneName(profile: { name?: string; camp?: { name?: string | null } | null } | null | undefined) {
return profile?.camp?.name?.trim() || (profile?.name ? `${profile.name}归舍` : '归舍');
}
export function getAttributeLabelsForWorld(_worldType: WorldType | null) {

View File

@@ -11,6 +11,7 @@ import {
ItemUseProfile,
WorldType,
} from '../types';
import { resolveCustomWorldCampScene } from './customWorldCamp';
import {
type CustomWorldThemeMode,
detectCustomWorldThemeMode,
@@ -49,6 +50,30 @@ type WorldPresentation = {
};
const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
mythic: {
mode: 'mythic',
attributeLabels: { strength: '体魄', agility: '身法', intelligence: '识见', spirit: '心魂' },
hpLabel: '生命',
mpLabel: '心流',
maxHpLabel: '生命上限',
maxMpLabel: '心流上限',
damageLabel: '势能',
guardLabel: '防护',
rangeLabel: '距离',
cooldownLabel: '回整',
manaCostLabel: '心流消耗',
campSuffix: '归舍',
itemPrefixes: ['远岸', '回声', '曙迹', '长旅', '微光', '新铭'],
itemInfixes: ['印', '纹', '辉', '迹', '息', '铭'],
skillPrefixes: ['映', '折', '回', '逐', '临', '流'],
skillSuffixByStyle: {
burst: ['震', '断', '破', '坠'],
steady: ['守', '定', '护', '镇'],
mobility: ['跃', '移', '转', '行'],
finisher: ['终', '决', '落', '尽'],
projectile: ['矢', '刃', '波', '纹'],
},
},
martial: {
mode: 'martial',
attributeLabels: { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' },
@@ -61,7 +86,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '招距',
cooldownLabel: '调息',
manaCostLabel: '内力消耗',
campSuffix: '行侠客栈',
campSuffix: '归舍',
itemPrefixes: ['风雨', '青锋', '断桥', '冷铁', '旧案', '残影'],
itemInfixes: ['刃','锋','魂','诀','式','影'],
skillPrefixes: ['破','斩','击','御','飞','隐'],
@@ -85,7 +110,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '术距',
cooldownLabel: '回息',
manaCostLabel: '灵韵消耗',
campSuffix: '宗门行馆',
campSuffix: '栖居',
itemPrefixes: ['灵韵', '道纹', '云篆', '星芒', '界辉', '道痕'],
itemInfixes: ['灵','道','法','术','诀','印'],
skillPrefixes: ['灵','道','法','界','星','印'],
@@ -109,7 +134,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '射程',
cooldownLabel: '充能',
manaCostLabel: '能量消耗',
campSuffix: '机动前哨',
campSuffix: '整备居',
itemPrefixes: ['铁脊', '钢律', '脉冲', '核列', '新星', '等离'],
itemInfixes: ['芯', '驱', '链', '阵', '节', '机'],
skillPrefixes: ['超载', '脉冲', '聚核', '磁轨', '新星', '裂火'],
@@ -133,7 +158,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '潮距',
cooldownLabel: '回潮',
manaCostLabel: '潮息消耗',
campSuffix: '潮栖营地',
campSuffix: '潮',
itemPrefixes: ['潮纹', '海晕', '霜浪', '天澜', '潮歌', '沧流'],
itemInfixes: ['潮', '浪', '汐', '海', '涛', '澜'],
skillPrefixes: ['潮', '浪', '汐', '海', '澜', '涌'],
@@ -157,7 +182,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '界距',
cooldownLabel: '复界',
manaCostLabel: '裂能消耗',
campSuffix: '裂界驻营',
campSuffix: '界隙居所',
itemPrefixes: ['裂界', '断层', '边潮', '灰域', '界桥', '前哨'],
itemInfixes: ['锋', '隙', '锚', '印', '界', '核'],
skillPrefixes: ['裂', '断', '界', '相', '折', '迁'],
@@ -364,8 +389,7 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
export function buildCustomCampSceneName(profile: CustomWorldProfile) {
const presentation = getWorldPresentation(profile);
return `${presentation.itemPrefixes[0]}${presentation.campSuffix}`;
return resolveCustomWorldCampScene(profile).name;
}
export function buildThemedSkillName(

View File

@@ -1,6 +1,12 @@
import { CustomWorldProfile, WorldTemplateType, WorldType } from '../types';
export type CustomWorldThemeMode = 'martial' | 'arcane' | 'machina' | 'tide' | 'rift';
export type CustomWorldThemeMode =
| 'martial'
| 'arcane'
| 'machina'
| 'tide'
| 'rift'
| 'mythic';
export function detectCustomWorldThemeMode(
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
@@ -13,12 +19,12 @@ export function detectCustomWorldThemeMode(
if (/[]/u.test(source)) return 'arcane';
if (/[]/u.test(source)) return 'martial';
return profile.templateWorldType === WorldType.XIANXIA ? 'arcane' : 'martial';
return 'mythic';
}
export function resolveCustomWorldAnchorWorldType(
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
): WorldTemplateType {
const themeMode = detectCustomWorldThemeMode(profile);
return themeMode === 'arcane' || themeMode === 'rift' ? WorldType.XIANXIA : WorldType.WUXIA;
return themeMode === 'arcane' ? WorldType.XIANXIA : WorldType.WUXIA;
}

View File

@@ -46,7 +46,11 @@ function logLlmDebug(title: string, payload: unknown) {
}
function normalizeLlmError(error: unknown): never {
if (error instanceof DOMException && error.name === 'AbortError') {
if (
typeof DOMException !== 'undefined'
&& error instanceof DOMException
&& error.name === 'AbortError'
) {
throw new LlmTimeoutError('The LLM request timed out. Please check the network or endpoint.');
}

View File

@@ -0,0 +1,68 @@
const CJK_CHAR_PATTERN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/gu;
const LATIN_WORD_PATTERN = /[A-Za-z][A-Za-z'-]{1,}/g;
const LATIN_FRAGMENT_PATTERN =
/[A-Za-z][A-Za-z0-9'"()\-,:;!?/]*(?:\s+[A-Za-z0-9'"()\-,:;!?/]+)+/gu;
const SAFE_LATIN_TOKENS = new Set([
'act',
'ai',
'boss',
'cd',
'hp',
'json',
'llm',
'mp',
'npc',
'qa',
'rpg',
]);
function getCjkCharCount(text: string) {
return text.match(CJK_CHAR_PATTERN)?.length ?? 0;
}
function getSignificantLatinWords(text: string) {
return (text.match(LATIN_WORD_PATTERN) ?? [])
.map((word) => word.toLowerCase())
.filter(
(word) => word.length >= 4 && !SAFE_LATIN_TOKENS.has(word),
);
}
export function hasMixedNarrativeLanguage(text: string) {
const trimmed = text.trim();
if (!trimmed) {
return false;
}
const cjkCharCount = getCjkCharCount(trimmed);
const latinSentenceFragments = (trimmed.match(LATIN_FRAGMENT_PATTERN) ?? [])
.map((fragment) => fragment.trim())
.filter((fragment) => fragment.split(/\s+/u).length >= 2);
const significantLatinWords = getSignificantLatinWords(trimmed);
if (latinSentenceFragments.length > 0) {
return true;
}
if (cjkCharCount > 0 && significantLatinWords.length >= 2) {
return true;
}
return cjkCharCount === 0 && significantLatinWords.length >= 3;
}
export function sanitizePromptNarrativeText(
text: string | null | undefined,
fallback: string | null = null,
) {
if (typeof text !== 'string') {
return fallback;
}
const trimmed = text.trim();
if (!trimmed) {
return fallback;
}
return hasMixedNarrativeLanguage(trimmed) ? fallback : trimmed;
}

View File

@@ -197,4 +197,106 @@ describe('buildUserPrompt', () => {
expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content);
expect(prompt).not.toContain(npc.initialItems[0]!.name);
});
it('requires an empty encounter payload during non-pending follow-up reasoning such as post-battle continuation', () => {
const prompt = buildUserPrompt(
WorldType.WUXIA,
createCharacter(),
[],
[
{
text: '挥刀抢攻',
options: [],
historyRole: 'action',
},
{
text: '山道客已经败下阵来。',
options: [],
historyRole: 'result',
},
],
{
playerHp: 26,
playerMaxHp: 40,
playerMana: 8,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: 'forest_road',
sceneName: '山道',
sceneDescription: '风从林梢压下来,地上还留着刚才交手的痕迹。',
pendingSceneEncounter: false,
},
'挥刀抢攻',
);
expect(prompt).toContain('encounter 必须为 null');
expect(prompt).toContain('战斗结束后的续写');
});
it('does not feed mixed-language history and directive snippets back into story prompts', () => {
const prompt = buildUserPrompt(
WorldType.WUXIA,
createCharacter(),
[],
[
{
text: 'Move forward carefully.',
options: [],
historyRole: 'action',
},
{
text: 'The wind is cold. 你听见山道尽头有脚步声。',
options: [],
historyRole: 'result',
},
],
{
playerHp: 26,
playerMaxHp: 40,
playerMana: 8,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.ATTACK,
skillCooldowns: {},
sceneId: 'forest_road',
sceneName: '山道',
sceneDescription: '风从林梢压下来。',
pendingSceneEncounter: false,
conversationSituation: 'post_battle_breath',
conversationPressure: 'medium',
recentSharedEvent:
'A fight just ended. Both sides are still catching their breath.',
talkPriority:
'Focus on the most useful judgment, danger, and next step.',
partyRelationshipNotes:
'Lan is becoming more open in private conversation.',
recentChronicleSummary: 'Baseline summary from previous run.',
sceneNarrativeDirective: {
primaryPressure: 'Danger is still active near the camp.',
activeThreadIds: ['thread-old-case'],
foregroundActorIds: [],
foregroundCarrierIds: [],
revealBudget: 'low',
emotionalCadence: 'tense',
},
},
'Move forward carefully.',
);
expect(prompt).not.toContain('A fight just ended');
expect(prompt).not.toContain('Focus on the most useful judgment');
expect(prompt).not.toContain('Baseline summary');
expect(prompt).not.toContain('Move forward carefully');
expect(prompt).not.toContain('thread-old-case');
expect(prompt).not.toContain('Danger is still active');
expect(prompt).toContain('战后缓气');
expect(prompt).toContain('紧绷');
expect(prompt).toContain('这一轮的局势已经出现了新的变化。');
});
});

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),

View File

@@ -27,6 +27,19 @@ function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceQuestTitle(value: unknown, fallback: string) {
const title = coerceString(value, fallback)
.replace(/["']/gu, '')
.replace(/[,.!?;:].*$/u, '')
.trim();
if (title.length <= 12) {
return title;
}
return fallback.length <= 12 ? fallback : fallback.slice(0, 10);
}
function coerceStringArray(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return fallback;
@@ -82,7 +95,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
const intent = rawIntent as Record<string, unknown>;
return {
title: coerceString(intent.title, fallback.title),
title: coerceQuestTitle(intent.title, fallback.title),
description: coerceString(intent.description, fallback.description),
summary: coerceString(intent.summary, fallback.summary),
narrativeType: (
@@ -237,4 +250,3 @@ export async function generateQuestForNpcEncounter(params: {
);
}
}

View File

@@ -138,7 +138,9 @@ export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
- 如果当前场景存在威胁或异常,任务应当自然从该局势中生长出来。`;
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
export function buildQuestIntentPrompt(params: {
context: QuestGenerationContext;

View File

@@ -0,0 +1,365 @@
import { describe, expect, it } from 'vitest';
import type {
CampEvent,
ChapterState,
GameState,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
StoryOption,
} from '../../types';
import { AnimationState } from '../../types';
import {
annotateStoryOptionsWithGoalAffordance,
buildGoalHandoffFromState,
buildGoalStackState,
createGoalPulseSnapshot,
deriveGoalPulseEvent,
describeGoalStackForPrompt,
sortQuestsForGoalPanel,
} from './goalDirector';
function createQuest(overrides: Partial<QuestLogEntry> & Pick<QuestLogEntry, 'id' | 'title'>): QuestLogEntry {
return {
id: overrides.id,
issuerNpcId: overrides.issuerNpcId ?? `${overrides.id}-issuer`,
issuerNpcName: overrides.issuerNpcName ?? '林朔',
sceneId: overrides.sceneId ?? 'scene-ruins',
title: overrides.title,
description: overrides.description ?? `${overrides.title} 的说明`,
summary: overrides.summary ?? `${overrides.title} 的摘要`,
objective: overrides.objective ?? {
kind: 'inspect_treasure',
targetSceneId: 'scene-ruins',
requiredCount: 1,
},
progress: overrides.progress ?? 0,
status: overrides.status ?? 'active',
reward: overrides.reward ?? {
affinityBonus: 10,
currency: 20,
items: [],
},
rewardText: overrides.rewardText ?? '奖励已准备',
narrativeBinding: overrides.narrativeBinding,
steps: overrides.steps,
activeStepId: overrides.activeStepId,
threadId: overrides.threadId ?? null,
completionNotified: overrides.completionNotified ?? false,
};
}
function createSceneDirective() {
return {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
};
}
describe('goalDirector', () => {
it('uses the ready-to-turn-in quest as the current goal and immediate step', () => {
const chapterState: ChapterState = {
id: 'chapter-1',
title: '封桥旧案',
theme: '封桥旧案',
primaryThreadIds: ['thread-bridge'],
stage: 'expansion',
chapterSummary: '桥上的旧案正被重新翻开。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-1',
beatType: 'investigation',
title: '追查旧桥异动',
triggerThreadIds: ['thread-bridge'],
recommendedSceneIds: ['scene-bridge'],
emotionalGoal: '把线索从零散异常收束成可追查的方向。',
};
const setpieceDirective: SetpieceDirective = {
id: 'setpiece-1',
title: '桥门对峙',
setpieceType: 'boss_prelude',
relatedThreadIds: ['thread-bridge'],
sceneFocusId: 'scene-bridge',
dramaticQuestion: '旧桥另一侧到底是谁在阻拦真相?',
};
const currentCampEvent: CampEvent = {
id: 'camp-1',
eventType: 'private_talk',
title: '夜谈未尽之事',
participantCharacterIds: ['companion-1'],
triggerReason: '同伴对桥上的旧案起了新的疑心。',
relatedThreadIds: ['thread-bridge'],
};
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
threadId: 'thread-bridge',
narrativeBinding: {
origin: 'ai_compiled',
narrativeType: 'investigation',
dramaticNeed: '必须确认遗迹的异动来源。',
issuerGoal: '拿到调查结果后继续推进旧桥线索。',
playerHook: '你已经掌握了最关键的现场信息。',
worldReason: '如果再拖下去,线索会继续散掉。',
followupHooks: [],
},
rewardText: '回去找陆清交付调查结果。',
});
const sideQuest = createQuest({
id: 'quest-side',
title: '整理营地补给',
status: 'active',
narrativeBinding: {
origin: 'fallback_builder',
narrativeType: 'relationship',
dramaticNeed: '营地气氛有些不稳。',
issuerGoal: '先把补给和情绪都稳住。',
playerHook: '这能让后续推进更从容。',
worldReason: '大家都还没完全从上一段冲突里缓过来。',
followupHooks: [],
},
});
const goalStack = buildGoalStackState({
quests: [sideQuest, readyQuest],
worldType: null,
chapterState,
journeyBeat,
setpieceDirective,
currentCampEvent,
currentSceneName: '断桥旧哨',
});
expect(goalStack.northStarGoal?.sourceKind).toBe('setpiece');
expect(goalStack.activeGoal?.sourceKind).toBe('quest');
expect(goalStack.activeGoal?.sourceId).toBe('quest-ready');
expect(goalStack.immediateStepGoal?.title).toContain('陆清');
expect(goalStack.supportGoals.some((goal) => goal.sourceId === 'quest-side')).toBe(true);
expect(goalStack.supportGoals.some((goal) => goal.sourceKind === 'relationship')).toBe(true);
const sortedQuestIds = sortQuestsForGoalPanel([sideQuest, readyQuest], goalStack).map((quest) => quest.id);
expect(sortedQuestIds[0]).toBe('quest-ready');
});
it('falls back to the current journey beat when no quest is active', () => {
const chapterState: ChapterState = {
id: 'chapter-2',
title: '山门前夜',
theme: '山门风声',
primaryThreadIds: ['thread-gate'],
stage: 'opening',
chapterSummary: '风声刚起,矛盾还在缓慢聚拢。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-2',
beatType: 'approach',
title: '接近山门真相',
triggerThreadIds: ['thread-gate'],
recommendedSceneIds: ['scene-gate'],
emotionalGoal: '先把前情、威胁和方向重新拢到一起。',
};
const goalStack = buildGoalStackState({
quests: [],
worldType: null,
chapterState,
journeyBeat,
currentSceneName: '山门外缘',
});
expect(goalStack.northStarGoal?.sourceKind).toBe('chapter');
expect(goalStack.activeGoal?.sourceKind).toBe('journey_beat');
expect(goalStack.immediateStepGoal?.nextStepText).toContain('前往');
expect(goalStack.immediateStepGoal?.nextStepText).toContain('scene-gate');
expect(describeGoalStackForPrompt(goalStack)).toContain('当前玩家任务推进');
});
it('annotates options with advance/support affordances and builds quest reward handoff', () => {
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
narrativeBinding: {
origin: 'ai_compiled',
narrativeType: 'investigation',
dramaticNeed: '必须确认遗迹的异动来源。',
issuerGoal: '拿到调查结果后继续推进旧桥线索。',
playerHook: '你已经掌握了最关键的现场信息。',
worldReason: '如果再拖下去,线索会继续散掉。',
followupHooks: [],
},
});
const goalStack = buildGoalStackState({
quests: [readyQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const options: StoryOption[] = [
{
functionId: 'npc.quest_turn_in',
actionText: '把调查结果告诉陆清',
visuals: createSceneDirective(),
interaction: {
kind: 'npc',
npcId: 'quest-ready-issuer',
action: 'quest_turn_in',
questId: 'quest-ready',
},
},
{
functionId: 'idle_explore_forward',
actionText: '继续向前探查',
visuals: createSceneDirective(),
},
];
const annotated = annotateStoryOptionsWithGoalAffordance(options, goalStack);
expect(annotated[0]?.goalAffordance?.relation).toBe('advance');
expect(annotated[0]?.goalAffordance?.label).toBe('推进当前任务');
expect(annotated[1]?.goalAffordance).toBeNull();
const state = {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
},
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-ruins',
name: '断桥旧哨',
description: '',
imageSrc: '',
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [readyQuest],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
const handoff = buildGoalHandoffFromState(state);
expect(handoff?.title).toContain('陆清');
expect(handoff?.detail).toContain('结果');
});
it('derives pulse events for newly accepted and newly ready quests', () => {
const acceptedQuest = createQuest({
id: 'quest-accepted',
title: '追查桥上的雾信',
status: 'active',
issuerNpcName: '陆清',
summary: '先去断桥边确认最新痕迹。',
});
const acceptedGoalStack = buildGoalStackState({
quests: [acceptedQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const acceptPulse = deriveGoalPulseEvent({
previous: createGoalPulseSnapshot([], acceptedGoalStack),
quests: [acceptedQuest],
goalStack: acceptedGoalStack,
});
expect(acceptPulse?.pulseType).toBe('progress');
expect(acceptPulse?.title).toContain('接取');
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
summary: '带着结果回去向陆清交待。',
});
const readyGoalStack = buildGoalStackState({
quests: [readyQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const readyPulse = deriveGoalPulseEvent({
previous: createGoalPulseSnapshot(
[
{
...readyQuest,
status: 'active',
},
],
readyGoalStack,
),
quests: [readyQuest],
goalStack: readyGoalStack,
});
expect(readyPulse?.pulseType).toBe('ready_to_turn_in');
expect(readyPulse?.detail).toContain('陆清');
});
});

View File

@@ -0,0 +1,895 @@
import { isContinueAdventureOption } from '../../data/functionCatalog';
import { getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import { getScenePresetById } from '../../data/scenePresets';
import type {
CampEvent,
ChapterState,
GameState,
GoalHandoff,
GoalLayer,
GoalPulseEvent,
GoalStackEntry,
GoalStackState,
GoalStatus,
GoalTrack,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
StoryOption,
WorldType,
} from '../../types';
const TERMINAL_QUEST_STATUSES = new Set<QuestLogEntry['status']>([
'turned_in',
'failed',
'expired',
]);
type GoalPulseSnapshot = {
questStatuses: Record<string, QuestLogEntry['status']>;
activeGoalId: string | null;
immediateGoalId: string | null;
immediateGoalText: string | null;
};
function isLiveQuest(quest: QuestLogEntry) {
return !TERMINAL_QUEST_STATUSES.has(quest.status);
}
function getChapterStageLabel(stage: ChapterState['stage']) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
}
}
function getJourneyBeatLabel(beatType: JourneyBeat['beatType']) {
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 getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType']) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '剧情节点';
}
}
function cleanTaskTitle(title: string, fallback = '当前任务') {
const cleaned = title
.replace(/["']/gu, '')
.replace(/[·|:].*$/u, '')
.replace(/[,.!?;].*$/u, '')
.trim();
if (!cleaned) {
return fallback;
}
return cleaned.length > 12 ? cleaned.slice(0, 10) : cleaned;
}
function buildJourneyTaskTitle(beatType: JourneyBeat['beatType']) {
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 buildJourneyTaskCondition(params: {
beatType: JourneyBeat['beatType'];
sceneHint: string | null;
}) {
const { beatType, sceneHint } = params;
const place = sceneHint ?? '当前区域';
switch (beatType) {
case 'approach':
return `前往 ${place},确认新的线索。`;
case 'investigation':
return `${place} 调查线索或异常。`;
case 'camp':
return '返回营地,整理队伍或与同伴交谈。';
case 'conflict':
return `处理 ${place} 的冲突。`;
case 'boss_prelude':
return `前往 ${place},准备关键战斗。`;
case 'climax':
return `${place} 完成关键对峙。`;
case 'recovery':
return '查看任务结果,决定下一步去向。';
default:
return `继续推进 ${place} 的任务。`;
}
}
function resolveJourneySceneHint(params: {
beat: JourneyBeat;
currentSceneName?: string | null;
worldType?: WorldType | null;
}) {
const rawSceneId = params.beat.recommendedSceneIds[0] ?? null;
if (!rawSceneId) {
return params.currentSceneName ?? null;
}
if (!params.worldType) {
return rawSceneId;
}
return getScenePresetById(params.worldType, rawSceneId)?.name
?? params.currentSceneName
?? rawSceneId;
}
export function getGoalTrackLabel(track: GoalTrack) {
switch (track) {
case 'main':
return '主推进';
case 'side':
return '支线';
case 'relationship':
return '关系';
case 'survival':
return '整备';
case 'exploration':
return '探索';
default:
return '任务';
}
}
function getQuestSceneHint(quest: QuestLogEntry, worldType: WorldType | null) {
if (!quest.sceneId) {
return null;
}
if (!worldType) {
return quest.sceneId;
}
return getScenePresetById(worldType, quest.sceneId)?.name ?? quest.sceneId;
}
function getQuestTrack(quest: QuestLogEntry, fallbackTrack: GoalTrack) {
const narrativeType = quest.narrativeBinding?.narrativeType ?? null;
if (narrativeType === 'relationship' || narrativeType === 'trial') {
return 'relationship';
}
if (narrativeType === 'investigation' || quest.objective.kind === 'inspect_treasure') {
return fallbackTrack === 'main' ? 'main' : 'exploration';
}
return fallbackTrack;
}
function getQuestStatus(quest: QuestLogEntry): GoalStatus {
if (isQuestReadyToClaim(quest)) {
return 'ready_to_resolve';
}
if (quest.status === 'turned_in') {
return 'resolved';
}
if (quest.status === 'failed' || quest.status === 'expired') {
return 'archived';
}
if (quest.status === 'discovered') {
return 'teased';
}
return 'active';
}
function getQuestUrgency(quest: QuestLogEntry): GoalStackEntry['urgency'] {
if (isQuestReadyToClaim(quest)) {
return 'high';
}
const narrativeType = quest.narrativeBinding?.narrativeType ?? null;
if (narrativeType === 'investigation' || narrativeType === 'retrieval') {
return 'medium';
}
if (narrativeType === 'relationship' || narrativeType === 'trial') {
return 'low';
}
return 'medium';
}
function getQuestProgressLabel(quest: QuestLogEntry) {
if (isQuestReadyToClaim(quest)) {
return '待交付';
}
const activeStep = getQuestActiveStep(quest);
if (activeStep) {
return `步骤 ${activeStep.progress}/${activeStep.requiredCount}`;
}
return `进度 ${quest.progress}/${quest.objective.requiredCount}`;
}
function buildQuestGoalEntry(params: {
quest: QuestLogEntry;
worldType: WorldType | null;
layer: GoalLayer;
fallbackTrack: GoalTrack;
}) {
const { quest, worldType, layer, fallbackTrack } = params;
const sceneHint = getQuestSceneHint(quest, worldType);
const relatedThreadIds = quest.threadId ? [quest.threadId] : [];
return {
id: `goal:${layer}:${quest.id}`,
sourceKind: 'quest',
sourceId: quest.id,
layer,
track: getQuestTrack(quest, fallbackTrack),
title: quest.title,
promiseText:
quest.narrativeBinding?.playerHook
|| quest.description
|| `${quest.issuerNpcName} 把这件事托付给了你。`,
whyNow:
quest.narrativeBinding?.worldReason
|| `${quest.issuerNpcName} 认为现在正是处理这件事的时机。`,
nextStepText: isQuestReadyToClaim(quest)
? `回去找 ${quest.issuerNpcName} 交付委托并领取报酬。`
: getQuestActiveStep(quest)?.revealText ?? quest.summary,
sceneHint,
npcHint: quest.issuerNpcName,
progressLabel: getQuestProgressLabel(quest),
status: getQuestStatus(quest),
urgency: getQuestUrgency(quest),
relatedThreadIds,
} satisfies GoalStackEntry;
}
function buildQuestImmediateGoal(params: {
quest: QuestLogEntry;
worldType: WorldType | null;
}) {
const { quest, worldType } = params;
const activeStep = getQuestActiveStep(quest);
const sceneHint = getQuestSceneHint(quest, worldType);
if (isQuestReadyToClaim(quest)) {
return {
...buildQuestGoalEntry({
quest,
worldType,
layer: 'immediate_step',
fallbackTrack: 'main',
}),
title: `${quest.issuerNpcName} 交付结果`,
promiseText: '委托已经完成,只差最后汇报和结算。',
whyNow: `${quest.issuerNpcName} 的报酬已经准备好,这一步能把当前委托正式结清。`,
nextStepText: `去找 ${quest.issuerNpcName} 对话,把结果说清楚。`,
sceneHint,
npcHint: quest.issuerNpcName,
} satisfies GoalStackEntry;
}
if (!activeStep) {
return null;
}
return {
...buildQuestGoalEntry({
quest,
worldType,
layer: 'immediate_step',
fallbackTrack: 'main',
}),
title: activeStep.title,
promiseText: activeStep.revealText,
whyNow:
quest.narrativeBinding?.issuerGoal
|| `${quest.issuerNpcName} 的委托正在推进中。`,
nextStepText: activeStep.revealText,
npcHint: activeStep.targetNpcId ? quest.issuerNpcName : null,
progressLabel: `步骤 ${activeStep.progress}/${activeStep.requiredCount}`,
} satisfies GoalStackEntry;
}
function buildChapterNorthStarGoal(params: {
chapterState: ChapterState;
journeyBeat: JourneyBeat | null;
setpieceDirective: SetpieceDirective | null;
worldType: WorldType | null;
currentSceneName?: string | null;
}) {
const { chapterState, journeyBeat, setpieceDirective, worldType, currentSceneName } = params;
const sceneHint = journeyBeat
? resolveJourneySceneHint({
beat: journeyBeat,
currentSceneName,
worldType,
})
: currentSceneName ?? null;
return {
id: `goal:north_star:chapter:${chapterState.id}`,
sourceKind: 'chapter',
sourceId: chapterState.id,
layer: 'north_star',
track: 'main',
title: cleanTaskTitle(chapterState.theme || chapterState.title, '主线任务'),
promiseText: chapterState.chapterSummary,
whyNow: `当前章节已进入${getChapterStageLabel(chapterState.stage)}阶段。`,
nextStepText: setpieceDirective
? `继续收束线索与局势,逼近 ${setpieceDirective.title}`
: journeyBeat
? buildJourneyTaskCondition({
beatType: journeyBeat.beatType,
sceneHint,
})
: `围绕 ${chapterState.theme} 继续推进当前主线。`,
sceneHint: null,
npcHint: null,
progressLabel: getChapterStageLabel(chapterState.stage),
status: 'active',
urgency: chapterState.stage === 'climax' || chapterState.stage === 'turning_point'
? 'high'
: chapterState.stage === 'expansion'
? 'medium'
: 'low',
relatedThreadIds: chapterState.primaryThreadIds,
} satisfies GoalStackEntry;
}
function buildJourneyGoal(params: {
journeyBeat: JourneyBeat;
layer: GoalLayer;
currentSceneName?: string | null;
worldType?: WorldType | null;
}) {
const { journeyBeat, layer, currentSceneName, worldType } = params;
const recommendedSceneHint = resolveJourneySceneHint({
beat: journeyBeat,
currentSceneName,
worldType,
});
const nextStepText = buildJourneyTaskCondition({
beatType: journeyBeat.beatType,
sceneHint: recommendedSceneHint,
});
return {
id: `goal:${layer}:journey:${journeyBeat.id}`,
sourceKind: 'journey_beat',
sourceId: journeyBeat.id,
layer,
track: journeyBeat.beatType === 'camp' ? 'relationship' : 'main',
title: buildJourneyTaskTitle(journeyBeat.beatType),
promiseText: journeyBeat.emotionalGoal,
whyNow: journeyBeat.emotionalGoal || '当前主线需要继续推进。',
nextStepText,
sceneHint: recommendedSceneHint,
npcHint: null,
progressLabel: getJourneyBeatLabel(journeyBeat.beatType),
status: 'active',
urgency: journeyBeat.beatType === 'boss_prelude' || journeyBeat.beatType === 'climax'
? 'high'
: journeyBeat.beatType === 'investigation' || journeyBeat.beatType === 'conflict'
? 'medium'
: 'low',
relatedThreadIds: journeyBeat.triggerThreadIds,
} satisfies GoalStackEntry;
}
function buildSetpieceNorthStarGoal(setpieceDirective: SetpieceDirective) {
return {
id: `goal:north_star:setpiece:${setpieceDirective.id}`,
sourceKind: 'setpiece',
sourceId: setpieceDirective.id,
layer: 'north_star',
track: 'main',
title: setpieceDirective.title,
promiseText: setpieceDirective.dramaticQuestion,
whyNow: `当前局势已经逼近${getSetpieceLabel(setpieceDirective.setpieceType)}`,
nextStepText: `继续收束线索、关系和状态,为 ${setpieceDirective.title} 做准备。`,
sceneHint: setpieceDirective.sceneFocusId ?? null,
npcHint: null,
progressLabel: getSetpieceLabel(setpieceDirective.setpieceType),
status: 'active',
urgency: setpieceDirective.setpieceType === 'climax' || setpieceDirective.setpieceType === 'showdown'
? 'high'
: 'medium',
relatedThreadIds: setpieceDirective.relatedThreadIds,
} satisfies GoalStackEntry;
}
function buildCampEventSupportGoal(currentCampEvent: CampEvent) {
return {
id: `goal:support:camp:${currentCampEvent.id}`,
sourceKind: 'relationship',
sourceId: currentCampEvent.id,
layer: 'support',
track: 'relationship',
title: currentCampEvent.title,
promiseText: currentCampEvent.triggerReason,
whyNow: '队伍里的情绪和关系已经积累到值得回应的程度。',
nextStepText: '留意营地或旅途中新的交流时机,把这段关系事件接住。',
sceneHint: null,
npcHint: null,
progressLabel: '关系事件',
status: 'teased',
urgency: currentCampEvent.eventType === 'conflict' || currentCampEvent.eventType === 'decision'
? 'medium'
: 'low',
relatedThreadIds: currentCampEvent.relatedThreadIds,
} satisfies GoalStackEntry;
}
function resolvePrimaryQuest(quests: QuestLogEntry[]) {
const liveQuests = quests.filter(isLiveQuest);
if (liveQuests.length <= 0) {
return null;
}
return liveQuests.find((quest) => isQuestReadyToClaim(quest))
?? liveQuests.find((quest) => quest.status === 'active')
?? liveQuests.find((quest) => quest.status === 'discovered')
?? liveQuests[0]
?? null;
}
export function buildGoalStackState(params: {
quests: QuestLogEntry[];
worldType: WorldType | null;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
setpieceDirective?: SetpieceDirective | null;
currentCampEvent?: CampEvent | null;
currentSceneName?: string | null;
}) {
const {
quests,
worldType,
chapterState = null,
journeyBeat = null,
setpieceDirective = null,
currentCampEvent = null,
currentSceneName = null,
} = params;
const primaryQuest = resolvePrimaryQuest(quests);
const northStarGoal = setpieceDirective
? buildSetpieceNorthStarGoal(setpieceDirective)
: chapterState
? buildChapterNorthStarGoal({
chapterState,
journeyBeat,
setpieceDirective,
worldType,
currentSceneName,
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'north_star',
currentSceneName,
worldType,
})
: null;
const activeGoal = primaryQuest
? buildQuestGoalEntry({
quest: primaryQuest,
worldType,
layer: 'active_contract',
fallbackTrack: 'main',
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'active_contract',
currentSceneName,
worldType,
})
: currentCampEvent
? buildCampEventSupportGoal(currentCampEvent)
: northStarGoal;
const immediateStepGoal = primaryQuest
? buildQuestImmediateGoal({
quest: primaryQuest,
worldType,
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'immediate_step',
currentSceneName,
worldType,
})
: null;
const supportGoals: GoalStackEntry[] = quests
.filter((quest) => isLiveQuest(quest) && quest.id !== primaryQuest?.id)
.map((quest) =>
buildQuestGoalEntry({
quest,
worldType,
layer: 'support',
fallbackTrack: 'side',
}),
);
if (
currentCampEvent
&& !supportGoals.some((goal) => goal.sourceKind === 'relationship')
) {
supportGoals.push(buildCampEventSupportGoal(currentCampEvent));
}
return {
northStarGoal,
activeGoal,
immediateStepGoal,
supportGoals: supportGoals.slice(0, 2),
} satisfies GoalStackState;
}
function getQuestPanelPriority(params: {
quest: QuestLogEntry;
goalStack: GoalStackState | null | undefined;
}) {
const { quest, goalStack } = params;
if (goalStack?.activeGoal?.sourceKind === 'quest' && goalStack.activeGoal.sourceId === quest.id) {
return 0;
}
if (goalStack?.immediateStepGoal?.sourceKind === 'quest' && goalStack.immediateStepGoal.sourceId === quest.id) {
return 1;
}
if (isQuestReadyToClaim(quest)) {
return 2;
}
if (isLiveQuest(quest)) {
return 3;
}
return 4;
}
export function sortQuestsForGoalPanel(
quests: QuestLogEntry[],
goalStack: GoalStackState | null | undefined,
) {
return [...quests].sort((left, right) => {
const priorityDiff = getQuestPanelPriority({
quest: left,
goalStack,
}) - getQuestPanelPriority({
quest: right,
goalStack,
});
if (priorityDiff !== 0) {
return priorityDiff;
}
if (left.status !== right.status) {
return left.status.localeCompare(right.status);
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
export function describeGoalStackForPrompt(goalStack: GoalStackState | null | undefined) {
if (!goalStack) {
return null;
}
const lines = [
goalStack.northStarGoal
? `- 长期方向:${goalStack.northStarGoal.title};承诺:${goalStack.northStarGoal.promiseText}`
: null,
goalStack.activeGoal
? `- 当前主任务:${goalStack.activeGoal.title};为什么现在做:${goalStack.activeGoal.whyNow}`
: null,
goalStack.immediateStepGoal
? `- 下一步:${goalStack.immediateStepGoal.nextStepText}`
: null,
goalStack.supportGoals.length > 0
? `- 支持任务:${goalStack.supportGoals.map((goal) => goal.title).join(' / ')}`
: null,
].filter(Boolean);
if (lines.length <= 0) {
return null;
}
return ['当前玩家任务推进:', ...lines].join('\n');
}
function buildQuestOptionGoalAffordance(
option: StoryOption,
goalStack: GoalStackState,
) {
if (option.interaction?.kind !== 'npc') {
return null;
}
if (
option.interaction.action === 'quest_turn_in'
&& goalStack.immediateStepGoal?.sourceKind === 'quest'
) {
return {
goalId: goalStack.immediateStepGoal.id,
relation: 'advance',
label: '推进当前任务',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (option.interaction.action === 'quest_accept') {
const targetGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!targetGoal) {
return null;
}
return {
goalId: targetGoal.id,
relation: goalStack.activeGoal?.sourceKind === 'quest' ? 'detour' : 'support',
label: goalStack.activeGoal?.sourceKind === 'quest' ? '暂接支线' : '接入委托',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (
goalStack.activeGoal?.track === 'relationship'
&& ['chat', 'gift', 'help', 'recruit'].includes(option.interaction.action)
) {
return {
goalId: goalStack.activeGoal.id,
relation: 'advance',
label: '推进关系任务',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (['chat', 'gift', 'help', 'recruit'].includes(option.interaction.action)) {
const targetGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!targetGoal) {
return null;
}
return {
goalId: targetGoal.id,
relation: 'support',
label: '经营关系',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
return null;
}
export function annotateStoryOptionsWithGoalAffordance(
options: StoryOption[],
goalStack: GoalStackState | null | undefined,
) {
if (!goalStack) {
return options.map((option) => ({
...option,
goalAffordance: null,
}));
}
return options.map((option) => {
const questAffordance = buildQuestOptionGoalAffordance(option, goalStack);
if (questAffordance) {
return {
...option,
goalAffordance: questAffordance,
} satisfies StoryOption;
}
if (
isContinueAdventureOption(option)
&& (
goalStack.immediateStepGoal?.sourceKind === 'journey_beat'
|| goalStack.activeGoal?.sourceKind === 'journey_beat'
|| goalStack.activeGoal?.sourceKind === 'chapter'
|| goalStack.northStarGoal?.sourceKind === 'setpiece'
)
) {
const targetGoal =
goalStack.immediateStepGoal
?? goalStack.activeGoal
?? goalStack.northStarGoal;
if (!targetGoal) {
return {
...option,
goalAffordance: null,
} satisfies StoryOption;
}
return {
...option,
goalAffordance: {
goalId: targetGoal.id,
relation: 'advance',
label: '继续推进',
},
} satisfies StoryOption;
}
return {
...option,
goalAffordance: null,
} satisfies StoryOption;
});
}
export function buildGoalHandoffFromState(state: GameState): GoalHandoff | null {
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
chapterState: state.chapterState ?? state.storyEngineMemory?.currentChapter ?? null,
journeyBeat: state.storyEngineMemory?.currentJourneyBeat ?? null,
setpieceDirective: state.storyEngineMemory?.currentSetpieceDirective ?? null,
currentCampEvent: state.storyEngineMemory?.currentCampEvent ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
});
const nextGoal =
goalStack.immediateStepGoal
?? goalStack.activeGoal
?? goalStack.northStarGoal;
if (!nextGoal) {
return null;
}
if (nextGoal.sourceKind !== 'quest') {
return null;
}
return {
goalId: nextGoal.id,
title: nextGoal.title,
detail: nextGoal.nextStepText,
track: nextGoal.track,
} satisfies GoalHandoff;
}
function isRewardReadyStatus(status: QuestLogEntry['status']) {
return status === 'ready_to_turn_in' || status === 'completed';
}
export function createGoalPulseSnapshot(
quests: QuestLogEntry[],
goalStack: GoalStackState | null | undefined,
) {
return {
questStatuses: Object.fromEntries(
quests.map((quest) => [quest.id, quest.status]),
),
activeGoalId: goalStack?.activeGoal?.id ?? null,
immediateGoalId: goalStack?.immediateStepGoal?.id ?? null,
immediateGoalText: goalStack?.immediateStepGoal?.nextStepText ?? null,
} satisfies GoalPulseSnapshot;
}
function buildGoalPulse(params: {
goal: GoalStackEntry;
pulseType: GoalPulseEvent['pulseType'];
title: string;
detail: string;
}) {
const { goal, pulseType, title, detail } = params;
return {
id: `${pulseType}:${goal.id}:${Date.now()}`,
goalId: goal.id,
pulseType,
title,
detail,
track: goal.track,
} satisfies GoalPulseEvent;
}
export function deriveGoalPulseEvent(params: {
previous: GoalPulseSnapshot;
quests: QuestLogEntry[];
goalStack: GoalStackState | null | undefined;
}) {
const { previous, quests, goalStack } = params;
const immediateGoal = goalStack?.immediateStepGoal ?? null;
const activeGoal = goalStack?.activeGoal ?? null;
const fallbackGoal = immediateGoal ?? activeGoal ?? goalStack?.northStarGoal ?? null;
const questGoal =
fallbackGoal && fallbackGoal.sourceKind === 'quest'
? fallbackGoal
: null;
const newQuest = quests.find(
(quest) =>
previous.questStatuses[quest.id] == null
&& !TERMINAL_QUEST_STATUSES.has(quest.status),
);
if (newQuest && questGoal) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'progress',
title: '已接取新任务',
detail: immediateGoal?.nextStepText ?? newQuest.summary,
});
}
const newlyReadyQuest = quests.find((quest) => {
const previousStatus = previous.questStatuses[quest.id];
return !isRewardReadyStatus(previousStatus ?? 'active')
&& isRewardReadyStatus(quest.status);
});
if (newlyReadyQuest && questGoal) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'ready_to_turn_in',
title: '当前任务可交付',
detail: `回去找 ${newlyReadyQuest.issuerNpcName} 对话,把结果说清楚。`,
});
}
if (
questGoal
&& (
previous.immediateGoalId !== (immediateGoal?.id ?? null)
|| previous.immediateGoalText !== (immediateGoal?.nextStepText ?? null)
|| previous.activeGoalId !== (activeGoal?.id ?? null)
)
) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'handoff',
title:
previous.activeGoalId !== (activeGoal?.id ?? null)
? '当前任务已更新'
: '下一步已更新',
detail: questGoal.nextStepText,
});
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,18 @@ type ThemePackPreset = Omit<ThemePack, 'id' | 'displayName'> & {
};
const THEME_PACK_PRESETS: Record<string, ThemePackPreset> = {
mythic: {
displayName: '自定义回响',
toneRange: ['未知', '克制', '余波未定', '局势待开'],
institutionLexicon: ['据点', '同盟', '旅团', '档案室', '哨站', '归舍'],
tabooLexicon: ['失约', '旧痕', '越界', '封存', '误触', '回响'],
artifactClasses: ['信物', '残页', '封匣', '样本', '旧钥', '印记'],
actorArchetypes: ['见证者', '守望人', '异乡来客', '带路人', '失序幸存者'],
conflictForms: ['追查', '护送', '回收', '分歧对峙', '失踪追索'],
clueForms: ['痕迹', '记录', '口供', '残片', '旧图'],
namingPatterns: ['地点+余痕+器类', '势力+旧称+用途', '事件+残响+物件'],
revealStyles: ['循序松口', '线索回指', '保留一层', '让事实自己浮出'],
},
martial: {
displayName: '江湖旧事',
toneRange: ['冷峻', '克制', '刀锋般紧绷', '旧案余震'],
@@ -113,7 +125,7 @@ function resolveThemeModeFromWorldType(
if (worldType === 'XIANXIA') {
return 'arcane';
}
return 'martial';
return 'mythic';
}
export function resolveFallbackThemePack(

View File

@@ -1,4 +1,5 @@
import { StoryHistoryRole, StoryMoment, StoryOption } from '../types';
import { sanitizePromptNarrativeText } from './narrativeLanguage';
const RECENT_ROUND_COUNT = 3;
const MAX_SUMMARY_GROUPS = 6;
@@ -41,13 +42,17 @@ function buildStoryRounds(history: StoryMoment[]): StoryHistoryRound[] {
let currentRound: StoryHistoryRound | null = null;
for (const [index, entry] of history.entries()) {
const text = entry.text.trim();
const historyRole = resolveHistoryRole(entry, index);
const text = sanitizePromptNarrativeText(
entry.text,
historyRole === 'action'
? '玩家做出了新的决定。'
: '这一轮的局势已经出现了新的变化。',
);
if (!text) {
continue;
}
const historyRole = resolveHistoryRole(entry, index);
if (historyRole === 'action') {
if (currentRound && hasRoundContent(currentRound)) {
rounds.push(currentRound);