Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View File

@@ -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: '扩写后的提示词',
});
});

View File

@@ -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: [],

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,