Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

View File

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

View File

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

View File

@@ -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;

View File

@@ -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} 个。`,

View 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);
});
});

View File

@@ -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,
};
}

View 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();
});
});

View 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
View 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);
});
});

View File

@@ -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)}`,

View File

@@ -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 ?? [])

View File

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

View File

@@ -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'];
};

View File

@@ -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 ?? ''),
};
}

View File

@@ -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 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;

View 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');
});
});

View 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;
}

View 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,
};
}

View 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('自适应提示');
});
});

View 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;
}

View 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('封桥旧案');
});
});

View 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;
}

View 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);
});
});

View 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;
}

View 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('营火边的私话');
});
});

View 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;
}

View 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);
});
});

View 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,
};
}

View 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');
});
});

View 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,
};
}

View 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('适合当前局势里的临场构筑调整');
});
});

View 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(' ');
}

View 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);
});
});

View 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;
}

View 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('旧案');
});
});

View 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),
};
});
}

View 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,
);
});
});

View 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,
};
}

View 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');
});
});

View 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,
}),
);
}

View 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');
});
});

View 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');
}

View 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);
});
});

View 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 };
}

View 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);
});
});

View 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;
}

View 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('断桥旧哨');
});
});

View 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],
},
];
}

View 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');
});
});

View 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,
),
},
};
}

View 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();
});
});

View 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;
}

View 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('并肩到底');
});
});

View 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');
}

View 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('巡边司');
});
});

View 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,
),
}));
}

View 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');
});
});

View 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];
}

View 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');
});
});

View 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;
}

View 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);
});
});

View 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),
]
: [];
}

View 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);
}

View 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);
});
});

View 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);
}

View 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);
});
});

View 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];
}

View 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 条');
});
});

View 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;
}

View 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');
});
});

View 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}`,
};
}

View 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('平均活跃线程');
});
});

View 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}`,
};
}

View 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);
});
});

View 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;
}

View 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 条');
});
});

View 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} 条 simulationending family ${endingCount} 类,单次最高 QA 问题 ${maxIssueCount} 条。`;
}

View 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');
});
});

View 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');
}

View 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');
});
});

View 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;
}

View 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');
});
});

View 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,
},
};
}

View 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);
});
});

View 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()];
}

View 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;
}

View 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('断桥旧痕');
});
});

View 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');
}

View 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');
});
});

View 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;
}

View 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('封桥旧案·展开');
});
});

View 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');
}

View 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);
});
});

View 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;
}

View 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,
);
}

View 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);
});
});

View 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,
}),
);
}

View 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');
});
});

View 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),
),
};
}

View 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