1321 lines
38 KiB
TypeScript
1321 lines
38 KiB
TypeScript
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<unknown, string, unknown>;
|
|
|
|
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<ProfileSaveArchiveSummary, 'lastPlayedAt'>;
|
|
|
|
export type RuntimeRepositoryPort = {
|
|
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
|
|
putSnapshot(
|
|
userId: string,
|
|
payload: Omit<SavedSnapshot, 'version'>,
|
|
): Promise<SavedSnapshot>;
|
|
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary>;
|
|
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]>;
|
|
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse>;
|
|
listProfileSaveArchives(userId: string): Promise<ProfileSaveArchiveSummary[]>;
|
|
resumeProfileSaveArchive(
|
|
userId: string,
|
|
worldKey: string,
|
|
): Promise<{
|
|
entry: ProfileSaveArchiveSummary;
|
|
snapshot: SavedSnapshot;
|
|
} | null>;
|
|
deleteSnapshot(userId: string): Promise<void>;
|
|
getSettings(userId: string): Promise<RuntimeSettings>;
|
|
putSettings(
|
|
userId: string,
|
|
settings: RuntimeSettings,
|
|
): Promise<RuntimeSettings>;
|
|
listCustomWorldProfiles(
|
|
userId: string,
|
|
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
|
listPlatformBrowseHistory(
|
|
userId: string,
|
|
): Promise<PlatformBrowseHistoryEntry[]>;
|
|
upsertPlatformBrowseHistoryEntries(
|
|
userId: string,
|
|
entries: PlatformBrowseHistoryWriteEntry[],
|
|
): Promise<PlatformBrowseHistoryEntry[]>;
|
|
clearPlatformBrowseHistory(userId: string): Promise<void>;
|
|
upsertCustomWorldProfile(
|
|
userId: string,
|
|
profileId: string,
|
|
profile: Record<string, unknown>,
|
|
authorDisplayName: string,
|
|
): Promise<{
|
|
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
|
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
|
}>;
|
|
deleteCustomWorldProfile(
|
|
userId: string,
|
|
profileId: string,
|
|
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
|
listCustomWorldSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
|
|
getCustomWorldSession(
|
|
userId: string,
|
|
sessionId: string,
|
|
): Promise<CustomWorldSessionRecord | null>;
|
|
upsertCustomWorldSession(
|
|
userId: string,
|
|
sessionId: string,
|
|
session: CustomWorldSessionRecord,
|
|
): Promise<CustomWorldSessionRecord>;
|
|
publishCustomWorldProfile(
|
|
userId: string,
|
|
profileId: string,
|
|
authorDisplayName: string,
|
|
): Promise<{
|
|
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
|
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
|
} | null>;
|
|
unpublishCustomWorldProfile(
|
|
userId: string,
|
|
profileId: string,
|
|
authorDisplayName: string,
|
|
): Promise<{
|
|
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
|
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
|
} | null>;
|
|
listPublishedCustomWorldGallery(): Promise<CustomWorldGalleryCard[]>;
|
|
getPublishedCustomWorldGalleryDetail(
|
|
ownerUserId: string,
|
|
profileId: string,
|
|
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | 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<string, unknown> | null {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: 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<string, unknown>,
|
|
assets: {
|
|
imageSrc?: string | null;
|
|
generatedVisualAssetId?: string | null;
|
|
generatedAnimationSetId?: string | null;
|
|
animationMap?: Record<string, unknown> | null;
|
|
},
|
|
) {
|
|
let changed = false;
|
|
const nextRole: Record<string, unknown> = { ...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<string, unknown>,
|
|
roleId: string,
|
|
assets: Parameters<typeof mergeSnapshotRoleAssets>[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<string, unknown>,
|
|
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<ProfileDashboardStateRow>(
|
|
`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<ProfilePlayedWorldRow>(
|
|
`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<ProfileSaveArchiveRow>(
|
|
`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<SnapshotRow>(
|
|
`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<SnapshotRow>(
|
|
`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<SavedSnapshot, 'version'>) {
|
|
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<ProfileWalletLedgerRow>(
|
|
`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<ProfilePlayedWorldRow>(
|
|
`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<ProfileSaveArchiveRow>(
|
|
`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<SettingsRow>(
|
|
`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<SettingsRow>(
|
|
`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<PlatformBrowseHistoryRow>(
|
|
`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<string, unknown>,
|
|
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,
|
|
);
|
|
}
|
|
}
|