Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
@@ -7,12 +7,17 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import {
|
||||
type CustomWorldSessionRecord,
|
||||
type CustomWorldGalleryCard,
|
||||
type CustomWorldLibraryEntry,
|
||||
type CustomWorldPublicationStatus,
|
||||
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>;
|
||||
|
||||
@@ -28,9 +33,21 @@ type SettingsRow = QueryResultRow & {
|
||||
musicVolume: number;
|
||||
};
|
||||
|
||||
type ProfileRow = QueryResultRow & {
|
||||
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 & {
|
||||
@@ -39,6 +56,22 @@ type SessionRow = QueryResultRow & {
|
||||
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;
|
||||
};
|
||||
|
||||
export type RuntimeRepositoryPort = {
|
||||
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
|
||||
putSnapshot(
|
||||
@@ -51,17 +84,25 @@ export type RuntimeRepositoryPort = {
|
||||
userId: string,
|
||||
settings: RuntimeSettings,
|
||||
): Promise<RuntimeSettings>;
|
||||
listCustomWorldProfiles(userId: string): Promise<CustomWorldProfileRecord[]>;
|
||||
listCustomWorldProfiles(
|
||||
userId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
): Promise<CustomWorldProfileRecord[]>;
|
||||
authorDisplayName: string,
|
||||
): Promise<{
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
||||
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
||||
}>;
|
||||
deleteCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<CustomWorldProfileRecord[]>;
|
||||
listCustomWorldSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
listCustomWorldSessions(
|
||||
userId: string,
|
||||
): Promise<CustomWorldSessionRecord[]>;
|
||||
getCustomWorldSession(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
@@ -71,11 +112,118 @@ export type RuntimeRepositoryPort = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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`,
|
||||
[userId, profileId],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
return row ? toCustomWorldLibraryEntry(row) : null;
|
||||
}
|
||||
|
||||
async getSnapshot(userId: string) {
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`SELECT version,
|
||||
@@ -192,42 +340,92 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
}
|
||||
|
||||
async listCustomWorldProfiles(userId: string) {
|
||||
const result = await this.db.query<ProfileRow>(
|
||||
`SELECT payload_json AS payload,
|
||||
updated_at AS "updatedAt"
|
||||
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
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2`,
|
||||
[userId, MAX_CUSTOM_WORLD_PROFILES],
|
||||
);
|
||||
|
||||
return result.rows.map((row: ProfileRow) => ({
|
||||
...row.payload,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
return result.rows.map((row) => toCustomWorldLibraryEntry(row));
|
||||
}
|
||||
|
||||
async upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: CustomWorldProfileRecord,
|
||||
profile: Record<string, unknown>,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const payload = {
|
||||
...profile,
|
||||
id: profileId,
|
||||
};
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`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`,
|
||||
[userId, profileId, payload, new Date().toISOString()],
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
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.listCustomWorldProfiles(userId);
|
||||
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) {
|
||||
@@ -310,4 +508,169 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
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'
|
||||
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'`,
|
||||
[ownerUserId, profileId],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
return row ? toCustomWorldLibraryEntry(row) : null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user