Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -30,7 +30,7 @@ vi.mock('./llmClient', () => ({
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
SceneMonster,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../types';
|
||||
@@ -365,7 +365,7 @@ describe('ai orchestration fallbacks', () => {
|
||||
});
|
||||
const context = createContext();
|
||||
const targetStatus = createTargetStatus();
|
||||
const monsters: SceneMonster[] = [];
|
||||
const monsters: SceneHostileNpc[] = [];
|
||||
const storyHistory: StoryMoment[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -481,7 +481,7 @@ describe('ai orchestration fallbacks', () => {
|
||||
await expect(
|
||||
generateCustomWorldProfile('一个需要很多角色和场景的世界'),
|
||||
).rejects.toThrow(
|
||||
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景|至少需要 25 名场景角色/i,
|
||||
/requires at least 10 generated scenes|至少产出 10 个场景|至少需要 10 个场景/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -674,6 +674,52 @@ describe('ai orchestration fallbacks', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches creator intent and anchor pack when generating from creator cards', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
name: '锚点世界',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile = await generateCustomWorldProfile({
|
||||
settingText: '世界一句话:一个被灵潮反复改写地形的边境世界。',
|
||||
creatorIntent: {
|
||||
sourceMode: 'card',
|
||||
rawSettingText: '',
|
||||
worldHook: '一个被灵潮反复改写地形的边境世界。',
|
||||
themeKeywords: ['边境', '灵潮'],
|
||||
toneDirectives: ['紧张', '潮湿'],
|
||||
playerPremise: '玩家是前巡夜人。',
|
||||
openingSituation: '刚进城就卷入旧案。',
|
||||
coreConflicts: ['旧案名单再次出现'],
|
||||
keyFactions: [],
|
||||
keyCharacters: [
|
||||
{
|
||||
id: 'creator-character-1',
|
||||
name: '沈砺',
|
||||
role: '灰炬向导',
|
||||
publicMask: '看起来只是个带路人',
|
||||
hiddenHook: '一直在查旧撤离线',
|
||||
relationToPlayer: '会先怀疑玩家身份',
|
||||
notes: '',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
keyLandmarks: [],
|
||||
iconicElements: ['裂潮灯塔'],
|
||||
forbiddenDirectives: ['不要出现现代枪械'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile.name).toBe('锚点世界');
|
||||
expect(profile.creatorIntent?.sourceMode).toBe('card');
|
||||
expect(profile.creatorIntent?.keyCharacters[0]?.name).toBe('沈砺');
|
||||
expect(profile.anchorPack?.keyCharacterAnchors[0]?.name).toBe('沈砺');
|
||||
expect(profile.anchorPack?.lockedAnchorIds).toContain('creator-character-1');
|
||||
});
|
||||
|
||||
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSceneNpcMonstersFromEncounters } from '../data/hostileNpcs';
|
||||
import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs';
|
||||
import {
|
||||
buildEncounterFromSceneNpc,
|
||||
getScenePresetById,
|
||||
@@ -13,13 +13,17 @@ import {
|
||||
AIResponse,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldGenerationMode,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
SceneEncounterResult,
|
||||
SceneMonster,
|
||||
SceneHostileNpc,
|
||||
SceneNpc,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
ThemePack,
|
||||
WorldStoryGraph,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
@@ -45,6 +49,8 @@ import {
|
||||
CharacterChatTargetStatus,
|
||||
} from './characterChatPrompt';
|
||||
import {
|
||||
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt,
|
||||
buildCustomWorldActorNarrativeProfileBatchPrompt,
|
||||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
buildCustomWorldFrameworkPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
|
||||
@@ -57,6 +63,10 @@ import {
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
buildCustomWorldStoryGraphJsonRepairPrompt,
|
||||
buildCustomWorldStoryGraphPrompt,
|
||||
buildCustomWorldThemePackJsonRepairPrompt,
|
||||
buildCustomWorldThemePackPrompt,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationLandmarkOutline,
|
||||
type CustomWorldGenerationRoleBatchStage,
|
||||
@@ -73,6 +83,12 @@ import {
|
||||
validateGeneratedCustomWorldProfile,
|
||||
} from './customWorld';
|
||||
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
deriveCustomWorldLockStateFromIntent,
|
||||
hasMeaningfulCustomWorldCreatorIntent,
|
||||
} from './customWorldCreatorIntent';
|
||||
import {
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS as CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
|
||||
isLlmConnectivityError as isLlmConnectivityErrorFromClient,
|
||||
@@ -94,6 +110,18 @@ import {
|
||||
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
SYSTEM_PROMPT,
|
||||
} from './prompt';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from './storyEngine/actorNarrativeProfile';
|
||||
import {
|
||||
buildThemePackFromWorldProfile,
|
||||
normalizeThemePack,
|
||||
} from './storyEngine/themePack';
|
||||
import {
|
||||
buildFallbackWorldStoryGraph,
|
||||
normalizeWorldStoryGraph,
|
||||
} from './storyEngine/worldStoryGraph';
|
||||
|
||||
export type {
|
||||
StoryGenerationContext,
|
||||
@@ -169,6 +197,20 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
total: 1,
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
id: 'theme-pack',
|
||||
label: '题材适配层',
|
||||
detail: '提炼制度词汇、禁忌词与命名范式。',
|
||||
total: 1,
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
id: 'story-graph',
|
||||
label: '世界线程图谱',
|
||||
detail: '补出明线、暗线、旧伤与意象母题。',
|
||||
total: 1,
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
id: 'playable-outline',
|
||||
label: '可扮演角色骨架',
|
||||
@@ -245,6 +287,18 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'playable-profile',
|
||||
label: '可扮演角色叙事档案',
|
||||
detail: '为可扮演角色生成首遇面具、当前压力和暗线钩子。',
|
||||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'story-narrative',
|
||||
label: '场景角色叙事',
|
||||
@@ -255,6 +309,18 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'story-profile',
|
||||
label: '场景角色叙事档案',
|
||||
detail: '为场景角色生成首遇面具、当前压力和暗线钩子。',
|
||||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'story-dossier',
|
||||
label: '场景角色档案',
|
||||
@@ -305,6 +371,16 @@ export interface GenerateCustomWorldProfileOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface GenerateCustomWorldProfileInput {
|
||||
settingText: string;
|
||||
creatorIntent?: CustomWorldCreatorIntent | null;
|
||||
generationMode?: CustomWorldGenerationMode;
|
||||
}
|
||||
|
||||
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
|
||||
const FAST_CUSTOM_WORLD_STORY_COUNT = 8;
|
||||
const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4;
|
||||
|
||||
class CustomWorldGenerationAbortedError extends Error {
|
||||
constructor(message = '世界生成已中断。') {
|
||||
super(message);
|
||||
@@ -341,6 +417,60 @@ function normalizeApiErrorMessage(
|
||||
return responseText;
|
||||
}
|
||||
|
||||
function resolveCustomWorldGenerationInput(
|
||||
input: string | GenerateCustomWorldProfileInput,
|
||||
): {
|
||||
settingText: string;
|
||||
generationSeedText: string;
|
||||
creatorIntent: CustomWorldCreatorIntent | null;
|
||||
generationMode: CustomWorldGenerationMode;
|
||||
} {
|
||||
if (typeof input === 'string') {
|
||||
return {
|
||||
settingText: input.trim(),
|
||||
generationSeedText: input.trim(),
|
||||
creatorIntent: null as CustomWorldCreatorIntent | null,
|
||||
generationMode: 'full' as CustomWorldGenerationMode,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedSettingText = input.settingText.trim();
|
||||
const creatorIntent = input.creatorIntent ?? null;
|
||||
const generationSeedText =
|
||||
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
|
||||
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
|
||||
: normalizedSettingText;
|
||||
|
||||
return {
|
||||
settingText: normalizedSettingText,
|
||||
generationSeedText: generationSeedText.trim(),
|
||||
creatorIntent,
|
||||
generationMode: input.generationMode === 'fast'
|
||||
? ('fast' as const)
|
||||
: ('full' as const),
|
||||
};
|
||||
}
|
||||
|
||||
function getCustomWorldGenerationTargets(
|
||||
generationMode: CustomWorldGenerationMode,
|
||||
) {
|
||||
if (generationMode === 'fast') {
|
||||
return {
|
||||
playableCount: FAST_CUSTOM_WORLD_PLAYABLE_COUNT,
|
||||
storyCount: FAST_CUSTOM_WORLD_STORY_COUNT,
|
||||
landmarkCount: FAST_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
generationStatus: 'key_only' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
playableCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
storyCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
landmarkCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
generationStatus: 'complete' as const,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeJsonLikeText(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
@@ -493,6 +623,12 @@ function getCustomWorldGenerationStageIdForRoleExpansion(
|
||||
return stage === 'narrative' ? 'story-narrative' : 'story-dossier';
|
||||
}
|
||||
|
||||
function getCustomWorldGenerationStageIdForActorProfile(
|
||||
roleType: CustomWorldGenerationRoleBatchType,
|
||||
): CustomWorldGenerationStageId {
|
||||
return roleType === 'playable' ? 'playable-profile' : 'story-profile';
|
||||
}
|
||||
|
||||
function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) {
|
||||
if (!signal?.aborted) {
|
||||
return;
|
||||
@@ -952,6 +1088,154 @@ async function expandCustomWorldRoleEntries<
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
async function generateCustomWorldThemePackWithAi(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, signal } = params;
|
||||
const fallback = buildThemePackFromWorldProfile({
|
||||
...framework,
|
||||
templateWorldType:
|
||||
framework.templateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: WorldType.WUXIA,
|
||||
});
|
||||
const raw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldThemePackPrompt({ framework }),
|
||||
debugLabel: 'custom-world-theme-pack',
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldThemePackJsonRepairPrompt({ responseText }),
|
||||
repairDebugLabel: 'custom-world-theme-pack-json-repair',
|
||||
emptyResponseMessage: '自定义世界 ThemePack 生成失败:模型没有返回有效内容。',
|
||||
signal,
|
||||
});
|
||||
|
||||
return normalizeThemePack(raw, fallback);
|
||||
}
|
||||
|
||||
async function generateCustomWorldStoryGraphWithAi(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
themePack: ThemePack;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, themePack, signal } = params;
|
||||
const profileSeed = buildExpandedCustomWorldProfile(
|
||||
buildCustomWorldRawProfileFromFramework(framework),
|
||||
framework.settingText,
|
||||
);
|
||||
const fallback = buildFallbackWorldStoryGraph(
|
||||
{
|
||||
...profileSeed,
|
||||
themePack,
|
||||
},
|
||||
themePack,
|
||||
);
|
||||
const raw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldStoryGraphPrompt({
|
||||
framework,
|
||||
themePack,
|
||||
}),
|
||||
debugLabel: 'custom-world-story-graph',
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldStoryGraphJsonRepairPrompt({ responseText }),
|
||||
repairDebugLabel: 'custom-world-story-graph-json-repair',
|
||||
emptyResponseMessage: '自定义世界 StoryGraph 生成失败:模型没有返回有效内容。',
|
||||
signal,
|
||||
});
|
||||
|
||||
return normalizeWorldStoryGraph(raw, fallback);
|
||||
}
|
||||
|
||||
async function expandCustomWorldActorNarrativeProfiles<
|
||||
T extends MergeableCustomWorldRoleEntry,
|
||||
>(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
baseEntries: T[];
|
||||
batchSize: number;
|
||||
themePack: ThemePack;
|
||||
storyGraph: WorldStoryGraph;
|
||||
reporter?: CustomWorldGenerationReporter;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const {
|
||||
framework,
|
||||
roleType,
|
||||
baseEntries,
|
||||
batchSize,
|
||||
themePack,
|
||||
storyGraph,
|
||||
reporter = createCustomWorldGenerationReporter(),
|
||||
signal,
|
||||
} = params;
|
||||
const roleBatchSource = baseEntries;
|
||||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
const stageId = getCustomWorldGenerationStageIdForActorProfile(roleType);
|
||||
const plannedBatchCount = Math.max(1, Math.ceil(roleBatchSource.length / batchSize));
|
||||
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||||
let processedCount = 0;
|
||||
|
||||
for (const [batchIndex, roleBatch] of chunkArray(roleBatchSource, batchSize).entries()) {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
reporter.update(stageId, processedCount, {
|
||||
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
const stageRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldActorNarrativeProfileBatchPrompt({
|
||||
framework,
|
||||
roleType,
|
||||
roleBatch: roleBatch as Array<Record<string, unknown>>,
|
||||
themePack,
|
||||
storyGraph,
|
||||
}),
|
||||
debugLabel: `custom-world-${roleType}-actor-profile-batch-${batchIndex + 1}`,
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt({
|
||||
responseText,
|
||||
roleType,
|
||||
expectedNames: roleBatch.map((role) => getNamedRecordKey(role.name)),
|
||||
}),
|
||||
repairDebugLabel: `custom-world-${roleType}-actor-profile-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界${roleLabel}叙事档案批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
signal,
|
||||
});
|
||||
|
||||
mergedEntries = mergeRoleBatchDetails(
|
||||
mergedEntries,
|
||||
toRecordArray(
|
||||
stageRaw && typeof stageRaw === 'object'
|
||||
? (stageRaw as Record<string, unknown>)[
|
||||
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
processedCount = Math.min(roleBatchSource.length, processedCount + roleBatch.length);
|
||||
reporter.update(stageId, processedCount, {
|
||||
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
}
|
||||
|
||||
return mergedEntries.map((entry) => {
|
||||
const item = entry as Record<string, unknown>;
|
||||
const fallbackProfile = buildFallbackActorNarrativeProfile(
|
||||
entry as unknown as CustomWorldProfile['storyNpcs'][number],
|
||||
storyGraph,
|
||||
themePack,
|
||||
);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
narrativeProfile: normalizeActorNarrativeProfile(
|
||||
item.narrativeProfile,
|
||||
fallbackProfile,
|
||||
),
|
||||
} as T;
|
||||
});
|
||||
}
|
||||
|
||||
async function parseCustomWorldStageResponseJson(params: {
|
||||
responseText: string;
|
||||
repairPrompt: string;
|
||||
@@ -1060,7 +1344,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
function buildFunctionContext(
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
): FunctionAvailabilityContext {
|
||||
return {
|
||||
@@ -1089,17 +1373,8 @@ function normalizeEncounterResult(
|
||||
const kind = typeof item.kind === 'string' ? item.kind.trim() : '';
|
||||
|
||||
if (kind === 'monster') {
|
||||
const rawMonsterIds = Array.isArray(item.monsterIds) ? item.monsterIds : [];
|
||||
const fallbackHostileNpc =
|
||||
scene?.npcs.find(
|
||||
(npc: SceneNpc) =>
|
||||
isHostileSceneNpc(npc) &&
|
||||
rawMonsterIds.some(
|
||||
(monsterId) =>
|
||||
typeof monsterId === 'string' &&
|
||||
npc.monsterPresetId === monsterId,
|
||||
),
|
||||
) ?? scene?.npcs.find((npc: SceneNpc) => isHostileSceneNpc(npc));
|
||||
scene?.npcs.find((npc: SceneNpc) => isHostileSceneNpc(npc));
|
||||
|
||||
return fallbackHostileNpc
|
||||
? { kind: 'npc', npcId: fallbackHostileNpc.id }
|
||||
@@ -1128,7 +1403,7 @@ function normalizeEncounterResult(
|
||||
|
||||
function buildEncounterDrivenResolution(
|
||||
worldType: WorldType,
|
||||
inputMonsters: SceneMonster[],
|
||||
inputMonsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
encounter: SceneEncounterResult | undefined,
|
||||
) {
|
||||
@@ -1148,7 +1423,7 @@ function buildEncounterDrivenResolution(
|
||||
);
|
||||
if (sceneNpc?.monsterPresetId && isHostileSceneNpc(sceneNpc)) {
|
||||
return {
|
||||
monsters: createSceneNpcMonstersFromEncounters(
|
||||
monsters: createSceneHostileNpcsFromEncounters(
|
||||
worldType,
|
||||
[buildEncounterFromSceneNpc(sceneNpc, context.playerX)],
|
||||
context.playerX,
|
||||
@@ -1176,7 +1451,7 @@ function resolveOptionsFromFunctionIds(
|
||||
items: RawOptionItem[],
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
): StoryOption[] {
|
||||
const functionContext = buildFunctionContext(
|
||||
@@ -1305,7 +1580,7 @@ function resolveOptionsFromOptionCatalog(
|
||||
function getFallbackOptions(
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
): StoryOption[] {
|
||||
const functionContext = buildFunctionContext(
|
||||
@@ -1329,7 +1604,7 @@ function getFallbackOptions(
|
||||
function buildOfflineResponse(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
choice?: string,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
@@ -1391,7 +1666,7 @@ function normalizeResponse(
|
||||
raw: unknown,
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): AIResponse {
|
||||
@@ -1483,7 +1758,7 @@ async function requestCompletion(
|
||||
userPrompt: string,
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
@@ -1584,10 +1859,16 @@ export async function generateCustomWorldSceneImage({
|
||||
}
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
settingText: string,
|
||||
input: string | GenerateCustomWorldProfileInput,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedSettingText = settingText.trim();
|
||||
const {
|
||||
settingText: normalizedSettingText,
|
||||
generationSeedText,
|
||||
creatorIntent,
|
||||
generationMode,
|
||||
} = resolveCustomWorldGenerationInput(input);
|
||||
const generationTargets = getCustomWorldGenerationTargets(generationMode);
|
||||
const reporter = createCustomWorldGenerationReporter(options.onProgress);
|
||||
const signal = options.signal;
|
||||
|
||||
@@ -1597,7 +1878,7 @@ export async function generateCustomWorldProfile(
|
||||
phaseDetail: '正在解析你的设定文本,准备搭建世界框架。',
|
||||
});
|
||||
const frameworkRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldFrameworkPrompt(normalizedSettingText),
|
||||
userPrompt: buildCustomWorldFrameworkPrompt(generationSeedText),
|
||||
debugLabel: 'custom-world-framework',
|
||||
repairPromptBuilder: buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
repairDebugLabel: 'custom-world-framework-json-repair',
|
||||
@@ -1607,7 +1888,7 @@ export async function generateCustomWorldProfile(
|
||||
const frameworkBase = {
|
||||
...normalizeCustomWorldGenerationFramework(
|
||||
frameworkRaw,
|
||||
normalizedSettingText,
|
||||
generationSeedText,
|
||||
),
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
@@ -1616,6 +1897,16 @@ export async function generateCustomWorldProfile(
|
||||
reporter.complete('framework', {
|
||||
phaseDetail: `世界框架已确定,基础模板锚定为${frameworkBase.templateWorldType === WorldType.WUXIA ? '武侠' : '仙侠'}。`,
|
||||
});
|
||||
reporter.begin('theme-pack', {
|
||||
phaseDetail: '正在提炼题材适配层词汇与命名范式。',
|
||||
});
|
||||
const themePack = await generateCustomWorldThemePackWithAi({
|
||||
framework: frameworkBase,
|
||||
signal,
|
||||
});
|
||||
reporter.complete('theme-pack', {
|
||||
phaseDetail: `题材适配层已完成,当前题材包为“${themePack.displayName}”。`,
|
||||
});
|
||||
|
||||
reporter.begin('playable-outline', {
|
||||
phaseDetail: '正在生成可扮演角色骨架。',
|
||||
@@ -1624,7 +1915,7 @@ export async function generateCustomWorldProfile(
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkBase,
|
||||
roleType: 'playable',
|
||||
totalCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
totalCount: generationTargets.playableCount,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
@@ -1644,7 +1935,7 @@ export async function generateCustomWorldProfile(
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkWithPlayable,
|
||||
roleType: 'story',
|
||||
totalCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
totalCount: generationTargets.storyCount,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
@@ -1663,7 +1954,7 @@ export async function generateCustomWorldProfile(
|
||||
const landmarkSeeds =
|
||||
(await generateCustomWorldLandmarkSeedEntries({
|
||||
framework: frameworkWithStory,
|
||||
totalCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
totalCount: generationTargets.landmarkCount,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
@@ -1696,7 +1987,20 @@ export async function generateCustomWorldProfile(
|
||||
...frameworkWithStory,
|
||||
landmarks,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
validateCustomWorldGenerationFramework(framework);
|
||||
if (generationMode === 'full') {
|
||||
validateCustomWorldGenerationFramework(framework);
|
||||
}
|
||||
reporter.begin('story-graph', {
|
||||
phaseDetail: '正在生成世界线程、旧伤与意象母题。',
|
||||
});
|
||||
const storyGraph = await generateCustomWorldStoryGraphWithAi({
|
||||
framework,
|
||||
themePack,
|
||||
signal,
|
||||
});
|
||||
reporter.complete('story-graph', {
|
||||
phaseDetail: `世界线程图谱已完成,当前可见线程 ${storyGraph.visibleThreads.length} 条,暗线 ${storyGraph.hiddenThreads.length} 条。`,
|
||||
});
|
||||
|
||||
const baseRawProfile = buildCustomWorldRawProfileFromFramework(framework);
|
||||
reporter.begin('playable-narrative', {
|
||||
@@ -1722,6 +2026,52 @@ export async function generateCustomWorldProfile(
|
||||
reporter,
|
||||
signal,
|
||||
});
|
||||
const profileSeed = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
...baseRawProfile,
|
||||
playableNpcs: mergedPlayableNpcs,
|
||||
storyNpcs: mergedStoryNpcs,
|
||||
themePack,
|
||||
storyGraph,
|
||||
creatorIntent,
|
||||
anchorPack: buildCustomWorldAnchorPackFromIntent(creatorIntent),
|
||||
generationMode,
|
||||
generationStatus: generationTargets.generationStatus,
|
||||
},
|
||||
generationSeedText,
|
||||
);
|
||||
reporter.begin('playable-profile', {
|
||||
phaseDetail: '正在补充可扮演角色的叙事档案。',
|
||||
});
|
||||
const playableNpcsWithNarrativeProfile = await expandCustomWorldActorNarrativeProfiles({
|
||||
framework,
|
||||
roleType: 'playable',
|
||||
baseEntries: profileSeed.playableNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
themePack,
|
||||
storyGraph,
|
||||
reporter,
|
||||
signal,
|
||||
});
|
||||
reporter.complete('playable-profile', {
|
||||
phaseDetail: `可扮演角色叙事档案已完成,共 ${playableNpcsWithNarrativeProfile.length} 名。`,
|
||||
});
|
||||
reporter.begin('story-profile', {
|
||||
phaseDetail: '正在补充场景角色的叙事档案。',
|
||||
});
|
||||
const storyNpcsWithNarrativeProfile = await expandCustomWorldActorNarrativeProfiles({
|
||||
framework,
|
||||
roleType: 'story',
|
||||
baseEntries: profileSeed.storyNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||||
themePack,
|
||||
storyGraph,
|
||||
reporter,
|
||||
signal,
|
||||
});
|
||||
reporter.complete('story-profile', {
|
||||
phaseDetail: `场景角色叙事档案已完成,共 ${storyNpcsWithNarrativeProfile.length} 名。`,
|
||||
});
|
||||
|
||||
reporter.begin('finalize', {
|
||||
phaseDetail: '正在归档世界并做完整性校验。',
|
||||
@@ -1730,17 +2080,33 @@ export async function generateCustomWorldProfile(
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
...baseRawProfile,
|
||||
playableNpcs: mergedPlayableNpcs,
|
||||
storyNpcs: mergedStoryNpcs,
|
||||
playableNpcs: playableNpcsWithNarrativeProfile,
|
||||
storyNpcs: storyNpcsWithNarrativeProfile,
|
||||
themePack,
|
||||
storyGraph,
|
||||
creatorIntent,
|
||||
anchorPack: buildCustomWorldAnchorPackFromIntent(creatorIntent),
|
||||
generationMode,
|
||||
generationStatus: generationTargets.generationStatus,
|
||||
},
|
||||
normalizedSettingText,
|
||||
generationSeedText,
|
||||
);
|
||||
validateGeneratedCustomWorldProfile(profile);
|
||||
if (generationMode === 'full') {
|
||||
validateGeneratedCustomWorldProfile(profile);
|
||||
}
|
||||
reporter.complete('finalize', {
|
||||
phaseDetail: `世界“${profile.name}”已完成归档。`,
|
||||
});
|
||||
return {
|
||||
...profile,
|
||||
settingText: normalizedSettingText || profile.settingText,
|
||||
creatorIntent,
|
||||
anchorPack:
|
||||
profile.anchorPack ?? buildCustomWorldAnchorPackFromIntent(creatorIntent),
|
||||
lockState:
|
||||
profile.lockState ?? deriveCustomWorldLockStateFromIntent(creatorIntent),
|
||||
generationMode,
|
||||
generationStatus: generationTargets.generationStatus,
|
||||
items: [],
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -1904,7 +2270,7 @@ export async function generateCharacterPanelChatSummary(
|
||||
export async function generateInitialStory(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
@@ -1944,7 +2310,7 @@ export async function generateInitialStory(
|
||||
export async function generateNextStep(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
choice: string,
|
||||
context: StoryGenerationContext,
|
||||
@@ -1987,7 +2353,7 @@ export async function streamNpcChatDialogue(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
@@ -2028,7 +2394,7 @@ export async function streamNpcRecruitDialogue(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
invitationText: string,
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
import type {
|
||||
ActorNarrativeProfile,
|
||||
ActState,
|
||||
AnimationState,
|
||||
AuthorialConstraintPack,
|
||||
CampaignPack,
|
||||
CampaignState,
|
||||
CampEvent,
|
||||
ChapterState,
|
||||
Character,
|
||||
CharacterConversationStyle,
|
||||
CharacterGender,
|
||||
CompanionArcState,
|
||||
CompanionReactionRecord,
|
||||
CompanionResolution,
|
||||
CompanionStanceProfile,
|
||||
CompanionState,
|
||||
ConsequenceRecord,
|
||||
CustomWorldNpc,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
FacingDirection,
|
||||
FactionTensionState,
|
||||
InventoryItem,
|
||||
JourneyBeat,
|
||||
KnowledgeFact,
|
||||
NarrativeQaReport,
|
||||
NpcAnswerMode,
|
||||
NpcDisclosureStage,
|
||||
NpcWarmthStage,
|
||||
PlayerStyleProfile,
|
||||
QuestStatus,
|
||||
ReleaseGateReport,
|
||||
ScenarioPack,
|
||||
SceneNarrativeDirective,
|
||||
SetpieceDirective,
|
||||
SimulationRunResult,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
VisibilitySlice,
|
||||
WorldMutation,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {ConversationPressure, ConversationSituation} from '../types';
|
||||
@@ -49,10 +73,12 @@ export interface StoryGenerationContext {
|
||||
encounterName?: string | null;
|
||||
encounterDescription?: string | null;
|
||||
encounterContext?: string | null;
|
||||
encounterId?: string | null;
|
||||
encounterCharacterId?: string | null;
|
||||
encounterGender?: CharacterGender | null;
|
||||
encounterAffinity?: number | null;
|
||||
encounterAffinityText?: string | null;
|
||||
encounterStanceProfile?: CompanionStanceProfile | null;
|
||||
encounterConversationStyle?: CharacterConversationStyle | null;
|
||||
encounterDisclosureStage?: NpcDisclosureStage | null;
|
||||
encounterWarmthStage?: NpcWarmthStage | null;
|
||||
@@ -82,8 +108,36 @@ export interface StoryGenerationContext {
|
||||
| 'initialItems'
|
||||
| 'imageSrc'
|
||||
| 'visual'
|
||||
| 'narrativeProfile'
|
||||
>
|
||||
> | null;
|
||||
visibilitySlice?: VisibilitySlice | null;
|
||||
sceneNarrativeDirective?: SceneNarrativeDirective | null;
|
||||
campaignState?: CampaignState | null;
|
||||
actState?: ActState | null;
|
||||
chapterState?: ChapterState | null;
|
||||
journeyBeat?: JourneyBeat | null;
|
||||
currentCampEvent?: CampEvent | null;
|
||||
setpieceDirective?: SetpieceDirective | null;
|
||||
encounterNarrativeProfile?: ActorNarrativeProfile | null;
|
||||
knowledgeFacts?: KnowledgeFact[] | null;
|
||||
activeThreadIds?: string[] | null;
|
||||
companionArcStates?: CompanionArcState[] | null;
|
||||
companionResolutions?: CompanionResolution[] | null;
|
||||
consequenceLedger?: ConsequenceRecord[] | null;
|
||||
authorialConstraintPack?: AuthorialConstraintPack | null;
|
||||
activeScenarioPack?: ScenarioPack | null;
|
||||
activeCampaignPack?: CampaignPack | null;
|
||||
playerStyleProfile?: PlayerStyleProfile | null;
|
||||
recentCompanionReactions?: CompanionReactionRecord[] | null;
|
||||
recentCarrierEchoes?: string[] | null;
|
||||
recentWorldMutations?: WorldMutation[] | null;
|
||||
recentFactionTensionStates?: FactionTensionState[] | null;
|
||||
recentChronicleSummary?: string | null;
|
||||
narrativeQaReport?: NarrativeQaReport | null;
|
||||
releaseGateReport?: ReleaseGateReport | null;
|
||||
simulationRunResults?: SimulationRunResult[] | null;
|
||||
branchBudgetPressure?: string | null;
|
||||
partyRelationshipNotes?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
openingCampBackground?: string | null;
|
||||
@@ -100,6 +154,7 @@ export interface QuestSummarySnapshot {
|
||||
export interface QuestGenerationContext {
|
||||
worldType: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
actState?: ActState | null;
|
||||
currentSceneId?: string | null;
|
||||
currentSceneName?: string | null;
|
||||
currentSceneDescription?: string | null;
|
||||
@@ -107,8 +162,10 @@ export interface QuestGenerationContext {
|
||||
issuerNpcName?: string | null;
|
||||
issuerNpcContext?: string | null;
|
||||
issuerAffinity?: number | null;
|
||||
issuerNarrativeProfile?: ActorNarrativeProfile | null;
|
||||
issuerDisclosureStage?: NpcDisclosureStage | null;
|
||||
issuerWarmthStage?: NpcWarmthStage | null;
|
||||
activeThreadIds?: string[] | null;
|
||||
encounterKind?: 'npc' | 'treasure' | 'none' | null;
|
||||
currentSceneHostileNpcIds?: string[];
|
||||
currentSceneTreasureHintCount?: number;
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
ActorNarrativeProfile,
|
||||
CharacterBackstoryChapter,
|
||||
CharacterBackstoryRevealConfig,
|
||||
CustomWorldAnchorPack,
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
@@ -20,9 +22,23 @@ import {
|
||||
CustomWorldRoleInitialItem,
|
||||
CustomWorldRoleSkill,
|
||||
ItemRarity,
|
||||
ThemePack,
|
||||
WorldStoryGraph,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { generateWorldAttributeSchema } from './attributeSchemaGenerator';
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
deriveCustomWorldLockStateFromIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldLockState,
|
||||
} from './customWorldCreatorIntent';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from './storyEngine/actorNarrativeProfile';
|
||||
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
|
||||
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
|
||||
|
||||
const CUSTOM_WORLD_RARITIES: ItemRarity[] = [
|
||||
'common',
|
||||
@@ -528,6 +544,8 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary],
|
||||
attributeSchema: generateWorldAttributeSchema({
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: name,
|
||||
@@ -542,6 +560,13 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
themePack: null,
|
||||
storyGraph: null,
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: normalizeCustomWorldLockState(null),
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -673,7 +698,7 @@ function normalizeRoleProfile(
|
||||
normalizeTags(item.tags),
|
||||
);
|
||||
const normalizedRole = {
|
||||
id: createEntryId(options.idPrefix, name, index),
|
||||
id: toText(item.id) || createEntryId(options.idPrefix, name, index),
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
@@ -695,6 +720,10 @@ function normalizeRoleProfile(
|
||||
backstoryReveal: normalizeBackstoryReveal(item.backstoryReveal, normalizedRole),
|
||||
skills: normalizeRoleSkillList(item.skills, normalizedRole),
|
||||
initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole),
|
||||
narrativeProfile:
|
||||
item.narrativeProfile && typeof item.narrativeProfile === 'object'
|
||||
? (item.narrativeProfile as ActorNarrativeProfile)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -737,7 +766,7 @@ function normalizeItemList(value: unknown) {
|
||||
const name = toText(item.name);
|
||||
const category = toText(item.category);
|
||||
return {
|
||||
id: createEntryId('item', name, index),
|
||||
id: toText(item.id) || createEntryId('item', name, index),
|
||||
name,
|
||||
category,
|
||||
rarity: normalizeRarity(item.rarity, 'rare'),
|
||||
@@ -854,7 +883,7 @@ function normalizeLandmarkDraftList(value: unknown) {
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
return {
|
||||
id: createEntryId('landmark', name, index),
|
||||
id: toText(item.id) || createEntryId('landmark', name, index),
|
||||
name,
|
||||
description: toText(item.description),
|
||||
dangerLevel: toText(item.dangerLevel),
|
||||
@@ -922,7 +951,9 @@ export function normalizeCustomWorldProfile(
|
||||
const landmarkDrafts = normalizeLandmarkDraftList(item.landmarks);
|
||||
|
||||
return {
|
||||
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
id:
|
||||
toText(item.id) ||
|
||||
`custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
settingText: settingText.trim(),
|
||||
name,
|
||||
subtitle: toText(item.subtitle) || fallback.subtitle,
|
||||
@@ -930,6 +961,8 @@ export function normalizeCustomWorldProfile(
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
majorFactions: normalizeTags(item.majorFactions, []),
|
||||
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
|
||||
attributeSchema: coerceWorldAttributeSchema(
|
||||
item.attributeSchema,
|
||||
generatedAttributeSchema,
|
||||
@@ -941,6 +974,35 @@ export function normalizeCustomWorldProfile(
|
||||
landmarks: landmarkDrafts,
|
||||
storyNpcs,
|
||||
}),
|
||||
themePack:
|
||||
item.themePack && typeof item.themePack === 'object'
|
||||
? (item.themePack as ThemePack)
|
||||
: null,
|
||||
storyGraph:
|
||||
item.storyGraph && typeof item.storyGraph === 'object'
|
||||
? (item.storyGraph as WorldStoryGraph)
|
||||
: null,
|
||||
creatorIntent: normalizeCustomWorldCreatorIntent(item.creatorIntent),
|
||||
anchorPack:
|
||||
item.anchorPack && typeof item.anchorPack === 'object'
|
||||
? (item.anchorPack as CustomWorldAnchorPack)
|
||||
: buildCustomWorldAnchorPackFromIntent(
|
||||
normalizeCustomWorldCreatorIntent(item.creatorIntent),
|
||||
),
|
||||
lockState:
|
||||
item.lockState && typeof item.lockState === 'object'
|
||||
? normalizeCustomWorldLockState(item.lockState)
|
||||
: deriveCustomWorldLockStateFromIntent(
|
||||
normalizeCustomWorldCreatorIntent(item.creatorIntent),
|
||||
),
|
||||
generationMode:
|
||||
item.generationMode === 'fast' || item.generationMode === 'full'
|
||||
? item.generationMode
|
||||
: fallback.generationMode,
|
||||
generationStatus:
|
||||
item.generationStatus === 'key_only' || item.generationStatus === 'complete'
|
||||
? item.generationStatus
|
||||
: fallback.generationStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1038,12 +1100,7 @@ export function validateCustomWorldGenerationFramework(
|
||||
framework: CustomWorldGenerationFramework,
|
||||
) {
|
||||
const playableCount = countUniqueNames(framework.playableNpcs);
|
||||
const storyCount = countUniqueNames(framework.storyNpcs);
|
||||
const landmarkCount = countUniqueNames(framework.landmarks);
|
||||
const totalNpcCount = countUniqueNames([
|
||||
...framework.playableNpcs,
|
||||
...framework.storyNpcs,
|
||||
]);
|
||||
|
||||
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
|
||||
throw new Error(
|
||||
@@ -1051,18 +1108,6 @@ export function validateCustomWorldGenerationFramework(
|
||||
);
|
||||
}
|
||||
|
||||
if (storyCount < MIN_CUSTOM_WORLD_STORY_NPC_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_STORY_NPC_COUNT} 名场景角色。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (totalNpcCount < MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT} 名唯一角色,当前仅有 ${totalNpcCount} 名。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅有 ${landmarkCount} 个。`,
|
||||
@@ -1101,6 +1146,246 @@ export function buildCustomWorldFrameworkPrompt(settingText: string) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldThemePackPrompt(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
}) {
|
||||
const { framework } = params;
|
||||
|
||||
return [
|
||||
'请根据下面的世界框架,生成一份题材适配层 ThemePack。',
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
'世界框架摘要:',
|
||||
buildFrameworkSummaryText(framework, { maxLandmarks: 6 }),
|
||||
'',
|
||||
'输出 JSON 模板:',
|
||||
'{',
|
||||
' "id": "theme-pack-id",',
|
||||
' "displayName": "题材包名称",',
|
||||
' "toneRange": ["基调1", "基调2"],',
|
||||
' "institutionLexicon": ["制度词1", "制度词2", "制度词3"],',
|
||||
' "tabooLexicon": ["禁忌词1", "禁忌词2", "禁忌词3"],',
|
||||
' "artifactClasses": ["载体种类1", "载体种类2", "载体种类3"],',
|
||||
' "actorArchetypes": ["角色原型1", "角色原型2", "角色原型3"],',
|
||||
' "conflictForms": ["冲突形式1", "冲突形式2", "冲突形式3"],',
|
||||
' "clueForms": ["线索形态1", "线索形态2", "线索形态3"],',
|
||||
' "namingPatterns": ["命名范式1", "命名范式2"],',
|
||||
' "revealStyles": ["揭示方式1", "揭示方式2"]',
|
||||
'}',
|
||||
'',
|
||||
'要求:',
|
||||
'- 所有文本必须使用中文。',
|
||||
'- 输出必须贴合当前世界,不要写泛化奇幻模板。',
|
||||
'- institutionLexicon / tabooLexicon / artifactClasses / conflictForms / clueForms 至少各给 4 项。',
|
||||
'- 命名范式要直接服务后续 NPC、场景、物件、文书的统一词根。',
|
||||
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldThemePackJsonRepairPrompt(params: {
|
||||
responseText: string;
|
||||
}) {
|
||||
return [
|
||||
'下面这段文本本应是自定义世界 ThemePack 的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
|
||||
'请只输出修复后的 JSON 对象。',
|
||||
'顶层必须包含:id、displayName、toneRange、institutionLexicon、tabooLexicon、artifactClasses、actorArchetypes、conflictForms、clueForms、namingPatterns、revealStyles。',
|
||||
'如果缺少数组字段,补空数组;如果缺少字符串字段,补空字符串。',
|
||||
'原始文本:',
|
||||
params.responseText.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldStoryGraphPrompt(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
themePack: ThemePack;
|
||||
}) {
|
||||
const { framework, themePack } = params;
|
||||
const roleText = [
|
||||
...framework.playableNpcs.slice(0, 5),
|
||||
...framework.storyNpcs.slice(0, 10),
|
||||
]
|
||||
.map((role) => `- ${role.name} / ${role.role}:${role.description}`)
|
||||
.join('\n');
|
||||
const landmarkText = framework.landmarks
|
||||
.slice(0, 10)
|
||||
.map((landmark) => `- ${landmark.name}:${landmark.description}`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'请根据下面的世界框架和 ThemePack,生成 WorldStoryGraph。',
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
'世界框架摘要:',
|
||||
buildFrameworkSummaryText(framework, { maxLandmarks: 8 }),
|
||||
'',
|
||||
`ThemePack:${themePack.displayName}`,
|
||||
`制度词汇:${themePack.institutionLexicon.join('、')}`,
|
||||
`禁忌词汇:${themePack.tabooLexicon.join('、')}`,
|
||||
`冲突形式:${themePack.conflictForms.join('、')}`,
|
||||
`线索形态:${themePack.clueForms.join('、')}`,
|
||||
'',
|
||||
`角色索引:\n${roleText}`,
|
||||
`场景索引:\n${landmarkText}`,
|
||||
'',
|
||||
'输出 JSON 模板:',
|
||||
'{',
|
||||
' "visibleThreads": [',
|
||||
' {',
|
||||
' "id": "visible-thread-1",',
|
||||
' "title": "明线标题",',
|
||||
' "visibility": "visible",',
|
||||
' "summary": "明线摘要",',
|
||||
' "conflictType": "冲突形式",',
|
||||
' "stakes": "代价与利害",',
|
||||
' "involvedFactionIds": ["势力1"],',
|
||||
' "involvedActorIds": ["角色id1"],',
|
||||
' "relatedLocationIds": ["场景id1"]',
|
||||
' }',
|
||||
' ],',
|
||||
' "hiddenThreads": [',
|
||||
' {',
|
||||
' "id": "hidden-thread-1",',
|
||||
' "title": "暗线标题",',
|
||||
' "visibility": "hidden",',
|
||||
' "summary": "暗线摘要",',
|
||||
' "conflictType": "冲突形式",',
|
||||
' "stakes": "代价与利害",',
|
||||
' "involvedFactionIds": ["势力1"],',
|
||||
' "involvedActorIds": ["角色id1"],',
|
||||
' "relatedLocationIds": ["场景id1"]',
|
||||
' }',
|
||||
' ],',
|
||||
' "scars": [',
|
||||
' {',
|
||||
' "id": "scar-1",',
|
||||
' "title": "旧伤标题",',
|
||||
' "pastEvent": "过去发生的事件",',
|
||||
' "publicResidue": "表面残痕",',
|
||||
' "hiddenTruth": "隐藏真相",',
|
||||
' "relatedActorIds": ["角色id1"],',
|
||||
' "relatedLocationIds": ["场景id1"]',
|
||||
' }',
|
||||
' ],',
|
||||
' "motifs": [',
|
||||
' {',
|
||||
' "id": "motif-1",',
|
||||
' "label": "意象词根",',
|
||||
' "semanticRole": "institution|ritual|technology|taboo|ruin|memory|resource|creature",',
|
||||
' "lexicalHints": ["提示1", "提示2"]',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
'',
|
||||
'要求:',
|
||||
'- 至少生成 3 条 visibleThreads、4 条 hiddenThreads、4 条 scars、8 个 motifs。',
|
||||
'- involvedActorIds / relatedLocationIds 优先使用已给出的真实角色与场景 id。',
|
||||
'- 所有文本必须使用中文。',
|
||||
'- 输出要让角色、场景、旧痕之间可互相印证,不要让每条线程彼此无关。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldStoryGraphJsonRepairPrompt(params: {
|
||||
responseText: string;
|
||||
}) {
|
||||
return [
|
||||
'下面这段文本本应是自定义世界 WorldStoryGraph 的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
|
||||
'请只输出修复后的 JSON 对象。',
|
||||
'顶层必须包含:visibleThreads、hiddenThreads、scars、motifs。',
|
||||
'每个线程对象必须包含:id、title、visibility、summary、conflictType、stakes、involvedFactionIds、involvedActorIds、relatedLocationIds。',
|
||||
'每个 scar 必须包含:id、title、pastEvent、publicResidue、hiddenTruth、relatedActorIds、relatedLocationIds。',
|
||||
'每个 motif 必须包含:id、label、semanticRole、lexicalHints。',
|
||||
'原始文本:',
|
||||
params.responseText.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldActorNarrativeProfileBatchPrompt(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
roleBatch: Array<Record<string, unknown>>;
|
||||
themePack: ThemePack;
|
||||
storyGraph: WorldStoryGraph;
|
||||
}) {
|
||||
const { framework, roleType, roleBatch, themePack, storyGraph } = params;
|
||||
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
|
||||
const label = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
const roleText = roleBatch
|
||||
.map((role) => {
|
||||
const roleName = toText(role.name);
|
||||
return `- ${roleName} / ${toText(role.role)}:${toText(role.description)};背景:${toText(role.backstory)};动机:${toText(role.motivation)};关系切口:${normalizeTags(role.relationshipHooks).join('、')}`;
|
||||
})
|
||||
.join('\n');
|
||||
const threadText = [...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
|
||||
.slice(0, 8)
|
||||
.map((thread) => `- ${thread.id} / ${thread.title}:${thread.summary}`)
|
||||
.join('\n');
|
||||
const scarText = storyGraph.scars
|
||||
.slice(0, 8)
|
||||
.map((scar) => `- ${scar.id} / ${scar.title}:${scar.publicResidue}`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
`请根据世界框架、ThemePack 和 StoryGraph,为这一批${label}生成 ActorNarrativeProfile。`,
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
'世界框架摘要:',
|
||||
buildFrameworkSummaryText(framework, { maxLandmarks: 6 }),
|
||||
'',
|
||||
`ThemePack:${themePack.displayName}`,
|
||||
`揭示方式:${themePack.revealStyles.join('、')}`,
|
||||
`命名范式:${themePack.namingPatterns.join('、')}`,
|
||||
'',
|
||||
`世界线程:\n${threadText}`,
|
||||
`世界旧伤:\n${scarText}`,
|
||||
`本批角色:\n${roleText}`,
|
||||
'',
|
||||
'输出 JSON 模板:',
|
||||
'{',
|
||||
` "${key}": [`,
|
||||
' {',
|
||||
' "name": "角色名称",',
|
||||
' "narrativeProfile": {',
|
||||
' "publicMask": "公开面",',
|
||||
' "firstContactMask": "首遇说辞",',
|
||||
' "visibleLine": "表层线",',
|
||||
' "hiddenLine": "隐藏线",',
|
||||
' "contradiction": "说辞错位",',
|
||||
' "debtOrBurden": "债务或负担",',
|
||||
' "taboo": "不愿被提起的禁区",',
|
||||
' "immediatePressure": "此刻压力",',
|
||||
' "relatedThreadIds": ["thread-id"],',
|
||||
' "relatedScarIds": ["scar-id"],',
|
||||
' "reactionHooks": ["反应钩子1", "反应钩子2"]',
|
||||
' }',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
'',
|
||||
'要求:',
|
||||
'- 名称必须与本批角色完全一致,不得改名。',
|
||||
'- 每个角色都必须给出 1 个 publicMask、1 个 firstContactMask、1 个 visibleLine、1 个 hiddenLine、1 个 contradiction、1 个 debtOrBurden、1 个 taboo、1 个 immediatePressure。',
|
||||
'- relatedThreadIds 至少 1 个,relatedScarIds 至少 0 到 2 个,reactionHooks 至少 2 个。',
|
||||
'- 低好感角色必须明显表现“压力、错位、钩子”,不要只写冷淡。',
|
||||
'- 所有文本必须使用中文。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt(params: {
|
||||
responseText: string;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
expectedNames: string[];
|
||||
}) {
|
||||
const key = params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
|
||||
|
||||
return [
|
||||
`下面这段文本本应是自定义世界角色叙事档案批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`,
|
||||
'请只输出修复后的 JSON 对象。',
|
||||
`顶层必须只包含一个 ${key} 数组。`,
|
||||
`数组里只能保留这些名称:${params.expectedNames.join('、')}。`,
|
||||
'每个角色对象必须包含:name、narrativeProfile。',
|
||||
'narrativeProfile 必须包含:publicMask、firstContactMask、visibleLine、hiddenLine、contradiction、debtOrBurden、taboo、immediatePressure、relatedThreadIds、relatedScarIds、reactionHooks。',
|
||||
'原始文本:',
|
||||
params.responseText.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldFrameworkJsonRepairPrompt(
|
||||
responseText: string,
|
||||
) {
|
||||
@@ -1614,24 +1899,57 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldReferenceText(profile: CustomWorldProfile) {
|
||||
export function buildCustomWorldReferenceText(
|
||||
profile: CustomWorldProfile,
|
||||
options: {
|
||||
activeThreadIds?: string[] | null;
|
||||
highlightNpcNames?: string[] | null;
|
||||
} = {},
|
||||
) {
|
||||
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
|
||||
const landmarkById = new Map(
|
||||
profile.landmarks.map((landmark) => [landmark.id, landmark]),
|
||||
);
|
||||
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
|
||||
const storyGraph =
|
||||
profile.storyGraph ?? buildFallbackWorldStoryGraph(profile, themePack);
|
||||
const activeThreadIds =
|
||||
options.activeThreadIds?.filter(Boolean)?.length
|
||||
? options.activeThreadIds.filter(Boolean)
|
||||
: storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
|
||||
const activeThreads = [...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
|
||||
.filter((thread) => activeThreadIds.includes(thread.id))
|
||||
.slice(0, 3);
|
||||
const highlightNpcNames = new Set(
|
||||
(options.highlightNpcNames ?? []).map((name) => name.trim()).filter(Boolean),
|
||||
);
|
||||
const describeNpcReference = (
|
||||
npc: CustomWorldProfile['storyNpcs'][number] | CustomWorldProfile['playableNpcs'][number],
|
||||
) => {
|
||||
const narrativeProfile = normalizeActorNarrativeProfile(
|
||||
npc.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
|
||||
);
|
||||
|
||||
return `- ${npc.name} / ${npc.title}:身份 ${npc.role};公开面:${narrativeProfile.publicMask};表层线:${narrativeProfile.visibleLine};当前压力:${narrativeProfile.immediatePressure};相关线程:${
|
||||
narrativeProfile.relatedThreadIds
|
||||
.map((threadId) =>
|
||||
[...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
|
||||
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
||||
)
|
||||
.join('、') || '暂无'
|
||||
};反应钩子:${narrativeProfile.reactionHooks.join('、') || '暂无'}`;
|
||||
};
|
||||
const playableNpcText = profile.playableNpcs
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(npc) =>
|
||||
`- ${npc.name} / ${npc.title}:${npc.description};身份:${npc.role};公开背景:${npc.backstoryReveal.publicSummary};背景:${npc.backstory};动机:${npc.motivation};风格:${npc.combatStyle};技能:${npc.skills.map((skill) => `${skill.name}(${skill.style})`).join('、') || '暂无'};初始物品:${npc.initialItems.map((item) => `${item.name}x${item.quantity}`).join('、') || '暂无'};好感章节:${npc.backstoryReveal.chapters.map((chapter) => `${chapter.affinityRequired}:${chapter.teaser}`).join(' / ')};初始好感:${npc.initialAffinity}`,
|
||||
)
|
||||
.map((npc) => describeNpcReference(npc))
|
||||
.join('\n');
|
||||
const storyNpcText = profile.storyNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc) =>
|
||||
`- ${npc.name} / ${npc.role}:${npc.description};称号:${npc.title};公开背景:${npc.backstoryReveal.publicSummary};背景:${npc.backstory};动机:${npc.motivation};风格:${npc.combatStyle};技能:${npc.skills.map((skill) => `${skill.name}(${skill.style})`).join('、') || '暂无'};初始物品:${npc.initialItems.map((item) => `${item.name}x${item.quantity}`).join('、') || '暂无'};好感章节:${npc.backstoryReveal.chapters.map((chapter) => `${chapter.affinityRequired}:${chapter.teaser}`).join(' / ')};初始好感:${npc.initialAffinity}`,
|
||||
.filter((npc) =>
|
||||
highlightNpcNames.size > 0 ? highlightNpcNames.has(npc.name) : true,
|
||||
)
|
||||
.slice(0, highlightNpcNames.size > 0 ? 3 : 6)
|
||||
.map((npc) => describeNpcReference(npc))
|
||||
.join('\n');
|
||||
const landmarkText = profile.landmarks
|
||||
.slice(0, 10)
|
||||
@@ -1664,6 +1982,8 @@ export function buildCustomWorldReferenceText(profile: CustomWorldProfile) {
|
||||
`世界概述:${profile.summary}`,
|
||||
`世界基调:${profile.tone}`,
|
||||
`玩家核心目标:${profile.playerGoal}`,
|
||||
`题材适配层:${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(';')}`,
|
||||
`可扮演角色档案:\n${playableNpcText || '- 暂无'}`,
|
||||
`世界场景角色档案:\n${storyNpcText || '- 暂无'}`,
|
||||
@@ -1679,12 +1999,7 @@ export function validateGeneratedCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const playableCount = countUniqueNames(profile.playableNpcs);
|
||||
const storyCount = countUniqueNames(profile.storyNpcs);
|
||||
const landmarkCount = countUniqueNames(profile.landmarks);
|
||||
const totalNpcCount = countUniqueNames([
|
||||
...profile.playableNpcs,
|
||||
...profile.storyNpcs,
|
||||
]);
|
||||
|
||||
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
|
||||
throw new Error(
|
||||
@@ -1692,22 +2007,6 @@ export function validateGeneratedCustomWorldProfile(
|
||||
);
|
||||
}
|
||||
|
||||
if (totalNpcCount < MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT} 名唯一角色,当前仅返回 ${totalNpcCount} 名。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
storyCount <
|
||||
Math.max(
|
||||
0,
|
||||
MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
)
|
||||
) {
|
||||
throw new Error('自定义世界生成返回的非可扮演场景角色数量不足。');
|
||||
}
|
||||
|
||||
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`,
|
||||
|
||||
93
src/services/customWorldBuilder.test.ts
Normal file
93
src/services/customWorldBuilder.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
|
||||
|
||||
describe('buildExpandedCustomWorldProfile', () => {
|
||||
it('attaches theme pack, story graph, and narrative profiles', () => {
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
id: 'custom-world-test',
|
||||
name: '裂潮边城',
|
||||
subtitle: '风暴前夜',
|
||||
summary: '一座被裂潮与旧案同时牵动的边城。',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
playerGoal: '查清边城裂潮背后的真相',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['巡边司', '潮商会'],
|
||||
coreConflicts: ['裂潮反复冲垮旧防线', '旧案名单再次出现'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '向导',
|
||||
description: '熟悉裂潮边路的灰炬向导。',
|
||||
backstory: '曾在旧撤离线里失去一整支同行队。',
|
||||
personality: '谨慎寡言,先看风向再开口。',
|
||||
motivation: '想查清旧撤离线为何再次失控。',
|
||||
combatStyle: '短弓牵制后贴近补刀。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['旧撤离线', '名单'],
|
||||
tags: ['裂潮', '向导'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只说自己熟悉边路。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。 ', contextSnippet: '他总先谈路和风。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '梁砺',
|
||||
title: '断桥巡守',
|
||||
role: '巡守',
|
||||
description: '守着断桥与旧哨火的巡守。',
|
||||
backstory: '旧案爆发时,他是最后一个封桥的人。',
|
||||
personality: '警觉直接,不喜欢绕弯。',
|
||||
motivation: '不想让旧案再次借裂潮翻上来。',
|
||||
combatStyle: '长兵先压,再卡住路口。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['封桥', '旧哨火'],
|
||||
tags: ['巡守', '断桥'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只承认自己还在守桥。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '断桥旧哨',
|
||||
description: '旧哨火和断桥一起守着边城北口。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
'玩家想要一个裂潮边城与旧案回响交织的世界。',
|
||||
);
|
||||
|
||||
expect(profile.themePack?.displayName).toBeTruthy();
|
||||
expect(profile.storyGraph?.visibleThreads.length).toBeGreaterThan(0);
|
||||
expect(profile.storyGraph?.hiddenThreads.length).toBeGreaterThan(0);
|
||||
expect(profile.storyNpcs[0]?.narrativeProfile?.immediatePressure).toBeTruthy();
|
||||
expect(profile.playableNpcs[0]?.narrativeProfile?.relatedThreadIds.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,23 @@ import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags';
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { CustomWorldProfile, WorldType } from '../types';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from './storyEngine/actorNarrativeProfile';
|
||||
import { compileCampaignFromWorldProfile } from './storyEngine/campaignPackCompiler';
|
||||
import { buildKnowledgeGraph } from './storyEngine/knowledgeGraph';
|
||||
import { registerScenarioPack } from './storyEngine/scenarioPackRegistry';
|
||||
import { buildSceneNarrativeResidues } from './storyEngine/sceneResidueCompiler';
|
||||
import {
|
||||
buildThemePackFromWorldProfile,
|
||||
normalizeThemePack,
|
||||
} from './storyEngine/themePack';
|
||||
import { buildThreadContractsFromProfile } from './storyEngine/threadContract';
|
||||
import {
|
||||
buildFallbackWorldStoryGraph,
|
||||
normalizeWorldStoryGraph,
|
||||
} from './storyEngine/worldStoryGraph';
|
||||
|
||||
const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
|
||||
'sword-princess',
|
||||
@@ -103,7 +120,7 @@ export function buildExpandedCustomWorldProfile(
|
||||
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
|
||||
return {
|
||||
...npc,
|
||||
id: createEntryId('playable-npc', npc.name, index),
|
||||
id: npc.id || createEntryId('playable-npc', npc.name, index),
|
||||
templateCharacterId,
|
||||
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
|
||||
templateCharacterId,
|
||||
@@ -116,7 +133,7 @@ export function buildExpandedCustomWorldProfile(
|
||||
});
|
||||
const storyNpcs = dedupeByName(profile.storyNpcs).map((npc, index) => ({
|
||||
...npc,
|
||||
id: createEntryId('story-npc', npc.name, index),
|
||||
id: npc.id || createEntryId('story-npc', npc.name, index),
|
||||
description: clampText(npc.description, 72),
|
||||
motivation: clampText(npc.motivation, 72),
|
||||
relationshipHooks: normalizeHooks(npc.relationshipHooks),
|
||||
@@ -139,7 +156,7 @@ export function buildExpandedCustomWorldProfile(
|
||||
});
|
||||
const landmarkDrafts = dedupeByName(profile.landmarks).map((landmark, index) => ({
|
||||
...landmark,
|
||||
id: createEntryId('landmark', landmark.name, index),
|
||||
id: landmark.id || createEntryId('landmark', landmark.name, index),
|
||||
description: clampText(landmark.description, 96),
|
||||
dangerLevel:
|
||||
landmark.dangerLevel ||
|
||||
@@ -176,19 +193,90 @@ export function buildExpandedCustomWorldProfile(
|
||||
})),
|
||||
storyNpcs,
|
||||
});
|
||||
|
||||
return {
|
||||
const items = dedupeByName(profile.items).map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || createEntryId('item', item.name, index),
|
||||
description: clampText(item.description, 72),
|
||||
tags: normalizeTags(item.tags),
|
||||
attributeResonance:
|
||||
item.attributeResonance ?? buildItemAttributeResonance(item),
|
||||
}));
|
||||
const baseExpandedProfile = {
|
||||
...profile,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
items: dedupeByName(profile.items).map((item, index) => ({
|
||||
...item,
|
||||
id: createEntryId('item', item.name, index),
|
||||
description: clampText(item.description, 72),
|
||||
tags: normalizeTags(item.tags),
|
||||
attributeResonance:
|
||||
item.attributeResonance ?? buildItemAttributeResonance(item),
|
||||
})),
|
||||
items,
|
||||
landmarks,
|
||||
} satisfies CustomWorldProfile;
|
||||
const themePack = normalizeThemePack(
|
||||
profile.themePack,
|
||||
buildThemePackFromWorldProfile(baseExpandedProfile),
|
||||
);
|
||||
const storyGraph = normalizeWorldStoryGraph(
|
||||
profile.storyGraph,
|
||||
buildFallbackWorldStoryGraph(baseExpandedProfile, themePack),
|
||||
);
|
||||
const enrichedPlayableNpcs = playableNpcs.map((npc) => ({
|
||||
...npc,
|
||||
narrativeProfile: normalizeActorNarrativeProfile(
|
||||
npc.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
|
||||
),
|
||||
}));
|
||||
const enrichedStoryNpcs = storyNpcs.map((npc) => ({
|
||||
...npc,
|
||||
narrativeProfile: normalizeActorNarrativeProfile(
|
||||
npc.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
|
||||
),
|
||||
}));
|
||||
const landmarksWithResidues = landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
narrativeResidues:
|
||||
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
|
||||
? landmark.narrativeResidues
|
||||
: buildSceneNarrativeResidues({
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
profile: {
|
||||
...baseExpandedProfile,
|
||||
playableNpcs: enrichedPlayableNpcs,
|
||||
storyNpcs: enrichedStoryNpcs,
|
||||
storyGraph,
|
||||
themePack,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const profileWithNarrative = {
|
||||
...baseExpandedProfile,
|
||||
playableNpcs: enrichedPlayableNpcs,
|
||||
storyNpcs: enrichedStoryNpcs,
|
||||
themePack,
|
||||
storyGraph,
|
||||
landmarks: landmarksWithResidues,
|
||||
} satisfies CustomWorldProfile;
|
||||
const knowledgeFacts =
|
||||
profile.knowledgeFacts && profile.knowledgeFacts.length > 0
|
||||
? profile.knowledgeFacts
|
||||
: buildKnowledgeGraph(profileWithNarrative);
|
||||
const threadContracts =
|
||||
profile.threadContracts && profile.threadContracts.length > 0
|
||||
? profile.threadContracts
|
||||
: buildThreadContractsFromProfile(profileWithNarrative);
|
||||
const compiledPacks = compileCampaignFromWorldProfile({
|
||||
profile: {
|
||||
...profileWithNarrative,
|
||||
knowledgeFacts,
|
||||
threadContracts,
|
||||
},
|
||||
});
|
||||
registerScenarioPack(compiledPacks.scenarioPack);
|
||||
|
||||
return {
|
||||
...profileWithNarrative,
|
||||
knowledgeFacts,
|
||||
threadContracts,
|
||||
scenarioPackId: profile.scenarioPackId ?? compiledPacks.scenarioPack.id,
|
||||
campaignPackId: profile.campaignPackId ?? compiledPacks.campaignPack.id,
|
||||
};
|
||||
}
|
||||
|
||||
93
src/services/customWorldCreatorIntent.test.ts
Normal file
93
src/services/customWorldCreatorIntent.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
createEmptyCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from './customWorldCreatorIntent';
|
||||
|
||||
describe('customWorldCreatorIntent', () => {
|
||||
it('builds a readable summary from creator intent cards', () => {
|
||||
const intent = {
|
||||
...createEmptyCustomWorldCreatorIntent('card'),
|
||||
worldHook: '一个会被灵潮反复改写地形的边境世界。',
|
||||
themeKeywords: ['边境', '灵潮'],
|
||||
toneDirectives: ['紧张', '潮湿'],
|
||||
playerPremise: '玩家是带着旧名单回来的前巡夜人。',
|
||||
coreConflicts: ['旧案名单再次出现'],
|
||||
keyCharacters: [
|
||||
{
|
||||
id: 'character-1',
|
||||
name: '沈砺',
|
||||
role: '灰炬向导',
|
||||
publicMask: '看起来只是熟路的带路人',
|
||||
hiddenHook: '他一直在追查撤离线失控真相',
|
||||
relationToPlayer: '会先怀疑玩家身份',
|
||||
notes: '',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const summary = buildCustomWorldCreatorIntentDisplayText(intent);
|
||||
|
||||
expect(summary).toContain('世界一句话:一个会被灵潮反复改写地形的边境世界。');
|
||||
expect(summary).toContain('主题关键词:边境、灵潮');
|
||||
expect(summary).toContain('关键角色:沈砺 / 灰炬向导');
|
||||
});
|
||||
|
||||
it('builds anchor pack from creator intent and keeps locked ids', () => {
|
||||
const intent = {
|
||||
...createEmptyCustomWorldCreatorIntent('card'),
|
||||
worldHook: '边境世界',
|
||||
coreConflicts: ['裂潮失控'],
|
||||
keyFactions: [
|
||||
{
|
||||
id: 'faction-1',
|
||||
name: '巡边司',
|
||||
publicGoal: '维持边境秩序',
|
||||
tension: '正在被旧案拖入裂潮',
|
||||
notes: '',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
keyLandmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '断桥旧哨',
|
||||
purpose: '边境咽喉',
|
||||
mood: '压迫',
|
||||
secret: '封桥旧令来源不明',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const anchorPack = buildCustomWorldAnchorPackFromIntent(intent);
|
||||
|
||||
expect(anchorPack?.keyConflictSummaries).toEqual(['裂潮失控']);
|
||||
expect(anchorPack?.keyFactionSummaries[0]).toContain('巡边司');
|
||||
expect(anchorPack?.lockedAnchorIds).toEqual(
|
||||
expect.arrayContaining(['faction-1', 'landmark-1']),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes sparse creator intent payloads', () => {
|
||||
const intent = normalizeCustomWorldCreatorIntent({
|
||||
sourceMode: 'card',
|
||||
worldHook: '雾海边城',
|
||||
themeKeywords: ['雾海', '旧案'],
|
||||
keyCharacters: [
|
||||
{
|
||||
name: '梁砺',
|
||||
role: '断桥巡守',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(intent?.sourceMode).toBe('card');
|
||||
expect(intent?.keyCharacters[0]?.name).toBe('梁砺');
|
||||
expect(intent?.keyCharacters[0]?.id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
536
src/services/customWorldCreatorIntent.ts
Normal file
536
src/services/customWorldCreatorIntent.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import type {
|
||||
ActorAnchor,
|
||||
CreatorCharacterSeed,
|
||||
CreatorFactionSeed,
|
||||
CreatorLandmarkSeed,
|
||||
CustomWorldAnchorPack,
|
||||
CustomWorldCreatorInputMode,
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldLockState,
|
||||
LandmarkAnchor,
|
||||
} from '../types';
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, maxCount = 8) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, maxCount);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized || 'entry';
|
||||
}
|
||||
|
||||
function createSeedId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||||
}
|
||||
|
||||
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 normalizeCreatorFactionSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorFactionSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const publicGoal = toText(item.publicGoal);
|
||||
const tension = toText(item.tension);
|
||||
const notes = toText(item.notes);
|
||||
|
||||
if (!name && !publicGoal && !tension && !notes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || createSeedId('creator-faction', name || publicGoal, index),
|
||||
name,
|
||||
publicGoal,
|
||||
tension,
|
||||
notes,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreatorCharacterSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorCharacterSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const role = toText(item.role);
|
||||
const publicMask = toText(item.publicMask);
|
||||
const hiddenHook = toText(item.hiddenHook);
|
||||
const relationToPlayer = toText(item.relationToPlayer);
|
||||
const notes = toText(item.notes);
|
||||
|
||||
if (
|
||||
!name &&
|
||||
!role &&
|
||||
!publicMask &&
|
||||
!hiddenHook &&
|
||||
!relationToPlayer &&
|
||||
!notes
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-character', name || role || publicMask, index),
|
||||
name,
|
||||
role,
|
||||
publicMask,
|
||||
hiddenHook,
|
||||
relationToPlayer,
|
||||
notes,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreatorLandmarkSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorLandmarkSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const purpose = toText(item.purpose);
|
||||
const mood = toText(item.mood);
|
||||
const secret = toText(item.secret);
|
||||
|
||||
if (!name && !purpose && !mood && !secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-landmark', name || purpose || mood, index),
|
||||
name,
|
||||
purpose,
|
||||
mood,
|
||||
secret,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnchorArray<T>(
|
||||
value: unknown,
|
||||
normalizer: (value: unknown, index: number) => T | null,
|
||||
maxCount: number,
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item, index) => normalizer(item, index))
|
||||
.filter((item): item is T => Boolean(item))
|
||||
.slice(0, maxCount);
|
||||
}
|
||||
|
||||
export function createEmptyCustomWorldCreatorIntent(
|
||||
sourceMode: CustomWorldCreatorInputMode = 'freeform',
|
||||
): CustomWorldCreatorIntent {
|
||||
return {
|
||||
sourceMode,
|
||||
rawSettingText: '',
|
||||
worldHook: '',
|
||||
themeKeywords: [],
|
||||
toneDirectives: [],
|
||||
playerPremise: '',
|
||||
openingSituation: '',
|
||||
coreConflicts: [],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: [],
|
||||
forbiddenDirectives: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldCreatorIntent(
|
||||
value: unknown,
|
||||
fallbackMode: CustomWorldCreatorInputMode = 'freeform',
|
||||
): CustomWorldCreatorIntent | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const sourceMode =
|
||||
item.sourceMode === 'card' || item.sourceMode === 'freeform'
|
||||
? item.sourceMode
|
||||
: fallbackMode;
|
||||
const rawSettingText = toText(item.rawSettingText);
|
||||
const worldHook = toText(item.worldHook);
|
||||
const playerPremise = toText(item.playerPremise);
|
||||
const openingSituation = toText(item.openingSituation);
|
||||
const themeKeywords = toStringArray(item.themeKeywords, 8);
|
||||
const toneDirectives = toStringArray(item.toneDirectives, 8);
|
||||
const coreConflicts = toStringArray(item.coreConflicts, 6);
|
||||
const iconicElements = toStringArray(item.iconicElements, 8);
|
||||
const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8);
|
||||
const keyFactions = normalizeAnchorArray(
|
||||
item.keyFactions,
|
||||
normalizeCreatorFactionSeed,
|
||||
6,
|
||||
);
|
||||
const keyCharacters = normalizeAnchorArray(
|
||||
item.keyCharacters,
|
||||
normalizeCreatorCharacterSeed,
|
||||
8,
|
||||
);
|
||||
const keyLandmarks = normalizeAnchorArray(
|
||||
item.keyLandmarks,
|
||||
normalizeCreatorLandmarkSeed,
|
||||
8,
|
||||
);
|
||||
|
||||
if (
|
||||
!rawSettingText &&
|
||||
!worldHook &&
|
||||
themeKeywords.length === 0 &&
|
||||
toneDirectives.length === 0 &&
|
||||
!playerPremise &&
|
||||
!openingSituation &&
|
||||
coreConflicts.length === 0 &&
|
||||
keyFactions.length === 0 &&
|
||||
keyCharacters.length === 0 &&
|
||||
keyLandmarks.length === 0 &&
|
||||
iconicElements.length === 0 &&
|
||||
forbiddenDirectives.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceMode,
|
||||
rawSettingText,
|
||||
worldHook,
|
||||
themeKeywords,
|
||||
toneDirectives,
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
coreConflicts,
|
||||
keyFactions,
|
||||
keyCharacters,
|
||||
keyLandmarks,
|
||||
iconicElements,
|
||||
forbiddenDirectives,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldLockState(
|
||||
value: unknown,
|
||||
): CustomWorldLockState {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
worldLockedFields: [],
|
||||
lockedCharacterIds: [],
|
||||
lockedLandmarkIds: [],
|
||||
lockedConflictIds: [],
|
||||
lockedFactionIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
return {
|
||||
worldLockedFields: toStringArray(item.worldLockedFields, 12),
|
||||
lockedCharacterIds: toStringArray(item.lockedCharacterIds, 20),
|
||||
lockedLandmarkIds: toStringArray(item.lockedLandmarkIds, 20),
|
||||
lockedConflictIds: toStringArray(item.lockedConflictIds, 20),
|
||||
lockedFactionIds: toStringArray(item.lockedFactionIds, 20),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveCustomWorldLockStateFromIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
): CustomWorldLockState {
|
||||
return {
|
||||
worldLockedFields: [],
|
||||
lockedCharacterIds:
|
||||
intent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
lockedLandmarkIds:
|
||||
intent?.keyLandmarks
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
lockedConflictIds: [],
|
||||
lockedFactionIds:
|
||||
intent?.keyFactions
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMeaningfulCustomWorldCreatorIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
intent &&
|
||||
(
|
||||
intent.rawSettingText ||
|
||||
intent.worldHook ||
|
||||
intent.themeKeywords.length > 0 ||
|
||||
intent.toneDirectives.length > 0 ||
|
||||
intent.playerPremise ||
|
||||
intent.openingSituation ||
|
||||
intent.coreConflicts.length > 0 ||
|
||||
intent.keyFactions.length > 0 ||
|
||||
intent.keyCharacters.length > 0 ||
|
||||
intent.keyLandmarks.length > 0 ||
|
||||
intent.iconicElements.length > 0 ||
|
||||
intent.forbiddenDirectives.length > 0
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnchorLine(label: string, content: string) {
|
||||
return content ? `${label}:${content}` : '';
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentDisplayText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = [
|
||||
intent?.worldHook ? `世界一句话:${intent.worldHook}` : '',
|
||||
intent?.rawSettingText ? `补充设定:${intent.rawSettingText}` : '',
|
||||
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
|
||||
buildAnchorLine('世界气质', intent?.toneDirectives.join('、') || ''),
|
||||
buildAnchorLine('玩家是谁', intent?.playerPremise || ''),
|
||||
buildAnchorLine('开局处境', intent?.openingSituation || ''),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
|
||||
buildAnchorLine(
|
||||
'关键势力',
|
||||
intent?.keyFactions
|
||||
.map((entry) =>
|
||||
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键角色',
|
||||
intent?.keyCharacters
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.role,
|
||||
entry.publicMask,
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键地点',
|
||||
intent?.keyLandmarks
|
||||
.map((entry) =>
|
||||
[entry.name, entry.purpose, entry.mood].filter(Boolean).join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
|
||||
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
|
||||
].filter(Boolean);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentGenerationText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections = [
|
||||
buildAnchorLine('世界核心命题', intent?.worldHook || ''),
|
||||
buildAnchorLine('补充设定原文', intent?.rawSettingText || ''),
|
||||
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
|
||||
buildAnchorLine('情绪与气质', intent?.toneDirectives.join('、') || ''),
|
||||
buildAnchorLine('玩家身份', intent?.playerPremise || ''),
|
||||
buildAnchorLine('开局处境', intent?.openingSituation || ''),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
|
||||
buildAnchorLine(
|
||||
'关键势力锚点',
|
||||
intent?.keyFactions
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.publicGoal ? `目标 ${entry.publicGoal}` : '',
|
||||
entry.tension ? `张力 ${entry.tension}` : '',
|
||||
entry.notes ? `补充 ${entry.notes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键角色锚点',
|
||||
intent?.keyCharacters
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.role ? `身份 ${entry.role}` : '',
|
||||
entry.publicMask ? `表面 ${entry.publicMask}` : '',
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
|
||||
entry.notes ? `补充 ${entry.notes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键地点锚点',
|
||||
intent?.keyLandmarks
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.purpose ? `作用 ${entry.purpose}` : '',
|
||||
entry.mood ? `氛围 ${entry.mood}` : '',
|
||||
entry.secret ? `秘密 ${entry.secret}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
|
||||
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
|
||||
].filter(Boolean);
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
|
||||
const summary = clampText(
|
||||
[
|
||||
entry.role,
|
||||
entry.publicMask,
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name || '未命名关键角色',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLandmarkAnchorSummary(entry: CreatorLandmarkSeed): LandmarkAnchor {
|
||||
const summary = clampText(
|
||||
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name || '未命名关键地点',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomWorldAnchorPackFromIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
): CustomWorldAnchorPack | null {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lockedAnchorIds = [
|
||||
...(intent?.keyCharacters.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyLandmarks.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyFactions.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
|
||||
];
|
||||
|
||||
return {
|
||||
worldSummary: clampText(
|
||||
intent?.worldHook || intent?.rawSettingText || '',
|
||||
96,
|
||||
),
|
||||
creatorIntentSummary: clampText(
|
||||
buildCustomWorldCreatorIntentDisplayText(intent),
|
||||
240,
|
||||
),
|
||||
lockedAnchorIds,
|
||||
keyConflictSummaries: intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
|
||||
keyFactionSummaries:
|
||||
intent?.keyFactions.map((entry) =>
|
||||
clampText(
|
||||
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(';'),
|
||||
72,
|
||||
),
|
||||
) ?? [],
|
||||
keyCharacterAnchors:
|
||||
intent?.keyCharacters.map((entry) => buildCharacterAnchorSummary(entry)) ?? [],
|
||||
keyLandmarkAnchors:
|
||||
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ?? [],
|
||||
motifDirectives: [
|
||||
...(intent?.themeKeywords ?? []),
|
||||
...(intent?.toneDirectives ?? []),
|
||||
...(intent?.iconicElements ?? []),
|
||||
].slice(0, 12),
|
||||
};
|
||||
}
|
||||
200
src/services/prompt.test.ts
Normal file
200
src/services/prompt.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type Character, WorldType } from '../types';
|
||||
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
|
||||
import { buildUserPrompt } from './prompt';
|
||||
import { buildSceneNarrativeDirective } from './storyEngine/sceneNarrativeDirector';
|
||||
import { buildEncounterVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '林澈',
|
||||
title: '行旅客',
|
||||
description: '一名谨慎前行的旅人。',
|
||||
backstory: '从北境一路追着旧案残线而来。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎、克制、先看局势。',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildUserPrompt', () => {
|
||||
it('does not leak full custom-world backstory on first contact', () => {
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
id: 'prompt-world',
|
||||
name: '裂潮边城',
|
||||
subtitle: '旧案回响',
|
||||
summary: '一座在裂潮与旧案回响之间摇摇欲坠的边城。',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
playerGoal: '查清边城裂潮背后的封桥旧令',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['巡边司', '潮商会'],
|
||||
coreConflicts: ['裂潮再度逼近边路', '封桥旧案再被人提起'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '向导',
|
||||
description: '熟悉裂潮边路的灰炬向导。',
|
||||
backstory: '曾在旧撤离线里失去一整支同行队。',
|
||||
personality: '谨慎寡言,先看风向再开口。',
|
||||
motivation: '想查清旧撤离线为何再次失控。',
|
||||
combatStyle: '短弓牵制后贴近补刀。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['旧撤离线', '名单'],
|
||||
tags: ['裂潮', '向导'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只说自己熟悉边路。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。', contextSnippet: '他总先谈路和风。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '梁砺',
|
||||
title: '断桥巡守',
|
||||
role: '巡守',
|
||||
description: '守着断桥与旧哨火的巡守。',
|
||||
backstory: '旧案爆发时,他是最后一个封桥的人。',
|
||||
personality: '警觉直接,不喜欢绕弯。',
|
||||
motivation: '不想让旧案再次借裂潮翻上来。',
|
||||
combatStyle: '长兵先压,再卡住路口。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['封桥', '旧哨火'],
|
||||
tags: ['巡守', '断桥'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只承认自己还在守桥。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '旧哨铜钥',
|
||||
category: '稀有品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '钥身磨得发亮。',
|
||||
tags: ['旧哨火'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '断桥旧哨',
|
||||
description: '旧哨火和断桥一起守着边城北口。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
'玩家想要一个裂潮边城与旧案回响交织的世界。',
|
||||
);
|
||||
|
||||
const npc = profile.storyNpcs[0]!;
|
||||
const visibilitySlice = buildEncounterVisibilitySlice({
|
||||
narrativeProfile: npc.narrativeProfile,
|
||||
backstoryReveal: npc.backstoryReveal,
|
||||
disclosureStage: 'guarded',
|
||||
isFirstMeaningfulContact: true,
|
||||
seenBackstoryChapterIds: [],
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
},
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
});
|
||||
const prompt = buildUserPrompt(
|
||||
WorldType.CUSTOM,
|
||||
createCharacter(),
|
||||
[],
|
||||
[],
|
||||
{
|
||||
playerHp: 30,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 10,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
sceneName: '断桥旧哨',
|
||||
sceneDescription: '风里尽是旧哨火和潮声。',
|
||||
encounterKind: 'npc',
|
||||
encounterId: npc.id,
|
||||
encounterName: npc.name,
|
||||
encounterDescription: npc.description,
|
||||
encounterContext: npc.role,
|
||||
encounterAffinity: npc.initialAffinity,
|
||||
encounterAffinityText: '对你仍有戒备,也在观察你会怎么试探。',
|
||||
encounterDisclosureStage: 'guarded',
|
||||
encounterWarmthStage: 'distant',
|
||||
encounterAnswerMode: 'situational_only',
|
||||
encounterAllowedTopics: ['眼前危险', '现场判断', '模糊钩子'],
|
||||
encounterBlockedTopics: ['完整来历', '真正目标', '旧事全貌'],
|
||||
isFirstMeaningfulContact: true,
|
||||
firstContactRelationStance: 'guarded',
|
||||
recentSharedEvent: '你们还只是刚刚真正把话对上。',
|
||||
talkPriority: '优先谈桥口、来意和眼前压力,不要直接摊开旧案全貌。',
|
||||
encounterCustomProfile: npc,
|
||||
encounterNarrativeProfile: npc.narrativeProfile,
|
||||
visibilitySlice,
|
||||
sceneNarrativeDirective: buildSceneNarrativeDirective({
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
sceneName: '断桥旧哨',
|
||||
encounterId: npc.id,
|
||||
encounterName: npc.name,
|
||||
recentActions: [],
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
visibilitySlice,
|
||||
encounterNarrativeProfile: npc.narrativeProfile,
|
||||
disclosureStage: 'guarded',
|
||||
isFirstMeaningfulContact: true,
|
||||
affinity: npc.initialAffinity,
|
||||
}),
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
customWorldProfile: profile,
|
||||
},
|
||||
);
|
||||
|
||||
expect(prompt).toContain(npc.narrativeProfile?.publicMask ?? '');
|
||||
expect(prompt).toContain(npc.narrativeProfile?.immediatePressure ?? '');
|
||||
expect(prompt).not.toContain(npc.backstory);
|
||||
expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content);
|
||||
expect(prompt).not.toContain(npc.initialItems[0]!.name);
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,17 @@ import {
|
||||
resolveEncounterRecruitCharacter,
|
||||
} from '../data/characterPresets';
|
||||
import { getMonsterPresetById } from '../data/hostileNpcPresets';
|
||||
import { createSceneMonstersFromIds } from '../data/hostileNpcs';
|
||||
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
|
||||
import {
|
||||
describeConversationStyle as describeNpcConversationStyle,
|
||||
describeDisclosureStage,
|
||||
describeWarmthStage,
|
||||
} from '../data/npcInteractions';
|
||||
import { buildSceneEntityCatalogText, getScenePresetById } from '../data/scenePresets';
|
||||
import {
|
||||
buildSceneEntityCatalogText,
|
||||
getSceneHostileNpcPresetIds,
|
||||
getScenePresetById,
|
||||
} from '../data/scenePresets';
|
||||
import {
|
||||
buildFunctionCatalogText,
|
||||
getFunctionById,
|
||||
@@ -31,7 +35,7 @@ import {
|
||||
CharacterGender,
|
||||
CustomWorldProfile,
|
||||
FacingDirection,
|
||||
SceneMonster,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
@@ -147,9 +151,12 @@ function describeWorldForPrompt(world: WorldType, customWorldProfile?: CustomWor
|
||||
: describeWorld(world);
|
||||
}
|
||||
|
||||
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
|
||||
return customWorldProfile
|
||||
? `自定义世界补充档案:\n${buildCustomWorldReferenceText(customWorldProfile)}`
|
||||
function describeCustomWorldSection(context: StoryGenerationContext) {
|
||||
return context.customWorldProfile
|
||||
? `自定义世界补充档案:\n${buildCustomWorldReferenceText(context.customWorldProfile, {
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
highlightNpcNames: context.encounterName ? [context.encounterName] : [],
|
||||
})}`
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -292,6 +299,316 @@ function describeFirstMeaningfulContactDirective(context: StoryGenerationContext
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function hasVisibilityFact(
|
||||
slice: StoryGenerationContext['visibilitySlice'],
|
||||
factId: string,
|
||||
) {
|
||||
return Boolean(slice?.sayableFactIds.includes(factId));
|
||||
}
|
||||
|
||||
function describeVisibilityFactLabel(factId: string) {
|
||||
if (factId === 'publicMask') return '公开面';
|
||||
if (factId === 'firstContactMask') return '首遇遮挡说辞';
|
||||
if (factId === 'visibleLine') return '表层线';
|
||||
if (factId === 'immediatePressure') return '当前压力';
|
||||
if (factId === 'contradiction') return '说辞错位';
|
||||
if (factId === 'hiddenLine') return '隐藏线';
|
||||
if (factId === 'debtOrBurden') return '债务或负担';
|
||||
if (factId === 'taboo') return '禁区';
|
||||
if (factId.startsWith('thread:')) return '故事线程索引';
|
||||
if (factId.startsWith('scar:')) return '旧痕索引';
|
||||
if (factId.startsWith('chapter:')) return '已解锁背景摘要';
|
||||
if (factId.startsWith('reaction:')) return '反应钩子';
|
||||
return factId;
|
||||
}
|
||||
|
||||
function describeVisibilitySliceSection(context: StoryGenerationContext) {
|
||||
if (!context.visibilitySlice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sayable = context.visibilitySlice.sayableFactIds
|
||||
.map(describeVisibilityFactLabel)
|
||||
.join('、');
|
||||
const inferred = context.visibilitySlice.inferredFactIds
|
||||
.map(describeVisibilityFactLabel)
|
||||
.join('、');
|
||||
const forbidden = context.visibilitySlice.forbiddenFactIds
|
||||
.map(describeVisibilityFactLabel)
|
||||
.join('、');
|
||||
|
||||
return [
|
||||
'当前信息可见性切片:',
|
||||
sayable ? `- 可直接进入本轮上下文:${sayable}` : null,
|
||||
inferred ? `- 只能写成推测或缝隙:${inferred}` : null,
|
||||
forbidden ? `- 禁止直接说破:${forbidden}` : null,
|
||||
...(context.visibilitySlice.misdirectionHints ?? []).map(
|
||||
(hint) => `- 误导/遮挡提示:${hint}`,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeSceneNarrativeDirectiveSection(context: StoryGenerationContext) {
|
||||
if (!context.sceneNarrativeDirective) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directive = context.sceneNarrativeDirective;
|
||||
return [
|
||||
'当前场景导演指令:',
|
||||
`- 主压力:${directive.primaryPressure}`,
|
||||
`- 激活线程:${directive.activeThreadIds.join('、') || '暂无'}`,
|
||||
`- 揭示预算:${directive.revealBudget}`,
|
||||
`- 情绪节奏:${directive.emotionalCadence}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeRecentCompanionReactionsSection(context: StoryGenerationContext) {
|
||||
if (!context.recentCompanionReactions?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'最近一次同行反应:',
|
||||
...context.recentCompanionReactions.slice(-3).map(
|
||||
(reaction) =>
|
||||
`- ${reaction.characterId} / ${reaction.reactionType}:${reaction.reason}`,
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeRecentCarrierEchoesSection(context: StoryGenerationContext) {
|
||||
if (!context.recentCarrierEchoes?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'最近叙事载体回响:',
|
||||
...context.recentCarrierEchoes.slice(0, 4).map((echo) => `- ${echo}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeCampaignSection(context: StoryGenerationContext) {
|
||||
if (!context.campaignState && !context.actState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前战役状态:',
|
||||
context.campaignState
|
||||
? `- Campaign:${context.campaignState.title}(Act ${context.campaignState.currentActIndex + 1})`
|
||||
: null,
|
||||
context.actState
|
||||
? `- 当前 Act:${context.actState.title} / ${context.actState.status} / ${context.actState.theme}`
|
||||
: null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeConsequenceLedgerSection(context: StoryGenerationContext) {
|
||||
if (!context.consequenceLedger?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'关键后果账本:',
|
||||
...context.consequenceLedger.slice(-5).map(
|
||||
(record) => `- ${record.title}(权重 ${record.weight}):${record.summary}`,
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeConstraintSection(context: StoryGenerationContext) {
|
||||
if (!context.authorialConstraintPack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pack = context.authorialConstraintPack;
|
||||
return [
|
||||
'作者性约束:',
|
||||
`- 基调规则:${pack.toneRules.join('、') || '暂无'}`,
|
||||
`- 禁止模式:${pack.noGoPatterns.join('、') || '暂无'}`,
|
||||
`- 必须回收:${pack.requiredPayoffs.join('、') || '暂无'}`,
|
||||
context.branchBudgetPressure
|
||||
? `- 当前分支预算压力:${context.branchBudgetPressure}`
|
||||
: null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describePackSection(context: StoryGenerationContext) {
|
||||
if (!context.activeScenarioPack && !context.activeCampaignPack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前内容包:',
|
||||
context.activeScenarioPack
|
||||
? `- Scenario Pack:${context.activeScenarioPack.title} v${context.activeScenarioPack.version}`
|
||||
: null,
|
||||
context.activeCampaignPack
|
||||
? `- Campaign Pack:${context.activeCampaignPack.title} / ${context.activeCampaignPack.authoringStyle}`
|
||||
: null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describePlayerStyleSection(context: StoryGenerationContext) {
|
||||
if (!context.playerStyleProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前玩家画像:',
|
||||
`- 风格:${context.playerStyleProfile.dominantStyle}`,
|
||||
`- 倾向:剧情 ${context.playerStyleProfile.preferenceWeights.story} / 探索 ${context.playerStyleProfile.preferenceWeights.exploration} / 战斗 ${context.playerStyleProfile.preferenceWeights.combat} / 同伴 ${context.playerStyleProfile.preferenceWeights.companion} / 收集 ${context.playerStyleProfile.preferenceWeights.collection}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeNarrativeQaSection(context: StoryGenerationContext) {
|
||||
if (!context.narrativeQaReport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前叙事 QA:',
|
||||
`- 摘要:${context.narrativeQaReport.summary}`,
|
||||
...context.narrativeQaReport.issues.slice(0, 4).map(
|
||||
(issue) => `- ${issue.severity}/${issue.category}:${issue.summary}`,
|
||||
),
|
||||
context.releaseGateReport
|
||||
? `- Release Gate:${context.releaseGateReport.status} / ${context.releaseGateReport.summary}`
|
||||
: null,
|
||||
context.simulationRunResults?.length
|
||||
? `- Simulation 覆盖:${context.simulationRunResults.length} 条`
|
||||
: null,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeChapterSection(context: StoryGenerationContext) {
|
||||
if (!context.chapterState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前章节状态:',
|
||||
`- 标题:${context.chapterState.title}`,
|
||||
`- 阶段:${context.chapterState.stage}`,
|
||||
`- 主题:${context.chapterState.theme}`,
|
||||
`- 摘要:${context.chapterState.chapterSummary}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeJourneyBeatSection(context: StoryGenerationContext) {
|
||||
if (!context.journeyBeat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前旅程段落:',
|
||||
`- 类型:${context.journeyBeat.beatType}`,
|
||||
`- 标题:${context.journeyBeat.title}`,
|
||||
`- 情绪目标:${context.journeyBeat.emotionalGoal}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeCampEventSection(context: StoryGenerationContext) {
|
||||
if (!context.currentCampEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前可触发营地/旅途事件:',
|
||||
`- 标题:${context.currentCampEvent.title}`,
|
||||
`- 类型:${context.currentCampEvent.eventType}`,
|
||||
`- 原因:${context.currentCampEvent.triggerReason}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeSetpieceSection(context: StoryGenerationContext) {
|
||||
if (!context.setpieceDirective) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前高光导演指令:',
|
||||
`- 类型:${context.setpieceDirective.setpieceType}`,
|
||||
`- 标题:${context.setpieceDirective.title}`,
|
||||
`- 核心问题:${context.setpieceDirective.dramaticQuestion}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeWorldMutationSection(context: StoryGenerationContext) {
|
||||
if (!context.recentWorldMutations?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'最近世界变化:',
|
||||
...context.recentWorldMutations.slice(-4).map(
|
||||
(mutation) =>
|
||||
`- ${mutation.mutationType} / ${mutation.targetId}:${mutation.reason}`,
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeFactionTensionSection(context: StoryGenerationContext) {
|
||||
if (!context.recentFactionTensionStates?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'当前阵营温度:',
|
||||
...context.recentFactionTensionStates.slice(0, 4).map(
|
||||
(tension) =>
|
||||
`- ${tension.factionId} / 温度 ${tension.temperature}:${tension.pressureSummary}`,
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeChronicleSection(context: StoryGenerationContext) {
|
||||
if (!context.recentChronicleSummary?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `近期旅程回顾:\n${context.recentChronicleSummary}`;
|
||||
}
|
||||
|
||||
function buildCustomEncounterBackstoryLines(context: StoryGenerationContext) {
|
||||
const encounterCustomProfile = context.encounterCustomProfile;
|
||||
const narrativeProfile = context.encounterNarrativeProfile;
|
||||
if (!encounterCustomProfile || !narrativeProfile) {
|
||||
return ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (hasVisibilityFact(context.visibilitySlice, 'publicMask')) {
|
||||
lines.push(narrativeProfile.publicMask);
|
||||
}
|
||||
if (hasVisibilityFact(context.visibilitySlice, 'firstContactMask')) {
|
||||
lines.push(narrativeProfile.firstContactMask);
|
||||
}
|
||||
if (hasVisibilityFact(context.visibilitySlice, 'visibleLine')) {
|
||||
lines.push(narrativeProfile.visibleLine);
|
||||
}
|
||||
if (hasVisibilityFact(context.visibilitySlice, 'immediatePressure')) {
|
||||
lines.push(narrativeProfile.immediatePressure);
|
||||
}
|
||||
|
||||
(encounterCustomProfile.backstoryReveal?.chapters ?? []).forEach((chapter) => {
|
||||
if (hasVisibilityFact(context.visibilitySlice, `chapter:${chapter.id}`)) {
|
||||
const snippet =
|
||||
chapter.contextSnippet || chapter.teaser || encounterCustomProfile.backstoryReveal?.publicSummary;
|
||||
if (snippet) {
|
||||
lines.push(snippet);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return lines.length > 0
|
||||
? [...new Set(lines.filter(Boolean))]
|
||||
: [encounterCustomProfile.backstoryReveal?.publicSummary ?? narrativeProfile.publicMask];
|
||||
}
|
||||
|
||||
function describeBackstoryContext(label: string, snippets: string[]) {
|
||||
const normalized = snippets
|
||||
.map(snippet => snippet.trim())
|
||||
@@ -408,7 +725,7 @@ function describeSkills(character: Character, context: StoryGenerationContext) {
|
||||
function describeFrontEntity(
|
||||
world: WorldType,
|
||||
context: StoryGenerationContext,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, context.customWorldProfile);
|
||||
if (context.encounterName) {
|
||||
@@ -427,37 +744,20 @@ function describeFrontEntity(
|
||||
|
||||
const attributeProfile = encounterCharacter
|
||||
? resolveCharacterAttributeProfile(encounterCharacter, world, context.customWorldProfile)
|
||||
: inferEncounterAttributeProfile(world, context, `encounter:${context.encounterName}`, [
|
||||
encounterCustomProfile?.personality ||
|
||||
inferEncounterPersonality(
|
||||
context.encounterContext,
|
||||
context.encounterDescription,
|
||||
),
|
||||
encounterCustomProfile?.backstory ?? '',
|
||||
encounterCustomProfile?.motivation ?? '',
|
||||
encounterCustomProfile?.combatStyle ?? '',
|
||||
...(encounterCustomProfile?.relationshipHooks ?? []),
|
||||
...(encounterCustomProfile?.tags ?? []),
|
||||
...(encounterCustomProfile?.backstoryReveal?.chapters ?? []).flatMap(
|
||||
(chapter) => [
|
||||
chapter.title,
|
||||
chapter.teaser,
|
||||
chapter.content,
|
||||
chapter.contextSnippet,
|
||||
],
|
||||
),
|
||||
...(encounterCustomProfile?.skills ?? []).flatMap((skill) => [
|
||||
skill.name,
|
||||
skill.summary,
|
||||
skill.style,
|
||||
]),
|
||||
...(encounterCustomProfile?.initialItems ?? []).flatMap((item) => [
|
||||
item.name,
|
||||
item.category,
|
||||
item.description,
|
||||
...item.tags,
|
||||
]),
|
||||
]);
|
||||
: inferEncounterAttributeProfile(world, context, `encounter:${context.encounterName}`, [
|
||||
encounterCustomProfile?.personality ||
|
||||
inferEncounterPersonality(
|
||||
context.encounterContext,
|
||||
context.encounterDescription,
|
||||
),
|
||||
context.encounterNarrativeProfile?.publicMask ?? '',
|
||||
context.encounterNarrativeProfile?.visibleLine ?? '',
|
||||
context.encounterNarrativeProfile?.immediatePressure ?? '',
|
||||
...(context.visibilitySlice?.sayableFactIds.includes('contradiction')
|
||||
&& context.encounterNarrativeProfile?.contradiction
|
||||
? [context.encounterNarrativeProfile.contradiction]
|
||||
: []),
|
||||
]);
|
||||
const title =
|
||||
encounterCharacter?.title ??
|
||||
encounterCustomProfile?.title ??
|
||||
@@ -484,17 +784,7 @@ function describeFrontEntity(
|
||||
world,
|
||||
)
|
||||
: encounterCustomProfile
|
||||
? [
|
||||
encounterCustomProfile.backstoryReveal?.publicSummary ??
|
||||
'对方有自己的来路与立场。',
|
||||
encounterCustomProfile.backstory,
|
||||
...(
|
||||
encounterCustomProfile.backstoryReveal?.chapters.map(
|
||||
(chapter) =>
|
||||
chapter.contextSnippet || chapter.content || chapter.teaser,
|
||||
) ?? []
|
||||
),
|
||||
].filter((line): line is string => Boolean(line))
|
||||
? buildCustomEncounterBackstoryLines(context)
|
||||
: ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
|
||||
const status = context.encounterKind === 'npc'
|
||||
? context.isFirstMeaningfulContact
|
||||
@@ -511,24 +801,37 @@ function describeFrontEntity(
|
||||
`- 描述:${description}`,
|
||||
...describeBackstoryContext('背景', backstoryLines).map(line => `- ${line}`),
|
||||
`- 性格:${personality}`,
|
||||
encounterCustomProfile?.motivation
|
||||
context.encounterNarrativeProfile?.firstContactMask
|
||||
? `- 首遇遮挡说辞:${context.encounterNarrativeProfile.firstContactMask}`
|
||||
: null,
|
||||
context.encounterNarrativeProfile?.visibleLine
|
||||
? `- 表层线:${context.encounterNarrativeProfile.visibleLine}`
|
||||
: null,
|
||||
context.encounterNarrativeProfile?.immediatePressure
|
||||
? `- 当前压力:${context.encounterNarrativeProfile.immediatePressure}`
|
||||
: null,
|
||||
context.visibilitySlice?.inferredFactIds.includes('contradiction') &&
|
||||
context.encounterNarrativeProfile?.contradiction
|
||||
? `- 可写成推测的错位:${context.encounterNarrativeProfile.contradiction}`
|
||||
: null,
|
||||
!context.encounterNarrativeProfile && encounterCustomProfile?.motivation
|
||||
? `- 当前动机:${encounterCustomProfile.motivation}`
|
||||
: null,
|
||||
encounterCustomProfile?.combatStyle
|
||||
!context.encounterNarrativeProfile && encounterCustomProfile?.combatStyle
|
||||
? `- 战斗风格:${encounterCustomProfile.combatStyle}`
|
||||
: null,
|
||||
encounterCustomProfile?.relationshipHooks?.length
|
||||
!context.encounterNarrativeProfile && encounterCustomProfile?.relationshipHooks?.length
|
||||
? `- 关系切入口:${encounterCustomProfile.relationshipHooks.join('、')}`
|
||||
: null,
|
||||
encounterCustomProfile?.tags?.length
|
||||
!context.encounterNarrativeProfile && encounterCustomProfile?.tags?.length
|
||||
? `- 标签:${encounterCustomProfile.tags.join('、')}`
|
||||
: null,
|
||||
encounterCustomProfile?.skills?.length
|
||||
!context.encounterNarrativeProfile && encounterCustomProfile?.skills?.length
|
||||
? `- 自定义技能:${encounterCustomProfile.skills
|
||||
.map((skill) => `${skill.name}(${skill.style}):${skill.summary}`)
|
||||
.join(';')}`
|
||||
: null,
|
||||
encounterCustomProfile?.initialItems?.length
|
||||
!context.encounterNarrativeProfile && encounterCustomProfile?.initialItems?.length
|
||||
? `- 随身物:${encounterCustomProfile.initialItems
|
||||
.map(
|
||||
(item) =>
|
||||
@@ -602,7 +905,7 @@ function describePlayerState(world: WorldType, character: Character, context: St
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeMonsters(monsters: SceneMonster[]) {
|
||||
function describeMonsters(monsters: SceneHostileNpc[]) {
|
||||
if (monsters.length === 0) {
|
||||
return '当前没有可见敌对目标。';
|
||||
}
|
||||
@@ -646,7 +949,7 @@ function describeStoryHistory(history: StoryMoment[]) {
|
||||
function _buildResolvedUserPrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
choice?: string,
|
||||
@@ -674,12 +977,12 @@ function _buildResolvedUserPrompt(
|
||||
const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions
|
||||
&& Boolean(context.openingCampBackground?.trim())
|
||||
&& Boolean(context.openingCampDialogue?.trim());
|
||||
const sceneMonsterIds = scene?.monsterIds ?? [];
|
||||
const sceneMonsterIds = getSceneHostileNpcPresetIds(scene);
|
||||
const battleCatalog = scene
|
||||
? buildFunctionCatalogText({
|
||||
...functionContext,
|
||||
inBattle: true,
|
||||
monsters: createSceneMonstersFromIds(world, sceneMonsterIds, context.playerX),
|
||||
monsters: createSceneHostileNpcsFromIds(world, sceneMonsterIds, context.playerX),
|
||||
})
|
||||
: '';
|
||||
const idleCatalog = buildFunctionCatalogText({
|
||||
@@ -694,9 +997,26 @@ function _buildResolvedUserPrompt(
|
||||
const sections = [
|
||||
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
|
||||
describePlayerState(world, character, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
describeCustomWorldSection(context),
|
||||
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
describeFrontEntity(world, context, monsters),
|
||||
describePackSection(context),
|
||||
describePlayerStyleSection(context),
|
||||
describeCampaignSection(context),
|
||||
describeChapterSection(context),
|
||||
describeJourneyBeatSection(context),
|
||||
describeSceneNarrativeDirectiveSection(context),
|
||||
describeVisibilitySliceSection(context),
|
||||
describeConsequenceLedgerSection(context),
|
||||
describeConstraintSection(context),
|
||||
describeCampEventSection(context),
|
||||
describeSetpieceSection(context),
|
||||
describeRecentCompanionReactionsSection(context),
|
||||
describeRecentCarrierEchoesSection(context),
|
||||
describeWorldMutationSection(context),
|
||||
describeFactionTensionSection(context),
|
||||
describeChronicleSection(context),
|
||||
describeNarrativeQaSection(context),
|
||||
describeConversationSituationDirective(context),
|
||||
describeEncounterConversationDirective(context),
|
||||
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
|
||||
@@ -824,7 +1144,7 @@ function describeProvidedOptions(options: StoryOption[]) {
|
||||
function buildCatalogAwareUserPrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
choice?: string,
|
||||
@@ -856,7 +1176,7 @@ function buildCatalogAwareUserPrompt(
|
||||
? buildFunctionCatalogText({
|
||||
...functionContext,
|
||||
inBattle: true,
|
||||
monsters: createSceneMonstersFromIds(world, scene?.monsterIds ?? [], context.playerX),
|
||||
monsters: createSceneHostileNpcsFromIds(world, getSceneHostileNpcPresetIds(scene), context.playerX),
|
||||
})
|
||||
: '';
|
||||
const idleCatalog = buildFunctionCatalogText({
|
||||
@@ -871,9 +1191,26 @@ function buildCatalogAwareUserPrompt(
|
||||
const sections = [
|
||||
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
|
||||
describePlayerState(world, character, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
describeCustomWorldSection(context),
|
||||
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
describeFrontEntity(world, context, monsters),
|
||||
describePackSection(context),
|
||||
describePlayerStyleSection(context),
|
||||
describeCampaignSection(context),
|
||||
describeChapterSection(context),
|
||||
describeJourneyBeatSection(context),
|
||||
describeSceneNarrativeDirectiveSection(context),
|
||||
describeVisibilitySliceSection(context),
|
||||
describeConsequenceLedgerSection(context),
|
||||
describeConstraintSection(context),
|
||||
describeCampEventSection(context),
|
||||
describeSetpieceSection(context),
|
||||
describeRecentCompanionReactionsSection(context),
|
||||
describeRecentCarrierEchoesSection(context),
|
||||
describeWorldMutationSection(context),
|
||||
describeFactionTensionSection(context),
|
||||
describeChronicleSection(context),
|
||||
describeNarrativeQaSection(context),
|
||||
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
|
||||
describeSkills(character, context),
|
||||
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
|
||||
@@ -940,7 +1277,7 @@ function buildCatalogAwareUserPrompt(
|
||||
export function buildUserPrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
choice?: string,
|
||||
@@ -954,7 +1291,7 @@ function buildResolvedNpcChatDialoguePrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounterName: string,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
@@ -963,8 +1300,26 @@ function buildResolvedNpcChatDialoguePrompt(
|
||||
return [
|
||||
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
|
||||
describePlayerState(world, character, context),
|
||||
describeCustomWorldSection(context),
|
||||
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
describeFrontEntity(world, context, monsters),
|
||||
describePackSection(context),
|
||||
describePlayerStyleSection(context),
|
||||
describeCampaignSection(context),
|
||||
describeChapterSection(context),
|
||||
describeJourneyBeatSection(context),
|
||||
describeSceneNarrativeDirectiveSection(context),
|
||||
describeVisibilitySliceSection(context),
|
||||
describeConsequenceLedgerSection(context),
|
||||
describeConstraintSection(context),
|
||||
describeCampEventSection(context),
|
||||
describeSetpieceSection(context),
|
||||
describeRecentCompanionReactionsSection(context),
|
||||
describeRecentCarrierEchoesSection(context),
|
||||
describeWorldMutationSection(context),
|
||||
describeFactionTensionSection(context),
|
||||
describeChronicleSection(context),
|
||||
describeNarrativeQaSection(context),
|
||||
`当前面前实体性别:${describeGender(getEncounterGender(context))}`,
|
||||
describeSkills(character, context),
|
||||
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
|
||||
@@ -988,7 +1343,7 @@ function buildNpcChatDialoguePrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounterName: string,
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
@@ -1010,7 +1365,7 @@ export function buildStrictNpcChatDialoguePrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: { npcName: string },
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
@@ -1030,17 +1385,35 @@ export function buildNpcRecruitDialoguePrompt(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: { npcName: string },
|
||||
monsters: SceneMonster[],
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
invitationText: string,
|
||||
recruitSummary: string,
|
||||
) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
|
||||
describePlayerState(world, character, context),
|
||||
describeCustomWorldSection(context),
|
||||
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
describeFrontEntity(world, context, monsters),
|
||||
describePackSection(context),
|
||||
describePlayerStyleSection(context),
|
||||
describeCampaignSection(context),
|
||||
describeChapterSection(context),
|
||||
describeJourneyBeatSection(context),
|
||||
describeSceneNarrativeDirectiveSection(context),
|
||||
describeVisibilitySliceSection(context),
|
||||
describeConsequenceLedgerSection(context),
|
||||
describeConstraintSection(context),
|
||||
describeCampEventSection(context),
|
||||
describeSetpieceSection(context),
|
||||
describeRecentCompanionReactionsSection(context),
|
||||
describeRecentCarrierEchoesSection(context),
|
||||
describeWorldMutationSection(context),
|
||||
describeFactionTensionSection(context),
|
||||
describeChronicleSection(context),
|
||||
describeNarrativeQaSection(context),
|
||||
`当前招募对象性别:${describeGender(getEncounterGender(context))}`,
|
||||
describeSkills(character, context),
|
||||
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
|
||||
|
||||
@@ -14,6 +14,12 @@ import {requestChatMessageContent} from './llmClient';
|
||||
import {parseJsonResponseText} from './llmParsers';
|
||||
import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt';
|
||||
import type {QuestIntent, QuestPreviewRequest} from './questTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from './storyEngine/actorNarrativeProfile';
|
||||
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
|
||||
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
|
||||
|
||||
const QUEST_DIRECTOR_TIMEOUT_MS = 12000;
|
||||
|
||||
@@ -33,6 +39,41 @@ function coerceStringArray(value: unknown, fallback: string[]) {
|
||||
return items.length > 0 ? items : fallback;
|
||||
}
|
||||
|
||||
function resolveIssuerNarrativeProfile(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) {
|
||||
if (encounter.narrativeProfile) {
|
||||
return encounter.narrativeProfile;
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
)
|
||||
?? state.customWorldProfile.playableNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent {
|
||||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||||
return fallback;
|
||||
@@ -92,10 +133,12 @@ export function buildQuestGenerationContextFromState(params: {
|
||||
const {state, encounter} = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const issuerState = state.npcStates[issuerNpcId];
|
||||
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(state, encounter);
|
||||
|
||||
return {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
actState: state.storyEngineMemory?.actState ?? null,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
currentSceneDescription: state.currentScenePreset?.description ?? null,
|
||||
@@ -103,8 +146,13 @@ export function buildQuestGenerationContextFromState(params: {
|
||||
issuerNpcName: encounter.npcName,
|
||||
issuerNpcContext: encounter.context,
|
||||
issuerAffinity: issuerState?.affinity ?? 0,
|
||||
issuerNarrativeProfile,
|
||||
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
|
||||
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
|
||||
activeThreadIds:
|
||||
state.storyEngineMemory?.activeThreadIds?.slice(0, 4)
|
||||
?? issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4)
|
||||
?? [],
|
||||
encounterKind: encounter.kind ?? 'npc',
|
||||
currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0,
|
||||
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {QuestGenerationContext} from './aiTypes';
|
||||
import type {QuestOpportunity, QuestSceneSnapshot} from './questTypes';
|
||||
import { buildQuestVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
@@ -64,6 +65,51 @@ function summarizeScene(scene: QuestSceneSnapshot | null, context: QuestGenerati
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeActiveThreads(context: QuestGenerationContext) {
|
||||
if (!context.activeThreadIds?.length) {
|
||||
return '暂无明确激活线程';
|
||||
}
|
||||
|
||||
const storyGraph = context.customWorldProfile?.storyGraph;
|
||||
const labels = context.activeThreadIds.map((threadId) =>
|
||||
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
|
||||
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
||||
);
|
||||
|
||||
return labels.join('、');
|
||||
}
|
||||
|
||||
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
const profile = context.issuerNarrativeProfile;
|
||||
if (!profile) {
|
||||
return '暂无额外叙事档案';
|
||||
}
|
||||
|
||||
return [
|
||||
`公开面:${profile.publicMask}`,
|
||||
`表层线:${profile.visibleLine}`,
|
||||
`当前压力:${profile.immediatePressure}`,
|
||||
profile.reactionHooks.length > 0
|
||||
? `反应钩子:${profile.reactionHooks.join('、')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function summarizeQuestVisibility(context: QuestGenerationContext) {
|
||||
const slice = buildQuestVisibilitySlice({
|
||||
issuerNarrativeProfile: context.issuerNarrativeProfile,
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
});
|
||||
|
||||
return [
|
||||
`可直接披露:${slice.sayableFactIds.join('、') || '无'}`,
|
||||
`只宜写成推测:${slice.inferredFactIds.join('、') || '无'}`,
|
||||
`禁止直接说破:${slice.forbiddenFactIds.join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
|
||||
只返回 JSON,不要输出 Markdown。
|
||||
|
||||
@@ -112,6 +158,9 @@ export function buildQuestIntentPrompt(params: {
|
||||
`发布者好感:${context.issuerAffinity ?? 0}`,
|
||||
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
|
||||
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
|
||||
`当前激活线程:${summarizeActiveThreads(context)}`,
|
||||
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
|
||||
`任务可见性切片:\n${summarizeQuestVisibility(context)}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
|
||||
@@ -16,7 +16,7 @@ export type QuestFailPolicy = 'never' | 'leave_scene' | 'issuer_hostile' | 'time
|
||||
|
||||
export type QuestSceneSnapshot = Pick<
|
||||
ScenePresetInfo,
|
||||
'id' | 'name' | 'hostileNpcIds' | 'monsterIds' | 'npcs' | 'treasureHints'
|
||||
'id' | 'name' | 'npcs' | 'treasureHints'
|
||||
> & {
|
||||
description?: ScenePresetInfo['description'];
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../types';
|
||||
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
|
||||
import {requestChatMessageContent} from './llmClient';
|
||||
import {parseJsonResponseText} from './llmParsers';
|
||||
import {
|
||||
@@ -64,6 +64,19 @@ function sanitizeRuntimeItemAiIntent(
|
||||
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
|
||||
? (tone as RuntimeItemAiIntent['tone'])
|
||||
: fallback.tone,
|
||||
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
|
||||
witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''),
|
||||
unfinishedBusiness: coerceString(
|
||||
intent.unfinishedBusiness,
|
||||
fallback.unfinishedBusiness ?? '',
|
||||
),
|
||||
hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''),
|
||||
reactionHooks: coerceStringArray(
|
||||
intent.reactionHooks,
|
||||
fallback.reactionHooks ?? [],
|
||||
4,
|
||||
),
|
||||
namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import {buildRuntimeItemAiPromptInput} from '../data/runtimeItemNarrative';
|
||||
import {
|
||||
buildRuntimeItemAiIntent,
|
||||
buildRuntimeItemAiPromptInput,
|
||||
} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import { buildRuntimeItemStoryFingerprint } from './storyEngine/carrierNarrativeCompiler';
|
||||
import { buildCarrierVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
|
||||
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
@@ -22,12 +27,31 @@ function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
}
|
||||
}
|
||||
|
||||
function describeCarrierFactId(factId: string) {
|
||||
if (factId === 'visibleClue') return '可见线索';
|
||||
if (factId === 'currentAppearanceReason') return '当前出现理由';
|
||||
if (factId === 'witnessMark') return '见证痕';
|
||||
if (factId === 'unresolvedQuestion') return '未完成问题';
|
||||
if (factId.startsWith('thread:')) return `线程索引(${factId.slice('thread:'.length)})`;
|
||||
return factId;
|
||||
}
|
||||
|
||||
function describePlan(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
index: number,
|
||||
) {
|
||||
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
|
||||
const fallbackIntent = buildRuntimeItemAiIntent(context, plan);
|
||||
const fallbackFingerprint = buildRuntimeItemStoryFingerprint({
|
||||
context,
|
||||
plan,
|
||||
intent: fallbackIntent,
|
||||
});
|
||||
const visibilitySlice = buildCarrierVisibilitySlice({
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
storyFingerprint: fallbackFingerprint,
|
||||
});
|
||||
|
||||
return [
|
||||
`物品 ${index + 1}`,
|
||||
@@ -39,10 +63,13 @@ function describePlan(
|
||||
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
|
||||
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
|
||||
`- 相关人物: ${promptInput.relatedNpcSummary}`,
|
||||
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
|
||||
`- 近期剧情: ${promptInput.recentStorySummary}`,
|
||||
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
|
||||
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
|
||||
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
|
||||
`- 物件可直写线索: ${visibilitySlice.sayableFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
`- 物件只宜暗写: ${visibilitySlice.inferredFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -59,7 +86,13 @@ export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival"
|
||||
"tone": "grim|mysterious|martial|ritual|survival",
|
||||
"visibleClue": "玩家第一眼能抓到的痕迹",
|
||||
"witnessMark": "它见证过什么的使用痕",
|
||||
"unfinishedBusiness": "背后仍未结清的问题",
|
||||
"hiddenHook": "更深一层但别直接讲穿的钩子",
|
||||
"reactionHooks": ["以后谁会对它起反应"],
|
||||
"namingPattern": "命名范式建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -68,6 +101,7 @@ export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- visibleClue / witnessMark / unfinishedBusiness / hiddenHook 要优先围绕当前激活线程、相关角色压力和旧痕来写。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
26
src/services/storyEngine/actPlanner.test.ts
Normal file
26
src/services/storyEngine/actPlanner.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveCurrentActState } from './actPlanner';
|
||||
|
||||
describe('actPlanner', () => {
|
||||
it('maps chapter stages to act states', () => {
|
||||
const actState = resolveCurrentActState({
|
||||
state: {
|
||||
storyEngineMemory: {
|
||||
activeThreadIds: ['thread-1'],
|
||||
},
|
||||
} as never,
|
||||
chapterState: {
|
||||
id: 'chapter-1',
|
||||
title: '封桥旧案·高潮',
|
||||
theme: '封桥旧案',
|
||||
primaryThreadIds: ['thread-1'],
|
||||
stage: 'climax',
|
||||
chapterSummary: '旧案被逼到台前。',
|
||||
},
|
||||
});
|
||||
|
||||
expect(actState?.actIndex).toBe(2);
|
||||
expect(actState?.status).toBe('finale');
|
||||
});
|
||||
});
|
||||
69
src/services/storyEngine/actPlanner.ts
Normal file
69
src/services/storyEngine/actPlanner.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ActState, ChapterState, GameState } from '../../types';
|
||||
|
||||
function resolveActIndex(chapterState: ChapterState | null | undefined) {
|
||||
if (!chapterState) return 0;
|
||||
if (chapterState.stage === 'climax' || chapterState.stage === 'aftermath') return 2;
|
||||
if (chapterState.stage === 'turning_point') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildActPlan(params: {
|
||||
state: GameState;
|
||||
}) {
|
||||
const primaryThreads = params.state.storyEngineMemory?.activeThreadIds ?? [];
|
||||
return [
|
||||
{
|
||||
id: 'act-1',
|
||||
title: '第一幕·起线',
|
||||
actIndex: 0,
|
||||
theme: '铺陈与引线',
|
||||
primaryThreadIds: primaryThreads.slice(0, 2),
|
||||
status: 'opening',
|
||||
},
|
||||
{
|
||||
id: 'act-2',
|
||||
title: '第二幕·扩张',
|
||||
actIndex: 1,
|
||||
theme: '冲突升级',
|
||||
primaryThreadIds: primaryThreads.slice(0, 3),
|
||||
status: 'midgame',
|
||||
},
|
||||
{
|
||||
id: 'act-3',
|
||||
title: '第三幕·收束',
|
||||
actIndex: 2,
|
||||
theme: '决战与余波',
|
||||
primaryThreadIds: primaryThreads.slice(0, 3),
|
||||
status: 'finale',
|
||||
},
|
||||
] satisfies ActState[];
|
||||
}
|
||||
|
||||
export function resolveCurrentActState(params: {
|
||||
state: GameState;
|
||||
chapterState?: ChapterState | null;
|
||||
}) {
|
||||
const chapterState = params.chapterState ?? params.state.chapterState ?? null;
|
||||
const actIndex = resolveActIndex(chapterState);
|
||||
const actPlan = buildActPlan(params);
|
||||
const candidate = actPlan[actIndex] ?? actPlan[0];
|
||||
if (!candidate) return null;
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
theme: chapterState?.theme ?? candidate.theme,
|
||||
primaryThreadIds: chapterState?.primaryThreadIds ?? candidate.primaryThreadIds,
|
||||
status:
|
||||
chapterState?.stage === 'opening'
|
||||
? 'opening'
|
||||
: chapterState?.stage === 'expansion'
|
||||
? 'midgame'
|
||||
: chapterState?.stage === 'turning_point'
|
||||
? 'late_game'
|
||||
: chapterState?.stage === 'climax'
|
||||
? 'finale'
|
||||
: chapterState?.stage === 'aftermath'
|
||||
? 'resolved'
|
||||
: candidate.status,
|
||||
} satisfies ActState;
|
||||
}
|
||||
206
src/services/storyEngine/actorNarrativeProfile.ts
Normal file
206
src/services/storyEngine/actorNarrativeProfile.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
ActorNarrativeProfile,
|
||||
CustomWorldRoleProfile,
|
||||
ThemePack,
|
||||
WorldStoryGraph,
|
||||
} from '../../types';
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 6) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function pickFirst(values: Array<string | null | undefined>, fallback: string) {
|
||||
const found = values.find((value) => typeof value === 'string' && value.trim());
|
||||
return found?.trim() ?? fallback;
|
||||
}
|
||||
|
||||
function findRelatedThreadIds(
|
||||
role: Pick<CustomWorldRoleProfile, 'id' | 'name' | 'role' | 'backstory' | 'motivation' | 'relationshipHooks' | 'tags'>,
|
||||
graph: WorldStoryGraph,
|
||||
) {
|
||||
const source = [
|
||||
role.name,
|
||||
role.role,
|
||||
role.backstory,
|
||||
role.motivation,
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' ');
|
||||
|
||||
return dedupeStrings(
|
||||
[...graph.visibleThreads, ...graph.hiddenThreads].flatMap((thread) => {
|
||||
if (thread.involvedActorIds.includes(role.id)) {
|
||||
return [thread.id];
|
||||
}
|
||||
|
||||
return source.includes(thread.title) || source.includes(thread.summary)
|
||||
? [thread.id]
|
||||
: [];
|
||||
}),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
function findRelatedScarIds(
|
||||
role: Pick<CustomWorldRoleProfile, 'id' | 'backstory' | 'motivation' | 'relationshipHooks' | 'tags'>,
|
||||
graph: WorldStoryGraph,
|
||||
) {
|
||||
const source = [
|
||||
role.backstory,
|
||||
role.motivation,
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' ');
|
||||
|
||||
return dedupeStrings(
|
||||
graph.scars.flatMap((scar) => {
|
||||
if (scar.relatedActorIds.includes(role.id)) {
|
||||
return [scar.id];
|
||||
}
|
||||
|
||||
return source.includes(scar.title) || source.includes(scar.publicResidue)
|
||||
? [scar.id]
|
||||
: [];
|
||||
}),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildFallbackActorNarrativeProfile(
|
||||
role: CustomWorldRoleProfile,
|
||||
graph: WorldStoryGraph,
|
||||
themePack?: ThemePack | null,
|
||||
) {
|
||||
const relatedThreadIds = (() => {
|
||||
const matched = findRelatedThreadIds(role, graph);
|
||||
if (matched.length > 0) {
|
||||
return matched;
|
||||
}
|
||||
return graph.visibleThreads[0]?.id ? [graph.visibleThreads[0].id] : [];
|
||||
})();
|
||||
const relatedScarIds = (() => {
|
||||
const matched = findRelatedScarIds(role, graph);
|
||||
if (matched.length > 0) {
|
||||
return matched;
|
||||
}
|
||||
return graph.scars[0]?.id ? [graph.scars[0].id] : [];
|
||||
})();
|
||||
const primaryThread =
|
||||
[...graph.visibleThreads, ...graph.hiddenThreads].find((thread) =>
|
||||
relatedThreadIds.includes(thread.id),
|
||||
) ?? graph.visibleThreads[0] ?? graph.hiddenThreads[0];
|
||||
const primaryScar =
|
||||
graph.scars.find((scar) => relatedScarIds.includes(scar.id)) ?? graph.scars[0];
|
||||
const fallbackRevealStyle =
|
||||
themePack?.revealStyles[0] ?? '试探式回应';
|
||||
|
||||
return {
|
||||
publicMask: pickFirst(
|
||||
[role.backstoryReveal.publicSummary, role.description, `${role.title},${role.role}`],
|
||||
`${role.name}对外只承认自己是${role.role}。`,
|
||||
),
|
||||
firstContactMask: pickFirst(
|
||||
[
|
||||
role.backstoryReveal.chapters[0]?.teaser,
|
||||
`${role.name}会先拿${role.role}的身份与眼前局势挡在前面。`,
|
||||
],
|
||||
`${role.name}会先以${fallbackRevealStyle}的方式挡开过深的问题。`,
|
||||
),
|
||||
visibleLine: pickFirst(
|
||||
[role.motivation, role.description, primaryThread?.summary],
|
||||
`${role.name}显然正在被眼前局势推着走。`,
|
||||
),
|
||||
hiddenLine: pickFirst(
|
||||
[
|
||||
role.backstoryReveal.chapters[3]?.content,
|
||||
role.backstory,
|
||||
primaryThread?.summary,
|
||||
],
|
||||
`${role.name}和${primaryThread?.title ?? '世界暗线'}之间仍有一段没说完的牵连。`,
|
||||
),
|
||||
contradiction: pickFirst(
|
||||
[
|
||||
role.backstoryReveal.chapters[1]?.teaser,
|
||||
`${role.name}嘴上把话收得很稳,但提到${role.relationshipHooks[0] ?? '旧事'}时会明显变调。`,
|
||||
],
|
||||
`${role.name}的说辞和真正的焦点并不完全一致。`,
|
||||
),
|
||||
debtOrBurden: pickFirst(
|
||||
[
|
||||
primaryScar?.title,
|
||||
role.backstoryReveal.chapters[2]?.content,
|
||||
role.backstory,
|
||||
],
|
||||
`${role.name}背后压着一件还没了结的旧事。`,
|
||||
),
|
||||
taboo: pickFirst(
|
||||
[
|
||||
role.relationshipHooks[0],
|
||||
role.tags[0],
|
||||
primaryScar?.title,
|
||||
],
|
||||
'某个旧称呼或旧地点',
|
||||
),
|
||||
immediatePressure: pickFirst(
|
||||
[
|
||||
role.motivation,
|
||||
primaryThread?.stakes,
|
||||
primaryScar?.publicResidue,
|
||||
],
|
||||
`${role.name}眼下正被${primaryThread?.title ?? '当前局势'}逼着表态。`,
|
||||
),
|
||||
relatedThreadIds,
|
||||
relatedScarIds,
|
||||
reactionHooks: dedupeStrings([
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
primaryThread?.title,
|
||||
primaryScar?.title,
|
||||
], 5),
|
||||
} satisfies ActorNarrativeProfile;
|
||||
}
|
||||
|
||||
export async function generateActorNarrativeProfileWithAi(
|
||||
role: CustomWorldRoleProfile,
|
||||
graph: WorldStoryGraph,
|
||||
themePack?: ThemePack | null,
|
||||
) {
|
||||
return buildFallbackActorNarrativeProfile(role, graph, themePack);
|
||||
}
|
||||
|
||||
export function normalizeActorNarrativeProfile(
|
||||
value: unknown,
|
||||
fallback: ActorNarrativeProfile,
|
||||
) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const item = value as Partial<ActorNarrativeProfile>;
|
||||
const readText = (candidate: unknown, fallbackText: string) =>
|
||||
typeof candidate === 'string' && candidate.trim()
|
||||
? candidate.trim()
|
||||
: fallbackText;
|
||||
|
||||
return {
|
||||
publicMask: readText(item.publicMask, fallback.publicMask),
|
||||
firstContactMask: readText(item.firstContactMask, fallback.firstContactMask),
|
||||
visibleLine: readText(item.visibleLine, fallback.visibleLine),
|
||||
hiddenLine: readText(item.hiddenLine, fallback.hiddenLine),
|
||||
contradiction: readText(item.contradiction, fallback.contradiction),
|
||||
debtOrBurden: readText(item.debtOrBurden, fallback.debtOrBurden),
|
||||
taboo: readText(item.taboo, fallback.taboo),
|
||||
immediatePressure: readText(item.immediatePressure, fallback.immediatePressure),
|
||||
relatedThreadIds: dedupeStrings(item.relatedThreadIds as string[], 6),
|
||||
relatedScarIds: dedupeStrings(item.relatedScarIds as string[], 6),
|
||||
reactionHooks:
|
||||
dedupeStrings(item.reactionHooks as string[], fallback.reactionHooks.length || 5)
|
||||
.length > 0
|
||||
? dedupeStrings(
|
||||
item.reactionHooks as string[],
|
||||
fallback.reactionHooks.length || 5,
|
||||
)
|
||||
: fallback.reactionHooks,
|
||||
};
|
||||
}
|
||||
49
src/services/storyEngine/adaptiveNarrativeTuner.test.ts
Normal file
49
src/services/storyEngine/adaptiveNarrativeTuner.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyAdaptiveTuningToPromptContext, resolveAdaptiveNarrativeBias } from './adaptiveNarrativeTuner';
|
||||
|
||||
describe('adaptiveNarrativeTuner', () => {
|
||||
it('builds bias and applies it to prompt context', () => {
|
||||
const bias = resolveAdaptiveNarrativeBias({
|
||||
profile: {
|
||||
id: 'style',
|
||||
preferenceWeights: {
|
||||
story: 40,
|
||||
exploration: 35,
|
||||
combat: 22,
|
||||
companion: 78,
|
||||
collection: 20,
|
||||
},
|
||||
dominantStyle: 'companion_bond',
|
||||
},
|
||||
});
|
||||
|
||||
expect(bias.emphasis).toBe('companion');
|
||||
const tuned = applyAdaptiveTuningToPromptContext({
|
||||
context: {
|
||||
playerHp: 10,
|
||||
playerMaxHp: 10,
|
||||
playerMana: 10,
|
||||
playerMaxMana: 10,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: 'idle' as never,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
profile: {
|
||||
id: 'style',
|
||||
preferenceWeights: {
|
||||
story: 40,
|
||||
exploration: 35,
|
||||
combat: 22,
|
||||
companion: 78,
|
||||
collection: 20,
|
||||
},
|
||||
dominantStyle: 'companion_bond',
|
||||
},
|
||||
});
|
||||
|
||||
expect(tuned.recentChronicleSummary).toContain('自适应提示');
|
||||
});
|
||||
});
|
||||
62
src/services/storyEngine/adaptiveNarrativeTuner.ts
Normal file
62
src/services/storyEngine/adaptiveNarrativeTuner.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { PlayerStyleProfile } from '../../types';
|
||||
import type { StoryGenerationContext } from '../aiTypes';
|
||||
|
||||
export interface AdaptiveNarrativeBias {
|
||||
emphasis: 'story' | 'exploration' | 'combat' | 'companion' | 'collection';
|
||||
promptHint: string;
|
||||
}
|
||||
|
||||
export function resolveAdaptiveNarrativeBias(params: {
|
||||
profile: PlayerStyleProfile | null | undefined;
|
||||
}) {
|
||||
const style = params.profile?.dominantStyle ?? 'story_first';
|
||||
|
||||
if (style === 'explorer') {
|
||||
return {
|
||||
emphasis: 'exploration',
|
||||
promptHint: '适度提高场景残痕、调查线索和环境细节的比重。',
|
||||
} satisfies AdaptiveNarrativeBias;
|
||||
}
|
||||
if (style === 'combat_driver') {
|
||||
return {
|
||||
emphasis: 'combat',
|
||||
promptHint: '适度提高冲突推进、压迫感和战斗后果的比重。',
|
||||
} satisfies AdaptiveNarrativeBias;
|
||||
}
|
||||
if (style === 'companion_bond') {
|
||||
return {
|
||||
emphasis: 'companion',
|
||||
promptHint: '适度提高队友反应、私聊、关系回响和营地事件的比重。',
|
||||
} satisfies AdaptiveNarrativeBias;
|
||||
}
|
||||
if (style === 'collector') {
|
||||
return {
|
||||
emphasis: 'collection',
|
||||
promptHint: '适度提高文书、证据、物件命名与载体回响的比重。',
|
||||
} satisfies AdaptiveNarrativeBias;
|
||||
}
|
||||
return {
|
||||
emphasis: 'story',
|
||||
promptHint: '保持主线推进和章节摘要的权重更高。',
|
||||
} satisfies AdaptiveNarrativeBias;
|
||||
}
|
||||
|
||||
export function applyAdaptiveTuningToPromptContext(params: {
|
||||
context: StoryGenerationContext;
|
||||
profile: PlayerStyleProfile | null | undefined;
|
||||
}) {
|
||||
const bias = resolveAdaptiveNarrativeBias({ profile: params.profile });
|
||||
|
||||
return {
|
||||
...params.context,
|
||||
branchBudgetPressure: params.context.branchBudgetPressure
|
||||
? `${params.context.branchBudgetPressure} / 自适应偏向:${bias.emphasis}`
|
||||
: `自适应偏向:${bias.emphasis}`,
|
||||
recentChronicleSummary: [
|
||||
params.context.recentChronicleSummary,
|
||||
`自适应提示:${bias.promptHint}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
} satisfies StoryGenerationContext;
|
||||
}
|
||||
23
src/services/storyEngine/authorialConstraintPack.test.ts
Normal file
23
src/services/storyEngine/authorialConstraintPack.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAuthorialConstraintPack } from './authorialConstraintPack';
|
||||
|
||||
describe('authorialConstraintPack', () => {
|
||||
it('builds authorial rules from profile context', () => {
|
||||
const pack = buildAuthorialConstraintPack({
|
||||
profile: {
|
||||
coreConflicts: ['封桥旧案再起'],
|
||||
themePack: {
|
||||
toneRange: ['紧张', '克制'],
|
||||
},
|
||||
storyGraph: {
|
||||
visibleThreads: [{ title: '封桥旧案' }],
|
||||
scars: [{ title: '断桥旧痕' }],
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(pack.toneRules).toContain('紧张');
|
||||
expect(pack.requiredPayoffs).toContain('封桥旧案');
|
||||
});
|
||||
});
|
||||
23
src/services/storyEngine/authorialConstraintPack.ts
Normal file
23
src/services/storyEngine/authorialConstraintPack.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
AuthorialConstraintPack,
|
||||
CustomWorldProfile,
|
||||
} from '../../types';
|
||||
|
||||
export function buildAuthorialConstraintPack(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
}) {
|
||||
const profile = params.profile;
|
||||
return {
|
||||
toneRules: profile?.themePack?.toneRange.slice(0, 4) ?? ['保持当前章节基调一致。'],
|
||||
noGoPatterns: ['禁止全知泄露', '禁止一次说完全部底牌', '禁止高光节点无后果'],
|
||||
branchBudget: {
|
||||
maxMajorDivergences: 3,
|
||||
maxEndingFamilies: 5,
|
||||
},
|
||||
mandatoryThemes: profile?.coreConflicts.slice(0, 3) ?? ['核心冲突需要被持续回收。'],
|
||||
requiredPayoffs: [
|
||||
...(profile?.storyGraph?.visibleThreads.slice(0, 2).map((thread) => thread.title) ?? []),
|
||||
...(profile?.storyGraph?.scars.slice(0, 2).map((scar) => scar.title) ?? []),
|
||||
],
|
||||
} satisfies AuthorialConstraintPack;
|
||||
}
|
||||
30
src/services/storyEngine/branchBudgetPlanner.test.ts
Normal file
30
src/services/storyEngine/branchBudgetPlanner.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { evaluateBranchBudget } from './branchBudgetPlanner';
|
||||
|
||||
describe('branchBudgetPlanner', () => {
|
||||
it('reports high pressure when divergences exceed authorial budget', () => {
|
||||
const status = evaluateBranchBudget({
|
||||
consequenceLedger: [
|
||||
{ id: '1', category: 'thread', title: 'A', summary: 'A', weight: 3, relatedIds: [], irreversible: true },
|
||||
{ id: '2', category: 'thread', title: 'B', summary: 'B', weight: 3, relatedIds: [], irreversible: true },
|
||||
{ id: '3', category: 'thread', title: 'C', summary: 'C', weight: 3, relatedIds: [], irreversible: true },
|
||||
{ id: '4', category: 'thread', title: 'D', summary: 'D', weight: 3, relatedIds: [], irreversible: true },
|
||||
],
|
||||
authorialConstraintPack: {
|
||||
toneRules: [],
|
||||
noGoPatterns: [],
|
||||
branchBudget: {
|
||||
maxMajorDivergences: 3,
|
||||
maxEndingFamilies: 5,
|
||||
},
|
||||
mandatoryThemes: [],
|
||||
requiredPayoffs: [],
|
||||
},
|
||||
endingFamilyCount: 1,
|
||||
});
|
||||
|
||||
expect(status.pressure).toBe('high');
|
||||
expect(status.issues.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
57
src/services/storyEngine/branchBudgetPlanner.ts
Normal file
57
src/services/storyEngine/branchBudgetPlanner.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
AuthorialConstraintPack,
|
||||
ConsequenceRecord,
|
||||
NarrativeQaIssue,
|
||||
} from '../../types';
|
||||
|
||||
export interface BranchBudgetStatus {
|
||||
currentMajorDivergences: number;
|
||||
maxMajorDivergences: number;
|
||||
currentEndingFamilies: number;
|
||||
maxEndingFamilies: number;
|
||||
pressure: 'low' | 'medium' | 'high';
|
||||
issues: NarrativeQaIssue[];
|
||||
}
|
||||
|
||||
export function evaluateBranchBudget(params: {
|
||||
consequenceLedger: ConsequenceRecord[];
|
||||
authorialConstraintPack: AuthorialConstraintPack | null | undefined;
|
||||
endingFamilyCount: number;
|
||||
}) {
|
||||
const maxMajorDivergences =
|
||||
params.authorialConstraintPack?.branchBudget.maxMajorDivergences ?? 3;
|
||||
const maxEndingFamilies =
|
||||
params.authorialConstraintPack?.branchBudget.maxEndingFamilies ?? 5;
|
||||
const currentMajorDivergences = params.consequenceLedger.filter(
|
||||
(record) => record.irreversible && record.weight >= 3,
|
||||
).length;
|
||||
const currentEndingFamilies = params.endingFamilyCount;
|
||||
const pressure =
|
||||
currentMajorDivergences > maxMajorDivergences ||
|
||||
currentEndingFamilies > maxEndingFamilies
|
||||
? 'high'
|
||||
: currentMajorDivergences === maxMajorDivergences ||
|
||||
currentEndingFamilies === maxEndingFamilies
|
||||
? 'medium'
|
||||
: 'low';
|
||||
|
||||
return {
|
||||
currentMajorDivergences,
|
||||
maxMajorDivergences,
|
||||
currentEndingFamilies,
|
||||
maxEndingFamilies,
|
||||
pressure,
|
||||
issues:
|
||||
pressure === 'high'
|
||||
? [
|
||||
{
|
||||
id: 'branch-budget-overflow',
|
||||
severity: 'high',
|
||||
category: 'branch_budget',
|
||||
summary: '当前分支预算已经逼近或超过设定上限。',
|
||||
relatedIds: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
} satisfies BranchBudgetStatus;
|
||||
}
|
||||
52
src/services/storyEngine/campEventDirector.test.ts
Normal file
52
src/services/storyEngine/campEventDirector.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ChapterState, CompanionArcState, JourneyBeat } from '../../types';
|
||||
import { buildCampEvent, evaluateCampEventOpportunity } from './campEventDirector';
|
||||
|
||||
describe('campEventDirector', () => {
|
||||
it('opens camp events during camp/recovery beats or conflicted arcs', () => {
|
||||
const chapterState: ChapterState = {
|
||||
id: 'chapter-1',
|
||||
title: '封桥旧案·余波',
|
||||
theme: '封桥旧案',
|
||||
primaryThreadIds: ['thread-1'],
|
||||
stage: 'aftermath',
|
||||
chapterSummary: '旧案留下的余波正在扩散。',
|
||||
};
|
||||
const journeyBeat: JourneyBeat = {
|
||||
id: 'beat-camp',
|
||||
beatType: 'camp',
|
||||
title: '营火边的休整',
|
||||
triggerThreadIds: ['thread-1'],
|
||||
recommendedSceneIds: ['scene-1'],
|
||||
emotionalGoal: '让角色先缓口气。',
|
||||
};
|
||||
const companionArcStates: CompanionArcState[] = [
|
||||
{
|
||||
characterId: 'archer-hero',
|
||||
arcTheme: '旧案',
|
||||
currentStage: 'bonded',
|
||||
activeConflictTags: [],
|
||||
pendingEventIds: ['event-1'],
|
||||
resolvedEventIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
evaluateCampEventOpportunity({
|
||||
state: {} as never,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
buildCampEvent({
|
||||
state: {} as never,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})?.title,
|
||||
).toBe('营火边的私话');
|
||||
});
|
||||
});
|
||||
51
src/services/storyEngine/campEventDirector.ts
Normal file
51
src/services/storyEngine/campEventDirector.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
CampEvent,
|
||||
ChapterState,
|
||||
CompanionArcState,
|
||||
GameState,
|
||||
JourneyBeat,
|
||||
} from '../../types';
|
||||
|
||||
export function evaluateCampEventOpportunity(params: {
|
||||
state: GameState;
|
||||
chapterState: ChapterState | null | undefined;
|
||||
journeyBeat: JourneyBeat | null | undefined;
|
||||
companionArcStates: CompanionArcState[];
|
||||
}) {
|
||||
if (!params.companionArcStates.length) return false;
|
||||
if (params.journeyBeat?.beatType === 'camp' || params.chapterState?.stage === 'aftermath') {
|
||||
return true;
|
||||
}
|
||||
return params.companionArcStates.some((arc) => arc.currentStage === 'conflicted');
|
||||
}
|
||||
|
||||
export function buildCampEvent(params: {
|
||||
state: GameState;
|
||||
chapterState: ChapterState | null | undefined;
|
||||
journeyBeat: JourneyBeat | null | undefined;
|
||||
companionArcStates: CompanionArcState[];
|
||||
}) {
|
||||
const primaryArc = params.companionArcStates[0];
|
||||
if (!primaryArc) return null;
|
||||
|
||||
const eventType: CampEvent['eventType'] =
|
||||
primaryArc.currentStage === 'conflicted'
|
||||
? 'conflict'
|
||||
: primaryArc.currentStage === 'bonded' || primaryArc.currentStage === 'resolved'
|
||||
? 'private_talk'
|
||||
: 'party_banter';
|
||||
|
||||
return {
|
||||
id: `camp-event:${primaryArc.characterId}:${params.chapterState?.stage ?? 'opening'}`,
|
||||
eventType,
|
||||
title:
|
||||
eventType === 'conflict'
|
||||
? '营地里的争执'
|
||||
: eventType === 'private_talk'
|
||||
? '营火边的私话'
|
||||
: '旅途里的插话',
|
||||
participantCharacterIds: [primaryArc.characterId],
|
||||
triggerReason: primaryArc.arcTheme,
|
||||
relatedThreadIds: params.chapterState?.primaryThreadIds ?? [],
|
||||
} satisfies CampEvent;
|
||||
}
|
||||
29
src/services/storyEngine/campaignDirector.test.ts
Normal file
29
src/services/storyEngine/campaignDirector.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { advanceCampaignState, resolveCampaignState } from './campaignDirector';
|
||||
|
||||
describe('campaignDirector', () => {
|
||||
it('resolves and advances campaign state', () => {
|
||||
const campaign = resolveCampaignState({
|
||||
state: {
|
||||
customWorldProfile: { name: '裂潮边城' },
|
||||
} as never,
|
||||
actState: {
|
||||
id: 'act-2',
|
||||
title: '第二幕·扩张',
|
||||
actIndex: 1,
|
||||
theme: '冲突升级',
|
||||
primaryThreadIds: ['thread-1'],
|
||||
status: 'midgame',
|
||||
},
|
||||
});
|
||||
|
||||
expect(campaign.currentActIndex).toBe(1);
|
||||
expect(
|
||||
advanceCampaignState({
|
||||
previous: campaign,
|
||||
next: campaign,
|
||||
}).id,
|
||||
).toBe(campaign.id);
|
||||
});
|
||||
});
|
||||
37
src/services/storyEngine/campaignDirector.ts
Normal file
37
src/services/storyEngine/campaignDirector.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ActState, CampaignState, GameState } from '../../types';
|
||||
|
||||
export function resolveCampaignState(params: {
|
||||
state: GameState;
|
||||
actState?: ActState | null;
|
||||
}) {
|
||||
const existing = params.state.storyEngineMemory?.campaignState ?? params.state.campaignState ?? null;
|
||||
const actState = params.actState ?? params.state.storyEngineMemory?.actState ?? null;
|
||||
const currentActIndex = actState?.actIndex ?? existing?.currentActIndex ?? 0;
|
||||
|
||||
return {
|
||||
id: existing?.id ?? 'campaign:main',
|
||||
title: existing?.title ?? params.state.customWorldProfile?.name ?? '主线战役',
|
||||
currentActId: actState?.id ?? existing?.currentActId ?? null,
|
||||
currentActIndex,
|
||||
resolvedEndingId: existing?.resolvedEndingId ?? null,
|
||||
} satisfies CampaignState;
|
||||
}
|
||||
|
||||
export function advanceCampaignState(params: {
|
||||
previous: CampaignState | null | undefined;
|
||||
next: CampaignState;
|
||||
}) {
|
||||
if (!params.previous) return params.next;
|
||||
if (
|
||||
params.previous.currentActId === params.next.currentActId &&
|
||||
params.previous.currentActIndex === params.next.currentActIndex &&
|
||||
params.previous.resolvedEndingId === params.next.resolvedEndingId
|
||||
) {
|
||||
return params.previous;
|
||||
}
|
||||
return {
|
||||
...params.next,
|
||||
id: params.previous.id,
|
||||
title: params.previous.title || params.next.title,
|
||||
};
|
||||
}
|
||||
24
src/services/storyEngine/campaignPackCompiler.test.ts
Normal file
24
src/services/storyEngine/campaignPackCompiler.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type CustomWorldProfile } from '../../types';
|
||||
import { compileCampaignFromWorldProfile } from './campaignPackCompiler';
|
||||
|
||||
describe('campaignPackCompiler', () => {
|
||||
it('builds scenario and campaign packs from a world profile', () => {
|
||||
const profile = {
|
||||
id: 'world-1',
|
||||
name: '裂潮边城',
|
||||
scenarioPackId: 'scenario-pack:rift',
|
||||
campaignPackId: 'campaign-pack:rift-main',
|
||||
storyGraph: {
|
||||
visibleThreads: [{ id: 'thread-1', title: '封桥旧案' }],
|
||||
},
|
||||
playableNpcs: [{ id: 'npc-1', templateCharacterId: 'archer-hero' }],
|
||||
} as unknown as CustomWorldProfile;
|
||||
|
||||
const compiled = compileCampaignFromWorldProfile({ profile });
|
||||
|
||||
expect(compiled.scenarioPack.id).toBe('scenario-pack:rift');
|
||||
expect(compiled.campaignPack.id).toBe('campaign-pack:rift-main');
|
||||
});
|
||||
});
|
||||
79
src/services/storyEngine/campaignPackCompiler.ts
Normal file
79
src/services/storyEngine/campaignPackCompiler.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
CampaignPack,
|
||||
CustomWorldProfile,
|
||||
ScenarioPack,
|
||||
} from '../../types';
|
||||
import { buildActPlan } from './actPlanner';
|
||||
import { resolveCampaignState } from './campaignDirector';
|
||||
|
||||
function slugify(value: string) {
|
||||
const ascii = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/giu, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return ascii || 'campaign';
|
||||
}
|
||||
|
||||
export function buildCampaignPack(params: {
|
||||
scenarioPackId: string;
|
||||
profile: CustomWorldProfile;
|
||||
authoringStyle?: string;
|
||||
}) {
|
||||
const { scenarioPackId, profile, authoringStyle = 'classic_story_rpg' } = params;
|
||||
const campaignStateSeed = resolveCampaignState({
|
||||
state: {
|
||||
customWorldProfile: profile,
|
||||
chapterState: null,
|
||||
storyEngineMemory: {
|
||||
activeThreadIds:
|
||||
profile.storyGraph?.visibleThreads.slice(0, 3).map((thread) => thread.id) ?? [],
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
const actTemplates = buildActPlan({
|
||||
state: {
|
||||
customWorldProfile: profile,
|
||||
storyEngineMemory: {
|
||||
activeThreadIds:
|
||||
profile.storyGraph?.visibleThreads.slice(0, 3).map((thread) => thread.id) ?? [],
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
return {
|
||||
id: profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`,
|
||||
scenarioPackId,
|
||||
title: `${profile.name} 主战役`,
|
||||
authoringStyle,
|
||||
campaignStateSeed,
|
||||
actTemplates,
|
||||
requiredCompanionIds: profile.playableNpcs.slice(0, 2).map((npc) => npc.templateCharacterId ?? npc.id),
|
||||
} satisfies CampaignPack;
|
||||
}
|
||||
|
||||
export function compileCampaignFromWorldProfile(params: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const scenarioPack: ScenarioPack = {
|
||||
id: params.profile.scenarioPackId ?? `scenario-pack:${slugify(params.profile.name)}`,
|
||||
title: `${params.profile.name} Scenario Pack`,
|
||||
version: '0.1.0',
|
||||
worldPackIds: [params.profile.id],
|
||||
campaignIds: [],
|
||||
sharedConstraintPackIds: [],
|
||||
};
|
||||
const campaignPack = buildCampaignPack({
|
||||
scenarioPackId: scenarioPack.id,
|
||||
profile: params.profile,
|
||||
});
|
||||
|
||||
return {
|
||||
scenarioPack: {
|
||||
...scenarioPack,
|
||||
campaignIds: [campaignPack.id],
|
||||
sharedConstraintPackIds: [campaignPack.id],
|
||||
},
|
||||
campaignPack,
|
||||
};
|
||||
}
|
||||
131
src/services/storyEngine/carrierNarrativeCompiler.test.ts
Normal file
131
src/services/storyEngine/carrierNarrativeCompiler.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type InventoryItem,
|
||||
type RuntimeItemAiIntent,
|
||||
type RuntimeItemGenerationContext,
|
||||
type RuntimeItemPlan,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildCarrierNarrativeDescription,
|
||||
buildCarrierNarrativeName,
|
||||
buildRuntimeItemStoryFingerprint,
|
||||
} from './carrierNarrativeCompiler';
|
||||
|
||||
function createContext(): RuntimeItemGenerationContext {
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: null,
|
||||
sceneId: 'scene-1',
|
||||
sceneName: '断桥旧哨',
|
||||
sceneDescription: '旧哨火和断桥一起守着边城北口。',
|
||||
sceneTags: ['断桥', '旧哨火'],
|
||||
treasureHints: [],
|
||||
encounter: null,
|
||||
encounterNpcId: 'npc-1',
|
||||
encounterNpcName: '梁砺',
|
||||
encounterContextText: '巡守',
|
||||
relatedNpcState: null,
|
||||
relatedNpcNarrativeProfile: {
|
||||
publicMask: '他只承认自己还在守桥。',
|
||||
firstContactMask: '先别问旧案,桥口还不能放开。',
|
||||
visibleLine: '他把全部注意力都压在桥口和来路上。',
|
||||
hiddenLine: '他真正盯着的是封桥旧令背后的名字。',
|
||||
contradiction: '嘴上只说守桥,提到名单时却会明显收紧语气。',
|
||||
debtOrBurden: '封桥那夜的后果还压在他身上。',
|
||||
taboo: '封桥令',
|
||||
immediatePressure: '裂潮再逼桥口,他不敢轻易放人过去。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
reactionHooks: ['名单', '旧哨火'],
|
||||
},
|
||||
relatedScene: {
|
||||
id: 'scene-1',
|
||||
name: '断桥旧哨',
|
||||
description: '旧哨火和断桥一起守着边城北口。',
|
||||
treasureHints: [],
|
||||
},
|
||||
recentStorySummary: '桥口的风声越来越不对。',
|
||||
recentActions: ['你刚和巡守对上了第一轮试探。'],
|
||||
activeThreadIds: ['thread-1'],
|
||||
playerCharacterId: 'hero',
|
||||
playerBuildTags: ['守御', '追击'],
|
||||
playerBuildGaps: ['mana_gap'],
|
||||
playerEquipmentTags: ['weapon'],
|
||||
generationChannel: 'quest_reward',
|
||||
};
|
||||
}
|
||||
|
||||
describe('carrierNarrativeCompiler', () => {
|
||||
it('builds fingerprint, patterned name, and layered description', () => {
|
||||
const item: InventoryItem = {
|
||||
id: 'runtime:test-item',
|
||||
category: '稀有品',
|
||||
name: '未命名秘物',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['relic'],
|
||||
equipmentSlotId: 'relic',
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled',
|
||||
generationChannel: 'quest_reward',
|
||||
seedKey: 'seed',
|
||||
sourceReason: '桥口的旧事把它重新推到了你眼前。',
|
||||
},
|
||||
};
|
||||
const plan: RuntimeItemPlan = {
|
||||
slot: 'primary',
|
||||
itemKind: 'quest',
|
||||
permanence: 'permanent',
|
||||
narrativeWeight: 'heavy',
|
||||
targetBuildDirection: ['守御', '追击'],
|
||||
relationAnchor: {
|
||||
type: 'npc',
|
||||
npcId: 'npc-1',
|
||||
npcName: '梁砺',
|
||||
roleText: '巡守',
|
||||
},
|
||||
};
|
||||
const intent: RuntimeItemAiIntent = {
|
||||
shortNameSeed: '断桥',
|
||||
sourcePhrase: '梁砺',
|
||||
reasonToAppear: '桥口的旧事把它重新推到了你眼前。',
|
||||
relationHooks: ['旧哨火'],
|
||||
desiredBuildTags: ['守御', '追击'],
|
||||
desiredFunctionalBias: ['guard'],
|
||||
tone: 'ritual',
|
||||
visibleClue: '桥口旧铜味一直留在它的边角。',
|
||||
witnessMark: '它曾被人攥着在封桥夜里来回传递。',
|
||||
unfinishedBusiness: '它为什么会在封桥之后一直没有被交回去?',
|
||||
hiddenHook: '有人故意让它留在旧哨火旁。',
|
||||
reactionHooks: ['名单', '封桥令'],
|
||||
namingPattern: 'forbidden_object',
|
||||
};
|
||||
|
||||
const fingerprint = buildRuntimeItemStoryFingerprint({
|
||||
context: createContext(),
|
||||
plan,
|
||||
intent,
|
||||
});
|
||||
const name = buildCarrierNarrativeName({
|
||||
item,
|
||||
context: createContext(),
|
||||
plan,
|
||||
intent,
|
||||
});
|
||||
const description = buildCarrierNarrativeDescription({
|
||||
item,
|
||||
context: createContext(),
|
||||
plan,
|
||||
intent,
|
||||
});
|
||||
|
||||
expect(fingerprint.visibleClue).toContain('桥口');
|
||||
expect(name).toContain('封桥令'.slice(0, 2));
|
||||
expect(name).toContain('封痕');
|
||||
expect(description).toContain('它和');
|
||||
expect(description).toContain('如今它会在断桥旧哨出现');
|
||||
expect(description).toContain('适合当前局势里的临场构筑调整');
|
||||
});
|
||||
});
|
||||
217
src/services/storyEngine/carrierNarrativeCompiler.ts
Normal file
217
src/services/storyEngine/carrierNarrativeCompiler.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type {
|
||||
CarrierStoryFingerprint,
|
||||
InventoryItem,
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
ThemePack,
|
||||
WorldStoryGraph,
|
||||
} from '../../types';
|
||||
import { buildThemePackFromWorldProfile } from './themePack';
|
||||
import { buildFallbackWorldStoryGraph } from './worldStoryGraph';
|
||||
|
||||
type CarrierNarrativeParams = {
|
||||
item: InventoryItem;
|
||||
context: RuntimeItemGenerationContext;
|
||||
plan: RuntimeItemPlan;
|
||||
intent: RuntimeItemAiIntent;
|
||||
};
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 8) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
|
||||
return (value ?? '')
|
||||
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return anchor.npcName;
|
||||
case 'scene':
|
||||
return anchor.sceneName;
|
||||
case 'landmark':
|
||||
return anchor.landmarkName;
|
||||
case 'monster':
|
||||
return anchor.monsterName;
|
||||
case 'faction':
|
||||
return anchor.factionName;
|
||||
case 'quest':
|
||||
return anchor.questName;
|
||||
default:
|
||||
return '此地';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveThemePack(context: RuntimeItemGenerationContext): ThemePack | null {
|
||||
if (!context.customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return context.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(context.customWorldProfile);
|
||||
}
|
||||
|
||||
function resolveStoryGraph(
|
||||
context: RuntimeItemGenerationContext,
|
||||
themePack: ThemePack | null,
|
||||
): WorldStoryGraph | null {
|
||||
if (!context.customWorldProfile || !themePack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return context.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(context.customWorldProfile, themePack);
|
||||
}
|
||||
|
||||
function resolveThreadLabel(
|
||||
graph: WorldStoryGraph | null,
|
||||
threadIds: string[],
|
||||
) {
|
||||
const thread = [...(graph?.visibleThreads ?? []), ...(graph?.hiddenThreads ?? [])]
|
||||
.find((candidate) => threadIds.includes(candidate.id));
|
||||
return thread?.title ?? '未尽旧事';
|
||||
}
|
||||
|
||||
function resolveScarLabel(
|
||||
graph: WorldStoryGraph | null,
|
||||
scarIds: string[],
|
||||
) {
|
||||
const scar = graph?.scars.find((candidate) => scarIds.includes(candidate.id));
|
||||
return scar?.title ?? '旧痕';
|
||||
}
|
||||
|
||||
function resolveFunctionWord(item: InventoryItem, plan: RuntimeItemPlan, intent: RuntimeItemAiIntent) {
|
||||
const topTag = intent.desiredBuildTags[0] ?? plan.targetBuildDirection[0] ?? '';
|
||||
|
||||
if (plan.itemKind === 'consumable') {
|
||||
if (intent.desiredFunctionalBias.includes('heal')) return '灵露';
|
||||
if (intent.desiredFunctionalBias.includes('mana')) return '回气散';
|
||||
if (intent.desiredFunctionalBias.includes('cooldown')) return '压纹符';
|
||||
return '药包';
|
||||
}
|
||||
|
||||
if (plan.itemKind === 'material') {
|
||||
return topTag ? `${topTag}残材` : '残材';
|
||||
}
|
||||
|
||||
if (plan.itemKind === 'quest') {
|
||||
return '信物';
|
||||
}
|
||||
|
||||
if (item.equipmentSlotId === 'weapon') {
|
||||
if (topTag === '快剑' || topTag === '追击') return '短刃';
|
||||
if (topTag === '远射') return '短弓';
|
||||
if (topTag === '重击') return '战锤';
|
||||
return '兵刃';
|
||||
}
|
||||
|
||||
if (item.equipmentSlotId === 'armor') {
|
||||
return topTag === '守御' ? '护甲' : '护符';
|
||||
}
|
||||
|
||||
if (item.equipmentSlotId === 'relic' || plan.itemKind === 'relic') {
|
||||
return topTag === '法力' ? '灵坠' : '护心佩';
|
||||
}
|
||||
|
||||
return sanitizeFragment(intent.shortNameSeed) || '秘物';
|
||||
}
|
||||
|
||||
export function buildRuntimeItemStoryFingerprint(
|
||||
params: Pick<CarrierNarrativeParams, 'context' | 'plan' | 'intent'>,
|
||||
) {
|
||||
const { context, plan, intent } = params;
|
||||
const themePack = resolveThemePack(context);
|
||||
const graph = resolveStoryGraph(context, themePack);
|
||||
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
|
||||
const relatedThreadIds = dedupeStrings([
|
||||
...(context.activeThreadIds ?? []),
|
||||
...(context.relatedNpcNarrativeProfile?.relatedThreadIds ?? []),
|
||||
graph?.visibleThreads[0]?.id,
|
||||
], 3);
|
||||
const relatedScarIds = dedupeStrings([
|
||||
...(context.relatedNpcNarrativeProfile?.relatedScarIds ?? []),
|
||||
graph?.scars[0]?.id,
|
||||
], 2);
|
||||
const primaryThreadLabel = resolveThreadLabel(graph, relatedThreadIds);
|
||||
const primaryScarLabel = resolveScarLabel(graph, relatedScarIds);
|
||||
const sceneLabel = context.sceneName ?? context.customWorldProfile?.name ?? '此地';
|
||||
|
||||
return {
|
||||
visibleClue:
|
||||
intent.visibleClue
|
||||
?? `${anchorLabel}留下的${themePack?.clueForms[0] ?? '旧痕'}仍粘在这件物上。`,
|
||||
witnessMark:
|
||||
intent.witnessMark
|
||||
?? `${primaryScarLabel}的余震被磨进了它的纹路与边角。`,
|
||||
unresolvedQuestion:
|
||||
intent.unfinishedBusiness
|
||||
?? context.relatedNpcNarrativeProfile?.contradiction
|
||||
?? `${primaryThreadLabel}为什么会在${sceneLabel}重新露头?`,
|
||||
currentAppearanceReason:
|
||||
intent.reasonToAppear ||
|
||||
`${sceneLabel}与最近的局势把它推到了你眼前。`,
|
||||
relatedThreadIds,
|
||||
relatedScarIds,
|
||||
reactionHooks: dedupeStrings([
|
||||
...(intent.reactionHooks ?? []),
|
||||
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
|
||||
primaryThreadLabel,
|
||||
anchorLabel,
|
||||
], 5),
|
||||
} satisfies CarrierStoryFingerprint;
|
||||
}
|
||||
|
||||
export function buildCarrierNarrativeName(params: CarrierNarrativeParams) {
|
||||
const { item, plan, intent } = params;
|
||||
const fingerprint = buildRuntimeItemStoryFingerprint(params);
|
||||
const anchorWord = sanitizeFragment(resolveAnchorLabel(plan.relationAnchor), 4) || '旧誓';
|
||||
const threadWord =
|
||||
sanitizeFragment(fingerprint.visibleClue, 4)
|
||||
|| sanitizeFragment(fingerprint.witnessMark, 4)
|
||||
|| sanitizeFragment(intent.sourcePhrase, 4)
|
||||
|| '余痕';
|
||||
const functionWord = resolveFunctionWord(item, plan, intent);
|
||||
const tabooWord =
|
||||
sanitizeFragment(params.context.relatedNpcNarrativeProfile?.taboo, 4) || '禁名';
|
||||
|
||||
switch (intent.namingPattern) {
|
||||
case 'scene_relic':
|
||||
return `${anchorWord}${sanitizeFragment(fingerprint.witnessMark, 4) || '灰纹'}${functionWord}`;
|
||||
case 'faction_issue':
|
||||
return `${anchorWord}制式${functionWord}`;
|
||||
case 'monster_trophy':
|
||||
return `${anchorWord}${sanitizeFragment(fingerprint.visibleClue, 4) || '猎印'}${functionWord}`;
|
||||
case 'quest_evidence':
|
||||
return `${sanitizeFragment(fingerprint.unresolvedQuestion, 4) || anchorWord}${threadWord}信物`;
|
||||
case 'forbidden_object':
|
||||
return `${tabooWord}封痕${functionWord}`;
|
||||
case 'npc_relic':
|
||||
default:
|
||||
return `${anchorWord}${threadWord}${functionWord}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCarrierNarrativeDescription(params: CarrierNarrativeParams) {
|
||||
const fingerprint = buildRuntimeItemStoryFingerprint(params);
|
||||
const threadLabel = resolveThreadLabel(
|
||||
resolveStoryGraph(params.context, resolveThemePack(params.context)),
|
||||
fingerprint.relatedThreadIds,
|
||||
);
|
||||
const buildDirectionText =
|
||||
params.intent.desiredBuildTags.join('、')
|
||||
|| params.context.playerBuildTags.join('、')
|
||||
|| '均衡';
|
||||
const sceneLabel = params.context.sceneName ?? params.context.customWorldProfile?.name ?? '此地';
|
||||
|
||||
return [
|
||||
fingerprint.visibleClue,
|
||||
`${fingerprint.witnessMark} 它和${threadLabel}之间的牵连还没有真正结清,${fingerprint.unresolvedQuestion}`,
|
||||
`如今它会在${sceneLabel}出现,是因为${fingerprint.currentAppearanceReason}。它也自然偏向${buildDirectionText}方向,适合当前局势里的临场构筑调整。`,
|
||||
].join(' ');
|
||||
}
|
||||
92
src/services/storyEngine/chapterDirector.test.ts
Normal file
92
src/services/storyEngine/chapterDirector.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState } from '../../types';
|
||||
import { advanceChapterState, resolveCurrentChapterState } from './chapterDirector';
|
||||
|
||||
function createState(signalCount: number): GameState {
|
||||
return {
|
||||
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: ['thread-1'],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
recentSignalIds: Array.from({ length: signalCount }, (_, index) => `signal-${index + 1}`),
|
||||
recentCompanionReactions: [],
|
||||
currentChapter: null,
|
||||
currentJourneyBeatId: null,
|
||||
companionArcStates: [],
|
||||
worldMutations: [],
|
||||
chronicle: [],
|
||||
factionTensionStates: [],
|
||||
currentCampEvent: null,
|
||||
currentSetpieceDirective: null,
|
||||
continueGameDigest: null,
|
||||
},
|
||||
chapterState: null,
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
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: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('chapterDirector', () => {
|
||||
it('resolves chapter stages from signal intensity', () => {
|
||||
expect(resolveCurrentChapterState({ state: createState(1) }).stage).toBe('opening');
|
||||
expect(resolveCurrentChapterState({ state: createState(4) }).stage).toBe('expansion');
|
||||
expect(resolveCurrentChapterState({ state: createState(10) }).stage).toBe('climax');
|
||||
});
|
||||
|
||||
it('keeps chapter id stable when stage and theme do not change', () => {
|
||||
const previous = resolveCurrentChapterState({ state: createState(4) });
|
||||
const next = advanceChapterState({
|
||||
previousChapter: previous,
|
||||
nextChapter: resolveCurrentChapterState({ state: createState(4) }),
|
||||
});
|
||||
|
||||
expect(next.id).toBe(previous.id);
|
||||
});
|
||||
});
|
||||
88
src/services/storyEngine/chapterDirector.ts
Normal file
88
src/services/storyEngine/chapterDirector.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ChapterState, CustomWorldProfile, GameState } from '../../types';
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 4) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function resolveChapterStage(params: {
|
||||
signalCount: number;
|
||||
chronicleCount: number;
|
||||
activeThreadCount: number;
|
||||
currentStage?: ChapterState['stage'] | null;
|
||||
}) {
|
||||
const score = params.signalCount + params.chronicleCount + params.activeThreadCount;
|
||||
if (score >= 12) return 'aftermath' as const;
|
||||
if (score >= 9) return 'climax' as const;
|
||||
if (score >= 6) return 'turning_point' as const;
|
||||
if (score >= 3) return 'expansion' as const;
|
||||
return params.currentStage === 'aftermath' ? 'aftermath' : 'opening';
|
||||
}
|
||||
|
||||
function resolveChapterTheme(profile: CustomWorldProfile | null | undefined, primaryThreadTitles: string[]) {
|
||||
if (primaryThreadTitles.length > 0) {
|
||||
return primaryThreadTitles.join('、');
|
||||
}
|
||||
return profile?.themePack?.displayName ?? profile?.summary ?? '旅程推进';
|
||||
}
|
||||
|
||||
export function resolveCurrentChapterState(params: {
|
||||
state: GameState;
|
||||
}) {
|
||||
const { state } = params;
|
||||
const storyEngineMemory = state.storyEngineMemory;
|
||||
const profile = state.customWorldProfile;
|
||||
const activeThreadIds = storyEngineMemory?.activeThreadIds ?? [];
|
||||
const threadTitles = activeThreadIds.map((threadId) =>
|
||||
[...(profile?.storyGraph?.visibleThreads ?? []), ...(profile?.storyGraph?.hiddenThreads ?? [])]
|
||||
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
||||
);
|
||||
const signalCount = storyEngineMemory?.recentSignalIds?.length ?? 0;
|
||||
const chronicleCount = storyEngineMemory?.chronicle?.length ?? 0;
|
||||
const stage = resolveChapterStage({
|
||||
signalCount,
|
||||
chronicleCount,
|
||||
activeThreadCount: activeThreadIds.length,
|
||||
currentStage: state.chapterState?.stage ?? storyEngineMemory?.currentChapter?.stage ?? null,
|
||||
});
|
||||
const theme = resolveChapterTheme(profile, threadTitles);
|
||||
const title = `${theme || '旅程'}·${stage === 'opening'
|
||||
? '序章'
|
||||
: stage === 'expansion'
|
||||
? '展开'
|
||||
: stage === 'turning_point'
|
||||
? '转折'
|
||||
: stage === 'climax'
|
||||
? '高潮'
|
||||
: '余波'}`;
|
||||
|
||||
return {
|
||||
id: `chapter:${dedupeStrings(activeThreadIds, 2).join('+') || 'default'}:${stage}`,
|
||||
title,
|
||||
theme,
|
||||
primaryThreadIds: dedupeStrings(activeThreadIds, 3),
|
||||
stage,
|
||||
chapterSummary: `${title} 当前围绕 ${theme || '旅程主线'} 推进。`,
|
||||
} satisfies ChapterState;
|
||||
}
|
||||
|
||||
export function advanceChapterState(params: {
|
||||
previousChapter: ChapterState | null | undefined;
|
||||
nextChapter: ChapterState;
|
||||
}) {
|
||||
if (!params.previousChapter) {
|
||||
return params.nextChapter;
|
||||
}
|
||||
|
||||
if (
|
||||
params.previousChapter.stage === params.nextChapter.stage &&
|
||||
params.previousChapter.theme === params.nextChapter.theme
|
||||
) {
|
||||
return {
|
||||
...params.nextChapter,
|
||||
id: params.previousChapter.id,
|
||||
};
|
||||
}
|
||||
|
||||
return params.nextChapter;
|
||||
}
|
||||
102
src/services/storyEngine/companionArcDirector.test.ts
Normal file
102
src/services/storyEngine/companionArcDirector.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState } from '../../types';
|
||||
import { buildCompanionArcState } from './companionArcDirector';
|
||||
|
||||
describe('companionArcDirector', () => {
|
||||
it('derives companion arc stage from stance and reactions', () => {
|
||||
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: undefined,
|
||||
chapterState: null,
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
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: {
|
||||
'npc-companion': {
|
||||
affinity: 48,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: true,
|
||||
revealedFacts: [],
|
||||
knownAttributeRumors: [],
|
||||
firstMeaningfulContactResolved: true,
|
||||
seenBackstoryChapterIds: [],
|
||||
stanceProfile: {
|
||||
trust: 66,
|
||||
warmth: 60,
|
||||
ideologicalFit: 52,
|
||||
fearOrGuard: 28,
|
||||
loyalty: 44,
|
||||
currentConflictTag: '旧案',
|
||||
recentApprovals: [],
|
||||
recentDisapprovals: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-companion',
|
||||
characterId: 'archer-hero',
|
||||
joinedAtAffinity: 48,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
mana: 10,
|
||||
maxMana: 10,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} satisfies GameState;
|
||||
|
||||
const arcState = buildCompanionArcState({
|
||||
state,
|
||||
characterId: 'archer-hero',
|
||||
reactions: [],
|
||||
});
|
||||
|
||||
expect(arcState?.currentStage).toBe('bonded');
|
||||
expect(arcState?.arcTheme).toBe('旧案');
|
||||
});
|
||||
});
|
||||
106
src/services/storyEngine/companionArcDirector.ts
Normal file
106
src/services/storyEngine/companionArcDirector.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
CompanionArcState,
|
||||
CompanionReactionRecord,
|
||||
GameState,
|
||||
} from '../../types';
|
||||
|
||||
function resolveArcStage(params: {
|
||||
trust: number;
|
||||
warmth: number;
|
||||
fearOrGuard: number;
|
||||
recentDisapprovals: string[];
|
||||
}) {
|
||||
if (params.trust >= 78 && params.warmth >= 70) return 'resolved' as const;
|
||||
if (params.trust >= 64 && params.warmth >= 56) return 'bonded' as const;
|
||||
if (params.recentDisapprovals.length > 0 || params.fearOrGuard >= 62) return 'conflicted' as const;
|
||||
if (params.trust >= 48) return 'opening' as const;
|
||||
if (params.trust >= 32) return 'guarded' as const;
|
||||
return 'closed' as const;
|
||||
}
|
||||
|
||||
export function buildCompanionArcState(params: {
|
||||
state: GameState;
|
||||
characterId: string;
|
||||
reactions?: CompanionReactionRecord[];
|
||||
}): CompanionArcState | null {
|
||||
const companion =
|
||||
params.state.companions.find((item) => item.characterId === params.characterId)
|
||||
?? params.state.roster.find((item) => item.characterId === params.characterId);
|
||||
if (!companion) return null;
|
||||
|
||||
const npcState = params.state.npcStates[companion.npcId];
|
||||
const stance = npcState?.stanceProfile;
|
||||
if (!stance) return null;
|
||||
|
||||
const reactions = (params.reactions ?? []).filter(
|
||||
(reaction) => reaction.characterId === params.characterId,
|
||||
);
|
||||
const activeConflictTags = [
|
||||
...stance.recentDisapprovals.map(() => '价值观摩擦'),
|
||||
...(stance.currentConflictTag ? [stance.currentConflictTag] : []),
|
||||
].slice(0, 3);
|
||||
|
||||
return {
|
||||
characterId: params.characterId,
|
||||
arcTheme: activeConflictTags[0] ?? stance.currentConflictTag ?? '同行关系',
|
||||
currentStage: resolveArcStage({
|
||||
trust: stance.trust,
|
||||
warmth: stance.warmth,
|
||||
fearOrGuard: stance.fearOrGuard,
|
||||
recentDisapprovals: stance.recentDisapprovals,
|
||||
}),
|
||||
activeConflictTags,
|
||||
pendingEventIds: reactions
|
||||
.filter((reaction) => reaction.reactionType !== 'silence')
|
||||
.map((reaction, index) => `arc-event:${params.characterId}:${index + 1}`),
|
||||
resolvedEventIds: [] as string[],
|
||||
} satisfies CompanionArcState;
|
||||
}
|
||||
|
||||
export function buildCompanionArcStates(params: {
|
||||
state: GameState;
|
||||
reactions?: CompanionReactionRecord[];
|
||||
}) {
|
||||
const allCompanions = [
|
||||
...params.state.companions,
|
||||
...params.state.roster.filter((rosterItem) =>
|
||||
!params.state.companions.some((companion) => companion.npcId === rosterItem.npcId),
|
||||
),
|
||||
];
|
||||
|
||||
return allCompanions
|
||||
.map((companion) =>
|
||||
buildCompanionArcState({
|
||||
state: params.state,
|
||||
characterId: companion.characterId,
|
||||
reactions: params.reactions,
|
||||
}),
|
||||
)
|
||||
.filter((arc): arc is CompanionArcState => arc !== null);
|
||||
}
|
||||
|
||||
export function advanceCompanionArc(params: {
|
||||
previous: CompanionArcState[] | undefined;
|
||||
next: CompanionArcState[];
|
||||
}) {
|
||||
if (!params.previous?.length) {
|
||||
return params.next;
|
||||
}
|
||||
|
||||
return params.next.map((nextArc) => {
|
||||
const previousArc = params.previous?.find((item) => item.characterId === nextArc.characterId);
|
||||
if (!previousArc) {
|
||||
return nextArc;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextArc,
|
||||
resolvedEventIds: [
|
||||
...previousArc.resolvedEventIds,
|
||||
...previousArc.pendingEventIds.filter(
|
||||
(eventId) => !nextArc.pendingEventIds.includes(eventId),
|
||||
),
|
||||
].slice(-6),
|
||||
};
|
||||
});
|
||||
}
|
||||
131
src/services/storyEngine/companionReactionDirector.test.ts
Normal file
131
src/services/storyEngine/companionReactionDirector.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState } from '../../types';
|
||||
import {
|
||||
applyCompanionReactionToStance,
|
||||
buildCompanionReactionBatch,
|
||||
} from './companionReactionDirector';
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
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: ['thread-1'],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
},
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
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: {
|
||||
'npc-companion': {
|
||||
affinity: 30,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: true,
|
||||
revealedFacts: [],
|
||||
knownAttributeRumors: [],
|
||||
firstMeaningfulContactResolved: true,
|
||||
seenBackstoryChapterIds: [],
|
||||
stanceProfile: {
|
||||
trust: 52,
|
||||
warmth: 48,
|
||||
ideologicalFit: 50,
|
||||
fearOrGuard: 30,
|
||||
loyalty: 40,
|
||||
currentConflictTag: null,
|
||||
recentApprovals: [],
|
||||
recentDisapprovals: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-companion',
|
||||
characterId: 'archer-hero',
|
||||
joinedAtAffinity: 30,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
mana: 10,
|
||||
maxMana: 10,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('companionReactionDirector', () => {
|
||||
it('builds reactions and writes them back to stance', () => {
|
||||
const state = createState();
|
||||
const reactions = buildCompanionReactionBatch({
|
||||
state,
|
||||
signals: [
|
||||
{
|
||||
id: 'signal-1',
|
||||
signalType: 'accept_contract',
|
||||
threadIds: ['thread-1'],
|
||||
},
|
||||
],
|
||||
actionText: '接下调查断桥旧案的委托',
|
||||
});
|
||||
const nextState = applyCompanionReactionToStance({
|
||||
state,
|
||||
reactions,
|
||||
});
|
||||
|
||||
expect(reactions[0]?.reactionType).toBe('approve');
|
||||
expect(nextState.npcStates['npc-companion']?.stanceProfile?.trust).toBeGreaterThan(
|
||||
state.npcStates['npc-companion']?.stanceProfile?.trust ?? 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
123
src/services/storyEngine/companionReactionDirector.ts
Normal file
123
src/services/storyEngine/companionReactionDirector.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
CompanionReactionRecord,
|
||||
GameState,
|
||||
StorySignal,
|
||||
} from '../../types';
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 6) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function buildReactionType(actionText: string, signalTypes: string[]) {
|
||||
if (/强行|掠夺|恶意|开战|逼近|威胁/u.test(actionText)) {
|
||||
return 'disapprove' as const;
|
||||
}
|
||||
if (signalTypes.includes('accept_contract') || /帮|援|接下|调查/u.test(actionText)) {
|
||||
return 'approve' as const;
|
||||
}
|
||||
if (signalTypes.includes('obtain_carrier') || signalTypes.includes('inspect_scene')) {
|
||||
return 'curious' as const;
|
||||
}
|
||||
if (/礼|赠|送/u.test(actionText)) {
|
||||
return 'concern' as const;
|
||||
}
|
||||
return 'silence' as const;
|
||||
}
|
||||
|
||||
function buildReactionReason(actionText: string, reactionType: CompanionReactionRecord['reactionType']) {
|
||||
switch (reactionType) {
|
||||
case 'approve':
|
||||
return `同行角色觉得你这一步接得住局势:${actionText}`;
|
||||
case 'disapprove':
|
||||
return `同行角色对这一步明显有保留:${actionText}`;
|
||||
case 'concern':
|
||||
return `同行角色觉得你这一步可能会牵出额外代价:${actionText}`;
|
||||
case 'curious':
|
||||
return `同行角色被这一步新露出的线索勾住了注意力:${actionText}`;
|
||||
default:
|
||||
return `同行角色暂时没有正面插话,但显然记住了这一步:${actionText}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCompanionReactionBatch(params: {
|
||||
state: GameState;
|
||||
signals: StorySignal[];
|
||||
actionText: string;
|
||||
}) {
|
||||
const signalTypes = params.signals.map((signal) => signal.signalType);
|
||||
const relatedThreadIds = dedupeStrings(
|
||||
params.signals.flatMap((signal) => signal.threadIds ?? []),
|
||||
4,
|
||||
);
|
||||
const companions = [
|
||||
...params.state.companions,
|
||||
...params.state.roster.filter((rosterItem) =>
|
||||
!params.state.companions.some((companion) => companion.npcId === rosterItem.npcId),
|
||||
),
|
||||
].slice(0, 2);
|
||||
|
||||
return companions.map((companion, index) => {
|
||||
const reactionType = buildReactionType(params.actionText, signalTypes);
|
||||
return {
|
||||
id: `reaction:${companion.characterId}:${Date.now().toString(36)}:${index + 1}`,
|
||||
characterId: companion.characterId,
|
||||
reactionType,
|
||||
reason: buildReactionReason(params.actionText, reactionType),
|
||||
relatedThreadIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
} satisfies CompanionReactionRecord;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyCompanionReactionToStance(params: {
|
||||
state: GameState;
|
||||
reactions: CompanionReactionRecord[];
|
||||
}) {
|
||||
if (params.reactions.length <= 0) {
|
||||
return params.state;
|
||||
}
|
||||
|
||||
const nextNpcStates = { ...params.state.npcStates };
|
||||
|
||||
params.reactions.forEach((reaction) => {
|
||||
const companion = params.state.companions.find(
|
||||
(item) => item.characterId === reaction.characterId,
|
||||
)
|
||||
?? params.state.roster.find((item) => item.characterId === reaction.characterId);
|
||||
if (!companion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNpcState = nextNpcStates[companion.npcId];
|
||||
if (!currentNpcState?.stanceProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stance = { ...currentNpcState.stanceProfile };
|
||||
if (reaction.reactionType === 'approve') {
|
||||
stance.trust = Math.min(100, stance.trust + 2);
|
||||
stance.loyalty = Math.min(100, stance.loyalty + 1);
|
||||
stance.recentApprovals = [...stance.recentApprovals, reaction.reason].slice(-3);
|
||||
} else if (reaction.reactionType === 'disapprove') {
|
||||
stance.fearOrGuard = Math.min(100, stance.fearOrGuard + 3);
|
||||
stance.recentDisapprovals = [...stance.recentDisapprovals, reaction.reason].slice(-3);
|
||||
} else if (reaction.reactionType === 'concern') {
|
||||
stance.fearOrGuard = Math.min(100, stance.fearOrGuard + 2);
|
||||
stance.recentDisapprovals = [...stance.recentDisapprovals, reaction.reason].slice(-3);
|
||||
} else if (reaction.reactionType === 'curious') {
|
||||
stance.ideologicalFit = Math.min(100, stance.ideologicalFit + 1);
|
||||
stance.recentApprovals = [...stance.recentApprovals, reaction.reason].slice(-3);
|
||||
}
|
||||
|
||||
nextNpcStates[companion.npcId] = {
|
||||
...currentNpcState,
|
||||
stanceProfile: stance,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...params.state,
|
||||
npcStates: nextNpcStates,
|
||||
};
|
||||
}
|
||||
42
src/services/storyEngine/companionResolutionDirector.test.ts
Normal file
42
src/services/storyEngine/companionResolutionDirector.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveCompanionResolution } from './companionResolutionDirector';
|
||||
|
||||
describe('companionResolutionDirector', () => {
|
||||
it('resolves companion endings from arcs and consequences', () => {
|
||||
const resolution = resolveCompanionResolution({
|
||||
state: {} as never,
|
||||
arcState: {
|
||||
characterId: 'archer-hero',
|
||||
arcTheme: '旧案',
|
||||
currentStage: 'conflicted',
|
||||
activeConflictTags: ['旧案'],
|
||||
pendingEventIds: [],
|
||||
resolvedEventIds: [],
|
||||
},
|
||||
ledger: [
|
||||
{
|
||||
id: 'consequence-1',
|
||||
category: 'companion',
|
||||
title: '分歧',
|
||||
summary: '她对这一步明显有保留。',
|
||||
weight: 3,
|
||||
relatedIds: ['archer-hero'],
|
||||
irreversible: true,
|
||||
},
|
||||
],
|
||||
reactions: [
|
||||
{
|
||||
id: 'reaction-1',
|
||||
characterId: 'archer-hero',
|
||||
reactionType: 'disapprove',
|
||||
reason: '她对这一步明显有保留。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(resolution.resolutionType).toBe('estranged');
|
||||
});
|
||||
});
|
||||
92
src/services/storyEngine/companionResolutionDirector.ts
Normal file
92
src/services/storyEngine/companionResolutionDirector.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CompanionArcState,
|
||||
CompanionReactionRecord,
|
||||
CompanionResolution,
|
||||
ConsequenceRecord,
|
||||
GameState,
|
||||
} from '../../types';
|
||||
|
||||
function resolveResolutionType(params: {
|
||||
arcState: CompanionArcState;
|
||||
negativeWeight: number;
|
||||
positiveWeight: number;
|
||||
}) {
|
||||
if (params.arcState.currentStage === 'resolved' || params.arcState.currentStage === 'bonded') {
|
||||
return 'bonded' as const;
|
||||
}
|
||||
if (params.arcState.currentStage === 'conflicted' && params.negativeWeight >= 5) {
|
||||
return 'estranged' as const;
|
||||
}
|
||||
if (params.arcState.currentStage === 'closed' && params.negativeWeight >= 7) {
|
||||
return 'departed' as const;
|
||||
}
|
||||
if (params.positiveWeight > params.negativeWeight) {
|
||||
return 'reconciled' as const;
|
||||
}
|
||||
return 'neutral' as const;
|
||||
}
|
||||
|
||||
export function resolveCompanionResolution(params: {
|
||||
state: GameState;
|
||||
arcState: CompanionArcState;
|
||||
ledger?: ConsequenceRecord[];
|
||||
reactions?: CompanionReactionRecord[];
|
||||
}) {
|
||||
const ledger = params.ledger ?? [];
|
||||
const reactions = params.reactions ?? [];
|
||||
const negativeWeight =
|
||||
ledger
|
||||
.filter((record) =>
|
||||
record.category === 'companion' &&
|
||||
record.relatedIds.includes(params.arcState.characterId),
|
||||
)
|
||||
.reduce((sum, record) => sum + (record.summary.includes('保留') ? record.weight : 0), 0)
|
||||
+ reactions.filter(
|
||||
(reaction) =>
|
||||
reaction.characterId === params.arcState.characterId &&
|
||||
(reaction.reactionType === 'disapprove' || reaction.reactionType === 'concern'),
|
||||
).length * 2;
|
||||
const positiveWeight =
|
||||
reactions.filter(
|
||||
(reaction) =>
|
||||
reaction.characterId === params.arcState.characterId &&
|
||||
(reaction.reactionType === 'approve' || reaction.reactionType === 'curious'),
|
||||
).length * 2;
|
||||
const resolutionType = resolveResolutionType({
|
||||
arcState: params.arcState,
|
||||
negativeWeight,
|
||||
positiveWeight,
|
||||
});
|
||||
|
||||
return {
|
||||
characterId: params.arcState.characterId,
|
||||
resolutionType,
|
||||
summary:
|
||||
resolutionType === 'bonded'
|
||||
? `${params.arcState.characterId} 最终选择与你并肩到底。`
|
||||
: resolutionType === 'reconciled'
|
||||
? `${params.arcState.characterId} 最终和你重新对齐了步调。`
|
||||
: resolutionType === 'estranged'
|
||||
? `${params.arcState.characterId} 虽未彻底离开,但已经和你渐行渐远。`
|
||||
: resolutionType === 'departed'
|
||||
? `${params.arcState.characterId} 最终没有继续与你同行。`
|
||||
: `${params.arcState.characterId} 在结局前仍保持着一种未完全定型的距离。`,
|
||||
relatedThreadIds: params.arcState.activeConflictTags,
|
||||
} satisfies CompanionResolution;
|
||||
}
|
||||
|
||||
export function resolveAllCompanionResolutions(params: {
|
||||
state: GameState;
|
||||
arcStates: CompanionArcState[];
|
||||
ledger?: ConsequenceRecord[];
|
||||
reactions?: CompanionReactionRecord[];
|
||||
}) {
|
||||
return params.arcStates.map((arcState) =>
|
||||
resolveCompanionResolution({
|
||||
state: params.state,
|
||||
arcState,
|
||||
ledger: params.ledger,
|
||||
reactions: params.reactions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
31
src/services/storyEngine/consequenceLedger.test.ts
Normal file
31
src/services/storyEngine/consequenceLedger.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { appendConsequenceRecord, buildConsequenceLedgerSummary } from './consequenceLedger';
|
||||
|
||||
describe('consequenceLedger', () => {
|
||||
it('builds consequence records from signals and reactions', () => {
|
||||
const ledger = appendConsequenceRecord({
|
||||
existing: [],
|
||||
signals: [
|
||||
{
|
||||
id: 'signal-1',
|
||||
signalType: 'accept_contract',
|
||||
threadIds: ['thread-1'],
|
||||
},
|
||||
],
|
||||
reactions: [
|
||||
{
|
||||
id: 'reaction-1',
|
||||
characterId: 'archer-hero',
|
||||
reactionType: 'disapprove',
|
||||
reason: '她对这一步明显有保留。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ledger.length).toBe(2);
|
||||
expect(buildConsequenceLedgerSummary(ledger)).toContain('accept_contract');
|
||||
});
|
||||
});
|
||||
90
src/services/storyEngine/consequenceLedger.ts
Normal file
90
src/services/storyEngine/consequenceLedger.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
CampEvent,
|
||||
CompanionReactionRecord,
|
||||
ConsequenceRecord,
|
||||
StorySignal,
|
||||
WorldMutation,
|
||||
} from '../../types';
|
||||
|
||||
function dedupeById(records: ConsequenceRecord[]) {
|
||||
const map = new Map<string, ConsequenceRecord>();
|
||||
records.forEach((record) => map.set(record.id, record));
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
export function appendConsequenceRecord(params: {
|
||||
existing?: ConsequenceRecord[] | null;
|
||||
signals?: StorySignal[];
|
||||
reactions?: CompanionReactionRecord[];
|
||||
worldMutations?: WorldMutation[];
|
||||
campEvent?: CampEvent | null;
|
||||
}) {
|
||||
const next: ConsequenceRecord[] = [...(params.existing ?? [])];
|
||||
|
||||
(params.signals ?? []).forEach((signal) => {
|
||||
next.push({
|
||||
id: `consequence:signal:${signal.id}`,
|
||||
category:
|
||||
signal.signalType === 'accept_contract'
|
||||
? 'thread'
|
||||
: signal.signalType === 'give_item'
|
||||
? 'companion'
|
||||
: signal.signalType === 'obtain_carrier'
|
||||
? 'world'
|
||||
: 'thread',
|
||||
title: signal.signalType,
|
||||
summary: `你触发了 ${signal.signalType}。`,
|
||||
weight: signal.signalType === 'accept_contract' ? 2 : 1,
|
||||
relatedIds: [...(signal.threadIds ?? []), signal.actorId ?? '', signal.sceneId ?? '', signal.carrierId ?? ''].filter(Boolean),
|
||||
irreversible:
|
||||
signal.signalType === 'accept_contract' ||
|
||||
signal.signalType === 'give_item' ||
|
||||
signal.signalType === 'resolve_contract_step',
|
||||
});
|
||||
});
|
||||
|
||||
(params.reactions ?? []).forEach((reaction) => {
|
||||
next.push({
|
||||
id: `consequence:reaction:${reaction.id}`,
|
||||
category: 'companion',
|
||||
title: `${reaction.characterId}:${reaction.reactionType}`,
|
||||
summary: reaction.reason,
|
||||
weight: reaction.reactionType === 'disapprove' ? 3 : reaction.reactionType === 'approve' ? 2 : 1,
|
||||
relatedIds: reaction.relatedThreadIds,
|
||||
irreversible: reaction.reactionType === 'disapprove',
|
||||
});
|
||||
});
|
||||
|
||||
(params.worldMutations ?? []).forEach((mutation) => {
|
||||
next.push({
|
||||
id: `consequence:mutation:${mutation.id}`,
|
||||
category: mutation.mutationType === 'npc_attitude' ? 'faction' : 'world',
|
||||
title: mutation.mutationType,
|
||||
summary: mutation.reason,
|
||||
weight: mutation.mutationType === 'route_lock' || mutation.mutationType === 'route_unlock' ? 3 : 2,
|
||||
relatedIds: [mutation.targetId, ...mutation.relatedThreadIds],
|
||||
irreversible: mutation.mutationType === 'route_lock' || mutation.mutationType === 'route_unlock',
|
||||
});
|
||||
});
|
||||
|
||||
if (params.campEvent) {
|
||||
next.push({
|
||||
id: `consequence:camp:${params.campEvent.id}`,
|
||||
category: 'companion',
|
||||
title: params.campEvent.title,
|
||||
summary: params.campEvent.triggerReason,
|
||||
weight: 2,
|
||||
relatedIds: [...params.campEvent.participantCharacterIds, ...params.campEvent.relatedThreadIds],
|
||||
irreversible: params.campEvent.eventType === 'decision',
|
||||
});
|
||||
}
|
||||
|
||||
return dedupeById(next).slice(-24);
|
||||
}
|
||||
|
||||
export function buildConsequenceLedgerSummary(records: ConsequenceRecord[]) {
|
||||
return records
|
||||
.slice(-5)
|
||||
.map((record) => `- ${record.title}:${record.summary}`)
|
||||
.join('\n');
|
||||
}
|
||||
43
src/services/storyEngine/contentDependencyGraph.test.ts
Normal file
43
src/services/storyEngine/contentDependencyGraph.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildContentDependencyGraph } from './contentDependencyGraph';
|
||||
|
||||
describe('contentDependencyGraph', () => {
|
||||
it('connects scenario, campaign, world, companions, and threads', () => {
|
||||
const graph = buildContentDependencyGraph({
|
||||
scenarioPack: {
|
||||
id: 'scenario-1',
|
||||
title: 'Scenario',
|
||||
version: '0.1.0',
|
||||
worldPackIds: ['world-1'],
|
||||
campaignIds: ['campaign-1'],
|
||||
sharedConstraintPackIds: [],
|
||||
},
|
||||
campaignPack: {
|
||||
id: 'campaign-1',
|
||||
scenarioPackId: 'scenario-1',
|
||||
title: 'Campaign',
|
||||
authoringStyle: 'classic',
|
||||
campaignStateSeed: {
|
||||
id: 'campaign-state',
|
||||
title: 'Campaign',
|
||||
currentActId: 'act-1',
|
||||
currentActIndex: 0,
|
||||
},
|
||||
actTemplates: [],
|
||||
requiredCompanionIds: [],
|
||||
},
|
||||
profile: {
|
||||
id: 'world-1',
|
||||
name: 'World',
|
||||
playableNpcs: [{ id: 'npc-1', name: 'A' }],
|
||||
storyGraph: {
|
||||
visibleThreads: [{ id: 'thread-1', title: 'T1' }],
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(graph.nodes.length).toBeGreaterThan(2);
|
||||
expect(graph.edges.some((edge) => edge.from === 'campaign-1' && edge.to === 'world-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
76
src/services/storyEngine/contentDependencyGraph.ts
Normal file
76
src/services/storyEngine/contentDependencyGraph.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
CampaignPack,
|
||||
CustomWorldProfile,
|
||||
ScenarioPack,
|
||||
} from '../../types';
|
||||
|
||||
export interface ContentDependencyNode {
|
||||
id: string;
|
||||
type: 'scenario' | 'campaign' | 'world' | 'thread' | 'companion' | 'constraint';
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ContentDependencyEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function buildContentDependencyGraph(params: {
|
||||
scenarioPack: ScenarioPack;
|
||||
campaignPack: CampaignPack;
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const nodes: ContentDependencyNode[] = [
|
||||
{
|
||||
id: params.scenarioPack.id,
|
||||
type: 'scenario',
|
||||
label: params.scenarioPack.title,
|
||||
},
|
||||
{
|
||||
id: params.campaignPack.id,
|
||||
type: 'campaign',
|
||||
label: params.campaignPack.title,
|
||||
},
|
||||
{
|
||||
id: params.profile.id,
|
||||
type: 'world',
|
||||
label: params.profile.name,
|
||||
},
|
||||
...params.profile.playableNpcs.map((npc) => ({
|
||||
id: npc.id,
|
||||
type: 'companion',
|
||||
label: npc.name,
|
||||
} as ContentDependencyNode)),
|
||||
...(params.profile.storyGraph?.visibleThreads ?? []).map((thread) => ({
|
||||
id: thread.id,
|
||||
type: 'thread',
|
||||
label: thread.title,
|
||||
} as ContentDependencyNode)),
|
||||
];
|
||||
|
||||
const edges: ContentDependencyEdge[] = [
|
||||
{
|
||||
from: params.scenarioPack.id,
|
||||
to: params.campaignPack.id,
|
||||
reason: 'scenario contains campaign',
|
||||
},
|
||||
{
|
||||
from: params.campaignPack.id,
|
||||
to: params.profile.id,
|
||||
reason: 'campaign depends on world profile',
|
||||
},
|
||||
...params.profile.playableNpcs.map((npc) => ({
|
||||
from: params.campaignPack.id,
|
||||
to: npc.id,
|
||||
reason: 'campaign references companion',
|
||||
})),
|
||||
...(params.profile.storyGraph?.visibleThreads ?? []).map((thread) => ({
|
||||
from: params.campaignPack.id,
|
||||
to: thread.id,
|
||||
reason: 'campaign advances thread',
|
||||
})),
|
||||
];
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
30
src/services/storyEngine/contentDiffReport.test.ts
Normal file
30
src/services/storyEngine/contentDiffReport.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildContentDiffReport } from './contentDiffReport';
|
||||
|
||||
describe('contentDiffReport', () => {
|
||||
it('reports differences between profile/campaign versions', () => {
|
||||
const report = buildContentDiffReport({
|
||||
previousProfile: {
|
||||
summary: '旧摘要',
|
||||
scenarioPackId: 'scenario-old',
|
||||
campaignPackId: 'campaign-old',
|
||||
} as never,
|
||||
nextProfile: {
|
||||
summary: '新摘要',
|
||||
scenarioPackId: 'scenario-new',
|
||||
campaignPackId: 'campaign-new',
|
||||
} as never,
|
||||
previousCampaignPack: {
|
||||
authoringStyle: 'classic',
|
||||
actTemplates: [],
|
||||
} as never,
|
||||
nextCampaignPack: {
|
||||
authoringStyle: 'grim',
|
||||
actTemplates: [{}, {}],
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(report.changedFields.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
35
src/services/storyEngine/contentDiffReport.ts
Normal file
35
src/services/storyEngine/contentDiffReport.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
CampaignPack,
|
||||
CustomWorldProfile,
|
||||
} from '../../types';
|
||||
|
||||
export interface ContentDiffReport {
|
||||
summary: string;
|
||||
changedFields: string[];
|
||||
}
|
||||
|
||||
export function buildContentDiffReport(params: {
|
||||
previousProfile: CustomWorldProfile | null | undefined;
|
||||
nextProfile: CustomWorldProfile | null | undefined;
|
||||
previousCampaignPack?: CampaignPack | null;
|
||||
nextCampaignPack?: CampaignPack | null;
|
||||
}) {
|
||||
const changedFields: string[] = [];
|
||||
if (params.previousProfile?.summary !== params.nextProfile?.summary) changedFields.push('profile.summary');
|
||||
if (params.previousProfile?.scenarioPackId !== params.nextProfile?.scenarioPackId) changedFields.push('profile.scenarioPackId');
|
||||
if (params.previousProfile?.campaignPackId !== params.nextProfile?.campaignPackId) changedFields.push('profile.campaignPackId');
|
||||
if (params.previousCampaignPack?.authoringStyle !== params.nextCampaignPack?.authoringStyle) {
|
||||
changedFields.push('campaignPack.authoringStyle');
|
||||
}
|
||||
if (params.previousCampaignPack?.actTemplates.length !== params.nextCampaignPack?.actTemplates.length) {
|
||||
changedFields.push('campaignPack.actTemplates');
|
||||
}
|
||||
|
||||
return {
|
||||
summary:
|
||||
changedFields.length > 0
|
||||
? `当前版本相较上个版本改动了 ${changedFields.length} 个关键 narrative 字段。`
|
||||
: '当前版本与上个版本相比没有明显 narrative 结构差异。',
|
||||
changedFields,
|
||||
} satisfies ContentDiffReport;
|
||||
}
|
||||
33
src/services/storyEngine/documentCarrierCompiler.test.ts
Normal file
33
src/services/storyEngine/documentCarrierCompiler.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ThreadContract } from '../../types';
|
||||
import { buildNarrativeDocument, compileDocumentKnowledgeFacts } from './documentCarrierCompiler';
|
||||
|
||||
describe('documentCarrierCompiler', () => {
|
||||
it('builds a document carrier and related knowledge fact', () => {
|
||||
const contract: ThreadContract = {
|
||||
id: 'thread-contract:thread-1',
|
||||
threadId: 'thread-1',
|
||||
issuerActorId: 'npc-1',
|
||||
narrativeType: 'investigation',
|
||||
currentStepId: 'thread-contract:thread-1:step_1',
|
||||
visibleStage: 0,
|
||||
steps: [
|
||||
{
|
||||
id: 'step-1',
|
||||
title: '追上旧案线索',
|
||||
revealText: '先去断桥旧哨看清楚残痕。',
|
||||
completionSignalIds: ['inspect_scene:scene-1'],
|
||||
optionalFactIds: ['fact-1'],
|
||||
},
|
||||
],
|
||||
followupThreadIds: [],
|
||||
};
|
||||
|
||||
const document = buildNarrativeDocument({ contract, titleSeed: '断桥调查简札' });
|
||||
const facts = compileDocumentKnowledgeFacts({ document, contract });
|
||||
|
||||
expect(document.category).toBe('文书');
|
||||
expect(facts[0]?.content).toContain('断桥旧哨');
|
||||
});
|
||||
});
|
||||
57
src/services/storyEngine/documentCarrierCompiler.ts
Normal file
57
src/services/storyEngine/documentCarrierCompiler.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { InventoryItem, ThreadContract } from '../../types';
|
||||
|
||||
export function buildNarrativeDocument(params: {
|
||||
contract: ThreadContract;
|
||||
titleSeed?: string;
|
||||
}) {
|
||||
const title = params.titleSeed || `${params.contract.threadId}调查简札`;
|
||||
const currentStep = params.contract.steps[params.contract.visibleStage] ?? params.contract.steps[0];
|
||||
const relatedThreadIds = [params.contract.threadId];
|
||||
|
||||
return {
|
||||
id: `document:${params.contract.id}`,
|
||||
category: '文书',
|
||||
name: title,
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['document', 'relic'],
|
||||
description: `${title} 里记着当前线程的阶段性摘要与下一步线索。`,
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled',
|
||||
generationChannel: 'quest_reward',
|
||||
seedKey: `document:${params.contract.id}`,
|
||||
sourceReason: `${params.contract.threadId} 当前进入了新的阶段。`,
|
||||
storyFingerprint: {
|
||||
visibleClue: currentStep?.revealText ?? `${title} 里写着当前线程的进展。`,
|
||||
witnessMark: `${title} 记录着 ${params.contract.threadId} 这一线被谁、在何处继续推了下去。`,
|
||||
unresolvedQuestion:
|
||||
currentStep?.title
|
||||
?? `${params.contract.threadId} 接下来还要如何继续推进?`,
|
||||
currentAppearanceReason: `${params.contract.threadId} 当前进入了新的阶段。`,
|
||||
relatedThreadIds,
|
||||
relatedScarIds: [],
|
||||
reactionHooks: [params.contract.threadId],
|
||||
},
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
|
||||
export function compileDocumentKnowledgeFacts(params: {
|
||||
document: InventoryItem;
|
||||
contract: ThreadContract;
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
id: `document-fact:${params.document.id}`,
|
||||
title: `${params.document.name}的记录`,
|
||||
content: params.contract.steps[params.contract.visibleStage]?.revealText ?? params.contract.threadId,
|
||||
ownerActorIds: [],
|
||||
relatedThreadIds: [params.contract.threadId],
|
||||
relatedScarIds: [],
|
||||
sourceType: 'document',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'direct',
|
||||
aliases: [params.document.name],
|
||||
},
|
||||
];
|
||||
}
|
||||
183
src/services/storyEngine/echoMemory.test.ts
Normal file
183
src/services/storyEngine/echoMemory.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Encounter,
|
||||
type InventoryItem,
|
||||
type NpcPersistentState,
|
||||
} from '../../types';
|
||||
import { appendStoryEngineCarrierMemory, syncNpcNarrativeState } from './echoMemory';
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-bridge-watch',
|
||||
kind: 'npc',
|
||||
npcName: '梁砺',
|
||||
npcDescription: '守着断桥与旧哨火的巡守。',
|
||||
npcAvatar: '',
|
||||
context: '巡守',
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只承认自己还在守桥。',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: 0,
|
||||
teaser: '他只说桥还不能放开。',
|
||||
content: '他总先谈桥和路。',
|
||||
contextSnippet: '桥还不能放开。',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 30,
|
||||
teaser: '封桥那夜明显留下了后劲。',
|
||||
content: '他始终忘不了那夜桥上的名单。',
|
||||
contextSnippet: '封桥旧事还压着他。',
|
||||
},
|
||||
],
|
||||
},
|
||||
narrativeProfile: {
|
||||
publicMask: '他只承认自己还在守桥。',
|
||||
firstContactMask: '先别问旧案,桥口还不能放开。',
|
||||
visibleLine: '他把全部注意力都压在桥口和来路上。',
|
||||
hiddenLine: '他真正盯着的是封桥旧令背后的名字。',
|
||||
contradiction: '嘴上只说守桥,提到名单时却会明显收紧语气。',
|
||||
debtOrBurden: '封桥那夜的后果还压在他身上。',
|
||||
taboo: '封桥令',
|
||||
immediatePressure: '裂潮再逼桥口,他不敢轻易放人过去。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
reactionHooks: ['名单', '旧哨火'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('echoMemory', () => {
|
||||
it('syncs npc revealed facts and unlocked chapters from current disclosure', () => {
|
||||
const npcState: NpcPersistentState = {
|
||||
affinity: 34,
|
||||
helpUsed: false,
|
||||
chattedCount: 1,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
revealedFacts: [],
|
||||
knownAttributeRumors: [],
|
||||
firstMeaningfulContactResolved: true,
|
||||
seenBackstoryChapterIds: [],
|
||||
stanceProfile: {
|
||||
trust: 50,
|
||||
warmth: 46,
|
||||
ideologicalFit: 50,
|
||||
fearOrGuard: 38,
|
||||
loyalty: 28,
|
||||
currentConflictTag: null,
|
||||
recentApprovals: [],
|
||||
recentDisapprovals: [],
|
||||
},
|
||||
};
|
||||
|
||||
const synced = syncNpcNarrativeState({
|
||||
encounter: createEncounter(),
|
||||
npcState,
|
||||
});
|
||||
|
||||
expect(synced.revealedFacts).toContain('publicMask');
|
||||
expect(synced.revealedFacts).toContain('contradiction');
|
||||
expect(synced.seenBackstoryChapterIds).toContain('surface');
|
||||
expect(synced.seenBackstoryChapterIds).toContain('scar');
|
||||
});
|
||||
|
||||
it('writes recent carriers and scar echoes into story engine memory', () => {
|
||||
const item: InventoryItem = {
|
||||
id: 'runtime:quest:evidence',
|
||||
category: '专属物品',
|
||||
name: '封痕信物',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['relic'],
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled',
|
||||
generationChannel: 'quest_reward',
|
||||
seedKey: 'seed',
|
||||
sourceReason: '旧案把它重新推到了你眼前。',
|
||||
storyFingerprint: {
|
||||
visibleClue: '桥口旧铜味一直留在它的边角。',
|
||||
witnessMark: '它曾被人攥着在封桥夜里来回传递。',
|
||||
unresolvedQuestion: '它为什么一直没有被交回去?',
|
||||
currentAppearanceReason: '封桥旧案再次被提起。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
reactionHooks: ['名单'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const nextState = appendStoryEngineCarrierMemory(
|
||||
{
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
},
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
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: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
[item],
|
||||
);
|
||||
|
||||
expect(nextState.storyEngineMemory?.recentCarrierIds).toContain(item.id);
|
||||
expect(nextState.storyEngineMemory?.resolvedScarIds).toContain('scar-1');
|
||||
expect(nextState.storyEngineMemory?.activeThreadIds).toContain('thread-1');
|
||||
});
|
||||
});
|
||||
169
src/services/storyEngine/echoMemory.ts
Normal file
169
src/services/storyEngine/echoMemory.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
NpcPersistentState,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from './actorNarrativeProfile';
|
||||
import { buildThemePackFromWorldProfile } from './themePack';
|
||||
import {
|
||||
buildEncounterVisibilitySlice,
|
||||
createEmptyStoryEngineMemoryState,
|
||||
} from './visibilityEngine';
|
||||
import { buildFallbackWorldStoryGraph } from './worldStoryGraph';
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 16) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(-limit);
|
||||
}
|
||||
|
||||
function resolveDisclosureStage(npcState: NpcPersistentState) {
|
||||
if (npcState.recruited || npcState.affinity >= 50) return 'deep' as const;
|
||||
if (npcState.affinity >= 30) return 'honest' as const;
|
||||
if (npcState.affinity >= 15) return 'partial' as const;
|
||||
return 'guarded' as const;
|
||||
}
|
||||
|
||||
function resolveEncounterNarrativeProfile(
|
||||
customWorldProfile: CustomWorldProfile | null | undefined,
|
||||
encounter: Encounter,
|
||||
) {
|
||||
if (encounter.narrativeProfile) {
|
||||
return encounter.narrativeProfile;
|
||||
}
|
||||
if (!customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
customWorldProfile.storyNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
)
|
||||
?? customWorldProfile.playableNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themePack =
|
||||
customWorldProfile.themePack ?? buildThemePackFromWorldProfile(customWorldProfile);
|
||||
const storyGraph =
|
||||
customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(customWorldProfile, themePack);
|
||||
|
||||
return normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
}
|
||||
|
||||
export function syncNpcNarrativeState(params: {
|
||||
encounter: Encounter;
|
||||
npcState: NpcPersistentState;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
storyEngineMemory?: GameState['storyEngineMemory'];
|
||||
}) {
|
||||
const { encounter, npcState, customWorldProfile } = params;
|
||||
if (encounter.kind !== 'npc') {
|
||||
return npcState;
|
||||
}
|
||||
|
||||
const narrativeProfile = resolveEncounterNarrativeProfile(
|
||||
customWorldProfile,
|
||||
encounter,
|
||||
);
|
||||
if (!narrativeProfile) {
|
||||
return npcState;
|
||||
}
|
||||
|
||||
const storyEngineMemory =
|
||||
params.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const activeThreadIds =
|
||||
storyEngineMemory.activeThreadIds.length > 0
|
||||
? storyEngineMemory.activeThreadIds
|
||||
: narrativeProfile.relatedThreadIds;
|
||||
const visibilitySlice = buildEncounterVisibilitySlice({
|
||||
narrativeProfile,
|
||||
backstoryReveal: encounter.backstoryReveal ?? null,
|
||||
disclosureStage: resolveDisclosureStage(npcState),
|
||||
isFirstMeaningfulContact: npcState.firstMeaningfulContactResolved !== true,
|
||||
seenBackstoryChapterIds: npcState.seenBackstoryChapterIds ?? [],
|
||||
storyEngineMemory,
|
||||
activeThreadIds,
|
||||
});
|
||||
|
||||
return {
|
||||
...npcState,
|
||||
revealedFacts: dedupeStrings([
|
||||
...(npcState.revealedFacts ?? []),
|
||||
...visibilitySlice.sayableFactIds.filter((factId) => !factId.startsWith('chapter:')),
|
||||
...visibilitySlice.inferredFactIds.filter(
|
||||
(factId) =>
|
||||
factId === 'contradiction' ||
|
||||
factId.startsWith('thread:') ||
|
||||
factId.startsWith('scar:'),
|
||||
),
|
||||
], 20),
|
||||
seenBackstoryChapterIds: dedupeStrings([
|
||||
...(npcState.seenBackstoryChapterIds ?? []),
|
||||
...visibilitySlice.sayableFactIds
|
||||
.filter((factId) => factId.startsWith('chapter:'))
|
||||
.map((factId) => factId.slice('chapter:'.length)),
|
||||
], 8),
|
||||
};
|
||||
}
|
||||
|
||||
export function appendStoryEngineCarrierMemory(
|
||||
state: GameState,
|
||||
items: InventoryItem[],
|
||||
) {
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const carriers = items.filter((item) => item.runtimeMetadata?.storyFingerprint);
|
||||
if (carriers.length <= 0) {
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const recentCarrierIds = dedupeStrings([
|
||||
...storyEngineMemory.recentCarrierIds,
|
||||
...carriers.map((item) => item.id),
|
||||
], 8);
|
||||
const scarIds = carriers.flatMap(
|
||||
(item) => item.runtimeMetadata?.storyFingerprint?.relatedScarIds ?? [],
|
||||
);
|
||||
const threadIds = carriers.flatMap(
|
||||
(item) => item.runtimeMetadata?.storyFingerprint?.relatedThreadIds ?? [],
|
||||
);
|
||||
const visibleClues = carriers.flatMap((item) => {
|
||||
const clue = item.runtimeMetadata?.storyFingerprint?.visibleClue;
|
||||
return clue ? [clue] : [];
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
recentCarrierIds,
|
||||
resolvedScarIds: dedupeStrings(
|
||||
[...storyEngineMemory.resolvedScarIds, ...scarIds],
|
||||
10,
|
||||
),
|
||||
activeThreadIds: dedupeStrings(
|
||||
[...storyEngineMemory.activeThreadIds, ...threadIds],
|
||||
8,
|
||||
),
|
||||
discoveredFactIds: dedupeStrings(
|
||||
[...storyEngineMemory.discoveredFactIds, ...visibleClues],
|
||||
24,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
34
src/services/storyEngine/endingResolver.test.ts
Normal file
34
src/services/storyEngine/endingResolver.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveEndingState } from './endingResolver';
|
||||
|
||||
describe('endingResolver', () => {
|
||||
it('builds ending states from threads, companions, and factions', () => {
|
||||
const ending = resolveEndingState({
|
||||
state: {
|
||||
storyEngineMemory: {
|
||||
activeThreadIds: ['thread-1', 'thread-2', 'thread-3'],
|
||||
},
|
||||
} as never,
|
||||
companionResolutions: [
|
||||
{
|
||||
characterId: 'archer-hero',
|
||||
resolutionType: 'bonded',
|
||||
summary: '她最终选择与你并肩到底。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
},
|
||||
],
|
||||
factionTensionStates: [
|
||||
{
|
||||
factionId: 'faction:巡边司:1',
|
||||
temperature: 72,
|
||||
pressureSummary: '巡边司一线已经被旧案推到了临界点。',
|
||||
activeConflictThreadIds: ['thread-1'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ending.endingType).toBe('bitter_sweet');
|
||||
expect(ending.title).toBeTruthy();
|
||||
});
|
||||
});
|
||||
81
src/services/storyEngine/endingResolver.ts
Normal file
81
src/services/storyEngine/endingResolver.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
CompanionResolution,
|
||||
EndingState,
|
||||
FactionTensionState,
|
||||
GameState,
|
||||
} from '../../types';
|
||||
|
||||
function resolveEndingType(params: {
|
||||
activeThreadCount: number;
|
||||
fracturedCompanions: number;
|
||||
hottestFactionTemperature: number;
|
||||
}) {
|
||||
if (params.hottestFactionTemperature >= 78 && params.fracturedCompanions >= 2) {
|
||||
return 'tragic' as const;
|
||||
}
|
||||
if (params.activeThreadCount >= 3 && params.hottestFactionTemperature >= 70) {
|
||||
return 'bitter_sweet' as const;
|
||||
}
|
||||
if (params.fracturedCompanions >= 1) {
|
||||
return 'fractured' as const;
|
||||
}
|
||||
if (params.activeThreadCount >= 3) {
|
||||
return 'ascendant' as const;
|
||||
}
|
||||
return 'heroic' as const;
|
||||
}
|
||||
|
||||
export function resolveEndingState(params: {
|
||||
state: GameState;
|
||||
companionResolutions: CompanionResolution[];
|
||||
factionTensionStates: FactionTensionState[];
|
||||
}) {
|
||||
const activeThreadIds = params.state.storyEngineMemory?.activeThreadIds ?? [];
|
||||
const fracturedCompanions = params.companionResolutions.filter((resolution) =>
|
||||
resolution.resolutionType === 'estranged' || resolution.resolutionType === 'departed',
|
||||
).length;
|
||||
const hottestFactionTemperature =
|
||||
params.factionTensionStates.reduce(
|
||||
(max, item) => Math.max(max, item.temperature),
|
||||
0,
|
||||
);
|
||||
const endingType = resolveEndingType({
|
||||
activeThreadCount: activeThreadIds.length,
|
||||
fracturedCompanions,
|
||||
hottestFactionTemperature,
|
||||
});
|
||||
|
||||
return {
|
||||
id: `ending:${endingType}:${activeThreadIds.slice(0, 2).join('+') || 'main'}`,
|
||||
title:
|
||||
endingType === 'heroic'
|
||||
? '守住火种'
|
||||
: endingType === 'tragic'
|
||||
? '余烬之末'
|
||||
: endingType === 'bitter_sweet'
|
||||
? '代价之后'
|
||||
: endingType === 'fractured'
|
||||
? '裂开的同路'
|
||||
: '新秩序的门槛',
|
||||
endingType,
|
||||
summary:
|
||||
endingType === 'heroic'
|
||||
? '你勉强把局势拖回了可继续前行的方向。'
|
||||
: endingType === 'tragic'
|
||||
? '真相被揭开,但代价也一并吞没了许多人。'
|
||||
: endingType === 'bitter_sweet'
|
||||
? '你走到了终点,但一路上失去的东西无法被轻易抹平。'
|
||||
: endingType === 'fractured'
|
||||
? '你推进了主线,却没能把所有同行者一起带到终点。'
|
||||
: '你不只是解开旧线,也推开了一个更大的新局面。',
|
||||
contributingThreadIds: activeThreadIds,
|
||||
companionResolutions: params.companionResolutions,
|
||||
worldOutcomeSummary:
|
||||
params.factionTensionStates.length > 0
|
||||
? params.factionTensionStates
|
||||
.slice(0, 2)
|
||||
.map((item) => item.pressureSummary)
|
||||
.join(' ')
|
||||
: '世界在你的选择之后开始改口。',
|
||||
} satisfies EndingState;
|
||||
}
|
||||
30
src/services/storyEngine/epilogueComposer.test.ts
Normal file
30
src/services/storyEngine/epilogueComposer.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildEpilogueSummary } from './epilogueComposer';
|
||||
|
||||
describe('epilogueComposer', () => {
|
||||
it('composes an epilogue from ending and companion resolutions', () => {
|
||||
const summary = buildEpilogueSummary({
|
||||
endingState: {
|
||||
id: 'ending-1',
|
||||
title: '守住火种',
|
||||
endingType: 'heroic',
|
||||
summary: '你把局势拖回了可继续前行的方向。',
|
||||
contributingThreadIds: ['thread-1'],
|
||||
companionResolutions: [],
|
||||
worldOutcomeSummary: '边城暂时稳了下来。',
|
||||
},
|
||||
companionResolutions: [
|
||||
{
|
||||
characterId: 'archer-hero',
|
||||
resolutionType: 'bonded',
|
||||
summary: '她最终选择与你并肩到底。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(summary).toContain('守住火种');
|
||||
expect(summary).toContain('并肩到底');
|
||||
});
|
||||
});
|
||||
19
src/services/storyEngine/epilogueComposer.ts
Normal file
19
src/services/storyEngine/epilogueComposer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CompanionResolution, EndingState } from '../../types';
|
||||
|
||||
export function buildEpilogueSummary(params: {
|
||||
endingState: EndingState;
|
||||
companionResolutions: CompanionResolution[];
|
||||
}) {
|
||||
const companionText = params.companionResolutions.length > 0
|
||||
? params.companionResolutions
|
||||
.slice(0, 3)
|
||||
.map((resolution) => resolution.summary)
|
||||
.join(' ')
|
||||
: '与你同行的人们各自带着新的立场散入余波里。';
|
||||
|
||||
return [
|
||||
`${params.endingState.title}:${params.endingState.summary}`,
|
||||
params.endingState.worldOutcomeSummary,
|
||||
companionText,
|
||||
].join('\n');
|
||||
}
|
||||
70
src/services/storyEngine/factionTensionState.test.ts
Normal file
70
src/services/storyEngine/factionTensionState.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type CustomWorldProfile,WorldType } from '../../types';
|
||||
import { buildFactionTensionState } from './factionTensionState';
|
||||
|
||||
describe('factionTensionState', () => {
|
||||
it('builds faction temperatures from active threads', () => {
|
||||
const profile = {
|
||||
id: 'world-1',
|
||||
settingText: '裂潮边城',
|
||||
name: '裂潮边城',
|
||||
subtitle: '旧案回响',
|
||||
summary: '一座在裂潮和旧案之间摇摇欲坠的边城。',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
playerGoal: '查清封桥旧令',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: ['巡边司'],
|
||||
coreConflicts: ['封桥旧案再起'],
|
||||
attributeSchema: {
|
||||
id: 'schema',
|
||||
worldId: 'world-1',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '裂潮边城',
|
||||
settingSummary: '裂潮边城',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
conflictCore: '封桥旧案再起',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
themePack: null,
|
||||
storyGraph: {
|
||||
visibleThreads: [
|
||||
{
|
||||
id: 'thread-1',
|
||||
title: '巡边司的封桥旧案',
|
||||
visibility: 'visible',
|
||||
summary: '巡边司正在被封桥旧案拖住。',
|
||||
conflictType: '调查',
|
||||
stakes: '边城安稳',
|
||||
involvedFactionIds: ['巡边司'],
|
||||
involvedActorIds: [],
|
||||
relatedLocationIds: [],
|
||||
},
|
||||
],
|
||||
hiddenThreads: [],
|
||||
scars: [],
|
||||
motifs: [],
|
||||
},
|
||||
knowledgeFacts: null,
|
||||
threadContracts: null,
|
||||
} satisfies CustomWorldProfile;
|
||||
|
||||
const tensions = buildFactionTensionState(profile, {
|
||||
discoveredFactIds: [],
|
||||
inferredFactIds: [],
|
||||
activeThreadIds: ['thread-1'],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
});
|
||||
|
||||
expect(tensions[0]?.temperature).toBeGreaterThan(40);
|
||||
expect(tensions[0]?.pressureSummary).toContain('巡边司');
|
||||
});
|
||||
});
|
||||
63
src/services/storyEngine/factionTensionState.ts
Normal file
63
src/services/storyEngine/factionTensionState.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
CustomWorldProfile,
|
||||
FactionTensionState,
|
||||
StoryEngineMemoryState,
|
||||
StorySignal,
|
||||
} from '../../types';
|
||||
|
||||
function clampTemperature(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
export function buildFactionTensionState(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
memory: StoryEngineMemoryState | null | undefined,
|
||||
) {
|
||||
if (!profile) {
|
||||
return [] as FactionTensionState[];
|
||||
}
|
||||
|
||||
const activeThreadIds = memory?.activeThreadIds ?? [];
|
||||
return profile.majorFactions.map((factionName, index) => {
|
||||
const factionId = `faction:${factionName}:${index + 1}`;
|
||||
const activeConflictThreadIds = [
|
||||
...(profile.storyGraph?.visibleThreads ?? []),
|
||||
...(profile.storyGraph?.hiddenThreads ?? []),
|
||||
]
|
||||
.filter((thread) =>
|
||||
thread.involvedFactionIds.some((candidate) => candidate.includes(factionName))
|
||||
|| thread.title.includes(factionName),
|
||||
)
|
||||
.map((thread) => thread.id)
|
||||
.filter((threadId) => activeThreadIds.length <= 0 || activeThreadIds.includes(threadId));
|
||||
|
||||
return {
|
||||
factionId,
|
||||
temperature: clampTemperature(40 + activeConflictThreadIds.length * 12),
|
||||
pressureSummary:
|
||||
activeConflictThreadIds.length > 0
|
||||
? `${factionName}一线正在被${activeConflictThreadIds.length}条冲突同时拉扯。`
|
||||
: `${factionName}暂时还维持着表面的平衡。`,
|
||||
activeConflictThreadIds,
|
||||
} satisfies FactionTensionState;
|
||||
});
|
||||
}
|
||||
|
||||
export function applySignalToFactionTension(params: {
|
||||
tensions: FactionTensionState[];
|
||||
signals: StorySignal[];
|
||||
}) {
|
||||
if (params.signals.length <= 0) {
|
||||
return params.tensions;
|
||||
}
|
||||
|
||||
return params.tensions.map((tension) => ({
|
||||
...tension,
|
||||
temperature: clampTemperature(
|
||||
tension.temperature +
|
||||
params.signals.filter((signal) =>
|
||||
signal.threadIds?.some((threadId) => tension.activeConflictThreadIds.includes(threadId)),
|
||||
).length * 3,
|
||||
),
|
||||
}));
|
||||
}
|
||||
90
src/services/storyEngine/journeyBeatPlanner.test.ts
Normal file
90
src/services/storyEngine/journeyBeatPlanner.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type ChapterState, type GameState } from '../../types';
|
||||
import { buildJourneyBeatQueue, resolveCurrentJourneyBeat } from './journeyBeatPlanner';
|
||||
|
||||
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: ['thread-1'],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
currentChapter: null,
|
||||
currentJourneyBeatId: null,
|
||||
companionArcStates: [],
|
||||
worldMutations: [],
|
||||
chronicle: [],
|
||||
factionTensionStates: [],
|
||||
currentCampEvent: null,
|
||||
currentSetpieceDirective: null,
|
||||
continueGameDigest: null,
|
||||
},
|
||||
chapterState: null,
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: { id: 'scene-1', 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: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} satisfies GameState;
|
||||
|
||||
describe('journeyBeatPlanner', () => {
|
||||
it('builds a beat queue and resolves the current beat', () => {
|
||||
const chapterState: ChapterState = {
|
||||
id: 'chapter-1',
|
||||
title: '封桥旧案·展开',
|
||||
theme: '封桥旧案',
|
||||
primaryThreadIds: ['thread-1'],
|
||||
stage: 'expansion',
|
||||
chapterSummary: '旧案正在铺开。',
|
||||
};
|
||||
const queue = buildJourneyBeatQueue({ state, chapterState });
|
||||
const beat = resolveCurrentJourneyBeat({ state, chapterState });
|
||||
|
||||
expect(queue).toHaveLength(3);
|
||||
expect(beat?.beatType).toBe('investigation');
|
||||
});
|
||||
});
|
||||
74
src/services/storyEngine/journeyBeatPlanner.ts
Normal file
74
src/services/storyEngine/journeyBeatPlanner.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { ChapterState, GameState, JourneyBeat } from '../../types';
|
||||
|
||||
function resolveBeatType(stage: ChapterState['stage']) {
|
||||
switch (stage) {
|
||||
case 'opening':
|
||||
return 'approach' as const;
|
||||
case 'expansion':
|
||||
return 'investigation' as const;
|
||||
case 'turning_point':
|
||||
return 'conflict' as const;
|
||||
case 'climax':
|
||||
return 'climax' as const;
|
||||
case 'aftermath':
|
||||
return 'recovery' as const;
|
||||
default:
|
||||
return 'approach' as const;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildJourneyBeatQueue(params: {
|
||||
state: GameState;
|
||||
chapterState: ChapterState | null | undefined;
|
||||
}) {
|
||||
const chapterState = params.chapterState;
|
||||
if (!chapterState) return [] as JourneyBeat[];
|
||||
|
||||
const currentSceneId = params.state.currentScenePreset?.id;
|
||||
const shared = {
|
||||
triggerThreadIds: chapterState.primaryThreadIds,
|
||||
recommendedSceneIds: currentSceneId ? [currentSceneId] : [],
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${chapterState.id}:approach`,
|
||||
beatType: 'approach',
|
||||
title: `${chapterState.title}·接近`,
|
||||
emotionalGoal: '先把前情和压力重新拢到一起。',
|
||||
...shared,
|
||||
},
|
||||
{
|
||||
id: `${chapterState.id}:${resolveBeatType(chapterState.stage)}`,
|
||||
beatType: resolveBeatType(chapterState.stage),
|
||||
title: `${chapterState.title}·当前段落`,
|
||||
emotionalGoal:
|
||||
chapterState.stage === 'climax'
|
||||
? '把冲突推到最前台。'
|
||||
: chapterState.stage === 'aftermath'
|
||||
? '让角色和世界消化刚发生的后果。'
|
||||
: '让线索、关系和压力继续叠加。',
|
||||
...shared,
|
||||
},
|
||||
{
|
||||
id: `${chapterState.id}:camp`,
|
||||
beatType: 'camp',
|
||||
title: `${chapterState.title}·休整`,
|
||||
emotionalGoal: '给队友、营地和回顾留出呼吸。 ',
|
||||
...shared,
|
||||
},
|
||||
] satisfies JourneyBeat[];
|
||||
}
|
||||
|
||||
export function resolveCurrentJourneyBeat(params: {
|
||||
state: GameState;
|
||||
chapterState: ChapterState | null | undefined;
|
||||
}) {
|
||||
const queue = buildJourneyBeatQueue(params);
|
||||
if (queue.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedBeatId = params.state.storyEngineMemory?.currentJourneyBeatId;
|
||||
return queue.find((beat) => beat.id === storedBeatId) ?? queue[1] ?? queue[0];
|
||||
}
|
||||
56
src/services/storyEngine/knowledgeContract.test.ts
Normal file
56
src/services/storyEngine/knowledgeContract.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { KnowledgeFact } from '../../types';
|
||||
import { buildVisibilitySliceFromFacts } from './knowledgeContract';
|
||||
|
||||
describe('buildVisibilitySliceFromFacts', () => {
|
||||
it('separates direct, indirect, and forbidden facts', () => {
|
||||
const facts: KnowledgeFact[] = [
|
||||
{
|
||||
id: 'fact-public',
|
||||
title: '公开面',
|
||||
content: '他只承认自己还在守桥。',
|
||||
ownerActorIds: ['npc-1'],
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: [],
|
||||
sourceType: 'actor',
|
||||
visibility: 'public',
|
||||
sayability: 'direct',
|
||||
},
|
||||
{
|
||||
id: 'fact-indirect',
|
||||
title: '错位',
|
||||
content: '嘴上只说守桥,提到名单时却会明显收紧语气。',
|
||||
ownerActorIds: ['npc-1'],
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: [],
|
||||
sourceType: 'actor',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'indirect',
|
||||
},
|
||||
{
|
||||
id: 'fact-forbidden',
|
||||
title: '禁区',
|
||||
content: '封桥令',
|
||||
ownerActorIds: ['npc-1'],
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: [],
|
||||
sourceType: 'actor',
|
||||
visibility: 'forbidden',
|
||||
sayability: 'reactive_only',
|
||||
},
|
||||
];
|
||||
|
||||
const slice = buildVisibilitySliceFromFacts({
|
||||
facts,
|
||||
discoveredFactIds: ['fact-public', 'fact-indirect'],
|
||||
activeThreadIds: ['thread-1'],
|
||||
disclosureStage: 'guarded',
|
||||
isFirstMeaningfulContact: false,
|
||||
});
|
||||
|
||||
expect(slice.sayableFactIds).toContain('fact-public');
|
||||
expect(slice.inferredFactIds).toContain('fact-indirect');
|
||||
expect(slice.forbiddenFactIds).toContain('fact-forbidden');
|
||||
});
|
||||
});
|
||||
98
src/services/storyEngine/knowledgeContract.ts
Normal file
98
src/services/storyEngine/knowledgeContract.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
KnowledgeFact,
|
||||
NpcDisclosureStage,
|
||||
VisibilitySlice,
|
||||
} from '../../types';
|
||||
|
||||
type BuildVisibilitySliceFromFactsParams = {
|
||||
facts: KnowledgeFact[];
|
||||
discoveredFactIds?: string[] | null;
|
||||
activeThreadIds?: string[] | null;
|
||||
disclosureStage?: NpcDisclosureStage | null;
|
||||
isFirstMeaningfulContact?: boolean;
|
||||
};
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 20) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function canDiscloseFact(
|
||||
fact: KnowledgeFact,
|
||||
disclosureStage: NpcDisclosureStage | null | undefined,
|
||||
isFirstMeaningfulContact: boolean | undefined,
|
||||
) {
|
||||
const visibility = fact.visibility;
|
||||
if (visibility === 'forbidden') return false;
|
||||
if (isFirstMeaningfulContact) {
|
||||
return visibility === 'public' || fact.title.includes('首遇') || fact.title.includes('当前压力');
|
||||
}
|
||||
if (disclosureStage === 'guarded') {
|
||||
return visibility === 'public' || fact.sayability === 'direct';
|
||||
}
|
||||
if (disclosureStage === 'partial') {
|
||||
return fact.sayability !== 'reactive_only';
|
||||
}
|
||||
if (disclosureStage === 'honest') {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildMisdirectionFacts(params: {
|
||||
facts: KnowledgeFact[];
|
||||
activeThreadIds?: string[] | null;
|
||||
}) {
|
||||
return params.facts.filter((fact) =>
|
||||
fact.sayability === 'indirect'
|
||||
&& (
|
||||
!params.activeThreadIds?.length
|
||||
|| fact.relatedThreadIds.some((threadId) => params.activeThreadIds?.includes(threadId))
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildVisibilitySliceFromFacts(
|
||||
params: BuildVisibilitySliceFromFactsParams,
|
||||
) {
|
||||
const discoveredFactIds = new Set(params.discoveredFactIds ?? []);
|
||||
const activeThreadIds = params.activeThreadIds ?? [];
|
||||
const relevantFacts = params.facts.filter((fact) =>
|
||||
activeThreadIds.length <= 0
|
||||
|| fact.relatedThreadIds.length <= 0
|
||||
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
|
||||
);
|
||||
const discoveredFacts = relevantFacts.filter((fact) =>
|
||||
discoveredFactIds.has(fact.id) || fact.visibility === 'public',
|
||||
);
|
||||
const sayableFacts = relevantFacts.filter((fact) =>
|
||||
canDiscloseFact(
|
||||
fact,
|
||||
params.disclosureStage ?? null,
|
||||
params.isFirstMeaningfulContact,
|
||||
)
|
||||
&& (fact.visibility === 'public' || discoveredFactIds.has(fact.id) || fact.sayability === 'direct'),
|
||||
);
|
||||
const inferredFacts = buildMisdirectionFacts({
|
||||
facts: relevantFacts,
|
||||
activeThreadIds,
|
||||
});
|
||||
const forbiddenFacts = relevantFacts.filter((fact) =>
|
||||
['forbidden'].includes(fact.visibility)
|
||||
|| fact.sayability === 'reactive_only'
|
||||
|| (fact.visibility === 'private' && !discoveredFactIds.has(fact.id)),
|
||||
);
|
||||
|
||||
return {
|
||||
factIds: dedupeStrings(relevantFacts.map((fact) => fact.id)),
|
||||
sayableFactIds: dedupeStrings(sayableFacts.map((fact) => fact.id)),
|
||||
inferredFactIds: dedupeStrings(inferredFacts.map((fact) => fact.id)),
|
||||
forbiddenFactIds: dedupeStrings(forbiddenFacts.map((fact) => fact.id)),
|
||||
misdirectionHints: dedupeStrings([
|
||||
...inferredFacts.map((fact) => fact.content),
|
||||
...discoveredFacts
|
||||
.filter((fact) => fact.sayability === 'indirect')
|
||||
.map((fact) => `可让玩家先误以为:${fact.content}`),
|
||||
], 8),
|
||||
} satisfies VisibilitySlice;
|
||||
}
|
||||
118
src/services/storyEngine/knowledgeGraph.test.ts
Normal file
118
src/services/storyEngine/knowledgeGraph.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type CustomWorldProfile,WorldType } from '../../types';
|
||||
import { buildKnowledgeGraph } from './knowledgeGraph';
|
||||
|
||||
const profile: CustomWorldProfile = {
|
||||
id: 'world-1',
|
||||
settingText: '裂潮边城',
|
||||
name: '裂潮边城',
|
||||
subtitle: '旧案回响',
|
||||
summary: '一座在裂潮和旧案之间摇摇欲坠的边城。',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
playerGoal: '查清封桥旧令',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: ['巡边司'],
|
||||
coreConflicts: ['封桥旧案再起'],
|
||||
attributeSchema: {
|
||||
id: 'schema',
|
||||
worldId: 'world-1',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '裂潮边城',
|
||||
settingSummary: '裂潮边城',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
conflictCore: '封桥旧案再起',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'npc-1',
|
||||
name: '梁砺',
|
||||
title: '断桥巡守',
|
||||
role: '巡守',
|
||||
description: '守着断桥与旧哨火的巡守。',
|
||||
backstory: '旧案爆发时,他是最后一个封桥的人。',
|
||||
personality: '警觉直接,不喜欢绕弯。',
|
||||
motivation: '不想让旧案再次借裂潮翻上来。',
|
||||
combatStyle: '长兵先压,再卡住路口。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['封桥', '旧哨火'],
|
||||
tags: ['巡守', '断桥'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只承认自己还在守桥。',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: 0,
|
||||
teaser: '桥还不能放开。',
|
||||
content: '他总先谈桥和路。',
|
||||
contextSnippet: '桥还不能放开。',
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
narrativeProfile: {
|
||||
publicMask: '他只承认自己还在守桥。',
|
||||
firstContactMask: '先别问旧案,桥口还不能放开。',
|
||||
visibleLine: '他把全部注意力都压在桥口和来路上。',
|
||||
hiddenLine: '他真正盯着的是封桥旧令背后的名字。',
|
||||
contradiction: '嘴上只说守桥,提到名单时却会明显收紧语气。',
|
||||
debtOrBurden: '封桥那夜的后果还压在他身上。',
|
||||
taboo: '封桥令',
|
||||
immediatePressure: '裂潮再逼桥口,他不敢轻易放人过去。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
reactionHooks: ['名单', '旧哨火'],
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
themePack: null,
|
||||
storyGraph: {
|
||||
visibleThreads: [
|
||||
{
|
||||
id: 'thread-1',
|
||||
title: '封桥旧案',
|
||||
visibility: 'visible',
|
||||
summary: '封桥旧案再次被人提起。',
|
||||
conflictType: '调查',
|
||||
stakes: '边城安稳',
|
||||
involvedFactionIds: [],
|
||||
involvedActorIds: ['npc-1'],
|
||||
relatedLocationIds: ['bridge'],
|
||||
},
|
||||
],
|
||||
hiddenThreads: [],
|
||||
scars: [
|
||||
{
|
||||
id: 'scar-1',
|
||||
title: '断桥旧痕',
|
||||
pastEvent: '封桥夜留下的旧痕。',
|
||||
publicResidue: '桥柱上还留着旧封痕。',
|
||||
hiddenTruth: '封桥令另有来头。',
|
||||
relatedActorIds: ['npc-1'],
|
||||
relatedLocationIds: ['bridge'],
|
||||
},
|
||||
],
|
||||
motifs: [],
|
||||
},
|
||||
knowledgeFacts: null,
|
||||
threadContracts: null,
|
||||
};
|
||||
|
||||
describe('buildKnowledgeGraph', () => {
|
||||
it('builds actor facts from narrative profile and backstory chapters', () => {
|
||||
const facts = buildKnowledgeGraph(profile);
|
||||
|
||||
expect(facts.some((fact) => fact.title.includes('公开面'))).toBe(true);
|
||||
expect(facts.some((fact) => fact.title.includes('禁区') && fact.visibility === 'forbidden')).toBe(true);
|
||||
expect(facts.some((fact) => fact.title === '表层来意')).toBe(true);
|
||||
});
|
||||
});
|
||||
278
src/services/storyEngine/knowledgeGraph.ts
Normal file
278
src/services/storyEngine/knowledgeGraph.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type {
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleProfile,
|
||||
InventoryItem,
|
||||
KnowledgeFact,
|
||||
WorldStoryGraph,
|
||||
} from '../../types';
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 12) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const ascii = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/giu, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return ascii || 'fact';
|
||||
}
|
||||
|
||||
function createFactId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}:${slugify(label)}:${index + 1}`;
|
||||
}
|
||||
|
||||
function resolveRelatedFacts(
|
||||
role: Pick<CustomWorldRoleProfile, 'id' | 'narrativeProfile'>,
|
||||
graph: WorldStoryGraph,
|
||||
) {
|
||||
const relatedThreadIds =
|
||||
role.narrativeProfile?.relatedThreadIds.length
|
||||
? role.narrativeProfile.relatedThreadIds
|
||||
: graph.visibleThreads.slice(0, 1).map((thread) => thread.id);
|
||||
const relatedScarIds = role.narrativeProfile?.relatedScarIds ?? [];
|
||||
|
||||
return {
|
||||
relatedThreadIds,
|
||||
relatedScarIds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFact(
|
||||
role: Pick<CustomWorldRoleProfile, 'id' | 'name' | 'narrativeProfile'>,
|
||||
graph: WorldStoryGraph,
|
||||
options: {
|
||||
key: string;
|
||||
title: string;
|
||||
content: string;
|
||||
sourceType: KnowledgeFact['sourceType'];
|
||||
visibility: KnowledgeFact['visibility'];
|
||||
sayability: KnowledgeFact['sayability'];
|
||||
aliases?: string[];
|
||||
index: number;
|
||||
},
|
||||
) {
|
||||
const related = resolveRelatedFacts(role, graph);
|
||||
return {
|
||||
id: createFactId(`${role.id}:${options.key}`, options.title, options.index),
|
||||
title: options.title,
|
||||
content: options.content,
|
||||
ownerActorIds: [role.id],
|
||||
relatedThreadIds: related.relatedThreadIds,
|
||||
relatedScarIds: related.relatedScarIds,
|
||||
sourceType: options.sourceType,
|
||||
visibility: options.visibility,
|
||||
sayability: options.sayability,
|
||||
aliases: options.aliases,
|
||||
} satisfies KnowledgeFact;
|
||||
}
|
||||
|
||||
export function buildActorKnowledgeFacts(
|
||||
role: CustomWorldRoleProfile,
|
||||
graph: WorldStoryGraph,
|
||||
) {
|
||||
const profile = role.narrativeProfile;
|
||||
if (!profile) {
|
||||
return [] as KnowledgeFact[];
|
||||
}
|
||||
|
||||
const chapterFacts = role.backstoryReveal.chapters.map((chapter, index) =>
|
||||
buildFact(role, graph, {
|
||||
key: `chapter:${chapter.id}`,
|
||||
title: chapter.title,
|
||||
content: chapter.contextSnippet || chapter.content || chapter.teaser,
|
||||
sourceType: 'actor',
|
||||
visibility:
|
||||
index === 0 ? 'public' : index === 1 ? 'discoverable' : index === 2 ? 'private' : 'forbidden',
|
||||
sayability:
|
||||
index <= 1 ? 'direct' : index === 2 ? 'indirect' : 'reactive_only',
|
||||
aliases: [chapter.id, chapter.teaser],
|
||||
index,
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
buildFact(role, graph, {
|
||||
key: 'publicMask',
|
||||
title: `${role.name}的公开面`,
|
||||
content: profile.publicMask,
|
||||
sourceType: 'actor',
|
||||
visibility: 'public',
|
||||
sayability: 'direct',
|
||||
aliases: [role.name, role.title],
|
||||
index: 0,
|
||||
}),
|
||||
buildFact(role, graph, {
|
||||
key: 'firstContactMask',
|
||||
title: `${role.name}的首遇说辞`,
|
||||
content: profile.firstContactMask,
|
||||
sourceType: 'actor',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'direct',
|
||||
aliases: [role.name, '首遇'],
|
||||
index: 1,
|
||||
}),
|
||||
buildFact(role, graph, {
|
||||
key: 'visibleLine',
|
||||
title: `${role.name}的表层线`,
|
||||
content: profile.visibleLine,
|
||||
sourceType: 'actor',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'direct',
|
||||
aliases: profile.reactionHooks,
|
||||
index: 2,
|
||||
}),
|
||||
buildFact(role, graph, {
|
||||
key: 'contradiction',
|
||||
title: `${role.name}的错位`,
|
||||
content: profile.contradiction,
|
||||
sourceType: 'actor',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'indirect',
|
||||
aliases: profile.reactionHooks,
|
||||
index: 3,
|
||||
}),
|
||||
buildFact(role, graph, {
|
||||
key: 'immediatePressure',
|
||||
title: `${role.name}的当前压力`,
|
||||
content: profile.immediatePressure,
|
||||
sourceType: 'actor',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'direct',
|
||||
aliases: profile.relatedThreadIds,
|
||||
index: 4,
|
||||
}),
|
||||
buildFact(role, graph, {
|
||||
key: 'hiddenLine',
|
||||
title: `${role.name}的隐藏线`,
|
||||
content: profile.hiddenLine,
|
||||
sourceType: 'actor',
|
||||
visibility: 'private',
|
||||
sayability: 'reactive_only',
|
||||
aliases: profile.relatedThreadIds,
|
||||
index: 5,
|
||||
}),
|
||||
buildFact(role, graph, {
|
||||
key: 'debtOrBurden',
|
||||
title: `${role.name}的代价线`,
|
||||
content: profile.debtOrBurden,
|
||||
sourceType: 'actor',
|
||||
visibility: 'private',
|
||||
sayability: 'indirect',
|
||||
aliases: profile.relatedScarIds,
|
||||
index: 6,
|
||||
}),
|
||||
buildFact(role, graph, {
|
||||
key: 'taboo',
|
||||
title: `${role.name}的禁区`,
|
||||
content: profile.taboo,
|
||||
sourceType: 'actor',
|
||||
visibility: 'forbidden',
|
||||
sayability: 'reactive_only',
|
||||
aliases: profile.reactionHooks,
|
||||
index: 7,
|
||||
}),
|
||||
...chapterFacts,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildCarrierKnowledgeFacts(
|
||||
carrier: InventoryItem,
|
||||
_graph: WorldStoryGraph,
|
||||
) {
|
||||
const fingerprint = carrier.runtimeMetadata?.storyFingerprint;
|
||||
if (!fingerprint) {
|
||||
return [] as KnowledgeFact[];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: createFactId(`carrier:${carrier.id}`, carrier.name, 0),
|
||||
title: `${carrier.name}的可见线索`,
|
||||
content: fingerprint.visibleClue,
|
||||
ownerActorIds: [],
|
||||
relatedThreadIds: fingerprint.relatedThreadIds,
|
||||
relatedScarIds: fingerprint.relatedScarIds,
|
||||
sourceType: 'item',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'direct',
|
||||
aliases: dedupeStrings([carrier.name]),
|
||||
},
|
||||
{
|
||||
id: createFactId(`carrier:${carrier.id}`, `${carrier.name}-witness`, 1),
|
||||
title: `${carrier.name}的见证痕`,
|
||||
content: fingerprint.witnessMark,
|
||||
ownerActorIds: [],
|
||||
relatedThreadIds: fingerprint.relatedThreadIds,
|
||||
relatedScarIds: fingerprint.relatedScarIds,
|
||||
sourceType: 'item',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'indirect',
|
||||
aliases: fingerprint.reactionHooks,
|
||||
},
|
||||
{
|
||||
id: createFactId(`carrier:${carrier.id}`, `${carrier.name}-question`, 2),
|
||||
title: `${carrier.name}的未完成问题`,
|
||||
content: fingerprint.unresolvedQuestion,
|
||||
ownerActorIds: [],
|
||||
relatedThreadIds: fingerprint.relatedThreadIds,
|
||||
relatedScarIds: fingerprint.relatedScarIds,
|
||||
sourceType: 'item',
|
||||
visibility: 'private',
|
||||
sayability: 'indirect',
|
||||
aliases: fingerprint.reactionHooks,
|
||||
},
|
||||
] satisfies KnowledgeFact[];
|
||||
}
|
||||
|
||||
function buildSceneKnowledgeFacts(profile: CustomWorldProfile, graph: WorldStoryGraph) {
|
||||
return profile.landmarks.flatMap((landmark, landmarkIndex) =>
|
||||
(landmark.narrativeResidues ?? []).map((residue, residueIndex) => ({
|
||||
id: createFactId(`scene:${landmark.id}`, residue.title, residueIndex + landmarkIndex * 10),
|
||||
title: residue.title,
|
||||
content: residue.visibleClue,
|
||||
ownerActorIds: landmark.sceneNpcIds,
|
||||
relatedThreadIds: residue.linkedThreadIds,
|
||||
relatedScarIds: graph.scars
|
||||
.filter((scar) => scar.relatedLocationIds.includes(landmark.id))
|
||||
.map((scar) => scar.id),
|
||||
sourceType: 'scene',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'direct',
|
||||
aliases: [landmark.name, residue.title],
|
||||
}) satisfies KnowledgeFact),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildKnowledgeGraph(profile: CustomWorldProfile) {
|
||||
const graph = profile.storyGraph;
|
||||
if (!graph) {
|
||||
return [] as KnowledgeFact[];
|
||||
}
|
||||
|
||||
return dedupeStrings([
|
||||
...profile.playableNpcs.flatMap((role) => buildActorKnowledgeFacts(role, graph).map((fact) => fact.id)),
|
||||
]).length >= 0
|
||||
? [
|
||||
...profile.playableNpcs.flatMap((role) => buildActorKnowledgeFacts(role, graph)),
|
||||
...profile.storyNpcs.flatMap((role) => buildActorKnowledgeFacts(role, graph)),
|
||||
...profile.items.flatMap((item) =>
|
||||
buildCarrierKnowledgeFacts(
|
||||
{
|
||||
id: item.id,
|
||||
category: item.category,
|
||||
name: item.name,
|
||||
quantity: 1,
|
||||
rarity: item.rarity,
|
||||
tags: item.tags,
|
||||
description: item.description,
|
||||
} as InventoryItem,
|
||||
graph,
|
||||
),
|
||||
),
|
||||
...buildSceneKnowledgeFacts(profile, graph),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
54
src/services/storyEngine/narrativeCarrierCatalog.ts
Normal file
54
src/services/storyEngine/narrativeCarrierCatalog.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { GameState } from '../../types';
|
||||
|
||||
export interface NarrativeCarrierRecord {
|
||||
id: string;
|
||||
type: 'item' | 'scene_residue';
|
||||
title: string;
|
||||
visibleClue: string;
|
||||
relatedThreadIds: string[];
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[]) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
export function buildNarrativeCarrierCatalog(state: GameState) {
|
||||
const itemCarriers = state.playerInventory.flatMap((item) => {
|
||||
const fingerprint = item.runtimeMetadata?.storyFingerprint;
|
||||
if (!fingerprint) return [];
|
||||
return [{
|
||||
id: item.id,
|
||||
type: 'item',
|
||||
title: item.name,
|
||||
visibleClue: fingerprint.visibleClue,
|
||||
relatedThreadIds: fingerprint.relatedThreadIds,
|
||||
} satisfies NarrativeCarrierRecord];
|
||||
});
|
||||
const sceneResidues = (state.currentScenePreset?.narrativeResidues ?? []).map((residue) => ({
|
||||
id: residue.id,
|
||||
type: 'scene_residue',
|
||||
title: residue.title,
|
||||
visibleClue: residue.visibleClue,
|
||||
relatedThreadIds: residue.linkedThreadIds,
|
||||
}) satisfies NarrativeCarrierRecord);
|
||||
|
||||
return [...itemCarriers, ...sceneResidues];
|
||||
}
|
||||
|
||||
export function resolveCarrierById(
|
||||
catalog: NarrativeCarrierRecord[],
|
||||
id: string,
|
||||
) {
|
||||
return catalog.find((carrier) => carrier.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function buildRecentCarrierEchoes(state: GameState) {
|
||||
const catalog = buildNarrativeCarrierCatalog(state);
|
||||
const recentIds = state.storyEngineMemory?.recentCarrierIds ?? [];
|
||||
|
||||
return dedupeStrings(
|
||||
recentIds
|
||||
.map((carrierId) => resolveCarrierById(catalog, carrierId)?.visibleClue ?? '')
|
||||
.filter(Boolean),
|
||||
).slice(0, 4);
|
||||
}
|
||||
62
src/services/storyEngine/narrativeCodex.test.ts
Normal file
62
src/services/storyEngine/narrativeCodex.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildNarrativeCodex } from './narrativeCodex';
|
||||
|
||||
describe('narrativeCodex', () => {
|
||||
it('builds codex sections from facts, documents, scenes, and chronicle', () => {
|
||||
const codex = buildNarrativeCodex({
|
||||
customWorldProfile: {
|
||||
knowledgeFacts: [
|
||||
{
|
||||
id: 'fact-1',
|
||||
title: '封桥旧令',
|
||||
content: '那夜真正下令的人还没露面。',
|
||||
ownerActorIds: [],
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: [],
|
||||
sourceType: 'actor',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'indirect',
|
||||
},
|
||||
],
|
||||
},
|
||||
playerInventory: [
|
||||
{
|
||||
id: 'document-1',
|
||||
category: '文书',
|
||||
name: '断桥调查简札',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['document'],
|
||||
description: '记录着当前线程的阶段性线索。',
|
||||
},
|
||||
],
|
||||
currentScenePreset: {
|
||||
narrativeResidues: [
|
||||
{
|
||||
id: 'residue-1',
|
||||
title: '断桥旧痕',
|
||||
visibleClue: '桥柱上还留着旧封痕。',
|
||||
linkedFactIds: ['fact-1'],
|
||||
linkedThreadIds: ['thread-1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
storyEngineMemory: {
|
||||
chronicle: [
|
||||
{
|
||||
id: 'chronicle-1',
|
||||
category: 'thread',
|
||||
title: '封桥旧案·展开',
|
||||
summary: '旧案正在铺开。',
|
||||
relatedIds: ['thread-1'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(codex.length).toBeGreaterThan(0);
|
||||
expect(codex.some((section) => section.title === '文书与证据')).toBe(true);
|
||||
});
|
||||
});
|
||||
115
src/services/storyEngine/narrativeCodex.ts
Normal file
115
src/services/storyEngine/narrativeCodex.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
GameState,
|
||||
NarrativeCodexEntry,
|
||||
NarrativeCodexSection,
|
||||
} from '../../types';
|
||||
|
||||
function toEntry(params: {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
category: NarrativeCodexEntry['category'];
|
||||
relatedIds?: string[];
|
||||
}) {
|
||||
return {
|
||||
id: params.id,
|
||||
title: params.title,
|
||||
summary: params.summary,
|
||||
category: params.category,
|
||||
relatedIds: params.relatedIds ?? [],
|
||||
} satisfies NarrativeCodexEntry;
|
||||
}
|
||||
|
||||
export function buildCodexSections(state: GameState) {
|
||||
const factEntries = (state.customWorldProfile?.knowledgeFacts ?? [])
|
||||
.slice(0, 12)
|
||||
.map((fact) =>
|
||||
toEntry({
|
||||
id: fact.id,
|
||||
title: fact.title,
|
||||
summary: fact.content,
|
||||
category: 'fact',
|
||||
relatedIds: fact.relatedThreadIds,
|
||||
}),
|
||||
);
|
||||
const documentEntries = state.playerInventory
|
||||
.filter((item) => item.category === '文书' || item.tags.includes('document'))
|
||||
.map((item) =>
|
||||
toEntry({
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
summary: item.description ?? '',
|
||||
category: 'document',
|
||||
}),
|
||||
);
|
||||
const sceneEntries = (state.currentScenePreset?.narrativeResidues ?? []).map((residue) =>
|
||||
toEntry({
|
||||
id: residue.id,
|
||||
title: residue.title,
|
||||
summary: residue.visibleClue,
|
||||
category: 'scene',
|
||||
relatedIds: residue.linkedThreadIds,
|
||||
}),
|
||||
);
|
||||
const chronicleEntries = (state.storyEngineMemory?.chronicle ?? []).map((entry) =>
|
||||
toEntry({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
category:
|
||||
entry.category === 'carrier'
|
||||
? 'document'
|
||||
: entry.category === 'scene'
|
||||
? 'scene'
|
||||
: entry.category === 'companion'
|
||||
? 'companion'
|
||||
: entry.category === 'thread'
|
||||
? 'thread'
|
||||
: 'fact',
|
||||
relatedIds: entry.relatedIds,
|
||||
}),
|
||||
);
|
||||
const endingEntry = state.storyEngineMemory?.endingState
|
||||
? [
|
||||
toEntry({
|
||||
id: state.storyEngineMemory.endingState.id,
|
||||
title: state.storyEngineMemory.endingState.title,
|
||||
summary: state.storyEngineMemory.endingState.summary,
|
||||
category: 'ending',
|
||||
relatedIds: state.storyEngineMemory.endingState.contributingThreadIds,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'codex-facts',
|
||||
title: '关键真相',
|
||||
entries: factEntries,
|
||||
},
|
||||
{
|
||||
id: 'codex-documents',
|
||||
title: '文书与证据',
|
||||
entries: documentEntries,
|
||||
},
|
||||
{
|
||||
id: 'codex-scenes',
|
||||
title: '场景残痕',
|
||||
entries: sceneEntries,
|
||||
},
|
||||
{
|
||||
id: 'codex-chronicle',
|
||||
title: '旅程记要',
|
||||
entries: chronicleEntries,
|
||||
},
|
||||
{
|
||||
id: 'codex-ending',
|
||||
title: '结局与尾声',
|
||||
entries: endingEntry,
|
||||
},
|
||||
].filter((section) => section.entries.length > 0) satisfies NarrativeCodexSection[];
|
||||
}
|
||||
|
||||
export function buildNarrativeCodex(state: GameState) {
|
||||
return buildCodexSections(state);
|
||||
}
|
||||
41
src/services/storyEngine/narrativeConsistencyChecks.test.ts
Normal file
41
src/services/storyEngine/narrativeConsistencyChecks.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runNarrativeConsistencyChecks } from './narrativeConsistencyChecks';
|
||||
|
||||
describe('narrativeConsistencyChecks', () => {
|
||||
it('flags too many active threads and missing payoff chronicle', () => {
|
||||
const issues = runNarrativeConsistencyChecks({
|
||||
memory: {
|
||||
discoveredFactIds: [],
|
||||
inferredFactIds: [],
|
||||
activeThreadIds: ['a', 'b', 'c', 'd', 'e'],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
chronicle: [],
|
||||
},
|
||||
threadContracts: [
|
||||
{
|
||||
id: 'contract-1',
|
||||
threadId: 'thread-1',
|
||||
issuerActorId: null,
|
||||
narrativeType: 'investigation',
|
||||
currentStepId: null,
|
||||
visibleStage: 0,
|
||||
steps: [],
|
||||
followupThreadIds: [],
|
||||
},
|
||||
],
|
||||
branchBudgetStatus: {
|
||||
currentMajorDivergences: 1,
|
||||
maxMajorDivergences: 3,
|
||||
currentEndingFamilies: 1,
|
||||
maxEndingFamilies: 5,
|
||||
pressure: 'low',
|
||||
issues: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(issues.some((issue) => issue.category === 'pacing')).toBe(true);
|
||||
expect(issues.some((issue) => issue.category === 'payoff')).toBe(true);
|
||||
});
|
||||
});
|
||||
42
src/services/storyEngine/narrativeConsistencyChecks.ts
Normal file
42
src/services/storyEngine/narrativeConsistencyChecks.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
NarrativeQaIssue,
|
||||
StoryEngineMemoryState,
|
||||
ThreadContract,
|
||||
} from '../../types';
|
||||
import type { BranchBudgetStatus } from './branchBudgetPlanner';
|
||||
|
||||
export function runNarrativeConsistencyChecks(params: {
|
||||
memory: StoryEngineMemoryState;
|
||||
threadContracts: ThreadContract[];
|
||||
branchBudgetStatus: BranchBudgetStatus;
|
||||
}) {
|
||||
const issues: NarrativeQaIssue[] = [];
|
||||
|
||||
const unresolvedThreadCount = params.memory.activeThreadIds.length;
|
||||
if (unresolvedThreadCount > 4) {
|
||||
issues.push({
|
||||
id: 'too-many-active-threads',
|
||||
severity: 'medium',
|
||||
category: 'pacing',
|
||||
summary: '当前同时活跃的线程过多,可能会稀释章节焦点。',
|
||||
relatedIds: params.memory.activeThreadIds,
|
||||
});
|
||||
}
|
||||
|
||||
const missingPayoffContracts = params.threadContracts.filter((contract) =>
|
||||
!params.memory.chronicle?.some((entry) =>
|
||||
entry.relatedIds.includes(contract.threadId),
|
||||
),
|
||||
);
|
||||
if (missingPayoffContracts.length > 0) {
|
||||
issues.push({
|
||||
id: 'missing-payoff-contracts',
|
||||
severity: 'medium',
|
||||
category: 'payoff',
|
||||
summary: '有线程合约尚未在 chronicle 中留下足够的回收痕迹。',
|
||||
relatedIds: missingPayoffContracts.map((contract) => contract.threadId),
|
||||
});
|
||||
}
|
||||
|
||||
return [...issues, ...params.branchBudgetStatus.issues];
|
||||
}
|
||||
22
src/services/storyEngine/narrativeQaReport.test.ts
Normal file
22
src/services/storyEngine/narrativeQaReport.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildNarrativeQaReport } from './narrativeQaReport';
|
||||
|
||||
describe('narrativeQaReport', () => {
|
||||
it('summarizes QA issues', () => {
|
||||
const report = buildNarrativeQaReport({
|
||||
issues: [
|
||||
{
|
||||
id: 'issue-1',
|
||||
severity: 'high',
|
||||
category: 'payoff',
|
||||
summary: '有关键 payoff 尚未回收。',
|
||||
relatedIds: ['thread-1'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(report.issues).toHaveLength(1);
|
||||
expect(report.summary).toContain('1 条');
|
||||
});
|
||||
});
|
||||
20
src/services/storyEngine/narrativeQaReport.ts
Normal file
20
src/services/storyEngine/narrativeQaReport.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
NarrativeQaIssue,
|
||||
NarrativeQaReport,
|
||||
} from '../../types';
|
||||
|
||||
export function buildNarrativeQaReport(params: {
|
||||
issues: NarrativeQaIssue[];
|
||||
}) {
|
||||
const issues = params.issues;
|
||||
const summary =
|
||||
issues.length <= 0
|
||||
? '当前 campaign 叙事结构未发现明显的节奏、泄露或收束问题。'
|
||||
: `当前 campaign 共有 ${issues.length} 条需要关注的叙事 QA 问题。`;
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
issues,
|
||||
summary,
|
||||
} satisfies NarrativeQaReport;
|
||||
}
|
||||
28
src/services/storyEngine/narrativeRegressionReplay.test.ts
Normal file
28
src/services/storyEngine/narrativeRegressionReplay.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { recordReplaySeed, replayNarrativeRun } from './narrativeRegressionReplay';
|
||||
|
||||
describe('narrativeRegressionReplay', () => {
|
||||
it('records and replays a narrative seed summary', () => {
|
||||
const seed = recordReplaySeed({
|
||||
seed: 'baseline',
|
||||
label: 'Baseline',
|
||||
});
|
||||
const replay = replayNarrativeRun({
|
||||
recordedSeed: seed,
|
||||
result: {
|
||||
id: 'simulation-1',
|
||||
scenarioPackId: 'scenario-1',
|
||||
campaignPackId: 'campaign-1',
|
||||
seed: 'baseline',
|
||||
endingId: 'ending-1',
|
||||
activeThreadCountPeak: 2,
|
||||
fracturedCompanionCount: 0,
|
||||
issueCount: 1,
|
||||
summary: 'Baseline summary',
|
||||
},
|
||||
});
|
||||
|
||||
expect(replay.summary).toContain('Baseline');
|
||||
});
|
||||
});
|
||||
29
src/services/storyEngine/narrativeRegressionReplay.ts
Normal file
29
src/services/storyEngine/narrativeRegressionReplay.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { SimulationRunResult } from '../../types';
|
||||
|
||||
export interface NarrativeReplaySeed {
|
||||
id: string;
|
||||
seed: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function recordReplaySeed(params: {
|
||||
seed: string;
|
||||
label: string;
|
||||
}) {
|
||||
return {
|
||||
id: `replay-seed:${params.seed}`,
|
||||
seed: params.seed,
|
||||
label: params.label,
|
||||
} satisfies NarrativeReplaySeed;
|
||||
}
|
||||
|
||||
export function replayNarrativeRun(params: {
|
||||
recordedSeed: NarrativeReplaySeed;
|
||||
result: SimulationRunResult;
|
||||
}) {
|
||||
return {
|
||||
replayId: `replay:${params.recordedSeed.id}`,
|
||||
seed: params.recordedSeed.seed,
|
||||
summary: `${params.recordedSeed.label} 回放结果:${params.result.summary}`,
|
||||
};
|
||||
}
|
||||
34
src/services/storyEngine/narrativeTelemetry.test.ts
Normal file
34
src/services/storyEngine/narrativeTelemetry.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTelemetrySnapshot, captureNarrativeTelemetry } from './narrativeTelemetry';
|
||||
|
||||
describe('narrativeTelemetry', () => {
|
||||
it('builds telemetry snapshots and summaries', () => {
|
||||
const snapshot = buildTelemetrySnapshot({
|
||||
memory: {
|
||||
activeThreadIds: ['a', 'b'],
|
||||
recentCompanionReactions: [{}, {}],
|
||||
endingState: { id: 'ending-1' },
|
||||
} as never,
|
||||
qaReport: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
issues: [{ id: 'issue-1', severity: 'medium', category: 'payoff', summary: 'missing', relatedIds: [] }],
|
||||
summary: '1 issue',
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.averageActiveThreadCount).toBe(2);
|
||||
expect(captureNarrativeTelemetry({
|
||||
memory: {
|
||||
activeThreadIds: ['a', 'b'],
|
||||
recentCompanionReactions: [{}, {}],
|
||||
endingState: { id: 'ending-1' },
|
||||
} as never,
|
||||
qaReport: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
issues: [{ id: 'issue-1', severity: 'medium', category: 'payoff', summary: 'missing', relatedIds: [] }],
|
||||
summary: '1 issue',
|
||||
},
|
||||
}).summary).toContain('平均活跃线程');
|
||||
});
|
||||
});
|
||||
35
src/services/storyEngine/narrativeTelemetry.ts
Normal file
35
src/services/storyEngine/narrativeTelemetry.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
NarrativeQaReport,
|
||||
StoryEngineMemoryState,
|
||||
} from '../../types';
|
||||
|
||||
export interface NarrativeTelemetrySnapshot {
|
||||
averageActiveThreadCount: number;
|
||||
companionReactionDensity: number;
|
||||
endingFamilyCount: number;
|
||||
unresolvedPayoffCount: number;
|
||||
}
|
||||
|
||||
export function buildTelemetrySnapshot(params: {
|
||||
memory: StoryEngineMemoryState;
|
||||
qaReport?: NarrativeQaReport | null;
|
||||
}) {
|
||||
return {
|
||||
averageActiveThreadCount: params.memory.activeThreadIds.length,
|
||||
companionReactionDensity: params.memory.recentCompanionReactions?.length ?? 0,
|
||||
endingFamilyCount: params.memory.endingState ? 1 : 0,
|
||||
unresolvedPayoffCount:
|
||||
params.qaReport?.issues.filter((issue) => issue.category === 'payoff').length ?? 0,
|
||||
} satisfies NarrativeTelemetrySnapshot;
|
||||
}
|
||||
|
||||
export function captureNarrativeTelemetry(params: {
|
||||
memory: StoryEngineMemoryState;
|
||||
qaReport?: NarrativeQaReport | null;
|
||||
}) {
|
||||
const snapshot = buildTelemetrySnapshot(params);
|
||||
return {
|
||||
...snapshot,
|
||||
summary: `当前平均活跃线程 ${snapshot.averageActiveThreadCount},队友反应密度 ${snapshot.companionReactionDensity},未回收 payoff ${snapshot.unresolvedPayoffCount}。`,
|
||||
};
|
||||
}
|
||||
16
src/services/storyEngine/playerStyleProfiler.test.ts
Normal file
16
src/services/storyEngine/playerStyleProfiler.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPlayerStyleProfile, updatePlayerStyleProfileFromAction } from './playerStyleProfiler';
|
||||
|
||||
describe('playerStyleProfiler', () => {
|
||||
it('builds defaults and updates style from actions', () => {
|
||||
const profile = buildPlayerStyleProfile({ storyEngineMemory: {} } as never);
|
||||
const next = updatePlayerStyleProfileFromAction({
|
||||
current: profile,
|
||||
actionText: '我想先和同伴聊聊,再去观察周围残痕',
|
||||
});
|
||||
|
||||
expect(next.preferenceWeights.companion).toBeGreaterThan(profile.preferenceWeights.companion);
|
||||
expect(next.preferenceWeights.exploration).toBeGreaterThan(profile.preferenceWeights.exploration);
|
||||
});
|
||||
});
|
||||
85
src/services/storyEngine/playerStyleProfiler.ts
Normal file
85
src/services/storyEngine/playerStyleProfiler.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
GameState,
|
||||
PlayerStyleProfile,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
|
||||
function clampWeight(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function resolveDominantStyle(weights: PlayerStyleProfile['preferenceWeights']) {
|
||||
const entries = Object.entries(weights) as Array<
|
||||
[keyof PlayerStyleProfile['preferenceWeights'], number]
|
||||
>;
|
||||
entries.sort((a, b) => b[1] - a[1]);
|
||||
const top = entries[0]?.[0] ?? 'story';
|
||||
if (top === 'story') return 'story_first';
|
||||
if (top === 'exploration') return 'explorer';
|
||||
if (top === 'combat') return 'combat_driver';
|
||||
if (top === 'companion') return 'companion_bond';
|
||||
return 'collector';
|
||||
}
|
||||
|
||||
export function buildPlayerStyleProfile(state: GameState) {
|
||||
const existing = state.storyEngineMemory?.playerStyleProfile;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const weights = {
|
||||
story: 45,
|
||||
exploration: 40,
|
||||
combat: 35,
|
||||
companion: 35,
|
||||
collection: 30,
|
||||
};
|
||||
|
||||
return {
|
||||
id: 'player-style:default',
|
||||
preferenceWeights: weights,
|
||||
dominantStyle: resolveDominantStyle(weights),
|
||||
} satisfies PlayerStyleProfile;
|
||||
}
|
||||
|
||||
export function updatePlayerStyleProfileFromAction(params: {
|
||||
current: PlayerStyleProfile | null | undefined;
|
||||
actionText: string;
|
||||
option?: StoryOption | null;
|
||||
}) {
|
||||
const current =
|
||||
params.current ??
|
||||
({
|
||||
id: 'player-style:default',
|
||||
preferenceWeights: {
|
||||
story: 45,
|
||||
exploration: 40,
|
||||
combat: 35,
|
||||
companion: 35,
|
||||
collection: 30,
|
||||
},
|
||||
dominantStyle: 'story_first',
|
||||
} satisfies PlayerStyleProfile);
|
||||
const nextWeights = { ...current.preferenceWeights };
|
||||
const text = `${params.actionText} ${params.option?.functionId ?? ''}`;
|
||||
|
||||
if (/聊|问|私聊|同伴|营地/u.test(text)) nextWeights.companion += 4;
|
||||
if (/探|观察|前进|调查|场景|线索/u.test(text)) nextWeights.exploration += 4;
|
||||
if (/战|攻击|切磋|压制|收割/u.test(text)) nextWeights.combat += 4;
|
||||
if (/文书|证据|残痕|任务|剧情/u.test(text)) nextWeights.story += 4;
|
||||
if (/拿|获得|收集|宝藏|拾取/u.test(text)) nextWeights.collection += 4;
|
||||
|
||||
const normalizedWeights = {
|
||||
story: clampWeight(nextWeights.story),
|
||||
exploration: clampWeight(nextWeights.exploration),
|
||||
combat: clampWeight(nextWeights.combat),
|
||||
companion: clampWeight(nextWeights.companion),
|
||||
collection: clampWeight(nextWeights.collection),
|
||||
};
|
||||
|
||||
return {
|
||||
...current,
|
||||
preferenceWeights: normalizedWeights,
|
||||
dominantStyle: resolveDominantStyle(normalizedWeights),
|
||||
} satisfies PlayerStyleProfile;
|
||||
}
|
||||
34
src/services/storyEngine/playthroughMatrixLab.test.ts
Normal file
34
src/services/storyEngine/playthroughMatrixLab.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildMatrixSummary, runPlaythroughMatrix } from './playthroughMatrixLab';
|
||||
|
||||
describe('playthroughMatrixLab', () => {
|
||||
it('runs multiple deterministic simulations and summarizes them', () => {
|
||||
const results = runPlaythroughMatrix({
|
||||
scenarioPackId: 'scenario-1',
|
||||
campaignPack: {
|
||||
id: 'campaign-1',
|
||||
scenarioPackId: 'scenario-1',
|
||||
title: 'Campaign',
|
||||
authoringStyle: 'classic',
|
||||
campaignStateSeed: {
|
||||
id: 'campaign-state',
|
||||
title: 'Campaign',
|
||||
currentActId: 'act-1',
|
||||
currentActIndex: 0,
|
||||
},
|
||||
actTemplates: [],
|
||||
requiredCompanionIds: [],
|
||||
},
|
||||
memory: {
|
||||
activeThreadIds: ['thread-1'],
|
||||
companionResolutions: [],
|
||||
endingState: null,
|
||||
} as never,
|
||||
seeds: ['a', 'b', 'c'],
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(buildMatrixSummary(results)).toContain('3 条');
|
||||
});
|
||||
});
|
||||
32
src/services/storyEngine/playthroughMatrixLab.ts
Normal file
32
src/services/storyEngine/playthroughMatrixLab.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
CampaignPack,
|
||||
SimulationRunResult,
|
||||
StoryEngineMemoryState,
|
||||
} from '../../types';
|
||||
import { runStorySimulation } from './storySimulationRunner';
|
||||
|
||||
export function runPlaythroughMatrix(params: {
|
||||
scenarioPackId: string;
|
||||
campaignPack: CampaignPack;
|
||||
memory: StoryEngineMemoryState;
|
||||
seeds: string[];
|
||||
}) {
|
||||
return params.seeds.map((seed) =>
|
||||
runStorySimulation({
|
||||
scenarioPackId: params.scenarioPackId,
|
||||
campaignPack: params.campaignPack,
|
||||
memory: params.memory,
|
||||
seed,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMatrixSummary(results: SimulationRunResult[]) {
|
||||
if (results.length <= 0) {
|
||||
return '当前没有可用的仿真结果。';
|
||||
}
|
||||
|
||||
const endingCount = new Set(results.map((result) => result.endingId ?? 'none')).size;
|
||||
const maxIssueCount = results.reduce((max, result) => Math.max(max, result.issueCount), 0);
|
||||
return `共跑了 ${results.length} 条 simulation,ending family ${endingCount} 类,单次最高 QA 问题 ${maxIssueCount} 条。`;
|
||||
}
|
||||
86
src/services/storyEngine/recapDigest.test.ts
Normal file
86
src/services/storyEngine/recapDigest.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState } from '../../types';
|
||||
import { buildChapterRecap, buildContinueGameDigest } from './recapDigest';
|
||||
|
||||
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: ['carrier-1'],
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
currentChapter: {
|
||||
id: 'chapter-1',
|
||||
title: '封桥旧案·展开',
|
||||
theme: '封桥旧案',
|
||||
primaryThreadIds: ['thread-1'],
|
||||
stage: 'expansion',
|
||||
chapterSummary: '旧案正在铺开。',
|
||||
},
|
||||
currentJourneyBeatId: null,
|
||||
companionArcStates: [],
|
||||
worldMutations: [],
|
||||
chronicle: [],
|
||||
factionTensionStates: [],
|
||||
currentCampEvent: null,
|
||||
currentSetpieceDirective: null,
|
||||
continueGameDigest: null,
|
||||
},
|
||||
chapterState: null,
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
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: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} satisfies GameState;
|
||||
|
||||
describe('recapDigest', () => {
|
||||
it('builds chapter recap and continue digest', () => {
|
||||
expect(buildChapterRecap({ state })).toContain('封桥旧案·展开');
|
||||
expect(buildContinueGameDigest({ state })).toContain('carrier-1');
|
||||
});
|
||||
});
|
||||
32
src/services/storyEngine/recapDigest.ts
Normal file
32
src/services/storyEngine/recapDigest.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { GameState } from '../../types';
|
||||
import { buildChronicleSummary } from './storyChronicle';
|
||||
|
||||
export function buildChapterRecap(params: {
|
||||
state: GameState;
|
||||
}) {
|
||||
const chapter = params.state.chapterState ?? params.state.storyEngineMemory?.currentChapter;
|
||||
if (!chapter) {
|
||||
return '当前旅程仍在积累新的线索与关系变化。';
|
||||
}
|
||||
|
||||
return [
|
||||
`${chapter.title}:${chapter.chapterSummary}`,
|
||||
buildChronicleSummary(params.state),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildContinueGameDigest(params: {
|
||||
state: GameState;
|
||||
}) {
|
||||
const recap = buildChapterRecap(params);
|
||||
const carrierEchoes = params.state.storyEngineMemory?.recentCarrierIds?.slice(-3).join('、');
|
||||
|
||||
return [
|
||||
recap,
|
||||
carrierEchoes ? `最近仍在回响的载体:${carrierEchoes}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
19
src/services/storyEngine/releaseGateReport.test.ts
Normal file
19
src/services/storyEngine/releaseGateReport.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildReleaseGateReport } from './releaseGateReport';
|
||||
|
||||
describe('releaseGateReport', () => {
|
||||
it('blocks when high severity QA issues exist', () => {
|
||||
const report = buildReleaseGateReport({
|
||||
qaReport: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
issues: [{ id: 'issue-1', severity: 'high', category: 'payoff', summary: 'critical', relatedIds: [] }],
|
||||
summary: 'critical issue',
|
||||
},
|
||||
simulationResults: [],
|
||||
unresolvedThreadCount: 0,
|
||||
});
|
||||
|
||||
expect(report.status).toBe('block');
|
||||
});
|
||||
});
|
||||
36
src/services/storyEngine/releaseGateReport.ts
Normal file
36
src/services/storyEngine/releaseGateReport.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
NarrativeQaReport,
|
||||
ReleaseGateReport,
|
||||
SimulationRunResult,
|
||||
} from '../../types';
|
||||
|
||||
export function buildReleaseGateReport(params: {
|
||||
qaReport: NarrativeQaReport | null | undefined;
|
||||
simulationResults: SimulationRunResult[];
|
||||
unresolvedThreadCount: number;
|
||||
}) {
|
||||
const issueCount = params.qaReport?.issues.length ?? 0;
|
||||
const blockingIssues = [
|
||||
...(params.qaReport?.issues.filter((issue) => issue.severity === 'high').map((issue) => issue.summary) ?? []),
|
||||
...(params.unresolvedThreadCount > 5 ? ['活跃线程过多,可能会导致结局收束不稳。'] : []),
|
||||
];
|
||||
const status =
|
||||
blockingIssues.length > 0
|
||||
? 'block'
|
||||
: issueCount > 0
|
||||
? 'warn'
|
||||
: 'pass';
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
status,
|
||||
summary:
|
||||
status === 'pass'
|
||||
? '当前版本通过 narrative release gate。'
|
||||
: status === 'warn'
|
||||
? '当前版本可继续观察,但仍有若干 narrative 风险。'
|
||||
: '当前版本存在 narrative 阻断问题,不建议发布。',
|
||||
blockingIssues,
|
||||
simulationCoverage: params.simulationResults.length,
|
||||
} satisfies ReleaseGateReport;
|
||||
}
|
||||
19
src/services/storyEngine/saveMigrationManifest.test.ts
Normal file
19
src/services/storyEngine/saveMigrationManifest.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyStoryEngineMigration, buildSaveMigrationManifest } from './saveMigrationManifest';
|
||||
|
||||
describe('saveMigrationManifest', () => {
|
||||
it('builds a manifest and applies migration defaults', () => {
|
||||
const manifest = buildSaveMigrationManifest({
|
||||
version: 'story-engine-v5',
|
||||
});
|
||||
const state = applyStoryEngineMigration({
|
||||
state: {
|
||||
storyEngineMemory: undefined,
|
||||
} as never,
|
||||
manifest,
|
||||
});
|
||||
|
||||
expect(state.storyEngineMemory?.saveMigrationManifest?.version).toBe('story-engine-v5');
|
||||
});
|
||||
});
|
||||
36
src/services/storyEngine/saveMigrationManifest.ts
Normal file
36
src/services/storyEngine/saveMigrationManifest.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
GameState,
|
||||
SaveMigrationManifest,
|
||||
} from '../../types';
|
||||
import { createEmptyStoryEngineMemoryState } from './visibilityEngine';
|
||||
|
||||
export function buildSaveMigrationManifest(params: {
|
||||
version: string;
|
||||
}) {
|
||||
return {
|
||||
version: params.version,
|
||||
requiredTransforms: [
|
||||
'ensure_story_engine_memory',
|
||||
'ensure_campaign_state',
|
||||
'ensure_player_style_profile',
|
||||
],
|
||||
backwardCompatible: true,
|
||||
} satisfies SaveMigrationManifest;
|
||||
}
|
||||
|
||||
export function applyStoryEngineMigration(params: {
|
||||
state: GameState;
|
||||
manifest: SaveMigrationManifest;
|
||||
}) {
|
||||
const storyEngineMemory =
|
||||
params.state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
|
||||
return {
|
||||
...params.state,
|
||||
storyEngineMemory: {
|
||||
...createEmptyStoryEngineMemoryState(),
|
||||
...storyEngineMemory,
|
||||
saveMigrationManifest: params.manifest,
|
||||
},
|
||||
};
|
||||
}
|
||||
23
src/services/storyEngine/scenarioPackRegistry.test.ts
Normal file
23
src/services/storyEngine/scenarioPackRegistry.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
listScenarioPacks,
|
||||
registerScenarioPack,
|
||||
resolveScenarioPack,
|
||||
} from './scenarioPackRegistry';
|
||||
|
||||
describe('scenarioPackRegistry', () => {
|
||||
it('registers and resolves scenario packs', () => {
|
||||
const pack = registerScenarioPack({
|
||||
id: 'scenario-pack:test',
|
||||
title: '测试 Scenario',
|
||||
version: '0.1.0',
|
||||
worldPackIds: ['world-1'],
|
||||
campaignIds: ['campaign-1'],
|
||||
sharedConstraintPackIds: ['constraint-1'],
|
||||
});
|
||||
|
||||
expect(resolveScenarioPack(pack.id)?.title).toBe('测试 Scenario');
|
||||
expect(listScenarioPacks().some((item) => item.id === pack.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
16
src/services/storyEngine/scenarioPackRegistry.ts
Normal file
16
src/services/storyEngine/scenarioPackRegistry.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ScenarioPack } from '../../types';
|
||||
|
||||
const scenarioPackRegistry = new Map<string, ScenarioPack>();
|
||||
|
||||
export function registerScenarioPack(pack: ScenarioPack) {
|
||||
scenarioPackRegistry.set(pack.id, pack);
|
||||
return pack;
|
||||
}
|
||||
|
||||
export function resolveScenarioPack(id: string | null | undefined) {
|
||||
return id ? scenarioPackRegistry.get(id) ?? null : null;
|
||||
}
|
||||
|
||||
export function listScenarioPacks() {
|
||||
return [...scenarioPackRegistry.values()];
|
||||
}
|
||||
82
src/services/storyEngine/sceneNarrativeDirector.ts
Normal file
82
src/services/storyEngine/sceneNarrativeDirector.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
ActorNarrativeProfile,
|
||||
NpcDisclosureStage,
|
||||
SceneNarrativeDirective,
|
||||
VisibilitySlice,
|
||||
} from '../../types';
|
||||
|
||||
type BuildSceneNarrativeDirectiveParams = {
|
||||
sceneId?: string | null;
|
||||
sceneName?: string | null;
|
||||
encounterId?: string | null;
|
||||
encounterName?: string | null;
|
||||
recentActions?: string[] | null;
|
||||
activeThreadIds?: string[] | null;
|
||||
foregroundCarrierIds?: string[] | null;
|
||||
visibilitySlice?: VisibilitySlice | null;
|
||||
encounterNarrativeProfile?: ActorNarrativeProfile | null;
|
||||
disclosureStage?: NpcDisclosureStage | null;
|
||||
isFirstMeaningfulContact?: boolean;
|
||||
affinity?: number | null;
|
||||
};
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 6) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function resolveRevealBudget(
|
||||
disclosureStage?: NpcDisclosureStage | null,
|
||||
isFirstMeaningfulContact?: boolean,
|
||||
) {
|
||||
if (isFirstMeaningfulContact || disclosureStage === 'guarded') {
|
||||
return 'low' as const;
|
||||
}
|
||||
if (disclosureStage === 'partial' || disclosureStage === 'honest') {
|
||||
return 'medium' as const;
|
||||
}
|
||||
return 'high' as const;
|
||||
}
|
||||
|
||||
function resolveEmotionalCadence(params: BuildSceneNarrativeDirectiveParams) {
|
||||
if ((params.affinity ?? 0) < -10) {
|
||||
return 'hostile' as const;
|
||||
}
|
||||
if (params.isFirstMeaningfulContact) {
|
||||
return 'curious' as const;
|
||||
}
|
||||
if ((params.visibilitySlice?.forbiddenFactIds.length ?? 0) > 3) {
|
||||
return 'mysterious' as const;
|
||||
}
|
||||
if ((params.affinity ?? 0) >= 45) {
|
||||
return 'intimate' as const;
|
||||
}
|
||||
if ((params.recentActions ?? []).some((action) => /战|伤|逃|追|压/u.test(action))) {
|
||||
return 'tense' as const;
|
||||
}
|
||||
return 'curious' as const;
|
||||
}
|
||||
|
||||
export function buildSceneNarrativeDirective(
|
||||
params: BuildSceneNarrativeDirectiveParams,
|
||||
) {
|
||||
const primaryPressure =
|
||||
params.encounterNarrativeProfile?.immediatePressure ||
|
||||
params.recentActions?.[0] ||
|
||||
`${params.sceneName ?? '当前场景'}里仍有未被说透的动静。`;
|
||||
|
||||
return {
|
||||
primaryPressure,
|
||||
activeThreadIds: dedupeStrings(params.activeThreadIds ?? [], 4),
|
||||
foregroundActorIds: dedupeStrings([
|
||||
params.encounterId,
|
||||
params.encounterName,
|
||||
], 3),
|
||||
foregroundCarrierIds: dedupeStrings(params.foregroundCarrierIds ?? [], 4),
|
||||
revealBudget: resolveRevealBudget(
|
||||
params.disclosureStage,
|
||||
params.isFirstMeaningfulContact,
|
||||
),
|
||||
emotionalCadence: resolveEmotionalCadence(params),
|
||||
} satisfies SceneNarrativeDirective;
|
||||
}
|
||||
86
src/services/storyEngine/sceneResidueCompiler.test.ts
Normal file
86
src/services/storyEngine/sceneResidueCompiler.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type CustomWorldProfile,WorldType } from '../../types';
|
||||
import { buildResidueInspectResult, buildSceneNarrativeResidues } from './sceneResidueCompiler';
|
||||
|
||||
const profile: CustomWorldProfile = {
|
||||
id: 'world-1',
|
||||
settingText: '裂潮边城',
|
||||
name: '裂潮边城',
|
||||
subtitle: '旧案回响',
|
||||
summary: '一座在裂潮和旧案之间摇摇欲坠的边城。',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
playerGoal: '查清封桥旧令',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: ['巡边司'],
|
||||
coreConflicts: ['封桥旧案再起'],
|
||||
attributeSchema: {
|
||||
id: 'schema',
|
||||
worldId: 'world-1',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '裂潮边城',
|
||||
settingSummary: '裂潮边城',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
conflictCore: '封桥旧案再起',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
themePack: null,
|
||||
storyGraph: {
|
||||
visibleThreads: [
|
||||
{
|
||||
id: 'thread-1',
|
||||
title: '封桥旧案',
|
||||
visibility: 'visible',
|
||||
summary: '封桥旧案再次被人提起。',
|
||||
conflictType: '调查',
|
||||
stakes: '边城安稳',
|
||||
involvedFactionIds: [],
|
||||
involvedActorIds: ['npc-1'],
|
||||
relatedLocationIds: ['bridge'],
|
||||
},
|
||||
],
|
||||
hiddenThreads: [],
|
||||
scars: [
|
||||
{
|
||||
id: 'scar-1',
|
||||
title: '断桥旧痕',
|
||||
pastEvent: '封桥夜留下的旧痕。',
|
||||
publicResidue: '桥柱上还留着旧封痕。',
|
||||
hiddenTruth: '封桥令另有来头。',
|
||||
relatedActorIds: ['npc-1'],
|
||||
relatedLocationIds: ['bridge'],
|
||||
},
|
||||
],
|
||||
motifs: [],
|
||||
},
|
||||
knowledgeFacts: null,
|
||||
threadContracts: null,
|
||||
};
|
||||
|
||||
describe('sceneResidueCompiler', () => {
|
||||
it('builds residues and inspect text for a scene', () => {
|
||||
const residues = buildSceneNarrativeResidues({
|
||||
sceneId: 'bridge',
|
||||
sceneName: '断桥旧哨',
|
||||
profile,
|
||||
linkedThreadIds: ['thread-1'],
|
||||
});
|
||||
const text = buildResidueInspectResult({
|
||||
scene: {
|
||||
name: '断桥旧哨',
|
||||
narrativeResidues: residues,
|
||||
},
|
||||
});
|
||||
|
||||
expect(residues.length).toBeGreaterThan(0);
|
||||
expect(text).toContain('断桥旧哨');
|
||||
expect(text).toContain('断桥旧痕');
|
||||
});
|
||||
});
|
||||
66
src/services/storyEngine/sceneResidueCompiler.ts
Normal file
66
src/services/storyEngine/sceneResidueCompiler.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
CustomWorldProfile,
|
||||
SceneNarrativeResidue,
|
||||
ScenePresetInfo,
|
||||
} from '../../types';
|
||||
|
||||
function createResidueId(sceneId: string, index: number) {
|
||||
return `residue:${sceneId}:${index + 1}`;
|
||||
}
|
||||
|
||||
export function buildSceneNarrativeResidues(params: {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
profile?: CustomWorldProfile | null;
|
||||
linkedThreadIds?: string[] | null;
|
||||
}) {
|
||||
const { sceneId, sceneName, profile, linkedThreadIds } = params;
|
||||
if (!profile?.storyGraph) {
|
||||
return [] as SceneNarrativeResidue[];
|
||||
}
|
||||
|
||||
const relatedThreads = [
|
||||
...profile.storyGraph.visibleThreads,
|
||||
...profile.storyGraph.hiddenThreads,
|
||||
].filter((thread) =>
|
||||
thread.relatedLocationIds.includes(sceneId)
|
||||
|| thread.relatedLocationIds.includes(sceneName)
|
||||
|| (linkedThreadIds ?? []).includes(thread.id),
|
||||
);
|
||||
const relatedScars = profile.storyGraph.scars.filter((scar) =>
|
||||
scar.relatedLocationIds.includes(sceneId) || scar.relatedLocationIds.includes(sceneName),
|
||||
);
|
||||
|
||||
return [
|
||||
...relatedScars.map((scar, index) => ({
|
||||
id: createResidueId(sceneId, index),
|
||||
title: scar.title,
|
||||
visibleClue: scar.publicResidue,
|
||||
linkedFactIds: [`scar:${scar.id}`],
|
||||
linkedThreadIds: scar.relatedActorIds.length > 0
|
||||
? relatedThreads.slice(0, 2).map((thread) => thread.id)
|
||||
: [],
|
||||
}) satisfies SceneNarrativeResidue),
|
||||
...relatedThreads.slice(0, 2).map((thread, index) => ({
|
||||
id: createResidueId(sceneId, index + relatedScars.length),
|
||||
title: `${thread.title} 的余波`,
|
||||
visibleClue: `${sceneName}里还能看出:${thread.summary}`,
|
||||
linkedFactIds: [`thread:${thread.id}`],
|
||||
linkedThreadIds: [thread.id],
|
||||
}) satisfies SceneNarrativeResidue),
|
||||
].slice(0, 4);
|
||||
}
|
||||
|
||||
export function buildResidueInspectResult(params: {
|
||||
scene: Pick<ScenePresetInfo, 'name' | 'narrativeResidues'> | null;
|
||||
}) {
|
||||
const residues = params.scene?.narrativeResidues ?? [];
|
||||
if (residues.length <= 0) {
|
||||
return `${params.scene?.name ?? '此地'}表面上没有太多新痕,但空气里仍像压着没被说完的旧事。`;
|
||||
}
|
||||
|
||||
return [
|
||||
`你在${params.scene?.name ?? '此地'}留意到几处不像偶然留下的痕迹:`,
|
||||
...residues.map((residue) => `- ${residue.title}:${residue.visibleClue}`),
|
||||
].join('\n');
|
||||
}
|
||||
36
src/services/storyEngine/setpieceDirector.test.ts
Normal file
36
src/services/storyEngine/setpieceDirector.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ChapterState } from '../../types';
|
||||
import { buildSetpieceDirective,evaluateSetpieceOpportunity } from './setpieceDirector';
|
||||
|
||||
describe('setpieceDirector', () => {
|
||||
it('creates setpiece directives for climax chapters', () => {
|
||||
const chapterState: ChapterState = {
|
||||
id: 'chapter-1',
|
||||
title: '封桥旧案·高潮',
|
||||
theme: '封桥旧案',
|
||||
primaryThreadIds: ['thread-1'],
|
||||
stage: 'climax',
|
||||
chapterSummary: '旧案被逼到台前。',
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateSetpieceOpportunity({
|
||||
state: {
|
||||
currentScenePreset: { id: 'scene-1', currentPressureLevel: 'high' },
|
||||
} as never,
|
||||
chapterState,
|
||||
journeyBeat: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
buildSetpieceDirective({
|
||||
state: {
|
||||
currentScenePreset: { id: 'scene-1' },
|
||||
} as never,
|
||||
chapterState,
|
||||
journeyBeat: null,
|
||||
})?.setpieceType,
|
||||
).toBe('climax');
|
||||
});
|
||||
});
|
||||
50
src/services/storyEngine/setpieceDirector.ts
Normal file
50
src/services/storyEngine/setpieceDirector.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ChapterState, GameState, JourneyBeat, SetpieceDirective } from '../../types';
|
||||
|
||||
export function evaluateSetpieceOpportunity(params: {
|
||||
state: GameState;
|
||||
chapterState: ChapterState | null | undefined;
|
||||
journeyBeat: JourneyBeat | null | undefined;
|
||||
}) {
|
||||
if (!params.chapterState) return false;
|
||||
return (
|
||||
params.chapterState.stage === 'climax'
|
||||
|| params.journeyBeat?.beatType === 'boss_prelude'
|
||||
|| params.state.currentScenePreset?.currentPressureLevel === 'extreme'
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSetpieceDirective(params: {
|
||||
state: GameState;
|
||||
chapterState: ChapterState | null | undefined;
|
||||
journeyBeat: JourneyBeat | null | undefined;
|
||||
}) {
|
||||
if (!params.chapterState) return null;
|
||||
|
||||
const setpieceType: SetpieceDirective['setpieceType'] =
|
||||
params.chapterState.stage === 'aftermath'
|
||||
? 'aftermath'
|
||||
: params.chapterState.stage === 'climax'
|
||||
? 'climax'
|
||||
: params.journeyBeat?.beatType === 'boss_prelude'
|
||||
? 'boss_prelude'
|
||||
: 'showdown';
|
||||
|
||||
return {
|
||||
id: `setpiece:${params.chapterState.id}:${setpieceType}`,
|
||||
title:
|
||||
setpieceType === 'climax'
|
||||
? `${params.chapterState.title}·高潮`
|
||||
: setpieceType === 'aftermath'
|
||||
? `${params.chapterState.title}·余波`
|
||||
: `${params.chapterState.title}·对峙`,
|
||||
setpieceType,
|
||||
relatedThreadIds: params.chapterState.primaryThreadIds,
|
||||
sceneFocusId: params.state.currentScenePreset?.id ?? null,
|
||||
dramaticQuestion:
|
||||
setpieceType === 'climax'
|
||||
? '这一轮冲突会把谁的真相和代价一起推到台前?'
|
||||
: setpieceType === 'aftermath'
|
||||
? '高潮过后,谁会先承受余波?'
|
||||
: '这一步会把暗线逼到什么程度?',
|
||||
} satisfies SetpieceDirective;
|
||||
}
|
||||
98
src/services/storyEngine/storyChronicle.test.ts
Normal file
98
src/services/storyEngine/storyChronicle.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState } from '../../types';
|
||||
import { appendChronicleEntries, buildChronicleSummary } from './storyChronicle';
|
||||
|
||||
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,
|
||||
companionArcStates: [],
|
||||
worldMutations: [],
|
||||
chronicle: [],
|
||||
factionTensionStates: [],
|
||||
currentCampEvent: null,
|
||||
currentSetpieceDirective: null,
|
||||
continueGameDigest: null,
|
||||
},
|
||||
chapterState: null,
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
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: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} satisfies GameState;
|
||||
|
||||
describe('storyChronicle', () => {
|
||||
it('appends chronicle entries and builds summaries', () => {
|
||||
const chronicle = appendChronicleEntries({
|
||||
state,
|
||||
chapterState: {
|
||||
id: 'chapter-1',
|
||||
title: '封桥旧案·展开',
|
||||
theme: '封桥旧案',
|
||||
primaryThreadIds: ['thread-1'],
|
||||
stage: 'expansion',
|
||||
chapterSummary: '旧案正在铺开。',
|
||||
},
|
||||
});
|
||||
const summary = buildChronicleSummary({
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...state.storyEngineMemory!,
|
||||
chronicle,
|
||||
},
|
||||
});
|
||||
|
||||
expect(chronicle.length).toBeGreaterThan(0);
|
||||
expect(summary).toContain('封桥旧案·展开');
|
||||
});
|
||||
});
|
||||
92
src/services/storyEngine/storyChronicle.ts
Normal file
92
src/services/storyEngine/storyChronicle.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CampEvent,
|
||||
ChapterState,
|
||||
ChronicleEntry,
|
||||
CompanionReactionRecord,
|
||||
GameState,
|
||||
SetpieceDirective,
|
||||
StorySignal,
|
||||
WorldMutation,
|
||||
} from '../../types';
|
||||
|
||||
function createChronicleId(category: ChronicleEntry['category'], key: string) {
|
||||
return `chronicle:${category}:${key}`;
|
||||
}
|
||||
|
||||
export function appendChronicleEntries(params: {
|
||||
state: GameState;
|
||||
chapterState?: ChapterState | null;
|
||||
worldMutations?: WorldMutation[];
|
||||
reactions?: CompanionReactionRecord[];
|
||||
signals?: StorySignal[];
|
||||
campEvent?: CampEvent | null;
|
||||
setpieceDirective?: SetpieceDirective | null;
|
||||
}) {
|
||||
const existing = params.state.storyEngineMemory?.chronicle ?? [];
|
||||
const additions: ChronicleEntry[] = [];
|
||||
|
||||
if (params.chapterState) {
|
||||
additions.push({
|
||||
id: createChronicleId('chapter', params.chapterState.id),
|
||||
category: 'chapter',
|
||||
title: params.chapterState.title,
|
||||
summary: params.chapterState.chapterSummary,
|
||||
relatedIds: params.chapterState.primaryThreadIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
(params.worldMutations ?? []).forEach((mutation) => {
|
||||
additions.push({
|
||||
id: createChronicleId('world_event', mutation.id),
|
||||
category: 'world_event',
|
||||
title: mutation.reason,
|
||||
summary: `${mutation.mutationType} 影响了 ${mutation.targetId}`,
|
||||
relatedIds: mutation.relatedThreadIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
(params.reactions ?? []).forEach((reaction) => {
|
||||
additions.push({
|
||||
id: createChronicleId('companion', reaction.id),
|
||||
category: 'companion',
|
||||
title: reaction.characterId,
|
||||
summary: reaction.reason,
|
||||
relatedIds: reaction.relatedThreadIds,
|
||||
createdAt: reaction.createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
if (params.campEvent) {
|
||||
additions.push({
|
||||
id: createChronicleId('companion', params.campEvent.id),
|
||||
category: 'companion',
|
||||
title: params.campEvent.title,
|
||||
summary: params.campEvent.triggerReason,
|
||||
relatedIds: params.campEvent.relatedThreadIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (params.setpieceDirective) {
|
||||
additions.push({
|
||||
id: createChronicleId('thread', params.setpieceDirective.id),
|
||||
category: 'thread',
|
||||
title: params.setpieceDirective.title,
|
||||
summary: params.setpieceDirective.dramaticQuestion,
|
||||
relatedIds: params.setpieceDirective.relatedThreadIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return [...existing, ...additions].slice(-18);
|
||||
}
|
||||
|
||||
export function buildChronicleSummary(state: GameState) {
|
||||
const chronicle = state.storyEngineMemory?.chronicle ?? [];
|
||||
return chronicle
|
||||
.slice(-4)
|
||||
.map((entry) => `- ${entry.title}:${entry.summary}`)
|
||||
.join('\n');
|
||||
}
|
||||
54
src/services/storyEngine/storySimulationRunner.test.ts
Normal file
54
src/services/storyEngine/storySimulationRunner.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runStorySimulation } from './storySimulationRunner';
|
||||
|
||||
describe('storySimulationRunner', () => {
|
||||
it('creates a deterministic simulation result', () => {
|
||||
const result = runStorySimulation({
|
||||
scenarioPackId: 'scenario-1',
|
||||
campaignPack: {
|
||||
id: 'campaign-1',
|
||||
scenarioPackId: 'scenario-1',
|
||||
title: 'Campaign',
|
||||
authoringStyle: 'classic',
|
||||
campaignStateSeed: {
|
||||
id: 'campaign-state',
|
||||
title: 'Campaign',
|
||||
currentActId: 'act-1',
|
||||
currentActIndex: 0,
|
||||
},
|
||||
actTemplates: [],
|
||||
requiredCompanionIds: [],
|
||||
},
|
||||
memory: {
|
||||
activeThreadIds: ['a', 'b'],
|
||||
companionResolutions: [
|
||||
{
|
||||
characterId: 'archer-hero',
|
||||
resolutionType: 'estranged',
|
||||
summary: '离心',
|
||||
relatedThreadIds: [],
|
||||
},
|
||||
],
|
||||
endingState: {
|
||||
id: 'ending-1',
|
||||
title: '结局',
|
||||
endingType: 'heroic',
|
||||
summary: '达成',
|
||||
contributingThreadIds: [],
|
||||
companionResolutions: [],
|
||||
worldOutcomeSummary: '稳定',
|
||||
},
|
||||
narrativeQaReport: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
issues: [{ id: 'issue-1', severity: 'low', category: 'pacing', summary: 'ok', relatedIds: [] }],
|
||||
summary: '有 1 条 QA 问题',
|
||||
},
|
||||
} as never,
|
||||
seed: 'baseline',
|
||||
});
|
||||
|
||||
expect(result.endingId).toBe('ending-1');
|
||||
expect(result.issueCount).toBe(1);
|
||||
});
|
||||
});
|
||||
32
src/services/storyEngine/storySimulationRunner.ts
Normal file
32
src/services/storyEngine/storySimulationRunner.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
CampaignPack,
|
||||
SimulationRunResult,
|
||||
StoryEngineMemoryState,
|
||||
} from '../../types';
|
||||
|
||||
export function runStorySimulation(params: {
|
||||
scenarioPackId: string;
|
||||
campaignPack: CampaignPack;
|
||||
memory: StoryEngineMemoryState;
|
||||
seed: string;
|
||||
}) {
|
||||
const activeThreadCountPeak = params.memory.activeThreadIds.length;
|
||||
const fracturedCompanionCount = (
|
||||
params.memory.companionResolutions ?? []
|
||||
).filter((resolution) =>
|
||||
resolution.resolutionType === 'estranged' || resolution.resolutionType === 'departed',
|
||||
).length;
|
||||
const issueCount = params.memory.narrativeQaReport?.issues.length ?? 0;
|
||||
|
||||
return {
|
||||
id: `simulation:${params.campaignPack.id}:${params.seed}`,
|
||||
scenarioPackId: params.scenarioPackId,
|
||||
campaignPackId: params.campaignPack.id,
|
||||
seed: params.seed,
|
||||
endingId: params.memory.endingState?.id ?? null,
|
||||
activeThreadCountPeak,
|
||||
fracturedCompanionCount,
|
||||
issueCount,
|
||||
summary: `${params.campaignPack.title} / ${params.seed} 跑出了 ${params.memory.endingState?.title ?? '未结局'},线程峰值 ${activeThreadCountPeak},QA 问题 ${issueCount}。`,
|
||||
} satisfies SimulationRunResult;
|
||||
}
|
||||
198
src/services/storyEngine/themePack.ts
Normal file
198
src/services/storyEngine/themePack.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type {
|
||||
CustomWorldProfile,
|
||||
ThemePack,
|
||||
WorldTemplateType,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { detectCustomWorldThemeMode } from '../customWorldTheme';
|
||||
|
||||
type ThemePackPreset = Omit<ThemePack, 'id' | 'displayName'> & {
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
const THEME_PACK_PRESETS: Record<string, ThemePackPreset> = {
|
||||
martial: {
|
||||
displayName: '江湖旧事',
|
||||
toneRange: ['冷峻', '克制', '刀锋般紧绷', '旧案余震'],
|
||||
institutionLexicon: ['门派', '镖局', '巡司', '商号', '关隘', '行旅'],
|
||||
tabooLexicon: ['旧案', '灭门', '失契', '私印', '禁脉', '断誓'],
|
||||
artifactClasses: ['遗兵', '信物', '令牌', '残卷', '旧佩', '封匣'],
|
||||
actorArchetypes: ['游侠', '守路人', '旧案见证者', '带路人', '避祸者'],
|
||||
conflictForms: ['寻仇', '护送', '围剿', '失踪追查', '门派角力'],
|
||||
clueForms: ['刀痕', '旧誓', '口供', '渡口风声', '残页'],
|
||||
namingPatterns: ['旧称+伤痕+器类', '地标+余痕+器类', '势力+制式+用途'],
|
||||
revealStyles: ['试探', '旁敲侧击', '旧事回响', '迟疑松口'],
|
||||
},
|
||||
arcane: {
|
||||
displayName: '灵脉秘闻',
|
||||
toneRange: ['清冷', '玄异', '高压', '因果牵引'],
|
||||
institutionLexicon: ['宗门', '法坛', '巡守司', '灵舟会', '洞府', '云阙'],
|
||||
tabooLexicon: ['封印', '逆脉', '禁术', '残魂', '天契', '旧誓'],
|
||||
artifactClasses: ['法器', '灵符', '玉简', '残卷', '封匣', '阵核'],
|
||||
actorArchetypes: ['巡守使', '隐修者', '守秘人', '失格弟子', '旧阵幸存者'],
|
||||
conflictForms: ['夺脉', '追索', '封印失衡', '宗门旧案', '秘境争夺'],
|
||||
clueForms: ['灵痕', '阵纹', '残识', '旧契', '法简'],
|
||||
namingPatterns: ['云阙+残痕+器类', '灵脉+封痕+器类', '誓约+余烬+器类'],
|
||||
revealStyles: ['碎片式透露', '借象示意', '以术遮掩', '因果回指'],
|
||||
},
|
||||
machina: {
|
||||
displayName: '机巧裂域',
|
||||
toneRange: ['冷硬', '压迫', '机械失序', '余温未散'],
|
||||
institutionLexicon: ['财团', '工坊', '舰队', '前哨站', '调查局', '机库'],
|
||||
tabooLexicon: ['过载', '黑匣', '失控协议', '封存日志', '污染区'],
|
||||
artifactClasses: ['芯片', '驱动核', '制式装置', '记录模组', '封存匣'],
|
||||
actorArchetypes: ['技师', '巡检员', '失联驾驶员', '前线回收者', '见证者'],
|
||||
conflictForms: ['封锁', '回收', '追查事故', '公司内斗', '前线失守'],
|
||||
clueForms: ['烧蚀痕', '日志', '封条', '校准码', '脉冲残波'],
|
||||
namingPatterns: ['编号+余痕+器类', '站点+制式+用途', '事故+残片+器类'],
|
||||
revealStyles: ['日志碎片', '术语遮掩', '延迟承认', '故障回声'],
|
||||
},
|
||||
tide: {
|
||||
displayName: '潮痕旧闻',
|
||||
toneRange: ['潮湿', '迷雾', '迟滞', '暗潮涌动'],
|
||||
institutionLexicon: ['港务', '巡海司', '渡船会', '哨塔', '湾区营地', '潮站'],
|
||||
tabooLexicon: ['沉船', '失契', '潮誓', '禁海区', '回潮夜'],
|
||||
artifactClasses: ['潮印', '旧锚', '航图', '信标', '封潮匣', '雾骨遗物'],
|
||||
actorArchetypes: ['渡口守更人', '巡潮者', '失船幸存者', '采录员', '岸线猎人'],
|
||||
conflictForms: ['封港', '追查失踪', '海路争夺', '潮灾余波', '护送穿渡'],
|
||||
clueForms: ['潮痕', '盐霜', '航线残页', '旧锚印', '失物清单'],
|
||||
namingPatterns: ['潮名+伤痕+器类', '港口+遗痕+器类', '誓约+余潮+器类'],
|
||||
revealStyles: ['传闻递进', '潮汐比喻', '回避本名', '隔雾指认'],
|
||||
},
|
||||
rift: {
|
||||
displayName: '裂界前线',
|
||||
toneRange: ['焦灼', '边境压力', '失序', '战线余烬'],
|
||||
institutionLexicon: ['前哨', '巡边队', '断层站', '界桥营', '回收组', '观察哨'],
|
||||
tabooLexicon: ['断层失守', '回响名单', '界外污染', '封桥令', '旧撤离线'],
|
||||
artifactClasses: ['界核', '锚印', '边潮样本', '回响记录', '裂缝制式物'],
|
||||
actorArchetypes: ['边巡者', '失线幸存者', '回收员', '名单记录人', '封桥见证者'],
|
||||
conflictForms: ['守线', '撤离', '回收异常', '追查失线', '前线补给争夺'],
|
||||
clueForms: ['裂痕', '界压残波', '名单残页', '旧封条', '警示标记'],
|
||||
namingPatterns: ['裂界+旧痕+器类', '前哨+制式+用途', '名单+残响+器类'],
|
||||
revealStyles: ['压力主导', '证词错位', '名单回响', '旧事倒灌'],
|
||||
},
|
||||
};
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 8) {
|
||||
return [...new Set((values ?? []).map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function collectProfileLexicon(profile: Pick<
|
||||
CustomWorldProfile,
|
||||
'majorFactions' | 'coreConflicts' | 'summary' | 'tone' | 'playerGoal'
|
||||
>) {
|
||||
return dedupeStrings([
|
||||
...(profile.majorFactions ?? []),
|
||||
...(profile.coreConflicts ?? []),
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
]);
|
||||
}
|
||||
|
||||
function cloneThemePack(mode: string, preset: ThemePackPreset): ThemePack {
|
||||
return {
|
||||
id: `theme-pack:${mode}`,
|
||||
displayName: preset.displayName,
|
||||
toneRange: [...preset.toneRange],
|
||||
institutionLexicon: [...preset.institutionLexicon],
|
||||
tabooLexicon: [...preset.tabooLexicon],
|
||||
artifactClasses: [...preset.artifactClasses],
|
||||
actorArchetypes: [...preset.actorArchetypes],
|
||||
conflictForms: [...preset.conflictForms],
|
||||
clueForms: [...preset.clueForms],
|
||||
namingPatterns: [...preset.namingPatterns],
|
||||
revealStyles: [...preset.revealStyles],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveThemeModeFromWorldType(
|
||||
worldType: WorldTemplateType | WorldType | null | undefined,
|
||||
) {
|
||||
if (worldType === 'XIANXIA') {
|
||||
return 'arcane';
|
||||
}
|
||||
return 'martial';
|
||||
}
|
||||
|
||||
export function resolveFallbackThemePack(
|
||||
worldType: WorldTemplateType | WorldType | null | undefined,
|
||||
) {
|
||||
const mode = resolveThemeModeFromWorldType(worldType);
|
||||
return cloneThemePack(mode, THEME_PACK_PRESETS[mode]!);
|
||||
}
|
||||
|
||||
export function normalizeThemePack(
|
||||
value: unknown,
|
||||
fallback: ThemePack,
|
||||
): ThemePack {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const item = value as Partial<ThemePack>;
|
||||
const readList = (candidate: unknown, fallbackValue: string[]) => {
|
||||
const next = dedupeStrings(candidate as string[], fallbackValue.length);
|
||||
return next.length > 0 ? next : fallbackValue;
|
||||
};
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : fallback.id,
|
||||
displayName:
|
||||
typeof item.displayName === 'string' && item.displayName.trim()
|
||||
? item.displayName.trim()
|
||||
: fallback.displayName,
|
||||
toneRange: readList(item.toneRange, fallback.toneRange),
|
||||
institutionLexicon: readList(
|
||||
item.institutionLexicon,
|
||||
fallback.institutionLexicon,
|
||||
),
|
||||
tabooLexicon: readList(item.tabooLexicon, fallback.tabooLexicon),
|
||||
artifactClasses: readList(item.artifactClasses, fallback.artifactClasses),
|
||||
actorArchetypes: readList(item.actorArchetypes, fallback.actorArchetypes),
|
||||
conflictForms: readList(item.conflictForms, fallback.conflictForms),
|
||||
clueForms: readList(item.clueForms, fallback.clueForms),
|
||||
namingPatterns: readList(item.namingPatterns, fallback.namingPatterns),
|
||||
revealStyles: readList(item.revealStyles, fallback.revealStyles),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildThemePackFromWorldProfile(
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
| 'settingText'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'templateWorldType'
|
||||
| 'majorFactions'
|
||||
| 'coreConflicts'
|
||||
> & {
|
||||
templateWorldType: WorldTemplateType | WorldType;
|
||||
},
|
||||
) {
|
||||
const mode = detectCustomWorldThemeMode(profile);
|
||||
const base = cloneThemePack(mode, THEME_PACK_PRESETS[mode]!);
|
||||
const lexicon = collectProfileLexicon(profile);
|
||||
|
||||
return normalizeThemePack(
|
||||
{
|
||||
...base,
|
||||
institutionLexicon: dedupeStrings([
|
||||
...base.institutionLexicon,
|
||||
...lexicon.filter((item) => item.length >= 2),
|
||||
]),
|
||||
tabooLexicon: dedupeStrings([
|
||||
...base.tabooLexicon,
|
||||
...(profile.coreConflicts ?? []).slice(0, 4),
|
||||
]),
|
||||
clueForms: dedupeStrings([
|
||||
...base.clueForms,
|
||||
...(profile.majorFactions ?? []).slice(0, 3),
|
||||
]),
|
||||
toneRange: dedupeStrings([profile.tone, ...base.toneRange]),
|
||||
},
|
||||
base,
|
||||
);
|
||||
}
|
||||
26
src/services/storyEngine/threadContract.test.ts
Normal file
26
src/services/storyEngine/threadContract.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildThreadContract } from './threadContract';
|
||||
|
||||
describe('buildThreadContract', () => {
|
||||
it('builds a two-step contract for a thread', () => {
|
||||
const contract = buildThreadContract({
|
||||
thread: {
|
||||
id: 'thread-1',
|
||||
title: '封桥旧案',
|
||||
visibility: 'visible',
|
||||
summary: '封桥旧案再次被人提起。',
|
||||
conflictType: '调查',
|
||||
stakes: '边城安稳',
|
||||
involvedFactionIds: [],
|
||||
involvedActorIds: ['npc-1'],
|
||||
relatedLocationIds: ['bridge'],
|
||||
},
|
||||
issuerActorId: 'npc-1',
|
||||
});
|
||||
|
||||
expect(contract.steps).toHaveLength(2);
|
||||
expect(contract.currentStepId).toBe(contract.steps[0]?.id ?? null);
|
||||
expect(contract.steps[0]?.completionSignalIds.some((signalId) => signalId.includes('bridge'))).toBe(true);
|
||||
});
|
||||
});
|
||||
91
src/services/storyEngine/threadContract.ts
Normal file
91
src/services/storyEngine/threadContract.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
CustomWorldProfile,
|
||||
StoryThread,
|
||||
ThreadContract,
|
||||
ThreadContractStep,
|
||||
} from '../../types';
|
||||
|
||||
function createStep(
|
||||
contractId: string,
|
||||
suffix: string,
|
||||
title: string,
|
||||
revealText: string,
|
||||
completionSignalIds: string[],
|
||||
optionalFactIds: string[],
|
||||
) {
|
||||
return {
|
||||
id: `${contractId}:${suffix}`,
|
||||
title,
|
||||
revealText,
|
||||
completionSignalIds,
|
||||
optionalFactIds,
|
||||
} satisfies ThreadContractStep;
|
||||
}
|
||||
|
||||
export function buildThreadContract(params: {
|
||||
thread: StoryThread;
|
||||
issuerActorId?: string | null;
|
||||
}) {
|
||||
const { thread, issuerActorId = thread.involvedActorIds[0] ?? null } = params;
|
||||
const contractId = `thread-contract:${thread.id}`;
|
||||
const narrativeType: ThreadContract['narrativeType'] =
|
||||
/调查|追查|真相|线索/u.test(`${thread.title} ${thread.summary}`)
|
||||
? 'investigation'
|
||||
: /试炼|考验/u.test(`${thread.title} ${thread.summary}`)
|
||||
? 'trial'
|
||||
: /关系|誓|旧识/u.test(`${thread.title} ${thread.summary}`)
|
||||
? 'relationship'
|
||||
: /护送|撤离/u.test(`${thread.title} ${thread.summary}`)
|
||||
? 'escort'
|
||||
: /夺回|收复|回收/u.test(`${thread.title} ${thread.summary}`)
|
||||
? 'recovery'
|
||||
: 'investigation';
|
||||
const primarySceneSignal = thread.relatedLocationIds[0]
|
||||
? [`enter_scene:${thread.relatedLocationIds[0]}`, `inspect_scene:${thread.relatedLocationIds[0]}`]
|
||||
: [`talk_to_actor:${issuerActorId ?? 'actor'}`];
|
||||
const actorSignal = issuerActorId
|
||||
? [`talk_to_actor:${issuerActorId}`]
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: contractId,
|
||||
threadId: thread.id,
|
||||
issuerActorId,
|
||||
narrativeType,
|
||||
currentStepId: `${contractId}:step_1`,
|
||||
visibleStage: 0,
|
||||
steps: [
|
||||
createStep(
|
||||
contractId,
|
||||
'step_1',
|
||||
`追上 ${thread.title} 的表层线索`,
|
||||
thread.summary,
|
||||
primarySceneSignal,
|
||||
thread.relatedLocationIds.map((locationId) => `scene:${locationId}`),
|
||||
),
|
||||
createStep(
|
||||
contractId,
|
||||
'step_2',
|
||||
`把 ${thread.title} 的线头对回角色`,
|
||||
`${thread.stakes} 还需要有人当面松口。`,
|
||||
actorSignal.length > 0 ? actorSignal : [`resolve_contract_step:${thread.id}`],
|
||||
thread.involvedActorIds.map((actorId) => `actor:${actorId}`),
|
||||
),
|
||||
],
|
||||
followupThreadIds: [],
|
||||
} satisfies ThreadContract;
|
||||
}
|
||||
|
||||
export function buildThreadContractsFromProfile(profile: CustomWorldProfile) {
|
||||
const threads = [
|
||||
...(profile.storyGraph?.visibleThreads ?? []),
|
||||
...(profile.storyGraph?.hiddenThreads ?? []),
|
||||
];
|
||||
|
||||
return threads.map((thread) =>
|
||||
buildThreadContract({
|
||||
thread,
|
||||
issuerActorId: thread.involvedActorIds[0] ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
158
src/services/storyEngine/threadSignalRouter.test.ts
Normal file
158
src/services/storyEngine/threadSignalRouter.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState } from '../../types';
|
||||
import { collectStorySignals, resolveSignalsToThreadUpdates } from './threadSignalRouter';
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
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: ['thread-1'],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
},
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'scene-1',
|
||||
name: '断桥旧哨',
|
||||
description: '旧哨火和断桥一起守着边城北口。',
|
||||
imageSrc: '',
|
||||
treasureHints: [],
|
||||
narrativeResidues: [],
|
||||
},
|
||||
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: [
|
||||
{
|
||||
id: 'quest-1',
|
||||
issuerNpcId: 'npc-1',
|
||||
issuerNpcName: '梁砺',
|
||||
sceneId: 'scene-1',
|
||||
threadId: 'thread-1',
|
||||
contractId: 'thread-contract:thread-1',
|
||||
title: '调查断桥旧案',
|
||||
description: '先去看清桥口到底发生了什么。',
|
||||
summary: '调查断桥旧案',
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
targetSceneId: 'scene-1',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
reward: {
|
||||
affinityBonus: 0,
|
||||
currency: 0,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '',
|
||||
steps: [],
|
||||
activeStepId: null,
|
||||
visibleStage: 0,
|
||||
hiddenFlags: [],
|
||||
discoveredFactIds: [],
|
||||
relatedCarrierIds: [],
|
||||
},
|
||||
],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('threadSignalRouter', () => {
|
||||
it('collects signals and advances memory/quest stage', () => {
|
||||
const prevState = createState();
|
||||
const nextState = {
|
||||
...createState(),
|
||||
playerInventory: [
|
||||
{
|
||||
id: 'carrier-1',
|
||||
category: '专属物品',
|
||||
name: '封痕信物',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['relic'],
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled',
|
||||
generationChannel: 'quest_reward',
|
||||
seedKey: 'seed',
|
||||
sourceReason: '旧案把它重新推到了你眼前。',
|
||||
storyFingerprint: {
|
||||
visibleClue: '桥口旧铜味一直留在它的边角。',
|
||||
witnessMark: '它曾被人攥着在封桥夜里来回传递。',
|
||||
unresolvedQuestion: '它为什么一直没有被交回去?',
|
||||
currentAppearanceReason: '封桥旧案再次被提起。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
reactionHooks: ['名单'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies GameState;
|
||||
const signals = collectStorySignals({
|
||||
prevState,
|
||||
nextState,
|
||||
actionText: '接下调查断桥旧案的委托',
|
||||
lastFunctionId: 'npc_quest_accept',
|
||||
rewardItems: nextState.playerInventory,
|
||||
});
|
||||
const resolved = resolveSignalsToThreadUpdates({
|
||||
state: nextState,
|
||||
signals,
|
||||
contracts: [],
|
||||
});
|
||||
|
||||
expect(signals.some((signal) => signal.signalType === 'accept_contract')).toBe(true);
|
||||
expect(signals.some((signal) => signal.signalType === 'obtain_carrier')).toBe(true);
|
||||
expect(resolved.storyEngineMemory?.recentSignalIds?.length).toBeGreaterThan(0);
|
||||
expect(resolved.quests[0]?.visibleStage).toBeGreaterThan(0);
|
||||
expect(resolved.quests[0]?.relatedCarrierIds).toContain('carrier-1');
|
||||
});
|
||||
});
|
||||
169
src/services/storyEngine/threadSignalRouter.ts
Normal file
169
src/services/storyEngine/threadSignalRouter.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
GameState,
|
||||
InventoryItem,
|
||||
QuestLogEntry,
|
||||
StorySignal,
|
||||
ThreadContract,
|
||||
} from '../../types';
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 12) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function createSignalId(prefix: string, key: string) {
|
||||
return `${prefix}:${key}`;
|
||||
}
|
||||
|
||||
export function collectStorySignals(params: {
|
||||
prevState: GameState;
|
||||
nextState: GameState;
|
||||
actionText: string;
|
||||
lastFunctionId?: string | null;
|
||||
rewardItems?: InventoryItem[];
|
||||
}) {
|
||||
const signals: StorySignal[] = [];
|
||||
const activeThreadIds = params.nextState.storyEngineMemory?.activeThreadIds ?? [];
|
||||
|
||||
if (params.prevState.currentScenePreset?.id !== params.nextState.currentScenePreset?.id) {
|
||||
if (params.prevState.currentScenePreset?.id) {
|
||||
signals.push({
|
||||
id: createSignalId('leave_scene', params.prevState.currentScenePreset.id),
|
||||
signalType: 'leave_scene',
|
||||
sceneId: params.prevState.currentScenePreset.id,
|
||||
threadIds: activeThreadIds,
|
||||
});
|
||||
}
|
||||
if (params.nextState.currentScenePreset?.id) {
|
||||
signals.push({
|
||||
id: createSignalId('enter_scene', params.nextState.currentScenePreset.id),
|
||||
signalType: 'enter_scene',
|
||||
sceneId: params.nextState.currentScenePreset.id,
|
||||
threadIds: activeThreadIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (params.lastFunctionId === 'idle_observe_signs') {
|
||||
signals.push({
|
||||
id: createSignalId('inspect_scene', params.nextState.currentScenePreset?.id ?? 'scene'),
|
||||
signalType: 'inspect_scene',
|
||||
sceneId: params.nextState.currentScenePreset?.id ?? null,
|
||||
threadIds: activeThreadIds,
|
||||
});
|
||||
}
|
||||
if (params.nextState.currentEncounter?.kind === 'npc' || /聊|问|试探/u.test(params.actionText)) {
|
||||
signals.push({
|
||||
id: createSignalId(
|
||||
'talk_to_actor',
|
||||
params.nextState.currentEncounter?.id
|
||||
?? params.prevState.currentEncounter?.id
|
||||
?? params.actionText,
|
||||
),
|
||||
signalType: 'talk_to_actor',
|
||||
actorId:
|
||||
params.nextState.currentEncounter?.id
|
||||
?? params.prevState.currentEncounter?.id
|
||||
?? null,
|
||||
threadIds: activeThreadIds,
|
||||
});
|
||||
}
|
||||
if (params.lastFunctionId === 'npc_gift') {
|
||||
signals.push({
|
||||
id: createSignalId('give_item', params.actionText),
|
||||
signalType: 'give_item',
|
||||
actorId:
|
||||
params.prevState.currentEncounter?.id
|
||||
?? params.nextState.currentEncounter?.id
|
||||
?? null,
|
||||
threadIds: activeThreadIds,
|
||||
});
|
||||
}
|
||||
if (params.lastFunctionId === 'npc_quest_accept') {
|
||||
signals.push({
|
||||
id: createSignalId('accept_contract', params.actionText),
|
||||
signalType: 'accept_contract',
|
||||
actorId:
|
||||
params.prevState.currentEncounter?.id
|
||||
?? params.nextState.currentEncounter?.id
|
||||
?? null,
|
||||
threadIds: activeThreadIds,
|
||||
});
|
||||
}
|
||||
if ((params.rewardItems ?? []).length > 0) {
|
||||
params.rewardItems!.forEach((item) => {
|
||||
const threadIds = item.runtimeMetadata?.storyFingerprint?.relatedThreadIds ?? activeThreadIds;
|
||||
signals.push({
|
||||
id: createSignalId('obtain_carrier', item.id),
|
||||
signalType: 'obtain_carrier',
|
||||
carrierId: item.id,
|
||||
threadIds,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
function updateQuestFromSignals(
|
||||
quest: QuestLogEntry,
|
||||
signals: StorySignal[],
|
||||
contracts: ThreadContract[],
|
||||
) {
|
||||
const relevantSignals = signals.filter((signal) =>
|
||||
(quest.threadId && signal.threadIds?.includes(quest.threadId))
|
||||
|| (quest.contractId && contracts.some((contract) => contract.id === quest.contractId)),
|
||||
);
|
||||
if (relevantSignals.length <= 0) {
|
||||
return quest;
|
||||
}
|
||||
|
||||
const relatedCarrierIds = dedupeStrings([
|
||||
...(quest.relatedCarrierIds ?? []),
|
||||
...relevantSignals.map((signal) => signal.carrierId ?? ''),
|
||||
], 8);
|
||||
const discoveredFactIds = dedupeStrings([
|
||||
...(quest.discoveredFactIds ?? []),
|
||||
...relevantSignals.flatMap((signal) => signal.threadIds ?? []),
|
||||
], 12);
|
||||
|
||||
return {
|
||||
...quest,
|
||||
visibleStage: Math.min(
|
||||
(quest.visibleStage ?? 0) + relevantSignals.length,
|
||||
Math.max(1, quest.steps?.length ?? 1),
|
||||
),
|
||||
relatedCarrierIds,
|
||||
discoveredFactIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSignalsToThreadUpdates(params: {
|
||||
state: GameState;
|
||||
signals: StorySignal[];
|
||||
contracts?: ThreadContract[] | null;
|
||||
}) {
|
||||
const storyEngineMemory = params.state.storyEngineMemory;
|
||||
if (!storyEngineMemory || params.signals.length <= 0) {
|
||||
return params.state;
|
||||
}
|
||||
|
||||
const contracts = params.contracts ?? [];
|
||||
return {
|
||||
...params.state,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
activeThreadIds: dedupeStrings([
|
||||
...storyEngineMemory.activeThreadIds,
|
||||
...params.signals.flatMap((signal) => signal.threadIds ?? []),
|
||||
], 8),
|
||||
recentSignalIds: dedupeStrings([
|
||||
...(storyEngineMemory.recentSignalIds ?? []),
|
||||
...params.signals.map((signal) => signal.id),
|
||||
], 12),
|
||||
},
|
||||
quests: params.state.quests.map((quest) =>
|
||||
updateQuestFromSignals(quest, params.signals, contracts),
|
||||
),
|
||||
};
|
||||
}
|
||||
55
src/services/storyEngine/visibilityEngine.test.ts
Normal file
55
src/services/storyEngine/visibilityEngine.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildEncounterVisibilitySlice, createEmptyStoryEngineMemoryState } from './visibilityEngine';
|
||||
|
||||
describe('buildEncounterVisibilitySlice', () => {
|
||||
it('keeps full backstory facts out of first contact visibility', () => {
|
||||
const slice = buildEncounterVisibilitySlice({
|
||||
narrativeProfile: {
|
||||
publicMask: '她只说自己还在守桥。',
|
||||
firstContactMask: '她会先把话题拉回桥和风声。',
|
||||
visibleLine: '她盯着桥口与来路的每一次波动。',
|
||||
hiddenLine: '她真正盯着的是封桥旧令背后的名字。',
|
||||
contradiction: '嘴上说只守桥,提到名单时却明显收紧语气。',
|
||||
debtOrBurden: '她还在替旧命令续着一口气。',
|
||||
taboo: '封桥令',
|
||||
immediatePressure: '裂潮又在逼近桥口,她不敢轻易放开通路。',
|
||||
relatedThreadIds: ['thread-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
reactionHooks: ['名单', '旧哨火'],
|
||||
},
|
||||
backstoryReveal: {
|
||||
publicSummary: '她只承认自己还在守桥。',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: 0,
|
||||
teaser: '她只说桥还不能放开。',
|
||||
content: '她总先谈桥和路。',
|
||||
contextSnippet: '桥还不能放开。',
|
||||
},
|
||||
{
|
||||
id: 'truth',
|
||||
title: '最终底牌',
|
||||
affinityRequired: 90,
|
||||
teaser: '那夜真正下封桥令的人还没有露面。',
|
||||
content: '她知道封桥命令真正的源头。',
|
||||
contextSnippet: '封桥命令另有来头。',
|
||||
},
|
||||
],
|
||||
},
|
||||
disclosureStage: 'guarded',
|
||||
isFirstMeaningfulContact: true,
|
||||
seenBackstoryChapterIds: [],
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
activeThreadIds: ['thread-1'],
|
||||
});
|
||||
|
||||
expect(slice.sayableFactIds).toContain('publicMask');
|
||||
expect(slice.sayableFactIds).toContain('firstContactMask');
|
||||
expect(slice.sayableFactIds).not.toContain('hiddenLine');
|
||||
expect(slice.forbiddenFactIds).toContain('hiddenLine');
|
||||
expect(slice.forbiddenFactIds).toContain('chapter:truth');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user