@@ -978,6 +978,11 @@ export async function streamNpcChatTurn(
|
||||
state: GameState;
|
||||
turnCount: number;
|
||||
} | null;
|
||||
combatContext?: {
|
||||
summary: string;
|
||||
logLines: string[];
|
||||
battleOutcome: 'victory' | 'spar_complete';
|
||||
} | null;
|
||||
chatDirective?: NpcChatTurnDirective | null;
|
||||
npcInitiatesConversation?: boolean;
|
||||
} = {},
|
||||
@@ -1002,6 +1007,7 @@ export async function streamNpcChatTurn(
|
||||
turnCount: options.questOfferContext.turnCount,
|
||||
}
|
||||
: null,
|
||||
combatContext: options.combatContext ?? null,
|
||||
chatDirective: options.chatDirective ?? null,
|
||||
} satisfies NpcChatTurnRequest;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
|
||||
type CustomWorldLandmarkDraft,
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
normalizeCustomWorldSceneRelativePosition,
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
@@ -120,9 +121,15 @@ export interface CustomWorldGenerationLandmarkOutline {
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationCampOutline {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds?: string[];
|
||||
sceneNpcNames?: string[];
|
||||
connections?: CustomWorldGenerationLandmarkConnectionOutline[];
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationFramework {
|
||||
@@ -1061,9 +1068,33 @@ function normalizeCampOutline(
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: toText(item.id) || fallback.id,
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
sceneNpcNames: [
|
||||
...toStringArray(item.sceneNpcNames),
|
||||
...toStringArray(item.npcs, 'name'),
|
||||
...toStringArray(item.sceneNpcs, 'name'),
|
||||
...toStringArray(item.npcNames),
|
||||
],
|
||||
connections: toRecordArray(item.connections)
|
||||
.map((connection) => ({
|
||||
targetLandmarkName:
|
||||
toText(connection.targetLandmarkName) ||
|
||||
toText(connection.target) ||
|
||||
toText(connection.sceneName),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) ||
|
||||
toText(connection.position) ||
|
||||
'forward',
|
||||
summary:
|
||||
toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkName),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1181,10 +1212,23 @@ function normalizeCampScene(
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: toText(item.id) || fallback.id,
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
connections: toRecordArray(item.connections)
|
||||
.map((connection) => ({
|
||||
targetLandmarkId: toText(connection.targetLandmarkId),
|
||||
relativePosition: normalizeCustomWorldSceneRelativePosition(
|
||||
toText(connection.relativePosition) || toText(connection.position) || 'forward',
|
||||
),
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkId),
|
||||
narrativeResidues: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -582,6 +582,72 @@ test('adapts agent draft profile into legacy custom world result profile', () =>
|
||||
expect(profile?.landmarks[0]?.name).toBe('回潮旧灯塔');
|
||||
});
|
||||
|
||||
test('agent draft result keeps generated role portraits and scene act backgrounds', () => {
|
||||
const profile = buildCustomWorldProfileFromAgentDraft({
|
||||
...session,
|
||||
draftProfile: {
|
||||
...session.draftProfile,
|
||||
playableNpcs: [
|
||||
{
|
||||
...session.draftProfile.playableNpcs[0],
|
||||
imageSrc: '/generated-characters/playable-1/visual/asset-1/master.png',
|
||||
generatedVisualAssetId: 'asset-1',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
...session.draftProfile.storyNpcs[0],
|
||||
imageSrc: '/generated-characters/story-1/visual/asset-2/master.png',
|
||||
generatedVisualAssetId: 'asset-2',
|
||||
},
|
||||
],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
sceneName: '回潮旧灯塔',
|
||||
title: '灯塔初章',
|
||||
summary: '围绕灯塔推进的首个场景章节。',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
linkedLandmarkIds: ['landmark-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-act-1',
|
||||
title: '第一幕',
|
||||
summary: '先接住回潮灯塔的入口压力。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc:
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
backgroundAssetId: 'scene-asset-1',
|
||||
encounterNpcIds: ['story-1'],
|
||||
primaryNpcId: 'story-1',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
actGoal: '接住首幕入口',
|
||||
transitionHook: '向第二幕推进。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/playable-1/visual/asset-1/master.png',
|
||||
);
|
||||
expect(profile?.playableNpcs[0]?.generatedVisualAssetId).toBe('asset-1');
|
||||
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/story-1/visual/asset-2/master.png',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe('asset-2');
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
);
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundAssetId).toBe(
|
||||
'scene-asset-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('prefers embedded legacy result profile without dropping compiled runtime fields', () => {
|
||||
const profile = buildProfileFromEmbeddedLegacyResult();
|
||||
|
||||
@@ -604,6 +670,87 @@ test('prefers embedded legacy result profile without dropping compiled runtime f
|
||||
expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.title).toBe('潮痕');
|
||||
});
|
||||
|
||||
test('embedded legacy result profile merges latest draft asset fields for result view', () => {
|
||||
const profile = buildCustomWorldProfileFromAgentDraft({
|
||||
...session,
|
||||
draftProfile: {
|
||||
...session.draftProfile,
|
||||
legacyResultProfile: buildLegacyResultProfile(),
|
||||
playableNpcs: [
|
||||
{
|
||||
...session.draftProfile.playableNpcs[0],
|
||||
imageSrc: '/generated-characters/playable-1/visual/asset-runtime/master.png',
|
||||
generatedVisualAssetId: 'asset-runtime-playable',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
...session.draftProfile.storyNpcs[0],
|
||||
imageSrc: '/generated-characters/story-1/visual/asset-runtime/master.png',
|
||||
generatedVisualAssetId: 'asset-runtime-story',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
...session.draftProfile.landmarks[0],
|
||||
imageSrc: '/generated-custom-world-scenes/landmark-1/scene.png',
|
||||
},
|
||||
],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
sceneName: '回潮旧灯塔',
|
||||
title: '灯塔初章',
|
||||
summary: '围绕灯塔推进的首个场景章节。',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
linkedLandmarkIds: ['landmark-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-act-1',
|
||||
title: '第一幕',
|
||||
summary: '先接住回潮灯塔的入口压力。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc:
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
backgroundAssetId: 'scene-asset-runtime',
|
||||
encounterNpcIds: ['story-1'],
|
||||
primaryNpcId: 'story-1',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
actGoal: '接住首幕入口',
|
||||
transitionHook: '向第二幕推进。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile?.name).toBe('旧版完整结果');
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/playable-1/visual/asset-runtime/master.png',
|
||||
);
|
||||
expect(profile?.playableNpcs[0]?.generatedVisualAssetId).toBe(
|
||||
'asset-runtime-playable',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/story-1/visual/asset-runtime/master.png',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe(
|
||||
'asset-runtime-story',
|
||||
);
|
||||
expect(profile?.landmarks[0]?.imageSrc).toBe(
|
||||
'/generated-custom-world-scenes/landmark-1/scene.png',
|
||||
);
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
);
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundAssetId).toBe(
|
||||
'scene-asset-runtime',
|
||||
);
|
||||
});
|
||||
|
||||
test('embedded legacy result profile keeps result-page settings in runtime characters and scenes', () => {
|
||||
const profile = buildProfileFromEmbeddedLegacyResult();
|
||||
|
||||
|
||||
@@ -178,6 +178,110 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
|
||||
.filter(Boolean) as AdaptedDraftLandmark[];
|
||||
}
|
||||
|
||||
function mergeDraftRoleAssetsIntoProfile(
|
||||
baseProfile: CustomWorldProfile,
|
||||
draftRoles: AdaptedDraftCharacter[],
|
||||
roleKind: 'playable' | 'story',
|
||||
) {
|
||||
const draftRoleById = new Map(draftRoles.map((role) => [role.id, role]));
|
||||
const currentRoles =
|
||||
roleKind === 'playable' ? baseProfile.playableNpcs : baseProfile.storyNpcs;
|
||||
const mergedRoles = currentRoles.map((role) => {
|
||||
const draftRole = draftRoleById.get(role.id);
|
||||
if (!draftRole) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
imageSrc: draftRole.imageSrc ?? role.imageSrc,
|
||||
generatedVisualAssetId:
|
||||
draftRole.generatedVisualAssetId ?? role.generatedVisualAssetId,
|
||||
generatedAnimationSetId:
|
||||
draftRole.generatedAnimationSetId ?? role.generatedAnimationSetId,
|
||||
animationMap: draftRole.animationMap ?? role.animationMap,
|
||||
};
|
||||
});
|
||||
|
||||
if (roleKind === 'playable') {
|
||||
return {
|
||||
...baseProfile,
|
||||
playableNpcs: mergedRoles,
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...baseProfile,
|
||||
storyNpcs: mergedRoles,
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function mergeDraftSceneAssetsIntoProfile(
|
||||
baseProfile: CustomWorldProfile,
|
||||
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'],
|
||||
draftLandmarks: AdaptedDraftLandmark[],
|
||||
) {
|
||||
const normalizedDraftSceneChapters = draftSceneChapters ?? [];
|
||||
const draftSceneChapterBySceneId = new Map(
|
||||
normalizedDraftSceneChapters.map((chapter) => [chapter.sceneId, chapter]),
|
||||
);
|
||||
const draftLandmarkById = new Map(draftLandmarks.map((entry) => [entry.id, entry]));
|
||||
|
||||
const nextCamp = baseProfile.camp
|
||||
? {
|
||||
...baseProfile.camp,
|
||||
imageSrc: baseProfile.camp.imageSrc,
|
||||
}
|
||||
: baseProfile.camp;
|
||||
|
||||
const nextLandmarks = baseProfile.landmarks.map((landmark) => {
|
||||
const draftLandmark = draftLandmarkById.get(landmark.id);
|
||||
return {
|
||||
...landmark,
|
||||
imageSrc: draftLandmark?.imageSrc ?? landmark.imageSrc,
|
||||
};
|
||||
});
|
||||
|
||||
const nextSceneChapterBlueprints =
|
||||
normalizedDraftSceneChapters.length > 0
|
||||
? baseProfile.sceneChapterBlueprints?.map((chapter) => {
|
||||
const draftChapter = draftSceneChapterBySceneId.get(chapter.sceneId);
|
||||
if (!draftChapter) {
|
||||
return chapter;
|
||||
}
|
||||
|
||||
const draftActById = new Map(
|
||||
draftChapter.acts.map((act) => [act.id, act]),
|
||||
);
|
||||
|
||||
return {
|
||||
...chapter,
|
||||
acts: chapter.acts.map((act) => {
|
||||
const draftAct = draftActById.get(act.id);
|
||||
if (!draftAct) {
|
||||
return act;
|
||||
}
|
||||
|
||||
return {
|
||||
...act,
|
||||
backgroundImageSrc:
|
||||
draftAct.backgroundImageSrc ?? act.backgroundImageSrc,
|
||||
backgroundAssetId:
|
||||
draftAct.backgroundAssetId ?? act.backgroundAssetId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}) ?? normalizedDraftSceneChapters
|
||||
: baseProfile.sceneChapterBlueprints;
|
||||
|
||||
return {
|
||||
...baseProfile,
|
||||
camp: nextCamp,
|
||||
landmarks: nextLandmarks,
|
||||
sceneChapterBlueprints: nextSceneChapterBlueprints,
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function toStageCoverage(value: unknown) {
|
||||
const stageCoverage = Array.isArray(value)
|
||||
? value
|
||||
@@ -227,6 +331,8 @@ function adaptDraftSceneChapters(
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc:
|
||||
toText(actRecord.backgroundImageSrc) || undefined,
|
||||
backgroundAssetId:
|
||||
toText(actRecord.backgroundAssetId) || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId,
|
||||
linkedThreadIds: toStringArray(actRecord.linkedThreadIds),
|
||||
@@ -268,13 +374,6 @@ export function buildCustomWorldProfileFromAgentDraft(
|
||||
}
|
||||
|
||||
const draftProfile = session.draftProfile;
|
||||
const legacyResultProfile = normalizeCustomWorldProfileRecord(
|
||||
draftProfile.legacyResultProfile,
|
||||
);
|
||||
if (legacyResultProfile) {
|
||||
return legacyResultProfile;
|
||||
}
|
||||
|
||||
const settingText = buildAgentDraftFoundationSettingText(session);
|
||||
const templateWorldType = inferTemplateWorldType(settingText);
|
||||
const playableNpcs = adaptDraftCharacters(
|
||||
@@ -292,6 +391,32 @@ export function buildCustomWorldProfileFromAgentDraft(
|
||||
const landmarkIdSet = new Set(
|
||||
adaptedLandmarks.map((entry) => toText(entry.id)).filter(Boolean),
|
||||
);
|
||||
const draftSceneChapterBlueprints = adaptDraftSceneChapters(
|
||||
draftProfile.sceneChapters,
|
||||
storyNpcIdSet,
|
||||
landmarkIdSet,
|
||||
);
|
||||
const legacyResultProfile = normalizeCustomWorldProfileRecord(
|
||||
draftProfile.legacyResultProfile,
|
||||
);
|
||||
if (legacyResultProfile) {
|
||||
const mergedPlayableProfile = mergeDraftRoleAssetsIntoProfile(
|
||||
legacyResultProfile,
|
||||
playableNpcs,
|
||||
'playable',
|
||||
);
|
||||
const mergedStoryProfile = mergeDraftRoleAssetsIntoProfile(
|
||||
mergedPlayableProfile,
|
||||
storyNpcs,
|
||||
'story',
|
||||
);
|
||||
return mergeDraftSceneAssetsIntoProfile(
|
||||
mergedStoryProfile,
|
||||
draftSceneChapterBlueprints,
|
||||
adaptedLandmarks,
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = normalizeCustomWorldProfileRecord({
|
||||
id: `agent-draft-${session.sessionId}`,
|
||||
settingText,
|
||||
@@ -320,11 +445,7 @@ export function buildCustomWorldProfileFromAgentDraft(
|
||||
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
|
||||
}
|
||||
: undefined,
|
||||
sceneChapterBlueprints: adaptDraftSceneChapters(
|
||||
draftProfile.sceneChapters,
|
||||
storyNpcIdSet,
|
||||
landmarkIdSet,
|
||||
),
|
||||
sceneChapterBlueprints: draftSceneChapterBlueprints,
|
||||
anchorContent: session.anchorContent,
|
||||
creatorIntent: session.creatorIntent,
|
||||
anchorPack: session.anchorPack,
|
||||
|
||||
@@ -14,8 +14,8 @@ const baseOperation: CustomWorldAgentOperationRecord = {
|
||||
operationId: 'operation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'running',
|
||||
phaseLabel: '生成世界底稿',
|
||||
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
|
||||
phaseLabel: '生成场景角色',
|
||||
phaseDetail: '正在生成场景角色第 1 / 1 批,当前已完成 0/4。',
|
||||
progress: 38,
|
||||
error: null,
|
||||
};
|
||||
@@ -96,7 +96,7 @@ const baseSession: CustomWorldAgentSessionSnapshot = {
|
||||
updatedAt: '2026-04-14T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('maps running draft_foundation operation to legacy generation progress', () => {
|
||||
test('maps running draft_foundation operation to refined generation progress steps', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
baseOperation,
|
||||
1_000,
|
||||
@@ -104,21 +104,51 @@ test('maps running draft_foundation operation to legacy generation progress', ()
|
||||
);
|
||||
|
||||
expect(progress).not.toBeNull();
|
||||
expect(progress?.phaseId).toBe('foundation');
|
||||
expect(progress?.batchLabel).toBe('生成世界底稿');
|
||||
expect(progress?.phaseId).toBe('story-outline');
|
||||
expect(progress?.batchLabel).toBe('生成场景角色');
|
||||
expect(progress?.overallProgress).toBe(38);
|
||||
expect(progress?.elapsedMs).toBe(4_000);
|
||||
expect(progress?.estimatedRemainingMs).toBeGreaterThan(0);
|
||||
expect(progress?.steps).toHaveLength(13);
|
||||
expect(progress?.steps.map((step) => step.status)).toEqual([
|
||||
'completed',
|
||||
'completed',
|
||||
'completed',
|
||||
'active',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
]);
|
||||
expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true);
|
||||
});
|
||||
|
||||
test('marks all legacy progress steps complete when draft foundation finishes', () => {
|
||||
test('maps auto asset phases to refined generation progress steps', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
phaseLabel: '生成幕背景图',
|
||||
phaseDetail: '正在生成幕背景图 3/6:潮汐码头 · 封锁加压。',
|
||||
progress: 99,
|
||||
},
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('act-backgrounds');
|
||||
expect(progress?.batchLabel).toBe('生成幕背景图');
|
||||
expect(progress?.steps.filter((step) => step.status === 'completed')).toHaveLength(
|
||||
10,
|
||||
);
|
||||
expect(progress?.steps[10]?.status).toBe('active');
|
||||
});
|
||||
|
||||
test('marks all refined progress steps complete when draft foundation finishes', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
@@ -138,6 +168,28 @@ test('marks all legacy progress steps complete when draft foundation finishes',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps failed draft foundation progress on explicit failure state instead of pretending it is still compiling cards', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
status: 'failed',
|
||||
phaseLabel: '底稿生成失败',
|
||||
phaseDetail: '角色主形象补齐失败,但世界底稿尚未完成写回。',
|
||||
progress: 100,
|
||||
error: 'dashscope timeout',
|
||||
},
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('failed');
|
||||
expect(progress?.phaseLabel).toBe('底稿生成失败');
|
||||
expect(progress?.phaseDetail).toContain('角色主形象补齐失败');
|
||||
expect(progress?.steps.some((step) => step.label === '编译草稿卡')).toBe(true);
|
||||
expect(progress?.steps.some((step) => step.status === 'active')).toBe(false);
|
||||
expect(progress?.steps.filter((step) => step.status === 'completed').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('builds readable draft setting text from creator intent first', () => {
|
||||
const settingText = buildAgentDraftFoundationSettingText(baseSession);
|
||||
|
||||
|
||||
@@ -193,32 +193,120 @@ export function buildAgentDraftFoundationAnchorEntries(
|
||||
].filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
type AgentDraftFoundationStepDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
matchers: string[];
|
||||
minProgress: number;
|
||||
};
|
||||
|
||||
type AgentDraftFoundationFailedStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
// 这里按真实服务端 phaseLabel 归并步骤,避免把草稿生成硬折成 4 个失真的阶段。
|
||||
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
|
||||
{
|
||||
id: 'queue',
|
||||
label: '接收生成请求',
|
||||
detail: '正在锁定当前已确认的世界锚点与草稿范围。',
|
||||
detail: '正在校验当前锚点并准备底稿编译链路。',
|
||||
matchers: ['已接收请求'],
|
||||
minProgress: 0,
|
||||
},
|
||||
{
|
||||
id: 'foundation',
|
||||
label: '生成世界底稿',
|
||||
detail: '正在根据世界核心、关系种子与冲突线编排第一版世界结构。',
|
||||
id: 'framework',
|
||||
label: '整理世界骨架',
|
||||
detail: '正在生成第一版世界框架、主题与核心冲突。',
|
||||
matchers: ['整理世界骨架', '生成世界底稿'],
|
||||
minProgress: 12,
|
||||
},
|
||||
{
|
||||
id: 'playable-outline',
|
||||
label: '生成可扮演角色',
|
||||
detail: '正在补出玩家视角角色的首轮名单与定位。',
|
||||
matchers: ['生成可扮演角色'],
|
||||
minProgress: 16,
|
||||
},
|
||||
{
|
||||
id: 'story-outline',
|
||||
label: '生成场景角色',
|
||||
detail: '正在整理关键 NPC、势力接口人与关系入口。',
|
||||
matchers: ['生成场景角色'],
|
||||
minProgress: 30,
|
||||
},
|
||||
{
|
||||
id: 'landmark-seed',
|
||||
label: '生成关键场景',
|
||||
detail: '正在补出第一批关键场景与地点骨架。',
|
||||
matchers: ['生成关键场景'],
|
||||
minProgress: 44,
|
||||
},
|
||||
{
|
||||
id: 'landmark-network',
|
||||
label: '建立场景连接',
|
||||
detail: '正在串联地点关系、线程挂钩与角色分布。',
|
||||
matchers: ['建立场景连接'],
|
||||
minProgress: 56,
|
||||
},
|
||||
{
|
||||
id: 'playable-detail',
|
||||
label: '补全可扮演角色细节',
|
||||
detail: '正在补全可扮演角色的叙事基础与档案细节。',
|
||||
matchers: ['补全可扮演角色'],
|
||||
minProgress: 66,
|
||||
},
|
||||
{
|
||||
id: 'story-detail',
|
||||
label: '补全场景角色细节',
|
||||
detail: '正在补全场景角色的叙事基础与档案细节。',
|
||||
matchers: ['补全场景角色'],
|
||||
minProgress: 84,
|
||||
},
|
||||
{
|
||||
id: 'finalize',
|
||||
label: '编译世界底稿',
|
||||
detail: '正在把分批生成结果汇总成第一版可浏览的世界底稿。',
|
||||
matchers: ['编译世界底稿'],
|
||||
minProgress: 97,
|
||||
},
|
||||
{
|
||||
id: 'role-visuals',
|
||||
label: '生成角色主形象',
|
||||
detail: '正在为关键角色补主形象预览资源。',
|
||||
matchers: ['生成角色主形象'],
|
||||
minProgress: 97,
|
||||
},
|
||||
{
|
||||
id: 'act-backgrounds',
|
||||
label: '生成幕背景图',
|
||||
detail: '正在为场景章节的每一幕补背景图预览资源。',
|
||||
matchers: ['生成幕背景图'],
|
||||
minProgress: 98,
|
||||
},
|
||||
{
|
||||
id: 'cards',
|
||||
label: '编译草稿卡',
|
||||
detail: '正在整理世界卡、角色卡与地点卡的摘要和详情。',
|
||||
detail: '正在整理世界卡、角色卡、地点卡与详情结构。',
|
||||
matchers: ['编译草稿卡'],
|
||||
minProgress: 99,
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: '准备精修工作区',
|
||||
detail: '正在写回草稿数据,并切回可继续精修的工作区。',
|
||||
matchers: ['世界底稿已生成'],
|
||||
minProgress: 100,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
}>;
|
||||
] as const satisfies ReadonlyArray<AgentDraftFoundationStepDefinition>;
|
||||
|
||||
const AGENT_DRAFT_FOUNDATION_FAILED_STEP = {
|
||||
id: 'failed',
|
||||
label: '生成失败',
|
||||
detail: '这一轮世界草稿没有编译完成,可以返回工作区补充设定后重试。',
|
||||
} as const satisfies AgentDraftFoundationFailedStep;
|
||||
|
||||
function clampProgress(progress: number | null | undefined) {
|
||||
if (typeof progress !== 'number' || Number.isNaN(progress)) {
|
||||
@@ -228,29 +316,68 @@ function clampProgress(progress: number | null | undefined) {
|
||||
return Math.max(0, Math.min(100, Math.round(progress)));
|
||||
}
|
||||
|
||||
function resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
|
||||
let matchedIndex = 0;
|
||||
|
||||
for (
|
||||
let index = 0;
|
||||
index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
|
||||
index += 1
|
||||
) {
|
||||
if (progress >= AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index].minProgress) {
|
||||
matchedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
return matchedIndex;
|
||||
}
|
||||
|
||||
function resolveAgentDraftFoundationStepIndex(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
const progress = clampProgress(operation.progress);
|
||||
const phaseLabel = operation.phaseLabel.trim();
|
||||
|
||||
if (
|
||||
operation.status === 'completed' ||
|
||||
phaseLabel.includes('世界底稿已生成') ||
|
||||
progress >= 90
|
||||
if (operation.status === 'completed' || phaseLabel.includes('世界底稿已生成')) {
|
||||
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
|
||||
}
|
||||
|
||||
for (
|
||||
let index = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 2;
|
||||
index >= 0;
|
||||
index -= 1
|
||||
) {
|
||||
return 3;
|
||||
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
|
||||
if (step.matchers.some((matcher) => phaseLabel.includes(matcher))) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
if (phaseLabel.includes('编译草稿卡') || progress >= 60) {
|
||||
return 2;
|
||||
return resolveAgentDraftFoundationStepIndexByProgress(progress);
|
||||
}
|
||||
|
||||
function resolveAgentDraftFoundationFailedStep(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
if (operation.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (phaseLabel.includes('生成世界底稿') || progress >= 25) {
|
||||
return 1;
|
||||
}
|
||||
const phaseLabel = operation.phaseLabel.trim();
|
||||
const phaseDetail = operation.phaseDetail.trim();
|
||||
const error = operation.error?.trim() ?? '';
|
||||
|
||||
return 0;
|
||||
return {
|
||||
id: AGENT_DRAFT_FOUNDATION_FAILED_STEP.id,
|
||||
label:
|
||||
phaseLabel ||
|
||||
error ||
|
||||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.label,
|
||||
detail:
|
||||
phaseDetail ||
|
||||
error ||
|
||||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.detail,
|
||||
} satisfies AgentDraftFoundationFailedStep;
|
||||
}
|
||||
|
||||
function buildAgentDraftFoundationSteps(
|
||||
@@ -259,8 +386,12 @@ function buildAgentDraftFoundationSteps(
|
||||
) {
|
||||
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => {
|
||||
const isCompleted =
|
||||
operation.status === 'completed' || index < activeStepIndex;
|
||||
const isActive = !isCompleted && index === activeStepIndex;
|
||||
operation.status === 'completed' ||
|
||||
(operation.status === 'failed'
|
||||
? index < activeStepIndex
|
||||
: index < activeStepIndex);
|
||||
const isActive =
|
||||
operation.status !== 'failed' && !isCompleted && index === activeStepIndex;
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
@@ -326,7 +457,9 @@ export function buildAgentDraftFoundationGenerationProgress(
|
||||
nowMs,
|
||||
operation.status,
|
||||
);
|
||||
const failedStep = resolveAgentDraftFoundationFailedStep(operation);
|
||||
const activeStep =
|
||||
failedStep ??
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
|
||||
|
||||
|
||||
@@ -10,7 +10,15 @@ type CampProfileSeed = Pick<
|
||||
> & {
|
||||
camp?: Pick<
|
||||
CustomWorldCampScene,
|
||||
'name' | 'description' | 'dangerLevel' | 'imageSrc'
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'visualDescription'
|
||||
| 'dangerLevel'
|
||||
| 'imageSrc'
|
||||
| 'sceneNpcIds'
|
||||
| 'connections'
|
||||
| 'narrativeResidues'
|
||||
> | null;
|
||||
};
|
||||
|
||||
@@ -81,9 +89,13 @@ export function buildFallbackCustomWorldCampScene(
|
||||
const fallbackName = buildFallbackCampName(profile);
|
||||
|
||||
return {
|
||||
id: 'custom-scene-camp',
|
||||
name: fallbackName,
|
||||
description: buildFallbackCampDescription(profile, fallbackName),
|
||||
dangerLevel: 'low',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
narrativeResidues: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,9 +106,18 @@ export function resolveCustomWorldCampScene(
|
||||
const camp = profile.camp;
|
||||
|
||||
return {
|
||||
id: camp?.id?.trim() || fallback.id,
|
||||
name: camp?.name?.trim() || fallback.name,
|
||||
description: camp?.description?.trim() || fallback.description,
|
||||
visualDescription: camp?.visualDescription?.trim() || undefined,
|
||||
dangerLevel: camp?.dangerLevel?.trim() || fallback.dangerLevel,
|
||||
imageSrc: camp?.imageSrc?.trim() || undefined,
|
||||
sceneNpcIds: Array.isArray(camp?.sceneNpcIds)
|
||||
? [...new Set(camp.sceneNpcIds.map((entry) => entry.trim()).filter(Boolean))]
|
||||
: fallback.sceneNpcIds,
|
||||
connections: Array.isArray(camp?.connections)
|
||||
? camp.connections
|
||||
: fallback.connections,
|
||||
narrativeResidues: camp?.narrativeResidues ?? fallback.narrativeResidues,
|
||||
};
|
||||
}
|
||||
|
||||
144
src/services/customWorldCover.test.ts
Normal file
144
src/services/customWorldCover.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { WorldType, type CustomWorldProfile } from '../types';
|
||||
import { resolveCustomWorldCoverPresentation } from './customWorldCover';
|
||||
|
||||
function createBaseProfile(): CustomWorldProfile {
|
||||
return {
|
||||
id: 'custom-world-cover-test',
|
||||
settingText: '潮雾群岛',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '封面规则测试',
|
||||
summary: '用于验证默认封面优先级。',
|
||||
tone: '潮湿、压抑',
|
||||
playerGoal: '查明旧航道真相。',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
compatibilityTemplateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试属性',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '潮雾群岛',
|
||||
settingSummary: '封面规则测试',
|
||||
tone: '潮湿、压抑',
|
||||
conflictCore: '旧航道真相',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '林潮',
|
||||
title: '守潮人',
|
||||
role: '可扮演角色',
|
||||
description: '负责守住第一道进港口。',
|
||||
backstory: '他在港口旧案里失去过同伴。',
|
||||
personality: '谨慎克制。',
|
||||
motivation: '想查清货船去向。',
|
||||
combatStyle: '借地形换位。',
|
||||
initialAffinity: 20,
|
||||
relationshipHooks: ['旧案'],
|
||||
tags: ['港口'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他对港口格外熟悉。',
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
imageSrc: '/images/roles/linchao.webp',
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
camp: {
|
||||
id: 'camp-1',
|
||||
name: '守夜营地',
|
||||
description: '潮线后的临时据点。',
|
||||
dangerLevel: 'medium',
|
||||
imageSrc: '/images/camp/camp.webp',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '潮汐码头',
|
||||
description: '涨潮时会吞掉半截栈桥。',
|
||||
dangerLevel: 'high',
|
||||
imageSrc: '/images/landmark/docks.webp',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '潮汐码头',
|
||||
summary: '第一章开局场景。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '雾里靠岸',
|
||||
summary: '玩家第一次进入港口。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/images/scene/act-1.webp',
|
||||
backgroundAssetId: 'asset-scene-act-1',
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: 'playable-1',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '拿到第一句真话。',
|
||||
transitionHook: '下一幕将进入封锁区。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveCustomWorldCoverPresentation', () => {
|
||||
it('优先使用开局场景第一幕图片作为默认封面底图', () => {
|
||||
const profile = createBaseProfile();
|
||||
|
||||
const result = resolveCustomWorldCoverPresentation(profile);
|
||||
|
||||
expect(result.imageSrc).toBe('/images/scene/act-1.webp');
|
||||
expect(result.renderMode).toBe('scene_with_roles');
|
||||
expect(result.characterImageSrcs).toEqual(['/images/roles/linchao.webp']);
|
||||
});
|
||||
|
||||
it('当第一幕图片缺失时按营地图与地标图顺序回退', () => {
|
||||
const profile = createBaseProfile();
|
||||
profile.sceneChapterBlueprints = [
|
||||
{
|
||||
...profile.sceneChapterBlueprints![0],
|
||||
acts: [
|
||||
{
|
||||
...profile.sceneChapterBlueprints![0]!.acts[0]!,
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackToCamp = resolveCustomWorldCoverPresentation(profile);
|
||||
expect(fallbackToCamp.imageSrc).toBe('/images/camp/camp.webp');
|
||||
|
||||
profile.camp = {
|
||||
...profile.camp!,
|
||||
imageSrc: '',
|
||||
};
|
||||
const fallbackToLandmark = resolveCustomWorldCoverPresentation(profile);
|
||||
expect(fallbackToLandmark.imageSrc).toBe('/images/landmark/docks.webp');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,17 @@ export type CustomWorldCoverPresentation = {
|
||||
sourceType: CustomWorldCoverProfile['sourceType'];
|
||||
};
|
||||
|
||||
function resolveOpeningSceneFirstActImageSrc(profile: CustomWorldProfile) {
|
||||
return profile.sceneChapterBlueprints?.[0]?.acts?.[0]?.backgroundImageSrc?.trim() || null;
|
||||
}
|
||||
|
||||
function resolveOpeningSceneImageSrc(profile: CustomWorldProfile) {
|
||||
// 默认封面优先取开局场景第一幕图,避免草稿页与作品库继续沿用旧的营地兜底策略。
|
||||
const firstActImageSrc = resolveOpeningSceneFirstActImageSrc(profile);
|
||||
if (firstActImageSrc) {
|
||||
return firstActImageSrc;
|
||||
}
|
||||
|
||||
const campImageSrc = profile.camp?.imageSrc?.trim() || '';
|
||||
if (campImageSrc) {
|
||||
return campImageSrc;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requestJson } from './apiClient';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import type { CustomWorldCoverCropRect, CustomWorldProfile } from '../types';
|
||||
|
||||
const CUSTOM_WORLD_COVER_API_BASE = '/api/runtime/custom-world';
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface UploadCustomWorldCoverImageRequest {
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
imageDataUrl: string;
|
||||
cropRect: CustomWorldCoverCropRect;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldCoverImage(
|
||||
|
||||
Reference in New Issue
Block a user