1480 lines
43 KiB
TypeScript
1480 lines
43 KiB
TypeScript
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<unknown, string, unknown>;
|
|
|
|
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<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>;
|
|
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 normalizeStoredProfile(
|
|
profileId: string,
|
|
profile: Record<string, unknown>,
|
|
): CustomWorldProfileRecord {
|
|
return {
|
|
...profile,
|
|
id: profileId,
|
|
};
|
|
}
|
|
|
|
function toCustomWorldLibraryEntry(
|
|
row: CustomWorldEntryRow,
|
|
): CustomWorldLibraryEntry<CustomWorldProfileRecord> {
|
|
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<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 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,
|
|
),
|
|
};
|
|
}
|
|
|
|
export class RuntimeRepository implements RuntimeRepositoryPort {
|
|
constructor(private readonly db: AppDatabase) {}
|
|
|
|
private async findCustomWorldProfileEntry(userId: string, profileId: string) {
|
|
const result = await this.db.query<CustomWorldEntryRow>(
|
|
`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<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 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<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 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];
|
|
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<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 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"
|
|
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<SettingsRow>(
|
|
`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<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) {
|
|
const result = await this.db.query<CustomWorldEntryRow>(
|
|
`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<string, unknown>,
|
|
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<SessionRow>(
|
|
`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<SessionRow>(
|
|
`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<CustomWorldCardRow>(
|
|
`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<CustomWorldEntryRow>(
|
|
`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;
|
|
}
|
|
}
|