1
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
// Temporary bridge for legacy pure build calculation logic from src/**.
|
||||
export { getEquipmentBonuses } from '../modules/runtime/runtimeEquipmentModule.js';
|
||||
export {
|
||||
getPlayerBuildDamageBreakdown,
|
||||
resolvePlayerOutgoingDamageResult,
|
||||
} from '../modules/runtime/runtimeBuildModule.js';
|
||||
@@ -1,8 +0,0 @@
|
||||
// Temporary bridge for legacy pure runtime item resolution logic from src/**.
|
||||
export {
|
||||
buildLooseRuntimeItemGenerationContext,
|
||||
buildQuestRuntimeItemGenerationContext,
|
||||
buildDirectedRuntimeReward,
|
||||
buildRuntimeInventoryStock,
|
||||
flattenDirectedRuntimeRewardItems,
|
||||
} from '../modules/runtime-item/runtimeItemModule.js';
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* 兼容期 façade:
|
||||
* 旧的 runtimeProfileCompiler 文件名暂时保留,避免工作包 G 完整拆分后影响仍未迁移的局部导入。
|
||||
* 新实现已经拆到目录模块中,后续新增逻辑禁止继续回写到这个文件。
|
||||
*/
|
||||
export * from './buildAttributeSchema.js';
|
||||
export * from './buildCompiledProfile.js';
|
||||
export * from './creatorIntentBridge.js';
|
||||
export * from './normalizeCamp.js';
|
||||
export * from './normalizeLandmark.js';
|
||||
export * from './normalizeRole.js';
|
||||
export * from './normalizeSceneChapter.js';
|
||||
export * from './normalizeShared.js';
|
||||
@@ -1 +0,0 @@
|
||||
export * from './inventoryMutationService.js';
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './questProgressionService.js';
|
||||
export { generateQuestForNpcEncounter } from '../../services/questService.js';
|
||||
@@ -1,35 +0,0 @@
|
||||
export {
|
||||
buildRpgRuntimeAvailableOptions,
|
||||
buildRpgRuntimeLegacyCurrentStory,
|
||||
buildRpgRuntimeViewModel,
|
||||
} from './RpgRuntimeOptionCompiler.js';
|
||||
export {
|
||||
appendStoryHistory,
|
||||
getEncounterKey,
|
||||
getEncounterNpcState,
|
||||
getPlayerCharacter,
|
||||
getPlayerSkillCooldowns,
|
||||
isCombatFunctionId,
|
||||
isNpcFunctionId,
|
||||
isStoryFunctionId,
|
||||
isTask5FunctionId,
|
||||
isTask6RuntimeFunctionId,
|
||||
MAX_TASK5_COMPANIONS,
|
||||
setEncounterNpcState,
|
||||
TASK6_DEFERRED_FUNCTION_IDS,
|
||||
type RuntimeEncounter,
|
||||
type RuntimeNpcState,
|
||||
type RuntimeSession as RuntimeSessionPrimitives,
|
||||
} from './RpgRuntimeSessionPrimitives.js';
|
||||
export {
|
||||
loadRpgRuntimeSession,
|
||||
type RuntimeSession,
|
||||
} from './RpgRuntimeSessionLoader.js';
|
||||
export {
|
||||
replaceRpgRuntimeSessionRawGameState,
|
||||
syncRpgRuntimeSnapshot,
|
||||
} from './RpgRuntimeSnapshotSync.js';
|
||||
export {
|
||||
resolveRpgRuntimeStoryAction,
|
||||
} from './RpgRuntimeStoryActionService.js';
|
||||
export { getRpgRuntimeStoryState } from './RpgRuntimeStoryStateService.js';
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './runtimeItemResolutionService.js';
|
||||
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';
|
||||
@@ -1,96 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildLooseRuntimeItemGenerationContext,
|
||||
buildQuestRuntimeItemGenerationContext,
|
||||
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
|
||||
import {
|
||||
resolveDirectedReward,
|
||||
resolveRuntimeInventoryStock,
|
||||
} from './runtimeItemResolutionService.js';
|
||||
|
||||
const TEST_WUXIA_WORLD = 'WUXIA' as Parameters<
|
||||
typeof buildLooseRuntimeItemGenerationContext
|
||||
>[0]['worldType'];
|
||||
const TEST_XIANXIA_WORLD = 'XIANXIA' as NonNullable<
|
||||
Parameters<typeof buildQuestRuntimeItemGenerationContext>[0]['context']['worldType']
|
||||
>;
|
||||
|
||||
test('resolveDirectedReward returns flattened runtime reward items on the server side', () => {
|
||||
const context = buildLooseRuntimeItemGenerationContext({
|
||||
worldType: TEST_WUXIA_WORLD,
|
||||
scene: {
|
||||
id: 'scene-ruins',
|
||||
name: '断碑古道',
|
||||
description: '碎碑与旧誓散落在路旁。',
|
||||
treasureHints: ['残匣', '旧祭火'],
|
||||
},
|
||||
encounter: {
|
||||
id: 'treasure-altar',
|
||||
kind: 'treasure',
|
||||
npcName: '断誓秘匣',
|
||||
npcDescription: '匣盖上留着未熄的旧印。',
|
||||
npcAvatar: '',
|
||||
context: '古道祭坛',
|
||||
},
|
||||
playerCharacterId: 'hero',
|
||||
playerBuildTags: ['快剑', '追击'],
|
||||
generationChannel: 'treasure',
|
||||
});
|
||||
|
||||
const result = resolveDirectedReward(context, {
|
||||
seedKey: 'task6:treasure',
|
||||
fixedKinds: ['relic', 'consumable'],
|
||||
fixedPermanence: ['permanent', 'timed'],
|
||||
itemCount: 2,
|
||||
});
|
||||
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.equal(
|
||||
result.reward.primaryItem?.runtimeMetadata?.generationChannel,
|
||||
'treasure',
|
||||
);
|
||||
assert.equal(result.items[0]?.id, result.reward.primaryItem?.id);
|
||||
assert.ok(result.reward.primaryItem?.description?.includes('构筑'));
|
||||
});
|
||||
|
||||
test('resolveRuntimeInventoryStock builds quest-flavored stock without browser fallback', () => {
|
||||
const context = buildQuestRuntimeItemGenerationContext({
|
||||
context: {
|
||||
worldType: TEST_XIANXIA_WORLD,
|
||||
currentSceneId: 'scene-cloud',
|
||||
currentSceneName: '云阙旧渡',
|
||||
currentSceneDescription: '旧渡口残留着灵潮和巡守痕迹。',
|
||||
issuerNpcId: 'npc-issuer',
|
||||
issuerNpcName: '巡守使',
|
||||
issuerNpcContext: '巡守',
|
||||
issuerAffinity: 24,
|
||||
recentStoryMoments: [],
|
||||
playerCharacter: null,
|
||||
},
|
||||
issuerNpcId: 'npc-issuer',
|
||||
issuerNpcName: '巡守使',
|
||||
roleText: '巡守',
|
||||
scene: {
|
||||
id: 'scene-cloud',
|
||||
name: '云阙旧渡',
|
||||
description: '旧渡口残留着灵潮和巡守痕迹。',
|
||||
treasureHints: ['旧印'],
|
||||
},
|
||||
});
|
||||
|
||||
const items = resolveRuntimeInventoryStock(context, {
|
||||
seedKey: 'task6:quest',
|
||||
fixedKinds: ['equipment', 'consumable'],
|
||||
fixedPermanence: ['permanent', 'timed'],
|
||||
itemCount: 2,
|
||||
});
|
||||
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(
|
||||
items.every((item) => item.runtimeMetadata?.generationChannel === 'quest_reward'),
|
||||
true,
|
||||
);
|
||||
assert.equal(items.some((item) => Boolean(item.buildProfile || item.useProfile)), true);
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import {
|
||||
buildDirectedRuntimeReward,
|
||||
buildRuntimeInventoryStock,
|
||||
flattenDirectedRuntimeRewardItems,
|
||||
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
|
||||
|
||||
export type RuntimeItemGenerationContext = Parameters<
|
||||
typeof buildDirectedRuntimeReward
|
||||
>[0];
|
||||
export type RuntimeRewardOptions = Parameters<
|
||||
typeof buildDirectedRuntimeReward
|
||||
>[1];
|
||||
export type DirectedRuntimeReward = ReturnType<typeof buildDirectedRuntimeReward>;
|
||||
export type ResolvedRuntimeRewardItem = ReturnType<
|
||||
typeof buildRuntimeInventoryStock
|
||||
>[number];
|
||||
|
||||
export type RuntimeRewardResolution = {
|
||||
reward: DirectedRuntimeReward;
|
||||
items: ResolvedRuntimeRewardItem[];
|
||||
};
|
||||
|
||||
export function resolveDirectedReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): RuntimeRewardResolution {
|
||||
const reward = buildDirectedRuntimeReward(context, options);
|
||||
return {
|
||||
reward,
|
||||
items: flattenDirectedRuntimeRewardItems(reward),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeInventoryStock(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): ResolvedRuntimeRewardItem[] {
|
||||
return buildRuntimeInventoryStock(context, options);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export {
|
||||
RpgSaveArchiveRepository,
|
||||
type RpgSaveArchiveRepositoryPort,
|
||||
} from './RpgSaveArchiveRepository.js';
|
||||
export {
|
||||
RpgWorldLibraryRepository,
|
||||
type RpgWorldLibraryRepositoryPort,
|
||||
} from './RpgWorldLibraryRepository.js';
|
||||
@@ -1,8 +0,0 @@
|
||||
export {
|
||||
RpgBrowseHistoryRepository,
|
||||
type RpgBrowseHistoryRepositoryPort,
|
||||
} from './RpgBrowseHistoryRepository.js';
|
||||
export {
|
||||
RpgProfileDashboardRepository,
|
||||
type RpgProfileDashboardRepositoryPort,
|
||||
} from './RpgProfileDashboardRepository.js';
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
RpgRuntimeSnapshotRepository,
|
||||
type RpgRuntimeSnapshotRepositoryPort,
|
||||
} from './RpgRuntimeSnapshotRepository.js';
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest, notFound } from '../errors.js';
|
||||
import { asyncHandler, prepareApiResponse, sendApiResponse } from '../http.js';
|
||||
import { requireJwtAuth } from '../middleware/auth.js';
|
||||
import { routeMeta } from '../middleware/routeMeta.js';
|
||||
|
||||
const createSessionSchema = z.object({
|
||||
@@ -98,6 +99,9 @@ function readParam(param: string | string[] | undefined) {
|
||||
|
||||
export function createCustomWorldAgentRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.post(
|
||||
'/sessions',
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export {
|
||||
createRpgEntrySaveRoutes,
|
||||
RPG_ENTRY_SAVE_ARCHIVE_ROUTE_BASE_PATH,
|
||||
RPG_ENTRY_SAVE_ROUTE_BASE_PATH,
|
||||
} from './rpgEntrySaveRoutes.js';
|
||||
export {
|
||||
createRpgWorldLibraryRoutes,
|
||||
RPG_WORLD_GALLERY_ROUTE_BASE_PATH,
|
||||
RPG_WORLD_LIBRARY_ROUTE_BASE_PATH,
|
||||
RPG_WORLD_WORKS_ROUTE_BASE_PATH,
|
||||
} from './rpgWorldLibraryRoutes.js';
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
createRpgProfileRoutes,
|
||||
RPG_PROFILE_ROUTE_BASE_PATH,
|
||||
} from './rpgProfileRoutes.js';
|
||||
@@ -1,8 +0,0 @@
|
||||
export {
|
||||
createRpgRuntimeAiAssistRoutes,
|
||||
RPG_RUNTIME_AI_ASSIST_ROUTE_BASE_PATH,
|
||||
} from './rpgRuntimeAiAssistRoutes.js';
|
||||
export {
|
||||
createRpgRuntimeStoryRoutes,
|
||||
RPG_RUNTIME_STORY_ROUTE_BASE_PATH,
|
||||
} from './rpgRuntimeStoryRoutes.js';
|
||||
@@ -1 +0,0 @@
|
||||
export { createCustomWorldAgentRoutes as createRpgCreationAgentRoutes } from './customWorldAgent.js';
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import type { AppContext } from '../context.js';
|
||||
|
||||
/**
|
||||
* 工作包 A 先建立 RPG 世界广场路由的命名骨架。
|
||||
* 当前广场查询仍由旧 runtime 路由承载,后续工作包会再迁移实现。
|
||||
*/
|
||||
export const RPG_WORLD_GALLERY_ROUTE_BASE_PATH =
|
||||
'/runtime/custom-world-gallery';
|
||||
|
||||
export function createRpgWorldGalleryRoutes(_context: AppContext) {
|
||||
return Router();
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { createDatabase } from '../db.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { RpgAgentSessionRepository } from '../repositories/RpgAgentSessionRepository.js';
|
||||
import { RpgWorldProfileRepository } from '../repositories/RpgWorldProfileRepository.js';
|
||||
import { CustomWorldAgentSessionStore } from '../services/customWorldAgentSessionStore.js';
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is RecordValue {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is RecordValue => isRecord(entry))
|
||||
: [];
|
||||
}
|
||||
|
||||
function resolvePublicAssetPath(publicDir: string, imageSrc: unknown) {
|
||||
const normalizedImageSrc = toText(imageSrc);
|
||||
if (!normalizedImageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.join(publicDir, normalizedImageSrc.replace(/^\/+/u, ''));
|
||||
}
|
||||
|
||||
async function ensureSquareRoleImage(publicDir: string, imageSrc: unknown) {
|
||||
const assetPath = resolvePublicAssetPath(publicDir, imageSrc);
|
||||
if (!assetPath || !fs.existsSync(assetPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = await sharp(assetPath).metadata();
|
||||
if (
|
||||
typeof metadata.width === 'number' &&
|
||||
typeof metadata.height === 'number' &&
|
||||
metadata.width === metadata.height
|
||||
) {
|
||||
return {
|
||||
imageSrc: toText(imageSrc),
|
||||
updated: false,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
};
|
||||
}
|
||||
|
||||
const squaredBuffer = await sharp(assetPath)
|
||||
.resize(1024, 1024, {
|
||||
fit: 'cover',
|
||||
position: 'attention',
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
fs.writeFileSync(assetPath, squaredBuffer);
|
||||
|
||||
return {
|
||||
imageSrc: toText(imageSrc),
|
||||
updated: true,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const userId = 'user_02b1dea4e951b13fe53db236560bdf28';
|
||||
const sessionId = 'custom-world-agent-session-019e192a4060d18b92df127f1dafe8ae';
|
||||
const profileId = 'custom-world-mo744tca-深海奇境';
|
||||
const config = loadConfig({
|
||||
projectRoot: path.resolve(process.cwd(), '..'),
|
||||
});
|
||||
const db = await createDatabase(config);
|
||||
|
||||
try {
|
||||
const rpgAgentSessionRepository = new RpgAgentSessionRepository(db);
|
||||
const rpgWorldProfileRepository = new RpgWorldProfileRepository(db);
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const session = await sessionStore.getSnapshot(userId, sessionId);
|
||||
if (!session || !isRecord(session.draftProfile)) {
|
||||
throw new Error('未找到目标世界草稿 session,无法同步历史保存档案。');
|
||||
}
|
||||
|
||||
const savedProfileEntry = (
|
||||
await rpgWorldProfileRepository.listOwnProfiles(userId)
|
||||
).find((entry) => entry.profileId === profileId);
|
||||
if (!savedProfileEntry) {
|
||||
throw new Error('未找到目标 saved profile,无法同步历史保存档案。');
|
||||
}
|
||||
|
||||
const draftProfile = session.draftProfile;
|
||||
const nextProfile = JSON.parse(
|
||||
JSON.stringify(savedProfileEntry.profile),
|
||||
) as RecordValue;
|
||||
|
||||
const draftPlayableById = new Map(
|
||||
toRecordArray(draftProfile.playableNpcs).map((entry) => [toText(entry.id), entry] as const),
|
||||
);
|
||||
const draftStoryById = new Map(
|
||||
toRecordArray(draftProfile.storyNpcs).map((entry) => [toText(entry.id), entry] as const),
|
||||
);
|
||||
const draftLandmarkById = new Map(
|
||||
toRecordArray(draftProfile.landmarks).map((entry) => [toText(entry.id), entry] as const),
|
||||
);
|
||||
const draftSceneChapterBySceneId = new Map(
|
||||
toRecordArray(draftProfile.sceneChapters).map((entry) => [toText(entry.sceneId), entry] as const),
|
||||
);
|
||||
|
||||
const playableNpcs = toRecordArray(nextProfile.playableNpcs).map((role) => {
|
||||
const draftRole = draftPlayableById.get(toText(role.id));
|
||||
if (!draftRole) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
imageSrc: toText(draftRole.imageSrc) || role.imageSrc,
|
||||
generatedVisualAssetId:
|
||||
toText(draftRole.generatedVisualAssetId) || role.generatedVisualAssetId,
|
||||
};
|
||||
});
|
||||
|
||||
const storyNpcs = toRecordArray(nextProfile.storyNpcs).map((role) => {
|
||||
const draftRole = draftStoryById.get(toText(role.id));
|
||||
if (!draftRole) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
imageSrc: toText(draftRole.imageSrc) || role.imageSrc,
|
||||
generatedVisualAssetId:
|
||||
toText(draftRole.generatedVisualAssetId) || role.generatedVisualAssetId,
|
||||
};
|
||||
});
|
||||
|
||||
const landmarks = toRecordArray(nextProfile.landmarks).map((landmark) => {
|
||||
const draftLandmark = draftLandmarkById.get(toText(landmark.id));
|
||||
const draftSceneChapter = draftSceneChapterBySceneId.get(toText(landmark.id));
|
||||
const firstActImageSrc =
|
||||
toRecordArray(draftSceneChapter?.acts)
|
||||
.map((act) => toText(act.backgroundImageSrc))
|
||||
.find(Boolean) || '';
|
||||
|
||||
return {
|
||||
...landmark,
|
||||
imageSrc:
|
||||
toText(draftLandmark?.imageSrc) ||
|
||||
firstActImageSrc ||
|
||||
toText(landmark.imageSrc) ||
|
||||
undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const sceneChapterBlueprints = toRecordArray(
|
||||
nextProfile.sceneChapterBlueprints,
|
||||
).map((chapter) => {
|
||||
const draftChapter = draftSceneChapterBySceneId.get(toText(chapter.sceneId));
|
||||
if (!draftChapter) {
|
||||
return chapter;
|
||||
}
|
||||
|
||||
const draftActById = new Map(
|
||||
toRecordArray(draftChapter.acts).map((act) => [toText(act.id), act] as const),
|
||||
);
|
||||
|
||||
return {
|
||||
...chapter,
|
||||
acts: toRecordArray(chapter.acts).map((act) => {
|
||||
const draftAct = draftActById.get(toText(act.id));
|
||||
if (!draftAct) {
|
||||
return act;
|
||||
}
|
||||
|
||||
return {
|
||||
...act,
|
||||
backgroundImageSrc:
|
||||
toText(draftAct.backgroundImageSrc) || act.backgroundImageSrc,
|
||||
backgroundAssetId:
|
||||
toText(draftAct.backgroundAssetId) || act.backgroundAssetId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const roleImageUpdates = await Promise.all(
|
||||
[...playableNpcs, ...storyNpcs].map((role) =>
|
||||
ensureSquareRoleImage(config.publicDir, role.imageSrc),
|
||||
),
|
||||
);
|
||||
|
||||
nextProfile.playableNpcs = playableNpcs;
|
||||
nextProfile.storyNpcs = storyNpcs;
|
||||
nextProfile.landmarks = landmarks;
|
||||
nextProfile.sceneChapterBlueprints = sceneChapterBlueprints;
|
||||
|
||||
const updatedEntry = await rpgWorldProfileRepository.upsertOwnProfile(
|
||||
userId,
|
||||
profileId,
|
||||
nextProfile,
|
||||
savedProfileEntry.authorDisplayName || '玩家',
|
||||
);
|
||||
|
||||
const summary = {
|
||||
profileId,
|
||||
syncedPlayableCount: playableNpcs.length,
|
||||
syncedStoryCount: storyNpcs.length,
|
||||
syncedLandmarkCount: landmarks.length,
|
||||
syncedSceneChapterCount: sceneChapterBlueprints.length,
|
||||
squareRoleImagesUpdated: roleImageUpdates.filter((entry) => entry?.updated).length,
|
||||
coverImageSrc: updatedEntry.entry.coverImageSrc,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { CustomWorldAgentOrchestrator as RpgAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
@@ -1,5 +0,0 @@
|
||||
export type { CustomWorldAgentSessionRecord as RpgAgentSessionRecord } from './customWorldAgentSessionStore.js';
|
||||
export {
|
||||
CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX as RPG_AGENT_SESSION_ID_PREFIX,
|
||||
CustomWorldAgentSessionStore as RpgAgentSessionStore,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
@@ -13,7 +13,7 @@ import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'
|
||||
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
|
||||
|
||||
async function waitForOperation(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
@@ -217,10 +217,10 @@ test('phase2 work summaries compile draft title and summary from creator intent'
|
||||
update.operation.operationId,
|
||||
);
|
||||
|
||||
const items = await listCustomWorldWorkSummaries(userId, {
|
||||
rpgWorldProfiles: rpgWorldProfileRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const items = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
const draft = items.find(
|
||||
(item) => item.sessionId === createdSession.sessionId,
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'
|
||||
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
|
||||
|
||||
function createAutoAssetTestConfig(testName: string): AppConfig {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
@@ -368,10 +368,10 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
|
||||
response.operation.operationId,
|
||||
);
|
||||
|
||||
const items = await listCustomWorldWorkSummaries(userId, {
|
||||
rpgWorldProfiles: rpgWorldProfileRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const items = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
const draft = items.find((item) => item.sessionId === readySession.sessionId);
|
||||
const compiledProfile = normalizeFoundationDraftProfile(
|
||||
(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'
|
||||
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
|
||||
|
||||
async function waitForOperation(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
@@ -552,10 +552,10 @@ test('phase4 generate_characters appends story npcs and updates work summary cou
|
||||
[...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id),
|
||||
),
|
||||
].length;
|
||||
const workItems = await listCustomWorldWorkSummaries(userId, {
|
||||
rpgWorldProfiles: rpgWorldProfileRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const workItems = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
const draftItem = workItems.find((item) => item.sessionId === session.sessionId);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
@@ -641,10 +641,10 @@ test('phase4 work summaries exclude library draft entries after phase3 downgrade
|
||||
'玩家',
|
||||
);
|
||||
|
||||
const workItems = await listCustomWorldWorkSummaries(userId, {
|
||||
rpgWorldProfiles: rpgWorldProfileRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const workItems = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
|
||||
assert.ok(workItems.some((item) => item.sessionId === session.sessionId));
|
||||
assert.equal(
|
||||
@@ -688,10 +688,10 @@ test('phase4 work summaries hide published agent sessions from draft lane and ke
|
||||
'玩家',
|
||||
);
|
||||
|
||||
const workItems = await listCustomWorldWorkSummaries(userId, {
|
||||
rpgWorldProfiles: rpgWorldProfileRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const workItems = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list(userId);
|
||||
const draftItem = workItems.find((item) => item.sessionId === session.sessionId);
|
||||
const publishedItem = workItems.find(
|
||||
(item) => item.profileId === `agent-draft-${session.sessionId}`,
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
|
||||
export type CustomWorldAgentPublishGateResult = {
|
||||
blockers: CustomWorldAgentSessionSnapshot['qualityFindings'];
|
||||
warnings: CustomWorldAgentSessionSnapshot['qualityFindings'];
|
||||
};
|
||||
|
||||
function buildFinding(params: {
|
||||
id: string;
|
||||
code: string;
|
||||
severity: 'warning' | 'blocker';
|
||||
targetId?: string | null;
|
||||
message: string;
|
||||
}) {
|
||||
return {
|
||||
id: params.id,
|
||||
code: params.code,
|
||||
severity: params.severity,
|
||||
targetId: params.targetId ?? null,
|
||||
message: params.message,
|
||||
} satisfies CustomWorldAgentSessionSnapshot['qualityFindings'][number];
|
||||
}
|
||||
|
||||
export class CustomWorldAgentPublishGateService {
|
||||
evaluate(params: {
|
||||
draftProfile: unknown;
|
||||
qualityFindings: CustomWorldAgentSessionSnapshot['qualityFindings'];
|
||||
}): CustomWorldAgentPublishGateResult {
|
||||
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
const blockers = params.qualityFindings.filter(
|
||||
(entry) => entry.severity === 'blocker',
|
||||
);
|
||||
const warnings = params.qualityFindings.filter(
|
||||
(entry) => entry.severity === 'warning',
|
||||
);
|
||||
|
||||
if (!draftProfile) {
|
||||
return {
|
||||
blockers: [
|
||||
...blockers,
|
||||
buildFinding({
|
||||
id: 'publish-empty-draft',
|
||||
code: 'publish_empty_draft',
|
||||
severity: 'blocker',
|
||||
message: '当前还没有可发布的世界底稿,请先整理世界骨架。',
|
||||
}),
|
||||
],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if ((draftProfile.chapters?.length ?? 0) <= 0) {
|
||||
blockers.push(
|
||||
buildFinding({
|
||||
id: 'publish-missing-main-chapter',
|
||||
code: 'publish_missing_main_chapter',
|
||||
severity: 'blocker',
|
||||
message: '发布前至少需要保留主线第一幕,当前世界还缺少章节草稿。',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const missingRoleVisuals = [
|
||||
...draftProfile.playableNpcs,
|
||||
...draftProfile.storyNpcs,
|
||||
].filter(
|
||||
(entry) =>
|
||||
!entry.generatedVisualAssetId?.trim() ||
|
||||
!entry.generatedAnimationSetId?.trim(),
|
||||
);
|
||||
if (missingRoleVisuals.length > 0) {
|
||||
blockers.push(
|
||||
buildFinding({
|
||||
id: 'publish-role-assets-incomplete',
|
||||
code: 'publish_role_assets_incomplete',
|
||||
severity: 'blocker',
|
||||
targetId: missingRoleVisuals[0]?.id ?? null,
|
||||
message: '仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!draftProfile.camp?.imageSrc?.trim() ||
|
||||
!(draftProfile.camp as Record<string, unknown> | null)?.generatedSceneAssetId
|
||||
) {
|
||||
blockers.push(
|
||||
buildFinding({
|
||||
id: 'publish-camp-scene-missing',
|
||||
code: 'publish_camp_scene_missing',
|
||||
severity: 'blocker',
|
||||
targetId: draftProfile.camp?.id ?? null,
|
||||
message: '营地还缺少正式场景图资产,发布前需要先确认营地图。',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const missingLandmarkScenes = draftProfile.landmarks.filter((entry) => {
|
||||
const record = entry as Record<string, unknown>;
|
||||
return (
|
||||
!entry.imageSrc?.trim() || !String(record.generatedSceneAssetId ?? '').trim()
|
||||
);
|
||||
});
|
||||
if (missingLandmarkScenes.length > 0) {
|
||||
blockers.push(
|
||||
buildFinding({
|
||||
id: 'publish-landmark-scenes-missing',
|
||||
code: 'publish_landmark_scenes_missing',
|
||||
severity: 'blocker',
|
||||
targetId: missingLandmarkScenes[0]?.id ?? null,
|
||||
message: '仍有地点缺少正式场景图资产,发布前需要先补齐地点图。',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const invalidSceneChapters = draftProfile.sceneChapters.filter(
|
||||
(entry) =>
|
||||
entry.linkedThreadIds.length <= 0 ||
|
||||
entry.acts.every((act) => act.encounterNpcIds.length <= 0),
|
||||
);
|
||||
if (invalidSceneChapters.length > 0) {
|
||||
blockers.push(
|
||||
buildFinding({
|
||||
id: 'publish-scene-chapter-unbound',
|
||||
code: 'publish_scene_chapter_unbound',
|
||||
severity: 'blocker',
|
||||
targetId: invalidSceneChapters[0]?.id ?? null,
|
||||
message: '场景章节还没有绑定足够的线程或角色,发布前请先补齐主线挂钩。',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
blockers,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import type {
|
||||
CustomWorldAgentSessionRecord,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
import {
|
||||
buildCompiledCustomWorldProfile,
|
||||
} from '../modules/custom-world/runtimeProfile.js';
|
||||
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
|
||||
import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js';
|
||||
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
|
||||
function toText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, max = 8) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
|
||||
0,
|
||||
max,
|
||||
);
|
||||
}
|
||||
|
||||
function buildSettingTextFromSession(session: CustomWorldAgentSessionRecord) {
|
||||
const anchorContent = session.anchorContent;
|
||||
|
||||
const anchorLines = [
|
||||
anchorContent.worldPromise
|
||||
? `世界承诺:${[
|
||||
anchorContent.worldPromise.hook,
|
||||
anchorContent.worldPromise.differentiator,
|
||||
anchorContent.worldPromise.desiredExperience,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';')}`
|
||||
: '',
|
||||
anchorContent.playerFantasy
|
||||
? `玩家幻想:${[
|
||||
anchorContent.playerFantasy.playerRole,
|
||||
anchorContent.playerFantasy.corePursuit,
|
||||
anchorContent.playerFantasy.fearOfLoss,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';')}`
|
||||
: '',
|
||||
anchorContent.coreConflict
|
||||
? `核心冲突:${[
|
||||
anchorContent.coreConflict.surfaceConflicts.join('、'),
|
||||
anchorContent.coreConflict.hiddenCrisis,
|
||||
anchorContent.coreConflict.firstTouchedConflict,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';')}`
|
||||
: '',
|
||||
anchorContent.iconicElements
|
||||
? `标志元素:${[
|
||||
anchorContent.iconicElements.iconicMotifs.join('、'),
|
||||
anchorContent.iconicElements.institutionsOrArtifacts.join('、'),
|
||||
anchorContent.iconicElements.hardRules.join('、'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';')}`
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (anchorLines.length > 0) {
|
||||
return anchorLines.join('\n');
|
||||
}
|
||||
|
||||
return session.seedText.trim() || '当前世界草稿已经进入发布阶段。';
|
||||
}
|
||||
|
||||
function buildRuntimeRoleFromDraft(
|
||||
draftRole: Record<string, unknown>,
|
||||
roleKind: 'playable' | 'story',
|
||||
index: number,
|
||||
) {
|
||||
const name = toText(draftRole.name) || `角色 ${index + 1}`;
|
||||
const title =
|
||||
toText(draftRole.title) ||
|
||||
toText(draftRole.role) ||
|
||||
(roleKind === 'playable' ? '关键角色' : '场景角色');
|
||||
const role = toText(draftRole.role) || title;
|
||||
|
||||
return {
|
||||
id: toText(draftRole.id) || `${roleKind}-draft-${index + 1}`,
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description:
|
||||
toText(draftRole.summary) ||
|
||||
toText(draftRole.publicIdentity) ||
|
||||
toText(draftRole.publicMask) ||
|
||||
toText(draftRole.currentPressure),
|
||||
backstory: [
|
||||
toText(draftRole.publicIdentity),
|
||||
toText(draftRole.currentPressure),
|
||||
toText(draftRole.hiddenHook)
|
||||
? `暗线:${toText(draftRole.hiddenHook)}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
personality:
|
||||
toText(draftRole.publicMask) ||
|
||||
toText(draftRole.publicIdentity) ||
|
||||
toText(draftRole.summary),
|
||||
motivation:
|
||||
toText(draftRole.relationToPlayer) ||
|
||||
toText(draftRole.currentPressure) ||
|
||||
toText(draftRole.hiddenHook),
|
||||
combatStyle: role,
|
||||
initialAffinity: roleKind === 'playable' ? 18 : 6,
|
||||
relationshipHooks: [
|
||||
toText(draftRole.relationToPlayer),
|
||||
toText(draftRole.currentPressure),
|
||||
toText(draftRole.hiddenHook),
|
||||
].filter(Boolean),
|
||||
tags: [
|
||||
...toStringArray(draftRole.threadIds, 4),
|
||||
roleKind === 'playable' ? '草稿主角' : '草稿角色',
|
||||
],
|
||||
imageSrc: toText(draftRole.imageSrc) || undefined,
|
||||
generatedVisualAssetId:
|
||||
toText(draftRole.generatedVisualAssetId) || undefined,
|
||||
generatedAnimationSetId:
|
||||
toText(draftRole.generatedAnimationSetId) || undefined,
|
||||
animationMap: isRecord(draftRole.animationMap)
|
||||
? draftRole.animationMap
|
||||
: undefined,
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildRuntimeLandmarkFromDraft(
|
||||
draftLandmark: Record<string, unknown>,
|
||||
storyNpcIdSet: Set<string>,
|
||||
index: number,
|
||||
) {
|
||||
return {
|
||||
id: toText(draftLandmark.id) || `landmark-draft-${index + 1}`,
|
||||
name: toText(draftLandmark.name) || `关键地点 ${index + 1}`,
|
||||
description:
|
||||
toText(draftLandmark.description) ||
|
||||
toText(draftLandmark.summary) ||
|
||||
[toText(draftLandmark.purpose), toText(draftLandmark.mood)]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
dangerLevel:
|
||||
toText(draftLandmark.dangerLevel) ||
|
||||
toText(draftLandmark.importance) ||
|
||||
toText(draftLandmark.mood) ||
|
||||
'medium',
|
||||
imageSrc: toText(draftLandmark.imageSrc) || undefined,
|
||||
generatedSceneAssetId:
|
||||
toText(draftLandmark.generatedSceneAssetId) || undefined,
|
||||
generatedScenePrompt:
|
||||
toText(draftLandmark.generatedScenePrompt) || undefined,
|
||||
generatedSceneModel:
|
||||
toText(draftLandmark.generatedSceneModel) || undefined,
|
||||
sceneNpcIds: toStringArray(draftLandmark.characterIds).filter((entry) =>
|
||||
storyNpcIdSet.has(entry),
|
||||
),
|
||||
connections: [],
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildRuntimeCampFromDraft(draftCamp: Record<string, unknown> | null) {
|
||||
if (!draftCamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = toText(draftCamp.name);
|
||||
const description = toText(draftCamp.description);
|
||||
if (!name && !description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(draftCamp.id) || 'camp-home',
|
||||
name: name || '开局营地',
|
||||
description: description || '当前世界的开局落脚点。',
|
||||
dangerLevel:
|
||||
toText(draftCamp.dangerLevel) || toText(draftCamp.mood) || 'low',
|
||||
imageSrc: toText(draftCamp.imageSrc) || undefined,
|
||||
generatedSceneAssetId:
|
||||
toText(draftCamp.generatedSceneAssetId) || undefined,
|
||||
generatedScenePrompt:
|
||||
toText(draftCamp.generatedScenePrompt) || undefined,
|
||||
generatedSceneModel:
|
||||
toText(draftCamp.generatedSceneModel) || undefined,
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildRuntimeSceneChaptersFromDraft(
|
||||
draftProfile: Record<string, unknown>,
|
||||
storyNpcIdSet: Set<string>,
|
||||
landmarkIdSet: Set<string>,
|
||||
) {
|
||||
return toRecordArray(draftProfile.sceneChapters)
|
||||
.map((sceneChapter, chapterIndex) => {
|
||||
const sceneId = toText(sceneChapter.sceneId);
|
||||
if (!sceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const acts = toRecordArray(sceneChapter.acts)
|
||||
.map((act, actIndex) => {
|
||||
const encounterNpcIds = toStringArray(act.encounterNpcIds).filter(
|
||||
(entry) => storyNpcIdSet.has(entry),
|
||||
);
|
||||
const primaryNpcId =
|
||||
toText(act.primaryNpcId) || encounterNpcIds[0] || '';
|
||||
|
||||
return {
|
||||
id: toText(act.id) || `scene-act-${sceneId}-${actIndex + 1}`,
|
||||
sceneId,
|
||||
title: toText(act.title) || `第 ${actIndex + 1} 幕`,
|
||||
summary:
|
||||
toText(act.summary) ||
|
||||
toText(act.actGoal) ||
|
||||
`围绕${toText(sceneChapter.sceneName, sceneId)}继续推进`,
|
||||
stageCoverage:
|
||||
toStringArray(act.stageCoverage).length > 0
|
||||
? toStringArray(act.stageCoverage)
|
||||
: actIndex === 0
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc:
|
||||
toText(act.backgroundImageSrc) || undefined,
|
||||
backgroundAssetId: toText(act.backgroundAssetId) || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId,
|
||||
linkedThreadIds: toStringArray(act.linkedThreadIds, 8),
|
||||
advanceRule:
|
||||
toText(act.advanceRule) || 'after_active_step_complete',
|
||||
actGoal: toText(act.actGoal),
|
||||
transitionHook: toText(act.transitionHook),
|
||||
} satisfies Record<string, unknown>;
|
||||
})
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.encounterNpcIds.length > 0 || entry.backgroundImageSrc,
|
||||
);
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(sceneChapter.id) ||
|
||||
`scene-chapter-${sceneId}-${chapterIndex + 1}`,
|
||||
sceneId,
|
||||
title:
|
||||
toText(sceneChapter.title) ||
|
||||
toText(sceneChapter.sceneName) ||
|
||||
sceneId,
|
||||
summary:
|
||||
toText(sceneChapter.summary) ||
|
||||
toText(sceneChapter.title) ||
|
||||
toText(sceneChapter.sceneName) ||
|
||||
sceneId,
|
||||
linkedThreadIds: toStringArray(sceneChapter.linkedThreadIds, 8),
|
||||
linkedLandmarkIds: toStringArray(
|
||||
sceneChapter.linkedLandmarkIds,
|
||||
8,
|
||||
).filter((entry) => landmarkIdSet.has(entry)),
|
||||
acts,
|
||||
} satisfies Record<string, unknown>;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildPublishRawProfile(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
profileId: string,
|
||||
) {
|
||||
const draftProfile = isRecord(session.draftProfile) ? session.draftProfile : {};
|
||||
const legacyResultProfile = isRecord(draftProfile.legacyResultProfile)
|
||||
? draftProfile.legacyResultProfile
|
||||
: null;
|
||||
const playableNpcs = toRecordArray(draftProfile.playableNpcs).map(
|
||||
(entry, index) => buildRuntimeRoleFromDraft(entry, 'playable', index),
|
||||
);
|
||||
const storyNpcs = toRecordArray(draftProfile.storyNpcs).map((entry, index) =>
|
||||
buildRuntimeRoleFromDraft(entry, 'story', index),
|
||||
);
|
||||
const storyNpcIdSet = new Set(
|
||||
storyNpcs.map((entry) => toText(entry.id)).filter(Boolean),
|
||||
);
|
||||
const landmarks = toRecordArray(draftProfile.landmarks).map((entry, index) =>
|
||||
buildRuntimeLandmarkFromDraft(entry, storyNpcIdSet, index),
|
||||
);
|
||||
const landmarkIdSet = new Set(
|
||||
landmarks.map((entry) => toText(entry.id)).filter(Boolean),
|
||||
);
|
||||
|
||||
return {
|
||||
...(legacyResultProfile ?? {}),
|
||||
id: profileId,
|
||||
settingText: buildSettingTextFromSession(session),
|
||||
name:
|
||||
toText(draftProfile.name) ||
|
||||
toText(legacyResultProfile?.name) ||
|
||||
'未命名世界底稿',
|
||||
subtitle:
|
||||
toText(draftProfile.subtitle) ||
|
||||
toText(legacyResultProfile?.subtitle) ||
|
||||
'已发布世界',
|
||||
summary:
|
||||
toText(draftProfile.summary) ||
|
||||
toText(legacyResultProfile?.summary) ||
|
||||
'当前世界已经进入发布态。',
|
||||
tone:
|
||||
toText(draftProfile.tone) ||
|
||||
toText(legacyResultProfile?.tone) ||
|
||||
'整体气质仍带着明显张力',
|
||||
playerGoal:
|
||||
toText(draftProfile.playerGoal) ||
|
||||
toText(legacyResultProfile?.playerGoal) ||
|
||||
'先站稳局势,再判断下一步',
|
||||
majorFactions:
|
||||
toStringArray(draftProfile.majorFactions, 6).length > 0
|
||||
? toStringArray(draftProfile.majorFactions, 6)
|
||||
: Array.isArray(legacyResultProfile?.majorFactions)
|
||||
? legacyResultProfile.majorFactions
|
||||
: [],
|
||||
coreConflicts:
|
||||
toStringArray(draftProfile.coreConflicts, 6).length > 0
|
||||
? toStringArray(draftProfile.coreConflicts, 6)
|
||||
: Array.isArray(legacyResultProfile?.coreConflicts)
|
||||
? legacyResultProfile.coreConflicts
|
||||
: [toText(draftProfile.summary) || '核心冲突仍待继续补强'],
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
camp: buildRuntimeCampFromDraft(
|
||||
isRecord(draftProfile.camp) ? draftProfile.camp : null,
|
||||
),
|
||||
sceneChapterBlueprints: buildRuntimeSceneChaptersFromDraft(
|
||||
draftProfile,
|
||||
storyNpcIdSet,
|
||||
landmarkIdSet,
|
||||
),
|
||||
anchorContent: session.anchorContent,
|
||||
creatorIntent: session.creatorIntent,
|
||||
anchorPack: session.anchorPack,
|
||||
lockState: session.lockState,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class CustomWorldAgentPublishService {
|
||||
buildProfileId(sessionId: string) {
|
||||
return `agent-draft-${sessionId}`;
|
||||
}
|
||||
|
||||
compilePublishedProfile(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
): CustomWorldProfile {
|
||||
const profileId = this.buildProfileId(session.sessionId);
|
||||
const rawProfile = buildPublishRawProfile(session, profileId);
|
||||
|
||||
return buildCompiledCustomWorldProfile(
|
||||
rawProfile,
|
||||
buildSettingTextFromSession(session),
|
||||
);
|
||||
}
|
||||
|
||||
async publish(params: {
|
||||
session: CustomWorldAgentSessionRecord;
|
||||
userId: string;
|
||||
authorDisplayName: string;
|
||||
profileRepository: RpgWorldProfileRepositoryPort;
|
||||
}) {
|
||||
const publishedProfile = this.compilePublishedProfile(params.session);
|
||||
const profileId = this.buildProfileId(params.session.sessionId);
|
||||
const mutation = await params.profileRepository.upsertOwnProfile(
|
||||
params.userId,
|
||||
profileId,
|
||||
publishedProfile as unknown as CustomWorldProfileRecord,
|
||||
params.authorDisplayName || '玩家',
|
||||
);
|
||||
const publishedMutation = await params.profileRepository.publishOwnProfile(
|
||||
params.userId,
|
||||
profileId,
|
||||
params.authorDisplayName || '玩家',
|
||||
);
|
||||
|
||||
return {
|
||||
profileId,
|
||||
publishedProfile,
|
||||
mutation: publishedMutation ?? mutation,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
import {
|
||||
CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX,
|
||||
CustomWorldAgentSessionStore,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
|
||||
|
||||
test('work summary service can aggregate shared RPG fixtures into draft and published entries', async () => {
|
||||
const sessionFixture = createRpgAgentSessionFixture();
|
||||
@@ -34,10 +34,10 @@ test('work summary service can aggregate shared RPG fixtures into draft and publ
|
||||
const sessionStore = new CustomWorldAgentSessionStore(
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
const summaries = await listCustomWorldWorkSummaries('fixture-user', {
|
||||
rpgWorldProfiles: rpgWorldProfileRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const summaries = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list('fixture-user');
|
||||
const expected = createRpgCreationWorksResponseFixture();
|
||||
|
||||
assert.equal(summaries.length, expected.items.length);
|
||||
@@ -97,10 +97,10 @@ test('published agent sessions are filtered out after works unify to published p
|
||||
rpgAgentSessionRepository,
|
||||
);
|
||||
|
||||
const summaries = await listCustomWorldWorkSummaries('fixture-user', {
|
||||
rpgWorldProfiles: rpgWorldProfileRepository,
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const summaries = await new RpgWorldWorkSummaryService(
|
||||
rpgWorldProfileRepository,
|
||||
sessionStore,
|
||||
).list('fixture-user');
|
||||
|
||||
assert.equal(
|
||||
summaries.some((entry) => entry.sourceType === 'agent_session'),
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js';
|
||||
import type { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
|
||||
|
||||
/**
|
||||
* 兼容服务入口保留旧文件名,内部则改走 RPG works 组装器,便于后续继续迁到新命名。
|
||||
*/
|
||||
export async function listCustomWorldWorkSummaries(
|
||||
userId: string,
|
||||
dependencies: {
|
||||
rpgWorldProfiles: RpgWorldProfileRepositoryPort;
|
||||
customWorldAgentSessions: CustomWorldAgentSessionStore;
|
||||
},
|
||||
) {
|
||||
const service = new RpgWorldWorkSummaryService(
|
||||
dependencies.rpgWorldProfiles,
|
||||
dependencies.customWorldAgentSessions,
|
||||
);
|
||||
return service.list(userId);
|
||||
}
|
||||
Reference in New Issue
Block a user