325 lines
9.4 KiB
TypeScript
325 lines
9.4 KiB
TypeScript
import type {
|
|
CustomWorldRoleAssetSummary,
|
|
CustomWorldSceneAssetSummary,
|
|
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
|
import {
|
|
getRoleAssetSummaryById,
|
|
rebuildRoleAssetCoverage,
|
|
mergeRoleAssetIntoDraftProfile,
|
|
} from './customWorldAgentRoleAssetStateService.js';
|
|
|
|
function toText(value: unknown) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function toRecord(value: unknown) {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function toRecordArray(value: unknown) {
|
|
return Array.isArray(value)
|
|
? value.filter(
|
|
(item): item is Record<string, unknown> =>
|
|
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
|
|
)
|
|
: [];
|
|
}
|
|
|
|
type SyncRoleAssetsPayload = {
|
|
roleId: string;
|
|
portraitPath: string;
|
|
generatedVisualAssetId: string;
|
|
generatedAnimationSetId?: string | null;
|
|
animationMap?: Record<string, unknown> | null;
|
|
};
|
|
|
|
type SceneKind = 'camp' | 'landmark';
|
|
|
|
type SyncSceneAssetsPayload = {
|
|
sceneId: string;
|
|
sceneKind: SceneKind;
|
|
imageSrc: string;
|
|
generatedSceneAssetId: string;
|
|
generatedScenePrompt?: string | null;
|
|
generatedSceneModel?: string | null;
|
|
};
|
|
|
|
export type SyncRoleAssetsResult = {
|
|
roleId: string;
|
|
updatedRole: Record<string, unknown>;
|
|
updatedAssetSummary: CustomWorldRoleAssetSummary;
|
|
draftProfile: Record<string, unknown>;
|
|
};
|
|
|
|
export type SceneAssetStudioContext = {
|
|
sceneId: string;
|
|
sceneKind: SceneKind;
|
|
sceneName: string;
|
|
sceneDescription: string;
|
|
imageSrc: string | null;
|
|
readyActCount: number;
|
|
missingActCount: number;
|
|
};
|
|
|
|
export type SyncSceneAssetsResult = {
|
|
sceneId: string;
|
|
sceneKind: SceneKind;
|
|
updatedScene: Record<string, unknown>;
|
|
updatedAssetSummaries: CustomWorldSceneAssetSummary[];
|
|
draftProfile: Record<string, unknown>;
|
|
};
|
|
|
|
function cloneRecord<T extends Record<string, unknown>>(value: T): T {
|
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
}
|
|
|
|
function toSceneDescription(scene: Record<string, unknown>, sceneKind: SceneKind) {
|
|
if (sceneKind === 'camp') {
|
|
return (
|
|
toText(scene.description) ||
|
|
toText(scene.summary) ||
|
|
toText(scene.mood)
|
|
);
|
|
}
|
|
|
|
return (
|
|
toText(scene.description) ||
|
|
toText(scene.summary) ||
|
|
toText(scene.purpose) ||
|
|
toText(scene.mood)
|
|
);
|
|
}
|
|
|
|
function findSceneActsBySceneId(
|
|
draftProfile: Record<string, unknown>,
|
|
sceneId: string,
|
|
) {
|
|
return toRecordArray(draftProfile.sceneChapters)
|
|
.filter((chapter) => toText(chapter.sceneId) === sceneId)
|
|
.flatMap((chapter) => toRecordArray(chapter.acts));
|
|
}
|
|
|
|
function updateSceneChapterActsForScene(params: {
|
|
draftProfile: Record<string, unknown>;
|
|
sceneId: string;
|
|
imageSrc: string;
|
|
generatedSceneAssetId: string;
|
|
}) {
|
|
return toRecordArray(params.draftProfile.sceneChapters).map((chapter) => {
|
|
if (toText(chapter.sceneId) !== params.sceneId) {
|
|
return chapter;
|
|
}
|
|
|
|
return {
|
|
...chapter,
|
|
acts: toRecordArray(chapter.acts).map((act) => ({
|
|
...act,
|
|
backgroundImageSrc: params.imageSrc,
|
|
backgroundAssetId: params.generatedSceneAssetId,
|
|
})),
|
|
} satisfies Record<string, unknown>;
|
|
});
|
|
}
|
|
|
|
function buildSceneAssetFallbackSummary(params: {
|
|
sceneId: string;
|
|
sceneKind: SceneKind;
|
|
updatedScene: Record<string, unknown>;
|
|
imageSrc: string;
|
|
generatedSceneAssetId: string;
|
|
}) {
|
|
return {
|
|
sceneId: params.sceneId,
|
|
sceneName:
|
|
toText(params.updatedScene.name) ||
|
|
(params.sceneKind === 'camp' ? '开局营地' : '未命名场景'),
|
|
actId: null,
|
|
actTitle: params.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图',
|
|
imageSrc: params.imageSrc,
|
|
assetId: params.generatedSceneAssetId,
|
|
status: 'ready',
|
|
nextPointCost: 0,
|
|
} satisfies CustomWorldSceneAssetSummary;
|
|
}
|
|
|
|
export class CustomWorldAgentAssetBridgeService {
|
|
buildRoleAssetStudioContext(snapshot: unknown, roleId: string) {
|
|
const profile = toRecord(snapshot);
|
|
if (!profile) {
|
|
throw new Error('当前世界草稿为空,无法打开角色资产工坊。');
|
|
}
|
|
|
|
const playableRole = toRecordArray(profile.playableNpcs).find(
|
|
(item) => toText(item.id) === roleId,
|
|
);
|
|
const storyRole = toRecordArray(profile.storyNpcs).find(
|
|
(item) => toText(item.id) === roleId,
|
|
);
|
|
const role = playableRole ?? storyRole;
|
|
if (!role) {
|
|
throw new Error('未找到目标角色,无法进入角色资产工坊。');
|
|
}
|
|
|
|
const assetSummary = getRoleAssetSummaryById(profile, roleId);
|
|
if (!assetSummary) {
|
|
throw new Error('未找到目标角色的资产摘要。');
|
|
}
|
|
|
|
return {
|
|
roleId,
|
|
roleName: toText(role.name) || assetSummary.roleName,
|
|
roleKind: playableRole ? ('playable' as const) : ('story' as const),
|
|
startFrom:
|
|
assetSummary.status === 'missing' ? ('visual' as const) : ('animation' as const),
|
|
assetSummary,
|
|
};
|
|
}
|
|
|
|
applyRoleAssetPublishResult(
|
|
snapshot: unknown,
|
|
payload: SyncRoleAssetsPayload,
|
|
): SyncRoleAssetsResult {
|
|
const profile = toRecord(snapshot);
|
|
if (!profile) {
|
|
throw new Error('当前世界草稿为空,无法同步角色资产。');
|
|
}
|
|
|
|
const { draftProfile, updatedRole } = mergeRoleAssetIntoDraftProfile(
|
|
profile,
|
|
payload,
|
|
);
|
|
const assetSummary = getRoleAssetSummaryById(draftProfile, payload.roleId);
|
|
if (!assetSummary) {
|
|
throw new Error('角色资产同步后未能生成新的资产摘要。');
|
|
}
|
|
|
|
return {
|
|
roleId: payload.roleId,
|
|
updatedRole,
|
|
updatedAssetSummary: assetSummary,
|
|
draftProfile,
|
|
};
|
|
}
|
|
|
|
buildSceneAssetStudioContext(
|
|
snapshot: unknown,
|
|
sceneId: string,
|
|
sceneKind: SceneKind,
|
|
): SceneAssetStudioContext {
|
|
const profile = toRecord(snapshot);
|
|
if (!profile) {
|
|
throw new Error('当前世界草稿为空,无法打开场景资产工坊。');
|
|
}
|
|
|
|
const scene =
|
|
sceneKind === 'camp'
|
|
? toRecord(profile.camp)
|
|
: toRecordArray(profile.landmarks).find(
|
|
(item) => toText(item.id) === sceneId,
|
|
) ?? null;
|
|
if (!scene) {
|
|
throw new Error('未找到目标场景,无法进入场景资产工坊。');
|
|
}
|
|
|
|
const sceneActs = findSceneActsBySceneId(profile, sceneId);
|
|
const readyActCount = sceneActs.filter((act) =>
|
|
Boolean(toText(act.backgroundImageSrc) || toText(act.backgroundAssetId)),
|
|
).length;
|
|
|
|
return {
|
|
sceneId,
|
|
sceneKind,
|
|
sceneName:
|
|
toText(scene.name) || (sceneKind === 'camp' ? '开局营地' : '未命名场景'),
|
|
sceneDescription: toSceneDescription(scene, sceneKind),
|
|
imageSrc: toText(scene.imageSrc) || null,
|
|
readyActCount,
|
|
missingActCount: Math.max(0, sceneActs.length - readyActCount),
|
|
};
|
|
}
|
|
|
|
applySceneAssetPublishResult(
|
|
snapshot: unknown,
|
|
payload: SyncSceneAssetsPayload,
|
|
): SyncSceneAssetsResult {
|
|
const profile = toRecord(snapshot);
|
|
if (!profile) {
|
|
throw new Error('当前世界草稿为空,无法同步场景资产。');
|
|
}
|
|
|
|
const nextDraftProfile = cloneRecord(profile);
|
|
let updatedScene: Record<string, unknown> | null = null;
|
|
|
|
if (payload.sceneKind === 'camp') {
|
|
const currentCamp = toRecord(nextDraftProfile.camp);
|
|
if (!currentCamp || toText(currentCamp.id) !== payload.sceneId) {
|
|
throw new Error('目标营地不存在,无法同步场景资产。');
|
|
}
|
|
|
|
updatedScene = {
|
|
...currentCamp,
|
|
imageSrc: payload.imageSrc,
|
|
generatedSceneAssetId: payload.generatedSceneAssetId,
|
|
generatedScenePrompt: payload.generatedScenePrompt ?? null,
|
|
generatedSceneModel: payload.generatedSceneModel ?? null,
|
|
};
|
|
nextDraftProfile.camp = updatedScene;
|
|
} else {
|
|
let touched = false;
|
|
nextDraftProfile.landmarks = toRecordArray(nextDraftProfile.landmarks).map(
|
|
(item) => {
|
|
if (toText(item.id) !== payload.sceneId) {
|
|
return item;
|
|
}
|
|
|
|
touched = true;
|
|
updatedScene = {
|
|
...item,
|
|
imageSrc: payload.imageSrc,
|
|
generatedSceneAssetId: payload.generatedSceneAssetId,
|
|
generatedScenePrompt: payload.generatedScenePrompt ?? null,
|
|
generatedSceneModel: payload.generatedSceneModel ?? null,
|
|
};
|
|
return updatedScene;
|
|
},
|
|
);
|
|
|
|
if (!touched || !updatedScene) {
|
|
throw new Error('目标地点不存在,无法同步场景资产。');
|
|
}
|
|
}
|
|
|
|
nextDraftProfile.sceneChapters = updateSceneChapterActsForScene({
|
|
draftProfile: nextDraftProfile,
|
|
sceneId: payload.sceneId,
|
|
imageSrc: payload.imageSrc,
|
|
generatedSceneAssetId: payload.generatedSceneAssetId,
|
|
});
|
|
|
|
const updatedAssetSummaries = rebuildRoleAssetCoverage(
|
|
nextDraftProfile,
|
|
).sceneAssets.filter((entry) => entry.sceneId === payload.sceneId);
|
|
|
|
return {
|
|
sceneId: payload.sceneId,
|
|
sceneKind: payload.sceneKind,
|
|
updatedScene: updatedScene ?? {},
|
|
updatedAssetSummaries:
|
|
updatedAssetSummaries.length > 0
|
|
? updatedAssetSummaries
|
|
: [
|
|
buildSceneAssetFallbackSummary({
|
|
sceneId: payload.sceneId,
|
|
sceneKind: payload.sceneKind,
|
|
updatedScene: updatedScene ?? {},
|
|
imageSrc: payload.imageSrc,
|
|
generatedSceneAssetId: payload.generatedSceneAssetId,
|
|
}),
|
|
],
|
|
draftProfile: nextDraftProfile,
|
|
};
|
|
}
|
|
}
|