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

@@ -340,6 +340,199 @@ function buildBuiltinWorldTitle(worldType: string) {
}
}
function looksLikeGeneratedAssetPath(value: string) {
return /^\/generated-/u.test(value);
}
function mergeSnapshotRoleAssets(
role: Record<string, unknown>,
assets: {
imageSrc?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
) {
let changed = false;
const nextRole: Record<string, unknown> = { ...role };
const nextImageSrc = readString(assets.imageSrc);
const nextGeneratedVisualAssetId = readString(assets.generatedVisualAssetId);
const nextGeneratedAnimationSetId = readString(
assets.generatedAnimationSetId,
);
const nextAnimationMap = asRecord(assets.animationMap);
if (nextImageSrc && readString(role.imageSrc) !== nextImageSrc) {
nextRole.imageSrc = nextImageSrc;
changed = true;
}
if (
nextGeneratedVisualAssetId &&
readString(role.generatedVisualAssetId) !== nextGeneratedVisualAssetId
) {
nextRole.generatedVisualAssetId = nextGeneratedVisualAssetId;
changed = true;
}
if (
nextGeneratedAnimationSetId &&
readString(role.generatedAnimationSetId) !== nextGeneratedAnimationSetId
) {
nextRole.generatedAnimationSetId = nextGeneratedAnimationSetId;
changed = true;
}
if (nextAnimationMap && Object.keys(nextAnimationMap).length > 0) {
nextRole.animationMap = {
...(asRecord(role.animationMap) ?? {}),
...nextAnimationMap,
};
changed = true;
}
return changed ? nextRole : role;
}
function syncSnapshotRoleAssetsIntoProfile(
profile: Record<string, unknown>,
roleId: string,
assets: Parameters<typeof mergeSnapshotRoleAssets>[1],
) {
if (!roleId) {
return profile;
}
let changed = false;
const syncRoleArray = (value: unknown) => {
if (!Array.isArray(value)) {
return value;
}
return value.map((entry) => {
if (!asRecord(entry) || readString(entry.id) !== roleId) {
return entry;
}
const nextEntry = mergeSnapshotRoleAssets(entry, assets);
if (nextEntry !== entry) {
changed = true;
}
return nextEntry;
});
};
const nextPlayableNpcs = syncRoleArray(profile.playableNpcs);
const nextStoryNpcs = syncRoleArray(profile.storyNpcs);
return changed
? {
...profile,
playableNpcs: nextPlayableNpcs,
storyNpcs: nextStoryNpcs,
}
: profile;
}
function syncSnapshotSceneImageIntoProfile(
profile: Record<string, unknown>,
sceneId: string,
imageSrc: string,
) {
if (!sceneId || !imageSrc) {
return profile;
}
if (sceneId === 'custom-scene-camp') {
const currentCamp = asRecord(profile.camp) ?? {};
if (readString(currentCamp.imageSrc) === imageSrc) {
return profile;
}
return {
...profile,
camp: {
...currentCamp,
imageSrc,
},
};
}
const landmarkMatch = /^custom-scene-landmark-(\d+)$/u.exec(sceneId);
if (!landmarkMatch || !Array.isArray(profile.landmarks)) {
return profile;
}
const landmarkIndex = Number.parseInt(landmarkMatch[1] ?? '', 10) - 1;
if (
!Number.isInteger(landmarkIndex) ||
landmarkIndex < 0 ||
landmarkIndex >= profile.landmarks.length
) {
return profile;
}
const currentLandmark = asRecord(profile.landmarks[landmarkIndex]);
if (!currentLandmark || readString(currentLandmark.imageSrc) === imageSrc) {
return profile;
}
const nextLandmarks = [...profile.landmarks];
nextLandmarks[landmarkIndex] = {
...currentLandmark,
imageSrc,
};
return {
...profile,
landmarks: nextLandmarks,
};
}
function syncSnapshotCustomWorldProfile(gameState: unknown) {
const currentGameState = asRecord(gameState);
const currentProfile = asRecord(currentGameState?.customWorldProfile);
if (!currentGameState || !currentProfile) {
return gameState;
}
let nextProfile = currentProfile;
const playerCharacter = asRecord(currentGameState.playerCharacter);
const playerCharacterId = readString(playerCharacter?.id);
const playerPortrait = readString(playerCharacter?.portrait);
const playerAnimationMap = asRecord(playerCharacter?.animationMap);
const playerHasGeneratedAssets =
Boolean(readString(playerCharacter?.generatedVisualAssetId)) ||
Boolean(readString(playerCharacter?.generatedAnimationSetId)) ||
Boolean(playerAnimationMap && Object.keys(playerAnimationMap).length > 0) ||
looksLikeGeneratedAssetPath(playerPortrait);
nextProfile = syncSnapshotRoleAssetsIntoProfile(nextProfile, playerCharacterId, {
imageSrc: playerHasGeneratedAssets ? playerPortrait : null,
generatedVisualAssetId:
readString(playerCharacter?.generatedVisualAssetId) || null,
generatedAnimationSetId:
readString(playerCharacter?.generatedAnimationSetId) || null,
animationMap: playerAnimationMap,
});
const currentScenePreset = asRecord(currentGameState.currentScenePreset);
nextProfile = syncSnapshotSceneImageIntoProfile(
nextProfile,
readString(currentScenePreset?.id),
readString(currentScenePreset?.imageSrc),
);
if (nextProfile === currentProfile) {
return currentGameState;
}
return {
...currentGameState,
customWorldProfile: nextProfile,
};
}
function resolveProfileWorldSnapshotMeta(
snapshot: SavedSnapshot,
): ProfileWorldSnapshotMeta | null {
@@ -595,6 +788,67 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
});
}
private async syncCustomWorldProfileFromSnapshot(
userId: string,
snapshot: SavedSnapshot,
) {
const gameState = asRecord(snapshot.gameState);
const customWorldProfile = asRecord(gameState?.customWorldProfile);
const profileId = readString(customWorldProfile?.id);
if (!customWorldProfile || !profileId) {
return;
}
const payload = normalizeStoredProfile(profileId, customWorldProfile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const syncedAt = snapshot.savedAt || new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count,
deleted_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
syncedAt,
'玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
);
}
async getSnapshot(userId: string) {
const result = await this.db.query<SnapshotRow>(
`SELECT version,
@@ -625,7 +879,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
const snapshot = {
version: SAVE_SNAPSHOT_VERSION,
savedAt: payload.savedAt,
gameState: payload.gameState,
gameState: syncSnapshotCustomWorldProfile(payload.gameState),
bottomTab: payload.bottomTab,
currentStory: payload.currentStory,
} satisfies SavedSnapshot;
@@ -668,6 +922,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
} satisfies SavedSnapshot;
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot);
return persistedSnapshot;
}