1
This commit is contained in:
100
server-node/src/repositories/RpgAgentSessionRepository.ts
Normal file
100
server-node/src/repositories/RpgAgentSessionRepository.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppDatabase } from '../db.js';
|
||||
import {
|
||||
type RpgAgentSessionRow,
|
||||
} from './rpgWorldRepositoryShared.js';
|
||||
|
||||
/**
|
||||
* RPG Agent session 仓储最小读写接口。
|
||||
* 工作包 F 后续所有 session store / test stub 都应优先依赖这个领域端口。
|
||||
*/
|
||||
export type RpgAgentSessionRepositoryPort = {
|
||||
listSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
|
||||
getSession(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
): Promise<CustomWorldSessionRecord | null>;
|
||||
upsertSession(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
session: CustomWorldSessionRecord,
|
||||
): Promise<CustomWorldSessionRecord>;
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG Agent session 仓储只负责 session 表读写,不再承担兼容补齐与快照派生。
|
||||
*/
|
||||
export class RpgAgentSessionRepository implements RpgAgentSessionRepositoryPort {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
async listSessions(userId: string) {
|
||||
const result = await this.db.query<RpgAgentSessionRow>(
|
||||
`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 getSession(userId: string, sessionId: string) {
|
||||
const result = await this.db.query<RpgAgentSessionRow>(
|
||||
`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 upsertSession(
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
433
server-node/src/repositories/RpgWorldProfileRepository.ts
Normal file
433
server-node/src/repositories/RpgWorldProfileRepository.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldProfileRecord,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
|
||||
import type { AppDatabase } from '../db.js';
|
||||
import {
|
||||
MAX_RPG_WORLD_GALLERY_ENTRIES,
|
||||
MAX_RPG_WORLD_PROFILE_ENTRIES,
|
||||
normalizeStoredRpgWorldProfile,
|
||||
toRpgWorldGalleryCard,
|
||||
toRpgWorldLibraryEntry,
|
||||
type RpgWorldGalleryRow,
|
||||
type RpgWorldProfileRow,
|
||||
} from './rpgWorldRepositoryShared.js';
|
||||
|
||||
/**
|
||||
* RPG 世界 profile 领域端口。
|
||||
* works、library、gallery、脚本同步等链路后续统一依赖这个接口,而不是 RuntimeRepositoryPort。
|
||||
*/
|
||||
export type RpgWorldProfileRepositoryPort = {
|
||||
listOwnProfiles(
|
||||
userId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
upsertOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
authorDisplayName: string,
|
||||
): Promise<{
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
||||
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
||||
}>;
|
||||
syncProfileFromSnapshot(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
syncedAt: string,
|
||||
): Promise<void>;
|
||||
softDeleteOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
publishOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
): Promise<{
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
||||
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
||||
} | null>;
|
||||
unpublishOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
): Promise<{
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
||||
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
||||
} | null>;
|
||||
listPublishedGallery(): Promise<CustomWorldGalleryCard[]>;
|
||||
getPublishedGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG 世界 profile 仓储统一负责作品库、发布态与画廊读写。
|
||||
*/
|
||||
export class RpgWorldProfileRepository implements RpgWorldProfileRepositoryPort {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
private async findOwnProfileEntry(userId: string, profileId: string) {
|
||||
const result = await this.db.query<RpgWorldProfileRow>(
|
||||
`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 ? toRpgWorldLibraryEntry(row) : null;
|
||||
}
|
||||
|
||||
async listOwnProfiles(userId: string) {
|
||||
const result = await this.db.query<RpgWorldProfileRow>(
|
||||
`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_RPG_WORLD_PROFILE_ENTRIES],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => toRpgWorldLibraryEntry(row));
|
||||
}
|
||||
|
||||
async upsertOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const payload = normalizeStoredRpgWorldProfile(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.findOwnProfileEntry(userId, profileId);
|
||||
if (!entry) {
|
||||
throw new Error('failed to resolve custom world after upsert');
|
||||
}
|
||||
|
||||
return {
|
||||
entry,
|
||||
entries: await this.listOwnProfiles(userId),
|
||||
};
|
||||
}
|
||||
|
||||
async syncProfileFromSnapshot(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
syncedAt: string,
|
||||
) {
|
||||
const payload = normalizeStoredRpgWorldProfile(profileId, profile);
|
||||
const metadata = extractCustomWorldLibraryMetadata(payload);
|
||||
|
||||
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 softDeleteOwnProfile(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.listOwnProfiles(userId);
|
||||
}
|
||||
|
||||
async publishOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const existingEntry = await this.findOwnProfileEntry(userId, profileId);
|
||||
if (!existingEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = normalizeStoredRpgWorldProfile(
|
||||
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.findOwnProfileEntry(userId, profileId);
|
||||
if (!entry) {
|
||||
throw new Error('failed to resolve custom world after publish');
|
||||
}
|
||||
|
||||
return {
|
||||
entry,
|
||||
entries: await this.listOwnProfiles(userId),
|
||||
};
|
||||
}
|
||||
|
||||
async unpublishOwnProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const existingEntry = await this.findOwnProfileEntry(userId, profileId);
|
||||
if (!existingEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = normalizeStoredRpgWorldProfile(
|
||||
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.findOwnProfileEntry(userId, profileId);
|
||||
if (!entry) {
|
||||
throw new Error('failed to resolve custom world after unpublish');
|
||||
}
|
||||
|
||||
return {
|
||||
entry,
|
||||
entries: await this.listOwnProfiles(userId),
|
||||
};
|
||||
}
|
||||
|
||||
async listPublishedGallery() {
|
||||
const result = await this.db.query<RpgWorldGalleryRow>(
|
||||
`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_RPG_WORLD_GALLERY_ENTRIES],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => toRpgWorldGalleryCard(row));
|
||||
}
|
||||
|
||||
async getPublishedGalleryDetail(ownerUserId: string, profileId: string) {
|
||||
const result = await this.db.query<RpgWorldProfileRow>(
|
||||
`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 ? toRpgWorldLibraryEntry(row) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
|
||||
import { RpgSaveArchiveRepository } from './RpgSaveArchiveRepository.js';
|
||||
import { RpgWorldLibraryRepository } from './RpgWorldLibraryRepository.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
return {
|
||||
async getSnapshot() {
|
||||
return null;
|
||||
},
|
||||
async putSnapshot(_userId, payload) {
|
||||
return {
|
||||
version: 1,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
async getProfileDashboard() {
|
||||
return {
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
};
|
||||
},
|
||||
async listProfileWalletLedger() {
|
||||
return [];
|
||||
},
|
||||
async getProfilePlayStats() {
|
||||
return {
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorks: [],
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
};
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [
|
||||
{
|
||||
worldKey: 'world-1',
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
worldType: 'custom',
|
||||
worldName: '潮影群岛',
|
||||
subtitle: '港雾与旧航道',
|
||||
summaryText: '最近一次继续游戏入口',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-20T23:59:59.000Z',
|
||||
},
|
||||
];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return {
|
||||
entry: {
|
||||
worldKey: 'world-1',
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
worldType: 'custom',
|
||||
worldName: '潮影群岛',
|
||||
subtitle: '港雾与旧航道',
|
||||
summaryText: '最近一次继续游戏入口',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-20T23:59:59.000Z',
|
||||
},
|
||||
snapshot: {
|
||||
version: 1,
|
||||
savedAt: '2026-04-20T23:59:59.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: { currentScene: '潮影港' },
|
||||
currentStory: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
async deleteSnapshot() {},
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
return settings;
|
||||
},
|
||||
async listCustomWorldProfiles() {
|
||||
return [
|
||||
{
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
},
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T08:00:00.000Z',
|
||||
updatedAt: '2026-04-20T08:00:00.000Z',
|
||||
authorDisplayName: '造物者',
|
||||
worldName: '潮影群岛',
|
||||
subtitle: '港雾与旧航道',
|
||||
summaryText: '一座在潮汐中漂移的群岛。',
|
||||
coverImageSrc: '/covers/tide.png',
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 2,
|
||||
landmarkCount: 3,
|
||||
},
|
||||
];
|
||||
},
|
||||
async listPlatformBrowseHistory() {
|
||||
return [];
|
||||
},
|
||||
async upsertPlatformBrowseHistoryEntries() {
|
||||
return [];
|
||||
},
|
||||
async clearPlatformBrowseHistory() {},
|
||||
async upsertCustomWorldProfile() {
|
||||
return {
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
},
|
||||
visibility: 'draft',
|
||||
publishedAt: null,
|
||||
updatedAt: '2026-04-20T08:00:00.000Z',
|
||||
authorDisplayName: '造物者',
|
||||
worldName: '潮影群岛',
|
||||
subtitle: '港雾与旧航道',
|
||||
summaryText: '一座在潮汐中漂移的群岛。',
|
||||
coverImageSrc: '/covers/tide.png',
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 2,
|
||||
landmarkCount: 3,
|
||||
},
|
||||
entries: [],
|
||||
};
|
||||
},
|
||||
async deleteCustomWorldProfile() {
|
||||
return [];
|
||||
},
|
||||
async listCustomWorldSessions() {
|
||||
return [];
|
||||
},
|
||||
async getCustomWorldSession() {
|
||||
return null;
|
||||
},
|
||||
async upsertCustomWorldSession(_userId, _sessionId, session) {
|
||||
return session;
|
||||
},
|
||||
async publishCustomWorldProfile() {
|
||||
return {
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
},
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T08:00:00.000Z',
|
||||
updatedAt: '2026-04-20T08:00:00.000Z',
|
||||
authorDisplayName: '造物者',
|
||||
worldName: '潮影群岛',
|
||||
subtitle: '港雾与旧航道',
|
||||
summaryText: '一座在潮汐中漂移的群岛。',
|
||||
coverImageSrc: '/covers/tide.png',
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 2,
|
||||
landmarkCount: 3,
|
||||
},
|
||||
entries: [],
|
||||
};
|
||||
},
|
||||
async unpublishCustomWorldProfile() {
|
||||
return null;
|
||||
},
|
||||
async listPublishedCustomWorldGallery() {
|
||||
return [
|
||||
{
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T08:00:00.000Z',
|
||||
updatedAt: '2026-04-20T08:00:00.000Z',
|
||||
authorDisplayName: '造物者',
|
||||
worldName: '潮影群岛',
|
||||
subtitle: '港雾与旧航道',
|
||||
summaryText: '一座在潮汐中漂移的群岛。',
|
||||
coverImageSrc: '/covers/tide.png',
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 2,
|
||||
landmarkCount: 3,
|
||||
},
|
||||
];
|
||||
},
|
||||
async getPublishedCustomWorldGalleryDetail() {
|
||||
return {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
},
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T08:00:00.000Z',
|
||||
updatedAt: '2026-04-20T08:00:00.000Z',
|
||||
authorDisplayName: '造物者',
|
||||
worldName: '潮影群岛',
|
||||
subtitle: '港雾与旧航道',
|
||||
summaryText: '一座在潮汐中漂移的群岛。',
|
||||
coverImageSrc: '/covers/tide.png',
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 2,
|
||||
landmarkCount: 3,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('RpgSaveArchiveRepository 只承接继续游戏归档读取职责', async () => {
|
||||
const repository = new RpgSaveArchiveRepository(createRuntimeRepositoryStub());
|
||||
|
||||
const archives = await repository.listProfileSaveArchives('user-1');
|
||||
const resumed = await repository.resumeProfileSaveArchive('user-1', 'world-1');
|
||||
|
||||
assert.equal(archives[0]?.worldName, '潮影群岛');
|
||||
assert.equal(resumed?.snapshot.bottomTab, 'adventure');
|
||||
assert.equal('getSnapshot' in repository, false);
|
||||
});
|
||||
|
||||
test('RpgWorldLibraryRepository 独立承接作品库与广场读取职责', async () => {
|
||||
const repository = new RpgWorldLibraryRepository(createRuntimeRepositoryStub());
|
||||
|
||||
const profiles = await repository.listCustomWorldProfiles('user-1');
|
||||
const gallery = await repository.listPublishedCustomWorldGallery();
|
||||
const detail = await repository.getPublishedCustomWorldGalleryDetail(
|
||||
'owner-1',
|
||||
'profile-1',
|
||||
);
|
||||
|
||||
assert.equal(profiles[0]?.worldName, '潮影群岛');
|
||||
assert.equal(gallery[0]?.themeMode, 'tide');
|
||||
assert.equal(detail?.profileId, 'profile-1');
|
||||
assert.equal('listProfileSaveArchives' in repository, false);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
RuntimeRepositoryPort,
|
||||
SavedSnapshot,
|
||||
} from '../runtimeRepository.js';
|
||||
import type { ProfileSaveArchiveSummary } from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
|
||||
/**
|
||||
* RPG 继续游戏归档仓储端口。
|
||||
* 当前仍由 runtimeRepository 提供真实实现,本文件只建立按领域命名的兼容入口。
|
||||
*/
|
||||
export type RpgSaveArchiveRepositoryPort = Pick<
|
||||
RuntimeRepositoryPort,
|
||||
'listProfileSaveArchives' | 'resumeProfileSaveArchive'
|
||||
>;
|
||||
export type RpgSaveArchiveSnapshot = SavedSnapshot;
|
||||
|
||||
export class RpgSaveArchiveRepository implements RpgSaveArchiveRepositoryPort {
|
||||
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
|
||||
|
||||
listProfileSaveArchives(userId: string): Promise<ProfileSaveArchiveSummary[]> {
|
||||
return this.runtimeRepository.listProfileSaveArchives(userId);
|
||||
}
|
||||
|
||||
resumeProfileSaveArchive(
|
||||
userId: string,
|
||||
worldKey: string,
|
||||
): Promise<
|
||||
| {
|
||||
entry: ProfileSaveArchiveSummary;
|
||||
snapshot: RpgSaveArchiveSnapshot;
|
||||
}
|
||||
| null
|
||||
> {
|
||||
return this.runtimeRepository.resumeProfileSaveArchive(userId, worldKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldProfileRecord,
|
||||
} from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
|
||||
|
||||
/**
|
||||
* RPG 世界库仓储端口。
|
||||
* 当前先桥接旧 runtimeRepository 中的世界库与广场读写,为工作包 H 的按域拆仓储提供命名骨架。
|
||||
*/
|
||||
export type RpgWorldLibraryRepositoryPort = Pick<
|
||||
RuntimeRepositoryPort,
|
||||
| 'deleteCustomWorldProfile'
|
||||
| 'getPublishedCustomWorldGalleryDetail'
|
||||
| 'listCustomWorldProfiles'
|
||||
| 'listPublishedCustomWorldGallery'
|
||||
| 'publishCustomWorldProfile'
|
||||
| 'unpublishCustomWorldProfile'
|
||||
| 'upsertCustomWorldProfile'
|
||||
>;
|
||||
|
||||
export class RpgWorldLibraryRepository
|
||||
implements RpgWorldLibraryRepositoryPort
|
||||
{
|
||||
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
|
||||
|
||||
listCustomWorldProfiles(
|
||||
userId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]> {
|
||||
return this.runtimeRepository.listCustomWorldProfiles(userId);
|
||||
}
|
||||
|
||||
upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
return this.runtimeRepository.upsertCustomWorldProfile(
|
||||
userId,
|
||||
profileId,
|
||||
profile,
|
||||
authorDisplayName,
|
||||
);
|
||||
}
|
||||
|
||||
deleteCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]> {
|
||||
return this.runtimeRepository.deleteCustomWorldProfile(userId, profileId);
|
||||
}
|
||||
|
||||
publishCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
return this.runtimeRepository.publishCustomWorldProfile(
|
||||
userId,
|
||||
profileId,
|
||||
authorDisplayName,
|
||||
);
|
||||
}
|
||||
|
||||
unpublishCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
return this.runtimeRepository.unpublishCustomWorldProfile(
|
||||
userId,
|
||||
profileId,
|
||||
authorDisplayName,
|
||||
);
|
||||
}
|
||||
|
||||
listPublishedCustomWorldGallery(): Promise<CustomWorldGalleryCard[]> {
|
||||
return this.runtimeRepository.listPublishedCustomWorldGallery();
|
||||
}
|
||||
|
||||
getPublishedCustomWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | null> {
|
||||
return this.runtimeRepository.getPublishedCustomWorldGalleryDetail(
|
||||
ownerUserId,
|
||||
profileId,
|
||||
);
|
||||
}
|
||||
}
|
||||
8
server-node/src/repositories/rpg-entry/index.ts
Normal file
8
server-node/src/repositories/rpg-entry/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
RpgSaveArchiveRepository,
|
||||
type RpgSaveArchiveRepositoryPort,
|
||||
} from './RpgSaveArchiveRepository.js';
|
||||
export {
|
||||
RpgWorldLibraryRepository,
|
||||
type RpgWorldLibraryRepositoryPort,
|
||||
} from './RpgWorldLibraryRepository.js';
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
} from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
|
||||
|
||||
/**
|
||||
* RPG 浏览历史仓储端口。
|
||||
* 将继续游戏与平台浏览足迹独立命名,避免继续堆叠在资料看板仓储内。
|
||||
*/
|
||||
export type RpgBrowseHistoryRepositoryPort = Pick<
|
||||
RuntimeRepositoryPort,
|
||||
| 'clearPlatformBrowseHistory'
|
||||
| 'listPlatformBrowseHistory'
|
||||
| 'upsertPlatformBrowseHistoryEntries'
|
||||
>;
|
||||
|
||||
export class RpgBrowseHistoryRepository
|
||||
implements RpgBrowseHistoryRepositoryPort
|
||||
{
|
||||
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
|
||||
|
||||
listPlatformBrowseHistory(
|
||||
userId: string,
|
||||
): Promise<PlatformBrowseHistoryEntry[]> {
|
||||
return this.runtimeRepository.listPlatformBrowseHistory(userId);
|
||||
}
|
||||
|
||||
upsertPlatformBrowseHistoryEntries(
|
||||
userId: string,
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
): Promise<PlatformBrowseHistoryEntry[]> {
|
||||
return this.runtimeRepository.upsertPlatformBrowseHistoryEntries(
|
||||
userId,
|
||||
entries,
|
||||
);
|
||||
}
|
||||
|
||||
clearPlatformBrowseHistory(userId: string): Promise<void> {
|
||||
return this.runtimeRepository.clearPlatformBrowseHistory(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerEntry,
|
||||
RuntimeSettings,
|
||||
} from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
|
||||
|
||||
/**
|
||||
* RPG profile 域仓储端口。
|
||||
* 当前以委托方式桥接旧 runtimeRepository,给后续按域仓储拆分保留稳定依赖面。
|
||||
*/
|
||||
export type RpgProfileDashboardRepositoryPort = Pick<
|
||||
RuntimeRepositoryPort,
|
||||
| 'getProfileDashboard'
|
||||
| 'getProfilePlayStats'
|
||||
| 'getSettings'
|
||||
| 'listProfileWalletLedger'
|
||||
| 'putSettings'
|
||||
>;
|
||||
|
||||
export class RpgProfileDashboardRepository
|
||||
implements RpgProfileDashboardRepositoryPort
|
||||
{
|
||||
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
|
||||
|
||||
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary> {
|
||||
return this.runtimeRepository.getProfileDashboard(userId);
|
||||
}
|
||||
|
||||
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]> {
|
||||
return this.runtimeRepository.listProfileWalletLedger(userId);
|
||||
}
|
||||
|
||||
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse> {
|
||||
return this.runtimeRepository.getProfilePlayStats(userId);
|
||||
}
|
||||
|
||||
getSettings(userId: string): Promise<RuntimeSettings> {
|
||||
return this.runtimeRepository.getSettings(userId);
|
||||
}
|
||||
|
||||
putSettings(
|
||||
userId: string,
|
||||
settings: RuntimeSettings,
|
||||
): Promise<RuntimeSettings> {
|
||||
return this.runtimeRepository.putSettings(userId, settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
|
||||
import { RpgBrowseHistoryRepository } from './RpgBrowseHistoryRepository.js';
|
||||
import { RpgProfileDashboardRepository } from './RpgProfileDashboardRepository.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
return {
|
||||
async getSnapshot() {
|
||||
return null;
|
||||
},
|
||||
async putSnapshot(_userId, payload) {
|
||||
return {
|
||||
version: 1,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
async getProfileDashboard() {
|
||||
return {
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
};
|
||||
},
|
||||
async listProfileWalletLedger() {
|
||||
return [];
|
||||
},
|
||||
async getProfilePlayStats() {
|
||||
return {
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorks: [],
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
};
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async deleteSnapshot() {},
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.5,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
return settings;
|
||||
},
|
||||
async listCustomWorldProfiles() {
|
||||
return [];
|
||||
},
|
||||
async listPlatformBrowseHistory() {
|
||||
return [
|
||||
{
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '雾港',
|
||||
subtitle: '沿海试炼',
|
||||
summaryText: '最近访问',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试者',
|
||||
visitedAt: '2026-04-21T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
},
|
||||
async upsertPlatformBrowseHistoryEntries(_userId, entries) {
|
||||
return entries.map((entry) => ({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle ?? '',
|
||||
summaryText: entry.summaryText ?? '',
|
||||
coverImageSrc: entry.coverImageSrc ?? null,
|
||||
themeMode: entry.themeMode ?? 'mythic',
|
||||
authorDisplayName: entry.authorDisplayName ?? '玩家',
|
||||
visitedAt: entry.visitedAt ?? '2026-04-21T00:00:00.000Z',
|
||||
}));
|
||||
},
|
||||
async clearPlatformBrowseHistory() {},
|
||||
async upsertCustomWorldProfile() {
|
||||
return {
|
||||
entry: {} as never,
|
||||
entries: [],
|
||||
};
|
||||
},
|
||||
async deleteCustomWorldProfile() {
|
||||
return [];
|
||||
},
|
||||
async listCustomWorldSessions() {
|
||||
return [];
|
||||
},
|
||||
async getCustomWorldSession() {
|
||||
return null;
|
||||
},
|
||||
async upsertCustomWorldSession(_userId, _sessionId, session) {
|
||||
return session;
|
||||
},
|
||||
async publishCustomWorldProfile() {
|
||||
return null;
|
||||
},
|
||||
async unpublishCustomWorldProfile() {
|
||||
return null;
|
||||
},
|
||||
async listPublishedCustomWorldGallery() {
|
||||
return [];
|
||||
},
|
||||
async getPublishedCustomWorldGalleryDetail() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('RpgProfileDashboardRepository 只暴露资料看板域方法', async () => {
|
||||
const repository = new RpgProfileDashboardRepository(
|
||||
createRuntimeRepositoryStub(),
|
||||
);
|
||||
|
||||
const dashboard = await repository.getProfileDashboard('user-1');
|
||||
const playStats = await repository.getProfilePlayStats('user-1');
|
||||
const settings = await repository.getSettings('user-1');
|
||||
|
||||
assert.equal(dashboard.playedWorldCount, 0);
|
||||
assert.equal(playStats.playedWorks.length, 0);
|
||||
assert.equal(settings.platformTheme, 'light');
|
||||
assert.equal('listPlatformBrowseHistory' in repository, false);
|
||||
});
|
||||
|
||||
test('RpgBrowseHistoryRepository 独立承接浏览历史读写,不再混入资料看板仓储', async () => {
|
||||
const repository = new RpgBrowseHistoryRepository(createRuntimeRepositoryStub());
|
||||
|
||||
const history = await repository.listPlatformBrowseHistory('user-1');
|
||||
const updated = await repository.upsertPlatformBrowseHistoryEntries('user-1', [
|
||||
{
|
||||
ownerUserId: 'owner-2',
|
||||
profileId: 'profile-2',
|
||||
worldName: '盐雾镇',
|
||||
subtitle: '盐路补给点',
|
||||
summaryText: '测试写入浏览历史',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试者二号',
|
||||
visitedAt: '2026-04-21T01:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(history[0]?.worldName, '雾港');
|
||||
assert.equal(updated[0]?.profileId, 'profile-2');
|
||||
assert.equal('getProfileDashboard' in repository, false);
|
||||
});
|
||||
8
server-node/src/repositories/rpg-profile/index.ts
Normal file
8
server-node/src/repositories/rpg-profile/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
RpgBrowseHistoryRepository,
|
||||
type RpgBrowseHistoryRepositoryPort,
|
||||
} from './RpgBrowseHistoryRepository.js';
|
||||
export {
|
||||
RpgProfileDashboardRepository,
|
||||
type RpgProfileDashboardRepositoryPort,
|
||||
} from './RpgProfileDashboardRepository.js';
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
|
||||
import { RpgRuntimeSnapshotRepository } from './RpgRuntimeSnapshotRepository.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
const deletedUserIds: string[] = [];
|
||||
|
||||
return {
|
||||
async getSnapshot(userId) {
|
||||
return {
|
||||
version: 2,
|
||||
savedAt: '2026-04-21T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {
|
||||
owner: userId,
|
||||
},
|
||||
currentStory: null,
|
||||
};
|
||||
},
|
||||
async putSnapshot(_userId, payload) {
|
||||
return {
|
||||
version: 2,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
async getProfileDashboard() {
|
||||
return {
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
};
|
||||
},
|
||||
async listProfileWalletLedger() {
|
||||
return [];
|
||||
},
|
||||
async getProfilePlayStats() {
|
||||
return {
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorks: [],
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
};
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async deleteSnapshot(userId) {
|
||||
deletedUserIds.push(userId);
|
||||
},
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
return settings;
|
||||
},
|
||||
async listCustomWorldProfiles() {
|
||||
return [];
|
||||
},
|
||||
async listPlatformBrowseHistory() {
|
||||
return [];
|
||||
},
|
||||
async upsertPlatformBrowseHistoryEntries() {
|
||||
return [];
|
||||
},
|
||||
async clearPlatformBrowseHistory() {},
|
||||
async upsertCustomWorldProfile() {
|
||||
return {
|
||||
entry: {} as never,
|
||||
entries: [],
|
||||
};
|
||||
},
|
||||
async deleteCustomWorldProfile() {
|
||||
return [];
|
||||
},
|
||||
async listCustomWorldSessions() {
|
||||
return [];
|
||||
},
|
||||
async getCustomWorldSession() {
|
||||
return null;
|
||||
},
|
||||
async upsertCustomWorldSession(_userId, _sessionId, session) {
|
||||
return session;
|
||||
},
|
||||
async publishCustomWorldProfile() {
|
||||
return null;
|
||||
},
|
||||
async unpublishCustomWorldProfile() {
|
||||
return null;
|
||||
},
|
||||
async listPublishedCustomWorldGallery() {
|
||||
return [];
|
||||
},
|
||||
async getPublishedCustomWorldGalleryDetail() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('RpgRuntimeSnapshotRepository 独立承接 snapshot 读写与删除职责', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const repository = new RpgRuntimeSnapshotRepository(runtimeRepository);
|
||||
|
||||
const snapshot = await repository.getSnapshot('user-7');
|
||||
const saved = await repository.putSnapshot('user-7', {
|
||||
savedAt: '2026-04-21T01:00:00.000Z',
|
||||
bottomTab: 'inventory',
|
||||
gameState: {
|
||||
owner: 'user-7',
|
||||
currentScene: '雾港',
|
||||
},
|
||||
currentStory: null,
|
||||
});
|
||||
await repository.deleteSnapshot('user-7');
|
||||
|
||||
assert.equal(snapshot?.gameState.owner, 'user-7');
|
||||
assert.equal(saved.bottomTab, 'inventory');
|
||||
assert.equal('listProfileSaveArchives' in repository, false);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
RuntimeRepositoryPort,
|
||||
SavedSnapshot,
|
||||
} from '../runtimeRepository.js';
|
||||
|
||||
/**
|
||||
* RPG runtime 快照仓储端口。
|
||||
* 工作包 A 先把 snapshot 领域从大仓储中抽出独立命名入口,真实读写仍委托现有 runtimeRepository。
|
||||
*/
|
||||
export type RpgRuntimeSnapshotRepositoryPort = Pick<
|
||||
RuntimeRepositoryPort,
|
||||
'deleteSnapshot' | 'getSnapshot' | 'putSnapshot'
|
||||
>;
|
||||
export type RpgRuntimeSavedSnapshot = SavedSnapshot;
|
||||
|
||||
export class RpgRuntimeSnapshotRepository
|
||||
implements RpgRuntimeSnapshotRepositoryPort
|
||||
{
|
||||
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
|
||||
|
||||
getSnapshot(userId: string): Promise<RpgRuntimeSavedSnapshot | null> {
|
||||
return this.runtimeRepository.getSnapshot(userId);
|
||||
}
|
||||
|
||||
putSnapshot(
|
||||
userId: string,
|
||||
payload: Omit<RpgRuntimeSavedSnapshot, 'version'>,
|
||||
): Promise<RpgRuntimeSavedSnapshot> {
|
||||
return this.runtimeRepository.putSnapshot(userId, payload);
|
||||
}
|
||||
|
||||
deleteSnapshot(userId: string): Promise<void> {
|
||||
return this.runtimeRepository.deleteSnapshot(userId);
|
||||
}
|
||||
}
|
||||
4
server-node/src/repositories/rpg-runtime/index.ts
Normal file
4
server-node/src/repositories/rpg-runtime/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
RpgRuntimeSnapshotRepository,
|
||||
type RpgRuntimeSnapshotRepositoryPort,
|
||||
} from './RpgRuntimeSnapshotRepository.js';
|
||||
116
server-node/src/repositories/rpgWorldRepositoryShared.ts
Normal file
116
server-node/src/repositories/rpgWorldRepositoryShared.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import type {
|
||||
CustomWorldProfileRecord,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import {
|
||||
type CustomWorldGalleryCard,
|
||||
type CustomWorldLibraryEntry,
|
||||
type CustomWorldPublicationStatus,
|
||||
type CustomWorldSessionRecord,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
|
||||
|
||||
export const MAX_RPG_WORLD_PROFILE_ENTRIES = 12;
|
||||
export const MAX_RPG_WORLD_GALLERY_ENTRIES = 36;
|
||||
|
||||
export type RpgWorldProfileRow = 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;
|
||||
};
|
||||
|
||||
export type RpgAgentSessionRow = QueryResultRow & {
|
||||
payload: CustomWorldSessionRecord;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type RpgWorldGalleryRow = 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 落库前统一补齐 profileId,避免不同入口写入时出现同一世界两个 id 口径。
|
||||
*/
|
||||
export function normalizeStoredRpgWorldProfile(
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
): CustomWorldProfileRecord {
|
||||
return {
|
||||
...profile,
|
||||
id: profileId,
|
||||
};
|
||||
}
|
||||
|
||||
export function toRpgWorldLibraryEntry(
|
||||
row: RpgWorldProfileRow,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function toRpgWorldGalleryCard(
|
||||
row: RpgWorldGalleryRow,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user