Files
Genarrative/src/services/storageService.ts
2026-04-19 09:17:15 +00:00

477 lines
14 KiB
TypeScript

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<CustomWorldProfile>;
}
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<CustomWorldProfile>;
}
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<CustomWorldProfile>;
}
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<CustomWorldProfile>['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 };