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