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:
2026-04-21 20:16:01 +08:00
477 changed files with 38047 additions and 26570 deletions

View File

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -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('已恢复到检查点'),
),
);
});

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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: [],
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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