Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb
# Conflicts: # docs/technical/README.md # server-node/src/modules/assets/qwenSpriteRoutes.ts # src/components/CustomWorldResultView.test.tsx # src/components/CustomWorldResultView.tsx # src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx # src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx # src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx # src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx # src/components/rpg-entry/RpgEntryCharacterSelectView.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx # src/services/apiClient.ts # src/tools/QwenSpriteSheetTool.tsx
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createRpgAgentFoundationDraftProfileFixture,
|
||||
createRpgCreationPublishedProfileFixture,
|
||||
} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
|
||||
import {
|
||||
buildRpgWorldPreviewEnvelope,
|
||||
normalizeRpgWorldPreviewEnvelope,
|
||||
} from './RpgWorldPreviewCompiler.js';
|
||||
|
||||
test('rpg world preview compiler can consume shared published profile fixture as a stable unit baseline', () => {
|
||||
const publishedProfile = createRpgCreationPublishedProfileFixture();
|
||||
const previewEnvelope = buildRpgWorldPreviewEnvelope(
|
||||
publishedProfile,
|
||||
String(publishedProfile.settingText ?? ''),
|
||||
);
|
||||
|
||||
assert.equal(previewEnvelope.source, 'session_preview');
|
||||
assert.equal(previewEnvelope.preview.name, publishedProfile.name);
|
||||
assert.equal(
|
||||
(previewEnvelope.preview.playableNpcs as Array<{ generatedAnimationSetId?: string }>)[0]
|
||||
?.generatedAnimationSetId,
|
||||
'animation-set-playable-1',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
previewEnvelope.preview.sceneChapterBlueprints as Array<{
|
||||
acts?: Array<{ backgroundImageSrc?: string }>;
|
||||
}>
|
||||
)[0]?.acts?.[0]?.backgroundImageSrc,
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('regression: foundation-like shared fixture fields are preserved after normalize + preview compile chain', () => {
|
||||
const foundationDraft = createRpgAgentFoundationDraftProfileFixture();
|
||||
const normalizedPreviewEnvelope = normalizeRpgWorldPreviewEnvelope(
|
||||
{
|
||||
name: foundationDraft.name,
|
||||
subtitle: foundationDraft.subtitle,
|
||||
summary: foundationDraft.summary,
|
||||
tone: foundationDraft.tone,
|
||||
playerGoal: foundationDraft.playerGoal,
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: foundationDraft.majorFactions,
|
||||
coreConflicts: foundationDraft.coreConflicts,
|
||||
playableNpcs: foundationDraft.playableNpcs,
|
||||
storyNpcs: foundationDraft.storyNpcs,
|
||||
camp: foundationDraft.camp,
|
||||
landmarks: foundationDraft.landmarks,
|
||||
sceneChapterBlueprints: foundationDraft.sceneChapters,
|
||||
themePack: foundationDraft.themePack,
|
||||
storyGraph: foundationDraft.storyGraph,
|
||||
},
|
||||
foundationDraft.worldHook,
|
||||
);
|
||||
|
||||
assert.equal(normalizedPreviewEnvelope.source, 'session_preview');
|
||||
assert.equal(
|
||||
(normalizedPreviewEnvelope.preview.playableNpcs as Array<{ imageSrc?: string }>)[0]
|
||||
?.imageSrc,
|
||||
'/generated-characters/playable-1/visual/asset-runtime/master.png',
|
||||
);
|
||||
assert.equal(
|
||||
(normalizedPreviewEnvelope.preview.playableNpcs as Array<{
|
||||
animationMap?: { attack?: { basePath?: string } };
|
||||
}>)[0]?.animationMap?.attack?.basePath,
|
||||
'/generated-characters/playable-1/animations/attack',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
normalizedPreviewEnvelope.preview.sceneChapterBlueprints as Array<{
|
||||
acts?: Array<{ backgroundAssetId?: string }>;
|
||||
}>
|
||||
)[0]?.acts?.[0]?.backgroundAssetId,
|
||||
'scene-asset-runtime',
|
||||
);
|
||||
});
|
||||
269
server-node/src/services/RpgWorldPreviewCompiler.test.ts
Normal file
269
server-node/src/services/RpgWorldPreviewCompiler.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildRpgWorldPreviewEnvelope,
|
||||
buildRpgWorldPreviewProfile,
|
||||
normalizeRpgWorldPreviewEnvelope,
|
||||
} from './RpgWorldPreviewCompiler.js';
|
||||
import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js';
|
||||
|
||||
function createPreviewFixture() {
|
||||
const storyNpcs = Array.from({ length: 25 }, (_, index) => ({
|
||||
name: `场景角色${index + 1}`,
|
||||
title: `头衔${index + 1}`,
|
||||
role: `职责${index + 1}`,
|
||||
description: `场景角色描述${index + 1}`,
|
||||
backstory: `场景角色背景${index + 1}`,
|
||||
personality: `场景角色性格${index + 1}`,
|
||||
motivation: `场景角色动机${index + 1}`,
|
||||
combatStyle: `场景角色战斗风格${index + 1}`,
|
||||
initialAffinity: index % 4 === 0 ? -10 : 6,
|
||||
relationshipHooks: [`关系${index + 1}`],
|
||||
tags: [`线索${index + 1}`],
|
||||
}));
|
||||
|
||||
return {
|
||||
id: 'preview-world',
|
||||
name: '预览测试世界',
|
||||
subtitle: '预览副标题',
|
||||
summary: '服务端预览编译的兼容结果。',
|
||||
tone: '压抑、潮湿',
|
||||
playerGoal: '先确认谁在推动局势,再决定站位。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '潮线商盟'],
|
||||
coreConflicts: ['旧航道解释权正在被重写'],
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) => ({
|
||||
name: `角色${index + 1}`,
|
||||
title: `称号${index + 1}`,
|
||||
role: `身份${index + 1}`,
|
||||
description: `角色描述${index + 1}`,
|
||||
backstory: `角色背景${index + 1}`,
|
||||
personality: `角色性格${index + 1}`,
|
||||
motivation: `角色动机${index + 1}`,
|
||||
combatStyle: `战斗风格${index + 1}`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: [`接触点${index + 1}`],
|
||||
tags: [`标签${index + 1}`],
|
||||
})),
|
||||
storyNpcs,
|
||||
landmarks: Array.from({ length: 10 }, (_, index) => ({
|
||||
name: `场景${index + 1}`,
|
||||
description: `场景描述${index + 1}`,
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcNames: [
|
||||
storyNpcs[index % storyNpcs.length]?.name ?? `场景角色${index + 1}`,
|
||||
storyNpcs[(index + 1) % storyNpcs.length]?.name ??
|
||||
`场景角色${index + 2}`,
|
||||
storyNpcs[(index + 2) % storyNpcs.length]?.name ??
|
||||
`场景角色${index + 3}`,
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: `场景${((index + 1) % 10) + 1}`,
|
||||
relativePosition: 'forward',
|
||||
summary: '沿主路前行',
|
||||
},
|
||||
{
|
||||
targetLandmarkName: `场景${((index + 9) % 10) + 1}`,
|
||||
relativePosition: 'back',
|
||||
summary: '回身可返',
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
test('rpg world preview compiler builds a legacy-compatible preview envelope on the server', () => {
|
||||
const settingText = '一个被潮雾反复切开的边境世界。';
|
||||
const rawProfile = createPreviewFixture();
|
||||
|
||||
const previewProfile = buildRpgWorldPreviewProfile(rawProfile, settingText);
|
||||
const previewEnvelope = buildRpgWorldPreviewEnvelope(rawProfile, settingText);
|
||||
const normalizedEnvelope = normalizeRpgWorldPreviewEnvelope(
|
||||
rawProfile,
|
||||
settingText,
|
||||
);
|
||||
|
||||
assert.equal(previewProfile.name, '预览测试世界');
|
||||
assert.equal(previewProfile.playableNpcs.length, 5);
|
||||
assert.equal(previewEnvelope.source, 'session_preview');
|
||||
assert.equal(normalizedEnvelope.source, 'session_preview');
|
||||
assert.equal(previewEnvelope.preview.name, '预览测试世界');
|
||||
assert.equal(previewEnvelope.preview.scenarioPackId, 'scenario-pack:预览测试世界');
|
||||
assert.equal(
|
||||
normalizedEnvelope.preview.campaignPackId,
|
||||
'campaign-pack:预览测试世界',
|
||||
);
|
||||
});
|
||||
|
||||
test('phase5 preview builder keeps legacy runtime-rich fields while merging latest draft assets', () => {
|
||||
const previewProfile = buildRpgCreationPreviewProfileFromDraftProfile({
|
||||
sessionId: 'session-phase5-preview',
|
||||
draftProfile: {
|
||||
name: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '第一版世界底稿已经整理完成。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
imageSrc: '/generated-characters/playable-1/visual/asset-runtime/master.png',
|
||||
generatedVisualAssetId: 'asset-runtime-playable',
|
||||
generatedAnimationSetId: 'animation-set-runtime-playable',
|
||||
animationMap: {
|
||||
attack: {
|
||||
basePath: '/generated-characters/playable-1/animations/attack',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '顾潮音',
|
||||
imageSrc: '/generated-characters/story-1/visual/asset-runtime/master.png',
|
||||
generatedVisualAssetId: 'asset-runtime-story',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
imageSrc: '/generated-custom-world-scenes/landmark-1/scene.png',
|
||||
generatedSceneAssetId: 'scene-asset-runtime',
|
||||
},
|
||||
],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '灯塔初章',
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-act-1',
|
||||
title: '第一幕',
|
||||
backgroundImageSrc:
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
backgroundAssetId: 'scene-act-runtime',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
legacyResultProfile: {
|
||||
id: 'agent-draft-session-phase5-preview',
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '潮雾列岛·结果页精修版',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '服务端 preview 需要保留结果页富字段。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '旧航路引路人',
|
||||
role: '关键同行者',
|
||||
description: '最熟悉旧航路的人。',
|
||||
backstory: '曾在沉船夜里带着半支船队逃出海雾。',
|
||||
personality: '表面沉稳,心里一直在算退路。',
|
||||
motivation: '想赶在守灯会封航前查清真相。',
|
||||
combatStyle: '借地形和潮路换位,先拉扯再压近。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['旧友', '沉船旧案'],
|
||||
tags: ['潮路', '引路'],
|
||||
narrativeProfile: {
|
||||
publicMask: '像个只想把旧路再走通一次的熟路人。',
|
||||
},
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '顾潮音',
|
||||
title: '守灯会值夜人',
|
||||
role: '场景关键角色',
|
||||
description: '夜里巡灯与封锁禁航区的人。',
|
||||
backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。',
|
||||
personality: '冷静克制,但提到旧灯册时会显得过分警觉。',
|
||||
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
|
||||
combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。',
|
||||
initialAffinity: 8,
|
||||
relationshipHooks: ['禁航记录', '灯塔值夜'],
|
||||
tags: ['守灯会', '灯塔'],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'item-world-1',
|
||||
name: '潮雾罗盘',
|
||||
category: '饰品',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
themePack: {
|
||||
id: 'theme-pack:tide',
|
||||
},
|
||||
knowledgeFacts: [
|
||||
{
|
||||
id: 'fact-1',
|
||||
title: '高处潮痕',
|
||||
},
|
||||
],
|
||||
threadContracts: [
|
||||
{
|
||||
id: 'contract-1',
|
||||
threadId: 'thread-visible-1',
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '灯塔初章',
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-act-1',
|
||||
title: '第一幕',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(previewProfile.name, '潮雾列岛');
|
||||
assert.equal(previewProfile.playerGoal, '查清沉船与禁航区异动的真相。');
|
||||
assert.equal(previewProfile.themePack?.id, 'theme-pack:tide');
|
||||
assert.equal(previewProfile.knowledgeFacts?.[0]?.id, 'fact-1');
|
||||
assert.equal(previewProfile.threadContracts?.[0]?.id, 'contract-1');
|
||||
assert.equal(previewProfile.playableNpcs[0]?.imageSrc, '/generated-characters/playable-1/visual/asset-runtime/master.png');
|
||||
assert.equal(previewProfile.playableNpcs[0]?.generatedAnimationSetId, 'animation-set-runtime-playable');
|
||||
assert.equal(
|
||||
previewProfile.playableNpcs[0]?.narrativeProfile?.publicMask,
|
||||
'像个只想把旧路再走通一次的熟路人。',
|
||||
);
|
||||
assert.equal(
|
||||
previewProfile.sceneChapterBlueprints?.[0]?.acts?.[0]?.backgroundAssetId,
|
||||
'scene-act-runtime',
|
||||
);
|
||||
});
|
||||
65
server-node/src/services/RpgWorldPreviewCompiler.ts
Normal file
65
server-node/src/services/RpgWorldPreviewCompiler.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
buildCompiledCustomWorldProfile,
|
||||
normalizeCustomWorldProfile,
|
||||
} from '../modules/custom-world/runtime-profile/index.js';
|
||||
import type {
|
||||
RpgCreationPreview,
|
||||
RpgCreationPreviewEnvelope,
|
||||
RpgCreationPreviewSource,
|
||||
} from '../../../packages/shared/src/contracts/rpgCreationPreview.js';
|
||||
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
|
||||
|
||||
/**
|
||||
* 工作包 G 把服务端结果预览编译入口收口到这里。
|
||||
* Phase 5 后当前 preview 正式作为 session_preview 主链输出,
|
||||
* 编译边界已经从 foundation draft 流程中抽离。
|
||||
*/
|
||||
export type RpgWorldPreviewProfile = CustomWorldProfile;
|
||||
|
||||
const RPG_WORLD_PREVIEW_SOURCE: RpgCreationPreviewSource =
|
||||
'session_preview';
|
||||
|
||||
function toRpgCreationPreview(
|
||||
profile: RpgWorldPreviewProfile,
|
||||
): RpgCreationPreview {
|
||||
return profile as unknown as RpgCreationPreview;
|
||||
}
|
||||
|
||||
export function buildRpgWorldPreviewProfile(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
): RpgWorldPreviewProfile {
|
||||
return buildCompiledCustomWorldProfile(raw, settingText);
|
||||
}
|
||||
|
||||
export function normalizeRpgWorldPreviewProfile(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
): RpgWorldPreviewProfile {
|
||||
return normalizeCustomWorldProfile(raw, settingText);
|
||||
}
|
||||
|
||||
export function buildRpgWorldPreviewEnvelope(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
): RpgCreationPreviewEnvelope {
|
||||
return {
|
||||
preview: toRpgCreationPreview(buildRpgWorldPreviewProfile(raw, settingText)),
|
||||
source: RPG_WORLD_PREVIEW_SOURCE,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRpgWorldPreviewEnvelope(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
): RpgCreationPreviewEnvelope {
|
||||
return {
|
||||
preview: toRpgCreationPreview(
|
||||
buildRpgWorldPreviewProfile(
|
||||
normalizeRpgWorldPreviewProfile(raw, settingText),
|
||||
settingText,
|
||||
),
|
||||
),
|
||||
source: RPG_WORLD_PREVIEW_SOURCE,
|
||||
};
|
||||
}
|
||||
46
server-node/src/services/RpgWorldWorkCoverResolver.ts
Normal file
46
server-node/src/services/RpgWorldWorkCoverResolver.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldProfileRecord,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js';
|
||||
import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js';
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 作品封面解析统一收口在这里,避免 works 聚合服务重复理解草稿态与发布态的封面规则。
|
||||
*/
|
||||
export function resolveRpgWorldDraftWorkCover(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
) {
|
||||
const draftProfile = toRecord(session.draftProfile);
|
||||
if (!draftProfile) {
|
||||
return {
|
||||
imageSrc: null,
|
||||
renderMode: 'image' as const,
|
||||
characterImageSrcs: [],
|
||||
};
|
||||
}
|
||||
|
||||
return resolveCustomWorldCoverPresentation(
|
||||
draftProfile as CustomWorldProfileRecord,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRpgWorldPublishedWorkCover(
|
||||
libraryEntry: CustomWorldLibraryEntry<CustomWorldProfileRecord>,
|
||||
) {
|
||||
const coverPresentation = resolveCustomWorldCoverPresentation(
|
||||
libraryEntry.profile,
|
||||
);
|
||||
|
||||
return {
|
||||
imageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc,
|
||||
renderMode: coverPresentation.renderMode,
|
||||
characterImageSrcs: coverPresentation.characterImageSrcs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createRpgAgentSessionFixture,
|
||||
createRpgCreationWorksResponseFixture,
|
||||
createRpgWorldLibraryEntryFixture,
|
||||
} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
|
||||
import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js';
|
||||
|
||||
test('rpg world work summary assembler can consume shared fixture baselines as a unit test', () => {
|
||||
const assembler = new RpgWorldWorkSummaryAssembler();
|
||||
const session = createRpgAgentSessionFixture();
|
||||
const libraryEntry = createRpgWorldLibraryEntryFixture();
|
||||
const [draftItem] = assembler.assembleDraftItems([
|
||||
{
|
||||
...JSON.parse(JSON.stringify(session)),
|
||||
userId: 'fixture-user',
|
||||
seedText: '被海雾吞没的旧航路群岛',
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: session.updatedAt,
|
||||
},
|
||||
]);
|
||||
const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]);
|
||||
|
||||
assert.equal(draftItem.sourceType, 'agent_session');
|
||||
assert.equal(draftItem.roleVisualReadyCount, 2);
|
||||
assert.equal(draftItem.roleAnimationReadyCount, 2);
|
||||
assert.equal(draftItem.roleAssetSummaryLabel, '沈砺 · 动作已就绪');
|
||||
assert.equal(draftItem.canEnterWorld, false);
|
||||
assert.equal(draftItem.publishReady, true);
|
||||
assert.equal(draftItem.blockerCount, 0);
|
||||
assert.equal(publishedItem.sourceType, 'published_profile');
|
||||
assert.equal(publishedItem.canEnterWorld, true);
|
||||
assert.equal(publishedItem.publishReady, true);
|
||||
assert.equal(publishedItem.blockerCount, 0);
|
||||
assert.equal(publishedItem.roleAnimationReadyCount, 1);
|
||||
});
|
||||
|
||||
test('regression: assembler output stays aligned with shared works response fixture', () => {
|
||||
const assembler = new RpgWorldWorkSummaryAssembler();
|
||||
const session = createRpgAgentSessionFixture();
|
||||
const libraryEntry = createRpgWorldLibraryEntryFixture();
|
||||
const expected = createRpgCreationWorksResponseFixture();
|
||||
|
||||
const [draftItem] = assembler.assembleDraftItems([
|
||||
{
|
||||
...JSON.parse(JSON.stringify(session)),
|
||||
userId: 'fixture-user',
|
||||
seedText: '被海雾吞没的旧航路群岛',
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: session.updatedAt,
|
||||
},
|
||||
]);
|
||||
const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]);
|
||||
const expectedDraft = expected.items.find((entry) => entry.sourceType === 'agent_session');
|
||||
const expectedPublished = expected.items.find(
|
||||
(entry) => entry.sourceType === 'published_profile',
|
||||
);
|
||||
|
||||
assert.ok(expectedDraft);
|
||||
assert.ok(expectedPublished);
|
||||
assert.equal(draftItem.coverImageSrc, expectedDraft.coverImageSrc);
|
||||
assert.deepEqual(
|
||||
draftItem.coverCharacterImageSrcs,
|
||||
expectedDraft.coverCharacterImageSrcs,
|
||||
);
|
||||
assert.equal(draftItem.stageLabel, expectedDraft.stageLabel);
|
||||
assert.equal(draftItem.publishReady, expectedDraft.publishReady);
|
||||
assert.equal(draftItem.blockerCount, expectedDraft.blockerCount);
|
||||
assert.equal(publishedItem.coverImageSrc, expectedPublished.coverImageSrc);
|
||||
assert.equal(
|
||||
publishedItem.roleAssetSummaryLabel,
|
||||
expectedPublished.roleAssetSummaryLabel,
|
||||
);
|
||||
});
|
||||
|
||||
test('published sessions do not leak back into draft work summaries', () => {
|
||||
const assembler = new RpgWorldWorkSummaryAssembler();
|
||||
const session = createRpgAgentSessionFixture();
|
||||
const draftItems = assembler.assembleDraftItems([
|
||||
{
|
||||
...JSON.parse(JSON.stringify(session)),
|
||||
userId: 'fixture-user',
|
||||
seedText: '被海雾吞没的旧航路群岛',
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: session.updatedAt,
|
||||
stage: 'published',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(draftItems.length, 0);
|
||||
});
|
||||
301
server-node/src/services/RpgWorldWorkSummaryAssembler.ts
Normal file
301
server-node/src/services/RpgWorldWorkSummaryAssembler.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import type {
|
||||
CustomWorldAgentStage,
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type {
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldProfileRecord,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import {
|
||||
buildDraftSummaryFromIntent,
|
||||
buildDraftTitleFromIntent,
|
||||
normalizeCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import {
|
||||
rebuildRoleAssetCoverage,
|
||||
resolveRoleAssetStatusLabel,
|
||||
} from './customWorldAgentRoleAssetStateService.js';
|
||||
import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js';
|
||||
import {
|
||||
buildDraftSummaryFromEightAnchorContent,
|
||||
buildDraftTitleFromEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
import {
|
||||
resolveRpgWorldDraftWorkCover,
|
||||
resolveRpgWorldPublishedWorkCover,
|
||||
} from './RpgWorldWorkCoverResolver.js';
|
||||
import { CustomWorldAgentPublishingService } from './customWorldAgentPublishingService.js';
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => item && typeof item === 'object')
|
||||
: [];
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number) {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function formatDraftStageLabel(stage: CustomWorldAgentStage) {
|
||||
if (stage === 'collecting_intent') return '收集世界锚点';
|
||||
if (stage === 'clarifying') return '补齐关键锚点';
|
||||
if (stage === 'foundation_review') return '准备整理底稿';
|
||||
if (stage === 'object_refining') return '待完善草稿';
|
||||
if (stage === 'visual_refining') return '视觉工坊';
|
||||
if (stage === 'long_tail_review') return '扩展长尾';
|
||||
if (stage === 'ready_to_publish') return '准备发布';
|
||||
if (stage === 'published') return '已发布';
|
||||
return '发生错误';
|
||||
}
|
||||
|
||||
function resolveDraftTitle(session: CustomWorldAgentSessionRecord) {
|
||||
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
|
||||
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
|
||||
return (
|
||||
draftProfile?.name ||
|
||||
buildDraftTitleFromEightAnchorContent(session.anchorContent) ||
|
||||
buildDraftTitleFromIntent(intent) ||
|
||||
toText(session.draftProfile?.title) ||
|
||||
truncateText(session.seedText, 18) ||
|
||||
'未命名草稿'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
|
||||
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
|
||||
const compiledSummary = buildDraftSummaryFromIntent(intent);
|
||||
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
|
||||
return (
|
||||
draftProfile?.summary ||
|
||||
buildDraftSummaryFromEightAnchorContent(session.anchorContent) ||
|
||||
compiledSummary ||
|
||||
toText(session.draftProfile?.summary) ||
|
||||
truncateText(session.seedText, 72) ||
|
||||
'还在收集你的世界锚点。'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDraftCounts(session: CustomWorldAgentSessionRecord) {
|
||||
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
if (draftProfile) {
|
||||
// 草稿作品卡需要展示当前可编辑的全部角色数量,而不是仅统计可扮演角色。
|
||||
const totalRoleCount = [
|
||||
...new Set(
|
||||
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
),
|
||||
].length;
|
||||
|
||||
return {
|
||||
playableNpcCount: totalRoleCount,
|
||||
landmarkCount: draftProfile.landmarks.length,
|
||||
};
|
||||
}
|
||||
|
||||
const playableNpcCount = session.draftCards.filter(
|
||||
(card) => card.kind === 'character',
|
||||
).length;
|
||||
const landmarkCount = session.draftCards.filter(
|
||||
(card) => card.kind === 'landmark' || card.kind === 'camp',
|
||||
).length;
|
||||
|
||||
return {
|
||||
playableNpcCount,
|
||||
landmarkCount,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) {
|
||||
const coverage = rebuildRoleAssetCoverage(session.draftProfile);
|
||||
const roleVisualReadyCount = coverage.roleAssets.filter(
|
||||
(entry) => entry.status !== 'missing',
|
||||
).length;
|
||||
const roleAnimationReadyCount = coverage.roleAssets.filter(
|
||||
(entry) => entry.status === 'complete',
|
||||
).length;
|
||||
const leadRole = coverage.roleAssets[0];
|
||||
|
||||
return {
|
||||
roleVisualReadyCount,
|
||||
roleAnimationReadyCount,
|
||||
roleAssetSummaryLabel: leadRole
|
||||
? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}`
|
||||
: coverage.roleAssets.length > 0
|
||||
? '角色资产进行中'
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function isLibraryEntry(
|
||||
value: unknown,
|
||||
): value is CustomWorldLibraryEntry<CustomWorldProfileRecord> {
|
||||
const record = toRecord(value);
|
||||
return (
|
||||
record !== null &&
|
||||
typeof record.ownerUserId === 'string' &&
|
||||
typeof record.profileId === 'string' &&
|
||||
Boolean(toRecord(record.profile))
|
||||
);
|
||||
}
|
||||
|
||||
function isPublishedLibraryEntry(
|
||||
value: unknown,
|
||||
): value is CustomWorldLibraryEntry<CustomWorldProfileRecord> {
|
||||
return isLibraryEntry(value) && value.visibility === 'published';
|
||||
}
|
||||
|
||||
/**
|
||||
* works 组装器只负责把 session/profile 转成稳定读模型,不直接发起仓储读取。
|
||||
*/
|
||||
export class RpgWorldWorkSummaryAssembler {
|
||||
private readonly publishGateService = new CustomWorldAgentPublishingService({
|
||||
listOwnProfiles: async () => [],
|
||||
upsertOwnProfile: async () => {
|
||||
throw new Error('publish repository is unavailable in work summary assembler');
|
||||
},
|
||||
syncProfileFromSnapshot: async () => undefined,
|
||||
softDeleteOwnProfile: async () => [],
|
||||
publishOwnProfile: async () => null,
|
||||
unpublishOwnProfile: async () => null,
|
||||
listPublishedGallery: async () => [],
|
||||
getPublishedGalleryDetail: async () => null,
|
||||
});
|
||||
|
||||
assembleDraftItems(sessions: CustomWorldAgentSessionRecord[]) {
|
||||
return sessions
|
||||
.filter((session) => session.stage !== 'published')
|
||||
.map((session) => {
|
||||
const counts = resolveDraftCounts(session);
|
||||
const roleAssetProgress = resolveDraftRoleAssetProgress(session);
|
||||
const coverPresentation = resolveRpgWorldDraftWorkCover(session);
|
||||
const publishState = this.publishGateService.summarizePublishGate({
|
||||
sessionId: session.sessionId,
|
||||
stage: session.stage,
|
||||
draftProfile: session.draftProfile,
|
||||
qualityFindings: session.qualityFindings,
|
||||
});
|
||||
|
||||
return {
|
||||
workId: `draft:${session.sessionId}`,
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: resolveDraftTitle(session),
|
||||
subtitle:
|
||||
normalizeFoundationDraftProfile(session.draftProfile)?.subtitle ||
|
||||
formatDraftStageLabel(session.stage),
|
||||
summary: resolveDraftSummary(session),
|
||||
coverImageSrc: coverPresentation.imageSrc,
|
||||
coverRenderMode: coverPresentation.renderMode,
|
||||
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
||||
updatedAt: session.updatedAt,
|
||||
publishedAt: null,
|
||||
stage: session.stage,
|
||||
stageLabel: formatDraftStageLabel(session.stage),
|
||||
playableNpcCount: counts.playableNpcCount,
|
||||
landmarkCount: counts.landmarkCount,
|
||||
roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount,
|
||||
roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount,
|
||||
roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel,
|
||||
sessionId: session.sessionId,
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: publishState.canEnterWorld,
|
||||
blockerCount: publishState.blockerCount,
|
||||
publishReady: publishState.publishReady,
|
||||
} satisfies CustomWorldWorkSummary;
|
||||
});
|
||||
}
|
||||
|
||||
assemblePublishedItems(
|
||||
profiles: Array<CustomWorldLibraryEntry<CustomWorldProfileRecord>>,
|
||||
) {
|
||||
return profiles.filter(isPublishedLibraryEntry).map((libraryEntry) => {
|
||||
const profileRecord = libraryEntry.profile as CustomWorldProfileRecord &
|
||||
Record<string, unknown>;
|
||||
const playableNpcs = toRecordArray(profileRecord.playableNpcs);
|
||||
const landmarks = toRecordArray(profileRecord.landmarks);
|
||||
const updatedAt =
|
||||
toText(libraryEntry.updatedAt) ||
|
||||
toText(profileRecord.updatedAt) ||
|
||||
new Date().toISOString();
|
||||
const coverPresentation = resolveRpgWorldPublishedWorkCover(libraryEntry);
|
||||
const roleVisualReadyCount = playableNpcs.filter(
|
||||
(entry) =>
|
||||
Boolean(toText(entry.imageSrc)) &&
|
||||
Boolean(toText(entry.generatedVisualAssetId)),
|
||||
).length;
|
||||
const roleAnimationReadyCount = playableNpcs.filter((entry) =>
|
||||
Boolean(toText(entry.generatedAnimationSetId)),
|
||||
).length;
|
||||
|
||||
return {
|
||||
workId: `published:${toText(profileRecord.id) || updatedAt}`,
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title:
|
||||
toText(libraryEntry.worldName) ||
|
||||
toText(profileRecord.name) ||
|
||||
'未命名世界',
|
||||
subtitle:
|
||||
toText(libraryEntry.subtitle) ||
|
||||
toText(profileRecord.subtitle) ||
|
||||
'已保存作品',
|
||||
summary:
|
||||
toText(libraryEntry.summaryText) ||
|
||||
toText(profileRecord.summary) ||
|
||||
'这个世界已经可以直接进入体验。',
|
||||
coverImageSrc: coverPresentation.imageSrc,
|
||||
coverRenderMode: coverPresentation.renderMode,
|
||||
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
||||
updatedAt,
|
||||
publishedAt:
|
||||
toText(libraryEntry.publishedAt) ||
|
||||
toText(profileRecord.publishedAt) ||
|
||||
updatedAt,
|
||||
stage: 'published',
|
||||
stageLabel: '已发布',
|
||||
playableNpcCount:
|
||||
libraryEntry.playableNpcCount > 0
|
||||
? libraryEntry.playableNpcCount
|
||||
: playableNpcs.length,
|
||||
landmarkCount:
|
||||
libraryEntry.landmarkCount > 0
|
||||
? libraryEntry.landmarkCount
|
||||
: landmarks.length,
|
||||
roleVisualReadyCount,
|
||||
roleAnimationReadyCount,
|
||||
roleAssetSummaryLabel:
|
||||
roleAnimationReadyCount > 0
|
||||
? `动作已就绪 ${roleAnimationReadyCount}`
|
||||
: roleVisualReadyCount > 0
|
||||
? `主图已就绪 ${roleVisualReadyCount}`
|
||||
: null,
|
||||
sessionId: null,
|
||||
profileId:
|
||||
toText(libraryEntry.profileId) || toText(profileRecord.id) || null,
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
blockerCount: 0,
|
||||
publishReady: true,
|
||||
} satisfies CustomWorldWorkSummary;
|
||||
});
|
||||
}
|
||||
}
|
||||
44
server-node/src/services/RpgWorldWorkSummaryService.ts
Normal file
44
server-node/src/services/RpgWorldWorkSummaryService.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js';
|
||||
import type { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js';
|
||||
|
||||
/**
|
||||
* RPG 作品卡服务只负责组织“草稿 session + 已发布作品”两类读模型,
|
||||
* 不再直接承担读库 SQL 或封面字段推导细节。
|
||||
*/
|
||||
export class RpgWorldWorkSummaryService {
|
||||
private readonly assembler: RpgWorldWorkSummaryAssembler;
|
||||
|
||||
constructor(
|
||||
private readonly rpgWorldProfiles: RpgWorldProfileRepositoryPort,
|
||||
private readonly customWorldAgentSessions: CustomWorldAgentSessionStore,
|
||||
assembler: RpgWorldWorkSummaryAssembler = new RpgWorldWorkSummaryAssembler(),
|
||||
) {
|
||||
this.assembler = assembler;
|
||||
}
|
||||
|
||||
async list(userId: string): Promise<CustomWorldWorkSummary[]> {
|
||||
const [sessions, profiles] = await Promise.all([
|
||||
this.customWorldAgentSessions.list(userId),
|
||||
this.rpgWorldProfiles.listOwnProfiles(userId),
|
||||
]);
|
||||
|
||||
const draftItems = this.assembler.assembleDraftItems(sessions);
|
||||
const publishedItems = this.assembler.assemblePublishedItems(profiles);
|
||||
|
||||
return [...draftItems, ...publishedItems].sort((left, right) => {
|
||||
const updatedAtDiff =
|
||||
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime();
|
||||
if (updatedAtDiff !== 0) {
|
||||
return updatedAtDiff;
|
||||
}
|
||||
|
||||
if (left.sourceType !== right.sourceType) {
|
||||
return left.sourceType === 'agent_session' ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.workId.localeCompare(right.workId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
NpcChatDialogueRequest,
|
||||
NpcChatTurnRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js';
|
||||
|
||||
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
buildCreatorIntentFromEightAnchorContent,
|
||||
buildAnchorPackFromEightAnchorContent,
|
||||
} from '../eightAnchorCompatibilityService.js';
|
||||
import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js';
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js';
|
||||
import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildFoundationDraftAssistantMessage } from './helpers.js';
|
||||
import {
|
||||
buildCheckpointSnapshot,
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createDraftFoundationExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
foundationDraftService: CustomWorldAgentFoundationDraftService;
|
||||
autoAssetService: CustomWorldAgentAutoAssetService | null;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'draft_foundation'> {
|
||||
return async ({ userId, sessionId, operationId }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '整理世界骨架',
|
||||
phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。',
|
||||
progress: 12,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
if (latestSession.progressPercent < 100) {
|
||||
throw new Error('session progressPercent is below 100');
|
||||
}
|
||||
|
||||
const creatorIntent = buildCreatorIntentFromEightAnchorContent(
|
||||
latestSession.anchorContent,
|
||||
);
|
||||
const anchorPack = buildAnchorPackFromEightAnchorContent(
|
||||
latestSession.anchorContent,
|
||||
latestSession.progressPercent,
|
||||
);
|
||||
const draftProfile = await params.foundationDraftService.generate({
|
||||
creatorIntent,
|
||||
anchorPack,
|
||||
anchorContent: latestSession.anchorContent,
|
||||
onProgress: async (progress) => {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: progress.phaseLabel,
|
||||
phaseDetail: progress.phaseDetail,
|
||||
progress: progress.progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const draftWithAssets = params.autoAssetService
|
||||
? await params.autoAssetService.populateDraftAssets({
|
||||
draftProfile,
|
||||
onProgress: async (progress) => {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: progress.phaseLabel,
|
||||
phaseDetail: progress.phaseDetail,
|
||||
progress: progress.progress,
|
||||
});
|
||||
},
|
||||
})
|
||||
: {
|
||||
draftProfile,
|
||||
assetCoverage: rebuildRoleAssetCoverage(draftProfile),
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
await updateOperation({
|
||||
phaseLabel: '编译草稿卡',
|
||||
phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。',
|
||||
progress: 98,
|
||||
});
|
||||
|
||||
const nextState = params.snapshotBuilder.buildFoundationDraftState({
|
||||
creatorIntent,
|
||||
anchorPack,
|
||||
draftProfile:
|
||||
draftWithAssets.draftProfile as unknown as Record<string, unknown>,
|
||||
assetCoverage: draftWithAssets.assetCoverage,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendCheckpoint(userId, sessionId, {
|
||||
label: '世界底稿 V1',
|
||||
snapshot: buildCheckpointSnapshot(latestSession, nextState),
|
||||
});
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildFoundationDraftAssistantMessage({
|
||||
relatedOperationId: operationId,
|
||||
draftProfile: draftWithAssets.draftProfile,
|
||||
warnings: draftWithAssets.warnings,
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '世界底稿已生成',
|
||||
phaseDetail:
|
||||
draftWithAssets.warnings.length > 0
|
||||
? `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。`
|
||||
: `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const currentOperation = await params.sessionStore.getOperation(
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
);
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: currentOperation?.phaseLabel?.trim() || '底稿生成失败',
|
||||
phaseDetail:
|
||||
currentOperation?.phaseDetail?.trim() ||
|
||||
'这一轮没有成功把设定编成世界底稿。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'draft foundation failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CustomWorldAgentOperationRecord } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type { CustomWorldAgentSessionRecord, CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
|
||||
export type UpdateExecutorOperation = (
|
||||
patch: Partial<CustomWorldAgentOperationRecord>,
|
||||
) => Promise<void>;
|
||||
|
||||
export async function getRequiredSession(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const session = (await params.sessionStore.get(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
)) as CustomWorldAgentSessionRecord | null;
|
||||
if (!session) {
|
||||
throw new Error('custom world agent session not found');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export function createOperationUpdater(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
}): UpdateExecutorOperation {
|
||||
return (patch) =>
|
||||
params.sessionStore.updateOperation(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
params.operationId,
|
||||
patch,
|
||||
);
|
||||
}
|
||||
|
||||
// checkpoint 恢复依赖这份最小可回放快照,统一由 executor 共享,避免每个动作手写字段集合。
|
||||
export function buildCheckpointSnapshot(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
CustomWorldAgentSessionRecord,
|
||||
| 'currentTurn'
|
||||
| 'anchorContent'
|
||||
| 'progressPercent'
|
||||
| 'lastAssistantReply'
|
||||
| 'stage'
|
||||
| 'focusCardId'
|
||||
| 'creatorIntent'
|
||||
| 'creatorIntentReadiness'
|
||||
| 'anchorPack'
|
||||
| 'lockState'
|
||||
| 'draftProfile'
|
||||
| 'pendingClarifications'
|
||||
| 'suggestedActions'
|
||||
| 'recommendedReplies'
|
||||
| 'draftCards'
|
||||
| 'qualityFindings'
|
||||
| 'assetCoverage'
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return {
|
||||
currentTurn: patch.currentTurn ?? session.currentTurn,
|
||||
anchorContent: patch.anchorContent ?? session.anchorContent,
|
||||
progressPercent: patch.progressPercent ?? session.progressPercent,
|
||||
lastAssistantReply:
|
||||
patch.lastAssistantReply !== undefined
|
||||
? patch.lastAssistantReply
|
||||
: session.lastAssistantReply,
|
||||
stage: patch.stage ?? session.stage,
|
||||
focusCardId:
|
||||
patch.focusCardId !== undefined ? patch.focusCardId : session.focusCardId,
|
||||
creatorIntent:
|
||||
patch.creatorIntent !== undefined
|
||||
? patch.creatorIntent
|
||||
: session.creatorIntent,
|
||||
creatorIntentReadiness:
|
||||
patch.creatorIntentReadiness ?? session.creatorIntentReadiness,
|
||||
anchorPack: patch.anchorPack !== undefined ? patch.anchorPack : session.anchorPack,
|
||||
lockState: patch.lockState !== undefined ? patch.lockState : session.lockState,
|
||||
draftProfile:
|
||||
patch.draftProfile !== undefined ? patch.draftProfile : session.draftProfile,
|
||||
pendingClarifications:
|
||||
patch.pendingClarifications !== undefined
|
||||
? patch.pendingClarifications
|
||||
: session.pendingClarifications,
|
||||
suggestedActions:
|
||||
patch.suggestedActions !== undefined
|
||||
? patch.suggestedActions
|
||||
: session.suggestedActions,
|
||||
recommendedReplies:
|
||||
patch.recommendedReplies !== undefined
|
||||
? patch.recommendedReplies
|
||||
: session.recommendedReplies,
|
||||
draftCards: patch.draftCards !== undefined ? patch.draftCards : session.draftCards,
|
||||
qualityFindings:
|
||||
patch.qualityFindings !== undefined
|
||||
? patch.qualityFindings
|
||||
: session.qualityFindings,
|
||||
assetCoverage:
|
||||
patch.assetCoverage !== undefined
|
||||
? patch.assetCoverage
|
||||
: session.assetCoverage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js';
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
buildCheckpointSnapshot,
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createExpandLongTailExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
entityGenerationService: CustomWorldAgentEntityGenerationService;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'expand_long_tail'> {
|
||||
return async ({ userId, sessionId, operationId }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '扩展长尾内容',
|
||||
phaseDetail: '正在补充边缘角色与次级地点,让世界草稿更完整可玩。',
|
||||
progress: 28,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const baseDraftProfile =
|
||||
(latestSession.draftProfile ?? {}) as Record<string, unknown>;
|
||||
const characterResult =
|
||||
await params.entityGenerationService.generateAdditionalCharacters({
|
||||
creatorIntent: latestSession.creatorIntent,
|
||||
anchorPack: latestSession.anchorPack,
|
||||
draftProfile: baseDraftProfile,
|
||||
count: 2,
|
||||
anchorCardIds:
|
||||
latestSession.focusCardId && latestSession.focusCardId.trim()
|
||||
? [latestSession.focusCardId]
|
||||
: [getWorldFoundationCardId()],
|
||||
});
|
||||
|
||||
await updateOperation({
|
||||
phaseLabel: '补充次级地点',
|
||||
phaseDetail: '正在围绕新线索补齐可承接支线与长尾内容的地点。',
|
||||
progress: 62,
|
||||
});
|
||||
|
||||
const landmarkResult =
|
||||
await params.entityGenerationService.generateAdditionalLandmarks({
|
||||
creatorIntent: latestSession.creatorIntent,
|
||||
anchorPack: latestSession.anchorPack,
|
||||
draftProfile: characterResult.draftProfile,
|
||||
count: 2,
|
||||
anchorCardIds:
|
||||
characterResult.generatedCharacters.length > 0
|
||||
? [characterResult.generatedCharacters[0]!.id]
|
||||
: latestSession.focusCardId && latestSession.focusCardId.trim()
|
||||
? [latestSession.focusCardId]
|
||||
: [getWorldFoundationCardId()],
|
||||
});
|
||||
|
||||
const focusCardId =
|
||||
landmarkResult.generatedLandmarks[0]?.id ??
|
||||
characterResult.generatedCharacters[0]?.id ??
|
||||
latestSession.focusCardId;
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: latestSession.stage,
|
||||
nextStage: 'long_tail_review',
|
||||
draftProfile: landmarkResult.draftProfile,
|
||||
focusCardId,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendCheckpoint(userId, sessionId, {
|
||||
label: `扩展长尾 ${characterResult.generatedCharacters.length} 角色 / ${landmarkResult.generatedLandmarks.length} 地点`,
|
||||
snapshot: buildCheckpointSnapshot(latestSession, nextState),
|
||||
});
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: `已补出 ${characterResult.generatedCharacters.length} 个长尾角色和 ${landmarkResult.generatedLandmarks.length} 个次级地点,当前阶段进入补全长尾内容。`,
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '长尾内容已扩展',
|
||||
phaseDetail: '长尾角色与次级地点已经补回草稿,可继续收口后进入发布前检查。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '扩展长尾失败',
|
||||
phaseDetail: '这一轮没有成功补出长尾内容。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'expand long tail failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js';
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js';
|
||||
import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
buildCheckpointSnapshot,
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createGenerateCharactersExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
entityGenerationService: CustomWorldAgentEntityGenerationService;
|
||||
changeSummaryService: CustomWorldAgentChangeSummaryService;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'generate_characters'> {
|
||||
return async ({ userId, sessionId, operationId, payload }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '生成新角色',
|
||||
phaseDetail: '正在围绕当前世界底稿补出新角色。',
|
||||
progress: 32,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const generationResult =
|
||||
await params.entityGenerationService.generateAdditionalCharacters({
|
||||
creatorIntent: latestSession.creatorIntent,
|
||||
anchorPack: latestSession.anchorPack,
|
||||
draftProfile: (latestSession.draftProfile ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
count: payload.count,
|
||||
promptText: payload.promptText,
|
||||
anchorCardIds:
|
||||
payload.anchorCardIds && payload.anchorCardIds.length > 0
|
||||
? payload.anchorCardIds
|
||||
: latestSession.focusCardId
|
||||
? [latestSession.focusCardId]
|
||||
: [getWorldFoundationCardId()],
|
||||
});
|
||||
|
||||
await updateOperation({
|
||||
phaseLabel: '插入新角色卡',
|
||||
phaseDetail: '正在把新角色插回草稿并刷新卡片列表。',
|
||||
progress: 74,
|
||||
});
|
||||
|
||||
const focusCardId = generationResult.generatedCharacters[0]?.id ?? null;
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: latestSession.stage,
|
||||
draftProfile: generationResult.draftProfile,
|
||||
focusCardId,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendCheckpoint(userId, sessionId, {
|
||||
label: `新增角色 ${generationResult.generatedCharacters.length} 个`,
|
||||
snapshot: buildCheckpointSnapshot(latestSession, nextState),
|
||||
});
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: params.changeSummaryService.buildSummary({
|
||||
action: 'generate_characters',
|
||||
names: generationResult.generatedCharacters.map(
|
||||
(entry) => entry.name,
|
||||
),
|
||||
draftProfile: generationResult.draftProfile,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '新角色已加入草稿',
|
||||
phaseDetail: `已补出 ${generationResult.generatedCharacters.length} 个新角色。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '角色生成失败',
|
||||
phaseDetail: '这一轮没有成功补出新角色。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'generate characters failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js';
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js';
|
||||
import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
buildCheckpointSnapshot,
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createGenerateLandmarksExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
entityGenerationService: CustomWorldAgentEntityGenerationService;
|
||||
changeSummaryService: CustomWorldAgentChangeSummaryService;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'generate_landmarks'> {
|
||||
return async ({ userId, sessionId, operationId, payload }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '生成新地点',
|
||||
phaseDetail: '正在围绕当前世界底稿补出新地点。',
|
||||
progress: 32,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const generationResult =
|
||||
await params.entityGenerationService.generateAdditionalLandmarks({
|
||||
creatorIntent: latestSession.creatorIntent,
|
||||
anchorPack: latestSession.anchorPack,
|
||||
draftProfile: (latestSession.draftProfile ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
count: payload.count,
|
||||
promptText: payload.promptText,
|
||||
anchorCardIds:
|
||||
payload.anchorCardIds && payload.anchorCardIds.length > 0
|
||||
? payload.anchorCardIds
|
||||
: latestSession.focusCardId
|
||||
? [latestSession.focusCardId]
|
||||
: [getWorldFoundationCardId()],
|
||||
});
|
||||
|
||||
await updateOperation({
|
||||
phaseLabel: '插入新地点卡',
|
||||
phaseDetail: '正在把新地点插回草稿并刷新卡片列表。',
|
||||
progress: 74,
|
||||
});
|
||||
|
||||
const focusCardId = generationResult.generatedLandmarks[0]?.id ?? null;
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: latestSession.stage,
|
||||
draftProfile: generationResult.draftProfile,
|
||||
focusCardId,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendCheckpoint(userId, sessionId, {
|
||||
label: `新增地点 ${generationResult.generatedLandmarks.length} 个`,
|
||||
snapshot: buildCheckpointSnapshot(latestSession, nextState),
|
||||
});
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: params.changeSummaryService.buildSummary({
|
||||
action: 'generate_landmarks',
|
||||
names: generationResult.generatedLandmarks.map(
|
||||
(entry) => entry.name,
|
||||
),
|
||||
draftProfile: generationResult.draftProfile,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '新地点已加入草稿',
|
||||
phaseDetail: `已补出 ${generationResult.generatedLandmarks.length} 个新地点。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '地点生成失败',
|
||||
phaseDetail: '这一轮没有成功补出新地点。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'generate landmarks failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createGenerateRoleAssetsExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
assetBridgeService: CustomWorldAgentAssetBridgeService;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'generate_role_assets'> {
|
||||
return async ({ userId, sessionId, operationId, payload }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '准备角色资产工坊',
|
||||
phaseDetail: '正在校验角色并整理工坊上下文。',
|
||||
progress: 40,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const roleId = payload.roleIds[0]!;
|
||||
const studioContext = params.assetBridgeService.buildRoleAssetStudioContext(
|
||||
latestSession.draftProfile,
|
||||
roleId,
|
||||
);
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: latestSession.stage,
|
||||
nextStage: 'visual_refining',
|
||||
draftProfile:
|
||||
(latestSession.draftProfile ?? {}) as Record<string, unknown>,
|
||||
draftCards: latestSession.draftCards,
|
||||
assetCoverage: latestSession.assetCoverage,
|
||||
focusCardId: roleId,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: `已为「${studioContext.roleName}」准备好角色资产工坊,先生成主图候选,再补核心动作。`,
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '角色资产工坊已就绪',
|
||||
phaseDetail: `「${studioContext.roleName}」现在可以开始生成主图和动作。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '角色资产工坊准备失败',
|
||||
phaseDetail: '这一轮没有成功进入角色资产工坊。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'generate role assets failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createGenerateSceneAssetsExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
assetBridgeService: CustomWorldAgentAssetBridgeService;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'generate_scene_assets'> {
|
||||
return async ({ userId, sessionId, operationId, payload }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '准备场景资产工坊',
|
||||
phaseDetail: '正在校验目标场景并整理场景图工坊上下文。',
|
||||
progress: 40,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const sceneId = payload.sceneIds[0]!;
|
||||
const sceneKind =
|
||||
latestSession.draftCards.find((entry) => entry.id === sceneId)?.kind ===
|
||||
'camp'
|
||||
? 'camp'
|
||||
: 'landmark';
|
||||
const sceneContext = params.assetBridgeService.buildSceneAssetStudioContext(
|
||||
latestSession.draftProfile,
|
||||
sceneId,
|
||||
sceneKind,
|
||||
);
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: latestSession.stage,
|
||||
nextStage: 'visual_refining',
|
||||
draftProfile:
|
||||
(latestSession.draftProfile ?? {}) as Record<string, unknown>,
|
||||
draftCards: latestSession.draftCards,
|
||||
assetCoverage: latestSession.assetCoverage,
|
||||
focusCardId: sceneId,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: `已为「${sceneContext.sceneName}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。`,
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '场景资产工坊已就绪',
|
||||
phaseDetail: `「${sceneContext.sceneName}」现在可以继续生成和确认正式场景图。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '场景资产工坊准备失败',
|
||||
phaseDetail: '这一轮没有成功进入场景资产工坊。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'generate scene assets failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { CustomWorldAgentMessage } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
normalizeFoundationDraftProfile,
|
||||
} from '../customWorldAgentDraftCompiler.js';
|
||||
|
||||
export function buildRoleAssetSyncResultText(params: {
|
||||
roleName: string;
|
||||
assetStatusLabel: string;
|
||||
}) {
|
||||
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`;
|
||||
}
|
||||
|
||||
export function buildFoundationDraftAssistantMessage(params: {
|
||||
relatedOperationId: string;
|
||||
draftProfile: unknown;
|
||||
warnings?: string[];
|
||||
}) {
|
||||
const profile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
const leadCharacter = profile?.playableNpcs[0];
|
||||
const leadLandmark = profile?.landmarks[0];
|
||||
const warnings = (params.warnings ?? []).filter(Boolean);
|
||||
|
||||
return {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: [
|
||||
`我先把第一版世界底稿整理出来了:${profile?.summary || '底稿已经生成完成。'}`,
|
||||
'',
|
||||
`当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`,
|
||||
`建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`,
|
||||
...(warnings.length > 0
|
||||
? [
|
||||
'',
|
||||
`这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`,
|
||||
]
|
||||
: []),
|
||||
].join('\n'),
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: params.relatedOperationId,
|
||||
} satisfies CustomWorldAgentMessage;
|
||||
}
|
||||
|
||||
export function buildActionResultMessage(params: {
|
||||
relatedOperationId: string;
|
||||
text: string;
|
||||
}) {
|
||||
return {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
role: 'assistant',
|
||||
kind: 'action_result',
|
||||
text: params.text,
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: params.relatedOperationId,
|
||||
} satisfies CustomWorldAgentMessage;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js';
|
||||
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
|
||||
import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js';
|
||||
import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js';
|
||||
import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js';
|
||||
import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js';
|
||||
import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js';
|
||||
import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import { createDraftFoundationExecutor } from './draftFoundationExecutor.js';
|
||||
import { createExpandLongTailExecutor } from './expandLongTailExecutor.js';
|
||||
import { createGenerateCharactersExecutor } from './generateCharactersExecutor.js';
|
||||
import { createGenerateLandmarksExecutor } from './generateLandmarksExecutor.js';
|
||||
import { createGenerateRoleAssetsExecutor } from './generateRoleAssetsExecutor.js';
|
||||
import { createGenerateSceneAssetsExecutor } from './generateSceneAssetsExecutor.js';
|
||||
import { createPublishWorldExecutor } from './publishWorldExecutor.js';
|
||||
import { createRevertCheckpointExecutor } from './revertCheckpointExecutor.js';
|
||||
import { createSyncResultProfileExecutor } from './syncResultProfileExecutor.js';
|
||||
import { createSyncRoleAssetsExecutor } from './syncRoleAssetsExecutor.js';
|
||||
import { createSyncSceneAssetsExecutor } from './syncSceneAssetsExecutor.js';
|
||||
import type { CustomWorldAgentActionExecutorMap } from './types.js';
|
||||
import { createUpdateDraftCardExecutor } from './updateDraftCardExecutor.js';
|
||||
|
||||
export * from './types.js';
|
||||
|
||||
export function createCustomWorldAgentActionExecutorMap(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
foundationDraftService: CustomWorldAgentFoundationDraftService;
|
||||
draftCompiler: CustomWorldAgentDraftCompiler;
|
||||
entityGenerationService: CustomWorldAgentEntityGenerationService;
|
||||
changeSummaryService: CustomWorldAgentChangeSummaryService;
|
||||
assetBridgeService: CustomWorldAgentAssetBridgeService;
|
||||
autoAssetService: CustomWorldAgentAutoAssetService | null;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
resultSyncService: CustomWorldAgentResultSyncService;
|
||||
publishingService: CustomWorldAgentPublishingService;
|
||||
resolveAuthorDisplayName?: ((userId: string) => Promise<string>) | null;
|
||||
}): CustomWorldAgentActionExecutorMap {
|
||||
return {
|
||||
draft_foundation: createDraftFoundationExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
foundationDraftService: params.foundationDraftService,
|
||||
autoAssetService: params.autoAssetService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
update_draft_card: createUpdateDraftCardExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
draftCompiler: params.draftCompiler,
|
||||
changeSummaryService: params.changeSummaryService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
sync_result_profile: createSyncResultProfileExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
resultSyncService: params.resultSyncService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
generate_characters: createGenerateCharactersExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
entityGenerationService: params.entityGenerationService,
|
||||
changeSummaryService: params.changeSummaryService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
generate_landmarks: createGenerateLandmarksExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
entityGenerationService: params.entityGenerationService,
|
||||
changeSummaryService: params.changeSummaryService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
generate_role_assets: createGenerateRoleAssetsExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
assetBridgeService: params.assetBridgeService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
sync_role_assets: createSyncRoleAssetsExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
assetBridgeService: params.assetBridgeService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
generate_scene_assets: createGenerateSceneAssetsExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
assetBridgeService: params.assetBridgeService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
sync_scene_assets: createSyncSceneAssetsExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
assetBridgeService: params.assetBridgeService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
expand_long_tail: createExpandLongTailExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
entityGenerationService: params.entityGenerationService,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
publish_world: createPublishWorldExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
publishingService: params.publishingService,
|
||||
resolveAuthorDisplayName: params.resolveAuthorDisplayName ?? null,
|
||||
}),
|
||||
revert_checkpoint: createRevertCheckpointExecutor({
|
||||
sessionStore: params.sessionStore,
|
||||
snapshotBuilder: params.snapshotBuilder,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
buildCheckpointSnapshot,
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function extractPublishBlockerMessages(message: string) {
|
||||
const normalized = message.trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const detailText = normalized.includes(':')
|
||||
? normalized.split(':').slice(1).join(':').trim()
|
||||
: normalized;
|
||||
|
||||
return detailText
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildGateFailureMessage(errorMessage: string) {
|
||||
return [
|
||||
'当前世界还不能发布,先把这些阻断项补齐:',
|
||||
...(extractPublishBlockerMessages(errorMessage).length > 0
|
||||
? extractPublishBlockerMessages(errorMessage)
|
||||
: [errorMessage.trim()]
|
||||
)
|
||||
.slice(0, 4)
|
||||
.map((entry, index) => `${index + 1}. ${entry}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolvePublishedWorldName(profile: unknown) {
|
||||
const profileRecord =
|
||||
profile && typeof profile === 'object' && !Array.isArray(profile)
|
||||
? (profile as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return toText(profileRecord?.name) || '当前世界';
|
||||
}
|
||||
|
||||
export function createPublishWorldExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
publishingService: CustomWorldAgentPublishingService;
|
||||
resolveAuthorDisplayName?: ((userId: string) => Promise<string>) | null;
|
||||
}): CustomWorldAgentActionExecutor<'publish_world'> {
|
||||
return async ({ userId, sessionId, operationId }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '执行发布校验',
|
||||
phaseDetail: '正在检查角色资产、场景图和主线草稿是否满足发布门槛。',
|
||||
progress: 28,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
try {
|
||||
params.publishingService.buildPublishReadiness({
|
||||
sessionId,
|
||||
draftProfile: latestSession.draftProfile,
|
||||
qualityFindings: latestSession.qualityFindings,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'publish world failed';
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
{
|
||||
id: `message-${Date.now().toString(36)}-publish-warning`,
|
||||
role: 'assistant',
|
||||
kind: 'warning',
|
||||
text: buildGateFailureMessage(errorMessage),
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: operationId,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await updateOperation({
|
||||
phaseLabel: '发布正式世界',
|
||||
phaseDetail: '正在把当前草稿编译成正式世界档案并写入作品库。',
|
||||
progress: 68,
|
||||
});
|
||||
|
||||
const authorDisplayName = params.resolveAuthorDisplayName
|
||||
? await params.resolveAuthorDisplayName(userId)
|
||||
: '玩家';
|
||||
const publishResult = await params.publishingService.publishSessionDraft({
|
||||
userId,
|
||||
authorDisplayName: authorDisplayName.trim() || '玩家',
|
||||
sessionId,
|
||||
draftProfile:
|
||||
(latestSession.draftProfile ?? {}) as Record<string, unknown>,
|
||||
qualityFindings: latestSession.qualityFindings,
|
||||
});
|
||||
const worldName = resolvePublishedWorldName(publishResult.publishedProfile);
|
||||
const publishedQualityFindings = latestSession.qualityFindings.filter(
|
||||
(entry) => entry.severity !== 'blocker',
|
||||
);
|
||||
const publishedState = {
|
||||
stage: 'published' as const,
|
||||
qualityFindings: publishedQualityFindings,
|
||||
};
|
||||
|
||||
await params.sessionStore.replaceDerivedState(
|
||||
userId,
|
||||
sessionId,
|
||||
publishedState,
|
||||
);
|
||||
await params.sessionStore.appendCheckpoint(userId, sessionId, {
|
||||
label: `发布世界 ${worldName}`,
|
||||
snapshot: buildCheckpointSnapshot(latestSession, publishedState),
|
||||
});
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text:
|
||||
publishedQualityFindings.length > 0
|
||||
? `世界「${worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。`
|
||||
: `世界「${worldName}」已正式发布,可以进入作品库与世界入口。`,
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '世界已发布',
|
||||
phaseDetail: `正式世界档案已写入作品库:${publishResult.profileId}。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '发布失败',
|
||||
phaseDetail: '当前世界还没有通过发布校验或写入作品库失败。',
|
||||
progress: 100,
|
||||
error: error instanceof Error ? error.message : 'publish world failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createRevertCheckpointExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'revert_checkpoint'> {
|
||||
return async ({ userId, sessionId, operationId, payload }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '恢复历史检查点',
|
||||
phaseDetail: '正在把指定检查点的草稿状态恢复到当前会话。',
|
||||
progress: 36,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const checkpoint = latestSession.checkpoints.find(
|
||||
(entry) => entry.checkpointId === payload.checkpointId,
|
||||
);
|
||||
if (!checkpoint?.snapshot) {
|
||||
throw new Error('目标检查点不存在,或当前检查点还没有可恢复快照。');
|
||||
}
|
||||
|
||||
await params.sessionStore.restoreCheckpoint(
|
||||
userId,
|
||||
sessionId,
|
||||
payload.checkpointId,
|
||||
);
|
||||
const restoredSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: restoredSession.stage,
|
||||
nextStage:
|
||||
restoredSession.stage === 'visual_refining' ||
|
||||
restoredSession.stage === 'long_tail_review' ||
|
||||
restoredSession.stage === 'ready_to_publish'
|
||||
? restoredSession.stage
|
||||
: 'object_refining',
|
||||
draftProfile:
|
||||
(restoredSession.draftProfile ?? {}) as Record<string, unknown>,
|
||||
focusCardId: restoredSession.focusCardId,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: `已恢复到检查点「${checkpoint.label}」,当前草稿和卡片摘要已经回滚到对应版本。`,
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '检查点已恢复',
|
||||
phaseDetail: `已恢复到「${checkpoint.label}」。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '恢复检查点失败',
|
||||
phaseDetail: '这一轮没有成功恢复历史检查点。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'revert checkpoint failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
buildCheckpointSnapshot,
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createSyncResultProfileExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
resultSyncService: CustomWorldAgentResultSyncService;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'sync_result_profile'> {
|
||||
return async ({ userId, sessionId, operationId, payload }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '同步结果页快照',
|
||||
phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。',
|
||||
progress: 36,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const nextDraftProfile =
|
||||
params.resultSyncService.syncResultProfileIntoDraftProfile({
|
||||
currentDraftProfile: latestSession.draftProfile,
|
||||
resultProfile: payload.profile as never,
|
||||
});
|
||||
|
||||
await updateOperation({
|
||||
phaseLabel: '重编译草稿摘要',
|
||||
phaseDetail: '正在刷新草稿卡摘要、资产覆盖和结果页恢复快照。',
|
||||
progress: 72,
|
||||
});
|
||||
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: latestSession.stage,
|
||||
draftProfile: nextDraftProfile,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendCheckpoint(userId, sessionId, {
|
||||
label: '同步结果页编辑',
|
||||
snapshot: buildCheckpointSnapshot(latestSession, nextState),
|
||||
});
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: '结果页里的最新世界结构已经同步回当前草稿。',
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '结果页快照已同步',
|
||||
phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '结果页同步失败',
|
||||
phaseDetail: '这一轮结果页编辑没有成功写回当前草稿。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'sync result profile failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { resolveRoleAssetStatusLabel } from '../customWorldAgentRoleAssetStateService.js';
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import {
|
||||
buildActionResultMessage,
|
||||
buildRoleAssetSyncResultText,
|
||||
} from './helpers.js';
|
||||
import {
|
||||
buildCheckpointSnapshot,
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createSyncRoleAssetsExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
assetBridgeService: CustomWorldAgentAssetBridgeService;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'sync_role_assets'> {
|
||||
return async ({ userId, sessionId, operationId, payload }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '同步角色资产',
|
||||
phaseDetail: '正在把主图与动作结果写回当前世界草稿。',
|
||||
progress: 36,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const syncResult = params.assetBridgeService.applyRoleAssetPublishResult(
|
||||
latestSession.draftProfile,
|
||||
payload,
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
phaseLabel: '刷新角色卡摘要',
|
||||
phaseDetail: '正在同步更新角色卡状态与资产覆盖。',
|
||||
progress: 72,
|
||||
});
|
||||
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: latestSession.stage,
|
||||
nextStage: 'visual_refining',
|
||||
draftProfile: syncResult.draftProfile,
|
||||
focusCardId: payload.roleId,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendCheckpoint(userId, sessionId, {
|
||||
label: `同步角色资产 ${syncResult.updatedAssetSummary.roleName}`,
|
||||
snapshot: buildCheckpointSnapshot(latestSession, nextState),
|
||||
});
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: buildRoleAssetSyncResultText({
|
||||
roleName: syncResult.updatedAssetSummary.roleName,
|
||||
assetStatusLabel: resolveRoleAssetStatusLabel(
|
||||
syncResult.updatedAssetSummary.status,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '角色资产已同步',
|
||||
phaseDetail: `「${syncResult.updatedAssetSummary.roleName}」的资产状态已更新为${resolveRoleAssetStatusLabel(syncResult.updatedAssetSummary.status)}。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '角色资产同步失败',
|
||||
phaseDetail: '这一轮没有成功把角色资产写回草稿。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'sync role assets failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
buildCheckpointSnapshot,
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createSyncSceneAssetsExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
assetBridgeService: CustomWorldAgentAssetBridgeService;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'sync_scene_assets'> {
|
||||
return async ({ userId, sessionId, operationId, payload }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '同步场景资产',
|
||||
phaseDetail: '正在把营地/地点场景图写回当前世界草稿。',
|
||||
progress: 38,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const syncResult = params.assetBridgeService.applySceneAssetPublishResult(
|
||||
latestSession.draftProfile,
|
||||
payload,
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
phaseLabel: '刷新场景卡摘要',
|
||||
phaseDetail: '正在更新地点卡、幕背景摘要和场景资产覆盖率。',
|
||||
progress: 72,
|
||||
});
|
||||
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: latestSession.stage,
|
||||
nextStage: 'visual_refining',
|
||||
draftProfile: syncResult.draftProfile,
|
||||
focusCardId: payload.sceneId,
|
||||
});
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendCheckpoint(userId, sessionId, {
|
||||
label: `同步场景资产 ${String(syncResult.updatedScene.name ?? payload.sceneId)}`,
|
||||
snapshot: buildCheckpointSnapshot(latestSession, nextState),
|
||||
});
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: `已把「${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。`,
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '场景资产已同步',
|
||||
phaseDetail: `「${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图已经进入当前草稿。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '场景资产同步失败',
|
||||
phaseDetail: '这一轮没有成功把场景图写回当前草稿。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'sync scene assets failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { CustomWorldAgentActionRequest } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
|
||||
export type CustomWorldAgentActionPayload<
|
||||
K extends CustomWorldAgentActionRequest['action'],
|
||||
> = Extract<CustomWorldAgentActionRequest, { action: K }>;
|
||||
|
||||
export type CustomWorldAgentActionExecutor<
|
||||
K extends CustomWorldAgentActionRequest['action'],
|
||||
> = (params: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
payload: CustomWorldAgentActionPayload<K>;
|
||||
}) => Promise<void>;
|
||||
|
||||
export type CustomWorldAgentActionExecutorMap = {
|
||||
draft_foundation: CustomWorldAgentActionExecutor<'draft_foundation'>;
|
||||
update_draft_card: CustomWorldAgentActionExecutor<'update_draft_card'>;
|
||||
sync_result_profile: CustomWorldAgentActionExecutor<'sync_result_profile'>;
|
||||
generate_characters: CustomWorldAgentActionExecutor<'generate_characters'>;
|
||||
generate_landmarks: CustomWorldAgentActionExecutor<'generate_landmarks'>;
|
||||
generate_role_assets: CustomWorldAgentActionExecutor<'generate_role_assets'>;
|
||||
sync_role_assets: CustomWorldAgentActionExecutor<'sync_role_assets'>;
|
||||
generate_scene_assets: CustomWorldAgentActionExecutor<'generate_scene_assets'>;
|
||||
sync_scene_assets: CustomWorldAgentActionExecutor<'sync_scene_assets'>;
|
||||
expand_long_tail: CustomWorldAgentActionExecutor<'expand_long_tail'>;
|
||||
publish_world: CustomWorldAgentActionExecutor<'publish_world'>;
|
||||
revert_checkpoint: CustomWorldAgentActionExecutor<'revert_checkpoint'>;
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { updateDraftCardSections } from '../customWorldAgentDraftEditService.js';
|
||||
import type { CustomWorldAgentActionExecutor } from './types.js';
|
||||
import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js';
|
||||
import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js';
|
||||
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
|
||||
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
|
||||
import { buildActionResultMessage } from './helpers.js';
|
||||
import {
|
||||
buildCheckpointSnapshot,
|
||||
createOperationUpdater,
|
||||
getRequiredSession,
|
||||
} from './executorShared.js';
|
||||
|
||||
export function createUpdateDraftCardExecutor(params: {
|
||||
sessionStore: CustomWorldAgentSessionStore;
|
||||
draftCompiler: CustomWorldAgentDraftCompiler;
|
||||
changeSummaryService: CustomWorldAgentChangeSummaryService;
|
||||
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
|
||||
}): CustomWorldAgentActionExecutor<'update_draft_card'> {
|
||||
return async ({ userId, sessionId, operationId, payload }) => {
|
||||
const updateOperation = createOperationUpdater({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateOperation({
|
||||
status: 'running',
|
||||
phaseLabel: '写回草稿设定',
|
||||
phaseDetail: '正在把这次编辑内容写回当前世界底稿。',
|
||||
progress: 34,
|
||||
});
|
||||
|
||||
const latestSession = await getRequiredSession({
|
||||
sessionStore: params.sessionStore,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
const nextDraftProfile = updateDraftCardSections({
|
||||
draftProfile: (latestSession.draftProfile ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
cardId: payload.cardId,
|
||||
sections: payload.sections,
|
||||
});
|
||||
|
||||
await updateOperation({
|
||||
phaseLabel: '重编译草稿卡',
|
||||
phaseDetail: '正在同步更新草稿摘要和详情内容。',
|
||||
progress: 72,
|
||||
});
|
||||
|
||||
const nextState = params.snapshotBuilder.buildRefiningState({
|
||||
previousStage: latestSession.stage,
|
||||
draftProfile: nextDraftProfile,
|
||||
focusCardId: payload.cardId,
|
||||
});
|
||||
const updatedDetail = params.draftCompiler.getDraftCardDetail(
|
||||
nextDraftProfile,
|
||||
payload.cardId,
|
||||
);
|
||||
const changedSectionIds = new Set(
|
||||
payload.sections
|
||||
.map((section) => section.sectionId.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
|
||||
await params.sessionStore.appendCheckpoint(userId, sessionId, {
|
||||
label: `编辑 ${updatedDetail?.title || '草稿卡'}`,
|
||||
snapshot: buildCheckpointSnapshot(latestSession, nextState),
|
||||
});
|
||||
await params.sessionStore.appendMessage(
|
||||
userId,
|
||||
sessionId,
|
||||
buildActionResultMessage({
|
||||
relatedOperationId: operationId,
|
||||
text: params.changeSummaryService.buildSummary({
|
||||
action: 'update_draft_card',
|
||||
cardId: payload.cardId,
|
||||
changedLabels:
|
||||
updatedDetail?.sections
|
||||
.filter((section) => changedSectionIds.has(section.id))
|
||||
.map((section) => section.label) ?? [],
|
||||
draftProfile: nextDraftProfile,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await updateOperation({
|
||||
status: 'completed',
|
||||
phaseLabel: '草稿设定已保存',
|
||||
phaseDetail: `「${updatedDetail?.title || '当前卡片'}」的设定已经同步更新。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateOperation({
|
||||
status: 'failed',
|
||||
phaseLabel: '保存失败',
|
||||
phaseDetail: '这次草稿编辑没有成功写回到底稿。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'update draft card failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
260
server-node/src/services/customWorldAgentActionRegistry.test.ts
Normal file
260
server-node/src/services/customWorldAgentActionRegistry.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldAgentActionExecutorMap } from './customWorldAgentActionExecutors/index.js';
|
||||
import { CustomWorldAgentActionRegistry } from './customWorldAgentActionRegistry.js';
|
||||
import { createRpgAgentSessionFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
|
||||
|
||||
function createExecutorLog() {
|
||||
const calls: Array<{
|
||||
action: keyof CustomWorldAgentActionExecutorMap;
|
||||
payload: unknown;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
}> = [];
|
||||
|
||||
const createExecutor = <K extends keyof CustomWorldAgentActionExecutorMap>(
|
||||
action: K,
|
||||
): CustomWorldAgentActionExecutorMap[K] => {
|
||||
return (async (params) => {
|
||||
calls.push({
|
||||
action,
|
||||
payload: params.payload,
|
||||
userId: params.userId,
|
||||
sessionId: params.sessionId,
|
||||
operationId: params.operationId,
|
||||
});
|
||||
}) as CustomWorldAgentActionExecutorMap[K];
|
||||
};
|
||||
|
||||
return {
|
||||
calls,
|
||||
executors: {
|
||||
draft_foundation: createExecutor('draft_foundation'),
|
||||
update_draft_card: createExecutor('update_draft_card'),
|
||||
sync_result_profile: createExecutor('sync_result_profile'),
|
||||
generate_characters: createExecutor('generate_characters'),
|
||||
generate_landmarks: createExecutor('generate_landmarks'),
|
||||
generate_role_assets: createExecutor('generate_role_assets'),
|
||||
sync_role_assets: createExecutor('sync_role_assets'),
|
||||
generate_scene_assets: createExecutor('generate_scene_assets'),
|
||||
sync_scene_assets: createExecutor('sync_scene_assets'),
|
||||
expand_long_tail: createExecutor('expand_long_tail'),
|
||||
publish_world: createExecutor('publish_world'),
|
||||
revert_checkpoint: createExecutor('revert_checkpoint'),
|
||||
} satisfies CustomWorldAgentActionExecutorMap,
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionRecord(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const session = createRpgAgentSessionFixture();
|
||||
|
||||
return {
|
||||
...JSON.parse(JSON.stringify(session)),
|
||||
userId: 'fixture-user',
|
||||
seedText: '被海雾吞没的旧航路群岛',
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: session.updatedAt,
|
||||
updatedAt: session.updatedAt,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('action registry exposes supported actions with stage-aware enablement and disabled reasons', () => {
|
||||
const { executors } = createExecutorLog();
|
||||
const registry = new CustomWorldAgentActionRegistry(executors);
|
||||
const session = createSessionRecord({
|
||||
stage: 'foundation_review',
|
||||
progressPercent: 80,
|
||||
});
|
||||
const supportedActions = registry.buildSupportedActions(session as never);
|
||||
const draftFoundation = supportedActions.find(
|
||||
(entry) => entry.action === 'draft_foundation',
|
||||
);
|
||||
const syncResultProfile = supportedActions.find(
|
||||
(entry) => entry.action === 'sync_result_profile',
|
||||
);
|
||||
const publishWorld = supportedActions.find(
|
||||
(entry) => entry.action === 'publish_world',
|
||||
);
|
||||
const expandLongTail = supportedActions.find(
|
||||
(entry) => entry.action === 'expand_long_tail',
|
||||
);
|
||||
const revertCheckpoint = supportedActions.find(
|
||||
(entry) => entry.action === 'revert_checkpoint',
|
||||
);
|
||||
|
||||
assert.equal(draftFoundation?.enabled, false);
|
||||
assert.match(draftFoundation?.reason ?? '', /progressPercent >= 100/u);
|
||||
assert.equal(syncResultProfile?.enabled, false);
|
||||
assert.match(
|
||||
syncResultProfile?.reason ?? '',
|
||||
/object_refining or visual_refining/u,
|
||||
);
|
||||
assert.equal(publishWorld?.enabled, false);
|
||||
assert.match(
|
||||
publishWorld?.reason ?? '',
|
||||
/object_refining, visual_refining, long_tail_review or ready_to_publish/u,
|
||||
);
|
||||
assert.equal(expandLongTail?.enabled, false);
|
||||
assert.match(
|
||||
expandLongTail?.reason ?? '',
|
||||
/object_refining, visual_refining, long_tail_review or ready_to_publish/u,
|
||||
);
|
||||
assert.equal(revertCheckpoint?.enabled, false);
|
||||
assert.match(
|
||||
revertCheckpoint?.reason ?? '',
|
||||
/requires at least one restorable checkpoint snapshot/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('action registry enables long-tail and publish actions in late stages, and exposes revert when restorable checkpoint exists', () => {
|
||||
const { executors } = createExecutorLog();
|
||||
const registry = new CustomWorldAgentActionRegistry(executors);
|
||||
const session = createSessionRecord({
|
||||
stage: 'ready_to_publish',
|
||||
checkpoints: [
|
||||
{
|
||||
checkpointId: 'checkpoint-1',
|
||||
createdAt: '2026-04-21T12:00:00.000Z',
|
||||
label: '可回滚版本',
|
||||
snapshot: {
|
||||
currentTurn: 2,
|
||||
anchorContent: createSessionRecord().anchorContent,
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '已生成草稿。',
|
||||
stage: 'object_refining',
|
||||
focusCardId: 'world-foundation',
|
||||
creatorIntent: {},
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: {},
|
||||
lockState: {},
|
||||
draftProfile: createSessionRecord().draftProfile,
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
draftCards: createSessionRecord().draftCards,
|
||||
qualityFindings: [],
|
||||
assetCoverage: createSessionRecord().assetCoverage,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const supportedActions = registry.buildSupportedActions(session as never);
|
||||
|
||||
assert.equal(
|
||||
supportedActions.find((entry) => entry.action === 'expand_long_tail')?.enabled,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
supportedActions.find((entry) => entry.action === 'publish_world')?.enabled,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
supportedActions.find((entry) => entry.action === 'revert_checkpoint')?.enabled,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('action registry validates sync_scene_assets required payload and dispatches scene action executors', async () => {
|
||||
const { calls, executors } = createExecutorLog();
|
||||
const registry = new CustomWorldAgentActionRegistry(executors);
|
||||
const session = createSessionRecord({
|
||||
stage: 'visual_refining',
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.prepareExecution(session as never, {
|
||||
action: 'sync_scene_assets',
|
||||
sceneId: 'camp-home',
|
||||
sceneKind: 'camp',
|
||||
imageSrc: '',
|
||||
generatedSceneAssetId: 'scene-asset-1',
|
||||
}),
|
||||
/imageSrc and generatedSceneAssetId/u,
|
||||
);
|
||||
|
||||
const prepared = registry.prepareExecution(session as never, {
|
||||
action: 'generate_scene_assets',
|
||||
sceneIds: ['camp-home'],
|
||||
});
|
||||
|
||||
assert.equal(prepared.operationType, 'generate_scene_assets');
|
||||
|
||||
await prepared.execute({
|
||||
userId: 'fixture-user',
|
||||
sessionId: 'fixture-session',
|
||||
operationId: 'operation-scene-1',
|
||||
});
|
||||
|
||||
assert.equal(calls.at(-1)?.action, 'generate_scene_assets');
|
||||
});
|
||||
|
||||
test('action registry normalizes sync_result_profile payload before dispatching executor', async () => {
|
||||
const { calls, executors } = createExecutorLog();
|
||||
const registry = new CustomWorldAgentActionRegistry(executors);
|
||||
const session = createSessionRecord({
|
||||
stage: 'object_refining',
|
||||
});
|
||||
const prepared = registry.prepareExecution(session as never, {
|
||||
action: 'sync_result_profile',
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
settingText: '潮雾列岛',
|
||||
name: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '结果页确认版。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清真相。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会'],
|
||||
coreConflicts: ['争夺旧航路控制权'],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prepared.operationType, 'sync_result_profile');
|
||||
|
||||
await prepared.execute({
|
||||
userId: 'fixture-user',
|
||||
sessionId: 'fixture-session',
|
||||
operationId: 'operation-1',
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.action, 'sync_result_profile');
|
||||
assert.equal(
|
||||
(calls[0]?.payload as { profile?: { name?: string } })?.profile?.name,
|
||||
'潮雾列岛',
|
||||
);
|
||||
});
|
||||
|
||||
test('action registry rejects invalid generate_role_assets payload with unit-level validation', () => {
|
||||
const { executors } = createExecutorLog();
|
||||
const registry = new CustomWorldAgentActionRegistry(executors);
|
||||
const session = createSessionRecord({
|
||||
stage: 'object_refining',
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
registry.prepareExecution(session as never, {
|
||||
action: 'generate_role_assets',
|
||||
roleIds: ['playable-1', 'story-1'],
|
||||
}),
|
||||
/exactly one roleId/u,
|
||||
);
|
||||
});
|
||||
403
server-node/src/services/customWorldAgentActionRegistry.ts
Normal file
403
server-node/src/services/customWorldAgentActionRegistry.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import type {
|
||||
CustomWorldAgentActionRequest,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldSupportedAction,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import { normalizeCustomWorldProfile } from '../modules/custom-world/runtimeProfile.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import type {
|
||||
CustomWorldAgentActionExecutorMap,
|
||||
CustomWorldAgentActionPayload,
|
||||
} from './customWorldAgentActionExecutors/index.js';
|
||||
import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js';
|
||||
|
||||
type EnabledAction = keyof CustomWorldAgentActionExecutorMap;
|
||||
type EnabledDescriptor<K extends EnabledAction> = {
|
||||
operationType: CustomWorldAgentOperationRecord['type'];
|
||||
normalizePayload?: (
|
||||
payload: CustomWorldAgentActionPayload<K>,
|
||||
) => CustomWorldAgentActionPayload<K>;
|
||||
validate?: (
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
payload: CustomWorldAgentActionPayload<K>,
|
||||
) => void;
|
||||
execute: CustomWorldAgentActionExecutorMap[K];
|
||||
};
|
||||
type DisabledAction = Exclude<CustomWorldAgentActionRequest['action'], EnabledAction>;
|
||||
type DisabledDescriptor = {
|
||||
disabledReason: string;
|
||||
};
|
||||
|
||||
type ActionCapabilityState = {
|
||||
enabled: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
function assertDraftRefiningActionAvailable(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
action: string,
|
||||
) {
|
||||
if (
|
||||
session.stage !== 'object_refining' &&
|
||||
session.stage !== 'visual_refining'
|
||||
) {
|
||||
throw badRequest(
|
||||
`${action} is only available during object_refining or visual_refining`,
|
||||
);
|
||||
}
|
||||
|
||||
const hasDraftFoundation = Boolean(
|
||||
normalizeFoundationDraftProfile(session.draftProfile) &&
|
||||
session.draftCards.length > 0,
|
||||
);
|
||||
if (!hasDraftFoundation) {
|
||||
throw badRequest(`${action} requires an existing draft foundation`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertLongTailActionAvailable(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
action: string,
|
||||
) {
|
||||
if (
|
||||
session.stage !== 'object_refining' &&
|
||||
session.stage !== 'visual_refining' &&
|
||||
session.stage !== 'long_tail_review' &&
|
||||
session.stage !== 'ready_to_publish'
|
||||
) {
|
||||
throw badRequest(
|
||||
`${action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPublishActionAvailable(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
action: string,
|
||||
) {
|
||||
assertLongTailActionAvailable(session, action);
|
||||
if (!normalizeFoundationDraftProfile(session.draftProfile)) {
|
||||
throw badRequest(`${action} requires an existing draft foundation`);
|
||||
}
|
||||
}
|
||||
|
||||
export type PreparedCustomWorldAgentActionExecution = {
|
||||
operationType: CustomWorldAgentOperationRecord['type'];
|
||||
execute: (params: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
export class CustomWorldAgentActionRegistry {
|
||||
private readonly descriptors: Record<
|
||||
CustomWorldAgentActionRequest['action'],
|
||||
EnabledDescriptor<EnabledAction> | DisabledDescriptor
|
||||
>;
|
||||
|
||||
constructor(executors: CustomWorldAgentActionExecutorMap) {
|
||||
this.descriptors = {
|
||||
draft_foundation: {
|
||||
operationType: 'draft_foundation',
|
||||
validate: (session) => {
|
||||
if (session.progressPercent < 100) {
|
||||
throw badRequest('draft_foundation requires progressPercent >= 100');
|
||||
}
|
||||
},
|
||||
execute: executors.draft_foundation,
|
||||
},
|
||||
update_draft_card: {
|
||||
operationType: 'update_draft_card',
|
||||
validate: (session, payload) => {
|
||||
assertDraftRefiningActionAvailable(session, payload.action);
|
||||
if (!payload.cardId.trim()) {
|
||||
throw badRequest('update_draft_card requires cardId');
|
||||
}
|
||||
if (!Array.isArray(payload.sections) || payload.sections.length === 0) {
|
||||
throw badRequest('update_draft_card requires sections');
|
||||
}
|
||||
},
|
||||
execute: executors.update_draft_card,
|
||||
},
|
||||
sync_result_profile: {
|
||||
operationType: 'sync_result_profile',
|
||||
normalizePayload: (payload) => {
|
||||
const normalizedProfile = normalizeCustomWorldProfile(payload.profile, '');
|
||||
if (!normalizedProfile) {
|
||||
throw badRequest('sync_result_profile requires a valid profile');
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
profile: normalizedProfile as unknown as Record<string, unknown>,
|
||||
};
|
||||
},
|
||||
validate: (session, payload) => {
|
||||
assertDraftRefiningActionAvailable(session, payload.action);
|
||||
},
|
||||
execute: executors.sync_result_profile,
|
||||
},
|
||||
generate_characters: {
|
||||
operationType: 'generate_characters',
|
||||
validate: (session, payload) => {
|
||||
assertDraftRefiningActionAvailable(session, payload.action);
|
||||
if (payload.count < 1 || payload.count > 3) {
|
||||
throw badRequest(
|
||||
'generate_characters count must be between 1 and 3',
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: executors.generate_characters,
|
||||
},
|
||||
generate_landmarks: {
|
||||
operationType: 'generate_landmarks',
|
||||
validate: (session, payload) => {
|
||||
assertDraftRefiningActionAvailable(session, payload.action);
|
||||
if (payload.count < 1 || payload.count > 3) {
|
||||
throw badRequest(
|
||||
'generate_landmarks count must be between 1 and 3',
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: executors.generate_landmarks,
|
||||
},
|
||||
generate_role_assets: {
|
||||
operationType: 'generate_role_assets',
|
||||
validate: (session, payload) => {
|
||||
assertDraftRefiningActionAvailable(session, payload.action);
|
||||
if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) {
|
||||
throw badRequest(
|
||||
'generate_role_assets currently requires exactly one roleId',
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: executors.generate_role_assets,
|
||||
},
|
||||
sync_role_assets: {
|
||||
operationType: 'sync_role_assets',
|
||||
validate: (session, payload) => {
|
||||
assertDraftRefiningActionAvailable(session, payload.action);
|
||||
if (!payload.roleId.trim()) {
|
||||
throw badRequest('sync_role_assets requires roleId');
|
||||
}
|
||||
if (
|
||||
!payload.portraitPath.trim() ||
|
||||
!payload.generatedVisualAssetId.trim()
|
||||
) {
|
||||
throw badRequest(
|
||||
'sync_role_assets requires portraitPath and generatedVisualAssetId',
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: executors.sync_role_assets,
|
||||
},
|
||||
generate_scene_assets: {
|
||||
operationType: 'generate_scene_assets',
|
||||
validate: (session, payload) => {
|
||||
assertDraftRefiningActionAvailable(session, payload.action);
|
||||
if (!Array.isArray(payload.sceneIds) || payload.sceneIds.length !== 1) {
|
||||
throw badRequest(
|
||||
'generate_scene_assets currently requires exactly one sceneId',
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: executors.generate_scene_assets,
|
||||
},
|
||||
sync_scene_assets: {
|
||||
operationType: 'sync_scene_assets',
|
||||
validate: (session, payload) => {
|
||||
assertDraftRefiningActionAvailable(session, payload.action);
|
||||
if (!payload.sceneId.trim()) {
|
||||
throw badRequest('sync_scene_assets requires sceneId');
|
||||
}
|
||||
if (!payload.imageSrc.trim() || !payload.generatedSceneAssetId.trim()) {
|
||||
throw badRequest(
|
||||
'sync_scene_assets requires imageSrc and generatedSceneAssetId',
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: executors.sync_scene_assets,
|
||||
},
|
||||
expand_long_tail: {
|
||||
operationType: 'expand_long_tail',
|
||||
validate: (session, payload) => {
|
||||
assertLongTailActionAvailable(session, payload.action);
|
||||
if (!normalizeFoundationDraftProfile(session.draftProfile)) {
|
||||
throw badRequest('expand_long_tail requires an existing draft foundation');
|
||||
}
|
||||
},
|
||||
execute: executors.expand_long_tail,
|
||||
},
|
||||
publish_world: {
|
||||
operationType: 'publish_world',
|
||||
validate: (session, payload) => {
|
||||
assertPublishActionAvailable(session, payload.action);
|
||||
},
|
||||
execute: executors.publish_world,
|
||||
},
|
||||
revert_checkpoint: {
|
||||
operationType: 'revert_checkpoint',
|
||||
validate: (session, payload) => {
|
||||
assertLongTailActionAvailable(session, payload.action);
|
||||
if (!payload.checkpointId.trim()) {
|
||||
throw badRequest('revert_checkpoint requires checkpointId');
|
||||
}
|
||||
const checkpoint = session.checkpoints.find(
|
||||
(entry) => entry.checkpointId === payload.checkpointId,
|
||||
);
|
||||
if (!checkpoint) {
|
||||
throw badRequest('revert_checkpoint target checkpoint does not exist');
|
||||
}
|
||||
if (!checkpoint.snapshot) {
|
||||
throw badRequest(
|
||||
'revert_checkpoint target checkpoint does not contain a restorable snapshot',
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: executors.revert_checkpoint,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// orchestrator 只关心“拿到一个已校验的动作执行计划”,不再自己维护所有 action 分支。
|
||||
prepareExecution(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
payload: CustomWorldAgentActionRequest,
|
||||
): PreparedCustomWorldAgentActionExecution {
|
||||
const descriptor = this.descriptors[payload.action];
|
||||
if ('disabledReason' in descriptor) {
|
||||
throw badRequest(descriptor.disabledReason);
|
||||
}
|
||||
|
||||
const normalizedPayload = descriptor.normalizePayload
|
||||
? descriptor.normalizePayload(payload as never)
|
||||
: payload;
|
||||
|
||||
descriptor.validate?.(session, normalizedPayload as never);
|
||||
|
||||
return {
|
||||
operationType: descriptor.operationType,
|
||||
execute: ({ userId, sessionId, operationId }) =>
|
||||
descriptor.execute({
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
payload: normalizedPayload as never,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
buildSupportedActions(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
): CustomWorldSupportedAction[] {
|
||||
return (
|
||||
Object.entries(this.descriptors) as Array<
|
||||
[
|
||||
CustomWorldAgentActionRequest['action'],
|
||||
EnabledDescriptor<EnabledAction> | DisabledDescriptor,
|
||||
]
|
||||
>
|
||||
).map(([action, descriptor]) => {
|
||||
const capability = this.resolveCapabilityState(session, action, descriptor);
|
||||
|
||||
return {
|
||||
action,
|
||||
enabled: capability.enabled,
|
||||
reason: capability.reason ?? null,
|
||||
} satisfies CustomWorldSupportedAction;
|
||||
});
|
||||
}
|
||||
|
||||
private resolveCapabilityState(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
action: CustomWorldAgentActionRequest['action'],
|
||||
descriptor: EnabledDescriptor<EnabledAction> | DisabledDescriptor,
|
||||
): ActionCapabilityState {
|
||||
if ('disabledReason' in descriptor) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: descriptor.disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'draft_foundation') {
|
||||
return session.progressPercent >= 100
|
||||
? { enabled: true }
|
||||
: {
|
||||
enabled: false,
|
||||
reason: 'draft_foundation requires progressPercent >= 100',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
action === 'update_draft_card' ||
|
||||
action === 'sync_result_profile' ||
|
||||
action === 'generate_characters' ||
|
||||
action === 'generate_landmarks' ||
|
||||
action === 'generate_role_assets' ||
|
||||
action === 'sync_role_assets' ||
|
||||
action === 'generate_scene_assets' ||
|
||||
action === 'sync_scene_assets'
|
||||
) {
|
||||
try {
|
||||
assertDraftRefiningActionAvailable(session, action);
|
||||
return { enabled: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: error instanceof Error ? error.message : 'action unavailable',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'expand_long_tail') {
|
||||
try {
|
||||
assertLongTailActionAvailable(session, action);
|
||||
return { enabled: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: error instanceof Error ? error.message : 'action unavailable',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'publish_world') {
|
||||
try {
|
||||
assertPublishActionAvailable(session, action);
|
||||
return { enabled: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: error instanceof Error ? error.message : 'action unavailable',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'revert_checkpoint') {
|
||||
const restorableCheckpoint = session.checkpoints.find(
|
||||
(entry) => Boolean(entry.snapshot),
|
||||
);
|
||||
if (!restorableCheckpoint) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: 'revert_checkpoint requires at least one restorable checkpoint snapshot',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
assertLongTailActionAvailable(session, action);
|
||||
return { enabled: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: error instanceof Error ? error.message : 'action unavailable',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { enabled: true };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { CustomWorldRoleAssetSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type {
|
||||
CustomWorldRoleAssetSummary,
|
||||
CustomWorldSceneAssetSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
getRoleAssetSummaryById,
|
||||
rebuildRoleAssetCoverage,
|
||||
mergeRoleAssetIntoDraftProfile,
|
||||
} from './customWorldAgentRoleAssetStateService.js';
|
||||
|
||||
@@ -31,6 +35,17 @@ type SyncRoleAssetsPayload = {
|
||||
animationMap?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type SceneKind = 'camp' | 'landmark';
|
||||
|
||||
type SyncSceneAssetsPayload = {
|
||||
sceneId: string;
|
||||
sceneKind: SceneKind;
|
||||
imageSrc: string;
|
||||
generatedSceneAssetId: string;
|
||||
generatedScenePrompt?: string | null;
|
||||
generatedSceneModel?: string | null;
|
||||
};
|
||||
|
||||
export type SyncRoleAssetsResult = {
|
||||
roleId: string;
|
||||
updatedRole: Record<string, unknown>;
|
||||
@@ -38,6 +53,97 @@ export type SyncRoleAssetsResult = {
|
||||
draftProfile: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SceneAssetStudioContext = {
|
||||
sceneId: string;
|
||||
sceneKind: SceneKind;
|
||||
sceneName: string;
|
||||
sceneDescription: string;
|
||||
imageSrc: string | null;
|
||||
readyActCount: number;
|
||||
missingActCount: number;
|
||||
};
|
||||
|
||||
export type SyncSceneAssetsResult = {
|
||||
sceneId: string;
|
||||
sceneKind: SceneKind;
|
||||
updatedScene: Record<string, unknown>;
|
||||
updatedAssetSummaries: CustomWorldSceneAssetSummary[];
|
||||
draftProfile: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function cloneRecord<T extends Record<string, unknown>>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function toSceneDescription(scene: Record<string, unknown>, sceneKind: SceneKind) {
|
||||
if (sceneKind === 'camp') {
|
||||
return (
|
||||
toText(scene.description) ||
|
||||
toText(scene.summary) ||
|
||||
toText(scene.mood)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
toText(scene.description) ||
|
||||
toText(scene.summary) ||
|
||||
toText(scene.purpose) ||
|
||||
toText(scene.mood)
|
||||
);
|
||||
}
|
||||
|
||||
function findSceneActsBySceneId(
|
||||
draftProfile: Record<string, unknown>,
|
||||
sceneId: string,
|
||||
) {
|
||||
return toRecordArray(draftProfile.sceneChapters)
|
||||
.filter((chapter) => toText(chapter.sceneId) === sceneId)
|
||||
.flatMap((chapter) => toRecordArray(chapter.acts));
|
||||
}
|
||||
|
||||
function updateSceneChapterActsForScene(params: {
|
||||
draftProfile: Record<string, unknown>;
|
||||
sceneId: string;
|
||||
imageSrc: string;
|
||||
generatedSceneAssetId: string;
|
||||
}) {
|
||||
return toRecordArray(params.draftProfile.sceneChapters).map((chapter) => {
|
||||
if (toText(chapter.sceneId) !== params.sceneId) {
|
||||
return chapter;
|
||||
}
|
||||
|
||||
return {
|
||||
...chapter,
|
||||
acts: toRecordArray(chapter.acts).map((act) => ({
|
||||
...act,
|
||||
backgroundImageSrc: params.imageSrc,
|
||||
backgroundAssetId: params.generatedSceneAssetId,
|
||||
})),
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
function buildSceneAssetFallbackSummary(params: {
|
||||
sceneId: string;
|
||||
sceneKind: SceneKind;
|
||||
updatedScene: Record<string, unknown>;
|
||||
imageSrc: string;
|
||||
generatedSceneAssetId: string;
|
||||
}) {
|
||||
return {
|
||||
sceneId: params.sceneId,
|
||||
sceneName:
|
||||
toText(params.updatedScene.name) ||
|
||||
(params.sceneKind === 'camp' ? '开局营地' : '未命名场景'),
|
||||
actId: null,
|
||||
actTitle: params.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图',
|
||||
imageSrc: params.imageSrc,
|
||||
assetId: params.generatedSceneAssetId,
|
||||
status: 'ready',
|
||||
nextPointCost: 0,
|
||||
} satisfies CustomWorldSceneAssetSummary;
|
||||
}
|
||||
|
||||
export class CustomWorldAgentAssetBridgeService {
|
||||
buildRoleAssetStudioContext(snapshot: unknown, roleId: string) {
|
||||
const profile = toRecord(snapshot);
|
||||
@@ -96,4 +202,123 @@ export class CustomWorldAgentAssetBridgeService {
|
||||
draftProfile,
|
||||
};
|
||||
}
|
||||
|
||||
buildSceneAssetStudioContext(
|
||||
snapshot: unknown,
|
||||
sceneId: string,
|
||||
sceneKind: SceneKind,
|
||||
): SceneAssetStudioContext {
|
||||
const profile = toRecord(snapshot);
|
||||
if (!profile) {
|
||||
throw new Error('当前世界草稿为空,无法打开场景资产工坊。');
|
||||
}
|
||||
|
||||
const scene =
|
||||
sceneKind === 'camp'
|
||||
? toRecord(profile.camp)
|
||||
: toRecordArray(profile.landmarks).find(
|
||||
(item) => toText(item.id) === sceneId,
|
||||
) ?? null;
|
||||
if (!scene) {
|
||||
throw new Error('未找到目标场景,无法进入场景资产工坊。');
|
||||
}
|
||||
|
||||
const sceneActs = findSceneActsBySceneId(profile, sceneId);
|
||||
const readyActCount = sceneActs.filter((act) =>
|
||||
Boolean(toText(act.backgroundImageSrc) || toText(act.backgroundAssetId)),
|
||||
).length;
|
||||
|
||||
return {
|
||||
sceneId,
|
||||
sceneKind,
|
||||
sceneName:
|
||||
toText(scene.name) || (sceneKind === 'camp' ? '开局营地' : '未命名场景'),
|
||||
sceneDescription: toSceneDescription(scene, sceneKind),
|
||||
imageSrc: toText(scene.imageSrc) || null,
|
||||
readyActCount,
|
||||
missingActCount: Math.max(0, sceneActs.length - readyActCount),
|
||||
};
|
||||
}
|
||||
|
||||
applySceneAssetPublishResult(
|
||||
snapshot: unknown,
|
||||
payload: SyncSceneAssetsPayload,
|
||||
): SyncSceneAssetsResult {
|
||||
const profile = toRecord(snapshot);
|
||||
if (!profile) {
|
||||
throw new Error('当前世界草稿为空,无法同步场景资产。');
|
||||
}
|
||||
|
||||
const nextDraftProfile = cloneRecord(profile);
|
||||
let updatedScene: Record<string, unknown> | null = null;
|
||||
|
||||
if (payload.sceneKind === 'camp') {
|
||||
const currentCamp = toRecord(nextDraftProfile.camp);
|
||||
if (!currentCamp || toText(currentCamp.id) !== payload.sceneId) {
|
||||
throw new Error('目标营地不存在,无法同步场景资产。');
|
||||
}
|
||||
|
||||
updatedScene = {
|
||||
...currentCamp,
|
||||
imageSrc: payload.imageSrc,
|
||||
generatedSceneAssetId: payload.generatedSceneAssetId,
|
||||
generatedScenePrompt: payload.generatedScenePrompt ?? null,
|
||||
generatedSceneModel: payload.generatedSceneModel ?? null,
|
||||
};
|
||||
nextDraftProfile.camp = updatedScene;
|
||||
} else {
|
||||
let touched = false;
|
||||
nextDraftProfile.landmarks = toRecordArray(nextDraftProfile.landmarks).map(
|
||||
(item) => {
|
||||
if (toText(item.id) !== payload.sceneId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
touched = true;
|
||||
updatedScene = {
|
||||
...item,
|
||||
imageSrc: payload.imageSrc,
|
||||
generatedSceneAssetId: payload.generatedSceneAssetId,
|
||||
generatedScenePrompt: payload.generatedScenePrompt ?? null,
|
||||
generatedSceneModel: payload.generatedSceneModel ?? null,
|
||||
};
|
||||
return updatedScene;
|
||||
},
|
||||
);
|
||||
|
||||
if (!touched || !updatedScene) {
|
||||
throw new Error('目标地点不存在,无法同步场景资产。');
|
||||
}
|
||||
}
|
||||
|
||||
nextDraftProfile.sceneChapters = updateSceneChapterActsForScene({
|
||||
draftProfile: nextDraftProfile,
|
||||
sceneId: payload.sceneId,
|
||||
imageSrc: payload.imageSrc,
|
||||
generatedSceneAssetId: payload.generatedSceneAssetId,
|
||||
});
|
||||
|
||||
const updatedAssetSummaries = rebuildRoleAssetCoverage(
|
||||
nextDraftProfile,
|
||||
).sceneAssets.filter((entry) => entry.sceneId === payload.sceneId);
|
||||
|
||||
return {
|
||||
sceneId: payload.sceneId,
|
||||
sceneKind: payload.sceneKind,
|
||||
updatedScene: updatedScene ?? {},
|
||||
updatedAssetSummaries:
|
||||
updatedAssetSummaries.length > 0
|
||||
? updatedAssetSummaries
|
||||
: [
|
||||
buildSceneAssetFallbackSummary({
|
||||
sceneId: payload.sceneId,
|
||||
sceneKind: payload.sceneKind,
|
||||
updatedScene: updatedScene ?? {},
|
||||
imageSrc: payload.imageSrc,
|
||||
generatedSceneAssetId: payload.generatedSceneAssetId,
|
||||
}),
|
||||
],
|
||||
draftProfile: nextDraftProfile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,11 @@ function createTestConfig(testName: string): AppConfig {
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
accessCookieName: 'genarrative_access_session',
|
||||
accessCookieTtlSeconds: 7200,
|
||||
accessCookieSecure: false,
|
||||
accessCookieSameSite: 'Lax',
|
||||
accessCookiePath: '/',
|
||||
refreshCookieName: 'refresh_token',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
|
||||
@@ -377,6 +377,9 @@ function normalizeLandmark(
|
||||
secret: secret || '玩家第一次抵达就会意识到它不只是背景',
|
||||
dangerLevel: dangerLevel || '中',
|
||||
imageSrc: toText(record.imageSrc) || null,
|
||||
generatedSceneAssetId: toText(record.generatedSceneAssetId) || null,
|
||||
generatedScenePrompt: toText(record.generatedScenePrompt) || null,
|
||||
generatedSceneModel: toText(record.generatedSceneModel) || null,
|
||||
characterIds: toStringArray(record.characterIds, 8),
|
||||
threadIds: toStringArray(record.threadIds, 8),
|
||||
summary:
|
||||
@@ -501,6 +504,9 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
|
||||
mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
|
||||
dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
|
||||
imageSrc: toText(record.imageSrc) || null,
|
||||
generatedSceneAssetId: toText(record.generatedSceneAssetId) || null,
|
||||
generatedScenePrompt: toText(record.generatedScenePrompt) || null,
|
||||
generatedSceneModel: toText(record.generatedSceneModel) || null,
|
||||
summary:
|
||||
summary ||
|
||||
clampText(
|
||||
@@ -1060,6 +1066,9 @@ function buildLandmarkWarnings(landmark: CustomWorldFoundationDraftLandmark) {
|
||||
if (landmark.threadIds.length === 0) {
|
||||
warnings.push('这个地点还缺少更清楚的线程挂钩。');
|
||||
}
|
||||
if (!landmark.imageSrc || !landmark.generatedSceneAssetId) {
|
||||
warnings.push('这个地点还没有绑定正式场景图。');
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -1163,8 +1172,12 @@ function buildSceneChapterWarnings(params: {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function buildCampWarnings() {
|
||||
return [] as string[];
|
||||
function buildCampWarnings(camp: CustomWorldFoundationDraftCamp) {
|
||||
const warnings: string[] = [];
|
||||
if (!camp.imageSrc || !camp.generatedSceneAssetId) {
|
||||
warnings.push('营地还没有绑定正式场景图。');
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharacter) {
|
||||
@@ -1332,12 +1345,22 @@ export class CustomWorldAgentDraftCompiler {
|
||||
});
|
||||
|
||||
if (profile.camp) {
|
||||
const campWarnings = buildCampWarnings();
|
||||
const campWarnings = buildCampWarnings(profile.camp);
|
||||
pushCard({
|
||||
id: profile.camp.id,
|
||||
kind: 'camp',
|
||||
title: profile.camp.name,
|
||||
subtitle: clampText(profile.camp.mood || '开局落脚处', 28),
|
||||
subtitle: clampText(
|
||||
[
|
||||
profile.camp.mood || '开局落脚处',
|
||||
profile.camp.imageSrc && profile.camp.generatedSceneAssetId
|
||||
? '背景图已就绪'
|
||||
: '待生成背景图',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
28,
|
||||
),
|
||||
summary: profile.camp.summary,
|
||||
linkedIds: [
|
||||
...profile.landmarks.slice(0, 2).map((entry) => entry.id),
|
||||
@@ -1347,14 +1370,21 @@ export class CustomWorldAgentDraftCompiler {
|
||||
sections: [
|
||||
buildSection('name', '营地名称', profile.camp.name),
|
||||
buildSection('description', '当前定位', profile.camp.description),
|
||||
buildSection(
|
||||
'dangerLevel',
|
||||
'危险等级',
|
||||
profile.camp.dangerLevel || profile.camp.mood,
|
||||
),
|
||||
buildSection(
|
||||
'linkedObjects',
|
||||
'关联对象',
|
||||
buildSection(
|
||||
'dangerLevel',
|
||||
'危险等级',
|
||||
profile.camp.dangerLevel || profile.camp.mood,
|
||||
),
|
||||
buildSection(
|
||||
'sceneAsset',
|
||||
'场景资产',
|
||||
profile.camp.imageSrc || profile.camp.generatedSceneAssetId
|
||||
? '正式场景图已就绪'
|
||||
: '待生成正式场景图',
|
||||
),
|
||||
buildSection(
|
||||
'linkedObjects',
|
||||
'关联对象',
|
||||
[
|
||||
resolveLandmarkNames(
|
||||
profile.landmarks.slice(0, 2).map((entry) => entry.id),
|
||||
@@ -1490,22 +1520,39 @@ export class CustomWorldAgentDraftCompiler {
|
||||
|
||||
profile.landmarks.forEach((landmark) => {
|
||||
const warnings = buildLandmarkWarnings(landmark);
|
||||
pushCard({
|
||||
id: landmark.id,
|
||||
kind: 'landmark',
|
||||
title: landmark.name,
|
||||
subtitle: clampText(landmark.purpose || landmark.mood, 28),
|
||||
summary: landmark.summary,
|
||||
linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8),
|
||||
sections: [
|
||||
buildSection('name', '地点名', landmark.name),
|
||||
buildSection('purpose', '地点定位', landmark.purpose),
|
||||
buildSection('mood', '场景情绪', landmark.mood),
|
||||
buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance),
|
||||
buildSection('summary', '地点摘要', landmark.summary),
|
||||
buildSection(
|
||||
'characterIds',
|
||||
'关联角色',
|
||||
pushCard({
|
||||
id: landmark.id,
|
||||
kind: 'landmark',
|
||||
title: landmark.name,
|
||||
subtitle: clampText(
|
||||
[
|
||||
landmark.purpose || landmark.mood,
|
||||
landmark.imageSrc && landmark.generatedSceneAssetId
|
||||
? '背景图已就绪'
|
||||
: '待生成背景图',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
28,
|
||||
),
|
||||
summary: landmark.summary,
|
||||
linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8),
|
||||
sections: [
|
||||
buildSection('name', '地点名', landmark.name),
|
||||
buildSection('purpose', '地点定位', landmark.purpose),
|
||||
buildSection('mood', '场景情绪', landmark.mood),
|
||||
buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance),
|
||||
buildSection('summary', '地点摘要', landmark.summary),
|
||||
buildSection(
|
||||
'sceneAsset',
|
||||
'场景资产',
|
||||
landmark.imageSrc || landmark.generatedSceneAssetId
|
||||
? '正式场景图已就绪'
|
||||
: '待生成正式场景图',
|
||||
),
|
||||
buildSection(
|
||||
'characterIds',
|
||||
'关联角色',
|
||||
resolveCharacterNames(landmark.characterIds),
|
||||
),
|
||||
buildSection(
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
|
||||
function createFoundationDraftLlmClient(): UpstreamLlmClient {
|
||||
let roleOutlineBatch = 0;
|
||||
let landmarkSeedBatch = 0;
|
||||
let landmarkNetworkBatch = 0;
|
||||
let playableNarrativeBatch = 0;
|
||||
let playableDossierBatch = 0;
|
||||
let storyNarrativeBatch = 0;
|
||||
let storyDossierBatch = 0;
|
||||
|
||||
return {
|
||||
requestMessageContent: async (params) => {
|
||||
const debugLabel = params.debugLabel ?? '';
|
||||
|
||||
if (debugLabel === 'agent-foundation-framework') {
|
||||
return JSON.stringify({
|
||||
name: '潮雾列岛',
|
||||
subtitle: '盐火灯塔与失控航路',
|
||||
summary: '潮雾列岛正在被假航灯和沉船商盟重新切开。',
|
||||
tone: '冷峻、潮湿、悬疑',
|
||||
playerGoal: '先确认谁在操盘假航灯,再决定自己站在哪一边。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '沉船商盟'],
|
||||
coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'],
|
||||
camp: {
|
||||
name: '雾湾前哨',
|
||||
description: '玩家在盐火灯塔下方临时收束线索的地方。',
|
||||
dangerLevel: 'medium',
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (debugLabel.startsWith('agent-foundation-playable-outline-batch-')) {
|
||||
roleOutlineBatch += 1;
|
||||
return JSON.stringify({
|
||||
playableNpcs: [
|
||||
{
|
||||
name: '返灯人',
|
||||
title: '失职守灯人',
|
||||
role: '玩家前线身份',
|
||||
description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。',
|
||||
visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。',
|
||||
actionDescription: '先守住灯塔,再判断该不该相信旧友。',
|
||||
sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['这是玩家贴近世界的第一切口'],
|
||||
tags: ['玩家视角', '守灯'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (debugLabel.startsWith('agent-foundation-story-outline-batch-')) {
|
||||
roleOutlineBatch += 1;
|
||||
return JSON.stringify({
|
||||
storyNpcs: [
|
||||
{
|
||||
name: '沈砺',
|
||||
title: '旧友兼宿敌',
|
||||
role: '沉船商盟引路人',
|
||||
description: '他像旧友,也像最早知道假航灯秘密的人。',
|
||||
visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。',
|
||||
actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。',
|
||||
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'],
|
||||
tags: ['旧友', '宿敌'],
|
||||
},
|
||||
{
|
||||
name: '岚珀',
|
||||
title: '守灯会巡夜官',
|
||||
role: '守灯会前台接口人',
|
||||
description: '她负责把守灯会的怀疑与命令直接压到玩家面前。',
|
||||
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
|
||||
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
|
||||
sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['会逼玩家更早站队'],
|
||||
tags: ['守灯会', '巡夜'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (debugLabel.startsWith('agent-foundation-landmark-seed-batch-')) {
|
||||
landmarkSeedBatch += 1;
|
||||
return JSON.stringify({
|
||||
landmarks: [
|
||||
{
|
||||
name: '盐火灯塔',
|
||||
description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。',
|
||||
visualDescription: '塔身被盐霜和旧火痕反复覆盖。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['沈砺', '岚珀'],
|
||||
connections: [],
|
||||
},
|
||||
{
|
||||
name: '沉船码头',
|
||||
description: '假航灯把沉船和黑市都引到了这片雾港。',
|
||||
visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['沈砺'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (debugLabel.startsWith('agent-foundation-landmark-network-batch-')) {
|
||||
landmarkNetworkBatch += 1;
|
||||
return JSON.stringify({
|
||||
landmarks: [
|
||||
{
|
||||
name: '盐火灯塔',
|
||||
description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。',
|
||||
visualDescription: '塔身被盐霜和旧火痕反复覆盖。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['沈砺', '岚珀'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: '沉船码头',
|
||||
relativePosition: 'forward',
|
||||
summary: '顺着残灯下的潮道走,就会被拖进沉船码头。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '沉船码头',
|
||||
description: '假航灯把沉船和黑市都引到了这片雾港。',
|
||||
visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['沈砺'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: '盐火灯塔',
|
||||
relativePosition: 'back',
|
||||
summary: '码头所有线头最终都会重新指回灯塔。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (debugLabel.startsWith('agent-foundation-playable-narrative-batch-')) {
|
||||
playableNarrativeBatch += 1;
|
||||
return JSON.stringify({
|
||||
playableNpcs: [
|
||||
{
|
||||
name: '返灯人',
|
||||
title: '失职守灯人',
|
||||
role: '玩家前线身份',
|
||||
description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。',
|
||||
visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。',
|
||||
actionDescription: '先守住灯塔,再判断该不该相信旧友。',
|
||||
sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。',
|
||||
relationshipHooks: ['这是玩家贴近世界的第一切口'],
|
||||
tags: ['玩家视角', '守灯'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (debugLabel.startsWith('agent-foundation-playable-dossier-batch-')) {
|
||||
playableDossierBatch += 1;
|
||||
return JSON.stringify({
|
||||
playableNpcs: [
|
||||
{
|
||||
name: '返灯人',
|
||||
title: '失职守灯人',
|
||||
role: '玩家前线身份',
|
||||
description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。',
|
||||
visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。',
|
||||
actionDescription: '先守住灯塔,再判断该不该相信旧友。',
|
||||
sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。',
|
||||
relationshipHooks: ['这是玩家贴近世界的第一切口'],
|
||||
tags: ['玩家视角', '守灯'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (debugLabel.startsWith('agent-foundation-story-narrative-batch-')) {
|
||||
storyNarrativeBatch += 1;
|
||||
return JSON.stringify({
|
||||
storyNpcs: [
|
||||
{
|
||||
name: '沈砺',
|
||||
title: '旧友兼宿敌',
|
||||
role: '沉船商盟引路人',
|
||||
description: '他像旧友,也像最早知道假航灯秘密的人。',
|
||||
visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。',
|
||||
actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。',
|
||||
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
|
||||
relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'],
|
||||
tags: ['旧友', '宿敌'],
|
||||
},
|
||||
{
|
||||
name: '岚珀',
|
||||
title: '守灯会巡夜官',
|
||||
role: '守灯会前台接口人',
|
||||
description: '她负责把守灯会的怀疑与命令直接压到玩家面前。',
|
||||
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
|
||||
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
|
||||
sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。',
|
||||
relationshipHooks: ['会逼玩家更早站队'],
|
||||
tags: ['守灯会', '巡夜'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (debugLabel.startsWith('agent-foundation-story-dossier-batch-')) {
|
||||
storyDossierBatch += 1;
|
||||
return JSON.stringify({
|
||||
storyNpcs: [
|
||||
{
|
||||
name: '沈砺',
|
||||
title: '旧友兼宿敌',
|
||||
role: '沉船商盟引路人',
|
||||
description: '他像旧友,也像最早知道假航灯秘密的人。',
|
||||
visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。',
|
||||
actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。',
|
||||
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
|
||||
relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'],
|
||||
tags: ['旧友', '宿敌'],
|
||||
},
|
||||
{
|
||||
name: '岚珀',
|
||||
title: '守灯会巡夜官',
|
||||
role: '守灯会前台接口人',
|
||||
description: '她负责把守灯会的怀疑与命令直接压到玩家面前。',
|
||||
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
|
||||
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
|
||||
sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。',
|
||||
relationshipHooks: ['会逼玩家更早站队'],
|
||||
tags: ['守灯会', '巡夜'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`未覆盖的测试 debugLabel: ${debugLabel}`);
|
||||
},
|
||||
streamMessageContent: async () => {
|
||||
throw new Error('这个测试不应该走流式接口');
|
||||
},
|
||||
} as UpstreamLlmClient;
|
||||
}
|
||||
|
||||
test('foundation draft service builds draft fields directly from framework instead of reusing preview compiler output', async () => {
|
||||
const service = new CustomWorldAgentFoundationDraftService(
|
||||
createFoundationDraftLlmClient(),
|
||||
);
|
||||
|
||||
const draft = await service.generate({
|
||||
creatorIntent: {
|
||||
sourceMode: 'freeform',
|
||||
rawSettingText: '被海雾反复切开的列岛世界。',
|
||||
worldHook: '旧灯塔、假航灯与失控航路重新把列岛撕开。',
|
||||
themeKeywords: ['海岛', '悬疑'],
|
||||
toneDirectives: ['冷峻', '潮湿'],
|
||||
playerPremise: '玩家是被迫返乡的失职守灯人',
|
||||
openingSituation: '开局时正站在即将熄灭的旧灯塔上',
|
||||
coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: ['潮雾钟声', '盐火灯塔'],
|
||||
forbiddenDirectives: [],
|
||||
},
|
||||
anchorPack: {
|
||||
creatorIntentSummary: '潮雾、旧灯塔、假航灯和被迫返乡的守灯人。',
|
||||
},
|
||||
});
|
||||
|
||||
const normalized = normalizeFoundationDraftProfile(draft);
|
||||
const legacyResultProfile = (draft as Record<string, unknown>)
|
||||
.legacyResultProfile as Record<string, unknown> | undefined;
|
||||
const legacyStoryNpcs = Array.isArray(legacyResultProfile?.storyNpcs)
|
||||
? (legacyResultProfile?.storyNpcs as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
|
||||
assert.ok(normalized);
|
||||
assert.equal(normalized?.name, '潮雾列岛');
|
||||
assert.equal(
|
||||
normalized?.summary,
|
||||
'潮雾列岛正在被假航灯和沉船商盟重新切开。',
|
||||
);
|
||||
assert.equal(normalized?.playableNpcs.length, 1);
|
||||
assert.equal(normalized?.storyNpcs.length, 2);
|
||||
assert.equal(normalized?.storyNpcs[0]?.name, '沈砺');
|
||||
assert.match(
|
||||
normalized?.storyNpcs[0]?.summary ?? '',
|
||||
/旧友|假航灯|灯塔/u,
|
||||
);
|
||||
assert.equal(
|
||||
normalized?.storyNpcs[0]?.publicMask,
|
||||
'衣角总带着潮水味,像是刚从夜雾里走出来。',
|
||||
);
|
||||
assert.equal(normalized?.landmarks.length, 2);
|
||||
assert.equal(normalized?.landmarks[0]?.name, '盐火灯塔');
|
||||
assert.equal(normalized?.sceneChapters.length, 2);
|
||||
assert.equal(legacyResultProfile?.name, '潮雾列岛');
|
||||
assert.equal(
|
||||
legacyResultProfile?.scenarioPackId,
|
||||
'scenario-pack:潮雾列岛',
|
||||
);
|
||||
assert.equal(
|
||||
legacyResultProfile?.campaignPackId,
|
||||
'campaign-pack:潮雾列岛',
|
||||
);
|
||||
assert.equal(legacyStoryNpcs[0]?.name, '沈砺');
|
||||
assert.equal(legacyStoryNpcs[0]?.backstory, undefined);
|
||||
});
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
} from '../prompts/customWorldPrompts.js';
|
||||
import {
|
||||
buildCompiledCustomWorldProfile,
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationLandmarkOutline,
|
||||
@@ -36,8 +35,7 @@ import {
|
||||
normalizeCustomWorldGenerationFramework,
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch,
|
||||
normalizeCustomWorldGenerationRoleOutlineBatch,
|
||||
} from '../modules/custom-world/runtimeProfile.js';
|
||||
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
|
||||
} from '../modules/custom-world/runtime-profile/index.js';
|
||||
import {
|
||||
buildDraftSummaryFromIntent,
|
||||
type CreatorCharacterSeedRecord,
|
||||
@@ -961,6 +959,379 @@ function buildSceneChaptersFromDraft(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function buildDraftFactionsFromFramework(params: {
|
||||
majorFactions: string[];
|
||||
coreConflicts: string[];
|
||||
fallbackSummary: string;
|
||||
}): CustomWorldFoundationDraftFaction[] {
|
||||
const names = dedupeStrings(params.majorFactions, 4);
|
||||
const fallbackConflict =
|
||||
params.coreConflicts[0] || params.fallbackSummary || '局势仍在持续升温';
|
||||
|
||||
return names.map((name, index) => {
|
||||
const relatedConflict =
|
||||
params.coreConflicts[index % Math.max(1, params.coreConflicts.length)] ||
|
||||
fallbackConflict;
|
||||
const conflictTarget = extractConflictTarget(relatedConflict);
|
||||
|
||||
return {
|
||||
id: createId('faction', name, index),
|
||||
name,
|
||||
title: name,
|
||||
publicGoal: clampText(
|
||||
conflictTarget
|
||||
? `拿下${conflictTarget}的主动解释权`
|
||||
: '在失衡局势里先一步抢到主动权',
|
||||
28,
|
||||
),
|
||||
relatedConflict,
|
||||
tension: clampText(relatedConflict, 48),
|
||||
playerRelation: clampText(
|
||||
index === 0
|
||||
? '它会先一步影响玩家的开局站位'
|
||||
: '玩家迟早要和它发生正面碰撞',
|
||||
32,
|
||||
),
|
||||
summary: clampText(
|
||||
`${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接改变玩家的开局判断。`,
|
||||
120,
|
||||
),
|
||||
} satisfies CustomWorldFoundationDraftFaction;
|
||||
});
|
||||
}
|
||||
|
||||
function buildDraftCharactersFromGenerationRoles(params: {
|
||||
roles: CustomWorldGenerationRoleOutline[];
|
||||
roleKind: 'playable' | 'story';
|
||||
threads: CustomWorldFoundationDraftThread[];
|
||||
maxCount: number;
|
||||
fallbackPressure: string;
|
||||
}): CustomWorldFoundationDraftCharacter[] {
|
||||
const threadCount = Math.max(1, params.threads.length);
|
||||
|
||||
return params.roles.slice(0, params.maxCount).map((role, index) => {
|
||||
const primaryThreadId = params.threads[index % threadCount]?.id ?? '';
|
||||
const secondaryThreadId =
|
||||
params.threads[
|
||||
(index + (params.roleKind === 'playable' ? 2 : 1)) % threadCount
|
||||
]?.id ?? '';
|
||||
const fallbackRelation =
|
||||
params.roleKind === 'playable'
|
||||
? '这是玩家当前最贴近世界的切入口'
|
||||
: '会直接改变玩家的下一步选择';
|
||||
const publicIdentity =
|
||||
clampText(role.description, 36) || '站在当前局势前台的人';
|
||||
const currentPressure =
|
||||
clampText(
|
||||
role.actionDescription ||
|
||||
role.sceneVisualDescription ||
|
||||
params.fallbackPressure,
|
||||
48,
|
||||
) || '正在被当前局势不断加压';
|
||||
const publicMask = clampText(role.visualDescription, 36) || undefined;
|
||||
const hiddenHook =
|
||||
clampText(role.sceneVisualDescription || role.tags[0] || '', 48) ||
|
||||
undefined;
|
||||
const relationToPlayer =
|
||||
clampText(role.relationshipHooks[0] || fallbackRelation, 36) ||
|
||||
fallbackRelation;
|
||||
|
||||
return {
|
||||
id: createId(
|
||||
'character',
|
||||
`${params.roleKind}-${role.name || role.role || index + 1}`,
|
||||
index,
|
||||
),
|
||||
name:
|
||||
clampText(role.name, 16) ||
|
||||
buildCompactLabel(role.role || role.title, '关键角色', 10),
|
||||
title: clampText(role.title || role.role, 18) || '关键角色',
|
||||
role: clampText(role.role || role.title, 28) || '关键角色',
|
||||
publicIdentity,
|
||||
publicMask,
|
||||
currentPressure,
|
||||
hiddenHook,
|
||||
relationToPlayer,
|
||||
threadIds: dedupeStrings([primaryThreadId, secondaryThreadId], 3),
|
||||
summary: clampText(
|
||||
[
|
||||
publicIdentity,
|
||||
currentPressure ? `眼下压力是${currentPressure}` : '',
|
||||
relationToPlayer ? `与玩家的关系是${relationToPlayer}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
130,
|
||||
),
|
||||
} satisfies CustomWorldFoundationDraftCharacter;
|
||||
});
|
||||
}
|
||||
|
||||
function buildDraftLandmarksFromFramework(params: {
|
||||
landmarks: CustomWorldGenerationLandmarkOutline[];
|
||||
threads: CustomWorldFoundationDraftThread[];
|
||||
storyNpcs: CustomWorldFoundationDraftCharacter[];
|
||||
maxCount: number;
|
||||
fallbackConflict: string;
|
||||
}): CustomWorldFoundationDraftLandmark[] {
|
||||
const threadCount = Math.max(1, params.threads.length);
|
||||
const storyNpcIdByName = new Map(
|
||||
params.storyNpcs.map((role) => [role.name.trim(), role.id] as const),
|
||||
);
|
||||
|
||||
return params.landmarks.slice(0, params.maxCount).map((landmark, index) => {
|
||||
const threadIds = dedupeStrings(
|
||||
[
|
||||
params.threads[index % threadCount]?.id ?? '',
|
||||
params.threads[(index + 1) % threadCount]?.id ?? '',
|
||||
],
|
||||
3,
|
||||
);
|
||||
const characterIds = dedupeStrings(
|
||||
landmark.sceneNpcNames.map(
|
||||
(name) => storyNpcIdByName.get(name.trim()) ?? '',
|
||||
),
|
||||
4,
|
||||
);
|
||||
|
||||
return {
|
||||
id: createId('landmark', landmark.name, index),
|
||||
name: clampText(landmark.name, 16) || `关键地点${index + 1}`,
|
||||
description:
|
||||
clampText(landmark.visualDescription || landmark.description, 48) ||
|
||||
undefined,
|
||||
purpose: clampText(landmark.description, 28) || '承接关键剧情推进',
|
||||
mood:
|
||||
clampText(landmark.visualDescription || landmark.dangerLevel, 24) ||
|
||||
'带着明显风险的关键地点',
|
||||
importance: clampText(
|
||||
`${landmark.name}和“${buildCompactLabel(params.fallbackConflict, '主线冲突', 16)}”直接勾连,第一次抵达时就会意识到它不只是背景。`,
|
||||
60,
|
||||
),
|
||||
secret:
|
||||
clampText(landmark.connections[0]?.summary || '', 36) || undefined,
|
||||
dangerLevel: clampText(landmark.dangerLevel, 24) || undefined,
|
||||
characterIds,
|
||||
threadIds,
|
||||
summary: clampText(
|
||||
landmark.description ||
|
||||
landmark.visualDescription ||
|
||||
`${landmark.name}会把当前局势的压力直接抬到台前。`,
|
||||
120,
|
||||
),
|
||||
} satisfies CustomWorldFoundationDraftLandmark;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 G 的最小收口实现:
|
||||
* foundation draft 主字段直接由 framework / role detail / landmark detail 组装,
|
||||
* 不再通过 preview compiler 先转成 legacy runtime profile 再反解回 draft。
|
||||
*/
|
||||
function buildFoundationDraftProfileFromFramework(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
playableDetailed: CustomWorldGenerationRoleOutline[];
|
||||
storyDetailed: CustomWorldGenerationRoleOutline[];
|
||||
creatorIntent: CustomWorldCreatorIntentRecord;
|
||||
anchorPack: unknown;
|
||||
anchorContent?: EightAnchorContent | null;
|
||||
settingText: string;
|
||||
}) {
|
||||
const normalizedAnchorContent = normalizeEightAnchorContent(
|
||||
params.anchorContent,
|
||||
);
|
||||
const coreConflicts =
|
||||
dedupeStrings(params.framework.coreConflicts, 4).length > 0
|
||||
? dedupeStrings(params.framework.coreConflicts, 4)
|
||||
: dedupeStrings(params.creatorIntent.coreConflicts, 4).length > 0
|
||||
? dedupeStrings(params.creatorIntent.coreConflicts, 4)
|
||||
: [params.framework.summary || '旧秩序与新力量正在争夺这个世界的解释权'];
|
||||
const iconicElements = dedupeStrings(params.creatorIntent.iconicElements, 6);
|
||||
const playerPremise =
|
||||
clampText(params.creatorIntent.playerPremise, 72) ||
|
||||
'玩家是一名被卷进局势中心的行动者';
|
||||
const openingSituation =
|
||||
clampText(params.creatorIntent.openingSituation, 72) ||
|
||||
'故事开局时,玩家已经站在必须立刻选边的位置上';
|
||||
const worldHook =
|
||||
clampText(
|
||||
params.creatorIntent.worldHook ||
|
||||
params.creatorIntent.rawSettingText ||
|
||||
params.framework.summary,
|
||||
72,
|
||||
) || '一个仍在失衡边缘不断扩张的世界';
|
||||
const fallbackFactions = buildFactions({
|
||||
intent: params.creatorIntent,
|
||||
coreConflicts,
|
||||
playerPremise,
|
||||
iconicElements,
|
||||
});
|
||||
const factions =
|
||||
dedupeStrings(params.framework.majorFactions, 4).length > 0
|
||||
? buildDraftFactionsFromFramework({
|
||||
majorFactions: params.framework.majorFactions,
|
||||
coreConflicts,
|
||||
fallbackSummary: params.framework.summary,
|
||||
})
|
||||
: fallbackFactions;
|
||||
const baseThreads = buildBaseThreads({
|
||||
intent: params.creatorIntent,
|
||||
coreConflicts,
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
iconicElements,
|
||||
});
|
||||
const playableNpcs = buildDraftCharactersFromGenerationRoles({
|
||||
roles: params.playableDetailed,
|
||||
roleKind: 'playable',
|
||||
threads: baseThreads,
|
||||
maxCount: FOUNDATION_DRAFT_PLAYABLE_COUNT,
|
||||
fallbackPressure: coreConflicts[0] || params.framework.summary,
|
||||
});
|
||||
const storyNpcs = buildDraftCharactersFromGenerationRoles({
|
||||
roles: params.storyDetailed,
|
||||
roleKind: 'story',
|
||||
threads: baseThreads,
|
||||
maxCount: FOUNDATION_DRAFT_STORY_COUNT,
|
||||
fallbackPressure: coreConflicts[0] || params.framework.summary,
|
||||
});
|
||||
const landmarks = buildDraftLandmarksFromFramework({
|
||||
landmarks: params.framework.landmarks,
|
||||
threads: baseThreads,
|
||||
storyNpcs,
|
||||
maxCount: FOUNDATION_DRAFT_LANDMARK_COUNT,
|
||||
fallbackConflict: coreConflicts[0] || params.framework.summary,
|
||||
});
|
||||
const threads = finalizeThreads({
|
||||
threads: baseThreads.slice(0, 4),
|
||||
characters: [...playableNpcs, ...storyNpcs],
|
||||
landmarks,
|
||||
});
|
||||
const chapter = buildChapter({
|
||||
worldName: params.framework.name,
|
||||
openingSituation,
|
||||
playerGoal: params.framework.playerGoal,
|
||||
characters: [...playableNpcs, ...storyNpcs],
|
||||
landmarks,
|
||||
threads,
|
||||
});
|
||||
const sceneChapters = buildSceneChaptersFromDraft({
|
||||
landmarks,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
threads,
|
||||
});
|
||||
|
||||
const legacyFramework: CustomWorldGenerationFramework = {
|
||||
...params.framework,
|
||||
playableNpcs: params.playableDetailed,
|
||||
storyNpcs: params.storyDetailed,
|
||||
};
|
||||
const legacyResultProfile = buildCustomWorldRawProfileFromFramework(
|
||||
legacyFramework,
|
||||
) as Record<string, unknown>;
|
||||
legacyResultProfile.id =
|
||||
legacyResultProfile.id ??
|
||||
`agent-draft-${slugify(params.framework.name || 'world')}`;
|
||||
legacyResultProfile.settingText = params.settingText;
|
||||
legacyResultProfile.sceneChapterBlueprints = sceneChapters;
|
||||
legacyResultProfile.generationMode = 'fast';
|
||||
legacyResultProfile.generationStatus = 'key_only';
|
||||
legacyResultProfile.scenarioPackId =
|
||||
legacyResultProfile.scenarioPackId ??
|
||||
`scenario-pack:${slugify(params.framework.name || 'world')}`;
|
||||
legacyResultProfile.campaignPackId =
|
||||
legacyResultProfile.campaignPackId ??
|
||||
`campaign-pack:${slugify(params.framework.name || 'world')}`;
|
||||
legacyResultProfile.creatorIntent =
|
||||
legacyResultProfile.creatorIntent ??
|
||||
(params.creatorIntent as unknown as Record<string, unknown>);
|
||||
legacyResultProfile.anchorPack =
|
||||
legacyResultProfile.anchorPack ??
|
||||
(toRecord(params.anchorPack) ??
|
||||
({ value: params.anchorPack } as Record<string, unknown>));
|
||||
if (normalizedAnchorContent) {
|
||||
legacyResultProfile.anchorContent =
|
||||
legacyResultProfile.anchorContent ??
|
||||
(normalizedAnchorContent as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return {
|
||||
name: clampText(params.framework.name, 40) || '未命名世界底稿',
|
||||
subtitle:
|
||||
clampText(params.framework.subtitle, 40) ||
|
||||
clampText(
|
||||
[
|
||||
buildCompactLabel(playerPremise, '玩家视角', 12),
|
||||
buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
40,
|
||||
) ||
|
||||
'第一版世界底稿',
|
||||
summary:
|
||||
clampText(params.framework.summary, 180) ||
|
||||
clampText(
|
||||
`${worldHook} 玩家会以“${playerPremise}”切入这个世界,眼下最直接的冲突是“${coreConflicts[0]}”。`,
|
||||
180,
|
||||
) ||
|
||||
'第一版世界底稿已经整理完成。',
|
||||
tone:
|
||||
clampText(params.framework.tone, 72) ||
|
||||
buildTone(params.creatorIntent),
|
||||
playerGoal:
|
||||
clampText(params.framework.playerGoal, 72) ||
|
||||
buildPlayerGoal({
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
coreConflict: coreConflicts[0] || '',
|
||||
}),
|
||||
majorFactions:
|
||||
dedupeStrings(params.framework.majorFactions, 6).length > 0
|
||||
? dedupeStrings(params.framework.majorFactions, 6)
|
||||
: factions.map((entry) => entry.name),
|
||||
coreConflicts,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
camp: {
|
||||
id: 'camp-home',
|
||||
name: clampText(params.framework.camp.name, 16) || '开局据点',
|
||||
description:
|
||||
clampText(params.framework.camp.description, 72) ||
|
||||
'玩家暂时还能整顿情报、换口气并决定下一步站位的落脚处。',
|
||||
mood:
|
||||
clampText(params.framework.tone, 36) || '紧绷但还可暂时收住局势',
|
||||
dangerLevel:
|
||||
clampText(params.framework.camp.dangerLevel, 24) || undefined,
|
||||
summary: clampText(
|
||||
params.framework.camp.description ||
|
||||
`${params.framework.camp.name}仍是玩家在风暴边缘还能勉强站稳的一块地方。`,
|
||||
88,
|
||||
),
|
||||
} satisfies CustomWorldFoundationDraftCamp,
|
||||
themePack: null,
|
||||
storyGraph: null,
|
||||
factions,
|
||||
threads,
|
||||
chapters: [chapter],
|
||||
sceneChapters,
|
||||
worldHook,
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
iconicElements,
|
||||
sourceAnchorSummary:
|
||||
buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) ||
|
||||
toText(toRecord(params.anchorPack)?.creatorIntentSummary) ||
|
||||
buildDraftSummaryFromIntent(params.creatorIntent) ||
|
||||
params.framework.summary,
|
||||
legacyResultProfile,
|
||||
} satisfies CustomWorldFoundationDraftProfile & {
|
||||
legacyResultProfile: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function getNamedRecordKey(value: unknown) {
|
||||
return toText(value).replace(/\s+/gu, '');
|
||||
}
|
||||
@@ -1496,271 +1867,6 @@ async function expandFoundationRoleEntries(params: {
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
function buildDraftFactionsFromRuntimeProfile(profile: CustomWorldProfile) {
|
||||
const factionNames = dedupeStrings(profile.majorFactions, 4);
|
||||
const firstConflict = profile.coreConflicts[0] || profile.summary;
|
||||
|
||||
return factionNames.slice(0, 4).map((name, index) => {
|
||||
const relatedConflict =
|
||||
profile.coreConflicts[
|
||||
index % Math.max(1, profile.coreConflicts.length)
|
||||
] || firstConflict;
|
||||
return {
|
||||
id: createId('faction', name, index),
|
||||
name,
|
||||
title: name,
|
||||
publicGoal: clampText(
|
||||
extractConflictTarget(relatedConflict)
|
||||
? `拿下${extractConflictTarget(relatedConflict)}的主导权`
|
||||
: '在失衡局势里先抢到主动权',
|
||||
28,
|
||||
),
|
||||
relatedConflict,
|
||||
tension: clampText(relatedConflict, 48),
|
||||
playerRelation: clampText(
|
||||
index === 0
|
||||
? '它会主动影响玩家的第一步站位'
|
||||
: '玩家迟早要和它发生直接交集',
|
||||
32,
|
||||
),
|
||||
summary: clampText(
|
||||
`${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接影响玩家的开局判断。`,
|
||||
120,
|
||||
),
|
||||
} satisfies CustomWorldFoundationDraftFaction;
|
||||
});
|
||||
}
|
||||
|
||||
function buildDraftThreadsFromRuntimeProfile(
|
||||
profile: CustomWorldProfile,
|
||||
): CustomWorldFoundationDraftThread[] {
|
||||
const graphThreads = [
|
||||
...(profile.storyGraph?.visibleThreads ?? []).slice(0, 2),
|
||||
...(profile.storyGraph?.hiddenThreads ?? []).slice(0, 2),
|
||||
];
|
||||
|
||||
if (graphThreads.length > 0) {
|
||||
return graphThreads.map(
|
||||
(thread, index) =>
|
||||
({
|
||||
id: thread.id || createId('thread', thread.title, index),
|
||||
title: clampText(thread.title, 18),
|
||||
type: thread.visibility === 'hidden' ? 'hidden' : 'main',
|
||||
conflictType: clampText(thread.conflictType, 18),
|
||||
conflict: clampText(thread.summary || thread.stakes, 72),
|
||||
stakes: clampText(thread.stakes, 48),
|
||||
characterIds: thread.involvedActorIds.slice(0, 4),
|
||||
landmarkIds: thread.relatedLocationIds.slice(0, 4),
|
||||
summary: clampText(thread.summary, 120),
|
||||
}) satisfies CustomWorldFoundationDraftThread,
|
||||
);
|
||||
}
|
||||
|
||||
return profile.coreConflicts.slice(0, 3).map((conflict, index) => ({
|
||||
id: createId('thread', conflict, index),
|
||||
title: buildCompactLabel(conflict, `主线${index + 1}`, 16),
|
||||
type: index === 1 ? 'hidden' : 'main',
|
||||
conflict,
|
||||
characterIds: [],
|
||||
landmarkIds: [],
|
||||
summary: clampText(`这条线围绕“${conflict}”持续推进。`, 80),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDraftCharactersFromRuntimeProfile(
|
||||
roles: CustomWorldProfile['playableNpcs'] | CustomWorldProfile['storyNpcs'],
|
||||
fallbackThreadIds: string[],
|
||||
) {
|
||||
return roles.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
title: clampText(role.title || role.role, 18) || '关键角色',
|
||||
role: clampText(role.role || role.title, 28) || '关键角色',
|
||||
publicIdentity:
|
||||
clampText(
|
||||
role.narrativeProfile?.publicMask ||
|
||||
role.backstoryReveal.publicSummary ||
|
||||
role.description,
|
||||
36,
|
||||
) || '站在局势前台的人',
|
||||
publicMask:
|
||||
clampText(
|
||||
role.narrativeProfile?.firstContactMask || role.personality,
|
||||
36,
|
||||
) || undefined,
|
||||
currentPressure:
|
||||
clampText(
|
||||
role.narrativeProfile?.immediatePressure ||
|
||||
role.motivation ||
|
||||
role.backstory,
|
||||
48,
|
||||
) || '正在被当前局势不断加压',
|
||||
hiddenHook:
|
||||
clampText(
|
||||
role.narrativeProfile?.hiddenLine ||
|
||||
role.backstoryReveal.chapters[2]?.content ||
|
||||
role.backstory,
|
||||
48,
|
||||
) || undefined,
|
||||
relationToPlayer:
|
||||
clampText(
|
||||
role.relationshipHooks[0] ||
|
||||
role.narrativeProfile?.visibleLine ||
|
||||
role.motivation,
|
||||
36,
|
||||
) || '会直接改变玩家的下一步选择',
|
||||
threadIds:
|
||||
role.narrativeProfile?.relatedThreadIds?.slice(0, 3) ??
|
||||
fallbackThreadIds.slice(0, 3),
|
||||
summary:
|
||||
clampText(role.description || role.backstoryReveal.publicSummary, 120) ||
|
||||
'这个角色会持续推动当前世界底稿继续展开。',
|
||||
})) satisfies CustomWorldFoundationDraftCharacter[];
|
||||
}
|
||||
|
||||
function buildDraftLandmarksFromRuntimeProfile(
|
||||
profile: CustomWorldProfile,
|
||||
threads: CustomWorldFoundationDraftThread[],
|
||||
) {
|
||||
return profile.landmarks
|
||||
.slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT)
|
||||
.map((landmark) => {
|
||||
const relatedThreadIds = threads
|
||||
.filter((thread) => thread.landmarkIds.includes(landmark.id))
|
||||
.map((thread) => thread.id)
|
||||
.slice(0, 3);
|
||||
|
||||
return {
|
||||
id: landmark.id,
|
||||
name: landmark.name,
|
||||
description: clampText(landmark.description, 48) || undefined,
|
||||
purpose: clampText(landmark.description, 28) || '承接关键剧情推进',
|
||||
mood:
|
||||
clampText(
|
||||
landmark.narrativeResidues?.[0]?.summary ||
|
||||
landmark.dangerLevel ||
|
||||
'带着明显风险的关键地点',
|
||||
24,
|
||||
) || '带着明显风险的关键地点',
|
||||
importance: clampText(
|
||||
landmark.narrativeResidues?.[0]?.changeHint ||
|
||||
landmark.description ||
|
||||
'和当前主线冲突直接勾连的关键地点',
|
||||
60,
|
||||
),
|
||||
secret:
|
||||
clampText(
|
||||
landmark.narrativeResidues?.[0]?.hiddenTruth ||
|
||||
landmark.connections[0]?.summary ||
|
||||
'',
|
||||
36,
|
||||
) || undefined,
|
||||
dangerLevel: landmark.dangerLevel,
|
||||
characterIds: landmark.sceneNpcIds.slice(0, 4),
|
||||
threadIds: relatedThreadIds,
|
||||
summary: clampText(
|
||||
landmark.description ||
|
||||
landmark.narrativeResidues?.[0]?.summary ||
|
||||
'',
|
||||
120,
|
||||
),
|
||||
} satisfies CustomWorldFoundationDraftLandmark;
|
||||
});
|
||||
}
|
||||
|
||||
function convertRuntimeProfileToFoundationDraft(params: {
|
||||
profile: CustomWorldProfile;
|
||||
intent: CustomWorldCreatorIntentRecord;
|
||||
anchorPack: unknown;
|
||||
}) {
|
||||
const factions = buildDraftFactionsFromRuntimeProfile(params.profile);
|
||||
const threads = buildDraftThreadsFromRuntimeProfile(params.profile);
|
||||
const playableNpcs = buildDraftCharactersFromRuntimeProfile(
|
||||
params.profile.playableNpcs.slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT),
|
||||
threads.slice(0, 2).map((entry) => entry.id),
|
||||
);
|
||||
const storyNpcs = buildDraftCharactersFromRuntimeProfile(
|
||||
params.profile.storyNpcs.slice(0, FOUNDATION_DRAFT_STORY_COUNT),
|
||||
threads.slice(1, 3).map((entry) => entry.id),
|
||||
);
|
||||
const landmarks = buildDraftLandmarksFromRuntimeProfile(
|
||||
params.profile,
|
||||
threads,
|
||||
);
|
||||
const chapter = buildChapter({
|
||||
worldName: params.profile.name,
|
||||
openingSituation:
|
||||
clampText(params.intent.openingSituation, 60) || params.profile.summary,
|
||||
playerGoal: params.profile.playerGoal,
|
||||
characters: [...playableNpcs, ...storyNpcs],
|
||||
landmarks,
|
||||
threads,
|
||||
});
|
||||
const sceneChapters = buildSceneChaptersFromDraft({
|
||||
landmarks,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
threads,
|
||||
});
|
||||
const anchorRecord = toRecord(params.anchorPack);
|
||||
|
||||
return {
|
||||
name: params.profile.name,
|
||||
subtitle: params.profile.subtitle,
|
||||
summary: params.profile.summary,
|
||||
tone: params.profile.tone,
|
||||
playerGoal: params.profile.playerGoal,
|
||||
majorFactions:
|
||||
params.profile.majorFactions.length > 0
|
||||
? params.profile.majorFactions
|
||||
: factions.map((entry) => entry.name),
|
||||
coreConflicts:
|
||||
params.profile.coreConflicts.length > 0
|
||||
? params.profile.coreConflicts
|
||||
: [params.profile.summary],
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
camp: params.profile.camp
|
||||
? ({
|
||||
id: 'camp-home',
|
||||
name: params.profile.camp.name,
|
||||
description: params.profile.camp.description,
|
||||
mood: clampText(params.profile.tone, 36) || '紧绷但还可暂时收住局势',
|
||||
dangerLevel: params.profile.camp.dangerLevel,
|
||||
summary: clampText(params.profile.camp.description, 88),
|
||||
} satisfies CustomWorldFoundationDraftCamp)
|
||||
: null,
|
||||
themePack:
|
||||
(params.profile.themePack as unknown as Record<string, unknown> | null) ??
|
||||
null,
|
||||
storyGraph:
|
||||
(params.profile.storyGraph as unknown as Record<string, unknown> | null) ??
|
||||
null,
|
||||
factions,
|
||||
threads,
|
||||
chapters: [chapter],
|
||||
sceneChapters,
|
||||
worldHook:
|
||||
clampText(params.intent.worldHook || params.profile.summary, 72) ||
|
||||
params.profile.summary,
|
||||
playerPremise:
|
||||
clampText(params.intent.playerPremise, 72) ||
|
||||
'玩家是一名被卷进局势中心的行动者',
|
||||
openingSituation:
|
||||
clampText(params.intent.openingSituation, 72) ||
|
||||
'故事开局时,玩家已经站在必须立刻选边的位置上',
|
||||
iconicElements: dedupeStrings(params.intent.iconicElements, 6),
|
||||
sourceAnchorSummary:
|
||||
toText(anchorRecord?.creatorIntentSummary) ||
|
||||
buildDraftSummaryFromIntent(params.intent) ||
|
||||
params.profile.summary,
|
||||
legacyResultProfile: params.profile as unknown as Record<string, unknown>,
|
||||
} satisfies CustomWorldFoundationDraftProfile & {
|
||||
legacyResultProfile: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
async function buildFoundationDraftProfileWithLlm(params: {
|
||||
llmClient: UpstreamLlmClient;
|
||||
creatorIntent: CustomWorldCreatorIntentRecord;
|
||||
@@ -1885,25 +1991,19 @@ async function buildFoundationDraftProfileWithLlm(params: {
|
||||
|
||||
await emitDraftProgress(params.onProgress, {
|
||||
phaseLabel: '编译世界底稿',
|
||||
phaseDetail: '正在把分批生成结果整理成旧版世界结果结构,再编成草稿卡底稿。',
|
||||
phaseDetail:
|
||||
'正在把分批生成结果直接整理成第一版 foundation draft,并同步兼容结果快照。',
|
||||
progress: 97,
|
||||
});
|
||||
|
||||
const rawProfile = buildCustomWorldRawProfileFromFramework(
|
||||
return buildFoundationDraftProfileFromFramework({
|
||||
framework,
|
||||
) as Record<string, unknown>;
|
||||
rawProfile.playableNpcs = playableDetailed;
|
||||
rawProfile.storyNpcs = storyDetailed;
|
||||
rawProfile.landmarks = framework.landmarks;
|
||||
|
||||
const runtimeProfile = buildCompiledCustomWorldProfile(
|
||||
rawProfile,
|
||||
settingText,
|
||||
);
|
||||
return convertRuntimeProfileToFoundationDraft({
|
||||
profile: runtimeProfile,
|
||||
intent: params.creatorIntent,
|
||||
playableDetailed,
|
||||
storyDetailed,
|
||||
creatorIntent: params.creatorIntent,
|
||||
anchorPack: params.anchorPack,
|
||||
anchorContent: params.anchorContent,
|
||||
settingText,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
196
server-node/src/services/customWorldAgentMessageTurnService.ts
Normal file
196
server-node/src/services/customWorldAgentMessageTurnService.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldAgentMessage,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
buildPendingClarifications,
|
||||
evaluateCreatorIntentReadiness,
|
||||
resolveCreatorIntentStage,
|
||||
} from './customWorldAgentClarificationService.js';
|
||||
import {
|
||||
buildAnchorPackFromIntent,
|
||||
buildDraftSummaryFromIntent,
|
||||
buildDraftTitleFromIntent,
|
||||
type CustomWorldCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import type {
|
||||
CustomWorldAgentSessionRecord,
|
||||
CustomWorldAgentSessionStore,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
import type { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js';
|
||||
import { CustomWorldAgentSnapshotBuilder } from './customWorldAgentSnapshotBuilder.js';
|
||||
import {
|
||||
buildCreatorIntentFromEightAnchorContent,
|
||||
buildEightAnchorContentFromCreatorIntent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js';
|
||||
|
||||
function buildDerivedState(
|
||||
intent: CustomWorldCreatorIntentRecord,
|
||||
hasUserInput: boolean,
|
||||
suggestedActionService: CustomWorldAgentSuggestedActionService,
|
||||
) {
|
||||
const readiness = evaluateCreatorIntentReadiness(intent);
|
||||
const pendingClarifications = buildPendingClarifications(intent, readiness);
|
||||
const stage = resolveCreatorIntentStage({
|
||||
hasUserInput,
|
||||
readiness,
|
||||
});
|
||||
|
||||
return {
|
||||
readiness,
|
||||
pendingClarifications,
|
||||
stage,
|
||||
anchorPack: buildAnchorPackFromIntent(intent, {
|
||||
completedKeys: readiness.completedKeys,
|
||||
missingKeys: readiness.missingKeys,
|
||||
}),
|
||||
draftProfile: {
|
||||
title: buildDraftTitleFromIntent(intent),
|
||||
summary: buildDraftSummaryFromIntent(intent),
|
||||
},
|
||||
suggestedActions: suggestedActionService.buildSuggestedActions({
|
||||
stage,
|
||||
isReady: readiness.isReady,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export class CustomWorldAgentMessageTurnService {
|
||||
constructor(
|
||||
private readonly sessionStore: CustomWorldAgentSessionStore,
|
||||
private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService,
|
||||
private readonly suggestedActionService: CustomWorldAgentSuggestedActionService,
|
||||
private readonly snapshotBuilder: CustomWorldAgentSnapshotBuilder,
|
||||
) {}
|
||||
|
||||
async applyMessageTurn(params: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
latestUserText: string;
|
||||
quickFillRequested: boolean;
|
||||
relatedOperationId?: string | null;
|
||||
onReplyUpdate?: (text: string) => void;
|
||||
}) {
|
||||
const latestSession = (await this.sessionStore.get(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
)) as CustomWorldAgentSessionRecord | null;
|
||||
if (!latestSession) {
|
||||
throw new Error('custom world agent session not found');
|
||||
}
|
||||
|
||||
const shouldPreserveDraftStage =
|
||||
(latestSession.stage === 'object_refining' ||
|
||||
latestSession.stage === 'visual_refining') &&
|
||||
latestSession.draftCards.length > 0;
|
||||
|
||||
const assistantTurn = await this.eightAnchorSingleTurnService.streamTurn(
|
||||
{
|
||||
currentTurn: latestSession.currentTurn + 1,
|
||||
progressPercent: latestSession.progressPercent,
|
||||
quickFillRequested: params.quickFillRequested,
|
||||
currentAnchorContent: latestSession.anchorContent,
|
||||
chatHistory: latestSession.messages
|
||||
.filter(
|
||||
(message): message is CustomWorldAgentMessage =>
|
||||
(message.role === 'user' || message.role === 'assistant') &&
|
||||
Boolean(message.text.trim()),
|
||||
)
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.text,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onReplyUpdate: params.onReplyUpdate,
|
||||
},
|
||||
);
|
||||
const nextCreatorIntent = buildCreatorIntentFromEightAnchorContent(
|
||||
assistantTurn.nextAnchorContent,
|
||||
);
|
||||
const progressPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(assistantTurn.progressPercent)),
|
||||
);
|
||||
const creatorIntentReadiness: CreatorIntentReadiness =
|
||||
progressPercent >= 100
|
||||
? {
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
}
|
||||
: evaluateCreatorIntentReadiness(nextCreatorIntent);
|
||||
const derivedState = buildDerivedState(
|
||||
nextCreatorIntent,
|
||||
true,
|
||||
this.suggestedActionService,
|
||||
);
|
||||
const shouldStayInDraftStage =
|
||||
shouldPreserveDraftStage && progressPercent >= 100;
|
||||
const assistantMessage = {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: assistantTurn.replyText,
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: params.relatedOperationId ?? null,
|
||||
} satisfies CustomWorldAgentMessage;
|
||||
|
||||
await this.sessionStore.replaceDerivedState(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
this.snapshotBuilder.buildMessageTurnState({
|
||||
latestSession,
|
||||
nextAnchorContent: assistantTurn.nextAnchorContent,
|
||||
progressPercent,
|
||||
replyText: assistantTurn.replyText,
|
||||
nextCreatorIntent,
|
||||
creatorIntentReadiness,
|
||||
derivedDraftProfile: derivedState.draftProfile,
|
||||
derivedPendingClarifications: derivedState.pendingClarifications,
|
||||
derivedStage: derivedState.stage,
|
||||
shouldStayInDraftStage,
|
||||
}),
|
||||
);
|
||||
await this.sessionStore.appendMessage(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
assistantMessage,
|
||||
);
|
||||
|
||||
return (await this.sessionStore.getSnapshot(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
)) as CustomWorldAgentSessionSnapshot;
|
||||
}
|
||||
|
||||
deriveInitialSessionState(params: {
|
||||
seedText: string;
|
||||
creatorIntent: CustomWorldCreatorIntentRecord;
|
||||
}) {
|
||||
const anchorContent = buildEightAnchorContentFromCreatorIntent(
|
||||
params.creatorIntent,
|
||||
);
|
||||
const derivedState = buildDerivedState(
|
||||
params.creatorIntent,
|
||||
Boolean(params.seedText),
|
||||
this.suggestedActionService,
|
||||
);
|
||||
|
||||
return {
|
||||
anchorContent,
|
||||
...derivedState,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import {
|
||||
buildPendingClarifications,
|
||||
evaluateCreatorIntentReadiness,
|
||||
@@ -12,89 +10,10 @@ import {
|
||||
mergeCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
const sessionsByUser = new Map<
|
||||
string,
|
||||
Map<string, CustomWorldSessionRecord>
|
||||
>();
|
||||
const profilesByUser = new Map<string, Record<string, unknown>[]>();
|
||||
|
||||
const getSessionBucket = (userId: string) => {
|
||||
const existing = sessionsByUser.get(userId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const nextBucket = new Map<string, CustomWorldSessionRecord>();
|
||||
sessionsByUser.set(userId, nextBucket);
|
||||
return nextBucket;
|
||||
};
|
||||
|
||||
return {
|
||||
async getSnapshot(_userId) {
|
||||
return null;
|
||||
},
|
||||
async putSnapshot(_userId, _payload) {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
async deleteSnapshot(_userId) {
|
||||
return undefined;
|
||||
},
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
return settings;
|
||||
},
|
||||
async listCustomWorldProfiles(userId) {
|
||||
return [...(profilesByUser.get(userId) ?? [])];
|
||||
},
|
||||
async upsertCustomWorldProfile(userId, profileId, profile) {
|
||||
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
||||
(item) => String(item.id ?? '') !== profileId,
|
||||
);
|
||||
current.unshift({
|
||||
...profile,
|
||||
id: profileId,
|
||||
});
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async deleteCustomWorldProfile(userId, profileId) {
|
||||
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
||||
(item) => String(item.id ?? '') !== profileId,
|
||||
);
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
async getCustomWorldSession(userId, sessionId) {
|
||||
return getSessionBucket(userId).get(sessionId) ?? null;
|
||||
},
|
||||
async upsertCustomWorldSession(userId, sessionId, session) {
|
||||
getSessionBucket(userId).set(
|
||||
sessionId,
|
||||
JSON.parse(JSON.stringify(session)),
|
||||
);
|
||||
return JSON.parse(JSON.stringify(session));
|
||||
},
|
||||
};
|
||||
}
|
||||
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
|
||||
|
||||
async function waitForOperation(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
@@ -184,8 +103,10 @@ test('phase2 clarification service only keeps the top highest leverage gap', ()
|
||||
});
|
||||
|
||||
test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
@@ -265,8 +186,11 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
|
||||
});
|
||||
|
||||
test('phase2 work summaries compile draft title and summary from creator intent', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository, rpgWorldProfileRepository } =
|
||||
createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
@@ -293,10 +217,10 @@ test('phase2 work summaries compile draft title and summary from creator intent'
|
||||
update.operation.operationId,
|
||||
);
|
||||
|
||||
const items = await listCustomWorldWorkSummaries(userId, {
|
||||
runtimeRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const items = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
const draft = items.find(
|
||||
(item) => item.sessionId === createdSession.sessionId,
|
||||
);
|
||||
|
||||
@@ -4,95 +4,14 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
const sessionsByUser = new Map<
|
||||
string,
|
||||
Map<string, CustomWorldSessionRecord>
|
||||
>();
|
||||
const profilesByUser = new Map<string, Record<string, unknown>[]>();
|
||||
|
||||
const getSessionBucket = (userId: string) => {
|
||||
const existing = sessionsByUser.get(userId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const nextBucket = new Map<string, CustomWorldSessionRecord>();
|
||||
sessionsByUser.set(userId, nextBucket);
|
||||
return nextBucket;
|
||||
};
|
||||
|
||||
return {
|
||||
async getSnapshot(_userId) {
|
||||
return null;
|
||||
},
|
||||
async putSnapshot(_userId, _payload) {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
async deleteSnapshot(_userId) {
|
||||
return undefined;
|
||||
},
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
return settings;
|
||||
},
|
||||
async listCustomWorldProfiles(userId) {
|
||||
return [...(profilesByUser.get(userId) ?? [])];
|
||||
},
|
||||
async upsertCustomWorldProfile(userId, profileId, profile) {
|
||||
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
||||
(item) => String(item.id ?? '') !== profileId,
|
||||
);
|
||||
current.unshift({
|
||||
...profile,
|
||||
id: profileId,
|
||||
});
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async deleteCustomWorldProfile(userId, profileId) {
|
||||
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
||||
(item) => String(item.id ?? '') !== profileId,
|
||||
);
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
async getCustomWorldSession(userId, sessionId) {
|
||||
return getSessionBucket(userId).get(sessionId) ?? null;
|
||||
},
|
||||
async upsertCustomWorldSession(userId, sessionId, session) {
|
||||
getSessionBucket(userId).set(
|
||||
sessionId,
|
||||
JSON.parse(JSON.stringify(session)),
|
||||
);
|
||||
return JSON.parse(JSON.stringify(session));
|
||||
},
|
||||
};
|
||||
}
|
||||
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
|
||||
|
||||
function createAutoAssetTestConfig(testName: string): AppConfig {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
@@ -172,6 +91,11 @@ function createAutoAssetTestConfig(testName: string): AppConfig {
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
accessCookieName: 'genarrative_access_session',
|
||||
accessCookieTtlSeconds: 7200,
|
||||
accessCookieSecure: false,
|
||||
accessCookieSameSite: 'Lax',
|
||||
accessCookiePath: '/',
|
||||
refreshCookieName: 'refresh_token',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
@@ -254,13 +178,28 @@ async function createReadySession(
|
||||
|
||||
assert.equal(readySession?.stage, 'foundation_review');
|
||||
assert.equal(readySession?.creatorIntentReadiness.isReady, true);
|
||||
assert.equal(readySession?.resultPreview, null);
|
||||
assert.equal(
|
||||
readySession?.supportedActions?.find(
|
||||
(entry) => entry.action === 'draft_foundation',
|
||||
)?.enabled,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
readySession?.supportedActions?.find(
|
||||
(entry) => entry.action === 'sync_result_profile',
|
||||
)?.enabled,
|
||||
false,
|
||||
);
|
||||
|
||||
return readySession!;
|
||||
}
|
||||
|
||||
test('phase3 ready session can execute draft_foundation and expose card detail', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('draft'),
|
||||
@@ -296,6 +235,21 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(snapshot?.stage, 'object_refining');
|
||||
assert.ok(snapshot?.draftCards.length);
|
||||
assert.equal(snapshot?.resultPreview?.source, 'session_preview');
|
||||
assert.equal(
|
||||
snapshot?.resultPreview?.preview.name,
|
||||
typeof (snapshot?.draftProfile as Record<string, unknown>)?.name === 'string'
|
||||
? ((snapshot?.draftProfile as Record<string, unknown>).name as string)
|
||||
: '未命名世界底稿',
|
||||
);
|
||||
assert.ok(Array.isArray(snapshot?.resultPreview?.blockers));
|
||||
assert.ok((snapshot?.resultPreview?.blockers?.length ?? 0) >= 0);
|
||||
assert.equal(snapshot?.resultPreview?.publishReady, false);
|
||||
assert.equal(snapshot?.resultPreview?.canEnterWorld, false);
|
||||
assert.equal(
|
||||
snapshot?.resultPreview?.qualityFindings?.length,
|
||||
snapshot?.qualityFindings.length,
|
||||
);
|
||||
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'world'));
|
||||
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'faction'));
|
||||
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'character'));
|
||||
@@ -330,6 +284,24 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
message.text.includes('第一版世界底稿整理出来了'),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
snapshot?.supportedActions?.find(
|
||||
(entry) => entry.action === 'update_draft_card',
|
||||
)?.enabled,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot?.supportedActions?.find(
|
||||
(entry) => entry.action === 'generate_role_assets',
|
||||
)?.enabled,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot?.supportedActions?.find(
|
||||
(entry) => entry.action === 'publish_world',
|
||||
)?.enabled,
|
||||
true,
|
||||
);
|
||||
|
||||
const worldCard = snapshot?.draftCards.find((card) => card.kind === 'world');
|
||||
assert.ok(worldCard);
|
||||
@@ -347,8 +319,10 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
});
|
||||
|
||||
test('phase3 draft_foundation rejects not-ready session', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('not-ready'),
|
||||
@@ -368,8 +342,11 @@ test('phase3 draft_foundation rejects not-ready session', async () => {
|
||||
});
|
||||
|
||||
test('phase3 work summaries prefer compiled foundation draft fields', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository, rpgWorldProfileRepository } =
|
||||
createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('summary'),
|
||||
@@ -391,10 +368,10 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
|
||||
response.operation.operationId,
|
||||
);
|
||||
|
||||
const items = await listCustomWorldWorkSummaries(userId, {
|
||||
runtimeRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const items = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
const draft = items.find((item) => item.sessionId === readySession.sessionId);
|
||||
const compiledProfile = normalizeFoundationDraftProfile(
|
||||
(
|
||||
@@ -418,8 +395,10 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
|
||||
});
|
||||
|
||||
test('phase3 draft foundation still completes when auto asset generation fails', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const autoAssetService = new CustomWorldAgentAutoAssetService(
|
||||
createAutoAssetTestConfig('asset-failure'),
|
||||
async () => {
|
||||
|
||||
@@ -1,93 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
const sessionsByUser = new Map<
|
||||
string,
|
||||
Map<string, CustomWorldSessionRecord>
|
||||
>();
|
||||
const profilesByUser = new Map<string, Record<string, unknown>[]>();
|
||||
|
||||
const getSessionBucket = (userId: string) => {
|
||||
const existing = sessionsByUser.get(userId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const nextBucket = new Map<string, CustomWorldSessionRecord>();
|
||||
sessionsByUser.set(userId, nextBucket);
|
||||
return nextBucket;
|
||||
};
|
||||
|
||||
return {
|
||||
async getSnapshot(_userId) {
|
||||
return null;
|
||||
},
|
||||
async putSnapshot(_userId, _payload) {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
async deleteSnapshot(_userId) {
|
||||
return undefined;
|
||||
},
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
return settings;
|
||||
},
|
||||
async listCustomWorldProfiles(userId) {
|
||||
return [...(profilesByUser.get(userId) ?? [])];
|
||||
},
|
||||
async upsertCustomWorldProfile(userId, profileId, profile) {
|
||||
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
||||
(item) => String(item.id ?? '') !== profileId,
|
||||
);
|
||||
current.unshift({
|
||||
...profile,
|
||||
id: profileId,
|
||||
});
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async deleteCustomWorldProfile(userId, profileId) {
|
||||
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
||||
(item) => String(item.id ?? '') !== profileId,
|
||||
);
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
async getCustomWorldSession(userId, sessionId) {
|
||||
return getSessionBucket(userId).get(sessionId) ?? null;
|
||||
},
|
||||
async upsertCustomWorldSession(userId, sessionId, session) {
|
||||
getSessionBucket(userId).set(
|
||||
sessionId,
|
||||
JSON.parse(JSON.stringify(session)),
|
||||
);
|
||||
return JSON.parse(JSON.stringify(session));
|
||||
},
|
||||
};
|
||||
}
|
||||
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
|
||||
|
||||
async function waitForOperation(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
@@ -167,8 +86,10 @@ async function createObjectRefiningSession(
|
||||
}
|
||||
|
||||
test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
@@ -227,9 +148,377 @@ test('phase4 update_draft_card writes back draft profile and recompiles summarie
|
||||
);
|
||||
});
|
||||
|
||||
test('phase4 sync_result_profile writes result-page snapshot back into session draft chain', async () => {
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase4-sync-result-profile';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
|
||||
const response = await orchestrator.executeAction(userId, session.sessionId, {
|
||||
action: 'sync_result_profile',
|
||||
profile: {
|
||||
id: `agent-draft-${session.sessionId}`,
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '潮雾列岛·结果页精修版',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '结果页已经把世界概述继续往沉船夜暗线收紧。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛·结果页精修版',
|
||||
settingSummary: '测试',
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
},
|
||||
});
|
||||
const operation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
|
||||
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
|
||||
const draftRecord = snapshot?.draftProfile as Record<string, unknown> | null;
|
||||
const legacyResultProfile = draftRecord?.legacyResultProfile as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(profile?.name, '潮雾列岛·结果页精修版');
|
||||
assert.equal(
|
||||
profile?.summary,
|
||||
'结果页已经把世界概述继续往沉船夜暗线收紧。',
|
||||
);
|
||||
assert.equal(snapshot?.resultPreview?.source, 'session_preview');
|
||||
assert.equal(
|
||||
snapshot?.resultPreview?.preview.name,
|
||||
'潮雾列岛·结果页精修版',
|
||||
);
|
||||
assert.equal(
|
||||
snapshot?.resultPreview?.preview.playerGoal,
|
||||
'查清沉船夜与假航灯的真正操盘者。',
|
||||
);
|
||||
assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版');
|
||||
assert.equal(
|
||||
legacyResultProfile?.playerGoal,
|
||||
'查清沉船夜与假航灯的真正操盘者。',
|
||||
);
|
||||
assert.ok(
|
||||
snapshot?.messages.some(
|
||||
(message) =>
|
||||
message.kind === 'action_result' &&
|
||||
message.text.includes('结果页里的最新世界结构已经同步回当前草稿'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('phase4 sync_result_profile keeps existing foundation structure while updating summary snapshot', async () => {
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase4-sync-result-profile-structure';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
const baselinePlayableName = baselineProfile?.playableNpcs[0]?.name;
|
||||
const baselineStoryName = baselineProfile?.storyNpcs[0]?.name;
|
||||
const baselineLandmarkName = baselineProfile?.landmarks[0]?.name;
|
||||
|
||||
assert.ok(baselinePlayableName);
|
||||
assert.ok(baselineStoryName);
|
||||
assert.ok(baselineLandmarkName);
|
||||
|
||||
const response = await orchestrator.executeAction(userId, session.sessionId, {
|
||||
action: 'sync_result_profile',
|
||||
profile: {
|
||||
id: `agent-draft-${session.sessionId}`,
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '潮雾列岛·结果页精修版',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '结果页已经把世界概述继续往沉船夜暗线收紧。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛·结果页精修版',
|
||||
settingSummary: '测试',
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-runtime-only',
|
||||
name: '结果页临时角色',
|
||||
title: '运行时角色',
|
||||
role: '测试角色',
|
||||
description: '不应该直接覆盖 foundation draft。',
|
||||
backstory: '仅用于验证 sync 边界。',
|
||||
personality: '谨慎',
|
||||
motivation: '验证同步边界',
|
||||
combatStyle: '观察',
|
||||
initialAffinity: 0,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-runtime-only',
|
||||
name: '结果页临时场景角色',
|
||||
title: '运行时场景角色',
|
||||
role: '测试角色',
|
||||
description: '不应该直接覆盖 foundation draft。',
|
||||
backstory: '仅用于验证 sync 边界。',
|
||||
personality: '克制',
|
||||
motivation: '验证同步边界',
|
||||
combatStyle: '观察',
|
||||
initialAffinity: 0,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-runtime-only',
|
||||
name: '结果页临时地点',
|
||||
description: '不应该直接覆盖 foundation draft。',
|
||||
dangerLevel: '低',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
},
|
||||
});
|
||||
const operation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
|
||||
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
|
||||
const draftRecord = snapshot?.draftProfile as Record<string, unknown> | null;
|
||||
const legacyResultProfile = draftRecord?.legacyResultProfile as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(profile?.name, '潮雾列岛·结果页精修版');
|
||||
assert.equal(profile?.playableNpcs[0]?.name, baselinePlayableName);
|
||||
assert.equal(profile?.storyNpcs[0]?.name, baselineStoryName);
|
||||
assert.equal(profile?.landmarks[0]?.name, baselineLandmarkName);
|
||||
assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版');
|
||||
assert.equal(
|
||||
(legacyResultProfile?.playableNpcs as Array<{ name?: string }> | undefined)?.[0]
|
||||
?.name,
|
||||
'结果页临时角色',
|
||||
);
|
||||
});
|
||||
|
||||
test('phase4 sync_result_profile also writes latest role and scene assets back into draft profile', async () => {
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase4-sync-result-profile-assets';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
|
||||
const playableRole = baselineProfile.playableNpcs[0]!;
|
||||
const storyRole = baselineProfile.storyNpcs[0]!;
|
||||
const landmark = baselineProfile.landmarks[0]!;
|
||||
|
||||
const response = await orchestrator.executeAction(userId, session.sessionId, {
|
||||
action: 'sync_result_profile',
|
||||
profile: {
|
||||
id: `agent-draft-${session.sessionId}`,
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '潮雾列岛·结果页精修版',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '结果页已经把最新图与动作一起确认。 ',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛·结果页精修版',
|
||||
settingSummary: '测试',
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: playableRole.id,
|
||||
name: playableRole.name,
|
||||
title: '结果页角色',
|
||||
role: '关键同行者',
|
||||
description: '结果页确认的最新角色资产。',
|
||||
backstory: '测试',
|
||||
personality: '冷静',
|
||||
motivation: '验证资产回写',
|
||||
combatStyle: '观察',
|
||||
initialAffinity: 12,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
imageSrc: '/generated/playable/latest-master.png',
|
||||
generatedVisualAssetId: 'visual-playable-latest',
|
||||
generatedAnimationSetId: 'anim-playable-latest',
|
||||
animationMap: {
|
||||
idle: {
|
||||
spriteSheetPath: '/generated/playable/idle.png',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: storyRole.id,
|
||||
name: storyRole.name,
|
||||
title: '结果页场景角色',
|
||||
role: '场景关键角色',
|
||||
description: '结果页确认的最新场景角色资产。',
|
||||
backstory: '测试',
|
||||
personality: '克制',
|
||||
motivation: '验证资产回写',
|
||||
combatStyle: '观察',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
imageSrc: '/generated/story/latest-master.png',
|
||||
generatedVisualAssetId: 'visual-story-latest',
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: landmark.id,
|
||||
name: landmark.name,
|
||||
description: '结果页确认的最新地点图。',
|
||||
dangerLevel: '中',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
imageSrc: '/generated/landmark/latest-scene.png',
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: landmark.id,
|
||||
title: '灯塔初章',
|
||||
summary: '结果页确认最新分幕图。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [landmark.id],
|
||||
acts: [
|
||||
{
|
||||
id: `${landmark.id}-act-1`,
|
||||
sceneId: landmark.id,
|
||||
title: '第一幕',
|
||||
summary: '第一幕',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/generated/scene/act-1-latest.png',
|
||||
backgroundAssetId: 'scene-asset-latest',
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '验证分幕图回写',
|
||||
transitionHook: '进入下一幕',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
},
|
||||
});
|
||||
const operation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
|
||||
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!;
|
||||
const syncedPlayable = profile.playableNpcs.find(
|
||||
(entry) => entry.id === playableRole.id,
|
||||
);
|
||||
const syncedStory = profile.storyNpcs.find((entry) => entry.id === storyRole.id);
|
||||
const syncedLandmark = profile.landmarks.find((entry) => entry.id === landmark.id);
|
||||
const syncedSceneAct = profile.sceneChapters[0]?.acts[0];
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(syncedPlayable?.imageSrc, '/generated/playable/latest-master.png');
|
||||
assert.equal(syncedPlayable?.generatedVisualAssetId, 'visual-playable-latest');
|
||||
assert.equal(syncedPlayable?.generatedAnimationSetId, 'anim-playable-latest');
|
||||
assert.deepEqual(syncedPlayable?.animationMap, {
|
||||
idle: {
|
||||
spriteSheetPath: '/generated/playable/idle.png',
|
||||
},
|
||||
});
|
||||
assert.equal(syncedStory?.imageSrc, '/generated/story/latest-master.png');
|
||||
assert.equal(syncedStory?.generatedVisualAssetId, 'visual-story-latest');
|
||||
assert.equal(syncedLandmark?.imageSrc, '/generated/landmark/latest-scene.png');
|
||||
assert.equal(syncedSceneAct?.backgroundImageSrc, '/generated/scene/act-1-latest.png');
|
||||
assert.equal(syncedSceneAct?.backgroundAssetId, 'scene-asset-latest');
|
||||
});
|
||||
|
||||
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository, rpgWorldProfileRepository } =
|
||||
createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
@@ -263,10 +552,10 @@ test('phase4 generate_characters appends story npcs and updates work summary cou
|
||||
[...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id),
|
||||
),
|
||||
].length;
|
||||
const workItems = await listCustomWorldWorkSummaries(userId, {
|
||||
runtimeRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const workItems = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
const draftItem = workItems.find((item) => item.sessionId === session.sessionId);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
@@ -284,8 +573,10 @@ test('phase4 generate_characters appends story npcs and updates work summary cou
|
||||
});
|
||||
|
||||
test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
@@ -323,3 +614,92 @@ test('phase4 generate_landmarks appends new landmark cards and checkpoints', asy
|
||||
);
|
||||
assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2);
|
||||
});
|
||||
|
||||
test('phase4 work summaries exclude library draft entries after phase3 downgrade', async () => {
|
||||
const { rpgAgentSessionRepository, rpgWorldProfileRepository } =
|
||||
createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase4-work-summary-phase3';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
|
||||
await rpgWorldProfileRepository.upsertOwnProfile(
|
||||
userId,
|
||||
'library-draft-1',
|
||||
{
|
||||
id: 'library-draft-1',
|
||||
name: '旧兼容草稿',
|
||||
subtitle: '仍保留在作品库',
|
||||
summary: '不应该继续出现在创作中心 works 聚合里。',
|
||||
playableNpcs: [],
|
||||
landmarks: [],
|
||||
},
|
||||
'玩家',
|
||||
);
|
||||
|
||||
const workItems = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
|
||||
assert.ok(workItems.some((item) => item.sessionId === session.sessionId));
|
||||
assert.equal(
|
||||
workItems.some((item) => item.profileId === 'library-draft-1'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('phase4 work summaries hide published agent sessions from draft lane and keep published entry enterable', async () => {
|
||||
const { rpgAgentSessionRepository, rpgWorldProfileRepository } =
|
||||
createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase4-work-summary-published';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
|
||||
await sessionStore.replaceDerivedState(userId, session.sessionId, {
|
||||
stage: 'published',
|
||||
qualityFindings: [],
|
||||
});
|
||||
await rpgWorldProfileRepository.upsertOwnProfile(
|
||||
userId,
|
||||
`agent-draft-${session.sessionId}`,
|
||||
{
|
||||
id: `agent-draft-${session.sessionId}`,
|
||||
name: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '已发布版本。',
|
||||
playableNpcs: [],
|
||||
landmarks: [],
|
||||
},
|
||||
'玩家',
|
||||
);
|
||||
await rpgWorldProfileRepository.publishOwnProfile(
|
||||
userId,
|
||||
`agent-draft-${session.sessionId}`,
|
||||
'玩家',
|
||||
);
|
||||
|
||||
const workItems = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
const draftItem = workItems.find((item) => item.sessionId === session.sessionId);
|
||||
const publishedItem = workItems.find(
|
||||
(item) => item.profileId === `agent-draft-${session.sessionId}`,
|
||||
);
|
||||
|
||||
assert.equal(draftItem, undefined);
|
||||
assert.equal(publishedItem?.status, 'published');
|
||||
assert.equal(publishedItem?.canEnterWorld, true);
|
||||
assert.equal(publishedItem?.publishReady, true);
|
||||
assert.equal(publishedItem?.blockerCount, 0);
|
||||
});
|
||||
|
||||
@@ -4,95 +4,15 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import type { UserRepositoryPort } from '../repositories/userRepository.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
const sessionsByUser = new Map<
|
||||
string,
|
||||
Map<string, CustomWorldSessionRecord>
|
||||
>();
|
||||
const profilesByUser = new Map<string, Record<string, unknown>[]>();
|
||||
|
||||
const getSessionBucket = (userId: string) => {
|
||||
const existing = sessionsByUser.get(userId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const nextBucket = new Map<string, CustomWorldSessionRecord>();
|
||||
sessionsByUser.set(userId, nextBucket);
|
||||
return nextBucket;
|
||||
};
|
||||
|
||||
return {
|
||||
async getSnapshot() {
|
||||
return null;
|
||||
},
|
||||
async putSnapshot(_userId, payload) {
|
||||
return payload;
|
||||
},
|
||||
async deleteSnapshot() {
|
||||
return undefined;
|
||||
},
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
return settings;
|
||||
},
|
||||
async listCustomWorldProfiles(userId) {
|
||||
return [...(profilesByUser.get(userId) ?? [])];
|
||||
},
|
||||
async upsertCustomWorldProfile(userId, profileId, profile) {
|
||||
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
||||
(item) => String(item.id ?? '') !== profileId,
|
||||
);
|
||||
current.unshift({
|
||||
...profile,
|
||||
id: profileId,
|
||||
});
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async deleteCustomWorldProfile(userId, profileId) {
|
||||
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
||||
(item) => String(item.id ?? '') !== profileId,
|
||||
);
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
async getCustomWorldSession(userId, sessionId) {
|
||||
return getSessionBucket(userId).get(sessionId) ?? null;
|
||||
},
|
||||
async upsertCustomWorldSession(userId, sessionId, session) {
|
||||
getSessionBucket(userId).set(
|
||||
sessionId,
|
||||
JSON.parse(JSON.stringify(session)),
|
||||
);
|
||||
return JSON.parse(JSON.stringify(session));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAutoAssetTestConfig(testName: string): AppConfig {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-agent-phase5-${testName}-`),
|
||||
@@ -171,6 +91,11 @@ function createAutoAssetTestConfig(testName: string): AppConfig {
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
accessCookieName: 'genarrative_access_session',
|
||||
accessCookieTtlSeconds: 7200,
|
||||
accessCookieSecure: false,
|
||||
accessCookieSameSite: 'Lax',
|
||||
accessCookiePath: '/',
|
||||
refreshCookieName: 'refresh_token',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
@@ -189,6 +114,36 @@ function createFallbackAutoAssetService(testName: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// 发布执行器当前通过 userRepository 读取作者展示名,这里用内存 stub 对齐主链接口。
|
||||
function createUserRepository(displayName = '测试玩家'): UserRepositoryPort {
|
||||
const now = '2026-04-21T00:00:00.000Z';
|
||||
|
||||
return {
|
||||
findByUsername: async () => null,
|
||||
findByPhoneNumber: async () => null,
|
||||
findById: async (userId) => ({
|
||||
id: userId,
|
||||
username: null,
|
||||
passwordHash: '',
|
||||
tokenVersion: 1,
|
||||
displayName,
|
||||
loginProvider: 'password',
|
||||
accountStatus: 'active',
|
||||
phoneNumber: null,
|
||||
phoneVerifiedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
create: async () => null,
|
||||
createPhoneUser: async () => null,
|
||||
createWechatPendingUser: async () => null,
|
||||
activatePendingWechatUser: async () => null,
|
||||
updatePhoneInfo: async () => null,
|
||||
deleteUser: async () => undefined,
|
||||
incrementTokenVersion: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForOperation(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
userId: string,
|
||||
@@ -274,9 +229,119 @@ async function createObjectRefiningSession(
|
||||
))!;
|
||||
}
|
||||
|
||||
async function createPublishReadySession(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
sessionStore: CustomWorldAgentSessionStore,
|
||||
userId: string,
|
||||
) {
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const profile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
|
||||
assert.ok(profile);
|
||||
assert.ok(profile.playableNpcs.length > 0);
|
||||
assert.ok(profile.storyNpcs.length > 0);
|
||||
assert.ok(profile.landmarks.length > 0);
|
||||
assert.ok(profile.sceneChapters.length > 0);
|
||||
|
||||
const publishReadyProfile = {
|
||||
...(session.draftProfile as Record<string, unknown>),
|
||||
camp: {
|
||||
...(profile.camp ?? {}),
|
||||
id: profile.camp?.id ?? 'camp-home',
|
||||
name: profile.camp?.name ?? '归潮营地',
|
||||
description: profile.camp?.description ?? '可供玩家整理线索的临时据点。',
|
||||
imageSrc: '/generated/camp/publish-ready.png',
|
||||
generatedSceneAssetId: 'scene-camp-publish-ready',
|
||||
generatedScenePrompt: '潮雾营地发布正式图',
|
||||
generatedSceneModel: 'test-scene-model',
|
||||
},
|
||||
playableNpcs: profile.playableNpcs.map((entry, index) => ({
|
||||
...entry,
|
||||
imageSrc:
|
||||
entry.imageSrc || `/generated/playable/publish-ready-${index + 1}.png`,
|
||||
generatedVisualAssetId:
|
||||
entry.generatedVisualAssetId || `visual-playable-publish-${index + 1}`,
|
||||
generatedAnimationSetId:
|
||||
entry.generatedAnimationSetId || `anim-playable-publish-${index + 1}`,
|
||||
})),
|
||||
storyNpcs: profile.storyNpcs.map((entry, index) => ({
|
||||
...entry,
|
||||
imageSrc:
|
||||
entry.imageSrc || `/generated/story/publish-ready-${index + 1}.png`,
|
||||
generatedVisualAssetId:
|
||||
entry.generatedVisualAssetId || `visual-story-publish-${index + 1}`,
|
||||
generatedAnimationSetId:
|
||||
entry.generatedAnimationSetId || `anim-story-publish-${index + 1}`,
|
||||
})),
|
||||
landmarks: profile.landmarks.map((entry, index) => ({
|
||||
...entry,
|
||||
imageSrc:
|
||||
entry.imageSrc || `/generated/landmark/publish-ready-${index + 1}.png`,
|
||||
generatedSceneAssetId:
|
||||
entry.generatedSceneAssetId || `scene-landmark-publish-${index + 1}`,
|
||||
generatedScenePrompt:
|
||||
entry.generatedScenePrompt || `地点 ${entry.name} 的正式场景图`,
|
||||
generatedSceneModel:
|
||||
entry.generatedSceneModel || 'test-scene-model',
|
||||
})),
|
||||
sceneChapters: profile.sceneChapters.map((chapter) => ({
|
||||
...chapter,
|
||||
linkedThreadIds:
|
||||
chapter.linkedThreadIds.length > 0
|
||||
? chapter.linkedThreadIds
|
||||
: [profile.threads[0]?.id ?? 'thread-publish-ready'],
|
||||
acts: chapter.acts.map((act, index) => ({
|
||||
...act,
|
||||
encounterNpcIds:
|
||||
act.encounterNpcIds.length > 0
|
||||
? act.encounterNpcIds
|
||||
: [profile.storyNpcs[0]?.id ?? profile.playableNpcs[0]?.id ?? 'role-publish-ready'],
|
||||
primaryNpcId:
|
||||
act.primaryNpcId ||
|
||||
act.encounterNpcIds[0] ||
|
||||
profile.storyNpcs[0]?.id ||
|
||||
profile.playableNpcs[0]?.id ||
|
||||
'role-publish-ready',
|
||||
backgroundImageSrc:
|
||||
act.backgroundImageSrc ||
|
||||
`/generated/scene/publish-ready-${chapter.id}-${index + 1}.png`,
|
||||
backgroundAssetId:
|
||||
act.backgroundAssetId || `scene-act-publish-${chapter.id}-${index + 1}`,
|
||||
})),
|
||||
})),
|
||||
chapters: profile.chapters,
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
await sessionStore.replaceDerivedState(userId, session.sessionId, {
|
||||
stage: 'ready_to_publish',
|
||||
draftProfile: publishReadyProfile,
|
||||
draftCards: session.draftCards,
|
||||
qualityFindings: [],
|
||||
focusCardId: session.focusCardId,
|
||||
assetCoverage: session.assetCoverage,
|
||||
});
|
||||
|
||||
const publishReadySession = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
assert.equal(publishReadySession?.stage, 'ready_to_publish');
|
||||
assert.equal(
|
||||
publishReadySession?.supportedActions.find(
|
||||
(entry) => entry.action === 'publish_world',
|
||||
)?.enabled,
|
||||
true,
|
||||
);
|
||||
|
||||
return publishReadySession!;
|
||||
}
|
||||
|
||||
test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('generate-role-assets'),
|
||||
@@ -312,6 +377,18 @@ test('phase5 generate_role_assets only allows a single role and moves session in
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(snapshot?.stage, 'visual_refining');
|
||||
assert.equal(snapshot?.focusCardId, characterIds[0]);
|
||||
assert.equal(
|
||||
snapshot?.supportedActions?.find(
|
||||
(entry) => entry.action === 'generate_role_assets',
|
||||
)?.enabled,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot?.supportedActions?.find(
|
||||
(entry) => entry.action === 'sync_role_assets',
|
||||
)?.enabled,
|
||||
true,
|
||||
);
|
||||
assert.ok(
|
||||
snapshot?.messages.some(
|
||||
(message) =>
|
||||
@@ -326,8 +403,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in
|
||||
});
|
||||
|
||||
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('sync-role-assets'),
|
||||
@@ -404,7 +483,7 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
|
||||
?.idle?.basePath,
|
||||
'/generated/characters/shenli/idle',
|
||||
);
|
||||
const syncedSkillIds = syncedRole?.skills.map((skill) => skill.id) ?? [];
|
||||
const syncedSkillIds = syncedRole?.skills?.map((skill) => skill.id) ?? [];
|
||||
assert.ok(syncedSkillIds.length > 0);
|
||||
assert.equal(syncedAssetSummary?.status, 'animations_ready');
|
||||
assert.deepEqual(
|
||||
@@ -421,3 +500,484 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
|
||||
);
|
||||
assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2);
|
||||
});
|
||||
|
||||
test('phase5 publish_world persists published profile and moves session into published stage', async () => {
|
||||
const { rpgAgentSessionRepository, rpgWorldProfileRepository } =
|
||||
createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('publish-world'),
|
||||
rpgWorldProfileRepository,
|
||||
userRepository: createUserRepository('发布测试玩家'),
|
||||
});
|
||||
const userId = 'user-phase5-publish-world';
|
||||
const session = await createPublishReadySession(
|
||||
orchestrator,
|
||||
sessionStore,
|
||||
userId,
|
||||
);
|
||||
|
||||
const response = await orchestrator.executeAction(userId, session.sessionId, {
|
||||
action: 'publish_world',
|
||||
});
|
||||
const operation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
const profiles = await rpgWorldProfileRepository.listOwnProfiles(userId);
|
||||
const publishedEntry = profiles.find(
|
||||
(entry) => entry.profileId === `agent-draft-${session.sessionId}`,
|
||||
);
|
||||
const latestRecord = await sessionStore.get(userId, session.sessionId);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(snapshot?.stage, 'published');
|
||||
assert.equal(snapshot?.resultPreview?.publishReady, true);
|
||||
assert.equal(snapshot?.resultPreview?.canEnterWorld, true);
|
||||
assert.deepEqual(snapshot?.resultPreview?.blockers ?? [], []);
|
||||
assert.equal(
|
||||
snapshot?.supportedActions.find(
|
||||
(entry) => entry.action === 'publish_world',
|
||||
)?.enabled,
|
||||
false,
|
||||
);
|
||||
assert.equal(publishedEntry?.visibility, 'published');
|
||||
assert.equal(publishedEntry?.authorDisplayName, '发布测试玩家');
|
||||
assert.equal(publishedEntry?.profile.id, `agent-draft-${session.sessionId}`);
|
||||
assert.ok(
|
||||
snapshot?.messages.some(
|
||||
(message) =>
|
||||
message.kind === 'action_result' &&
|
||||
message.text.includes('已正式发布'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
latestRecord?.checkpoints.some(
|
||||
(checkpoint) =>
|
||||
checkpoint.label.includes('发布世界') &&
|
||||
checkpoint.snapshot?.stage === 'published',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('phase5 generate_scene_assets prepares scene studio and sync_scene_assets writes back camp asset fields', async () => {
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('sync-scene-assets'),
|
||||
});
|
||||
const userId = 'user-phase5-sync-scene-assets';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const campCard = session.draftCards.find((card) => card.kind === 'camp');
|
||||
|
||||
assert.ok(campCard);
|
||||
|
||||
const prepareResponse = await orchestrator.executeAction(
|
||||
userId,
|
||||
session.sessionId,
|
||||
{
|
||||
action: 'generate_scene_assets',
|
||||
sceneIds: [campCard!.id],
|
||||
},
|
||||
);
|
||||
const prepareOperation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
prepareResponse.operation.operationId,
|
||||
);
|
||||
const preparedSnapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
assert.equal(prepareOperation?.status, 'completed');
|
||||
assert.equal(preparedSnapshot?.stage, 'visual_refining');
|
||||
assert.equal(preparedSnapshot?.focusCardId, campCard!.id);
|
||||
assert.ok(
|
||||
preparedSnapshot?.messages.some(
|
||||
(message) =>
|
||||
message.kind === 'action_result' &&
|
||||
message.text.includes('场景图工坊'),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await orchestrator.executeAction(userId, session.sessionId, {
|
||||
action: 'sync_scene_assets',
|
||||
sceneId: campCard!.id,
|
||||
sceneKind: 'camp',
|
||||
imageSrc: '/generated/scenes/camp-home.png',
|
||||
generatedSceneAssetId: 'scene-camp-home-1',
|
||||
generatedScenePrompt: '潮雾中的灯塔营地',
|
||||
generatedSceneModel: 'test-scene-model',
|
||||
});
|
||||
const operation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
|
||||
const latestRecord = await sessionStore.get(userId, session.sessionId);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(snapshot?.stage, 'visual_refining');
|
||||
assert.equal(snapshot?.focusCardId, campCard!.id);
|
||||
assert.equal(profile?.camp?.imageSrc, '/generated/scenes/camp-home.png');
|
||||
assert.equal(profile?.camp?.generatedSceneAssetId, 'scene-camp-home-1');
|
||||
assert.equal(profile?.camp?.generatedScenePrompt, '潮雾中的灯塔营地');
|
||||
assert.equal(profile?.camp?.generatedSceneModel, 'test-scene-model');
|
||||
assert.ok(
|
||||
profile?.sceneChapters.every((chapter) =>
|
||||
chapter.sceneId === campCard!.id
|
||||
? chapter.acts.every(
|
||||
(act) =>
|
||||
act.backgroundImageSrc === '/generated/scenes/camp-home.png' &&
|
||||
act.backgroundAssetId === 'scene-camp-home-1',
|
||||
)
|
||||
: true,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
snapshot?.messages.some(
|
||||
(message) =>
|
||||
message.kind === 'action_result' &&
|
||||
message.text.includes('场景图写回草稿'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
latestRecord?.checkpoints.some(
|
||||
(checkpoint) =>
|
||||
checkpoint.label.includes('同步场景资产') && Boolean(checkpoint.snapshot),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('phase5 expand_long_tail appends characters and landmarks then moves into long_tail_review', async () => {
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('expand-long-tail'),
|
||||
});
|
||||
const userId = 'user-phase5-expand-long-tail';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
|
||||
const baselineCharacterCount = [
|
||||
...new Set(
|
||||
[...baselineProfile.playableNpcs, ...baselineProfile.storyNpcs].map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
),
|
||||
].length;
|
||||
const baselineLandmarkCount = baselineProfile.landmarks.length;
|
||||
|
||||
const response = await orchestrator.executeAction(userId, session.sessionId, {
|
||||
action: 'expand_long_tail',
|
||||
});
|
||||
const operation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!;
|
||||
const nextCharacterCount = [
|
||||
...new Set(
|
||||
[...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id),
|
||||
),
|
||||
].length;
|
||||
const latestRecord = await sessionStore.get(userId, session.sessionId);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(snapshot?.stage, 'long_tail_review');
|
||||
assert.ok(nextCharacterCount >= baselineCharacterCount + 2);
|
||||
assert.ok(profile.landmarks.length >= baselineLandmarkCount + 2);
|
||||
assert.ok(
|
||||
snapshot?.messages.some(
|
||||
(message) =>
|
||||
message.kind === 'action_result' &&
|
||||
message.text.includes('长尾角色'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
latestRecord?.checkpoints.some(
|
||||
(checkpoint) =>
|
||||
checkpoint.label.includes('扩展长尾') && Boolean(checkpoint.snapshot),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('phase5 publish_world blocks incomplete draft and publishes complete world into repository', async () => {
|
||||
const { rpgAgentSessionRepository, rpgWorldProfileRepository } =
|
||||
createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('publish-world'),
|
||||
rpgWorldProfileRepository,
|
||||
userRepository: createUserRepository(),
|
||||
});
|
||||
const userId = 'user-phase5-publish-world';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
|
||||
const blockedResponse = await orchestrator.executeAction(
|
||||
userId,
|
||||
session.sessionId,
|
||||
{
|
||||
action: 'publish_world',
|
||||
},
|
||||
);
|
||||
const blockedOperation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
blockedResponse.operation.operationId,
|
||||
);
|
||||
const blockedSnapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
assert.equal(blockedOperation?.status, 'failed');
|
||||
assert.ok(
|
||||
blockedSnapshot?.messages.some(
|
||||
(message) =>
|
||||
message.kind === 'warning' && message.text.includes('当前世界还不能发布'),
|
||||
),
|
||||
);
|
||||
|
||||
const profile = normalizeFoundationDraftProfile(blockedSnapshot?.draftProfile)!;
|
||||
const roleIds = [...profile.playableNpcs, ...profile.storyNpcs].map(
|
||||
(entry) => entry.id,
|
||||
);
|
||||
|
||||
for (const roleId of roleIds) {
|
||||
const syncRoleResponse = await orchestrator.executeAction(
|
||||
userId,
|
||||
session.sessionId,
|
||||
{
|
||||
action: 'sync_role_assets',
|
||||
roleId,
|
||||
portraitPath: `/generated/characters/${roleId}.png`,
|
||||
generatedVisualAssetId: `visual-${roleId}`,
|
||||
generatedAnimationSetId: `animation-${roleId}`,
|
||||
animationMap: {
|
||||
run: { basePath: `/generated/characters/${roleId}/run` },
|
||||
attack: { basePath: `/generated/characters/${roleId}/attack` },
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
syncRoleResponse.operation.operationId,
|
||||
);
|
||||
}
|
||||
|
||||
const latestSnapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
const latestProfile = normalizeFoundationDraftProfile(latestSnapshot?.draftProfile)!;
|
||||
const sceneTargets = [
|
||||
{
|
||||
sceneId: latestProfile.camp?.id ?? 'camp-home',
|
||||
sceneKind: 'camp' as const,
|
||||
},
|
||||
...latestProfile.landmarks.map((entry) => ({
|
||||
sceneId: entry.id,
|
||||
sceneKind: 'landmark' as const,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const sceneTarget of sceneTargets) {
|
||||
const syncSceneResponse = await orchestrator.executeAction(
|
||||
userId,
|
||||
session.sessionId,
|
||||
{
|
||||
action: 'sync_scene_assets',
|
||||
sceneId: sceneTarget.sceneId,
|
||||
sceneKind: sceneTarget.sceneKind,
|
||||
imageSrc: `/generated/scenes/${sceneTarget.sceneId}.png`,
|
||||
generatedSceneAssetId: `scene-${sceneTarget.sceneId}`,
|
||||
generatedScenePrompt: `${sceneTarget.sceneId} 场景图`,
|
||||
generatedSceneModel: 'test-scene-model',
|
||||
},
|
||||
);
|
||||
await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
syncSceneResponse.operation.operationId,
|
||||
);
|
||||
}
|
||||
|
||||
const publishResponse = await orchestrator.executeAction(
|
||||
userId,
|
||||
session.sessionId,
|
||||
{
|
||||
action: 'publish_world',
|
||||
},
|
||||
);
|
||||
const publishOperation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
publishResponse.operation.operationId,
|
||||
);
|
||||
const publishedSnapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
const libraryEntries = await rpgWorldProfileRepository.listOwnProfiles(userId);
|
||||
const publishedEntry = libraryEntries.find(
|
||||
(entry) => entry.visibility === 'published',
|
||||
);
|
||||
const latestRecord = await sessionStore.get(userId, session.sessionId);
|
||||
|
||||
assert.equal(blockedOperation?.status, 'failed');
|
||||
assert.match(blockedOperation?.error ?? '', /缺少正式主图|缺少正式场景图|缺少章节草稿/u);
|
||||
assert.equal(blockedSnapshot?.resultPreview?.publishReady, false);
|
||||
assert.ok((blockedSnapshot?.resultPreview?.blockers?.length ?? 0) > 0);
|
||||
assert.equal(publishOperation?.status, 'completed');
|
||||
assert.equal(publishedSnapshot?.stage, 'published');
|
||||
assert.equal(publishedSnapshot?.resultPreview?.publishReady, true);
|
||||
assert.equal(publishedSnapshot?.resultPreview?.canEnterWorld, true);
|
||||
assert.ok(publishedSnapshot?.qualityFindings.every((entry) => entry.severity !== 'blocker'));
|
||||
assert.ok(
|
||||
publishedSnapshot?.messages.some(
|
||||
(message) =>
|
||||
message.kind === 'action_result' &&
|
||||
message.text.includes('已正式发布'),
|
||||
),
|
||||
);
|
||||
assert.ok(publishedEntry);
|
||||
assert.equal(publishedEntry?.profileId, `agent-draft-${session.sessionId}`);
|
||||
assert.equal(publishedEntry?.authorDisplayName, '测试玩家');
|
||||
assert.ok(
|
||||
latestRecord?.checkpoints.some(
|
||||
(checkpoint) =>
|
||||
checkpoint.label.includes('发布世界') &&
|
||||
checkpoint.snapshot?.stage === 'published',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('phase5 revert_checkpoint restores previous draft snapshot', async () => {
|
||||
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('revert-checkpoint'),
|
||||
});
|
||||
const userId = 'user-phase5-revert-checkpoint';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
|
||||
const updateResponse = await orchestrator.executeAction(
|
||||
userId,
|
||||
session.sessionId,
|
||||
{
|
||||
action: 'update_draft_card',
|
||||
cardId: 'world-foundation',
|
||||
sections: [
|
||||
{
|
||||
sectionId: 'summary',
|
||||
value: '回滚测试摘要版本',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
updateResponse.operation.operationId,
|
||||
);
|
||||
|
||||
const afterUpdateRecord = await sessionStore.get(userId, session.sessionId);
|
||||
const restorableCheckpoint = [...(afterUpdateRecord?.checkpoints ?? [])]
|
||||
.reverse()
|
||||
.find((checkpoint) => Boolean(checkpoint.snapshot));
|
||||
|
||||
assert.ok(restorableCheckpoint);
|
||||
|
||||
const syncRoleResponse = await orchestrator.executeAction(
|
||||
userId,
|
||||
session.sessionId,
|
||||
{
|
||||
action: 'sync_role_assets',
|
||||
roleId:
|
||||
normalizeFoundationDraftProfile(
|
||||
(await orchestrator.getSessionSnapshot(userId, session.sessionId))
|
||||
?.draftProfile,
|
||||
)?.playableNpcs[0]?.id ?? 'unknown-role',
|
||||
portraitPath: '/generated/characters/revert-test.png',
|
||||
generatedVisualAssetId: 'visual-revert-test',
|
||||
generatedAnimationSetId: 'animation-revert-test',
|
||||
animationMap: {
|
||||
run: { basePath: '/generated/characters/revert-test/run' },
|
||||
attack: { basePath: '/generated/characters/revert-test/attack' },
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
syncRoleResponse.operation.operationId,
|
||||
);
|
||||
|
||||
const response = await orchestrator.executeAction(userId, session.sessionId, {
|
||||
action: 'revert_checkpoint',
|
||||
checkpointId: restorableCheckpoint!.checkpointId,
|
||||
});
|
||||
const operation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
session.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(profile?.summary, '回滚测试摘要版本');
|
||||
assert.ok(
|
||||
snapshot?.messages.some(
|
||||
(message) =>
|
||||
message.kind === 'action_result' &&
|
||||
message.text.includes('已恢复到检查点'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
256
server-node/src/services/customWorldAgentPublishingService.ts
Normal file
256
server-node/src/services/customWorldAgentPublishingService.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js';
|
||||
import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
|
||||
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 hasGeneratedSceneAsset(
|
||||
value: unknown,
|
||||
) {
|
||||
return Boolean(toText((value as Record<string, unknown> | null)?.generatedSceneAssetId));
|
||||
}
|
||||
|
||||
export class CustomWorldAgentPublishingService {
|
||||
constructor(
|
||||
private readonly rpgWorldProfileRepository: RpgWorldProfileRepositoryPort,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Phase4 需要把“能不能发布”收成可读的后端真相,
|
||||
* 这样结果页、works 和 publish executor 才能共享同一套 blocker 语义。
|
||||
*/
|
||||
evaluatePublishReadiness(params: {
|
||||
sessionId: string;
|
||||
draftProfile: unknown;
|
||||
qualityFindings?: Array<{
|
||||
severity: 'info' | 'warning' | 'blocker';
|
||||
code?: string;
|
||||
targetId?: string | null;
|
||||
message: string;
|
||||
}>;
|
||||
}) {
|
||||
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
if (!draftProfile) {
|
||||
return {
|
||||
profileId: `agent-draft-${params.sessionId}`,
|
||||
blockers: [
|
||||
{
|
||||
severity: 'blocker' as const,
|
||||
code: 'publish_empty_draft',
|
||||
message: '当前世界草稿为空,无法发布。',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const findings = params.qualityFindings ?? [];
|
||||
const blockers = findings.filter((entry) => entry.severity === 'blocker');
|
||||
const readinessBlockers = [...blockers];
|
||||
|
||||
if (!draftProfile.worldHook.trim()) {
|
||||
readinessBlockers.push({
|
||||
severity: 'blocker',
|
||||
code: 'publish_missing_world_hook',
|
||||
message: '当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。',
|
||||
});
|
||||
}
|
||||
|
||||
if (!draftProfile.playerPremise.trim()) {
|
||||
readinessBlockers.push({
|
||||
severity: 'blocker',
|
||||
code: 'publish_missing_player_premise',
|
||||
message: '当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
draftProfile.coreConflicts.length <= 0 ||
|
||||
!draftProfile.coreConflicts.some((entry) => toText(entry))
|
||||
) {
|
||||
readinessBlockers.push({
|
||||
severity: 'blocker',
|
||||
code: 'publish_missing_core_conflict',
|
||||
message: '当前世界缺少核心冲突,发布前需要先补齐核心冲突。',
|
||||
});
|
||||
}
|
||||
|
||||
if ((draftProfile.chapters?.length ?? 0) <= 0) {
|
||||
readinessBlockers.push({
|
||||
severity: 'blocker',
|
||||
code: 'publish_missing_main_chapter',
|
||||
message: '当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。',
|
||||
});
|
||||
}
|
||||
|
||||
const firstSceneActExists = draftProfile.sceneChapters.some(
|
||||
(chapter) => chapter.acts.length > 0,
|
||||
);
|
||||
if (!firstSceneActExists) {
|
||||
readinessBlockers.push({
|
||||
severity: 'blocker',
|
||||
code: 'publish_missing_first_act',
|
||||
message: '当前世界还没有主线第一幕,发布前至少要保留一个场景幕。',
|
||||
});
|
||||
}
|
||||
|
||||
const missingRoleAssets = [
|
||||
...draftProfile.playableNpcs,
|
||||
...draftProfile.storyNpcs,
|
||||
].filter(
|
||||
(role) =>
|
||||
!toText(role.generatedVisualAssetId) ||
|
||||
!toText(role.generatedAnimationSetId),
|
||||
);
|
||||
if (missingRoleAssets.length > 0) {
|
||||
readinessBlockers.push({
|
||||
severity: 'blocker',
|
||||
code: 'publish_role_assets_incomplete',
|
||||
targetId: missingRoleAssets[0]?.id ?? null,
|
||||
message: '仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
|
||||
});
|
||||
}
|
||||
|
||||
if (!draftProfile.camp || !toText(draftProfile.camp.imageSrc) || !hasGeneratedSceneAsset(draftProfile.camp)) {
|
||||
readinessBlockers.push({
|
||||
severity: 'blocker',
|
||||
code: 'publish_camp_scene_missing',
|
||||
targetId: draftProfile.camp?.id ?? null,
|
||||
message: '营地还缺少正式场景图资产,发布前需要先确认营地图。',
|
||||
});
|
||||
}
|
||||
|
||||
const missingLandmarkScenes = draftProfile.landmarks.filter(
|
||||
(landmark) =>
|
||||
!toText(landmark.imageSrc) || !hasGeneratedSceneAsset(landmark),
|
||||
);
|
||||
if (missingLandmarkScenes.length > 0) {
|
||||
readinessBlockers.push({
|
||||
severity: 'blocker',
|
||||
code: 'publish_landmark_scene_missing',
|
||||
targetId: missingLandmarkScenes[0]?.id ?? null,
|
||||
message: '仍有地点缺少正式场景图资产,发布前需要先补齐地点图。',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
profileId:
|
||||
toText(
|
||||
(params.draftProfile as Record<string, unknown> | null)
|
||||
?.legacyResultProfile?.id,
|
||||
) || `agent-draft-${params.sessionId}`,
|
||||
blockers: readinessBlockers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase4 统一复用发布门禁摘要,避免 preview / works / enter-world 各自拼 blocker 口径。
|
||||
*/
|
||||
summarizePublishGate(params: {
|
||||
sessionId: string;
|
||||
stage?: string | null;
|
||||
draftProfile: unknown;
|
||||
qualityFindings?: Array<{
|
||||
severity: 'info' | 'warning' | 'blocker';
|
||||
code?: string;
|
||||
targetId?: string | null;
|
||||
message: string;
|
||||
}>;
|
||||
}) {
|
||||
const readiness = this.evaluatePublishReadiness(params);
|
||||
const blockers = readiness.blockers.map((entry) => ({
|
||||
id:
|
||||
typeof entry.code === 'string' && entry.code.trim()
|
||||
? entry.code
|
||||
: `publish-blocker-${entry.message}`,
|
||||
code:
|
||||
typeof entry.code === 'string' && entry.code.trim()
|
||||
? entry.code
|
||||
: 'publish_blocker',
|
||||
message: entry.message,
|
||||
}));
|
||||
|
||||
return {
|
||||
profileId: readiness.profileId,
|
||||
blockers,
|
||||
blockerCount: blockers.length,
|
||||
publishReady: blockers.length === 0,
|
||||
canEnterWorld:
|
||||
String(params.stage ?? '').trim() === 'published' && blockers.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
buildPublishReadiness(params: {
|
||||
sessionId: string;
|
||||
draftProfile: unknown;
|
||||
qualityFindings?: Array<{
|
||||
severity: 'info' | 'warning' | 'blocker';
|
||||
code?: string;
|
||||
targetId?: string | null;
|
||||
message: string;
|
||||
}>;
|
||||
}) {
|
||||
const readiness = this.evaluatePublishReadiness(params);
|
||||
if (readiness.blockers.length > 0) {
|
||||
throw new Error(
|
||||
`当前世界仍有 ${readiness.blockers.length} 个 blocker,暂时不能发布:${readiness.blockers
|
||||
.map((entry) => entry.message)
|
||||
.join(';')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
profileId: readiness.profileId,
|
||||
};
|
||||
}
|
||||
|
||||
async publishSessionDraft(params: {
|
||||
userId: string;
|
||||
authorDisplayName: string;
|
||||
sessionId: string;
|
||||
draftProfile: Record<string, unknown>;
|
||||
qualityFindings?: Array<{
|
||||
severity: 'info' | 'warning' | 'blocker';
|
||||
message: string;
|
||||
}>;
|
||||
}) {
|
||||
const readiness = this.buildPublishReadiness({
|
||||
sessionId: params.sessionId,
|
||||
draftProfile: params.draftProfile,
|
||||
qualityFindings: params.qualityFindings,
|
||||
});
|
||||
const publishedProfile = buildRpgCreationPreviewProfileFromDraftProfile({
|
||||
sessionId: params.sessionId,
|
||||
draftProfile: params.draftProfile,
|
||||
profileId: readiness.profileId,
|
||||
});
|
||||
|
||||
await this.rpgWorldProfileRepository.upsertOwnProfile(
|
||||
params.userId,
|
||||
readiness.profileId,
|
||||
publishedProfile as unknown as Record<string, unknown>,
|
||||
params.authorDisplayName,
|
||||
);
|
||||
|
||||
const mutation = await this.rpgWorldProfileRepository.publishOwnProfile(
|
||||
params.userId,
|
||||
readiness.profileId,
|
||||
params.authorDisplayName,
|
||||
);
|
||||
if (!mutation) {
|
||||
throw new Error('世界发布失败,未找到目标作品。');
|
||||
}
|
||||
|
||||
return {
|
||||
profileId: readiness.profileId,
|
||||
publishedProfile,
|
||||
mutation,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldAgentStage,
|
||||
CustomWorldAssetCoverageSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
|
||||
export type CustomWorldAgentQualityFinding =
|
||||
CustomWorldAgentSessionSnapshot['qualityFindings'][number];
|
||||
|
||||
const QUALITY_GATE_STAGES = new Set<CustomWorldAgentStage>([
|
||||
'object_refining',
|
||||
'visual_refining',
|
||||
'long_tail_review',
|
||||
'ready_to_publish',
|
||||
]);
|
||||
|
||||
export class CustomWorldAgentQualityGateService {
|
||||
// 当前先把最核心的阻断项和提醒项独立收口,后续 publish gate 可以直接复用同一套 finding。
|
||||
buildQualityFindings(params: {
|
||||
draftProfile: unknown;
|
||||
assetCoverage?: CustomWorldAssetCoverageSummary | null;
|
||||
stage?: CustomWorldAgentStage;
|
||||
}): CustomWorldAgentQualityFinding[] {
|
||||
if (params.stage && !QUALITY_GATE_STAGES.has(params.stage)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const profile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
if (!profile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const findings: CustomWorldAgentQualityFinding[] = [];
|
||||
const totalRoleCount = [
|
||||
...new Set(
|
||||
[...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id),
|
||||
),
|
||||
].length;
|
||||
|
||||
if (totalRoleCount === 0) {
|
||||
findings.push({
|
||||
id: 'missing-core-roles',
|
||||
severity: 'blocker',
|
||||
code: 'missing_core_roles',
|
||||
message: '当前世界底稿还没有任何角色,暂时无法进入发布前收口阶段。',
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.landmarks.length === 0) {
|
||||
findings.push({
|
||||
id: 'missing-core-landmarks',
|
||||
severity: 'blocker',
|
||||
code: 'missing_core_landmarks',
|
||||
message: '当前世界底稿还没有任何地点,至少需要补出一处关键地点。',
|
||||
});
|
||||
}
|
||||
|
||||
if (!profile.playerGoal.trim()) {
|
||||
findings.push({
|
||||
id: 'missing-player-goal',
|
||||
severity: 'warning',
|
||||
code: 'missing_player_goal',
|
||||
message: '玩家目标还不够明确,后续进入结果页后建议优先补齐可执行目标。',
|
||||
});
|
||||
}
|
||||
|
||||
if (params.assetCoverage && !params.assetCoverage.allRoleAssetsReady) {
|
||||
findings.push({
|
||||
id: 'role-assets-pending',
|
||||
severity: 'warning',
|
||||
code: 'role_assets_pending',
|
||||
message: '仍有角色资产未完全补齐,结果页可继续补主图与动作资源。',
|
||||
});
|
||||
}
|
||||
|
||||
if (params.assetCoverage && !params.assetCoverage.allSceneAssetsReady) {
|
||||
findings.push({
|
||||
id: 'scene-assets-pending',
|
||||
severity: 'warning',
|
||||
code: 'scene_assets_pending',
|
||||
message: '仍有场景分幕图未补齐,后续结果页进入发布前需要继续完善。',
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldProfileRecord,
|
||||
CustomWorldSessionRecord,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import { extractCustomWorldLibraryMetadata } from '../repositories/customWorldLibraryMetadata.js';
|
||||
import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js';
|
||||
import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js';
|
||||
|
||||
type StoredProfileEntry = CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
||||
type SeedSessionRecord = CustomWorldSessionRecord & { userId: string };
|
||||
|
||||
function cloneRepositoryValue<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function ensureProfileRecord(
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
): CustomWorldProfileRecord {
|
||||
return {
|
||||
...cloneRepositoryValue(profile),
|
||||
id: profileId,
|
||||
} as CustomWorldProfileRecord;
|
||||
}
|
||||
|
||||
function buildProfileEntry(params: {
|
||||
userId: string;
|
||||
profileId: string;
|
||||
profile: Record<string, unknown>;
|
||||
authorDisplayName: string;
|
||||
visibility: 'draft' | 'published';
|
||||
updatedAt: string;
|
||||
publishedAt: string | null;
|
||||
}) {
|
||||
const profileRecord = ensureProfileRecord(params.profileId, params.profile);
|
||||
const metadata = extractCustomWorldLibraryMetadata(profileRecord);
|
||||
|
||||
return {
|
||||
ownerUserId: params.userId,
|
||||
profileId: params.profileId,
|
||||
profile: profileRecord,
|
||||
visibility: params.visibility,
|
||||
publishedAt: params.visibility === 'published' ? params.publishedAt : null,
|
||||
updatedAt: params.updatedAt,
|
||||
authorDisplayName: params.authorDisplayName || '玩家',
|
||||
worldName: metadata.worldName,
|
||||
subtitle: metadata.subtitle,
|
||||
summaryText: metadata.summaryText,
|
||||
coverImageSrc: metadata.coverImageSrc,
|
||||
themeMode: metadata.themeMode,
|
||||
playableNpcCount: metadata.playableNpcCount,
|
||||
landmarkCount: metadata.landmarkCount,
|
||||
} satisfies StoredProfileEntry;
|
||||
}
|
||||
|
||||
function toGalleryCard(entry: StoredProfileEntry): CustomWorldGalleryCard {
|
||||
const { profile: _profile, ...card } = entry;
|
||||
return cloneRepositoryValue(card);
|
||||
}
|
||||
|
||||
function sortEntriesByUpdatedAt<T extends { updatedAt: string }>(entries: T[]) {
|
||||
return [...entries].sort((left, right) =>
|
||||
right.updatedAt.localeCompare(left.updatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这组内存仓储 helper 让 phase2~5 与 works 集成测试直接依赖工作包 F 拆出来的领域端口,
|
||||
* 避免继续把 RuntimeRepositoryPort 当成 session/profile 的测试替身。
|
||||
*/
|
||||
export function createInMemoryRpgWorldRepositoryPorts(options?: {
|
||||
sessionRecords?: SeedSessionRecord[];
|
||||
profileEntries?: Array<CustomWorldLibraryEntry<CustomWorldProfileRecord>>;
|
||||
}) {
|
||||
const sessionsByUser = new Map<string, Map<string, CustomWorldSessionRecord>>();
|
||||
const profilesByUser = new Map<string, Map<string, StoredProfileEntry>>();
|
||||
|
||||
const ensureSessionBucket = (userId: string) => {
|
||||
const currentBucket = sessionsByUser.get(userId);
|
||||
if (currentBucket) {
|
||||
return currentBucket;
|
||||
}
|
||||
|
||||
const nextBucket = new Map<string, CustomWorldSessionRecord>();
|
||||
sessionsByUser.set(userId, nextBucket);
|
||||
return nextBucket;
|
||||
};
|
||||
|
||||
const ensureProfileBucket = (userId: string) => {
|
||||
const currentBucket = profilesByUser.get(userId);
|
||||
if (currentBucket) {
|
||||
return currentBucket;
|
||||
}
|
||||
|
||||
const nextBucket = new Map<string, StoredProfileEntry>();
|
||||
profilesByUser.set(userId, nextBucket);
|
||||
return nextBucket;
|
||||
};
|
||||
|
||||
const listOwnEntries = (userId: string) =>
|
||||
sortEntriesByUpdatedAt([...ensureProfileBucket(userId).values()]).map((entry) =>
|
||||
cloneRepositoryValue(entry),
|
||||
);
|
||||
|
||||
options?.sessionRecords?.forEach((record) => {
|
||||
ensureSessionBucket(record.userId).set(
|
||||
record.sessionId,
|
||||
cloneRepositoryValue(record),
|
||||
);
|
||||
});
|
||||
|
||||
options?.profileEntries?.forEach((entry) => {
|
||||
ensureProfileBucket(entry.ownerUserId).set(
|
||||
entry.profileId,
|
||||
cloneRepositoryValue(entry),
|
||||
);
|
||||
});
|
||||
|
||||
const rpgAgentSessionRepository: RpgAgentSessionRepositoryPort = {
|
||||
async listSessions(userId: string) {
|
||||
return sortEntriesByUpdatedAt([...ensureSessionBucket(userId).values()]).map(
|
||||
(record) => cloneRepositoryValue(record),
|
||||
);
|
||||
},
|
||||
|
||||
async getSession(userId: string, sessionId: string) {
|
||||
const record = ensureSessionBucket(userId).get(sessionId) ?? null;
|
||||
return record ? cloneRepositoryValue(record) : null;
|
||||
},
|
||||
|
||||
async upsertSession(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
session: CustomWorldSessionRecord,
|
||||
) {
|
||||
const nextSession = cloneRepositoryValue({
|
||||
...session,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
ensureSessionBucket(userId).set(sessionId, nextSession);
|
||||
return cloneRepositoryValue(nextSession);
|
||||
},
|
||||
};
|
||||
|
||||
const rpgWorldProfileRepository: RpgWorldProfileRepositoryPort = {
|
||||
async listOwnProfiles(userId: string) {
|
||||
return listOwnEntries(userId);
|
||||
},
|
||||
|
||||
async upsertOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const bucket = ensureProfileBucket(userId);
|
||||
const currentEntry = bucket.get(profileId);
|
||||
const now = new Date().toISOString();
|
||||
const nextEntry = buildProfileEntry({
|
||||
userId,
|
||||
profileId,
|
||||
profile,
|
||||
authorDisplayName:
|
||||
authorDisplayName || currentEntry?.authorDisplayName || '玩家',
|
||||
visibility: currentEntry?.visibility ?? 'draft',
|
||||
updatedAt: now,
|
||||
publishedAt:
|
||||
currentEntry?.visibility === 'published'
|
||||
? currentEntry.publishedAt || now
|
||||
: null,
|
||||
});
|
||||
|
||||
bucket.set(profileId, nextEntry);
|
||||
|
||||
return {
|
||||
entry: cloneRepositoryValue(nextEntry),
|
||||
entries: await listOwnEntries(userId),
|
||||
};
|
||||
},
|
||||
|
||||
async syncProfileFromSnapshot(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
syncedAt: string,
|
||||
) {
|
||||
const bucket = ensureProfileBucket(userId);
|
||||
const currentEntry = bucket.get(profileId);
|
||||
bucket.set(
|
||||
profileId,
|
||||
buildProfileEntry({
|
||||
userId,
|
||||
profileId,
|
||||
profile,
|
||||
authorDisplayName: currentEntry?.authorDisplayName || '玩家',
|
||||
visibility: currentEntry?.visibility ?? 'draft',
|
||||
updatedAt: syncedAt,
|
||||
publishedAt:
|
||||
currentEntry?.visibility === 'published'
|
||||
? currentEntry.publishedAt || syncedAt
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async softDeleteOwnProfile(userId: string, profileId: string) {
|
||||
ensureProfileBucket(userId).delete(profileId);
|
||||
return listOwnEntries(userId);
|
||||
},
|
||||
|
||||
async publishOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const bucket = ensureProfileBucket(userId);
|
||||
const currentEntry = bucket.get(profileId);
|
||||
if (!currentEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const nextEntry = buildProfileEntry({
|
||||
userId,
|
||||
profileId,
|
||||
profile: currentEntry.profile,
|
||||
authorDisplayName:
|
||||
authorDisplayName || currentEntry.authorDisplayName || '玩家',
|
||||
visibility: 'published',
|
||||
updatedAt: now,
|
||||
publishedAt: now,
|
||||
});
|
||||
bucket.set(profileId, nextEntry);
|
||||
|
||||
return {
|
||||
entry: cloneRepositoryValue(nextEntry),
|
||||
entries: await listOwnEntries(userId),
|
||||
};
|
||||
},
|
||||
|
||||
async unpublishOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const bucket = ensureProfileBucket(userId);
|
||||
const currentEntry = bucket.get(profileId);
|
||||
if (!currentEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const nextEntry = buildProfileEntry({
|
||||
userId,
|
||||
profileId,
|
||||
profile: currentEntry.profile,
|
||||
authorDisplayName:
|
||||
authorDisplayName || currentEntry.authorDisplayName || '玩家',
|
||||
visibility: 'draft',
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
});
|
||||
bucket.set(profileId, nextEntry);
|
||||
|
||||
return {
|
||||
entry: cloneRepositoryValue(nextEntry),
|
||||
entries: await listOwnEntries(userId),
|
||||
};
|
||||
},
|
||||
|
||||
async listPublishedGallery() {
|
||||
return [...profilesByUser.values()]
|
||||
.flatMap((bucket) => [...bucket.values()])
|
||||
.filter((entry) => entry.visibility === 'published')
|
||||
.sort((left, right) => {
|
||||
const publishedAtDiff = (right.publishedAt || '').localeCompare(
|
||||
left.publishedAt || '',
|
||||
);
|
||||
if (publishedAtDiff !== 0) {
|
||||
return publishedAtDiff;
|
||||
}
|
||||
|
||||
return right.updatedAt.localeCompare(left.updatedAt);
|
||||
})
|
||||
.map((entry) => toGalleryCard(entry));
|
||||
},
|
||||
|
||||
async getPublishedGalleryDetail(ownerUserId: string, profileId: string) {
|
||||
const entry = ensureProfileBucket(ownerUserId).get(profileId);
|
||||
if (!entry || entry.visibility !== 'published') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cloneRepositoryValue(entry);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
rpgAgentSessionRepository,
|
||||
rpgWorldProfileRepository,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createRpgAgentFoundationDraftProfileFixture,
|
||||
createRpgCreationPublishedProfileFixture,
|
||||
} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
|
||||
import { CustomWorldAgentResultSyncService } from './customWorldAgentResultSyncService.js';
|
||||
|
||||
test('result sync service only writes summary fields and matching asset confirmations back into draft profile', () => {
|
||||
const service = new CustomWorldAgentResultSyncService();
|
||||
const currentDraftProfile = createRpgAgentFoundationDraftProfileFixture();
|
||||
const resultProfile = createRpgCreationPublishedProfileFixture();
|
||||
const nextDraftProfile = service.syncResultProfileIntoDraftProfile({
|
||||
currentDraftProfile: currentDraftProfile as unknown as Record<string, unknown>,
|
||||
resultProfile,
|
||||
});
|
||||
|
||||
assert.equal(nextDraftProfile.name, resultProfile.name);
|
||||
assert.equal(nextDraftProfile.summary, resultProfile.summary);
|
||||
assert.equal(
|
||||
(
|
||||
nextDraftProfile.playableNpcs as Array<{
|
||||
generatedAnimationSetId?: string | null;
|
||||
}>
|
||||
)[0]?.generatedAnimationSetId,
|
||||
'animation-set-playable-1',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
nextDraftProfile.landmarks as Array<{
|
||||
imageSrc?: string | null;
|
||||
}>
|
||||
)[0]?.imageSrc,
|
||||
'/generated-custom-world-scenes/landmark-1/latest-scene.png',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
nextDraftProfile.sceneChapters as Array<{
|
||||
acts?: Array<{ backgroundAssetId?: string | null }>;
|
||||
}>
|
||||
)[0]?.acts?.[0]?.backgroundAssetId,
|
||||
'scene-asset-runtime',
|
||||
);
|
||||
});
|
||||
|
||||
test('result sync service keeps existing foundation structure when result profile carries unmatched runtime-only entities', () => {
|
||||
const service = new CustomWorldAgentResultSyncService();
|
||||
const currentDraftProfile = createRpgAgentFoundationDraftProfileFixture();
|
||||
const nextDraftProfile = service.syncResultProfileIntoDraftProfile({
|
||||
currentDraftProfile: currentDraftProfile as unknown as Record<string, unknown>,
|
||||
resultProfile: {
|
||||
...createRpgCreationPublishedProfileFixture(),
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'runtime-only-role',
|
||||
name: '运行时临时角色',
|
||||
title: '结果页临时角色',
|
||||
role: '测试角色',
|
||||
description: '不应覆盖 foundation draft。',
|
||||
backstory: '测试',
|
||||
personality: '冷静',
|
||||
motivation: '测试',
|
||||
combatStyle: '观察',
|
||||
initialAffinity: 0,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
backstoryReveal: {
|
||||
publicSummary: '测试',
|
||||
privateChatUnlockAffinity: 0,
|
||||
chapters: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'runtime-only-landmark',
|
||||
name: '运行时临时地点',
|
||||
description: '不应覆盖 foundation draft。',
|
||||
dangerLevel: 'low',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
nextDraftProfile.playableNpcs as Array<{ id?: string; name?: string }>
|
||||
)[0]?.id,
|
||||
'playable-1',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
nextDraftProfile.playableNpcs as Array<{ id?: string; name?: string }>
|
||||
)[0]?.name,
|
||||
'沈砺',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
nextDraftProfile.landmarks as Array<{ id?: string; name?: string }>
|
||||
)[0]?.id,
|
||||
'landmark-1',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
nextDraftProfile.legacyResultProfile as {
|
||||
playableNpcs?: Array<{ id?: string }>;
|
||||
}
|
||||
).playableNpcs?.[0]?.id,
|
||||
'runtime-only-role',
|
||||
);
|
||||
});
|
||||
150
server-node/src/services/customWorldAgentResultSyncService.ts
Normal file
150
server-node/src/services/customWorldAgentResultSyncService.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
|
||||
|
||||
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 cloneJsonRecord<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function syncRoleAssetsFromResultProfile(params: {
|
||||
currentRoles: unknown;
|
||||
resultRoles: unknown;
|
||||
}) {
|
||||
const resultRoleById = new Map(
|
||||
toRecordArray(params.resultRoles).map((role) => [toText(role.id), role]),
|
||||
);
|
||||
|
||||
return toRecordArray(params.currentRoles).map((currentRole) => {
|
||||
const resultRole = resultRoleById.get(toText(currentRole.id));
|
||||
if (!resultRole) {
|
||||
return currentRole;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentRole,
|
||||
imageSrc: toText(resultRole.imageSrc) || null,
|
||||
generatedVisualAssetId: toText(resultRole.generatedVisualAssetId) || null,
|
||||
generatedAnimationSetId:
|
||||
toText(resultRole.generatedAnimationSetId) || null,
|
||||
animationMap: isRecord(resultRole.animationMap)
|
||||
? cloneJsonRecord(resultRole.animationMap)
|
||||
: null,
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
function syncLandmarkAssetsFromResultProfile(params: {
|
||||
currentLandmarks: unknown;
|
||||
resultLandmarks: unknown;
|
||||
}) {
|
||||
const resultLandmarkById = new Map(
|
||||
toRecordArray(params.resultLandmarks).map((landmark) => [
|
||||
toText(landmark.id),
|
||||
landmark,
|
||||
]),
|
||||
);
|
||||
|
||||
return toRecordArray(params.currentLandmarks).map((currentLandmark) => {
|
||||
const resultLandmark = resultLandmarkById.get(toText(currentLandmark.id));
|
||||
if (!resultLandmark) {
|
||||
return currentLandmark;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentLandmark,
|
||||
imageSrc: toText(resultLandmark.imageSrc) || null,
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
function syncSceneChapterAssetsFromResultProfile(params: {
|
||||
currentSceneChapters: unknown;
|
||||
resultSceneChapters: unknown;
|
||||
}) {
|
||||
const resultSceneChapterBySceneId = new Map(
|
||||
toRecordArray(params.resultSceneChapters).map((chapter) => [
|
||||
toText(chapter.sceneId),
|
||||
chapter,
|
||||
]),
|
||||
);
|
||||
|
||||
return toRecordArray(params.currentSceneChapters).map((currentChapter) => {
|
||||
const resultChapter = resultSceneChapterBySceneId.get(
|
||||
toText(currentChapter.sceneId),
|
||||
);
|
||||
if (!resultChapter) {
|
||||
return currentChapter;
|
||||
}
|
||||
|
||||
const resultActById = new Map(
|
||||
toRecordArray(resultChapter.acts).map((act) => [toText(act.id), act]),
|
||||
);
|
||||
|
||||
return {
|
||||
...currentChapter,
|
||||
acts: toRecordArray(currentChapter.acts).map((currentAct) => {
|
||||
const resultAct = resultActById.get(toText(currentAct.id));
|
||||
if (!resultAct) {
|
||||
return currentAct;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentAct,
|
||||
backgroundImageSrc: toText(resultAct.backgroundImageSrc) || null,
|
||||
backgroundAssetId: toText(resultAct.backgroundAssetId) || null,
|
||||
} satisfies Record<string, unknown>;
|
||||
}),
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
export class CustomWorldAgentResultSyncService {
|
||||
// 阶段一只允许结果页把摘要与资产确认结果回写进 foundation draft,避免 runtime 结构反向污染草稿真相源。
|
||||
syncResultProfileIntoDraftProfile(params: {
|
||||
currentDraftProfile: Record<string, unknown> | null | undefined;
|
||||
resultProfile: CustomWorldProfile;
|
||||
}) {
|
||||
const currentDraftProfile = params.currentDraftProfile ?? {};
|
||||
const resultProfile = params.resultProfile;
|
||||
|
||||
return {
|
||||
...currentDraftProfile,
|
||||
name: resultProfile.name,
|
||||
subtitle: resultProfile.subtitle,
|
||||
summary: resultProfile.summary,
|
||||
tone: resultProfile.tone,
|
||||
playerGoal: resultProfile.playerGoal,
|
||||
majorFactions: resultProfile.majorFactions,
|
||||
coreConflicts: resultProfile.coreConflicts,
|
||||
playableNpcs: syncRoleAssetsFromResultProfile({
|
||||
currentRoles: currentDraftProfile.playableNpcs,
|
||||
resultRoles: resultProfile.playableNpcs,
|
||||
}),
|
||||
storyNpcs: syncRoleAssetsFromResultProfile({
|
||||
currentRoles: currentDraftProfile.storyNpcs,
|
||||
resultRoles: resultProfile.storyNpcs,
|
||||
}),
|
||||
landmarks: syncLandmarkAssetsFromResultProfile({
|
||||
currentLandmarks: currentDraftProfile.landmarks,
|
||||
resultLandmarks: resultProfile.landmarks,
|
||||
}),
|
||||
sceneChapters: syncSceneChapterAssetsFromResultProfile({
|
||||
currentSceneChapters: currentDraftProfile.sceneChapters,
|
||||
resultSceneChapters: resultProfile.sceneChapterBlueprints,
|
||||
}),
|
||||
legacyResultProfile: resultProfile as unknown as Record<string, unknown>,
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,14 @@ type DraftSceneChapterRecord = {
|
||||
acts: DraftSceneActRecord[];
|
||||
};
|
||||
|
||||
type DraftStandaloneSceneRecord = {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
imageSrc: string | null;
|
||||
assetId: string | null;
|
||||
sceneKind: 'camp' | 'landmark';
|
||||
};
|
||||
|
||||
type MergeRoleAssetIntoDraftProfilePayload = {
|
||||
roleId: string;
|
||||
portraitPath: string;
|
||||
@@ -244,6 +252,77 @@ function collectDraftSceneChapters(profileInput: unknown) {
|
||||
.filter((item): item is DraftSceneChapterRecord => Boolean(item));
|
||||
}
|
||||
|
||||
function buildStandaloneSceneAssetSummary(
|
||||
scene: DraftStandaloneSceneRecord,
|
||||
): CustomWorldSceneAssetSummary {
|
||||
const ready = Boolean(scene.imageSrc || scene.assetId);
|
||||
|
||||
return {
|
||||
sceneId: scene.sceneId,
|
||||
sceneName: scene.sceneName,
|
||||
actId: null,
|
||||
actTitle: scene.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图',
|
||||
imageSrc: scene.imageSrc,
|
||||
assetId: scene.assetId,
|
||||
status: ready ? 'ready' : 'missing',
|
||||
nextPointCost: ready ? 0 : 12,
|
||||
};
|
||||
}
|
||||
|
||||
function collectStandaloneSceneRecords(
|
||||
profileInput: unknown,
|
||||
coveredSceneIds: Set<string>,
|
||||
) {
|
||||
const profile = toRecord(profileInput);
|
||||
if (!profile) {
|
||||
return [] as DraftStandaloneSceneRecord[];
|
||||
}
|
||||
|
||||
const standaloneScenes: DraftStandaloneSceneRecord[] = [];
|
||||
const camp = toRecord(profile.camp);
|
||||
if (camp) {
|
||||
const campId = toText(camp.id);
|
||||
const campImageSrc = toText(camp.imageSrc) || null;
|
||||
const campAssetId = toText(camp.generatedSceneAssetId) || null;
|
||||
if (
|
||||
campId &&
|
||||
!coveredSceneIds.has(campId) &&
|
||||
Boolean(campImageSrc || campAssetId)
|
||||
) {
|
||||
standaloneScenes.push({
|
||||
sceneId: campId,
|
||||
sceneName: toText(camp.name) || '开局营地',
|
||||
imageSrc: campImageSrc,
|
||||
assetId: campAssetId,
|
||||
sceneKind: 'camp',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toRecordArray(profile.landmarks).forEach((landmark, index) => {
|
||||
const landmarkId = toText(landmark.id);
|
||||
const landmarkImageSrc = toText(landmark.imageSrc) || null;
|
||||
const landmarkAssetId = toText(landmark.generatedSceneAssetId) || null;
|
||||
if (
|
||||
!landmarkId ||
|
||||
coveredSceneIds.has(landmarkId) ||
|
||||
!Boolean(landmarkImageSrc || landmarkAssetId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
standaloneScenes.push({
|
||||
sceneId: landmarkId,
|
||||
sceneName: toText(landmark.name) || `关键地点 ${index + 1}`,
|
||||
imageSrc: landmarkImageSrc,
|
||||
assetId: landmarkAssetId,
|
||||
sceneKind: 'landmark',
|
||||
});
|
||||
});
|
||||
|
||||
return standaloneScenes;
|
||||
}
|
||||
|
||||
export function resolveRoleAssetStatusLabel(
|
||||
status: CustomWorldRoleAssetStatus,
|
||||
) {
|
||||
@@ -317,7 +396,7 @@ export function rebuildRoleAssetCoverage(
|
||||
const roleAssets = collectDraftRoles(draftProfile).map((entry) =>
|
||||
buildRoleAssetSummary(entry),
|
||||
);
|
||||
const sceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters(
|
||||
const chapterSceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters(
|
||||
draftProfile,
|
||||
).flatMap((sceneChapter) =>
|
||||
sceneChapter.acts.map((act) => {
|
||||
@@ -337,6 +416,14 @@ export function rebuildRoleAssetCoverage(
|
||||
} satisfies CustomWorldSceneAssetSummary;
|
||||
}),
|
||||
);
|
||||
const coveredSceneIds = new Set(
|
||||
chapterSceneAssets.map((entry) => entry.sceneId).filter(Boolean),
|
||||
);
|
||||
const standaloneSceneAssets = collectStandaloneSceneRecords(
|
||||
draftProfile,
|
||||
coveredSceneIds,
|
||||
).map((scene) => buildStandaloneSceneAssetSummary(scene));
|
||||
const sceneAssets = [...chapterSceneAssets, ...standaloneSceneAssets];
|
||||
|
||||
return {
|
||||
roleAssets,
|
||||
|
||||
@@ -1,546 +1,27 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldAgentMessage,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldAgentStage,
|
||||
CustomWorldAssetCoverageSummary,
|
||||
CustomWorldDraftCardSummary,
|
||||
CustomWorldPendingClarification,
|
||||
CustomWorldSuggestedAction,
|
||||
EightAnchorContent,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import {
|
||||
buildPendingClarifications,
|
||||
evaluateCreatorIntentReadiness,
|
||||
resolveCreatorIntentStage,
|
||||
} from './customWorldAgentClarificationService.js';
|
||||
applyCustomWorldAgentSessionCompatibility,
|
||||
isCustomWorldAgentSessionRecord,
|
||||
} from './rpg-agent-session-store/rpgAgentSessionCompatibility.js';
|
||||
import { createCustomWorldAgentSessionRecord } from './rpg-agent-session-store/rpgAgentSessionFactory.js';
|
||||
import {
|
||||
normalizeCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js';
|
||||
cloneRpgAgentSessionValue,
|
||||
type CreateCustomWorldAgentSessionInput,
|
||||
type CustomWorldAgentSessionRecord,
|
||||
CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX,
|
||||
} from './rpg-agent-session-store/rpgAgentSessionRecord.js';
|
||||
import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js';
|
||||
import {
|
||||
buildAnchorPackFromEightAnchorContent,
|
||||
buildCreatorIntentFromEightAnchorContent,
|
||||
buildDraftSummaryFromEightAnchorContent,
|
||||
buildDraftTitleFromEightAnchorContent,
|
||||
buildEightAnchorContentFromCreatorIntent,
|
||||
createEmptyEightAnchorContent,
|
||||
estimateProgressPercentFromAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
|
||||
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
|
||||
'custom-world-agent-session-';
|
||||
|
||||
export type CustomWorldAgentSessionRecord = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
seedText: string;
|
||||
currentTurn: number;
|
||||
anchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
lastAssistantReply: string | null;
|
||||
stage: CustomWorldAgentStage;
|
||||
focusCardId: string | null;
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
creatorIntentReadiness: CreatorIntentReadiness;
|
||||
anchorPack: Record<string, unknown> | null;
|
||||
lockState: Record<string, unknown> | null;
|
||||
draftProfile: Record<string, unknown> | null;
|
||||
messages: CustomWorldAgentMessage[];
|
||||
draftCards: CustomWorldDraftCardSummary[];
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
suggestedActions: CustomWorldSuggestedAction[];
|
||||
recommendedReplies: string[];
|
||||
qualityFindings: Array<{
|
||||
id: string;
|
||||
severity: 'info' | 'warning' | 'blocker';
|
||||
code: string;
|
||||
targetId?: string | null;
|
||||
message: string;
|
||||
}>;
|
||||
assetCoverage: CustomWorldAssetCoverageSummary;
|
||||
operations: CustomWorldAgentOperationRecord[];
|
||||
checkpoints: Array<{
|
||||
checkpointId: string;
|
||||
createdAt: string;
|
||||
label: string;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type CreateSessionInput = {
|
||||
seedText?: string;
|
||||
welcomeMessage: string;
|
||||
currentTurn?: number;
|
||||
anchorContent?: EightAnchorContent;
|
||||
progressPercent?: number;
|
||||
lastAssistantReply?: string | null;
|
||||
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
|
||||
creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent'];
|
||||
creatorIntentReadiness?: CreatorIntentReadiness;
|
||||
anchorPack?: CustomWorldAgentSessionRecord['anchorPack'];
|
||||
draftProfile?: CustomWorldAgentSessionRecord['draftProfile'];
|
||||
stage?: CustomWorldAgentStage;
|
||||
suggestedActions: CustomWorldSuggestedAction[];
|
||||
recommendedReplies?: string[];
|
||||
};
|
||||
|
||||
function cloneRecord<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isStage(value: unknown): value is CustomWorldAgentStage {
|
||||
return (
|
||||
value === 'collecting_intent' ||
|
||||
value === 'clarifying' ||
|
||||
value === 'foundation_review' ||
|
||||
value === 'object_refining' ||
|
||||
value === 'visual_refining' ||
|
||||
value === 'long_tail_review' ||
|
||||
value === 'ready_to_publish' ||
|
||||
value === 'published' ||
|
||||
value === 'error'
|
||||
);
|
||||
}
|
||||
|
||||
function isAgentSessionRecord(
|
||||
value: unknown,
|
||||
): value is CustomWorldAgentSessionRecord {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof record.sessionId === 'string' &&
|
||||
record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) &&
|
||||
typeof record.userId === 'string' &&
|
||||
isStage(record.stage) &&
|
||||
Array.isArray(record.messages) &&
|
||||
Array.isArray(record.operations) &&
|
||||
typeof record.createdAt === 'string' &&
|
||||
typeof record.updatedAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isCreatorIntentReadiness(
|
||||
value: unknown,
|
||||
): value is CreatorIntentReadiness {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof record.isReady === 'boolean' &&
|
||||
Array.isArray(record.completedKeys) &&
|
||||
Array.isArray(record.missingKeys)
|
||||
);
|
||||
}
|
||||
|
||||
function mapLegacyClarificationTargetKey(id: string) {
|
||||
if (id === 'world_hook') return 'world_hook';
|
||||
if (id === 'player_premise') return 'player_premise';
|
||||
if (id === 'theme_and_tone' || id === 'tone_boundary') {
|
||||
return 'theme_and_tone';
|
||||
}
|
||||
if (id === 'core_conflict') return 'core_conflict';
|
||||
if (id === 'relationship_seed' || id === 'relationship_hook') {
|
||||
return 'relationship_seed';
|
||||
}
|
||||
if (id === 'iconic_element' || id === 'iconic_elements') {
|
||||
return 'iconic_element';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasUserInput(record: CustomWorldAgentSessionRecord) {
|
||||
return (
|
||||
Boolean(record.seedText.trim()) ||
|
||||
record.messages.some(
|
||||
(message) => message.role === 'user' && message.text.trim(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) {
|
||||
const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent(
|
||||
normalizeEightAnchorContent(
|
||||
(record as Record<string, unknown>).anchorContent ?? null,
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
compatibleAnchorIntent &&
|
||||
(compatibleAnchorIntent.worldHook ||
|
||||
compatibleAnchorIntent.rawSettingText ||
|
||||
compatibleAnchorIntent.playerPremise ||
|
||||
compatibleAnchorIntent.openingSituation ||
|
||||
compatibleAnchorIntent.coreConflicts.length > 0 ||
|
||||
compatibleAnchorIntent.keyCharacters.length > 0 ||
|
||||
compatibleAnchorIntent.iconicElements.length > 0)
|
||||
) {
|
||||
return compatibleAnchorIntent;
|
||||
}
|
||||
|
||||
return normalizeCreatorIntentRecord(record.creatorIntent);
|
||||
}
|
||||
|
||||
function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) {
|
||||
if (typeof (record as Record<string, unknown>).currentTurn === 'number') {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round((record as Record<string, unknown>).currentTurn as number),
|
||||
);
|
||||
}
|
||||
|
||||
return record.messages.filter((message) => message.role === 'user').length;
|
||||
}
|
||||
|
||||
function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) {
|
||||
const normalized = normalizeEightAnchorContent(
|
||||
(record as Record<string, unknown>).anchorContent ?? null,
|
||||
);
|
||||
|
||||
if (
|
||||
normalized.worldPromise ||
|
||||
normalized.playerFantasy ||
|
||||
normalized.themeBoundary ||
|
||||
normalized.playerEntryPoint ||
|
||||
normalized.coreConflict ||
|
||||
normalized.keyRelationships.length > 0 ||
|
||||
normalized.hiddenLines ||
|
||||
normalized.iconicElements
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return buildEightAnchorContentFromCreatorIntent(
|
||||
buildCompatibleCreatorIntent(record),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) {
|
||||
const rawProgress = (record as Record<string, unknown>).progressPercent;
|
||||
if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) {
|
||||
return Math.max(0, Math.min(100, Math.round(rawProgress)));
|
||||
}
|
||||
|
||||
if (
|
||||
record.stage === 'foundation_review' ||
|
||||
record.stage === 'object_refining' ||
|
||||
record.stage === 'visual_refining' ||
|
||||
record.stage === 'long_tail_review' ||
|
||||
record.stage === 'ready_to_publish' ||
|
||||
record.stage === 'published'
|
||||
) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return estimateProgressPercentFromAnchorContent(
|
||||
buildCompatibleAnchorContent(record),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) {
|
||||
const existingReply = (record as Record<string, unknown>).lastAssistantReply;
|
||||
if (typeof existingReply === 'string') {
|
||||
return existingReply;
|
||||
}
|
||||
|
||||
const lastAssistantMessage = [...record.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant' && message.text.trim());
|
||||
|
||||
return lastAssistantMessage?.text ?? null;
|
||||
}
|
||||
|
||||
function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) {
|
||||
if (
|
||||
isCreatorIntentReadiness(
|
||||
(record as Record<string, unknown>).creatorIntentReadiness,
|
||||
)
|
||||
) {
|
||||
return record.creatorIntentReadiness;
|
||||
}
|
||||
|
||||
return evaluateCreatorIntentReadiness(
|
||||
normalizeCreatorIntentRecord(record.creatorIntent),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatiblePendingClarifications(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
) {
|
||||
const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent);
|
||||
const readiness = buildCompatibleReadiness(record);
|
||||
const legacyClarifications = Array.isArray(record.pendingClarifications)
|
||||
? record.pendingClarifications
|
||||
: [];
|
||||
|
||||
const nextClarifications = legacyClarifications
|
||||
.map((entry, index) => {
|
||||
const targetKey = mapLegacyClarificationTargetKey(entry.id);
|
||||
if (!targetKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: entry.id || targetKey,
|
||||
label: entry.label || '待补充问题',
|
||||
question: entry.question || '',
|
||||
targetKey,
|
||||
priority:
|
||||
typeof entry.priority === 'number' ? entry.priority : index + 1,
|
||||
answer: entry.answer,
|
||||
} satisfies CustomWorldPendingClarification;
|
||||
})
|
||||
.filter((entry): entry is CustomWorldPendingClarification =>
|
||||
Boolean(entry?.question),
|
||||
)
|
||||
.slice(0, 3);
|
||||
|
||||
if (nextClarifications.length > 0) {
|
||||
return nextClarifications;
|
||||
}
|
||||
|
||||
return buildPendingClarifications(normalizedIntent, readiness);
|
||||
}
|
||||
|
||||
function buildCompatibleDraftProfile(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
) {
|
||||
const anchorContent = buildCompatibleAnchorContent(record);
|
||||
const existingDraftProfile = toRecord(record.draftProfile);
|
||||
const hasFoundationContent = Boolean(
|
||||
existingDraftProfile &&
|
||||
(typeof existingDraftProfile.name === 'string' ||
|
||||
Array.isArray(existingDraftProfile.playableNpcs) ||
|
||||
Array.isArray(existingDraftProfile.landmarks) ||
|
||||
Array.isArray(existingDraftProfile.factions) ||
|
||||
Array.isArray(existingDraftProfile.threads) ||
|
||||
Array.isArray(existingDraftProfile.chapters)),
|
||||
);
|
||||
|
||||
if (hasFoundationContent) {
|
||||
return {
|
||||
...existingDraftProfile,
|
||||
name:
|
||||
toText(existingDraftProfile?.name) ||
|
||||
toText(existingDraftProfile?.title) ||
|
||||
buildDraftTitleFromEightAnchorContent(anchorContent),
|
||||
summary:
|
||||
toText(existingDraftProfile?.summary) ||
|
||||
buildDraftSummaryFromEightAnchorContent(anchorContent),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...(existingDraftProfile ?? {}),
|
||||
title:
|
||||
toText(existingDraftProfile?.title) ||
|
||||
buildDraftTitleFromEightAnchorContent(anchorContent),
|
||||
summary:
|
||||
toText(existingDraftProfile?.summary) ||
|
||||
buildDraftSummaryFromEightAnchorContent(anchorContent),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCompatibleSuggestedActions(params: {
|
||||
record: CustomWorldAgentSessionRecord;
|
||||
stage: CustomWorldAgentStage;
|
||||
readiness: CreatorIntentReadiness;
|
||||
draftProfile: Record<string, unknown>;
|
||||
}) {
|
||||
if (params.record.suggestedActions.length > 0) {
|
||||
return params.record.suggestedActions;
|
||||
}
|
||||
|
||||
const actions: CustomWorldSuggestedAction[] = [
|
||||
{
|
||||
id: 'request_summary',
|
||||
type: 'request_summary',
|
||||
label:
|
||||
params.stage === 'object_refining' || params.stage === 'visual_refining'
|
||||
? '总结当前世界底稿'
|
||||
: '总结当前设定',
|
||||
},
|
||||
];
|
||||
const playableNpcs = Array.isArray(params.draftProfile.playableNpcs)
|
||||
? params.draftProfile.playableNpcs
|
||||
: [];
|
||||
const storyNpcs = Array.isArray(params.draftProfile.storyNpcs)
|
||||
? params.draftProfile.storyNpcs
|
||||
: [];
|
||||
const landmarks = Array.isArray(params.draftProfile.landmarks)
|
||||
? params.draftProfile.landmarks
|
||||
: [];
|
||||
|
||||
if (params.stage === 'foundation_review' && params.readiness.isReady) {
|
||||
actions.push({
|
||||
id: 'draft_foundation',
|
||||
type: 'draft_foundation',
|
||||
label: '整理一版世界底稿',
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (params.stage === 'object_refining' || params.stage === 'visual_refining') {
|
||||
const firstCharacter = toRecord([...playableNpcs, ...storyNpcs][0]);
|
||||
const firstLandmark = toRecord(landmarks[0]);
|
||||
|
||||
actions.push({
|
||||
id: 'refine_world',
|
||||
type: 'refine_focus_target',
|
||||
label: '先看世界总卡',
|
||||
targetId: 'world-foundation',
|
||||
});
|
||||
|
||||
if (firstCharacter) {
|
||||
actions.push({
|
||||
id: `refine-character-${toText(firstCharacter.id) || 'seed'}`,
|
||||
type: 'refine_focus_target',
|
||||
label: `精修角色:${toText(firstCharacter.name) || '关键角色'}`,
|
||||
targetId: toText(firstCharacter.id) || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (firstLandmark) {
|
||||
actions.push({
|
||||
id: `refine-landmark-${toText(firstLandmark.id) || 'seed'}`,
|
||||
type: 'refine_focus_target',
|
||||
label: `继续补地点:${toText(firstLandmark.name) || '关键地点'}`,
|
||||
targetId: toText(firstLandmark.id) || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function normalizeRecommendedReplies(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function buildCompatibleAssetCoverage(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
draftProfile: Record<string, unknown>,
|
||||
) {
|
||||
const derivedCoverage = rebuildRoleAssetCoverage(draftProfile);
|
||||
const existingCoverage = toRecord(record.assetCoverage);
|
||||
const sceneAssets =
|
||||
derivedCoverage.sceneAssets.length > 0
|
||||
? derivedCoverage.sceneAssets
|
||||
: Array.isArray(existingCoverage?.sceneAssets)
|
||||
? existingCoverage.sceneAssets
|
||||
: [];
|
||||
const allSceneAssetsReady =
|
||||
derivedCoverage.sceneAssets.length > 0
|
||||
? derivedCoverage.allSceneAssetsReady
|
||||
: typeof existingCoverage?.allSceneAssetsReady === 'boolean'
|
||||
? existingCoverage.allSceneAssetsReady
|
||||
: false;
|
||||
|
||||
return {
|
||||
...derivedCoverage,
|
||||
sceneAssets,
|
||||
allSceneAssetsReady,
|
||||
} satisfies CustomWorldAssetCoverageSummary;
|
||||
}
|
||||
|
||||
function applyCompatibility(record: CustomWorldAgentSessionRecord) {
|
||||
const creatorIntent = buildCompatibleCreatorIntent(record);
|
||||
const currentTurn = buildCompatibleCurrentTurn(record);
|
||||
const anchorContent = buildCompatibleAnchorContent(record);
|
||||
const progressPercent = buildCompatibleProgressPercent(record);
|
||||
const lastAssistantReply = buildCompatibleLastAssistantReply(record);
|
||||
const creatorIntentReadiness =
|
||||
progressPercent >= 100
|
||||
? {
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
}
|
||||
: evaluateCreatorIntentReadiness(creatorIntent);
|
||||
const stage =
|
||||
record.stage === 'object_refining' ||
|
||||
record.stage === 'visual_refining' ||
|
||||
record.stage === 'long_tail_review' ||
|
||||
record.stage === 'ready_to_publish' ||
|
||||
record.stage === 'published'
|
||||
? record.stage
|
||||
: progressPercent >= 100
|
||||
? ('foundation_review' as const)
|
||||
: resolveCreatorIntentStage({
|
||||
hasUserInput: hasUserInput(record),
|
||||
readiness: creatorIntentReadiness,
|
||||
});
|
||||
const pendingClarifications = buildCompatiblePendingClarifications({
|
||||
...record,
|
||||
creatorIntent,
|
||||
creatorIntentReadiness,
|
||||
});
|
||||
const draftProfile = buildCompatibleDraftProfile(record);
|
||||
|
||||
return {
|
||||
...record,
|
||||
currentTurn,
|
||||
anchorContent,
|
||||
progressPercent,
|
||||
lastAssistantReply,
|
||||
stage,
|
||||
creatorIntent,
|
||||
creatorIntentReadiness,
|
||||
anchorPack:
|
||||
record.anchorPack && Object.keys(record.anchorPack).length > 0
|
||||
? record.anchorPack
|
||||
: buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent),
|
||||
draftProfile,
|
||||
pendingClarifications,
|
||||
suggestedActions: buildCompatibleSuggestedActions({
|
||||
record,
|
||||
stage,
|
||||
readiness: creatorIntentReadiness,
|
||||
draftProfile,
|
||||
}),
|
||||
assetCoverage: buildCompatibleAssetCoverage(record, draftProfile),
|
||||
recommendedReplies: normalizeRecommendedReplies(
|
||||
(record as Record<string, unknown>).recommendedReplies,
|
||||
),
|
||||
} satisfies CustomWorldAgentSessionRecord;
|
||||
}
|
||||
RpgAgentSessionRepositoryAdapter,
|
||||
} from './rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.js';
|
||||
import { normalizeEightAnchorContent } from './eightAnchorCompatibilityService.js';
|
||||
|
||||
function toSnapshot(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
@@ -548,132 +29,118 @@ function toSnapshot(
|
||||
return {
|
||||
sessionId: record.sessionId,
|
||||
currentTurn: record.currentTurn,
|
||||
anchorContent: cloneRecord(record.anchorContent),
|
||||
anchorContent: cloneRpgAgentSessionValue(record.anchorContent),
|
||||
progressPercent: record.progressPercent,
|
||||
lastAssistantReply: record.lastAssistantReply,
|
||||
stage: record.stage,
|
||||
focusCardId: record.focusCardId,
|
||||
creatorIntent: cloneRecord(record.creatorIntent),
|
||||
creatorIntentReadiness: cloneRecord(record.creatorIntentReadiness),
|
||||
anchorPack: cloneRecord(record.anchorPack),
|
||||
lockState: cloneRecord(record.lockState),
|
||||
draftProfile: cloneRecord(record.draftProfile),
|
||||
messages: cloneRecord(record.messages),
|
||||
draftCards: cloneRecord(record.draftCards),
|
||||
pendingClarifications: cloneRecord(record.pendingClarifications),
|
||||
suggestedActions: cloneRecord(record.suggestedActions),
|
||||
recommendedReplies: cloneRecord(record.recommendedReplies),
|
||||
qualityFindings: cloneRecord(record.qualityFindings),
|
||||
assetCoverage: cloneRecord(record.assetCoverage),
|
||||
creatorIntent: cloneRpgAgentSessionValue(record.creatorIntent),
|
||||
creatorIntentReadiness: cloneRpgAgentSessionValue(
|
||||
record.creatorIntentReadiness,
|
||||
),
|
||||
anchorPack: cloneRpgAgentSessionValue(record.anchorPack),
|
||||
lockState: cloneRpgAgentSessionValue(record.lockState),
|
||||
draftProfile: cloneRpgAgentSessionValue(record.draftProfile),
|
||||
messages: cloneRpgAgentSessionValue(record.messages),
|
||||
draftCards: cloneRpgAgentSessionValue(record.draftCards),
|
||||
pendingClarifications: cloneRpgAgentSessionValue(
|
||||
record.pendingClarifications,
|
||||
),
|
||||
suggestedActions: cloneRpgAgentSessionValue(record.suggestedActions),
|
||||
recommendedReplies: cloneRpgAgentSessionValue(record.recommendedReplies),
|
||||
qualityFindings: cloneRpgAgentSessionValue(record.qualityFindings),
|
||||
assetCoverage: cloneRpgAgentSessionValue(record.assetCoverage),
|
||||
checkpoints: record.checkpoints.map((checkpoint) => ({
|
||||
checkpointId: checkpoint.checkpointId,
|
||||
createdAt: checkpoint.createdAt,
|
||||
label: checkpoint.label,
|
||||
})),
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export class CustomWorldAgentSessionStore {
|
||||
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
|
||||
function normalizeCompatibleSessionRecord(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
): CustomWorldAgentSessionRecord {
|
||||
return cloneRpgAgentSessionValue(
|
||||
applyCustomWorldAgentSessionCompatibility(
|
||||
record,
|
||||
) as unknown as CustomWorldAgentSessionRecord,
|
||||
);
|
||||
}
|
||||
|
||||
private async persist(record: CustomWorldAgentSessionRecord) {
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
export { CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX };
|
||||
export type { CustomWorldAgentSessionRecord };
|
||||
|
||||
export class CustomWorldAgentSessionStore {
|
||||
private readonly sessionRepository: RpgAgentSessionRepositoryAdapter;
|
||||
|
||||
constructor(sessionRepository: RpgAgentSessionRepositoryPort) {
|
||||
this.sessionRepository = new RpgAgentSessionRepositoryAdapter(
|
||||
sessionRepository,
|
||||
);
|
||||
}
|
||||
|
||||
private async persist(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
): Promise<CustomWorldAgentSessionRecord> {
|
||||
await this.sessionRepository.upsert(
|
||||
record.userId,
|
||||
record.sessionId,
|
||||
record as unknown as LegacyCustomWorldSessionRecord,
|
||||
);
|
||||
return cloneRecord(record);
|
||||
|
||||
return cloneRpgAgentSessionValue(record);
|
||||
}
|
||||
|
||||
private async mutate(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
mutateFn: (record: CustomWorldAgentSessionRecord) => void,
|
||||
) {
|
||||
): Promise<CustomWorldAgentSessionRecord | null> {
|
||||
const current = await this.get(userId, sessionId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextRecord = cloneRecord(current);
|
||||
const nextRecord = cloneRpgAgentSessionValue(current);
|
||||
mutateFn(nextRecord);
|
||||
nextRecord.updatedAt = new Date().toISOString();
|
||||
return this.persist(nextRecord);
|
||||
}
|
||||
|
||||
async create(userId: string, input: CreateSessionInput) {
|
||||
const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`;
|
||||
const now = new Date().toISOString();
|
||||
const welcomeMessage: CustomWorldAgentMessage = {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: input.welcomeMessage,
|
||||
createdAt: now,
|
||||
relatedOperationId: null,
|
||||
};
|
||||
const record: CustomWorldAgentSessionRecord = {
|
||||
sessionId,
|
||||
userId,
|
||||
seedText: input.seedText?.trim() ?? '',
|
||||
currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)),
|
||||
anchorContent: normalizeEightAnchorContent(
|
||||
input.anchorContent ?? createEmptyEightAnchorContent(),
|
||||
),
|
||||
progressPercent: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(input.progressPercent ?? 0)),
|
||||
),
|
||||
lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage,
|
||||
stage: input.stage ?? 'collecting_intent',
|
||||
focusCardId: null,
|
||||
creatorIntent: cloneRecord(input.creatorIntent ?? {}),
|
||||
creatorIntentReadiness: input.creatorIntentReadiness ?? {
|
||||
isReady: false,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: cloneRecord(input.anchorPack ?? {}),
|
||||
lockState: {},
|
||||
draftProfile: cloneRecord(input.draftProfile ?? {}),
|
||||
messages: [welcomeMessage],
|
||||
draftCards: [],
|
||||
pendingClarifications: cloneRecord(input.pendingClarifications),
|
||||
suggestedActions: cloneRecord(input.suggestedActions),
|
||||
recommendedReplies: cloneRecord(input.recommendedReplies ?? []),
|
||||
qualityFindings: [],
|
||||
assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}),
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const compatibleRecord = applyCompatibility(record);
|
||||
await this.persist(compatibleRecord);
|
||||
return cloneRecord(compatibleRecord);
|
||||
async create(
|
||||
userId: string,
|
||||
input: CreateCustomWorldAgentSessionInput,
|
||||
): Promise<CustomWorldAgentSessionRecord> {
|
||||
const record = createCustomWorldAgentSessionRecord(userId, input);
|
||||
await this.persist(record);
|
||||
return cloneRpgAgentSessionValue(record);
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
const records =
|
||||
await this.runtimeRepository.listCustomWorldSessions(userId);
|
||||
async list(userId: string): Promise<CustomWorldAgentSessionRecord[]> {
|
||||
const records = await this.sessionRepository.list(userId);
|
||||
|
||||
return records
|
||||
.filter((record) => isAgentSessionRecord(record))
|
||||
.map((record) => cloneRecord(applyCompatibility(record)))
|
||||
.filter((record) => isCustomWorldAgentSessionRecord(record))
|
||||
.map((record) => normalizeCompatibleSessionRecord(record))
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
async get(userId: string, sessionId: string) {
|
||||
async get(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
): Promise<CustomWorldAgentSessionRecord | null> {
|
||||
if (!sessionId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = await this.runtimeRepository.getCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
);
|
||||
if (!isAgentSessionRecord(record)) {
|
||||
const record = await this.sessionRepository.get(userId, sessionId);
|
||||
if (!isCustomWorldAgentSessionRecord(record)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cloneRecord(applyCompatibility(record));
|
||||
return normalizeCompatibleSessionRecord(record);
|
||||
}
|
||||
|
||||
async getSnapshot(userId: string, sessionId: string) {
|
||||
@@ -687,7 +154,7 @@ export class CustomWorldAgentSessionStore {
|
||||
message: CustomWorldAgentMessage,
|
||||
) {
|
||||
return this.mutate(userId, sessionId, (record) => {
|
||||
record.messages.push(cloneRecord(message));
|
||||
record.messages.push(cloneRpgAgentSessionValue(message));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -740,39 +207,47 @@ export class CustomWorldAgentSessionStore {
|
||||
record.focusCardId = patch.focusCardId;
|
||||
}
|
||||
if (patch.creatorIntent !== undefined) {
|
||||
record.creatorIntent = cloneRecord(patch.creatorIntent);
|
||||
record.creatorIntent = cloneRpgAgentSessionValue(patch.creatorIntent);
|
||||
}
|
||||
if (patch.creatorIntentReadiness !== undefined) {
|
||||
record.creatorIntentReadiness = cloneRecord(
|
||||
record.creatorIntentReadiness = cloneRpgAgentSessionValue(
|
||||
patch.creatorIntentReadiness,
|
||||
);
|
||||
}
|
||||
if (patch.anchorPack !== undefined) {
|
||||
record.anchorPack = cloneRecord(patch.anchorPack);
|
||||
record.anchorPack = cloneRpgAgentSessionValue(patch.anchorPack);
|
||||
}
|
||||
if (patch.lockState !== undefined) {
|
||||
record.lockState = cloneRecord(patch.lockState);
|
||||
record.lockState = cloneRpgAgentSessionValue(patch.lockState);
|
||||
}
|
||||
if (patch.draftProfile !== undefined) {
|
||||
record.draftProfile = cloneRecord(patch.draftProfile);
|
||||
record.draftProfile = cloneRpgAgentSessionValue(patch.draftProfile);
|
||||
}
|
||||
if (patch.pendingClarifications !== undefined) {
|
||||
record.pendingClarifications = cloneRecord(patch.pendingClarifications);
|
||||
record.pendingClarifications = cloneRpgAgentSessionValue(
|
||||
patch.pendingClarifications,
|
||||
);
|
||||
}
|
||||
if (patch.suggestedActions !== undefined) {
|
||||
record.suggestedActions = cloneRecord(patch.suggestedActions);
|
||||
record.suggestedActions = cloneRpgAgentSessionValue(
|
||||
patch.suggestedActions,
|
||||
);
|
||||
}
|
||||
if (patch.recommendedReplies !== undefined) {
|
||||
record.recommendedReplies = cloneRecord(patch.recommendedReplies);
|
||||
record.recommendedReplies = cloneRpgAgentSessionValue(
|
||||
patch.recommendedReplies,
|
||||
);
|
||||
}
|
||||
if (patch.draftCards !== undefined) {
|
||||
record.draftCards = cloneRecord(patch.draftCards);
|
||||
record.draftCards = cloneRpgAgentSessionValue(patch.draftCards);
|
||||
}
|
||||
if (patch.qualityFindings !== undefined) {
|
||||
record.qualityFindings = cloneRecord(patch.qualityFindings);
|
||||
record.qualityFindings = cloneRpgAgentSessionValue(
|
||||
patch.qualityFindings,
|
||||
);
|
||||
}
|
||||
if (patch.assetCoverage !== undefined) {
|
||||
record.assetCoverage = cloneRecord(patch.assetCoverage);
|
||||
record.assetCoverage = cloneRpgAgentSessionValue(patch.assetCoverage);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -783,7 +258,7 @@ export class CustomWorldAgentSessionStore {
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
return this.mutate(userId, sessionId, (record) => {
|
||||
record.operations.push(cloneRecord(operation));
|
||||
record.operations.push(cloneRpgAgentSessionValue(operation));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -796,7 +271,7 @@ export class CustomWorldAgentSessionStore {
|
||||
const operation = record.operations.find(
|
||||
(item) => item.operationId === operationId,
|
||||
);
|
||||
return operation ? cloneRecord(operation) : null;
|
||||
return operation ? cloneRpgAgentSessionValue(operation) : null;
|
||||
}
|
||||
|
||||
async updateOperation(
|
||||
@@ -840,6 +315,28 @@ export class CustomWorldAgentSessionStore {
|
||||
input: {
|
||||
checkpointId?: string;
|
||||
label: string;
|
||||
snapshot?: Partial<
|
||||
Pick<
|
||||
CustomWorldAgentSessionRecord,
|
||||
| 'currentTurn'
|
||||
| 'anchorContent'
|
||||
| 'progressPercent'
|
||||
| 'lastAssistantReply'
|
||||
| 'stage'
|
||||
| 'focusCardId'
|
||||
| 'creatorIntent'
|
||||
| 'creatorIntentReadiness'
|
||||
| 'anchorPack'
|
||||
| 'lockState'
|
||||
| 'draftProfile'
|
||||
| 'pendingClarifications'
|
||||
| 'suggestedActions'
|
||||
| 'recommendedReplies'
|
||||
| 'draftCards'
|
||||
| 'qualityFindings'
|
||||
| 'assetCoverage'
|
||||
>
|
||||
> | null;
|
||||
},
|
||||
) {
|
||||
return this.mutate(userId, sessionId, (record) => {
|
||||
@@ -849,12 +346,106 @@ export class CustomWorldAgentSessionStore {
|
||||
`checkpoint-${crypto.randomBytes(8).toString('hex')}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
label: input.label,
|
||||
snapshot: input.snapshot
|
||||
? {
|
||||
currentTurn:
|
||||
typeof input.snapshot.currentTurn === 'number'
|
||||
? Math.max(0, Math.round(input.snapshot.currentTurn))
|
||||
: record.currentTurn,
|
||||
anchorContent: cloneRpgAgentSessionValue(
|
||||
input.snapshot.anchorContent ?? record.anchorContent,
|
||||
),
|
||||
progressPercent:
|
||||
typeof input.snapshot.progressPercent === 'number'
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(input.snapshot.progressPercent)),
|
||||
)
|
||||
: record.progressPercent,
|
||||
lastAssistantReply:
|
||||
input.snapshot.lastAssistantReply ?? record.lastAssistantReply,
|
||||
stage: input.snapshot.stage ?? record.stage,
|
||||
focusCardId:
|
||||
input.snapshot.focusCardId !== undefined
|
||||
? input.snapshot.focusCardId
|
||||
: record.focusCardId,
|
||||
creatorIntent: cloneRpgAgentSessionValue(
|
||||
input.snapshot.creatorIntent ?? record.creatorIntent,
|
||||
),
|
||||
creatorIntentReadiness: cloneRpgAgentSessionValue(
|
||||
input.snapshot.creatorIntentReadiness ??
|
||||
record.creatorIntentReadiness,
|
||||
),
|
||||
anchorPack: cloneRpgAgentSessionValue(
|
||||
input.snapshot.anchorPack ?? record.anchorPack,
|
||||
),
|
||||
lockState: cloneRpgAgentSessionValue(
|
||||
input.snapshot.lockState ?? record.lockState,
|
||||
),
|
||||
draftProfile: cloneRpgAgentSessionValue(
|
||||
input.snapshot.draftProfile ?? record.draftProfile,
|
||||
),
|
||||
pendingClarifications: cloneRpgAgentSessionValue(
|
||||
input.snapshot.pendingClarifications ??
|
||||
record.pendingClarifications,
|
||||
),
|
||||
suggestedActions: cloneRpgAgentSessionValue(
|
||||
input.snapshot.suggestedActions ?? record.suggestedActions,
|
||||
),
|
||||
recommendedReplies: cloneRpgAgentSessionValue(
|
||||
input.snapshot.recommendedReplies ?? record.recommendedReplies,
|
||||
),
|
||||
draftCards: cloneRpgAgentSessionValue(
|
||||
input.snapshot.draftCards ?? record.draftCards,
|
||||
),
|
||||
qualityFindings: cloneRpgAgentSessionValue(
|
||||
input.snapshot.qualityFindings ?? record.qualityFindings,
|
||||
),
|
||||
assetCoverage: cloneRpgAgentSessionValue(
|
||||
input.snapshot.assetCoverage ?? record.assetCoverage,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async restoreCheckpoint(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
checkpointId: string,
|
||||
) {
|
||||
return this.mutate(userId, sessionId, (record) => {
|
||||
const checkpoint = record.checkpoints.find(
|
||||
(entry) => entry.checkpointId === checkpointId,
|
||||
);
|
||||
if (!checkpoint?.snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = cloneRpgAgentSessionValue(checkpoint.snapshot);
|
||||
record.currentTurn = snapshot.currentTurn;
|
||||
record.anchorContent = snapshot.anchorContent;
|
||||
record.progressPercent = snapshot.progressPercent;
|
||||
record.lastAssistantReply = snapshot.lastAssistantReply;
|
||||
record.stage = snapshot.stage;
|
||||
record.focusCardId = snapshot.focusCardId;
|
||||
record.creatorIntent = snapshot.creatorIntent;
|
||||
record.creatorIntentReadiness = snapshot.creatorIntentReadiness;
|
||||
record.anchorPack = snapshot.anchorPack;
|
||||
record.lockState = snapshot.lockState;
|
||||
record.draftProfile = snapshot.draftProfile;
|
||||
record.pendingClarifications = snapshot.pendingClarifications;
|
||||
record.suggestedActions = snapshot.suggestedActions;
|
||||
record.recommendedReplies = snapshot.recommendedReplies;
|
||||
record.draftCards = snapshot.draftCards;
|
||||
record.qualityFindings = snapshot.qualityFindings;
|
||||
record.assetCoverage = snapshot.assetCoverage;
|
||||
});
|
||||
}
|
||||
|
||||
async listDraftCards(userId: string, sessionId: string) {
|
||||
const record = await this.get(userId, sessionId);
|
||||
return record ? cloneRecord(record.draftCards) : null;
|
||||
return record ? cloneRpgAgentSessionValue(record.draftCards) : null;
|
||||
}
|
||||
}
|
||||
|
||||
199
server-node/src/services/customWorldAgentSnapshotBuilder.ts
Normal file
199
server-node/src/services/customWorldAgentSnapshotBuilder.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldAgentStage,
|
||||
CustomWorldAssetCoverageSummary,
|
||||
CustomWorldDraftCardSummary,
|
||||
CustomWorldPendingClarification,
|
||||
EightAnchorContent,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
buildDraftSummaryFromIntent,
|
||||
buildDraftTitleFromIntent,
|
||||
type CustomWorldCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import { CustomWorldAgentQualityGateService } from './customWorldAgentQualityGateService.js';
|
||||
import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js';
|
||||
import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js';
|
||||
import { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js';
|
||||
import { buildAnchorPackFromEightAnchorContent } from './eightAnchorCompatibilityService.js';
|
||||
import { CustomWorldAgentDraftCompiler } from './customWorldAgentDraftCompiler.js';
|
||||
|
||||
export type CustomWorldAgentDerivedStatePatch = Partial<
|
||||
Pick<
|
||||
CustomWorldAgentSessionRecord,
|
||||
| 'currentTurn'
|
||||
| 'anchorContent'
|
||||
| 'progressPercent'
|
||||
| 'lastAssistantReply'
|
||||
| 'stage'
|
||||
| 'focusCardId'
|
||||
| 'creatorIntent'
|
||||
| 'creatorIntentReadiness'
|
||||
| 'anchorPack'
|
||||
| 'draftProfile'
|
||||
| 'pendingClarifications'
|
||||
| 'suggestedActions'
|
||||
| 'recommendedReplies'
|
||||
| 'draftCards'
|
||||
| 'qualityFindings'
|
||||
| 'assetCoverage'
|
||||
>
|
||||
>;
|
||||
|
||||
export class CustomWorldAgentSnapshotBuilder {
|
||||
constructor(
|
||||
private readonly draftCompiler: CustomWorldAgentDraftCompiler,
|
||||
private readonly suggestedActionService: CustomWorldAgentSuggestedActionService,
|
||||
private readonly qualityGateService: CustomWorldAgentQualityGateService,
|
||||
) {}
|
||||
|
||||
// 把“草稿改动后需要重算哪些派生字段”统一封装成一个入口,避免每个 action 都重复拼 patch。
|
||||
buildRefiningState(params: {
|
||||
previousStage: CustomWorldAgentStage;
|
||||
draftProfile: Record<string, unknown>;
|
||||
draftCards?: CustomWorldDraftCardSummary[];
|
||||
assetCoverage?: CustomWorldAssetCoverageSummary;
|
||||
nextStage?: Extract<
|
||||
CustomWorldAgentStage,
|
||||
| 'object_refining'
|
||||
| 'visual_refining'
|
||||
| 'long_tail_review'
|
||||
| 'ready_to_publish'
|
||||
>;
|
||||
focusCardId?: string | null;
|
||||
}): CustomWorldAgentDerivedStatePatch {
|
||||
const nextDraftCards =
|
||||
params.draftCards ?? this.draftCompiler.compileDraftCards(params.draftProfile);
|
||||
const assetCoverage =
|
||||
params.assetCoverage ?? rebuildRoleAssetCoverage(params.draftProfile);
|
||||
const nextStage =
|
||||
params.nextStage ??
|
||||
(params.previousStage === 'visual_refining'
|
||||
? 'visual_refining'
|
||||
: params.previousStage === 'long_tail_review'
|
||||
? 'long_tail_review'
|
||||
: params.previousStage === 'ready_to_publish'
|
||||
? 'ready_to_publish'
|
||||
: 'object_refining');
|
||||
|
||||
return {
|
||||
stage: nextStage,
|
||||
draftProfile: params.draftProfile,
|
||||
draftCards: nextDraftCards,
|
||||
assetCoverage,
|
||||
qualityFindings: this.qualityGateService.buildQualityFindings({
|
||||
draftProfile: params.draftProfile,
|
||||
assetCoverage,
|
||||
stage: nextStage,
|
||||
}),
|
||||
focusCardId: params.focusCardId,
|
||||
suggestedActions: this.suggestedActionService.buildSuggestedActions({
|
||||
stage: nextStage,
|
||||
isReady: true,
|
||||
draftProfile: params.draftProfile,
|
||||
draftCards: nextDraftCards,
|
||||
}),
|
||||
recommendedReplies: [],
|
||||
};
|
||||
}
|
||||
|
||||
buildFoundationDraftState(params: {
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
anchorPack: Record<string, unknown> | null;
|
||||
draftProfile: Record<string, unknown>;
|
||||
assetCoverage?: CustomWorldAssetCoverageSummary;
|
||||
}): CustomWorldAgentDerivedStatePatch {
|
||||
return {
|
||||
...this.buildRefiningState({
|
||||
previousStage: 'object_refining',
|
||||
nextStage: 'object_refining',
|
||||
draftProfile: params.draftProfile,
|
||||
assetCoverage: params.assetCoverage,
|
||||
}),
|
||||
creatorIntent: params.creatorIntent,
|
||||
anchorPack: params.anchorPack,
|
||||
pendingClarifications: [],
|
||||
};
|
||||
}
|
||||
|
||||
buildMessageTurnState(params: {
|
||||
latestSession: CustomWorldAgentSessionRecord;
|
||||
nextAnchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
replyText: string;
|
||||
nextCreatorIntent: CustomWorldCreatorIntentRecord;
|
||||
creatorIntentReadiness: CreatorIntentReadiness;
|
||||
derivedDraftProfile: {
|
||||
title: string;
|
||||
summary: string;
|
||||
};
|
||||
derivedPendingClarifications: CustomWorldPendingClarification[];
|
||||
derivedStage: CustomWorldAgentStage;
|
||||
shouldStayInDraftStage: boolean;
|
||||
}): CustomWorldAgentDerivedStatePatch {
|
||||
const preservedStage =
|
||||
params.latestSession.stage === 'visual_refining'
|
||||
? ('visual_refining' as const)
|
||||
: ('object_refining' as const);
|
||||
const nextDraftProfile = params.shouldStayInDraftStage
|
||||
? ((params.latestSession.draftProfile ?? {}) as Record<string, unknown>)
|
||||
: params.progressPercent >= 100
|
||||
? {
|
||||
title: buildDraftTitleFromIntent(params.nextCreatorIntent),
|
||||
summary: buildDraftSummaryFromIntent(params.nextCreatorIntent),
|
||||
}
|
||||
: params.derivedDraftProfile;
|
||||
const nextStage = params.shouldStayInDraftStage
|
||||
? preservedStage
|
||||
: params.derivedStage;
|
||||
const assetCoverage = params.shouldStayInDraftStage
|
||||
? params.latestSession.assetCoverage
|
||||
: rebuildRoleAssetCoverage(nextDraftProfile);
|
||||
|
||||
return {
|
||||
currentTurn: params.latestSession.currentTurn + 1,
|
||||
anchorContent: params.nextAnchorContent,
|
||||
progressPercent: params.progressPercent,
|
||||
lastAssistantReply: params.replyText,
|
||||
stage: nextStage,
|
||||
focusCardId: params.shouldStayInDraftStage
|
||||
? params.latestSession.focusCardId
|
||||
: null,
|
||||
creatorIntent: params.nextCreatorIntent,
|
||||
creatorIntentReadiness: params.creatorIntentReadiness,
|
||||
anchorPack: buildAnchorPackFromEightAnchorContent(
|
||||
params.nextAnchorContent,
|
||||
params.progressPercent,
|
||||
),
|
||||
draftProfile: nextDraftProfile,
|
||||
draftCards: params.shouldStayInDraftStage
|
||||
? params.latestSession.draftCards
|
||||
: [],
|
||||
assetCoverage,
|
||||
qualityFindings: this.qualityGateService.buildQualityFindings({
|
||||
draftProfile: nextDraftProfile,
|
||||
assetCoverage,
|
||||
stage: nextStage,
|
||||
}),
|
||||
pendingClarifications:
|
||||
params.progressPercent >= 100 ? [] : params.derivedPendingClarifications,
|
||||
suggestedActions: params.shouldStayInDraftStage
|
||||
? this.suggestedActionService.buildSuggestedActions({
|
||||
stage: preservedStage,
|
||||
isReady: true,
|
||||
draftProfile: params.latestSession.draftProfile,
|
||||
draftCards: params.latestSession.draftCards,
|
||||
})
|
||||
: params.progressPercent >= 100
|
||||
? [
|
||||
{
|
||||
id: 'draft_foundation',
|
||||
type: 'draft_foundation',
|
||||
label: '生成游戏设定草稿',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
recommendedReplies: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
CustomWorldAgentStage,
|
||||
CustomWorldDraftCardSummary,
|
||||
CustomWorldSuggestedAction,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
getWorldFoundationCardId,
|
||||
normalizeFoundationDraftProfile,
|
||||
} from './customWorldAgentDraftCompiler.js';
|
||||
|
||||
export class CustomWorldAgentSuggestedActionService {
|
||||
// 统一维护 Agent 草稿阶段的建议动作,避免继续散落在 orchestrator 和 store 的兼容逻辑里。
|
||||
buildSuggestedActions(
|
||||
params: {
|
||||
stage?: CustomWorldAgentStage;
|
||||
isReady?: boolean;
|
||||
draftProfile?: unknown;
|
||||
draftCards?: CustomWorldDraftCardSummary[];
|
||||
} = {},
|
||||
): CustomWorldSuggestedAction[] {
|
||||
const profile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
const actions: CustomWorldSuggestedAction[] = [
|
||||
{
|
||||
id: 'request_summary',
|
||||
type: 'request_summary',
|
||||
label:
|
||||
params.stage === 'object_refining' ||
|
||||
params.stage === 'visual_refining'
|
||||
? '总结当前世界底稿'
|
||||
: '总结当前设定',
|
||||
},
|
||||
];
|
||||
|
||||
if (params.stage === 'foundation_review' && params.isReady) {
|
||||
actions.push({
|
||||
id: 'draft_foundation',
|
||||
type: 'draft_foundation',
|
||||
label: '整理一版世界底稿',
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (
|
||||
(params.stage === 'object_refining' ||
|
||||
params.stage === 'visual_refining') &&
|
||||
profile
|
||||
) {
|
||||
const worldCardId =
|
||||
params.draftCards?.find((entry) => entry.kind === 'world')?.id ??
|
||||
getWorldFoundationCardId();
|
||||
const firstCharacter = [...profile.playableNpcs, ...profile.storyNpcs][0];
|
||||
const firstLandmark = profile.landmarks[0];
|
||||
|
||||
actions.push({
|
||||
id: 'refine_world',
|
||||
type: 'refine_focus_target',
|
||||
label: '先看世界总卡',
|
||||
targetId: worldCardId,
|
||||
});
|
||||
|
||||
if (firstCharacter) {
|
||||
actions.push({
|
||||
id: `refine-character-${firstCharacter.id}`,
|
||||
type: 'refine_focus_target',
|
||||
label: `精修角色:${firstCharacter.name}`,
|
||||
targetId: firstCharacter.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (firstLandmark) {
|
||||
actions.push({
|
||||
id: `refine-landmark-${firstLandmark.id}`,
|
||||
type: 'refine_focus_target',
|
||||
label: `继续补地点:${firstLandmark.name}`,
|
||||
targetId: firstLandmark.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { AppContext } from '../context.js';
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfileFromOrchestrator,
|
||||
type GenerateCustomWorldProfileInput,
|
||||
} from '../modules/ai/customWorldOrchestrator.js';
|
||||
import type { CustomWorldSession } from './customWorldSessionStore.js';
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
context: AppContext,
|
||||
session: CustomWorldSession,
|
||||
options: {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) {
|
||||
const input = {
|
||||
settingText: session.settingText,
|
||||
creatorIntent: session.creatorIntent,
|
||||
generationMode: session.generationMode,
|
||||
} satisfies GenerateCustomWorldProfileInput;
|
||||
|
||||
const profile = await generateCustomWorldProfileFromOrchestrator(
|
||||
context.llmClient,
|
||||
input,
|
||||
{
|
||||
onProgress: options.onProgress,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
|
||||
return JSON.parse(JSON.stringify(profile)) as Record<string, unknown>;
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { JsonObject } from '../../../packages/shared/src/contracts/common.js';
|
||||
import type {
|
||||
CustomWorldGenerationMode,
|
||||
CustomWorldQuestion,
|
||||
CustomWorldSessionRecord,
|
||||
CustomWorldSessionStatus,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
|
||||
export type CustomWorldSession = {
|
||||
sessionId: string;
|
||||
status: CustomWorldSessionStatus;
|
||||
settingText: string;
|
||||
creatorIntent: JsonObject | null;
|
||||
generationMode: CustomWorldGenerationMode;
|
||||
questions: CustomWorldQuestion[];
|
||||
result?: JsonObject;
|
||||
lastError?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function cloneSession(session: CustomWorldSession) {
|
||||
return JSON.parse(JSON.stringify(session)) as CustomWorldSession;
|
||||
}
|
||||
|
||||
function toSessionRecord(session: CustomWorldSession): CustomWorldSessionRecord {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
status: session.status,
|
||||
settingText: session.settingText,
|
||||
creatorIntent: session.creatorIntent,
|
||||
generationMode: session.generationMode,
|
||||
questions: session.questions,
|
||||
result: session.result,
|
||||
lastError: session.lastError,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toSession(record: CustomWorldSessionRecord) {
|
||||
return cloneSession({
|
||||
sessionId: record.sessionId,
|
||||
status: record.status,
|
||||
settingText: record.settingText,
|
||||
creatorIntent: record.creatorIntent ?? null,
|
||||
generationMode: record.generationMode,
|
||||
questions: record.questions,
|
||||
result: record.result,
|
||||
lastError: record.lastError,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function hasPendingQuestion(questions: CustomWorldQuestion[]) {
|
||||
return questions.some((question) => !question.answer?.trim());
|
||||
}
|
||||
|
||||
function buildClarificationQuestions(
|
||||
settingText: string,
|
||||
creatorIntent: JsonObject | null,
|
||||
) {
|
||||
const questions: CustomWorldQuestion[] = [];
|
||||
const worldHook =
|
||||
typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : '';
|
||||
const playerPremise =
|
||||
typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : '';
|
||||
const openingSituation =
|
||||
typeof creatorIntent?.openingSituation === 'string'
|
||||
? creatorIntent.openingSituation.trim()
|
||||
: '';
|
||||
const coreConflicts = Array.isArray(creatorIntent?.coreConflicts)
|
||||
? creatorIntent.coreConflicts
|
||||
: [];
|
||||
|
||||
if (!worldHook && settingText.trim().length < 24) {
|
||||
questions.push({
|
||||
id: 'world_hook',
|
||||
label: '世界核心',
|
||||
question: '请用一句话补充这个世界最核心的命题或独特卖点。',
|
||||
});
|
||||
}
|
||||
if (!playerPremise) {
|
||||
questions.push({
|
||||
id: 'player_premise',
|
||||
label: '玩家身份',
|
||||
question: '玩家在这个世界里是什么身份、立场或来历?',
|
||||
});
|
||||
}
|
||||
if (!openingSituation) {
|
||||
questions.push({
|
||||
id: 'opening_situation',
|
||||
label: '开局处境',
|
||||
question: '故事开局时,玩家正处于什么局面?',
|
||||
});
|
||||
}
|
||||
if (coreConflicts.length === 0) {
|
||||
questions.push({
|
||||
id: 'core_conflict',
|
||||
label: '核心冲突',
|
||||
question: '这个世界当前最核心的冲突、危机或悬念是什么?',
|
||||
});
|
||||
}
|
||||
|
||||
return questions;
|
||||
}
|
||||
|
||||
export class CustomWorldSessionStore {
|
||||
constructor(
|
||||
private readonly runtimeRepository: RuntimeRepositoryPort,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
settingText: string,
|
||||
creatorIntent: JsonObject | null,
|
||||
generationMode: CustomWorldGenerationMode,
|
||||
) {
|
||||
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
|
||||
const now = new Date().toISOString();
|
||||
const session: CustomWorldSession = {
|
||||
sessionId,
|
||||
status: 'ready_to_generate',
|
||||
settingText,
|
||||
creatorIntent,
|
||||
generationMode,
|
||||
questions: buildClarificationQuestions(settingText, creatorIntent),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (hasPendingQuestion(session.questions)) {
|
||||
session.status = 'clarifying';
|
||||
}
|
||||
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
const sessions = await this.runtimeRepository.listCustomWorldSessions(userId);
|
||||
return sessions.map((session) => toSession(session));
|
||||
}
|
||||
|
||||
async get(userId: string, sessionId: string) {
|
||||
const session = await this.runtimeRepository.getCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
);
|
||||
return session ? toSession(session) : null;
|
||||
}
|
||||
|
||||
async answer(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
questionId: string,
|
||||
answer: string,
|
||||
) {
|
||||
const session = await this.get(userId, sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = session.questions.find((item) => item.id === questionId);
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
question.answer = answer;
|
||||
session.status = hasPendingQuestion(session.questions)
|
||||
? 'clarifying'
|
||||
: 'ready_to_generate';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
status: CustomWorldSessionStatus,
|
||||
lastError = '',
|
||||
) {
|
||||
const session = await this.get(userId, sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = status;
|
||||
session.lastError = lastError || undefined;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
async setResult(userId: string, sessionId: string, result: JsonObject) {
|
||||
const session = await this.get(userId, sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = 'completed';
|
||||
session.lastError = undefined;
|
||||
session.result = JSON.parse(JSON.stringify(result)) as JsonObject;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createRpgAgentSessionFixture,
|
||||
createRpgCreationWorksResponseFixture,
|
||||
createRpgWorldLibraryEntryFixture,
|
||||
} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
|
||||
import {
|
||||
CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX,
|
||||
CustomWorldAgentSessionStore,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
|
||||
|
||||
test('work summary service can aggregate shared RPG fixtures into draft and published entries', async () => {
|
||||
const sessionFixture = createRpgAgentSessionFixture();
|
||||
const sessionRecord: CustomWorldSessionRecord = {
|
||||
...JSON.parse(JSON.stringify(sessionFixture)),
|
||||
sessionId: `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}fixture`,
|
||||
userId: 'fixture-user',
|
||||
seedText: '被海雾吞没的旧航路群岛',
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: sessionFixture.updatedAt,
|
||||
updatedAt: sessionFixture.updatedAt,
|
||||
} as CustomWorldSessionRecord;
|
||||
const { rpgAgentSessionRepository, rpgWorldProfileRepository } =
|
||||
createInMemoryRpgWorldRepositoryPorts({
|
||||
sessionRecords: [sessionRecord],
|
||||
profileEntries: [createRpgWorldLibraryEntryFixture()],
|
||||
});
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const summaries = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list('fixture-user');
|
||||
const expected = createRpgCreationWorksResponseFixture();
|
||||
|
||||
assert.equal(summaries.length, expected.items.length);
|
||||
|
||||
const draftItem = summaries.find((entry) => entry.sourceType === 'agent_session');
|
||||
const publishedItem = summaries.find(
|
||||
(entry) => entry.sourceType === 'published_profile',
|
||||
);
|
||||
const expectedDraft = expected.items.find(
|
||||
(entry) => entry.sourceType === 'agent_session',
|
||||
);
|
||||
const expectedPublished = expected.items.find(
|
||||
(entry) => entry.sourceType === 'published_profile',
|
||||
);
|
||||
|
||||
assert.ok(draftItem);
|
||||
assert.ok(publishedItem);
|
||||
assert.ok(expectedDraft);
|
||||
assert.ok(expectedPublished);
|
||||
|
||||
assert.equal(draftItem?.title, expectedDraft?.title);
|
||||
assert.equal(draftItem?.subtitle, expectedDraft?.subtitle);
|
||||
assert.equal(draftItem?.coverRenderMode, expectedDraft?.coverRenderMode);
|
||||
assert.deepEqual(
|
||||
draftItem?.coverCharacterImageSrcs,
|
||||
expectedDraft?.coverCharacterImageSrcs,
|
||||
);
|
||||
assert.equal(draftItem?.roleAssetSummaryLabel, expectedDraft?.roleAssetSummaryLabel);
|
||||
assert.equal(draftItem?.publishReady, expectedDraft?.publishReady);
|
||||
assert.equal(draftItem?.blockerCount, expectedDraft?.blockerCount);
|
||||
|
||||
assert.equal(publishedItem?.title, expectedPublished?.title);
|
||||
assert.equal(publishedItem?.profileId, expectedPublished?.profileId);
|
||||
assert.equal(publishedItem?.canEnterWorld, true);
|
||||
assert.equal(publishedItem?.coverRenderMode, expectedPublished?.coverRenderMode);
|
||||
});
|
||||
|
||||
test('published agent sessions are filtered out after works unify to published profile truth', async () => {
|
||||
const sessionFixture = createRpgAgentSessionFixture();
|
||||
const sessionRecord: CustomWorldSessionRecord = {
|
||||
...JSON.parse(JSON.stringify(sessionFixture)),
|
||||
sessionId: `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}published-fixture`,
|
||||
userId: 'fixture-user',
|
||||
seedText: '被海雾吞没的旧航路群岛',
|
||||
stage: 'published',
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: sessionFixture.updatedAt,
|
||||
updatedAt: sessionFixture.updatedAt,
|
||||
} as CustomWorldSessionRecord;
|
||||
const { rpgAgentSessionRepository, rpgWorldProfileRepository } =
|
||||
createInMemoryRpgWorldRepositoryPorts({
|
||||
sessionRecords: [sessionRecord],
|
||||
profileEntries: [createRpgWorldLibraryEntryFixture()],
|
||||
});
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
|
||||
const summaries = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list('fixture-user');
|
||||
|
||||
assert.equal(
|
||||
summaries.some((entry) => entry.sourceType === 'agent_session'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
summaries.filter((entry) => entry.sourceType === 'published_profile').length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -1,297 +0,0 @@
|
||||
import type {
|
||||
CustomWorldAgentStage,
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type {
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldProfileRecord,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import {
|
||||
buildDraftSummaryFromIntent,
|
||||
buildDraftTitleFromIntent,
|
||||
normalizeCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import {
|
||||
rebuildRoleAssetCoverage,
|
||||
resolveRoleAssetStatusLabel,
|
||||
} from './customWorldAgentRoleAssetStateService.js';
|
||||
import type {
|
||||
CustomWorldAgentSessionRecord,
|
||||
CustomWorldAgentSessionStore,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
import {
|
||||
buildDraftSummaryFromEightAnchorContent,
|
||||
buildDraftTitleFromEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => item && typeof item === 'object')
|
||||
: [];
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number) {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function formatDraftStageLabel(stage: CustomWorldAgentStage) {
|
||||
if (stage === 'collecting_intent') return '收集世界锚点';
|
||||
if (stage === 'clarifying') return '补齐关键锚点';
|
||||
if (stage === 'foundation_review') return '准备整理底稿';
|
||||
if (stage === 'object_refining') return '精修对象';
|
||||
if (stage === 'visual_refining') return '视觉工坊';
|
||||
if (stage === 'long_tail_review') return '扩展长尾';
|
||||
if (stage === 'ready_to_publish') return '准备发布';
|
||||
if (stage === 'published') return '已发布';
|
||||
return '发生错误';
|
||||
}
|
||||
|
||||
function resolveDraftTitle(session: CustomWorldAgentSessionRecord) {
|
||||
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
|
||||
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
|
||||
return (
|
||||
draftProfile?.name ||
|
||||
buildDraftTitleFromEightAnchorContent(session.anchorContent) ||
|
||||
buildDraftTitleFromIntent(intent) ||
|
||||
toText(session.draftProfile?.title) ||
|
||||
truncateText(session.seedText, 18) ||
|
||||
'未命名草稿'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
|
||||
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
|
||||
const compiledSummary = buildDraftSummaryFromIntent(intent);
|
||||
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
|
||||
return (
|
||||
draftProfile?.summary ||
|
||||
buildDraftSummaryFromEightAnchorContent(session.anchorContent) ||
|
||||
compiledSummary ||
|
||||
toText(session.draftProfile?.summary) ||
|
||||
truncateText(session.seedText, 72) ||
|
||||
'还在收集你的世界锚点。'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDraftCounts(session: CustomWorldAgentSessionRecord) {
|
||||
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
if (draftProfile) {
|
||||
// 草稿列表里的“角色”展示的是当前草稿中全部可编辑角色,而不是仅限可扮演角色。
|
||||
const totalRoleCount = [
|
||||
...new Set(
|
||||
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
),
|
||||
].length;
|
||||
|
||||
return {
|
||||
playableNpcCount: totalRoleCount,
|
||||
landmarkCount: draftProfile.landmarks.length,
|
||||
};
|
||||
}
|
||||
|
||||
const playableNpcCount = session.draftCards.filter(
|
||||
(card) => card.kind === 'character',
|
||||
).length;
|
||||
const landmarkCount = session.draftCards.filter(
|
||||
(card) => card.kind === 'landmark' || card.kind === 'camp',
|
||||
).length;
|
||||
|
||||
return {
|
||||
playableNpcCount,
|
||||
landmarkCount,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) {
|
||||
const coverage = rebuildRoleAssetCoverage(session.draftProfile);
|
||||
const roleVisualReadyCount = coverage.roleAssets.filter(
|
||||
(entry) => entry.status !== 'missing',
|
||||
).length;
|
||||
const roleAnimationReadyCount = coverage.roleAssets.filter(
|
||||
(entry) => entry.status === 'complete',
|
||||
).length;
|
||||
const leadRole = coverage.roleAssets[0];
|
||||
|
||||
return {
|
||||
roleVisualReadyCount,
|
||||
roleAnimationReadyCount,
|
||||
roleAssetSummaryLabel: leadRole
|
||||
? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}`
|
||||
: coverage.roleAssets.length > 0
|
||||
? '角色资产进行中'
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDraftCover(session: CustomWorldAgentSessionRecord) {
|
||||
const draftProfile = toRecord(session.draftProfile);
|
||||
if (!draftProfile) {
|
||||
return {
|
||||
imageSrc: null,
|
||||
renderMode: 'image' as const,
|
||||
characterImageSrcs: [],
|
||||
};
|
||||
}
|
||||
|
||||
return resolveCustomWorldCoverPresentation(
|
||||
draftProfile as CustomWorldProfileRecord,
|
||||
);
|
||||
}
|
||||
|
||||
function isLibraryEntry(
|
||||
value: unknown,
|
||||
): value is CustomWorldLibraryEntry<CustomWorldProfileRecord> {
|
||||
const record = toRecord(value);
|
||||
return (
|
||||
Boolean(record) &&
|
||||
typeof record.ownerUserId === 'string' &&
|
||||
typeof record.profileId === 'string' &&
|
||||
Boolean(toRecord(record.profile))
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCustomWorldWorkSummaries(
|
||||
userId: string,
|
||||
dependencies: {
|
||||
runtimeRepository: RuntimeRepositoryPort;
|
||||
customWorldAgentSessions: CustomWorldAgentSessionStore;
|
||||
},
|
||||
) {
|
||||
const [profiles, sessions] = await Promise.all([
|
||||
dependencies.runtimeRepository.listCustomWorldProfiles(userId),
|
||||
dependencies.customWorldAgentSessions.list(userId),
|
||||
]);
|
||||
|
||||
const draftItems: CustomWorldWorkSummary[] = sessions.map((session) => {
|
||||
const counts = resolveDraftCounts(session);
|
||||
const roleAssetProgress = resolveDraftRoleAssetProgress(session);
|
||||
const coverPresentation = resolveDraftCover(session);
|
||||
|
||||
return {
|
||||
workId: `draft:${session.sessionId}`,
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: resolveDraftTitle(session),
|
||||
subtitle:
|
||||
normalizeFoundationDraftProfile(session.draftProfile)?.subtitle ||
|
||||
formatDraftStageLabel(session.stage),
|
||||
summary: resolveDraftSummary(session),
|
||||
coverImageSrc: coverPresentation.imageSrc,
|
||||
coverRenderMode: coverPresentation.renderMode,
|
||||
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
||||
updatedAt: session.updatedAt,
|
||||
publishedAt: null,
|
||||
stage: session.stage,
|
||||
stageLabel: formatDraftStageLabel(session.stage),
|
||||
playableNpcCount: counts.playableNpcCount,
|
||||
landmarkCount: counts.landmarkCount,
|
||||
roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount,
|
||||
roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount,
|
||||
roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel,
|
||||
sessionId: session.sessionId,
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
};
|
||||
});
|
||||
|
||||
const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => {
|
||||
const libraryEntry = isLibraryEntry(profile) ? profile : null;
|
||||
const profileRecord = (
|
||||
libraryEntry?.profile ?? profile
|
||||
) as CustomWorldProfileRecord & Record<string, unknown>;
|
||||
const playableNpcs = toRecordArray(profileRecord.playableNpcs);
|
||||
const landmarks = toRecordArray(profileRecord.landmarks);
|
||||
const updatedAt =
|
||||
(libraryEntry ? toText(libraryEntry.updatedAt) : '') ||
|
||||
toText(profileRecord.updatedAt) ||
|
||||
new Date().toISOString();
|
||||
const coverPresentation = resolveCustomWorldCoverPresentation(profileRecord);
|
||||
const roleVisualReadyCount = playableNpcs.filter(
|
||||
(entry) =>
|
||||
Boolean(toText(entry.imageSrc)) &&
|
||||
Boolean(toText(entry.generatedVisualAssetId)),
|
||||
).length;
|
||||
const roleAnimationReadyCount = playableNpcs.filter(
|
||||
(entry) => Boolean(toText(entry.generatedAnimationSetId)),
|
||||
).length;
|
||||
|
||||
return {
|
||||
workId: `published:${toText(profileRecord.id) || updatedAt}`,
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title:
|
||||
(libraryEntry ? toText(libraryEntry.worldName) : '') ||
|
||||
toText(profileRecord.name) ||
|
||||
'未命名世界',
|
||||
subtitle:
|
||||
(libraryEntry ? toText(libraryEntry.subtitle) : '') ||
|
||||
toText(profileRecord.subtitle) ||
|
||||
'已保存作品',
|
||||
summary:
|
||||
(libraryEntry ? toText(libraryEntry.summaryText) : '') ||
|
||||
toText(profileRecord.summary) ||
|
||||
'这个世界已经可以直接进入体验。',
|
||||
coverImageSrc:
|
||||
(libraryEntry ? libraryEntry.coverImageSrc : null) ||
|
||||
coverPresentation.imageSrc,
|
||||
coverRenderMode: coverPresentation.renderMode,
|
||||
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
||||
updatedAt,
|
||||
publishedAt:
|
||||
(libraryEntry ? toText(libraryEntry.publishedAt) : '') ||
|
||||
toText(profileRecord.publishedAt) ||
|
||||
updatedAt,
|
||||
stage: 'published',
|
||||
stageLabel: '已发布',
|
||||
playableNpcCount:
|
||||
(libraryEntry?.playableNpcCount ?? 0) > 0
|
||||
? libraryEntry!.playableNpcCount
|
||||
: playableNpcs.length,
|
||||
landmarkCount:
|
||||
(libraryEntry?.landmarkCount ?? 0) > 0
|
||||
? libraryEntry!.landmarkCount
|
||||
: landmarks.length,
|
||||
roleVisualReadyCount,
|
||||
roleAnimationReadyCount,
|
||||
roleAssetSummaryLabel:
|
||||
roleAnimationReadyCount > 0
|
||||
? `动作已就绪 ${roleAnimationReadyCount}`
|
||||
: roleVisualReadyCount > 0
|
||||
? `主图已就绪 ${roleVisualReadyCount}`
|
||||
: null,
|
||||
sessionId: null,
|
||||
profileId:
|
||||
(libraryEntry ? toText(libraryEntry.profileId) : '') ||
|
||||
toText(profileRecord.id) ||
|
||||
null,
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
};
|
||||
});
|
||||
|
||||
return [...draftItems, ...publishedItems].sort((left, right) =>
|
||||
right.updatedAt.localeCompare(left.updatedAt),
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/story.js';
|
||||
import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
|
||||
import {
|
||||
QUEST_INTIMACY_LEVELS,
|
||||
QUEST_NARRATIVE_TYPES,
|
||||
QUEST_OBJECTIVE_KINDS,
|
||||
QUEST_REWARD_THEMES,
|
||||
QUEST_URGENCY_LEVELS,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import {
|
||||
buildFallbackQuestIntent,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRpgAgentSessionFixture } from '../../../../packages/shared/src/contracts/rpgCreationFixtures.js';
|
||||
import { applyCustomWorldAgentSessionCompatibility } from './rpgAgentSessionCompatibility.js';
|
||||
|
||||
function createLegacySessionRecord() {
|
||||
const session = createRpgAgentSessionFixture();
|
||||
|
||||
return {
|
||||
...JSON.parse(JSON.stringify(session)),
|
||||
userId: 'fixture-user',
|
||||
seedText: '被海雾吞没的旧航路群岛',
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: session.updatedAt,
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
test('session compatibility can backfill foundation_review state directly without store participation', () => {
|
||||
const legacyRecord = createLegacySessionRecord();
|
||||
legacyRecord.stage = 'collecting_intent';
|
||||
legacyRecord.progressPercent = 0;
|
||||
legacyRecord.currentTurn = 0;
|
||||
legacyRecord.lastAssistantReply = null;
|
||||
legacyRecord.anchorPack = {};
|
||||
legacyRecord.pendingClarifications = [];
|
||||
legacyRecord.suggestedActions = [];
|
||||
legacyRecord.recommendedReplies = [];
|
||||
legacyRecord.creatorIntentReadiness = {
|
||||
isReady: false,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
};
|
||||
legacyRecord.anchorContent = {};
|
||||
legacyRecord.creatorIntent = {
|
||||
rawSettingText: '',
|
||||
worldHook: '被海雾吞没的旧航路群岛',
|
||||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||||
themeKeywords: ['压抑', '潮湿', '悬疑'],
|
||||
toneDirectives: ['旧灯塔', '潮雾'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
keyCharacters: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
role: '旧航路引路人',
|
||||
publicMask: '看上去像可靠旧友。',
|
||||
hiddenHook: '暗中替沉船商盟引路。',
|
||||
relationToPlayer: '旧友兼潜在背叛者',
|
||||
notes: '关键同行者。',
|
||||
},
|
||||
],
|
||||
iconicElements: ['回潮旧灯塔', '会移动的海雾'],
|
||||
};
|
||||
legacyRecord.draftProfile = {
|
||||
title: '潮雾列岛',
|
||||
summary: '第一版世界底稿已经整理完成。',
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
role: '旧航路引路人',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const normalized = applyCustomWorldAgentSessionCompatibility(legacyRecord);
|
||||
|
||||
assert.equal(normalized.stage, 'foundation_review');
|
||||
assert.equal(normalized.progressPercent, 0);
|
||||
assert.equal(normalized.creatorIntentReadiness.isReady, true);
|
||||
assert.match(
|
||||
normalized.lastAssistantReply ?? '',
|
||||
/世界底稿已整理完成|结果页确认资产与发布门槛/u,
|
||||
);
|
||||
assert.equal(normalized.pendingClarifications.length, 0);
|
||||
assert.equal(
|
||||
normalized.suggestedActions.some(
|
||||
(entry) => entry.type === 'draft_foundation',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(normalized.anchorContent.worldPromise?.hook, '被海雾吞没的旧航路群岛');
|
||||
assert.equal(normalized.draftProfile.name, '潮雾列岛');
|
||||
assert.equal(normalized.assetCoverage.roleAssets.length, 1);
|
||||
});
|
||||
|
||||
test('session compatibility can recover missing clarifications and anchor pack from sparse collecting records', () => {
|
||||
const legacyRecord = createLegacySessionRecord();
|
||||
legacyRecord.seedText = '';
|
||||
legacyRecord.stage = 'collecting_intent';
|
||||
legacyRecord.progressPercent = Number.NaN;
|
||||
legacyRecord.currentTurn = undefined;
|
||||
legacyRecord.lastAssistantReply = undefined;
|
||||
legacyRecord.anchorPack = null;
|
||||
legacyRecord.pendingClarifications = [];
|
||||
legacyRecord.suggestedActions = [];
|
||||
legacyRecord.recommendedReplies = [1, '继续补世界一句话', null];
|
||||
legacyRecord.anchorContent = {};
|
||||
legacyRecord.creatorIntent = {
|
||||
rawSettingText: '',
|
||||
worldHook: '一个被潮雾反复切开的边境世界。',
|
||||
playerPremise: '',
|
||||
openingSituation: '',
|
||||
themeKeywords: [],
|
||||
toneDirectives: [],
|
||||
coreConflicts: [],
|
||||
keyCharacters: [],
|
||||
iconicElements: [],
|
||||
};
|
||||
legacyRecord.messages = [
|
||||
{
|
||||
id: 'message-user',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '这个世界先定成一个被潮雾反复切开的边境世界。',
|
||||
createdAt: legacyRecord.updatedAt,
|
||||
relatedOperationId: null,
|
||||
},
|
||||
{
|
||||
id: 'message-assistant',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '你好!我是你的世界设定助手。',
|
||||
createdAt: legacyRecord.updatedAt,
|
||||
relatedOperationId: null,
|
||||
},
|
||||
];
|
||||
legacyRecord.draftProfile = {
|
||||
title: '潮雾边境',
|
||||
summary: '还在收集你的世界锚点。',
|
||||
};
|
||||
|
||||
const normalized = applyCustomWorldAgentSessionCompatibility(legacyRecord);
|
||||
|
||||
assert.equal(normalized.stage, 'clarifying');
|
||||
assert.ok(normalized.progressPercent > 0);
|
||||
assert.equal(normalized.creatorIntentReadiness.isReady, false);
|
||||
assert.ok(normalized.pendingClarifications.length > 0);
|
||||
assert.ok(normalized.pendingClarifications[0]?.question);
|
||||
assert.ok(normalized.anchorPack);
|
||||
assert.deepEqual(normalized.recommendedReplies, ['继续补世界一句话']);
|
||||
assert.ok(
|
||||
normalized.suggestedActions.some(
|
||||
(entry) => entry.type === 'request_summary',
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldAgentStage,
|
||||
CustomWorldAssetCoverageSummary,
|
||||
CustomWorldPendingClarification,
|
||||
} from '../../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
buildPendingClarifications,
|
||||
evaluateCreatorIntentReadiness,
|
||||
resolveCreatorIntentStage,
|
||||
} from '../customWorldAgentClarificationService.js';
|
||||
import {
|
||||
normalizeCreatorIntentRecord,
|
||||
} from '../customWorldAgentIntentExtractionService.js';
|
||||
import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js';
|
||||
import {
|
||||
buildAnchorPackFromEightAnchorContent,
|
||||
buildCreatorIntentFromEightAnchorContent,
|
||||
buildDraftSummaryFromEightAnchorContent,
|
||||
buildDraftTitleFromEightAnchorContent,
|
||||
buildEightAnchorContentFromCreatorIntent,
|
||||
estimateProgressPercentFromAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from '../eightAnchorCompatibilityService.js';
|
||||
import {
|
||||
CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX,
|
||||
type CustomWorldAgentSessionRecord,
|
||||
} from './rpgAgentSessionRecord.js';
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isStage(value: unknown): value is CustomWorldAgentStage {
|
||||
return (
|
||||
value === 'collecting_intent' ||
|
||||
value === 'clarifying' ||
|
||||
value === 'foundation_review' ||
|
||||
value === 'object_refining' ||
|
||||
value === 'visual_refining' ||
|
||||
value === 'long_tail_review' ||
|
||||
value === 'ready_to_publish' ||
|
||||
value === 'published' ||
|
||||
value === 'error'
|
||||
);
|
||||
}
|
||||
|
||||
export function isCustomWorldAgentSessionRecord(
|
||||
value: unknown,
|
||||
): value is CustomWorldAgentSessionRecord {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof record.sessionId === 'string' &&
|
||||
record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) &&
|
||||
typeof record.userId === 'string' &&
|
||||
isStage(record.stage) &&
|
||||
Array.isArray(record.messages) &&
|
||||
Array.isArray(record.operations) &&
|
||||
typeof record.createdAt === 'string' &&
|
||||
typeof record.updatedAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isCreatorIntentReadiness(
|
||||
value: unknown,
|
||||
): value is CreatorIntentReadiness {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof record.isReady === 'boolean' &&
|
||||
Array.isArray(record.completedKeys) &&
|
||||
Array.isArray(record.missingKeys)
|
||||
);
|
||||
}
|
||||
|
||||
function mapLegacyClarificationTargetKey(id: string) {
|
||||
if (id === 'world_hook') return 'world_hook';
|
||||
if (id === 'player_premise') return 'player_premise';
|
||||
if (id === 'theme_and_tone' || id === 'tone_boundary') {
|
||||
return 'theme_and_tone';
|
||||
}
|
||||
if (id === 'core_conflict') return 'core_conflict';
|
||||
if (id === 'relationship_seed' || id === 'relationship_hook') {
|
||||
return 'relationship_seed';
|
||||
}
|
||||
if (id === 'iconic_element' || id === 'iconic_elements') {
|
||||
return 'iconic_element';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasUserInput(record: CustomWorldAgentSessionRecord) {
|
||||
return (
|
||||
Boolean(record.seedText.trim()) ||
|
||||
record.messages.some(
|
||||
(message) => message.role === 'user' && message.text.trim(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) {
|
||||
const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent(
|
||||
normalizeEightAnchorContent(
|
||||
(record as Record<string, unknown>).anchorContent ?? null,
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
compatibleAnchorIntent &&
|
||||
(compatibleAnchorIntent.worldHook ||
|
||||
compatibleAnchorIntent.rawSettingText ||
|
||||
compatibleAnchorIntent.playerPremise ||
|
||||
compatibleAnchorIntent.openingSituation ||
|
||||
compatibleAnchorIntent.coreConflicts.length > 0 ||
|
||||
compatibleAnchorIntent.keyCharacters.length > 0 ||
|
||||
compatibleAnchorIntent.iconicElements.length > 0)
|
||||
) {
|
||||
return compatibleAnchorIntent;
|
||||
}
|
||||
|
||||
return normalizeCreatorIntentRecord(record.creatorIntent);
|
||||
}
|
||||
|
||||
function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) {
|
||||
if (typeof (record as Record<string, unknown>).currentTurn === 'number') {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round((record as Record<string, unknown>).currentTurn as number),
|
||||
);
|
||||
}
|
||||
|
||||
return record.messages.filter((message) => message.role === 'user').length;
|
||||
}
|
||||
|
||||
function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) {
|
||||
const normalized = normalizeEightAnchorContent(
|
||||
(record as Record<string, unknown>).anchorContent ?? null,
|
||||
);
|
||||
|
||||
if (
|
||||
normalized.worldPromise ||
|
||||
normalized.playerFantasy ||
|
||||
normalized.themeBoundary ||
|
||||
normalized.playerEntryPoint ||
|
||||
normalized.coreConflict ||
|
||||
normalized.keyRelationships.length > 0 ||
|
||||
normalized.hiddenLines ||
|
||||
normalized.iconicElements
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return buildEightAnchorContentFromCreatorIntent(
|
||||
buildCompatibleCreatorIntent(record),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) {
|
||||
const rawProgress = (record as Record<string, unknown>).progressPercent;
|
||||
if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) {
|
||||
return Math.max(0, Math.min(100, Math.round(rawProgress)));
|
||||
}
|
||||
|
||||
if (
|
||||
record.stage === 'foundation_review' ||
|
||||
record.stage === 'object_refining' ||
|
||||
record.stage === 'visual_refining' ||
|
||||
record.stage === 'long_tail_review' ||
|
||||
record.stage === 'ready_to_publish' ||
|
||||
record.stage === 'published'
|
||||
) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return estimateProgressPercentFromAnchorContent(
|
||||
buildCompatibleAnchorContent(record),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) {
|
||||
const existingReply = (record as Record<string, unknown>).lastAssistantReply;
|
||||
if (typeof existingReply === 'string') {
|
||||
return existingReply;
|
||||
}
|
||||
|
||||
const lastAssistantMessage = [...record.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant' && message.text.trim());
|
||||
|
||||
return lastAssistantMessage?.text ?? null;
|
||||
}
|
||||
|
||||
function buildCompatiblePendingClarifications(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
) {
|
||||
const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent);
|
||||
const readiness = buildCompatibleReadiness(record);
|
||||
const legacyClarifications = Array.isArray(record.pendingClarifications)
|
||||
? record.pendingClarifications
|
||||
: [];
|
||||
|
||||
const nextClarifications = legacyClarifications
|
||||
.map((entry, index) => {
|
||||
const targetKey = mapLegacyClarificationTargetKey(entry.id);
|
||||
if (!targetKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: entry.id || targetKey,
|
||||
label: entry.label || '待补充问题',
|
||||
question: entry.question || '',
|
||||
targetKey,
|
||||
priority:
|
||||
typeof entry.priority === 'number' ? entry.priority : index + 1,
|
||||
answer: entry.answer,
|
||||
} satisfies CustomWorldPendingClarification;
|
||||
})
|
||||
.filter((entry): entry is CustomWorldPendingClarification =>
|
||||
Boolean(entry?.question),
|
||||
)
|
||||
.slice(0, 3);
|
||||
|
||||
if (nextClarifications.length > 0) {
|
||||
return nextClarifications;
|
||||
}
|
||||
|
||||
return buildPendingClarifications(normalizedIntent, readiness);
|
||||
}
|
||||
|
||||
function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) {
|
||||
if (
|
||||
isCreatorIntentReadiness(
|
||||
(record as Record<string, unknown>).creatorIntentReadiness,
|
||||
)
|
||||
) {
|
||||
return record.creatorIntentReadiness;
|
||||
}
|
||||
|
||||
return evaluateCreatorIntentReadiness(
|
||||
normalizeCreatorIntentRecord(record.creatorIntent),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatibleDraftProfile(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
) {
|
||||
const anchorContent = buildCompatibleAnchorContent(record);
|
||||
const existingDraftProfile = toRecord(record.draftProfile);
|
||||
const hasFoundationContent = Boolean(
|
||||
existingDraftProfile &&
|
||||
(typeof existingDraftProfile.name === 'string' ||
|
||||
Array.isArray(existingDraftProfile.playableNpcs) ||
|
||||
Array.isArray(existingDraftProfile.landmarks) ||
|
||||
Array.isArray(existingDraftProfile.factions) ||
|
||||
Array.isArray(existingDraftProfile.threads) ||
|
||||
Array.isArray(existingDraftProfile.chapters)),
|
||||
);
|
||||
|
||||
if (hasFoundationContent) {
|
||||
return {
|
||||
...existingDraftProfile,
|
||||
name:
|
||||
toText(existingDraftProfile?.name) ||
|
||||
toText(existingDraftProfile?.title) ||
|
||||
buildDraftTitleFromEightAnchorContent(anchorContent),
|
||||
summary:
|
||||
toText(existingDraftProfile?.summary) ||
|
||||
buildDraftSummaryFromEightAnchorContent(anchorContent),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...(existingDraftProfile ?? {}),
|
||||
title:
|
||||
toText(existingDraftProfile?.title) ||
|
||||
buildDraftTitleFromEightAnchorContent(anchorContent),
|
||||
summary:
|
||||
toText(existingDraftProfile?.summary) ||
|
||||
buildDraftSummaryFromEightAnchorContent(anchorContent),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCompatibleSuggestedActions(params: {
|
||||
record: CustomWorldAgentSessionRecord;
|
||||
stage: CustomWorldAgentStage;
|
||||
readiness: CreatorIntentReadiness;
|
||||
}) {
|
||||
if (params.record.suggestedActions.length > 0) {
|
||||
// 旧快照里可能带有“精修对象”的 Agent 建议动作;当前 Agent 对话框只服务八锚点收集。
|
||||
const compatibleActions = params.record.suggestedActions.filter(
|
||||
(action) => action.type !== 'refine_focus_target',
|
||||
);
|
||||
if (compatibleActions.length > 0) {
|
||||
return compatibleActions;
|
||||
}
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
id: 'request_summary',
|
||||
type: 'request_summary',
|
||||
label:
|
||||
params.stage === 'object_refining' || params.stage === 'visual_refining'
|
||||
? '总结当前世界底稿'
|
||||
: '总结当前设定',
|
||||
},
|
||||
] as CustomWorldAgentSessionRecord['suggestedActions'];
|
||||
|
||||
if (params.stage === 'foundation_review' && params.readiness.isReady) {
|
||||
actions.push({
|
||||
id: 'draft_foundation',
|
||||
type: 'draft_foundation',
|
||||
label: '整理一版世界底稿',
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function normalizeRecommendedReplies(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function buildCompatibleAssetCoverage(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
draftProfile: Record<string, unknown>,
|
||||
) {
|
||||
const derivedCoverage = rebuildRoleAssetCoverage(draftProfile);
|
||||
const existingCoverage = toRecord(record.assetCoverage);
|
||||
const sceneAssets =
|
||||
derivedCoverage.sceneAssets.length > 0
|
||||
? derivedCoverage.sceneAssets
|
||||
: Array.isArray(existingCoverage?.sceneAssets)
|
||||
? existingCoverage.sceneAssets
|
||||
: [];
|
||||
const allSceneAssetsReady =
|
||||
derivedCoverage.sceneAssets.length > 0
|
||||
? derivedCoverage.allSceneAssetsReady
|
||||
: typeof existingCoverage?.allSceneAssetsReady === 'boolean'
|
||||
? existingCoverage.allSceneAssetsReady
|
||||
: false;
|
||||
|
||||
return {
|
||||
...derivedCoverage,
|
||||
sceneAssets,
|
||||
allSceneAssetsReady,
|
||||
} satisfies CustomWorldAssetCoverageSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容层集中收口旧 session 字段兜底,避免继续把兼容判断散落回 store 主逻辑。
|
||||
*/
|
||||
export function applyCustomWorldAgentSessionCompatibility(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
) {
|
||||
const creatorIntent = buildCompatibleCreatorIntent(record);
|
||||
const currentTurn = buildCompatibleCurrentTurn(record);
|
||||
const anchorContent = buildCompatibleAnchorContent(record);
|
||||
const progressPercent = buildCompatibleProgressPercent(record);
|
||||
const lastAssistantReply = buildCompatibleLastAssistantReply(record);
|
||||
const creatorIntentReadiness =
|
||||
progressPercent >= 100
|
||||
? {
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
}
|
||||
: evaluateCreatorIntentReadiness(creatorIntent);
|
||||
const stage =
|
||||
record.stage === 'object_refining' ||
|
||||
record.stage === 'visual_refining' ||
|
||||
record.stage === 'long_tail_review' ||
|
||||
record.stage === 'ready_to_publish' ||
|
||||
record.stage === 'published'
|
||||
? record.stage
|
||||
: progressPercent >= 100
|
||||
? ('foundation_review' as const)
|
||||
: resolveCreatorIntentStage({
|
||||
hasUserInput: hasUserInput(record),
|
||||
readiness: creatorIntentReadiness,
|
||||
});
|
||||
const pendingClarifications = buildCompatiblePendingClarifications({
|
||||
...record,
|
||||
creatorIntent,
|
||||
creatorIntentReadiness,
|
||||
});
|
||||
const draftProfile = buildCompatibleDraftProfile(record);
|
||||
|
||||
return {
|
||||
...record,
|
||||
currentTurn,
|
||||
anchorContent,
|
||||
progressPercent,
|
||||
lastAssistantReply,
|
||||
stage,
|
||||
creatorIntent,
|
||||
creatorIntentReadiness,
|
||||
anchorPack:
|
||||
record.anchorPack && Object.keys(record.anchorPack).length > 0
|
||||
? record.anchorPack
|
||||
: buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent),
|
||||
draftProfile,
|
||||
pendingClarifications,
|
||||
suggestedActions: buildCompatibleSuggestedActions({
|
||||
record,
|
||||
stage,
|
||||
readiness: creatorIntentReadiness,
|
||||
}),
|
||||
assetCoverage: buildCompatibleAssetCoverage(record, draftProfile),
|
||||
recommendedReplies: normalizeRecommendedReplies(
|
||||
(record as Record<string, unknown>).recommendedReplies,
|
||||
),
|
||||
} satisfies CustomWorldAgentSessionRecord;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { CustomWorldAgentMessage } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js';
|
||||
import {
|
||||
createEmptyEightAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from '../eightAnchorCompatibilityService.js';
|
||||
import { applyCustomWorldAgentSessionCompatibility } from './rpgAgentSessionCompatibility.js';
|
||||
import {
|
||||
cloneRpgAgentSessionValue,
|
||||
type CreateCustomWorldAgentSessionInput,
|
||||
type CustomWorldAgentSessionRecord,
|
||||
CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX,
|
||||
} from './rpgAgentSessionRecord.js';
|
||||
|
||||
/**
|
||||
* 新建 session 的初始值统一在这里生成,后续 store 只负责持久化与状态变更。
|
||||
*/
|
||||
export function createCustomWorldAgentSessionRecord(
|
||||
userId: string,
|
||||
input: CreateCustomWorldAgentSessionInput,
|
||||
) {
|
||||
const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`;
|
||||
const now = new Date().toISOString();
|
||||
const welcomeMessage: CustomWorldAgentMessage = {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: input.welcomeMessage,
|
||||
createdAt: now,
|
||||
relatedOperationId: null,
|
||||
};
|
||||
const record: CustomWorldAgentSessionRecord = {
|
||||
sessionId,
|
||||
userId,
|
||||
seedText: input.seedText?.trim() ?? '',
|
||||
currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)),
|
||||
anchorContent: normalizeEightAnchorContent(
|
||||
input.anchorContent ?? createEmptyEightAnchorContent(),
|
||||
),
|
||||
progressPercent: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(input.progressPercent ?? 0)),
|
||||
),
|
||||
lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage,
|
||||
stage: input.stage ?? 'collecting_intent',
|
||||
focusCardId: null,
|
||||
creatorIntent: cloneRpgAgentSessionValue(input.creatorIntent ?? {}),
|
||||
creatorIntentReadiness: input.creatorIntentReadiness ?? {
|
||||
isReady: false,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: cloneRpgAgentSessionValue(input.anchorPack ?? {}),
|
||||
lockState: {},
|
||||
draftProfile: cloneRpgAgentSessionValue(input.draftProfile ?? {}),
|
||||
messages: [welcomeMessage],
|
||||
draftCards: [],
|
||||
pendingClarifications: cloneRpgAgentSessionValue(
|
||||
input.pendingClarifications,
|
||||
),
|
||||
suggestedActions: cloneRpgAgentSessionValue(input.suggestedActions),
|
||||
recommendedReplies: cloneRpgAgentSessionValue(input.recommendedReplies ?? []),
|
||||
qualityFindings: [],
|
||||
assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}),
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
return applyCustomWorldAgentSessionCompatibility(record);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldAgentMessage,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentStage,
|
||||
CustomWorldAssetCoverageSummary,
|
||||
CustomWorldDraftCardSummary,
|
||||
CustomWorldPendingClarification,
|
||||
CustomWorldSuggestedAction,
|
||||
EightAnchorContent,
|
||||
} from '../../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
|
||||
/**
|
||||
* 当前阶段仍沿用旧 sessionId 前缀,避免影响已落库数据与前端恢复逻辑。
|
||||
*/
|
||||
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
|
||||
'custom-world-agent-session-';
|
||||
|
||||
export type CustomWorldAgentSessionRecord = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
seedText: string;
|
||||
currentTurn: number;
|
||||
anchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
lastAssistantReply: string | null;
|
||||
stage: CustomWorldAgentStage;
|
||||
focusCardId: string | null;
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
creatorIntentReadiness: CreatorIntentReadiness;
|
||||
anchorPack: Record<string, unknown> | null;
|
||||
lockState: Record<string, unknown> | null;
|
||||
draftProfile: Record<string, unknown> | null;
|
||||
messages: CustomWorldAgentMessage[];
|
||||
draftCards: CustomWorldDraftCardSummary[];
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
suggestedActions: CustomWorldSuggestedAction[];
|
||||
recommendedReplies: string[];
|
||||
qualityFindings: Array<{
|
||||
id: string;
|
||||
severity: 'info' | 'warning' | 'blocker';
|
||||
code: string;
|
||||
targetId?: string | null;
|
||||
message: string;
|
||||
}>;
|
||||
assetCoverage: CustomWorldAssetCoverageSummary;
|
||||
operations: CustomWorldAgentOperationRecord[];
|
||||
checkpoints: Array<{
|
||||
checkpointId: string;
|
||||
createdAt: string;
|
||||
label: string;
|
||||
snapshot?: {
|
||||
currentTurn: number;
|
||||
anchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
lastAssistantReply: string | null;
|
||||
stage: CustomWorldAgentStage;
|
||||
focusCardId: string | null;
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
creatorIntentReadiness: CreatorIntentReadiness;
|
||||
anchorPack: Record<string, unknown> | null;
|
||||
lockState: Record<string, unknown> | null;
|
||||
draftProfile: Record<string, unknown> | null;
|
||||
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
|
||||
suggestedActions: CustomWorldAgentSessionRecord['suggestedActions'];
|
||||
recommendedReplies: string[];
|
||||
draftCards: CustomWorldDraftCardSummary[];
|
||||
qualityFindings: CustomWorldAgentSessionRecord['qualityFindings'];
|
||||
assetCoverage: CustomWorldAssetCoverageSummary;
|
||||
} | null;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateCustomWorldAgentSessionInput = {
|
||||
seedText?: string;
|
||||
welcomeMessage: string;
|
||||
currentTurn?: number;
|
||||
anchorContent?: EightAnchorContent;
|
||||
progressPercent?: number;
|
||||
lastAssistantReply?: string | null;
|
||||
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
|
||||
creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent'];
|
||||
creatorIntentReadiness?: CreatorIntentReadiness;
|
||||
anchorPack?: CustomWorldAgentSessionRecord['anchorPack'];
|
||||
draftProfile?: CustomWorldAgentSessionRecord['draftProfile'];
|
||||
stage?: CustomWorldAgentStage;
|
||||
suggestedActions: CustomWorldSuggestedAction[];
|
||||
recommendedReplies?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* session 记录里大量字段都是 JSON 结构,统一走结构化克隆可避免调用方误共享引用。
|
||||
*/
|
||||
export function cloneRpgAgentSessionValue<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CustomWorldSessionRecord } from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RpgAgentSessionRepositoryPort } from '../../repositories/RpgAgentSessionRepository.js';
|
||||
|
||||
export class RpgAgentSessionRepositoryAdapter {
|
||||
constructor(
|
||||
private readonly repository: RpgAgentSessionRepositoryPort,
|
||||
) {}
|
||||
|
||||
async list(userId: string) {
|
||||
return this.repository.listSessions(userId);
|
||||
}
|
||||
|
||||
async get(userId: string, sessionId: string) {
|
||||
return this.repository.getSession(userId, sessionId);
|
||||
}
|
||||
|
||||
async upsert(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
session: CustomWorldSessionRecord,
|
||||
) {
|
||||
return this.repository.upsertSession(userId, sessionId, session);
|
||||
}
|
||||
}
|
||||
348
server-node/src/services/rpgCreationPreviewProfileBuilder.ts
Normal file
348
server-node/src/services/rpgCreationPreviewProfileBuilder.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import { buildRpgWorldPreviewProfile } from './RpgWorldPreviewCompiler.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
|
||||
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(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
function cloneRecord<T extends Record<string, unknown>>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function normalizeMatchText(value: unknown) {
|
||||
return toText(value).toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function findUnusedMatchIndex(
|
||||
records: Record<string, unknown>[],
|
||||
usedIndexes: Set<number>,
|
||||
matcher: (record: Record<string, unknown>) => boolean,
|
||||
) {
|
||||
const matchedIndex = records.findIndex(
|
||||
(record, index) => !usedIndexes.has(index) && matcher(record),
|
||||
);
|
||||
if (matchedIndex >= 0) {
|
||||
usedIndexes.add(matchedIndex);
|
||||
}
|
||||
return matchedIndex;
|
||||
}
|
||||
|
||||
function mergeDraftRolesIntoProfileRecord(params: {
|
||||
baseRoles: unknown;
|
||||
draftRoles: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const baseRoles = toRecordArray(params.baseRoles);
|
||||
if (params.draftRoles.length <= 0) {
|
||||
return baseRoles;
|
||||
}
|
||||
|
||||
const usedIndexes = new Set<number>();
|
||||
return params.draftRoles.map((draftRole) => {
|
||||
let matchedIndex = findUnusedMatchIndex(
|
||||
baseRoles,
|
||||
usedIndexes,
|
||||
(record) => toText(record.id) === toText(draftRole.id),
|
||||
);
|
||||
|
||||
if (matchedIndex < 0) {
|
||||
matchedIndex = findUnusedMatchIndex(
|
||||
baseRoles,
|
||||
usedIndexes,
|
||||
(record) =>
|
||||
normalizeMatchText(record.name) === normalizeMatchText(draftRole.name),
|
||||
);
|
||||
}
|
||||
|
||||
const baseRole = matchedIndex >= 0 ? baseRoles[matchedIndex] : null;
|
||||
return {
|
||||
...(baseRole ?? {}),
|
||||
...draftRole,
|
||||
imageSrc: toText(draftRole.imageSrc) || toText(baseRole?.imageSrc) || undefined,
|
||||
generatedVisualAssetId:
|
||||
toText(draftRole.generatedVisualAssetId) ||
|
||||
toText(baseRole?.generatedVisualAssetId) ||
|
||||
undefined,
|
||||
generatedAnimationSetId:
|
||||
toText(draftRole.generatedAnimationSetId) ||
|
||||
toText(baseRole?.generatedAnimationSetId) ||
|
||||
undefined,
|
||||
animationMap:
|
||||
isRecord(draftRole.animationMap)
|
||||
? draftRole.animationMap
|
||||
: isRecord(baseRole?.animationMap)
|
||||
? baseRole.animationMap
|
||||
: undefined,
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeDraftLandmarksIntoProfileRecord(params: {
|
||||
baseLandmarks: unknown;
|
||||
draftLandmarks: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const baseLandmarks = toRecordArray(params.baseLandmarks);
|
||||
if (params.draftLandmarks.length <= 0) {
|
||||
return baseLandmarks;
|
||||
}
|
||||
|
||||
const usedIndexes = new Set<number>();
|
||||
return params.draftLandmarks.map((draftLandmark) => {
|
||||
let matchedIndex = findUnusedMatchIndex(
|
||||
baseLandmarks,
|
||||
usedIndexes,
|
||||
(record) => toText(record.id) === toText(draftLandmark.id),
|
||||
);
|
||||
|
||||
if (matchedIndex < 0) {
|
||||
matchedIndex = findUnusedMatchIndex(
|
||||
baseLandmarks,
|
||||
usedIndexes,
|
||||
(record) =>
|
||||
normalizeMatchText(record.name) ===
|
||||
normalizeMatchText(draftLandmark.name),
|
||||
);
|
||||
}
|
||||
|
||||
const baseLandmark = matchedIndex >= 0 ? baseLandmarks[matchedIndex] : null;
|
||||
return {
|
||||
...(baseLandmark ?? {}),
|
||||
...draftLandmark,
|
||||
imageSrc:
|
||||
toText(draftLandmark.imageSrc) || toText(baseLandmark?.imageSrc) || undefined,
|
||||
generatedSceneAssetId:
|
||||
toText(draftLandmark.generatedSceneAssetId) ||
|
||||
toText(baseLandmark?.generatedSceneAssetId) ||
|
||||
undefined,
|
||||
generatedScenePrompt:
|
||||
toText(draftLandmark.generatedScenePrompt) ||
|
||||
toText(baseLandmark?.generatedScenePrompt) ||
|
||||
undefined,
|
||||
generatedSceneModel:
|
||||
toText(draftLandmark.generatedSceneModel) ||
|
||||
toText(baseLandmark?.generatedSceneModel) ||
|
||||
undefined,
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeDraftSceneChaptersIntoProfileRecord(params: {
|
||||
baseSceneChapters: unknown;
|
||||
draftSceneChapters: unknown;
|
||||
}) {
|
||||
const baseSceneChapters = toRecordArray(params.baseSceneChapters);
|
||||
const draftSceneChapters = toRecordArray(params.draftSceneChapters);
|
||||
if (draftSceneChapters.length <= 0) {
|
||||
return baseSceneChapters;
|
||||
}
|
||||
|
||||
const usedChapterIndexes = new Set<number>();
|
||||
return draftSceneChapters.map((draftChapter) => {
|
||||
let matchedIndex = findUnusedMatchIndex(
|
||||
baseSceneChapters,
|
||||
usedChapterIndexes,
|
||||
(record) => toText(record.sceneId) === toText(draftChapter.sceneId),
|
||||
);
|
||||
|
||||
if (matchedIndex < 0) {
|
||||
matchedIndex = findUnusedMatchIndex(
|
||||
baseSceneChapters,
|
||||
usedChapterIndexes,
|
||||
(record) =>
|
||||
normalizeMatchText(record.title) === normalizeMatchText(draftChapter.title),
|
||||
);
|
||||
}
|
||||
|
||||
const baseChapter = matchedIndex >= 0 ? baseSceneChapters[matchedIndex] : null;
|
||||
const baseActs = toRecordArray(baseChapter?.acts);
|
||||
const usedActIndexes = new Set<number>();
|
||||
const mergedActs = toRecordArray(draftChapter.acts).map((draftAct) => {
|
||||
let matchedActIndex = findUnusedMatchIndex(
|
||||
baseActs,
|
||||
usedActIndexes,
|
||||
(record) => toText(record.id) === toText(draftAct.id),
|
||||
);
|
||||
|
||||
if (matchedActIndex < 0) {
|
||||
matchedActIndex = findUnusedMatchIndex(
|
||||
baseActs,
|
||||
usedActIndexes,
|
||||
(record) =>
|
||||
normalizeMatchText(record.title) ===
|
||||
normalizeMatchText(draftAct.title),
|
||||
);
|
||||
}
|
||||
|
||||
const baseAct = matchedActIndex >= 0 ? baseActs[matchedActIndex] : null;
|
||||
return {
|
||||
...(baseAct ?? {}),
|
||||
...draftAct,
|
||||
backgroundImageSrc:
|
||||
toText(draftAct.backgroundImageSrc) ||
|
||||
toText(baseAct?.backgroundImageSrc) ||
|
||||
undefined,
|
||||
backgroundAssetId:
|
||||
toText(draftAct.backgroundAssetId) ||
|
||||
toText(baseAct?.backgroundAssetId) ||
|
||||
undefined,
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
|
||||
return {
|
||||
...(baseChapter ?? {}),
|
||||
...draftChapter,
|
||||
acts: mergedActs,
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeDraftCampIntoProfileRecord(params: {
|
||||
baseCamp: unknown;
|
||||
draftCamp: unknown;
|
||||
}) {
|
||||
const draftCamp = isRecord(params.draftCamp) ? params.draftCamp : null;
|
||||
if (!draftCamp) {
|
||||
return isRecord(params.baseCamp) ? params.baseCamp : undefined;
|
||||
}
|
||||
|
||||
const baseCamp = isRecord(params.baseCamp) ? params.baseCamp : null;
|
||||
return {
|
||||
...(baseCamp ?? {}),
|
||||
...draftCamp,
|
||||
imageSrc: toText(draftCamp.imageSrc) || toText(baseCamp?.imageSrc) || undefined,
|
||||
generatedSceneAssetId:
|
||||
toText(draftCamp.generatedSceneAssetId) ||
|
||||
toText(baseCamp?.generatedSceneAssetId) ||
|
||||
undefined,
|
||||
generatedScenePrompt:
|
||||
toText(draftCamp.generatedScenePrompt) ||
|
||||
toText(baseCamp?.generatedScenePrompt) ||
|
||||
undefined,
|
||||
generatedSceneModel:
|
||||
toText(draftCamp.generatedSceneModel) ||
|
||||
toText(baseCamp?.generatedSceneModel) ||
|
||||
undefined,
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildPreviewRawProfileSeed(params: {
|
||||
sessionId: string;
|
||||
profileId: string;
|
||||
draftProfile: Record<string, unknown>;
|
||||
}) {
|
||||
const foundationDraft = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
if (!foundationDraft) {
|
||||
throw new Error('当前世界草稿为空,无法构建结果页预览。');
|
||||
}
|
||||
|
||||
const legacyResultProfile = isRecord(params.draftProfile.legacyResultProfile)
|
||||
? cloneRecord(params.draftProfile.legacyResultProfile)
|
||||
: null;
|
||||
|
||||
const baseProfile = legacyResultProfile ?? {
|
||||
id: params.profileId,
|
||||
settingText: foundationDraft.worldHook,
|
||||
name: foundationDraft.name,
|
||||
subtitle: foundationDraft.subtitle,
|
||||
summary: foundationDraft.summary,
|
||||
tone: foundationDraft.tone,
|
||||
playerGoal: foundationDraft.playerGoal,
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: foundationDraft.majorFactions,
|
||||
coreConflicts: foundationDraft.coreConflicts,
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
camp: null,
|
||||
sceneChapterBlueprints: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
};
|
||||
|
||||
return {
|
||||
...baseProfile,
|
||||
id: params.profileId,
|
||||
settingText:
|
||||
toText(baseProfile.settingText) || foundationDraft.worldHook || foundationDraft.summary,
|
||||
name: foundationDraft.name,
|
||||
subtitle: foundationDraft.subtitle,
|
||||
summary: foundationDraft.summary,
|
||||
tone: foundationDraft.tone,
|
||||
playerGoal: foundationDraft.playerGoal,
|
||||
majorFactions: foundationDraft.majorFactions,
|
||||
coreConflicts: foundationDraft.coreConflicts,
|
||||
playableNpcs: mergeDraftRolesIntoProfileRecord({
|
||||
baseRoles: baseProfile.playableNpcs,
|
||||
draftRoles: toRecordArray(params.draftProfile.playableNpcs),
|
||||
}),
|
||||
storyNpcs: mergeDraftRolesIntoProfileRecord({
|
||||
baseRoles: baseProfile.storyNpcs,
|
||||
draftRoles: toRecordArray(params.draftProfile.storyNpcs),
|
||||
}),
|
||||
landmarks: mergeDraftLandmarksIntoProfileRecord({
|
||||
baseLandmarks: baseProfile.landmarks,
|
||||
draftLandmarks: toRecordArray(params.draftProfile.landmarks),
|
||||
}),
|
||||
camp: mergeDraftCampIntoProfileRecord({
|
||||
baseCamp: baseProfile.camp,
|
||||
draftCamp: params.draftProfile.camp,
|
||||
}),
|
||||
sceneChapterBlueprints: mergeDraftSceneChaptersIntoProfileRecord({
|
||||
baseSceneChapters: baseProfile.sceneChapterBlueprints,
|
||||
draftSceneChapters: params.draftProfile.sceneChapters,
|
||||
}),
|
||||
creatorIntent:
|
||||
(params.draftProfile.creatorIntent as Record<string, unknown> | undefined) ??
|
||||
(baseProfile.creatorIntent as Record<string, unknown> | undefined) ??
|
||||
null,
|
||||
anchorPack:
|
||||
(params.draftProfile.anchorPack as Record<string, unknown> | undefined) ??
|
||||
(baseProfile.anchorPack as Record<string, unknown> | undefined) ??
|
||||
null,
|
||||
lockState:
|
||||
(params.draftProfile.lockState as Record<string, unknown> | undefined) ??
|
||||
(baseProfile.lockState as Record<string, unknown> | undefined) ??
|
||||
null,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果页预览与正式发布统一走同一套“foundation draft + legacy 富字段合并”规则,
|
||||
* 这样 Phase5 才能安全删除前端本地 fallback 编译桥。
|
||||
*/
|
||||
export function buildRpgCreationPreviewProfileFromDraftProfile(params: {
|
||||
sessionId: string;
|
||||
draftProfile: Record<string, unknown>;
|
||||
profileId?: string;
|
||||
}) {
|
||||
const profileId =
|
||||
toText(params.profileId) ||
|
||||
toText(params.draftProfile.legacyResultProfile?.id) ||
|
||||
`agent-draft-${params.sessionId}`;
|
||||
const mergedProfile = buildPreviewRawProfileSeed({
|
||||
sessionId: params.sessionId,
|
||||
profileId,
|
||||
draftProfile: params.draftProfile,
|
||||
});
|
||||
|
||||
return buildRpgWorldPreviewProfile(
|
||||
mergedProfile,
|
||||
toText(mergedProfile.settingText) || '',
|
||||
) as unknown as CustomWorldProfileRecord;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
|
||||
RUNTIME_ITEM_TONE_VALUES,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
|
||||
import type {
|
||||
RuntimeItemIntentRequest,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import {
|
||||
buildRuntimeItemAiIntent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/story.js';
|
||||
import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/rpgRuntimeChat.js';
|
||||
import {
|
||||
generateInitialStoryFromOrchestrator,
|
||||
generateNextStoryFromOrchestrator,
|
||||
|
||||
Reference in New Issue
Block a user