Files
Genarrative/server-node/src/services/customWorldAgentPublishingService.ts
2026-04-21 18:27:46 +08:00

257 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}
}