1
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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<CustomWorldProfileRecord>;
|
||||
type SeedSessionRecord = CustomWorldSessionRecord & { userId: string };
|
||||
|
||||
function cloneRepositoryValue<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function ensureProfileRecord(
|
||||
profileId: string,
|
||||
profile: Record<string, unknown>,
|
||||
): CustomWorldProfileRecord {
|
||||
return {
|
||||
...cloneRepositoryValue(profile),
|
||||
id: profileId,
|
||||
} as CustomWorldProfileRecord;
|
||||
}
|
||||
|
||||
function buildProfileEntry(params: {
|
||||
userId: string;
|
||||
profileId: string;
|
||||
profile: Record<string, unknown>;
|
||||
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<T extends { updatedAt: string }>(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<CustomWorldLibraryEntry<CustomWorldProfileRecord>>;
|
||||
}) {
|
||||
const sessionsByUser = new Map<string, Map<string, CustomWorldSessionRecord>>();
|
||||
const profilesByUser = new Map<string, Map<string, StoredProfileEntry>>();
|
||||
|
||||
const ensureSessionBucket = (userId: string) => {
|
||||
const currentBucket = sessionsByUser.get(userId);
|
||||
if (currentBucket) {
|
||||
return currentBucket;
|
||||
}
|
||||
|
||||
const nextBucket = new Map<string, CustomWorldSessionRecord>();
|
||||
sessionsByUser.set(userId, nextBucket);
|
||||
return nextBucket;
|
||||
};
|
||||
|
||||
const ensureProfileBucket = (userId: string) => {
|
||||
const currentBucket = profilesByUser.get(userId);
|
||||
if (currentBucket) {
|
||||
return currentBucket;
|
||||
}
|
||||
|
||||
const nextBucket = new Map<string, StoredProfileEntry>();
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user