306 lines
9.1 KiB
TypeScript
306 lines
9.1 KiB
TypeScript
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,
|
|
};
|
|
}
|