Simplify custom world result editing controls
This commit is contained in:
@@ -201,9 +201,30 @@ function createPlayableNpc(index: number) {
|
||||
{ name: `技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: `物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
||||
{ name: `物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
||||
{ name: `物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
||||
{
|
||||
name: `物品${index + 1}-1`,
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明1',
|
||||
tags: ['物品标签1'],
|
||||
},
|
||||
{
|
||||
name: `物品${index + 1}-2`,
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '物品说明2',
|
||||
tags: ['物品标签2'],
|
||||
},
|
||||
{
|
||||
name: `物品${index + 1}-3`,
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明3',
|
||||
tags: ['物品标签3'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -259,14 +280,47 @@ function createStoryNpc(index: number) {
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ name: `世界技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
||||
{ name: `世界技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
||||
{ name: `世界技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
||||
{
|
||||
name: `世界技能${index + 1}-1`,
|
||||
summary: '技能说明1',
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
name: `世界技能${index + 1}-2`,
|
||||
summary: '技能说明2',
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
name: `世界技能${index + 1}-3`,
|
||||
summary: '技能说明3',
|
||||
style: '爆发终结',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{ name: `世界物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
||||
{ name: `世界物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
||||
{ name: `世界物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
||||
{
|
||||
name: `世界物品${index + 1}-1`,
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明1',
|
||||
tags: ['物品标签1'],
|
||||
},
|
||||
{
|
||||
name: `世界物品${index + 1}-2`,
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '物品说明2',
|
||||
tags: ['物品标签2'],
|
||||
},
|
||||
{
|
||||
name: `世界物品${index + 1}-3`,
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明3',
|
||||
tags: ['物品标签3'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -692,15 +746,12 @@ describe('ai orchestration fallbacks', () => {
|
||||
|
||||
it('passes abort signals through custom world generation and rejects when interrupted', async () => {
|
||||
requestPlainTextCompletionMock.mockImplementation(
|
||||
(
|
||||
_system: string,
|
||||
_user: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
) =>
|
||||
(_system: string, _user: string, options?: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(options.signal?.reason ?? new Error('世界生成已中断。')),
|
||||
() =>
|
||||
reject(options.signal?.reason ?? new Error('世界生成已中断。')),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
@@ -786,7 +837,9 @@ describe('ai orchestration fallbacks', () => {
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('你是 JSON 修复器'),
|
||||
expect.stringContaining('不要输出 playableNpcs、storyNpcs、landmarks、items'),
|
||||
expect.stringContaining(
|
||||
'不要输出 playableNpcs、storyNpcs、landmarks、items',
|
||||
),
|
||||
expect.objectContaining({
|
||||
debugLabel: 'custom-world-framework-json-repair',
|
||||
}),
|
||||
@@ -836,7 +889,9 @@ describe('ai orchestration fallbacks', () => {
|
||||
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');
|
||||
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 () => {
|
||||
@@ -846,10 +901,10 @@ describe('ai orchestration fallbacks', () => {
|
||||
JSON.stringify({
|
||||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||||
assetId: 'custom-scene-1',
|
||||
model: 'wan2.2-t2i-flash',
|
||||
model: 'wan2.7-image',
|
||||
size: '1280*720',
|
||||
taskId: 'task-123',
|
||||
prompt: '用于测试的提示词',
|
||||
prompt: '系统整理后的提示词',
|
||||
actualPrompt: '扩写后的提示词',
|
||||
}),
|
||||
} as Response);
|
||||
@@ -870,9 +925,9 @@ describe('ai orchestration fallbacks', () => {
|
||||
description: '被潮雾与旧升降机包围的码头。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
prompt: '用于测试的提示词',
|
||||
negativePrompt: '文字,水印',
|
||||
userPrompt: '雨夜的栈桥横跨黑色海沟,塔楼灯火被潮雾吞没。',
|
||||
size: '1280*720',
|
||||
referenceImageSrc: '/scene_bg/reference-layout.png',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
@@ -883,13 +938,30 @@ describe('ai orchestration fallbacks', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const requestBody = JSON.parse(String(request.body)) as {
|
||||
prompt: string;
|
||||
referenceImageSrc?: string;
|
||||
};
|
||||
expect(requestBody.referenceImageSrc).toBe(
|
||||
'/scene_bg/reference-layout.png',
|
||||
);
|
||||
expect(requestBody.prompt).toContain('像素风场景背景');
|
||||
expect(requestBody.prompt).toContain('画面构图必须严格按上下 1:1 分区');
|
||||
expect(requestBody.prompt).toContain('下半部分严格占据整张图的 1/2 高度');
|
||||
expect(requestBody.prompt).toContain('模拟 3D 游戏视角的地面近景');
|
||||
expect(requestBody.prompt).toContain(
|
||||
'下半部分的内容必须是明确可站立的地面本体',
|
||||
);
|
||||
expect(requestBody.prompt).toContain('已提供一张自定义参考图');
|
||||
expect(requestBody.prompt).toContain('雨夜的栈桥横跨黑色海沟');
|
||||
expect(result).toEqual({
|
||||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||||
assetId: 'custom-scene-1',
|
||||
model: 'wan2.2-t2i-flash',
|
||||
model: 'wan2.7-image',
|
||||
size: '1280*720',
|
||||
taskId: 'task-123',
|
||||
prompt: '用于测试的提示词',
|
||||
prompt: '系统整理后的提示词',
|
||||
actualPrompt: '扩写后的提示词',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,9 +180,11 @@ export interface CustomWorldSceneImageRequest {
|
||||
CustomWorldProfile['landmarks'][number],
|
||||
'id' | 'name' | 'description' | 'dangerLevel'
|
||||
>;
|
||||
userPrompt?: string;
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
size?: string;
|
||||
referenceImageSrc?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldSceneImageResult {
|
||||
@@ -312,7 +314,9 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -334,7 +338,9 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -451,9 +457,8 @@ function resolveCustomWorldGenerationInput(
|
||||
settingText: normalizedSettingText,
|
||||
generationSeedText: generationSeedText.trim(),
|
||||
creatorIntent,
|
||||
generationMode: input.generationMode === 'fast'
|
||||
? ('fast' as const)
|
||||
: ('full' as const),
|
||||
generationMode:
|
||||
input.generationMode === 'fast' ? ('fast' as const) : ('full' as const),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -621,9 +626,7 @@ function getCustomWorldGenerationStageIdForRoleExpansion(
|
||||
stage: CustomWorldGenerationRoleBatchStage,
|
||||
): CustomWorldGenerationStageId {
|
||||
if (roleType === 'playable') {
|
||||
return stage === 'narrative'
|
||||
? 'playable-narrative'
|
||||
: 'playable-dossier';
|
||||
return stage === 'narrative' ? 'playable-narrative' : 'playable-dossier';
|
||||
}
|
||||
|
||||
return stage === 'narrative' ? 'story-narrative' : 'story-dossier';
|
||||
@@ -704,8 +707,7 @@ function createCustomWorldGenerationReporter(
|
||||
|
||||
const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
|
||||
(sum, item) =>
|
||||
sum +
|
||||
(completedByStage[item.id] / item.total || 0) * item.weight,
|
||||
sum + (completedByStage[item.id] / item.total || 0) * item.weight,
|
||||
0,
|
||||
);
|
||||
const progressFraction =
|
||||
@@ -715,10 +717,7 @@ function createCustomWorldGenerationReporter(
|
||||
const elapsedMs = Math.max(0, performance.now() - startedAt);
|
||||
const estimatedRemainingMs =
|
||||
progressFraction > 0 && progressFraction < 1
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(elapsedMs / progressFraction - elapsedMs),
|
||||
)
|
||||
? Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs))
|
||||
: progressFraction >= 1
|
||||
? 0
|
||||
: null;
|
||||
@@ -1023,10 +1022,11 @@ async function expandCustomWorldRoleEntries<
|
||||
1,
|
||||
Math.ceil(roleBatchSource.length / batchSize),
|
||||
);
|
||||
const processedByStage: Record<CustomWorldGenerationRoleBatchStage, number> = {
|
||||
narrative: 0,
|
||||
dossier: 0,
|
||||
};
|
||||
const processedByStage: Record<CustomWorldGenerationRoleBatchStage, number> =
|
||||
{
|
||||
narrative: 0,
|
||||
dossier: 0,
|
||||
};
|
||||
|
||||
const requestBatchStage = async (
|
||||
roleBatch: typeof roleBatchSource,
|
||||
@@ -1070,7 +1070,7 @@ async function expandCustomWorldRoleEntries<
|
||||
? (stageRaw as Record<string, unknown>)[
|
||||
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||||
]
|
||||
: []
|
||||
: [],
|
||||
),
|
||||
);
|
||||
processedByStage[stage] = Math.min(
|
||||
@@ -1112,7 +1112,8 @@ async function generateCustomWorldThemePackWithAi(params: {
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldThemePackJsonRepairPrompt({ responseText }),
|
||||
repairDebugLabel: 'custom-world-theme-pack-json-repair',
|
||||
emptyResponseMessage: '自定义世界 ThemePack 生成失败:模型没有返回有效内容。',
|
||||
emptyResponseMessage:
|
||||
'自定义世界 ThemePack 生成失败:模型没有返回有效内容。',
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -1145,7 +1146,8 @@ async function generateCustomWorldStoryGraphWithAi(params: {
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldStoryGraphJsonRepairPrompt({ responseText }),
|
||||
repairDebugLabel: 'custom-world-story-graph-json-repair',
|
||||
emptyResponseMessage: '自定义世界 StoryGraph 生成失败:模型没有返回有效内容。',
|
||||
emptyResponseMessage:
|
||||
'自定义世界 StoryGraph 生成失败:模型没有返回有效内容。',
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -1177,11 +1179,17 @@ async function expandCustomWorldActorNarrativeProfiles<
|
||||
const roleBatchSource = baseEntries;
|
||||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
const stageId = getCustomWorldGenerationStageIdForActorProfile(roleType);
|
||||
const plannedBatchCount = Math.max(1, Math.ceil(roleBatchSource.length / batchSize));
|
||||
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()) {
|
||||
for (const [batchIndex, roleBatch] of chunkArray(
|
||||
roleBatchSource,
|
||||
batchSize,
|
||||
).entries()) {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
reporter.update(stageId, processedCount, {
|
||||
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}。`,
|
||||
@@ -1217,7 +1225,10 @@ async function expandCustomWorldActorNarrativeProfiles<
|
||||
: [],
|
||||
),
|
||||
);
|
||||
processedCount = Math.min(roleBatchSource.length, processedCount + roleBatch.length);
|
||||
processedCount = Math.min(
|
||||
roleBatchSource.length,
|
||||
processedCount + roleBatch.length,
|
||||
);
|
||||
reporter.update(stageId, processedCount, {
|
||||
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
@@ -1268,7 +1279,10 @@ async function parseCustomWorldStageResponseJson(params: {
|
||||
{
|
||||
timeoutMs: Math.max(
|
||||
30000,
|
||||
Math.min(90000, Math.round(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS / 2)),
|
||||
Math.min(
|
||||
90000,
|
||||
Math.round(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS / 2),
|
||||
),
|
||||
),
|
||||
debugLabel: repairDebugLabel,
|
||||
signal,
|
||||
@@ -1379,8 +1393,9 @@ function normalizeEncounterResult(
|
||||
const kind = typeof item.kind === 'string' ? item.kind.trim() : '';
|
||||
|
||||
if (kind === 'monster') {
|
||||
const fallbackHostileNpc =
|
||||
scene?.npcs.find((npc: SceneNpc) => isHostileSceneNpc(npc));
|
||||
const fallbackHostileNpc = scene?.npcs.find((npc: SceneNpc) =>
|
||||
isHostileSceneNpc(npc),
|
||||
);
|
||||
|
||||
return fallbackHostileNpc
|
||||
? { kind: 'npc', npcId: fallbackHostileNpc.id }
|
||||
@@ -1429,7 +1444,7 @@ function buildEncounterDrivenResolution(
|
||||
);
|
||||
if (sceneNpc?.monsterPresetId && isHostileSceneNpc(sceneNpc)) {
|
||||
return {
|
||||
monsters: createSceneHostileNpcsFromEncounters(
|
||||
monsters: createSceneHostileNpcsFromEncounters(
|
||||
worldType,
|
||||
[buildEncounterFromSceneNpc(sceneNpc, context.playerX)],
|
||||
context.playerX,
|
||||
@@ -1751,7 +1766,11 @@ async function repairStoryNarrativeLanguage(
|
||||
).inBattle;
|
||||
|
||||
if (!needsStoryLanguageRepair(response)) {
|
||||
return finalizeStoryNarrativeLanguage(response, context, responseBattleState);
|
||||
return finalizeStoryNarrativeLanguage(
|
||||
response,
|
||||
context,
|
||||
responseBattleState,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1783,7 +1802,11 @@ async function repairStoryNarrativeLanguage(
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to repair mixed-language story response:', error);
|
||||
return finalizeStoryNarrativeLanguage(response, context, responseBattleState);
|
||||
return finalizeStoryNarrativeLanguage(
|
||||
response,
|
||||
context,
|
||||
responseBattleState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1913,12 +1936,17 @@ async function requestCompletion(
|
||||
export async function generateCustomWorldSceneImage({
|
||||
profile,
|
||||
landmark,
|
||||
userPrompt,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
size = '1280*720',
|
||||
referenceImageSrc,
|
||||
}: CustomWorldSceneImageRequest): Promise<CustomWorldSceneImageResult> {
|
||||
const resolvedPrompt =
|
||||
prompt?.trim() || buildCustomWorldSceneImagePrompt(profile, landmark);
|
||||
prompt?.trim() ||
|
||||
buildCustomWorldSceneImagePrompt(profile, landmark, userPrompt, {
|
||||
hasReferenceImage: Boolean(referenceImageSrc?.trim()),
|
||||
});
|
||||
const resolvedNegativePrompt =
|
||||
negativePrompt?.trim() || DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT;
|
||||
const controller = new AbortController();
|
||||
@@ -1939,6 +1967,9 @@ export async function generateCustomWorldSceneImage({
|
||||
prompt: resolvedPrompt,
|
||||
negativePrompt: resolvedNegativePrompt,
|
||||
size,
|
||||
...(referenceImageSrc?.trim()
|
||||
? { referenceImageSrc: referenceImageSrc.trim() }
|
||||
: {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -2045,15 +2076,14 @@ export async function generateCustomWorldProfile(
|
||||
reporter.begin('playable-outline', {
|
||||
phaseDetail: '正在生成可扮演角色骨架。',
|
||||
});
|
||||
const playableNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkBase,
|
||||
roleType: 'playable',
|
||||
totalCount: generationTargets.playableCount,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['playableNpcs'];
|
||||
const playableNpcs = (await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkBase,
|
||||
roleType: 'playable',
|
||||
totalCount: generationTargets.playableCount,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['playableNpcs'];
|
||||
reporter.complete('playable-outline', {
|
||||
phaseDetail: `可扮演角色骨架已完成,共 ${playableNpcs.length} 名。`,
|
||||
});
|
||||
@@ -2065,15 +2095,14 @@ export async function generateCustomWorldProfile(
|
||||
reporter.begin('story-outline', {
|
||||
phaseDetail: '正在生成场景角色骨架。',
|
||||
});
|
||||
const storyNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkWithPlayable,
|
||||
roleType: 'story',
|
||||
totalCount: generationTargets.storyCount,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['storyNpcs'];
|
||||
const storyNpcs = (await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkWithPlayable,
|
||||
roleType: 'story',
|
||||
totalCount: generationTargets.storyCount,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['storyNpcs'];
|
||||
reporter.complete('story-outline', {
|
||||
phaseDetail: `场景角色骨架已完成,共 ${storyNpcs.length} 名。`,
|
||||
});
|
||||
@@ -2085,14 +2114,13 @@ export async function generateCustomWorldProfile(
|
||||
reporter.begin('landmark-seed', {
|
||||
phaseDetail: '正在生成场景骨架。',
|
||||
});
|
||||
const landmarkSeeds =
|
||||
(await generateCustomWorldLandmarkSeedEntries({
|
||||
framework: frameworkWithStory,
|
||||
totalCount: generationTargets.landmarkCount,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
const landmarkSeeds = (await generateCustomWorldLandmarkSeedEntries({
|
||||
framework: frameworkWithStory,
|
||||
totalCount: generationTargets.landmarkCount,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
reporter.complete('landmark-seed', {
|
||||
phaseDetail: `场景骨架已完成,共 ${landmarkSeeds.length} 个地标。`,
|
||||
});
|
||||
@@ -2104,15 +2132,14 @@ export async function generateCustomWorldProfile(
|
||||
reporter.begin('landmark-network', {
|
||||
phaseDetail: '正在建立场景连接与场景角色分布。',
|
||||
});
|
||||
const landmarks =
|
||||
(await expandCustomWorldLandmarkNetworkEntries({
|
||||
framework: frameworkWithLandmarkSeeds,
|
||||
storyNpcs,
|
||||
baseEntries: landmarkSeeds,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
const landmarks = (await expandCustomWorldLandmarkNetworkEntries({
|
||||
framework: frameworkWithLandmarkSeeds,
|
||||
storyNpcs,
|
||||
baseEntries: landmarkSeeds,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
reporter.complete('landmark-network', {
|
||||
phaseDetail: `场景连接已完成,共整理 ${landmarks.length} 个地标网络。`,
|
||||
});
|
||||
@@ -2177,32 +2204,34 @@ export async function generateCustomWorldProfile(
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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} 名。`,
|
||||
});
|
||||
@@ -2236,9 +2265,11 @@ export async function generateCustomWorldProfile(
|
||||
settingText: normalizedSettingText || profile.settingText,
|
||||
creatorIntent,
|
||||
anchorPack:
|
||||
profile.anchorPack ?? buildCustomWorldAnchorPackFromIntent(creatorIntent),
|
||||
profile.anchorPack ??
|
||||
buildCustomWorldAnchorPackFromIntent(creatorIntent),
|
||||
lockState:
|
||||
profile.lockState ?? deriveCustomWorldLockStateFromIntent(creatorIntent),
|
||||
profile.lockState ??
|
||||
deriveCustomWorldLockStateFromIntent(creatorIntent),
|
||||
generationMode,
|
||||
generationStatus: generationTargets.generationStatus,
|
||||
items: [],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||
import { getCurrencyName } from '../data/economy';
|
||||
import { WorldType } from '../types';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
|
||||
describe('normalizeCustomWorldProfile', () => {
|
||||
@@ -224,4 +226,59 @@ describe('normalizeCustomWorldProfile', () => {
|
||||
(connection) => connection.targetLandmarkId === profile.landmarks[0]?.id,
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('compiles and preserves owned setting layers for runtime consumption', () => {
|
||||
const profile = normalizeCustomWorldProfile(
|
||||
{
|
||||
name: '雾潮港',
|
||||
summary: '被潮灾旧闻反复撕开的边港。',
|
||||
tone: '潮湿、迷雾、压抑',
|
||||
playerGoal: '查清港区失踪名单为何重复出现',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
ownedSettingLayers: {
|
||||
ruleProfile: {
|
||||
resourceLabels: {
|
||||
hp: '潮命',
|
||||
mp: '潮息',
|
||||
maxHp: '潮命上限',
|
||||
maxMp: '潮息上限',
|
||||
damage: '潮势',
|
||||
guard: '潮护',
|
||||
range: '潮距',
|
||||
cooldown: '回潮',
|
||||
manaCost: '潮息消耗',
|
||||
currency: '雾银',
|
||||
},
|
||||
economyProfile: {
|
||||
initialCurrency: 188,
|
||||
},
|
||||
},
|
||||
semanticAnchor: {
|
||||
genreSignals: ['海岸悬疑'],
|
||||
conflictForms: ['追查失踪'],
|
||||
institutionTypes: ['港务'],
|
||||
tabooTypes: ['回潮夜'],
|
||||
carrierTypes: ['航图'],
|
||||
forceSystemTypes: ['潮汐'],
|
||||
atmosphereTags: ['迷雾'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'玩家想要一个围绕迷雾港区与潮灾旧闻展开的世界。',
|
||||
);
|
||||
|
||||
expect(profile.ownedSettingLayers?.ruleProfile.resourceLabels.currency).toBe(
|
||||
'雾银',
|
||||
);
|
||||
expect(profile.ownedSettingLayers?.ruleProfile.economyProfile.initialCurrency).toBe(
|
||||
188,
|
||||
);
|
||||
expect(getCurrencyName(WorldType.CUSTOM, profile)).toBe('雾银');
|
||||
expect(
|
||||
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType,
|
||||
).toBe(WorldType.WUXIA);
|
||||
expect(
|
||||
profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
ActorNarrativeProfile,
|
||||
AnimationState,
|
||||
CharacterAnimationConfig,
|
||||
CharacterBackstoryChapter,
|
||||
CharacterBackstoryRevealConfig,
|
||||
CustomWorldAnchorPack,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldLockState,
|
||||
} from './customWorldCreatorIntent';
|
||||
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
@@ -73,6 +76,9 @@ const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3;
|
||||
const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3;
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES =
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
const CUSTOM_WORLD_ANIMATION_STATES = new Set<AnimationState>(
|
||||
Object.values(AnimationState),
|
||||
);
|
||||
|
||||
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
|
||||
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
|
||||
@@ -135,6 +141,12 @@ function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toFiniteInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.round(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? (value.filter((item) => item && typeof item === 'object') as Array<
|
||||
@@ -165,6 +177,59 @@ function normalizeInitialAffinity(value: unknown, fallback: number) {
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeGeneratedAnimationConfig(
|
||||
value: unknown,
|
||||
): CharacterAnimationConfig | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const folder = toText(item.folder);
|
||||
const prefix = toText(item.prefix);
|
||||
const frames = Math.max(1, toFiniteInteger(item.frames) ?? 0);
|
||||
|
||||
if (!folder || !prefix || frames <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startFrame = toFiniteInteger(item.startFrame);
|
||||
const extension = toText(item.extension);
|
||||
const file = toText(item.file);
|
||||
const basePath = toText(item.basePath);
|
||||
|
||||
return {
|
||||
folder,
|
||||
prefix,
|
||||
frames,
|
||||
...(startFrame ? { startFrame: Math.max(1, startFrame) } : {}),
|
||||
...(extension ? { extension } : {}),
|
||||
...(file ? { file } : {}),
|
||||
...(basePath ? { basePath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGeneratedAnimationMap(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value).flatMap(([key, rawConfig]) => {
|
||||
if (!CUSTOM_WORLD_ANIMATION_STATES.has(key as AnimationState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const config = normalizeGeneratedAnimationConfig(rawConfig);
|
||||
return config ? [[key as AnimationState, config] as const] : [];
|
||||
});
|
||||
|
||||
return entries.length > 0
|
||||
? (Object.fromEntries(entries) as Partial<
|
||||
Record<AnimationState, CharacterAnimationConfig>
|
||||
>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeWorldType(value: unknown, sourceText: string) {
|
||||
const worldType = toText(value).toUpperCase();
|
||||
if (worldType === WorldType.WUXIA || worldType === WorldType.XIANXIA) {
|
||||
@@ -184,9 +249,7 @@ function normalizeRarity(
|
||||
function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
|
||||
const category = toText(value);
|
||||
if (
|
||||
(
|
||||
CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]
|
||||
).includes(category)
|
||||
(CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category)
|
||||
) {
|
||||
return category === '专属物' ? '专属物品' : category;
|
||||
}
|
||||
@@ -289,7 +352,10 @@ function buildFallbackBackstoryReveal(
|
||||
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ??
|
||||
`背景片段${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: truncateText(fallbackContents[index] ?? normalizedBackstory, 22),
|
||||
teaser: truncateText(
|
||||
fallbackContents[index] ?? normalizedBackstory,
|
||||
22,
|
||||
),
|
||||
content: truncateText(
|
||||
fallbackContents[index] ?? normalizedBackstory,
|
||||
72,
|
||||
@@ -335,7 +401,8 @@ function normalizeBackstoryReveal(
|
||||
(rawChapter && toText(rawChapter.title)) ||
|
||||
fallbackChapter?.title ||
|
||||
`背景片段${index + 1}`,
|
||||
affinityRequired: fallbackChapter?.affinityRequired ?? defaultAffinity,
|
||||
affinityRequired:
|
||||
fallbackChapter?.affinityRequired ?? defaultAffinity,
|
||||
teaser:
|
||||
(rawChapter && toText(rawChapter.teaser)) ||
|
||||
fallbackChapter?.teaser ||
|
||||
@@ -358,7 +425,8 @@ function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
|
||||
const skillNameSeed = source.title || source.role || source.name || '角色';
|
||||
const skillSummarySeed =
|
||||
source.combatStyle || source.description || `${source.name}善于把握局势。`;
|
||||
const motivationSeed = source.motivation || source.personality || source.backstory;
|
||||
const motivationSeed =
|
||||
source.motivation || source.personality || source.backstory;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -447,7 +515,9 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: truncateText(
|
||||
source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`,
|
||||
source.backstory ||
|
||||
source.motivation ||
|
||||
`${source.name}不愿随意交出的信物。`,
|
||||
36,
|
||||
),
|
||||
tags: normalizeTags(
|
||||
@@ -540,7 +610,7 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
|
||||
templateWorldType,
|
||||
});
|
||||
|
||||
return {
|
||||
const baseProfile = {
|
||||
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
settingText: settingText.trim(),
|
||||
name,
|
||||
@@ -573,6 +643,14 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
|
||||
lockState: normalizeCustomWorldLockState(null),
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} satisfies CustomWorldProfile;
|
||||
|
||||
return {
|
||||
...baseProfile,
|
||||
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
|
||||
null,
|
||||
baseProfile,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -715,7 +793,8 @@ function normalizeRoleProfile(
|
||||
},
|
||||
) {
|
||||
const name = toText(item.name);
|
||||
const title = toText(item.title) || toText(item.role) || options.titleFallback;
|
||||
const title =
|
||||
toText(item.title) || toText(item.role) || options.titleFallback;
|
||||
const role = toText(item.role) || title;
|
||||
const relationshipHooks = normalizeTags(
|
||||
item.relationshipHooks,
|
||||
@@ -741,9 +820,19 @@ function normalizeRoleProfile(
|
||||
|
||||
return {
|
||||
...normalizedRole,
|
||||
backstoryReveal: normalizeBackstoryReveal(item.backstoryReveal, normalizedRole),
|
||||
backstoryReveal: normalizeBackstoryReveal(
|
||||
item.backstoryReveal,
|
||||
normalizedRole,
|
||||
),
|
||||
skills: normalizeRoleSkillList(item.skills, normalizedRole),
|
||||
initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole),
|
||||
initialItems: normalizeRoleInitialItemList(
|
||||
item.initialItems,
|
||||
normalizedRole,
|
||||
),
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined,
|
||||
generatedAnimationSetId: toText(item.generatedAnimationSetId) || undefined,
|
||||
animationMap: normalizeGeneratedAnimationMap(item.animationMap),
|
||||
narrativeProfile:
|
||||
item.narrativeProfile && typeof item.narrativeProfile === 'object'
|
||||
? (item.narrativeProfile as ActorNarrativeProfile)
|
||||
@@ -767,19 +856,20 @@ function normalizePlayableNpcList(value: unknown) {
|
||||
|
||||
function normalizeStoryNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) =>
|
||||
({
|
||||
...normalizeRoleProfile(item, index, {
|
||||
idPrefix: 'story-npc',
|
||||
titleFallback: '未定称号',
|
||||
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
}),
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
visual:
|
||||
item.visual && typeof item.visual === 'object'
|
||||
? (item.visual as CustomWorldNpc['visual'])
|
||||
: undefined,
|
||||
}) satisfies CustomWorldNpc,
|
||||
.map(
|
||||
(item, index) =>
|
||||
({
|
||||
...normalizeRoleProfile(item, index, {
|
||||
idPrefix: 'story-npc',
|
||||
titleFallback: '未定称号',
|
||||
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
}),
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
visual:
|
||||
item.visual && typeof item.visual === 'object'
|
||||
? (item.visual as CustomWorldNpc['visual'])
|
||||
: undefined,
|
||||
}) satisfies CustomWorldNpc,
|
||||
)
|
||||
.filter((entry) => entry.name);
|
||||
}
|
||||
@@ -812,7 +902,8 @@ function normalizeRoleOutlineList(
|
||||
const normalized = toRecordArray(value)
|
||||
.map((item) => {
|
||||
const name = toText(item.name);
|
||||
const title = toText(item.title) || toText(item.role) || options.titleFallback;
|
||||
const title =
|
||||
toText(item.title) || toText(item.role) || options.titleFallback;
|
||||
const role = toText(item.role) || title;
|
||||
const relationshipHooks = normalizeTags(
|
||||
item.relationshipHooks,
|
||||
@@ -846,12 +937,19 @@ function normalizeCampOutline(
|
||||
value: unknown,
|
||||
fallbackProfile: Pick<
|
||||
CustomWorldProfile,
|
||||
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
|
||||
| 'name'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'settingText'
|
||||
| 'templateWorldType'
|
||||
>,
|
||||
): CustomWorldGenerationCampOutline {
|
||||
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
|
||||
const item =
|
||||
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
name: toText(item.name) || fallback.name,
|
||||
@@ -867,7 +965,8 @@ function normalizeLandmarkOutlineList(value: unknown) {
|
||||
return {
|
||||
name,
|
||||
description:
|
||||
toText(item.description) || truncateText(`${name}暗藏新的局势变化。`, 40),
|
||||
toText(item.description) ||
|
||||
truncateText(`${name}暗藏新的局势变化。`, 40),
|
||||
dangerLevel: toText(item.dangerLevel) || 'medium',
|
||||
sceneNpcNames: [
|
||||
...toStringArray(item.sceneNpcNames),
|
||||
@@ -956,12 +1055,19 @@ function normalizeCampScene(
|
||||
value: unknown,
|
||||
fallbackProfile: Pick<
|
||||
CustomWorldProfile,
|
||||
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
|
||||
| 'name'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'settingText'
|
||||
| 'templateWorldType'
|
||||
>,
|
||||
): CustomWorldCampScene {
|
||||
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
|
||||
const item =
|
||||
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
name: toText(item.name) || fallback.name,
|
||||
@@ -1019,7 +1125,7 @@ export function normalizeCustomWorldProfile(
|
||||
templateWorldType,
|
||||
});
|
||||
|
||||
return {
|
||||
const normalizedProfile = {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
`custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
@@ -1070,9 +1176,18 @@ export function normalizeCustomWorldProfile(
|
||||
? item.generationMode
|
||||
: fallback.generationMode,
|
||||
generationStatus:
|
||||
item.generationStatus === 'key_only' || item.generationStatus === 'complete'
|
||||
item.generationStatus === 'key_only' ||
|
||||
item.generationStatus === 'complete'
|
||||
? item.generationStatus
|
||||
: fallback.generationStatus,
|
||||
} satisfies CustomWorldProfile;
|
||||
|
||||
return {
|
||||
...normalizedProfile,
|
||||
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
|
||||
item.ownedSettingLayers,
|
||||
normalizedProfile,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1148,7 +1263,7 @@ function buildRoleOutlinePromptLines(
|
||||
.map((role) => {
|
||||
const appearanceText =
|
||||
options.roleType === 'story'
|
||||
? appearanceLookup.get(role.name)?.join('、') ?? '未指定'
|
||||
? (appearanceLookup.get(role.name)?.join('、') ?? '未指定')
|
||||
: '';
|
||||
return [
|
||||
`- ${role.name} / ${role.title}`,
|
||||
@@ -1636,9 +1751,10 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: {
|
||||
storyNpcs: CustomWorldGenerationRoleOutline[];
|
||||
}) {
|
||||
const { framework, landmarkBatch, storyNpcs } = params;
|
||||
const relativePositionValues = CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
|
||||
(option) => option.value,
|
||||
).join('|');
|
||||
const relativePositionValues =
|
||||
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
|
||||
(option) => option.value,
|
||||
).join('|');
|
||||
const allLandmarkNames = framework.landmarks.map((landmark) => landmark.name);
|
||||
const storyNpcNames = storyNpcs.map((npc) => npc.name);
|
||||
|
||||
@@ -1779,8 +1895,8 @@ export function buildCustomWorldRoleBatchPrompt(params: {
|
||||
'{',
|
||||
` "${key}": [`,
|
||||
' {',
|
||||
' "name": "角色名称",',
|
||||
' "backstoryReveal": {',
|
||||
' "name": "角色名称",',
|
||||
' "backstoryReveal": {',
|
||||
' "publicSummary": "公开可见的背景摘要",',
|
||||
' "chapters": [',
|
||||
` { "id": "surface", "title": "表层来意", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`,
|
||||
@@ -1862,9 +1978,10 @@ export function buildCustomWorldRoleBatchJsonRepairPrompt(params: {
|
||||
}
|
||||
|
||||
export function buildCustomWorldGenerationPrompt(settingText: string) {
|
||||
const relativePositionValues = CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
|
||||
(option) => option.value,
|
||||
).join('|');
|
||||
const relativePositionValues =
|
||||
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
|
||||
(option) => option.value,
|
||||
).join('|');
|
||||
return [
|
||||
'请根据下面的玩家设定创建一份自定义世界档案。',
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
@@ -2005,21 +2122,28 @@ export function buildCustomWorldReferenceText(
|
||||
const landmarkById = new Map(
|
||||
profile.landmarks.map((landmark) => [landmark.id, landmark]),
|
||||
);
|
||||
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
|
||||
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]
|
||||
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),
|
||||
(options.highlightNpcNames ?? [])
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const describeNpcReference = (
|
||||
npc: CustomWorldProfile['storyNpcs'][number] | CustomWorldProfile['playableNpcs'][number],
|
||||
npc:
|
||||
| CustomWorldProfile['storyNpcs'][number]
|
||||
| CustomWorldProfile['playableNpcs'][number],
|
||||
) => {
|
||||
const narrativeProfile = normalizeActorNarrativeProfile(
|
||||
npc.narrativeProfile,
|
||||
@@ -2028,9 +2152,11 @@ export function buildCustomWorldReferenceText(
|
||||
|
||||
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,
|
||||
.map(
|
||||
(threadId) =>
|
||||
[...storyGraph.visibleThreads, ...storyGraph.hiddenThreads].find(
|
||||
(thread) => thread.id === threadId,
|
||||
)?.title ?? threadId,
|
||||
)
|
||||
.join('、') || '暂无'
|
||||
};反应钩子:${narrativeProfile.reactionHooks.join('、') || '暂无'}`;
|
||||
@@ -2058,7 +2184,9 @@ export function buildCustomWorldReferenceText(
|
||||
};连接:${
|
||||
landmark.connections
|
||||
.map((connection) => {
|
||||
const targetLandmark = landmarkById.get(connection.targetLandmarkId);
|
||||
const targetLandmark = landmarkById.get(
|
||||
connection.targetLandmarkId,
|
||||
);
|
||||
if (!targetLandmark) {
|
||||
return '';
|
||||
}
|
||||
@@ -2110,7 +2238,9 @@ export function validateGeneratedCustomWorldProfile(
|
||||
}
|
||||
|
||||
const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id));
|
||||
const validLandmarkIds = new Set(profile.landmarks.map((landmark) => landmark.id));
|
||||
const validLandmarkIds = new Set(
|
||||
profile.landmarks.map((landmark) => landmark.id),
|
||||
);
|
||||
|
||||
profile.landmarks.forEach((landmark) => {
|
||||
const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)];
|
||||
@@ -2185,6 +2315,10 @@ export function buildCustomWorldSceneImagePrompt(
|
||||
'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText'
|
||||
>,
|
||||
landmark: Pick<CustomWorldLandmark, 'name' | 'description' | 'dangerLevel'>,
|
||||
userPrompt = '',
|
||||
options: {
|
||||
hasReferenceImage?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const worldName = clampSceneImageText(profile.name, 18) || '未命名世界';
|
||||
const worldSubtitle = clampSceneImageText(profile.subtitle, 18);
|
||||
@@ -2194,10 +2328,18 @@ export function buildCustomWorldSceneImagePrompt(
|
||||
const worldSetting = clampSceneImageText(profile.settingText, 72);
|
||||
const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景';
|
||||
const landmarkDescription = clampSceneImageText(landmark.description, 96);
|
||||
const requestedVisual = clampSceneImageText(userPrompt, 120);
|
||||
const dangerMood = describeDangerLevel(landmark.dangerLevel);
|
||||
|
||||
return [
|
||||
'横版幻想 RPG 场景背景概念图,适合作为 2D 游戏战斗与探索背景,环境主体清晰,空间层次明确,电影感光影,细节丰富。',
|
||||
'为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。',
|
||||
'画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。',
|
||||
'下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。',
|
||||
'下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。',
|
||||
'下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。',
|
||||
options.hasReferenceImage
|
||||
? '已提供一张自定义参考图,可适度参考其构图、镜头或氛围,但仍以本次场景需求为准,不要生硬照搬。'
|
||||
: '',
|
||||
`世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`,
|
||||
worldSetting ? `玩家设定:${worldSetting}。` : '',
|
||||
worldSummary ? `世界概述:${worldSummary}。` : '',
|
||||
@@ -2205,8 +2347,9 @@ export function buildCustomWorldSceneImagePrompt(
|
||||
worldGoal ? `玩家目标关联:${worldGoal}。` : '',
|
||||
`场景名称:${landmarkName}。`,
|
||||
landmarkDescription ? `场景描述:${landmarkDescription}。` : '',
|
||||
requestedVisual ? `本次想要生成的画面内容:${requestedVisual}。` : '',
|
||||
`${dangerMood}。`,
|
||||
'不要出现 UI、字幕、文字、水印或 logo,人物仅可作为很小的远景剪影,画面重点放在建筑、地貌、光线与氛围。',
|
||||
'不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
|
||||
@@ -89,5 +89,12 @@ describe('buildExpandedCustomWorldProfile', () => {
|
||||
expect(profile.storyGraph?.hiddenThreads.length).toBeGreaterThan(0);
|
||||
expect(profile.storyNpcs[0]?.narrativeProfile?.immediatePressure).toBeTruthy();
|
||||
expect(profile.playableNpcs[0]?.narrativeProfile?.relatedThreadIds.length).toBeGreaterThan(0);
|
||||
expect(profile.ownedSettingLayers?.expressionProfile.themePack.displayName).toBe(
|
||||
profile.themePack?.displayName,
|
||||
);
|
||||
expect(profile.ownedSettingLayers?.referenceProfile.roleArchetypes.length).toBeGreaterThan(0);
|
||||
expect(profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType).toBe(
|
||||
'WUXIA',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags';
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { CustomWorldProfile, WorldType } from '../types';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
@@ -272,11 +273,19 @@ export function buildExpandedCustomWorldProfile(
|
||||
});
|
||||
registerScenarioPack(compiledPacks.scenarioPack);
|
||||
|
||||
return {
|
||||
const finalizedProfile = {
|
||||
...profileWithNarrative,
|
||||
knowledgeFacts,
|
||||
threadContracts,
|
||||
scenarioPackId: profile.scenarioPackId ?? compiledPacks.scenarioPack.id,
|
||||
campaignPackId: profile.campaignPackId ?? compiledPacks.campaignPack.id,
|
||||
} satisfies CustomWorldProfile;
|
||||
|
||||
return {
|
||||
...finalizedProfile,
|
||||
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
|
||||
finalizedProfile.ownedSettingLayers,
|
||||
finalizedProfile,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
969
src/services/customWorldOwnedSettingLayers.ts
Normal file
969
src/services/customWorldOwnedSettingLayers.ts
Normal file
@@ -0,0 +1,969 @@
|
||||
import { coerceWorldAttributeSchema } from '../data/attributeValidation';
|
||||
import {
|
||||
type CreatureArchetypeProfile,
|
||||
type CustomWorldCompatibilityProfile,
|
||||
type CustomWorldExpressionProfile,
|
||||
type CustomWorldOwnedSettingLayers,
|
||||
type CustomWorldProfile,
|
||||
type CustomWorldReferenceProfile,
|
||||
type CustomWorldRuleProfile,
|
||||
type CustomWorldSemanticAnchor,
|
||||
type RoleArchetypeProfile,
|
||||
type SceneArchetypeBucket,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { type CustomWorldThemeMode, detectCustomWorldThemeMode } from './customWorldTheme';
|
||||
import {
|
||||
buildThemePackFromWorldProfile,
|
||||
normalizeThemePack,
|
||||
} from './storyEngine/themePack';
|
||||
|
||||
const OWNED_SETTING_LAYER_MIGRATION_VERSION =
|
||||
'2026-04-08-owned-setting-layers-v1';
|
||||
|
||||
const RESOURCE_LABEL_PRESETS: Record<
|
||||
CustomWorldThemeMode,
|
||||
CustomWorldRuleProfile['resourceLabels']
|
||||
> = {
|
||||
mythic: {
|
||||
hp: '生命',
|
||||
mp: '心流',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '心流上限',
|
||||
damage: '势能',
|
||||
guard: '防护',
|
||||
range: '距离',
|
||||
cooldown: '回整',
|
||||
manaCost: '心流消耗',
|
||||
currency: '旅券',
|
||||
},
|
||||
martial: {
|
||||
hp: '气血',
|
||||
mp: '内力',
|
||||
maxHp: '气血上限',
|
||||
maxMp: '内力上限',
|
||||
damage: '招式',
|
||||
guard: '防御',
|
||||
range: '招距',
|
||||
cooldown: '调息',
|
||||
manaCost: '内力消耗',
|
||||
currency: '铜钱',
|
||||
},
|
||||
arcane: {
|
||||
hp: '元命',
|
||||
mp: '灵韵',
|
||||
maxHp: '元命上限',
|
||||
maxMp: '灵韵上限',
|
||||
damage: '术法',
|
||||
guard: '护盾',
|
||||
range: '术距',
|
||||
cooldown: '回息',
|
||||
manaCost: '灵韵消耗',
|
||||
currency: '灵石',
|
||||
},
|
||||
machina: {
|
||||
hp: '耐久',
|
||||
mp: '能量',
|
||||
maxHp: '耐久上限',
|
||||
maxMp: '能量上限',
|
||||
damage: '火力',
|
||||
guard: '护盾',
|
||||
range: '射程',
|
||||
cooldown: '充能',
|
||||
manaCost: '能量消耗',
|
||||
currency: '配给券',
|
||||
},
|
||||
tide: {
|
||||
hp: '潮命',
|
||||
mp: '潮息',
|
||||
maxHp: '潮命上限',
|
||||
maxMp: '潮息上限',
|
||||
damage: '潮势',
|
||||
guard: '潮护',
|
||||
range: '潮距',
|
||||
cooldown: '回潮',
|
||||
manaCost: '潮息消耗',
|
||||
currency: '潮银',
|
||||
},
|
||||
rift: {
|
||||
hp: '界命',
|
||||
mp: '裂能',
|
||||
maxHp: '界命上限',
|
||||
maxMp: '裂能上限',
|
||||
damage: '界势',
|
||||
guard: '稳界',
|
||||
range: '界距',
|
||||
cooldown: '复界',
|
||||
manaCost: '裂能消耗',
|
||||
currency: '边贸券',
|
||||
},
|
||||
};
|
||||
|
||||
const INITIAL_CURRENCY_PRESETS: Record<CustomWorldThemeMode, number> = {
|
||||
mythic: 160,
|
||||
martial: 160,
|
||||
arcane: 140,
|
||||
machina: 160,
|
||||
tide: 160,
|
||||
rift: 160,
|
||||
};
|
||||
|
||||
const SEMANTIC_ANCHOR_PRESETS: Record<
|
||||
CustomWorldThemeMode,
|
||||
Omit<CustomWorldSemanticAnchor, 'atmosphereTags'>
|
||||
> = {
|
||||
mythic: {
|
||||
genreSignals: ['跨题材冒险', '未知旅境'],
|
||||
conflictForms: ['追查', '护送', '回收', '失踪追索'],
|
||||
institutionTypes: ['据点', '旅团', '档案室', '归舍'],
|
||||
tabooTypes: ['越界', '封存', '失约', '旧痕'],
|
||||
carrierTypes: ['信物', '残页', '样本', '旧钥'],
|
||||
forceSystemTypes: ['回响', '誓约', '遗物', '余波'],
|
||||
},
|
||||
martial: {
|
||||
genreSignals: ['江湖纷争', '旧案追索'],
|
||||
conflictForms: ['寻仇', '围剿', '护送', '失踪追查'],
|
||||
institutionTypes: ['门派', '镖局', '巡司', '商号'],
|
||||
tabooTypes: ['旧案', '断誓', '禁脉', '失契'],
|
||||
carrierTypes: ['遗兵', '令牌', '残卷', '旧佩'],
|
||||
forceSystemTypes: ['心法', '招式', '经脉', '誓约'],
|
||||
},
|
||||
arcane: {
|
||||
genreSignals: ['灵异修行', '秘境因果'],
|
||||
conflictForms: ['夺脉', '封印失衡', '宗门旧案', '秘境争夺'],
|
||||
institutionTypes: ['宗门', '法坛', '巡守司', '灵舟会'],
|
||||
tabooTypes: ['封印', '禁术', '残魂', '逆脉'],
|
||||
carrierTypes: ['法器', '灵符', '玉简', '阵核'],
|
||||
forceSystemTypes: ['灵脉', '术式', '契约', '神识'],
|
||||
},
|
||||
machina: {
|
||||
genreSignals: ['工业前线', '失控科技'],
|
||||
conflictForms: ['封锁', '回收', '追查事故', '前线失守'],
|
||||
institutionTypes: ['财团', '工坊', '舰队', '调查局'],
|
||||
tabooTypes: ['过载', '失控协议', '封存日志', '污染区'],
|
||||
carrierTypes: ['芯片', '驱动核', '记录模组', '封存匣'],
|
||||
forceSystemTypes: ['科技', '协议', '驱动', '能量网'],
|
||||
},
|
||||
tide: {
|
||||
genreSignals: ['海岸悬疑', '潮灾余波'],
|
||||
conflictForms: ['封港', '海路争夺', '追查失踪', '护送穿渡'],
|
||||
institutionTypes: ['港务', '巡海司', '渡船会', '潮站'],
|
||||
tabooTypes: ['沉船', '禁海区', '回潮夜', '失契'],
|
||||
carrierTypes: ['航图', '潮印', '信标', '封潮匣'],
|
||||
forceSystemTypes: ['潮汐', '雾潮', '海誓', '异流'],
|
||||
},
|
||||
rift: {
|
||||
genreSignals: ['裂界边境', '战线余烬'],
|
||||
conflictForms: ['守线', '撤离', '回收异常', '追查失线'],
|
||||
institutionTypes: ['前哨', '巡边队', '断层站', '回收组'],
|
||||
tabooTypes: ['断层失守', '界外污染', '封桥令', '旧撤离线'],
|
||||
carrierTypes: ['界核', '锚印', '样本', '回响记录'],
|
||||
forceSystemTypes: ['裂界', '界压', '污染', '锚定'],
|
||||
},
|
||||
};
|
||||
|
||||
const CREATURE_ARCHETYPE_PRESETS: Record<
|
||||
CustomWorldThemeMode,
|
||||
Array<Omit<CreatureArchetypeProfile, 'id'>>
|
||||
> = {
|
||||
mythic: [
|
||||
{
|
||||
label: '潜伏袭击者',
|
||||
threatStyle: '借地形潜伏后突然贴身施压。',
|
||||
keywords: ['潜伏', '伏击', '前探阻断'],
|
||||
},
|
||||
{
|
||||
label: '群居骚扰者',
|
||||
threatStyle: '依靠数量与机动性反复撕扯阵线。',
|
||||
keywords: ['群居', '扰动', '消耗'],
|
||||
},
|
||||
{
|
||||
label: '回响追猎者',
|
||||
threatStyle: '会追着异常痕迹与关键目标持续压迫。',
|
||||
keywords: ['回响', '追索', '持续压迫'],
|
||||
},
|
||||
],
|
||||
martial: [
|
||||
{
|
||||
label: '潜伏袭击者',
|
||||
threatStyle: '先藏身再借速度打出首轮杀招。',
|
||||
keywords: ['潜袭', '伏击', '贴身爆发'],
|
||||
},
|
||||
{
|
||||
label: '重甲承压者',
|
||||
threatStyle: '站住正面、顶着伤害强行换血。',
|
||||
keywords: ['承压', '守线', '正面对撞'],
|
||||
},
|
||||
{
|
||||
label: '远程威胁者',
|
||||
threatStyle: '依靠暗器、弓弩或投掷不断压制走位。',
|
||||
keywords: ['远程', '压制', '封走位'],
|
||||
},
|
||||
],
|
||||
arcane: [
|
||||
{
|
||||
label: '灵体回响体',
|
||||
threatStyle: '借余波与残识干扰节奏并持续追逼。',
|
||||
keywords: ['灵体', '回响', '术式残留'],
|
||||
},
|
||||
{
|
||||
label: '异化污染体',
|
||||
threatStyle: '被灵潮扭曲后具备高压近身威胁。',
|
||||
keywords: ['异化', '污染', '近身撕咬'],
|
||||
},
|
||||
{
|
||||
label: '机关守卫体',
|
||||
threatStyle: '围绕阵核或封印节点进行固守打击。',
|
||||
keywords: ['机关', '守卫', '节点压制'],
|
||||
},
|
||||
],
|
||||
machina: [
|
||||
{
|
||||
label: '远程威胁者',
|
||||
threatStyle: '依靠火力、脉冲或投射装置封锁空间。',
|
||||
keywords: ['火力', '远程', '封锁'],
|
||||
},
|
||||
{
|
||||
label: '重装阻断者',
|
||||
threatStyle: '借重甲和装置正面堵截推进线路。',
|
||||
keywords: ['重装', '阻断', '压线'],
|
||||
},
|
||||
{
|
||||
label: '失控追击者',
|
||||
threatStyle: '高频位移并持续追杀被标记目标。',
|
||||
keywords: ['失控', '追击', '高机动'],
|
||||
},
|
||||
],
|
||||
tide: [
|
||||
{
|
||||
label: '群居骚扰者',
|
||||
threatStyle: '借潮湿地形和数量优势消耗行进队伍。',
|
||||
keywords: ['群居', '潮湿', '消耗'],
|
||||
},
|
||||
{
|
||||
label: '潜伏袭击者',
|
||||
threatStyle: '利用雾潮与死角打出突袭。',
|
||||
keywords: ['迷雾', '潜伏', '突袭'],
|
||||
},
|
||||
{
|
||||
label: '异化污染体',
|
||||
threatStyle: '会沿潮灾痕迹持续扩散压迫。',
|
||||
keywords: ['潮灾', '异化', '扩散'],
|
||||
},
|
||||
],
|
||||
rift: [
|
||||
{
|
||||
label: '异化污染体',
|
||||
threatStyle: '长期暴露在裂界环境后具备高压侵蚀性。',
|
||||
keywords: ['污染', '侵蚀', '裂界'],
|
||||
},
|
||||
{
|
||||
label: '远程威胁者',
|
||||
threatStyle: '依靠界压残波或碎片投射逼迫走位。',
|
||||
keywords: ['界压', '残波', '远程'],
|
||||
},
|
||||
{
|
||||
label: '机关守卫体',
|
||||
threatStyle: '围绕前哨节点和封桥设施持续守线。',
|
||||
keywords: ['前哨', '守线', '节点'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, max = 8) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
function dedupeStrings(
|
||||
values: Array<string | null | undefined>,
|
||||
max = 8,
|
||||
) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
function splitToneTags(tone: string) {
|
||||
return dedupeStrings(tone.split(/[,、,/\s]+/u), 6);
|
||||
}
|
||||
|
||||
function inferInstitutionType(labels: string[]) {
|
||||
return dedupeStrings(
|
||||
labels.map((label) => {
|
||||
if (/[宗门宫阁派]/u.test(label)) return '宗门';
|
||||
if (/[司局署院]/u.test(label)) return '调查局';
|
||||
if (/[团盟会]/u.test(label)) return '同盟';
|
||||
if (/[公司财团]/u.test(label)) return '公司';
|
||||
if (/[港坞渡站哨]/u.test(label)) return '前哨';
|
||||
if (/[学园学院书院]/u.test(label)) return '学园';
|
||||
if (/[舰船坞航]/u.test(label)) return '舰队';
|
||||
if (/[部族寨群落]/u.test(label)) return '部族';
|
||||
return label.length <= 8 ? label : '';
|
||||
}),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
function inferForceSystemTypes(profile: CustomWorldProfile) {
|
||||
const source = `${profile.settingText} ${profile.summary} ${profile.tone} ${profile.playerGoal}`;
|
||||
const detected = [
|
||||
/科技|机巧|协议|工坊|脉冲|驱动/u.test(source) ? '科技' : null,
|
||||
/海潮|潮汐|海雾|港湾/u.test(source) ? '潮汐' : null,
|
||||
/裂界|断层|界桥|灰域|污染/u.test(source) ? '裂界' : null,
|
||||
/仙|灵|术|阵|符|秘境|宗门/u.test(source) ? '灵脉' : null,
|
||||
/契约|誓约|旧誓/u.test(source) ? '契约' : null,
|
||||
/回响|残响|余波/u.test(source) ? '回响' : null,
|
||||
];
|
||||
|
||||
return dedupeStrings(detected, 4);
|
||||
}
|
||||
|
||||
function inferRoleArchetypeLabel(
|
||||
role: Pick<CustomWorldProfile['playableNpcs'][number], 'combatStyle' | 'role' | 'tags'>,
|
||||
) {
|
||||
const source = `${role.role} ${role.combatStyle} ${role.tags.join(' ')}`;
|
||||
|
||||
if (/[弓|炮|射|远程|投掷|火力]/u.test(source)) {
|
||||
return '远程压制型';
|
||||
}
|
||||
if (/[盾|守|承压|阵线|重甲|护]/u.test(source)) {
|
||||
return '续航承压型';
|
||||
}
|
||||
if (/[影|潜|刺|闪|遁|爆发]/u.test(source)) {
|
||||
return '潜行爆发型';
|
||||
}
|
||||
if (/[法|术|控|阵|咒|机关|干扰|牵制]/u.test(source)) {
|
||||
return '控场解构型';
|
||||
}
|
||||
return '正面推进型';
|
||||
}
|
||||
|
||||
function inferSceneBucketLabel(
|
||||
landmark: Pick<CustomWorldProfile['landmarks'][number], 'name' | 'description' | 'dangerLevel'>,
|
||||
) {
|
||||
const source = `${landmark.name} ${landmark.description}`;
|
||||
|
||||
if (/[港|湾|渡口|码头|潮]/u.test(source)) return '临水渡口区';
|
||||
if (/[桥|关|哨|门|入口]/u.test(source)) return '高压入口区';
|
||||
if (/[殿|坛|仪式|祭]/u.test(source)) return '仪式神殿区';
|
||||
if (/[塔|空桥|高空|云|崖]/u.test(source)) return '高空通路区';
|
||||
if (/[厂|工坊|机库|轨|站]/u.test(source)) return '工业热区';
|
||||
if (/[洞|穴|地下|遗迹|墓]/u.test(source)) return '地底遗迹区';
|
||||
if (/[街|巷|城|镇|居]/u.test(source)) return '群落聚居区';
|
||||
|
||||
return landmark.dangerLevel === 'high' || landmark.dangerLevel === 'extreme'
|
||||
? '高压交汇区'
|
||||
: '叙事缓冲区';
|
||||
}
|
||||
|
||||
function buildRoleArchetypes(profile: CustomWorldProfile) {
|
||||
return profile.playableNpcs.slice(0, 6).map((role, index) => ({
|
||||
id: `role-archetype-${index + 1}`,
|
||||
label: inferRoleArchetypeLabel(role),
|
||||
combatFocus: role.combatStyle.trim() || role.role.trim() || '围绕核心职责推进战局。',
|
||||
narrativeFunction:
|
||||
role.role.trim() || role.description.trim() || '在主线推进中提供关键响应。',
|
||||
sourceRoleIds: [role.id],
|
||||
sourceTemplateCharacterIds: role.templateCharacterId
|
||||
? [role.templateCharacterId]
|
||||
: [],
|
||||
tags: dedupeStrings(role.tags, 5),
|
||||
})) satisfies RoleArchetypeProfile[];
|
||||
}
|
||||
|
||||
function buildSceneBuckets(profile: CustomWorldProfile) {
|
||||
return profile.landmarks.slice(0, 8).map((landmark, index) => ({
|
||||
id: `scene-bucket-${index + 1}`,
|
||||
label: inferSceneBucketLabel(landmark),
|
||||
moodTags: dedupeStrings(
|
||||
[landmark.dangerLevel, ...splitToneTags(profile.tone)],
|
||||
4,
|
||||
),
|
||||
keywords: dedupeStrings([landmark.name, landmark.description], 4),
|
||||
referenceLandmarkIds: [landmark.id],
|
||||
})) satisfies SceneArchetypeBucket[];
|
||||
}
|
||||
|
||||
function buildCreatureArchetypes(mode: CustomWorldThemeMode) {
|
||||
return CREATURE_ARCHETYPE_PRESETS[mode].map((creature, index) => ({
|
||||
id: `creature-archetype-${index + 1}`,
|
||||
...creature,
|
||||
})) satisfies CreatureArchetypeProfile[];
|
||||
}
|
||||
|
||||
function buildThemePackSeed(profile: CustomWorldProfile) {
|
||||
return buildThemePackFromWorldProfile({
|
||||
settingText: profile.settingText,
|
||||
summary: profile.summary,
|
||||
tone: profile.tone,
|
||||
playerGoal: profile.playerGoal,
|
||||
templateWorldType: profile.templateWorldType,
|
||||
majorFactions: profile.majorFactions,
|
||||
coreConflicts: profile.coreConflicts,
|
||||
ownedSettingLayers: null,
|
||||
});
|
||||
}
|
||||
|
||||
function compileSemanticAnchor(
|
||||
profile: CustomWorldProfile,
|
||||
mode: CustomWorldThemeMode,
|
||||
) {
|
||||
const preset = SEMANTIC_ANCHOR_PRESETS[mode];
|
||||
const creatorIntent = profile.creatorIntent;
|
||||
const institutionHints = inferInstitutionType([
|
||||
...profile.majorFactions,
|
||||
...(creatorIntent?.keyFactions.map((seed) => seed.name) ?? []),
|
||||
]);
|
||||
const forceSystemTypes = inferForceSystemTypes(profile);
|
||||
|
||||
return {
|
||||
genreSignals: dedupeStrings(
|
||||
[...(creatorIntent?.themeKeywords ?? []), ...preset.genreSignals],
|
||||
6,
|
||||
),
|
||||
conflictForms: dedupeStrings(
|
||||
[...profile.coreConflicts, ...preset.conflictForms],
|
||||
6,
|
||||
),
|
||||
institutionTypes: dedupeStrings(
|
||||
[...institutionHints, ...preset.institutionTypes],
|
||||
6,
|
||||
),
|
||||
tabooTypes: dedupeStrings(
|
||||
[...profile.coreConflicts, ...preset.tabooTypes],
|
||||
6,
|
||||
),
|
||||
carrierTypes: dedupeStrings(
|
||||
[...(creatorIntent?.iconicElements ?? []), ...preset.carrierTypes],
|
||||
6,
|
||||
),
|
||||
forceSystemTypes: dedupeStrings(
|
||||
[...forceSystemTypes, ...preset.forceSystemTypes],
|
||||
6,
|
||||
),
|
||||
atmosphereTags: dedupeStrings(
|
||||
[...splitToneTags(profile.tone), ...preset.genreSignals],
|
||||
6,
|
||||
),
|
||||
} satisfies CustomWorldSemanticAnchor;
|
||||
}
|
||||
|
||||
function compileRuleProfile(
|
||||
profile: CustomWorldProfile,
|
||||
mode: CustomWorldThemeMode,
|
||||
) {
|
||||
return {
|
||||
attributeSchema: profile.attributeSchema,
|
||||
resourceLabels: RESOURCE_LABEL_PRESETS[mode],
|
||||
economyProfile: {
|
||||
initialCurrency: INITIAL_CURRENCY_PRESETS[mode],
|
||||
},
|
||||
} satisfies CustomWorldRuleProfile;
|
||||
}
|
||||
|
||||
function compileExpressionProfile(
|
||||
profile: CustomWorldProfile,
|
||||
semanticAnchor: CustomWorldSemanticAnchor,
|
||||
) {
|
||||
const fallbackThemePack = buildThemePackSeed(profile);
|
||||
const themePack = normalizeThemePack(profile.themePack, fallbackThemePack);
|
||||
|
||||
return {
|
||||
themePack,
|
||||
presentationTone: dedupeStrings(
|
||||
[profile.tone, ...semanticAnchor.atmosphereTags, ...themePack.toneRange],
|
||||
8,
|
||||
),
|
||||
namingDirectives: dedupeStrings(themePack.namingPatterns, 6),
|
||||
clueDirectives: dedupeStrings(themePack.clueForms, 6),
|
||||
revealDirectives: dedupeStrings(themePack.revealStyles, 6),
|
||||
} satisfies CustomWorldExpressionProfile;
|
||||
}
|
||||
|
||||
function compileReferenceProfile(
|
||||
profile: CustomWorldProfile,
|
||||
mode: CustomWorldThemeMode,
|
||||
) {
|
||||
return {
|
||||
roleArchetypes: buildRoleArchetypes(profile),
|
||||
sceneBuckets: buildSceneBuckets(profile),
|
||||
creatureArchetypes: buildCreatureArchetypes(mode),
|
||||
} satisfies CustomWorldReferenceProfile;
|
||||
}
|
||||
|
||||
function compileCompatibilityProfile(profile: CustomWorldProfile) {
|
||||
return {
|
||||
legacyTemplateWorldType: profile.templateWorldType ?? WorldType.WUXIA,
|
||||
migrationVersion: OWNED_SETTING_LAYER_MIGRATION_VERSION,
|
||||
} satisfies CustomWorldCompatibilityProfile;
|
||||
}
|
||||
|
||||
function normalizeRoleArchetypes(
|
||||
value: unknown,
|
||||
fallback: RoleArchetypeProfile[],
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = entry as Record<string, unknown>;
|
||||
const label = toText(item.label);
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || `role-archetype-${index + 1}`,
|
||||
label,
|
||||
combatFocus:
|
||||
toText(item.combatFocus) ||
|
||||
fallback[index]?.combatFocus ||
|
||||
'围绕核心职责推进战局。',
|
||||
narrativeFunction:
|
||||
toText(item.narrativeFunction) ||
|
||||
fallback[index]?.narrativeFunction ||
|
||||
'在主线推进中提供关键响应。',
|
||||
sourceRoleIds: toStringArray(item.sourceRoleIds, 4),
|
||||
sourceTemplateCharacterIds: toStringArray(
|
||||
item.sourceTemplateCharacterIds,
|
||||
4,
|
||||
),
|
||||
tags: dedupeStrings(toStringArray(item.tags, 5), 5),
|
||||
} satisfies RoleArchetypeProfile;
|
||||
})
|
||||
.filter((entry): entry is RoleArchetypeProfile => Boolean(entry));
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function normalizeSceneBuckets(
|
||||
value: unknown,
|
||||
fallback: SceneArchetypeBucket[],
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = entry as Record<string, unknown>;
|
||||
const label = toText(item.label);
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || `scene-bucket-${index + 1}`,
|
||||
label,
|
||||
moodTags: dedupeStrings(toStringArray(item.moodTags, 4), 4),
|
||||
keywords: dedupeStrings(toStringArray(item.keywords, 4), 4),
|
||||
referenceLandmarkIds: toStringArray(item.referenceLandmarkIds, 4),
|
||||
} satisfies SceneArchetypeBucket;
|
||||
})
|
||||
.filter((entry): entry is SceneArchetypeBucket => Boolean(entry));
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function normalizeCreatureArchetypes(
|
||||
value: unknown,
|
||||
fallback: CreatureArchetypeProfile[],
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = entry as Record<string, unknown>;
|
||||
const label = toText(item.label);
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || `creature-archetype-${index + 1}`,
|
||||
label,
|
||||
threatStyle:
|
||||
toText(item.threatStyle) ||
|
||||
fallback[index]?.threatStyle ||
|
||||
'围绕核心威胁方式持续施压。',
|
||||
keywords: dedupeStrings(toStringArray(item.keywords, 4), 4),
|
||||
} satisfies CreatureArchetypeProfile;
|
||||
})
|
||||
.filter((entry): entry is CreatureArchetypeProfile => Boolean(entry));
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
export function compileOwnedSettingLayersFromLegacyTemplate(
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const mode = detectCustomWorldThemeMode({
|
||||
settingText: profile.settingText,
|
||||
summary: profile.summary,
|
||||
tone: profile.tone,
|
||||
playerGoal: profile.playerGoal,
|
||||
templateWorldType: profile.templateWorldType,
|
||||
ownedSettingLayers: null,
|
||||
});
|
||||
const semanticAnchor = compileSemanticAnchor(profile, mode);
|
||||
|
||||
return {
|
||||
semanticAnchor,
|
||||
ruleProfile: compileRuleProfile(profile, mode),
|
||||
expressionProfile: compileExpressionProfile(profile, semanticAnchor),
|
||||
referenceProfile: compileReferenceProfile(profile, mode),
|
||||
compatibilityProfile: compileCompatibilityProfile(profile),
|
||||
} satisfies CustomWorldOwnedSettingLayers;
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldOwnedSettingLayers(
|
||||
value: unknown,
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const fallback = compileOwnedSettingLayersFromLegacyTemplate(profile);
|
||||
if (!value || typeof value !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const semanticAnchorItem =
|
||||
item.semanticAnchor && typeof item.semanticAnchor === 'object'
|
||||
? (item.semanticAnchor as Record<string, unknown>)
|
||||
: {};
|
||||
const ruleProfileItem =
|
||||
item.ruleProfile && typeof item.ruleProfile === 'object'
|
||||
? (item.ruleProfile as Record<string, unknown>)
|
||||
: {};
|
||||
const resourceLabelsItem =
|
||||
ruleProfileItem.resourceLabels &&
|
||||
typeof ruleProfileItem.resourceLabels === 'object'
|
||||
? (ruleProfileItem.resourceLabels as Record<string, unknown>)
|
||||
: {};
|
||||
const expressionProfileItem =
|
||||
item.expressionProfile && typeof item.expressionProfile === 'object'
|
||||
? (item.expressionProfile as Record<string, unknown>)
|
||||
: {};
|
||||
const referenceProfileItem =
|
||||
item.referenceProfile && typeof item.referenceProfile === 'object'
|
||||
? (item.referenceProfile as Record<string, unknown>)
|
||||
: {};
|
||||
const compatibilityProfileItem =
|
||||
item.compatibilityProfile && typeof item.compatibilityProfile === 'object'
|
||||
? (item.compatibilityProfile as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
semanticAnchor: {
|
||||
genreSignals: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.genreSignals,
|
||||
fallback.semanticAnchor.genreSignals.length,
|
||||
),
|
||||
fallback.semanticAnchor.genreSignals.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.genreSignals,
|
||||
fallback.semanticAnchor.genreSignals.length,
|
||||
),
|
||||
fallback.semanticAnchor.genreSignals.length,
|
||||
)
|
||||
: fallback.semanticAnchor.genreSignals,
|
||||
conflictForms: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.conflictForms,
|
||||
fallback.semanticAnchor.conflictForms.length,
|
||||
),
|
||||
fallback.semanticAnchor.conflictForms.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.conflictForms,
|
||||
fallback.semanticAnchor.conflictForms.length,
|
||||
),
|
||||
fallback.semanticAnchor.conflictForms.length,
|
||||
)
|
||||
: fallback.semanticAnchor.conflictForms,
|
||||
institutionTypes: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.institutionTypes,
|
||||
fallback.semanticAnchor.institutionTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.institutionTypes.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.institutionTypes,
|
||||
fallback.semanticAnchor.institutionTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.institutionTypes.length,
|
||||
)
|
||||
: fallback.semanticAnchor.institutionTypes,
|
||||
tabooTypes: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.tabooTypes,
|
||||
fallback.semanticAnchor.tabooTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.tabooTypes.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.tabooTypes,
|
||||
fallback.semanticAnchor.tabooTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.tabooTypes.length,
|
||||
)
|
||||
: fallback.semanticAnchor.tabooTypes,
|
||||
carrierTypes: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.carrierTypes,
|
||||
fallback.semanticAnchor.carrierTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.carrierTypes.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.carrierTypes,
|
||||
fallback.semanticAnchor.carrierTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.carrierTypes.length,
|
||||
)
|
||||
: fallback.semanticAnchor.carrierTypes,
|
||||
forceSystemTypes: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.forceSystemTypes,
|
||||
fallback.semanticAnchor.forceSystemTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.forceSystemTypes.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.forceSystemTypes,
|
||||
fallback.semanticAnchor.forceSystemTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.forceSystemTypes.length,
|
||||
)
|
||||
: fallback.semanticAnchor.forceSystemTypes,
|
||||
atmosphereTags: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.atmosphereTags,
|
||||
fallback.semanticAnchor.atmosphereTags.length,
|
||||
),
|
||||
fallback.semanticAnchor.atmosphereTags.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.atmosphereTags,
|
||||
fallback.semanticAnchor.atmosphereTags.length,
|
||||
),
|
||||
fallback.semanticAnchor.atmosphereTags.length,
|
||||
)
|
||||
: fallback.semanticAnchor.atmosphereTags,
|
||||
},
|
||||
ruleProfile: {
|
||||
attributeSchema: coerceWorldAttributeSchema(
|
||||
ruleProfileItem.attributeSchema,
|
||||
fallback.ruleProfile.attributeSchema,
|
||||
),
|
||||
resourceLabels: {
|
||||
hp: toText(resourceLabelsItem.hp) || fallback.ruleProfile.resourceLabels.hp,
|
||||
mp: toText(resourceLabelsItem.mp) || fallback.ruleProfile.resourceLabels.mp,
|
||||
maxHp:
|
||||
toText(resourceLabelsItem.maxHp) ||
|
||||
fallback.ruleProfile.resourceLabels.maxHp,
|
||||
maxMp:
|
||||
toText(resourceLabelsItem.maxMp) ||
|
||||
fallback.ruleProfile.resourceLabels.maxMp,
|
||||
damage:
|
||||
toText(resourceLabelsItem.damage) ||
|
||||
fallback.ruleProfile.resourceLabels.damage,
|
||||
guard:
|
||||
toText(resourceLabelsItem.guard) ||
|
||||
fallback.ruleProfile.resourceLabels.guard,
|
||||
range:
|
||||
toText(resourceLabelsItem.range) ||
|
||||
fallback.ruleProfile.resourceLabels.range,
|
||||
cooldown:
|
||||
toText(resourceLabelsItem.cooldown) ||
|
||||
fallback.ruleProfile.resourceLabels.cooldown,
|
||||
manaCost:
|
||||
toText(resourceLabelsItem.manaCost) ||
|
||||
fallback.ruleProfile.resourceLabels.manaCost,
|
||||
currency:
|
||||
toText(resourceLabelsItem.currency) ||
|
||||
fallback.ruleProfile.resourceLabels.currency,
|
||||
},
|
||||
economyProfile: {
|
||||
initialCurrency:
|
||||
typeof ruleProfileItem.economyProfile === 'object' &&
|
||||
ruleProfileItem.economyProfile &&
|
||||
typeof (ruleProfileItem.economyProfile as Record<string, unknown>)
|
||||
.initialCurrency === 'number' &&
|
||||
Number.isFinite(
|
||||
(ruleProfileItem.economyProfile as Record<string, unknown>)
|
||||
.initialCurrency,
|
||||
)
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(ruleProfileItem.economyProfile as Record<string, unknown>)
|
||||
.initialCurrency as number,
|
||||
),
|
||||
)
|
||||
: fallback.ruleProfile.economyProfile.initialCurrency,
|
||||
},
|
||||
},
|
||||
expressionProfile: {
|
||||
themePack: normalizeThemePack(
|
||||
expressionProfileItem.themePack,
|
||||
fallback.expressionProfile.themePack,
|
||||
),
|
||||
presentationTone: dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.presentationTone,
|
||||
fallback.expressionProfile.presentationTone.length,
|
||||
),
|
||||
fallback.expressionProfile.presentationTone.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.presentationTone,
|
||||
fallback.expressionProfile.presentationTone.length,
|
||||
),
|
||||
fallback.expressionProfile.presentationTone.length,
|
||||
)
|
||||
: fallback.expressionProfile.presentationTone,
|
||||
namingDirectives: dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.namingDirectives,
|
||||
fallback.expressionProfile.namingDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.namingDirectives.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.namingDirectives,
|
||||
fallback.expressionProfile.namingDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.namingDirectives.length,
|
||||
)
|
||||
: fallback.expressionProfile.namingDirectives,
|
||||
clueDirectives: dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.clueDirectives,
|
||||
fallback.expressionProfile.clueDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.clueDirectives.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.clueDirectives,
|
||||
fallback.expressionProfile.clueDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.clueDirectives.length,
|
||||
)
|
||||
: fallback.expressionProfile.clueDirectives,
|
||||
revealDirectives: dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.revealDirectives,
|
||||
fallback.expressionProfile.revealDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.revealDirectives.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.revealDirectives,
|
||||
fallback.expressionProfile.revealDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.revealDirectives.length,
|
||||
)
|
||||
: fallback.expressionProfile.revealDirectives,
|
||||
},
|
||||
referenceProfile: {
|
||||
roleArchetypes: normalizeRoleArchetypes(
|
||||
referenceProfileItem.roleArchetypes,
|
||||
fallback.referenceProfile.roleArchetypes,
|
||||
),
|
||||
sceneBuckets: normalizeSceneBuckets(
|
||||
referenceProfileItem.sceneBuckets,
|
||||
fallback.referenceProfile.sceneBuckets,
|
||||
),
|
||||
creatureArchetypes: normalizeCreatureArchetypes(
|
||||
referenceProfileItem.creatureArchetypes,
|
||||
fallback.referenceProfile.creatureArchetypes,
|
||||
),
|
||||
},
|
||||
compatibilityProfile: {
|
||||
legacyTemplateWorldType:
|
||||
compatibilityProfileItem.legacyTemplateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: compatibilityProfileItem.legacyTemplateWorldType === WorldType.WUXIA
|
||||
? WorldType.WUXIA
|
||||
: fallback.compatibilityProfile?.legacyTemplateWorldType ?? null,
|
||||
migrationVersion:
|
||||
toText(compatibilityProfileItem.migrationVersion) ||
|
||||
fallback.compatibilityProfile?.migrationVersion ||
|
||||
OWNED_SETTING_LAYER_MIGRATION_VERSION,
|
||||
},
|
||||
} satisfies CustomWorldOwnedSettingLayers;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldOwnedSettingLayers(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return profile.ownedSettingLayers ?? compileOwnedSettingLayersFromLegacyTemplate(profile);
|
||||
}
|
||||
|
||||
export function resolveCustomWorldRuleProfile(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
return resolveCustomWorldOwnedSettingLayers(profile)?.ruleProfile ?? null;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldExpressionProfile(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
return resolveCustomWorldOwnedSettingLayers(profile)?.expressionProfile ?? null;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldSemanticAnchor(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
return resolveCustomWorldOwnedSettingLayers(profile)?.semanticAnchor ?? null;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldCompatibilityProfile(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
return resolveCustomWorldOwnedSettingLayers(profile)?.compatibilityProfile ?? null;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { resolveCustomWorldCampScene } from './customWorldCamp';
|
||||
import { resolveCustomWorldRuleProfile } from './customWorldOwnedSettingLayers';
|
||||
import {
|
||||
type CustomWorldThemeMode,
|
||||
detectCustomWorldThemeMode,
|
||||
@@ -373,6 +374,21 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
|
||||
};
|
||||
}
|
||||
|
||||
const ruleProfile = resolveCustomWorldRuleProfile(profile);
|
||||
if (ruleProfile) {
|
||||
return {
|
||||
hp: ruleProfile.resourceLabels.hp,
|
||||
mp: ruleProfile.resourceLabels.mp,
|
||||
maxHp: ruleProfile.resourceLabels.maxHp,
|
||||
maxMp: ruleProfile.resourceLabels.maxMp,
|
||||
damage: ruleProfile.resourceLabels.damage,
|
||||
guard: ruleProfile.resourceLabels.guard,
|
||||
range: ruleProfile.resourceLabels.range,
|
||||
cooldown: ruleProfile.resourceLabels.cooldown,
|
||||
manaCost: ruleProfile.resourceLabels.manaCost,
|
||||
};
|
||||
}
|
||||
|
||||
const presentation = getWorldPresentation(profile);
|
||||
return {
|
||||
hp: presentation.hpLabel,
|
||||
|
||||
209
src/services/customWorldReferenceSignals.test.ts
Normal file
209
src/services/customWorldReferenceSignals.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type CustomWorldProfile, WorldType } from '../types';
|
||||
import {
|
||||
collectCreatureArchetypeSignals,
|
||||
collectSceneBucketSignalKeywords,
|
||||
resolveCreatureArchetypeForSource,
|
||||
resolveRoleTemplateCharacterIdFromReferenceProfile,
|
||||
resolveSceneBucketForLandmark,
|
||||
} from './customWorldReferenceSignals';
|
||||
|
||||
function buildReferenceProfileHarness() {
|
||||
return {
|
||||
id: 'reference-harness',
|
||||
settingText: '围绕裂界港区、断桥前线与工业旧站展开的世界。',
|
||||
name: '裂桥港区',
|
||||
subtitle: '前线潮压',
|
||||
summary: '断桥、港区和旧站之间的战线不断回响。',
|
||||
tone: '高压、潮湿、迟滞',
|
||||
playerGoal: '查清断桥封锁与旧站事故背后的真相',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'custom:test',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '裂桥港区',
|
||||
settingSummary: '断桥前线',
|
||||
tone: '高压',
|
||||
conflictCore: '旧站事故',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
ownedSettingLayers: {
|
||||
semanticAnchor: {
|
||||
genreSignals: ['裂界边境'],
|
||||
conflictForms: ['追查失线'],
|
||||
institutionTypes: ['前哨'],
|
||||
tabooTypes: ['封桥令'],
|
||||
carrierTypes: ['界核'],
|
||||
forceSystemTypes: ['裂界'],
|
||||
atmosphereTags: ['高压'],
|
||||
},
|
||||
ruleProfile: {
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'custom:test',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '裂桥港区',
|
||||
settingSummary: '断桥前线',
|
||||
tone: '高压',
|
||||
conflictCore: '旧站事故',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
resourceLabels: {
|
||||
hp: '界命',
|
||||
mp: '裂能',
|
||||
maxHp: '界命上限',
|
||||
maxMp: '裂能上限',
|
||||
damage: '界势',
|
||||
guard: '稳界',
|
||||
range: '界距',
|
||||
cooldown: '复界',
|
||||
manaCost: '裂能消耗',
|
||||
currency: '边贸券',
|
||||
},
|
||||
economyProfile: {
|
||||
initialCurrency: 160,
|
||||
},
|
||||
},
|
||||
expressionProfile: {
|
||||
themePack: {
|
||||
id: 'theme:test',
|
||||
displayName: '裂桥前线',
|
||||
toneRange: ['高压'],
|
||||
institutionLexicon: ['前哨'],
|
||||
tabooLexicon: ['封桥令'],
|
||||
artifactClasses: ['界核'],
|
||||
actorArchetypes: ['边巡者'],
|
||||
conflictForms: ['追查失线'],
|
||||
clueForms: ['裂痕'],
|
||||
namingPatterns: ['前哨+旧痕+器类'],
|
||||
revealStyles: ['证词错位'],
|
||||
},
|
||||
presentationTone: ['高压'],
|
||||
namingDirectives: ['前哨+旧痕+器类'],
|
||||
clueDirectives: ['裂痕'],
|
||||
revealDirectives: ['证词错位'],
|
||||
},
|
||||
referenceProfile: {
|
||||
roleArchetypes: [
|
||||
{
|
||||
id: 'role-1',
|
||||
label: '远程压制型',
|
||||
combatFocus: '依靠弓与远程火力持续压制。',
|
||||
narrativeFunction: '为队伍提供远程压制与侦查。',
|
||||
sourceRoleIds: [],
|
||||
sourceTemplateCharacterIds: [],
|
||||
tags: ['远程', '射击'],
|
||||
},
|
||||
],
|
||||
sceneBuckets: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
label: '工业热区',
|
||||
moodTags: ['高压'],
|
||||
keywords: ['旧站', '工坊'],
|
||||
referenceLandmarkIds: ['landmark-industrial'],
|
||||
},
|
||||
{
|
||||
id: 'scene-2',
|
||||
label: '临水渡口区',
|
||||
moodTags: ['潮湿'],
|
||||
keywords: ['港区', '渡桥'],
|
||||
referenceLandmarkIds: ['landmark-harbor'],
|
||||
},
|
||||
],
|
||||
creatureArchetypes: [
|
||||
{
|
||||
id: 'creature-1',
|
||||
label: '机关守卫体',
|
||||
threatStyle: '围绕节点和装置进行守线压制。',
|
||||
keywords: ['机关', '守卫', '旧站'],
|
||||
},
|
||||
{
|
||||
id: 'creature-2',
|
||||
label: '远程威胁者',
|
||||
threatStyle: '依靠远程投射和凝视压制走位。',
|
||||
keywords: ['远程', '压制', '索敌'],
|
||||
},
|
||||
],
|
||||
},
|
||||
compatibilityProfile: {
|
||||
legacyTemplateWorldType: WorldType.WUXIA,
|
||||
migrationVersion: 'test',
|
||||
},
|
||||
},
|
||||
themePack: null,
|
||||
storyGraph: null,
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
describe('customWorldReferenceSignals', () => {
|
||||
it('resolves scene buckets by explicit landmark ownership', () => {
|
||||
const profile = buildReferenceProfileHarness();
|
||||
|
||||
const bucket = resolveSceneBucketForLandmark(profile, {
|
||||
id: 'landmark-industrial',
|
||||
name: '旧站锅炉层',
|
||||
description: '轨道和锅炉残响仍卡在热区深处。',
|
||||
});
|
||||
|
||||
expect(bucket?.label).toBe('工业热区');
|
||||
expect(collectSceneBucketSignalKeywords(bucket!).includes('工坊')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves creature archetypes and exposes combat/habitat signal tags', () => {
|
||||
const profile = buildReferenceProfileHarness();
|
||||
|
||||
const archetype = resolveCreatureArchetypeForSource(profile, {
|
||||
name: '旧站守卫傀',
|
||||
role: '节点守卫',
|
||||
description: '围绕工坊旧站守线,遇敌后会启动压制炮座。',
|
||||
combatStyle: '守住节点后用远程火力封锁通路。',
|
||||
tags: ['机关', '旧站', '守卫'],
|
||||
});
|
||||
|
||||
const signals = collectCreatureArchetypeSignals(archetype!);
|
||||
|
||||
expect(archetype?.label).toBe('机关守卫体');
|
||||
expect(signals.combatTags).toContain('守御');
|
||||
expect(signals.habitatTags).toContain('工场');
|
||||
});
|
||||
|
||||
it('maps role archetypes back to suitable preset character templates', () => {
|
||||
const profile = buildReferenceProfileHarness();
|
||||
|
||||
const templateCharacterId = resolveRoleTemplateCharacterIdFromReferenceProfile(
|
||||
profile,
|
||||
{
|
||||
id: 'story-role-1',
|
||||
name: '雾港狙巡手',
|
||||
title: '岸线压制者',
|
||||
role: '远程巡手',
|
||||
description: '负责在港区高点做远程掩护与索敌压制。',
|
||||
personality: '冷静,开火前总先确认潮向。',
|
||||
combatStyle: '高点远程压制,必要时转为游击拉扯。',
|
||||
tags: ['远程', '射击', '港区'],
|
||||
},
|
||||
);
|
||||
|
||||
expect(templateCharacterId).toBe('archer-hero');
|
||||
});
|
||||
});
|
||||
369
src/services/customWorldReferenceSignals.ts
Normal file
369
src/services/customWorldReferenceSignals.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import type {
|
||||
CreatureArchetypeProfile,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
RoleArchetypeProfile,
|
||||
SceneArchetypeBucket,
|
||||
} from '../types';
|
||||
|
||||
type SceneBucketSignalPreset = {
|
||||
keywords: string[];
|
||||
};
|
||||
|
||||
type CreatureArchetypeSignalPreset = {
|
||||
keywords: string[];
|
||||
combatTags: string[];
|
||||
habitatTags: string[];
|
||||
};
|
||||
|
||||
type RoleArchetypeSignalPreset = {
|
||||
keywords: string[];
|
||||
templateCharacterIds: string[];
|
||||
};
|
||||
|
||||
const SCENE_BUCKET_SIGNAL_PRESETS: Record<string, SceneBucketSignalPreset> = {
|
||||
高压入口区: {
|
||||
keywords: ['入口', '关口', '哨站', '桥口', '门廊', '边关'],
|
||||
},
|
||||
临水渡口区: {
|
||||
keywords: ['渡口', '码头', '港口', '岸线', '船坞', '水路'],
|
||||
},
|
||||
仪式神殿区: {
|
||||
keywords: ['祭坛', '神殿', '仪式', '法坛', '庙宇', '圣所'],
|
||||
},
|
||||
高空通路区: {
|
||||
keywords: ['高空', '悬桥', '云阶', '塔顶', '崖道', '飞桥'],
|
||||
},
|
||||
工业热区: {
|
||||
keywords: ['工坊', '轨道', '机库', '熔炉', '工场', '锅炉'],
|
||||
},
|
||||
地底遗迹区: {
|
||||
keywords: ['地宫', '矿道', '遗迹', '洞窟', '墓道', '地底'],
|
||||
},
|
||||
群落聚居区: {
|
||||
keywords: ['街巷', '聚落', '城镇', '营地', '居所', '市集'],
|
||||
},
|
||||
高压交汇区: {
|
||||
keywords: ['险地', '封锁', '交汇', '前线', '险关', '断层'],
|
||||
},
|
||||
叙事缓冲区: {
|
||||
keywords: ['归处', '栖居', '缓冲', '休整', '据点', '落脚'],
|
||||
},
|
||||
};
|
||||
|
||||
const CREATURE_ARCHETYPE_SIGNAL_PRESETS: Record<
|
||||
string,
|
||||
CreatureArchetypeSignalPreset
|
||||
> = {
|
||||
潜伏袭击者: {
|
||||
keywords: ['潜伏', '伏击', '突袭', '暗影', '贴身'],
|
||||
combatTags: ['快袭', '突进', '机动'],
|
||||
habitatTags: ['雾林', '断垣', '妖雾', '崖壁'],
|
||||
},
|
||||
重甲承压者: {
|
||||
keywords: ['重甲', '承压', '守线', '堵截', '厚重'],
|
||||
combatTags: ['重甲', '守御', '护体', '堡垒'],
|
||||
habitatTags: ['矿道', '废城', '边关', '地宫'],
|
||||
},
|
||||
群居骚扰者: {
|
||||
keywords: ['群居', '骚扰', '游窜', '围猎', '消耗'],
|
||||
combatTags: ['机动', '追击', '控场'],
|
||||
habitatTags: ['竹林', '雾林', '荒野', '月湖'],
|
||||
},
|
||||
远程威胁者: {
|
||||
keywords: ['远程', '投射', '压制', '炮击', '凝视'],
|
||||
combatTags: ['远射', '法修', '雷法'],
|
||||
habitatTags: ['长街', '仙门', '星舟', '祭坛'],
|
||||
},
|
||||
异化污染体: {
|
||||
keywords: ['异化', '污染', '腐化', '潮灾', '侵蚀'],
|
||||
combatTags: ['法力', '回复', '重甲'],
|
||||
habitatTags: ['洞天', '谷地', '秘境', '灵泉'],
|
||||
},
|
||||
灵体回响体: {
|
||||
keywords: ['灵体', '回响', '残魂', '旧痕', '幽灵'],
|
||||
combatTags: ['镇邪', '控场', '法修'],
|
||||
habitatTags: ['遗迹', '祭坛', '古迹', '废寺'],
|
||||
},
|
||||
机关守卫体: {
|
||||
keywords: ['机关', '守卫', '节点', '封印', '装置'],
|
||||
combatTags: ['守御', '压制', '符阵'],
|
||||
habitatTags: ['铸坊', '工场', '前哨', '长廊'],
|
||||
},
|
||||
回响追猎者: {
|
||||
keywords: ['追猎', '回响', '索敌', '追索', '名单'],
|
||||
combatTags: ['追击', '压制', '机动'],
|
||||
habitatTags: ['前线', '断层', '渡口', '雾港'],
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_ARCHETYPE_SIGNAL_PRESETS: Record<string, RoleArchetypeSignalPreset> = {
|
||||
正面推进型: {
|
||||
keywords: ['推进', '压前', '正面', '先锋', '破阵'],
|
||||
templateCharacterIds: ['sword-princess', 'punch-hero'],
|
||||
},
|
||||
远程压制型: {
|
||||
keywords: ['远程', '弓', '射击', '投掷', '炮击'],
|
||||
templateCharacterIds: ['archer-hero'],
|
||||
},
|
||||
控场解构型: {
|
||||
keywords: ['控场', '阵', '法', '机关', '解构', '牵制'],
|
||||
templateCharacterIds: ['fighter-4', 'girl-hero'],
|
||||
},
|
||||
续航承压型: {
|
||||
keywords: ['承压', '护体', '守御', '续航', '稳阵'],
|
||||
templateCharacterIds: ['fighter-4', 'punch-hero'],
|
||||
},
|
||||
潜行爆发型: {
|
||||
keywords: ['潜行', '爆发', '影袭', '突进', '追击'],
|
||||
templateCharacterIds: ['girl-hero', 'sword-princess'],
|
||||
},
|
||||
};
|
||||
|
||||
type ReferenceRoleSource = Pick<
|
||||
CustomWorldPlayableNpc | CustomWorldNpc,
|
||||
'id' | 'name' | 'title' | 'role' | 'description' | 'personality' | 'combatStyle' | 'tags'
|
||||
>;
|
||||
|
||||
type ReferenceCreatureSource = Partial<
|
||||
Pick<
|
||||
CustomWorldPlayableNpc & CustomWorldNpc,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'title'
|
||||
| 'role'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
>
|
||||
>;
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function dedupeStrings(
|
||||
values: Array<string | null | undefined>,
|
||||
max = 12,
|
||||
) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function buildRoleSourceText(role: ReferenceRoleSource) {
|
||||
return dedupeStrings([
|
||||
role.name,
|
||||
role.title,
|
||||
role.role,
|
||||
role.description,
|
||||
role.personality,
|
||||
role.combatStyle,
|
||||
...(role.tags ?? []),
|
||||
]).join(' ');
|
||||
}
|
||||
|
||||
function buildCreatureSourceText(source: ReferenceCreatureSource) {
|
||||
return dedupeStrings([
|
||||
source.name,
|
||||
source.title,
|
||||
source.role,
|
||||
source.description,
|
||||
source.backstory,
|
||||
source.personality,
|
||||
source.motivation,
|
||||
source.combatStyle,
|
||||
...(source.relationshipHooks ?? []),
|
||||
...(source.tags ?? []),
|
||||
]).join(' ');
|
||||
}
|
||||
|
||||
function scoreTextMatches(sourceText: string, keywords: string[]) {
|
||||
return keywords.reduce((score, keyword) => {
|
||||
if (!keyword || !sourceText.includes(keyword)) {
|
||||
return score;
|
||||
}
|
||||
|
||||
if (keyword.length >= 4) {
|
||||
return score + 8;
|
||||
}
|
||||
|
||||
if (keyword.length === 3) {
|
||||
return score + 6;
|
||||
}
|
||||
|
||||
return score + 4;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getReferenceProfile(profile: CustomWorldProfile | null | undefined) {
|
||||
return profile?.ownedSettingLayers?.referenceProfile ?? null;
|
||||
}
|
||||
|
||||
export function collectSceneBucketSignalKeywords(
|
||||
bucket: Pick<SceneArchetypeBucket, 'label' | 'keywords' | 'moodTags'>,
|
||||
) {
|
||||
const preset = SCENE_BUCKET_SIGNAL_PRESETS[bucket.label];
|
||||
return dedupeStrings([
|
||||
bucket.label,
|
||||
...bucket.keywords,
|
||||
...bucket.moodTags,
|
||||
...(preset?.keywords ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveSceneBucketForLandmark(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
landmark: Pick<CustomWorldProfile['landmarks'][number], 'id' | 'name' | 'description'>,
|
||||
) {
|
||||
const sceneBuckets = getReferenceProfile(profile)?.sceneBuckets ?? [];
|
||||
if (sceneBuckets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitBucket = sceneBuckets.find((bucket) =>
|
||||
bucket.referenceLandmarkIds.includes(landmark.id),
|
||||
);
|
||||
if (explicitBucket) {
|
||||
return explicitBucket;
|
||||
}
|
||||
|
||||
const sourceText = dedupeStrings([landmark.name, landmark.description]).join(' ');
|
||||
|
||||
const scoredBuckets = sceneBuckets
|
||||
.map((bucket) => ({
|
||||
bucket,
|
||||
score: scoreTextMatches(sourceText, collectSceneBucketSignalKeywords(bucket)),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
return (scoredBuckets[0]?.score ?? 0) > 0 ? scoredBuckets[0]?.bucket ?? null : null;
|
||||
}
|
||||
|
||||
export function collectCreatureArchetypeSignals(
|
||||
archetype: Pick<CreatureArchetypeProfile, 'label' | 'threatStyle' | 'keywords'>,
|
||||
) {
|
||||
const preset = CREATURE_ARCHETYPE_SIGNAL_PRESETS[archetype.label];
|
||||
|
||||
return {
|
||||
keywords: dedupeStrings([
|
||||
archetype.label,
|
||||
archetype.threatStyle,
|
||||
...archetype.keywords,
|
||||
...(preset?.keywords ?? []),
|
||||
]),
|
||||
combatTags: dedupeStrings(preset?.combatTags ?? [], 6),
|
||||
habitatTags: dedupeStrings(preset?.habitatTags ?? [], 6),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCreatureArchetypeForSource(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
source: ReferenceCreatureSource,
|
||||
) {
|
||||
const creatureArchetypes = getReferenceProfile(profile)?.creatureArchetypes ?? [];
|
||||
if (creatureArchetypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceText = buildCreatureSourceText(source);
|
||||
const scoredArchetypes = creatureArchetypes
|
||||
.map((archetype) => ({
|
||||
archetype,
|
||||
score: scoreTextMatches(
|
||||
sourceText,
|
||||
collectCreatureArchetypeSignals(archetype).keywords,
|
||||
),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
return (scoredArchetypes[0]?.score ?? 0) > 0
|
||||
? scoredArchetypes[0]?.archetype ?? null
|
||||
: creatureArchetypes[0] ?? null;
|
||||
}
|
||||
|
||||
function collectRoleArchetypeSignals(
|
||||
archetype: Pick<
|
||||
RoleArchetypeProfile,
|
||||
| 'label'
|
||||
| 'combatFocus'
|
||||
| 'narrativeFunction'
|
||||
| 'tags'
|
||||
| 'sourceTemplateCharacterIds'
|
||||
>,
|
||||
) {
|
||||
const preset = ROLE_ARCHETYPE_SIGNAL_PRESETS[archetype.label];
|
||||
|
||||
return {
|
||||
keywords: dedupeStrings([
|
||||
archetype.label,
|
||||
archetype.combatFocus,
|
||||
archetype.narrativeFunction,
|
||||
...archetype.tags,
|
||||
...(preset?.keywords ?? []),
|
||||
]),
|
||||
templateCharacterIds:
|
||||
archetype.sourceTemplateCharacterIds.length > 0
|
||||
? archetype.sourceTemplateCharacterIds
|
||||
: preset?.templateCharacterIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRoleArchetypeForRole(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
role: ReferenceRoleSource,
|
||||
) {
|
||||
const roleArchetypes = getReferenceProfile(profile)?.roleArchetypes ?? [];
|
||||
if (roleArchetypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitArchetype = roleArchetypes.find((archetype) =>
|
||||
archetype.sourceRoleIds.includes(role.id),
|
||||
);
|
||||
if (explicitArchetype) {
|
||||
return explicitArchetype;
|
||||
}
|
||||
|
||||
const sourceText = buildRoleSourceText(role);
|
||||
const scoredArchetypes = roleArchetypes
|
||||
.map((archetype) => ({
|
||||
archetype,
|
||||
score: scoreTextMatches(sourceText, collectRoleArchetypeSignals(archetype).keywords),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
return (scoredArchetypes[0]?.score ?? 0) > 0
|
||||
? scoredArchetypes[0]?.archetype ?? null
|
||||
: roleArchetypes[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveRoleTemplateCharacterIdFromReferenceProfile(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
role: ReferenceRoleSource,
|
||||
) {
|
||||
const archetype = resolveRoleArchetypeForRole(profile, role);
|
||||
if (!archetype) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templateCharacterIds = collectRoleArchetypeSignals(archetype).templateCharacterIds;
|
||||
if (templateCharacterIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seedSource = toText(role.id) || buildRoleSourceText(role);
|
||||
return templateCharacterIds[hashText(seedSource) % templateCharacterIds.length] ?? null;
|
||||
}
|
||||
@@ -9,9 +9,32 @@ export type CustomWorldThemeMode =
|
||||
| 'mythic';
|
||||
|
||||
export function detectCustomWorldThemeMode(
|
||||
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
| 'settingText'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'templateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
>,
|
||||
): CustomWorldThemeMode {
|
||||
const source = `${profile.settingText} ${profile.summary} ${profile.tone} ${profile.playerGoal}`;
|
||||
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
|
||||
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
|
||||
const source = [
|
||||
profile.settingText,
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
...(semanticAnchor?.genreSignals ?? []),
|
||||
...(semanticAnchor?.conflictForms ?? []),
|
||||
...(semanticAnchor?.institutionTypes ?? []),
|
||||
...(semanticAnchor?.tabooTypes ?? []),
|
||||
...(semanticAnchor?.carrierTypes ?? []),
|
||||
...(semanticAnchor?.forceSystemTypes ?? []),
|
||||
...(semanticAnchor?.atmosphereTags ?? []),
|
||||
...(expressionProfile?.presentationTone ?? []),
|
||||
].join(' ');
|
||||
|
||||
if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina';
|
||||
if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide';
|
||||
@@ -23,8 +46,26 @@ export function detectCustomWorldThemeMode(
|
||||
}
|
||||
|
||||
export function resolveCustomWorldAnchorWorldType(
|
||||
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
| 'settingText'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'templateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
>,
|
||||
): WorldTemplateType {
|
||||
const legacyTemplateWorldType =
|
||||
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType;
|
||||
|
||||
if (
|
||||
legacyTemplateWorldType === WorldType.WUXIA ||
|
||||
legacyTemplateWorldType === WorldType.XIANXIA
|
||||
) {
|
||||
return legacyTemplateWorldType;
|
||||
}
|
||||
|
||||
const themeMode = detectCustomWorldThemeMode(profile);
|
||||
return themeMode === 'arcane' ? WorldType.XIANXIA : WorldType.WUXIA;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState } from '../../types';
|
||||
import { buildChapterQuestForScene } from '../../data/questFlow';
|
||||
import { AnimationState, type GameState, WorldType } from '../../types';
|
||||
import { advanceChapterState, resolveCurrentChapterState } from './chapterDirector';
|
||||
|
||||
function createState(signalCount: number): GameState {
|
||||
@@ -73,6 +74,54 @@ function createState(signalCount: number): GameState {
|
||||
};
|
||||
}
|
||||
|
||||
function createSceneChapterState() {
|
||||
const quest = buildChapterQuestForScene({
|
||||
scene: {
|
||||
id: 'scene-court',
|
||||
name: '宫苑内庭',
|
||||
description: '回廊深处静得过分。',
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc-maid',
|
||||
name: '旧宫侍女',
|
||||
description: '她总知道哪条回廊最近不该过去。',
|
||||
avatar: '侍',
|
||||
role: '宫人',
|
||||
hostile: false,
|
||||
},
|
||||
{
|
||||
id: 'hostile-shadow',
|
||||
name: '旧宫戍影',
|
||||
description: '巡行在回廊里的敌影。',
|
||||
avatar: '戍',
|
||||
role: '敌对角色',
|
||||
monsterPresetId: 'monster-11',
|
||||
hostile: true,
|
||||
},
|
||||
],
|
||||
treasureHints: ['回廊暗格里的香囊'],
|
||||
},
|
||||
worldType: WorldType.WUXIA,
|
||||
});
|
||||
|
||||
if (!quest) {
|
||||
throw new Error('Expected chapter quest');
|
||||
}
|
||||
|
||||
return {
|
||||
...createState(0),
|
||||
currentScenePreset: {
|
||||
id: 'scene-court',
|
||||
name: '宫苑内庭',
|
||||
description: '回廊深处静得过分。',
|
||||
imageSrc: '/scene.png',
|
||||
treasureHints: ['回廊暗格里的香囊'],
|
||||
npcs: [],
|
||||
},
|
||||
quests: [quest],
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
describe('chapterDirector', () => {
|
||||
it('resolves chapter stages from signal intensity', () => {
|
||||
expect(resolveCurrentChapterState({ state: createState(1) }).stage).toBe('opening');
|
||||
@@ -89,4 +138,58 @@ describe('chapterDirector', () => {
|
||||
|
||||
expect(next.id).toBe(previous.id);
|
||||
});
|
||||
|
||||
it('binds the current chapter to the current scene chapter quest', () => {
|
||||
const openingState = createSceneChapterState();
|
||||
const openingChapter = resolveCurrentChapterState({ state: openingState });
|
||||
expect(openingChapter.id).toBe('chapter:scene:scene-court');
|
||||
expect(openingChapter.sceneId).toBe('scene-court');
|
||||
expect(openingChapter.chapterQuestId).toBe('quest:chapter:scene-court');
|
||||
expect(openingChapter.stage).toBe('opening');
|
||||
|
||||
const turningState: GameState = {
|
||||
...openingState,
|
||||
quests: [
|
||||
{
|
||||
...openingState.quests[0]!,
|
||||
steps: openingState.quests[0]!.steps?.map((step) =>
|
||||
step.id === 'step_scene_opening'
|
||||
? { ...step, progress: step.requiredCount }
|
||||
: step.id === 'step_scene_pressure'
|
||||
? { ...step, progress: step.requiredCount }
|
||||
: step,
|
||||
),
|
||||
activeStepId: 'step_scene_turning',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(resolveCurrentChapterState({ state: turningState }).stage).toBe('turning_point');
|
||||
|
||||
const climaxState: GameState = {
|
||||
...turningState,
|
||||
quests: [
|
||||
{
|
||||
...turningState.quests[0]!,
|
||||
steps: turningState.quests[0]!.steps?.map((step) => ({
|
||||
...step,
|
||||
progress: step.requiredCount,
|
||||
})),
|
||||
activeStepId: null,
|
||||
status: 'ready_to_turn_in',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(resolveCurrentChapterState({ state: climaxState }).stage).toBe('climax');
|
||||
|
||||
const aftermathState: GameState = {
|
||||
...climaxState,
|
||||
quests: [
|
||||
{
|
||||
...climaxState.quests[0]!,
|
||||
status: 'turned_in',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(resolveCurrentChapterState({ state: aftermathState }).stage).toBe('aftermath');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChapterState, CustomWorldProfile, GameState } from '../../types';
|
||||
import { buildSceneChapterId, getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
|
||||
import type { ChapterState, CustomWorldProfile, GameState, QuestLogEntry } from '../../types';
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 4) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
@@ -26,6 +27,86 @@ function resolveChapterTheme(profile: CustomWorldProfile | null | undefined, pri
|
||||
return profile?.themePack?.displayName ?? profile?.summary ?? '旅程推进';
|
||||
}
|
||||
|
||||
function getStageLabel(stage: ChapterState['stage']) {
|
||||
switch (stage) {
|
||||
case 'opening':
|
||||
return '序章';
|
||||
case 'expansion':
|
||||
return '展开';
|
||||
case 'turning_point':
|
||||
return '转折';
|
||||
case 'climax':
|
||||
return '高潮';
|
||||
case 'aftermath':
|
||||
return '余波';
|
||||
default:
|
||||
return '推进';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSceneChapterQuest(state: GameState) {
|
||||
const sceneId = state.currentScenePreset?.id;
|
||||
if (!sceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chapterId = buildSceneChapterId(sceneId);
|
||||
return state.quests.find((quest) =>
|
||||
quest.chapterId === chapterId
|
||||
&& quest.status !== 'failed'
|
||||
&& quest.status !== 'expired',
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function deriveChapterStageFromQuest(quest: QuestLogEntry): ChapterState['stage'] {
|
||||
if (quest.status === 'turned_in') {
|
||||
return 'aftermath';
|
||||
}
|
||||
|
||||
if (isQuestReadyToClaim(quest)) {
|
||||
return 'climax';
|
||||
}
|
||||
|
||||
const activeStep = getQuestActiveStep(quest);
|
||||
const activeStepIndex = activeStep
|
||||
? Math.max(0, quest.steps?.findIndex((step) => step.id === activeStep.id) ?? 0)
|
||||
: -1;
|
||||
|
||||
if (activeStepIndex <= 0) {
|
||||
return 'opening';
|
||||
}
|
||||
|
||||
if (activeStepIndex === 1) {
|
||||
return 'expansion';
|
||||
}
|
||||
|
||||
return 'turning_point';
|
||||
}
|
||||
|
||||
function buildSceneChapterSummary(params: {
|
||||
sceneName: string;
|
||||
quest: QuestLogEntry;
|
||||
stage: ChapterState['stage'];
|
||||
}) {
|
||||
const {sceneName, quest, stage} = params;
|
||||
const activeStep = getQuestActiveStep(quest);
|
||||
|
||||
switch (stage) {
|
||||
case 'opening':
|
||||
return `${sceneName} 的这一章刚刚开启。${activeStep?.revealText ?? quest.description}`;
|
||||
case 'expansion':
|
||||
return `${sceneName} 的压力正在展开。${activeStep?.revealText ?? quest.summary}`;
|
||||
case 'turning_point':
|
||||
return `${sceneName} 的线索正在改写当前判断。${activeStep?.revealText ?? quest.summary}`;
|
||||
case 'climax':
|
||||
return `${sceneName} 的核心矛盾已经被推到最后一步,只差把这一章正式收束。`;
|
||||
case 'aftermath':
|
||||
return `${sceneName} 这一章已经完成收束,余波和下一段去向正在显形。`;
|
||||
default:
|
||||
return `${sceneName} 的这一章仍在推进中。`;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCurrentChapterState(params: {
|
||||
state: GameState;
|
||||
}) {
|
||||
@@ -39,6 +120,32 @@ export function resolveCurrentChapterState(params: {
|
||||
);
|
||||
const signalCount = storyEngineMemory?.recentSignalIds?.length ?? 0;
|
||||
const chronicleCount = storyEngineMemory?.chronicle?.length ?? 0;
|
||||
const sceneChapterQuest = resolveSceneChapterQuest(state);
|
||||
const currentSceneId = state.currentScenePreset?.id ?? null;
|
||||
const currentSceneName = state.currentScenePreset?.name ?? '当前区域';
|
||||
|
||||
if (sceneChapterQuest && currentSceneId) {
|
||||
const stage = deriveChapterStageFromQuest(sceneChapterQuest);
|
||||
const theme = sceneChapterQuest.title || resolveChapterTheme(profile, threadTitles);
|
||||
return {
|
||||
id: buildSceneChapterId(currentSceneId),
|
||||
title: `${currentSceneName}·${getStageLabel(stage)}`,
|
||||
theme,
|
||||
primaryThreadIds: dedupeStrings([
|
||||
sceneChapterQuest.threadId,
|
||||
...activeThreadIds,
|
||||
], 3),
|
||||
stage,
|
||||
chapterSummary: buildSceneChapterSummary({
|
||||
sceneName: currentSceneName,
|
||||
quest: sceneChapterQuest,
|
||||
stage,
|
||||
}),
|
||||
sceneId: currentSceneId,
|
||||
chapterQuestId: sceneChapterQuest.id,
|
||||
} satisfies ChapterState;
|
||||
}
|
||||
|
||||
const stage = resolveChapterStage({
|
||||
signalCount,
|
||||
chronicleCount,
|
||||
@@ -46,15 +153,7 @@ export function resolveCurrentChapterState(params: {
|
||||
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'
|
||||
? '高潮'
|
||||
: '余波'}`;
|
||||
const title = `${theme || '旅程'}·${getStageLabel(stage)}`;
|
||||
|
||||
return {
|
||||
id: `chapter:${dedupeStrings(activeThreadIds, 2).join('+') || 'default'}:${stage}`,
|
||||
@@ -63,6 +162,8 @@ export function resolveCurrentChapterState(params: {
|
||||
primaryThreadIds: dedupeStrings(activeThreadIds, 3),
|
||||
stage,
|
||||
chapterSummary: `${title} 当前围绕 ${theme || '旅程主线'} 推进。`,
|
||||
sceneId: null,
|
||||
chapterQuestId: null,
|
||||
} satisfies ChapterState;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ function createQuest(overrides: Partial<QuestLogEntry> & Pick<QuestLogEntry, 'id
|
||||
issuerNpcId: overrides.issuerNpcId ?? `${overrides.id}-issuer`,
|
||||
issuerNpcName: overrides.issuerNpcName ?? '林朔',
|
||||
sceneId: overrides.sceneId ?? 'scene-ruins',
|
||||
chapterId: overrides.chapterId ?? null,
|
||||
title: overrides.title,
|
||||
description: overrides.description ?? `${overrides.title} 的说明`,
|
||||
summary: overrides.summary ?? `${overrides.title} 的摘要`,
|
||||
@@ -181,6 +182,31 @@ describe('goalDirector', () => {
|
||||
expect(describeGoalStackForPrompt(goalStack)).toContain('当前玩家任务推进');
|
||||
});
|
||||
|
||||
it('prefers the current scene chapter quest over unrelated ready quests', () => {
|
||||
const currentSceneQuest = createQuest({
|
||||
id: 'quest-chapter-scene-court',
|
||||
title: '查明宫苑内庭',
|
||||
sceneId: 'scene-court',
|
||||
chapterId: 'chapter:scene:scene-court',
|
||||
status: 'active',
|
||||
});
|
||||
const unrelatedReadyQuest = createQuest({
|
||||
id: 'quest-ready-other',
|
||||
title: '回报断桥调查',
|
||||
sceneId: 'scene-bridge',
|
||||
status: 'ready_to_turn_in',
|
||||
});
|
||||
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: [unrelatedReadyQuest, currentSceneQuest],
|
||||
worldType: null,
|
||||
currentSceneId: 'scene-court',
|
||||
currentSceneName: '宫苑内庭',
|
||||
});
|
||||
|
||||
expect(goalStack.activeGoal?.sourceId).toBe('quest-chapter-scene-court');
|
||||
});
|
||||
|
||||
it('annotates options with advance/support affordances and builds quest reward handoff', () => {
|
||||
const readyQuest = createQuest({
|
||||
id: 'quest-ready',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isContinueAdventureOption } from '../../data/functionCatalog';
|
||||
import { getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
|
||||
import { buildSceneChapterId, getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
|
||||
import { getScenePresetById } from '../../data/scenePresets';
|
||||
import type {
|
||||
CampEvent,
|
||||
@@ -466,12 +466,20 @@ function buildCampEventSupportGoal(currentCampEvent: CampEvent) {
|
||||
} satisfies GoalStackEntry;
|
||||
}
|
||||
|
||||
function resolvePrimaryQuest(quests: QuestLogEntry[]) {
|
||||
function resolvePrimaryQuest(quests: QuestLogEntry[], currentSceneId?: string | null) {
|
||||
const liveQuests = quests.filter(isLiveQuest);
|
||||
if (liveQuests.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentSceneChapterId = currentSceneId ? buildSceneChapterId(currentSceneId) : null;
|
||||
const currentSceneChapterQuest = currentSceneChapterId
|
||||
? liveQuests.find((quest) => quest.chapterId === currentSceneChapterId) ?? null
|
||||
: null;
|
||||
if (currentSceneChapterQuest) {
|
||||
return currentSceneChapterQuest;
|
||||
}
|
||||
|
||||
return liveQuests.find((quest) => isQuestReadyToClaim(quest))
|
||||
?? liveQuests.find((quest) => quest.status === 'active')
|
||||
?? liveQuests.find((quest) => quest.status === 'discovered')
|
||||
@@ -486,6 +494,7 @@ export function buildGoalStackState(params: {
|
||||
journeyBeat?: JourneyBeat | null;
|
||||
setpieceDirective?: SetpieceDirective | null;
|
||||
currentCampEvent?: CampEvent | null;
|
||||
currentSceneId?: string | null;
|
||||
currentSceneName?: string | null;
|
||||
}) {
|
||||
const {
|
||||
@@ -495,9 +504,10 @@ export function buildGoalStackState(params: {
|
||||
journeyBeat = null,
|
||||
setpieceDirective = null,
|
||||
currentCampEvent = null,
|
||||
currentSceneId = null,
|
||||
currentSceneName = null,
|
||||
} = params;
|
||||
const primaryQuest = resolvePrimaryQuest(quests);
|
||||
const primaryQuest = resolvePrimaryQuest(quests, currentSceneId);
|
||||
const northStarGoal = setpieceDirective
|
||||
? buildSetpieceNorthStarGoal(setpieceDirective)
|
||||
: chapterState
|
||||
@@ -766,6 +776,7 @@ export function buildGoalHandoffFromState(state: GameState): GoalHandoff | null
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: state.quests,
|
||||
worldType: state.worldType,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
chapterState: state.chapterState ?? state.storyEngineMemory?.currentChapter ?? null,
|
||||
journeyBeat: state.storyEngineMemory?.currentJourneyBeat ?? null,
|
||||
setpieceDirective: state.storyEngineMemory?.currentSetpieceDirective ?? null,
|
||||
|
||||
@@ -95,4 +95,34 @@ describe('storyChronicle', () => {
|
||||
expect(chronicle.length).toBeGreaterThan(0);
|
||||
expect(summary).toContain('封桥旧案·展开');
|
||||
});
|
||||
|
||||
it('dedupes unchanged chapter chronicle entries', () => {
|
||||
const chapterState = {
|
||||
id: 'chapter:scene:scene-court',
|
||||
title: '宫苑内庭·展开',
|
||||
theme: '宫苑旧案',
|
||||
primaryThreadIds: ['thread-court'],
|
||||
stage: 'expansion' as const,
|
||||
chapterSummary: '宫苑内庭的这一章正在展开。',
|
||||
sceneId: 'scene-court',
|
||||
chapterQuestId: 'quest:chapter:scene-court',
|
||||
};
|
||||
|
||||
const firstChronicle = appendChronicleEntries({
|
||||
state,
|
||||
chapterState,
|
||||
});
|
||||
const secondChronicle = appendChronicleEntries({
|
||||
state: {
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...state.storyEngineMemory!,
|
||||
chronicle: firstChronicle,
|
||||
},
|
||||
},
|
||||
chapterState,
|
||||
});
|
||||
|
||||
expect(secondChronicle.filter((entry) => entry.id === 'chronicle:chapter:chapter:scene:scene-court')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,18 @@ function createChronicleId(category: ChronicleEntry['category'], key: string) {
|
||||
return `chronicle:${category}:${key}`;
|
||||
}
|
||||
|
||||
function dedupeChronicleEntries(entries: ChronicleEntry[]) {
|
||||
const seen = new Set<string>();
|
||||
return entries.filter((entry) => {
|
||||
const signature = `${entry.id}::${entry.summary}`;
|
||||
if (seen.has(signature)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(signature);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function appendChronicleEntries(params: {
|
||||
state: GameState;
|
||||
chapterState?: ChapterState | null;
|
||||
@@ -80,7 +92,7 @@ export function appendChronicleEntries(params: {
|
||||
});
|
||||
}
|
||||
|
||||
return [...existing, ...additions].slice(-18);
|
||||
return dedupeChronicleEntries([...existing, ...additions]).slice(-18);
|
||||
}
|
||||
|
||||
export function buildChronicleSummary(state: GameState) {
|
||||
|
||||
@@ -119,6 +119,31 @@ function cloneThemePack(mode: string, preset: ThemePackPreset): ThemePack {
|
||||
};
|
||||
}
|
||||
|
||||
function collectSemanticAnchorLexicon(
|
||||
profile: Pick<CustomWorldProfile, 'ownedSettingLayers'>,
|
||||
) {
|
||||
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
|
||||
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
|
||||
|
||||
if (!semanticAnchor && !expressionProfile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return dedupeStrings([
|
||||
...(semanticAnchor?.genreSignals ?? []),
|
||||
...(semanticAnchor?.conflictForms ?? []),
|
||||
...(semanticAnchor?.institutionTypes ?? []),
|
||||
...(semanticAnchor?.tabooTypes ?? []),
|
||||
...(semanticAnchor?.carrierTypes ?? []),
|
||||
...(semanticAnchor?.forceSystemTypes ?? []),
|
||||
...(semanticAnchor?.atmosphereTags ?? []),
|
||||
...(expressionProfile?.presentationTone ?? []),
|
||||
...(expressionProfile?.namingDirectives ?? []),
|
||||
...(expressionProfile?.clueDirectives ?? []),
|
||||
...(expressionProfile?.revealDirectives ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveThemeModeFromWorldType(
|
||||
worldType: WorldTemplateType | WorldType | null | undefined,
|
||||
) {
|
||||
@@ -180,30 +205,67 @@ export function buildThemePackFromWorldProfile(
|
||||
| 'templateWorldType'
|
||||
| 'majorFactions'
|
||||
| 'coreConflicts'
|
||||
| 'ownedSettingLayers'
|
||||
> & {
|
||||
templateWorldType: WorldTemplateType | WorldType;
|
||||
},
|
||||
) {
|
||||
const mode = detectCustomWorldThemeMode(profile);
|
||||
const base = cloneThemePack(mode, THEME_PACK_PRESETS[mode]!);
|
||||
const ownedThemePack = profile.ownedSettingLayers?.expressionProfile?.themePack;
|
||||
if (ownedThemePack) {
|
||||
return normalizeThemePack(ownedThemePack, base);
|
||||
}
|
||||
|
||||
const lexicon = collectProfileLexicon(profile);
|
||||
const semanticLexicon = collectSemanticAnchorLexicon(profile);
|
||||
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
|
||||
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
|
||||
|
||||
return normalizeThemePack(
|
||||
{
|
||||
...base,
|
||||
institutionLexicon: dedupeStrings([
|
||||
...base.institutionLexicon,
|
||||
...(semanticAnchor?.institutionTypes ?? []),
|
||||
...lexicon.filter((item) => item.length >= 2),
|
||||
]),
|
||||
tabooLexicon: dedupeStrings([
|
||||
...base.tabooLexicon,
|
||||
...(semanticAnchor?.tabooTypes ?? []),
|
||||
...(profile.coreConflicts ?? []).slice(0, 4),
|
||||
]),
|
||||
artifactClasses: dedupeStrings([
|
||||
...base.artifactClasses,
|
||||
...(semanticAnchor?.carrierTypes ?? []),
|
||||
]),
|
||||
conflictForms: dedupeStrings([
|
||||
...base.conflictForms,
|
||||
...(semanticAnchor?.conflictForms ?? []),
|
||||
...(profile.coreConflicts ?? []).slice(0, 3),
|
||||
]),
|
||||
clueForms: dedupeStrings([
|
||||
...base.clueForms,
|
||||
...(expressionProfile?.clueDirectives ?? []),
|
||||
...(profile.majorFactions ?? []).slice(0, 3),
|
||||
]),
|
||||
toneRange: dedupeStrings([profile.tone, ...base.toneRange]),
|
||||
namingPatterns: dedupeStrings([
|
||||
...base.namingPatterns,
|
||||
...(expressionProfile?.namingDirectives ?? []),
|
||||
]),
|
||||
revealStyles: dedupeStrings([
|
||||
...base.revealStyles,
|
||||
...(expressionProfile?.revealDirectives ?? []),
|
||||
]),
|
||||
toneRange: dedupeStrings([
|
||||
profile.tone,
|
||||
...(expressionProfile?.presentationTone ?? []),
|
||||
...base.toneRange,
|
||||
]),
|
||||
actorArchetypes: dedupeStrings([
|
||||
...base.actorArchetypes,
|
||||
...semanticLexicon.filter((item) => item.length >= 2),
|
||||
]),
|
||||
},
|
||||
base,
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
openedSceneChapterIds: [],
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
currentChapter: null,
|
||||
|
||||
Reference in New Issue
Block a user