import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, CustomWorldProfileRecord, CustomWorldSessionRecord, } from '../../../packages/shared/src/contracts/runtime.js'; import { extractCustomWorldLibraryMetadata } from '../repositories/customWorldLibraryMetadata.js'; import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js'; import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; type StoredProfileEntry = CustomWorldLibraryEntry; type SeedSessionRecord = CustomWorldSessionRecord & { userId: string }; function cloneRepositoryValue(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function ensureProfileRecord( profileId: string, profile: Record, ): CustomWorldProfileRecord { return { ...cloneRepositoryValue(profile), id: profileId, } as CustomWorldProfileRecord; } function buildProfileEntry(params: { userId: string; profileId: string; profile: Record; authorDisplayName: string; visibility: 'draft' | 'published'; updatedAt: string; publishedAt: string | null; }) { const profileRecord = ensureProfileRecord(params.profileId, params.profile); const metadata = extractCustomWorldLibraryMetadata(profileRecord); return { ownerUserId: params.userId, profileId: params.profileId, profile: profileRecord, visibility: params.visibility, publishedAt: params.visibility === 'published' ? params.publishedAt : null, updatedAt: params.updatedAt, authorDisplayName: params.authorDisplayName || '玩家', worldName: metadata.worldName, subtitle: metadata.subtitle, summaryText: metadata.summaryText, coverImageSrc: metadata.coverImageSrc, themeMode: metadata.themeMode, playableNpcCount: metadata.playableNpcCount, landmarkCount: metadata.landmarkCount, } satisfies StoredProfileEntry; } function toGalleryCard(entry: StoredProfileEntry): CustomWorldGalleryCard { const { profile: _profile, ...card } = entry; return cloneRepositoryValue(card); } function sortEntriesByUpdatedAt(entries: T[]) { return [...entries].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt), ); } /** * 这组内存仓储 helper 让 phase2~5 与 works 集成测试直接依赖工作包 F 拆出来的领域端口, * 避免继续把 RuntimeRepositoryPort 当成 session/profile 的测试替身。 */ export function createInMemoryRpgWorldRepositoryPorts(options?: { sessionRecords?: SeedSessionRecord[]; profileEntries?: Array>; }) { const sessionsByUser = new Map>(); const profilesByUser = new Map>(); const ensureSessionBucket = (userId: string) => { const currentBucket = sessionsByUser.get(userId); if (currentBucket) { return currentBucket; } const nextBucket = new Map(); sessionsByUser.set(userId, nextBucket); return nextBucket; }; const ensureProfileBucket = (userId: string) => { const currentBucket = profilesByUser.get(userId); if (currentBucket) { return currentBucket; } const nextBucket = new Map(); profilesByUser.set(userId, nextBucket); return nextBucket; }; const listOwnEntries = (userId: string) => sortEntriesByUpdatedAt([...ensureProfileBucket(userId).values()]).map((entry) => cloneRepositoryValue(entry), ); options?.sessionRecords?.forEach((record) => { ensureSessionBucket(record.userId).set( record.sessionId, cloneRepositoryValue(record), ); }); options?.profileEntries?.forEach((entry) => { ensureProfileBucket(entry.ownerUserId).set( entry.profileId, cloneRepositoryValue(entry), ); }); const rpgAgentSessionRepository: RpgAgentSessionRepositoryPort = { async listSessions(userId: string) { return sortEntriesByUpdatedAt([...ensureSessionBucket(userId).values()]).map( (record) => cloneRepositoryValue(record), ); }, async getSession(userId: string, sessionId: string) { const record = ensureSessionBucket(userId).get(sessionId) ?? null; return record ? cloneRepositoryValue(record) : null; }, async upsertSession( userId: string, sessionId: string, session: CustomWorldSessionRecord, ) { const nextSession = cloneRepositoryValue({ ...session, userId, sessionId, }); ensureSessionBucket(userId).set(sessionId, nextSession); return cloneRepositoryValue(nextSession); }, }; const rpgWorldProfileRepository: RpgWorldProfileRepositoryPort = { async listOwnProfiles(userId: string) { return listOwnEntries(userId); }, async upsertOwnProfile( userId: string, profileId: string, profile: Record, authorDisplayName: string, ) { const bucket = ensureProfileBucket(userId); const currentEntry = bucket.get(profileId); const now = new Date().toISOString(); const nextEntry = buildProfileEntry({ userId, profileId, profile, authorDisplayName: authorDisplayName || currentEntry?.authorDisplayName || '玩家', visibility: currentEntry?.visibility ?? 'draft', updatedAt: now, publishedAt: currentEntry?.visibility === 'published' ? currentEntry.publishedAt || now : null, }); bucket.set(profileId, nextEntry); return { entry: cloneRepositoryValue(nextEntry), entries: await listOwnEntries(userId), }; }, async syncProfileFromSnapshot( userId: string, profileId: string, profile: Record, syncedAt: string, ) { const bucket = ensureProfileBucket(userId); const currentEntry = bucket.get(profileId); bucket.set( profileId, buildProfileEntry({ userId, profileId, profile, authorDisplayName: currentEntry?.authorDisplayName || '玩家', visibility: currentEntry?.visibility ?? 'draft', updatedAt: syncedAt, publishedAt: currentEntry?.visibility === 'published' ? currentEntry.publishedAt || syncedAt : null, }), ); }, async softDeleteOwnProfile(userId: string, profileId: string) { ensureProfileBucket(userId).delete(profileId); return listOwnEntries(userId); }, async publishOwnProfile( userId: string, profileId: string, authorDisplayName: string, ) { const bucket = ensureProfileBucket(userId); const currentEntry = bucket.get(profileId); if (!currentEntry) { return null; } const now = new Date().toISOString(); const nextEntry = buildProfileEntry({ userId, profileId, profile: currentEntry.profile, authorDisplayName: authorDisplayName || currentEntry.authorDisplayName || '玩家', visibility: 'published', updatedAt: now, publishedAt: now, }); bucket.set(profileId, nextEntry); return { entry: cloneRepositoryValue(nextEntry), entries: await listOwnEntries(userId), }; }, async unpublishOwnProfile( userId: string, profileId: string, authorDisplayName: string, ) { const bucket = ensureProfileBucket(userId); const currentEntry = bucket.get(profileId); if (!currentEntry) { return null; } const now = new Date().toISOString(); const nextEntry = buildProfileEntry({ userId, profileId, profile: currentEntry.profile, authorDisplayName: authorDisplayName || currentEntry.authorDisplayName || '玩家', visibility: 'draft', updatedAt: now, publishedAt: null, }); bucket.set(profileId, nextEntry); return { entry: cloneRepositoryValue(nextEntry), entries: await listOwnEntries(userId), }; }, async listPublishedGallery() { return [...profilesByUser.values()] .flatMap((bucket) => [...bucket.values()]) .filter((entry) => entry.visibility === 'published') .sort((left, right) => { const publishedAtDiff = (right.publishedAt || '').localeCompare( left.publishedAt || '', ); if (publishedAtDiff !== 0) { return publishedAtDiff; } return right.updatedAt.localeCompare(left.updatedAt); }) .map((entry) => toGalleryCard(entry)); }, async getPublishedGalleryDetail(ownerUserId: string, profileId: string) { const entry = ensureProfileBucket(ownerUserId).get(profileId); if (!entry || entry.visibility !== 'published') { return null; } return cloneRepositoryValue(entry); }, }; return { rpgAgentSessionRepository, rpgWorldProfileRepository, }; }