fix: preserve rpg custom world detail profiles
This commit is contained in:
@@ -196,4 +196,287 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
'/generated-custom-world-scenes/opening/storyboard.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('保留结果页封面和关键图片资产槽位', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '星砂废都',
|
||||
settingText: '坠星沙海与废都钟楼',
|
||||
cover: {
|
||||
sourceType: 'generated',
|
||||
imageSrc: '/generated-custom-world-covers/star-waste/cover.webp',
|
||||
characterRoleIds: ['playable-shamian'],
|
||||
},
|
||||
camp: {
|
||||
id: 'camp-star-waste',
|
||||
name: '废都营地',
|
||||
description: '钟楼阴影下的临时营地。',
|
||||
imageSrc: '/assets/custom-world/camp-star-waste.png',
|
||||
sceneNpcIds: ['playable-shamian'],
|
||||
connections: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-shamian',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
role: '主角代理',
|
||||
imageSrc: '/assets/custom-world/playable-shamian.png',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-clock-keeper',
|
||||
name: '钟守',
|
||||
title: '钟楼守夜者',
|
||||
role: '第一幕主NPC',
|
||||
imageSrc: '/assets/custom-world/story-clock-keeper.png',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-clocktower',
|
||||
name: '坠星钟楼',
|
||||
description: '半截钟楼被星砂埋住。',
|
||||
imageSrc: '/assets/custom-world/landmark-clocktower.png',
|
||||
sceneNpcIds: ['story-clock-keeper'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-clocktower',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '钟楼第一夜',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-clocktower-opening',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '第一幕',
|
||||
summary: '砂眠带玩家进入坠星钟楼。',
|
||||
backgroundImageSrc:
|
||||
'/assets/custom-world/act-clocktower-opening.png',
|
||||
encounterNpcIds: ['砂眠', '钟守'],
|
||||
primaryNpcId: '砂眠',
|
||||
oppositeNpcId: '钟守',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile?.cover?.sourceType).toBe('generated');
|
||||
expect(profile?.cover?.imageSrc).toBe(
|
||||
'/generated-custom-world-covers/star-waste/cover.webp',
|
||||
);
|
||||
expect(profile?.camp?.imageSrc).toBe(
|
||||
'/assets/custom-world/camp-star-waste.png',
|
||||
);
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/assets/custom-world/playable-shamian.png',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
|
||||
'/assets/custom-world/story-clock-keeper.png',
|
||||
);
|
||||
expect(profile?.landmarks[0]?.imageSrc).toBe(
|
||||
'/assets/custom-world/landmark-clocktower.png',
|
||||
);
|
||||
expect(
|
||||
profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc,
|
||||
).toBe('/assets/custom-world/act-clocktower-opening.png');
|
||||
});
|
||||
|
||||
it('近似无损保留编辑态和运行态结构字段', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '星砂废都',
|
||||
settingText: '坠星沙海与废都钟楼',
|
||||
attributeSchema: {
|
||||
id: 'schema-stardust',
|
||||
worldId: 'world-stardust',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '星砂废都',
|
||||
settingSummary: '坠星沙海与废都钟楼',
|
||||
tone: '苍凉',
|
||||
conflictCore: '旧约与星砂潮汐冲突',
|
||||
},
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '星砂共鸣' },
|
||||
{ slotId: 'axis_b', name: '废都步法' },
|
||||
{ slotId: 'axis_c', name: '钟楼感知' },
|
||||
{ slotId: 'axis_d', name: '旧约心火' },
|
||||
{ slotId: 'axis_e', name: '尘缘牵引' },
|
||||
{ slotId: 'axis_f', name: '潮汐玄息' },
|
||||
],
|
||||
},
|
||||
camp: {
|
||||
id: 'camp-star-waste',
|
||||
name: '废都营地',
|
||||
description: '钟楼阴影下的临时营地。',
|
||||
narrativeResidues: [
|
||||
{
|
||||
id: 'residue-camp-1',
|
||||
summary: '营地火盆里混着星砂。',
|
||||
},
|
||||
],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-shamian',
|
||||
name: '砂眠',
|
||||
title: '废都引路人',
|
||||
role: '主角代理',
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-star-step',
|
||||
name: '星砂步',
|
||||
summary: '踏砂突进。',
|
||||
style: 'mobility',
|
||||
actionPreviewConfig: {
|
||||
folder: 'characters/shamian',
|
||||
prefix: 'skill_',
|
||||
frames: 8,
|
||||
basePath: '/assets/custom-world/shamian/skill',
|
||||
previewVideoPath: '/assets/custom-world/shamian/skill.mp4',
|
||||
file: 'skill.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-sand-compass',
|
||||
name: '星砂罗盘',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '能指出旧约埋藏方向。',
|
||||
tags: ['旧约'],
|
||||
iconSrc: '/assets/custom-world/items/sand-compass.png',
|
||||
},
|
||||
],
|
||||
attributeProfile: {
|
||||
schemaId: 'schema-stardust',
|
||||
values: { axis_a: 8, axis_b: 7 },
|
||||
topTraits: ['星砂共鸣'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-clock-keeper',
|
||||
name: '钟守',
|
||||
title: '钟楼守夜者',
|
||||
role: '第一幕主NPC',
|
||||
attributeProfile: {
|
||||
schemaId: 'schema-stardust',
|
||||
values: { axis_c: 9 },
|
||||
topTraits: ['钟楼感知'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_c', reason: '能辨认旧铃回声。' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-clocktower',
|
||||
name: '坠星钟楼',
|
||||
description: '半截钟楼被星砂埋住。',
|
||||
visualDescription: '钟楼外墙布满蓝白星砂结晶。',
|
||||
narrativeResidues: [
|
||||
{
|
||||
id: 'residue-clocktower-1',
|
||||
summary: '钟楼第十三声铃响仍未散去。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-clocktower',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '钟楼第一夜',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-clocktower-opening',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '第一幕',
|
||||
summary: '砂眠带玩家进入坠星钟楼。',
|
||||
backgroundAssetId: 'asset-act-clocktower-opening',
|
||||
backgroundImageSrc:
|
||||
'/assets/custom-world/act-clocktower-opening.png',
|
||||
eventDescription: '钟楼旧铃忽然自鸣。',
|
||||
linkedThreadIds: ['thread-old-vow'],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '进入钟楼。',
|
||||
transitionHook: '星砂开始倒流。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile?.attributeSchema.id).toBe('schema-stardust');
|
||||
expect(profile?.attributeSchema.slots[0]?.name).toBe('星砂共鸣');
|
||||
expect(profile?.camp?.narrativeResidues?.[0]?.summary).toBe(
|
||||
'营地火盆里混着星砂。',
|
||||
);
|
||||
expect(
|
||||
profile?.playableNpcs[0]?.skills[0]?.actionPreviewConfig
|
||||
?.previewVideoPath,
|
||||
).toBe('/assets/custom-world/shamian/skill.mp4');
|
||||
expect(profile?.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
|
||||
'/assets/custom-world/items/sand-compass.png',
|
||||
);
|
||||
expect(profile?.playableNpcs[0]?.attributeProfile?.values.axis_a).toBe(8);
|
||||
expect(profile?.storyNpcs[0]?.attributeProfile?.topTraits).toContain(
|
||||
'钟楼感知',
|
||||
);
|
||||
expect(profile?.landmarks[0]?.visualDescription).toBe(
|
||||
'钟楼外墙布满蓝白星砂结晶。',
|
||||
);
|
||||
expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.summary).toBe(
|
||||
'钟楼第十三声铃响仍未散去。',
|
||||
);
|
||||
const act = profile?.sceneChapterBlueprints?.[0]?.acts[0];
|
||||
expect(act?.backgroundAssetId).toBe('asset-act-clocktower-opening');
|
||||
expect(act?.eventDescription).toBe('钟楼旧铃忽然自鸣。');
|
||||
expect(act?.linkedThreadIds).toEqual(['thread-old-vow']);
|
||||
expect(act?.actGoal).toBe('进入钟楼。');
|
||||
expect(act?.transitionHook).toBe('星砂开始倒流。');
|
||||
});
|
||||
|
||||
it('保留只有背景资产的场景幕,避免恢复详情时丢失场景 CG', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '星砂废都',
|
||||
settingText: '坠星沙海与废都钟楼',
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-clocktower',
|
||||
sceneId: 'landmark-clocktower',
|
||||
title: '钟楼第一夜',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-background-only',
|
||||
sceneId: 'landmark-clocktower',
|
||||
backgroundAssetId: 'asset-background-only',
|
||||
backgroundImageSrc: '/assets/custom-world/background-only.png',
|
||||
backgroundPromptText: '坠星钟楼被蓝白星砂照亮。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const act = profile?.sceneChapterBlueprints?.[0]?.acts[0];
|
||||
expect(act?.id).toBe('act-background-only');
|
||||
expect(act?.backgroundImageSrc).toBe(
|
||||
'/assets/custom-world/background-only.png',
|
||||
);
|
||||
expect(act?.backgroundAssetId).toBe('asset-background-only');
|
||||
expect(act?.backgroundPromptText).toBe('坠星钟楼被蓝白星砂照亮。');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
CustomWorldNpcVisualGearType,
|
||||
CustomWorldNpcVisualRace,
|
||||
CustomWorldOpeningCgProfile,
|
||||
CustomWorldCoverProfile,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleInitialItem,
|
||||
@@ -864,6 +865,7 @@ function normalizeLandmark(
|
||||
id: toText(value.id, `saved-landmark-${index + 1}`),
|
||||
name,
|
||||
description: toText(value.description),
|
||||
visualDescription: toText(value.visualDescription) || undefined,
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
narrativeResidues:
|
||||
preserveStructuredRecordArray<SceneNarrativeResidue>(
|
||||
@@ -909,7 +911,10 @@ function normalizeCampScene(
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkId),
|
||||
narrativeResidues: null,
|
||||
narrativeResidues:
|
||||
preserveStructuredRecordArray<SceneNarrativeResidue>(
|
||||
value.narrativeResidues,
|
||||
) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -989,6 +994,13 @@ function normalizeSceneActBlueprint(
|
||||
const advanceRule = toText(value.advanceRule);
|
||||
const title = toText(value.title);
|
||||
const summary = toText(value.summary);
|
||||
const backgroundImageSrc = toText(value.backgroundImageSrc);
|
||||
const backgroundPromptText = toText(value.backgroundPromptText);
|
||||
const backgroundAssetId = toText(value.backgroundAssetId);
|
||||
const eventDescription = toText(value.eventDescription);
|
||||
const linkedThreadIds = toStringArray(value.linkedThreadIds);
|
||||
const actGoal = toText(value.actGoal);
|
||||
const transitionHook = toText(value.transitionHook);
|
||||
const primaryNpcId = resolveCustomWorldRoleIdReference(
|
||||
profileRoles,
|
||||
toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
|
||||
@@ -998,7 +1010,18 @@ function normalizeSceneActBlueprint(
|
||||
toText(value.oppositeNpcId, primaryNpcId),
|
||||
);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
if (
|
||||
!title &&
|
||||
!summary &&
|
||||
encounterNpcIds.length === 0 &&
|
||||
!backgroundImageSrc &&
|
||||
!backgroundPromptText &&
|
||||
!backgroundAssetId &&
|
||||
!eventDescription &&
|
||||
linkedThreadIds.length === 0 &&
|
||||
!actGoal &&
|
||||
!transitionHook
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1011,26 +1034,26 @@ function normalizeSceneActBlueprint(
|
||||
stageCoverage.length > 0
|
||||
? stageCoverage
|
||||
: index === 0
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
|
||||
backgroundPromptText: toText(value.backgroundPromptText) || undefined,
|
||||
backgroundAssetId: toText(value.backgroundAssetId) || undefined,
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc: backgroundImageSrc || undefined,
|
||||
backgroundPromptText: backgroundPromptText || undefined,
|
||||
backgroundAssetId: backgroundAssetId || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId,
|
||||
oppositeNpcId,
|
||||
eventDescription: toText(
|
||||
value.eventDescription,
|
||||
eventDescription,
|
||||
oppositeNpcId
|
||||
? `第 ${index + 1} 幕中,玩家与${oppositeNpcId}正面接触,推动当前场景事件升级。`
|
||||
: `第 ${index + 1} 幕中,玩家处理当前场景的关键事件。`,
|
||||
),
|
||||
linkedThreadIds: toStringArray(value.linkedThreadIds),
|
||||
linkedThreadIds,
|
||||
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
|
||||
? (advanceRule as SceneActBlueprint['advanceRule'])
|
||||
: 'after_active_step_complete',
|
||||
actGoal: toText(value.actGoal),
|
||||
transitionHook: toText(value.transitionHook),
|
||||
actGoal,
|
||||
transitionHook,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1159,6 +1182,9 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
const openingCg = preserveStructuredRecord<CustomWorldOpeningCgProfile>(
|
||||
value.openingCg,
|
||||
);
|
||||
const cover = preserveStructuredRecord<CustomWorldCoverProfile>(
|
||||
value.cover,
|
||||
);
|
||||
const normalizedProfile = {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
settingText,
|
||||
@@ -1184,6 +1210,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
.map((entry, index) => normalizeItem(entry, index))
|
||||
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
||||
: [],
|
||||
cover,
|
||||
openingCg,
|
||||
camp,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
|
||||
Reference in New Issue
Block a user