1
This commit is contained in:
@@ -198,6 +198,116 @@ function buildRoleAssetSyncResultText(params: {
|
||||
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`;
|
||||
}
|
||||
|
||||
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>;
|
||||
});
|
||||
}
|
||||
|
||||
function syncResultProfileIntoDraftProfile(params: {
|
||||
currentDraftProfile: Record<string, unknown> | null | undefined;
|
||||
resultProfile: CustomWorldProfile;
|
||||
@@ -215,6 +325,22 @@ function syncResultProfileIntoDraftProfile(params: {
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -421,6 +421,162 @@ test('phase4 sync_result_profile keeps existing foundation structure while updat
|
||||
);
|
||||
});
|
||||
|
||||
test('phase4 sync_result_profile also writes latest role and scene assets back into draft profile', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
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);
|
||||
|
||||
@@ -368,10 +368,15 @@ function buildCompatibleSuggestedActions(params: {
|
||||
record: CustomWorldAgentSessionRecord;
|
||||
stage: CustomWorldAgentStage;
|
||||
readiness: CreatorIntentReadiness;
|
||||
draftProfile: Record<string, unknown>;
|
||||
}) {
|
||||
if (params.record.suggestedActions.length > 0) {
|
||||
return params.record.suggestedActions;
|
||||
// 旧快照里可能带有“精修对象”的 Agent 建议动作;当前 Agent 对话框只服务八锚点收集。
|
||||
const compatibleActions = params.record.suggestedActions.filter(
|
||||
(action) => action.type !== 'refine_focus_target',
|
||||
);
|
||||
if (compatibleActions.length > 0) {
|
||||
return compatibleActions;
|
||||
}
|
||||
}
|
||||
|
||||
const actions: CustomWorldSuggestedAction[] = [
|
||||
@@ -384,16 +389,6 @@ function buildCompatibleSuggestedActions(params: {
|
||||
: '总结当前设定',
|
||||
},
|
||||
];
|
||||
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',
|
||||
@@ -403,36 +398,6 @@ function buildCompatibleSuggestedActions(params: {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -533,7 +498,6 @@ function applyCompatibility(record: CustomWorldAgentSessionRecord) {
|
||||
record,
|
||||
stage,
|
||||
readiness: creatorIntentReadiness,
|
||||
draftProfile,
|
||||
}),
|
||||
assetCoverage: buildCompatibleAssetCoverage(record, draftProfile),
|
||||
recommendedReplies: normalizeRecommendedReplies(
|
||||
|
||||
@@ -55,7 +55,7 @@ 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 === 'object_refining') return '待完善草稿';
|
||||
if (stage === 'visual_refining') return '视觉工坊';
|
||||
if (stage === 'long_tail_review') return '扩展长尾';
|
||||
if (stage === 'ready_to_publish') return '准备发布';
|
||||
|
||||
Reference in New Issue
Block a user