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

@@ -1877,11 +1877,15 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。',
'- 必须严格沿用框架中的 title、role、description、initialAffinity、relationshipHooks、tags 所表达的角色定位,不要改名,不要改阵营。',
'- backstory 必须写出角色和当前世界的具体关系,至少落到一个势力、一个地点、一个正在发生的局势变化,不要只写抽象气质或泛泛成长史。',
'- personality 不能只写单个形容词,要体现角色在这个世界里的处事习惯、应对压力的方式和与人相处的锋面。',
'- motivation 必须是“此刻正在推动角色行动”的现实目标,而不是空泛理想;它要和玩家目标、核心冲突或开局处境形成直接拉扯。',
'- combatStyle 要体现角色为什么会这样战斗,它最好能反映其身份、经历、所属势力或长期栖身的场景环境。',
roleType === 'story'
? '- 怪物型场景角色要在 backstory 或 combatStyle 中直接写出怪物特征、栖息环境或攻击方式。'
: '- 可扮演角色要体现成长空间、协作价值和明确的入队理由。',
'- 所有生成文本都必须使用中文。',
'- 每个字符串尽量简洁backstory/personality/motivation/combatStyle 控制在 1040 个汉字内。',
'- 每个字符串尽量简洁但不能空泛backstory/personality/motivation/combatStyle 控制在 1856 个汉字内。',
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
].join('\n');
}
@@ -1933,6 +1937,11 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 这一阶段只补全 backstoryReveal、skills、initialItems不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。',
'- 背景章节、技能和初始物品必须严格围绕框架中的角色定位来写,不要改变阵营、身份或出现场景。',
'- backstoryReveal 的 4 章必须形成明显递进:第 1 章写表层来意与第一印象,第 2 章写旧伤或代价,第 3 章写角色真正隐瞒的线索,第 4 章写最终底牌或不可回避的真相。',
'- 每一章都必须紧贴当前世界设定,至少落到具体势力、地点、事件、制度、禁忌或关系链中的一项,不要写成可套用到任何世界的空泛心情。',
'- teaser 必须像“继续相处后能戳到的钩子”content 必须像“真正解锁后得到的新信息”contextSnippet 必须可直接被后续剧情复用,三者不要只是同一句话改写。',
'- skills 不只是职业标签,要体现角色的个人经历、所属阵营、地理环境或禁忌系统影响,尽量写出这个世界独有的招式语感。',
'- initialItems 不只是常规装备清单,至少要有一件能反映角色背景、关系或任务压力的私人物件。',
`- backstoryReveal.chapters 必须恰好 4 章affinityRequired 固定使用 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}`,
'- 每个角色必须提供恰好 3 个 skills 和恰好 3 个 initialItems。',
'- initialItems.category 只能使用:武器、护甲、饰品、消耗品、材料、稀有品、专属物品。',
@@ -1940,7 +1949,7 @@ export function buildCustomWorldRoleBatchPrompt(params: {
? '- 怪物型角色仍然放进 storyNpcs并在 role、description、backstory、combatStyle、tags、backstoryReveal、skills 或 initialItems 中明确写出怪物特征、栖息环境、攻击方式或异形外观。'
: '- 可扮演角色要保持明确的成长空间、协作价值和入队理由。',
'- 所有生成文本都必须使用中文。',
'- 每个字符串尽量简洁backstoryReveal.publicSummary 控制在 10 到 28 个汉字内backstoryReveal.content 控制在 12 到 36 个汉字内skills.summary 和 initialItems.description 控制在 8 到 24 个汉字内。',
'- 每个字符串尽量简洁但要有信息量backstoryReveal.publicSummary 控制在 14 到 36 个汉字内backstoryReveal.teaser 控制在 12 到 28 个汉字内backstoryReveal.content 控制在 20 到 64 个汉字内contextSnippet 控制在 12 到 36 个汉字内skills.summary 和 initialItems.description 控制在 1232 个汉字内。',
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
].join('\n');
}

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(
'守灯会值夜人,对外总像比别人更冷静一步。',
);
});

View File

@@ -0,0 +1,242 @@
import type { CustomWorldAgentSessionSnapshot } from '../../packages/shared/src/contracts/customWorldAgent';
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
import { type CustomWorldProfile, WorldType } from '../types';
import { buildAgentDraftFoundationSettingText } from './customWorldAgentGenerationProgress';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is Record<string, unknown> => isRecord(item))
: [];
}
function toStringArray(value: unknown, max = 8) {
if (!Array.isArray(value)) {
return [];
}
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
0,
max,
);
}
function inferTemplateWorldType(settingText: string) {
return /[]/u.test(settingText)
? WorldType.XIANXIA
: WorldType.WUXIA;
}
function buildCharacterSummaryText(record: Record<string, unknown>) {
return (
toText(record.summary) ||
toText(record.publicIdentity) ||
toText(record.publicMask) ||
toText(record.currentPressure) ||
toText(record.relationToPlayer)
);
}
function buildCharacterBackstoryText(record: Record<string, unknown>) {
return [
toText(record.publicIdentity),
toText(record.currentPressure),
toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '',
]
.filter(Boolean)
.join('');
}
function buildRelationshipHooks(record: Record<string, unknown>) {
return [
toText(record.relationToPlayer),
toText(record.currentPressure),
toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '',
].filter(Boolean);
}
function buildCharacterTags(
record: Record<string, unknown>,
roleKind: 'playable' | 'story',
) {
const threadIds = toStringArray(record.threadIds, 4);
return [...threadIds, roleKind === 'playable' ? '草稿主角' : '草稿角色'];
}
type AdaptedDraftCharacter = {
id: string;
name: string;
title: string;
role: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
function adaptDraftCharacters(value: unknown, roleKind: 'playable' | 'story') {
return toRecordArray(value)
.map((record, index) => {
const name = toText(record.name);
if (!name) {
return null;
}
const title =
toText(record.title) ||
toText(record.role) ||
(roleKind === 'playable' ? '关键角色' : '场景角色');
const role = toText(record.role) || title;
const description = buildCharacterSummaryText(record);
const relationshipHooks = buildRelationshipHooks(record);
return {
id: toText(record.id) || `${roleKind}-draft-${index + 1}`,
name,
title,
role,
description,
backstory: buildCharacterBackstoryText(record),
personality:
toText(record.publicMask) ||
toText(record.publicIdentity) ||
description,
motivation:
toText(record.relationToPlayer) ||
toText(record.currentPressure) ||
toText(record.hiddenHook),
combatStyle: role,
initialAffinity: roleKind === 'playable' ? 18 : 6,
relationshipHooks,
tags: buildCharacterTags(record, roleKind),
imageSrc: toText(record.imageSrc) || undefined,
generatedVisualAssetId:
toText(record.generatedVisualAssetId) || undefined,
generatedAnimationSetId:
toText(record.generatedAnimationSetId) || undefined,
animationMap: isRecord(record.animationMap)
? record.animationMap
: undefined,
} satisfies AdaptedDraftCharacter;
})
.filter(Boolean) as AdaptedDraftCharacter[];
}
type AdaptedDraftLandmark = {
id: string;
name: string;
description: string;
dangerLevel: string;
imageSrc?: string;
sceneNpcIds: string[];
connections: never[];
};
function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
return toRecordArray(value)
.map((record, index) => {
const name = toText(record.name);
if (!name) {
return null;
}
return {
id: toText(record.id) || `landmark-draft-${index + 1}`,
name,
description:
toText(record.description) ||
toText(record.summary) ||
[toText(record.purpose), toText(record.mood)]
.filter(Boolean)
.join(''),
dangerLevel:
toText(record.dangerLevel) ||
toText(record.importance) ||
toText(record.mood),
imageSrc: toText(record.imageSrc) || undefined,
sceneNpcIds: toStringArray(record.characterIds).filter((id) =>
storyNpcIdSet.has(id),
),
connections: [],
} satisfies AdaptedDraftLandmark;
})
.filter(Boolean) as AdaptedDraftLandmark[];
}
export function buildCustomWorldProfileFromAgentDraft(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
if (!session || !isRecord(session.draftProfile)) {
return null;
}
const draftProfile = session.draftProfile;
const legacyResultProfile = normalizeCustomWorldProfileRecord(
draftProfile.legacyResultProfile,
);
if (legacyResultProfile) {
return legacyResultProfile;
}
const settingText = buildAgentDraftFoundationSettingText(session);
const templateWorldType = inferTemplateWorldType(settingText);
const playableNpcs = adaptDraftCharacters(
draftProfile.playableNpcs,
'playable',
);
const storyNpcs = adaptDraftCharacters(draftProfile.storyNpcs, 'story');
const storyNpcIdSet = new Set(
storyNpcs.map((entry) => toText(entry.id)).filter(Boolean),
);
const normalized = normalizeCustomWorldProfileRecord({
id: `agent-draft-${session.sessionId}`,
settingText,
name: toText(draftProfile.name) || '未命名世界底稿',
subtitle: toText(draftProfile.subtitle) || '第一版世界底稿',
summary:
toText(draftProfile.summary) ||
settingText ||
'第一版世界底稿已经整理完成。',
tone: toText(draftProfile.tone) || '整体气质仍可继续精修',
playerGoal: toText(draftProfile.playerGoal) || '先站稳开局,再判断下一步',
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: toStringArray(draftProfile.majorFactions, 6),
coreConflicts: toStringArray(draftProfile.coreConflicts, 6),
playableNpcs,
storyNpcs,
landmarks: adaptDraftLandmarks(draftProfile.landmarks, storyNpcIdSet),
camp: isRecord(draftProfile.camp)
? {
name: toText(draftProfile.camp.name),
description: toText(draftProfile.camp.description),
dangerLevel:
toText(draftProfile.camp.dangerLevel) ||
toText(draftProfile.camp.mood),
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
}
: undefined,
creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack,
lockState: session.lockState,
generationMode: 'fast',
generationStatus: 'key_only',
});
return normalized;
}

View File

@@ -116,9 +116,9 @@ test('marks all legacy progress steps complete when draft foundation finishes',
test('builds readable draft setting text from creator intent first', () => {
const settingText = buildAgentDraftFoundationSettingText(baseSession);
expect(settingText).toContain('世界核心');
expect(settingText).toContain('玩家开局');
expect(settingText).toContain('标志素');
expect(settingText).toContain('世界核心命题');
expect(settingText).toContain('玩家身份');
expect(settingText).toContain('标志性要素');
});
test('falls back to latest user message when creator intent is unavailable', () => {

View File

@@ -87,11 +87,7 @@ function buildAgentDraftFoundationSteps(
detail: step.detail,
completed: isCompleted ? 1 : 0,
total: 1,
status: isCompleted
? 'completed'
: isActive
? 'active'
: 'pending',
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
} satisfies CustomWorldGenerationStep;
});
}
@@ -113,10 +109,7 @@ function resolveEstimatedRemainingMs(
const elapsedMs = Math.max(0, nowMs - startedAtMs);
const progressFraction = progress / 100;
return Math.max(
0,
Math.round(elapsedMs / progressFraction - elapsedMs),
);
return Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs));
}
export function isDraftFoundationOperation(
@@ -184,19 +177,19 @@ export function buildAgentDraftFoundationSettingText(
);
if (creatorIntent) {
const displayText =
buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
const generationText =
buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim();
if (displayText) {
return displayText;
}
const displayText =
buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
if (generationText) {
return generationText;
}
if (displayText) {
return displayText;
}
if (creatorIntent.rawSettingText.trim()) {
return creatorIntent.rawSettingText.trim();
}