Persist custom world asset configs in runtime snapshots
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user