1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 21:06:48 +08:00
parent 1c72066bab
commit 75944b1f1f
102 changed files with 9648 additions and 1540 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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