This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

@@ -25,9 +25,9 @@ import {
} 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;
import { RpgAgentSessionRepository } from './RpgAgentSessionRepository.js';
import { RpgWorldProfileRepository } from './RpgWorldProfileRepository.js';
import { normalizeStoredRpgWorldProfile } from './rpgWorldRepositoryShared.js';
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
@@ -44,45 +44,6 @@ type SettingsRow = QueryResultRow & {
platformTheme: RuntimeSettings['platformTheme'];
};
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;
@@ -227,65 +188,6 @@ export type RuntimeRepositoryPort = {
): 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 {
@@ -678,7 +580,7 @@ function resolveProfileSaveArchiveMeta(
if (customWorldProfile) {
const profileId = readString(customWorldProfile.id) || 'custom-world';
const metadata = extractCustomWorldLibraryMetadata(
normalizeStoredProfile(profileId, customWorldProfile),
normalizeStoredRpgWorldProfile(profileId, customWorldProfile),
);
return {
@@ -717,33 +619,12 @@ function resolveProfileSaveArchiveMeta(
}
export class RuntimeRepository implements RuntimeRepositoryPort {
constructor(private readonly db: AppDatabase) {}
private readonly rpgAgentSessionRepository: RpgAgentSessionRepository;
private readonly rpgWorldProfileRepository: RpgWorldProfileRepository;
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;
constructor(private readonly db: AppDatabase) {
this.rpgAgentSessionRepository = new RpgAgentSessionRepository(db);
this.rpgWorldProfileRepository = new RpgWorldProfileRepository(db);
}
private async getProfileDashboardState(userId: string) {
@@ -1043,52 +924,13 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
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,
],
await this.rpgWorldProfileRepository.syncProfileFromSnapshot(
userId,
profileId,
customWorldProfile,
syncedAt,
);
}
@@ -1394,29 +1236,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
}
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));
return this.rpgWorldProfileRepository.listOwnProfiles(userId);
}
async upsertCustomWorldProfile(
@@ -1425,120 +1245,27 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
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,
],
return this.rpgWorldProfileRepository.upsertOwnProfile(
userId,
profileId,
profile,
authorDisplayName,
);
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.rpgWorldProfileRepository.softDeleteOwnProfile(
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,
}));
return this.rpgAgentSessionRepository.listSessions(userId);
}
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,
};
return this.rpgAgentSessionRepository.getSession(userId, sessionId);
}
async upsertCustomWorldSession(
@@ -1546,30 +1273,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
sessionId: string,
session: CustomWorldSessionRecord,
) {
const payload = {
...session,
return this.rpgAgentSessionRepository.upsertSession(
userId,
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],
session,
);
return {
...payload,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
async publishCustomWorldProfile(
@@ -1577,57 +1285,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findCustomWorldProfileEntry(
return this.rpgWorldProfileRepository.publishOwnProfile(
userId,
profileId,
authorDisplayName,
);
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(
@@ -1635,113 +1297,24 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findCustomWorldProfileEntry(
return this.rpgWorldProfileRepository.unpublishOwnProfile(
userId,
profileId,
authorDisplayName,
);
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));
return this.rpgWorldProfileRepository.listPublishedGallery();
}
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],
return this.rpgWorldProfileRepository.getPublishedGalleryDetail(
ownerUserId,
profileId,
);
const row = result.rows[0];
return row ? toCustomWorldLibraryEntry(row) : null;
}
}