477 lines
14 KiB
TypeScript
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 };
|