257 lines
7.9 KiB
TypeScript
257 lines
7.9 KiB
TypeScript
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||
import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js';
|
||
import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js';
|
||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||
|
||
function toText(value: unknown) {
|
||
return typeof value === 'string' ? value.trim() : '';
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
|
||
function hasGeneratedSceneAsset(
|
||
value: unknown,
|
||
) {
|
||
return Boolean(toText((value as Record<string, unknown> | null)?.generatedSceneAssetId));
|
||
}
|
||
|
||
export class CustomWorldAgentPublishingService {
|
||
constructor(
|
||
private readonly rpgWorldProfileRepository: RpgWorldProfileRepositoryPort,
|
||
) {}
|
||
|
||
/**
|
||
* Phase4 需要把“能不能发布”收成可读的后端真相,
|
||
* 这样结果页、works 和 publish executor 才能共享同一套 blocker 语义。
|
||
*/
|
||
evaluatePublishReadiness(params: {
|
||
sessionId: string;
|
||
draftProfile: unknown;
|
||
qualityFindings?: Array<{
|
||
severity: 'info' | 'warning' | 'blocker';
|
||
code?: string;
|
||
targetId?: string | null;
|
||
message: string;
|
||
}>;
|
||
}) {
|
||
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
|
||
if (!draftProfile) {
|
||
return {
|
||
profileId: `agent-draft-${params.sessionId}`,
|
||
blockers: [
|
||
{
|
||
severity: 'blocker' as const,
|
||
code: 'publish_empty_draft',
|
||
message: '当前世界草稿为空,无法发布。',
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
const findings = params.qualityFindings ?? [];
|
||
const blockers = findings.filter((entry) => entry.severity === 'blocker');
|
||
const readinessBlockers = [...blockers];
|
||
|
||
if (!draftProfile.worldHook.trim()) {
|
||
readinessBlockers.push({
|
||
severity: 'blocker',
|
||
code: 'publish_missing_world_hook',
|
||
message: '当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。',
|
||
});
|
||
}
|
||
|
||
if (!draftProfile.playerPremise.trim()) {
|
||
readinessBlockers.push({
|
||
severity: 'blocker',
|
||
code: 'publish_missing_player_premise',
|
||
message: '当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。',
|
||
});
|
||
}
|
||
|
||
if (
|
||
draftProfile.coreConflicts.length <= 0 ||
|
||
!draftProfile.coreConflicts.some((entry) => toText(entry))
|
||
) {
|
||
readinessBlockers.push({
|
||
severity: 'blocker',
|
||
code: 'publish_missing_core_conflict',
|
||
message: '当前世界缺少核心冲突,发布前需要先补齐核心冲突。',
|
||
});
|
||
}
|
||
|
||
if ((draftProfile.chapters?.length ?? 0) <= 0) {
|
||
readinessBlockers.push({
|
||
severity: 'blocker',
|
||
code: 'publish_missing_main_chapter',
|
||
message: '当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。',
|
||
});
|
||
}
|
||
|
||
const firstSceneActExists = draftProfile.sceneChapters.some(
|
||
(chapter) => chapter.acts.length > 0,
|
||
);
|
||
if (!firstSceneActExists) {
|
||
readinessBlockers.push({
|
||
severity: 'blocker',
|
||
code: 'publish_missing_first_act',
|
||
message: '当前世界还没有主线第一幕,发布前至少要保留一个场景幕。',
|
||
});
|
||
}
|
||
|
||
const missingRoleAssets = [
|
||
...draftProfile.playableNpcs,
|
||
...draftProfile.storyNpcs,
|
||
].filter(
|
||
(role) =>
|
||
!toText(role.generatedVisualAssetId) ||
|
||
!toText(role.generatedAnimationSetId),
|
||
);
|
||
if (missingRoleAssets.length > 0) {
|
||
readinessBlockers.push({
|
||
severity: 'blocker',
|
||
code: 'publish_role_assets_incomplete',
|
||
targetId: missingRoleAssets[0]?.id ?? null,
|
||
message: '仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
|
||
});
|
||
}
|
||
|
||
if (!draftProfile.camp || !toText(draftProfile.camp.imageSrc) || !hasGeneratedSceneAsset(draftProfile.camp)) {
|
||
readinessBlockers.push({
|
||
severity: 'blocker',
|
||
code: 'publish_camp_scene_missing',
|
||
targetId: draftProfile.camp?.id ?? null,
|
||
message: '营地还缺少正式场景图资产,发布前需要先确认营地图。',
|
||
});
|
||
}
|
||
|
||
const missingLandmarkScenes = draftProfile.landmarks.filter(
|
||
(landmark) =>
|
||
!toText(landmark.imageSrc) || !hasGeneratedSceneAsset(landmark),
|
||
);
|
||
if (missingLandmarkScenes.length > 0) {
|
||
readinessBlockers.push({
|
||
severity: 'blocker',
|
||
code: 'publish_landmark_scene_missing',
|
||
targetId: missingLandmarkScenes[0]?.id ?? null,
|
||
message: '仍有地点缺少正式场景图资产,发布前需要先补齐地点图。',
|
||
});
|
||
}
|
||
|
||
return {
|
||
profileId:
|
||
toText(
|
||
(params.draftProfile as Record<string, unknown> | null)
|
||
?.legacyResultProfile?.id,
|
||
) || `agent-draft-${params.sessionId}`,
|
||
blockers: readinessBlockers,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Phase4 统一复用发布门禁摘要,避免 preview / works / enter-world 各自拼 blocker 口径。
|
||
*/
|
||
summarizePublishGate(params: {
|
||
sessionId: string;
|
||
stage?: string | null;
|
||
draftProfile: unknown;
|
||
qualityFindings?: Array<{
|
||
severity: 'info' | 'warning' | 'blocker';
|
||
code?: string;
|
||
targetId?: string | null;
|
||
message: string;
|
||
}>;
|
||
}) {
|
||
const readiness = this.evaluatePublishReadiness(params);
|
||
const blockers = readiness.blockers.map((entry) => ({
|
||
id:
|
||
typeof entry.code === 'string' && entry.code.trim()
|
||
? entry.code
|
||
: `publish-blocker-${entry.message}`,
|
||
code:
|
||
typeof entry.code === 'string' && entry.code.trim()
|
||
? entry.code
|
||
: 'publish_blocker',
|
||
message: entry.message,
|
||
}));
|
||
|
||
return {
|
||
profileId: readiness.profileId,
|
||
blockers,
|
||
blockerCount: blockers.length,
|
||
publishReady: blockers.length === 0,
|
||
canEnterWorld:
|
||
String(params.stage ?? '').trim() === 'published' && blockers.length === 0,
|
||
};
|
||
}
|
||
|
||
buildPublishReadiness(params: {
|
||
sessionId: string;
|
||
draftProfile: unknown;
|
||
qualityFindings?: Array<{
|
||
severity: 'info' | 'warning' | 'blocker';
|
||
code?: string;
|
||
targetId?: string | null;
|
||
message: string;
|
||
}>;
|
||
}) {
|
||
const readiness = this.evaluatePublishReadiness(params);
|
||
if (readiness.blockers.length > 0) {
|
||
throw new Error(
|
||
`当前世界仍有 ${readiness.blockers.length} 个 blocker,暂时不能发布:${readiness.blockers
|
||
.map((entry) => entry.message)
|
||
.join(';')}`,
|
||
);
|
||
}
|
||
|
||
return {
|
||
profileId: readiness.profileId,
|
||
};
|
||
}
|
||
|
||
async publishSessionDraft(params: {
|
||
userId: string;
|
||
authorDisplayName: string;
|
||
sessionId: string;
|
||
draftProfile: Record<string, unknown>;
|
||
qualityFindings?: Array<{
|
||
severity: 'info' | 'warning' | 'blocker';
|
||
message: string;
|
||
}>;
|
||
}) {
|
||
const readiness = this.buildPublishReadiness({
|
||
sessionId: params.sessionId,
|
||
draftProfile: params.draftProfile,
|
||
qualityFindings: params.qualityFindings,
|
||
});
|
||
const publishedProfile = buildRpgCreationPreviewProfileFromDraftProfile({
|
||
sessionId: params.sessionId,
|
||
draftProfile: params.draftProfile,
|
||
profileId: readiness.profileId,
|
||
});
|
||
|
||
await this.rpgWorldProfileRepository.upsertOwnProfile(
|
||
params.userId,
|
||
readiness.profileId,
|
||
publishedProfile as unknown as Record<string, unknown>,
|
||
params.authorDisplayName,
|
||
);
|
||
|
||
const mutation = await this.rpgWorldProfileRepository.publishOwnProfile(
|
||
params.userId,
|
||
readiness.profileId,
|
||
params.authorDisplayName,
|
||
);
|
||
if (!mutation) {
|
||
throw new Error('世界发布失败,未找到目标作品。');
|
||
}
|
||
|
||
return {
|
||
profileId: readiness.profileId,
|
||
publishedProfile,
|
||
mutation,
|
||
};
|
||
}
|
||
}
|