This commit is contained in:
2026-04-21 19:18:26 +08:00
parent 4372ab5be1
commit 48957311bc
78 changed files with 643 additions and 3801 deletions

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -1 +0,0 @@
export * from './inventoryMutationService.js';

View File

@@ -1,2 +0,0 @@
export * from './questProgressionService.js';
export { generateQuestForNpcEncounter } from '../../services/questService.js';

View File

@@ -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';

View File

@@ -1,2 +0,0 @@
export * from './runtimeItemResolutionService.js';
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';

View File

@@ -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);
});

View File

@@ -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);
}

View File

@@ -1,8 +0,0 @@
export {
RpgSaveArchiveRepository,
type RpgSaveArchiveRepositoryPort,
} from './RpgSaveArchiveRepository.js';
export {
RpgWorldLibraryRepository,
type RpgWorldLibraryRepositoryPort,
} from './RpgWorldLibraryRepository.js';

View File

@@ -1,8 +0,0 @@
export {
RpgBrowseHistoryRepository,
type RpgBrowseHistoryRepositoryPort,
} from './RpgBrowseHistoryRepository.js';
export {
RpgProfileDashboardRepository,
type RpgProfileDashboardRepositoryPort,
} from './RpgProfileDashboardRepository.js';

View File

@@ -1,4 +0,0 @@
export {
RpgRuntimeSnapshotRepository,
type RpgRuntimeSnapshotRepositoryPort,
} from './RpgRuntimeSnapshotRepository.js';

View File

@@ -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',

View File

@@ -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';

View File

@@ -1,4 +0,0 @@
export {
createRpgProfileRoutes,
RPG_PROFILE_ROUTE_BASE_PATH,
} from './rpgProfileRoutes.js';

View File

@@ -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';

View File

@@ -1 +0,0 @@
export { createCustomWorldAgentRoutes as createRpgCreationAgentRoutes } from './customWorldAgent.js';

View File

@@ -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();
}

View File

@@ -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);
});

View File

@@ -1 +0,0 @@
export { CustomWorldAgentOrchestrator as RpgAgentOrchestrator } from './customWorldAgentOrchestrator.js';

View File

@@ -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';

View File

@@ -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,
);

View File

@@ -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(
(

View File

@@ -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}`,

View File

@@ -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,
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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'),

View File

@@ -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);
}