Files
Genarrative/server-node/src/repositories/runtimeRepository.ts

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;
}
}