1
This commit is contained in:
@@ -11,7 +11,6 @@ import type {
|
||||
EightAnchorContent,
|
||||
KeyRelationshipValue,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
resolveCustomWorldCampSceneImage,
|
||||
@@ -353,6 +352,25 @@ function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildFallbackSceneActImagePreviews(params: {
|
||||
sceneChapters: SceneChapterBlueprint[];
|
||||
sceneImageSrc?: string | null;
|
||||
}) {
|
||||
const actPreviews = collectSceneActImagePreviews(params.sceneChapters);
|
||||
const sceneImageSrc = params.sceneImageSrc?.trim() || '';
|
||||
|
||||
if (actPreviews.length > 0 || !sceneImageSrc) {
|
||||
return actPreviews;
|
||||
}
|
||||
|
||||
// 中文注释:旧草稿可能只把开局场景图写在 camp.imageSrc,尚未回填到每一幕;目录侧先用场景图兜底,避免开局场景看起来没有幕图片。
|
||||
return [1, 2, 3].map((actNumber) => ({
|
||||
id: `fallback-scene-act-${actNumber}`,
|
||||
title: `第${actNumber}幕`,
|
||||
imageSrc: sceneImageSrc,
|
||||
}));
|
||||
}
|
||||
|
||||
function SceneActPreviewStrip({
|
||||
acts,
|
||||
sceneName,
|
||||
@@ -536,13 +554,7 @@ function resolvePlayableRolePreviewImage(
|
||||
return previewCharacter.avatar;
|
||||
}
|
||||
|
||||
const template = role.templateCharacterId
|
||||
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === role.templateCharacterId,
|
||||
) ?? null
|
||||
: null;
|
||||
|
||||
return template?.portrait ?? '';
|
||||
return '';
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
@@ -1045,17 +1057,21 @@ export function CustomWorldEntityCatalog({
|
||||
sceneId: resolvedCampScene.id,
|
||||
sceneName: resolvedCampScene.name,
|
||||
});
|
||||
const openingSceneImageSrc = resolveSceneCardImage({
|
||||
sceneImageSrc: resolvedCampImageSrc,
|
||||
sceneChapters: openingSceneChapters,
|
||||
});
|
||||
const openingSceneEntry = {
|
||||
id: resolvedCampScene.id,
|
||||
kind: 'camp' as const,
|
||||
name: resolvedCampScene.name,
|
||||
description: resolvedCampScene.description,
|
||||
imageSrc: resolveSceneCardImage({
|
||||
sceneImageSrc: resolvedCampImageSrc,
|
||||
sceneChapters: openingSceneChapters,
|
||||
}),
|
||||
imageSrc: openingSceneImageSrc,
|
||||
sceneChapters: openingSceneChapters,
|
||||
actPreviews: collectSceneActImagePreviews(openingSceneChapters),
|
||||
actPreviews: buildFallbackSceneActImagePreviews({
|
||||
sceneChapters: openingSceneChapters,
|
||||
sceneImageSrc: openingSceneImageSrc,
|
||||
}),
|
||||
searchText: [
|
||||
buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||||
buildSceneChapterSearchText(openingSceneChapters, roleById),
|
||||
@@ -1069,18 +1085,22 @@ export function CustomWorldEntityCatalog({
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
});
|
||||
const sceneImageSrc = resolveSceneCardImage({
|
||||
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
sceneChapters,
|
||||
});
|
||||
|
||||
return {
|
||||
id: landmark.id,
|
||||
kind: 'landmark' as const,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
imageSrc: resolveSceneCardImage({
|
||||
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
sceneChapters,
|
||||
}),
|
||||
imageSrc: sceneImageSrc,
|
||||
sceneChapters,
|
||||
actPreviews: collectSceneActImagePreviews(sceneChapters),
|
||||
actPreviews: buildFallbackSceneActImagePreviews({
|
||||
sceneChapters,
|
||||
sceneImageSrc,
|
||||
}),
|
||||
searchText: [
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
buildSceneChapterSearchText(sceneChapters, roleById),
|
||||
|
||||
@@ -148,7 +148,6 @@ function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
|
||||
backstoryReveal: createBackstoryReveal(),
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
templateCharacterId: 'knight-female-1',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -445,8 +445,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'publish_world',
|
||||
}),
|
||||
syncAgentDraftResultProfile:
|
||||
autosaveCoordinator.syncAgentDraftResultProfile,
|
||||
setGeneratedCustomWorldProfile:
|
||||
sessionController.setGeneratedCustomWorldProfile,
|
||||
});
|
||||
|
||||
@@ -536,7 +536,6 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
motivation: role.motivation,
|
||||
combatStyle: role.combatStyle,
|
||||
tags: role.tags,
|
||||
templateCharacterId: role.templateCharacterId,
|
||||
imageSrc: role.imageSrc,
|
||||
generatedVisualAssetId: role.generatedVisualAssetId,
|
||||
generatedAnimationSetId: role.generatedAnimationSetId,
|
||||
@@ -558,7 +557,6 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
role.role,
|
||||
role.sceneVisualDescription,
|
||||
role.tags,
|
||||
role.templateCharacterId,
|
||||
role.title,
|
||||
role.visualDescription,
|
||||
],
|
||||
@@ -610,11 +608,7 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
useRoleAnimationWorkflow();
|
||||
|
||||
const selectedTemplate =
|
||||
roleKind === 'playable' && workingRole.templateCharacterId
|
||||
? (ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === workingRole.templateCharacterId,
|
||||
) ?? null)
|
||||
: null;
|
||||
roleKind === 'playable' ? (ROLE_TEMPLATE_CHARACTERS[0] ?? null) : null;
|
||||
const characterBriefText = useMemo(
|
||||
() =>
|
||||
buildRoleCharacterBrief(
|
||||
|
||||
@@ -14,7 +14,6 @@ export type EditableCustomWorldRole = {
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
templateCharacterId?: string;
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
|
||||
@@ -3473,13 +3473,7 @@ export function SectionPanel({
|
||||
function buildRolePreviewCharacter(
|
||||
role: CustomWorldPlayableNpc | CustomWorldNpc,
|
||||
): Character | null {
|
||||
const template =
|
||||
'templateCharacterId' in role && role.templateCharacterId
|
||||
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(entry) => entry.id === role.templateCharacterId,
|
||||
) ?? null
|
||||
: null;
|
||||
const portrait = role.imageSrc || template?.portrait;
|
||||
const portrait = role.imageSrc;
|
||||
|
||||
if (!portrait) {
|
||||
return null;
|
||||
@@ -3493,15 +3487,15 @@ function buildRolePreviewCharacter(
|
||||
backstory: role.backstory,
|
||||
avatar: portrait,
|
||||
portrait,
|
||||
assetFolder: template?.assetFolder ?? 'custom-world',
|
||||
assetVariant: template?.assetVariant ?? 'generated',
|
||||
assetFolder: 'custom-world',
|
||||
assetVariant: 'generated',
|
||||
generatedVisualAssetId: role.generatedVisualAssetId,
|
||||
generatedAnimationSetId: role.generatedAnimationSetId,
|
||||
animationMap: template?.animationMap ?? role.animationMap,
|
||||
attributes: template?.attributes ?? {},
|
||||
animationMap: role.animationMap,
|
||||
attributes: { strength: 0, agility: 0, intelligence: 0, spirit: 0 },
|
||||
personality: role.personality,
|
||||
skills: template?.skills ?? [],
|
||||
adventureOpenings: template?.adventureOpenings ?? {},
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
@@ -4568,12 +4562,7 @@ export function PlayableNpcEditor({
|
||||
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
|
||||
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
|
||||
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
||||
const selectedTemplate =
|
||||
ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === draft.templateCharacterId,
|
||||
) ??
|
||||
ROLE_TEMPLATE_CHARACTERS[0] ??
|
||||
null;
|
||||
const previewImageSrc = draft.imageSrc?.trim() ?? '';
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
[...profile.playableNpcs, ...profile.storyNpcs]
|
||||
@@ -4608,7 +4597,7 @@ export function PlayableNpcEditor({
|
||||
disableClose={isAiAssetStudioOpen || isCloseConfirmOpen}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{selectedTemplate ? (
|
||||
{previewImageSrc ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
|
||||
形象预览
|
||||
@@ -4616,20 +4605,17 @@ export function PlayableNpcEditor({
|
||||
<div className="mt-3 grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
|
||||
<img
|
||||
src={draft.imageSrc || selectedTemplate.portrait}
|
||||
alt={selectedTemplate.name}
|
||||
src={previewImageSrc}
|
||||
alt={draft.name || '角色形象'}
|
||||
className="h-28 w-full object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold text-white">
|
||||
{selectedTemplate.name}
|
||||
{draft.name || '未命名角色'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
{selectedTemplate.title}
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-6 text-zinc-300">
|
||||
{selectedTemplate.description}
|
||||
{draft.title || draft.role}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{draft.generatedVisualAssetId ? (
|
||||
@@ -4654,22 +4640,6 @@ export function PlayableNpcEditor({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field label="外观模板">
|
||||
<SelectField
|
||||
value={draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id ?? ''}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
templateCharacterId: value,
|
||||
}))
|
||||
}
|
||||
options={ROLE_TEMPLATE_CHARACTERS.map((character) => ({
|
||||
value: character.id,
|
||||
label: `${character.name} / ${character.title}`,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="名称">
|
||||
<TextInput
|
||||
value={draft.name}
|
||||
@@ -4798,11 +4768,7 @@ export function PlayableNpcEditor({
|
||||
<SaveBar
|
||||
onClose={handleRequestClose}
|
||||
onSave={() => {
|
||||
onSave({
|
||||
...draft,
|
||||
templateCharacterId:
|
||||
draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id,
|
||||
});
|
||||
onSave(draft);
|
||||
onClose();
|
||||
}}
|
||||
showClose={false}
|
||||
|
||||
@@ -127,7 +127,6 @@ export function createPlayableNpcDraft(
|
||||
tags: ['自定义'],
|
||||
},
|
||||
],
|
||||
templateCharacterId: profile.playableNpcs[0]?.templateCharacterId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
158
src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx
Normal file
158
src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { WorldType, type CustomWorldProfile } from '../../types';
|
||||
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||||
|
||||
function buildProfile(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
imageSrc: string;
|
||||
}): CustomWorldProfile {
|
||||
return {
|
||||
id: params.id,
|
||||
settingText: params.name,
|
||||
name: params.name,
|
||||
subtitle: params.name,
|
||||
summary: params.name,
|
||||
tone: '测试',
|
||||
playerGoal: '测试',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
compatibilityTemplateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: `${params.id}-attribute-schema`,
|
||||
worldId: params.id,
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: params.name,
|
||||
settingSummary: params.name,
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: `${params.id}-role`,
|
||||
name: '可扮演角色',
|
||||
title: '测试角色',
|
||||
role: '主角',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
personality: '测试性格',
|
||||
motivation: '测试动机',
|
||||
combatStyle: '测试战斗风格',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
backstoryReveal: {
|
||||
publicSummary: '测试角色',
|
||||
privateChatUnlockAffinity: 60,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
imageSrc: params.imageSrc,
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
currentTurn: 1,
|
||||
anchorContent: {
|
||||
worldPromise: null,
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '',
|
||||
stage: 'ready_to_publish',
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: { isReady: true, completedKeys: [], missingKeys: [] },
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: null,
|
||||
messages: [],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: true,
|
||||
allSceneAssetsReady: true,
|
||||
},
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRpgCreationEnterWorld', () => {
|
||||
it('Agent 草稿进入游戏时使用 session draft profile 的角色形象', async () => {
|
||||
const staleResultProfile = buildProfile({
|
||||
id: 'stale-result',
|
||||
name: '旧结果页快照',
|
||||
imageSrc: '/template/old-role.png',
|
||||
});
|
||||
const draftProfile = buildProfile({
|
||||
id: 'draft-profile',
|
||||
name: '草稿真相源',
|
||||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||||
});
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
const setGeneratedCustomWorldProfile = vi.fn();
|
||||
const executePublishWorld = vi.fn(async () => buildSession());
|
||||
|
||||
function Harness() {
|
||||
const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({
|
||||
isAgentDraftResultView: true,
|
||||
activeAgentSessionId: 'session-1',
|
||||
generatedCustomWorldProfile: staleResultProfile,
|
||||
agentSessionProfile: draftProfile,
|
||||
agentSession: buildSession(),
|
||||
handleCustomWorldSelect,
|
||||
executePublishWorld,
|
||||
setGeneratedCustomWorldProfile,
|
||||
});
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => void enterWorldForTestFromCurrentResult()}>
|
||||
进入
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByText } = render(<Harness />);
|
||||
await act(async () => {
|
||||
getByText('进入').click();
|
||||
});
|
||||
|
||||
expect(executePublishWorld).not.toHaveBeenCalled();
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(draftProfile);
|
||||
expect(handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/draft-role/portrait.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,18 +12,12 @@ type UseRpgCreationEnterWorldParams = {
|
||||
agentSession: CustomWorldAgentSessionSnapshot | null;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
|
||||
syncAgentDraftResultProfile: (
|
||||
profile: CustomWorldProfile,
|
||||
) => Promise<{
|
||||
profile: CustomWorldProfile | null;
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
}>;
|
||||
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一“进入世界”前的最终同步策略。
|
||||
* 非 Agent 草稿结果直接进入;Agent 草稿结果必须先把当前结果页并回 session。
|
||||
* Agent 草稿结果进入游戏时只读 session.draftProfile,不再把结果页快照回写成新的运行时 profile。
|
||||
*/
|
||||
export function useRpgCreationEnterWorld(
|
||||
params: UseRpgCreationEnterWorldParams,
|
||||
@@ -36,7 +30,6 @@ export function useRpgCreationEnterWorld(
|
||||
agentSession,
|
||||
handleCustomWorldSelect,
|
||||
executePublishWorld,
|
||||
syncAgentDraftResultProfile,
|
||||
setGeneratedCustomWorldProfile,
|
||||
} = params;
|
||||
|
||||
@@ -50,11 +43,7 @@ export function useRpgCreationEnterWorld(
|
||||
return;
|
||||
}
|
||||
|
||||
const latestResult = await syncAgentDraftResultProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
const latestProfile =
|
||||
latestResult.profile ?? agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
handleCustomWorldSelect(latestProfile);
|
||||
}, [
|
||||
@@ -64,7 +53,6 @@ export function useRpgCreationEnterWorld(
|
||||
handleCustomWorldSelect,
|
||||
isAgentDraftResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
syncAgentDraftResultProfile,
|
||||
]);
|
||||
|
||||
const publishCurrentResult = useCallback(async () => {
|
||||
@@ -76,14 +64,10 @@ export function useRpgCreationEnterWorld(
|
||||
return generatedCustomWorldProfile;
|
||||
}
|
||||
|
||||
const latestResult = await syncAgentDraftResultProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
const latestProfile =
|
||||
latestResult.profile ?? agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
|
||||
const latestSession = latestResult.session ?? agentSession;
|
||||
const latestSession = agentSession;
|
||||
const canEnterPublishedWorld =
|
||||
latestSession?.stage === 'published' &&
|
||||
latestSession.resultPreview?.canEnterWorld;
|
||||
@@ -108,7 +92,6 @@ export function useRpgCreationEnterWorld(
|
||||
handleCustomWorldSelect,
|
||||
isAgentDraftResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
syncAgentDraftResultProfile,
|
||||
]);
|
||||
|
||||
const enterWorldFromCurrentResult = useCallback(async () => {
|
||||
|
||||
Reference in New Issue
Block a user