import type { ListCustomWorldWorksResponse } from '../../packages/shared/src/contracts/customWorldAgent'; import type { BasicOkResult, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntry, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayStatsResponse, ProfileWalletLedgerResponse, RuntimeSettings, } from '../../packages/shared/src/contracts/runtime'; import type { SavedGameSnapshotInput } from '../persistence/gameSaveStorage'; import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes'; import type { CustomWorldProfile } from '../types'; import { ensureSpacetimeConnection } from '../spacetime/client'; import { mapBrowseHistoryEntry, mapCustomWorldLibraryEntry, mapCustomWorldSession, mapGalleryCard, mapPlayedWorldEntry, mapProfileDashboard, mapPublishedProfile, mapRuntimeSettings, mapSnapshotRow, mapWalletLedgerEntry, } from '../spacetime/mappers'; const DEFAULT_MUSIC_VOLUME = 0.42; export type RuntimeRequestOptions = { signal?: AbortSignal; }; function toBigIntMs(isoValue?: string) { if (!isoValue) { return 0n; } const ms = Date.parse(isoValue); return Number.isFinite(ms) ? BigInt(ms) : 0n; } function buildRequestMeta() { return { clientType: 'web', userAgent: typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null, ip: null, }; } function mapThemeModeInput( themeMode: PlatformBrowseHistoryWriteEntry['themeMode'], ) { switch (themeMode) { case 'martial': return { tag: 'Martial' } as const; case 'arcane': return { tag: 'Arcane' } as const; case 'machina': return { tag: 'Machina' } as const; case 'tide': return { tag: 'Tide' } as const; case 'rift': return { tag: 'Rift' } as const; default: return { tag: 'Mythic' } as const; } } async function waitForSnapshot(timeoutMs = 1200) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const connection = await ensureSpacetimeConnection(); const snapshot = Array.from(connection.db.my_snapshot.iter())[0]; if (snapshot) { return snapshot; } await new Promise((resolve) => window.setTimeout(resolve, 40)); } throw new Error('远端存档同步超时'); } async function waitForRuntimeSettings(timeoutMs = 1200) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const connection = await ensureSpacetimeConnection(); const row = Array.from(connection.db.my_runtime_settings.iter())[0]; if (row) { return row; } await new Promise((resolve) => window.setTimeout(resolve, 40)); } return null; } export async function getSaveSnapshot(_options: RuntimeRequestOptions = {}) { const connection = await ensureSpacetimeConnection(); const row = Array.from(connection.db.my_snapshot.iter())[0]; return row ? rehydrateSavedSnapshot(mapSnapshotRow(row) as HydratedSavedGameSnapshot) : null; } export async function putSaveSnapshot( snapshot: SavedGameSnapshotInput, _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.saveSnapshot({ meta: buildRequestMeta(), savedAtMs: toBigIntMs(snapshot.savedAt), gameStateJson: JSON.stringify(snapshot.gameState), bottomTab: snapshot.bottomTab, currentStoryJson: snapshot.currentStory === null || snapshot.currentStory === undefined ? null : JSON.stringify(snapshot.currentStory), }); if (!result.ok) { throw new Error(result.message || '保存存档失败'); } const row = await waitForSnapshot(); return rehydrateSavedSnapshot(mapSnapshotRow(row) as HydratedSavedGameSnapshot); } export async function deleteSaveSnapshot(_options: RuntimeRequestOptions = {}) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.deleteSnapshot({ meta: buildRequestMeta(), }); if (!result.ok) { throw new Error(result.message || '删除存档失败'); } return { ok: true, } satisfies BasicOkResult; } export async function getSettings(_options: RuntimeRequestOptions = {}) { const connection = await ensureSpacetimeConnection(); const row = Array.from(connection.db.my_runtime_settings.iter())[0] ?? null; return mapRuntimeSettings(row); } export async function getProfileDashboard(_options: RuntimeRequestOptions = {}) { const connection = await ensureSpacetimeConnection(); const row = Array.from(connection.db.my_profile_dashboard.iter())[0] ?? null; return mapProfileDashboard(row); } export async function getProfileWalletLedger( _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const entries = Array.from(connection.db.my_profile_wallet_ledger.iter()).map( mapWalletLedgerEntry, ); return { entries, } satisfies ProfileWalletLedgerResponse; } export async function getProfilePlayStats(_options: RuntimeRequestOptions = {}) { const connection = await ensureSpacetimeConnection(); const dashboard = mapProfileDashboard( Array.from(connection.db.my_profile_dashboard.iter())[0] ?? null, ); return { totalPlayTimeMs: dashboard.totalPlayTimeMs, playedWorks: Array.from(connection.db.my_profile_played_worlds.iter()).map( mapPlayedWorldEntry, ), updatedAt: dashboard.updatedAt, } satisfies ProfilePlayStatsResponse; } export async function putSettings( settings: RuntimeSettings, _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.putRuntimeSettings({ meta: buildRequestMeta(), musicVolume: settings.musicVolume, }); if (!result.ok) { throw new Error(result.message || '保存设置失败'); } const row = await waitForRuntimeSettings(); return row ? mapRuntimeSettings(row) : { musicVolume: DEFAULT_MUSIC_VOLUME }; } export async function listCustomWorldLibrary( _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); return Array.from(connection.db.my_custom_world_profiles.iter()).map( mapCustomWorldLibraryEntry, ); } export async function listCustomWorldWorks( _options: RuntimeRequestOptions = {}, ) { return { items: [], } satisfies ListCustomWorldWorksResponse; } export async function upsertCustomWorldProfile( profile: CustomWorldProfile, _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.upsertCustomWorldProfile({ meta: buildRequestMeta(), profileId: profile.id, payloadJson: JSON.stringify(profile), authorDisplayName: '玩家', }); if (!result.ok) { throw new Error(result.message || '保存自定义世界失败'); } const entries = await listCustomWorldLibrary(); const entry = entries.find((item) => item.profileId === profile.id) ?? mapCustomWorldLibraryEntry({ ownerUserId: '', profileId: profile.id, payloadJson: JSON.stringify(profile), visibility: { tag: 'Draft' }, publishedAtMs: null, updatedAtMs: BigInt(Date.now()), authorDisplayName: '玩家', worldName: profile.name, subtitle: profile.subtitle, summaryText: profile.summary, coverImageSrc: null, themeMode: { tag: 'Mythic' }, playableNpcCount: profile.playableNpcs.length, landmarkCount: profile.landmarks.length, }); return { entry, entries, } satisfies CustomWorldLibraryMutationResponse; } export async function deleteCustomWorldProfile( profileId: string, _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.deleteCustomWorldProfile({ meta: buildRequestMeta(), profileId, }); if (!result.ok) { throw new Error(result.message || '删除自定义世界失败'); } return listCustomWorldLibrary(); } export async function publishCustomWorldProfile( profileId: string, _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.publishCustomWorldProfile({ meta: buildRequestMeta(), profileId, authorDisplayName: '玩家', }); if (!result.ok) { throw new Error(result.message || '发布自定义世界失败'); } const entries = await listCustomWorldLibrary(); const entry = entries.find((item) => item.profileId === profileId); if (!entry) { throw new Error('发布后未找到自定义世界'); } return { entry, entries, } satisfies CustomWorldLibraryMutationResponse; } export async function unpublishCustomWorldProfile( profileId: string, _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.unpublishCustomWorldProfile({ meta: buildRequestMeta(), profileId, authorDisplayName: '玩家', }); if (!result.ok) { throw new Error(result.message || '下架自定义世界失败'); } const entries = await listCustomWorldLibrary(); const entry = entries.find((item) => item.profileId === profileId); if (!entry) { throw new Error('下架后未找到自定义世界'); } return { entry, entries, } satisfies CustomWorldLibraryMutationResponse; } export async function listCustomWorldGallery( _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); return Array.from(connection.db.published_custom_world_gallery.iter()).map( mapGalleryCard, ); } export async function getCustomWorldGalleryDetail( ownerUserId: string, profileId: string, _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const entry = Array.from(connection.db.published_custom_world_profiles.iter()) .map(mapPublishedProfile) .find( (row) => row.ownerUserId === ownerUserId && row.profileId === profileId, ); if (!entry) { throw new Error('读取作品详情失败'); } return entry satisfies CustomWorldGalleryDetailResponse['entry']; } export async function listProfileBrowseHistory( _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); return Array.from(connection.db.my_browse_history.iter()).map( mapBrowseHistoryEntry, ); } export async function upsertProfileBrowseHistory( entry: PlatformBrowseHistoryWriteEntry, _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.upsertPlatformBrowseHistory({ meta: buildRequestMeta(), entries: [ { ownerUserId: entry.ownerUserId, profileId: entry.profileId, worldName: entry.worldName, subtitle: entry.subtitle, summaryText: entry.summaryText, coverImageSrc: entry.coverImageSrc, themeMode: mapThemeModeInput(entry.themeMode), authorDisplayName: entry.authorDisplayName, visitedAtMs: entry.visitedAt ? toBigIntMs(entry.visitedAt) : 0n, }, ], }); if (!result.ok) { throw new Error(result.message || '写入浏览历史失败'); } return listProfileBrowseHistory(); } export async function syncProfileBrowseHistory( entries: PlatformBrowseHistoryWriteEntry[], _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.upsertPlatformBrowseHistory({ meta: buildRequestMeta(), entries: entries.map((entry) => ({ ownerUserId: entry.ownerUserId, profileId: entry.profileId, worldName: entry.worldName, subtitle: entry.subtitle, summaryText: entry.summaryText, coverImageSrc: entry.coverImageSrc, themeMode: mapThemeModeInput(entry.themeMode), authorDisplayName: entry.authorDisplayName, visitedAtMs: entry.visitedAt ? toBigIntMs(entry.visitedAt) : 0n, })), }); if (!result.ok) { throw new Error(result.message || '同步浏览历史失败'); } return listProfileBrowseHistory(); } export async function clearProfileBrowseHistory( _options: RuntimeRequestOptions = {}, ) { const connection = await ensureSpacetimeConnection(); const result = await connection.procedures.clearPlatformBrowseHistory({ meta: buildRequestMeta(), }); if (!result.ok) { throw new Error(result.message || '清空浏览历史失败'); } return [] satisfies PlatformBrowseHistoryEntry[]; } async function listCustomWorldSessions() { const connection = await ensureSpacetimeConnection(); return Array.from(connection.db.my_custom_world_sessions.iter()).map( mapCustomWorldSession, ); } export const runtimeStorageClient = { getSaveSnapshot, putSaveSnapshot, deleteSaveSnapshot, getSettings, putSettings, getProfileDashboard, getProfileWalletLedger, getProfilePlayStats, listCustomWorldLibrary, listCustomWorldWorks, upsertCustomWorldProfile, deleteCustomWorldProfile, publishCustomWorldProfile, unpublishCustomWorldProfile, listCustomWorldGallery, getCustomWorldGalleryDetail, listProfileBrowseHistory, upsertProfileBrowseHistory, syncProfileBrowseHistory, clearProfileBrowseHistory, }; export type { CustomWorldLibraryEntry }; export type { PlatformBrowseHistoryEntry };