This commit is contained in:
2026-04-16 15:45:00 +08:00
parent 6363267bca
commit 91b63675eb
43 changed files with 5652 additions and 853 deletions

View File

@@ -0,0 +1,607 @@
import { afterEach, expect, test } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../packages/shared/src/contracts/customWorldAgent';
import { buildCustomWorldRuntimeCharacters } from '../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
import { getScenePresetsByWorld } from '../data/scenePresets';
import { WorldType } from '../types';
import { buildCustomWorldProfileFromAgentDraft } from './customWorldAgentDraftResult';
afterEach(() => {
setRuntimeCustomWorldProfile(null);
});
const session: CustomWorldAgentSessionSnapshot = {
sessionId: 'session-1',
stage: 'object_refining',
focusCardId: null,
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
creatorIntentReadiness: {
isReady: true,
completedKeys: [],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
},
messages: [],
draftCards: [],
pendingClarifications: [],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: '2026-04-15T10:00:00.000Z',
};
function buildBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
privateChatUnlockAffinity: 40,
chapters: [
{
id: `${label}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${label}先只肯说表面的来意。`,
content: `${label}表面上只愿意谈当前局势。`,
contextSnippet: `${label}表面上还在收着话。`,
},
{
id: `${label}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${label}背后还有一段旧伤。`,
content: `${label}曾在旧案里留下无法轻易揭开的伤口。`,
contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`,
},
{
id: `${label}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${label}真正想追的不是表面那件事。`,
content: `${label}真正挂着的是旧案里还没结的那条线。`,
contextSnippet: `${label}真正执念指向旧案深处。`,
},
{
id: `${label}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${label}手里还压着最后一张牌。`,
content: `${label}手里还握着能直接证明真相的关键证据。`,
contextSnippet: `${label}最后的底牌足以改写局势。`,
},
],
};
}
function buildLegacyResultProfile() {
return {
id: 'legacy-profile-1',
settingText: '被海雾吞没的旧航路群岛',
name: '旧版完整结果',
subtitle: '直接展示',
summary: '优先使用服务端编译好的旧版 profile。',
tone: '压抑',
playerGoal: '查明真相',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['争夺航路控制权', '沉船真相'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '旧版完整结果',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
description: '最熟悉旧航路的人。',
backstory: '曾在沉船夜里带着半支船队逃出海雾。',
personality: '表面沉稳,心里一直在算退路。',
motivation: '想赶在守灯会封航前查清真相。',
combatStyle: '借地形和潮路换位,先拉扯再压近。',
initialAffinity: 18,
relationshipHooks: ['旧友', '沉船旧案'],
tags: ['潮路', '引路'],
backstoryReveal: buildBackstoryReveal('沈砺'),
skills: [
{
id: 'skill-playable-1',
name: '潮行引路',
summary: '踩着旧潮阶切线前压,替队伍打开角度。',
style: '机动周旋',
},
{
id: 'skill-playable-2',
name: '回雾折返',
summary: '借海雾遮住身位,再从侧线拉开。',
style: '起手压制',
},
{
id: 'skill-playable-3',
name: '旧图定标',
summary: '用旧潮图锁定退路和突入口。',
style: '爆发终结',
},
],
initialItems: [
{
id: 'item-playable-1',
name: '旧潮短刃',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '专门在湿滑甲板上近身换位用的短刃。',
tags: ['潮路', '近战'],
},
{
id: 'item-playable-2',
name: '雾盐药包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '压住寒潮后遗症的随身药包。',
tags: ['补给'],
},
{
id: 'item-playable-3',
name: '旧潮图残页',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '只剩半页,但足够指向沉船夜的另一条线。',
tags: ['线索', '真相'],
},
],
attributeProfile: {
schemaId: 'schema:test',
values: { axis_a: 48, axis_b: 72, axis_c: 78 },
topTraits: ['浪步', '舟识'],
evidence: [
{
slotId: 'axis_b',
reason: '长期依赖潮路换位与切线。',
},
],
},
narrativeProfile: {
publicMask: '像个只想把旧路再走通一次的熟路人。',
firstContactMask: '先别问太深,至少今晚这条路我还认得。',
visibleLine: '他明面上只想护着队伍别再走错一次潮线。',
hiddenLine: '真正让他回来的是沉船夜里被人卖掉的那条航线。',
contradiction: '嘴上说只想带路,实际每一步都在找能对上旧案的证据。',
debtOrBurden: '背着半支船队没能活着回来的旧债。',
taboo: '最忌讳别人轻描淡写地提起那晚的失踪名单。',
immediatePressure: '守灯会封航在即,他必须赶在封口前找到证据。',
relatedThreadIds: ['thread-visible-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['沉船夜', '封航记录'],
},
templateCharacterId: 'archer-hero',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
description: '夜里巡灯与封锁禁航区的人。',
backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。',
personality: '冷静克制,但提到旧灯册时会显得过分警觉。',
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。',
initialAffinity: 8,
relationshipHooks: ['禁航记录', '灯塔值夜'],
tags: ['守灯会', '灯塔'],
backstoryReveal: buildBackstoryReveal('顾潮音'),
skills: [
{
id: 'skill-story-1',
name: '夜潮灯语',
summary: '借灯语与潮声干扰对方判断。',
style: '起手压制',
},
{
id: 'skill-story-2',
name: '禁航暗潮',
summary: '封住错误航线,把人逼回她熟悉的区域。',
style: '机动周旋',
},
{
id: 'skill-story-3',
name: '回声巡线',
summary: '借塔顶回声迅速锁定异动方向。',
style: '爆发终结',
},
],
initialItems: [
{
id: 'item-story-1',
name: '值夜灯尺',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '兼作警械和测灯尺的长柄器具。',
tags: ['守灯会'],
},
{
id: 'item-story-2',
name: '防潮火折',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '在潮雾里也能点亮的值夜火折。',
tags: ['值夜'],
},
{
id: 'item-story-3',
name: '封灯令副本',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '一份被她私下截留下来的封灯令副本。',
tags: ['证据', '灯册'],
},
],
imageSrc: '/custom/npcs/gu-chaoyin.png',
attributeProfile: {
schemaId: 'schema:test',
values: { axis_a: 54, axis_c: 82, axis_f: 61 },
topTraits: ['舟识', '回澜'],
evidence: [
{
slotId: 'axis_c',
reason: '长期依赖值夜观察和读灯判断局势。',
},
],
},
narrativeProfile: {
publicMask: '守灯会值夜人,对外总像比别人更冷静一步。',
firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。',
visibleLine: '她表面上只是在守灯和封线。',
hiddenLine: '她真正盯着的是那本被改过的原始灯册。',
contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。',
debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。',
taboo: '最忌讳别人把那夜的失踪当成单纯天灾。',
immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。',
relatedThreadIds: ['thread-visible-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['原始灯册', '封灯令'],
},
visual: {
race: 'human',
bodyColor: 'blue',
headIndex: 2,
hairColorIndex: 3,
hairStyleFrame: 4,
facialHairEnabled: false,
facialHairColorIndex: 0,
facialHairStyleFrame: 0,
offHand: {
type: 'magic',
file: 'lantern.png',
frameIndex: 1,
},
},
},
],
items: [
{
id: 'item-world-1',
name: '潮雾罗盘',
category: '饰品',
rarity: 'rare',
description: '会在假航灯附近偏转的旧罗盘。',
tags: ['线索', '潮雾'],
attributeResonance: {
resonanceVector: { axis_c: 0.88, axis_e: 0.31 },
explanation: '它会把持有者的判断力牵到潮雾最异常的地方。',
},
},
],
camp: {
name: '回潮暂栖所',
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
dangerLevel: 'low',
imageSrc: '/custom/camp/huichao.png',
},
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
dangerLevel: 'high',
imageSrc: '/custom/scenes/lighthouse.png',
sceneNpcIds: ['story-1'],
connections: [
{
targetLandmarkId: 'landmark-2',
relativePosition: 'forward',
summary: '沿着旧潮阶继续前压到雾栈尽头。',
},
],
narrativeResidues: [
{
id: 'residue-1',
title: '潮痕',
visibleClue: '塔壁上有一圈不该出现在高处的潮痕。',
linkedFactIds: ['fact-1'],
linkedThreadIds: ['thread-visible-1'],
},
],
},
{
id: 'landmark-2',
name: '雾栈尽头',
description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。',
dangerLevel: 'high',
imageSrc: '/custom/scenes/pier.png',
sceneNpcIds: [],
connections: [
{
targetLandmarkId: 'landmark-1',
relativePosition: 'back',
summary: '退回灯塔还能重新整理路线。',
},
],
},
],
themePack: {
id: 'theme-pack:tide',
displayName: '潮雾悬疑',
toneRange: ['压抑', '潮湿', '悬疑'],
institutionLexicon: ['守灯会', '航运公会'],
tabooLexicon: ['假航灯', '封灯令'],
artifactClasses: ['旧潮图', '灯册', '罗盘'],
actorArchetypes: ['引路人', '值夜人'],
conflictForms: ['封航争夺', '旧案追查'],
clueForms: ['灯册残页', '潮痕'],
namingPatterns: ['潮', '雾', '灯'],
revealStyles: ['试探式回应'],
},
storyGraph: {
visibleThreads: [
{
id: 'thread-visible-1',
title: '封航争夺',
visibility: 'visible',
summary: '守灯会与航运公会正在争夺旧航路的解释权。',
conflictType: '控制权争夺',
stakes: '谁能定义禁航区,就能决定谁能活着穿过去。',
involvedFactionIds: ['faction-guard', 'faction-guild'],
involvedActorIds: ['playable-1', 'story-1'],
relatedLocationIds: ['landmark-1', 'landmark-2'],
},
],
hiddenThreads: [
{
id: 'thread-hidden-1',
title: '沉船旧案',
visibility: 'hidden',
summary: '沉船夜的航灯与灯册被人动过手脚。',
conflictType: '真相遮蔽',
stakes: '真相一旦坐实,守灯会内部会先崩。',
involvedFactionIds: ['faction-guard'],
involvedActorIds: ['playable-1', 'story-1'],
relatedLocationIds: ['landmark-1'],
},
],
scars: [
{
id: 'scar-1',
title: '沉船夜',
pastEvent: '假航灯把整支船队引进了死潮区。',
publicResidue: '每逢潮夜,灯塔下总有人提起那晚的失踪名单。',
hiddenTruth: '禁航记录和灯册都在事后被篡改过。',
relatedActorIds: ['playable-1', 'story-1'],
relatedLocationIds: ['landmark-1'],
},
],
motifs: [
{
id: 'motif-1',
label: '假航灯',
semanticRole: 'technology',
lexicalHints: ['假灯', '偏航', '禁航记录'],
},
],
},
knowledgeFacts: [
{
id: 'fact-1',
title: '高处潮痕',
content: '回潮旧灯塔的高处潮痕说明那晚海面高度异常。',
ownerActorIds: ['story-1'],
relatedThreadIds: ['thread-visible-1'],
relatedScarIds: ['scar-1'],
sourceType: 'scene',
visibility: 'discoverable',
sayability: 'indirect',
},
],
threadContracts: [
{
id: 'contract-1',
threadId: 'thread-visible-1',
issuerActorId: 'story-1',
narrativeType: 'investigation',
currentStepId: 'contract-step-1',
visibleStage: 1,
steps: [
{
id: 'contract-step-1',
title: '查灯塔',
revealText: '先查清灯塔顶上的高处潮痕。',
completionSignalIds: ['inspect_scene:landmark-1'],
optionalFactIds: ['fact-1'],
},
],
followupThreadIds: ['thread-hidden-1'],
},
],
scenarioPackId: 'scenario-pack:tide',
campaignPackId: 'campaign-pack:tide',
generationMode: 'fast',
generationStatus: 'key_only',
};
}
function buildProfileFromEmbeddedLegacyResult() {
return buildCustomWorldProfileFromAgentDraft({
...session,
draftProfile: {
...session.draftProfile,
legacyResultProfile: buildLegacyResultProfile(),
},
});
}
test('adapts agent draft profile into legacy custom world result profile', () => {
const profile = buildCustomWorldProfileFromAgentDraft(session);
expect(profile?.name).toBe('潮雾列岛');
expect(profile?.generationStatus).toBe('key_only');
expect(profile?.playableNpcs[0]?.name).toBe('沈砺');
expect(profile?.storyNpcs[0]?.name).toBe('顾潮音');
expect(profile?.landmarks[0]?.name).toBe('回潮旧灯塔');
});
test('prefers embedded legacy result profile without dropping compiled runtime fields', () => {
const profile = buildProfileFromEmbeddedLegacyResult();
expect(profile?.name).toBe('旧版完整结果');
expect(profile?.majorFactions).toEqual(['守灯会', '航运公会']);
expect(profile?.coreConflicts).toEqual(['争夺航路控制权', '沉船真相']);
expect(profile?.themePack?.id).toBe('theme-pack:tide');
expect(profile?.storyGraph?.visibleThreads[0]?.id).toBe('thread-visible-1');
expect(profile?.knowledgeFacts?.[0]?.id).toBe('fact-1');
expect(profile?.threadContracts?.[0]?.id).toBe('contract-1');
expect(profile?.scenarioPackId).toBe('scenario-pack:tide');
expect(profile?.campaignPackId).toBe('campaign-pack:tide');
expect(profile?.playableNpcs[0]?.attributeProfile?.schemaId).toBe(
'schema:test',
);
expect(profile?.storyNpcs[0]?.narrativeProfile?.publicMask).toContain(
'守灯会值夜人',
);
expect(profile?.items[0]?.attributeResonance?.explanation).toContain('潮雾');
expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.title).toBe('潮痕');
});
test('embedded legacy result profile keeps result-page settings in runtime characters and scenes', () => {
const profile = buildProfileFromEmbeddedLegacyResult();
expect(profile).toBeTruthy();
setRuntimeCustomWorldProfile(profile);
const runtimeCharacters = buildCustomWorldRuntimeCharacters(profile);
const leadCharacter = runtimeCharacters.find(
(character) => character.id === 'playable-1',
);
const lighthouseScene = getScenePresetsByWorld(WorldType.CUSTOM).find(
(scene) => scene.name === '回潮旧灯塔',
);
const guardNpc = lighthouseScene?.npcs.find((npc) => npc.id === 'story-1');
expect(leadCharacter?.skills[0]?.name).toBe('潮行引路');
expect(leadCharacter?.backstoryReveal?.publicSummary).toBe('沈砺的公开背景');
expect(lighthouseScene?.connections[0]?.summary).toBe(
'沿着旧潮阶继续前压到雾栈尽头。',
);
expect(lighthouseScene?.narrativeResidues?.[0]?.title).toBe('潮痕');
expect(guardNpc?.narrativeProfile?.publicMask).toBe(
'守灯会值夜人,对外总像比别人更冷静一步。',
);
});