1
This commit is contained in:
256
server-node/src/services/customWorldAgentPublishingService.ts
Normal file
256
server-node/src/services/customWorldAgentPublishingService.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user