Persist custom world asset configs in runtime snapshots

This commit is contained in:
2026-04-18 17:00:46 +08:00
parent 7ce61e9879
commit ac801fe05f
29 changed files with 3397 additions and 400 deletions

View File

@@ -3131,6 +3131,196 @@ test('runtime snapshot persistence accepts null currentStory payloads', async ()
});
});
test('runtime snapshot persistence syncs custom world asset configs into snapshot and profile storage', async () => {
await withTestServer(
'persistence-custom-world-assets',
async ({ baseUrl, context }) => {
const entry = await authEntry(
baseUrl,
'playercustomworldassets',
'secret123',
);
const saveResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(entry.token, {
method: 'PUT',
body: JSON.stringify({
gameState: {
currentScene: 'Story',
worldType: 'CUSTOM',
playerCharacter: {
id: 'playable-asset-role',
portrait:
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
generatedVisualAssetId: 'visual-1',
generatedAnimationSetId: 'animation-set-1',
animationMap: {
idle: {
folder: 'idle',
prefix: 'Idle',
frames: 4,
basePath:
'/generated-animations/playable-asset-role/animation-set-1/idle',
},
},
},
currentScenePreset: {
id: 'custom-scene-landmark-1',
name: '潮声断桥',
description: '旧桥横在潮雾之上。',
imageSrc:
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
},
customWorldProfile: {
id: 'cw-profile-asset',
name: '潮雾裂港',
subtitle: '退潮时响起旧讯号',
summary: '雾与潮共同切开港湾边境。',
tone: '冷潮压城,旧案未散',
playerGoal: '追出失落讯标的去向',
settingText: '一座被潮雾与旧讯号撕开的港湾世界。',
templateWorldType: 'WUXIA',
compatibilityTemplateWorldType: 'WUXIA',
majorFactions: ['潮关守备'],
coreConflicts: ['讯标争夺'],
playableNpcs: [
{
id: 'playable-asset-role',
name: '沈潮',
title: '归港行者',
role: '可扮演角色',
description: '总盯着退潮后的暗线。',
backstory: '他从失讯后的航路里活着回来。',
personality: '谨慎克制',
motivation: '找回失落讯标',
combatStyle: '借潮势游走压制',
initialAffinity: 18,
relationshipHooks: ['识得旧港规矩'],
tags: ['潮港', '追迹'],
backstoryReveal: {
publicSummary: '他像一直在等潮声回信。',
chapters: [],
},
skills: [],
initialItems: [],
},
],
storyNpcs: [],
items: [],
camp: {
name: '归潮居',
description: '退潮后还能落脚的旧屋。',
dangerLevel: 'low',
},
landmarks: [
{
id: 'landmark-1',
name: '潮声断桥',
description: '旧桥横在潮雾之上。',
dangerLevel: 'medium',
sceneNpcIds: [],
connections: [],
},
],
attributeSchema: {
slots: [],
},
},
},
bottomTab: 'adventure',
currentStory: {
text: '潮声还在桥下回荡。',
options: [],
},
}),
}),
);
const savePayload = (await saveResponse.json()) as {
gameState: {
customWorldProfile: {
playableNpcs: Array<{
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, { basePath?: string }>;
}>;
landmarks: Array<{
imageSrc?: string;
}>;
} | null;
};
};
assert.equal(saveResponse.status, 200);
assert.equal(
savePayload.gameState.customWorldProfile?.playableNpcs[0]?.imageSrc,
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
);
assert.equal(
savePayload.gameState.customWorldProfile?.playableNpcs[0]
?.generatedVisualAssetId,
'visual-1',
);
assert.equal(
savePayload.gameState.customWorldProfile?.playableNpcs[0]
?.generatedAnimationSetId,
'animation-set-1',
);
assert.equal(
savePayload.gameState.customWorldProfile?.playableNpcs[0]?.animationMap
?.idle?.basePath,
'/generated-animations/playable-asset-role/animation-set-1/idle',
);
assert.equal(
savePayload.gameState.customWorldProfile?.landmarks[0]?.imageSrc,
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
);
const persistedRows = await context.db.query<{
payload: {
playableNpcs?: Array<{
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, { basePath?: string }>;
}>;
landmarks?: Array<{
imageSrc?: string;
}>;
};
}>(
`SELECT payload_json AS payload
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2`,
[entry.user.id, 'cw-profile-asset'],
);
assert.equal(persistedRows.rows.length, 1);
assert.equal(
persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.imageSrc,
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
);
assert.equal(
persistedRows.rows[0]?.payload?.playableNpcs?.[0]
?.generatedAnimationSetId,
'animation-set-1',
);
assert.equal(
persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.animationMap?.idle
?.basePath,
'/generated-animations/playable-asset-role/animation-set-1/idle',
);
assert.equal(
persistedRows.rows[0]?.payload?.landmarks?.[0]?.imageSrc,
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
);
},
);
});
test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => {
await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => {
const entry = await authEntry(