1
This commit is contained in:
@@ -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 控制在 10 到 40 个汉字内。',
|
||||
'- 每个字符串尽量简洁但不能空泛:backstory/personality/motivation/combatStyle 控制在 18 到 56 个汉字内。',
|
||||
'- 返回前自检:必须是一个能被 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 控制在 12 到 32 个汉字内。',
|
||||
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
607
src/services/customWorldAgentDraftResult.test.ts
Normal file
607
src/services/customWorldAgentDraftResult.test.ts
Normal 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(
|
||||
'守灯会值夜人,对外总像比别人更冷静一步。',
|
||||
);
|
||||
});
|
||||
242
src/services/customWorldAgentDraftResult.ts
Normal file
242
src/services/customWorldAgentDraftResult.ts
Normal 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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user