Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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: '正在提炼题材适配层词汇与命名范式。',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 条 connections;relativePosition 只能使用:${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(';')}`,
|
||||
|
||||
102
src/services/customWorldCamp.ts
Normal file
102
src/services/customWorldCamp.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
68
src/services/narrativeLanguage.ts
Normal file
68
src/services/narrativeLanguage.ts
Normal 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;
|
||||
}
|
||||
@@ -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('这一轮的局势已经出现了新的变化。');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
365
src/services/storyEngine/goalDirector.test.ts
Normal file
365
src/services/storyEngine/goalDirector.test.ts
Normal 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('陆清');
|
||||
});
|
||||
});
|
||||
895
src/services/storyEngine/goalDirector.ts
Normal file
895
src/services/storyEngine/goalDirector.ts
Normal 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;
|
||||
}
|
||||
1175
src/services/storyEngine/storyAuditReport.ts
Normal file
1175
src/services/storyEngine/storyAuditReport.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user