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 { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function hasGeneratedSceneAsset( value: unknown, ) { return Boolean(toText((value as Record | 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 | 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; 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, 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, }; } }