This commit is contained in:
2026-04-25 13:44:48 +08:00
parent 03acbc5cb1
commit 2ebb7bf253
44 changed files with 1003 additions and 250 deletions

View File

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

View File

@@ -148,7 +148,6 @@ function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
backstoryReveal: createBackstoryReveal(),
skills: [],
initialItems: [],
templateCharacterId: 'knight-female-1',
};
}

View File

@@ -445,8 +445,6 @@ export function PlatformEntryFlowShellImpl({
autosaveCoordinator.executeAgentActionAndWait({
action: 'publish_world',
}),
syncAgentDraftResultProfile:
autosaveCoordinator.syncAgentDraftResultProfile,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
});

View File

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

View File

@@ -14,7 +14,6 @@ export type EditableCustomWorldRole = {
motivation?: string;
combatStyle?: string;
tags?: string[];
templateCharacterId?: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;

View File

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

View File

@@ -127,7 +127,6 @@ export function createPlayableNpcDraft(
tags: ['自定义'],
},
],
templateCharacterId: profile.playableNpcs[0]?.templateCharacterId,
};
}

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

View File

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

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
import { AnimationState } from '../types';
import {
buildCustomWorldPlayableCharacters,
buildCustomWorldRuntimeCharacters,
getCharacterById,
resolveEncounterRecruitCharacter,
@@ -109,19 +110,9 @@ describe('characterPresets custom world runtime characters', () => {
tone: '潮湿、压抑、克制',
playerGoal: '查清夜港失踪案和潮路背后的势力牵连。',
templateWorldType: 'WUXIA',
playableNpcs: Array.from({ length: 5 }, (_, index) => ({
...createRole(index),
templateCharacterId:
index === 0
? 'sword-princess'
: index === 1
? 'archer-hero'
: index === 2
? 'girl-hero'
: index === 3
? 'punch-hero'
: 'fighter-4',
})),
playableNpcs: Array.from({ length: 5 }, (_, index) =>
createRole(index),
),
storyNpcs: [
{
...createRole(10),
@@ -260,4 +251,38 @@ describe('characterPresets custom world runtime characters', () => {
expect(recruitCharacter?.id).toBe(storyRole!.id);
expect(recruitCharacter?.name).toBe('沈雾');
});
it('uses draft playable role image directly before generated animations exist', () => {
const profile = buildExpandedCustomWorldProfile(
{
name: '潮雾列岛',
subtitle: '灯塔未眠',
summary: '围绕潮雾、灯塔和失踪航路展开的世界。',
tone: '冷峻、潮湿、悬疑',
playerGoal: '找到灯塔失踪航路。',
templateWorldType: 'WUXIA',
playableNpcs: [
{
...createRole(0),
id: 'playable-lighthouse-keeper',
imageSrc: '/generated-characters/lighthouse-keeper/portrait.png',
generatedVisualAssetId: 'assetobj-lighthouse-keeper',
generatedAnimationSetId: undefined,
animationMap: undefined,
},
],
},
'玩家想测试灯塔守望者草稿。',
);
const [playableCharacter] = buildCustomWorldPlayableCharacters(profile);
expect(playableCharacter?.portrait).toBe(
'/generated-characters/lighthouse-keeper/portrait.png',
);
expect(playableCharacter?.avatar).toBe(
'/generated-characters/lighthouse-keeper/portrait.png',
);
expect(playableCharacter?.animationMap).toBeUndefined();
});
});

View File

@@ -216,9 +216,7 @@ function buildCharacterResourceProfile(character: Character) {
};
}
type CustomWorldRuntimeRole = CustomWorldPlayableNpc | (CustomWorldNpc & {
templateCharacterId?: string;
});
type CustomWorldRuntimeRole = CustomWorldPlayableNpc | CustomWorldNpc;
function buildFallbackCustomRuntimeRole(character: Character): CustomWorldRuntimeRole {
return {
@@ -1617,6 +1615,15 @@ function buildCustomWorldRoleCharacter(
role: CustomWorldRuntimeRole,
) {
const combatTags = deriveCustomWorldCharacterCombatTags(profile, role, baseCharacter);
const roleImageSrc = role.imageSrc?.trim() || '';
const roleAnimationMap = role.animationMap
? {
...(baseCharacter.animationMap ?? {}),
...role.animationMap,
}
: roleImageSrc
? undefined
: baseCharacter.animationMap;
const opening = buildCustomWorldAdventureOpening(profile, {
...baseCharacter,
name: role.name,
@@ -1634,15 +1641,13 @@ function buildCustomWorldRoleCharacter(
description: role.description,
backstory: role.backstory,
backstoryReveal: role.backstoryReveal,
portrait: role.imageSrc?.trim() || baseCharacter.portrait,
avatar: roleImageSrc || baseCharacter.avatar,
portrait: roleImageSrc || baseCharacter.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap
? {
...(baseCharacter.animationMap ?? {}),
...role.animationMap,
}
: baseCharacter.animationMap,
// 草稿 profile 已提供角色形象但尚未生成动作集时,不能继承模板动作帧,
// 否则角色选择页选中态会优先渲染旧模板动画,看起来像草稿形象没有加载。
animationMap: roleAnimationMap,
visual: 'visual' in role ? role.visual : undefined,
groundOffsetY: 'visual' in role && role.visual ? 22 : baseCharacter.groundOffsetY,
personality: role.personality,
@@ -1683,13 +1688,6 @@ function pickCustomWorldRoleTemplateCharacter(
throw new Error('Missing preset characters for custom world generation');
}
const explicitTemplateCharacter = role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
: null;
if (explicitTemplateCharacter) {
return explicitTemplateCharacter;
}
const referenceTemplateCharacterId = resolveRoleTemplateCharacterIdFromReferenceProfile(
profile ?? null,
{
@@ -1749,7 +1747,6 @@ export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile |
profile,
{
...role,
templateCharacterId: role.templateCharacterId ?? templateCharacter.id,
},
);
});

View File

@@ -10,9 +10,7 @@ type CustomWorldTagProfile = Pick<
type CustomWorldTagRole = Pick<
CustomWorldPlayableNpc,
'name' | 'title' | 'description' | 'backstory' | 'personality' | 'combatStyle' | 'tags'
> & {
templateCharacterId?: string;
};
>;
const TEMPLATE_CHARACTER_TAGS: Record<string, string[]> = {
'sword-princess': ['\u5feb\u5251', '\u7a81\u8fdb', '\u538b\u5236'],
@@ -160,7 +158,7 @@ export function deriveCustomWorldCharacterCombatTags(
) {
return deriveCustomWorldCombatTags(profile, role, {
fallbackTags: normalizeBuildTags(baseCharacter.combatTags, 3),
templateCharacterId: role.templateCharacterId ?? baseCharacter.id,
templateCharacterId: baseCharacter.id,
maxCount: 3,
});
}

View File

@@ -130,7 +130,6 @@ function resolveCustomWorldRole(
) {
return profile.playableNpcs.find(role => role.id === character.id)
?? profile.storyNpcs.find(role => role.id === character.id)
?? profile.playableNpcs.find(role => role.templateCharacterId === character.id)
?? profile.playableNpcs.find(role => role.name === character.name)
?? profile.storyNpcs.find(role => role.name === character.name)
?? null;

View File

@@ -72,5 +72,53 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
expect(profile?.playerPremise).toBe('玩家是返乡调查旧案的守灯人。');
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
});
it('直接读取 Rust 草稿角色字段和形象资源', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
playableNpcs: [
{
id: 'playable-cendeng',
name: '岑灯',
title: '返乡守灯人',
role: '主角代理',
publicMask: '深蓝旧雨衣、铜灯和卷边海图。',
currentPressure: '灯塔记录被人改写,旧案正在逼近。',
relationToPlayer: '这是玩家进入世界的第一视角。',
imageSrc: '/generated-characters/playable-cendeng/portrait.png',
generatedVisualAssetId: 'visual-playable-cendeng',
},
],
storyNpcs: [
{
id: 'story-yizhang',
name: '议长甲',
title: '群岛议长',
role: '遮掩者',
publicIdentity: '压住旧档的人。',
hiddenHook: '长期维持群岛议会体面并遮掩沉船旧案。',
relationToPlayer: '会阻止玩家继续追查。',
imageSrc: '/generated-characters/story-yizhang/portrait.png',
},
],
});
expect(profile?.playableNpcs[0]?.description).toBe(
'深蓝旧雨衣、铜灯和卷边海图。',
);
expect(profile?.playableNpcs[0]?.backstory).toContain('灯塔记录');
expect(profile?.playableNpcs[0]?.relationshipHooks[0]).toBe(
'这是玩家进入世界的第一视角。',
);
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/playable-cendeng/portrait.png',
);
expect(profile?.storyNpcs[0]?.description).toBe('压住旧档的人。');
expect(profile?.storyNpcs[0]?.backstory).toContain('沉船旧案');
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-yizhang/portrait.png',
);
});
});

View File

@@ -663,17 +663,23 @@ function normalizePlayableNpc(
.filter(Boolean)
.slice(0, 8);
const tags = toStringArray(value.tags);
const publicMask = toText(value.publicMask) || toText(value.publicIdentity);
const currentPressure = toText(value.currentPressure) || toText(value.hiddenHook);
const relationToPlayer = toText(value.relationToPlayer);
const fallbackSource = {
name,
title,
role,
description: toText(value.description),
backstory: toText(value.backstory),
personality: toText(value.personality),
motivation: toText(value.motivation, toText(value.description)),
description: toText(value.description) || publicMask,
backstory: toText(value.backstory) || currentPressure,
personality: toText(value.personality) || publicMask,
motivation:
toText(value.motivation) || relationToPlayer || currentPressure,
combatStyle: toText(value.combatStyle),
relationshipHooks:
relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
relationshipHooks.length > 0
? relationshipHooks
: [relationToPlayer, currentPressure, ...tags].filter(Boolean).slice(0, 3),
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
@@ -683,9 +689,10 @@ function normalizePlayableNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
visualDescription: toText(value.visualDescription) || publicMask || undefined,
actionDescription: toText(value.actionDescription) || currentPressure || undefined,
sceneVisualDescription:
toText(value.sceneVisualDescription) || currentPressure || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
@@ -717,7 +724,6 @@ function normalizePlayableNpc(
preserveStructuredRecord<CustomWorldPlayableNpc['narrativeProfile']>(
value.narrativeProfile,
) ?? undefined,
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
@@ -738,17 +744,23 @@ function normalizeStoryNpc(
.filter(Boolean)
.slice(0, 8);
const tags = toStringArray(value.tags);
const publicMask = toText(value.publicMask) || toText(value.publicIdentity);
const currentPressure = toText(value.currentPressure) || toText(value.hiddenHook);
const relationToPlayer = toText(value.relationToPlayer);
const fallbackSource = {
name,
title,
role,
description: toText(value.description),
backstory: toText(value.backstory),
personality: toText(value.personality),
motivation: toText(value.motivation),
description: toText(value.description) || publicMask,
backstory: toText(value.backstory) || currentPressure,
personality: toText(value.personality) || publicMask,
motivation:
toText(value.motivation) || relationToPlayer || currentPressure,
combatStyle: toText(value.combatStyle),
relationshipHooks:
relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
relationshipHooks.length > 0
? relationshipHooks
: [relationToPlayer, currentPressure, ...tags].filter(Boolean).slice(0, 3),
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
@@ -758,9 +770,10 @@ function normalizeStoryNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
visualDescription: toText(value.visualDescription) || publicMask || undefined,
actionDescription: toText(value.actionDescription) || currentPressure || undefined,
sceneVisualDescription:
toText(value.sceneVisualDescription) || currentPressure || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,

View File

@@ -79,9 +79,6 @@ function buildExplicitCustomWorldRoleStarterState(
const role =
profile.playableNpcs.find((entry) => entry.id === character.id) ??
profile.storyNpcs.find((entry) => entry.id === character.id) ??
profile.playableNpcs.find(
(entry) => entry.templateCharacterId === character.id,
) ??
profile.playableNpcs.find((entry) => entry.name === character.name) ??
profile.storyNpcs.find((entry) => entry.name === character.name) ??
null;

View File

@@ -146,7 +146,6 @@ function buildSavedProfile() {
tags: ['线索', '真相'],
},
],
templateCharacterId: 'archer-hero',
},
],
storyNpcs: [

View File

@@ -895,7 +895,6 @@ function normalizePlayableNpcList(value: unknown) {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
}),
templateCharacterId: toText(item.templateCharacterId) || undefined,
}))
.filter((entry) => entry.name)
.slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT);

View File

@@ -118,12 +118,10 @@ export function buildExpandedCustomWorldProfile(
const playableNpcs = dedupeByName(profile.playableNpcs)
.slice(0, PLAYABLE_TEMPLATE_CHARACTER_IDS.length)
.map((npc, index) => {
const templateCharacterId =
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
const templateCharacterId = getPlayableTemplateCharacterId(index);
return {
...npc,
id: npc.id || createEntryId('playable-npc', npc.name, index),
templateCharacterId,
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
templateCharacterId,
maxCount: 5,

View File

@@ -1,4 +1,3 @@
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import type {
CustomWorldCoverProfile,
CustomWorldPlayableNpc,
@@ -43,15 +42,7 @@ function resolvePlayableCoverImageSrc(role: CustomWorldPlayableNpc) {
return explicitImageSrc;
}
if (!role.templateCharacterId) {
return null;
}
return (
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
)?.portrait ?? null
);
return null;
}
function normalizeCoverCharacterRoleIds(

View File

@@ -378,9 +378,7 @@ function buildRoleArchetypes(profile: CustomWorldProfile) {
narrativeFunction:
role.role.trim() || role.description.trim() || '在主线推进中提供关键响应。',
sourceRoleIds: [role.id],
sourceTemplateCharacterIds: role.templateCharacterId
? [role.templateCharacterId]
: [],
sourceTemplateCharacterIds: [],
tags: dedupeStrings(role.tags, 5),
})) satisfies RoleArchetypeProfile[];
}

View File

@@ -32,13 +32,37 @@ const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
anchorPack: null,
lockState: null,
draftProfile: {
id: 'draft-profile-1',
settingText: '草稿 profile 直接进入游戏。',
name: '只作为 fallback 的本地草稿名',
subtitle: 'fallback',
summary: 'fallback',
tone: 'fallback',
playerGoal: 'fallback',
playableNpcs: [],
templateWorldType: 'WUXIA',
majorFactions: [],
coreConflicts: [],
playableNpcs: [
{
id: 'draft-playable-1',
name: '草稿角色',
title: '直读测试',
role: '可扮演角色',
description: '从 draftProfile 直接进入角色选择页。',
backstory: '草稿角色的背景不经过 resultPreview 转换。',
personality: '直接、清醒',
motivation: '验证草稿直读链路',
combatStyle: '以直读链路破局',
initialAffinity: 18,
relationshipHooks: ['来自草稿'],
tags: ['draft-profile'],
skills: [],
initialItems: [],
imageSrc: '/generated-characters/draft-playable-1/portrait.png',
},
],
storyNpcs: [],
items: [],
landmarks: [],
},
messages: [],
@@ -103,19 +127,21 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
});
test('buildRpgCreationPreviewFromSession prefers server resultPreview over draft fallback', () => {
test('buildRpgCreationPreviewFromSession reads draftProfile directly', () => {
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
expect(profile?.name).toBe('服务端结果预览');
expect(profile?.name).not.toBe('只作为 fallback 的本地草稿名');
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
expect(profile?.name).not.toBe('服务端结果预览');
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-playable-1/portrait.png',
);
});
test('buildRpgCreationPreviewFromSession returns null when server resultPreview is missing', () => {
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
const profile = buildRpgCreationPreviewFromSession({
...sessionWithPreview,
resultPreview: null,
});
expect(profile).toBeNull();
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
});

View File

@@ -2,10 +2,6 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/s
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { CustomWorldProfile } from '../../types';
/**
* Phase 5 起结果页只消费服务端回传的 result preview。
* 前端不再承担 session draft -> runtime profile 的本地兼容编译职责。
*/
export function buildCustomWorldProfileFromResultPreview(
resultPreview: CustomWorldAgentSessionSnapshot['resultPreview'] | null | undefined,
): CustomWorldProfile | null {
@@ -13,20 +9,18 @@ export function buildCustomWorldProfileFromResultPreview(
}
/**
* 统一“从 session 取结果页 profile”的主入口
* Phase 5 后主链没有 preview 就视为服务端未准备完成,而不是继续做前端本地编译
* RPG 运行时直接读取 Agent session 的 draftProfile。
* resultPreview 只作为质量/发布信息外壳,不再参与进入游戏 profile 的数据转换
*/
export function buildCustomWorldProfileFromAgentSession(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
return buildCustomWorldProfileFromResultPreview(session?.resultPreview);
return normalizeCustomWorldProfileRecord(session?.draftProfile ?? null);
}
/**
* 这是工作包 A 提供的新命名兼容层。
* Phase 3 后该适配层只负责:
* 1. 把服务端 resultPreview 转成前端 view model
* 2. 保持前端 session 读模型入口稳定
* 主入口保持命名稳定,但数据来源已经收敛为 draftProfile 单一真相源。
*/
export const rpgCreationPreviewAdapter = {
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,

View File

@@ -13,7 +13,7 @@ describe('campaignPackCompiler', () => {
storyGraph: {
visibleThreads: [{ id: 'thread-1', title: '封桥旧案' }],
},
playableNpcs: [{ id: 'npc-1', templateCharacterId: 'archer-hero' }],
playableNpcs: [{ id: 'npc-1' }],
} as unknown as CustomWorldProfile;
const compiled = compileCampaignFromWorldProfile({ profile });

View File

@@ -48,7 +48,7 @@ export function buildCampaignPack(params: {
authoringStyle,
campaignStateSeed,
actTemplates,
requiredCompanionIds: profile.playableNpcs.slice(0, 2).map((npc) => npc.templateCharacterId ?? npc.id),
requiredCompanionIds: profile.playableNpcs.slice(0, 2).map((npc) => npc.id),
} satisfies CampaignPack;
}

View File

@@ -281,9 +281,7 @@ export interface CustomWorldNpcVisual {
offHand?: CustomWorldNpcVisualGear | null;
}
export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile {
templateCharacterId?: string;
}
export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile {}
export interface CustomWorldNpc extends CustomWorldRoleProfile {
visual?: CustomWorldNpcVisual;
@@ -350,6 +348,10 @@ export interface SceneActBlueprint {
backgroundAssetId?: string | null;
encounterNpcIds: string[];
primaryNpcId: string;
/** 当前幕对面的角色,草稿阶段默认与 primaryNpcId 保持一致。 */
oppositeNpcId: string;
/** 当前幕发生的事件描述,需强绑定对面角色与场景主线压力。 */
eventDescription: string;
linkedThreadIds: string[];
advanceRule: SceneActAdvanceRule;
actGoal: string;
@@ -361,6 +363,8 @@ export interface SceneChapterBlueprint {
sceneId: string;
title: string;
summary: string;
/** 首次进入该场景时生成章节任务所需的核心上下文。 */
sceneTaskDescription: string;
linkedThreadIds: string[];
linkedLandmarkIds: string[];
acts: SceneActBlueprint[];