import { randomUUID } from 'node:crypto'; import type { QueryResultRow } from 'pg'; import type { CustomWorldProfileRecord, PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, ProfileWalletLedgerEntry, RuntimeSettings, SavedGameSnapshot, } from '../../../packages/shared/src/contracts/runtime.js'; import { type CustomWorldGalleryCard, type CustomWorldLibraryEntry, type CustomWorldPublicationStatus, type CustomWorldSessionRecord, DEFAULT_MUSIC_VOLUME, SAVE_SNAPSHOT_VERSION, } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppDatabase } from '../db.js'; import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; const MAX_CUSTOM_WORLD_PROFILES = 12; const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36; export type SavedSnapshot = SavedGameSnapshot; type SnapshotRow = QueryResultRow & { version: number; savedAt: string; gameState: unknown; bottomTab: string; currentStory: unknown; }; type SettingsRow = QueryResultRow & { musicVolume: number; }; type CustomWorldEntryRow = QueryResultRow & { ownerUserId: string; profileId: string; payload: CustomWorldProfileRecord; visibility: CustomWorldPublicationStatus; publishedAt: string | null; updatedAt: string; authorDisplayName: string; worldName: string; subtitle: string; summaryText: string; coverImageSrc: string | null; themeMode: CustomWorldLibraryEntry['themeMode']; playableNpcCount: number; landmarkCount: number; }; type SessionRow = QueryResultRow & { payload: CustomWorldSessionRecord; createdAt: string; updatedAt: string; }; type CustomWorldCardRow = QueryResultRow & { ownerUserId: string; profileId: string; visibility: CustomWorldPublicationStatus; publishedAt: string | null; updatedAt: string; authorDisplayName: string; worldName: string; subtitle: string; summaryText: string; coverImageSrc: string | null; themeMode: CustomWorldGalleryCard['themeMode']; playableNpcCount: number; landmarkCount: number; }; 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; }; 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; 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 normalizeStoredProfile( profileId: string, profile: Record, ): CustomWorldProfileRecord { return { ...profile, id: profileId, }; } function toCustomWorldLibraryEntry( row: CustomWorldEntryRow, ): CustomWorldLibraryEntry { const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload); return { ownerUserId: row.ownerUserId, profileId: row.profileId, profile: row.payload, visibility: row.visibility, publishedAt: row.publishedAt, updatedAt: row.updatedAt, authorDisplayName: row.authorDisplayName || '玩家', worldName: row.worldName || fallbackMetadata.worldName, subtitle: row.subtitle || fallbackMetadata.subtitle, summaryText: row.summaryText || fallbackMetadata.summaryText, coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc, themeMode: row.themeMode || fallbackMetadata.themeMode, playableNpcCount: row.playableNpcCount > 0 ? row.playableNpcCount : fallbackMetadata.playableNpcCount, landmarkCount: row.landmarkCount > 0 ? row.landmarkCount : fallbackMetadata.landmarkCount, }; } function toCustomWorldGalleryCard( row: CustomWorldCardRow, ): CustomWorldGalleryCard { return { ownerUserId: row.ownerUserId, profileId: row.profileId, visibility: row.visibility, publishedAt: row.publishedAt, updatedAt: row.updatedAt, authorDisplayName: row.authorDisplayName || '玩家', worldName: row.worldName || '未命名世界', subtitle: row.subtitle || '', summaryText: row.summaryText || '', coverImageSrc: row.coverImageSrc || null, themeMode: row.themeMode || 'mythic', playableNpcCount: row.playableNpcCount, landmarkCount: row.landmarkCount, }; } 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 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, ), }; } export class RuntimeRepository implements RuntimeRepositoryPort { constructor(private readonly db: AppDatabase) {} private async findCustomWorldProfileEntry(userId: string, profileId: string) { const result = await this.db.query( `SELECT user_id AS "ownerUserId", profile_id AS "profileId", payload_json AS payload, visibility, published_at AS "publishedAt", updated_at AS "updatedAt", author_display_name AS "authorDisplayName", world_name AS "worldName", subtitle, summary_text AS "summaryText", cover_image_src AS "coverImageSrc", theme_mode AS "themeMode", playable_npc_count AS "playableNpcCount", landmark_count AS "landmarkCount" FROM custom_world_profiles WHERE user_id = $1 AND profile_id = $2 AND deleted_at IS NULL`, [userId, profileId], ); const row = result.rows[0]; return row ? toCustomWorldLibraryEntry(row) : null; } 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 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 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 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( `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 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]; const persistedSnapshot = { version: row.version, savedAt: row.savedAt, gameState: row.gameState, bottomTab: row.bottomTab, currentStory: row.currentStory, } satisfies SavedSnapshot; await this.syncProfileDashboardFromSnapshot(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 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" FROM runtime_settings WHERE user_id = $1`, [userId], ); const row = result.rows[0]; return { musicVolume: typeof row?.musicVolume === 'number' ? row.musicVolume : DEFAULT_MUSIC_VOLUME, } satisfies RuntimeSettings; } async putSettings(userId: string, settings: RuntimeSettings) { const nextSettings = { musicVolume: Math.max(0, Math.min(1, settings.musicVolume)), } satisfies RuntimeSettings; const result = await this.db.query( `INSERT INTO runtime_settings (user_id, music_volume, updated_at) VALUES ($1, $2, $3) ON CONFLICT (user_id) DO UPDATE SET music_volume = EXCLUDED.music_volume, updated_at = EXCLUDED.updated_at RETURNING music_volume AS "musicVolume"`, [userId, nextSettings.musicVolume, new Date().toISOString()], ); return { musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume, } 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) { const result = await this.db.query( `SELECT user_id AS "ownerUserId", profile_id AS "profileId", payload_json AS payload, visibility, published_at AS "publishedAt", updated_at AS "updatedAt", author_display_name AS "authorDisplayName", world_name AS "worldName", subtitle, summary_text AS "summaryText", cover_image_src AS "coverImageSrc", theme_mode AS "themeMode", playable_npc_count AS "playableNpcCount", landmark_count AS "landmarkCount" FROM custom_world_profiles WHERE user_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT $2`, [userId, MAX_CUSTOM_WORLD_PROFILES], ); return result.rows.map((row) => toCustomWorldLibraryEntry(row)); } async upsertCustomWorldProfile( userId: string, profileId: string, profile: Record, authorDisplayName: string, ) { const payload = normalizeStoredProfile(profileId, profile); const metadata = extractCustomWorldLibraryMetadata(payload); const now = 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 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (user_id, profile_id) DO UPDATE SET payload_json = EXCLUDED.payload_json, updated_at = EXCLUDED.updated_at, deleted_at = NULL, author_display_name = EXCLUDED.author_display_name, 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, now, authorDisplayName || '玩家', metadata.worldName, metadata.subtitle, metadata.summaryText, metadata.coverImageSrc, metadata.themeMode, metadata.playableNpcCount, metadata.landmarkCount, ], ); const entry = await this.findCustomWorldProfileEntry(userId, profileId); if (!entry) { throw new Error('failed to resolve custom world after upsert'); } return { entry, entries: await this.listCustomWorldProfiles(userId), }; } async deleteCustomWorldProfile(userId: string, profileId: string) { const deletedAt = new Date().toISOString(); await this.db.query( `UPDATE custom_world_profiles SET deleted_at = $1, updated_at = $1, visibility = 'draft', published_at = NULL WHERE user_id = $2 AND profile_id = $3 AND deleted_at IS NULL`, [deletedAt, userId, profileId], ); return this.listCustomWorldProfiles(userId); } async listCustomWorldSessions(userId: string) { const result = await this.db.query( `SELECT payload_json AS payload, created_at AS "createdAt", updated_at AS "updatedAt" FROM custom_world_sessions WHERE user_id = $1 ORDER BY updated_at DESC`, [userId], ); return result.rows.map((row) => ({ ...row.payload, createdAt: row.createdAt, updatedAt: row.updatedAt, })); } async getCustomWorldSession(userId: string, sessionId: string) { const result = await this.db.query( `SELECT payload_json AS payload, created_at AS "createdAt", updated_at AS "updatedAt" FROM custom_world_sessions WHERE user_id = $1 AND session_id = $2`, [userId, sessionId], ); const row = result.rows[0]; if (!row) { return null; } return { ...row.payload, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } async upsertCustomWorldSession( userId: string, sessionId: string, session: CustomWorldSessionRecord, ) { const payload = { ...session, sessionId, } satisfies CustomWorldSessionRecord; await this.db.query( `INSERT INTO custom_world_sessions ( user_id, session_id, payload_json, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, session_id) DO UPDATE SET payload_json = EXCLUDED.payload_json, updated_at = EXCLUDED.updated_at`, [userId, sessionId, payload, session.createdAt, session.updatedAt], ); return { ...payload, createdAt: session.createdAt, updatedAt: session.updatedAt, }; } async publishCustomWorldProfile( userId: string, profileId: string, authorDisplayName: string, ) { const existingEntry = await this.findCustomWorldProfileEntry( userId, profileId, ); if (!existingEntry) { return null; } const payload = normalizeStoredProfile(profileId, existingEntry.profile); const metadata = extractCustomWorldLibraryMetadata(payload); const now = new Date().toISOString(); await this.db.query( `UPDATE custom_world_profiles SET visibility = 'published', published_at = $1, updated_at = $1, author_display_name = $2, world_name = $3, subtitle = $4, summary_text = $5, cover_image_src = $6, theme_mode = $7, playable_npc_count = $8, landmark_count = $9 WHERE user_id = $10 AND profile_id = $11`, [ now, authorDisplayName || '玩家', metadata.worldName, metadata.subtitle, metadata.summaryText, metadata.coverImageSrc, metadata.themeMode, metadata.playableNpcCount, metadata.landmarkCount, userId, profileId, ], ); const entry = await this.findCustomWorldProfileEntry(userId, profileId); if (!entry) { throw new Error('failed to resolve custom world after publish'); } return { entry, entries: await this.listCustomWorldProfiles(userId), }; } async unpublishCustomWorldProfile( userId: string, profileId: string, authorDisplayName: string, ) { const existingEntry = await this.findCustomWorldProfileEntry( userId, profileId, ); if (!existingEntry) { return null; } const payload = normalizeStoredProfile(profileId, existingEntry.profile); const metadata = extractCustomWorldLibraryMetadata(payload); const now = new Date().toISOString(); await this.db.query( `UPDATE custom_world_profiles SET visibility = 'draft', published_at = NULL, updated_at = $1, author_display_name = $2, world_name = $3, subtitle = $4, summary_text = $5, cover_image_src = $6, theme_mode = $7, playable_npc_count = $8, landmark_count = $9 WHERE user_id = $10 AND profile_id = $11`, [ now, authorDisplayName || '玩家', metadata.worldName, metadata.subtitle, metadata.summaryText, metadata.coverImageSrc, metadata.themeMode, metadata.playableNpcCount, metadata.landmarkCount, userId, profileId, ], ); const entry = await this.findCustomWorldProfileEntry(userId, profileId); if (!entry) { throw new Error('failed to resolve custom world after unpublish'); } return { entry, entries: await this.listCustomWorldProfiles(userId), }; } async listPublishedCustomWorldGallery() { const result = await this.db.query( `SELECT user_id AS "ownerUserId", profile_id AS "profileId", visibility, published_at AS "publishedAt", updated_at AS "updatedAt", author_display_name AS "authorDisplayName", world_name AS "worldName", subtitle, summary_text AS "summaryText", cover_image_src AS "coverImageSrc", theme_mode AS "themeMode", playable_npc_count AS "playableNpcCount", landmark_count AS "landmarkCount" FROM custom_world_profiles WHERE visibility = 'published' AND deleted_at IS NULL ORDER BY published_at DESC, updated_at DESC LIMIT $1`, [MAX_PUBLIC_CUSTOM_WORLD_PROFILES], ); return result.rows.map((row) => toCustomWorldGalleryCard(row)); } async getPublishedCustomWorldGalleryDetail( ownerUserId: string, profileId: string, ) { const result = await this.db.query( `SELECT user_id AS "ownerUserId", profile_id AS "profileId", payload_json AS payload, visibility, published_at AS "publishedAt", updated_at AS "updatedAt", author_display_name AS "authorDisplayName", world_name AS "worldName", subtitle, summary_text AS "summaryText", cover_image_src AS "coverImageSrc", theme_mode AS "themeMode", playable_npc_count AS "playableNpcCount", landmark_count AS "landmarkCount" FROM custom_world_profiles WHERE user_id = $1 AND profile_id = $2 AND visibility = 'published' AND deleted_at IS NULL`, [ownerUserId, profileId], ); const row = result.rows[0]; return row ? toCustomWorldLibraryEntry(row) : null; } }