import { randomUUID } from 'node:crypto'; import type { QueryResultRow } from 'pg'; import type { CustomWorldProfileRecord, PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, ProfileSaveArchiveSummary, ProfileWalletLedgerEntry, RuntimeSettings, SavedGameSnapshot, } from '../../../packages/shared/src/contracts/runtime.js'; import { type CustomWorldGalleryCard, type CustomWorldLibraryEntry, type CustomWorldPublicationStatus, type CustomWorldSessionRecord, DEFAULT_MUSIC_VOLUME, DEFAULT_PLATFORM_THEME, SAVE_SNAPSHOT_VERSION, } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppDatabase } from '../db.js'; import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; import { RpgAgentSessionRepository } from './RpgAgentSessionRepository.js'; import { RpgWorldProfileRepository } from './RpgWorldProfileRepository.js'; import { normalizeStoredRpgWorldProfile } from './rpgWorldRepositoryShared.js'; export type SavedSnapshot = SavedGameSnapshot; type SnapshotRow = QueryResultRow & { version: number; savedAt: string; gameState: unknown; bottomTab: string; currentStory: unknown; }; type SettingsRow = QueryResultRow & { musicVolume: number; platformTheme: RuntimeSettings['platformTheme']; }; type PlatformBrowseHistoryRow = QueryResultRow & { ownerUserId: string; profileId: string; worldName: string; subtitle: string; summaryText: string; coverImageSrc: string | null; themeMode: PlatformBrowseHistoryEntry['themeMode']; authorDisplayName: string; visitedAt: string; }; type ProfileDashboardStateRow = QueryResultRow & { walletBalance: number; totalPlayTimeMs: number | string; updatedAt: string; }; type ProfileWalletLedgerRow = QueryResultRow & { id: string; amountDelta: number; balanceAfter: number; sourceType: ProfileWalletLedgerEntry['sourceType']; createdAt: string; }; type ProfilePlayedWorldRow = QueryResultRow & { worldKey: string; ownerUserId: string | null; profileId: string | null; worldType: string | null; worldTitle: string; worldSubtitle: string; firstPlayedAt: string; lastPlayedAt: string; lastObservedPlayTimeMs: number | string; }; type ProfileWorldSnapshotMeta = { worldKey: string; ownerUserId: string | null; profileId: string | null; worldType: string | null; worldTitle: string; worldSubtitle: string; }; type ProfileSaveArchiveRow = QueryResultRow & { worldKey: string; ownerUserId: string | null; profileId: string | null; worldType: string | null; worldName: string; subtitle: string; summaryText: string; coverImageSrc: string | null; savedAt: string; bottomTab: string; gameState: unknown; currentStory: unknown; }; type ProfileSaveArchiveMeta = Omit; export type RuntimeRepositoryPort = { getSnapshot(userId: string): Promise; putSnapshot( userId: string, payload: Omit, ): Promise; getProfileDashboard(userId: string): Promise; listProfileWalletLedger(userId: string): Promise; getProfilePlayStats(userId: string): Promise; listProfileSaveArchives(userId: string): Promise; resumeProfileSaveArchive( userId: string, worldKey: string, ): Promise<{ entry: ProfileSaveArchiveSummary; snapshot: SavedSnapshot; } | null>; deleteSnapshot(userId: string): Promise; getSettings(userId: string): Promise; putSettings( userId: string, settings: RuntimeSettings, ): Promise; listCustomWorldProfiles( userId: string, ): Promise[]>; listPlatformBrowseHistory( userId: string, ): Promise; upsertPlatformBrowseHistoryEntries( userId: string, entries: PlatformBrowseHistoryWriteEntry[], ): Promise; clearPlatformBrowseHistory(userId: string): Promise; upsertCustomWorldProfile( userId: string, profileId: string, profile: Record, authorDisplayName: string, ): Promise<{ entry: CustomWorldLibraryEntry; entries: CustomWorldLibraryEntry[]; }>; deleteCustomWorldProfile( userId: string, profileId: string, ): Promise[]>; listCustomWorldSessions(userId: string): Promise; getCustomWorldSession( userId: string, sessionId: string, ): Promise; upsertCustomWorldSession( userId: string, sessionId: string, session: CustomWorldSessionRecord, ): Promise; publishCustomWorldProfile( userId: string, profileId: string, authorDisplayName: string, ): Promise<{ entry: CustomWorldLibraryEntry; entries: CustomWorldLibraryEntry[]; } | null>; unpublishCustomWorldProfile( userId: string, profileId: string, authorDisplayName: string, ): Promise<{ entry: CustomWorldLibraryEntry; entries: CustomWorldLibraryEntry[]; } | null>; listPublishedCustomWorldGallery(): Promise; getPublishedCustomWorldGalleryDetail( ownerUserId: string, profileId: string, ): Promise | null>; }; function toPlatformBrowseHistoryEntry( row: PlatformBrowseHistoryRow, ): PlatformBrowseHistoryEntry { return { ownerUserId: row.ownerUserId, profileId: row.profileId, worldName: row.worldName || '未命名世界', subtitle: row.subtitle || '', summaryText: row.summaryText || '', coverImageSrc: row.coverImageSrc || null, themeMode: row.themeMode || 'mythic', authorDisplayName: row.authorDisplayName || '玩家', visitedAt: row.visitedAt, }; } function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function readString(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } function normalizePlatformBrowseHistoryWriteEntry( entry: PlatformBrowseHistoryWriteEntry, ): PlatformBrowseHistoryEntry | null { const ownerUserId = readString(entry.ownerUserId); const profileId = readString(entry.profileId); const worldName = readString(entry.worldName); if (!ownerUserId || !profileId || !worldName) { return null; } const visitedAt = readString(entry.visitedAt) || new Date().toISOString(); return { ownerUserId, profileId, worldName, subtitle: readString(entry.subtitle), summaryText: readString(entry.summaryText), coverImageSrc: readString(entry.coverImageSrc) || null, themeMode: (readString( entry.themeMode, ) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic', authorDisplayName: readString(entry.authorDisplayName) || '玩家', visitedAt, }; } function readSavedStoryText(value: unknown) { return readString(asRecord(value)?.text); } function readFiniteNumber(value: unknown) { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string' && value.trim()) { const parsedValue = Number(value); return Number.isFinite(parsedValue) ? parsedValue : 0; } return 0; } function normalizeDashboardNumber(value: unknown) { return Math.max(0, Math.round(readFiniteNumber(value))); } function buildBuiltinWorldTitle(worldType: string) { switch (worldType) { case 'WUXIA': return '武侠世界'; case 'XIANXIA': return '仙侠世界'; default: return '叙事世界'; } } function looksLikeGeneratedAssetPath(value: string) { return /^\/generated-/u.test(value); } function mergeSnapshotRoleAssets( role: Record, assets: { imageSrc?: string | null; generatedVisualAssetId?: string | null; generatedAnimationSetId?: string | null; animationMap?: Record | null; }, ) { let changed = false; const nextRole: Record = { ...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, roleId: string, assets: Parameters[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, 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 { const gameState = asRecord(snapshot.gameState); if (!gameState) { return null; } const customWorldProfile = asRecord(gameState.customWorldProfile); if (customWorldProfile) { const profileId = readString(customWorldProfile.id); const worldTitle = readString(customWorldProfile.name) || readString(customWorldProfile.title); if (profileId || worldTitle) { return { worldKey: profileId ? `custom:${profileId}` : `custom:${worldTitle}`, ownerUserId: null, profileId: profileId || null, worldType: 'CUSTOM', worldTitle: worldTitle || '自定义世界', worldSubtitle: readString(customWorldProfile.summary) || readString(customWorldProfile.settingText), }; } } const worldType = readString(gameState.worldType); if (!worldType) { return null; } const currentScenePreset = asRecord(gameState.currentScenePreset); const worldTitle = readString(currentScenePreset?.name) || buildBuiltinWorldTitle(worldType); return { worldKey: `builtin:${worldType}`, ownerUserId: null, profileId: null, worldType, worldTitle, worldSubtitle: readString(currentScenePreset?.summary) || readString(currentScenePreset?.description), }; } function toProfilePlayedWorkSummary( row: ProfilePlayedWorldRow, ): ProfilePlayedWorkSummary { return { worldKey: row.worldKey, ownerUserId: row.ownerUserId, profileId: row.profileId, worldType: row.worldType, worldTitle: row.worldTitle, worldSubtitle: row.worldSubtitle, firstPlayedAt: row.firstPlayedAt, lastPlayedAt: row.lastPlayedAt, lastObservedPlayTimeMs: normalizeDashboardNumber( row.lastObservedPlayTimeMs, ), }; } function toProfileSaveArchiveSummary( row: Pick< ProfileSaveArchiveRow, | 'worldKey' | 'ownerUserId' | 'profileId' | 'worldType' | 'worldName' | 'subtitle' | 'summaryText' | 'coverImageSrc' | 'savedAt' >, ): ProfileSaveArchiveSummary { const subtitle = row.subtitle || ''; return { worldKey: row.worldKey, ownerUserId: row.ownerUserId, profileId: row.profileId, worldType: row.worldType, worldName: row.worldName || '未命名游戏', subtitle, summaryText: row.summaryText || subtitle || '继续推进上一次保存的故事。', coverImageSrc: row.coverImageSrc || null, lastPlayedAt: row.savedAt, }; } function resolveProfileSaveArchiveMeta( snapshot: SavedSnapshot, ): ProfileSaveArchiveMeta | null { const worldMeta = resolveProfileWorldSnapshotMeta(snapshot); if (!worldMeta) { return null; } const gameState = asRecord(snapshot.gameState); const continueGameDigest = readString( asRecord(gameState?.storyEngineMemory)?.continueGameDigest, ); const currentStoryText = readSavedStoryText(snapshot.currentStory); const customWorldProfile = asRecord(gameState?.customWorldProfile); if (customWorldProfile) { const profileId = readString(customWorldProfile.id) || 'custom-world'; const metadata = extractCustomWorldLibraryMetadata( normalizeStoredRpgWorldProfile(profileId, customWorldProfile), ); return { worldKey: worldMeta.worldKey, ownerUserId: worldMeta.ownerUserId, profileId: worldMeta.profileId, worldType: worldMeta.worldType, worldName: worldMeta.worldTitle || metadata.worldName || '自定义世界', subtitle: metadata.subtitle || worldMeta.worldSubtitle || '', summaryText: continueGameDigest || currentStoryText || metadata.summaryText || worldMeta.worldSubtitle || '继续推进上一次保存的故事。', coverImageSrc: metadata.coverImageSrc, }; } const currentScenePreset = asRecord(gameState?.currentScenePreset); return { worldKey: worldMeta.worldKey, ownerUserId: worldMeta.ownerUserId, profileId: worldMeta.profileId, worldType: worldMeta.worldType, worldName: worldMeta.worldTitle || '未命名游戏', subtitle: worldMeta.worldSubtitle || '', summaryText: continueGameDigest || currentStoryText || worldMeta.worldSubtitle || '继续推进上一次保存的故事。', coverImageSrc: readString(currentScenePreset?.imageSrc) || null, }; } export class RuntimeRepository implements RuntimeRepositoryPort { private readonly rpgAgentSessionRepository: RpgAgentSessionRepository; private readonly rpgWorldProfileRepository: RpgWorldProfileRepository; constructor(private readonly db: AppDatabase) { this.rpgAgentSessionRepository = new RpgAgentSessionRepository(db); this.rpgWorldProfileRepository = new RpgWorldProfileRepository(db); } private async getProfileDashboardState(userId: string) { const result = await this.db.query( `SELECT wallet_balance AS "walletBalance", total_play_time_ms AS "totalPlayTimeMs", updated_at AS "updatedAt" FROM profile_dashboard_state WHERE user_id = $1`, [userId], ); return result.rows[0] ?? null; } private async findProfilePlayedWorld(userId: string, worldKey: string) { const result = await this.db.query( `SELECT world_key AS "worldKey", owner_user_id AS "ownerUserId", profile_id AS "profileId", world_type AS "worldType", world_title AS "worldTitle", world_subtitle AS "worldSubtitle", first_played_at AS "firstPlayedAt", last_played_at AS "lastPlayedAt", last_observed_play_time_ms AS "lastObservedPlayTimeMs" FROM profile_played_worlds WHERE user_id = $1 AND world_key = $2`, [userId, worldKey], ); return result.rows[0] ?? null; } private async findProfileSaveArchive(userId: string, worldKey: string) { const result = await this.db.query( `SELECT world_key AS "worldKey", owner_user_id AS "ownerUserId", profile_id AS "profileId", world_type AS "worldType", world_name AS "worldName", world_subtitle AS subtitle, summary_text AS "summaryText", cover_image_src AS "coverImageSrc", saved_at AS "savedAt", bottom_tab AS "bottomTab", game_state_json AS "gameState", current_story_json AS "currentStory" FROM profile_save_archives WHERE user_id = $1 AND world_key = $2`, [userId, worldKey], ); return result.rows[0] ?? null; } private async upsertProfileDashboardState( userId: string, state: { walletBalance: number; totalPlayTimeMs: number; updatedAt: string; }, ) { await this.db.query( `INSERT INTO profile_dashboard_state ( user_id, wallet_balance, total_play_time_ms, updated_at ) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id) DO UPDATE SET wallet_balance = EXCLUDED.wallet_balance, total_play_time_ms = EXCLUDED.total_play_time_ms, updated_at = EXCLUDED.updated_at`, [userId, state.walletBalance, state.totalPlayTimeMs, state.updatedAt], ); } private async upsertCurrentSnapshot( userId: string, snapshot: SavedSnapshot, ) { const now = new Date().toISOString(); const result = await this.db.query( `INSERT INTO save_snapshots ( user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id) DO UPDATE SET version = EXCLUDED.version, saved_at = EXCLUDED.saved_at, bottom_tab = EXCLUDED.bottom_tab, game_state_json = EXCLUDED.game_state_json, current_story_json = EXCLUDED.current_story_json, updated_at = EXCLUDED.updated_at RETURNING version, saved_at AS "savedAt", game_state_json AS "gameState", bottom_tab AS "bottomTab", current_story_json AS "currentStory"`, [ userId, snapshot.version, snapshot.savedAt, snapshot.bottomTab, snapshot.gameState, snapshot.currentStory, now, ], ); const row = result.rows[0]; return { version: row.version, savedAt: row.savedAt, gameState: row.gameState, bottomTab: row.bottomTab, currentStory: row.currentStory, } satisfies SavedSnapshot; } private async syncProfileDashboardFromSnapshot( userId: string, snapshot: SavedSnapshot, ) { const state = (await this.getProfileDashboardState(userId)) ?? { walletBalance: 0, totalPlayTimeMs: 0, updatedAt: snapshot.savedAt, }; const syncedAt = snapshot.savedAt || new Date().toISOString(); const gameState = asRecord(snapshot.gameState); const nextWalletBalance = normalizeDashboardNumber( gameState?.playerCurrency, ); let nextTotalPlayTimeMs = normalizeDashboardNumber(state.totalPlayTimeMs); if (nextWalletBalance !== state.walletBalance) { const amountDelta = nextWalletBalance - state.walletBalance; await this.db.query( `INSERT INTO profile_wallet_ledger ( id, user_id, amount_delta, balance_after, source_type, source_key, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, source_key) DO NOTHING`, [ randomUUID(), userId, amountDelta, nextWalletBalance, 'snapshot_sync', `snapshot:${syncedAt}:wallet:${nextWalletBalance}`, syncedAt, ], ); } const worldMeta = resolveProfileWorldSnapshotMeta(snapshot); if (worldMeta) { const currentPlayTimeMs = normalizeDashboardNumber( asRecord(gameState?.runtimeStats)?.playTimeMs, ); const currentWorld = await this.findProfilePlayedWorld( userId, worldMeta.worldKey, ); const incrementalPlayTimeMs = Math.max( 0, currentPlayTimeMs - normalizeDashboardNumber(currentWorld?.lastObservedPlayTimeMs ?? 0), ); nextTotalPlayTimeMs += incrementalPlayTimeMs; await this.db.query( `INSERT INTO profile_played_worlds ( user_id, world_key, owner_user_id, profile_id, world_type, world_title, world_subtitle, first_played_at, last_played_at, last_observed_play_time_ms ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $9) ON CONFLICT (user_id, world_key) DO UPDATE SET owner_user_id = EXCLUDED.owner_user_id, profile_id = EXCLUDED.profile_id, world_type = EXCLUDED.world_type, world_title = EXCLUDED.world_title, world_subtitle = EXCLUDED.world_subtitle, last_played_at = EXCLUDED.last_played_at, last_observed_play_time_ms = GREATEST( profile_played_worlds.last_observed_play_time_ms, EXCLUDED.last_observed_play_time_ms )`, [ userId, worldMeta.worldKey, worldMeta.ownerUserId, worldMeta.profileId, worldMeta.worldType, worldMeta.worldTitle, worldMeta.worldSubtitle, syncedAt, currentPlayTimeMs, ], ); } await this.upsertProfileDashboardState(userId, { walletBalance: nextWalletBalance, totalPlayTimeMs: nextTotalPlayTimeMs, updatedAt: syncedAt, }); } private async syncProfileSaveArchiveFromSnapshot( userId: string, snapshot: SavedSnapshot, ) { const archiveMeta = resolveProfileSaveArchiveMeta(snapshot); if (!archiveMeta) { return; } const syncedAt = snapshot.savedAt || new Date().toISOString(); await this.db.query( `INSERT INTO profile_save_archives ( user_id, world_key, owner_user_id, profile_id, world_type, world_name, world_subtitle, summary_text, cover_image_src, saved_at, bottom_tab, game_state_json, current_story_json, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (user_id, world_key) DO UPDATE SET owner_user_id = EXCLUDED.owner_user_id, profile_id = EXCLUDED.profile_id, world_type = EXCLUDED.world_type, world_name = EXCLUDED.world_name, world_subtitle = EXCLUDED.world_subtitle, summary_text = EXCLUDED.summary_text, cover_image_src = EXCLUDED.cover_image_src, saved_at = EXCLUDED.saved_at, bottom_tab = EXCLUDED.bottom_tab, game_state_json = EXCLUDED.game_state_json, current_story_json = EXCLUDED.current_story_json, updated_at = EXCLUDED.updated_at`, [ userId, archiveMeta.worldKey, archiveMeta.ownerUserId, archiveMeta.profileId, archiveMeta.worldType, archiveMeta.worldName, archiveMeta.subtitle, archiveMeta.summaryText, archiveMeta.coverImageSrc, syncedAt, snapshot.bottomTab, snapshot.gameState, snapshot.currentStory, syncedAt, ], ); } 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 syncedAt = snapshot.savedAt || new Date().toISOString(); await this.rpgWorldProfileRepository.syncProfileFromSnapshot( userId, profileId, customWorldProfile, syncedAt, ); } async getSnapshot(userId: string) { const result = await this.db.query( `SELECT version, saved_at AS "savedAt", game_state_json AS "gameState", bottom_tab AS "bottomTab", current_story_json AS "currentStory" FROM save_snapshots WHERE user_id = $1`, [userId], ); const row = result.rows[0]; if (!row) { return null; } return { version: row.version, savedAt: row.savedAt, gameState: row.gameState, bottomTab: row.bottomTab, currentStory: row.currentStory, } satisfies SavedSnapshot; } async putSnapshot(userId: string, payload: Omit) { const snapshot = { version: SAVE_SNAPSHOT_VERSION, savedAt: payload.savedAt, gameState: syncSnapshotCustomWorldProfile(payload.gameState), bottomTab: payload.bottomTab, currentStory: payload.currentStory, } satisfies SavedSnapshot; const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot); await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot); await this.syncProfileSaveArchiveFromSnapshot(userId, persistedSnapshot); await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot); return persistedSnapshot; } async getProfileDashboard(userId: string) { const state = await this.getProfileDashboardState(userId); const playedWorldsResult = await this.db.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM profile_played_worlds WHERE user_id = $1`, [userId], ); return { walletBalance: normalizeDashboardNumber(state?.walletBalance ?? 0), totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0), playedWorldCount: Number.parseInt(playedWorldsResult.rows[0]?.count ?? '0', 10) || 0, updatedAt: state?.updatedAt ?? null, } satisfies ProfileDashboardSummary; } async listProfileWalletLedger(userId: string) { const result = await this.db.query( `SELECT id, amount_delta AS "amountDelta", balance_after AS "balanceAfter", source_type AS "sourceType", created_at AS "createdAt" FROM profile_wallet_ledger WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50`, [userId], ); return result.rows.map((row) => ({ id: row.id, amountDelta: row.amountDelta, balanceAfter: row.balanceAfter, sourceType: row.sourceType, createdAt: row.createdAt, })); } async getProfilePlayStats(userId: string) { const state = await this.getProfileDashboardState(userId); const result = await this.db.query( `SELECT world_key AS "worldKey", owner_user_id AS "ownerUserId", profile_id AS "profileId", world_type AS "worldType", world_title AS "worldTitle", world_subtitle AS "worldSubtitle", first_played_at AS "firstPlayedAt", last_played_at AS "lastPlayedAt", last_observed_play_time_ms AS "lastObservedPlayTimeMs" FROM profile_played_worlds WHERE user_id = $1 ORDER BY last_played_at DESC`, [userId], ); return { totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0), playedWorks: result.rows.map((row) => toProfilePlayedWorkSummary(row)), updatedAt: state?.updatedAt ?? null, } satisfies ProfilePlayStatsResponse; } async listProfileSaveArchives(userId: string) { const result = await this.db.query( `SELECT world_key AS "worldKey", owner_user_id AS "ownerUserId", profile_id AS "profileId", world_type AS "worldType", world_name AS "worldName", world_subtitle AS subtitle, summary_text AS "summaryText", cover_image_src AS "coverImageSrc", saved_at AS "savedAt", bottom_tab AS "bottomTab", game_state_json AS "gameState", current_story_json AS "currentStory" FROM profile_save_archives WHERE user_id = $1 ORDER BY saved_at DESC`, [userId], ); return result.rows.map((row) => toProfileSaveArchiveSummary(row)); } async resumeProfileSaveArchive(userId: string, worldKey: string) { const archive = await this.findProfileSaveArchive(userId, worldKey); if (!archive) { return null; } const snapshot = { version: SAVE_SNAPSHOT_VERSION, savedAt: archive.savedAt, gameState: archive.gameState, bottomTab: archive.bottomTab, currentStory: archive.currentStory, } satisfies SavedSnapshot; const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot); return { entry: toProfileSaveArchiveSummary(archive), snapshot: persistedSnapshot, }; } async deleteSnapshot(userId: string) { await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [ userId, ]); } async getSettings(userId: string) { const result = await this.db.query( `SELECT music_volume AS "musicVolume", platform_theme AS "platformTheme" FROM runtime_settings WHERE user_id = $1`, [userId], ); const row = result.rows[0]; return { musicVolume: typeof row?.musicVolume === 'number' ? row.musicVolume : DEFAULT_MUSIC_VOLUME, platformTheme: row?.platformTheme === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME, } satisfies RuntimeSettings; } async putSettings(userId: string, settings: RuntimeSettings) { const nextSettings = { musicVolume: Math.max(0, Math.min(1, settings.musicVolume)), platformTheme: settings.platformTheme === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME, } satisfies RuntimeSettings; const result = await this.db.query( `INSERT INTO runtime_settings (user_id, music_volume, platform_theme, updated_at) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id) DO UPDATE SET music_volume = EXCLUDED.music_volume, platform_theme = EXCLUDED.platform_theme, updated_at = EXCLUDED.updated_at RETURNING music_volume AS "musicVolume", platform_theme AS "platformTheme"`, [ userId, nextSettings.musicVolume, nextSettings.platformTheme, new Date().toISOString(), ], ); return { musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume, platformTheme: result.rows[0]?.platformTheme ?? nextSettings.platformTheme, } satisfies RuntimeSettings; } async listPlatformBrowseHistory(userId: string) { const result = await this.db.query( `SELECT owner_user_id AS "ownerUserId", profile_id AS "profileId", world_name AS "worldName", subtitle, summary_text AS "summaryText", cover_image_src AS "coverImageSrc", theme_mode AS "themeMode", author_display_name AS "authorDisplayName", visited_at AS "visitedAt" FROM user_browse_history WHERE user_id = $1 ORDER BY visited_at DESC`, [userId], ); return result.rows.map((row) => toPlatformBrowseHistoryEntry(row)); } async upsertPlatformBrowseHistoryEntries( userId: string, entries: PlatformBrowseHistoryWriteEntry[], ) { const dedupedEntries = [ ...new Map( entries .map((entry) => normalizePlatformBrowseHistoryWriteEntry(entry)) .filter((entry): entry is PlatformBrowseHistoryEntry => Boolean(entry), ) .sort( (left, right) => new Date(right.visitedAt).getTime() - new Date(left.visitedAt).getTime(), ) .map( (entry) => [`${entry.ownerUserId}:${entry.profileId}`, entry] as const, ), ).values(), ]; for (const entry of dedupedEntries) { await this.db.query( `INSERT INTO user_browse_history ( user_id, owner_user_id, profile_id, world_name, subtitle, summary_text, cover_image_src, theme_mode, author_display_name, visited_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (user_id, owner_user_id, profile_id) DO UPDATE SET 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, author_display_name = EXCLUDED.author_display_name, visited_at = EXCLUDED.visited_at`, [ userId, entry.ownerUserId, entry.profileId, entry.worldName, entry.subtitle, entry.summaryText, entry.coverImageSrc, entry.themeMode, entry.authorDisplayName, entry.visitedAt, ], ); } return this.listPlatformBrowseHistory(userId); } async clearPlatformBrowseHistory(userId: string) { await this.db.query(`DELETE FROM user_browse_history WHERE user_id = $1`, [ userId, ]); } async listCustomWorldProfiles(userId: string) { return this.rpgWorldProfileRepository.listOwnProfiles(userId); } async upsertCustomWorldProfile( userId: string, profileId: string, profile: Record, authorDisplayName: string, ) { return this.rpgWorldProfileRepository.upsertOwnProfile( userId, profileId, profile, authorDisplayName, ); } async deleteCustomWorldProfile(userId: string, profileId: string) { return this.rpgWorldProfileRepository.softDeleteOwnProfile( userId, profileId, ); } async listCustomWorldSessions(userId: string) { return this.rpgAgentSessionRepository.listSessions(userId); } async getCustomWorldSession(userId: string, sessionId: string) { return this.rpgAgentSessionRepository.getSession(userId, sessionId); } async upsertCustomWorldSession( userId: string, sessionId: string, session: CustomWorldSessionRecord, ) { return this.rpgAgentSessionRepository.upsertSession( userId, sessionId, session, ); } async publishCustomWorldProfile( userId: string, profileId: string, authorDisplayName: string, ) { return this.rpgWorldProfileRepository.publishOwnProfile( userId, profileId, authorDisplayName, ); } async unpublishCustomWorldProfile( userId: string, profileId: string, authorDisplayName: string, ) { return this.rpgWorldProfileRepository.unpublishOwnProfile( userId, profileId, authorDisplayName, ); } async listPublishedCustomWorldGallery() { return this.rpgWorldProfileRepository.listPublishedGallery(); } async getPublishedCustomWorldGalleryDetail( ownerUserId: string, profileId: string, ) { return this.rpgWorldProfileRepository.getPublishedGalleryDetail( ownerUserId, profileId, ); } }